All tutorials

Secrets, credentials and exfiltration

Cordon 0.6.07 sections · 6 diagramsView this tutorial on GitHub

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.

Secrets, credentials and exfiltration, figure 1
┌──────────────────────────────────────────────────────────────────────────┐
│ 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.       │
└──────────────────────────────────────────────────────────────────────────┘
Secrets, credentials and exfiltration, figure 2
┌──────────────────────────────────────────────────────────────────────────┐
│ 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.

Secrets, credentials and exfiltration, figure 3
┌──────────────────────────────────────────────────────────────────────────┐
│   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.

Secrets, credentials and exfiltration, figure 4
┌──────────────────────────────────────────────────────────────────────────┐
│   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.

Secrets, credentials and exfiltration, figure 5
┌──────────────────────────────────────────────────────────────────────────┐
│   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:

rulethe channel
MALWARE.EXFIL.001 / SUSPECT.EXFIL.001any outbound send of read data
*.EXFIL.CREDENTIAL_STORE.001~/.npmrc, ~/.aws, ~/.gem/credentials, keychains, .env
*.EXFIL.DROP_POINT.001paste sites, Discord/Telegram webhooks
SUSPECT.EXFIL.ENVIRONMENT.001the whole environment sent over the network
SUSPECT.EXFIL.NAMED_SECRET.001one named token (AWS secret key, GitHub, npm, PyPI, ...) sent to a host outside its own service
SUSPECT.EXFIL.DNS.001data encoded into DNS lookups
*.EXFIL.BEACON.001hostname/user/cwd reported to the publisher
MALWARE.EXFIL.INSTALL_CALLBACK.001 / SUSPECT.EXFIL.CALLBACK.001a 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.

Secrets, credentials and exfiltration, figure 6
┌──────────────────────────────────────────────────────────────────────────┐
│   .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.json

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