github.com/chainreactors/fingers@v1.2.1/cmd/fingerverify/fingerverify.go (about) 1 // fingerverify validates semantic equivalence between a CyberHub xray-style 2 // fingerprint YAML (rules + expression DSL) and a fingers-native fingerprint 3 // YAML by generating mock HTTP responses and comparing how each engine matches. 4 // 5 // Usage: 6 // 7 // fingerverify --source <xray.yml> --target <fingers.yaml> [--json] [-v] 8 // fingerverify <xray.yml> <fingers.yaml> [--json] [-v] 9 // 10 // Exits 0 on successful evaluation (consistent or divergent). Non-zero only on 11 // tool-level errors (I/O, YAML parse). 12 package main 13 14 import ( 15 "encoding/json" 16 "flag" 17 "fmt" 18 "os" 19 "regexp" 20 "sort" 21 "strings" 22 23 "github.com/Knetic/govaluate" 24 "github.com/chainreactors/fingers/fingers" 25 "gopkg.in/yaml.v3" 26 ) 27 28 // helperFunctions provides the subset of xray/nuclei DSL helpers needed for 29 // fingerprint expression evaluation. Kept self-contained to avoid pulling 30 // neutron/common (which transitively imports govalidator and panics on init 31 // under recent Go versions). 32 func helperFunctions() map[string]govaluate.ExpressionFunction { 33 asStr := func(v interface{}) string { return fmt.Sprintf("%v", v) } 34 return map[string]govaluate.ExpressionFunction{ 35 "contains": func(args ...interface{}) (interface{}, error) { 36 if len(args) < 2 { 37 return false, nil 38 } 39 return strings.Contains(asStr(args[0]), asStr(args[1])), nil 40 }, 41 "icontains": func(args ...interface{}) (interface{}, error) { 42 if len(args) < 2 { 43 return false, nil 44 } 45 return strings.Contains(strings.ToLower(asStr(args[0])), strings.ToLower(asStr(args[1]))), nil 46 }, 47 "starts_with": func(args ...interface{}) (interface{}, error) { 48 if len(args) < 2 { 49 return false, nil 50 } 51 return strings.HasPrefix(asStr(args[0]), asStr(args[1])), nil 52 }, 53 "ends_with": func(args ...interface{}) (interface{}, error) { 54 if len(args) < 2 { 55 return false, nil 56 } 57 return strings.HasSuffix(asStr(args[0]), asStr(args[1])), nil 58 }, 59 "regex": func(args ...interface{}) (interface{}, error) { 60 if len(args) < 2 { 61 return false, nil 62 } 63 pattern := asStr(args[0]) 64 target := asStr(args[1]) 65 re, err := regexp.Compile(pattern) 66 if err != nil { 67 return false, nil 68 } 69 return re.MatchString(target), nil 70 }, 71 "len": func(args ...interface{}) (interface{}, error) { 72 if len(args) < 1 { 73 return float64(0), nil 74 } 75 return float64(len(asStr(args[0]))), nil 76 }, 77 } 78 } 79 80 // --------------------------------------------------------------------------- 81 // Source fingerprint schema (xray-style as stored in CyberHub) 82 // --------------------------------------------------------------------------- 83 84 type sourceFPRequest struct { 85 Method string `yaml:"method"` 86 Path string `yaml:"path"` 87 Headers map[string]string `yaml:"headers"` 88 Body string `yaml:"body"` 89 FollowRedirects bool `yaml:"follow_redirects"` 90 Cache bool `yaml:"cache"` 91 } 92 93 type sourceFPRule struct { 94 Request *sourceFPRequest `yaml:"request,omitempty"` 95 Expression string `yaml:"expression"` 96 Output map[string]string `yaml:"output,omitempty"` 97 } 98 99 type sourceFP struct { 100 Name string `yaml:"name"` 101 Detail map[string]interface{} `yaml:"detail"` 102 Transport string `yaml:"transport"` 103 Rules map[string]sourceFPRule `yaml:"rules"` 104 Expression string `yaml:"expression"` 105 } 106 107 // --------------------------------------------------------------------------- 108 // Mock response + report 109 // --------------------------------------------------------------------------- 110 111 type mockResponse struct { 112 Name string `json:"name"` 113 StatusCode int `json:"status_code"` 114 Headers map[string]string `json:"headers"` 115 Body string `json:"body"` 116 } 117 118 // buildRawContent builds an HTTP-1.1 wire dump suitable for fingers.NewContent. 119 func (m mockResponse) buildRawContent() []byte { 120 var b strings.Builder 121 fmt.Fprintf(&b, "HTTP/1.1 %d OK\r\n", m.StatusCode) 122 // Keep deterministic header order for reproducibility. 123 keys := make([]string, 0, len(m.Headers)) 124 for k := range m.Headers { 125 keys = append(keys, k) 126 } 127 sort.Strings(keys) 128 for _, k := range keys { 129 fmt.Fprintf(&b, "%s: %s\r\n", k, m.Headers[k]) 130 } 131 b.WriteString("\r\n") 132 b.WriteString(m.Body) 133 return []byte(b.String()) 134 } 135 136 type caseResult struct { 137 MockName string `json:"mock_name"` 138 Mock *mockResponse `json:"mock,omitempty"` 139 SourceResult bool `json:"source_result"` 140 TargetResult bool `json:"target_result"` 141 Consistent bool `json:"consistent"` 142 SourceError string `json:"source_error,omitempty"` 143 TargetError string `json:"target_error,omitempty"` 144 TargetHit string `json:"target_hit,omitempty"` // which finger name matched 145 } 146 147 type fingerResult struct { 148 Name string `json:"name"` 149 Cases []caseResult `json:"cases"` 150 Consistent bool `json:"consistent"` 151 } 152 153 type report struct { 154 SourcePath string `json:"source_path"` 155 TargetPath string `json:"target_path"` 156 SourceName string `json:"source_name"` 157 TopExpression string `json:"top_expression"` 158 TargetCount int `json:"target_count"` 159 TargetFingers []string `json:"target_fingers"` 160 Results []fingerResult `json:"results"` 161 TotalCases int `json:"total_cases"` 162 Passed int `json:"passed"` 163 Failed int `json:"failed"` 164 Consistent bool `json:"consistent"` 165 Warnings []string `json:"warnings,omitempty"` 166 } 167 168 // --------------------------------------------------------------------------- 169 // main 170 // --------------------------------------------------------------------------- 171 172 func main() { 173 var ( 174 sourcePath string 175 targetPath string 176 jsonOut bool 177 verbose bool 178 ) 179 flag.StringVar(&sourcePath, "source", "", "path to xray-style fingerprint YAML") 180 flag.StringVar(&targetPath, "target", "", "path to fingers native fingerprint YAML") 181 flag.BoolVar(&jsonOut, "json", false, "emit JSON report") 182 flag.BoolVar(&verbose, "v", false, "verbose output (include mock dumps)") 183 flag.Usage = func() { 184 fmt.Fprintln(os.Stderr, "Usage:") 185 fmt.Fprintln(os.Stderr, " fingerverify --source <xray.yml> --target <fingers.yaml> [--json] [-v]") 186 fmt.Fprintln(os.Stderr, " fingerverify <xray.yml> <fingers.yaml> [--json] [-v]") 187 flag.PrintDefaults() 188 } 189 flag.Parse() 190 191 if sourcePath == "" && flag.NArg() >= 1 { 192 sourcePath = flag.Arg(0) 193 } 194 if targetPath == "" && flag.NArg() >= 2 { 195 targetPath = flag.Arg(1) 196 } 197 if sourcePath == "" || targetPath == "" { 198 flag.Usage() 199 os.Exit(2) 200 } 201 202 src, err := loadSource(sourcePath) 203 if err != nil { 204 fmt.Fprintf(os.Stderr, "load source: %v\n", err) 205 os.Exit(1) 206 } 207 tgt, err := loadTarget(targetPath) 208 if err != nil { 209 fmt.Fprintf(os.Stderr, "load target: %v\n", err) 210 os.Exit(1) 211 } 212 213 rep := verify(src, tgt, sourcePath, targetPath, verbose) 214 215 if jsonOut { 216 data, _ := json.MarshalIndent(rep, "", " ") 217 fmt.Println(string(data)) 218 } else { 219 printText(rep, verbose) 220 } 221 os.Exit(0) 222 } 223 224 // --------------------------------------------------------------------------- 225 // Loading 226 // --------------------------------------------------------------------------- 227 228 func loadSource(path string) (sourceFP, error) { 229 var src sourceFP 230 data, err := os.ReadFile(path) 231 if err != nil { 232 return src, err 233 } 234 data = stripControlChars(data) 235 if err := yaml.Unmarshal(data, &src); err != nil { 236 return src, fmt.Errorf("parse yaml: %w", err) 237 } 238 return src, nil 239 } 240 241 // stripControlChars removes ASCII control bytes that some CyberHub-exported 242 // YAML files carry at EOF (e.g. trailing 0x08 backspace), which the yaml 243 // parser rejects. Preserves \t, \n, \r. 244 func stripControlChars(in []byte) []byte { 245 out := in[:0:len(in)] 246 for _, b := range in { 247 if b < 0x20 && b != '\t' && b != '\n' && b != '\r' { 248 continue 249 } 250 out = append(out, b) 251 } 252 return out 253 } 254 255 func loadTarget(path string) (fingers.Fingers, error) { 256 data, err := os.ReadFile(path) 257 if err != nil { 258 return nil, err 259 } 260 // Try array form first (fingers canonical). 261 var arr fingers.Fingers 262 if err := yaml.Unmarshal(data, &arr); err == nil && len(arr) > 0 && arr[0] != nil && arr[0].Name != "" { 263 if err := compileFingers(arr); err != nil { 264 return nil, err 265 } 266 return arr, nil 267 } 268 // Fall back to single-finger form. 269 var single fingers.Finger 270 if err := yaml.Unmarshal(data, &single); err != nil { 271 return nil, fmt.Errorf("parse yaml: %w", err) 272 } 273 if single.Name == "" { 274 return nil, fmt.Errorf("target yaml has no fingers (missing 'name' or malformed)") 275 } 276 fs := fingers.Fingers{&single} 277 if err := compileFingers(fs); err != nil { 278 return nil, err 279 } 280 return fs, nil 281 } 282 283 func compileFingers(fs fingers.Fingers) error { 284 for _, f := range fs { 285 if err := f.Compile(false); err != nil { 286 return fmt.Errorf("compile finger %s: %w", f.Name, err) 287 } 288 } 289 return nil 290 } 291 292 // --------------------------------------------------------------------------- 293 // Verification 294 // --------------------------------------------------------------------------- 295 296 func verify(src sourceFP, tgt fingers.Fingers, srcPath, tgtPath string, verbose bool) report { 297 rep := report{ 298 SourcePath: srcPath, 299 TargetPath: tgtPath, 300 SourceName: src.Name, 301 TopExpression: src.Expression, 302 TargetCount: len(tgt), 303 Consistent: true, 304 } 305 for _, f := range tgt { 306 rep.TargetFingers = append(rep.TargetFingers, f.Name) 307 } 308 309 // Detect cross-path composition which fingers cannot model: if top expression 310 // contains '&&' and referenced rules have different request.path, warn. 311 if paths := collectRequestPaths(src); len(paths) > 1 && strings.Contains(src.Expression, "&&") { 312 rep.Warnings = append(rep.Warnings, 313 fmt.Sprintf("source has '&&' across rules with different request paths %v — "+ 314 "fingers cannot faithfully represent per-path AND; verification assumes single-response semantics", 315 paths)) 316 } 317 318 mocks := generateMocks(src, tgt) 319 320 // Report per target finger, but evaluate together (fingers engine OR-across-fingers). 321 // For simplicity and symmetry with pocverify, report under one synthetic block 322 // named after the source top expression. Individual finger hits are still recorded. 323 block := fingerResult{Name: src.Name, Consistent: true} 324 for _, m := range mocks { 325 cr := evaluateCase(src, tgt, m) 326 if verbose { 327 mCopy := m 328 cr.Mock = &mCopy 329 } 330 rep.TotalCases++ 331 if cr.Consistent { 332 rep.Passed++ 333 } else { 334 rep.Failed++ 335 block.Consistent = false 336 rep.Consistent = false 337 } 338 block.Cases = append(block.Cases, cr) 339 } 340 rep.Results = append(rep.Results, block) 341 return rep 342 } 343 344 func collectRequestPaths(src sourceFP) []string { 345 seen := map[string]bool{} 346 for _, r := range src.Rules { 347 if r.Request != nil && r.Request.Path != "" { 348 seen[r.Request.Path] = true 349 } 350 } 351 out := make([]string, 0, len(seen)) 352 for p := range seen { 353 out = append(out, p) 354 } 355 sort.Strings(out) 356 return out 357 } 358 359 func evaluateCase(src sourceFP, tgt fingers.Fingers, m mockResponse) caseResult { 360 cr := caseResult{MockName: m.Name} 361 srcOK, srcErr := evalSource(src, m) 362 tgtOK, tgtHit, tgtErr := evalTarget(tgt, m) 363 cr.SourceResult = srcOK 364 cr.TargetResult = tgtOK 365 cr.TargetHit = tgtHit 366 if srcErr != nil { 367 cr.SourceError = srcErr.Error() 368 } 369 if tgtErr != nil { 370 cr.TargetError = tgtErr.Error() 371 } 372 cr.Consistent = (srcOK == tgtOK) && cr.SourceError == "" && cr.TargetError == "" 373 return cr 374 } 375 376 // --------------------------------------------------------------------------- 377 // Source evaluation: each rN() is registered as a govaluate function that 378 // evaluates its DSL expression against the mock. 379 // --------------------------------------------------------------------------- 380 381 func evalSource(src sourceFP, m mockResponse) (bool, error) { 382 if strings.TrimSpace(src.Expression) == "" { 383 // Some fingerprints omit the top-level expression when there's one rule. 384 // Default to OR of all rules. 385 if len(src.Rules) == 0 { 386 return false, fmt.Errorf("source has no rules") 387 } 388 for _, r := range src.Rules { 389 ok, err := evalXrayDSL(r.Expression, m) 390 if err != nil { 391 return false, err 392 } 393 if ok { 394 return true, nil 395 } 396 } 397 return false, nil 398 } 399 400 funcs := helperFunctions() 401 // Register each rule as a no-arg function. 402 for name, rule := range src.Rules { 403 nameCapture := name 404 ruleCapture := rule 405 funcs[nameCapture] = func(args ...interface{}) (interface{}, error) { 406 ok, err := evalXrayDSL(ruleCapture.Expression, m) 407 if err != nil { 408 return false, err 409 } 410 return ok, nil 411 } 412 } 413 414 e, err := govaluate.NewEvaluableExpressionWithFunctions(src.Expression, funcs) 415 if err != nil { 416 return false, fmt.Errorf("compile top expression: %w", err) 417 } 418 res, err := e.Evaluate(nil) 419 if err != nil { 420 return false, fmt.Errorf("evaluate top expression: %w", err) 421 } 422 b, ok := res.(bool) 423 if !ok { 424 return false, fmt.Errorf("top expression did not return bool (got %T)", res) 425 } 426 return b, nil 427 } 428 429 // evalXrayDSL evaluates a single xray DSL expression (the body of one rule) 430 // against a mock response. Reused logic from pocverify. 431 func evalXrayDSL(expr string, m mockResponse) (bool, error) { 432 if strings.TrimSpace(expr) == "" { 433 return true, nil 434 } 435 norm := normalizeXrayExpr(expr) 436 e, err := govaluate.NewEvaluableExpressionWithFunctions(norm, helperFunctions()) 437 if err != nil { 438 return false, fmt.Errorf("compile xray expr: %w (normalized: %s)", err, norm) 439 } 440 params := buildXrayParams(expr, m) 441 res, err := e.Evaluate(params) 442 if err != nil { 443 return false, fmt.Errorf("evaluate xray expr: %w", err) 444 } 445 b, ok := res.(bool) 446 if !ok { 447 return false, fmt.Errorf("xray expr did not return bool (got %T)", res) 448 } 449 return b, nil 450 } 451 452 // --------------------------------------------------------------------------- 453 // xray DSL normalization (lifted from pocverify, adapted for fingerprints) 454 // --------------------------------------------------------------------------- 455 456 var ( 457 reBytesLiteral = regexp.MustCompile(`b"([^"\\]*(?:\\.[^"\\]*)*)"`) 458 // response.headers["X"] or response.headers['X'] (single or double quotes) 459 reHeaderRef = regexp.MustCompile(`response\.headers\[["']([^"']+)["']\]`) 460 // method-style call on flat ident 461 reMethodCall = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(contains|icontains|bcontains|matches|startsWith|endsWith|submatch|bsubmatch)\(`) 462 // method-style call on quoted string literal, xray-specific syntax: 463 // "pattern".matches(target) → regex(pattern, target) 464 // "pattern".submatch(target) → regex(pattern, target) (best-effort) 465 reStringMethodCall = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"\.(matches|submatch|bsubmatch)\(([^)]*)\)`) 466 // Match string literals like 'abc' (single-quoted) — convert to double-quoted for govaluate 467 reSingleQuoteStr = regexp.MustCompile(`'([^'\\]*(?:\\.[^'\\]*)*)'`) 468 ) 469 470 func normalizeXrayExpr(expr string) string { 471 out := expr 472 473 // b"..." -> "..." 474 out = reBytesLiteral.ReplaceAllString(out, `"$1"`) 475 476 // headers["X"] / headers['X'] -> xray_hdr_x 477 out = reHeaderRef.ReplaceAllStringFunc(out, func(match string) string { 478 m := reHeaderRef.FindStringSubmatch(match) 479 return headerVarName(m[1]) 480 }) 481 482 // response.body_string -> response.body 483 out = strings.ReplaceAll(out, "response.body_string", "response.body") 484 // response.raw_header -> xray_all_headers (fingers uses full raw content anyway) 485 out = strings.ReplaceAll(out, "response.raw_header", "xray_all_headers") 486 487 // dotted idents -> flat vars 488 out = strings.ReplaceAll(out, "response.content_type", "xray_content_type") 489 out = strings.ReplaceAll(out, "response.status", "xray_status") 490 out = strings.ReplaceAll(out, "response.body", "xray_body") 491 492 // 'str' -> "str" for govaluate, but ONLY for single-quoted strings at the 493 // top level — inner single quotes embedded inside a double-quoted string 494 // (e.g. contains("href='/custom/'")) must stay intact, otherwise the outer 495 // string literal would be broken. 496 out = convertTopLevelSingleQuotes(out) 497 498 // "pattern".matches(target) -> regex("pattern", target) 499 // Must run after single-quote conversion above so xray's 'pat'.matches(x) is 500 // normalized first. Parenthesis-balance is simplistic: we only match a 501 // single-level argument list here, which covers every xray corpus we've seen. 502 out = reStringMethodCall.ReplaceAllStringFunc(out, func(match string) string { 503 m := reStringMethodCall.FindStringSubmatch(match) 504 pattern, args := m[1], m[3] 505 return fmt.Sprintf(`regex("%s", %s)`, pattern, strings.TrimSpace(args)) 506 }) 507 508 // Method calls on idents -> function calls. 509 out = reMethodCall.ReplaceAllStringFunc(out, func(match string) string { 510 parts := reMethodCall.FindStringSubmatch(match) 511 ident, method := parts[1], parts[2] 512 fn := mapXrayMethod(method) 513 if fn == "regex" { 514 return fmt.Sprintf("regex(@@PATTERN@@__%s__", ident) 515 } 516 return fmt.Sprintf("%s(%s, ", fn, ident) 517 }) 518 out = fixRegexSwap(out) 519 520 return out 521 } 522 523 func mapXrayMethod(m string) string { 524 switch m { 525 case "contains", "bcontains": 526 return "contains" 527 case "icontains": 528 return "icontains" 529 case "matches": 530 return "regex" 531 case "startsWith": 532 return "starts_with" 533 case "endsWith": 534 return "ends_with" 535 case "submatch", "bsubmatch": 536 // submatch returns a group dict in xray; for equivalence purposes we treat 537 // it like a regex-match boolean. This is best-effort and conservatively 538 // indicates a match, not the extracted value. 539 return "regex" 540 } 541 return m 542 } 543 544 func fixRegexSwap(s string) string { 545 const marker = "regex(@@PATTERN@@__" 546 for { 547 i := strings.Index(s, marker) 548 if i < 0 { 549 return s 550 } 551 identStart := i + len(marker) 552 identEnd := strings.Index(s[identStart:], "__") 553 if identEnd < 0 { 554 return s 555 } 556 ident := s[identStart : identStart+identEnd] 557 openIdx := i + len("regex(") - 1 558 close := matchingParen(s, openIdx) 559 if close < 0 { 560 return s 561 } 562 argStart := identStart + identEnd + 2 563 args := s[argStart:close] 564 rebuilt := fmt.Sprintf("regex(%s, %s)", args, ident) 565 s = s[:i] + rebuilt + s[close+1:] 566 } 567 } 568 569 func matchingParen(s string, open int) int { 570 depth := 0 571 for i := open; i < len(s); i++ { 572 switch s[i] { 573 case '(': 574 depth++ 575 case ')': 576 depth-- 577 if depth == 0 { 578 return i 579 } 580 } 581 } 582 return -1 583 } 584 585 // convertTopLevelSingleQuotes replaces single-quoted string literals with 586 // double-quoted ones, skipping any characters that fall within an existing 587 // double-quoted literal. Backslash escapes are honored inside both kinds of 588 // strings so that constructs like "a\"b" and 'a\'b' round-trip correctly. 589 // Additionally, any UNESCAPED single quote encountered inside a double-quoted 590 // literal is escaped — govaluate's tokenizer otherwise splits `"x'y'z"` into 591 // three tokens, which is surprising but well-documented behavior. 592 func convertTopLevelSingleQuotes(s string) string { 593 var out strings.Builder 594 i := 0 595 for i < len(s) { 596 c := s[i] 597 if c == '"' { 598 // Emit the whole double-quoted literal, escaping any inner '. 599 out.WriteByte(c) 600 i++ 601 for i < len(s) { 602 c = s[i] 603 if c == '\\' && i+1 < len(s) { 604 out.WriteByte(c) 605 out.WriteByte(s[i+1]) 606 i += 2 607 continue 608 } 609 if c == '\'' { 610 out.WriteString(`\'`) 611 i++ 612 continue 613 } 614 out.WriteByte(c) 615 i++ 616 if c == '"' { 617 break 618 } 619 } 620 continue 621 } 622 if c == '\'' { 623 // Replace this single-quoted literal with a double-quoted one, 624 // escaping any embedded double quotes. 625 out.WriteByte('"') 626 i++ 627 for i < len(s) { 628 c = s[i] 629 if c == '\\' && i+1 < len(s) { 630 out.WriteByte(c) 631 out.WriteByte(s[i+1]) 632 i += 2 633 continue 634 } 635 if c == '\'' { 636 out.WriteByte('"') 637 i++ 638 break 639 } 640 if c == '"' { 641 // Embedded unescaped double quote inside a single-quoted 642 // string — escape it so the resulting double-quoted literal 643 // remains valid. 644 out.WriteString(`\"`) 645 i++ 646 continue 647 } 648 out.WriteByte(c) 649 i++ 650 } 651 continue 652 } 653 out.WriteByte(c) 654 i++ 655 } 656 return out.String() 657 } 658 659 func headerVarName(name string) string { 660 n := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), "-", "_")) 661 return "xray_hdr_" + n 662 } 663 664 func buildXrayParams(origExpr string, m mockResponse) map[string]interface{} { 665 p := map[string]interface{}{ 666 "xray_status": float64(m.StatusCode), 667 "xray_body": m.Body, 668 "xray_content_type": m.Headers["Content-Type"], 669 } 670 // Seed defaults for every header ident referenced in the expression so that 671 // absent headers evaluate to empty string (not "No parameter ..."). 672 for _, hm := range reHeaderRef.FindAllStringSubmatch(origExpr, -1) { 673 p[headerVarName(hm[1])] = "" 674 } 675 for k, v := range m.Headers { 676 p[headerVarName(k)] = v 677 } 678 // Build raw_header text (used by rare raw_header.bcontains patterns). 679 var raw strings.Builder 680 keys := make([]string, 0, len(m.Headers)) 681 for k := range m.Headers { 682 keys = append(keys, k) 683 } 684 sort.Strings(keys) 685 for _, k := range keys { 686 fmt.Fprintf(&raw, "%s: %s\r\n", k, m.Headers[k]) 687 } 688 p["xray_all_headers"] = raw.String() 689 return p 690 } 691 692 // --------------------------------------------------------------------------- 693 // Target evaluation (fingers production path) 694 // --------------------------------------------------------------------------- 695 696 func evalTarget(fs fingers.Fingers, m mockResponse) (bool, string, error) { 697 raw := m.buildRawContent() 698 content := fingers.NewContent(raw, "", true) 699 for _, f := range fs { 700 frame, _, ok := f.PassiveMatch(content) 701 if ok { 702 name := f.Name 703 if frame != nil && frame.Name != "" { 704 name = frame.Name 705 } 706 return true, name, nil 707 } 708 } 709 return false, "", nil 710 } 711 712 // --------------------------------------------------------------------------- 713 // Mock generation 714 // --------------------------------------------------------------------------- 715 716 func generateMocks(src sourceFP, tgt fingers.Fingers) []mockResponse { 717 hdrNeedles := extractSourceHeaderConstraints(src) 718 bodyNeedles := extractSourceBodyConstraints(src) 719 regexNeedles := extractSourceRegexHints(src) 720 721 // Gather fingers-side needles too so positive mock satisfies both sides. 722 tgtBody, tgtHeader, tgtRegex := extractTargetNeedles(tgt) 723 724 // Synthesize positive headers from source + target header constraints. 725 headers := buildPositiveHeaders(hdrNeedles, tgtHeader) 726 // Body combines all needles. 727 bodyParts := append([]string{}, bodyNeedles...) 728 bodyParts = append(bodyParts, regexNeedles...) 729 bodyParts = append(bodyParts, tgtBody...) 730 bodyParts = append(bodyParts, tgtRegex...) 731 body := strings.Join(dedup(bodyParts), " ") 732 733 positive := mockResponse{ 734 Name: "positive", 735 StatusCode: 200, 736 Headers: headers, 737 Body: body, 738 } 739 740 wrongStatus := positive 741 wrongStatus.Name = "wrong_status" 742 wrongStatus.Headers = cloneMap(headers) 743 wrongStatus.StatusCode = 500 744 745 emptyBody := positive 746 emptyBody.Name = "empty_body" 747 emptyBody.Headers = cloneMap(headers) 748 emptyBody.Body = "" 749 750 missingHeaders := positive 751 missingHeaders.Name = "missing_headers" 752 missingHeaders.Headers = map[string]string{} 753 754 mocks := []mockResponse{positive, wrongStatus, emptyBody, missingHeaders} 755 756 // --- drop-source body: positive minus one source body needle ------------ 757 // Catches "target doesn't check this source needle" (source=F, target=T). 758 for i, needle := range bodyNeedles { 759 partial := positive 760 partial.Name = fmt.Sprintf("drop_body[%d]=%s", i, shortLabel(needle)) 761 partial.Headers = cloneMap(headers) 762 stripped := strings.ReplaceAll(body, needle, "") 763 stripped = strings.ReplaceAll(stripped, strings.ToLower(needle), "") 764 partial.Body = stripped 765 mocks = append(mocks, partial) 766 } 767 // --- drop-source header: positive minus one source header needle -------- 768 hdrIdx := 0 769 for name, needles := range hdrNeedles { 770 for _, needle := range needles { 771 partial := positive 772 partial.Name = fmt.Sprintf("drop_hdr[%d]=%s/%s", hdrIdx, name, shortLabel(needle)) 773 hdrIdx++ 774 reduced := cloneStringSliceMap(hdrNeedles) 775 reduced[name] = dropFirst(reduced[name], needle) 776 partial.Headers = buildPositiveHeaders(reduced, tgtHeader) 777 needleLower := strings.ToLower(needle) 778 for k, v := range partial.Headers { 779 v = strings.ReplaceAll(v, needle, "") 780 v = strings.ReplaceAll(v, needleLower, "") 781 partial.Headers[k] = v 782 } 783 mocks = append(mocks, partial) 784 } 785 } 786 787 // --- drop-target body: positive minus one target-only body needle ------- 788 // Catches "target checks a constraint the source doesn't require" 789 // (source=T, target=F). Skip needles that also appear in source. 790 srcBodySet := toSet(bodyNeedles) 791 srcRegexSet := toSet(regexNeedles) 792 for i, needle := range tgtBody { 793 if srcBodySet[needle] || srcRegexSet[needle] { 794 continue 795 } 796 partial := positive 797 partial.Name = fmt.Sprintf("drop_tgt_body[%d]=%s", i, shortLabel(needle)) 798 partial.Headers = cloneMap(headers) 799 stripped := strings.ReplaceAll(body, needle, "") 800 stripped = strings.ReplaceAll(stripped, strings.ToLower(needle), "") 801 partial.Body = stripped 802 mocks = append(mocks, partial) 803 } 804 // --- drop-target header: for each target-only header keyword ----------- 805 srcHdrSet := map[string]bool{} 806 for _, ns := range hdrNeedles { 807 for _, n := range ns { 808 srcHdrSet[strings.ToLower(n)] = true 809 } 810 } 811 for i, needle := range tgtHeader { 812 if srcHdrSet[strings.ToLower(needle)] { 813 continue 814 } 815 partial := positive 816 partial.Name = fmt.Sprintf("drop_tgt_hdr[%d]=%s", i, shortLabel(needle)) 817 // Rebuild positive headers without injecting THIS target needle via X-Mock. 818 reducedTgt := dropFirstLower(tgtHeader, needle) 819 partial.Headers = buildPositiveHeaders(hdrNeedles, reducedTgt) 820 needleLower := strings.ToLower(needle) 821 for k, v := range partial.Headers { 822 v = strings.ReplaceAll(v, needle, "") 823 v = strings.ReplaceAll(v, needleLower, "") 824 partial.Headers[k] = v 825 } 826 mocks = append(mocks, partial) 827 } 828 829 // --- solo-source: mock contains ONLY one source needle ----------------- 830 // Catches "target treats a single needle as sufficient but source requires 831 // it in conjunction with others (AND-composition)". Critical for detecting 832 // cross-path `&&` leaks that the single-response model would otherwise hide. 833 for i, needle := range bodyNeedles { 834 partial := positive 835 partial.Name = fmt.Sprintf("solo_body[%d]=%s", i, shortLabel(needle)) 836 partial.Headers = map[string]string{} 837 partial.Body = needle 838 mocks = append(mocks, partial) 839 } 840 soloIdx := 0 841 for name, needles := range hdrNeedles { 842 for _, needle := range needles { 843 partial := positive 844 partial.Name = fmt.Sprintf("solo_hdr[%d]=%s/%s", soloIdx, name, shortLabel(needle)) 845 soloIdx++ 846 partial.Headers = map[string]string{name: synthesizeHeaderValue(name, []string{needle})} 847 partial.Body = "" 848 mocks = append(mocks, partial) 849 } 850 } 851 852 return mocks 853 } 854 855 // dropFirstLower returns a copy of xs with the first occurrence of v (compared 856 // case-insensitively) removed. Used when pruning target header keywords where 857 // fingers normalizes case during Compile. 858 func dropFirstLower(xs []string, v string) []string { 859 out := make([]string, 0, len(xs)) 860 dropped := false 861 vl := strings.ToLower(v) 862 for _, x := range xs { 863 if !dropped && strings.ToLower(x) == vl { 864 dropped = true 865 continue 866 } 867 out = append(out, x) 868 } 869 return out 870 } 871 872 func toSet(xs []string) map[string]bool { 873 m := map[string]bool{} 874 for _, x := range xs { 875 m[x] = true 876 } 877 return m 878 } 879 880 // extractSourceHeaderConstraints walks all rule expressions and collects every 881 // response.headers["X"].contains("Y") needle, plus response.content_type patterns. 882 // Double- and single-quoted arguments are handled separately so internal quotes 883 // of the OTHER type survive (same reason as extractSourceBodyConstraints). 884 func extractSourceHeaderConstraints(src sourceFP) map[string][]string { 885 out := map[string][]string{} 886 reHdrDbl := regexp.MustCompile(`response\.headers\[["']([^"']+)["']\]\.[b]?(?:icontains|contains|bcontains|matches|submatch|bsubmatch)\(b?"((?:[^"\\]|\\.)*)"\)`) 887 reHdrSgl := regexp.MustCompile(`response\.headers\[["']([^"']+)["']\]\.[b]?(?:icontains|contains|bcontains|matches|submatch|bsubmatch)\(b?'((?:[^'\\]|\\.)*)'\)`) 888 reCTDbl := regexp.MustCompile(`response\.content_type\.[b]?(?:contains|icontains|bcontains)\(b?"((?:[^"\\]|\\.)*)"\)`) 889 reCTSgl := regexp.MustCompile(`response\.content_type\.[b]?(?:contains|icontains|bcontains)\(b?'((?:[^'\\]|\\.)*)'\)`) 890 for _, r := range src.Rules { 891 for _, m := range reHdrDbl.FindAllStringSubmatch(r.Expression, -1) { 892 out[m[1]] = append(out[m[1]], m[2]) 893 } 894 for _, m := range reHdrSgl.FindAllStringSubmatch(r.Expression, -1) { 895 out[m[1]] = append(out[m[1]], m[2]) 896 } 897 for _, m := range reCTDbl.FindAllStringSubmatch(r.Expression, -1) { 898 out["Content-Type"] = append(out["Content-Type"], m[1]) 899 } 900 for _, m := range reCTSgl.FindAllStringSubmatch(r.Expression, -1) { 901 out["Content-Type"] = append(out["Content-Type"], m[1]) 902 } 903 } 904 return out 905 } 906 907 func extractSourceBodyConstraints(src sourceFP) []string { 908 // Separate patterns for double- vs single-quoted arguments so that internal 909 // quotes of the OTHER type (e.g. "href='/custom/'") are preserved — using a 910 // single greedy negated class would drop needles like 911 // response.body_string.contains("window.location.href='/custom/'"). 912 reBDbl := regexp.MustCompile(`response\.body(?:_string)?\.[b]?(?:icontains|contains|bcontains)\(b?"((?:[^"\\]|\\.)*)"\)`) 913 reBSgl := regexp.MustCompile(`response\.body(?:_string)?\.[b]?(?:icontains|contains|bcontains)\(b?'((?:[^'\\]|\\.)*)'\)`) 914 var out []string 915 for _, r := range src.Rules { 916 for _, m := range reBDbl.FindAllStringSubmatch(r.Expression, -1) { 917 out = append(out, m[1]) 918 } 919 for _, m := range reBSgl.FindAllStringSubmatch(r.Expression, -1) { 920 out = append(out, m[1]) 921 } 922 } 923 return out 924 } 925 926 // extractSourceRegexHints pulls literal substrings from body-side regex/matches patterns. 927 func extractSourceRegexHints(src sourceFP) []string { 928 reRx := regexp.MustCompile(`["']([^"']+)["']\.matches\(response\.body(?:_string)?\)`) 929 reRx2 := regexp.MustCompile(`response\.body(?:_string)?\.matches\(["']([^"']+)["']\)`) 930 var out []string 931 for _, r := range src.Rules { 932 for _, m := range reRx.FindAllStringSubmatch(r.Expression, -1) { 933 out = append(out, regexLiteralHint(m[1])) 934 } 935 for _, m := range reRx2.FindAllStringSubmatch(r.Expression, -1) { 936 out = append(out, regexLiteralHint(m[1])) 937 } 938 } 939 return out 940 } 941 942 // extractTargetNeedles collects body/header/regexp literal hints from fingers rules. 943 func extractTargetNeedles(fs fingers.Fingers) (body, header, regex []string) { 944 for _, f := range fs { 945 for _, r := range f.Rules { 946 if r.Regexps == nil { 947 continue 948 } 949 body = append(body, r.Regexps.Body...) 950 header = append(header, r.Regexps.Header...) 951 for _, pat := range r.Regexps.Regexp { 952 regex = append(regex, regexLiteralHint(pat)) 953 } 954 } 955 } 956 return 957 } 958 959 // buildPositiveHeaders synthesizes a headers map satisfying source header 960 // constraints and fingers header-keyword patterns. 961 func buildPositiveHeaders(srcConstraints map[string][]string, tgtHeaderWords []string) map[string]string { 962 hdr := map[string]string{} 963 for name, needles := range srcConstraints { 964 hdr[name] = synthesizeHeaderValue(name, needles) 965 } 966 // For each fingers target header keyword (e.g. "Server: nginx"), ensure it 967 // appears somewhere. fingers does a case-insensitive-lowered substring 968 // compare against the full header block, so stuffing into X-Mock works. 969 for _, w := range tgtHeaderWords { 970 lw := strings.ToLower(w) 971 if headerBlockContains(hdr, lw) { 972 continue 973 } 974 // Preserve the full word (including any "Key: val" form) in X-Mock. 975 hdr["X-Mock"] = strings.TrimSpace(hdr["X-Mock"] + " " + w) 976 } 977 return hdr 978 } 979 980 func headerBlockContains(hdr map[string]string, needleLower string) bool { 981 for k, v := range hdr { 982 if strings.Contains(strings.ToLower(k+": "+v), needleLower) { 983 return true 984 } 985 } 986 return false 987 } 988 989 func synthesizeHeaderValue(name string, needles []string) string { 990 joined := strings.Join(needles, " ") 991 switch strings.ToLower(name) { 992 case "set-cookie": 993 return joined + "=abc123; Path=/" 994 case "location": 995 return "/path/" + joined + "/redir" 996 case "server": 997 return joined 998 case "content-type": 999 if len(needles) > 0 { 1000 return needles[0] 1001 } 1002 return "text/html" 1003 } 1004 return "prefix-" + joined + "-suffix" 1005 } 1006 1007 // regexLiteralHint extracts the longest run of plain chars from a pattern. 1008 func regexLiteralHint(pat string) string { 1009 stripped := pat 1010 for _, r := range []string{"^", "$", "(?i)", "(?s)", "(?m)", "\\b", "\\B"} { 1011 stripped = strings.ReplaceAll(stripped, r, "") 1012 } 1013 var best, cur strings.Builder 1014 flush := func() { 1015 if cur.Len() > best.Len() { 1016 best.Reset() 1017 best.WriteString(cur.String()) 1018 } 1019 cur.Reset() 1020 } 1021 for _, ch := range stripped { 1022 if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || 1023 (ch >= '0' && ch <= '9') || ch == '_' || ch == ' ' || ch == '-' || ch == '.' || 1024 ch >= 0x4e00 && ch <= 0x9fff { 1025 cur.WriteRune(ch) 1026 } else { 1027 flush() 1028 } 1029 } 1030 flush() 1031 return strings.TrimSpace(best.String()) 1032 } 1033 1034 // --------------------------------------------------------------------------- 1035 // Output 1036 // --------------------------------------------------------------------------- 1037 1038 func printText(r report, verbose bool) { 1039 fmt.Printf("fingerverify: source=%s target=%s\n", r.SourcePath, r.TargetPath) 1040 fmt.Printf(" source name: %s\n", r.SourceName) 1041 fmt.Printf(" top expression: %s\n", r.TopExpression) 1042 fmt.Printf(" target fingers: %d %v\n", r.TargetCount, r.TargetFingers) 1043 1044 for _, w := range r.Warnings { 1045 fmt.Printf(" ! %s\n", w) 1046 } 1047 1048 for _, rr := range r.Results { 1049 fmt.Printf("\nFinger %s\n", rr.Name) 1050 for _, c := range rr.Cases { 1051 tag := "PASS" 1052 if !c.Consistent { 1053 tag = "FAIL" 1054 } 1055 fmt.Printf(" [%s] %-32s source=%s target=%s", 1056 tag, c.MockName, boolT(c.SourceResult), boolT(c.TargetResult)) 1057 if c.TargetHit != "" { 1058 fmt.Printf(" hit=%s", c.TargetHit) 1059 } 1060 fmt.Println() 1061 if c.SourceError != "" { 1062 fmt.Printf(" source error: %s\n", c.SourceError) 1063 } 1064 if c.TargetError != "" { 1065 fmt.Printf(" target error: %s\n", c.TargetError) 1066 } 1067 if verbose && c.Mock != nil { 1068 fmt.Printf(" mock: status=%d headers=%v body=%q\n", 1069 c.Mock.StatusCode, c.Mock.Headers, truncate(c.Mock.Body, 80)) 1070 } 1071 } 1072 fmt.Printf(" finger consistent: %s\n", yesNo(rr.Consistent)) 1073 } 1074 1075 fmt.Printf("\nSummary: %d/%d consistent, %d divergent\n", 1076 r.Passed, r.TotalCases, r.Failed) 1077 overall := "CONSISTENT" 1078 if !r.Consistent { 1079 overall = "DIVERGENT" 1080 } 1081 fmt.Printf("Overall: %s\n", overall) 1082 } 1083 1084 // --------------------------------------------------------------------------- 1085 // Small utilities 1086 // --------------------------------------------------------------------------- 1087 1088 func cloneMap(in map[string]string) map[string]string { 1089 out := make(map[string]string, len(in)) 1090 for k, v := range in { 1091 out[k] = v 1092 } 1093 return out 1094 } 1095 1096 func cloneStringSliceMap(in map[string][]string) map[string][]string { 1097 out := make(map[string][]string, len(in)) 1098 for k, v := range in { 1099 cp := make([]string, len(v)) 1100 copy(cp, v) 1101 out[k] = cp 1102 } 1103 return out 1104 } 1105 1106 func dropFirst(xs []string, v string) []string { 1107 out := make([]string, 0, len(xs)) 1108 dropped := false 1109 for _, x := range xs { 1110 if !dropped && x == v { 1111 dropped = true 1112 continue 1113 } 1114 out = append(out, x) 1115 } 1116 return out 1117 } 1118 1119 func dedup(xs []string) []string { 1120 seen := map[string]bool{} 1121 out := make([]string, 0, len(xs)) 1122 for _, x := range xs { 1123 if x == "" || seen[x] { 1124 continue 1125 } 1126 seen[x] = true 1127 out = append(out, x) 1128 } 1129 return out 1130 } 1131 1132 func shortLabel(s string) string { 1133 r := []rune(s) 1134 if len(r) > 12 { 1135 r = append(r[:12], '…') 1136 } 1137 out := strings.Map(func(c rune) rune { 1138 if c == ' ' || c == '\t' || c == '\r' || c == '\n' { 1139 return '_' 1140 } 1141 return c 1142 }, string(r)) 1143 if out == "" { 1144 return "x" 1145 } 1146 return out 1147 } 1148 1149 func yesNo(b bool) string { 1150 if b { 1151 return "yes" 1152 } 1153 return "no" 1154 } 1155 1156 func boolT(b bool) string { 1157 if b { 1158 return "T" 1159 } 1160 return "F" 1161 } 1162 1163 func truncate(s string, n int) string { 1164 if len(s) <= n { 1165 return s 1166 } 1167 return s[:n] + "…" 1168 }