Secrets, credentials and exfiltration
61 secret rules and 13 exfiltration rules: the part of the rule pack where the tool that finds the problem must not become the problem.
┌──────────────────────────────────────────────────────────────────────────┐ │ A credential is two findings, never one. │ │ │ │ WHAT IT IS ......... a token was committed, so it is public now │ │ WHAT IT DOES ....... something reads it and sends it somewhere │ │ │ │ The first is a leak. The second is exfiltration. Cordon reports │ │ them separately because they have different owners and different │ │ urgencies -- rotate the key, versus treat the host as compromised. │ └──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐ │ file bytes │ │ │ │ │ ├─ 1. PATTERN ....... 58 provider shapes: AWS, GitHub, Stripe, │ │ │ Slack, OpenAI, npm, PyPI, Vault, ... │ │ │ each anchored to that issuer's real format │ │ │ │ │ ├─ 2. ENTROPY ....... a high-entropy run where a name says │ │ │ 'secret' -- SECRET.GENERIC.ASSIGNMENT.001 │ │ │ │ │ ├─ 3. STRUCTURE ..... PEM blocks, JWTs, URLs with inline │ │ │ credentials -- shape, not vocabulary │ │ │ │ │ └─ 4. CONTEXT ....... is this file an install hook? is there │ │ egress nearby? that decides the severity │ └──────────────────────────────────────────────────────────────────────────┘
A scanner reports into CI logs, pull-request comments and SARIF files uploaded to third parties. All three outlive the repository and are read more widely.
┌──────────────────────────────────────────────────────────────────────────┐ │ RedactionMode what reaches the report │ ├──────────────────────────────────────────────────────────────────────────┤ │ none raw matched text refused for every SECRET.* rule │ │ masked structure kept, the default for everything else │ │ entropy masked │ │ hash_only the match hash only MANDATORY for SECRET.* rules │ ├──────────────────────────────────────────────────────────────────────────┤ │ So a leaked key is reported by its sha256, never by its value. │ │ You cannot configure your way out of this: `evidence_policy: │ │ none` on a secret rule is refused at rule-load time, not at │ │ report time. │ └──────────────────────────────────────────────────────────────────────────┘
Verify it yourself, the value never appears:
cordon-scanner scan . -f json:out.jsonjq '.findings[] | select(.rule_id|startswith("SECRET")) | .evidence' out.json
Sixty rules over a real repository would be unusable without these. Each one is a ceiling, not a suppression, the finding stays in the report.
┌──────────────────────────────────────────────────────────────────────────┐ │ a directory of 5+ private keys │ │ └─▶ one finding, severity capped at MEDIUM │ │ a CA + intermediate + client + server is a generated │ │ hierarchy far more often than a disclosure │ │ │ │ 3+ private keys in ONE file │ │ └─▶ one finding, capped a per-algorithm test table │ │ │ │ the same credential NAME in 10+ files │ │ └─▶ one finding, capped one decision, not ten leaks │ │ │ │ byte-identical file in N places │ │ └─▶ one finding one thing to fix, not N │ └──────────────────────────────────────────────────────────────────────────┘
Every one of them still says every value is committed: if one protects something live, all of them are in git history and in every clone.
┌──────────────────────────────────────────────────────────────────────────┐ │ reads ~/.npmrc ─┐ │ │ ├──▶ MALWARE.EXFIL.CREDENTIAL_STORE.001 │ │ posts to a webhook ─┘ critical · treat the host as breached │ │ │ │ reads os.environ ─┐ │ │ ├──▶ SUSPECT.EXFIL.001 │ │ urlopen(host, data=..) ─┘ high · in an install hook: critical │ │ │ │ hostname + user + cwd ─┐ │ │ ├──▶ MALWARE.EXFIL.BEACON.001 │ │ GET with them as params ─┘ the install-time beacon shape │ │ │ │ base64 chunks ─┐ │ │ ├──▶ SUSPECT.EXFIL.DNS.001 │ │ as DNS subdomains ─┘ tunnelling out where HTTP is blocked │ └──────────────────────────────────────────────────────────────────────────┘
The 13 exfiltration rules divide on where the data goes. MALWARE. is the install-time form of a rule, SUSPECT. the same behaviour in ordinary code:
| rule | the channel |
|---|---|
MALWARE.EXFIL.001 / SUSPECT.EXFIL.001 | any outbound send of read data |
*.EXFIL.CREDENTIAL_STORE.001 | ~/.npmrc, ~/.aws, ~/.gem/credentials, keychains, .env |
*.EXFIL.DROP_POINT.001 | paste sites, Discord/Telegram webhooks |
SUSPECT.EXFIL.ENVIRONMENT.001 | the whole environment sent over the network |
SUSPECT.EXFIL.NAMED_SECRET.001 | one named token (AWS secret key, GitHub, npm, PyPI, ...) sent to a host outside its own service |
SUSPECT.EXFIL.DNS.001 | data encoded into DNS lookups |
*.EXFIL.BEACON.001 | hostname/user/cwd reported to the publisher |
MALWARE.EXFIL.INSTALL_CALLBACK.001 / SUSPECT.EXFIL.CALLBACK.001 | a call to an interaction or canary host |
A credential committed and deleted in the next commit is gone from the tree and present in every clone. --history reads every blob git still holds that the tree no longer does, through the same detector and with the same hash-only evidence, and reports each distinct credential once, at the commit that introduced it:
cordon-scanner scan . --history--verify-secrets (with --online) then asks each credential's own issuer -- GitHub, GitLab, Slack, npm, OpenAI, Anthropic, Stripe -- whether it still works, with one read-only call to that issuer and nowhere else. A credential the issuer accepts becomes SECRET.LIVE.001 at critical; one it rejects becomes SECRET.LIVENESS.REJECTED.001 at info, so triage starts with what works. A Slack webhook is never checked, because the only check is posting to it.
┌──────────────────────────────────────────────────────────────────────────┐ │ .env is gitignored, so it is not committed, so there is nothing │ │ to find -- right? │ │ │ │ Cordon scans the WORKING TREE, not the index. A gitignored .env │ │ sitting on disk is read and reported, because: │ │ │ │ · it is on the machine running the scan │ │ · `git add -f` happens │ │ · a Docker COPY . ignores .gitignore entirely │ │ · the file lands in the image layer, which ships │ │ │ │ If you want it excluded, exclude it deliberately and see the │ │ POLICY.COVERAGE.TARGET_EXCLUSION finding that records you did. │ └──────────────────────────────────────────────────────────────────────────┘
1. Is it real? The hash lets you confirm without the value leaving.
cordon-scanner scan . -f json:out.json2. A whole class you do not use. In cordon.yaml -- there is no CLI flag for this, deliberately, so the decision is committed and reviewable:
rules: disabled: - SECRET.AIRTABLE.TOKEN.001
If that config lives in the repository being scanned, Cordon reports POLICY.COVERAGE.RULE_DISABLED at HIGH -- a scan target does not get to quietly switch off checks. In an operator's own --config file it is silent.
3. One path that is genuinely test material -- with an expiry and an owner. See tutorial 14: suppressions expire so the decision gets re-examined while somebody still remembers why.
Never lower evidence_policy on a secret rule to see the value. The rule loader refuses it, and that refusal is the feature.Next: 07 · CI/CD pipeline attacks, the sixteen rules for attacks on your pipeline, as opposed to running Cordon in it.