Skip to main content

secretscan attestor

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.

Namesecretscan
Predicate typehttps://aflock.ai/attestations/secretscan/v0.1
Lifecyclepostproduct
Default binary?No
Recommended traceoff — no syscall tracing needed
Auto-attaches whenNot 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.QueryUnescapes.

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​

FlagDefaultWhat it does
--attestor-secretscan-fail-on-detectionfalseFail 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-mb10Skip files larger than this; 0 disables the limit. Also passed to Gitleaks as MaxTargetMegaBytes.
--attestor-secretscan-max-decode-layers3Maximum 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-scopeproductsWhich 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-attestationstrueScan 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) stating what the findings cover; 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​

{
"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 (#9315). 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.

# 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.

See also​


This page is generated from the CI/lock tool catalog. Don't edit it here — the source is attestation/detection/docs/secretscan.doc.md in 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 →  ·  Add a new tool →

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.