Skip to content
27 changes: 27 additions & 0 deletions packages/artifacts/test/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 36 additions & 4 deletions packages/security/src/sensitive-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string> = 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[] = [];
Expand All @@ -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;
Comment on lines +388 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Longer mnemonics can pass A valid 24-word private mnemonic can start with the public Anvil phrase. This branch clears the word run after those first 12 words, so the scanner never checks all 24 together. If the remaining 12 words are not a valid mnemonic on their own, the publication gate accepts the private phrase. The previous code kept scanning after the public phrase. How this was verified: The public-prefix branch clears the word run before the 24-word window can be assembled, and the publication gate relies on this scan for mnemonic detection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/security/src/sensitive-redaction.ts
Line: 388-390

Comment:
**Longer mnemonics can pass** A valid 24-word private mnemonic can start with the public Anvil phrase. This branch clears the word run after those first 12 words, so the scanner never checks all 24 together. If the remaining 12 words are not a valid mnemonic on their own, the publication gate accepts the private phrase. The previous code kept scanning after the public phrase. **How this was verified:** The public-prefix branch clears the word run before the 24-word window can be assembled, and the publication gate relies on this scan for mnemonic detection.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

}
ranges.push({ start: window[0]!.start, end: window.at(-1)!.end });
run = [];
break;
Expand All @@ -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)
)
Expand Down
71 changes: 71 additions & 0 deletions packages/security/test/sensitive-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <redacted>"
);
// 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<redacted>`);

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 = <redacted>;"
);
});

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=<redacted>;");
// 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.
Expand Down
Loading