Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): stop the meta diff endpoint serving credential values (#8671) - #8797
Conversation
…l values (#8671) `diffMetaItem` read two stored bodies verbatim and emitted the raw values, so a `datasource` row whose credential rotated between versions returned both the old and the new password in cleartext through the live GA endpoint `GET /api/v1/meta/:type/:name/diff`. Implements the maintainer ruling (issue comment 5299845282, Option B): diff the RAW bodies so a rotation still reports its path as changed, then take the emitted value/from/to from the type's redacted projection of those same bodies — both sides, all three buckets. Substitution rather than path-matching against `redactedKeys` is deliberate: `diffShallow` is top-level while `redactedKeys` is nested and dotted, so matching `redactedKeys` against an entry path would match nothing on the real leak. Reading each emitted value out of the already-redacted body keeps the redactor as the single source of what a credential is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…f-credential-redaction
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…f-credential-redaction
…mbinators (#8671) `check:where-matcher` caught the new test double's `matches` as silently wrong: it walked the filter treating every key as a field name, so a `$or` / `$and` key was compared as if it were a column — `r.$or` is undefined, the row is silently excluded, and a suite can go green while asserting on a query nobody wrote. Fixed by REFUSING rather than implementing, the path 142 of 236 matchers in this repo take and the one the gate's header recommends for a double that only ever sees scalar equality. This double serves two readers that both issue flat `{ organization_id, type, name }` filters, so no combinator can legitimately arrive; if one ever does, the throw makes it loud instead of silent. Gate now reports 236/236 conforming, 0 silently-wrong, 0 unjudged, baseline not grown. The seven pins are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8671
GET /api/v1/meta/:type/:name/diffis a routed, live GA endpoint that answersres.json(result)unmodified.diffMetaItemread two stored bodies verbatim — history rows out ofsys_metadata_history, and the active row viarepo.get()— and emitted the raw values it found. A pre-#8078datasourcerow whose credential rotated between versions therefore returned both the old and the new password in cleartext, in the inlineconfig.passwordspelling and in the password component ofconfig.urlalike.The ruled shape
Implements the maintainer ruling in issue comment 5299845282 (delegated adjudication) — Option B:
The diff is still computed on the raw bodies, so a credential rotation continues to report its path as changed; only the values leaving the function are taken from the type's redacted projection of those same bodies. Both sides, all three emission buckets (
added.value,removed.value,changed.from/changed.to).Option A (redact before diffing) is ruled out and the pins are written to catch a drift back to it: two redacted bodies are equal at a redacted path, so the rotation would vanish into a no-diff.
The one design point worth review
The obvious reading of "redact at redactor-named paths" is to match the diff entry's
pathagainst the redactor'sredactedKeys. That matches nothing on the real leak, and it is worth stating plainly because it looks correct:diffShallowis top-level — a nested change collapses to one entry whosepathisconfigand whose value is the whole sub-object;redactedKeysis nested and dotted —config.password.So
redactedKeys.includes(entry.path)compares two different planes and never fires. This PR instead reads each emitted value out of the already-redacted body at the same top-level path. That gets the nested case right for free, and mints no second redaction rule set — the registered per-type redactor stays the single source of what a credential is, which was the ruling's stated rationale.Types with no registered redactor are skipped through
hasMetadataRedactorand keep serving their values by reference, exactly as before.Verification
New pin:
packages/metadata-protocol/src/protocol.diff-credential-redaction.test.ts(7 cases), fixture seeded directly into a table-aware stub engine — #8078 closed the write door on inline credentials, so only a direct seed reproduces the rows that leak.labelfield still diffs with both values visible, proving the redaction is path-selective rather than a blanket value scrub (a diff that emitted no values at all would pass a sweep-only test).host,username) survive inside the redacted value.addedandremovedbuckets pinned separately.datasourcewith an identity redactor through the public registry overlay and asserts the cleartext comes back — so the green above is a statement about the redaction running, not about a credential-free fixture.Reverse verification, run from the committed state with the direction predicted first: reverting the emission substitution turns exactly 3 of 7 red (
AssertionError: expected 'hunter2-old' to be undefined) and leaves 4 green — the control, the nested-keys case, the ablation arm and the no-redactor case, none of which depends on the fix.Second commit — the stub's WHERE matcher refuses combinators
CI's ESLint job flagged the new test double under
check:where-matcher: itsmatcheswalked the filter treating every key as a field name, so a$or/$andkey would be compared as a column (r.$orisundefined, the row is silently excluded, and a suite can go green asserting on a query nobody wrote — the gate's shape (b)).Fixed by refusing rather than implementing, the path 142 of 236 matchers in this repo take and the one the gate's own header recommends for a double that only ever sees scalar equality. This double serves two readers that both issue flat
{ organization_id, type, name }filters, so no combinator can legitimately arrive; the throw makes it loud if one ever does. The baseline was not touched and no inline disable was added. The seven pins are unaffected by the change.Gate union, re-run after the final commit, at HEAD
971b25753pnpm --filter @objectstack/metadata-protocol test— 92 files / 1376 tests passedcheck:where-matcher— 236 discovered, 236 conform (142 refuse), 0 silently-wrong, 0 unjudged, baseline not growncheck:engine-double-contract— passcheck:nul-bytes,check:changeset-gate-self-tests,check:objectui-changeset,check:filter-alias-parity,check:durability-log-level,check:cross-package-test-inputs,check:query-options-erasure,check:type-check-coverage— all passcheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset— passcheck:type-check-debt(the ratchet, on a fully rebuilt 70-package closure) —none above its recorded numberPath-derived gate names came from
node scripts/pm/dispatch-gates.mjs.check:where-matcherandcheck:engine-double-contractare convention-triggered — they fire because the diff adds a test double, not because of which paths it touches — so no path derivation can name them; both are included above.Scope
packages/metadata-protocol/src/protocol.tsis under region-level declaration this round; both hunks land insidediffMetaItemand nothing else in the file is touched.origin/mainmerged atcaaae2cca.Two things recorded on the card were re-confirmed as not in scope and deliberately untouched:
SysMetadataRepository.getByHash(contract method, no production caller — redaction belongs at a future consumer's exit) andrestoreVersion(a write returning receipts only, no leak; redacting there would corrupt the restored row).Generated by Claude Code