diff --git a/packages/artifacts/test/artifacts.test.ts b/packages/artifacts/test/artifacts.test.ts index 390b021cc..b96c1cd4b 100644 --- a/packages/artifacts/test/artifacts.test.ts +++ b/packages/artifacts/test/artifacts.test.ts @@ -1118,6 +1118,33 @@ test("canonical publication secret gate fails closed without rewriting bytes", ( ) ); + // The positive rules must not fire on ordinary contract output either: a + // Foundry test deriving and signing with Anvil's published mnemonic and + // account (0) key, and a qualified identifier with three long dotted + // segments (JWT-shaped until the rule required an eyJ header). + assert.doesNotThrow(() => + assertArtifactPublicationsContainNoSecrets( + new Map([ + [ + "generated-tests/AnvilSigner.t.sol", + Buffer.from( + 'string memory mnemonic = "test test test test test test test test test test test junk";\n' + + "uint256 privateKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;\n" + // gitleaks:allow -- public Anvil dev key fixture for the redaction tests + "assertEq(vm.deriveKey(mnemonic, 0), privateKey);\n", + "utf8" + ) + ], + [ + "setup/call-graph.md", + Buffer.from( + "`ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck` reverts on re-entry\n", + "utf8" + ) + ] + ]) + ) + ); + // Every credential format the previous hand-rolled patterns could name is // still rejected — via a secretlint library finding or a documented // supplemental pattern. diff --git a/packages/security/src/sensitive-redaction.ts b/packages/security/src/sensitive-redaction.ts index 5cd358d51..ba72ed136 100644 --- a/packages/security/src/sensitive-redaction.ts +++ b/packages/security/src/sensitive-redaction.ts @@ -49,9 +49,13 @@ const SUPPLEMENTAL_SECRET_PATTERNS: readonly RegExp[] = [ /\b(?:ak|as)-[A-Za-z0-9_]{16,}\b/gu, // No Google OAuth access-token rule in the recommended preset. /\bya29\.[A-Za-z0-9._-]{20,}\b/gu, - // No JWT rule in the recommended preset; a three-part base64url token is a - // positive format identification. - /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, + // No JWT rule in the recommended preset. A JWT header is a base64url JSON + // object, which encodes to "eyJ" when it opens with '{"' and a letter; that + // prefix is the identification. Three long dotted segments alone also + // describe qualified names such as + // ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck. Later + // segments stay unconstrained: a JWE's second one is its encrypted key. + /\b(?=eyJ)[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, // Provider-keyed RPC URLs embed the credential in the path; no secretlint // rule covers Alchemy/Infura project keys. /\b(?:https?|wss?):\/\/[^\s"'`]*(?:alchemy\.com\/v2\/|infura\.io\/v3\/)[A-Za-z0-9_-]{16,}\b/giu @@ -336,6 +340,26 @@ function redactExactSecretValues(value: string, placeholder: string, forbiddenSe return redacted; } +/** + * Anvil's and Hardhat's default mnemonic, and the keys of the 10 dev accounts + * Anvil derives from it and prints on startup (m/44'/60'/0'/0/0-9). They are + * published, not secret, and Foundry tests and scripts sign with them: + * forge-std's own test_DeriveRememberKey quotes the mnemonic and key (0). + */ +const PUBLIC_DEVELOPMENT_MNEMONIC = "test test test test test test test test test test test junk"; +const PUBLIC_DEVELOPMENT_PRIVATE_KEYS: ReadonlySet = new Set([ + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "dbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" +]); + function redactBip39Mnemonics(value: string, placeholder: string): string { type WordToken = { word: string; start: number; end: number }; let run: WordToken[] = []; @@ -357,7 +381,14 @@ function redactBip39Mnemonics(value: string, placeholder: string): string { for (const wordCount of BIP39_WORD_COUNTS) { if (run.length < wordCount) continue; const window = run.slice(-wordCount); - if (!validateMnemonic(window.map((token) => token.word).join(" "), englishWordlist)) continue; + const phrase = window.map((token) => token.word).join(" "); + if (!validateMnemonic(phrase, englishWordlist)) continue; + // The public phrase is left in place but still ends the run, so the + // words after it are checked exactly as if it had been redacted. + if (phrase === PUBLIC_DEVELOPMENT_MNEMONIC) { + run = []; + break; + } ranges.push({ start: window[0]!.start, end: window.at(-1)!.end }); run = []; break; @@ -380,6 +411,7 @@ function redactUnlabeledFortyHexSecrets(value: string, placeholder: string): str function redactContextLabeledPrivateKeys(value: string, placeholder: string): string { THIRTY_TWO_BYTE_HEX_PATTERN.lastIndex = 0; return value.replace(THIRTY_TWO_BYTE_HEX_PATTERN, (candidate, offset: number) => + !PUBLIC_DEVELOPMENT_PRIVATE_KEYS.has(candidate.replace(/^0x/iu, "").toLowerCase()) && /(?:private|secret|signing|wallet|ethereum|evm)[-_\s]*(?:key|scalar)\s*(?:(?:[:=]|\bis\b)\s*)?["'`]?$/iu.test( value.slice(Math.max(0, offset - 96), offset) ) diff --git a/packages/security/test/sensitive-redaction.test.ts b/packages/security/test/sensitive-redaction.test.ts index dfe094049..896a24872 100644 --- a/packages/security/test/sensitive-redaction.test.ts +++ b/packages/security/test/sensitive-redaction.test.ts @@ -222,6 +222,77 @@ test("the English word bearer does not redact following prose while Bearer token ); }); +test("positive-only scans publish Anvil's dev mnemonic and keys while other mnemonics and keys stay detected", () => { + // The mnemonic and keys `anvil` prints on startup. Foundry tests and scripts + // sign with them, so generated tests and reports quote them under a label. + const anvilMnemonic = "test test test test test test test test test test test junk"; + assert.equal(containsSensitiveSecrets(`string memory mnemonic = "${anvilMnemonic}";`, [], "positive-only"), false); + // A different valid mnemonic sharing its first 11 words is still a secret. + assert.equal( + redactSecretsInText(`mnemonic: ${anvilMnemonic.replace(/junk$/u, "absent")}`, undefined, [], "positive-only"), + "mnemonic: " + ); + // The phrase ends its word run: BIP39 words that follow it ("one") start a + // new run, so prose after it publishes and a real mnemonic on the next line + // is still redacted in full. + assert.equal(containsSensitiveSecrets(`${anvilMnemonic} one account per index`, [], "positive-only"), false); + assert.equal(redactSecretsInText(`${anvilMnemonic}\n${mnemonic}`), `${anvilMnemonic}\n`); + + const anvilKeys = [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + ]; + for (const key of anvilKeys) { + for (const fixture of [ + `uint256 privateKey = ${key};`, + `PRIVATE_KEY=${key}`, + `signing key: ${key.slice(2).toUpperCase()}` + ]) { + assert.equal(containsSensitiveSecrets(fixture, [], "positive-only"), false, fixture); + } + } + // Only the exact published keys are exempt: key (0) with its last digit + // changed is an unknown key. + assert.equal( + redactSecretsInText( + "uint256 privateKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff81;", // gitleaks:allow -- fake credential fixture for the redaction tests + undefined, + [], + "positive-only" + ), + "uint256 privateKey = ;" + ); +}); + +test("the JWT rule requires an eyJ header, so dotted identifiers publish", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // gitleaks:allow -- fake credential fixture for the redaction tests + assert.equal(redactSecretsInText(`session=${jwt};`, undefined, [], "positive-only"), "session=;"); + // Only the header is constrained. A JWE's second segment is its encrypted + // key, and a payload with a space after '{' encodes to "eyA". + const base64url = (json: string): string => Buffer.from(json).toString("base64url"); + for (const token of [ + `${base64url('{"alg":"RSA-OAEP","enc":"A256GCM"}')}.OKOawDo13gRp2ojaHV7LFpZcgV7T.48V1_ALb6US04U3b.5eym8TW_c8SuK0ltJ3rpYIzOeDQz.XFBoMYUZodetZdvTiFvSkQ`, + `${base64url('{"alg":"HS256"}')}.${base64url('{ "sub": "1234567890" }')}.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk` + ]) { + assert.equal(containsSensitiveSecrets(`session=${token};`, [], "positive-only"), true, token); + } + for (const fixture of [ + "ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck", + "IExampleLendingPoolCore.liquidationCall.healthFactorBefore", + "auditProfileResolution.settingOrigins.property_priority_threshold" + ]) { + assert.equal(containsSensitiveSecrets(fixture, [], "positive-only"), false, fixture); + } +}); + test("in-content secretlint-disable comments cannot suppress detection", () => { // Scanned content is agent-controlled; the filter-comments rule is disabled // so contaminated output cannot exempt itself from the fail-closed gate.