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  }