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
295 lines
18 KiB
Markdown
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.
|