# Secret & PII Scan

`forge secscan` is one of Forge's four [enforced controls](/harness/overview). 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.

| Flag | Applies to | Default | Effect |
|---|---|---|---|
| `--change <name>` | all | *(required)* | The change the waivers belong to |
| `--path <p>` | `scan` | `.` | File or directory to scan; **repeatable** |
| `--min-severity <high\|medium>` | `scan` | `high` | Severity floor at which a finding blocks |
| `--fingerprint <fp>` | `waive` | *(required)* | The finding being waived |
| `--cwd <dir>` | all | process working directory | Treat `<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.

| Rule | Kind | Severity | Matches |
|---|---|---|---|
| `aws-access-key` | secret | high | `AKIA` + 16 uppercase alphanumerics |
| `private-key-header` | secret | high | A `-----BEGIN … PRIVATE KEY-----` header line |
| `github-pat` | secret | high | `ghp_` + 36 alphanumerics |
| `slack-token` | secret | high | `xox[baprs]-…` tokens |
| `generic-secret` | secret | high | An assignment whose name contains `secret`, `token`, `api_key`, `password`, or `passwd`, with a quoted value of 16+ characters |
| `email` | pii | medium | An email address |
| `credit-card` | pii | medium | A 13–16 digit run that **passes the Luhn checksum** |
| `us-ssn` | pii | medium | A `NNN-NN-NNNN` US social security number |
| `prompt-injection` | injection | high | A 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.

::: info 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](/harness/live-agent-hooks).
- **CI** — any pipeline step that runs the command fails the job on a non-zero exit.
- **[`forge run`](/harness/autonomous-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](/harness/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.

::: warning 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

::: danger 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](/harness/circuit-breaker) bounds iterations, the
[spend budget](/harness/budget) bounds cost, the
[guardrails gate](/harness/guardrails) 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.

::: tip Cost note
Each `scan` walks every requested path. Inside [`forge run`](/harness/autonomous-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

- [Enforcement Doctrine](/harness/overview) — where secscan sits among the four controls.
- [Bounded autonomous run](/harness/autonomous-run) — the loop that treats secscan as a stop condition.
- [Verify circuit-breaker](/harness/circuit-breaker) — the sibling gate that bounds iterations.
- [Spend budget](/harness/budget) — the sibling gate that bounds cost.
- [The guardrails gate](/harness/guardrails) — the sibling gate that blocks dangerous actions.
- [Session timeline](/harness/timeline) — where every scan's finding counts land.
