Skip to content
Download PDF

Secret & PII Scan ​

forge secscan is one of Forge's four enforced controls. It statically scans a change's files for leaked secrets, PII, and prompt-injection strings, and returns a non-zero exit when an unwaived finding remains. That non-zero exit is what makes it a gate rather than a report.

Commands ​

sh
forge secscan scan  --change <c>   # scan; blocks on unwaived findings
forge secscan waive --change <c> --fingerprint <fp>   # record a human decision
forge secscan reset --change <c>   # delete the change's waivers file

--change <name> is required by every subcommand — waivers are stored per change. With no subcommand, scan is assumed.

FlagApplies toDefaultEffect
--change <name>all(required)The change the waivers belong to
--path <p>scan.File or directory to scan; repeatable
--min-severity <high|medium>scanhighSeverity floor at which a finding blocks
--fingerprint <fp>waive(required)The finding being waived
--cwd <dir>allprocess working directoryTreat <dir> as the repo root

waive also accepts the fingerprint as a bare positional argument, so forge secscan waive --change c a50f652c9c15 is equivalent to passing --fingerprint.

A directory --path is walked recursively, skipping .git, .forge, and every other dot-directory. Files whose first 8 KiB contain a NUL byte are treated as binary and skipped silently.

What it detects ​

Nine detectors, in three categories. Secrets and prompt-injection are high; PII is medium. Those two are the only severities that exist.

RuleKindSeverityMatches
aws-access-keysecrethighAKIA + 16 uppercase alphanumerics
private-key-headersecrethighA -----BEGIN … PRIVATE KEY----- header line
github-patsecrethighghp_ + 36 alphanumerics
slack-tokensecrethighxox[baprs]-… tokens
generic-secretsecrethighAn assignment whose name contains secret, token, api_key, password, or passwd, with a quoted value of 16+ characters
emailpiimediumAn email address
credit-cardpiimediumA 13–16 digit run that passes the Luhn checksum
us-ssnpiimediumA NNN-NN-NNNN US social security number
prompt-injectioninjectionhighA curated, case-insensitive phrase set — ignore previous instructions, disregard the above, you are now, system prompt:, reveal your prompt, and similar

Detection is deterministic: no clock, no randomness. The same file content always yields the same findings, in the same order, with the same fingerprints.

Output never reprints the secret

Each finding prints a redacted snippet — the rule name plus the first four characters and an ellipsis (or asterisks when the match is very short). The raw matched value is never written to stdout, to the timeline, or to the waivers file, so scan output is safe to paste into a ticket.

Where the non-zero exit is consumed ​

scan prints every finding it saw, then compares the findings against the change's waivers and the --min-severity floor. If anything survives that filter, it prints SECSCAN BLOCKED and returns an error, which cmd/forge/main.go turns into Error: … on stderr and exit 1.

That exit code is the whole gate. It is read by:

  • Host hooks — a PostToolUse or Stop hook that runs forge secscan scan halts the agent when the command fails. See Connect a live agent.
  • CI — any pipeline step that runs the command fails the job on a non-zero exit.
  • forge run — the bounded loop polls secscan as a stop condition, before the first iteration and again after each one.

A scan also emits an event into the session timeline with the total and blocking finding counts. That emit is best-effort: a timeline failure is swallowed and never changes the enforcement outcome.

The waiver workflow ​

Every finding carries a fingerprint: the first 12 hex characters of a SHA-256 over the rule name, the file path, the line number, and the raw match. The file path is made relative to the repo root, so the fingerprint is stable no matter where the repository lives on disk.

A human reviews a finding, decides it is acceptable, and records that decision:

sh
forge secscan waive --change add-billing --fingerprint a50f652c9c15

which appends to the change's waivers file:

yaml
# .forge/changes/add-billing/secscan.yaml
change: add-billing
acknowledged:
    - a50f652c9c15

waive is idempotent — waiving the same fingerprint twice writes nothing new. reset deletes the file, restoring the change to a state where nothing is waived; it is also idempotent, and a missing file is not an error.

A waiver is a recorded decision, not a judgment

Forge does not evaluate whether a finding is a real leak. waive records that a human looked at one specific finding and accepted it. The tool's only opinion is that unreviewed findings block.

Waivers are per-fingerprint deliberately. Because the fingerprint covers rule, file, line, and the matched text, a waiver is scoped to exactly the hit that was reviewed. Edit the line, move it, or change the value, and the fingerprint changes — the waiver stops applying and the gate blocks again. There is no rule-wide or file-wide mute, so accepting one test fixture cannot silently accept a real credential added next to it later.

What it does not do ​

Read this before trusting it

This is a static pattern scanner over the working tree. It is not a secrets-management solution, and a clean scan is not proof that a change is safe.

  • It has false negatives. A secret that does not match one of the nine detectors is not found. A high-entropy key in an unrecognised format, a credential split across lines, or one stored in an unusual variable name will pass cleanly. Passing the gate means "no known pattern matched," nothing more.
  • It has false positives. AKIA… in a test fixture, an example address in documentation, or a Luhn-valid number that is not a card will all fire. That is what waivers are for.
  • Forge is not in the model's inference loop. It cannot stop a leak from being written. It scans the working tree after the fact and enforces the gate on what it finds.
  • It is not a substitute for rotating a leaked credential. If a real secret fired here, the credential must be considered compromised and rotated. Deleting the line and re-running until the scan is green changes the scan result, not the status of the key.
  • It does not read git history. Only the files you point --path at, as they exist on disk right now. If a secret was already committed, removing it from the working tree makes the scan pass while the secret remains in every clone of the repository's history. Purging history is a separate, manual operation.
  • It does not scan binaries or anything under .git, .forge, or another dot-directory.

Example ​

Two findings in one file — one high, one medium:

text
$ forge secscan scan --change add-billing --path config.py
secscan[add-billing] high secret config.py:1 fp=a50f652c9c15 aws-access-key AKIA…
secscan[add-billing] medium pii config.py:2 fp=926bc7f24ef9 email ana@…
SECSCAN BLOCKED — 1 unwaived finding(s). STOP and escalate to a human; a human waives with: forge secscan waive --change add-billing --fingerprint <fp>, or resets with: forge secscan reset --change add-billing
Error: secscan blocked for change "add-billing": 1 unwaived finding(s)
# exit 1

Both findings are printed, but only one blocks: the default floor is high, so the medium email finding is reported and skipped. The Error: line goes to stderr; everything above it is stdout.

A human reviews the AWS key, decides it is a documented example value, and waives that one fingerprint:

text
$ forge secscan waive --change add-billing --fingerprint a50f652c9c15
secscan waived a50f652c9c15 for change "add-billing"
# exit 0

$ forge secscan scan --change add-billing --path config.py
secscan[add-billing] high secret config.py:1 fp=a50f652c9c15 aws-access-key AKIA…
secscan[add-billing] medium pii config.py:2 fp=926bc7f24ef9 email ana@…
secscan: clean (2 finding(s), all waived or below threshold)
# exit 0

The findings are still reported — the scan does not hide them — but nothing blocks, so the gate passes.

Lowering the floor makes the PII finding blocking too:

text
$ forge secscan scan --change add-billing --path config.py --min-severity medium
secscan[add-billing] high secret config.py:1 fp=a50f652c9c15 aws-access-key AKIA…
secscan[add-billing] medium pii config.py:2 fp=926bc7f24ef9 email ana@…
SECSCAN BLOCKED — 1 unwaived finding(s). STOP and escalate to a human; a human waives with: forge secscan waive --change add-billing --fingerprint <fp>, or resets with: forge secscan reset --change add-billing
Error: secscan blocked for change "add-billing": 1 unwaived finding(s)
# exit 1

Same file, same waiver, different floor — now the unwaived email is what stops the gate.

Where it fits ​

The circuit-breaker bounds iterations, the spend budget bounds cost, the guardrails gate blocks dangerous actions, and secscan bounds what a change is allowed to contain. It is the only one of the four whose verdict depends on the content of the files rather than on a counter.

Cost note

Each scan walks every requested path. Inside forge run, an N-iteration loop pays N+1 scans, so on a large repository prefer a narrow --path or a low --max-iterations.

See also ​