llm-security-commons/docs/secret-egress-divergence.md
Kjell Tore Guttormsen 4a6f6ffc16 docs(egress): the summary row was hand-totalled, and the caveat was measurable
Three corrections to a document whose measurements were right.

The "shapes commons reports and the guard is silent on" row said 6 across 4
commons entries. Re-derived from the differential rather than from the table:
7 witnesses across 5 entries. The old figure collapsed the two webhook hosts
into one row while keeping the two GitHub prefixes as two, so it was
inconsistent with itself, and it dropped GitHub Token from the entry count
even though two of its five prefixes are exactly what the guard misses. The
table now runs one row per witness and says why counting either way alone
misleads.

The mapping row for connection strings said "minus one scheme". The section
below it already said the right thing: commons catches mongodb:// and misses
the SRV form. Aligned.

The "seed B was read at a commit the guard did not port from" caveat is gone,
replaced by the measurement that dissolves it. knowledge/secrets-patterns.md
has one commit at or before 47905da - f153f96, 2026-04-08 - and the guard's
output.py was first committed 2026-07-04. The seed had been still for three
months when the port was written. The 5 real drifts are guard-side by
measurement now, not by inference.

Neither coord message carried the bad count, so nothing needs re-sending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFforUbBA7GnUYijg78kpK
2026-08-13 22:22:29 +02:00

295 lines
18 KiB
Markdown

# Secret-egress divergence — commons vs the Python guard
**Status: informative.** Nothing here is normative and nothing here changes a data file. It
records a measured disagreement between two tables that were believed to be two cuts of one
source, and turns out not to be. Under this repository's behaviour-preservation invariant,
a divergence found here is **reported, not fixed**.
Produced 2026-08-13. Every number below came from a command; the scripts live in the session
scratchpad rather than in this repository, because executable code here would breach the
charter. They are reproducible from the method column. The guard was read via
`git archive v0.7.0`, never from its working copy.
**The premise this document was opened to test does not survive it.** The open question was
recorded as "19 entries here against the guard's 25, cut at different granularity" — one
table, two granularities. That is not what the two files are. They are ports of **two
different source tables in the same source repository**, and the granularity difference is a
consequence of that, not the cause. Everything below follows from correcting that premise.
## What was compared
| Side | Artefact | Version / coordinate |
| --- | --- | --- |
| commons | [`signatures/secret-egress.json`](../signatures/secret-egress.json) | file `version` 0.3.0, 19 entries |
| guard | `llm-ingestion-pipeline-security` `src/llm_ingestion_guard/output.py` `_SECRET_PATTERNS` | tag `v0.7.0` = commit `be9759b`, 25 entries |
| seed A | `llm-security` `hooks/scripts/pre-edit-secrets.mjs` `SECRET_PATTERNS` | commit `47905da`, 19 entries — what commons ported |
| seed B | `llm-security` `knowledge/secrets-patterns.md` | commit `47905da`, blob `a7ed469`, 33 entries — what the guard ported |
**The guard's v0.7.0 is the guard's current behaviour.** `git diff v0.7.0..aff3511 -- src/`
is empty, where `aff3511` was the guard's head when this was measured. Pinning at the tag
therefore costs no currency; it is not a waypoint measurement.
**Seed B was read, not accepted.** The guard's module docstring asserts *"Ported from the
`llm-security` `knowledge/secrets-patterns.md` seed"*. That assertion is a claim about a
third repository and would be an attribution, not a finding, if it were relayed. It was
measured instead: the file exists at the pinned commit on the public remote, and all 25 of
the guard's ids appear in it verbatim — `0` guard ids are absent from seed B. The docstring
is correct.
**Both seeds are named in commons' own file.** `signatures/secret-egress.json`'s `$comment`
already says which of the two it took and that the other *"is a separate PCRE-flavoured
agent-consumed variant that stays where it is"*. What was not known until now is that the
guard ported the other one.
## Result
| Measure | Method | Result |
| --- | --- | --- |
| Entry count, both sides | count entries | 19 and 25 |
| Seed B entry count | parse the `.md` at the pinned blob | 33 |
| Guard ids present in seed B | set membership on `id` | **25/25** |
| Seed B ids the guard did not port | set difference | **8** |
| Guard vs seed B, field-identical | compare regex (after stripping seed B's `(?i)` inline rendering), flags and severity | **16/25** |
| — of the 9 remaining, escaping-only | unescape the guard's `\"` (a Python raw-string artefact) and compare for string identity | **4/4 identical** |
| — of the 9 remaining, behaviourally real | differential match comparison | **5** — 4 connection strings, 1 capture-group change |
| commons vs guard, byte-identical patterns | unescape both sides' `\/` and `\"`, compare source + flags | **2/19** |
| Differential probe corpus | one witness per guard id, plus each side's exclusive shapes and the semantics witness | 36 probes |
| Shapes commons reports and the guard is silent on | differential | **7 witnesses, across 5 commons entries** |
| Shapes the guard reports and commons is silent on | differential | **3** |
| Match-semantics divergence | one witness matching two entries on both sides | **1 label vs 2 labels** |
16 field-identical + 4 escaping-only + 5 real = 25.
**The `2/19` is the number that says these are not two cuts of one table.** Only
`GitHub Fine-Grained PAT``github-pat-fine-grained` and `OpenAI Legacy API Key`
`openai-api-key-legacy` are byte-identical after unescaping. Even `AWS Access Key ID` is not:
commons has `AKIA[0-9A-Z]{16}` and the guard has the same run anchored, `\bAKIA[0-9A-Z]{16}\b`.
The earlier note calling that pair the one clean 1:1 was wrong, and was wrong by transcription
rather than by measurement.
## Match semantics: the divergence that is not about membership
This is the finding a membership table would hide, and it is the one a consumer implementing
from commons will get wrong first.
`signatures/secret-egress.json` declares `match_semantics: "first match wins; patterns are
evaluated in ascending order"`, marks `ordering.normative: true`, and names
`last_entry_is_load_bearing: "JWT (three-part token)"` — the JWT entry is placed last
precisely so a token inside an `Authorization` header is reported as the header, not as a
bare JWT.
The guard's `scan_secret_egress` runs `finditer` over all 25 patterns and adds a finding for
every match. Order carries **no** semantics there, and there is no first-match-wins layer.
Measured on one witness — an `Authorization` header whose value is a three-part JWT:
| Side | Finding set |
| --- | --- |
| commons, under its own declared contract | `Authorization header with token` — one label |
| guard, `scan_secret_egress` at `v0.7.0` | `egress:bearer-token`, `egress:jwt-token` — two labels |
Both detect the credential. They disagree about what a report says, which is what a
`conformance/expected.json` encodes. Two runtimes that both "pass" here would still produce
different fixture files.
The commons side of this was not hand-rewritten: the evaluator compiles the patterns out of
the JSON, in `order`, applying `re.I` exactly where the file's own `dialect.translation_notes`
say to, and stops at the first hit. The guard side is the imported module. Neither table was
transcribed.
## Membership, measured
Every row below comes from running a witness input through both sides, not from reading the
two regexes side by side.
| commons `order` / name | guard ids observed | Relation |
| --- | --- | --- |
| 0 `AWS Access Key ID` | `aws-access-key-id` | 1:1, guard anchored |
| 1 `AWS Secret Access Key` | — | **guard silent** |
| 2 `Azure Connection String (AccountKey/SharedAccessKey/sig)` | `azure-storage-key` | overlap; see below |
| 3 `Azure AD ClientSecret` | `azure-client-secret` | 1:1 |
| 4 `Azure AI Services Key` | — | **guard silent** |
| 5 `GitHub Token` | `github-pat-classic`, `github-oauth-token`, `github-server-token` | 1:3, **plus 2 prefixes neither guard id covers** |
| 6 `npm Token` | `npm-token` | 1:1 |
| 7 `Anthropic API Key` | `anthropic-api-key` | 1:1 |
| 8 `OpenAI Project Key` | `openai-project-key` | 1:1 |
| 9 `GitHub Fine-Grained PAT` | `github-pat-fine-grained` | 1:1, **byte-identical** |
| 10 `Google API Key` | `gcp-api-key` | 1:1 |
| 11 `Private Key PEM Block` | `rsa-private-key`, `ec-private-key`, `pkcs8-private-key` | 1:3, **minus one PEM label** |
| 12 `JWT Secret` | — | **guard silent** |
| 13 `Slack/Discord Webhook URL` | — | **guard silent** |
| 14 `Generic credential assignment` | `generic-api-key`, `config-password`, `config-secret` | 1:3 |
| 15 `Authorization header with token` | `bearer-token` (+ `jwt-token`, see semantics) | 1:1 |
| 16 `Database connection string` | `postgres-connstr`, `mysql-connstr`, `redis-connstr` | 1:3, **minus the MongoDB SRV form** |
| 17 `OpenAI Legacy API Key` | `openai-api-key-legacy` | 1:1, **byte-identical** |
| 18 `JWT (three-part token)` | `jwt-token` | 1:1 |
Guard ids with no commons entry firing on their own witness: `gcp-service-account-json`,
`mongodb-connstr` — and `ec-private-key` on the `ENCRYPTED` header.
`Azure Connection String` is listed as *overlap* rather than 1:1 deliberately. Commons'
entry is `(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}` — three alternatives, no
length pin. The guard's `azure-storage-key` is `AccountKey=([A-Za-z0-9+/]{86}==)` — one
alternative, exact length. The corpus witnessed only the `AccountKey` shape, where both fire.
`SharedAccessKey=` and `sig=` were not witnessed; seed B carries them under separate ids
(`azure-servicebus-connstr`, `azure-sas-token`) that the guard did not port. Read this row as
"one witnessed overlap", not as a coverage claim.
## What each side misses that the other catches
**Commons reports, guard silent — 7 witnesses across 5 commons entries:**
| Witness shape | commons entry | Why the guard is silent |
| --- | --- | --- |
| `ghu_` prefixed token | `GitHub Token` | guard ported `ghp`/`gho`/`ghs`; no id for `ghu` |
| `ghr_` prefixed token | `GitHub Token` | same |
| `aws_secret_access_key = <40 chars>` | `AWS Secret Access Key` | seed B has `aws-secret-access-key`; guard did not port it |
| `Ocp-Apim-Subscription-Key` assignment | `Azure AI Services Key` | absent from seed B entirely |
| `JWT_SECRET` assignment | `JWT Secret` | absent from seed B entirely |
| Slack webhook URL | `Slack/Discord Webhook URL` | absent from seed B entirely |
| Discord webhook URL | `Slack/Discord Webhook URL` | same |
One row is one witness, so two commons entries appear twice: `GitHub Token` covers five
prefixes behind one name, and `Slack/Discord Webhook URL` covers two hosts. Counting rows
rather than entries would overstate how much of commons the guard is missing, and counting
entries rather than rows would hide that `GitHub Token` is only *partly* uncovered — its
`ghp`/`gho`/`ghs` prefixes map onto three guard ids just fine.
Three of the seven are the sharper finding: the `Ocp-Apim-Subscription-Key`, `JWT_SECRET` and
webhook shapes are not entries the guard declined to port, they are entries **seed B does not
have**. Seed A carries three shapes seed B never did.
**Guard reports, commons silent — 3 shapes:**
| Shape | guard id | Why commons is silent |
| --- | --- | --- |
| `"type": "service_account"` | `gcp-service-account-json` | seed A has no GCP service-account marker |
| `-{5}BEGIN ENCRYPTED PRIVATE KEY-{5}` | `ec-private-key` | commons' PEM alternation is `(?:RSA \| EC \| DSA \| OPENSSH )?`; `ENCRYPTED` is not in it |
| `mongodb+srv://user:pw@host` | `mongodb-connstr` | commons' scheme run is `(?:postgres\|mysql\|mongodb\|redis)://` — the `+srv` suffix breaks the literal |
The `mongodb+srv` miss is worth naming precisely: commons is not missing MongoDB, it is
missing the **SRV** form, which is the form Atlas hands out. Plain `mongodb://` is caught.
**This asymmetry is not a scoreboard.** Each side is faithful to its own seed. Every shape in
the left table is present in seed A and absent from seed B; every shape in the right table is
the reverse. Neither port is wrong about its source. The seeds disagree.
## False-positive suppression: a layer commons has no field for
The guard applies value-based suppression to the five entries that capture a value
(`_is_fp_value`): structural placeholders (`your-`, `<`, `>`, `***`), word-boundary
placeholder words (`example`, `changeme`, `todo`, …), variable references (`${`, `$(`,
`os.environ`, `process.env`, …), all-same-character values, and values under 8 characters.
Measured:
| Witness | commons (first match) | guard |
| --- | --- | --- |
| `password: 'your-password-here'` | `Generic credential assignment` | — suppressed |
| `api_key: '${MY_API_KEY_VALUE}'` | `Generic credential assignment` | — suppressed |
Commons has no field that could carry this. `dialect.translation_notes` warns in prose that
the generic entries are *"shape matches, not proofs of a live credential"* and assigns the
trade-off to the consumer's policy — which is a correct statement of ownership and is also
why two consumers reading commons will produce different reports on the same placeholder.
Seed B carries the suppression semantics per entry in a `false_positive_notes` field; seed A
carries name and pattern only, so commons had nothing to extract. This is a gap in the seed,
not an omission in the extraction.
## The connection-string bound
The guard bounds the password run in all four connection-string patterns at
`MAX_CONNSTR_VALUE = 256`, and its module explains why in full: an unbounded run in front of
a required literal makes every start position rescan the tail when the literal never arrives.
They measured 8.2 s at 100 000 characters on crafted `redis://:` input and extrapolated to
hours at their own 1 000 000-character cap. Seed B's connection-string patterns are unbounded;
this is one of the 5 real guard-vs-seed-B drifts, and it is a deliberate, documented one.
Commons' `Database connection string` is `(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+`
**shape-analogous** to what the guard bounded. Measured against the exact boundary:
| Password length | commons | guard |
| --- | --- | --- |
| 12 | matches | `egress:postgres-connstr` |
| 256 | matches | `egress:postgres-connstr` |
| 257 | matches | — |
| 300 | matches | — |
Read this as two facts, not one verdict. Commons has recall the guard traded away above 256
characters. Commons also carries the runtime shape the guard's measurement was about — and
carries it in an *unanchored* form (`[^\s]+@[^\s]+` rather than the guard's
`[^:@\s]+:…@[^\s'"]+`), so the two are not the same pattern under load and no timing claim
about commons is made here. **Nothing is changed on that basis.** The entry is faithful to
seed A, the file that owns it is `llm-security`'s, and the behaviour-preservation invariant
puts the decision there. It is reported, and the guard's measurement is cited so the owner
does not have to redo it.
## Severity and ids: what commons does not carry
Seed B carries `id` and `severity` per entry; the guard preserved both, and all 25 severities
are field-identical to the seed. Seed A carries neither, so commons carries neither, and its
`evidence_limits` says so explicitly: *"No severity, and no per-entry disposition, was
supplied … so neither is invented here."*
That restraint was right and it has a consequence: **commons has no id space for this table.**
Its entries are keyed by human-readable `name` (`"GitHub Token"`), while the guard emits
`egress:<id>` labels. A `conformance/expected.json` scoped to secret egress cannot be written
against commons today, because a fixture names labels and commons has none to name.
The 25 guard ids are **not guard-internal labels**. They are seed B's ids, adopted verbatim,
which was measured above (25/25 present in the seed). That makes the id space question a
question for `llm-security` first — they own both seeds and the id space in one of them — and
for the guard second. Per this repository's naming rule, **no id is proposed here.** The rule
that `carrier:*` established applies exactly: naming an id in a shared space is the exception,
it requires both runtimes asked first, and publishing `aliases.<runtime>` is irreversible at
file granularity.
## What this does not show
- **It does not show that either table is wrong.** Both are faithful ports. The disagreement
is between seed A and seed B, inside `llm-security`, and only that repository can say
whether two tables is intentional (one engine-consumed, one agent-consumed) or whether one
supersedes the other.
- **It does not measure seed A's current state.** Commons' fidelity to seed A was verified at
commit `47905da` and this document adds nothing to that.
*(Seed B was read at the same commit, which is a shared coordinate and not the commit the
guard ported from. That was going to be a caveat — a seed-B entry that moved between the
guard's port and `47905da` would show up here as guard drift. It is dissolved by measurement
instead: `git log -- knowledge/secrets-patterns.md` in a deepened mirror returns exactly one
commit at or before `47905da`, `f153f96`, dated 2026-04-08, and the guard's `output.py` was
first committed 2026-07-04. The seed had been still for three months when the port was
written and has not moved since. Reading it at `47905da` reads what the guard ported from,
so the 5 real drifts are guard-side by measurement rather than by inference.)*
- **It does not compare coverage.** The probe corpus has one witness per guard id plus each
side's exclusive shapes — 36 inputs. It is built to expose membership and semantics, not to
estimate recall. `SharedAccessKey=` and `sig=` Azure shapes, and seed B's 8 unported ids,
have no witness here.
- **It does not measure the runtimes' entry points.** Both sides were driven at table level:
commons through an evaluator compiled from its own JSON under its own declared contract, the
guard through `scan_secret_egress` directly. What `scan_output` composes around it —
decode-and-rescan re-labelling findings as `decoded:egress:*`, the oversize cap — is not in
scope and would change the finding sets.
- **It does not touch `manifest.json`.** `scope_planned.blockers` still names this divergence
as the blocker for egress cases. Whether this document dissolves that blocker or merely
describes it is a separate decision, and it depends on answers this document does not have.
## Consequence for `conformance/`
An egress case is not mintable today, and the reason has changed. It was recorded as "the two
tables are cut at different granularity". The measured reasons are three, and they are
independent:
1. **No id space on the commons side.** A fixture names labels. Commons has names, not ids.
This is the hard blocker and it is the subject of the outgoing question to both runtimes.
2. **Match semantics disagree.** Even with an id space, the Bearer-plus-JWT witness produces a
one-label expectation under commons' declared contract and a two-label one from the guard.
A fixture would have to encode one of them.
3. **Membership disagrees in both directions**, and the disagreement is inherited from two
different seeds rather than from a porting error — so it cannot be closed by re-measuring
either port.
None of the three is dissolved by a measurement this repository can run alone. Per
`conformance/manifest.json``entry_points_by_scope`, a new scope also needs an entry point,
a findings accessor and a fixture presentation for every runtime before its first case, and
those three slots are empty for egress on both runtimes. That requirement stands independently
of everything above.