Repository navigation
Secret-egress evidence and coverage map #1308
Description
Describe the bug
With request-body redaction enabled and a trusted upstream on response_scanning.exempt_domains (intent: redact-and-forward), a request hard-blocks whenever the body carries a critical secret the detector recognises but the redactor cannot rewrite. The unredacted survivor keeps the post-redaction re-scan non-empty, so the exempt-host carve-out (which requires a fully clean body) can't apply and shouldHardBlockBodyCriticalDLP blocks — even on the trusted host. The clearest instance is AWS Access ID: detection covers 9 prefixes, redaction only 5 (exact regexes under Additional context). GitHub PATs are at full parity and unaffected. Verified on main (post-v3.4.0).
To reproduce
- Config used:
request_body_scanning+redactionenabled (balancedprofile);response_scanning.exempt_domains: [api.trusted.example](intent: redact-and-forward to a trusted host). - Call made: POST a JSON body to
https://api.trusted.example/…containing a detected-but-unredactable AWS Access ID shape — e.g.A3Tfollowed by 16[A-Z0-9]. - What happened (derived from the gate logic — see disclosure):
403 blocked: request body contains secret: AWS Access ID, despite the host being exempt and redaction enabled. A standardAKIA…{16}value on the same host redacts-and-forwards as expected — the only difference is the prefix/length coverage.
(Disclosure: confirmed from source — the regex diff plus the gate logic — rather than a captured 403, because my live agent host is on passthrough_domains.)
Expected behavior
On a host trusted for redact-and-forward (exempt_domains), a detected AWS Access ID should be redacted and forwarded — as AKIA… values already are — not fail closed. Equivalently: the redaction class should track the detection regex it is paired with, so a detected critical is always redactable. Since detection here is the immutable core floor, parity can only be restored by widening the redaction class, not narrowing detection — assuming the missing prefixes (A3T/AIPA/ANPA/ANVA, AWS unique-ID shapes your detector already treats as critical) are safe to placeholder. If some aren't rewrite-safe, reconciling the two sets the other way is equally valid — your call on which.
Environment
- OS: Linux (agent devcontainer)
- Go version: N/A — source review, not a local build (no Docker/root in the workspace to run the binary)
- Pipelock version:
main(post-v3.4.0); the two regexes are byte-identical in v3.3.0 andmain
Additional context
Architecture, as I understand it (please correct me): two intentionally different pattern sets — detection (~65 patterns, defaults.go; loose is safe, a false match only blocks) and redaction (~41 classes, classes.go; a deliberate subset, since a false rewrite corrupts a legitimate request in flight). Request-body flow is scan → rewrite → re-scan → decide, and the decision gate is called from all four mediated surfaces (forward, intercept, reverse, websocket):
// internal/proxy/bodyscan.go
func shouldHardBlockBodyCriticalDLP(result BodyScanResult, hostname string, cfg *config.Config) bool {
if !shouldHardBlockRequestDLP(result.DLPMatches, cfg) { return false }
if result.RedactedDLPOnly && ... &&
isResponseScanExempt(hostname, cfg.ResponseScanning.ExemptDomains) {
return false // redact-and-forward escape hatch — needs a CLEAN post-redaction body
}
return true // else hard-block, even on an exempt host
}The exact gap: detect AWSAccessIDRegex (defaults.go:23) (AKIA|A3T|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16,} vs redact ClassAWSAccessKey (classes.go:97) \b(?:AKIA|ASIA|AIDA|AGPA|AROA)[A-Z0-9]{16}\b — 4 missing prefixes + {16,} vs {16}. Gate: shouldHardBlockBodyCriticalDLP (bodyscan.go:434–447; the re-scan runs the full detection floor, so a survivor keeps RedactedDLPOnly false — see :674–681), reached from all four surfaces.
Related design question (secondary): the concrete driver — my agent's own LLM channel (Anthropic) currently has to sit on passthrough_domains (your docs recommend passthrough for model APIs) because a detected-but-unredactable critical anywhere in the conversation fails closed and kills the agent — and the AWS gap above is exactly how I first hit this. That leaves my most sensitive channel entirely unscanned; best-effort redact-and-forward is what I'd want instead. More broadly, the only lever for redact-and-forward today is response_scanning.exempt_domains, which is per-host and all-or-nothing, and is documented for response injection scanning (schema.go:757). A per-credential-class → owning-host model would be stronger least-privilege — an AWS key forwards (redacted) only to *.amazonaws.com, a GitHub token only to github.com, blocked elsewhere; there's no legitimate reason for GitHub to receive an AWS key, or for a model endpoint to receive a database password. dlp.patterns[].exempt_domains expresses a coarse raw-forward version today, and DLPPattern.Action is reserved-but-unimplemented. Is wiring a per-pattern action the intended path, or is there an idiom I've missed? This would extend the narrowest-knob-first direction from #936.
Related work (a completed hardening arc this continues): #635 (redaction/config contract realignment + fail-closed matcher), #639 (preserve trusted-host traffic; curated this same aws-access-key class's SigV4 carve-out), #936 (surfaced the exempt_domains full-trust breadth).
What this is not: not a general "redaction is broken" claim — GitHub PATs are at full detect↔redact parity (gh[pousr]_…{36,} / github_pat_…{36,} on both sides, asserted in dlp_patterns_test.go) and redact-and-forward correctly. Scoped to classes where detection outruns redaction.
Offer: happy to open the PR — widen the AWS redaction class (or any other secret) to match detection, plus parity tests across forward/intercept/reverse/websocket in the existing bodyscan/classes style (per the "adding a DLP pattern touches these files" note in AGENTS.md).
Closing: pipelock is the backbone of my agent devcontainer's egress control, and I respect how much of this is already worked through — the deliberate detect/redact split, the fail-closed posture, the doctor trust-valve guidance. Is per-class redact-and-forward already on the roadmap (or expressible today in a way I've missed), or am I framing the trust model wrong? Glad to build in whatever direction you'd merge. Thanks!
Activity
luckyPipewrench commentedon Aug 22, 2026
@Sevelantis Thanks for the detailed report. I reproduced this on current main with the missing prefixes, an overlong AKIA value, lowercase, and whitespace-split variants. The detector is broader than the redactor, so trusted destinations can get blocked even with redaction enabled. It fails closed, so I don't see a leak here, but the availability bug is real.
One thing changes the fix: AWS currently documents AKIA and ASIA as access-key ID prefixes. AIDA, AROA, AIPA, AGPA, ANPA, and ANVA are IAM resource IDs, and I couldn't find current AWS support for A3T. Copying the detector regex into the redactor could rewrite legitimate resource IDs and still wouldn't safely handle normalized matches.
If you're still willing to take the PR, I'd be glad to have you do it. The PR should remove the resource-ID prefixes and A3T from the AWS access-key pattern while keeping longer and obfuscated AKIA/ASIA forms detection-only. Those forms should stay fail closed when Pipelock can't map them safely back to raw text.
Please also update the stale comments and add tests covering detection, redaction, the post-redaction scan, exempt and non-exempt hosts, and every request-body transport.
The per-pattern redact-and-forward action and a separate request-side trusted-destination setting are both worth doing, but I'd keep those in a follow-up so this PR stays reviewable.
Sevelantis commentedon Aug 29, 2026
@luckyPipewrench opened #1389 for the redact-class trim — thanks again for the correction; the AKIA/ASIA-vs-resource-ID distinction was the part I had backwards. It covers the trim, a fail-closed regression across all four request-body transports, and a scope note on A3T (which turns out to be detector-only).
When it lands, I'd like to pick up the follow-up you flagged as worth doing — the per-pattern redact-and-forward action plus a request-side trusted-destination setting — and bring you a design sketch as its own separate PR, so this one stays reviewable. On my side it's for least-privilege agent egress: a host only ever receiving the credential classes it owns, building on the trusted-destination direction you called out. No rush, and glad to shape it however you'd want it built.
luckyPipewrench commentedon Aug 29, 2026
@Sevelantis On A3T you're right and I was wrong. It only ever lived in the detection floor, so there was nothing to remove from the redact class, and pruning it from detection is a different change that narrows detection rather than redaction. Leave it out of this PR.
I don't want to prune it from detection on a guess either. AWS doesn't currently document it, but "undocumented" and "not in use anywhere" aren't the same thing, and dropping a detection prefix is the direction that loses coverage. Leaving it as a detection-only fail-closed catch is the right call for now. If we ever prune it, it should be its own change with a stated reason.
The follow-up you want to pick up is yours if you want it. The per-pattern redact-and-forward action and the request-side trusted-destination setting are related enough that I'd like to see them argued together before either gets built, since one of them decides whether the other is even the right shape.
Least-privilege egress, a host only receiving the credential classes it owns, is a good frame for it. Worth saying up front that anything which turns a block into a forward needs to be very sure about the direction it fails.
One thing left on #1389 and then I'll merge it: docs/guides/redaction.md still says the same access-key shape is redacted everywhere else, which now reads as if nothing narrowed. A paragraph in the false-positive hardening section naming the detection-only prefixes and what an operator reaches for when they hit the 403 on an exempt host.
fix(redact): narrow AWS access-key redaction to AKIA/ASIA prefixes (#…
Sevelantis commentedon Sep 2, 2026
@luckyPipewrench Some feedback from running pipelock as my agent's egress firewall, and a question.
The one thing I keep wishing pipelock gave me: a single map of secret egress. Every secret my agent sends, the host it reached, and the outcome — blocked, redacted, or forwarded raw. It would turn "did anything leak?" from a guess into something I can read at a glance. Blocks I can already see; redacted-vs-raw, per host — the line between contained and leaked — I can't.
And the traffic I most need on that map is the traffic pipelock doesn't seem able to see: my model endpoint runs on passthrough, unscanned, because redaction is all-or-nothing — one secret it can't mask blocks the whole request, so my most sensitive channel stays invisible to stay alive.
My read on what would unlock it is the redact-and-forward direction you flagged — and I've been keeping up with the route-scoped request-body work you've shipped since, the SigV4 credential routes and the entropy-warn routes.
RequestBodySigV4CredentialRoutein particular reads like the request-side trusted-destination half — an exact route authorizing one credential class to a destination, fail-closed elsewhere. The missing half is an action that forwards a redacted secret on that route instead of blocking — which is what lets a channel off passthrough and back into view.Is making secret egress legible like this worth pursuing, and is that route the right primitive — or does its SigV4 scope point elsewhere? I'm building the dashboard for my own setup anyway, and glad to hand it upstream if it's useful — the part I can't do alone is the pipelock side, which I'd take from a design sketch through to the PR, shaped however you'd take it. You know the design far better than I do, so I'd rather build your direction than guess at one.
luckyPipewrench commentedon Sep 2, 2026
@Sevelantis Yes, worth pursuing, and the map is the part I'd want first. The picture of what's silent today is different from both our reads though, so the order changes.
Two corrections first.
dlp.patterns[].exempt_domainsisn't a body raw-forward lever. It's wired into the URL and query scanner only; the body text scanner takes no hostname and never consults it. For bodies the levers today aresuppress, disabling a pattern, or a non-core warn action.And
RequestBodySigV4CredentialRouteisn't the request-side trusted-destination half. A matching route scrubs the credential out of the scan copy before DLP runs, so it never becomes a match and the body scans clean. The route authorizes the forward, which is the point, but nothing downstream records that it happened or which class went where. Generalizing that primitive is the one thing I don't want to do first, because more classes through it means more forwards nothing can account for.The default provider-key suppressions have the same shape. A provider credential going to its own provider's host is dropped before anything logs or classifies it, which is correct behavior and the reason those keys aren't constant false positives. It's still an unaccounted forward, and any map has to say so rather than skip it.
So a map built on today's evidence would be confidently incomplete, which on this question is worse than blank. The redaction receipts are better than I expected:
RedactionSummarycarries per-class counts, so you can already tell an AWS access key was redacted rather than seeing only that some secret was. But a bare install writes no receipts at all, and the authorized-forward paths above produce nothing to read. A map that renders clean while those stay unaccounted turns "I don't know" into "nothing leaked," and that's the one output I don't want it to produce.So: emission first, then the map, then the route action last. There's a config change I want to land ahead of all three, and I'll do that one myself since it moves a setting boundary the rest of this sits on.
The emission work is yours if you want it, and it's the bigger half of what you're after. Every path that authorizes a forward should produce a record naming the destination and the credential class, including the ones that authorize it by design. That's additive, it doesn't change whether anything blocks, and it's what makes the map honest instead of decorative.
On the trust model, your framing is close but it's two classes where there are three. Issuer-bound, where the credential's audience is the destination and raw is correct. Incidental payload, where the destination has no business with it and redact or block is correct. And delegated capability, a presigned URL you deliberately hand to a carrier that is neither the issuer nor the audience. A generic credential-class-to-owning-host rule refuses that third one, and it would show up as a false positive after shipping rather than during review.
Two things I want to keep on my side: the exact invariant for redact-and-forward, and anything touching the receipt schema. That record is signed and there are strict verifiers in three other languages that reject unknown fields, so map evidence has to live somewhere other than a new field on a signed v1 record. The rest of this is Apache core and free, including the route action when it comes, so nothing you build here lands behind a license.
I'll write up the evidence contract, the exact event and its fields, before you start, so you're not guessing at a shape I'd end up rejecting. Give me a few days roughly on that.
Sevelantis commentedon Sep 3, 2026
@luckyPipewrench Thank you for taking this on — it's the vision I was after.
I'll wait for your evidence contract before building anything, please flag me here whenever it's up and I'll pick it up asap.
luckyPipewrench commentedon Sep 3, 2026
@Sevelantis Here's the evidence contract I owe you before you build the map. I kept it to what the map has to be able to answer and what it can never get wrong. Where it says "must", that's the bar the PR has to clear. I'd rather we build this for every transport in one go and take our time than ship half and mark the rest "later".
What the map answers
For every secret-shaped value the DLP layer classified on an outbound request: what kind of destination it was going to, and what happened to it. Blocked, rewritten and sent, sent raw under a named authorization, sent raw because nothing could rewrite it, warned and sent, or suppressed under a named exemption.
It doesn't cover traffic that never reaches the proxy, encrypted passthrough traffic the proxy isn't set up to inspect, or response bodies. That's what the agent sends, not what it gets back.
Two separate questions: coverage and outcome
A request with nothing to classify produces no event. A request the evidence layer couldn't see also produces no event. Those two zero-event cases can't ever be reported the same way, so the map carries a coverage state per scope (a time window, a destination, a session, an agent) that's computed on its own, independent of whether any events exist:
completeunavailableout-of-scopeThe invariant: an absent event is never evidence of a clean request. "Was anything sent unprotected in this window" always comes back as
completeplus zero events, orunavailable, orout-of-scope. Never a bare empty list.The event
One event per enforcement or authorization decision about a secret-shaped value, emitted at the decision point. Not one per pattern match. A value that gets exempted or rewritten before it would ever register as a match still fires an event at the decision, because the decision is the thing we're recording.
Delivery is at-least-once with a stable
event_id, so a reader treats a duplicate id as one event.event_idtimestamptransportdestination_kindnetwork,local-process, and whatever else the real surfaces need. Not every surface has a network destination.destination_refnetworkit's the canonical host, never a raw header value recorded separately, or per-host rollups fragment.pattern_classcore-floor(baseline detection you can't turn off) orconfigured(an operator rule). They differ in what can suppress them.locationtransport.outcomeoutcome_authorityauthorized-raw-forward. Names the exact authorization, not a generic string.outcome_reasonraw-forward-unrewritable. Names the configured policy that let it through.suppression_idsuppressed. Names the exact exemption, and says whether it's operator-authored or built in.Outcomes:
blockedrewritten-and-forwardedauthorized-raw-forwardraw-forward-unrewritableblocked.warned-forwardsuppressedThere's no "unknown" outcome. If a decision path can't be classified into one of those six, that path doesn't ship evidence until it can.
Where events live
I'm not naming a sink or a CLI command here. Those are design choices for the PR. The properties aren't optional:
One durable append-and-confirm interface. Writing an event either succeeds durably or returns an error the caller can act on. No fire-and-forget on this path.
The append error reaches the forwarding decision. If a required event can't be confirmed, the request is blocked with a distinct, non-secret reason. It never goes through on the theory that some other log already caught it. A silent partial write is not a success.
Bounded and terminal. The append has a bound, fails with a classified error instead of hanging, and can't deadlock the hot path under sustained failure or backpressure.
A non-secret operator signal on failure, separate from the blocked request, so "evidence collection is degraded" and "policy denied this request" are told apart without either containing the value.
Defined recovery. When the sink comes back, the affected scope's coverage state gets corrected, and the gap stays visible. It never silently flips back to
complete.A read interface specified on its own terms. The command that renders the map is a proposal for the PR, and looking like an existing read-only command isn't evidence that command has the data access or the authorization model for this.
Scoping: coverage is tracked per session and per destination, and the read side can roll up per agent. Every agent behind one deployment is in the map by default.
Tests
Each of these pins one invariant, per transport:
destination_kindanddestination_refcorrectness, including the non-network case.pattern_classcorrectness for a baseline match versus a configured one.outcome_authority,outcome_reason, andsuppression_idname the exact matched thing.completewith zero events reads clean, degraded readsunavailable, uncovered readsout-of-scope, and none of the three can render as the same empty result.Before any of those count as passing, disable the thing each one claims to pin (pull the emitter call, disconnect the reader) and watch it fail under a deadline, then put it back. A test that stays green with its target off isn't evidence.
Not in this slice
The per-pattern rewrite-and-forward action, and a standing trusted-destination setting. The outcome vocabulary above leaves room for both, so nothing here has to change when they land, but they get argued on their own.
That's the contract. If any of it reads wrong from where you sit, just let me know.
Sevelantis commentedon Sep 5, 2026
@luckyPipewrench Thanks. I'll review and finish this up when I get a free moment.
Sevelantis commentedon Sep 9, 2026
@luckyPipewrench Following up on my holding note — I finished the census I owed before proposing anything to build: a decision-site map of where pipelock classifies a secret-shaped value on an outbound request and decides its fate, projected onto your six outcomes and the coverage trio. It's mine, it changes no block decision, and it lives off the signed record.
Two scope calls came out of it that I think are yours — I'd rather have your ruling than guess at a boundary you'd end up rejecting.
1. Is the
decide/agent-hook plane in this map, or a separate one?decide.Decide— shipped as the Claude Code and Cursor pre-execution hooks — classifies secret-shaped values and, onAllow, authorizes an agent-action forward (a shell target, a fetched URL, a tool destination). By your contract's own bar — an event per decision about a secret-shaped value, at the decision point — it qualifies. But it's a non-proxy surface, outside the transport fixture. One map, or two?2. Every proxy classify site before it's "done," or map the core and hand off the rest?
The classify surface is the
ScanTextForDLP(and URLScan) caller graph, not the handful of named transports — even setting aside the offline and response-side callers, it's bigger than the core I mapped, and each pass found another in-domain site. My lean: the map names the core, the boundary, and its coverage states, and your mechanical producer-to-emitter parity check owns exhaustiveness — a hand-enumerated list would just be a staler copy of what CI already guarantees. And "every transport in one go" reads to me as the emission-build bar, not the mapping bar. But completeness is your call.Why it matters — the one finding I'd surface now.
Only one mechanism force-blocks a core-critical secret regardless of the configured action: the
shouldHardBlockfloor. It covers the body, header, and WebSocket sites, and only while enforcement is on. Every other classify site — MCP tool arguments, a2a, the reverse path+query scan, cross-request reassembly — has no severity floor. (A couple force-block a hostname-exfil match, but nothing there blocks on severity.) So a core credential outside those narrow shapes — an AWS access-ID, this issue's own class — just follows the configured action: under awarnposture, warned-and-forwarded with no per-class record that it happened. Operators have reason to expect otherwise — on the body the floor blocks core regardless of action, so "core is always blocked" is the natural read, and it isn't true off the body. That's the surface that would render clean while leaking, and it's systemic: one missing floor, not a scattered set of transport bugs.What this map honestly is not.
It's a static source census — I haven't run a test against it, so by your own bar — "a test that stays green with its target off isn't evidence" — every "emitter fires here" claim in it is a hypothesis until the tests exist. And detection is shape-gated: a
completestate asserts "no recognized-format secret was seen leaving," never "nothing leaked." The map says that out loud, next to the number.Happy to expand any of it — the producer-side gap where the naming fields have no source at the decision point yet, the full list of the by-design-silent forwards, or where I'd take the sink once you've drawn the boundary. I kept this short on purpose.
Two things I'd especially want your eye on: the scope calls above, and whether I've mis-mapped an outcome — I've called the
exempt_domainsURL skipsuppressed, but you may read a destination-keyed skip as an authorization. Correct me on either; I'd rather fix the map than build wrong.luckyPipewrench commentedon Sep 10, 2026
@Sevelantis Correcting myself. Your finding names a real gap, but it isn't the whole enforcement picture. The difference comes down to configuration, not transport. I had that wrong when I read it.
You're right about exhaustiveness. A hand-maintained list rots the first time someone adds a classify site. I can't ask you for that check yet though. Nothing in the code defines what a classification site is, so a parity check has nothing to compare against. That definition is mine to write first.
Same problem with the
exempt_domainslabel, and your question turned up something bigger than that one row. The outcome names don't let an implementer say what happened to the request separately from what happened to the evidence behind it. Ruling on one label now gives you something to redo later.So don't build against the contract until I post a revised one. I'll bring a real target with it. You've waited on me twice already.
Sorry for the churn. Your census found something real and the parity requirement is the right direction.
luckyPipewrench commentedon Sep 30, 2026
@Sevelantis The revised contract is merged in #1737 as a spec,
docs/specs/secret-egress-evidence-v1.md, with typed structs and fixtures behind it ininternal/egressevidence.The six outcome names are gone. Each record now answers three things separately: which policy handled the finding, which bytes left, and whether the record had to be saved before the request went out. Your question about the destination-keyed skip is what broke the old names, and it fits now as an exemption with a named reference.
I had durability wrong in the September 3 contract. A record that can't be saved blocks the request only when the operator turned on
flight_recorder.require_receipts. With it off the request goes through, and that stretch of evidence reports as unavailable instead of clean. The spec still marks this section pending because one path, the credential-audience allow record, doesn't follow the rule yet. I'm fixing that now and the pending note comes out with it.The agent-hook plane stays out of this map. Hook sites get declared so coverage shows them as excluded instead of silently missing.
#1737 defines what a classification site is, an ID plus a registry, but doesn't register the real ones yet. That's the piece your parity check needs to compare against. I'll ping you here when it's there.
Sevelantis commentedon Oct 11, 2026
@luckyPipewrench Thanks! I'll look into this around October/November and reach out if I have any questions.
Btw, the issue title looks stale - how about an update?
[-]Redaction fails closed on trusted (exempt_domains) hosts for detected-but-unredactable AWS Access IDs[/-][+]Secret-egress evidence and coverage map[/+]luckyPipewrench commentedon Oct 11, 2026
Updated the title. Thanks, just flag any questions here when you get into it.