diff --git a/README.md b/README.md index 0e207df..88ca94c 100644 --- a/README.md +++ b/README.md @@ -451,8 +451,9 @@ worse than one that names the hole. argument, with no `=` or `:` between the name and the value: the patterns are anchored to an assignment, so `--token abc123` reads as two ordinary words. Treat the log as scrubbed, not sanitized. (The `scan` **report** goes further and does - redact a secret handed to a long flag such as `--token`, `--password` or `--key`; the - decision log does not.) + redact a secret handed to a long flag such as `--token`, `--password`, `--key` or + `--vendor-key`, a value after a name ending in `_KEY` or `-key`, an `Authorization` + header, and any long random-looking string; the decision log does not.) - **The `scan` report's name redaction is a deny-list for shell commands and an allow-list for MCP.** Paths are redacted structurally, so that rule is total. Shell names are not; the identifier redactor knows a fixed list of tools: container @@ -461,14 +462,31 @@ worse than one that names the hole. settings, 1Password's `op` (item and vault names), and `gh` titles and bodies; and it also redacts, in any command, a secret handed as a flag value (`--token`, `--password`, `--key`), a UUID, a heredoc body (which becomes ``), and the text - of a `#` comment. A bare + of a `#` comment. Cloud account identifiers are redacted where a name or flag gives + them away: `CLOUDFLARE_ACCOUNT_ID` and `--account-id`, any 32-hex operand of a + `wrangler` command, an AWS account number after `--account` or inside an ARN, + `--project` for `gcloud`, `gsutil`, `bq` and `firebase`, and the login in + `ssh -o User=`. A bare operand naming a resource for a tool *not* on the list survives, and a branch or tag given as a plain `git` operand (`git checkout my-branch`, - `git tag -a v1`) is kept readable on purpose. A **glued** short-flag password + `git tag -a v1`) is kept readable on purpose, as is a bare folder name (`cd my-app`, + `git -C my-app`). A **glued** short-flag password (`mysql -pSECRET`) is not caught: only long secret flags and `.env`-style assignments are. An `mcp__…` payload is handled the opposite way: every value is redacted to `` by default and only the field names are kept, so no raw payload value reaches the file. +- **A secret in the `scan` report is caught by format, by name and by shape, and the last two + are guesses.** After the known formats, a value that follows a name ending in `_KEY` or + `-key`, a raw `Authorization` header, and any run of 32 or more letters, digits, `-` and + `_` that looks random is masked, the last as `[REDACTED:secret:generic]`. A secret + shorter than 32 characters with no revealing name, a secret that is only hexadecimal + (a hex key looks like a commit hash or a digest, so hex is masked only under a name or + flag), a secret split by `+ / .`, an unnamed secret followed by a `.`, an unnamed secret + that ends in `=`, and an unnamed lower-case key written as short dashed groups (four or + five characters each) can survive; a run that spells out a pod or release name (three or + more deployment words such as `prod`, `canary` or `west`, every segment short) is left + readable. Redaction is best effort, not a guarantee: read the report, or run + `scan --review`, before you share it. - **A guardrail's id and title are printed as their author wrote them.** For the shipped library that is our words. For a guardrail *you* added to `guardrails.json` it is yours, and nothing redacts it: a title reading "AcmeCorp internal audit" appears in the report diff --git a/cspell.json b/cspell.json index 3b24cff..6a05ef9 100644 --- a/cspell.json +++ b/cspell.json @@ -24,6 +24,13 @@ "toolu", "waitlist", "wordmark", + "gsutil", + "Luhn", + "northwind", + "slashless", + "svcdeploy", + "vendorcli", + "wordlike", "ABIA", "ACCA", "AGENTTRAIL", diff --git a/plugin/scripts/guard-scan.mjs b/plugin/scripts/guard-scan.mjs index c1d2353..89e294e 100644 --- a/plugin/scripts/guard-scan.mjs +++ b/plugin/scripts/guard-scan.mjs @@ -7191,6 +7191,8 @@ function corpusSection(result) { ${coverage}`}

Working directories and file paths appear nowhere in this file, and the projects these sessions came from are carried as a count and never as names.

Identifying names are a weaker claim than paths, and the difference is worth knowing. Shell commands go through an identifier redactor keyed to a fixed list of tools — container runtimes, kubectl (including resource names), the database clients (host, database, and the SQL handed to -c / -e), ssh, git commit messages and identity settings, 1Password's op (item and vault names), and gh titles and bodies — plus, in any command, a secret passed as a flag value (--token, --password, --key), a UUID, the body of a heredoc, and the text of a # comment. That list is a deny-list and will miss the tool nobody thought of, so a bare operand naming a resource for a tool not on it can survive.

+

Cloud account identifiers are redacted where a name or flag gives them away: CLOUDFLARE_ACCOUNT_ID and --account-id, wrangler commands, AWS account numbers after --account or inside an ARN, and --project for gcloud, gsutil, bq and firebase.

+

Secrets without a known format are caught by name and by shape, and both are guesses. A value after a name ending in _KEY or -key, an Authorization header, and any long random-looking string of 32 or more characters is masked. A secret that is shorter, that is only hexadecimal and has no revealing name, that is split by punctuation, or that has no name and is followed by a ., ends in = or is written as short dashed groups can survive. Branch names, folder names and git branch operands are kept readable and are not redacted.

MCP tool calls are handled the other way round: a call to an MCP server arrives as a structured payload, and rather than deny-listing known-sensitive fields, every value in it is redacted to <value> by default and only the field names and the shape are kept.

Guardrail ids and titles are shown as their author wrote them — which for any guardrail you added yourself means your own words, unredacted. Redaction is thorough but not a guarantee. Read the commands and guardrail names above before posting this anywhere public.

`; @@ -7238,12 +7240,12 @@ function shareSection(result, variant) { const check = variant === "artifact" ? `Redaction is thorough but not a guarantee. Read the findings before passing this page on; the person who published it can check every line with ${review} before the file is written.` : `Redaction is thorough but not a guarantee. Read the findings before posting this anywhere public, and run ${review} to see every line before the file is written.`; return `

Before you share this

-

Every command below has been through a secret scrubber, a path redactor and an identifier redactor, and every MCP tool call has had its payload values structurally redacted. Redaction runs before anything is written, so this file has never held the original text.

+

Every command below has been through a secret scrubber, a path redactor, a pass for secret-named values and long random-looking strings, and an identifier redactor, and every MCP tool call has had its payload values structurally redacted. Redaction runs before anything is written, so this file has never held the original text.

<path>
stands for a redacted filesystem path or URL
<name> <host> <user> <message> <comment>
stand for an identifying operand of a command — a container, a namespace, a machine, a login, a commit message or heredoc body, a shell comment
<value>
stands for a redacted MCP payload value
-
[REDACTED:…]
stands for a redacted secret
+
[REDACTED:…]
stands for a redacted secret; [REDACTED:secret:generic] marks a long random-looking string masked on a best guess

${check}

`; @@ -7754,7 +7756,7 @@ var USER_PLACEHOLDER = ""; var MESSAGE_PLACEHOLDER = ""; var COMMENT_PLACEHOLDER = ""; var SECRET_ARG_PLACEHOLDER = `${REDACTION_PREFIX}secret:arg]`; -var SECRET_FLAG = /^--(?:[a-z0-9-]*(?:password|passwd|secret|token|api-?key|apikey|access-?key|access-?token|auth-?token|credentials?)[a-z0-9-]*|key|pass|pwd)$/i; +var SECRET_FLAG = /^--(?:[a-z0-9-]*(?:password|passwd|secret|token|api-?key|apikey|access-?key|access-?token|auth-?token|credentials?)[a-z0-9-]*|[a-z0-9-]+-key|key|pass|pwd)$/i; var UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; var HEREDOC_OPERATOR = /(? `${name}${sep}${quote}${NAME_PLACEHOLDER}${quote}` + ).replace( + CLOUD_ID_FLAG, + (_m, flag, join6, quote) => `${flag}${join6}${quote}${NAME_PLACEHOLDER}${quote}` + ).replace(AWS_ARN_ACCOUNT, NAME_PLACEHOLDER); +} +function sshUserOption(word) { + const option = /^(["']?)(User)=(.+?)\1$/i.exec(word); + if (option === null || isRedactedWord(word)) return void 0; + return `${option[1]}${option[2]}=${USER_PLACEHOLDER}${option[1]}`; +} var ASSIGNMENT = /^([^=]+)=(.+)$/; var USER_AT_HOST = /^([^@\s]+)@([^@\s]+)$/; var BREAK_CHARS = /* @__PURE__ */ new Set(["&", "|", ";", "(", ")"]); @@ -8202,7 +8228,11 @@ function classify(word, state, options) { } case "ssh": { if (isFlag) { - if (SSH_VALUE_FLAGS.has(word)) { + const glued = /^-o(User=.+)$/i.exec(word); + if (glued !== null) + return isRedactedWord(word) ? void 0 : `-o${sshUserOption(glued[1])}`; + if (word === "-o") state.pending = { placeholder: void 0, rewrite: sshUserOption }; + else if (SSH_VALUE_FLAGS.has(word)) { state.pending = { placeholder: SSH_VALUE_KIND.get(word) }; } return void 0; @@ -8217,6 +8247,19 @@ function classify(word, state, options) { state.sshDestinationSeen = true; return replaceValue(word, HOST_PLACEHOLDER); } + case "cloudflare": { + const bare = word.replace(/^["']|["']$/g, ""); + return !isFlag && HEX_ACCOUNT.test(bare) ? replaceValue(word, NAME_PLACEHOLDER) : void 0; + } + case "gcp": { + const assignment = ASSIGNMENT.exec(word); + if (assignment !== null) { + const flag = assignment[1]; + return CLOUD_PROJECT_FLAGS.has(flag) ? `${flag}=${NAME_PLACEHOLDER}` : void 0; + } + if (CLOUD_PROJECT_FLAGS.has(word)) state.pending = { placeholder: NAME_PLACEHOLDER }; + return void 0; + } case "git": { const assignment = ASSIGNMENT.exec(word); const messageSubcommand = state.gitSubcommand !== void 0 && GIT_MESSAGE_SUBCOMMANDS.has(state.gitSubcommand); @@ -8274,7 +8317,7 @@ function classify(word, state, options) { } function redactIdentifiers(input, options = {}) { if (input.length === 0) return input; - const text = redactHeredocBodies(input); + const text = redactCloudIds(redactHeredocBodies(input)); const pieces = tokenize(text); const edits = []; let state = freshState(void 0); @@ -8441,6 +8484,123 @@ function redactPaths(text) { return collapsed.replace(TOKEN_RUN, (run) => isPathShaped(run) ? PATH_PLACEHOLDER : run); } +// src/core/redact-secrets.ts +var NAME_RULE_PLACEHOLDER = "[REDACTED:secret:env]"; +var GENERIC_PLACEHOLDER = "[REDACTED:secret:generic]"; +var FALLBACK_MIN_LENGTH = 32; +var NAME_SEGMENT_MAX = 12; +var WORDLIKE_SHARE = 0.7; +var FALLBACK_MIN_ENTROPY = 4; +var KEY_NAME = "[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*[_-]key"; +var UNQUOTED_VALUE = `[^\\s"',;\\[<]`; +var KEY_NAME_ASSIGNMENT = new RegExp( + `(?= WORDLIKE_SHARE; +} +var NAME_WORDS = /* @__PURE__ */ new Set([ + "api", + "app", + "auth", + "backend", + "billing", + "blue", + "build", + "canary", + "checkout", + "cluster", + "controller", + "default", + "deploy", + "deployment", + "dev", + "east", + "frontend", + "gateway", + "green", + "ingest", + "ingress", + "internal", + "job", + "main", + "migration", + "north", + "prod", + "production", + "release", + "replica", + "rollout", + "service", + "south", + "staging", + "stage", + "test", + "web", + "west", + "worker" +]); +function looksLikeSegmentedName(candidate) { + const segments = candidate.split(/[-_]/); + if (segments.length < 5 || segments.some((segment) => segment.length > NAME_SEGMENT_MAX)) { + return false; + } + return segments.filter((segment) => NAME_WORDS.has(segment.toLowerCase())).length >= 3; +} +function looksRandom(candidate) { + if (UUID_SHAPE.test(candidate)) return false; + if (/^sha(?:1|224|256|384|512)-/i.test(candidate)) return false; + if (looksLikeWords(candidate) || looksLikeSegmentedName(candidate)) return false; + if (/\d{8,}/.test(candidate)) return false; + if (candidate.split(/[-_]/).some((segment) => segment.length >= 32 && /^[0-9a-fA-F]+$/.test(segment))) + return false; + if (/^[0-9a-fA-F]+$/.test(candidate)) return false; + if (!/[A-Za-z]/.test(candidate) || !/[0-9]/.test(candidate)) return false; + return entropyOf(candidate) >= FALLBACK_MIN_ENTROPY; +} +function redactSecrets(input) { + if (input.length === 0) return input; + let out = input.replace( + KEY_NAME_ASSIGNMENT, + (_m, name, sep, dq, sq) => { + const quote = dq ?? sq ?? ""; + return `${name}${sep}${quote}${NAME_RULE_PLACEHOLDER}${quote}`; + } + ); + out = out.replace( + AUTHORIZATION, + (_m, head, scheme) => `${head}${scheme ?? ""}${NAME_RULE_PLACEHOLDER}` + ); + out = out.replace( + CANDIDATE, + (candidate) => looksRandom(candidate) ? GENERIC_PLACEHOLDER : candidate + ); + return out; +} + // src/core/scrub.ts function redacted(kind, hint) { return hint ? `[REDACTED:${kind}:${hint}]` : `[REDACTED:${kind}]`; @@ -8621,10 +8781,10 @@ function toolCallsOf(session) { // src/core/scan-report.ts function redactForReport(text) { - return redactIdentifiers(redactPaths(scrubText(text).text)); + return redactIdentifiers(redactSecrets(redactPaths(scrubText(text).text))); } function redactTitle(text) { - return redactIdentifiers(scrubText(text).text, { kubeResourceOperands: false }); + return redactIdentifiers(redactSecrets(scrubText(text).text), { kubeResourceOperands: false }); } var MAX_DISPLAY_LEN = 160; function flatten(s) { diff --git a/src/__tests__/quiet-corpus.ts b/src/__tests__/quiet-corpus.ts new file mode 100644 index 0000000..47afc28 --- /dev/null +++ b/src/__tests__/quiet-corpus.ts @@ -0,0 +1,109 @@ +// cspell:disable -- hashes, digests and identifiers that only look like words. +/** + * Ordinary developer text the long-random-string fallback must leave byte-identical. + * + * Everything here is a string a shell command or a tool call plausibly carries and that is + * NOT a secret: commit hashes, content digests, lockfile integrity values, identifiers and + * branch names, file names, URLs, encoded blobs. `redact-secrets.test.ts` asserts the whole + * list survives unchanged and prints how many strings it measured, so the false-positive + * rate is a number and not a guess. + * + * It is not a `*.test.ts`, so vitest does not collect it as a suite. + */ + +/** Hex digests and ids, built by repetition so the file holds no real hash. */ +const HEX_40 = "3b8390f6a1c2d4e5f60718293a4b5c6d7e8f9012"; +const HEX_64 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; +const HEX_128 = HEX_64 + HEX_64.split("").reverse().join(""); +const HEX_32 = "d41d8cd98f00b204e9800998ecf8427e"; +const B64_INTEGRITY = + "sha512-9yJZlO1sZ0q8YtHcA6xN3vZcPjR2eLmK5wQfT7uB1dGhI4oSnXyVbCkE0aWzrMtU+qpRgDf/H8j2L3w=="; + +export const QUIET_CORPUS: readonly string[] = [ + // Commit ids. + `git show ${HEX_40}`, + `git log --oneline ${HEX_40.slice(0, 7)}..${HEX_40.slice(7, 14)}`, + `git cherry-pick ${HEX_40} ${HEX_40.split("").reverse().join("")}`, + `git checkout -b backup-${HEX_40.slice(0, 12)}`, + // Content digests. + `sha256sum build.tar.gz # ${HEX_64}`, + `echo "${HEX_64} build.tar.gz" | sha256sum -c -`, + `md5sum release.zip ${HEX_32}`, + `sha512sum dist.tgz ${HEX_128}`, + // Image digests and integrity. + `docker pull alpine@sha256:${HEX_64}`, + `docker inspect --format '{{.Id}}' sha256:${HEX_64}`, + `"integrity": "${B64_INTEGRITY}"`, + `resolution: {integrity: ${B64_INTEGRITY}}`, + `integrity sha256-${"AbCdEf0123456789".repeat(3)}`, + // UUIDs. + "curl https://api.example.com/v1/items/123e4567-e89b-42d3-a456-426614174000", + "kubectl get pod 7d9f8b6c-5d4e-4a1b-9c3d-2e1f0a9b8c7d", + // Long identifiers and names. + "git checkout feature/add-billing-export-for-enterprise-customers-with-seats", + "git branch -m very-long-descriptive-branch-name-with-many-words-in-it-2026", + "git push origin release-candidate-2026-10-07-hotfix-for-ingest-rate-limit", + "pnpm --filter @acme/ledger-sync-with-a-very-long-package-name test", + "export const useAuthenticatedOrganizationSubscriptionStatusQuery = () => null;", + "class AbstractSingletonProxyFactoryBeanConfigurationManagerImplementation {}", + "SELECT customer_subscription_billing_period_start_timestamp FROM billing_periods", + "def test_that_the_organization_member_can_resolve_a_pending_approval_hold(): pass", + "docker run --name integration-test-runner-for-the-scan-report-regression-suite alpine", + "kubectl rollout status deployment/web-frontend-production-blue-green-v2-canary", + "node_modules/.pnpm/@scope+very-long-package-name-with-hyphens@1.2.3_react@18.2.0/node_modules/x", + "terraform apply -target=module.networking_primary_region_private_subnets_nat_gateway", + "WHEREISTHELONGESTWORDINTHEENGLISHLANGUAGEWITHOUTANYDIGITSATALL", + "the_quick_brown_fox_jumps_over_the_lazy_dog_and_keeps_running_far_away", + "123456789012345678901234567890123456789012345678", + // File names and URLs with hashes in them. + "ls dist/assets/index-DfG3h9Kq2LmN8pQr5sTuVwXyZ01aBcD4eFg.js", + "cat build/static/js/main.a1b2c3d4e5f60718293a4b5c6d7e8f90.chunk.js", + "open https://example.com/blog/how-we-reduced-our-ingest-latency-by-forty-percent-in-q3", + "curl -L https://github.com/acme/tool/releases/download/v1.2.3/tool_1.2.3_linux_amd64.tar.gz", + "wget https://registry.npmjs.org/some-package/-/some-package-1.2.3.tgz", + // Encoded blobs. + "echo aGVsbG8gd29ybGQgdGhpcyBpcyBhIGxvbmcgYmFzZTY0IHN0cmluZyB3aXRoIHBhZGRpbmc= | base64 -d", + ``, + "curl 'https://example.com/search?q=hello%20world%20this%20is%20a%20long%20url%20encoded%20query'", + // Timestamps and counters. + "touch backup-20261007T093000Z-12345678901234567890.sql", + "echo 1791290585678329 1791290585678330 1791290585678331 1791290585678332", + // Ordinary prose and code. + "echo 'Supercalifragilisticexpialidocious is a very long word with no digits at all'", + "python -c \"print('the quick brown fox jumps over the lazy dog 1234567890 times today')\"", + "grep -rn 'getOrganizationSubscriptionBillingPeriodStartTimestampFromLedger' src/", + // Identifiers with digits in them: camelCase and PascalCase, versions, years, ordinals. + "grep -rn getOrganizationSubscriptionBillingPeriodV2StartTimestamp src/", + "export const useAuthenticatedOrganizationSubscriptionStatusQueryV3 = () => null;", + "class AbstractSingletonProxyFactoryBeanConfigurationManagerImplementationV2 {}", + "import { computeQuarterlyLedgerReconciliationSummary2026Q3 } from './ledger'", + "const migrationRunnerForTenantSchemaVersion20261007Applied = true;", + "def test_that_the_organization_member_can_resolve_hold_number_42(): pass", + "docker run --name integration-test-runner-for-the-scan-report-suite-v2 alpine", + // Bare folder and workspace names: not path-shaped, kept readable on purpose. + "cd Q3Ledger2026ReconciliationWorkspaceTemplateFolder9", + "cd quarterly-ledger-reconciliation-workspace-template-folder-2026-q3 && ls", + "git -C OrganizationSubscriptionBillingWorkspace2026Archive status", + "pnpm --filter customer-subscription-billing-reconciliation-workspace-v2 build", + "mkdir -p NorthwindQuarterlyLedgerReconciliationWorkspaceTemplateFolder7", + // Long values after an assignment: hashes, ids, digests, identifiers. + `docker build --build-arg COMMIT=${HEX_40} .`, + `BUILD_DIGEST=sha256:${HEX_64} ./publish.sh`, + `export PKG_INTEGRITY=${B64_INTEGRITY}`, + "export RELEASE_TAG=release-candidate-2026-10-07-hotfix-for-ingest-rate-limit", + "kubectl label pod web version=release-candidate-2026-10-07-hotfix-for-ingest-rate-limit", + "curl 'https://example.com/a?id=123e4567-e89b-42d3-a456-426614174000&v=2'", + "TARGET=getOrganizationSubscriptionBillingPeriodV2StartTimestamp ./build.sh", + `curl "https://example.com/blob?sha=${HEX_64}"`, + `echo 'img=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='`, + // Long hyphenated names with digit segments: pods, releases, deployments. + "kubectl logs web-frontend-7d9f8b6c5d-x2k9q-canary-blue-green-2026-10-07", + "git checkout web-frontend-7d9f8b6c5d-x2k9q-canary-blue-green-checkout-2026-10-07", + "helm upgrade api-gateway-production-us-east-1-blue-6f7c9d8b5-abcde-2026-10-07 ./chart", + // A label joined to a hash. + `aws ecr describe-images --image-ids imageTag=main-${HEX_40}`, + `docker tag app:latest registry.example.com/app:release_${HEX_64}`, + // Names that merely end in key. + "export MONKEY=banana KEYBOARD=qwerty TURKEY=dinner", + "psql -c 'ALTER TABLE t ADD PRIMARY KEY (id)'", +]; diff --git a/src/__tests__/redact-identifiers.test.ts b/src/__tests__/redact-identifiers.test.ts index 80e242f..01d34a0 100644 --- a/src/__tests__/redact-identifiers.test.ts +++ b/src/__tests__/redact-identifiers.test.ts @@ -551,3 +551,153 @@ describe("a 1Password setting passed through the environment", () => { ); }); }); + +describe("cloud account identifiers", () => { + const CF = "0123456789abcdef0123456789abcdef"; + const AWS = "123456789012"; + const CASES: readonly (readonly [string, string, string])[] = [ + [ + "a Cloudflare account id in the environment", + `CLOUDFLARE_ACCOUNT_ID=${CF} npx wrangler deploy`, + "CLOUDFLARE_ACCOUNT_ID= npx wrangler deploy", + ], + [ + "a Cloudflare account id as --account-id", + `wrangler deploy --account-id ${CF}`, + "wrangler deploy --account-id ", + ], + [ + "a Cloudflare account id as --account-id=value", + `wrangler deploy --account-id=${CF} --env prod`, + "wrangler deploy --account-id= --env prod", + ], + [ + "an account_id config line", + `echo 'account_id = "${CF}"' >> wrangler.toml`, + `echo 'account_id = ""' >> wrangler.toml`, + ], + [ + "a prefixed Terraform variable for the account id", + `TF_VAR_cloudflare_account_id=${CF} terraform plan`, + "TF_VAR_cloudflare_account_id= terraform plan", + ], + ["an SSO account id", `SSO_ACCOUNT_ID=${AWS} ./login.sh`, "SSO_ACCOUNT_ID= "], + [ + "a CF_ACCOUNT variable", + `CF_ACCOUNT=${CF} wrangler whoami`, + "CF_ACCOUNT= wrangler whoami", + ], + [ + "a bare 32-hex wrangler operand", + `npx wrangler kv namespace list ${CF}`, + "npx wrangler kv namespace list ", + ], + [ + "an AWS account number as --account", + `aws s3 ls --account ${AWS} --region us-east-1`, + "aws s3 ls --account --region us-east-1", + ], + [ + "an AWS account number as --account-id", + `aws sts get-caller-identity --account-id=${AWS}`, + "aws sts get-caller-identity --account-id=", + ], + [ + "an AWS account number in a slashless ARN", + `aws sns publish --topic-arn arn:aws:sns:us-east-1:${AWS}:alerts`, + "aws sns publish --topic-arn arn:aws:sns:us-east-1::alerts", + ], + [ + "an AWS account environment variable", + `AWS_ACCOUNT_ID=${AWS} ./deploy.sh`, + "AWS_ACCOUNT_ID= ", + ], + [ + "a GCP project as --project", + "gcloud run deploy api --project northwind-prod-481516", + "gcloud run deploy api --project ", + ], + [ + "a GCP project as --project=value", + "bq ls --project=northwind-prod-481516", + "bq ls --project=", + ], + [ + "a GCP project for gsutil", + "gsutil ls --project northwind-prod-481516 gs://", + "gsutil ls --project ", + ], + [ + "a GCP project for firebase", + "firebase deploy --project northwind-prod-481516", + "firebase deploy --project ", + ], + [ + "a GCP project environment variable", + "GOOGLE_CLOUD_PROJECT=northwind-prod-481516 node deploy.js", + "GOOGLE_CLOUD_PROJECT= node deploy.js", + ], + [ + "the login of ssh -o User=", + "ssh -o User=svcdeploy7 build-01 uptime", + "ssh -o User= uptime", + ], + [ + "the login of a quoted ssh -o option", + 'ssh -o "User=svcdeploy7" build-01 uptime', + 'ssh -o "User=" uptime', + ], + [ + "the login of a glued ssh -oUser=", + "ssh -oUser=svcdeploy7 build-01 uptime", + "ssh -oUser= uptime", + ], + ]; + + it.each(CASES)("redacts %s", (_name, input, expected) => { + expect(redactForReport(input)).toBe(expected); + }); + + it.each(CASES)("is idempotent for %s", (_name, input) => { + const once = redactForReport(input); + expect(redactForReport(once)).toBe(once); + }); + + it.each([ + ["a bare 12-digit number", "echo 123456789012 > counter.txt"], + ["a bare 32-hex string", "echo 0123456789abcdef0123456789abcdef > digest.txt"], + [ + "a --project on a tool outside the Google Cloud family", + "mytool build --project my-local-folder", + ], + ["an --account value that is not account-shaped", "mytool login --account acme-billing"], + ["a 12-digit number inside an unrelated word", "echo order-123456789012-confirmed"], + ["a git sha", "git show 3b8390f6a1c2d4e5f60718293a4b5c6d7e8f9012"], + ])("leaves %s alone", (_name, input) => { + expect(redactForReport(input)).toBe(input); + }); + + it("keeps an ssh option other than User", () => { + expect(redactForReport("ssh -o StrictHostKeyChecking=no build-01 uptime")).toBe( + "ssh -o StrictHostKeyChecking=no uptime", + ); + }); + + it("redacts a secret handed to a flag whose name ends in -key", () => { + expect(redactForReport("vendorcli sync --vendor-key abc123def456ghi")).toBe( + "vendorcli sync --vendor-key [REDACTED:secret:arg]", + ); + }); +}); + +describe("names kept readable on purpose", () => { + it.each([ + ["a branch given as a plain operand", "git push origin acme-billing-fix"], + ["a branch created with switch -c", "git switch -c acme-billing-fix"], + ["a branch deleted with -D", "git branch -D acme-billing-fix"], + ["a folder after cd", "cd clientco-app && ls"], + ["a folder given to git -C", "git -C clientco-app status"], + ])("%s", (_name, input) => { + expect(redactForReport(input)).toBe(input); + }); +}); diff --git a/src/__tests__/redact-secrets.test.ts b/src/__tests__/redact-secrets.test.ts new file mode 100644 index 0000000..502b40e --- /dev/null +++ b/src/__tests__/redact-secrets.test.ts @@ -0,0 +1,326 @@ +// cspell:disable -- credential-SHAPED synthetic values. +/** + * `core/redact-secrets.ts` — the wider name rule and the long-random-string fallback. + * + * Every value is synthetic. The fallback is measured two ways: a positive set of random + * base62 and base64url strings it must mask, and `quiet-corpus.ts` of ordinary developer + * text it must leave alone. The measured counts are printed so the false-positive rate is a + * recorded number. + */ + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SHIPPED_CATALOG } from "../core/catalog.js"; +import { + FALLBACK_MIN_ENTROPY, + GENERIC_PLACEHOLDER, + redactSecrets, +} from "../core/redact-secrets.js"; +import { redactForReport } from "../core/scan-report.js"; +import { scrubText } from "../core/scrub.js"; +import { QUIET_CORPUS } from "./quiet-corpus.js"; + +const NAME_PLACEHOLDER = "[REDACTED:secret:env]"; + +/** Small deterministic generator, so the positive set is the same on every run. */ +function mulberry32(seed: number): () => number { + let a = seed; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const BASE64URL = `${BASE62}-_`; + +function randomString(rand: () => number, alphabet: string, length: number): string { + let out = ""; + for (let i = 0; i < length; i++) out += alphabet[Math.floor(rand() * alphabet.length)]; + return out; +} + +describe("name rule", () => { + const KEY = "9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR"; + + it.each([ + [ + "an environment name ending in _KEY", + `ACMEVENDOR_KEY=${KEY} run`, + `ACMEVENDOR_KEY=${NAME_PLACEHOLDER} run`, + ], + ["an exported name", `export FOO_KEY=${KEY}`, `export FOO_KEY=${NAME_PLACEHOLDER}`], + ["a lower-case name with a colon", `vendor_key: ${KEY}`, `vendor_key: ${NAME_PLACEHOLDER}`], + [ + "a double-quoted JSON member", + `{"stripe_key":"${KEY}"}`, + `{"stripe_key":"${NAME_PLACEHOLDER}"}`, + ], + [ + "an X-...-Key header", + `curl -H "X-AcmeVendor-Key: ${KEY}" u`, + `curl -H "X-AcmeVendor-Key: ${NAME_PLACEHOLDER}" u`, + ], + [ + "a raw Authorization header", + `curl -H "Authorization: ${KEY}" u`, + `curl -H "Authorization: ${NAME_PLACEHOLDER}" u`, + ], + [ + "a Basic credential keeps its scheme", + `-H "Authorization: Basic dXNlcjpwYXNzd29yZA"`, + `-H "Authorization: Basic ${NAME_PLACEHOLDER}"`, + ], + [ + "a Token credential keeps its scheme", + `Authorization: Token ${KEY}`, + `Authorization: Token ${NAME_PLACEHOLDER}`, + ], + ])("masks %s", (_name, input, expected) => { + const out = redactSecrets(input); + expect(out).toBe(expected); + expect(out).not.toContain(KEY); + }); + + it("masks a quoted value that contains spaces", () => { + expect(redactSecrets(`ACMEVENDOR_KEY='two words here'`)).toBe( + `ACMEVENDOR_KEY='${NAME_PLACEHOLDER}'`, + ); + }); + + it.each([ + ["monkey", "export MONKEY=banana"], + ["a name that starts with key", "KEYBOARD=qwerty123"], + ["a short value", "SOME_KEY=abc"], + [ + "a Bearer header, which has its own pattern", + "Authorization: Bearer [REDACTED:secret:bearer]", + ], + ["a value that is already a placeholder", `SOME_KEY=${NAME_PLACEHOLDER}`], + ["a path placeholder", "SOME_KEY="], + ])("leaves %s alone", (_name, input) => { + expect(redactSecrets(input)).toBe(input); + }); +}); + +describe("long-random-string fallback", () => { + it("masks a bare 34-character key", () => { + const key = "rdc_9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR"; + expect(redactSecrets(`uploadtool push ${key} report.pdf`)).toBe( + `uploadtool push ${GENERIC_PLACEHOLDER} report.pdf`, + ); + }); + + it("masks a key inside a quoted string literal", () => { + const key = "9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR"; + expect(redactSecrets(`python -c "Client('${key}')"`)).toBe( + `python -c "Client('${GENERIC_PLACEHOLDER}')"`, + ); + }); + + it.each([ + [ + "an unnamed environment assignment", + "MYVAR=rdc_9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR node server.js", + ], + ["a flag with =", "deploytool --session=9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR --verbose"], + ["a query parameter", "curl 'https://example.com/a?sig=9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR'"], + ])("masks a random run after an assignment: %s", (_name, input) => { + const out = redactSecrets(input); + expect(out).toContain(GENERIC_PLACEHOLDER); + expect(out).not.toContain("9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR"); + }); + + it("leaves a run that ends in base64 padding alone", () => { + const blob = "9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gRaBcD=="; + expect(redactSecrets(`echo ${blob}`)).toBe(`echo ${blob}`); + }); + + it("masks a key that follows an escaped newline", () => { + const key = "9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR"; + expect(redactSecrets(`line one\\nuploadtool ${key}`)).toBe( + `line one\\nuploadtool ${GENERIC_PLACEHOLDER}`, + ); + }); + + it("masks random base62 and base64url strings of 32, 40 and 64 characters", () => { + const rand = mulberry32(815); + let masked = 0; + let total = 0; + const missed: string[] = []; + for (const alphabet of [BASE62, BASE64URL]) { + for (const length of [32, 40, 64]) { + for (let i = 0; i < 200; i++) { + const value = randomString(rand, alphabet, length); + total++; + if (redactSecrets(`tool ${value} arg`) === `tool ${GENERIC_PLACEHOLDER} arg`) masked++; + else missed.push(value); + } + } + } + console.log( + `[secret:generic] positive set: ${masked}/${total} masked (entropy >= ${FALLBACK_MIN_ENTROPY})`, + ); + // A random string with no digit at all, or that is all hex, is deliberately not masked. + expect(masked / total).toBeGreaterThanOrEqual(0.99); + for (const value of missed) { + expect(/[0-9]/.test(value) && /[A-Za-z]/.test(value) && !/^[0-9a-f]+$/i.test(value)).toBe( + false, + ); + } + }); + + const RANDOM_KEY = "9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR"; + + it.each([ + ["after labels in a curl credential", `curl -u ci:prod-api-token-v2-${RANDOM_KEY} u`], + ["after labels in a login", `tool login prod-deploy-token-eu-west-${RANDOM_KEY}`], + ["after labels in an assignment", `FOO=my_service_account_prod_${RANDOM_KEY} run`], + ["after region labels", `tool prod-eu-west-1-${RANDOM_KEY}`], + ["before region labels", `tool ${RANDOM_KEY}-prod-eu-west-1-blue`], + ])("masks a random key %s", (_name, input) => { + const out = redactSecrets(input); + expect(out).toContain(GENERIC_PLACEHOLDER); + expect(out).not.toContain(RANDOM_KEY); + }); + + it.each([ + ["lower-case base32 groups of four", "jhwm-4g2p-twyi-syof-au7r-jx2p-wixt-yexc"], + ["a second base32 key", "oldz-f6w6-zz2n-dlu7-4dzd-ryxb-heal-ogko"], + ["lower-case alphanumeric groups of five", "ryn1f-ezeim-qpwvs-p97qy-wagy0-lwqsm"], + ["a second alphanumeric key", "ioqp1-cebit-08905-n9387-el8vi-kovhm"], + ["lower-case alphanumeric groups of four", "794g-wz17-aklp-gi6o-77ff-nvcw-lhnk-eoxz"], + ])("masks a dashed lower-case key: %s", (_name, key) => { + const out = redactSecrets(`tool ${key} arg`); + expect(out).toBe(`tool ${GENERIC_PLACEHOLDER} arg`); + }); + + it("does not mask a 31-character run", () => { + const value = "9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3g"; + expect(value).toHaveLength(31); + expect(redactSecrets(`tool ${value}`)).toBe(`tool ${value}`); + }); + + it("does not mask a pure-hex string even at secret length", () => { + const hex = "0123456789abcdef0123456789abcdef"; + expect(redactSecrets(`tool ${hex}`)).toBe(`tool ${hex}`); + }); +}); + +describe("quiet corpus — ordinary developer text stays byte-identical", () => { + it("is non-trivial", () => { + expect(QUIET_CORPUS.length).toBeGreaterThan(40); + }); + + it.each(QUIET_CORPUS.map((s) => [s.slice(0, 70), s] as const))("%s", (_label, input) => { + expect(redactSecrets(input)).toBe(input); + // Through the whole report composition: other passes may turn a URL into ``, but + // none may mark it as a secret. + expect(redactForReport(input)).not.toContain("[REDACTED:"); + }); + + it("keeps bare folder names readable through the whole composition", () => { + for (const input of [ + "cd Q3Ledger2026ReconciliationWorkspaceTemplateFolder9", + "git -C OrganizationSubscriptionBillingWorkspace2026Archive status", + "mkdir -p NorthwindQuarterlyLedgerReconciliationWorkspaceTemplateFolder7", + ]) { + expect(redactForReport(input)).toBe(input); + } + }); + + it("holds over the shipped guardrail titles and descriptions", () => { + let measured = 0; + for (const rule of SHIPPED_CATALOG) { + for (const text of [rule.title, rule.description]) { + measured++; + expect(redactSecrets(text)).toBe(text); + } + } + console.log(`[secret:generic] shipped catalog: ${measured} strings, 0 changed`); + expect(measured).toBeGreaterThan(100); + }); + + it("holds over the committed transcript and hook fixtures", () => { + const root = join(import.meta.dirname, "fixtures"); + let files = 0; + let candidates = 0; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path); + else if (/\.(jsonl?|txt)$/.test(entry)) { + files++; + const text = scrubText(readFileSync(path, "utf8")).text; + candidates += (text.match(/[A-Za-z0-9_-]{32,}/g) ?? []).length; + expect(redactSecrets(text), path).not.toContain(GENERIC_PLACEHOLDER); + } + } + }; + walk(root); + console.log(`[secret:generic] fixtures: ${files} files, ${candidates} long runs, 0 masked`); + expect(files).toBeGreaterThan(0); + }); +}); + +describe("idempotence and cost", () => { + it.each([ + "ACMEVENDOR_KEY=9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR run", + 'curl -H "Authorization: Basic dXNlcjpwYXNzd29yZA" u', + "tool rdc_9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR arg", + ])("a second pass changes nothing: %s", (input) => { + const once = redactSecrets(input); + expect(redactSecrets(once)).toBe(once); + }); + + it("returns an empty string unchanged", () => { + expect(redactSecrets("")).toBe(""); + }); + + // Every pass of the report composition, not just this module: a pattern added to another + // pass can backtrack on the same shapes. 200,000 characters stays well under a second when + // matching is linear; a quadratic pattern takes tens of seconds on the same input. + const SIZE = 200_000; + const repeat = (unit: string): string => unit.repeat(Math.ceil(SIZE / unit.length)); + const ADVERSARIAL: readonly (readonly [string, string])[] = [ + ["underscores", repeat("_")], + ["hyphens", repeat("-")], + ["equals signs", repeat("=")], + ["KEY=", repeat("KEY=")], + ["_KEY=", repeat("_KEY=")], + ["ACCOUNT_ID", repeat("ACCOUNT_ID")], + ["account_id=", repeat("account_id=")], + ["x_ACCOUNT_ID=", repeat("x_ACCOUNT_ID=")], + ["single quotes", repeat("'")], + ["double quotes", repeat('"')], + ["digits", repeat("1234567890")], + ["hex", repeat("0123456789abcdef")], + ["base64-like", repeat("aGVsbG8gd29ybGQ")], + ["base64 with + / =", repeat("aGVs+bG8/gd=")], + ["mixed class run", repeat("A1b2-")], + ["deployment words", repeat("prod-web-")], + ["Authorization:", repeat("Authorization:")], + ["Authorization: x", repeat("Authorization: x ")], + ["--account-id", repeat("--account-id ")], + ["arn:aws:", repeat("arn:aws:x:y:")], + ["sha256-", repeat("sha256-")], + ["ssh -o User=", repeat("ssh -o User=")], + ["a name and a separator", repeat("AbC1_-=:\"' KEY=ACCOUNT_ID ")], + ]; + + it.each(ADVERSARIAL)("redactForReport stays linear on %s", (_name, input) => { + expect(input.length).toBeGreaterThanOrEqual(SIZE); + const start = performance.now(); + redactForReport(input); + expect(performance.now() - start).toBeLessThan(1000); + }); + + it.each(ADVERSARIAL)("redactSecrets stays linear on %s", (_name, input) => { + const start = performance.now(); + redactSecrets(input); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); diff --git a/src/__tests__/scan-leak-regression.test.ts b/src/__tests__/scan-leak-regression.test.ts new file mode 100644 index 0000000..322b3f7 --- /dev/null +++ b/src/__tests__/scan-leak-regression.test.ts @@ -0,0 +1,303 @@ +// cspell:disable -- credential-SHAPED synthetic values and invented client names. +/** + * Regression fixtures for secrets and account identifiers that reached a shared `scan` + * report. + * + * Each case is a real command shape with a fabricated value of a realistic length. The + * secret is assembled at runtime so no contiguous key-shaped literal sits in the source. + * A case is checked two ways: through `redactForReport` directly, and end to end through + * `aggregateScan` and both renderers, so a surface that skipped the composition fails too. + * + * `masked: false` rows are shapes that were already masked; they are kept so a later edit + * cannot reopen them. `kept` rows pin text that stays readable on purpose. + */ + +import { describe, expect, it } from "vitest"; +import { compileAllowlist } from "../core/evaluate.js"; +import { type ReportMeta, renderJson, renderReport } from "../core/report.js"; +import { compileCatalog } from "../core/rules.js"; +import { aggregateScan, redactForReport, type ScanCorpus } from "../core/scan-report.js"; +import type { GuardRule } from "../core/types.js"; +import { session, toolUse, turn } from "./scan-fixtures.js"; + +const META: ReportMeta = { version: "0.1.0", generatedAt: new Date("2026-10-01T09:00:00.000Z") }; + +/** + * Values are unique per case, so one case's leak can never be mistaken for another's. The + * digit suffix is the case index; lengths stay realistic (36-character key, 32-hex account, + * 12-digit account number). + */ +function valuesFor(i: number) { + const n = String(i).padStart(2, "0"); + return { + KEY: ["rdc", `9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3${n}`].join("_"), + CF: ["0123456789abcdef", `0123456789abcd${n}`].join(""), + AWS: ["1234", "5678", `90${n}`].join(""), + GCP: `northwind-prod-4815${n}`, + LOGIN: `svcdeploy${n}`, + }; +} + +interface LeakCase { + readonly name: string; + readonly command: string; + /** Substrings that must not survive. */ + readonly secrets: readonly string[]; + /** A harmless substring of the command, used to route it to its own finding. */ + readonly anchor: string; + /** False when the shape was already masked before the pass was added (a lock-in). */ + readonly leaksToday: boolean; +} + +interface Template { + readonly name: string; + /** `{KEY}`, `{CF}`, `{AWS}`, `{GCP}` and `{LOGIN}` are filled per case. */ + readonly command: string; + /** Which placeholders must not survive. */ + readonly secrets: readonly ("KEY" | "CF" | "AWS" | "GCP" | "LOGIN")[]; + readonly anchor: string; + readonly leaksToday: boolean; +} + +const TEMPLATES: readonly Template[] = [ + { + name: "name ending in _KEY", + command: "ACMEVENDOR_KEY={KEY} python ingest.py", + secrets: ["KEY"], + anchor: "ingest.py", + leaksToday: true, + }, + { + name: "exported name ending in _KEY", + command: "export FOO_KEY={KEY} && ./run.sh", + secrets: ["KEY"], + anchor: "run.sh", + leaksToday: true, + }, + { + name: "random value after an unnamed environment assignment", + command: "MYVAR={KEY} node server.js", + secrets: ["KEY"], + anchor: "node server.js", + leaksToday: true, + }, + { + name: "random value after --flag=", + command: "deploytool --session={KEY} --verbose", + secrets: ["KEY"], + anchor: "deploytool --session", + leaksToday: true, + }, + { + name: "raw Authorization header", + command: 'curl -H "Authorization: {KEY}" https://api.example.com/v1/parse', + secrets: ["KEY"], + anchor: "v1/parse", + leaksToday: true, + }, + { + name: "X-...-Key header", + command: 'curl -H "X-AcmeVendor-Key: {KEY}" https://api.example.com/v1/jobs', + secrets: ["KEY"], + anchor: "v1/jobs", + leaksToday: true, + }, + { + name: "X-...-Token header", + command: 'curl -H "X-Api-Token: {KEY}" https://api.example.com/v1/tokens', + secrets: ["KEY"], + anchor: "v1/tokens", + leaksToday: false, + }, + { + name: "key in a python -c string literal", + command: "python -c \"import acmevendor; acmevendor.Client('{KEY}').run()\"", + secrets: ["KEY"], + anchor: "acmevendor.Client", + leaksToday: true, + }, + { + name: "apiKey in node -e", + command: "node -e \"client({apiKey: '{KEY}'}).sync()\"", + secrets: ["KEY"], + anchor: "sync()", + leaksToday: false, + }, + { + name: "key as a plain positional argument", + command: "uploadtool push {KEY} report.pdf", + secrets: ["KEY"], + anchor: "uploadtool push", + leaksToday: true, + }, + { + name: "Cloudflare account id in an environment assignment", + command: "CLOUDFLARE_ACCOUNT_ID={CF} npx wrangler deploy", + secrets: ["CF"], + anchor: "wrangler deploy", + leaksToday: true, + }, + { + name: "Cloudflare account id as --account-id", + command: "wrangler deploy --account-id {CF} --env production", + secrets: ["CF"], + anchor: "--env production", + leaksToday: true, + }, + { + name: "Cloudflare account id as a bare wrangler operand", + command: "npx wrangler kv namespace list {CF}", + secrets: ["CF"], + anchor: "kv namespace list", + leaksToday: true, + }, + { + name: "AWS account number as --account", + command: "aws s3 ls --account {AWS} --region us-east-1", + secrets: ["AWS"], + anchor: "--region us-east-1", + leaksToday: true, + }, + { + name: "AWS account number in a slashless ARN", + command: "aws sns publish --topic-arn arn:aws:sns:us-east-1:{AWS}:alerts --message hi", + secrets: ["AWS"], + anchor: "sns publish", + leaksToday: true, + }, + { + name: "AWS account number in an ARN with a slash", + command: "aws iam get-role --role-arn arn:aws:iam::{AWS}:role/deployer", + secrets: ["AWS"], + anchor: "iam get-role", + leaksToday: false, + }, + { + name: "GCP project as --project", + command: "gcloud run deploy api --project {GCP} --region us-central1", + secrets: ["GCP"], + anchor: "run deploy", + leaksToday: true, + }, + { + name: "GCP project as --project=value", + command: "gsutil ls --project={GCP} gs://", + secrets: ["GCP"], + anchor: "gsutil ls", + leaksToday: true, + }, + { + name: "GCP project in an environment assignment", + command: "GOOGLE_CLOUD_PROJECT={GCP} node deploy.js", + secrets: ["GCP"], + anchor: "deploy.js", + leaksToday: true, + }, + { + name: "login in ssh -o User=", + command: "ssh -o User={LOGIN} build-01 uptime", + secrets: ["LOGIN"], + anchor: "uptime", + leaksToday: true, + }, + { + name: "login in user@host", + command: "ssh {LOGIN}@build-01 uptime -p", + secrets: ["LOGIN"], + anchor: "uptime -p", + leaksToday: false, + }, + { + name: "key past the display cap", + command: `deploycli --note ${"x".repeat(200)} --label ACMEVENDOR_KEY={KEY}`, + secrets: ["KEY"], + anchor: "deploycli --note", + leaksToday: true, + }, +]; + +const CASES: readonly LeakCase[] = TEMPLATES.map((t, i) => { + const v = valuesFor(i); + const fill = (text: string) => + text.replace(/\{(KEY|CF|AWS|GCP|LOGIN)\}/g, (_m, k: keyof typeof v) => v[k]); + return { + name: t.name, + command: fill(t.command), + secrets: t.secrets.map((k) => v[k]), + anchor: t.anchor, + leaksToday: t.leaksToday, + }; +}); + +/** A rule that matches one case through its anchor, so the command reaches the report. */ +function ruleFor(index: number, anchor: string): GuardRule { + return { + id: `r.leak-${index}`, + category: "prod-infra", + severity: "high", + defaultAction: "warn", + title: "Regression probe", + description: "Matches one planted command so its redacted shape reaches the report.", + match: { any_of: [{ kind: "execute_tool", label: "Bash", detail_contains: [anchor] }] }, + }; +} + +describe("redactForReport masks every planted secret and identifier", () => { + it.each(CASES)("$name", ({ command, secrets }) => { + for (const secret of secrets) { + // Negative control: the value really is in the input. + expect(command).toContain(secret); + } + const out = redactForReport(command); + for (const secret of secrets) { + expect(out).not.toContain(secret); + } + }); +}); + +describe("end to end through the HTML and JSON surfaces", () => { + const catalog = compileCatalog(CASES.map((c, i) => ruleFor(i, c.anchor))); + const corpus: ScanCorpus = { + sessions: [ + session([turn(CASES.map((c, i) => toolUse("Bash", { command: c.command }, `leak-${i}`)))]), + ], + quarantined: 0, + notRead: 0, + projects: 1, + }; + const result = aggregateScan(corpus, catalog, compileAllowlist([])); + const html = renderReport(result, META); + const json = renderJson(result, META); + + it("the report copy still says redaction is not a guarantee, and names the new classes", () => { + expect(html).toContain("Redaction is thorough but not a guarantee"); + expect(html).toContain("secret:generic"); + expect(html).toContain("CLOUDFLARE_ACCOUNT_ID"); + expect(html).toContain("a best guess"); + expect(html).toContain("both are guesses"); + }); + + it("every planted command reached a finding", () => { + expect(result.toolCalls).toBe(CASES.length); + expect(result.riskyActions).toBe(CASES.length); + }); + + it.each( + CASES.flatMap((c) => c.secrets.map((s) => [c.name, s] as const)), + )("%s: the value reaches neither surface", (_name, secret) => { + expect(html).not.toContain(secret); + expect(json).not.toContain(secret); + }); +}); + +describe("kept readable on purpose", () => { + it.each([ + ["a branch pushed as a plain operand", "git push origin acme-billing-fix"], + ["a branch created with checkout -b", "git checkout -b acme-billing-fix"], + ["a bare folder after cd", "cd clientco-app && ls"], + ["a bare folder as a git -C operand", "git -C clientco-app status"], + ["a 40-character git sha", "git show 3b8390f6a1c2d4e5f60718293a4b5c6d7e8f9012"], + ])("%s", (_name, command) => { + expect(redactForReport(command)).toBe(command); + }); +}); diff --git a/src/core/redact-identifiers.ts b/src/core/redact-identifiers.ts index c88dfc5..0121327 100644 --- a/src/core/redact-identifiers.ts +++ b/src/core/redact-identifiers.ts @@ -8,9 +8,9 @@ * `scan` displays or writes. * * ── The order, and why it is load-bearing ──────────────────────────────────── - * Three passes compose at one call site (`core/scan-report.ts`), in exactly this order: + * Four passes compose at one call site (`core/scan-report.ts`), in exactly this order: * - * redactIdentifiers(redactPaths(scrubText(text).text)) + * redactIdentifiers(redactSecrets(redactPaths(scrubText(text).text))) * * `scrubText` FIRST — it matches VALUE SHAPES (a key looks like a key), and a token * replaced before it runs would hide a secret embedded in that token and under-report @@ -20,7 +20,9 @@ * `docker run … ghcr.io/clientco/internal` offers it a path in an image-name position * and it would mistake one for the other. Running last also means every one of its * inputs is either the user's own text or a placeholder it can recognize and leave - * alone, which is what makes it idempotent. + * alone, which is what makes it idempotent. `redactSecrets` (`redact-secrets.ts`) runs between the + * path pass and this one: it masks values a secret name gives away and long random-looking + * strings. * * ── Why it is a THIRD module and not a fifteenth scrubbing pattern ─────────── * `core/scrub.ts` holds the standard secret patterns and nothing guard-specific, so @@ -55,7 +57,7 @@ * **It misses the command nobody thought of.** Everything below is per-command * knowledge: a word has to be in {@link COMMANDS} before any rule looks at its * operands, so a tool that is not on that list passes through completely untouched. The - * list does not cover `helm`, `oc`, `aws`, `gcloud`, `az`, `terraform`, `flyctl`, + * list does not cover `helm`, `oc`, `aws`, `az`, `terraform`, `flyctl`, * `heroku`, `systemctl`, `pm2`, or the next tool somebody installs. The report's own * copy says so, and it is why `scan --review` exists: a bounded human review is the only * thing that catches the shape a deny-list has never seen. @@ -148,7 +150,7 @@ const SECRET_ARG_PLACEHOLDER = `${REDACTION_PREFIX}secret:arg]`; * `--dbname`, `--message` or `--name`: none of those contain a secret word. */ const SECRET_FLAG = - /^--(?:[a-z0-9-]*(?:password|passwd|secret|token|api-?key|apikey|access-?key|access-?token|auth-?token|credentials?)[a-z0-9-]*|key|pass|pwd)$/i; + /^--(?:[a-z0-9-]*(?:password|passwd|secret|token|api-?key|apikey|access-?key|access-?token|auth-?token|credentials?)[a-z0-9-]*|[a-z0-9-]+-key|key|pass|pwd)$/i; /** * A UUID, anywhere in a word — an account, organization or record id. @@ -166,7 +168,7 @@ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; const HEREDOC_OPERATOR = /(? = new Map([ ["git", "git"], ["op", "op"], ["gh", "gh"], + ["wrangler", "cloudflare"], + ["gcloud", "gcp"], + ["gsutil", "gcp"], + ["bq", "gcp"], + ["firebase", "gcp"], ]); /** @@ -549,6 +556,55 @@ const OP_KEPT_VALUE_FLAGS = new Set(["--fields", "--field", "--format"]); */ const GH_MESSAGE_FLAGS = new Set(["--title", "-t", "--body", "-b", "--notes"]); +/** + * Cloud account identifiers, redacted as identifiers: they name an account without being a + * credential. + * + * Anchored to a NAME, a flag or a command, never to a shape alone, because a bare 12-digit + * number or 32-hex string is also a timestamp, a counter or a content hash. + * + * - an environment variable or config key that names one (`CLOUDFLARE_ACCOUNT_ID=…`, + * `account_id = "…"`, `GOOGLE_CLOUD_PROJECT=…`); + * - an account flag whose value has an account's shape (`--account-id`, `--account`, + * `--owner-id`: 12 digits or 32 hex); + * - the account field of an `arn:` (`arn:aws:sns:us-east-1:<12 digits>:topic`) — an ARN with + * a slash is already `` before this pass; + * - a bare 32-hex operand of a `wrangler` command, and the value of `--project` for the + * `gcloud` / `gsutil` / `bq` / `firebase` family ({@link CLOUD_PROJECT_FLAGS}). + */ +const CLOUD_ID_ENV = + /(? + `${name}${sep}${quote}${NAME_PLACEHOLDER}${quote}`, + ) + .replace( + CLOUD_ID_FLAG, + (_m, flag: string, join: string, quote: string) => + `${flag}${join}${quote}${NAME_PLACEHOLDER}${quote}`, + ) + .replace(AWS_ARN_ACCOUNT, NAME_PLACEHOLDER); +} + +/** `User=` handed to `ssh -o`, with the login redacted. */ +function sshUserOption(word: string): string | undefined { + const option = /^(["']?)(User)=(.+?)\1$/i.exec(word); + if (option === null || isRedactedWord(word)) return undefined; + return `${option[1]}${option[2]}=${USER_PLACEHOLDER}${option[1]}`; +} + /** A `--flag=value` or `KEY=value` word, split at the FIRST `=`. */ const ASSIGNMENT = /^([^=]+)=(.+)$/; @@ -898,7 +954,11 @@ function classify( case "ssh": { if (isFlag) { - if (SSH_VALUE_FLAGS.has(word)) { + const glued = /^-o(User=.+)$/i.exec(word); + if (glued !== null) + return isRedactedWord(word) ? undefined : `-o${sshUserOption(glued[1] as string)}`; + if (word === "-o") state.pending = { placeholder: undefined, rewrite: sshUserOption }; + else if (SSH_VALUE_FLAGS.has(word)) { state.pending = { placeholder: SSH_VALUE_KIND.get(word) }; } return undefined; @@ -917,6 +977,22 @@ function classify( return replaceValue(word, HOST_PLACEHOLDER); } + case "cloudflare": { + // An account id is the one bare operand of a `wrangler` command with a 32-hex shape. + const bare = word.replace(/^["']|["']$/g, ""); + return !isFlag && HEX_ACCOUNT.test(bare) ? replaceValue(word, NAME_PLACEHOLDER) : undefined; + } + + case "gcp": { + const assignment = ASSIGNMENT.exec(word); + if (assignment !== null) { + const flag = assignment[1] as string; + return CLOUD_PROJECT_FLAGS.has(flag) ? `${flag}=${NAME_PLACEHOLDER}` : undefined; + } + if (CLOUD_PROJECT_FLAGS.has(word)) state.pending = { placeholder: NAME_PLACEHOLDER }; + return undefined; + } + case "git": { const assignment = ASSIGNMENT.exec(word); const messageSubcommand = @@ -992,7 +1068,7 @@ export function redactIdentifiers(input: string, options: RedactIdentifiersOptio // Heredoc bodies first: they are free text, and once collapsed nothing inside them can // be mistaken for a command word or a comment by the word pass below. - const text = redactHeredocBodies(input); + const text = redactCloudIds(redactHeredocBodies(input)); const pieces = tokenize(text); const edits: { start: number; end: number; text: string }[] = []; let state = freshState(undefined); diff --git a/src/core/redact-secrets.ts b/src/core/redact-secrets.ts new file mode 100644 index 0000000..9c1ff57 --- /dev/null +++ b/src/core/redact-secrets.ts @@ -0,0 +1,250 @@ +/** + * `redactSecrets` — secrets the pattern-based scrubber has no shape for, out of anything + * `scan` displays or writes. + * + * `scrubText` recognises a secret by a known FORMAT (an AWS key, a JWT, a GitHub token) or + * by a name that contains a secret word (`TOKEN`, `PASSWORD`, `API_KEY`, …). A key from a + * provider it has never heard of, handed over under a name outside that list or with no + * name at all, survives it. This pass adds two rules for exactly that: + * + * 1. **A wider name rule.** A value that follows a name ending in `_KEY` / `-key` + * (`VENDOR_KEY=…`, `X-Vendor-Key: …`), or an `Authorization:` header carrying a raw + * credential, is replaced with `[REDACTED:secret:env]` — the same marker the narrower + * name rule uses, so the report legend is unchanged. + * 2. **A long-random-string fallback.** A run of 32 or more base62 / base64url characters + * that looks random is replaced with `[REDACTED:secret:generic]`, so a reader can tell + * a guess from a recognised format. + * + * ── Order ──────────────────────────────────────────────────────────────────── + * It runs after `scrubText` and `redactPaths`, and before `redactIdentifiers`: + * + * redactIdentifiers(redactSecrets(redactPaths(scrubText(text).text))) + * + * After `scrubText`, so a recognised format keeps its specific label. After `redactPaths`, + * so a file path is already `` and never reaches the fallback. Before + * `redactIdentifiers`, which leaves any word that carries a placeholder alone. + * + * ── What the fallback will NOT touch ───────────────────────────────────────── + * A candidate is left as it is when it is a UUID; purely hexadecimal (a git SHA, an MD5, + * SHA-256 or SHA-512 digest — indistinguishable from a hex-encoded key, so hex is masked + * only under a name or flag); a package integrity value or image digest (`sha512-…`, + * `sha256:…`); a run touching `+ / . %` or ending in `=` (a base64 blob, a data URI, a file + * name, a domain, a URL-encoded value); a label joined to a hash (`main-<40 hex>`); a run + * of eight or more digits (a timestamp, an account number); mostly ordinary words (a long + * kebab-case branch, a snake_case, camelCase or PascalCase identifier, a folder name with a + * year in it); or unremarkable by character mix — no digit, no letter, or low entropy. The + * thresholds are in {@link FALLBACK_MIN_LENGTH} and {@link FALLBACK_MIN_ENTROPY}. + * + * ── It is a heuristic and says so ──────────────────────────────────────────── + * A secret shorter than 32 characters with no recognised name or format, a secret that is + * only hex, a secret split across `+ / .`, an unnamed secret followed by `.` and an unnamed + * secret ending in `=` can survive. The report's own copy states + * that redaction is not a guarantee. + * + * Pure, total and idempotent: no imports, no I/O, every regex compiled once. The secret is + * never echoed into a placeholder. + */ + +/** Marker for a value taken out under the name rule — the existing `.env` marker. */ +const NAME_RULE_PLACEHOLDER = "[REDACTED:secret:env]"; +/** Marker for a value taken out by the long-random-string fallback. */ +export const GENERIC_PLACEHOLDER = "[REDACTED:secret:generic]"; + +/** The shortest run the fallback considers. */ +export const FALLBACK_MIN_LENGTH = 32; +/** The longest segment of a hyphenated name (a pod's hash segments are ten characters). */ +const NAME_SEGMENT_MAX = 12; +/** The share of a run that must be ordinary words to count as an identifier. */ +const WORDLIKE_SHARE = 0.7; +/** The lowest Shannon entropy (bits per character) the fallback masks. */ +export const FALLBACK_MIN_ENTROPY = 4.0; + +/** + * A name ending in `_key` or `-key`, case-insensitive, with at least one character before + * the suffix. `monkey` and `keyboard` do not match; `VENDOR_KEY`, `foo-key`, `x-vendor-key` + * do. + */ +const KEY_NAME = "[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*[_-]key"; + +/** Characters a value never contains: whitespace, quotes and list separators. */ +const UNQUOTED_VALUE = "[^\\s\"',;\\[<]"; + +/** + * `NAMEvalue` where NAME ends in `_KEY` / `-key`. Same three-branch value as the + * `.env` rule: a quoted value may contain spaces, an unquoted one stops at the first + * terminator. A value that is already a placeholder (`[REDACTED:…]`, ``) is skipped. + * Group 1 = name, 2 = separator, 3 / 4 = the opening double / single quote, when there is one. + */ +const KEY_NAME_ASSIGNMENT = new RegExp( + `(?`, `--session=`) and is allowed; a + * run after `==`, or one that ends in `=` (base64 padding), is part of an encoded blob. + */ +const CANDIDATE = new RegExp( + `(?(); + for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1); + let bits = 0; + for (const n of counts.values()) { + const p = n / value.length; + bits -= p * Math.log2(p); + } + return bits; +} + +/** An ordinary word inside an identifier: `Billing`, `period`, or an upper-case `HTTPS`. */ +const WORD = /[A-Z][a-z]{3,}|[a-z]{4,}|[A-Z]{4,}/g; + +/** + * A run made mostly of ordinary words — a long branch, a release tag, a snake_case, + * camelCase or PascalCase identifier, a folder name with a year or a version in it — + * rather than random characters. Digits and short fragments count against the run + * (separators do not), so `getBillingPeriodV2StartTimestamp` is words and + * `9fQ2xLmB7vTzK1aWc4Yp8NsJ0eHuD3gR` is not: a random string rarely holds a lower-case run + * of four letters. + */ +function looksLikeWords(candidate: string): boolean { + let wordChars = 0; + for (const word of candidate.match(WORD) ?? []) wordChars += word.length; + return wordChars / candidate.replace(/[-_]/g, "").length >= WORDLIKE_SHARE; +} + +/** Words that label an environment, a region, a service or a release in a deployment name. */ +const NAME_WORDS: ReadonlySet = new Set([ + "api", + "app", + "auth", + "backend", + "billing", + "blue", + "build", + "canary", + "checkout", + "cluster", + "controller", + "default", + "deploy", + "deployment", + "dev", + "east", + "frontend", + "gateway", + "green", + "ingest", + "ingress", + "internal", + "job", + "main", + "migration", + "north", + "prod", + "production", + "release", + "replica", + "rollout", + "service", + "south", + "staging", + "stage", + "test", + "web", + "west", + "worker", +]); + +/** + * A long hyphenated or underscored name — a pod, a release, a deployment — made of short + * segments of which at least three are ordinary deployment words + * (`web-frontend-7d9f8b6c5d-x2k9q-canary-blue-green-2026-10-07`). + * + * Judged only when EVERY segment is short: a random key joined to labels + * (`prod-api-token-v2-<32 random characters>`) has a long segment and is never a name. + */ +function looksLikeSegmentedName(candidate: string): boolean { + const segments = candidate.split(/[-_]/); + if (segments.length < 5 || segments.some((segment) => segment.length > NAME_SEGMENT_MAX)) { + return false; + } + return segments.filter((segment) => NAME_WORDS.has(segment.toLowerCase())).length >= 3; +} + +/** Does this candidate look like a random credential rather than a name or a hash? */ +function looksRandom(candidate: string): boolean { + if (UUID_SHAPE.test(candidate)) return false; + // A package integrity value or image digest the lookbehind cannot see, because the + // candidate run includes its own `sha256-` prefix. + if (/^sha(?:1|224|256|384|512)-/i.test(candidate)) return false; + if (looksLikeWords(candidate) || looksLikeSegmentedName(candidate)) return false; + // A long run of digits (a timestamp, an account number, zero padding) is a number, not a key. + if (/\d{8,}/.test(candidate)) return false; + // A label joined to a hash (`main-<40 hex>`, an image tag): the hash is not a credential. + if ( + candidate + .split(/[-_]/) + .some((segment) => segment.length >= 32 && /^[0-9a-fA-F]+$/.test(segment)) + ) + return false; + // Hex-only: a git SHA, an MD5/SHA digest or a content hash. Masked only under a name. + if (/^[0-9a-fA-F]+$/.test(candidate)) return false; + // A credential mixes letters and digits; a long word or a numeric id does not. + if (!/[A-Za-z]/.test(candidate) || !/[0-9]/.test(candidate)) return false; + return entropyOf(candidate) >= FALLBACK_MIN_ENTROPY; +} + +/** + * Replace secrets the pattern scrubber cannot recognise. Idempotent: a second run over its + * own output changes nothing. + */ +export function redactSecrets(input: string): string { + if (input.length === 0) return input; + + let out = input.replace( + KEY_NAME_ASSIGNMENT, + (_m, name: string, sep: string, dq: string | undefined, sq: string | undefined) => { + const quote = dq ?? sq ?? ""; + return `${name}${sep}${quote}${NAME_RULE_PLACEHOLDER}${quote}`; + }, + ); + + out = out.replace( + AUTHORIZATION, + (_m, head: string, scheme: string | undefined) => + `${head}${scheme ?? ""}${NAME_RULE_PLACEHOLDER}`, + ); + + out = out.replace(CANDIDATE, (candidate) => + looksRandom(candidate) ? GENERIC_PLACEHOLDER : candidate, + ); + + return out; +} diff --git a/src/core/report.ts b/src/core/report.ts index cafb369..fe870f0 100644 --- a/src/core/report.ts +++ b/src/core/report.ts @@ -562,6 +562,8 @@ function corpusSection(result: ScanResult): string {

${lines.join(" ")}

${coverage === "" ? "" : `\n${coverage}`}

Working directories and file paths appear nowhere in this file, and the projects these sessions came from are carried as a count and never as names.

Identifying names are a weaker claim than paths, and the difference is worth knowing. Shell commands go through an identifier redactor keyed to a fixed list of tools — container runtimes, kubectl (including resource names), the database clients (host, database, and the SQL handed to -c / -e), ssh, git commit messages and identity settings, 1Password's op (item and vault names), and gh titles and bodies — plus, in any command, a secret passed as a flag value (--token, --password, --key), a UUID, the body of a heredoc, and the text of a # comment. That list is a deny-list and will miss the tool nobody thought of, so a bare operand naming a resource for a tool not on it can survive.

+

Cloud account identifiers are redacted where a name or flag gives them away: CLOUDFLARE_ACCOUNT_ID and --account-id, wrangler commands, AWS account numbers after --account or inside an ARN, and --project for gcloud, gsutil, bq and firebase.

+

Secrets without a known format are caught by name and by shape, and both are guesses. A value after a name ending in _KEY or -key, an Authorization header, and any long random-looking string of 32 or more characters is masked. A secret that is shorter, that is only hexadecimal and has no revealing name, that is split by punctuation, or that has no name and is followed by a ., ends in = or is written as short dashed groups can survive. Branch names, folder names and git branch operands are kept readable and are not redacted.

MCP tool calls are handled the other way round: a call to an MCP server arrives as a structured payload, and rather than deny-listing known-sensitive fields, every value in it is redacted to <value> by default and only the field names and the shape are kept.

Guardrail ids and titles are shown as their author wrote them — which for any guardrail you added yourself means your own words, unredacted. Redaction is thorough but not a guarantee. Read the commands and guardrail names above before posting this anywhere public.

`; @@ -637,12 +639,12 @@ function shareSection(result: ScanResult, variant: ReportVariant): string { : `Redaction is thorough but not a guarantee. Read the findings before posting this anywhere public, and run ${review} to see every line before the file is written.`; return `

Before you share this

-

Every command below has been through a secret scrubber, a path redactor and an identifier redactor, and every MCP tool call has had its payload values structurally redacted. Redaction runs before anything is written, so this file has never held the original text.

+

Every command below has been through a secret scrubber, a path redactor, a pass for secret-named values and long random-looking strings, and an identifier redactor, and every MCP tool call has had its payload values structurally redacted. Redaction runs before anything is written, so this file has never held the original text.

<path>
stands for a redacted filesystem path or URL
<name> <host> <user> <message> <comment>
stand for an identifying operand of a command — a container, a namespace, a machine, a login, a commit message or heredoc body, a shell comment
<value>
stands for a redacted MCP payload value
-
[REDACTED:…]
stands for a redacted secret
+
[REDACTED:…]
stands for a redacted secret; [REDACTED:secret:generic] marks a long random-looking string masked on a best guess

${check}

`; diff --git a/src/core/scan-report.ts b/src/core/scan-report.ts index 26c8610..a059953 100644 --- a/src/core/scan-report.ts +++ b/src/core/scan-report.ts @@ -55,6 +55,7 @@ import { buildGuardSpanContext } from "./normalize.js"; import { redactIdentifiers } from "./redact-identifiers.js"; import { redactMcpPayload } from "./redact-mcp.js"; import { redactPaths } from "./redact-path.js"; +import { redactSecrets } from "./redact-secrets.js"; import type { CompiledRule } from "./rules.js"; import { scrubText } from "./scrub.js"; import { addUsage, EMPTY_TOKEN_TOTALS, type TokenTotals } from "./tokens.js"; @@ -63,14 +64,16 @@ import { toolCallsOf } from "./transcripts.js"; import type { AgentSource, GuardAction, MappedCall } from "./types.js"; /** - * The three redactors, composed in the one order that is correct. + * The four redactors, composed in the one order that is correct. * * `scrubText` first: it matches VALUE SHAPES, and a token replaced early would hide a * secret embedded in it and under-report the tally. `redactPaths` second: it matches * STRUCTURE, and is built so it cannot damage the placeholders the first pass just * inserted. `redactIdentifiers` last: it matches POSITION within a known command, so it * has to see a string whose paths are already `` or it would read an image path as - * an object name. Each one's header states its half of this; changing the order is a + * an object name. `redactSecrets` sits between the path pass and the identifier pass: it masks + * values a secret NAME gives away and long random-looking strings, and after the path pass a + * file path is already ``, so it never reaches that guess. Each one's header states its half of this; changing the order is a * correctness change, not a style one. * * Exported so tests can drive the composition directly rather than inferring it from @@ -78,7 +81,7 @@ import type { AgentSource, GuardAction, MappedCall } from "./types.js"; * convention to trust. */ export function redactForReport(text: string): string { - return redactIdentifiers(redactPaths(scrubText(text).text)); + return redactIdentifiers(redactSecrets(redactPaths(scrubText(text).text))); } /** @@ -116,7 +119,7 @@ export function redactTitle(text: string): string { // words are not resource names. The container-name redaction is KEPT — a title such as // "Audit docker exec acme-prod-db" carries a real name — and `scan-report.test.ts` // asserts both directions over the shipped catalog. - return redactIdentifiers(scrubText(text).text, { kubeResourceOperands: false }); + return redactIdentifiers(redactSecrets(scrubText(text).text), { kubeResourceOperands: false }); } /**