Parcourir la source

Composite rules (#1905)

* composite rules

* add test config

* tests

* taking care of some pr comments

* naming
Zachary Rice il y a 6 mois
Parent
commit
b1c9c7ecbe
9 fichiers modifiés avec 473 ajouts et 21 suppressions
  1. 88 0
      README.md
  2. 37 2
      config/config.go
  3. 12 0
      config/rule.go
  4. 143 1
      detect/detect.go
  5. 112 14
      detect/detect_test.go
  6. 5 0
      detect/utils.go
  7. 60 4
      report/finding.go
  8. 2 0
      sources/fragment.go
  9. 14 0
      testdata/config/composite.toml

+ 88 - 0
README.md

@@ -415,6 +415,94 @@ Refer to the default [gitleaks config](https://github.com/gitleaks/gitleaks/blob
 
 ### Additional Configuration
 
+#### Composite Rules (Multi-part or `required` Rules)
+In v8.29.0 Gitleaks introduced composite rules, which are made up of a single "primary" rule and one or more auxiliary or `required` rules. To create a composite rule, add a `[[rules.required]]` table to the primary rule specifying an `id` and optionally `withinLines` and/or `withinColumns` proximity constraints. A fragment is a chunk of content that Gitleaks processes at once (typically a file, part of a file, or git diff), and proximity matching instructs the primary rule to only report a finding if the auxiliary `required` rules also find matches within the specified area of the fragment.
+
+**Proximity matching:** Using the `withinLines` and `withinColumns` fields instructs the primary rule to only report a finding if the auxiliary `required` rules also find matches within the specified proximity. You can set:
+
+- **`withinLines: N`** - required findings must be within N lines (vertically)
+- **`withinColumns: N`** - required findings must be within N characters (horizontally)  
+- **Both** - creates a rectangular search area (both constraints must be satisfied)
+- **Neither** - fragment-level matching (required findings can be anywhere in the same fragment)
+
+Here are diagrams illustrating each proximity behavior:
+
+```
+p = primary captured secret
+a = auxiliary (required) captured secret
+fragment = section of data gitleaks is looking at
+
+
+    *Fragment-level proximity*               
+    Any required finding in the fragment
+          ┌────────┐                       
+   ┌──────┤fragment├─────┐                 
+   │      └──────┬─┤     │ ┌───────┐       
+   │             │a│◀────┼─│✓ MATCH│       
+   │          ┌─┐└─┘     │ └───────┘       
+   │┌─┐       │p│        │                 
+   ││a│    ┌─┐└─┘        │ ┌───────┐       
+   │└─┘    │a│◀──────────┼─│✓ MATCH│       
+   └─▲─────┴─┴───────────┘ └───────┘       
+     │    ┌───────┐                        
+     └────│✓ MATCH│                        
+          └───────┘                        
+                                           
+                                           
+   *Column bounded proximity*
+   `withinColumns = 3`                    
+          ┌────────┐                       
+   ┌────┬─┤fragment├─┬───┐                 
+   │      └──────┬─┤     │ ┌───────────┐   
+   │    │        │a│◀┼───┼─│+1C ✓ MATCH│   
+   │          ┌─┐└─┘     │ └───────────┘   
+   │┌─┐ │     │p│    │   │                 
+┌──▶│a│  ┌─┐  └─┘        │ ┌───────────┐   
+│  │└─┘ ││a│◀────────┼───┼─│-2C ✓ MATCH│   
+│  │       ┘             │ └───────────┘   
+│  └── -3C ───0C─── +3C ─┘                 
+│  ┌─────────┐                             
+│  │ -4C ✗ NO│                             
+└──│  MATCH  │                             
+   └─────────┘                             
+                                           
+                                           
+   *Line bounded proximity*
+   `withinLines = 4`                      
+         ┌────────┐                        
+   ┌─────┤fragment├─────┐                  
+  +4L─ ─ ┴────────┘─ ─ ─│                  
+   │                    │                  
+   │              ┌─┐   │ ┌────────────┐   
+   │         ┌─┐  │a│◀──┼─│+1L ✓ MATCH │   
+   0L  ┌─┐   │p│  └─┘   │ ├────────────┤   
+   │   │a│◀──┴─┴────────┼─│-1L ✓ MATCH │   
+   │   └─┘              │ └────────────┘   
+   │                    │ ┌─────────┐      
+  -4L─ ─ ─ ─ ─ ─ ─ ─┌─┐─│ │-5L ✗ NO │      
+   │                │a│◀┼─│  MATCH  │      
+   └────────────────┴─┴─┘ └─────────┘      
+                                           
+                                           
+   *Line and column bounded proximity*
+   `withinLines = 4`                      
+   `withinColumns = 3`                    
+         ┌────────┐                        
+   ┌─────┤fragment├─────┐                  
+  +4L   ┌└────────┴ ┐   │                  
+   │            ┌─┐     │ ┌───────────────┐
+   │    │       │a│◀┼───┼─│+2L/+1C ✓ MATCH│
+   │         ┌─┐└─┘     │ └───────────────┘
+   0L   │    │p│    │   │                  
+   │         └─┘        │                  
+   │    │           │   │ ┌────────────┐   
+  -4L    ─ ─ ─ ─ ─ ─┌─┐ │ │-5L/+3C ✗ NO│   
+   │                │a│◀┼─│   MATCH    │   
+   └───-3C────0L───+3C┴─┘ └────────────┘   
+```
+
+<details><summary>Some final quick thoughts on composite rules.</summary>This is an experimental feature! It's subject to change so don't go sellin' a new B2B SaaS feature built ontop of this feature. Scan type (git vs dir) based context is interesting. I'm monitoring the situation. Composite rules might not be super useful for git scans because gitleaks only looks at additions in the git history. It could be useful to scan non-additions in git history for `required` rules. Oh, right this is a readme, I'll shut up now.</details>
+  
 #### gitleaks:allow
 
 If you are knowingly committing a test secret that gitleaks will catch you can add a `gitleaks:allow` comment to that line which will instruct gitleaks

+ 37 - 2
config/config.go

@@ -43,15 +43,25 @@ type ViperConfig struct {
 
 		// Deprecated: this is a shim for backwards-compatibility.
 		// TODO: Remove this in 9.x.
-		AllowList  *viperRuleAllowlist
+		AllowList *viperRuleAllowlist
+
 		Allowlists []*viperRuleAllowlist
+		Required   []*viperRequired
+		SkipReport bool
 	}
 	// Deprecated: this is a shim for backwards-compatibility.
 	// TODO: Remove this in 9.x.
-	AllowList  *viperGlobalAllowlist
+	AllowList *viperGlobalAllowlist
+
 	Allowlists []*viperGlobalAllowlist
 }
 
+type viperRequired struct {
+	ID            string
+	WithinLines   *int `mapstructure:"withinLines"`
+	WithinColumns *int `mapstructure:"withinColumns"`
+}
+
 type viperRuleAllowlist struct {
 	Description string
 	Condition   string
@@ -130,6 +140,7 @@ func (vc *ViperConfig) Translate() (Config, error) {
 			Path:        pathPat,
 			Keywords:    vr.Keywords,
 			Tags:        vr.Tags,
+			SkipReport:  vr.SkipReport,
 		}
 
 		// Parse the rule allowlists, including the older format for backwards compatibility.
@@ -147,10 +158,34 @@ func (vc *ViperConfig) Translate() (Config, error) {
 			}
 			cr.Allowlists = append(cr.Allowlists, allowlist)
 		}
+
+		for _, r := range vr.Required {
+			if r.ID == "" {
+				return Config{}, fmt.Errorf("%s: [[rules.required]] rule ID is empty", cr.RuleID)
+			}
+			requiredRule := Required{
+				RuleID:        r.ID,
+				WithinLines:   r.WithinLines,
+				WithinColumns: r.WithinColumns,
+				// Distance: r.Distance,
+			}
+			cr.RequiredRules = append(cr.RequiredRules, &requiredRule)
+		}
+
 		orderedRules = append(orderedRules, cr.RuleID)
 		rulesMap[cr.RuleID] = cr
 	}
 
+	// after all the rules have been processed, let's ensure the required rules
+	// actually exist.
+	for _, r := range rulesMap {
+		for _, rr := range r.RequiredRules {
+			if _, ok := rulesMap[rr.RuleID]; !ok {
+				return Config{}, fmt.Errorf("%s: [[rules.required]] rule ID '%s' does not exist", r.RuleID, rr.RuleID)
+			}
+		}
+	}
+
 	// Assemble the config.
 	c := Config{
 		Title:        vc.Title,

+ 12 - 0
config/rule.go

@@ -46,6 +46,18 @@ type Rule struct {
 
 	// validated is an internal flag to track whether `Validate()` has been called.
 	validated bool
+
+	// If a rule has RequiredRules, it makes the rule dependent on the RequiredRules.
+	// In otherwords, this rule is now a composite rule.
+	RequiredRules []*Required
+
+	SkipReport bool
+}
+
+type Required struct {
+	RuleID        string
+	WithinLines   *int
+	WithinColumns *int
 }
 
 // Validate guards against common misconfigurations.

+ 143 - 1
detect/detect.go

@@ -357,6 +357,10 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rul
 		}()
 	)
 
+	if r.SkipReport == true && !fragment.InheritedFromFinding {
+		return findings
+	}
+
 	// check if commit or file is allowed for this rule.
 	if isAllowed, event := checkCommitOrPathAllowed(logger, fragment, r.Allowlists); isAllowed {
 		event.Msg("skipping file: rule allowlist")
@@ -539,7 +543,145 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rul
 		}
 		findings = append(findings, finding)
 	}
-	return findings
+
+	// Handle required rules (multi-part rules)
+	if fragment.InheritedFromFinding || len(r.RequiredRules) == 0 {
+		return findings
+	}
+
+	// Process required rules and create findings with auxiliary findings
+	return d.processRequiredRules(fragment, currentRaw, r, encodedSegments, findings, logger)
+}
+
+// processRequiredRules handles the logic for multi-part rules with auxiliary findings
+func (d *Detector) processRequiredRules(fragment Fragment, currentRaw string, r config.Rule, encodedSegments []*codec.EncodedSegment, primaryFindings []report.Finding, logger zerolog.Logger) []report.Finding {
+	if len(primaryFindings) == 0 {
+		logger.Debug().Msg("no primary findings to process for required rules")
+		return primaryFindings
+	}
+
+	// Pre-collect all required rule findings once
+	allRequiredFindings := make(map[string][]report.Finding)
+
+	for _, requiredRule := range r.RequiredRules {
+		rule, ok := d.Config.Rules[requiredRule.RuleID]
+		if !ok {
+			logger.Error().Str("rule-id", requiredRule.RuleID).Msg("required rule not found in config")
+			continue
+		}
+
+		// Mark fragment as inherited to prevent infinite recursion
+		inheritedFragment := fragment
+		inheritedFragment.InheritedFromFinding = true
+
+		// Call detectRule once for each required rule
+		requiredFindings := d.detectRule(inheritedFragment, currentRaw, rule, encodedSegments)
+		allRequiredFindings[requiredRule.RuleID] = requiredFindings
+
+		logger.Debug().
+			Str("rule-id", requiredRule.RuleID).
+			Int("findings", len(requiredFindings)).
+			Msg("collected required rule findings")
+	}
+
+	var finalFindings []report.Finding
+
+	// Now process each primary finding against the pre-collected required findings
+	for _, primaryFinding := range primaryFindings {
+		var requiredFindings []*report.RequiredFinding
+
+		for _, requiredRule := range r.RequiredRules {
+			foundRequiredFindings, exists := allRequiredFindings[requiredRule.RuleID]
+			if !exists {
+				continue // Rule wasn't found earlier, skip
+			}
+
+			// Filter findings that are within proximity of the primary finding
+			for _, requiredFinding := range foundRequiredFindings {
+				if d.withinProximity(primaryFinding, requiredFinding, requiredRule) {
+					req := &report.RequiredFinding{
+						RuleID:      requiredFinding.RuleID,
+						StartLine:   requiredFinding.StartLine,
+						EndLine:     requiredFinding.EndLine,
+						StartColumn: requiredFinding.StartColumn,
+						EndColumn:   requiredFinding.EndColumn,
+						Line:        requiredFinding.Line,
+						Match:       requiredFinding.Match,
+						Secret:      requiredFinding.Secret,
+					}
+					requiredFindings = append(requiredFindings, req)
+				}
+			}
+		}
+
+		// Check if we have at least one auxiliary finding for each required rule
+		if len(requiredFindings) > 0 && d.hasAllRequiredRules(requiredFindings, r.RequiredRules) {
+			// Create a finding with auxiliary findings
+			newFinding := primaryFinding // Copy the primary finding
+			newFinding.AddRequiredFindings(requiredFindings)
+			finalFindings = append(finalFindings, newFinding)
+
+			logger.Debug().
+				Str("primary-rule", r.RuleID).
+				Int("primary-line", primaryFinding.StartLine).
+				Int("auxiliary-count", len(requiredFindings)).
+				Msg("multi-part rule satisfied")
+		}
+	}
+
+	return finalFindings
+}
+
+// hasAllRequiredRules checks if we have at least one auxiliary finding for each required rule
+func (d *Detector) hasAllRequiredRules(auxiliaryFindings []*report.RequiredFinding, requiredRules []*config.Required) bool {
+	foundRules := make(map[string]bool)
+	// AuxiliaryFinding
+	for _, aux := range auxiliaryFindings {
+		foundRules[aux.RuleID] = true
+	}
+
+	for _, required := range requiredRules {
+		if !foundRules[required.RuleID] {
+			return false
+		}
+	}
+
+	return true
+}
+
+func (d *Detector) withinProximity(primary, required report.Finding, requiredRule *config.Required) bool {
+	// fmt.Println(requiredRule.WithinLines)
+	// If neither within_lines nor within_columns is set, findings just need to be in the same fragment
+	if requiredRule.WithinLines == nil && requiredRule.WithinColumns == nil {
+		return true
+	}
+
+	// Check line proximity (vertical distance)
+	if requiredRule.WithinLines != nil {
+		lineDiff := abs(primary.StartLine - required.StartLine)
+		if lineDiff > *requiredRule.WithinLines {
+			return false
+		}
+	}
+
+	// Check column proximity (horizontal distance)
+	if requiredRule.WithinColumns != nil {
+		// Use the start column of each finding for proximity calculation
+		colDiff := abs(primary.StartColumn - required.StartColumn)
+		if colDiff > *requiredRule.WithinColumns {
+			return false
+		}
+	}
+
+	return true
+}
+
+// abs returns the absolute value of an integer
+func abs(x int) int {
+	if x < 0 {
+		return -x
+	}
+	return x
 }
 
 // AddFinding synchronously adds a finding to the findings slice

+ 112 - 14
detect/detect_test.go

@@ -1,8 +1,10 @@
 package detect
 
 import (
+	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -10,6 +12,7 @@ import (
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/rs/zerolog"
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
@@ -86,6 +89,46 @@ secret=%25%35%61%25%34%37%25%35%36jb2RlZC1zZWNyZXQtdmFsdWU%25%32%35%25%33%33%25%
 secret%3D%25%35%61%25%34%37%25%35%36jb2RlZC1zZWNyZXQtdmFsdWU%25%32%35%25%33%33%25%36%34
 `
 
+var multili = `
+username = "admin"
+
+
+
+			password = "secret123"
+`
+
+func compare(t *testing.T, a, b []report.Finding) {
+	if diff := cmp.Diff(a, b,
+		cmpopts.SortSlices(func(a, b report.Finding) bool {
+			if a.File != b.File {
+				return a.File < b.File
+			}
+			if a.StartLine != b.StartLine {
+				return a.StartLine < b.StartLine
+			}
+			if a.StartColumn != b.StartColumn {
+				return a.StartColumn < b.StartColumn
+			}
+			if a.EndLine != b.EndLine {
+				return a.EndLine < b.EndLine
+			}
+			if a.EndColumn != b.EndColumn {
+				return a.EndColumn < b.EndColumn
+			}
+			if a.RuleID != b.RuleID {
+				return a.RuleID < b.RuleID
+			}
+			return a.Secret < b.Secret
+		}),
+		cmpopts.IgnoreFields(report.Finding{},
+			"Fingerprint", "Author", "Email", "Date", "Message", "Commit", "requiredFindings"),
+		cmpopts.EquateApprox(0.0001, 0), // For floating point Entropy comparison
+	); diff != "" {
+		t.Errorf("findings mismatch (-want +got):\n%s", diff)
+	}
+
+}
+
 func TestDetect(t *testing.T) {
 	logging.Logger = logging.Logger.Level(zerolog.TraceLevel)
 	tests := map[string]struct {
@@ -97,8 +140,9 @@ func TestDetect(t *testing.T) {
 		// I.e., if the finding is from a --no-git file, the line number will be
 		// increase by 1 in DetectFromFiles(). If the finding is from git,
 		// the line number will be increased by the patch delta.
-		expectedFindings []report.Finding
-		wantError        error
+		expectedFindings  []report.Finding
+		wantError         error
+		expectedAuxOutput string
 	}{
 		// General
 		"valid allow comment (1)": {
@@ -424,6 +468,28 @@ const token = "mockSecret";
 				FilePath: "tmp.go",
 			},
 		},
+		"fragment level composite": {
+			cfgName: "composite",
+			fragment: Fragment{
+				Raw: multili,
+			},
+			expectedFindings: []report.Finding{
+				{
+					Description: "Primary rule",
+					RuleID:      "primary-rule",
+					StartLine:   5,
+					EndLine:     5,
+					StartColumn: 5,
+					EndColumn:   26,
+					Line:        "\n\t\t\tpassword = \"secret123\"",
+					Match:       `password = "secret123"`,
+					Secret:      "secret123",
+					Entropy:     2.9477028846740723,
+					Tags:        []string{},
+				},
+			},
+			expectedAuxOutput: "Required:    username-rule:1:admin\n",
+		},
 		// Decoding
 		"detect encoded": {
 			cfgName: "encoded",
@@ -736,11 +802,50 @@ const token = "mockSecret";
 			d.baselinePath = tt.baselinePath
 
 			findings := d.Detect(tt.fragment)
-			assert.ElementsMatch(t, tt.expectedFindings, findings)
+
+			compare(t, findings, tt.expectedFindings)
+
+			// extremely goofy way to test auxiliary findings
+			// capture stdout and print that sonabitch
+			// TODO
+			if tt.expectedAuxOutput != "" {
+				capturedOutput := captureStdout(func() {
+					for _, finding := range findings {
+						finding.PrintRequiredFindings()
+					}
+				})
+
+				// Clean up the output for comparison (remove ANSI color codes)
+				cleanOutput := stripANSI(capturedOutput)
+				expectedClean := stripANSI(tt.expectedAuxOutput)
+
+				assert.Equal(t, expectedClean, cleanOutput, "Auxiliary output should match")
+			}
+
 		})
 	}
 }
 
+func stripANSI(s string) string {
+	ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+	return ansiRegex.ReplaceAllString(s, "")
+}
+
+func captureStdout(f func()) string {
+	oldStdout := os.Stdout
+	r, w, _ := os.Pipe()
+	os.Stdout = w
+
+	f()
+
+	w.Close()
+	os.Stdout = oldStdout
+
+	var buf bytes.Buffer
+	io.Copy(&buf, r)
+	return buf.String()
+}
+
 // TestFromGit tests the FromGit function
 func TestFromGit(t *testing.T) {
 	// TODO: Fix this test on windows.
@@ -2300,10 +2405,9 @@ let password = 'Summer2024!';`
 
 			f := tc.fragment
 			f.Raw = raw
+
 			actual := d.detectRule(f, raw, rule, []*codec.EncodedSegment{})
-			if diff := cmp.Diff(tc.expected, actual); diff != "" {
-				t.Errorf("diff: (-want +got)\n%s", diff)
-			}
+			compare(t, tc.expected, actual)
 		})
 	}
 }
@@ -2462,9 +2566,7 @@ func TestWindowsFileSeparator_RulePath(t *testing.T) {
 	for name, test := range tests {
 		t.Run(name, func(t *testing.T) {
 			actual := d.detectRule(test.fragment, test.fragment.Raw, test.rule, []*codec.EncodedSegment{})
-			if diff := cmp.Diff(test.expected, actual); diff != "" {
-				t.Errorf("diff: (-want +got)\n%s", diff)
-			}
+			compare(t, test.expected, actual)
 		})
 	}
 }
@@ -2648,11 +2750,7 @@ func TestWindowsFileSeparator_RuleAllowlistPaths(t *testing.T) {
 	for name, test := range tests {
 		t.Run(name, func(t *testing.T) {
 			actual := d.detectRule(test.fragment, test.fragment.Raw, test.rule, []*codec.EncodedSegment{})
-			if diff := cmp.Diff(test.expected, actual); diff != "" {
-				t.Errorf("diff: (-want +got)\n%s", diff)
-			}
+			compare(t, test.expected, actual)
 		})
 	}
 }
-
-//endregion

+ 5 - 0
detect/utils.go

@@ -232,7 +232,9 @@ func printFinding(f report.Finding, noColor bool) {
 
 	fmt.Printf("%-12s %s\n", "RuleID:", f.RuleID)
 	fmt.Printf("%-12s %f\n", "Entropy:", f.Entropy)
+
 	if f.File == "" {
+		f.PrintRequiredFindings()
 		fmt.Println("")
 		return
 	}
@@ -243,6 +245,7 @@ func printFinding(f report.Finding, noColor bool) {
 	fmt.Printf("%-12s %d\n", "Line:", f.StartLine)
 	if f.Commit == "" {
 		fmt.Printf("%-12s %s\n", "Fingerprint:", f.Fingerprint)
+		f.PrintRequiredFindings()
 		fmt.Println("")
 		return
 	}
@@ -254,5 +257,7 @@ func printFinding(f report.Finding, noColor bool) {
 	if f.Link != "" {
 		fmt.Printf("%-12s %s\n", "Link:", f.Link)
 	}
+
+	f.PrintRequiredFindings()
 	fmt.Println("")
 }

+ 60 - 4
report/finding.go

@@ -1,12 +1,16 @@
 package report
 
 import (
+	"fmt"
 	"math"
 	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+	"github.com/zricethezav/gitleaks/v8/sources"
 )
 
-// Finding contains information about strings that
-// have been captured by a tree-sitter query.
+// Finding contains a whole bunch of information about a secret finding.
+// Plenty of real estate in this bad boy so fillerup as needed.
 type Finding struct {
 	// Rule is the name of the rule that was matched
 	RuleID      string
@@ -21,8 +25,7 @@ type Finding struct {
 
 	Match string
 
-	// Secret contains the full content of what is matched in
-	// the tree-sitter query.
+	// Captured secret
 	Secret string
 
 	// File is the name of the file containing the finding
@@ -42,6 +45,33 @@ type Finding struct {
 
 	// unique identifier
 	Fingerprint string
+
+	// Fragment used for multi-part rule checking, CEL filtering,
+	// and eventually ML validation
+	Fragment *sources.Fragment `json:",omitempty"`
+
+	// TODO keeping private for now to during experimental phase
+	requiredFindings []*RequiredFinding
+}
+
+type RequiredFinding struct {
+	// contains a subset of the Finding fields
+	// only used for reporting
+	RuleID      string
+	StartLine   int
+	EndLine     int
+	StartColumn int
+	EndColumn   int
+	Line        string `json:"-"`
+	Match       string
+	Secret      string
+}
+
+func (f *Finding) AddRequiredFindings(afs []*RequiredFinding) {
+	if f.requiredFindings == nil {
+		f.requiredFindings = make([]*RequiredFinding, 0)
+	}
+	f.requiredFindings = append(f.requiredFindings, afs...)
 }
 
 // Redact removes sensitive information from a finding.
@@ -68,3 +98,29 @@ func maskSecret(secret string, percent uint) string {
 
 	return secret[:lth] + "..."
 }
+
+func (f *Finding) PrintRequiredFindings() {
+	if len(f.requiredFindings) == 0 {
+		return
+	}
+
+	fmt.Printf("%-12s ", "Required:")
+
+	// Create orange style for secrets
+	orangeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#bf9478"))
+
+	for i, aux := range f.requiredFindings {
+		auxSecret := strings.TrimSpace(aux.Secret)
+		// Truncate long secrets for readability
+		if len(auxSecret) > 40 {
+			auxSecret = auxSecret[:37] + "..."
+		}
+
+		// Format: rule-id:line:secret
+		if i == 0 {
+			fmt.Printf("%s:%d:%s\n", aux.RuleID, aux.StartLine, orangeStyle.Render(auxSecret))
+		} else {
+			fmt.Printf("%-12s %s:%d:%s\n", "", aux.RuleID, aux.StartLine, orangeStyle.Render(auxSecret))
+		}
+	}
+}

+ 2 - 0
sources/fragment.go

@@ -23,4 +23,6 @@ type Fragment struct {
 
 	// CommitInfo captures additional information about the git commit if applicable
 	CommitInfo *CommitInfo
+
+	InheritedFromFinding bool // Indicates if this fragment is inherited from a finding
 }

+ 14 - 0
testdata/config/composite.toml

@@ -0,0 +1,14 @@
+title = "Fragment level composite rule"
+
+[[rules]]
+id = "primary-rule"
+description = "Primary rule"
+regex = 'password\s*=\s*"([^"]+)"'
+[[rules.required]]
+id = "username-rule"
+
+[[rules]]
+id = "username-rule"
+description = "Username rule"
+regex = 'username\s*=\s*"([^"]+)"'
+skipReport = true