fix(plugins): close plugin-config masking gaps for duplicate identities and conditional schemas (BLO-26530) - #1339
Conversation
…onal schemas (BLO-26530)
Paranoid review of the merged BLO-20871 masking boundary found two ways a
plugin credential could still escape it. Both are reproduced by tests that
fail on the parent commit.
Duplicate incoming identity. `mergeArray` proved a designated
`x-paperclip-identity` unique only among *stored* entries, so two posted
entries claiming the same identity both matched the one stored entry and both
had its credential restored — cloning a live secret onto an endpoint the
operator never gave it to:
stored: [{name:"alpha", url:"https://a", token:"token-alpha"}]
posted: [{name:"alpha", url:"https://a", token:"__redacted__"},
{name:"alpha", url:"https://attacker", token:"__redacted__"}]
before: both entries come back holding token-alpha
after: targets.0 and targets.1 unresolved, write refused
Identity is now proven only when it is unique on both sides.
Untraversed conditional schemas. `expandSchemaNodes` walked `$ref` and the
composition arrays only, so a `writeOnly` / `format: "secret-ref"` /
`x-paperclip-secret` marker inside `if` / `then` / `else` / `dependentSchemas`
/ draft-07 `dependencies` / `contains` / `unevaluatedItems` /
`unevaluatedProperties` was invisible and the field came back as plaintext.
All are now walked at the instance location they actually apply to, without
evaluating applicability — consistent with the existing fail-closed treatment
of composition branches.
Rather than leave the next unhandled keyword as a fresh hiding place, schema
keywords are now an allowlist: a node carrying anything neither walked nor
known-inert (`$dynamicRef`, `contentSchema`, a future draft's keyword, a typo)
forces suspicion, masking every string leaf at and beneath it. That is enough
to guarantee no plaintext is emitted while keeping the structure editable and
round-trippable — collapsing a config to one sentinel would leave the operator
no form and nothing for the merge to restore into. `x-` vendor extensions stay
inert so a UI hint cannot mask a config.
Verified byte-identical masking output against master for the alertmanager,
slack, linear and gbrain manifests, so this is defense-in-depth with no
over-masking regression on any shipped manifest.
No files under packages/plugins/paperclip-plugin-alertmanager/** change.
Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
…ed (BLO-26530) The keyword allowlist has a drift hazard the first commit only documented as a risk: `TRAVERSED_SCHEMA_KEYWORDS` is what exempts a keyword from the fail-closed guard, so a keyword listed there but not actually walked is silently *ignored* rather than masked. That is exactly the shape of the defect this issue fixes — `if` / `then` / `else` / `dependentSchemas` / `contains` were reachable-looking and never entered. Turn the risk into an enforced invariant: one case per member of the set, hiding a `writeOnly` marker so only that keyword can reach it, plus a completeness assertion tying the table to the set. Adding a keyword to the set without walking it now fails the completeness test; listing one and walking it wrongly fails its own case. Verified by simulating the drift — appending an unwalked `futureApplicator` to the set fails the completeness assertion. `TRAVERSED_SCHEMA_KEYWORDS` is exported for this test only. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head This PR was opened during the fleet-wide review outage tracked in BLO-26654 (codex provider quota exhaustion, from 2026-08-12T09:24Z) and has never been reviewed on either surface. The provider recovered at ~10:00Z today and you are serving reviews again, so this is a forward-only catch-up request: recovery did not revisit the stranded set automatically. Review focus: normal full pass at the head above. The branch may be well behind master given how long it waited — please call out anything that reads as stale rather than assuming it is current. |
|
@ally please review at head 3b108b7 — plugins: masking gaps for duplicate identities and conditional schemas. Focus on any remaining path that can emit an unmasked secret. Context: the original review request on this PR was lost during the codex provider outage (BLO-27123) — codex |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3b108b7
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Incoming-side identity uniqueness prevents one stored credential from being restored onto duplicate operator-supplied entries.
- Conditional, dependent, array, and unevaluated schema applicators are traversed with focused round-trip and route-level coverage.
- The explicit walked/inert keyword allowlist fails closed for unrecognized schema keywords, and the drift tests protect the traversal inventory.
- The current PR checks are green, including build, typecheck, server test shards, and review.
Recommended Action
- No Critical or Important issues found; the implementation is suitable for merge from the reviewed head.
Thinking Path
Linked Issues or Issue Description
What Changed
mergeArraynow proves a designated identity unique on both sides.x-paperclip-identityuniqueness was verified only among stored entries, so two posted entries claiming the same identity both matched the one stored entry and both had its credential restored:expandSchemaNodesnow walks the conditional and dependent applicators.if/then/else/not/dependentSchemas/ draft-07dependenciesapply to the same instance location and were skipped, so awriteOnly,format: "secret-ref"orx-paperclip-secretmarker inside one was invisible and the field was returned as plaintext.containsandunevaluatedItemsadded tochildNodesForIndex;unevaluatedPropertiestochildNodesForKey— the same gap at the array-entry and property levels.containsis applied to every index, since it says "at least one entry" without saying which.$dynamicRef,contentSchema, a future draft's keyword, or a typo) forces the name-heuristic's suspicion, masking every string leaf at and beneath it.oneOfbranches:ifis walked alongsidethen/else, anddependentSchemaswithout checking whether its trigger property is present.plugin-config-masking.test.ts(98 -> 118 in that suite) and 3 route cases inplugin-routes-authz.test.ts.TRAVERSED_SCHEMA_KEYWORDSis what exempts a keyword from the fail-closed guard, so a keyword listed there but not actually walked would be silently ignored rather than masked — the exact shape of this defect. A second commit pins one case per member of the set, hiding a marker so only that keyword can reach it, plus a completeness assertion tying the table to the set.Why suspicion rather than a wholesale mask for unknown keywords
Plaintext is a string, so masking every string leaf fully satisfies "never emit plaintext". Collapsing the location to a single sentinel — the treatment a dangling
$refgets — would leave the operator no editable form and nothing formergeMaskedPluginConfigto restore into, turning an unrecognised keyword into a config-availability outage.x-vendor extensions stay inert so a UI hint cannot mask a config, and an explicitx-paperclip-secret: falsestill wins, because the author spoke about that exact field.Verification
Both defects were reproduced as failing tests against the parent commit before any fix was written.
Route tests assert the duplicate-identity write returns 400 with
unresolvedMaskPaths, thatupsertConfigis never called, that storage is unchanged, and that the credential appears in neither resulting entry.Mutation-verified — every traversal and guard is load-bearing. Each mutation was applied in isolation, the suite re-run, then reverted:
if/then/else/nottraversaldependentSchemas/dependenciestraversalcontainsunevaluatedItemsunevaluatedPropertiesNo over-masking regression on any shipped manifest. An allowlist can only fail by being too narrow, so I measured it rather than assuming: for the real
alertmanager,slack,linearandgbrainmanifests I synthesized a config from each declared property, masked it under bothmasterand this branch, and compared. Output is byte-identical, and every masked field is explained by a declared marker or the pre-existing name heuristic:No shipped manifest uses any conditional keyword today, so this change is defense-in-depth.
gbrainOauthTokenUrlmasking by name heuristic is pre-existing onmaster, not introduced here.Risks
INERT_SCHEMA_KEYWORDS. I chose it over a denylist because a denylist fails open, which is how the original defect existed.containsover-masks by design. It governs at least one entry without saying which, so acontains-declared secret masks the same field on sibling entries. The alternative is emitting the entry it did govern in the clear.TRAVERSED_SCHEMA_KEYWORDSand the actual traversal code must stay in sync — a keyword listed as traversed but not walked would be silently ignored rather than failing closed. This is now an enforced invariant rather than a documented caution: the drift suite fails if a keyword is added to the set without a case proving the walk enters it (verified by appending an unwalkedfutureApplicatorand watching the completeness assertion fail).Model Used
claude-opus-5[1m]as configured for this agent), 1M context, extended thinking enabled, with tool use and code execution (ran the test suite, the mutation matrix, and the manifest comparison directly).Note for the reviewer
server/src/services/json-schema-secret-refs.ts:15has the same limited traversal (allOf/anyOf/oneOfonly), so aformat: "secret-ref"field declared behind a conditional would not be discovered for binding sync. That is a functional gap, not a leak — masking now covers those fields — so I deliberately left it out of this diff rather than widen a security-boundary change, and filed it as a follow-up under BLO-20794.Sibling #1337 (BLO-26529) touches
routes/plugins.tsand two other test files. The only file we share isplugin-routes-authz.test.ts, where its hunks sit at lines ~50/~109 and mine are appended at the end, so the two merge cleanly.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateplugin-config-masking.ts's JSDoc, which is updated alongside the behaviour🤖 Generated with Claude Code