Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/artifacts/src/secret-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,19 @@ export function assertArtifactPublicationsContainNoSecrets(
// sequences with replacement so printable UTF-8/ASCII secret signatures
// are still scanned, while exact active credentials remain protected by
// the byte-level check above.
//
// "positive-only": this gate FAILS on a hit and deliberately does not
// rewrite the bytes, so a false positive destroys a run rather than
// over-redacting a log line. The speculative heuristics cannot carry that
// weight — Shannon entropy scores a versioned contract method (4.62) above
// a GitHub token (4.25), unlabeled 40-hex matches every commit hash, and
// the key-name rule fires on the English sentence "For every token:"
// (#819). Positively identified credentials — secretlint library findings,
// the documented supplemental vendor formats, mnemonics, labeled private
// keys, URL and Bearer credentials — are all still detected here, and the
// configured values are still matched byte-for-byte above.
const text = new TextDecoder("utf-8").decode(bytes);
if (containsSensitiveSecrets(text)) {
if (containsSensitiveSecrets(text, [], "positive-only")) {
throw new ArtifactSecretGateError(artifactPath, "secret-detected");
}
}
Expand Down
64 changes: 60 additions & 4 deletions packages/artifacts/test/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,9 @@ const entropyAtRestSecret = "aB3dE5fG7hJ9kL2mN4pQ6rS8tV0wXyZ1_cD3eF5gH7jK9mP2q";
function atRestSecretFixture(): string {
return [
`private-key=0x${"1a".repeat(32)}`,
"token npm_0123456789abcdefghijklmnopqrstuv",
// Real npm tokens are npm_ plus exactly 36 characters; secretlint encodes
// the true vendor format, so the fixture uses it.
"token npm_0123456789abcdefghijklmnopqrstuvwxyz",
`mnemonic ${mnemonicAtRestSecret}`,
"rpc https://eth-mainnet.g.alchemy.com/v2/0123456789abcdefghijklmnopqrstuv",
`opaque ${entropyAtRestSecret}`,
Expand Down Expand Up @@ -1025,13 +1027,16 @@ test("attempt failures redact key, token, mnemonic, URL, entropy, and exact run

test("canonical publication secret gate fails closed without rewriting bytes", () => {
const maintainedFixtures = new Map<string, Buffer>([
["report.md", Buffer.from(`wallet ${mnemonicAtRestSecret}`, "utf8")],
["generated/Exploit.t.sol", Buffer.from(`constant TOKEN = "${entropyAtRestSecret}";`, "utf8")]
["generated/Exploit.t.sol", Buffer.from(`constant TOKEN = "${entropyAtRestSecret}";`, "utf8")],
["report.md", Buffer.from(`wallet ${mnemonicAtRestSecret}`, "utf8")]
]);
const original = new Map([...maintainedFixtures].map(([artifactPath, bytes]) => [artifactPath, Buffer.from(bytes)]));
// The mnemonic is a positive identification and still fails the gate. The
// opaque high-entropy blob beside it no longer does — see the accepted cases
// below for why the speculative heuristics are off for publication.
assert.throws(
() => assertArtifactPublicationsContainNoSecrets(maintainedFixtures),
(error: unknown) => error instanceof ArtifactSecretGateError && error.artifactPath === "generated/Exploit.t.sol"
(error: unknown) => error instanceof ArtifactSecretGateError && error.artifactPath === "report.md"
);
for (const [artifactPath, bytes] of maintainedFixtures) assert.deepEqual(bytes, original.get(artifactPath));

Expand Down Expand Up @@ -1067,6 +1072,57 @@ test("canonical publication secret gate fails closed without rewriting bytes", (
["e"]
)
);

// Ordinary technical prose must publish. Each of these failed the gate
// before the speculative heuristics were scoped out of publication, and each
// killed a real campaign run (#819, #822): qualified method groups and long
// generated test names clear the entropy thresholds purely because
// mixed-case identifiers are character-diverse, pinned dependency commits
// are unlabeled 40-hex, the key-name rule fires on an English sentence about
// tokens, and hyphenated goal identifiers used to read as ak-/as- keys.
assert.doesNotThrow(() =>
assertArtifactPublicationsContainNoSecrets(
new Map([
["setup/actors.md", Buffer.from("IExampleVaultCore.setAuthority/togglePause/transferOwnership\n", "utf8")],
["setup/ledger.json", Buffer.from('{"result":"testFuzz_MaximumPrincipalForGrossIsSafeAndBounded"}\n', "utf8")],
["generated-tests/EnumFixture.t.sol", Buffer.from("IExampleBook.NativeExecInstruction.POST_ONLY;\n", "utf8")],
["setup/invariants.md", Buffer.from("For every token:\n balance >= sum of credits\n", "utf8")],
[
"setup/dependencies.md",
Buffer.from(`Pinned: \`${"da0e9c1b".repeat(5)}\`, \`${"1a2b3c4d".repeat(5)}\`.\n`, "utf8")
],
["setup/goals.md", Buffer.from("policy.record-treated-as-private-to-the-service\n", "utf8")],
["report.md", Buffer.from("the role bearer (i.e. `account`) is granted the permission\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.
for (const [artifactPath, body] of [
["k01.txt", "sk-ant-api03-AbCdEf1234567890AbCdEf1234567890AbCdEf"],
["k02.txt", "ghp_AbCdEf1234567890AbCdEf1234567890AbCd"],
["k03.txt", "AKIAIOSFODNN7EXAMPLE"],
["k04.txt", "AIzaSyD-1234567890abcdefghijklmnopqrstuv"],
["k05.txt", "xoxb-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrSt"],
["k06.txt", "npm_AbCdEf1234567890AbCdEf1234567890AbCd"],
["k07.txt", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"],
["k08.txt", "https://admin:hunter2hunter2@example.com/x"],
["k09.txt", "Authorization: Bearer AbCdEf1234567890AbCdEf1234567890"],
["k10.txt", "modal ak-0123456789abcdefghijklmnop"],
["k11.txt", "oauth ya29.a0AfH6SMB0123456789abcdefghijklmnop"],
["k12.txt", "rpc wss://mainnet.infura.io/v3/0123456789abcdefghijklmnopqrstuv"],
["k13.txt", `github fine-grained github_pat_${"A1".repeat(41)}`],
["k14.txt", "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----"],
["k15.txt", `signing key: ${"2b".repeat(32)}`]
] as const) {
assert.throws(
() => assertArtifactPublicationsContainNoSecrets(new Map([[artifactPath, Buffer.from(body, "utf8")]])),
new RegExp(artifactPath.replaceAll(".", "\\."), "u"),
`${artifactPath} must still be rejected`
);
}
assert.doesNotThrow(() =>
assertArtifactPublicationsContainNoSecrets(
new Map([
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime/test/generated-workflow-verifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2329,7 +2329,10 @@ test("generated Smithers verifier rejects secret-bearing captured bytes before p
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ultrafuzz-secret-output-")));
try {
const outputPath = path.join(root, "result.json");
const contaminated = Buffer.from("analysis token=otherwise-unknown-secret\n", "utf8");
// A positively identified vendor-format credential: the publication gate
// scans positive-only (#819), so a bare `token=` keyword assignment is a
// display-redaction heuristic and no longer fails publication.
const contaminated = Buffer.from("analysis ghp_AbCdEf1234567890AbCdEf1234567890AbCd\n", "utf8");
fs.writeFileSync(outputPath, contaminated);
const task = singleOutputVerificationTask(root, "ultrafuzz/text@1");
const harness = loadVerifyArtifactsHarness();
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime/test/verified-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ test("verified final-report reader binds immutable current bytes to verifier and

test("verified output readers reject authenticated historical publications containing secrets", () => {
const issue = currentIssue();
issue.description = "Executor leaked token=otherwise-unknown-historical-secret";
// A positively identified vendor-format credential: the publication gate
// scans positive-only (#819), so a bare `token=` keyword assignment is a
// display-redaction heuristic and no longer fails publication.
issue.description = "Executor leaked ghp_AbCdEf1234567890AbCdEf1234567890AbCd";
const fixture = createVerifiedReportFixture("verified-report-secret-contamination", {
report: currentReport("verified-report-secret-contamination", [issue])
});
Expand Down
7 changes: 6 additions & 1 deletion packages/security/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@scure/bip39": "2.3.0"
"@scure/bip39": "2.3.0",
"@secretlint/core": "13.0.4",
"@secretlint/secretlint-rule-preset-recommend": "13.0.4"
},
"devDependencies": {
"@secretlint/types": "13.0.4"
}
}
57 changes: 57 additions & 0 deletions packages/security/src/secretlint-scan-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Shared-memory layout of the synchronous secret-scanning bridge. One request
* is in flight at a time (the requesting side is synchronous), so a single
* length-prefixed payload region carries the request text in one direction and
* the findings JSON back in the other.
*/

/** Int32 slot counting scan requests issued by the requesting thread. */
export const SCAN_REQUEST_SLOT = 0;
/** Int32 slot counting scan responses completed by the worker. */
export const SCAN_RESPONSE_SLOT = 1;
export const SCAN_SIGNAL_BYTES = 8;

const PAYLOAD_LENGTH_BYTES = 4;
/**
* Initial payload capacity. The buffer grows on demand up to the maximum, and
* growth only reserves virtual address space until pages are touched.
*/
export const SCAN_CHANNEL_INITIAL_BYTES = 1 << 16;
/**
* Publication-gate artifacts are capped at 16MB of bytes, which decode to at
* most 16M UTF-16 code units and re-encode to at most 3 bytes each; 256MB
* covers any text a caller can realistically hand the redaction API.
*/
export const SCAN_CHANNEL_MAX_BYTES = 1 << 28;

const utf8Decoder = new TextDecoder();
const utf8Encoder = new TextEncoder();

export function createScanChannelBuffer(): SharedArrayBuffer {
return new SharedArrayBuffer(SCAN_CHANNEL_INITIAL_BYTES, { maxByteLength: SCAN_CHANNEL_MAX_BYTES });
}

/**
* Write one length-prefixed UTF-8 payload. UTF-8 round-tripping preserves
* code-unit counts even for ill-formed strings (a lone surrogate decodes back
* as one replacement code unit), so finding ranges computed by the worker
* stay valid offsets into the requester's original string.
*/
export function writeScanChannelPayload(data: SharedArrayBuffer, payload: string): void {
const encoded = utf8Encoder.encode(payload);
const required = PAYLOAD_LENGTH_BYTES + encoded.byteLength;
if (required > data.maxByteLength) {
throw new Error(`scan payload of ${encoded.byteLength} bytes exceeds the ${data.maxByteLength}-byte channel`);
}
if (required > data.byteLength) data.grow(required);
new Uint8Array(data).set(encoded, PAYLOAD_LENGTH_BYTES);
new DataView(data, 0, PAYLOAD_LENGTH_BYTES).setUint32(0, encoded.byteLength, true);
}

export function readScanChannelPayload(data: SharedArrayBuffer): string {
const length = new DataView(data, 0, PAYLOAD_LENGTH_BYTES).getUint32(0, true);
// TextDecoder refuses shared memory; copy the payload out first.
const copy = new Uint8Array(length);
copy.set(new Uint8Array(data).subarray(PAYLOAD_LENGTH_BYTES, PAYLOAD_LENGTH_BYTES + length));
return utf8Decoder.decode(copy);
}
80 changes: 80 additions & 0 deletions packages/security/src/secretlint-scan-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { workerData } from "node:worker_threads";

import { lintSource } from "@secretlint/core";
import { rules as recommendedRules } from "@secretlint/secretlint-rule-preset-recommend";

import {
readScanChannelPayload,
SCAN_REQUEST_SLOT,
SCAN_RESPONSE_SLOT,
writeScanChannelPayload
} from "./secretlint-scan-channel.js";

/**
* Worker side of the synchronous secret-scanning bridge. secretlint's public
* API is Promise-based, but every redaction and publication-gate caller in
* this codebase is synchronous, so the scan runs on this dedicated thread and
* the requesting thread blocks until the response sequence advances. All data
* moves through the shared buffers: MessagePort round trips are deliberately
* not used because Node retains per-message state on a heavily reused port,
* which measurably degraded a long-lived scanning session (95ms/scan after
* 2000 messages against a flat 0.1ms here). See secretlint-scanner.ts for the
* requesting side.
*/
interface ScanWorkerData {
signal: SharedArrayBuffer;
data: SharedArrayBuffer;
}

/**
* The recommended preset's rules, registered individually. Scanned content is
* agent-controlled, so the preset's filter-comments rule must be excluded: it
* honors "secretlint-disable" comments inside the scanned text itself, which
* would let contaminated output suppress its own findings and walk through
* the fail-closed publication gate. (The preset creator ignores `disabled` on
* child rules, so the exclusion has to happen at registration.)
*/
const scanRules = recommendedRules
.filter((rule) => rule.meta.id !== "@secretlint/secretlint-rule-filter-comments")
.map((rule) => ({ id: rule.meta.id, rule }));

const { signal, data } = workerData as ScanWorkerData;
const state = new Int32Array(signal);

async function scan(text: string): Promise<Array<{ start: number; end: number; ruleId: string }>> {
const result = await lintSource({
// The scanned text is artifact/diagnostic content, never a real file:
// noPhysicFilePath keeps file-oriented rules (GCP .p12) off the disk.
source: { filePath: "scanned-text.txt", content: text, ext: ".txt", contentType: "text" },
options: {
noPhysicFilePath: true,
config: { rules: scanRules }
}
});
return result.messages.map((message) => ({
start: message.range[0],
end: message.range[1],
ruleId: message.ruleId
}));
}

let handledRequests = 0;
for (;;) {
const requested = Atomics.load(state, SCAN_REQUEST_SLOT);
if (requested === handledRequests) {
// Blocking this dedicated thread while idle is intentional; scan requests
// are the only work it exists for, and process exit tears it down.
Atomics.wait(state, SCAN_REQUEST_SLOT, requested);
continue;
}
handledRequests = requested;
let payload: string;
try {
payload = JSON.stringify({ findings: await scan(readScanChannelPayload(data)) });
} catch (error) {
payload = JSON.stringify({ error: error instanceof Error ? error.message : String(error) });
}
writeScanChannelPayload(data, payload);
Atomics.add(state, SCAN_RESPONSE_SLOT, 1);
Atomics.notify(state, SCAN_RESPONSE_SLOT);
}
Loading