The open question was recorded as "19 entries here against the guard's 25, cut at different granularity". Measured at a pinned tag, that premise does not hold: commons ported llm-security's `hooks/scripts/pre-edit-secrets.mjs` (19 entries, name + pattern only), the guard ported its `knowledge/secrets-patterns.md` (33 entries, ids + severity + FP notes) and took 25 of them. Two seeds, not two cuts. The guard's docstring asserting the second seed was not relayed - the file was read at the pinned commit and all 25 guard ids are in it verbatim. Beyond the count, three things a membership table would have hidden: - Match semantics disagree. Commons declares first-match-wins with normative ordering and a load-bearing last entry; the guard reports every match. One Bearer-plus-JWT witness: one label vs two. - Only 2 of 19 patterns are byte-identical across the two sides. The pair earlier called the clean 1:1 (AWS access key) is not one of them. - The guard suppresses placeholder and variable-reference values; commons has no field that could carry that, because its seed carries none. Membership diverges both ways and neither port is at fault - the seeds disagree. Reported, not fixed, per the behaviour-preservation invariant. No id is proposed: that is the carrier rule, and both runtimes are asked first. manifest.json is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFforUbBA7GnUYijg78kpK
283 lines
17 KiB
Markdown
283 lines
17 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 | **6** (across 4 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 one scheme** |
|
|
| 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 — 6 shapes across 4 entries:**
|
|
|
|
| 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 / Discord webhook URL | `Slack/Discord Webhook URL` | absent from seed B entirely |
|
|
|
|
The last three are the sharper finding: they are not entries the guard declined to port, they
|
|
are entries **seed B does not have**. Seed A has three shapes seed B never carried.
|
|
|
|
**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 convenient shared coordinate and **not** the commit the guard ported from — the
|
|
guard's port predates it. A seed-B entry that moved between the guard's port and `47905da`
|
|
would appear here as guard drift. The 5 real drifts are all explained by the guard's own
|
|
module comments, which makes that unlikely, not impossible.
|
|
- **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.
|