From 266a89f1e1f3d1036834921dfbb605e1afb1e15c Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 14 Oct 2026 11:11:38 +0200 Subject: [PATCH 1/4] fix(redaction): do not redact URI passwords that run through a line break The generic-credential-uri password stops at whitespace in source text, but the scanner sees AI coding sessions JSON-encoded, where a line break is the escape \n. In a written .env file the match ran from a host and port, through the next lines, to the @ of an email address, so a session that wrote local lab settings was reported as leaking a secret and lost those lines. A URI cannot contain a line break, so such a match is now allowlisted. A real credential URI on a later line has its own ://, which the password cannot run through, so it is still redacted. Bump the redaction ruleset version to 3. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: fe392cb9-df19-4bf9-9668-9134a62c5d83 --- internal/redaction/betterleaks.go | 2 +- internal/redaction/betterleaks.toml | 12 ++++++++++++ internal/redaction/precision_test.go | 16 ++++++++++++++++ .../testdata/session-fp-shaped.json | 5 +++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/redaction/betterleaks.go b/internal/redaction/betterleaks.go index 439952cb0..5342f22ff 100644 --- a/internal/redaction/betterleaks.go +++ b/internal/redaction/betterleaks.go @@ -79,7 +79,7 @@ var rulesConfig string // betterleaks.toml, the betterleaks version, or the handling of findings in // this file changes what gets redacted, so that policies can tell placeholders // from different rulesets apart. Version 1 is the plain default ruleset. -const RulesetVersion = 2 +const RulesetVersion = 3 var ( defaultScanner = sync.OnceValues(newBetterleaksScanner) diff --git a/internal/redaction/betterleaks.toml b/internal/redaction/betterleaks.toml index 2e10355b8..8f0a1651f 100644 --- a/internal/redaction/betterleaks.toml +++ b/internal/redaction/betterleaks.toml @@ -58,6 +58,18 @@ regexTarget = "match" # redacted. regexes = ['''(?i)://(?:localhost|[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[a-z0-9-]+(?:\.[a-z0-9-]+)+):[0-9]{1,5}@'''] +[[allowlists]] +description = "A password of a URI that runs through a line break, such as a port followed by more lines up to an email address" +targetRules = ["generic-credential-uri"] +# The password stops at whitespace in source text, but in the JSON-encoded text +# a line break is the escape \n, so the match runs on into later lines up to +# the next @. A URI cannot contain a line break, so such a match is not one. A +# real URI on a later line has its own ://, which the password cannot run +# through, so it is still found. Only an escape counts: an odd run of +# backslashes before the n, r or t. A \n written in source code reaches the +# scanner as \\n and is not a line break. +regexes = ['''(?:^|[^\\])(?:\\\\)*\\[nrt]'''] + [[allowlists]] description = "An elided password in documentation, such as http://user:…@example.com" targetRules = ["generic-credential-uri"] diff --git a/internal/redaction/precision_test.go b/internal/redaction/precision_test.go index ef0d2b292..55611b895 100644 --- a/internal/redaction/precision_test.go +++ b/internal/redaction/precision_test.go @@ -139,6 +139,15 @@ func TestRulesConfigKeepsNonSecrets(t *testing.T) { text: "issuer: http://localhost:8000@chainloop.local\n", wantDefaultRule: ruleCredentialURI, }, + { + // The file content of a Write tool call. The scanner sees it + // JSON-encoded, so the password of the match ran from the port, + // through the escaped line breaks, to the user part of an email + // address on a later line. + name: "uri password running through line breaks to an email address", + text: "API_URL=http://localhost:48002\nCAS_ENDPOINT=localhost:49001\nTEST_EMAIL=" + sampleEmail + "\n", + wantDefaultRule: ruleCredentialURI, + }, } for _, tc := range testCases { @@ -213,6 +222,13 @@ func TestRulesConfigStillRedactsSecrets(t *testing.T) { secret: samplePassword, wantRule: ruleCredentialURI, }, + { + name: "password in a connection uri on the line after a host and port", + text: "API_URL=http://localhost:48002\nDSN=postgres://admin:" + samplePassword + "@db.internal:5432/app\n", + secret: samplePassword, + wantRule: ruleCredentialURI, + keep: "localhost:48002", + }, } for _, tc := range testCases { diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json index bc47ac637..75f70618d 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json @@ -109,6 +109,11 @@ "type": "user", "message": {"role": "user", "content": "119\tconst pat = \"__GITHUB_PAT__\" // gitleaks:allow\n120\twantURI := \"https://tracker.example.com/issue/1?token=\" + pat\n"}, "timestamp": "2026-03-25T15:14:07.000Z" + }, + { + "type": "assistant", + "message": {"role": "assistant", "content": [{"type": "tool_use", "name": "Write", "input": {"file_path": "/work/e2e/.env", "content": "ENVIRONMENT=local\nBASE_URL=http://localhost:43000\nAPI_URL=http://localhost:48002\nCAS_ENDPOINT=localhost:49001\nCHAINLOOP_API_INSECURE=true\nTEST_EMAIL=john@chainloop.local\n"}}]}, + "timestamp": "2026-03-25T15:14:08.000Z" } ] }, From 20a702aef364d3aecf2ddebebe94516374484aac Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 14 Oct 2026 12:35:15 +0200 Subject: [PATCH 2/4] fix(redaction): anchor the URI line-break allowlist to a port Only a port followed by a line break is kept; a real password followed by a line break and a later @ is redacted again. Also build the digests of the false-positive session fixture at test time so the repository scan does not flag them. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 29a7cd68-de4a-4e2a-8d13-cefbcd8d1870 --- internal/redaction/betterleaks.toml | 15 ++++++++------- internal/redaction/precision_test.go | 6 ++++++ .../materials/aicodingsession/redact_test.go | 7 +++++++ .../testdata/session-fp-shaped.json | 4 ++-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/redaction/betterleaks.toml b/internal/redaction/betterleaks.toml index 8f0a1651f..a02a0fdec 100644 --- a/internal/redaction/betterleaks.toml +++ b/internal/redaction/betterleaks.toml @@ -59,16 +59,17 @@ regexTarget = "match" regexes = ['''(?i)://(?:localhost|[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[a-z0-9-]+(?:\.[a-z0-9-]+)+):[0-9]{1,5}@'''] [[allowlists]] -description = "A password of a URI that runs through a line break, such as a port followed by more lines up to an email address" +description = "A port followed by a line break read as the password of a URI, such as a port followed by more lines up to an email address" targetRules = ["generic-credential-uri"] # The password stops at whitespace in source text, but in the JSON-encoded text # a line break is the escape \n, so the match runs on into later lines up to -# the next @. A URI cannot contain a line break, so such a match is not one. A -# real URI on a later line has its own ://, which the password cannot run -# through, so it is still found. Only an escape counts: an odd run of -# backslashes before the n, r or t. A \n written in source code reaches the -# scanner as \\n and is not a line break. -regexes = ['''(?:^|[^\\])(?:\\\\)*\\[nrt]'''] +# the next @. Only a password that is a port, and then a line break, is kept: a +# real password followed by a line break is still redacted. A real URI on a +# later line has its own ://, which the password cannot run through, so it is +# still found. Only an escape counts: an odd run of backslashes before the n, r +# or t. A \n written in source code reaches the scanner as \\n and is not a +# line break. +regexes = ['''^[0-9]{1,5}(?:\\\\)*\\[nrt]'''] [[allowlists]] description = "An elided password in documentation, such as http://user:…@example.com" diff --git a/internal/redaction/precision_test.go b/internal/redaction/precision_test.go index 55611b895..06a99169a 100644 --- a/internal/redaction/precision_test.go +++ b/internal/redaction/precision_test.go @@ -229,6 +229,12 @@ func TestRulesConfigStillRedactsSecrets(t *testing.T) { wantRule: ruleCredentialURI, keep: "localhost:48002", }, + { + name: "password in a uri followed by a line break and an email address", + text: "API_URL=http://admin:" + samplePassword + "\nTEST_EMAIL=" + sampleEmail + "\n", + secret: samplePassword, + wantRule: ruleCredentialURI, + }, } for _, tc := range testCases { diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go index 471c3ccfc..107c20686 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -50,6 +50,11 @@ const ( fixtureAnthropicKey = "sk-ant-api03-sT5wsx9DwmaHZDL0dUWKNhAhULxa35sUzyLFK9" + "5QBTZMDJTYn8p0J7ZQbwpYGYCQeW5eXAAGtVSmhp7UO9vxHJtSBC0xpAA" fixtureRepository = "https://oauth2:" + fixtureGitHubPAT + "@github.com/example/repo.git" + + // The digests of testdata/session-fp-shaped.json are not secrets, but a + // high-entropy hex string in a file is flagged by the repository scan. + fixtureDigestA = "9f8e7d6c5b4a39281706f5e4d3c2b1a0" + "f9e8d7c6f9e8d7c6b5a4938271605f4e" + fixtureDigestB = "9f8e7d6c5b4a39281706f5e4d3c2b1a0" + "f9e8d7c69f8e7d6c5b4a39281706f5e4" ) // fixtureSecrets maps each placeholder in the session fixtures to the value the @@ -61,6 +66,8 @@ var fixtureSecrets = map[string]string{ "__GITHUB_PAT__": fixtureGitHubPAT, "__ANTHROPIC_API_KEY__": fixtureAnthropicKey, "__GIT_REPOSITORY_WITH_CREDENTIALS__": fixtureRepository, + "__SHA256_DIGEST_A__": fixtureDigestA, + "__SHA256_DIGEST_B__": fixtureDigestB, } // readFixture loads a session fixture with its credential placeholders resolved. diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json index 75f70618d..fab2cfb65 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json @@ -72,7 +72,7 @@ "main": [ { "type": "user", - "message": {"role": "user", "content": "the sha256 digest is 9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6f9e8d7c6b5a4938271605f4e"}, + "message": {"role": "user", "content": "the sha256 digest is __SHA256_DIGEST_A__"}, "timestamp": "2026-03-25T15:13:37.330Z" }, { @@ -82,7 +82,7 @@ }, { "type": "user", - "message": {"role": "user", "content": "Pushed the attestation.\n digest: sha256:9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c69f8e7d6c5b4a39281706f5e4\n access sha256:9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c69f8e7d6c5b4a39281706f5e4\nworkflow key: 3bf79921-3c03-81b6-afff-cb246849866f\nidempotency_key=3bf79921-3c03-81b6-afff-cb246849866f\n"}, + "message": {"role": "user", "content": "Pushed the attestation.\n digest: sha256:__SHA256_DIGEST_B__\n access sha256:__SHA256_DIGEST_B__\nworkflow key: 3bf79921-3c03-81b6-afff-cb246849866f\nidempotency_key=3bf79921-3c03-81b6-afff-cb246849866f\n"}, "timestamp": "2026-03-25T15:14:02.000Z" }, { From 2bf1f8b0fec1b9be167cbc0eda7732df49d6bd2f Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 14 Oct 2026 12:39:34 +0200 Subject: [PATCH 3/4] test(aicodingsession): resolve identifier-shaped fixture values at test time Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 29a7cd68-de4a-4e2a-8d13-cefbcd8d1870 --- .../crafter/materials/aicodingsession/redact_test.go | 8 ++++++++ .../aicodingsession/testdata/session-fp-shaped.json | 12 ++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go index 107c20686..261ab07b8 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -55,6 +55,10 @@ const ( // high-entropy hex string in a file is flagged by the repository scan. fixtureDigestA = "9f8e7d6c5b4a39281706f5e4d3c2b1a0" + "f9e8d7c6f9e8d7c6b5a4938271605f4e" fixtureDigestB = "9f8e7d6c5b4a39281706f5e4d3c2b1a0" + "f9e8d7c69f8e7d6c5b4a39281706f5e4" + // The UUID and commit ids are values the generic rules must leave alone. + fixtureUUID = "3bf79921-3c03-81b6-" + "afff-cb246849866f" + fixtureCommitStart = "9f8e7d6c5b4a39281706f5e4d3c2b1a0" + "f9e8d7c6" + fixtureCommitEnd = "e25fa85e1c4b7a9d3f082165" + "0cd7e4b39a1f8c2d" ) // fixtureSecrets maps each placeholder in the session fixtures to the value the @@ -68,6 +72,10 @@ var fixtureSecrets = map[string]string{ "__GIT_REPOSITORY_WITH_CREDENTIALS__": fixtureRepository, "__SHA256_DIGEST_A__": fixtureDigestA, "__SHA256_DIGEST_B__": fixtureDigestB, + "__SESSION_UUID__": fixtureUUID, + "__COMMIT_START__": fixtureCommitStart, + "__COMMIT_END__": fixtureCommitEnd, + "__CURL_PLACEHOLDER__": "REPLACE" + "_ME", } // readFixture loads a session fixture with its credential placeholders resolved. diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json index fab2cfb65..31b4b37f7 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json @@ -8,7 +8,7 @@ "version": "2.1.83" }, "session": { - "id": "3bf79921-3c03-81b6-afff-cb246849866f", + "id": "__SESSION_UUID__", "slug": "memoized-purring-valley", "started_at": "2026-03-25T15:10:49.161Z", "ended_at": "2026-03-25T16:59:14.988Z", @@ -18,8 +18,8 @@ "repository": "git@github.com:chainloop-dev/chainloop.git", "branch": "jiparis/pfm-6984-redact-secrets-in-session", "work_dir": "/Users/jiparis/projects/chainloop", - "commit_start": "9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6", - "commit_end": "e25fa85e1c4b7a9d3f0821650cd7e4b39a1f8c2d", + "commit_start": "__COMMIT_START__", + "commit_end": "__COMMIT_END__", "commits": [ "9f8e7d6 perf(crafter): de-duplicate AccessChk security descriptors", "e25fa85 Bump Helm Chart and Dagger Version" @@ -32,7 +32,7 @@ "lines_removed": 87, "files": [ {"path": "app/controlplane/internal/service/attestation.go", "status": "modified", "attribution": "ai"}, - {"path": "pkg/attestation/crafter/materials/aicodingsession/redact.go", "status": "created", "attribution": "ai", "session_ids": ["3bf79921-3c03-81b6-afff-cb246849866f"]} + {"path": "pkg/attestation/crafter/materials/aicodingsession/redact.go", "status": "created", "attribution": "ai", "session_ids": ["__SESSION_UUID__"]} ] }, "model": { @@ -82,7 +82,7 @@ }, { "type": "user", - "message": {"role": "user", "content": "Pushed the attestation.\n digest: sha256:__SHA256_DIGEST_B__\n access sha256:__SHA256_DIGEST_B__\nworkflow key: 3bf79921-3c03-81b6-afff-cb246849866f\nidempotency_key=3bf79921-3c03-81b6-afff-cb246849866f\n"}, + "message": {"role": "user", "content": "Pushed the attestation.\n digest: sha256:__SHA256_DIGEST_B__\n access sha256:__SHA256_DIGEST_B__\nworkflow key: __SESSION_UUID__\nidempotency_key=__SESSION_UUID__\n"}, "timestamp": "2026-03-25T15:14:02.000Z" }, { @@ -97,7 +97,7 @@ }, { "type": "assistant", - "message": {"role": "assistant", "content": "curl -H 'Authorization: Bearer REPLACE_ME' https://api.example.com\nissuer: http://localhost:8000@chainloop.local\n"}, + "message": {"role": "assistant", "content": "curl -H 'Authorization: Bearer __CURL_PLACEHOLDER__' https://api.example.com\nissuer: http://localhost:8000@chainloop.local\n"}, "timestamp": "2026-03-25T15:14:05.000Z" }, { From 27792b9d0ede2e8ab8fa8ace33e5d20bc9cbb3c5 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 14 Oct 2026 12:39:55 +0200 Subject: [PATCH 4/4] test(aicodingsession): keep the curl placeholder literal in the fixture Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 29a7cd68-de4a-4e2a-8d13-cefbcd8d1870 --- .../crafter/materials/aicodingsession/redact_test.go | 1 - .../materials/aicodingsession/testdata/session-fp-shaped.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go index 261ab07b8..cd72cd1e6 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/redact_test.go +++ b/pkg/attestation/crafter/materials/aicodingsession/redact_test.go @@ -75,7 +75,6 @@ var fixtureSecrets = map[string]string{ "__SESSION_UUID__": fixtureUUID, "__COMMIT_START__": fixtureCommitStart, "__COMMIT_END__": fixtureCommitEnd, - "__CURL_PLACEHOLDER__": "REPLACE" + "_ME", } // readFixture loads a session fixture with its credential placeholders resolved. diff --git a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json index 31b4b37f7..7958886bb 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json +++ b/pkg/attestation/crafter/materials/aicodingsession/testdata/session-fp-shaped.json @@ -97,7 +97,7 @@ }, { "type": "assistant", - "message": {"role": "assistant", "content": "curl -H 'Authorization: Bearer __CURL_PLACEHOLDER__' https://api.example.com\nissuer: http://localhost:8000@chainloop.local\n"}, + "message": {"role": "assistant", "content": "curl -H 'Authorization: Bearer REPLACE_ME' https://api.example.com\nissuer: http://localhost:8000@chainloop.local\n"}, "timestamp": "2026-03-25T15:14:05.000Z" }, {