diff --git a/Dockerfile b/Dockerfile index 399da46e..9211c54d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # and "Missing User Instruction" since 2ms container is stopped after scan # Builder image -FROM checkmarx/go:1.27.0-r1-7ff3a27a305109@sha256:7ff3a27a305109341ebf351a1421172d7ee41aeeeb0609451ddb6c8ee5d144b3 AS builder +FROM checkmarx/go:1.27.1-r0-424cf19b9e848d@sha256:424cf19b9e848d86bbf0ed45b216d782f064bfb6b1dd7eba7f5a8cc3f750088f AS builder WORKDIR /app @@ -20,7 +20,7 @@ COPY . . RUN GOOS=linux GOARCH=amd64 go build -buildvcs=false -ldflags="-s -w" -a -o /app/2ms . # Runtime image -FROM checkmarx/git:2.55.0-r5-d0ccbb0b82fcb8@sha256:d0ccbb0b82fcb8c84ee36087b47eefbf59f8259c4f902fbb3591acd1ee00c546 +FROM checkmarx/git:2.55.0-r7-193d1e713216b7@sha256:193d1e713216b75b63eb05c3ebac0185620565b10a33d2ca1b3a89e8bd46c4fc WORKDIR /app diff --git a/engine/engine.go b/engine/engine.go index 9f696971..6c2e330b 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -527,6 +527,39 @@ func GetRulesCommand(engineConfig *EngineConfig) *cobra.Command { } } +// secretSuffixTailRegexes match, at the end of a string, whatever a rule's secret-suffix +// regex would have matched right after the secret's capture group. They're derived directly +// from ruledefine.SecretSuffix and ruledefine.SecretSuffixIncludingXml +var secretSuffixTailRegexes = []*regexp.Regexp{ + regexp.MustCompile(strings.TrimPrefix(ruledefine.SecretSuffix, ")") + "$"), + regexp.MustCompile(strings.TrimPrefix(ruledefine.SecretSuffixIncludingXml, ")") + "$"), +} + +// trimSecretSuffixOverlap returns endColumn adjusted so it no longer includes the trailing +// boundary characters matched by the rule's secret-suffix regex. +// +// Both suffix regexes always have a zero-width `$` alternative, so they'll always "match" at +// endColumn itself; what matters is the longest overlap found across both regexes, not merely +// whether one of them matched. +func trimSecretSuffixOverlap(line string, endColumn int) int { + if endColumn <= 0 || endColumn > len(line) { + return endColumn + } + head := line[:endColumn] + + overlap := 0 + for _, re := range secretSuffixTailRegexes { + matches := re.FindStringIndex(head) + if matches != nil { + matchedSuffixLength := matches[1] - matches[0] + if matchedSuffixLength > overlap { + overlap = matchedSuffixLength + } + } + } + return endColumn - overlap +} + // buildSecret creates a secret object from the given source item and finding func buildSecret( ctx context.Context, @@ -547,13 +580,14 @@ func buildSecret( hasNewline := strings.HasPrefix(value.Line, "\n") + adjustedEndColumn := trimSecretSuffixOverlap(value.Line, value.EndColumn) + if hasNewline { value.Line = strings.TrimPrefix(value.Line, "\n") } value.Line = strings.ReplaceAll(value.Line, "\r", "") adjustedStartColumn := value.StartColumn - adjustedEndColumn := value.EndColumn if hasNewline { adjustedStartColumn-- adjustedEndColumn-- diff --git a/engine/engine_test.go b/engine/engine_test.go index 0fc7cabb..30e3ed49 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -476,9 +476,19 @@ func TestDetectChunks(t *testing.T) { func TestSecretsColumnIndex(t *testing.T) { + const defaultSecret = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + xmlSuffixSecret := "AIzaSyATD" + xmlSuffixLine := "" + xmlSuffixSecret + "" + xmlSuffixSecretStart := len("") + 1 + xmlSuffixSecretEnd := xmlSuffixSecretStart + len(xmlSuffixSecret) - 1 + + generalSuffixSecret := "5qnwhuk" + tests := []struct { name string lineContent string + secret string startColumn int endColumn int expectedLineContent string @@ -488,6 +498,7 @@ func TestSecretsColumnIndex(t *testing.T) { { name: "secret on first line without newline", lineContent: `let apikey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"`, + secret: defaultSecret, startColumn: 14, endColumn: 50, expectedLineContent: `let apikey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"`, @@ -497,6 +508,7 @@ func TestSecretsColumnIndex(t *testing.T) { { name: "secret with leading newline", lineContent: "\nlet apikey = \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\"", + secret: defaultSecret, startColumn: 15, endColumn: 51, expectedLineContent: `let apikey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"`, @@ -506,6 +518,7 @@ func TestSecretsColumnIndex(t *testing.T) { { name: "leading newline followed by tab indentation", lineContent: "\n let apikey = \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\"", + secret: defaultSecret, startColumn: 2, endColumn: 7, expectedLineContent: " let apikey = \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\"", @@ -515,6 +528,7 @@ func TestSecretsColumnIndex(t *testing.T) { { name: "leading newline followed by tab indentation with special character", lineContent: "\n\tlet apikey€ = \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\"", + secret: defaultSecret, startColumn: 2, endColumn: 7, expectedLineContent: " let apikey€ = \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\"", @@ -524,12 +538,73 @@ func TestSecretsColumnIndex(t *testing.T) { { name: "newline with content larger than context limit", lineContent: "\n" + strings.Repeat("A", 500) + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + strings.Repeat("B", 500), + secret: defaultSecret, startColumn: 501, endColumn: 536, expectedLineContent: strings.Repeat("A", 250) + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + strings.Repeat("B", 250), expectedStartColumn: 500, expectedEndColumn: 535, }, + { + name: "generic-api-key xml suffix consumes closing tag", + lineContent: xmlSuffixLine, + secret: xmlSuffixSecret, + startColumn: xmlSuffixSecretStart, + endColumn: xmlSuffixSecretEnd + len(""), + expectedLineContent: xmlSuffixLine, + expectedStartColumn: xmlSuffixSecretStart, + expectedEndColumn: xmlSuffixSecretEnd, + }, + { + name: "secret followed by carriage return", + lineContent: generalSuffixSecret + "\r", + secret: generalSuffixSecret, + startColumn: 1, + endColumn: len(generalSuffixSecret + "\r"), + expectedLineContent: generalSuffixSecret, // buildSecret strips all \r bytes from Line + expectedStartColumn: 1, + expectedEndColumn: len(generalSuffixSecret), + }, + { + name: "secret followed by newline", + lineContent: generalSuffixSecret + "\n", + secret: generalSuffixSecret, + startColumn: 1, + endColumn: len(generalSuffixSecret + "\n"), + expectedLineContent: generalSuffixSecret + "\n", + expectedStartColumn: 1, + expectedEndColumn: len(generalSuffixSecret), + }, + { + name: "secret followed by semicolon", + lineContent: generalSuffixSecret + ";", + secret: generalSuffixSecret, + startColumn: 1, + endColumn: len(generalSuffixSecret + ";"), + expectedLineContent: generalSuffixSecret + ";", + expectedStartColumn: 1, + expectedEndColumn: len(generalSuffixSecret), + }, + { + name: `secret followed by double quote`, + lineContent: generalSuffixSecret + `"`, + secret: generalSuffixSecret, + startColumn: 1, + endColumn: len(generalSuffixSecret + `"`), + expectedLineContent: generalSuffixSecret + `"`, + expectedStartColumn: 1, + expectedEndColumn: len(generalSuffixSecret), + }, + { + name: `secret followed by literal backslash-r backslash-n`, + lineContent: generalSuffixSecret + `\r\n`, + secret: generalSuffixSecret, + startColumn: 1, + endColumn: len(generalSuffixSecret + `\r\n`), + expectedLineContent: generalSuffixSecret + `\r\n`, + expectedStartColumn: 1, + expectedEndColumn: len(generalSuffixSecret + `\r`), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -539,7 +614,7 @@ func TestSecretsColumnIndex(t *testing.T) { finding := report.Finding{ StartColumn: tt.startColumn, EndColumn: tt.endColumn, - Secret: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + Secret: tt.secret, RuleID: "test-rule", Description: "Test Description", Line: tt.lineContent, diff --git a/engine/rules/ruledefine/utils.go b/engine/rules/ruledefine/utils.go index 529b628a..4993317a 100644 --- a/engine/rules/ruledefine/utils.go +++ b/engine/rules/ruledefine/utils.go @@ -28,7 +28,7 @@ const ( secretPrefixUnique = `\b(` secretPrefix = `[\x60'"\s=]{0,20}(` //nolint:gosec // This is a regex pattern SecretSuffix = `)(?:[\x60'"\s;]|\\[nr]|$)` //nolint:gosec // This is a regex pattern - secretSuffixIncludingXml = `)(?:['|\"|\n|\r|\s|\x60|;]|\\n|\\r|$|\s{0,10}<\/string>)` //nolint:gosec // This is a regex pattern + SecretSuffixIncludingXml = `)(?:['|\"|\n|\r|\s|\x60|;]|\\n|\\r|$|\s{0,10}<\/string>)` //nolint:gosec // This is a regex pattern ) func generateSemiGenericRegex(identifiers []string, secretRegex string, isCaseInsensitive bool) *regexp.Regexp { @@ -83,7 +83,7 @@ func generateSemiGenericRegexIncludingXml(identifiers []string, secretRegex stri sb.WriteString(operator) sb.WriteString(secretPrefix) sb.WriteString(secretRegex) - sb.WriteString(secretSuffixIncludingXml) + sb.WriteString(SecretSuffixIncludingXml) return regexp.MustCompile(sb.String()) }