@blamejs/blamejs-shop 0.3.40 → 0.3.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +14 -3
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +8 -0
- package/lib/vendor/blamejs/README.md +8 -5
- package/lib/vendor/blamejs/SECURITY.md +7 -0
- package/lib/vendor/blamejs/api-snapshot.json +266 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +7 -1
- package/lib/vendor/blamejs/lib/agent-idempotency.js +113 -0
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +108 -0
- package/lib/vendor/blamejs/lib/agent-snapshot.js +137 -0
- package/lib/vendor/blamejs/lib/agent-tenant.js +193 -17
- package/lib/vendor/blamejs/lib/ai-input.js +167 -3
- package/lib/vendor/blamejs/lib/ai-output.js +463 -0
- package/lib/vendor/blamejs/lib/ai-prompt.js +304 -0
- package/lib/vendor/blamejs/lib/archive-wrap.js +234 -1
- package/lib/vendor/blamejs/lib/archive.js +1 -0
- package/lib/vendor/blamejs/lib/audit.js +3 -0
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +47 -28
- package/lib/vendor/blamejs/lib/cluster.js +186 -14
- package/lib/vendor/blamejs/lib/codepoint-class.js +18 -0
- package/lib/vendor/blamejs/lib/compliance-ai-act.js +446 -0
- package/lib/vendor/blamejs/lib/consent.js +104 -8
- package/lib/vendor/blamejs/lib/content-credentials.js +851 -41
- package/lib/vendor/blamejs/lib/crypto-field.js +5 -0
- package/lib/vendor/blamejs/lib/db.js +15 -0
- package/lib/vendor/blamejs/lib/framework-error.js +21 -0
- package/lib/vendor/blamejs/lib/mail-srs.js +122 -19
- package/lib/vendor/blamejs/lib/privacy.js +168 -0
- package/lib/vendor/blamejs/lib/safe-archive.js +196 -136
- package/lib/vendor/blamejs/lib/validate-opts.js +24 -0
- package/lib/vendor/blamejs/lib/vault/rotate.js +175 -15
- package/lib/vendor/blamejs/lib/vault-aad.js +84 -33
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.11.json +72 -0
- package/lib/vendor/blamejs/release-notes/v0.14.12.json +95 -0
- package/lib/vendor/blamejs/release-notes/v0.14.13.json +52 -0
- package/lib/vendor/blamejs/release-notes/v0.14.14.json +31 -0
- package/lib/vendor/blamejs/test/00-primitives.js +9 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +103 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +91 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +186 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +140 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +59 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-output.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-prompt.test.js +133 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +6 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-vault-rotation.test.js +243 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +130 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/consent-purposes.test.js +70 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +289 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-srs.test.js +61 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/privacy-vendor-review.test.js +69 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate-aad.test.js +158 -0
- package/package.json +1 -1
|
@@ -2554,6 +2554,23 @@ async function testNoDuplicateCodeBlocks() {
|
|
|
2554
2554
|
// so the audit trail records exactly which body of code shares the
|
|
2555
2555
|
// shape.
|
|
2556
2556
|
var KNOWN_CLUSTERS = [
|
|
2557
|
+
{
|
|
2558
|
+
mode: "family-subset",
|
|
2559
|
+
files: [
|
|
2560
|
+
"lib/agent-idempotency.js:_checkArgs",
|
|
2561
|
+
"lib/agent-tenant.js:_sealField",
|
|
2562
|
+
"lib/archive-wrap.js:_tenantKeyWithRoot",
|
|
2563
|
+
"lib/atomic-file.js:copyDirRecursive",
|
|
2564
|
+
"lib/ddl-change-control.js:approve",
|
|
2565
|
+
"lib/ddl-change-control.js:reject",
|
|
2566
|
+
"lib/deprecate.js:alias",
|
|
2567
|
+
"lib/guard-filename.js:verifyExtractionPath",
|
|
2568
|
+
"lib/jose-jwe-experimental.js:decrypt",
|
|
2569
|
+
"lib/mail-deploy.js:_validateTlsRptReport",
|
|
2570
|
+
"lib/totp.js:uri",
|
|
2571
|
+
],
|
|
2572
|
+
reason: "v0.14.12 — generic validate/derive/byte-walk control-flow shingle the vault-rotation reseal work tipped over the 3-file threshold. Members are unrelated primitives (agent-idempotency arg-check, agent-tenant per-tenant AEAD seal, archive-wrap explicit-root tenant-key derive, atomic-file recursive dir-copy, ddl-change-control dual-control approve/reject, deprecate alias, guard-filename path-segment safety walk, jose-jwe decrypt, mail-deploy TLS-RPT validate, totp otpauth URI build). No shared behaviour to extract; consolidating would couple ten unrelated subsystems.",
|
|
2573
|
+
},
|
|
2557
2574
|
{
|
|
2558
2575
|
files: ["lib/api-key.js:issue", "lib/db-query.js:<top>", "lib/session.js:create"],
|
|
2559
2576
|
reason: "Generic JS array helper / lambda shape — Object.keys(...).map(fn) + similar functional idioms appearing in any code that walks a column-or-key list.",
|
|
@@ -6302,6 +6319,239 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
6302
6319
|
reason: "Codex P1A on v0.12.12 PR #163 — ai-disclosure.chatbot's on-request placement returned shouldEmit=true unconditionally, breaking the three-mode placement contract. Detector locks the static shape so a future placement primitive in ai-disclosure.js can't regress.",
|
|
6303
6320
|
},
|
|
6304
6321
|
|
|
6322
|
+
{
|
|
6323
|
+
// b.ai.output.sanitize neutralizes the EchoLeak markdown-image /
|
|
6324
|
+
// link zero-click exfiltration class (CVE-2025-32711) by gating
|
|
6325
|
+
// every extracted URL through b.safeUrl.parse (scheme + credential)
|
|
6326
|
+
// AND b.ssrfGuard.classify (internal / loopback / link-local /
|
|
6327
|
+
// cloud-metadata IP-range). The bug class this locks: a future edit
|
|
6328
|
+
// that extracts a URL from model output in lib/ai-output.js (a
|
|
6329
|
+
// markdown image / link / reference URL or an HTML src / href) but
|
|
6330
|
+
// drops the SSRF gate would re-open auto-fetch to an internal /
|
|
6331
|
+
// metadata host. The file-scoped invariant: ai-output.js MUST
|
|
6332
|
+
// continue to compose ssrfGuard.classify. (safe-url alone is NOT
|
|
6333
|
+
// sufficient — it does protocol-allowlist + userinfo + IDN, but
|
|
6334
|
+
// does NOT classify 169.254.169.254 / RFC1918 / loopback.) If
|
|
6335
|
+
// ai-output.js ever stops gating URLs (e.g. the primitive is
|
|
6336
|
+
// removed), carry an `allow:ai-output-url-ssrf-gate` marker with the
|
|
6337
|
+
// reason.
|
|
6338
|
+
id: "ai-output-markdown-url-without-ssrf-gate",
|
|
6339
|
+
primitive: "lib/ai-output.js sanitize() MUST gate every URL extracted from model output (markdown image / link / reference + HTML src/href) through BOTH b.safeUrl.parse (scheme + credential refusal) AND b.ssrfGuard.classify (internal / loopback / link-local / cloud-metadata IP-range refusal) — the EchoLeak zero-click exfiltration class (CVE-2025-32711). safe-url alone does not classify private / metadata IPs; ssrfGuard is the IP-range layer. Dropping the ssrfGuard composition re-opens auto-fetch to an attacker / metadata host.",
|
|
6340
|
+
// File-scoped via a content shape unique to ai-output.js: its
|
|
6341
|
+
// markdown-image URL extractor variable (MD_IMAGE_RE). When that
|
|
6342
|
+
// extractor is present, the file MUST also reference
|
|
6343
|
+
// ssrfGuard.classify so the IP-range gate can't be silently
|
|
6344
|
+
// dropped. Other lib files that carry a `![` literal (e.g.
|
|
6345
|
+
// ai-input.js's markdown-injection pattern) don't define
|
|
6346
|
+
// MD_IMAGE_RE, so the detector is precise to the output gate.
|
|
6347
|
+
regex: /\bMD_IMAGE_RE\b/,
|
|
6348
|
+
requires: /ssrfGuard\.classify|allow:ai-output-url-ssrf-gate/,
|
|
6349
|
+
skipCommentLines: true,
|
|
6350
|
+
allowlist: [],
|
|
6351
|
+
reason: "b.ai.output.sanitize (v0.14.11) defends OWASP LLM05:2025 improper output handling. The EchoLeak markdown-image exfiltration class (CVE-2025-32711, Microsoft 365 Copilot, CVSS 9.3) coerces the model via indirect prompt injection to emit ; the client auto-fetches it zero-click. The defense gates every output URL through safeUrl.parse + ssrfGuard.classify so internal / cloud-metadata targets are neutralized. Detector locks the composition: a future edit to ai-output.js that handles markdown-image URLs without ssrfGuard.classify trips the gate so the SSRF layer can't be silently dropped.",
|
|
6352
|
+
},
|
|
6353
|
+
|
|
6354
|
+
|
|
6355
|
+
{
|
|
6356
|
+
// b.ai.input.classifyWithSources (v0.14.11) applies a TIER-RELATIVE
|
|
6357
|
+
// injection threshold to retrieval-augmented (RAG) sources: untrusted
|
|
6358
|
+
// / internal sources escalate on a single severity-2 or any
|
|
6359
|
+
// severity-3 signal, where the direct prompt keeps classify()'s
|
|
6360
|
+
// 2-severity-2 threshold. The bug class this locks: a handler in lib/
|
|
6361
|
+
// that maps b.ai.input.classify over a sources array on its own loses
|
|
6362
|
+
// that tier-relative threshold (and the worst-of aggregate +
|
|
6363
|
+
// tainted-source bookkeeping), re-opening the indirect prompt-
|
|
6364
|
+
// injection gap (OWASP LLM01:2025) that retrieved data must be
|
|
6365
|
+
// classified MORE strictly than operator input. The fix is to compose
|
|
6366
|
+
// classifyWithSources, which owns the per-tier escalation. A file that
|
|
6367
|
+
// legitimately maps classify over elements is classifyWithSources's
|
|
6368
|
+
// own module (it names the symbol) — cleared by the `requires`
|
|
6369
|
+
// companion. The lookbehind excludes a `function classify(` decl that
|
|
6370
|
+
// happens to follow an unrelated .map() in the same file.
|
|
6371
|
+
id: "rag-source-classify-without-classifywithsources",
|
|
6372
|
+
primitive: "to classify retrieval-augmented (RAG) sources alongside a prompt, compose b.ai.input.classifyWithSources — do NOT map b.ai.input.classify over a sources array by hand. classifyWithSources applies a tier-relative threshold (untrusted / internal sources escalate on a single severity-2 / any severity-3, vs classify's 2-severity-2 baseline) and computes the worst-of aggregate + tainted-source set. Mapping classify directly loses the stricter retrieved-data threshold (OWASP LLM01:2025 indirect prompt injection; CVE-2025-32711 EchoLeak).",
|
|
6373
|
+
regex: /\.map\(\s*(?:function\s*\([^)]*\)\s*\{|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)[\s\S]{0,60}?(?<!function\s)\bclassify\s*\(/,
|
|
6374
|
+
requires: /classifyWithSources|allow:rag-source-classify-without-classifywithsources/,
|
|
6375
|
+
skipCommentLines: true,
|
|
6376
|
+
allowlist: [],
|
|
6377
|
+
reason: "b.ai.input.classifyWithSources (v0.14.11) defends OWASP LLM01:2025 indirect prompt injection — retrieved RAG context is an attacker-influenceable channel (CVE-2025-32711 EchoLeak, CVSS 9.3; NIST AI 600-1 information-integrity). Mapping b.ai.input.classify over sources by hand applies the operator-prompt threshold to retrieved data, which is too permissive: a single severity-2 fragment in an untrusted document should escalate. Detector locks the composition so a future handler that re-rolls the per-source loop without classifyWithSources trips the gate. No pre-existing lib site maps classify over an array; allowlist is empty.",
|
|
6378
|
+
},
|
|
6379
|
+
|
|
6380
|
+
{
|
|
6381
|
+
// b.ai.prompt.template (v0.14.11) fences untrusted context / user
|
|
6382
|
+
// segments with a PER-RENDER crypto-nonce delimiter
|
|
6383
|
+
// (<<UNTRUSTED:role:NONCE>> ... <<END:role:NONCE>>) and strips any
|
|
6384
|
+
// forged occurrence of the active nonce before wrapping, so untrusted
|
|
6385
|
+
// content cannot close the boundary and break into the control plane
|
|
6386
|
+
// (spotlighting / datamarking, Microsoft 2024; NIST AI 100-2e2025;
|
|
6387
|
+
// OWASP LLM01:2025 indirect prompt injection). The bug class this
|
|
6388
|
+
// locks: a prompt-assembly site in lib/ that wraps untrusted content
|
|
6389
|
+
// in a FIXED / guessable literal fence (<user_input> ... </user_input>,
|
|
6390
|
+
// </context>, [DATA]) the attacker can simply emit to terminate. The
|
|
6391
|
+
// regex fires on a fence LITERAL used as a wrapper — concatenated with
|
|
6392
|
+
// a variable ("<user_input>" + x / x + "</user_input>") or carrying a
|
|
6393
|
+
// template interpolation (`<user_input>${...}`). It deliberately does
|
|
6394
|
+
// NOT match a bare `###` heading (markdown), and clears any file that
|
|
6395
|
+
// mints a `nonce` / calls generateBytes (the correct shape).
|
|
6396
|
+
// ai-prompt.js's own ROLE_CONTROL_TOKENS literals are an escape-target
|
|
6397
|
+
// allowlist, never concatenated as fences, so they don't trip this.
|
|
6398
|
+
id: "ai-prompt-template-fixed-delimiter",
|
|
6399
|
+
primitive: "prompt-assembly that wraps untrusted (context / user) content MUST fence it with a per-render high-entropy delimiter (compose b.ai.prompt.template / b.crypto.generateBytes), NOT a fixed / guessable literal fence (<user_input>, </context>, [DATA]). A static fence is forgeable: the model output emits the close-tag verbatim and breaks into the control plane (OWASP LLM01:2025 indirect prompt injection; spotlighting/datamarking, Microsoft 2024).",
|
|
6400
|
+
regex: /(?:`[^`]*(?:<\/?(?:user_input|context|user|data|untrusted)>|\[\/?(?:DATA|USER|CONTEXT|UNTRUSTED)\])[^`]*\$\{|["'](?:<\/?(?:user_input|context|user|data|untrusted)>|\[\/?(?:DATA|USER|CONTEXT|UNTRUSTED)\])["']\s*\+|\+\s*["'](?:<\/?(?:user_input|context|user|data|untrusted)>|\[\/?(?:DATA|USER|CONTEXT|UNTRUSTED)\])["'])/i,
|
|
6401
|
+
requires: /\bnonce\b|generateBytes|allow:ai-prompt-template-fixed-delimiter/,
|
|
6402
|
+
skipCommentLines: true,
|
|
6403
|
+
allowlist: [],
|
|
6404
|
+
reason: "b.ai.prompt.template (v0.14.11) defends OWASP LLM01:2025 indirect prompt injection by fencing untrusted segments with a per-render crypto nonce the content can't forge (spotlighting/datamarking, Microsoft 2024; NIST AI 100-2e2025). A fixed literal fence (<user_input>...</user_input>, [DATA]...[/DATA]) is forgeable — the model emits the close-tag and escapes the data plane. Detector locks the discipline: any lib prompt-assembly that wraps content in a static fence (literal + variable, or template interpolation) without a per-render nonce trips the gate. ai-prompt.js mints a nonce + calls generateBytes (cleared by requires) and its ROLE_CONTROL_TOKENS literals are an escape-target list, not a wrapper. No pre-existing lib site uses a fixed prompt fence; allowlist is empty.",
|
|
6405
|
+
},
|
|
6406
|
+
|
|
6407
|
+
{
|
|
6408
|
+
// CVE-2025-52556 / CWE-347 (improper signature verification) re-open
|
|
6409
|
+
// guard for the C2PA sigTst2 timestamp countersignature. The ONLY
|
|
6410
|
+
// correct timestamp-verification path is b.tsa.verifyToken, which does
|
|
6411
|
+
// the full RFC 3161 §2.4.2/§2.3 check: the CMS signature over the
|
|
6412
|
+
// signed attributes + the messageDigest recompute + a critical, sole
|
|
6413
|
+
// id-kp-timeStamping EKU — NOT a chain-only shortcut. A bespoke cert-
|
|
6414
|
+
// chain walk (checkIssued / X509Certificate(...).verify) on the
|
|
6415
|
+
// timestamp token in place of tsa.verifyToken accepts a backdated /
|
|
6416
|
+
// forged token whose CMS signature was never checked. Scoped to the
|
|
6417
|
+
// timestamp context (sigTst2 / tstToken / tstContainer /
|
|
6418
|
+
// CounterSignature) so the CAWG identity-assertion x509 chain check
|
|
6419
|
+
// (_verifyIdentityX509Chain), which legitimately walks its own chain,
|
|
6420
|
+
// is not flagged.
|
|
6421
|
+
id: "c2pa-timestamp-bespoke-chain-check",
|
|
6422
|
+
primitive: "verify a C2PA sigTst2 timestamp countersignature through b.tsa.verifyToken (full RFC 3161 CMS-signature + messageDigest + critical-sole-EKU check) — never a bespoke cert-chain-only walk on the timestamp token",
|
|
6423
|
+
regex: /(?:sigTst2|tstToken|tstContainer|CounterSignature)[\s\S]{0,400}?(?:\.checkIssued\s*\(|new\s+(?:nodeCrypto\.)?X509Certificate[\s\S]{0,120}?\.verify\s*\()/,
|
|
6424
|
+
skipCommentLines: true,
|
|
6425
|
+
allowlist: [],
|
|
6426
|
+
reason: "CVE-2025-52556 (RFC 3161 timestamp-validation bypass) / CWE-347 (improper signature verification). The C2PA sigTst2 (RFC 9921) timestamp countersignature in lib/content-credentials.js MUST be verified through b.tsa.verifyToken, which performs the full RFC 3161 §2.4.2/§2.3 check (CMS signature over the signed attributes, messageDigest recompute, critical + sole id-kp-timeStamping EKU). A bespoke cert-chain-only check (checkIssued / X509Certificate(...).verify) on the timestamp token accepts a token whose CMS signature was never verified — a backdating / key-compromise forgery. The detector fires when a chain-walk appears near a timestamp token; route through tsa.verifyToken instead. Scoped to the timestamp context so the CAWG identity x509 chain (_verifyIdentityX509Chain) is not affected.",
|
|
6427
|
+
},
|
|
6428
|
+
|
|
6429
|
+
{
|
|
6430
|
+
// v0.14.11 — the GPAI Code-of-Practice adherence declaration
|
|
6431
|
+
// (b.compliance.aiAct.gpai.declareAdherence) MUST ship inside a
|
|
6432
|
+
// signed CycloneDX 1.6 ML-BOM envelope via b.ai.modelManifest
|
|
6433
|
+
// .build/sign (ML-DSA-87 over canonical-JSON-1785), which carries
|
|
6434
|
+
// the signature-substitution defense in verify. A bare-JSON
|
|
6435
|
+
// adherence emitter that writes the `ai-act:gpai-cop-adherence`
|
|
6436
|
+
// property bag into a transport WITHOUT routing through
|
|
6437
|
+
// modelManifest().sign is a tamper-evidence regression: the
|
|
6438
|
+
// obligation set + per-commitment evidence hashes could be edited
|
|
6439
|
+
// after the fact (CWE-345 / CWE-347). Any lib/ file that emits the
|
|
6440
|
+
// adherence property MUST compose the signed envelope OR carry an
|
|
6441
|
+
// `allow:gpai-adherence-declaration-must-be-signed` marker with the
|
|
6442
|
+
// reason signing is bypassed.
|
|
6443
|
+
id: "gpai-adherence-declaration-must-be-signed",
|
|
6444
|
+
primitive: "any lib/ code that emits the `ai-act:gpai-cop-adherence` property bag MUST route through b.ai.modelManifest.sign (the signed CycloneDX 1.6 ML-BOM envelope) so the GPAI Code-of-Practice declaration is tamper-evident — never serialize a bare adherence JSON to a transport. Bypass requires an explicit `allow:gpai-adherence-declaration-must-be-signed` marker.",
|
|
6445
|
+
regex: /["']ai-act:gpai-cop-adherence["']/,
|
|
6446
|
+
requires: /modelManifest\(\)\.sign|allow:gpai-adherence-declaration-must-be-signed/,
|
|
6447
|
+
skipCommentLines: true,
|
|
6448
|
+
allowlist: [],
|
|
6449
|
+
reason: "v0.14.11 — b.compliance.aiAct.gpai.declareAdherence binds the EU AI Act Art. 53/55 GPAI Code-of-Practice adherence declaration (derived obligation set + per-commitment SHA3-512 evidence hashes) into an ML-DSA-87-signed CycloneDX 1.6 ML-BOM via b.ai.modelManifest.build/sign. The signed envelope is the ONLY emission path on the happy path; verify re-canonicalizes and never trusts embedded signed-bytes (CVE-2025-29774 / CVE-2025-29775 xml-crypto signature-substitution class). This detector locks the contract so a future emitter can't ship a bare unsigned adherence JSON whose obligation set could be silently downgraded after signing."
|
|
6450
|
+
},
|
|
6451
|
+
|
|
6452
|
+
{
|
|
6453
|
+
// A framework module that registers a cryptoField {aad:true} table on an
|
|
6454
|
+
// OPERATOR-SUPPLIED store via the lazy-require form cryptoField().registerTable
|
|
6455
|
+
// (agent-idempotency / agent-orchestrator / agent-tenant) seals AAD cells
|
|
6456
|
+
// OUTSIDE db.enc. The in-tree b.vaultRotate.rotate pipeline only walks tables
|
|
6457
|
+
// inside db.enc, so it CANNOT reach these stores — after a vault-key rotation
|
|
6458
|
+
// every such cell is orphaned under the retired root (CWE-320). Every such
|
|
6459
|
+
// module MUST export an AAD_ROTATION descriptor whose reseal hook rotates the
|
|
6460
|
+
// operator store out-of-band. The detector locks the contract so a new
|
|
6461
|
+
// external-store {aad:true} table can't silently re-introduce the orphan.
|
|
6462
|
+
// The db.init-reachable tables use the direct cryptoField.registerTable form
|
|
6463
|
+
// (no parens) and are rotated by the in-tree pipeline, so they don't match.
|
|
6464
|
+
id: "aad-external-store-table-without-rotation",
|
|
6465
|
+
primitive: "every lib/ module that calls cryptoField().registerTable(...) (the lazy-require form, used only for {aad:true} tables sealed on an operator-supplied store outside db.enc) MUST export an AAD_ROTATION descriptor { table, rowIdField, schemaVersion, backend: \"external\", reseal } whose reseal({ store, oldRootJson, newRootJson }) re-seals every persisted AAD cell old-root -> new-root via vaultAad.resealRoot, rebuilding AAD via cryptoField._aadParts. Without it, a vault-key rotation orphans the store's ciphertext under the retired root.",
|
|
6466
|
+
regex: /cryptoField\(\)\.registerTable/,
|
|
6467
|
+
requires: /AAD_ROTATION|allow:aad-external-store-table-without-rotation/,
|
|
6468
|
+
skipCommentLines: true,
|
|
6469
|
+
allowlist: [],
|
|
6470
|
+
reason: "External-store {aad:true} tables (agent-idempotency 'agent_idempotency', agent-orchestrator 'agent_orchestrator_registry', agent-tenant 'agent_tenant_registry') seal AAD cells on an operator-supplied backend the in-tree b.vaultRotate.rotate pipeline can't reach. Each now exports AAD_ROTATION.reseal so an operator rotates them out-of-band, composing vaultAad.resealRoot + cryptoField._aadParts (one source of truth for the AAD tuple). The detector ensures a future external-store {aad:true} table can't ship without a rotation hook, which would silently orphan its ciphertext under the old vault root after a rotation (CWE-320 cryptographic-key-management failure).",
|
|
6471
|
+
},
|
|
6472
|
+
|
|
6473
|
+
{
|
|
6474
|
+
// A module that declares a root-derived sealed-cell PREFIX FAMILY
|
|
6475
|
+
// (a `<name>-vN:` ciphertext prefix whose per-cell key is derived
|
|
6476
|
+
// from the vault master root — SHA3-512 of b.vault.getKeysJson())
|
|
6477
|
+
// MUST also ship an AAD_ROTATION reseal descriptor so a vault-key
|
|
6478
|
+
// rotation can re-seal every prior cell old-root -> new-root. Without
|
|
6479
|
+
// the reseal path a keypair rotation orphans every cell (decryptable
|
|
6480
|
+
// under neither root). The bug class this locks: a future module adds
|
|
6481
|
+
// a `var X_PREFIX = \"foo-v1:\";` sealed-cell family + an explicit-root
|
|
6482
|
+
// derivation but forgets the reseal hook, silently re-breaking the
|
|
6483
|
+
// \"rotation = re-seal\" promise. agent-tenant.js (tnt-v1:) and
|
|
6484
|
+
// agent-snapshot.js (snap-sealed-v1:) both define the prefix AND export
|
|
6485
|
+
// AAD_ROTATION, so they are cleared by the `requires` companion. vault-
|
|
6486
|
+
// aad.js / vault/rotate.js are the reseal SUBSTRATE, not a prefix
|
|
6487
|
+
// family, so they don't carry the literal and don't trip.
|
|
6488
|
+
id: "root-prefix-family-without-reseal",
|
|
6489
|
+
primitive: "a module that declares a root-derived sealed-cell prefix family (var <NAME>_PREFIX = \"<token>-vN:\" whose per-cell key derives from the vault root via SHA3-512 of b.vault.getKeysJson) MUST also export an AAD_ROTATION descriptor with a reseal({ store, oldRootJson, newRootJson }) hook (composing vaultAad.resealRoot for vault.aad: cells and the explicit-root derived key on both sides for the prefix-family cells). Otherwise a vault-key rotation orphans every prior cell — decryptable under neither root — silently breaking the rotation = re-seal contract.",
|
|
6490
|
+
regex: /\bvar\s+[A-Z][A-Z0-9_]*_PREFIX\s*=\s*"[a-z0-9-]+-v\d+:"/,
|
|
6491
|
+
requires: /AAD_ROTATION|allow:root-prefix-family-without-reseal/,
|
|
6492
|
+
skipCommentLines: true,
|
|
6493
|
+
allowlist: [],
|
|
6494
|
+
reason: "v0.14.x — the two root-derived prefix families (b.agent.tenant tnt-v1: + b.agent.snapshot snap-sealed-v1:) derive their per-cell XChaCha20-Poly1305 key from SHA3-512(b.vault.getKeysJson()); rotating the vault keypair changes the root, so every prior cell must be re-sealed old-root -> new-root via the module's AAD_ROTATION reseal hook (the migration is NOT automatic on read). Detector locks the discipline: any new lib module that declares a `<name>-vN:` sealed-cell prefix family without an AAD_ROTATION reseal path trips the gate. Both current matches export AAD_ROTATION (cleared by requires); allowlist is empty — a future prefix family with no reseal path is a real defect, not allowlist material.",
|
|
6495
|
+
},
|
|
6496
|
+
|
|
6497
|
+
{
|
|
6498
|
+
id: "wrapped-aad-seal-needs-reseal-path",
|
|
6499
|
+
primitive: "a lib module that AAD-seals a value (vault.aad: ciphertext) and wraps it behind its own string-prefix constant before persisting MUST export an AAD_ROTATION descriptor whose reseal() strips the wrapper, resealRoots the inner blob old->new root under the rebuilt AAD, and re-applies the wrapper - a `db.enc` scan for the bare \"vault.aad:\" prefix can't detect or re-key a wrapper-prefixed cell, so vault-key rotation would silently strand it. See lib/agent-snapshot.js (SEALED_PREFIX + AAD_ROTATION). Bypass requires an `allow:wrapped-aad-seal-needs-reseal-path` marker naming why the wrapped values are out of rotation scope.",
|
|
6500
|
+
regex: /=\s*"[^"]*sealed[^"]*:"\s*;/i,
|
|
6501
|
+
requires: /AAD_ROTATION|allow:wrapped-aad-seal-needs-reseal-path/,
|
|
6502
|
+
skipCommentLines: true,
|
|
6503
|
+
allowlist: [
|
|
6504
|
+
// cache.js wraps a PLAIN vault().seal ("vault:" envelope), not an
|
|
6505
|
+
// AAD-root-bound "vault.aad:" blob - re-keyed by the whole-vault
|
|
6506
|
+
// rotation, not the AAD reseal pipeline. Not in AAD_ROTATION scope.
|
|
6507
|
+
"lib/cache.js",
|
|
6508
|
+
],
|
|
6509
|
+
reason: "v0.14.12 b.agent.snapshot.reseal - the snapshot envelope is AAD-sealed then wrapped behind SEALED_PREFIX (\"snap-sealed-v1:\") and written to an operator backend, so a db.enc scan for the bare \"vault.aad:\" prefix can neither detect nor re-key it during a vault-key rotation. The module exports AAD_ROTATION { table, rowIdField, schemaVersion, backend:\"external\", reseal } so the rotation pipeline drives the re-key (resealRoot old->new root under the rebuilt _snapshotAad, prefix re-applied). Detector locks the invariant: any future module that wraps an AAD-sealed value behind its own prefix constant must ship the same reseal path so wrapper-hidden ciphertext can't escape vault-key rotation.",
|
|
6510
|
+
},
|
|
6511
|
+
|
|
6512
|
+
{ id: "archive-tenant-rewrap-must-compose-rewrapTenant", primitive: "b.archive.rewrapTenant", scanScope: "lib", regex: /archive-wrap\|tenant\||derivedKey(?:WithRoot)?\([^)]*["']archive-wrap["']/, allowlist: ["lib/archive-wrap.js", "lib/agent-tenant.js"], reason: "v0.14.12 — recipient: \"tenant\" archive blobs are keyed off the vault root (b.agent.tenant.derivedKey(tenantId, \"archive-wrap\")) and sealed under the tenant-bound AAD literal \"archive-wrap|tenant|<id>\". The vault rotation pipeline (b.vaultRotate.rotate) does NOT walk operator-placed blobs (files / object-storage / backups), so re-wrapping them old-root->new-root MUST compose b.archive.rewrapTenant — never re-derive the archive-wrap tenant key or rebuild the tenant AAD inline, which would (a) skip the explicit-root straddle the live singleton can't do and (b) risk drifting the AAD/derivation away from the single source. Data-loss class: CWE-325 (missing required cryptographic step) / CWE-665 (improper initialization of the new-root key). Owner lib/archive-wrap.js holds both the AAD construction and the explicit-root derivation; lib/agent-tenant.js is the home of the derivedKey derivation + carries the canonical \"archive-wrap\" purpose only in its @primitive docstring examples (stripped by the comment-skip preprocessor). Any OTHER lib file matching this shape is re-implementing tenant-blob crypto and must route through b.archive.rewrapTenant instead." },
|
|
6513
|
+
|
|
6514
|
+
{ id: "no-phantom-not-yet-supported-throw", primitive: "a primitive must not advertise a capability in @module/@intro/@card prose and then throw an unimplemented stub with no re-open condition — implement it or remove the advertisement (overdue-broken-promise class)", scanScope: "lib", skipCommentLines: true, regex: /not yet supported|operator demand TBD|not[ -]supported in v1\b/i, allowlist: [], reason: "v0.14.13 — mail-srs (the phantom srs1Rewrite fn) and oid4vp (DCQL null path-segment) each advertised a capability in their @module/@intro/@card and then threw a bare stub ('not yet supported', 'operator demand TBD', 'not supported in v1') with no written re-open condition. A defer is only complete WITH a condition (feedback_defer_is_a_v1_decision); legitimate defers read 're-opens when X' / 'defer-with-condition', never a bare 'not yet supported'. Both stubs are now implemented (SRS1 double-forward + DCQL array-wildcard); this detector (throw/code strings only — comments skipped) keeps the bare-defer phrasing from re-entering lib/." },
|
|
6515
|
+
|
|
6516
|
+
{ id: "dcql-null-path-must-recurse-not-refuse", primitive: "DCQL claims-path-pointer null segment selects all elements of the array at that depth (OpenID4VP 1.0 §7.1.1) — recurse over array elements, never throw 'not supported'", scanScope: "lib", regex: /null[- ]?path[- ]?segment[- ]?not[- ]?supported|null path segment \(any-element\) not supported/i, allowlist: [], reason: "v0.14.13 — lib/auth/oid4vp.js _resolvePath once refused null path segments with AuthError(\"auth-oid4vp/null-path-segment-not-supported\") while the module @card advertised DCQL. A null segment is the spec-mandated array wildcard (OpenID4VP 1.0 §7.1.1: select all elements of the currently selected array); refusing it under-discloses a legitimate presentation (CWE-863 incorrect authorization). _walkPath now recurses over array elements with existence semantics (null on a non-array node is a clean non-match, not a throw — holder credential data, rule §5 defensive-reader tier). Detector ensures the throw-stub phrasing cannot return; the testNoStaleDefers version-promise detector does not catch it because its regex requires a NN.NN version and the stub said single-digit \"in v1\"." },
|
|
6517
|
+
|
|
6518
|
+
{ id: "safe-archive-extract-to-memory-no-disk-write", primitive: "b.safeArchive.extractToMemory must stay disk-free — it composes the readers' extractEntries() async generators and yields { name, bytes, size } in memory; the disk path is b.safeArchive.extract", scanScope: "lib", regex: /async function\* extractToMemory[\s\S]{0,2000}?\b(writeFileSync|renameSync|mkdirSync|appendFileSync|createWriteStream)\b/, allowlist: [], reason: "v0.14.13 — extractToMemory is the read-only / serverless-FS counterpart to extract(): it MUST NOT touch disk, it yields decompressed entry bytes from the readers' extractEntries() async generators. A writeFileSync / renameSync / mkdirSync / createWriteStream inside the generator body means the no-disk-write contract regressed (the exact failure the serverless / read-only-FS use case forbids). The disk extract() is the only writer; the inverse must-compose-the-orchestrator direction is already covered by archive-substrate-bypass / safe-archive-extract-bypass. Empty allowlist — a disk write on this path is a real defect, not allowlist material." },
|
|
6519
|
+
|
|
6520
|
+
{ id: "safe-archive-no-phantom-encryptpacked-envelope", primitive: "b.safeArchive must not sniff or advertise a b.crypto.encryptPacked / EPACK archive envelope — encryptPacked writes a 1-byte XChaCha20 format header, not a magic prefix, and no framework primitive produces an EPACK-wrapped archive; only the real BAWRP / BAWPP wrap envelopes are sniffed + auto-unwrapped", scanScope: "lib", regex: /\bEPACK\b|encryptPacked-wrapped (archives|envelopes) (are )?auto-unwrapped|format:\s*"encryptPacked"/, allowlist: [], reason: "v0.14.13 — safe-archive's _sniffMagic carried a phantom MAGIC_ENCPACKED=\"EPACK\" branch returning { format: \"encryptPacked\" } and the @intro + format-unsupported messages advertised encryptPacked-wrapped archives as auto-unwrapped, but b.crypto.encryptPacked writes a 1-byte format header (0x02 XChaCha20-Poly1305), NOT an \"EPACK\" magic — so nothing ever produced or matched it, and b.archive.sniffEnvelope (the sibling sniffer) only knows BAWRP / BAWPP. Codex P2 on PR #294 surfaced the sniff vs unwrap vs advertisement drift; the phantom format + advertisement were removed. This detector keeps the dead EPACK magic and the false auto-unwrap advertisement from creeping back into lib/." },
|
|
6521
|
+
|
|
6522
|
+
{ id: "consent-grant-recognized-purpose-unenforced", primitive: "minting a consent row with a recognized gated purpose (educational-only) must go through b.consent — the PURPOSES vocabulary + grant() lawful-basis gate — not a hardcoded purpose literal that records the value without enforcing its FERPA/SOPIPA lawful-basis constraint", scanScope: "lib", regex: /purpose:\s*["']educational-only["']/, requires: /recognizedPurpose|PURPOSES/, allowlist: ["lib/consent.js"], reason: "v0.14.14 — F5.1 educational-only is a GATED consent purpose: b.consent.grant() refuses a legitimate_interests lawful basis for it (FERPA 34 CFR 99.31 school-official exception / California SOPIPA Cal. B&P 22584). A lib file that mints a consent row with a hardcoded purpose:\"educational-only\" literal WITHOUT composing the PURPOSES vocabulary re-introduces the broken-promise shape where the value is recorded but its lawful-basis constraint is never enforced (CWE-285 improper authorization). consent.js owns + enforces PURPOSES and is allowlisted; any other lib file matching the literal must route through b.consent. Must-compose detector for the gated-purpose primitive per feedback_new_safe_primitive_ships_with_must_compose_detector." },
|
|
6523
|
+
|
|
6524
|
+
{ id: "consent-purposes-null-proto", primitive: "the recognized-purpose map (PURPOSES) must be a null-prototype object so an operator-supplied free-form purpose colliding with an Object.prototype member (toString / constructor / __proto__) resolves to undefined, not the prototype value", scanScope: "lib", regex: /var PURPOSES\s*=\s*Object\.freeze\(/, requires: /Object\.create\(null\)/, allowlist: [], reason: "v0.14.14 Codex P2 on PR #295 (CWE-1321) — recognizedPurpose(name) + grant() index PURPOSES[purpose] with an operator-controlled value; a plain-prototype map returns Object.prototype.toString (truthy) for purpose \"toString\", breaking the null-for-free-form contract and entering grant()'s recognized branch for a value listPurposes() never exposes. PURPOSES is now Object.freeze(Object.assign(Object.create(null), {...})) so every unrecognized key resolves to undefined. Detector requires the null-prototype declaration so the lookup can't silently revert to a plain object." },
|
|
6525
|
+
|
|
6526
|
+
{
|
|
6527
|
+
// ROTATION-EPOCH ACCEPT (v0.14.x): a vault-key rotation (b.vault.rotate)
|
|
6528
|
+
// re-keys the local dataDir, which changes the SHA3-512 fingerprint of
|
|
6529
|
+
// the vault PUBLIC keys on every node. The canonical fingerprint in
|
|
6530
|
+
// _blamejs_cluster_state.vaultKeyFp then goes stale and
|
|
6531
|
+
// cluster.js _checkVaultKeyConsistency would VAULT_KEY_DRIFT-refuse boot
|
|
6532
|
+
// on every node. The fix gates the FATAL refusal on the operator's
|
|
6533
|
+
// acceptVaultKeyRotation declaration (configuredAcceptRotation): when
|
|
6534
|
+
// set, the node ADOPTS the new fingerprint + bumps a rotationEpoch
|
|
6535
|
+
// instead of refusing; when unset, the strict cross-node drift refusal
|
|
6536
|
+
// is unchanged (fail-closed against silent sealed-column corruption,
|
|
6537
|
+
// CWE-345). The bug class this locks: a future edit that re-hardens the
|
|
6538
|
+
// mismatch path to throw VAULT_KEY_DRIFT unconditionally (removing the
|
|
6539
|
+
// rotation escape hatch) would re-introduce the every-node-refuses-boot
|
|
6540
|
+
// outage after a legitimate rotation. VAULT_KEY_DRIFT is unique to
|
|
6541
|
+
// lib/cluster.js, so this is effectively file-scoped: the throw must
|
|
6542
|
+
// coexist with the configuredAcceptRotation gate. If the gate is ever
|
|
6543
|
+
// intentionally removed, carry an
|
|
6544
|
+
// `allow:cluster-vault-key-drift-without-rotation-accept-gate` marker
|
|
6545
|
+
// with the reason.
|
|
6546
|
+
id: "cluster-vault-key-drift-without-rotation-accept-gate",
|
|
6547
|
+
primitive: "lib/cluster.js _checkVaultKeyConsistency MUST gate the VAULT_KEY_DRIFT FATAL refusal on the operator's rotation declaration (configuredAcceptRotation, set from init opts.acceptVaultKeyRotation). A vault-key rotation legitimately changes the public-key fingerprint on every node; without the gate, every node refuses boot after a rotation. The undeclared-mismatch path stays fail-closed; the declared path adopts the new fingerprint and bumps rotationEpoch. Removing the gate re-opens the post-rotation cluster-wide boot outage.",
|
|
6548
|
+
regex: /throw\s+_err\(\s*["']VAULT_KEY_DRIFT["']/,
|
|
6549
|
+
requires: /configuredAcceptRotation|allow:cluster-vault-key-drift-without-rotation-accept-gate/,
|
|
6550
|
+
skipCommentLines: true,
|
|
6551
|
+
allowlist: [],
|
|
6552
|
+
reason: "ROTATION-EPOCH ACCEPT in lib/cluster.js: b.vault.rotate re-keys the local dataDir and changes the SHA3-512 vault-public-key fingerprint, but only the local dataDir — the external _blamejs_cluster_state coordination row keeps the old fingerprint, so every node would VAULT_KEY_DRIFT-refuse boot (a cluster-wide outage after a routine key rotation). The mismatch refusal is now gated on configuredAcceptRotation: declared rotation adopts the new fingerprint + bumps rotationEpoch; undeclared mismatch still fails closed (CWE-345 binding-integrity for sealed columns). Detector locks the gate so a future re-hardening of the drift path can't drop the rotation escape hatch. VAULT_KEY_DRIFT is unique to cluster.js; allowlist empty.",
|
|
6553
|
+
},
|
|
6554
|
+
|
|
6305
6555
|
// Codex P1 on v0.12.11 PR #162 — surfaced the NaN/Infinity bypass
|
|
6306
6556
|
// through `typeof X === "number" ? X : default` gating. The
|
|
6307
6557
|
// pattern exists widely in the codebase (~29 call sites at the
|
|
@@ -581,6 +581,136 @@ function run() {
|
|
|
581
581
|
check("crossWalkIso42001: returns defensive copies",
|
|
582
582
|
freshRow.iso42001.indexOf("MUTATION") === -1);
|
|
583
583
|
|
|
584
|
+
// ---- v0.14.11: GPAI Code-of-Practice signed adherence declaration ----
|
|
585
|
+
var gpaiCop = aiAct.gpai;
|
|
586
|
+
check("gpai.adherenceForm is fn", typeof gpaiCop.adherenceForm === "function");
|
|
587
|
+
check("gpai.declareAdherence is fn", typeof gpaiCop.declareAdherence === "function");
|
|
588
|
+
check("gpai.verifyAdherence is fn", typeof gpaiCop.verifyAdherence === "function");
|
|
589
|
+
|
|
590
|
+
var copPair = b.crypto.generateSigningKeyPair("ml-dsa-87");
|
|
591
|
+
var evidence = b.crypto.sha3Hash("annex-xi-technical-documentation-v1");
|
|
592
|
+
check("evidence hash is 128 hex chars", /^[0-9a-f]{128}$/.test(evidence));
|
|
593
|
+
|
|
594
|
+
function _copCommitments(articles) {
|
|
595
|
+
return articles.map(function (a) {
|
|
596
|
+
return { article: a, statement: "evidence for " + a, evidenceHash: evidence };
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
var ART_53_ALL = ["Art. 53(1)(a)", "Art. 53(1)(b)", "Art. 53(1)(c)", "Art. 53(1)(d)"];
|
|
600
|
+
|
|
601
|
+
// adherenceForm derives the four Art. 53 obligations (no systemic chapter)
|
|
602
|
+
var copForm = gpaiCop.adherenceForm({
|
|
603
|
+
modelId: "acme-llm-7b", modelVersion: "1.0",
|
|
604
|
+
commitments: _copCommitments(["Art. 53(1)(a)"]),
|
|
605
|
+
});
|
|
606
|
+
check("adherenceForm: 4 derived obligations", copForm.commitments.length === 4);
|
|
607
|
+
check("adherenceForm: not systemic by default", copForm.isSystemicRisk === false);
|
|
608
|
+
check("adherenceForm: articles Art. 53 only", copForm.articles.length === 1 && copForm.articles[0] === "Art. 53");
|
|
609
|
+
check("adherenceForm: first commitment evidenced", copForm.commitments[0].evidenced === true);
|
|
610
|
+
check("adherenceForm: unsupplied obligation surfaced", copForm.commitments[1].evidenced === false);
|
|
611
|
+
check("adherenceForm: deadlines bound into form", copForm.deadlines.generalPurposeAI === "2026-08-02");
|
|
612
|
+
check("adherenceForm: copVersion default", copForm.copVersion === "2025-07");
|
|
613
|
+
|
|
614
|
+
// Round-trip: build → sign → verify
|
|
615
|
+
var copEnv = gpaiCop.declareAdherence({
|
|
616
|
+
modelId: "acme-llm-7b", modelVersion: "1.0",
|
|
617
|
+
commitments: _copCommitments(ART_53_ALL),
|
|
618
|
+
privateKeyPem: copPair.privateKey,
|
|
619
|
+
});
|
|
620
|
+
check("declareAdherence: returns signed envelope", typeof copEnv.signature === "string" && copEnv.signature.length > 0);
|
|
621
|
+
check("declareAdherence: no unsigned path (has bom)", copEnv.bom && copEnv.bom.bomFormat === "CycloneDX");
|
|
622
|
+
check("declareAdherence: surfaces adherence form", copEnv.adherence && copEnv.adherence.modelId === "acme-llm-7b");
|
|
623
|
+
|
|
624
|
+
var copVerdict = gpaiCop.verifyAdherence(copEnv, copPair.publicKey);
|
|
625
|
+
check("verifyAdherence: round-trip valid", copVerdict.valid === true);
|
|
626
|
+
check("verifyAdherence: surfaces required articles", copVerdict.adherence.requiredArticles.indexOf("Art. 53(1)(d)") !== -1);
|
|
627
|
+
check("verifyAdherence: reason null on success", copVerdict.reason === null);
|
|
628
|
+
|
|
629
|
+
// Signature-substitution defense: tamper a BOM field, signature must fail
|
|
630
|
+
var copTampered = { bom: JSON.parse(JSON.stringify(copEnv.bom)), signature: copEnv.signature };
|
|
631
|
+
copTampered.bom.metadata.component.version = "9.9.9";
|
|
632
|
+
check("verifyAdherence: tampered bom rejected", gpaiCop.verifyAdherence(copTampered, copPair.publicKey).valid === false);
|
|
633
|
+
|
|
634
|
+
// Refusal 1 — hollow attestation / bad evidenceHash
|
|
635
|
+
rejects("declareAdherence: junk evidenceHash refused",
|
|
636
|
+
function () {
|
|
637
|
+
gpaiCop.declareAdherence({
|
|
638
|
+
modelId: "m", modelVersion: "1",
|
|
639
|
+
commitments: [{ article: "Art. 53(1)(a)", statement: "x", evidenceHash: "x" }],
|
|
640
|
+
privateKeyPem: copPair.privateKey,
|
|
641
|
+
});
|
|
642
|
+
}, /evidenceHash/);
|
|
643
|
+
|
|
644
|
+
// Refusal 2 — scope downgrade: 3e25-FLOP systemic model OMITTING the Art. 55 chapter
|
|
645
|
+
rejects("declareAdherence: systemic model omitting Art. 55 refused",
|
|
646
|
+
function () {
|
|
647
|
+
gpaiCop.declareAdherence({
|
|
648
|
+
modelId: "big", modelVersion: "1", trainingFlops: 3e25,
|
|
649
|
+
commitments: _copCommitments(ART_53_ALL), // omits Art. 55
|
|
650
|
+
privateKeyPem: copPair.privateKey,
|
|
651
|
+
});
|
|
652
|
+
}, /Art\. 55/);
|
|
653
|
+
|
|
654
|
+
// Systemic model that DOES cover Art. 55 succeeds + marks systemic risk
|
|
655
|
+
var copSysEnv = gpaiCop.declareAdherence({
|
|
656
|
+
modelId: "big", modelVersion: "1", trainingFlops: 3e25,
|
|
657
|
+
commitments: _copCommitments(ART_53_ALL.concat(["Art. 55"])),
|
|
658
|
+
privateKeyPem: copPair.privateKey,
|
|
659
|
+
});
|
|
660
|
+
var copSysVerdict = gpaiCop.verifyAdherence(copSysEnv, copPair.publicKey);
|
|
661
|
+
check("declareAdherence: systemic model with Art. 55 valid", copSysVerdict.valid === true);
|
|
662
|
+
check("declareAdherence: systemic flag set", copSysVerdict.adherence.isSystemicRisk === true);
|
|
663
|
+
check("declareAdherence: systemic articles include Art. 55", copSysVerdict.adherence.articles.indexOf("Art. 55") !== -1);
|
|
664
|
+
|
|
665
|
+
// Refusal 3 — missing modelId / modelVersion
|
|
666
|
+
rejects("declareAdherence: missing modelId refused",
|
|
667
|
+
function () {
|
|
668
|
+
gpaiCop.declareAdherence({ modelVersion: "1", privateKeyPem: copPair.privateKey });
|
|
669
|
+
}, /modelId/);
|
|
670
|
+
rejects("declareAdherence: missing modelVersion refused",
|
|
671
|
+
function () {
|
|
672
|
+
gpaiCop.declareAdherence({ modelId: "m", privateKeyPem: copPair.privateKey });
|
|
673
|
+
}, /modelVersion/);
|
|
674
|
+
rejects("declareAdherence: missing signing key refused",
|
|
675
|
+
function () {
|
|
676
|
+
gpaiCop.declareAdherence({ modelId: "m", modelVersion: "1" });
|
|
677
|
+
}, /privateKeyPem/);
|
|
678
|
+
|
|
679
|
+
// Refusal 4 — stale / replayed declaration past its validity window
|
|
680
|
+
var copStaleEnv = gpaiCop.declareAdherence({
|
|
681
|
+
modelId: "m", modelVersion: "1",
|
|
682
|
+
validityMs: 1000,
|
|
683
|
+
generatedAt: new Date(Date.now() - 60000).toISOString(),
|
|
684
|
+
commitments: _copCommitments(ART_53_ALL),
|
|
685
|
+
privateKeyPem: copPair.privateKey,
|
|
686
|
+
});
|
|
687
|
+
var copStaleVerdict = gpaiCop.verifyAdherence(copStaleEnv, copPair.publicKey);
|
|
688
|
+
check("verifyAdherence: expired declaration rejected", copStaleVerdict.valid === false && copStaleVerdict.reason === "expired");
|
|
689
|
+
|
|
690
|
+
// A fresh declaration verified within its window passes; the `now`
|
|
691
|
+
// override drives the clock without a setTimeout wait.
|
|
692
|
+
var copFreshVerdict = gpaiCop.verifyAdherence(copStaleEnv, copPair.publicKey, {
|
|
693
|
+
now: Date.parse(copStaleEnv.adherence.generatedAt) + 500,
|
|
694
|
+
});
|
|
695
|
+
check("verifyAdherence: within-window declaration valid", copFreshVerdict.valid === true);
|
|
696
|
+
|
|
697
|
+
// Unknown opt key throws at config time
|
|
698
|
+
rejects("declareAdherence: unknown opt key refused",
|
|
699
|
+
function () {
|
|
700
|
+
gpaiCop.declareAdherence({
|
|
701
|
+
modelId: "m", modelVersion: "1", privateKeyPem: copPair.privateKey, bogusKey: 1,
|
|
702
|
+
});
|
|
703
|
+
}, /unknown option/);
|
|
704
|
+
|
|
705
|
+
// Bad copVersion month group refused (shape-only regex with real month)
|
|
706
|
+
rejects("adherenceForm: copVersion month 13 refused",
|
|
707
|
+
function () {
|
|
708
|
+
gpaiCop.adherenceForm({
|
|
709
|
+
modelId: "m", modelVersion: "1", copVersion: "2025-13",
|
|
710
|
+
commitments: _copCommitments(["Art. 53(1)(a)"]),
|
|
711
|
+
});
|
|
712
|
+
}, /copVersion/);
|
|
713
|
+
|
|
584
714
|
console.log("OK — compliance-ai-act tests");
|
|
585
715
|
}
|
|
586
716
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// b.consent recognized-purpose vocabulary (educational-only) + grant gating.
|
|
3
|
+
|
|
4
|
+
var helpers = require("../helpers");
|
|
5
|
+
var b = helpers.b;
|
|
6
|
+
var check = helpers.check;
|
|
7
|
+
var fs = helpers.fs;
|
|
8
|
+
var os = helpers.os;
|
|
9
|
+
var path = helpers.path;
|
|
10
|
+
var setupTestDb = helpers.setupTestDb;
|
|
11
|
+
var teardownTestDb = helpers.teardownTestDb;
|
|
12
|
+
|
|
13
|
+
async function run() {
|
|
14
|
+
// ---- recognized-purpose vocabulary (pure, no DB) ----
|
|
15
|
+
var edu = b.consent.recognizedPurpose("educational-only");
|
|
16
|
+
check("recognizedPurpose(educational-only): present", edu !== null);
|
|
17
|
+
check("recognizedPurpose(educational-only): forbids legitimate_interests",
|
|
18
|
+
edu.forbidsLawfulBasis.indexOf("legitimate_interests") !== -1);
|
|
19
|
+
check("recognizedPurpose(educational-only): citation populated", typeof edu.citation === "string" && edu.citation.length > 0);
|
|
20
|
+
check("recognizedPurpose(educational-only): commercial-use prohibited", edu.commercialUseProhibited === true);
|
|
21
|
+
check("recognizedPurpose(marketing): null (free-form)", b.consent.recognizedPurpose("marketing") === null);
|
|
22
|
+
check("recognizedPurpose(unknown): null", b.consent.recognizedPurpose("nope-not-real") === null);
|
|
23
|
+
// A free-form purpose colliding with an Object.prototype member must stay
|
|
24
|
+
// free-form (null), not resolve to the prototype value (CWE-1321).
|
|
25
|
+
check("recognizedPurpose(toString): null, not the prototype fn", b.consent.recognizedPurpose("toString") === null);
|
|
26
|
+
check("recognizedPurpose(constructor): null", b.consent.recognizedPurpose("constructor") === null);
|
|
27
|
+
check("recognizedPurpose(__proto__): null", b.consent.recognizedPurpose("__proto__") === null);
|
|
28
|
+
check("recognizedPurpose(hasOwnProperty): null", b.consent.recognizedPurpose("hasOwnProperty") === null);
|
|
29
|
+
|
|
30
|
+
var purposes = b.consent.listPurposes();
|
|
31
|
+
check("listPurposes: frozen array", Array.isArray(purposes) && Object.isFrozen(purposes));
|
|
32
|
+
check("listPurposes: includes educational-only", purposes.indexOf("educational-only") !== -1);
|
|
33
|
+
|
|
34
|
+
// ---- DB-backed grant gating ----
|
|
35
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-consent-purp-"));
|
|
36
|
+
try {
|
|
37
|
+
await setupTestDb(dir);
|
|
38
|
+
|
|
39
|
+
// educational-only + legitimate_interests → refused (FERPA / SOPIPA).
|
|
40
|
+
var threw = null;
|
|
41
|
+
try { await b.consent.grant({ subjectId: "stu-1", purpose: "educational-only", lawfulBasis: "legitimate_interests", channel: "api" }); }
|
|
42
|
+
catch (e) { threw = e; }
|
|
43
|
+
check("grant educational-only + legitimate_interests refused", threw && /forbids lawfulBasis/.test(threw.message));
|
|
44
|
+
|
|
45
|
+
// educational-only + a school-authorization basis → succeeds + isGranted.
|
|
46
|
+
await b.consent.grant({ subjectId: "stu-1", purpose: "educational-only", lawfulBasis: "consent", channel: "api" });
|
|
47
|
+
check("grant educational-only + consent succeeds (isGranted)",
|
|
48
|
+
b.consent.isGranted({ subjectId: "stu-1", purpose: "educational-only" }) === true);
|
|
49
|
+
|
|
50
|
+
// A free-form purpose under any lawful basis still grants (back-compat).
|
|
51
|
+
await b.consent.grant({ subjectId: "stu-2", purpose: "marketing", lawfulBasis: "legitimate_interests", channel: "api" });
|
|
52
|
+
check("free-form purpose still grants (back-compat)",
|
|
53
|
+
b.consent.isGranted({ subjectId: "stu-2", purpose: "marketing" }) === true);
|
|
54
|
+
|
|
55
|
+
// A free-form purpose colliding with an Object.prototype name is still
|
|
56
|
+
// free-form — grant() must not enter the recognized-purpose branch.
|
|
57
|
+
await b.consent.grant({ subjectId: "stu-3", purpose: "toString", lawfulBasis: "legitimate_interests", channel: "api" });
|
|
58
|
+
check("Object-prototype-named purpose grants as free-form",
|
|
59
|
+
b.consent.isGranted({ subjectId: "stu-3", purpose: "toString" }) === true);
|
|
60
|
+
|
|
61
|
+
// withdraw → isGranted false for the same (subjectId, purpose).
|
|
62
|
+
await b.consent.withdraw({ subjectId: "stu-1", purpose: "educational-only" });
|
|
63
|
+
check("after withdraw isGranted false",
|
|
64
|
+
b.consent.isGranted({ subjectId: "stu-1", purpose: "educational-only" }) === false);
|
|
65
|
+
} finally {
|
|
66
|
+
await teardownTestDb(dir);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { run: run };
|