# secretscan attestor

Source: https://www.testifysec.com/docs/cilock/attestors/secretscan

The cilock secretscan attestor runs a Gitleaks pattern scan over every product and prior attestor's JSON, with recursive decoding, recording redacted findings into signed in-toto evidence.

Runs a Gitleaks pattern scan over every product and every prior attestor's JSON, with recursive base64/hex/URL decoding (default 3 layers) so secrets hidden inside encoded blobs still surface.

 

| Name | `secretscan` |
| --- | --- |
| Predicate type | `https://aflock.ai/attestations/secretscan/v0.1` |
| Lifecycle | `postproduct` |
| Default binary? | No |
| Recommended trace | off — no syscall tracing needed |
| Auto-attaches when | *Not auto-detected — attach explicitly with `-a`.* |

The facts in this box are generated from the CI/lock binary's own catalog (`cilock tools list`). Do not hand-edit — run `npm run gen:catalog`.

 

## What it captures

 

The predicate has a single top-level field, `findings`, which is always an array (empty `[]` on a clean scan). Each `Finding` carries:

 

- `ruleId` — Gitleaks rule that fired, lowercased.
 - `description` — human-readable rule description from Gitleaks.
 - `location` — `product:<path>` for product hits, `attestation:<attestor-name>` for hits inside a prior attestor's JSON (the `commandrun` attestor expands to `attestation:commandrun:stdout`, `:stderr`, and `:json`), `file:<path>` for a working-tree file read under a `diff` or `tree` scope that is not a product, or `commit:<sha>:<path>` for bytes a newly reachable commit introduced at a path, naming that commit. A `diff` scope covers every commit newly reachable from `HEAD`, not merely the difference between the base tree and the HEAD tree — a secret added in one commit and deleted in the next leaves both trees clean while the push still carries it — and for each commit the blobs it introduces relative to ALL of its parents, recursively, so neither a secret on a merged side branch nor one nested in a subdirectory of a root commit is missed. Ancestry is read from the commit objects' parent lines — never their dates, which rebases and wrong clocks set wrongly and anyone can set deliberately — rather than from `git rev-list`, because `.git/info/grafts` rewrites the parents git reports and `--no-replace-objects` does not disable it; a grafted or shallow repository is refused outright rather than scanned. Blobs already reachable from the base are not in scope. Every such blob is read from the object store unconditionally — git's own view of whether a path is dirty is never consulted, because `git update-index --assume-unchanged` makes it lie, and object replacement (`refs/replace/*`) is disabled on every git command so a substitute blob cannot stand in for the object a push carries. The index is read as its own source too — staging a secret and then restoring the working copy leaves bytes neither the commit nor the file on disk has — and those appear as `index:<path>`. Content already scanned for a path is not scanned again, so one set of bytes yields one finding whether it was read as a product, as a file, from the commit or from the index. Nothing but this attestor's own read may skip a read: a product another attestor labelled binary is still read, and binary-ness is decided from the bytes.
 - `startLine` — line number in the source (Go field `Line`, JSON tag `startLine`).
 - `secret` — a `cryptoutil.DigestSet` (multi-algorithm hashes) of the actual secret value. The raw secret is never stored.
 - `match` — a redacted snippet with context around the hit, truncated to `maxMatchDisplayLength` (40 chars) with `[SENSITIVE-VALUE]` standing in for the value itself.
 - `entropy` — Gitleaks' Shannon-entropy score for the match.
 - `encodingPath` — the decode chain that surfaced the secret, listed outermost to innermost (e.g., `["base64","hex"]` means base64-then-hex was peeled off before the secret matched). Empty/absent on direct hits.
 - `locationApproximate` — `true` whenever the finding came from a decoded layer, since line numbers in decoded payloads don't map cleanly back to the source file.

 

Scanned products are also added as `Subjects()` under the key `product:<path>`.

 

## When to use

 

On every CI build. Findings are **always** recorded as evidence in the signed collection — recording is the attestor's job, and it happens whether or not the build is gated. Pair with `--attestor-secretscan-fail-on-detection` to *additionally* fail closed, so a leaked key blocks the run as well as being recorded against it. Without the flag, findings are recorded and the build still passes — useful for triage before turning the guard on, and the natural pairing with a verify-time policy that gates on `findings`.

 

## Recursive decoding

 

`scanBytes` in `scanner.go` walks each layer of content through three encoding scanners defined in `encoding.go`:

 

- **base64** — matches `[A-Za-z0-9+/]{15,}={0,2}` and the URL-safe variant; tries `StdEncoding` then falls back to `RawURLEncoding`.
 - **hex** — matches `[0-9a-fA-F]{16,}` and requires even length.
 - **url** — matches both consecutive `%XX` runs (3+) and tokens containing an encoded `%3D` (equals sign), then `url.QueryUnescape`s.

 

For each decoded payload that's long enough, the scanner recurses with `currentDepth+1`. Recursion stops at `min(maxDecodeLayers, maxScanRecursionDepth=3)` — the hard safety cap in `constants.go` is 3 regardless of flag value. Each level prepends its codec name to `encodingPath` and flags findings as `locationApproximate=true`.

 

## Flags

 

| Flag | Default | What it does |
| --- | --- | --- |
| `--attestor-secretscan-fail-on-detection` | `false` | Fail the run if any finding is recorded OR if any per-file/per-attestor scan errored. Findings are recorded in the attestation either way. |
| `--attestor-secretscan-max-file-size-mb` | `10` | Skip files larger than this; `0` disables the limit. Also passed to Gitleaks as `MaxTargetMegaBytes`. |
| `--attestor-secretscan-max-decode-layers` | `3` | Maximum encoding layers to peel; capped at the hard recursion limit of 3. |
| `--attestor-secretscan-config-path` | (none) | Path to a custom Gitleaks TOML config (decoded with `pelletier/go-toml`). When set, manual allowlist flags are ignored. Path exceptions (`paths` in `[allowlist]` or `[[allowlists]]`, under either `condition`) and path-scoped rules match the file's path relative to the working directory; they never exempt a prior attestation or command output. Path exceptions gitleaks wrote (`node_modules`, lockfiles, vendored trees, any path containing `gitleaks.toml`, and the unanchored `testdata`, `.*test\.go`, `go\.mod` and \`(.\*?)(jpg |
| `--attestor-secretscan-allowlist-regex` | (none) | Regex pattern for content to ignore. Repeatable. Ignored when `--config-path` is set. |
| `--attestor-secretscan-allowlist-stopword` | (none) | Exact string to ignore. Repeatable. Ignored when `--config-path` is set. |
| `--attestor-secretscan-scope` | `products` | Which files to scan: `products` (the files earlier attestors recorded), `tree` (every file under the working directory, `.git` excluded), or `diff:<base-ref>` (products plus every file changed since the merge-base of `<base-ref>` and `HEAD`, including untracked files). A `diff` base that cannot be resolved fails the run. |
| `--attestor-secretscan-scan-attestations` | `true` | Scan the JSON of attestors that ran earlier in the step, which is where command-run stdout/stderr and the material inventory live. `false` scans files only. |
| `--attestor-secretscan-include-glob` | (none) | Only scan paths matching this glob, relative to the working directory. One pattern; use brace alternation for several (`{src,cmd}/**`). |
| `--attestor-secretscan-exclude-glob` | (none) | Never scan paths matching this glob; exclude wins over include. One pattern; brace alternation for several (`{**/,}{vendor,node_modules}/**`). |

 

When any scope flag is set the predicate gains a `scope` object (`files`, `baseRef`, `baseCommit`, `attestations`, `includeGlob`, `excludeGlob`, `productDigestMismatches`, `filesScanned`, `config`) stating what the findings cover, where `config` is how they were scanned: the custom config's `configDigest`, or the command-line `allowlist` when no config file is used, plus `maxFileSizeMB` and `maxDecodeLayers`. A gate can refuse a widened configuration, and treat a `scope` with no `config` as a producer too old to state one; a default scan records none, so existing predicates keep their shape. **Every subject carries the digest of the bytes this attestor actually read**, including `product:<path>`, which used to publish the digest the product attestor recorded — so when a file changed between the product snapshot and the scan, the subject named bytes nobody scanned. The two agree in the common case, so correlation with the product attestor is unchanged; when they disagree the scanned digest is published and the disagreement is listed in `scope.productDigestMismatches` (path, the product attestor's digest, ours), which also makes a default scan emit the `scope` object, because a file that changed between snapshot and scan is evidence a policy may want to deny on. Files read under `diff` or `tree` that are not products are located as `file:<path>` and recorded as subjects under the same key with the digest of the bytes read.

 

## Output shape

 

```json
{
  "findings": [
    {
      "ruleId": "aws-access-token",
      "description": "AWS Access Token",
      "location": "product:dist/config.yaml",
      "startLine": 42,
      "secret": {
        "sha256": "…",
        "sha1": "…"
      },
      "match": "key: [SENSITIVE-VALUE]…",
      "entropy": 4.7,
      "encodingPath": ["base64"],
      "locationApproximate": true
    }
  ]
}
```

 

## Gotchas

 

- **Fail-closed is strict.** With `--fail-on-detection`, `Attest()` errors not only on findings but also on accumulated scan errors (`scanErrors`). This is intentional (per the source comment): an empty findings list from a crashed Gitleaks call is otherwise indistinguishable from a clean scan, so an attacker who could induce a crash would bypass the guard.
 - **The guard never costs you the evidence.** A detected secret is reported as an `attestation.DetectionError` — "I looked, and I found something you told me to reject." The workflow keeps that attestor's payload in the signed collection, so the run exits non-zero *and* the findings are recorded for post-incident analysis and verify-time policy. A **plain** error from `Attest()` means something different and stronger: the scan COULD NOT RUN, so its incomplete payload is deliberately excluded from the collection rather than being recorded as a clean result. That classification does not depend on `--attestor-secretscan-fail-on-detection`: the flag decides whether a FINDING fails the run, never whether a file the scan could not read does. An empty findings list over files nobody could read is not a clean scan.
 - **`config-path` replaces, not merges.** Supplying a Gitleaks TOML disables the manual allowlist entirely and logs `command-line allowlists ignored`. If you need both, fold your allowlist into the TOML.
 - **The default scan reads products, not the tree.** With `-- true` (the Pushgate remedy line) nothing is written, so there are no products and the default scan covers only the prior attestations. A push gate that wants the changed files scanned must say so: `--attestor-secretscan-scope=diff:origin/main`. The `scope` object in the predicate is how a verifier can tell which it got.
 - **Post-product timing.** The lifecycle is `postproduct`, so the wrapped command has already run by the time fail-closed triggers. The guard prevents the leak from being signed and shipped, not from being executed locally. Use `cilock verify` with a policy that checks `findings == []` to gate downstream consumers.
 - **No self-scan, no peer-scan.** `shouldSkipAttestor` skips the `secretscan` attestor itself and any other `postproduct` attestor to avoid races and recursion.
 - **Binary products and directories are skipped** via `shouldSkipProduct` (MIME-type check), so secrets compiled into binaries won't be caught here.
 - **Every product is scanned; a scanner's own report is deduplicated, never skipped.** A SARIF report (`gitleaks --report-format sarif`, …) quotes every secret it found, so scanning it naively reports each one a second time as `product:<report>`. The attestor still scans the report like any other product — nothing in it is trusted, `tool.driver.name` included — and afterwards drops a finding inside it only when the report declares a result with the same rule id **and** the same (rule id, secret sha256) was found by this scan in some other location. A secret that exists only inside a "report", however it labels itself, is a real finding and `--fail-on-detection` fires on it. Parsed reports are recorded under `consumedReports` (path, sha256 of the bytes parsed, claimed driver, result count, `deduplicated` count) and remain in `Subjects()`. The sha256 is computed at read time and must equal the product attestor's digest; if the file changed in between, or no digest is recorded, the product is treated as ordinary (scanned, nothing deduplicated).
 - **Hard 3-layer recursion cap.** `maxScanRecursionDepth` in `constants.go` is 3 — setting `--max-decode-layers` higher has no effect.
 - **Partial env-value matches need most of the secret.** On decoded content the attestor also looks for a leading part of each sensitive environment value (a truncated leak such as `echo ${TOKEN:0:24} | base64`). Such a match is reported, with a `-partial` rule-id suffix, only when the decoded bytes carry at least half of the value and at least 8 characters of it. Decoded content is mostly not text — every sha256 in a material inventory, every `h1:` hash in `go.sum`, every lockfile integrity hash decodes to 32 bytes of noise — and a shorter prefix of *some* sensitive value in the caller's environment turns up in that noise on any large tree, which made the finding depend on who ran the scan. The half rule also keeps the prefix every secret of a kind shares (the HS256 JWT header, PEM armor, `ghp_`) from matching a different secret of the same kind.
 - **Which environment values are matched.** A value becomes a literal match rule only when its key is sensitive by the default list or `--env-add-sensitive-key`, is not named by `--env-allow-sensitive-key`, and the value has at least 8 characters. Locations and identities never are, whichever pattern catches them: `PWD`, `OLDPWD`, `HOME`, `TMPDIR`, `TMP`, `TEMP`, `SHELL`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_AUTHOR_DATE`, and any key ending in `PATH`. The masking globs are broad on purpose (`*PWD*` for `PASSWORD` spellings, `*PAT*` for `GITHUB_PAT`), which is harmless when the action is to mask a value but reported the working directory as a leak in every attestation. `--env-capture-allowlist` decides only what the environment attestor records; a key it leaves out is not thereby a secret.

 

## CLI example

 

Real secret-pattern scan against the wrapped command's outputs. Surfaces real findings (or none) and gates the build via `--attestor-secretscan-fail-on-detection`.

 

```bash
# secretscan is a postproduct attestor — it inspects the command's
# captured products and opened files (via the ptrace spy) for secret
# patterns. The wrapped command should be the real workload whose
# output you want scanned (a build, an install, a config render),
# not a synthetic echo.
cilock run --step build \
  --signer-file-key-path key.pem --outfile attestation.json \
  --attestations secretscan,environment,git \
  --attestor-secretscan-fail-on-detection \
  -- go build -o bin/myapp ./cmd/myapp
```

 

Validated against a real directory tree with `--fail-on-detection` enabled. See the full real-data example at [https://github.com/aflock-ai/attestor-compliance-examples/tree/main/39-secretscan](https://github.com/aflock-ai/attestor-compliance-examples/tree/main/39-secretscan).

 

## See also

 

- [Catalog row](https://www.testifysec.com/docs/cilock/reference/attestor-catalog)
 - [Concepts: secretscan attestor](https://www.testifysec.com/docs/cilock/concepts/attestors#secretscan-attestor)
 - [Defending against supply-chain attacks](https://www.testifysec.com/docs/cilock/tutorials/defending-against-supply-chain-attacks)
 - Upstream: [witness/secretscan.md](https://github.com/in-toto/witness/blob/main/docs/attestors/secretscan.md)

 

---

**This page is generated from the CI/lock tool catalog.** Don't edit it here — the source is [`attestation/detection/docs/secretscan.doc.md`](https://github.com/aflock-ai/rookery/edit/main/attestation/detection/docs/secretscan.doc.md) in [aflock-ai/rookery](https://github.com/aflock-ai/rookery). The same catalog powers `cilock tools show secretscan` in the CLI, so a single edit keeps the terminal and the website in sync.

[Edit this page →](https://github.com/aflock-ai/rookery/edit/main/attestation/detection/docs/secretscan.doc.md) ·  [Add a new tool →](https://github.com/aflock-ai/rookery/new/main/attestation/detection/docs)

When you open the pull request: if you are an **AI agent**, add the `ai-agent` label. If you are a **human**, add the `human` label.

Reference generated from the product documentation. Match commands and support details to your installed release.
