Skip to content

Topic/review findings - #3

Merged
wistefan merged 41 commits into
mainfrom
topic/review-findings
Aug 28, 2026
Merged

Topic/review findings#3
wistefan merged 41 commits into
mainfrom
topic/review-findings

Conversation

@wistefan

Copy link
Copy Markdown
Collaborator

No description provided.

wistefan and others added 30 commits August 27, 2026 10:32
…y (C-3)

The package-wide credential cache held both the participant access token and
the provider self-description derived from it, but was keyed only on
`baseURL + "|" + tokenAudience` — and TokenAudience defaults to the constant
"consent-manager" for every route.

Two routes fronting different provider tenants against the same
consent-manager therefore collapsed onto one cache entry. Whichever route
warmed it first installed its token and its selfDescriptionURL; the other then
ran its identifier search scoped to the wrong provider and its consents lookup
as the wrong participant. Decisions were silently wrong in both directions:
denials for subjects who had consented, and allows against another provider's
consent records.

cacheKey() now covers every input that can change the cached token or SD:
base URL, Host override, API prefix, token audience, token-service URL,
provider SD, and the static token and consent key (hashed with SHA-256, so the
key never retains a secret verbatim). Components are joined with a NUL
separator, which cannot occur in a URL or audience name, so no two distinct
identities can collide on the joined string.

Tests: a table-driven case asserting a distinct key for each differing field,
and an end-to-end regression test with two token services against one
consent-manager asserting each participant presents its own token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…olver (C-1)

When `owner_resolver_url` was unset — the documented default — the plugin took
the consent subject from the access token's `sub` claim. That answers "has the
CALLER granted some consent?", which establishes no link whatsoever between the
caller and the data the upstream is about to return.

Concretely: Alice holds one granted consent and a valid token. She requests
/ngsi-ld/v1/entities/urn:ngsi-ld:PersonalProfile:bob. The plugin resolved
*Alice's* user identifier, found *Alice's* granted consent, and returned
*Bob's* personal data. Any subject with a single granted consent was a
universal reader, so the gate was a no-op against the threat it exists for.
The JWT signature is also not verified by this plugin, so on a route without an
auth plugin the `sub` was attacker-supplied and the check collapsed entirely.

The owner-resolver path already answers the right question — it derives
ownership from the response DATA and checks consent per resolved owner — so the
fix is to finish that migration rather than patch the unsound path:

- `owner_resolver_url` is now required; `Validate()` rejects a config without
  one, so a route that cannot determine ownership fails to load instead of
  silently gating nothing.
- `buildConsentRequest`, the legacy `checkConsent` helper and the
  `jwtSubjectClaim` constant are deleted; `evaluate()` always goes through the
  resolver.
- `jwt_claims_to_forward` is documented for what it now does (naming the
  consumer for the contract lookup), not as the source of the data subject.
- `RequestFilter` documents that the JWT is decoded, never verified, and that
  an authenticating plugin in front of this one is a hard route prerequisite.

BREAKING CHANGE: routes without `owner_resolver_url` no longer load.

Tests: the plugin and integration suites are ported to resolver mode, and
TestResponseFilter_OwnerNotRequestor / TestIntegration_OwnerNotRequestor pin the
property directly — the resolver names Bob as the owner while the token's `sub`
is Alice, and the identifier search must ask about Bob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d purpose (C-2)

hasGrantedConsent filtered the consent list on `status` alone (plus, optionally,
`data[].resource`). It never checked WHO the consent was granted to, even though
the plugin had already resolved the consumer's self-description URL for the
resolver call and was holding it.

So: Bob grants consent to participant X for the purpose "insurance quote".
Participant Y — a different consumer, with no consent from Bob — requests Bob's
data through the gateway. The plugin saw Bob's granted consent to X and let Y
through. Under GDPR terms it authorised a processing purpose the subject never
agreed to, on the evidence of an entirely different agreement.

A consent is now matched only when all of these hold:
  - status is "granted";
  - it was granted to the consuming participant named in the request;
  - it covers the purpose, when the resolver could name one;
  - it covers the data resource, when the check is resource-scoped.

Supporting changes:
- `ConsentRequest` gains `Consumer` (required) and `Purpose` (optional).
  `CheckConsent` denies outright when no consumer is identified, rather than
  falling back to "does this subject have any consent at all?".
- The receipt is decoded into a `consentRecord` that reads the consumer from
  either `consumer` or `dataConsumer`, in either the bare-string or the embedded
  object shape, matching on selfDescriptionURL / _id / did. A record that names
  no consumer matches nothing — the plugin cannot tell whose agreement it is,
  and guessing is what the finding is about.
- `ownerresolver.Claim` gains `purpose`, so the resolver can name the contract
  purpose governing a claim; absent, only the consumer match applies.
- Deny reasons now say which scope failed, so the audit log distinguishes
  "consented to someone else" from "did not consent to this resource".

Tests: consumer-scoping cases in the client decision matrix (granted to another
consumer, consent naming no consumer, request without a consumer) and a
plugin-level regression test proving the wiring end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (H-1)

Both party lookups in evaluateWithResolver logged their error and carried on:

    if consumerSD, sdErr := ...; sdErr != nil { log.Printf(...) } else { ... }
    if providerSD, sdErr := ...; sdErr != nil { log.Printf(...) } else { ... }

Both failing left `resolveParties` empty, `Parties.IsZero()` then omitted the
field from the /resolve request entirely, and the plugin went on to trust
whatever came back — including `consentRequired: false`, which is an
unconditional allow. A fail-closed design with a fail-open seam in the middle
of it, reachable by nothing more dramatic than a briefly unreachable
consent-manager or a revoked participant token.

Both lookups now run before the resolver is asked anything, and either failing
is terminal — the fail policy applies instead of an unidentified-party
/resolve result reaching the allow branch. A token that names no consuming
participant at all is treated the same way. No degraded mode is offered: with
the consumer now scoping the consent match itself (C-2), a check without one
could not be sound anyway.

`ParticipantSelfDescriptionByDID` also gains what only `CheckConsent` had:

- a 401 refresh-and-retry, so a cached token that has since been revoked does
  not make the party mapping — and with it the whole exchange — fail
  terminally;
- negative caching of "no such participant" (30s), so one misconfigured DID no
  longer re-fetches the entire participant list on every request;
- a cache key that includes the full credential identity rather than just the
  base URL, matching the C-3 fix.

Tests: the 401 retry and the negative caching in the consent package, and two
plugin cases asserting an unresolvable consumer and a token with no consumer
claim both deny without the resolver being contacted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uration (H-2)

`fail_open` defaulted to true, so every unresolved situation — consent-manager
down, resolver erroring, missing request context, missing credentials,
unreadable body — became ALLOW unless the operator explicitly opted out. For an
availability filter that default is defensible; for a consent gate on personal
data it inverts the safe default, and it is reached by omission.

Two changes:

1. `IsFailOpen()` now returns false when unset. `fail_open: true` remains
   available as a deliberate opt-out, and `ParseConfig` logs a warning when it
   is enabled so the choice is visible in the runner's output.

2. Not every unresolved situation is an outage, so `failOutcome` now takes a
   `failMode`. `failByPolicy` (resolver/consent-manager/body-read failures) is
   what `fail_open` governs. `failAlwaysClosed` denies regardless: no
   correlation id, no request context, no participant credentials at all, and
   "consent required but no owner resolved". None of those are transient
   conditions to ride out — failing them open turns a lost request or a typo in
   the route config into a silent, total bypass of the gate, which is exactly
   how this finding composes with the documentation drift.

   Missing credentials are recognised through a new `consent.ErrNoCredentials`
   sentinel rather than by string matching.

BREAKING CHANGE: routes that relied on the implicit fail-open default now deny
on a dependency failure. Set `"fail_open": true` explicitly to keep the old
behaviour.

Tests: each fail-open case is split into a default (deny) and an explicit
opt-in (pass through) case, plus two cases proving that a missing request
context and missing participant credentials deny even with fail_open enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r tokens (H-3)

The store bridging the request and response phases was a package-level sync.Map
written in RequestFilter and deleted only by the response phase. It had no TTL,
no size cap, no eviction sweep and no gauge.

Any request whose response phase never runs leaked one entry permanently: the
client disconnects before the upstream answers, the upstream connect times out,
an earlier APISIX plugin short-circuits after pre-req, ext-plugin-post-resp is
missing from one of several routes, the runner restarts between phases. The
runner is a long-lived process, so the map grew monotonically until OOM — and it
was remotely drivable: open connections, send the request, abort before the
response, and you have an unauthenticated memory-exhaustion primitive.

Each leaked entry also held `RequestContext.Headers`, a copy of every request
header including `Authorization: Bearer <jwt>`. Nothing ever read it — the only
consumer was `len(rc.Headers)` in String() — so the leak was a leak of
credentials retained indefinitely, for no benefit at all.

- `RequestContext.Headers` and its capture loop are gone. Only method, path and
  the decoded claims are kept, which is all the response phase uses.
- Entries carry the time they were stored. A background janitor sweeps entries
  older than RequestContextTTL (60s) every 10s, and a load that finds an expired
  entry reports it as absent, so a stale context can never decide a fresh
  request.
- MaxRequestContexts (100k) caps the store. On overflow it sweeps expired
  entries first and, if everything is live, evicts the oldest — a leak from an
  older request never refuses service to a new one.
- `RequestContextStoreSize()` and `RequestContextsEvicted()` expose the leak:
  the size tracks in-flight gated requests and returns to zero when idle, and a
  climbing eviction count means requests are being lost or the store driven
  deliberately.
- The unused exported `LoadRequestContext` / `DeleteRequestContext` are removed
  (L-1); tests assert cleanup through the size gauge instead.

Tests cover expiry-on-load, the janitor sweep leaving live entries alone, cap
enforcement evicting the oldest, and a guard that RequestContext holds no
headers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ft in CI (C-4)

The README documented a configuration surface that had been removed from the
code months earlier:

  documented                          reality
  client_id / client_secret           removed from Config
  CONSENT_CLIENT_ID/_SECRET           env vars are CONSENT_KEY,
                                      CONSENT_TOKEN_SERVICE_URL,
                                      CONSENT_AUDIT_OTLP_ENDPOINT
  POST /participants/login            fetchToken posts to token_service_url
  —                                   token_service_url, token_audience,
                                      owner_resolver_url, owner_resolver_timeout,
                                      service, consumer_claim, consent_api_host
                                      all undocumented

Because `json.Unmarshal` silently discards unknown fields, an operator copying
the README's curl example got a config that parsed and validated cleanly, then
could not authenticate as the participant at all. Under the previously
documented `fail_open: true` default that meant every request was allowed, the
gate entirely bypassed, with nothing to signal it but one log line per request.
A documentation defect here is a security defect.

- The README's configuration table is rewritten field by field against
  config.go, the route example is a working `jq`/`curl` invocation carrying the
  same conf to both phases, and the two-call section describes the actual
  endpoints, the consumer/purpose scoping and the token-service flow.
- New sections state what was implicit and load-bearing: ownership comes from
  the data and never from the requestor, the JWT is decoded but not verified so
  an auth plugin in front is a hard prerequisite, and ext-plugin-post-resp
  buffers the whole upstream response.
- CLAUDE.md is refreshed: it described an `internal/filter` package that does
  not exist, omitted `internal/audit` and `internal/ownerresolver`, and still
  described the removed client-credentials login.
- `TestREADMEDocumentsEveryConfigField` parses the json tags off the Config AST
  and the README's configuration table and fails if either side has a field the
  other lacks. Drift on a security control needs a machine, not discipline —
  and it runs in `make test`, so CI already enforces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt registry (H-1/H-2)

Follow-up to the fail-mode classification. A consumer DID that the participant
registry does not know was mapped to failByPolicy, so with `fail_open: true` it
released the data. But "not registered" is not an outage: it is a permanent
condition, and retrying will not change it. Failing it open does not ride out a
blip — it grants that consumer standing, indefinite access to personal data
through a gate that is nominally enabled.

`ParticipantSelfDescriptionByDID` now wraps a new
`consent.ErrParticipantNotRegistered` sentinel, and `failModeForError` treats it
like `ErrNoCredentials`: always closed, regardless of `fail_open`. An
unreachable or erroring registry stays under the operator's policy, which is the
genuine availability case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The resolver path — the sound one, the one every other critical finding lives
in — had 0% coverage. `evaluateWithResolver`, `consumerFromClaims`,
`resourceOrPath`, `ParticipantSelfDescriptionByDID`, `decodeParticipants` and
`ProviderSelfDescription` were all measured at 0.0%, while the legacy mode that
has now been deleted was the one with eleven end-to-end cases behind it.

Coverage was also mis-measured. `make test-cover` and tests.yml both omitted
`-coverpkg=./...`, so the integration package's coverage of internal/plugin was
discarded outright — which understated the real number and, worse, made the
genuine 0% functions look like measurement noise. And the profile was uploaded
as an artifact and never asserted, so nothing would have noticed the gap.

- `internal/integration` gains a resolver-mode harness: a mock `/resolve` and a
  per-owner consent-manager, driving multi-owner deny_all, an owner unknown to
  the consent-manager, `consentRequired: false`, empty claims, a claim with an
  empty ownerId, resolver 5xx under both fail policies, and owner dedup.
- Two further integration tests pin what the plugin actually tells the resolver
  (resource descriptor, mapped contract parties, JSON payload) and the H-1
  fail-closed seam (an unidentified consumer never reaches the resolver and
  denies even with fail_open).
- `consent.ResetCaches()` gives tests a reset hook for the package-wide token
  and participant-SD caches. They passed only because httptest happens to
  allocate a fresh base URL per server; a reused URL or `t.Parallel()` would
  have produced order-dependent flakes.
- Unit tests for `claimKeysToDecode` and `decodeParticipants` (both registry
  shapes), and `TestConfig_IsFailOpen` moves to config_test.go where the rest of
  the config tests live.
- `-coverpkg=./...` in `make test-cover` and tests.yml, plus
  `hack/coverage-floor.sh` and a `coverage-floor` target enforcing an 80% floor
  in both CI pipelines.

Total coverage: 69.1% -> 85.7%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loop over the resolver's claims ran one full two-call consent check per
distinct (owner, dataResource), sequentially, each on context.Background() with
its own per-call timeout.

A collection endpoint returning 200 entities with 200 distinct owners therefore
issued ~400 sequential HTTP calls; at the default 5s per-call timeout the worst
case was ~2000s of held-open response while APISIX buffered the body. The client
and APISIX give up long before that, but the runner's goroutine keeps working
and its connections stay open, so a handful of such requests saturate both the
runner and the consent-manager. Any caller who can reach a list endpoint could
trigger it.

Four changes, one per limb of the problem:

(a) One `context.WithTimeout` for the whole response phase, derived once and
    passed to the party lookups, the resolve call and every consent check. The
    total time APISIX holds the response is now bounded regardless of owner
    count, and a cancelled phase propagates instead of leaving work running.
    New `response_phase_timeout` (default 10000ms, range 1-120000).

(b) `max_owners_per_response` (default 50, range 1-1000) caps the distinct
    owners checked. Above it the response is denied — a deliberate limit, so it
    denies even under `fail_open`.

(c) The per-owner checks run concurrently, at most 8 in flight, and cancel the
    rest on the first denial or error, since nothing the others could return
    would change a deny_all verdict. The reported outcome is always the
    lowest-indexed problem, so the decision and its audit record do not depend
    on which goroutine won the race.

(d) The subject -> userIdentifier mapping is memoised for 60s, so one owner
    appearing under several data resources is resolved once rather than once per
    resource. Only positive results are cached: a subject can register at any
    moment, and remembering "unknown" would keep denying them after they had
    consented.

Claim dedup moves into `distinctClaims`, which also turns a claim with no owner
into an error rather than an inline early return.

Tests: the owner cap denying even with fail_open, the phase deadline bounding a
consent-manager that never answers, `distinctClaims` as a table, and the
identifier memo (reused for a second resource, never reused for an unknown
subject).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
denyResponse set Content-Type and the status, wrote the deny body, and touched
nothing else — so every other upstream response header survived into the 403.

That is a side channel straight around the gate. A caller who was just refused
the data still learned that the entity exists and which version it is
(ETag / Last-Modified), how many records matched (X-Total-Count,
NGSILD-Results-Count), that more pages follow (Link), and was handed whatever
Set-Cookie the upstream issued. Content-Encoding was worse than a leak: an
upstream that answered `gzip` left the client trying to inflate a plain-JSON
deny body, and Content-Length still advertised the upstream body's size.

The denial now removes every upstream header except the `Access-Control-*`
family — CORS headers describe the exchange rather than the resource, and
dropping them would show a browser client a CORS error instead of the 403 it was
actually given — then sets Content-Type and a Content-Length computed from the
deny body itself.

The test mocks also stopped diverging from the runner in ways that hid this:
`mockHeader.View()` now returns the live map as the runner's does (so a caller
iterating and deleting behaves as in production), and `mockResponse.Write`
appends into a buffer rather than replacing, so a double-write regression would
now be caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ss the API (M-2)

`Validate()` checked URLs, one timeout and the status-code range, but never that
the route had any way to authenticate as the participant. The README even
documented this as intentional ("None are enforced at parse time").

So a route with neither `token_service_url` nor `participant_token` loaded
cleanly and only failed per request, on the data path, as one log line saying
"no participant_token and no token_service_url configured". Combined with the
old fail-open default that was a route which loaded successfully and allowed
everything. A config that cannot complete a single check is not a config worth
loading — it is now rejected at parse time.

Two more unchecked fields in the same function:

- `owner_resolver_timeout` was defaulted but never range-checked, unlike
  `consent_api_timeout`. Now bounded to 1–60000ms.
- `consent_api_prefix` was concatenated into every endpoint URL unchecked, so a
  prefix without a leading `/` silently produced a malformed URL and every call
  failed as a confusing 404. It must now start with `/`, and a trailing `/` is
  trimmed in applyDefaults so it cannot produce a double slash either.

The required-field checks also move ahead of the numeric range checks, so a
config missing `owner_resolver_url` reports that rather than complaining about a
timeout it never got the chance to default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rt (M-3)

The audit trail could not answer the question an access-decision log exists to
answer, and lost records on every redeploy.

- **It named the wrong owners.** In resolver mode only the *first denying* owner
  was recorded, and an allow recorded no owner at all — so the log said whether
  a response was released but not whose consent was consulted or what each said.
  `responseOutcome` now carries a `checked` entry per consulted owner and
  `recordAudit` emits one record for each. A request that failed before any
  owner was reached is still recorded once, as itself.

- **The emitter cache ignored half its configuration.** `Get` keyed on
  `endpoint|serviceName`, so the first route to create an emitter silently
  imposed its timeout on every other route sharing that Collector. The key is
  now the full `Config` (endpoint, service name, timeout, headers — header order
  normalised).

- **Nothing was flushed at shutdown.** `Shutdown` existed and was tested but
  `main()` never called it, so each restart discarded up to one flush interval
  (2s) of decisions. `audit.ShutdownAll()` is now wired to SIGTERM/SIGINT.

- **Upstream error bodies reached the sink.** Reasons are built by wrapping
  dependency errors, and those embed the consent-manager's response body, which
  can carry identifiers. `SanitizeReason` (applied inside `Emit`, so no call
  site can bypass it) collapses control characters and bounds the length.

- **Dropped records were invisible.** The bounded queue drops rather than
  blocking the request path — correct — but that means an attacker who can
  generate load can suppress the record of their own access. `Emitter.Dropped()`
  and package-level `Dropped()` expose the count instead of only logging every
  hundredth drop.

- **No way to authenticate to the Collector.** New optional
  `audit_otlp_headers` config carries extra headers on every export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`consumerFromClaims` walked a dotted path through `map[string]interface{}` only.
The default path is `verifiableCredential.issuer`, but a Verifiable Presentation
routinely carries `verifiableCredential` as a JSON **array** — so on an ordinary
VP token the type assertion failed, "" came back, and the function logged
nothing. The consumer was simply absent, which now (post H-1) denies every such
request with no indication of why, and previously fed the unidentified-party
allow seam.

- A bare segment landing on an array traverses its first element, so the default
  path works on the common shape without any configuration.
- Explicit indexing is supported — `verifiableCredential[0].issuer`, including
  repeated indices for nested arrays (`a[0][0].b`).
- The function returns an error rather than "", naming the segment that failed
  and distinguishing "no path configured" (`errClaimPathUnset`) from "the
  configured path did not resolve". A mistyped path is now diagnosable from the
  log line instead of looking like a consumer that simply is not there.
- `claimKeysToDecode` uses a shared `claimPathRoot` so an indexed first segment
  still resolves to the top-level claim the request phase must decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Roughly twenty `log.Printf` calls with a hand-written "[consent-filter]" prefix
were spread across plugin/ and audit/. Three consequences, all on the request
path:

- The runner ships its own zap logger and configures its level from the runner's
  environment, so these lines bypassed it entirely — an operator could not raise
  or lower the plugin's verbosity, or suppress it, at all.
- Messages carried subject and participant DIDs and upstream error bodies:
  personal data on stdout, with no retention policy, which is exactly what the
  OTLP audit path was built to avoid.
- Nothing was rate-limited, so a broken consent-manager produced one line per
  request.

A new `internal/logging` package addresses all three and every call site is
converted:

- `Debugf`/`Infof`/`Warnf`/`Errorf` delegate to the runner's logger, so the
  runner's level configuration applies and each line has an appropriate level
  instead of everything being an undifferentiated print.
- `Redact` turns an identifier into a stable 8-hex-character fingerprint —
  enough to correlate lines about the same subject while debugging, not enough
  to be a handle on the subject. The consent client's "no participant
  registered" error now carries a fingerprint rather than the DID, since that
  error is both logged and used as an audit reason.
- `Sanitize` collapses control characters and bounds length, so an HTML or JSON
  error page from a dependency cannot be splatted across the log. The audit
  package's `SanitizeReason` now delegates to it rather than duplicating it.
- `WarnfEvery`/`ErrorfEvery` collapse a repeated failure to one line per 10s per
  key and report how many occurrences were suppressed, so the rate limiting is
  never silent about itself. The key set is bounded so it cannot grow into a
  leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documented dev workflow could not start:

- `docker-compose.yaml` mounted `./apisix-config.yaml`, which was not in the
  repository. Docker creates a *directory* at a missing bind-mount path, so
  APISIX started with a directory where its config should be and failed to
  parse it.
- Both services bind-mounted `/tmp/runner.sock` — a socket file that does not
  exist at compose time, so Docker created a directory there too and the runner
  could not bind. The socket has to live inside a shared *directory*; a named
  `runner-socket` volume mounted at `/opt/runner` now provides one.
- `version: "3.8"` is obsolete under Compose v2.

Since README advertised `docker compose up --build` as the dev workflow, this
was the first thing a new contributor hit.

The stack is now self-contained and exercisable rather than merely startable:

- `dev/apisix-config.yaml` wires `ext-plugin.path_for_test` at the shared socket
  and enables both external-plugin phases.
- `mock` (WireMock, stubs in `dev/mocks/`) stands in for the consent-manager,
  the OwnerResolver and the participant token service — including the consumer
  scoping, so editing `consents.json` flips the gate's answer.
- `upstream` is an echo service so the resolver has a real payload to be asked
  about, and `otel-collector` receives the access-decision audit log with the
  routing that `service.name=consent-access-audit` exists for.
- Credentials reach the runner through env vars, mirroring how the Kubernetes
  deployment sources them from a Secret.

The README section now gives the route-creation and request commands end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`security-analysis.yml` ran both `govulncheck` and `gosec` with
`continue-on-error: true`, so their findings were informational only and a
known-vulnerable dependency merged cleanly. A security gate that cannot fail is
a dashboard, not a gate — both now block the pipeline, and the SARIF upload
still populates the Security tab either way.

Versions were also unpinned. `gosec` was installed from `@latest` and
`golangci-lint-action` used `version: latest`, which made CI non-reproducible (a
scanner release could redden an untouched PR) and put an unpinned binary inside
the build's trust boundary. Worse, the Gitea pipeline pinned golangci-lint to
v2.1.6 while GitHub used `latest`, so the two CIs could disagree about whether
the same commit lints.

- `gosec` is pinned via a `GOSEC_VERSION` env var, and runs without `-no-fail`;
  a reviewed finding is suppressed at the call site with a `#nosec` comment
  carrying its reason, so the exception shows up in the diff.
- golangci-lint is pinned to v2.13.1 in both `.github/workflows/style-guide.yml`
  and `.gitea/workflows/ci.yaml`, with a comment on each pointing at the other.
- `go mod verify` runs before the vulnerability scan, checking the module cache
  against go.sum rather than trusting whatever was downloaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`internal/consent/models.go` still described a `POST /check` API returning
allow/deny/**filter** with a list of denied fields. That design was replaced by
the coarse two-call gate; the client never calls such an endpoint and the plugin
does no field-level filtering. The types were referenced only by their own
tests, which flattered the coverage number, and their `json:` tags actively
misled anyone reading the file for the wire format.

Removed: `DecisionFilter`, `validDecisions`, `Decision.IsValid`,
`ConsentResponse.Validate`, `ConsentResponse.DeniedFields`,
`ConsentRequest.ResponseFields` and `ConsentRequest.Claims` (the last of which
also meant the whole decoded token was being carried into a request body that is
never sent anywhere).

`ownerresolver.Claim.Selector` / `.Participant` and `Result.Scheme` were decoded
and never read; they are gone too, with a comment saying the reply carries more
than this and only what the plugin acts on is decoded — an unread field should
not suggest the plugin considers something it does not.

`ConsentRequest.Subject` is now documented for what it is: the data owner from
the resolver, never the requestor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`internal/plugin`'s package doc (duplicated across config.go and consent.go)
still said the plugin "applies consent-based filtering for personal data", and
RequestFilter's doc still claimed it "captures all request headers" — which it
stopped doing when the header capture was removed. `internal/consent`'s package
doc described deciding whether data is "allowed, denied, or filtered".

None of that is true: the gate is coarse allow/deny, there is no field-level
filtering, and no headers are retained. The plugin package doc now says so and
says why the coarse choice is deliberate — a filter that removes fields silently
misses the one it does not know about, while a coarse gate still covers an empty
or non-JSON personal-data response. The consent package doc was corrected when
the dead filtering model was deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runtime image ran as **root** with no `USER`, on `alpine:3.19` (past
end-of-support, so no security patches), with no `HEALTHCHECK` and no
`.dockerignore` — meaning `COPY . .` pulled in `.git`, changing the build
context digest on every commit and invalidating the cache even when no source
file had changed.

- A fixed non-root uid (10001) runs the binary. The runner binds a unix socket
  in a directory it is given and makes outbound HTTP calls; root only widens
  what a compromise of it reaches. The uid is fixed so a shared socket volume
  can be given predictable ownership — the compose stack hands the volume over
  with a one-shot `socket-init` service, since a named volume is created
  root-owned.
- Base bumped to `alpine:3.22`.
- `HEALTHCHECK` probes the listener: the runner speaks the ext-plugin protocol
  rather than HTTP, so a bound socket is the only meaningful signal that it is
  accepting RPCs. APISIX now waits for it via `service_healthy`.
- `.dockerignore` excludes `.git`, CI config, docs and build artifacts.
- `CGO_ENABLED=0` for a static binary, and `go mod verify` in the build stage so
  the dependencies are checked against go.sum before anything is compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The direct dependencies had drifted years behind with nothing configured to
notice:

- `github.com/stretchr/testify` 1.8.4 -> 1.12.1
- `github.com/api7/ext-plugin-proto` v0.6.0 -> v0.6.1

`go mod tidy` also drops `github.com/davecgh/go-spew` and
`github.com/pmezard/go-difflib` from the graph and moves yaml to
`go.yaml.in/yaml/v3`. Tests pass under `-race` and the linter is clean on the
new versions.

`.github/dependabot.yml` turns future drift into a pull request rather than a
review finding, covering Go modules, GitHub Actions and Docker base images. The
go-plugin-runner is deliberately excluded from the batch group: it is pinned at
v0.5.0 and drags an old transitive tree (zap 1.17, flatbuffers 2.0.0), so
updating it is a decision to take deliberately — and a plugin whose runner is
unmaintained is a strategic risk worth being reminded of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`evaluateWithResolver` called `w.Header()` to read the upstream's Content-Type
for the resolve request. The runner materialises its header map on that first
call and `HasChange()` then returns true — so merely *looking* at a header sent
every gated response back to APISIX down the "this response was modified" path,
carrying an empty header diff. Probably benign, entirely untested, and needless.

The Content-Type now comes from the Nginx `$upstream_http_content_type`
variable, which has no such side effect. The header is consulted only when the
variable is unavailable, as a degraded fallback rather than the norm.

The response mock grows a `headerReads` counter so this is observable, and
`TestResponseFilter_AllowDoesNotTouchHeaders` asserts an allowed response never
materialises the header map. Both content-type sources are covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A component that can deny production traffic had no counters at all. Nothing
said how many requests were allowed, denied or failed open, how long the
consent-manager was taking, how large the request-context store had grown, or
how many audit records had been dropped. Logs were the only signal, and they are
unstructured and rate-limited — a misconfigured route denying everything looked
exactly like a quiet one.

`internal/metrics` exposes, in the Prometheus text format:

- `consent_decisions_total{decision,fail_mode}` — crucially labelled by fail
  mode, so "denied because there is no consent" and "denied because the
  consent-manager was down" are separable. They mean opposite things, and one of
  them is a page.
- `consent_dependency_calls_total{dependency,outcome}` and
  `consent_dependency_duration_seconds{dependency}` — a histogram whose buckets
  straddle the default 5s per-call timeout, so a dependency drifting toward it
  is visible before it starts failing.
- `consent_request_context_store_size` and
  `consent_request_contexts_evicted_total` — the H-3 leak, made observable: the
  size returns to zero when idle, and a rising floor is the leak.
- `consent_audit_events_dropped_total` — an attacker who can generate load can
  suppress the record of their own access, so the loss must be alertable.

The exporter is hand-rolled rather than pulling in a Prometheus client, for the
same reason the OTLP encoder is: this is a sidecar-adjacent plugin whose
dependency tree is part of its risk surface. Output is ordered deterministically
so a scrape diff reflects real change.

`main.go` serves `/metrics` only when `CONSENT_METRICS_ADDRESS` is set — the
runner is otherwise reached only over its unix socket, so opening a TCP port is
the deployment's decision — and a failure there logs rather than taking the gate
down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`time.Duration(cfg.ParticipantTokenTTL) * time.Second` was computed from a field
nothing validated. A large enough value overflows into a NEGATIVE duration, and
the cached token's expiry then lands in the past — so every request refetches a
token, hammering the token service, from a config that looked merely eccentric.

`participant_token_ttl` now defaults to `DefaultParticipantTokenTTL` (3000s,
matching the consent client's own default, and matching what the README already
claimed) and is bounded to 1–86400. A day is far longer than any token this
plugin is issued, and comfortably below the overflow range, so the conversion at
the call site is safe by construction — with a comment there saying why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A repository that gates access to personal data had no private path for
reporting a vulnerability and no required reviewers. Someone finding a way to
make the gate release data had nowhere to send it but a public issue.

- `SECURITY.md` points at GitHub private vulnerability reporting, says what to
  include (for a gate, "the response was released" is the key fact), and draws
  the scope line explicitly — a response released without consent, a consent for
  one consumer authorising another, ownership taken from the requestor, side
  channels around a denial, and audit suppression are all in scope; the absence
  of JWT signature verification is not, since it is a documented deployment
  requirement rather than a defect.
- `.github/CODEOWNERS` requires review everywhere, calling out the decision path,
  the audit trail and the CI/release configuration by name.
- CONTRIBUTING.md points at the security policy before it talks about PRs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`go.mod` requires Go 1.26, and on a machine with an older system Go where
`GOTOOLCHAIN` cannot download, the build fails with `toolchain not available` —
which reads like "Go 1.26 does not exist" rather than "the download was
blocked". The bare major version is what fails to resolve; a full patch version
in the module cache works.

CONTRIBUTING.md now says so, with the commands to find a cached toolchain and
pin it, and points at where CI pins golangci-lint so a contributor lints with
the same version the pipeline uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The response phase read the whole upstream body, then `ownerresolver.Resolve`
ran `json.Valid` over it and `json.Marshal` copied it again into the request
envelope as a `json.RawMessage`. Peak footprint is therefore roughly **3x** the
body size per in-flight request, on top of APISIX's own buffering of the same
response — so a handful of concurrent large-collection responses can drive the
runner's memory well past what the response size suggests. Nothing bounded it,
and transport security does not help: cluster mTLS does not make the copy
smaller.

New `max_resolve_body_bytes` (default 1 MiB, range 1–100 MiB). A larger body is
**denied** rather than forwarded — a body too large to examine is not a body the
gate can vouch for — and, like the owner cap, it denies regardless of
`fail_open`, since it is a deliberate limit rather than an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (L-11)

When `json.Valid(payload)` failed, the resolve envelope was sent with
`encoding: "none"` — exactly as it was when the response carried no body at all.
The resolver therefore could not tell "this response carried a payload I could
not parse" from "this response had no payload", and fell back to resolving
ownership from the resource descriptor alone.

So a malformed-but-personal payload was judged without ever being inspected: a
truncated write, a content-type mismatch, an upstream answering XML or NDJSON on
a route declared JSON. The gate looked at nothing and let it through.

The envelope now has a third encoding, `opaque`, carrying the declared content
type and the payload's size but not the payload. The resolver can act on the
distinction — fail closed on a body it was told exists but cannot be read —
rather than being told a falsehood about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review document was swept into the C-1 commit by `git add -A`. It is a local
working note, not a repository artifact — untracked here and ignored so it
cannot be committed by accident again. The file itself stays in the working
tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review's test-suite assessment noted there was no test for `$request_id`
being unavailable in only one phase. That case matters because it is the one
where the plugin is structurally unable to gate: with no correlation key the two
phases cannot be matched, so there is no captured context to decide on.

Three cases added:

- the response phase cannot read `$request_id` — denies, even with `fail_open`,
  since this is not an outage to ride out;
- the request phase left no context at all — likewise denies;
- the request phase with no correlation key stores nothing, rather than leaking
  an entry per request into the context store, and with one stores what the
  response phase later needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wistefan and others added 9 commits August 27, 2026 11:40
The shutdown flush was present and essentially never completed.

`runner.Run` installs its own `signal.Notify` for SIGINT/SIGTERM and returns as
soon as one arrives — its shutdown path is `close(done)` and return, i.e.
instant. The flush waited for the same signal in a goroutine of its own. Go
delivers a signal to *every* registered channel, so on SIGTERM both woke at once:
the runner returned, `main` returned, and the process exited while
`audit.ShutdownAll()` was still draining its queue and waiting on an HTTP export
(up to the 5s export timeout).

So a rolling redeploy still discarded up to one flush interval of access
decisions — every allow and deny in that window — with the added problem that
the loss was now invisible: `consent_audit_events_dropped_total` counts
queue-overflow drops, and these were not that. The fix looked done and fired
almost never.

Since `Run` already blocks until the signal, the flush belongs after it,
synchronously, where nothing can exit out from under it — and the plugin needs no
signal handler of its own. Registering one was actively harmful anyway:
`signal.Notify` disables the default SIGTERM disposition, so had the runner's
handler ever stopped returning promptly, nothing would have terminated the
process.

`runAndFlush` makes the ordering explicit and testable; `main_test.go` pins it,
because a flush that never completes still passes any test that only checks it
was called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduced by the H-5 concurrency work. After `wg.Wait()`, `checkOwners` walked
the per-owner results in index order and returned the first entry marked as a
problem — whether that problem was an error or a deny. Index ordering made the
verdict deterministic, which was the goal and was correctly achieved, but it
ranked error and deny purely by position, and they are not equivalent: a deny is
a definite answer, an error is the absence of one, and only the absence is
subject to the operator's fail policy.

Failure scenario: a response resolves to owners A (index 0) and B (index 1) on a
route configured `fail_open: true`. Both checks are in flight; B's returns deny
(no granted consent) while A's returns HTTP 500, both landing before either
cancellation takes effect. The loop reached index 0 first, saw a
non-cancellation error, and applied the fail policy — releasing B's data even
though B has explicitly not consented. The audit record then showed the
contradiction: a per-owner deny for B alongside an enforced allow.

The reduction is now by decisiveness first and index second: one pass looks for
a deny across all results, and only if there is none does the error pass run.
Within each pass the lowest index still wins, so the verdict remains independent
of goroutine scheduling.

The regression test forces the exact race with a two-party barrier holding both
checks open until both have genuinely completed, so neither result is a
cancellation artifact. It fails against the previous reduction and passes with
this one. A second table test confirms the error path still applies `fail_open`
when no deny exists anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When the store was at `MaxRequestContexts`, `evictForSpaceLocked` swept expired
entries (a full map scan) and, if none had expired, scanned the whole map again
to find the single oldest — all under `requestContextMu`, all to free exactly one
slot. The next request then repeated both scans.

At the 100k cap that is two ~100k-entry scans per incoming request, serialised
behind one mutex. And the store only *reaches* the cap under the leak or the
abort-flood the cap exists to contain, so the O(n) path was guaranteed to engage
precisely when load was already pathological: the cap converted an unbounded
memory leak into an unbounded latency cliff, which undercuts the mitigation it
was added to provide.

An overflow now evicts a batch of the oldest entries (`contextEvictionBatch`, 1%
of the cap) in one pass, so the scan is paid once per thousand requests rather
than on every one — the following requests find room without scanning at all.
The eviction still takes the *oldest* entries, so which contexts are dropped is
unchanged; only how many, and how often the scan runs.

The cap test now asserts the batch semantics, and a second test pins the
amortisation directly: after an overflow, the next `contextEvictionBatch-1`
stores must not evict again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The regression test I added with N-2 drove the ordering through two concurrent
HTTP checks held open by a barrier. It was inherently racy and failed about half
the time: whichever owner completes first cancels the other, so the sibling's
deny can arrive as a `context.Canceled` error rather than a deny, and the test
then passed or failed on timing instead of on the property it was meant to pin.
A flaky test that guards a security property is worse than none — it teaches
people to re-run.

The result reduction is now a pure function, `reduceOwnerResults`, with
`ownerCheckResult` lifted to package scope, so the ranking can be exercised with
scripted results at the level where it actually lives. The table covers a deny
at a higher index than an error (the finding, which still fails against the
index-only reduction), a deny at a lower index, two denies, error-only under
both fail policies, a cancellation caused by a sibling deny versus one caused by
the phase deadline, and an owner whose check never started.

No behaviour change: `checkOwners` calls the extracted function and the ranking
rule is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`coversPurpose` returns true for an empty purpose, which is a defensible and
documented rule — but it made half of the C-2 fix contingent on a *different
service* populating an optional field. A resolver deployment whose rules never
set `purpose` runs with purpose scoping entirely disabled, and nothing anywhere
said so: no log line, no metric, no way to demand it.

The failure is quiet and compliance-shaped: the resolver is upgraded, a rule
loses its purpose mapping, and consent granted for "insurance quote" now also
authorises release for research. The consumer match still holds, so it narrows
rather than opens the gate — but a property that was supposed to be enforced
silently stopped being, and no signal would have revealed it.

- `consent_purpose_unconstrained_total` counts every check run without a purpose
  to match against, so the state is alertable rather than merely documented, and
  a rate-limited warning says the same thing in the log.
- `require_purpose` (default false) turns a claim without a purpose into a
  denial. It defaults off because requiring it would break every deployment
  whose resolver does not emit one yet; turn it on once yours does and the
  property is enforced rather than hoped for. Being a policy the operator asked
  for rather than an outage, it is `failAlwaysClosed` — `fail_open` does not
  lift it.

Tests: `checkPurposeScoping` as a table (counted when not required, denied when
required, denied even with fail_open), plus the `coversPurpose` cases the
re-review flagged as missing at the consent-client level — matching purpose
allows, a different purpose denies, a consent covering no purpose denies a
purpose-scoped check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pe (N-5)

`Sanitize`'s doc comment stated the problem exactly — "messages built by
wrapping dependency errors embed the dependency's response body, which can carry
identifiers or other personal data" — and then collapsed control characters and
truncated to 200 characters. Neither removes the body. Truncation is not
redaction, and the surviving prefix of a JSON error page is usually precisely
the part with the identifiers in it.

Five call sites in the consent client (and one in the resolver client) embedded
`truncateBody(body)` in their errors, and those errors become the plugin's
decision reason — exported to the audit sink and written to stdout. A
consent-manager 500 echoing the user identifier landed in both, now
single-line and ≤200 chars, which is tidier but no less identifying.

`unexpectedStatus` now builds a stable, low-cardinality classification
("consent client: identifier search returned status 500") and sends the body to
a rate-limited DEBUG log instead, so it is available when debugging, off by
default, and never on a path that escapes. The resolver client does the same for
its own error page, which can echo the payload it was handed — the very personal
data the gate exists to protect. An audit `reason` wants a classification
anyway: it is queried, not read.

`truncateBody` and `truncate` are gone with their test; `logging.DebugfEvery` is
the new home for this kind of detail.

The regression test drives a consent-manager whose 500 body contains an email
address and asserts it does not reach the OTLP payload, while the classification
does. It fails if the body is put back into the error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…LP (N-6)

`consent_request_contexts_evicted_total` and `consent_audit_events_dropped_total`
were registered through `RegisterGauge` and emitted as `# TYPE … gauge`. Both are
monotonic, and their `_total` suffix says so: `promtool check metrics` flags the
mismatch, and anyone reaching for `rate(…_total[5m])` over a declared gauge is
relying on an accident rather than a stated contract. Gauge families also emitted
no `# HELP` line at all, unlike the counters and the histogram.

- `RegisterCounter` joins `RegisterGauge`; both now take a help string, and the
  two `_total` callbacks moved across. The constants are renamed to say which
  they are (`ContextEvictedCounter`, `AuditDroppedCounter`), leaving
  `ContextStoreSizeGauge` a gauge, which it correctly is — it goes down.
- Every callback family emits `# HELP` and its true `# TYPE`.
- The redundant `cumulative` local in the histogram rendering is gone, with a
  comment noting that `counts` is already cumulative because `observe`
  increments every bucket at or above the value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h (§4)

The re-review listed three small gaps, all in code that is live rather than
incidental.

- `audit.Dropped()` was at 0% despite being read by the registered metric
  callback — the only thing that makes suppression of an access record visible.
  Now covered, including that it aggregates across emitters.
- `fetchProviderSD` was at 65%. It runs before any consent check, so a failure
  there takes the whole exchange down; its 401, 5xx, unparseable-body and
  empty-selfDescriptionURL branches are now pinned, along with an assertion that
  the dependency's response body does not travel in the error (N-5). The static
  provider-SD override is covered too, asserting it makes no HTTP call at all.
- No benchmark existed for the store under load, so N-3's fix was unmeasured.

`BenchmarkStoreRequestContextAtCap` fills the store to `MaxRequestContexts` and
measures the overflow path — the one that only engages under the leak or
abort-flood the cap exists to contain, i.e. exactly when the gate is already
under pressure. On this machine:

    eviction batch 1 (the old behaviour)   ~3,000,000 ns/op
    eviction batch 1% of cap (current)         ~17,000 ns/op

about a 175x difference, and the batched figure sits within ~2x of the
uncontended `BenchmarkStoreRequestContext`. That is the latency cliff the
finding described, now measurable rather than argued.

Total coverage 89.0% -> 91.1%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…M-6 residual)

`docker compose config -q` passing is not the same as the stack running, and
actually running it — to settle the M-1 Content-Length question end to end —
turned up two things that made it fail:

- **`bitnami/etcd:3.5` no longer resolves.** Bitnami retired their public
  catalog, so the very first `docker compose up` died on
  `manifest unknown` before anything started. Switched to the upstream
  `quay.io/coreos/etcd`, which needs no auth-disabling env var because it is
  open by default — fine for a local stack, which this is, and it is not a
  deployment example.

- **WireMock rejected every stub file.** The `"//"` keys I used as comments are
  unknown top-level fields on a StubMapping, so WireMock exited at startup with
  `Unrecognized field "//"`. The explanations now live in the mapping's own
  `name` and `metadata` fields, which is what they are for.

The second failure was quiet in a way worth fixing rather than just correcting:
`docker compose ps` hides an exited container, so a dead mock surfaced only as
an unexplained deny from the gate several steps later, with a DNS error buried
in the runner's log. The mock now has a healthcheck and the runner waits on it,
so a broken stub fails where it happens.

Also wires `CONSENT_METRICS_ADDRESS` into the runner and publishes port 9091, so
the metrics endpoint is exercisable in the dev stack rather than only in tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wistefan wistefan added the patch label Aug 27, 2026
@wistefan
wistefan merged commit e10dbc9 into main Aug 28, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant