From 78e60bcd8f0a0b9e82621ceeb3dbf6147e70e3ed Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:51:14 -0400 Subject: [PATCH 1/2] feat(estate-safety-kit): canonical vendored source for cross-repo safety helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three security-relevant helpers were each written correctly once, then written AGAIN independently elsewhere in the estate with no code sharing, because there was nowhere shared to put the fix: - isSafeHttp (URL scheme allowlist / click-XSS guard): socioprophet#477 (app-vue), copy-pasted into socioprophet#550 (client-vue) rather than shared, since the two Vue packages don't share a workspace. - mintId (CSPRNG id minting, replacing hash-of-own-inputs schemes that collide same-millisecond): prophet-platform#1070 (health-twin ids.ts + consult.ts), the same pattern independently re-derived for socioprophet#484 (bootProofRecord). - bounded_int (non-negative-int-typed fields are not size-bounded when ints are arbitrary precision): prophet-platform#1067/#1071/#1118 (compute-gateway's exhaust guard), never generalized even to the sibling app nugget-extractor in the same repo. This follows the estate's existing vendoring convention (SHA-pinned tarball + PROVENANCE, as in prophet-platform's revendor_engine.py / assert_vendored_engine_marker.py, and the schemas/PROVENANCE.md pattern in apps/compute-gateway) rather than standing up a new npm/PyPI registry surface for three files. See estate-safety-kit/PROVENANCE.md for the full contract: consumers copy the file verbatim, record a PROVENANCE.txt with the source commit SHA, and run tools/check_vendored_safety_kit.py to prove byte-identity rather than trust a comment. 65 JS tests (node:test, zero dependencies) + 20 Python tests (pytest), all passing locally. Checker script smoke-tested against both a matching and a deliberately-drifted copy. New shared-infrastructure pattern — held for human review of the vendoring convention, not just the code. --- estate-safety-kit/PROVENANCE.md | 88 +++++++++ estate-safety-kit/js/mintId.test.ts | 86 +++++++++ estate-safety-kit/js/mintId.ts | 82 +++++++++ estate-safety-kit/js/urlSafe.test.ts | 139 ++++++++++++++ estate-safety-kit/js/urlSafe.ts | 56 ++++++ estate-safety-kit/py/bounded_int.py | 114 ++++++++++++ estate-safety-kit/py/test_bounded_int.py | 142 +++++++++++++++ .../tools/check_vendored_safety_kit.py | 170 ++++++++++++++++++ 8 files changed, 877 insertions(+) create mode 100644 estate-safety-kit/PROVENANCE.md create mode 100644 estate-safety-kit/js/mintId.test.ts create mode 100644 estate-safety-kit/js/mintId.ts create mode 100644 estate-safety-kit/js/urlSafe.test.ts create mode 100644 estate-safety-kit/js/urlSafe.ts create mode 100644 estate-safety-kit/py/bounded_int.py create mode 100644 estate-safety-kit/py/test_bounded_int.py create mode 100644 estate-safety-kit/tools/check_vendored_safety_kit.py diff --git a/estate-safety-kit/PROVENANCE.md b/estate-safety-kit/PROVENANCE.md new file mode 100644 index 0000000..21ddf3c --- /dev/null +++ b/estate-safety-kit/PROVENANCE.md @@ -0,0 +1,88 @@ +# estate-safety-kit — canonical source + vendoring contract + +Three small, security-relevant helpers that were each written correctly, then written +*again* — independently, with no code sharing — somewhere else in the estate, because +there was no shared home for cross-repo, cross-language "closes a real defect class" +code. This directory is that home. The estate's answer to "how do we share trust- +sensitive code across repos" is **not** a package registry — it is the same disciplined +vendoring pattern already used for the hellgraph engine tarball +(`prophet-platform/tools/revendor_engine.py` + +`tools/assert_vendored_engine_marker.py`) and for vendored schema sets +(`apps/compute-gateway/src/compute_gateway/schemas/PROVENANCE.md`): **one canonical +source, verbatim copies at consumers, a recorded commit pin, and a checker that proves +byte-identity instead of trusting a comment.** + +## The three helpers + +| file | closes | first written | duplicated / re-derived | +|---|---|---|---| +| `js/urlSafe.ts` | click-XSS via an unsanitised `:href` scheme (`javascript:`, `data:`, `vbscript:`, …) bound from an upstream-controlled URL | `socioprophet-web/app-vue/src/services/url-safe.ts` — [socioprophet#477](https://github.com/SocioProphet/socioprophet/pull/477) | copy-pasted (not imported — app-vue and client-vue don't share a workspace) into `socioprophet-web/client-vue/src/utils/urlSafe.ts` — [socioprophet#550](https://github.com/SocioProphet/socioprophet/pull/550) | +| `js/mintId.ts` | id-collision from minting an identifier as `hash(inputs + Date.now())` — collides same-millisecond, and is offline-recomputable from anything that publishes the inputs | `apps/health-twin/src/ids.ts` (`mintId`) — [prophet-platform#1070](https://github.com/SocioProphet/prophet-platform/pull/1070), fixing the exact same shape independently found in both a grant-id mint and `consult.ts`'s three id sites | the same PATTERN (not the code — nobody shared it) had to be independently re-derived for `bootProofRecord` in socioprophet's server contracts — [socioprophet#484](https://github.com/SocioProphet/socioprophet/pull/484) | +| `py/bounded_int.py` | "must be a non-negative int" treated as a size bound, when Python ints are arbitrary-precision — a digit-encoded blob smuggles arbitrary payload through a field typed only by sign | `apps/compute-gateway/src/compute_gateway/engine.py`'s exhaust guard — introduced [prophet-platform#1067](https://github.com/SocioProphet/prophet-platform/pull/1067), the open door found in [#1071](https://github.com/SocioProphet/prophet-platform/pull/1071), the int64 bound closing it in [#1118](https://github.com/SocioProphet/prophet-platform/pull/1118) | never generalized even to the sibling app `nugget-extractor` in the *same* repo, which independently grew its own, differently-shaped, pending-cap fix for a related-but-distinct DoS class (`apps/nugget-extractor/tests/test_pending_cap.py`) | + +Adjacent context, not the same specific defect but the same session's broader +socioprophet-web XSS/security-hardening sweep that this duplication was found inside of: +[socioprophet#478](https://github.com/SocioProphet/socioprophet/pull/478) (v-html +sanitization on notebook cell output), [#483](https://github.com/SocioProphet/socioprophet/pull/483) +(mesh bearer token moved out of localStorage), [#486](https://github.com/SocioProphet/socioprophet/pull/486) +(six low-severity cockpit findings). None of these three duplicate `isSafeHttp` / +`mintId` / bounded-int — they're cited here only because the pattern that produced this +kit ("fixed the same bug shape more than once, in more than one repo, because there was +nowhere to put the fix once") was noticed while working through that same sweep. + +## Why vendoring, not an npm package / PyPI package + +This estate has explicitly avoided adding new package-registry surfaces for +cross-repo-shared code (`feedback_vendor_dont_reference_external_cdn.md`, +`feedback_sovereign_decoupled_no_bloat.md`): a private registry is a new supply-chain +surface, a new publish pipeline, and a new versioning burden, for three files. The +existing convention for exactly this shape of problem — the hellgraph engine tarball, +the zero-trust kernel schemas above — is **vendor with a provenance record and a +freshness check**, so that's what this is. + +## The vendoring contract + +A consumer that wants one of these helpers: + +1. **Copies the file verbatim** into its own tree (e.g. + `socioprophet-web/app-vue/src/services/url-safe.ts`). No edits — if the helper needs + to change for that consumer, that is a signal the canonical source needs to change + (open a PR here, or generalize the API — see `mintId(prefix, bytes)`'s width + parameter for how the kit already accommodates one real divergence, health-twin's + stricter 64-hex ratchet, without forking the file). +2. **Records a `PROVENANCE.txt`** alongside the vendored file: + ``` + source-repo: SourceOS-Linux/sourceos-spec + source-path: estate-safety-kit/js/urlSafe.ts + source-commit: + vendored-path: socioprophet-web/app-vue/src/services/url-safe.ts + vendored-at: + ``` +3. **Runs `tools/check_vendored_safety_kit.py`** (in CI, and locally before a re-vendor + PR) against the vendored file. It reads the `PROVENANCE.txt`, fetches the canonical + file at the pinned commit (from `raw.githubusercontent.com`, or from a local + `--source-root` checkout for offline/dev use), and fails loudly on any byte + difference — the same "prove it, don't just claim it" discipline as + `assert_vendored_engine_marker.py`, applied to a small source file instead of a + tarball member (byte comparison instead of marker-substring containment, because a + file this size can be diffed exactly). + +## Re-vendoring (canonical source changed) + +1. Land the change here, get it merged to `main`. +2. For each consumer: copy the updated file, update `source-commit` in its + `PROVENANCE.txt` to the new merge commit, re-run + `tools/check_vendored_safety_kit.py` to confirm the copy is byte-identical again. +3. Consumers are NOT required to re-vendor in lockstep — an out-of-date pin is visible + (the checker still passes against the *old* pinned commit; it does not silently claim + currency with `main`) rather than invisible, which is the property that matters. A + freshness sweep across consumers is a separate, later concern (see + `feedback_vendored_dist_freshness.md`), not blocking on this PR. + +## Status of this PR + +New shared-infrastructure pattern — held for human review of the vendoring convention +itself, not just the code inside it (the three defects it closes are already fixed +independently at every site listed above; this PR does not change behavior anywhere +except socioprophet's two consumers being re-pointed to vendored copies of the same +logic they already ran). diff --git a/estate-safety-kit/js/mintId.test.ts b/estate-safety-kit/js/mintId.test.ts new file mode 100644 index 0000000..2a499a4 --- /dev/null +++ b/estate-safety-kit/js/mintId.test.ts @@ -0,0 +1,86 @@ +/** + * Pins the property `mintId` exists to guarantee — collision-free minting, not + * derivation from inputs — plus the shape and floor invariants. The regression this + * guards against (`hash(inputs + Date.now())` colliding same-millisecond) is exactly + * what "50 calls with identical inputs in a tight loop -> 50 unique ids" below would + * have caught in health-twin's consult.ts before it was fixed. + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mintId, idPattern, MIN_ID_BYTES } from './mintId.ts'; + +describe('mintId — CSPRNG id minting', () => { + test('default width is 128 bits (32 hex chars) behind the prefix', () => { + const id = mintId('grant'); + assert.match(id, /^grant-[0-9a-f]{32}$/); + }); + + test('is content-independent — identical prefix, called back-to-back, never repeats', () => { + // This is the exact regression: the old scheme was a hash of its own inputs, so + // identical logical inputs in the same millisecond minted the SAME id. mintId takes + // no content input at all, so "identical inputs" isn't even expressible here — the + // property under test is that N calls with the SAME prefix in a tight loop are N + // unique ids, which is what the old scheme failed at 79% of the time under load. + const n = 2000; + const ids = new Set(); + for (let i = 0; i < n; i++) ids.add(mintId('consult')); + assert.equal(ids.size, n, 'every mint in a tight loop must be unique — this is the collision defect'); + }); + + test('respects a wider width for callers with a stricter ratchet (health-twin: 32 bytes / 64 hex)', () => { + const id = mintId('op', 32); + assert.match(id, /^op-[0-9a-f]{64}$/); + }); + + test('refuses a width below the 128-bit floor', () => { + assert.throws(() => mintId('x', 4), RangeError); + assert.throws(() => mintId('x', 15), RangeError); + assert.doesNotThrow(() => mintId('x', MIN_ID_BYTES)); + }); + + test('refuses a non-integer width', () => { + assert.throws(() => mintId('x', 16.5), RangeError); + assert.throws(() => mintId('x', NaN), RangeError); + }); + + test('refuses an empty or non-string prefix', () => { + assert.throws(() => mintId(''), TypeError); + assert.throws(() => mintId(null as unknown as string), TypeError); + assert.throws(() => mintId(undefined as unknown as string), TypeError); + }); + + test('id is lowercase hex — no uppercase, no non-hex characters', () => { + const id = mintId('more'); + const [, hex] = id.split('-'); + assert.equal(hex, hex.toLowerCase()); + assert.match(hex, /^[0-9a-f]+$/); + }); +}); + +describe('idPattern — shape-check builder', () => { + test('matches what mintId actually produces, for the default width', () => { + const pattern = idPattern('grant'); + assert.match(mintId('grant'), pattern); + }); + + test('matches what mintId actually produces, for a non-default width', () => { + const pattern = idPattern('op', 32); + assert.match(mintId('op', 32), pattern); + }); + + test('does not match a narrower or wider id than the width it was built for', () => { + const pattern = idPattern('grant', 32); // expects 64 hex chars + assert.doesNotMatch(mintId('grant'), pattern); // mintId('grant') defaults to 32 hex chars + }); + + test('does not match a different prefix', () => { + const pattern = idPattern('grant'); + assert.doesNotMatch(mintId('consult'), pattern); + }); + + test('escapes regex metacharacters in the prefix rather than interpreting them', () => { + const pattern = idPattern('a.b'); + assert.doesNotMatch('aXb-' + '0'.repeat(32), pattern); + assert.match('a.b-' + '0'.repeat(32), pattern); + }); +}); diff --git a/estate-safety-kit/js/mintId.ts b/estate-safety-kit/js/mintId.ts new file mode 100644 index 0000000..f828302 --- /dev/null +++ b/estate-safety-kit/js/mintId.ts @@ -0,0 +1,82 @@ +/** + * ONE decision about where an identifier comes from, for every ledger that mints one. + * + * ESTATE SAFETY KIT — canonical source. See ../PROVENANCE.md for what this closes, + * which repos hit the defect independently, and the vendoring contract every consumer + * must follow. Do not edit a vendored copy directly — edit this file, re-vendor, done. + * + * THE DEFECT, found independently at least twice in prophet-platform's health-twin + * before this file existed: a grant id minted as `grant-` + * and a consult id minted as `consult-`. Both are + * a HASH OF THEIR OWN INPUTS, and both inherit two properties from that: + * + * 1. THEY COLLIDE. The only varying input is a millisecond. Two records minted with the + * same logical inputs in the same millisecond get the SAME id — measured at 79% of + * 200 concurrent issues on a laptop, for the grant case. A collided id is not a + * cosmetic duplicate: the ledger holds two rows under one identity, lookup silently + * resolves to one of them, the other holder's secret stops authenticating with no + * error anyone can point at, revoking the id revokes one row and leaves the other, + * and every receipt naming that id is ambiguous about which record it sealed. + * + * 2. THEY ARE RECOMPUTABLE OFFLINE. If the inputs and a millisecond-precision timestamp + * are ever published beside the id (a natural thing to do — `granted_at` on a grant, + * say), anyone holding that listing can recompute every id in it. The id is then an + * offline guessing target handed out for free, which matters most when the id is also + * the input to revocation or lookup. + * + * THE DECISION: an identifier is MINTED, not DERIVED. Bytes straight from the CSPRNG, hex, + * behind a human-readable typed prefix. Nothing about the record is recoverable from the + * id, nothing about the id is predictable from the record, and the collision probability + * over any ledger this helper could serve is not a number worth writing down. + * + * WHY NOT sha256(inputs + a random nonce), the other obvious repair: the moment a random + * nonce is inside the hash, the output IS random — the deterministic inputs contribute + * nothing an attacker cannot already see, and the hash contributes nothing but the + * appearance of derivation. It reads like a content address and is not one. Minting the + * bytes directly says what is actually happening. + * + * WHAT AN ID IS NOT. It is not a content address and it is not a receipt — those stay a + * hash over their own canonical, sealed content, because their entire job IS to be + * recomputable from the facts they seal. An id and a receipt are two different things that + * happen to both be hex strings. + * + * WIDTH. Default is 16 bytes (128 bits) — ample collision resistance for any per-service + * ledger, minted with `randomBytes`, not a UUID library (no extra dependency, same CSPRNG + * underneath). A caller with a stricter estate-wide ratchet — health-twin's is "no id ends + * in an 8-hex digest, all of them carry a full 64 hex", to keep every emitted id + * indistinguishable in shape from a sha256 digest — passes `bytes: 32` to mint the same + * shape it already emits; the floor below refuses anything under 128 bits so a caller + * cannot accidentally weaken it to something guessable. + */ +import { randomBytes } from 'node:crypto'; + +/** Minimum width this helper will mint. Below 128 bits is not "smaller ids", it is a + * different security property — refuse it here rather than let a caller discover the + * difference empirically. */ +export const MIN_ID_BYTES = 16; + +/** + * Mint a fresh, content-independent identifier: `-`. + * + * @param prefix human-readable, lowercase-and-hyphen typed prefix (`grant`, `consult`, `op`). + * @param bytes CSPRNG bytes to mint, default 16 (128 bits). Must be >= {@link MIN_ID_BYTES}. + */ +export function mintId(prefix: string, bytes: number = MIN_ID_BYTES): string { + if (typeof prefix !== 'string' || prefix.length === 0) { + throw new TypeError(`mintId: prefix must be a non-empty string (got ${JSON.stringify(prefix)})`); + } + if (!Number.isInteger(bytes) || bytes < MIN_ID_BYTES) { + throw new RangeError(`mintId: bytes must be an integer >= ${MIN_ID_BYTES} (got ${bytes})`); + } + return `${prefix}-${randomBytes(bytes).toString('hex')}`; +} + +/** Build the shape-check pattern `mintId` guarantees for a given prefix and width, so an + * invariant can check "is this string actually one of ours" without hardcoding hex length + * in two places. Matches a LOWERCASE prefix followed by `-` and `2*bytes` lowercase hex + * digits — the same shape `mintId` produces, nothing looser. */ +export function idPattern(prefix: string, bytes: number = MIN_ID_BYTES): RegExp { + const hexLen = bytes * 2; + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedPrefix}-[0-9a-f]{${hexLen}}$`); +} diff --git a/estate-safety-kit/js/urlSafe.test.ts b/estate-safety-kit/js/urlSafe.test.ts new file mode 100644 index 0000000..c2df3fb --- /dev/null +++ b/estate-safety-kit/js/urlSafe.test.ts @@ -0,0 +1,139 @@ +/** + * Pins the scheme-allowlist boundary for `isSafeHttp`. A deny case that leaks through + * here re-opens click-XSS on any `:href` bound from an untrusted-source URL; an allow + * case that wrongly rejects moves the bug rather than fixing it (real links go dead). + * + * Ported from the two independent suites that pinned this behaviour before this file + * was canonical: socioprophet app-vue's `url-safe.test.ts` (#477) and client-vue's + * `urlSafe.test.ts` (#550). Written against Node's built-in test runner (`node:test`) + * so this file has zero dependencies and runs the same way in any consumer, vitest or + * not — `node --test js/urlSafe.test.ts` from this directory. + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { isSafeHttp } from './urlSafe.ts'; + +describe('isSafeHttp — scheme allowlist for untrusted-source URLs', () => { + // ── ALLOW ────────────────────────────────────────────────────────────────── + for (const [u, why] of [ + ['https://example.com/path', 'canonical https'], + ['http://example.com/', 'plain http'], + ['HTTPS://Example.com', 'mixed-case scheme'], + ['https://example.com/x?y=1#z', 'with query + fragment'], + ['http://user:pass@example.com/', 'with credentials in URL — still http scheme'], + ['https://xn--nxasmq6b.example/', 'punycode host'], + ['https://github.com/SocioProphet/prophet-platform', 'evidence-link host'], + ] as const) { + test(`allows ${u} (${why})`, () => { + assert.equal(isSafeHttp(u), true); + }); + } + + // ── DENY: dangerous schemes ──────────────────────────────────────────────── + for (const [u, why] of [ + ['javascript:alert(1)', 'javascript: — the classic click-XSS'], + ['Javascript:alert(1)', 'javascript: mixed-case'], + ['JAVASCRIPT:alert(1)', 'javascript: uppercase'], + ['data:text/html,', 'data: URLs execute HTML on some UAs'], + ['vbscript:msgbox(1)', 'legacy IE surface, still handled by some UAs'], + ['file:///etc/passwd', 'local file exfil surface'], + ['ftp://example.com/', 'not-http — reject even if benign'], + ['mailto:alice@example.com', 'not-http — a caller wanting mail must opt in'], + ['tel:+1234', 'not-http — same rule'], + ['about:blank', 'browser-internal scheme'], + ['chrome-extension://abc/', 'extension scheme'], + ['view-source:https://example.com', 'view-source wrapping'], + ['blob:https://example.com/uuid', 'blob: — rendered content is caller-controlled'], + ] as const) { + test(`rejects ${u} (${why})`, () => { + assert.equal(isSafeHttp(u), false); + }); + } + + // ── DENY: browser normalisation traps ────────────────────────────────────── + for (const [u, why] of [ + ['\tjavascript:alert(1)', 'leading tab historically stripped before scheme parse'], + ['\njavascript:alert(1)', 'leading newline'], + ['\rjavascript:alert(1)', 'leading CR'], + [' javascript:alert(1)', 'leading space'], + ] as const) { + test(`rejects control-char-prefixed ${JSON.stringify(u)} (${why})`, () => { + assert.equal(isSafeHttp(u), false); + }); + } + + // ── DENY: no scheme / malformed ──────────────────────────────────────────── + for (const [u, why] of [ + ['', 'empty'], + ['/relative/path', 'relative path — no scheme'], + ['//example.com/foo', 'protocol-relative — deny (upstream may relative-resolve to javascript:)'], + ['//evil.example/path', 'protocol-relative, arbitrary host'], + ['example.com', 'bare host'], + ['not a url', 'prose'], + [':no-scheme', 'colon with no scheme'], + ['1nvalid://x', 'scheme must start with a letter'], + ] as const) { + test(`rejects malformed ${JSON.stringify(u)} (${why})`, () => { + assert.equal(isSafeHttp(u), false); + }); + } + + // ── DENY: non-string ─────────────────────────────────────────────────────── + for (const [u, why] of [ + [null, 'null'], + [undefined, 'undefined'], + [42, 'number'], + [{}, 'object'], + [{ url: 'https://example.com' }, 'object with url — do not resolve'], + [{ href: 'https://example.com' }, 'object with href — do not resolve'], + [['https://example.com'], 'array'], + [true, 'boolean'], + ] as const) { + test(`rejects non-string ${JSON.stringify(u)} (${why})`, () => { + assert.equal(isSafeHttp(u as unknown as string), false); + }); + } + + // ── extra allow-list — intersected with SAFE_EXTRAS ──────────────────────── + test('allows opt-in mailto/tel', () => { + assert.equal(isSafeHttp('mailto:a@b', ['mailto']), true); + assert.equal(isSafeHttp('tel:+1', ['tel']), true); + }); + + test('extras do not weaken the leading-control-char guard', () => { + assert.equal(isSafeHttp('\tmailto:a@b', ['mailto']), false); + }); + + // ── ABSOLUTE deny — a caller cannot re-enable an executable scheme ───────── + for (const [u, extras] of [ + ['javascript:alert(1)', ['javascript']], + ['data:text/html,x', ['data']], + ['vbscript:x', ['vbscript']], + ['file:///etc/passwd', ['file']], + ['blob:https://x/y', ['blob']], + ['about:blank', ['about']], + ['chrome-extension://x/', ['chrome-extension']], + // multi-value extras attempting to smuggle a dangerous scheme past a benign one + ['javascript:alert(1)', ['mailto', 'javascript']], + // uppercase in extras must not bypass the SAFE_EXTRAS check + ['javascript:alert(1)', ['JAVASCRIPT']], + ] as const) { + test(`a caller CANNOT re-enable ${u} by passing ${JSON.stringify(extras)} — deny-list is absolute`, () => { + assert.equal(isSafeHttp(u, extras), false); + }); + } + + test("extras are normalised — 'Mailto', 'MAILTO ', ' mailto' all opt in", () => { + assert.equal(isSafeHttp('mailto:a@b', ['Mailto']), true); + assert.equal(isSafeHttp('mailto:a@b', ['MAILTO ']), true); + assert.equal(isSafeHttp('mailto:a@b', [' mailto']), true); + }); + + test('non-string entries in extras are ignored silently', () => { + assert.equal( + isSafeHttp('mailto:a@b', [null as unknown as string, undefined as unknown as string, 'mailto']), + true, + ); + assert.equal(isSafeHttp('javascript:x', [null as unknown as string, 'javascript' as unknown as string]), false); + }); +}); diff --git a/estate-safety-kit/js/urlSafe.ts b/estate-safety-kit/js/urlSafe.ts new file mode 100644 index 0000000..d64a6db --- /dev/null +++ b/estate-safety-kit/js/urlSafe.ts @@ -0,0 +1,56 @@ +/** + * Scheme allow-list for URLs whose source is not the app rendering them. + * + * ESTATE SAFETY KIT — canonical source. See ../PROVENANCE.md for what this closes, + * which repos hit the defect independently, and the vendoring contract every consumer + * must follow (verbatim copy + PROVENANCE.txt + `tools/check_vendored_safety_kit.py`). + * Do not edit a vendored copy directly — edit this file, re-vendor, done. + * + * Search results, ontology hits, mail links, ledger provenance, evidence-board links — + * anything that lands in a `:href` from an upstream the app does not control (SearXNG + * federates to arbitrary engines; mail arrives from anywhere; an evidence receipt may + * cite any URI; a BFF's JSON is still an upstream even when it is estate-internal) must + * NOT render as a live `` unless the scheme is one a click cannot execute in-origin. + * Vue does not sanitise `:href`, and `rel="noopener"` blocks only the tab reference — + * the JS in a `javascript:` link runs before a tab exists. + * + * Fail closed to plain text for everything else (data:, vbscript:, javascript:, file:, + * ftp:, blob:, about:, custom schemes, protocol-relative `//`). If a caller genuinely + * wants mailto: or tel:, opt in via the second argument. That argument is INTERSECTED + * with a fixed safe-extras set — a caller CANNOT re-enable `javascript:` / `data:` / + * `vbscript:` by passing them in. + * + * Independently written twice before this file existed: `socioprophet-web/app-vue/src/ + * services/url-safe.ts` (SocioProphet/socioprophet#477, the Search.vue click-XSS) and + * `socioprophet-web/client-vue/src/utils/urlSafe.ts` (SocioProphet/socioprophet#550, the + * BoardTable evidence-link hardening) — copy-pasted rather than shared because the two + * Vue packages do not share a workspace. Same logic both times; this is that logic, once. + */ + +// The extras a caller may opt into. Everything not in this set is silently ignored +// when passed in `extra`, so `isSafeHttp(x, ['javascript'])` still returns false for +// a `javascript:` URL — the deny-list on executable schemes is ABSOLUTE. +const SAFE_EXTRAS: ReadonlySet = new Set(['mailto', 'tel']); + +export function isSafeHttp(url: unknown, extra: readonly string[] = []): boolean { + if (typeof url !== 'string') return false; + // Leading whitespace or control characters (0x00-0x20) historically let some + // browsers strip them and re-parse a URL as its trailing scheme — a payload of + // `"\tjavascript:..."` was executable in older Chromium and remains inconsistently + // handled. If the caller wanted a URL it does not start with whitespace. + if (/^[\s\x00-\x20]/.test(url)) return false; + const m = /^([a-z][a-z0-9+.-]*):/i.exec(url); + if (!m) return false; + const s = m[1].toLowerCase(); + if (s === 'http' || s === 'https') return true; + // Normalise the extras callers pass — lowercase + trim — so a callsite passing + // `['Mailto']` or `['MAILTO ']` is not mysteriously refused. The SAFE_EXTRAS filter + // is what keeps this from becoming an XSS vector: only schemes in the fixed + // safe-extras set can be enabled, regardless of what the caller passed in. + for (const raw of extra) { + if (typeof raw !== 'string') continue; + const norm = raw.trim().toLowerCase(); + if (norm === s && SAFE_EXTRAS.has(norm)) return true; + } + return false; +} diff --git a/estate-safety-kit/py/bounded_int.py b/estate-safety-kit/py/bounded_int.py new file mode 100644 index 0000000..3b3a305 --- /dev/null +++ b/estate-safety-kit/py/bounded_int.py @@ -0,0 +1,114 @@ +"""Bounded non-negative integer / bounded-payload validation — generalized. + +ESTATE SAFETY KIT — canonical source. See ../PROVENANCE.md for what this closes, which +repo hit the defect first, and the vendoring contract every consumer must follow. Do not +edit a vendored copy directly — edit this file, re-vendor, done. + +THE DEFECT, first caught in prophet-platform's compute-gateway exhaust guard +(`apps/compute-gateway/src/compute_gateway/engine.py`, closed in PR #1118 after an +adversarial review of PR #1104 flagged it): "must be a non-negative int" is a type/sign +check, NOT a size bound, because Python ints are arbitrary precision. + + def _bad_nonneg_int(v) -> bool: + return isinstance(v, bool) or not isinstance(v, int) or v < 0 + +passes `isinstance(v, int) and v >= 0` for a 1.9-million-digit integer just fine. A field +declared "a non-negative int" that is actually unbounded is exactly the "dimension nobody +enumerated" shape prophet-platform#1071 warned about generally: every individual field +check was declarative, not enforcing, and only an aggregate JSON-size backstop caught the +smuggled payload — which means a caller who trips it gets a catch-all reason with no idea +which field regressed. + +THE FIX: an explicit upper bound alongside the type/sign check, applied to every int field +a validator touches — not just the ones someone remembered while writing the schema. +`MAX_NONNEG_INT = 2**63 - 1` (int64 max) is the default: above any realistic byte count or +event tally (it exceeds exabyte scale), but small enough to refuse a digit-encoded blob +dressed as a "number". Callers with a tighter natural ceiling (a page count, a retry count) +should pass their own `max_value` — the default is a sane BACKSTOP, not a recommendation +that every field actually needs 63 bits of headroom. + +WHY THIS MATTERS BEYOND ONE FIELD: the same "type check without a size bound" shape closes +over labels and free-text too (a "short label string" with no `max_len` is not bounded), so +this module also carries `bad_bounded_str` / `check_bounded_str` for that half of the same +defect class, and `check_total_size` as the deliberately-last aggregate backstop — sized by +measurement of the legitimate ceiling, not guessed, and consulted last so it does not +restate the per-field assumptions it exists to catch when they are wrong. +""" +from __future__ import annotations + +__all__ = [ + "MAX_NONNEG_INT", + "bad_nonneg_int", + "check_nonneg_int", + "bad_bounded_str", + "check_bounded_str", + "check_total_size", +] + +# int64 max. See module docstring for the sizing rationale. +MAX_NONNEG_INT: int = 2**63 - 1 + + +def bad_nonneg_int(v: object, *, max_value: int = MAX_NONNEG_INT) -> bool: + """Return True if `v` is NOT an acceptable bounded non-negative int. + + `bool` is excluded first — `isinstance(True, int)` is True in Python, so a caller + passing a bool through a field typed "int" would otherwise silently pass. + """ + if isinstance(v, bool) or not isinstance(v, int) or v < 0: + return True + return v > max_value + + +def check_nonneg_int(v: object, field: str, *, max_value: int = MAX_NONNEG_INT) -> str | None: + """Return None if `v` passes for `field`, else a field-scoped reason string — never a + catch-all. Mirrors the reason-string shape compute-gateway's callers already parse: + " must be a bounded non-negative int (<= )".""" + if bad_nonneg_int(v, max_value=max_value): + return f"{field} must be a bounded non-negative int (<= {max_value})" + return None + + +def bad_bounded_str(v: object, *, max_len: int, allow_empty: bool = True) -> bool: + """Return True if `v` is NOT an acceptable bounded string: must be `str`, within + `max_len`, and non-empty unless `allow_empty=True`. The same "type check with no size + bound" defect applies to strings — a label field with no `max_len` is not bounded + just because it is typed `str`.""" + if not isinstance(v, str): + return True + if not allow_empty and len(v) == 0: + return True + return len(v) > max_len + + +def check_bounded_str(v: object, field: str, *, max_len: int, allow_empty: bool = True) -> str | None: + """Return None if `v` passes for `field`, else a field-scoped reason string.""" + if bad_bounded_str(v, max_len=max_len, allow_empty=allow_empty): + return f"{field} must be a bounded string (<= {max_len} chars)" + return None + + +def check_total_size(payload: object, *, max_bytes: int, dumps=None) -> str | None: + """Aggregate backstop: serialize `payload` (JSON by default) and refuse it over + `max_bytes`. Deliberately the LAST check a caller should run, and deliberately NOT + derived from the per-field bounds above — deriving it would make it restate the same + assumptions it exists to catch when one of them is wrong or missing. Closes the + dimensions nobody enumerated a per-field bound for at all, at the cost of a reason + that cannot say which field is at fault (that imprecision is the tradeoff for being + the check that still fires when a per-field bound was simply never written). + + `dumps` is injectable so a caller already serializing the payload for other reasons + (or wanting non-JSON accounting) is not forced to serialize it twice with a different + encoder; defaults to `json.dumps(payload, separators=(",", ":"))` (compact, matching + what would actually go over the wire). + """ + if dumps is None: + import json as _json + + def dumps(p: object) -> str: + return _json.dumps(p, separators=(",", ":")) + + size = len(dumps(payload).encode("utf-8")) + if size > max_bytes: + return f"payload is {size} bytes, exceeds the {max_bytes}-byte aggregate cap" + return None diff --git a/estate-safety-kit/py/test_bounded_int.py b/estate-safety-kit/py/test_bounded_int.py new file mode 100644 index 0000000..37a7733 --- /dev/null +++ b/estate-safety-kit/py/test_bounded_int.py @@ -0,0 +1,142 @@ +"""Pins the bounded-int / bounded-payload validators against the exact defect they close: +a type/sign check that Python's arbitrary-precision ints make unbounded in practice. + +Ported from prophet-platform's `apps/compute-gateway/tests/test_gateway_hardening.py` +(the digit-encoded-payload tests added in PR #1118) plus new tests for the generalized +string and aggregate-size helpers this module adds beyond the original single-purpose +`_bad_nonneg_int`. +""" +from __future__ import annotations + +import sys + +import pytest + +from bounded_int import ( + MAX_NONNEG_INT, + bad_nonneg_int, + check_nonneg_int, + bad_bounded_str, + check_bounded_str, + check_total_size, +) + + +def _giant_int_from_digits(mb: float) -> int: + """A decimal-digit-encoded int of ~`mb` megabytes — the exact smuggling shape #1118 + closed. Python 3.11+'s own PYTHONINTMAXSTRDIGITS guard (default 4300) already blocks + constructing this from a literal at the default limit, so bump it locally, mirroring + the upstream fixture.""" + digits = int(mb * 1_000_000) + if sys.get_int_max_str_digits() < digits + 64: + sys.set_int_max_str_digits(digits + 64) + return int("9" * digits) + + +class TestBadNonnegInt: + def test_negative_is_bad(self): + assert bad_nonneg_int(-1) is True + + def test_bool_is_bad_even_though_isinstance_int_is_true(self): + # isinstance(True, int) is True in Python — bool must be excluded explicitly, + # or a caller passing True/False through an "int" field silently passes. + assert bad_nonneg_int(True) is True + assert bad_nonneg_int(False) is True + + def test_non_int_is_bad(self): + assert bad_nonneg_int("3") is True + assert bad_nonneg_int(3.0) is True + assert bad_nonneg_int(None) is True + + def test_ordinary_values_pass(self): + assert bad_nonneg_int(0) is False + assert bad_nonneg_int(42) is False + assert bad_nonneg_int(MAX_NONNEG_INT) is False + + def test_over_max_is_bad(self): + assert bad_nonneg_int(MAX_NONNEG_INT + 1) is True + + def test_petabyte_scale_int_accepted(self): + # The fix must not become a limit on legitimate usage — 10**15 is petabyte-scale, + # well over any realistic byte tally, well under the int64 ceiling. + assert bad_nonneg_int(10**15) is False + + def test_digit_encoded_giant_int_rejected_by_the_field_bound(self): + # The exact defect: a 1.9-MB decimal-digit int passes isinstance(v, int) and + # v >= 0. Must be rejected BY THE UPPER BOUND, which is what this whole module + # exists to add. + giant = _giant_int_from_digits(1.9) + assert bad_nonneg_int(giant) is True + + def test_custom_max_value_is_honoured(self): + assert bad_nonneg_int(101, max_value=100) is True + assert bad_nonneg_int(100, max_value=100) is False + + +class TestCheckNonnegInt: + def test_passes_returns_none(self): + assert check_nonneg_int(42, "bytesIn") is None + + def test_fails_returns_field_scoped_reason(self): + reason = check_nonneg_int(-1, "bytesIn") + assert reason is not None + assert "bytesIn" in reason + assert "bounded non-negative int" in reason + + def test_digit_encoded_payload_named_by_field_not_a_catch_all(self): + giant = _giant_int_from_digits(1.9) + reason = check_nonneg_int(giant, "counts.dropped") + assert reason is not None + assert "counts.dropped must be a bounded non-negative int" in reason + + +class TestBoundedStr: + def test_over_max_len_is_bad(self): + assert bad_bounded_str("x" * 300, max_len=256) is True + + def test_within_max_len_passes(self): + assert bad_bounded_str("x" * 256, max_len=256) is False + + def test_non_str_is_bad(self): + assert bad_bounded_str(123, max_len=256) is True + + def test_empty_rejected_when_disallowed(self): + assert bad_bounded_str("", max_len=256, allow_empty=False) is True + assert bad_bounded_str("", max_len=256, allow_empty=True) is False + + def test_check_bounded_str_reason(self): + reason = check_bounded_str("x" * 999, "adapter", max_len=256) + assert reason is not None + assert "adapter" in reason + + +class TestCheckTotalSize: + def test_small_payload_passes(self): + assert check_total_size({"a": 1}, max_bytes=1024) is None + + def test_oversized_payload_fails_with_byte_count(self): + payload = {"blob": "x" * 5000} + reason = check_total_size(payload, max_bytes=1024) + assert reason is not None + assert "aggregate cap" in reason + + def test_catches_a_payload_legal_per_field_but_illegal_in_aggregate(self): + # The whole point of the aggregate backstop: many individually-legal fields can + # still sum to a real payload. 10_000 entries * two 256-char strings, each within + # a plausible per-field bound, is still ~5MB in aggregate. + payload = {"items": [{"a": "x" * 256, "b": "y" * 256} for _ in range(10_000)]} + assert check_total_size(payload, max_bytes=2 * 1024 * 1024) is not None + + def test_injectable_dumps(self): + calls = [] + + def fake_dumps(p): + calls.append(p) + return "{}" + + assert check_total_size({"x": 1}, max_bytes=10, dumps=fake_dumps) is None + assert calls == [{"x": 1}] + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/estate-safety-kit/tools/check_vendored_safety_kit.py b/estate-safety-kit/tools/check_vendored_safety_kit.py new file mode 100644 index 0000000..8d230d5 --- /dev/null +++ b/estate-safety-kit/tools/check_vendored_safety_kit.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Assert a vendored estate-safety-kit copy is byte-identical to the canonical source it +claims to be pinned to. + +The consumer-side half of the vendoring contract described in ../PROVENANCE.md: each +consumer (socioprophet's app-vue/client-vue, prophet-platform's health-twin, ...) copies a +file from this kit VERBATIM and records a `PROVENANCE.txt` alongside it naming the source +repo, path, and commit SHA it was copied from. A `version` field, or a comment saying "kept +in sync", is not evidence — the only evidence that a vendored copy is still what it claims +to be is the bytes matching what is actually at that pinned commit. This is the estate's +existing vendor-freshness discipline (see prophet-platform's +`tools/assert_vendored_engine_marker.py`), applied to source files instead of a tarball +member: same shape, byte comparison instead of marker-substring containment, because a +small source file can be diffed exactly rather than merely probed for a discriminating +string. + +Two sources for the canonical bytes, so this runs the same way locally and in CI: + + --source-root PATH read `PATH/` directly (a local sourceos-spec checkout, + e.g. this repo, or a CI job that actions/checkout's it as a sibling). + Ignores whatever commit that checkout currently has PATH at — it + reads the working tree as-is, so pass a checkout that is actually + AT `source-commit` (or accept this as "does the file match what is + on disk right now", a looser but zero-network check). + (no --source-root) fetch `https://raw.githubusercontent.com// + /` over the network. This is the one that actually + proves the pin (the exact historical commit), and is what CI should + run. + +Usage: + check_vendored_safety_kit.py [--provenance PROVENANCE.txt] + [--source-root PATH] [--timeout SECONDS] + +PROVENANCE.txt is `key: value` lines (see ../PROVENANCE.md for the full contract): + source-repo: SourceOS-Linux/sourceos-spec + source-path: estate-safety-kit/js/urlSafe.ts + source-commit: <40-hex sha> + vendored-path: (informational; not required to match the CLI argument) + +Exit 0 and print a receipt on success; exit 1 with the reason on failure. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import urllib.request +from pathlib import Path +from urllib.error import HTTPError, URLError + +# A vendored safety-kit file is source code measured in KB, not a bundle. 4 MiB is +# generous headroom over anything this kit will ever hold and still bounds a fetch of +# unverified-until-hashed bytes, matching the discipline in assert_vendored_engine_marker.py +# (never read a not-yet-trusted artifact unbounded into memory). +MAX_FETCH_BYTES = 4 * 1024 * 1024 + +_SHA_RE_LEN = 40 # git commit SHAs this tool accepts are full, not abbreviated — + # an abbreviated SHA is not a stable pin (it can become ambiguous + # as the repo grows) and silently truncating to it here would let + # a PROVENANCE.txt entry look pinned when it is not. + + +def parse_provenance(path: Path) -> dict[str, str]: + if not path.exists(): + raise SystemExit(f"ERR: provenance file not found: {path}") + fields: dict[str, str] = {} + for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + raise SystemExit(f"ERR: {path}:{lineno}: not a 'key: value' line: {raw!r}") + key, _, value = line.partition(":") + fields[key.strip()] = value.strip() + for required in ("source-repo", "source-path", "source-commit"): + if not fields.get(required): + raise SystemExit(f"ERR: {path} is missing required field {required!r}") + commit = fields["source-commit"] + if len(commit) != _SHA_RE_LEN or not all(c in "0123456789abcdef" for c in commit.lower()): + raise SystemExit( + f"ERR: {path} source-commit {commit!r} is not a full 40-hex commit SHA — " + "an abbreviated or symbolic ref is not a stable pin" + ) + return fields + + +def fetch_canonical(fields: dict[str, str], *, source_root: Path | None, timeout: float) -> bytes: + if source_root is not None: + p = source_root / fields["source-path"] + if not p.exists(): + raise SystemExit(f"ERR: {p} does not exist under --source-root {source_root}") + data = p.read_bytes() + if len(data) > MAX_FETCH_BYTES: + raise SystemExit(f"ERR: {p} is {len(data)} bytes, over the {MAX_FETCH_BYTES}-byte cap — refusing to read") + return data + + url = ( + f"https://raw.githubusercontent.com/{fields['source-repo']}/" + f"{fields['source-commit']}/{fields['source-path']}" + ) + req = urllib.request.Request(url, headers={"Accept": "text/plain"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read(MAX_FETCH_BYTES + 1) + except HTTPError as exc: + raise SystemExit(f"ERR: fetching {url} failed: HTTP {exc.code}") + except URLError as exc: + raise SystemExit(f"ERR: fetching {url} failed: {exc.reason}") + if len(data) > MAX_FETCH_BYTES: + raise SystemExit(f"ERR: {url} is over the {MAX_FETCH_BYTES}-byte cap — refusing to read") + return data + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("vendored_file", type=Path) + ap.add_argument("--provenance", type=Path, default=None, + help="defaults to PROVENANCE.txt next to the vendored file") + ap.add_argument("--source-root", type=Path, default=None, + help="read the canonical file from a local checkout instead of fetching over the network") + ap.add_argument("--timeout", type=float, default=15.0) + args = ap.parse_args(argv) + + if not args.vendored_file.exists(): + print(f"ERR: vendored file not found: {args.vendored_file}", file=sys.stderr) + return 1 + + provenance_path = args.provenance or (args.vendored_file.parent / "PROVENANCE.txt") + fields = parse_provenance(provenance_path) + + vendored_bytes = args.vendored_file.read_bytes() + canonical_bytes = fetch_canonical(fields, source_root=args.source_root, timeout=args.timeout) + + vendored_digest = hashlib.sha256(vendored_bytes).hexdigest() + canonical_digest = hashlib.sha256(canonical_bytes).hexdigest() + drifted = vendored_bytes != canonical_bytes + + receipt = { + "tool": "sourceos-spec.check_vendored_safety_kit.v1", + "vendored_file": str(args.vendored_file), + "provenance_file": str(provenance_path), + "source_repo": fields["source-repo"], + "source_path": fields["source-path"], + "source_commit": fields["source-commit"], + "vendored_sha256": vendored_digest, + "canonical_sha256": canonical_digest, + "byte_identical": not drifted, + "checked_against": "local --source-root" if args.source_root else "raw.githubusercontent.com (network)", + "non_claims": [ + "Proves byte-identity to the file at the pinned commit; does NOT judge whether " + "that commit is itself trustworthy or current — see PROVENANCE.md's re-vendoring " + "section for that.", + ], + } + print(json.dumps(receipt, indent=2, sort_keys=True)) + if drifted: + print( + f"ERR: {args.vendored_file} has DRIFTED from {fields['source-repo']}@" + f"{fields['source-commit']}:{fields['source-path']} — re-vendor the file or " + "update the pin, do not hand-edit the vendored copy", + file=sys.stderr, + ) + return 1 + print(f"OK: {args.vendored_file} is byte-identical to the pinned source", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 78f5cf69d231ef5d2c9bd6df45bdf500e7a97ced Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:51 -0400 Subject: [PATCH 2/2] fix(estate-safety-kit): correct doc-comment path + PR citation before vendoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical file docstrings said "See ../PROVENANCE.md", a path that is only valid inside this repo — once a consumer vendors the file verbatim, that relative path is wrong wherever it lands. Point at the repo-qualified location instead so the comment stays correct in every vendored copy. Also corrected the bounded_int.py citation: PR #1104 (found while drafting) is prophet-platform's GitHub Actions SHA-pinning PR, unrelated to the exhaust guard — the actual chain is #1067 (introduced) -> #1071 (open door found) -> #1118 (this fix). Caught before anyone reviewed it; not relying on an unverified citation in a PR body. --- estate-safety-kit/js/mintId.ts | 3 ++- estate-safety-kit/js/urlSafe.ts | 3 ++- estate-safety-kit/py/bounded_int.py | 15 +++++++++------ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/estate-safety-kit/js/mintId.ts b/estate-safety-kit/js/mintId.ts index f828302..d0ccaed 100644 --- a/estate-safety-kit/js/mintId.ts +++ b/estate-safety-kit/js/mintId.ts @@ -1,7 +1,8 @@ /** * ONE decision about where an identifier comes from, for every ledger that mints one. * - * ESTATE SAFETY KIT — canonical source. See ../PROVENANCE.md for what this closes, + * ESTATE SAFETY KIT — canonical source (SourceOS-Linux/sourceos-spec's estate-safety-kit/). + * See estate-safety-kit/PROVENANCE.md in that repo for what this closes, * which repos hit the defect independently, and the vendoring contract every consumer * must follow. Do not edit a vendored copy directly — edit this file, re-vendor, done. * diff --git a/estate-safety-kit/js/urlSafe.ts b/estate-safety-kit/js/urlSafe.ts index d64a6db..fc648d6 100644 --- a/estate-safety-kit/js/urlSafe.ts +++ b/estate-safety-kit/js/urlSafe.ts @@ -1,7 +1,8 @@ /** * Scheme allow-list for URLs whose source is not the app rendering them. * - * ESTATE SAFETY KIT — canonical source. See ../PROVENANCE.md for what this closes, + * ESTATE SAFETY KIT — canonical source (SourceOS-Linux/sourceos-spec's estate-safety-kit/). + * See estate-safety-kit/PROVENANCE.md in that repo for what this closes, * which repos hit the defect independently, and the vendoring contract every consumer * must follow (verbatim copy + PROVENANCE.txt + `tools/check_vendored_safety_kit.py`). * Do not edit a vendored copy directly — edit this file, re-vendor, done. diff --git a/estate-safety-kit/py/bounded_int.py b/estate-safety-kit/py/bounded_int.py index 3b3a305..6c883d8 100644 --- a/estate-safety-kit/py/bounded_int.py +++ b/estate-safety-kit/py/bounded_int.py @@ -1,13 +1,16 @@ """Bounded non-negative integer / bounded-payload validation — generalized. -ESTATE SAFETY KIT — canonical source. See ../PROVENANCE.md for what this closes, which -repo hit the defect first, and the vendoring contract every consumer must follow. Do not -edit a vendored copy directly — edit this file, re-vendor, done. +ESTATE SAFETY KIT — canonical source (SourceOS-Linux/sourceos-spec's estate-safety-kit/). +See estate-safety-kit/PROVENANCE.md in that repo for what this closes, which repo hit the +defect first, and the vendoring contract every consumer must follow. Do not edit a +vendored copy directly — edit this file, re-vendor, done. THE DEFECT, first caught in prophet-platform's compute-gateway exhaust guard -(`apps/compute-gateway/src/compute_gateway/engine.py`, closed in PR #1118 after an -adversarial review of PR #1104 flagged it): "must be a non-negative int" is a type/sign -check, NOT a size bound, because Python ints are arbitrary precision. +(`apps/compute-gateway/src/compute_gateway/engine.py`): introduced in PR #1067, an open +door in it found in PR #1071, and the specific gap this module closes — an int field +bounded only by type and sign, not size — found and fixed in PR #1118. +"must be a non-negative int" is a type/sign check, NOT a size bound, because Python ints +are arbitrary precision. def _bad_nonneg_int(v) -> bool: return isinstance(v, bool) or not isinstance(v, int) or v < 0