@blamejs/blamejs-shop 0.4.32 → 0.4.37

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +72 -52
  5. package/lib/vendor/blamejs/.github/workflows/ci.yml +12 -12
  6. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +37 -5
  7. package/lib/vendor/blamejs/.github/workflows/release-container.yml +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  9. package/lib/vendor/blamejs/MIGRATING.md +12 -0
  10. package/lib/vendor/blamejs/README.md +5 -2
  11. package/lib/vendor/blamejs/SECURITY.md +4 -2
  12. package/lib/vendor/blamejs/api-snapshot.json +137 -2
  13. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
  14. package/lib/vendor/blamejs/index.js +4 -0
  15. package/lib/vendor/blamejs/lib/archive-read.js +2 -1
  16. package/lib/vendor/blamejs/lib/archive-tar-read.js +2 -1
  17. package/lib/vendor/blamejs/lib/atomic-file.js +5 -0
  18. package/lib/vendor/blamejs/lib/audit.js +2 -0
  19. package/lib/vendor/blamejs/lib/auth/elevation-grant.js +6 -2
  20. package/lib/vendor/blamejs/lib/auth/oauth.js +13 -0
  21. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +5 -2
  22. package/lib/vendor/blamejs/lib/cli.js +8 -1
  23. package/lib/vendor/blamejs/lib/compliance.js +4 -0
  24. package/lib/vendor/blamejs/lib/config-drift.js +2 -1
  25. package/lib/vendor/blamejs/lib/credential-hash.js +9 -0
  26. package/lib/vendor/blamejs/lib/db.js +15 -2
  27. package/lib/vendor/blamejs/lib/dsa.js +482 -0
  28. package/lib/vendor/blamejs/lib/framework-error.js +14 -0
  29. package/lib/vendor/blamejs/lib/http-client.js +5 -2
  30. package/lib/vendor/blamejs/lib/local-db-thin.js +3 -2
  31. package/lib/vendor/blamejs/lib/log-stream-local.js +1 -1
  32. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +9 -2
  33. package/lib/vendor/blamejs/lib/log-stream-otlp.js +16 -7
  34. package/lib/vendor/blamejs/lib/middleware/clear-site-data.js +36 -11
  35. package/lib/vendor/blamejs/lib/mtls-ca.js +2 -2
  36. package/lib/vendor/blamejs/lib/observability.js +3 -2
  37. package/lib/vendor/blamejs/lib/pipl-cn.js +377 -0
  38. package/lib/vendor/blamejs/lib/restore-rollback.js +5 -5
  39. package/lib/vendor/blamejs/lib/retention.js +16 -2
  40. package/lib/vendor/blamejs/lib/scheduler.js +12 -0
  41. package/lib/vendor/blamejs/lib/self-update.js +1 -1
  42. package/lib/vendor/blamejs/lib/session.js +64 -0
  43. package/lib/vendor/blamejs/lib/ssrf-guard.js +25 -7
  44. package/lib/vendor/blamejs/lib/vault/passphrase-ops.js +3 -3
  45. package/lib/vendor/blamejs/lib/watcher.js +8 -0
  46. package/lib/vendor/blamejs/package.json +2 -2
  47. package/lib/vendor/blamejs/release-notes/v0.15.7.json +43 -0
  48. package/lib/vendor/blamejs/release-notes/v0.15.8.json +48 -0
  49. package/lib/vendor/blamejs/release-notes/v0.15.9.json +58 -0
  50. package/lib/vendor/blamejs/scripts/gen-migrating.js +16 -0
  51. package/lib/vendor/blamejs/scripts/generate-ssdf-attestation.js +338 -0
  52. package/lib/vendor/blamejs/test/00-primitives.js +51 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-rename-retry.test.js +70 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -3
  55. package/lib/vendor/blamejs/test/layer-0-primitives/credential-hash.test.js +18 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/db-init-extensions.test.js +32 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/dsa.test.js +169 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +40 -1
  59. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +172 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/retention-floor.test.js +59 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/safe-url-canonicalize.test.js +64 -11
  62. package/lib/vendor/blamejs/test/layer-0-primitives/scheduler-watchdog-stale-settle.test.js +71 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +57 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/watcher.test.js +7 -3
  65. package/package.json +2 -2
@@ -2601,10 +2601,22 @@ async function testNoDuplicateCodeBlocks() {
2601
2601
  var w = new Worker(WORKER_PATH, {
2602
2602
  workerData: Object.assign({ files: shardFiles }, SHINGLE_OPTS_FOR_WORKER),
2603
2603
  });
2604
- w.once("message", function (msg) { resolve(msg); w.terminate(); });
2605
- w.once("error", reject);
2604
+ // Reap the worker on EVERY settle path (#123). Previously the error and
2605
+ // exit handlers rejected without terminate(), so an errored worker thread
2606
+ // stayed alive holding its event-loop handles — the parent could not exit
2607
+ // and the smoke run ran to the 25-min watchdog on memory-starved
2608
+ // macOS-arm64 runners. settle() terminates first, idempotently.
2609
+ var settled = false;
2610
+ function settle(fn, arg) {
2611
+ if (settled) return;
2612
+ settled = true;
2613
+ try { w.terminate(); } catch (_e) { /* already terminating */ }
2614
+ fn(arg);
2615
+ }
2616
+ w.once("message", function (msg) { settle(resolve, msg); });
2617
+ w.once("error", function (e) { settle(reject, e); });
2606
2618
  w.once("exit", function (code) {
2607
- if (code !== 0 && code !== null) reject(new Error("shingle worker exited " + code));
2619
+ if (code !== 0 && code !== null) settle(reject, new Error("shingle worker exited " + code));
2608
2620
  });
2609
2621
  });
2610
2622
  }
@@ -2991,6 +3003,79 @@ async function testNoDuplicateCodeBlocks() {
2991
3003
  ],
2992
3004
  reason: "Config-time validateOpts cascade family — `validateOpts(opts, KEYS, label)` followed by per-field requireNonEmptyString / optionalPositiveInt / optionalFunction checks at a factory or builder entry point. jar.js:build joined in v0.14.22 (the RFC 9101 request-object builder validates clientId / audience / key / expiresInMs before minting). Each member emits its own error class and code namespace over a different opts vocabulary; the shared helper (validateOpts) is the extraction, and consolidating the cascades past the call boundary would surface the wrong error code for operator typos.",
2993
3005
  },
3006
+ {
3007
+ // The b.dsa (EU DSA) + b.pipl (China PIPL) compliance record-builders
3008
+ // are a fresh pair of validateOpts-cascade + Object.freeze-record +
3009
+ // audit.safeEmit functions. Each validates a DIFFERENT opts vocabulary
3010
+ // (notice / statement-of-reasons / transparency-report; cross-border
3011
+ // assessment / security-assessment certificate), builds a DIFFERENT
3012
+ // frozen record, and emits a DIFFERENT audit action — so their 50/60-tok
3013
+ // shingles coincide with the large existing compliance/builder family
3014
+ // (validateOpts cascade + audit-emit), bridging the config-validation
3015
+ // and audit-emit clusters. Shape-only: the shared primitives
3016
+ // (validateOpts, audit.safeEmit, Object.freeze) ARE the extraction;
3017
+ // collapsing the per-builder bodies would surface the wrong error code
3018
+ // and the wrong audit action for each regulation. Union of the clusters
3019
+ // the two new files participate in.
3020
+ mode: "family-subset",
3021
+ files: [
3022
+ "lib/ai-adverse-decision.js:wrap",
3023
+ "lib/ai-dp.js:budget",
3024
+ "lib/api-key.js:_validateIssueOpts",
3025
+ "lib/audit-daily-review.js:create",
3026
+ "lib/auth/jar.js:build",
3027
+ "lib/auth/oauth.js:buildClientAttestationPop",
3028
+ "lib/auth/oid4vci.js:create",
3029
+ "lib/auth/saml.js:create",
3030
+ "lib/auth/sd-jwt-vc-issuer.js:create",
3031
+ "lib/break-glass.js:_validatePolicySet",
3032
+ "lib/budr.js:declare",
3033
+ "lib/calendar.js:validate",
3034
+ "lib/cloud-events.js:wrap",
3035
+ "lib/compliance-eaa.js:create",
3036
+ "lib/compliance-sanctions-fetcher.js:create",
3037
+ "lib/cose.js:macVerify0",
3038
+ "lib/cose.js:verify",
3039
+ "lib/crypto-field.js:declarePerRowResidency",
3040
+ "lib/daemon.js:_validateStartOpts",
3041
+ "lib/daemon.js:_validateStopOpts",
3042
+ "lib/data-act.js:recordSwitchRequest",
3043
+ "lib/data-act.js:shareWithThirdParty",
3044
+ "lib/db.js:declareRequireDualControl",
3045
+ "lib/ddl-change-control.js:create",
3046
+ "lib/dsa.js:noticeAndAction",
3047
+ "lib/dsa.js:statementOfReasons",
3048
+ "lib/dsr.js:create",
3049
+ "lib/external-db-migrate.js:create",
3050
+ "lib/fda-21cfr11.js:posture",
3051
+ "lib/fdx.js:consentReceipt",
3052
+ "lib/fedcm.js:wellKnown",
3053
+ "lib/file-upload.js:_validateCreateOpts",
3054
+ "lib/http-client-cache.js:create",
3055
+ "lib/http-client.js:_validateDownloadOpts",
3056
+ "lib/mcp-tool-registry.js:verifyCall",
3057
+ "lib/middleware/assetlinks.js:create",
3058
+ "lib/middleware/protected-resource-metadata.js:create",
3059
+ "lib/middleware/span-http-server.js:create",
3060
+ "lib/network-heartbeat.js:start",
3061
+ "lib/network-tls.js:_emitAuditAdd",
3062
+ "lib/network-tls.js:_emitAuditRemove",
3063
+ "lib/observability-tracer.js:create",
3064
+ "lib/outbox.js:create",
3065
+ "lib/pipl-cn.js:sccFilingAssessment",
3066
+ "lib/pipl-cn.js:securityAssessmentCertificate",
3067
+ "lib/privacy.js:vendorReview",
3068
+ "lib/redact.js:installOutboundDlp",
3069
+ "lib/sec-cyber.js:eightKArtifact",
3070
+ "lib/self-update.js:_validatePollOpts",
3071
+ "lib/self-update.js:_validateVerifyOpts",
3072
+ "lib/static.js:_validateCreateOpts",
3073
+ "lib/tcpa-10dlc.js:recordConsent",
3074
+ "lib/vex.js:document",
3075
+ "lib/watcher.js:_validateOpts",
3076
+ ],
3077
+ reason: "v0.15.8 — b.dsa (EU Digital Services Act) + b.pipl (China PIPL) cross-border record-builders join the config-time validateOpts-cascade + Object.freeze-record + audit.safeEmit family. Each validates a distinct opts vocabulary, builds a distinct frozen record, and emits a distinct audit action; the shared primitives (validateOpts / audit.safeEmit / Object.freeze) are the extraction. Shape-only — collapsing the per-builder bodies would emit the wrong error code and audit action per regulation. Union of the 50/60-tok clusters the two new files participate in.",
3078
+ },
2994
3079
  {
2995
3080
  mode: "family-subset",
2996
3081
  files: [
@@ -6997,6 +7082,74 @@ var KNOWN_ANTIPATTERNS = [
6997
7082
  allowlist: [],
6998
7083
  reason: "CWE-532 — span/metric/resource attributes are a first-class egress sink. #69 fixed lib/otel-export.js _attrsToOtlp but pinned its detector to that one function name, leaving the SPAN exporter's two sibling encoders (lib/observability-otlp-exporter.js _attrToOtlp JSON + _attrsToProto protobuf) shipping attribute values verbatim to the collector (#125). The shared root is 'an attribute-map encoder that serializes without scrubbing'; every such encoder must pass each value through observability.redactAttrs() (default composes b.redact.redact, fail-toward-dropping on a throwing redactor) before the wire payload. The negative lookahead exempts the per-value type-encoders (_valueToOtlp/_anyValueToProto) which run after redaction; the {0,4000} bound is a ReDoS backstop well above the real encoder bodies (<2000 chars).",
6999
7084
  },
7085
+ // Same CWE-532 class, the OTHER OTLP egress family the span/metric detector
7086
+ // above could not see: the LOG sinks (lib/log-stream-otlp.js HTTP-JSON +
7087
+ // lib/log-stream-otlp-grpc.js gRPC). Their encoders are named per the OTLP
7088
+ // logs schema (_toLogRecord / _serializeBatch / _encodeLogRecord /
7089
+ // _encodeResourceLogs), not _attr*, so the function-name-anchored detector
7090
+ // never matched them — the #125 span fix left the log record-meta + resource
7091
+ // attributes shipping verbatim. Anchor on the buggy SHAPE instead: a raw
7092
+ // `record.meta` / `resourceAttrs` / `_resourceAttrs(cfg)` handed straight to an
7093
+ // _encode* call. The fix wraps the arg in observability().redactAttrs(...), so
7094
+ // the first token inside the paren becomes `observability` and the match dies.
7095
+ {
7096
+ id: "otlp-log-sink-encodes-attrs-without-redactor",
7097
+ primitive: "the OTLP LOG sinks (log-stream-otlp / log-stream-otlp-grpc) must run record.meta and resource attributes through observability.redactAttrs() before encoding — a log line's meta or a resource attribute holding a bearer token / password / API key ships to the collector verbatim otherwise (CWE-532), the same egress class as the span/metric exporters",
7098
+ scanScope: "lib",
7099
+ regex: /_encode(?:Attrs|Attributes|Resource)\(\s*(?:record\.meta\b|resourceAttrs\b|_resourceAttrs\(cfg\))/,
7100
+ allowlist: [],
7101
+ reason: "CWE-532 secret/PII egress. v0.15.4 (#125) baked observability.redactAttrs() into every SPAN + METRIC attribute encoder but the detector was anchored on the _attr* function names, so it was blind to the LOG sinks whose encoders carry the OTLP-logs schema names. lib/log-stream-otlp.js (_toLogRecord meta + _serializeBatch resource) and lib/log-stream-otlp-grpc.js (_encodeLogRecord meta + _encodeResourceLogs resource) handed record.meta and resourceAttrs straight to _encodeAttrs/_encodeAttributes/_encodeResource — a log record's meta or a resource attribute holding a credential reached the collector unscrubbed. Root: 'every OTLP egress encoder redacts'; the span detector saw spans/metrics, this one sees logs. Fires on the raw `_encode*(record.meta` / `_encode*(resourceAttrs` / `_encodeResource(_resourceAttrs(cfg)` shape; the fix wraps the arg in observability().redactAttrs(...) (the span/metric contract), making the first paren token `observability` so the match goes silent.",
7102
+ },
7103
+ // #146 — every FINAL temp->dest rename must route through
7104
+ // atomicFile.renameWithRetry, the bounded retry on a Windows-transient
7105
+ // destination lock (EPERM/EACCES/EBUSY from AV / the search indexer /
7106
+ // Dropbox / OneDrive briefly holding the target). A bare nodeFs.renameSync
7107
+ // surfaces a transient lock as a hard failure (httpClient.downloadStream
7108
+ // shipped exactly that) and re-hand-rolls a retry the framework already owns
7109
+ // as a primitive. The ONLY legitimate bare renameSync is inside the
7110
+ // primitive itself (atomic-file.js _renameWithRetry).
7111
+ {
7112
+ id: "bare-renamesync-not-via-renamewithretry",
7113
+ primitive: "route every final temp->dest rename through atomicFile.renameWithRetry (the Windows rename-lock retry) instead of a bare nodeFs.renameSync / fs.renameSync — a transient AV / indexer / cloud-sync lock on the destination must be retried, not surfaced as a hard failure",
7114
+ scanScope: "lib",
7115
+ regex: /\b(?:nodeFs|fs)\.renameSync\(/,
7116
+ allowlist: [
7117
+ // atomic-file.js IS the primitive — _renameWithRetry wraps the one real
7118
+ // nodeFs.renameSync with the bounded transient-lock retry loop.
7119
+ "lib/atomic-file.js",
7120
+ ],
7121
+ reason: "#146 (user-surfaced). atomicFile.writeSync retries its final rename on a Windows-transient destination lock; httpClient.downloadStream's final rename was a bare nodeFs.renameSync, so a download into a cloud-synced / AV-scanned directory surfaced the transient lock as a hard failure. The retry was also hand-rolled and un-reusable. The fix exports atomic-file's _renameWithRetry as atomicFile.renameWithRetry and routes EVERY final temp->dest rename through it (downloadStream + vault/passphrase-ops + mtls-ca + log-stream-local + self-update + config-drift + archive-read + archive-tar-read + local-db-thin + restore-rollback). Fires on any bare nodeFs.renameSync/fs.renameSync; the only allowlisted use is atomic-file.js where the primitive's retry loop lives.",
7122
+ },
7123
+ // v0.15.9 — db.init() must construct its DatabaseSync with the SQLITE_LIMIT_*
7124
+ // sqlLength cap: a parse-time DoS floor that rejects a megaquery on the
7125
+ // raw-SQL surface. The ephemeral storage headroom probe (new DatabaseSync(p))
7126
+ // is a different construction and is not anchored. Fires if the main db handle
7127
+ // is built without the limits shape.
7128
+ {
7129
+ id: "db-databasesync-without-sqlite-limits",
7130
+ primitive: "construct the main db.init DatabaseSync(dbPath, ...) with the node:sqlite limits option (sqlLength) — a parse-time DoS floor complementary to streamLimit",
7131
+ scanScope: "lib",
7132
+ regex: /new DatabaseSync\(dbPath\b/,
7133
+ requires: /limits:\s*\{[\s\S]{0,200}?sqlLength/,
7134
+ skipCommentLines: true,
7135
+ allowlist: [],
7136
+ reason: "v0.15.9 Node-24.16 adoption. The framework parameterizes every builder value, so SQLITE_LIMIT_LENGTH (sqlLength) guards the raw-SQL surface (b.db.runSql) against an attacker-influenced megaquery the parser would otherwise chew (SQLite default is 1 GB). Fires when the main db handle `new DatabaseSync(dbPath` is constructed without the `limits: { ... sqlLength` shape; the headroom probe `new DatabaseSync(p)` is intentionally not matched (ephemeral, no attacker input). (SQLITE_LIMIT_ATTACHED is left at the SQLite default — the snapshot/backup path uses ATTACH.)",
7137
+ },
7138
+ // v0.15.9 — the RFC 9527 Clear-Site-Data header value must be built via the
7139
+ // shared middleware/clear-site-data headerValue() helper (which validates
7140
+ // each directive against the known set), not a hand-rolled quoted-token
7141
+ // string. A literal `setHeader("Clear-Site-Data", '"cookies", ...')` skips
7142
+ // the directive validation and re-hand-rolls the RFC 9527 quoting the
7143
+ // primitive owns (the b.session.logout extraction lesson).
7144
+ {
7145
+ id: "clear-site-data-header-hand-rolled",
7146
+ primitive: "build the Clear-Site-Data response header via clearSiteData.headerValue(types) (validated RFC 9527 quoting) — do not hand-roll the quoted-directive string literal in setHeader",
7147
+ scanScope: "lib",
7148
+ regex: /setHeader\(\s*["']Clear-Site-Data["']\s*,\s*["']/,
7149
+ skipCommentLines: true,
7150
+ allowlist: [],
7151
+ reason: "v0.15.9 (Clear-Site-Data logout wiring). The middleware/clear-site-data headerValue() helper is the single builder of the RFC 9527 §3 quoted-token list and validates every directive against KNOWN_TYPES; both emitters (the middleware's create() and b.session.logout) route through it. A literal `setHeader(\"Clear-Site-Data\", '\"cookies\", ...')` hand-rolls the quoting and skips the validation. Fires when the second setHeader arg is a string literal; the live emitters pass a var/call so they stay silent.",
7152
+ },
7000
7153
  // #131 — the b.middleware.dpop factory must REQUIRE its replayStore at config
7001
7154
  // time. The store is DPoP's jti-replay defense (RFC 9449 §11.1); reading it
7002
7155
  // optionally and gating the check behind `if (replayStore)` silently mounts a
@@ -7034,6 +7187,82 @@ var KNOWN_ANTIPATTERNS = [
7034
7187
  allowlist: [],
7035
7188
  reason: "#137 — verifyIdToken wrapped its exp validation in `if (!vopts.skipExpCheck) { ... }` so any external caller (verifyIdToken is a public API) could pass skipExpCheck: true and verify an EXPIRED id_token clean — token-replay of expired-but-once-valid credentials. skipExpCheck exists only because OIDC Back-Channel Logout 1.0 §2.4 logout tokens carry no exp; the internal verifyBackchannelLogoutToken passes it. The fix flips the branch to `if (vopts.skipExpCheck) { <require the backchannel-logout events claim> + <iat freshness floor> } else { <exp check> }`, so skipExpCheck is refused (auth-oauth/skip-exp-check-not-allowed) on any token lacking the logout event claim and a stale logout token is refused (auth-oauth/logout-token-stale). The detector anchors on the bare negative gate and requires the new refusal code in-file; after the fix the bare gate is gone and the code is present, so it stays silent.",
7036
7189
  },
7190
+ // #130 — scheduler _runFire settle handlers must guard on the run generation.
7191
+ {
7192
+ id: "scheduler-runfire-settle-no-generation-guard",
7193
+ primitive: "scheduler._runFire's promise settle handlers must ignore a stale settle (if task.runGeneration !== gen return) before writing task state / emitting success|failure — the watchdog force-clears running and re-fires, so an abandoned run's late resolve otherwise clobbers the current run's state and double-emits",
7194
+ scanScope: "lib",
7195
+ regex: /Promise\.resolve\(promise\)\.then\(function \([^)]*\)\s*\{\s*task\.running = false/,
7196
+ allowlist: [],
7197
+ reason: "#130 — _runFire attaches success/failure settle handlers that unconditionally wrote task.running=false / runningSince=0 / lastFinish / lastError and emitted system.scheduler.task.success|failure. But the watchdog (maxJobMs) force-clears task.running on a hung run and _fireOnce re-fires, so the original slow promise settles LATE and clobbers the new run's running flag (disabling the watchdog for it / allowing a third concurrent fire) and emits a stale success for a run the watchdog already reported as a watchdog failure. The fix tags each run (task.runGeneration, bumped by _runFire AND the watchdog) and the settle handlers `return` early when the tag is stale. The detector fires while the success handler sets task.running=false with no intervening generation guard and goes silent once the `if (task.runGeneration !== gen) return;` guard precedes it; behavioral guard is scheduler-watchdog-stale-settle.test.js.",
7198
+ },
7199
+ // #121 — retention.complianceFloor must inherit the active posture.
7200
+ {
7201
+ id: "retention-compliancefloor-ignores-active-posture",
7202
+ primitive: "retention.complianceFloor must fall back to STATE.activePosture (set by applyPosture / the b.compliance.set cascade) when no explicit posture is passed — it hard-required a string posture and never read the active value, so the advertised optional-posture inheritance was unimplemented dead state",
7203
+ scanScope: "lib",
7204
+ regex: /function complianceFloor\s*\([^)]*\)\s*\{(?:(?!STATE\.activePosture)[\s\S]){0,300}?must be a string/,
7205
+ allowlist: [],
7206
+ reason: "#121 — applyPosture() records STATE.activePosture + STATE.activeFloorMs and both its docstring and the STATE comment advertise that complianceFloor callers without an explicit posture inherit the active value. But complianceFloor threw `posture must be a string` immediately and never consulted STATE.activePosture, so the inheritance never worked and activeFloorMs was a dead write. The fix inherits STATE.activePosture when posture is omitted (a numeric first arg is taken as candidateTtlMs so complianceFloor(ttl) works), and applyPosture(null) now clears the state (was a silent no-op, so b.compliance.clear couldn't reset it). The tempered span fires while complianceFloor reaches its `must be a string` throw without first reading STATE.activePosture; the behavioral guard is retention-floor.test.js.",
7207
+ },
7208
+ // #111 — credential-hash needsRehash must drive SHAKE256 length-rotation.
7209
+ {
7210
+ id: "credentialhash-needsrehash-ignores-shake256-length",
7211
+ primitive: "credentialHash.needsRehash must compare the stored SHAKE256 digest length against the configured/default length — without it, raising the SHAKE256 output length never triggers a rehash and the advertised length-rotation is a silent no-op",
7212
+ scanScope: "lib",
7213
+ regex: /function needsRehash\s*\([^)]*\)\s*\{(?=[\s\S]*?CRED_HASH_IDS\.ARGON2ID)(?:(?!CRED_HASH_IDS\.SHAKE256)(?!\n\})[\s\S]){0,1200}\n\}/,
7214
+ allowlist: [],
7215
+ reason: "#111 — needsRehash short-circuited the SHAKE256 case to `return false`: it checked the algorithm id and (for Argon2id) deferred to the password module's parameter-lag check, but never compared decoded.payload.length against the configured target length. So once an operator raised the SHAKE256 output length, every existing shorter digest reported needsRehash === false and the length-rotation the primitive advertises never fired (b.apiKey.verify's rehash-on-verify silently kept the old length). The fix adds, for the SHAKE256 branch, a compare of decoded.payload.length to (opts.params.length || SHAKE256_DEFAULT_LENGTH) → rehash when they differ. The tempered span anchors on needsRehash and fires while its body never references SHAKE256_DEFAULT_LENGTH (the length-target constant); the behavioral guard is credential-hash.test.js.",
7216
+ },
7217
+ // #136 — SD-JWT KB-JWT aud / nonce must compare constant-time.
7218
+ {
7219
+ id: "sdjwt-kbjwt-aud-nonce-non-consttime-compare",
7220
+ primitive: "the SD-JWT KB-JWT audience + nonce checks must compare with the constant-time _timingSafeEqStr helper (as the sd_hash check already does), not a bare !== — the nonce is a verifier-issued replay-defense value and a timing channel leaks a guess oracle; constant-time-ness cannot be asserted behaviorally, so this structural detector is the guard",
7221
+ scanScope: "lib",
7222
+ regex: /kbParsed\.payload\.(?:aud|nonce)\s*!==\s*opts\.(?:audience|nonce)/,
7223
+ allowlist: [],
7224
+ reason: "#136 — verify()'s KB-JWT binding checks compared `kbParsed.payload.aud !== opts.audience` and `kbParsed.payload.nonce !== opts.nonce` with a short-circuiting !==, while the adjacent sd_hash check already used the constant-time _timingSafeEqStr. The nonce is a per-presentation replay-defense value the verifier issued; a non-constant-time compare leaks a matching-prefix timing oracle. The fix routes both through _timingSafeEqStr (the framework's hash/token-compare discipline). A behavioral test can prove the accept/reject correctness but NOT the timing property, so this detector is the primary guard per the test-with-fix rule's structural-drift exception; it fires on the bare !== shape and goes silent once both use _timingSafeEqStr.",
7225
+ },
7226
+ // canonicalizeHost must fold an IPv4-mapped IPv6 address to its IPv4 form.
7227
+ {
7228
+ id: "ssrf-canonicalizehost-v4mapped-not-folded",
7229
+ primitive: "ssrfGuard.canonicalizeHost must fold an IPv4-mapped IPv6 address (::ffff:0:0/96) to its dotted IPv4 form — leaving it as IPv6 means a dual-stack peer on ::ffff:1.2.3.4 never unifies with an operator's 1.2.3.4 allowlist entry (an SSRF allowlist bypass the canonicalizer exists to defend)",
7230
+ scanScope: "lib",
7231
+ regex: /if \(family === 6\)\s*\{(?:(?!IPV6_V4_MAPPED_PREFIX)[\s\S]){0,400}?_ipv6BytesToString/,
7232
+ allowlist: [],
7233
+ reason: "canonicalizeHost's IPv6 branch emitted the RFC 5952 hex string for every IPv6 input, including IPv4-mapped (::ffff:a.b.c.d). But an IPv4-mapped address IS the IPv4 address a.b.c.d for routing/access — classify() already re-classifies it by the embedded v4, and a dual-stack listener reaching ::ffff:1.2.3.4 is the same host as 1.2.3.4. Without folding, canonicalize(::ffff:1.2.3.4) !== canonicalize(1.2.3.4), so an allowlist/dedup/SSRF comparison built on the canonical form is bypassed by presenting the dual-stack spelling. The fix folds the ::ffff:0:0/96 block to dotted IPv4 (only that block — 6to4/NAT64 are translation mechanisms, and a v4 suffix in any other prefix is a distinct address). The tempered span fires while the family-6 branch reaches _ipv6BytesToString with no IPV6_V4_MAPPED_PREFIX check first; the behavioral guard is safe-url-canonicalize.test.js.",
7234
+ },
7235
+ // compliance.clear must cascade the posture-clear to the primitives.
7236
+ {
7237
+ id: "compliance-clear-no-cascade",
7238
+ primitive: "compliance.clear() must cascade the posture reset to the primitives (_applyPostureCascade(null)) just as set() cascades the posture — otherwise a primitive that inherits the active posture (retention.complianceFloor) keeps applying the stale floor after b.compliance.clear()",
7239
+ scanScope: "lib",
7240
+ regex: /_emitAudit\("compliance\.posture\.cleared"/,
7241
+ requires: /_applyPostureCascade\(null\)/,
7242
+ skipCommentLines: true,
7243
+ allowlist: [],
7244
+ reason: "Codex P2 — b.compliance.set(posture) calls _applyPostureCascade(posture), which walks retention/audit/db/cryptoField calling applyPosture(posture); retention records it so complianceFloor() inherits it. b.compliance.clear() nulled only compliance's own STATE.posture and never cascaded, so after set(\"hipaa\") then clear(), compliance.current() is null but retention still inherits the stale HIPAA floor. clear() must call _applyPostureCascade(null) so each primitive's applyPosture(null) resets it (retention.applyPosture(null) clears its active posture). The detector fires while clear() exists with no _applyPostureCascade(null) call anywhere in the file (set() passes the posture, not null) and goes silent once clear() cascades the reset; the behavioral guard is retention-floor.test.js.",
7245
+ },
7246
+ // canonicalizeHost must NOT fold NAT64/6to4 (would flip an SSRF classify verdict).
7247
+ {
7248
+ id: "ssrf-canonicalizehost-folds-nat64",
7249
+ primitive: "ssrfGuard.canonicalizeHost must fold ONLY the IPv4-mapped block (::ffff:0:0/96) to IPv4 — NOT NAT64 (64:ff9b::/96). classify() treats a NAT64 literal as `classify(v4) || \"reserved\"`, so folding a public NAT64 address to its embedded IPv4 turns a blocked verdict into an allowed one (canonicalize-then-classify must agree with classify alone)",
7250
+ scanScope: "lib",
7251
+ regex: /_ipv6PrefixMatch\(\s*IPV6_NAT64_PREFIX\s*,\s*C\.BYTES\.bytes\(96\)\s*,\s*v6bytes\s*\)/,
7252
+ allowlist: [],
7253
+ reason: "canonicalizeHost folds an IPv4-mapped IPv6 host to its embedded IPv4 because classify(::ffff:x) === classify(x) (that branch has no reserved fallback), so the fold can't change an SSRF verdict. NAT64 is different: classify('64:ff9b::8.8.8.8') is `classify('8.8.8.8') || 'reserved'` = 'reserved' (blocked) while classify('8.8.8.8') is null (allowed) — so folding a public NAT64 literal to 8.8.8.8 before an allowlist/classify check flips a blocked address to an allowed public IPv4 (Codex P2). canonicalizeHost must leave NAT64 / 6to4 as IPv6; classify still reaches the embedded v4 for the deny side. The detector anchors on canonicalizeHost's NAT64 prefix-match (it uses the local `v6bytes`, so classify()'s own legitimate NAT64 extraction — which uses `bytes` — is not matched) and goes silent once canonicalizeHost no longer folds NAT64. The behavioral guard is the classify-agreement invariant in safe-url-canonicalize.test.js.",
7254
+ },
7255
+ // #134 — verifyIdToken must check azp on multi-audience ID tokens.
7256
+ {
7257
+ id: "oauth-verifyidtoken-no-azp-check",
7258
+ primitive: "verifyIdToken must verify the azp (authorized party) claim — OIDC Core §3.1.3.7: a multi-audience ID token requires an azp, and a present azp MUST equal the RP's client_id. Checking only that aud contains clientId lets a token minted for a DIFFERENT authorized party (that merely lists this RP in a multi-aud array) verify clean (confused deputy)",
7259
+ scanScope: "lib",
7260
+ regex: /throw new OAuthError\("auth-oauth\/aud-mismatch"/,
7261
+ requires: /auth-oauth\/azp-mismatch/,
7262
+ skipCommentLines: true,
7263
+ allowlist: [],
7264
+ reason: "#134 — verifyIdToken validated only `aud.indexOf(clientId) !== -1` (throwing auth-oauth/aud-mismatch) and ignored azp. OIDC Core §3.1.3.7 requires: if the ID token carries multiple audiences the Client must verify an azp is present, and if azp is present its value must be the Client's client_id. Without it, an IdP-issued token whose authorized party is a DIFFERENT client but whose aud array also lists this RP verifies clean — a confused-deputy / token-substitution hole. The fix adds, right after the aud check: reject when aud.length > 1 and no azp (auth-oauth/azp-required), and reject when azp is present and !== clientId (auth-oauth/azp-mismatch). The detector anchors on verifyIdToken's unique aud-mismatch throw and requires the azp-mismatch code in-file; the single-aud no-azp token (the common case) is unaffected.",
7265
+ },
7037
7266
  // #116 — crypto-field upgrade-on-read rewrite must honor the handle's dialect.
7038
7267
  {
7039
7268
  id: "cryptofield-upgrade-on-read-hardcodes-sqlite-dialect",
@@ -9298,6 +9527,24 @@ var KNOWN_ANTIPATTERNS = [
9298
9527
  // detector encoded. These 5 are the highest-priority subset (P1 +
9299
9528
  // P2 by audit ranking). The remaining 8 are scoped for follow-up.
9300
9529
 
9530
+ {
9531
+ // #123 — a worker_threads Worker whose error/exit handler rejects WITHOUT
9532
+ // terminating leaves the thread alive holding its event-loop handles, so the
9533
+ // parent process can't exit and the smoke run hangs under the watchdog on
9534
+ // memory-starved CI runners (macOS-arm64). Every Promise-settle path
9535
+ // (message / error / exit) must reap the worker via w.terminate() first; the
9536
+ // fix funnels all three through a settle() guard that terminates before
9537
+ // resolve/reject. Structural resource-hygiene drift a behavioral test can't
9538
+ // assert — the harness closure isn't exported and the hang is a slow-runner
9539
+ // race — so the detector is the guard.
9540
+ id: "test-worker-reject-without-terminate",
9541
+ primitive: "a worker_threads Worker error/exit handler must terminate() the worker before rejecting (route message/error/exit through a settle() helper) — a bare reject leaks the thread's handles and hangs process exit on slow CI runners",
9542
+ scanScope: "test",
9543
+ regex: /\bw\.once\("error",\s*reject\)/,
9544
+ skipCommentLines: true,
9545
+ allowlist: [],
9546
+ reason: "#123 macOS codebase-patterns watchdog hang. _scanShardInWorker rejected on worker error/exit without w.terminate(), so an errored worker thread stayed alive holding open handles; the parent then could not exit and the smoke run ran to the 25-min watchdog on memory-starved macOS-arm64 runners (it hung this very release's CI). Every settle path must reap the worker via w.terminate() first; the fix funnels message/error/exit through a settle() guard that terminates before resolve/reject. Fires on the bare `w.once(\"error\", reject)` shape; silent once error/exit route through settle().",
9547
+ },
9301
9548
  {
9302
9549
  // `Promise + setTimeout` direct sleep in tests is forbidden;
9303
9550
  // tests waiting on an asynchronous condition MUST use
@@ -52,6 +52,24 @@ async function testShake256ConfigurableLength() {
52
52
  check("verify 64-byte envelope", (await ch.verify("hi", env64)) === true);
53
53
  check("verify 192-byte envelope", (await ch.verify("hi", env192)) === true);
54
54
  check("64 envelope can't verify against 192", env64 !== env192);
55
+
56
+ // #111 — needsRehash must drive the advertised SHAKE256 length-rotation: a
57
+ // digest stored at the old length must be flagged for rehash when the
58
+ // configured/default length is larger. needsRehash ignored payload length,
59
+ // so raising the output length was a silent no-op.
60
+ check("#111 a 64-byte digest needs rehash under the 128-byte default",
61
+ ch.needsRehash(env64) === true);
62
+ check("#111 a 64-byte digest needs rehash when the target length is raised to 192",
63
+ ch.needsRehash(env64, { params: { length: 192 } }) === true);
64
+ check("#111 a 64-byte digest does NOT need rehash when the target stays 64",
65
+ ch.needsRehash(env64, { params: { length: 64 } }) === false);
66
+ check("#111 a default-length (128) digest does not need rehash at the default",
67
+ ch.needsRehash(await ch.hash("hi")) === false);
68
+ // Upgrade-only, matching the Argon2 needsRehash convention (rehash when the
69
+ // stored strength is BELOW target, never to actively shorten): a 192-byte
70
+ // digest must NOT be rehashed down to a 128-byte target.
71
+ check("#111 a longer (192) digest is NOT rehashed down to a smaller target (128)",
72
+ ch.needsRehash(env192, { params: { length: 128 } }) === false);
55
73
  }
56
74
 
57
75
  async function testShake256BufferSecret() {
@@ -146,6 +146,37 @@ async function testSnapshotPlainMode() {
146
146
  await b.db.close();
147
147
  }
148
148
 
149
+ async function testSqliteResourceLimits() {
150
+ // The node:sqlite `limits` option caps SQLITE_LIMIT_* at construction: a
151
+ // parse-time DoS floor. A statement over 1 MiB is rejected before SQLite's
152
+ // parser processes it, and ATTACH DATABASE is denied (the framework never
153
+ // uses it). RED on a tree without the limits option: the megaquery parses.
154
+ var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sqlim-"));
155
+ await _resetState();
156
+ await b.vault.init({ dataDir: tmpDir, mode: "plaintext" });
157
+ await b.db.init({
158
+ dataDir: tmpDir,
159
+ atRest: "plain",
160
+ schema: [{ name: "t", columns: { _id: "TEXT PRIMARY KEY" } }],
161
+ });
162
+
163
+ // A raw statement whose SQL TEXT exceeds 1 MiB is rejected at parse
164
+ // (sqlLength caps statement text, not bound-parameter values — the builder
165
+ // path always parameterizes, so this cap guards the raw-SQL surface).
166
+ var hugeSql = "SELECT '" + "x".repeat(1100000) + "' AS big";
167
+ var threw = null;
168
+ try { b.db.runSql(hugeSql); }
169
+ catch (e) { threw = e; }
170
+ check("sqlite limits: a >1 MiB raw statement is rejected at parse", threw !== null);
171
+
172
+ // A normal statement under the cap is unaffected.
173
+ b.db.from("t").insertOne({ _id: "normal" });
174
+ check("sqlite limits: a normal statement is unaffected",
175
+ b.db.from("t").where({ _id: "normal" }).first()._id === "normal");
176
+
177
+ await b.db.close();
178
+ }
179
+
149
180
  async function run() {
150
181
  await testFrameworkTablesOff();
151
182
  await testAuditSigningOff();
@@ -153,6 +184,7 @@ async function run() {
153
184
  await testDbKeyPathOverride();
154
185
  await testSnapshot();
155
186
  await testSnapshotPlainMode();
187
+ await testSqliteResourceLimits();
156
188
  }
157
189
 
158
190
  module.exports = { run: run };
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ // b.dsa — EU Digital Services Act (Reg 2022/2065) record-builders:
3
+ // Art. 16 noticeAndAction, Art. 17 statementOfReasons, Art. 15/24(3)
4
+ // transparencyReport. Pure builders (no DB); audit emission is captured
5
+ // by swapping b.audit.safeEmit for the duration of each assertion.
6
+
7
+ var helpers = require("../helpers");
8
+ var b = helpers.b;
9
+ var check = helpers.check;
10
+
11
+ // Capture the audit events a builder emits. b.dsa resolves its audit
12
+ // sink via require("./audit"), the same module object as b.audit, so
13
+ // swapping b.audit.safeEmit intercepts the emission. Always restore in a
14
+ // finally so a thrown assertion can't leak the stub.
15
+ function captureAudit(fn) {
16
+ var events = [];
17
+ var orig = b.audit.safeEmit;
18
+ b.audit.safeEmit = function (ev) { events.push(ev); };
19
+ try { fn(events); }
20
+ finally { b.audit.safeEmit = orig; }
21
+ return events;
22
+ }
23
+
24
+ function expectCode(label, fn, code) {
25
+ var threw = null;
26
+ try { fn(); } catch (e) { threw = e; }
27
+ check(label, threw && (threw.code || "") === code);
28
+ }
29
+
30
+ function expectThrows(label, fn) {
31
+ var threw = null;
32
+ try { fn(); } catch (e) { threw = e; }
33
+ check(label, threw instanceof Error);
34
+ }
35
+
36
+ function run() {
37
+ // ---- surface ----
38
+ check("noticeAndAction is a function", typeof b.dsa.noticeAndAction === "function");
39
+ check("statementOfReasons is a function", typeof b.dsa.statementOfReasons === "function");
40
+ check("transparencyReport is a function", typeof b.dsa.transparencyReport === "function");
41
+ check("listTransparencyMetrics is a function", typeof b.dsa.listTransparencyMetrics === "function");
42
+ check("DsaError exposed on b.frameworkError", typeof b.frameworkError.DsaError === "function");
43
+ check("b.dsa.DsaError is the same constructor", b.dsa.DsaError === b.frameworkError.DsaError);
44
+
45
+ // ===== Art. 16 — noticeAndAction =====
46
+ var submittedAt = 1700000000000;
47
+ var nEvents = captureAudit(function () {
48
+ var n = b.dsa.noticeAndAction({
49
+ contentId: "post-9931",
50
+ noticeType: "illegal-content",
51
+ reason: "Depicts a sale prohibited under national law.",
52
+ submittedAt: submittedAt,
53
+ submitterType: "trusted-flagger",
54
+ });
55
+ check("notice: record frozen", Object.isFrozen(n));
56
+ check("notice: status recorded", n.status === "recorded");
57
+ check("notice: default noticeId derived", n.noticeId === "dsa-notice-" + submittedAt);
58
+ check("notice: actionDueBy = submittedAt+24h", n.actionDueBy === submittedAt + b.constants.TIME.hours(24));
59
+ check("notice: illegal-content requires SoR", n.statementOfReasonsRequired === true);
60
+ });
61
+ check("notice: emitted one audit event", nEvents.length === 1);
62
+ check("notice: audit action dsa.notice.recorded", nEvents[0] && nEvents[0].action === "dsa.notice.recorded");
63
+ check("notice: audit metadata carries contentId", nEvents[0] && nEvents[0].metadata.contentId === "post-9931");
64
+
65
+ var nTerms = b.dsa.noticeAndAction({
66
+ contentId: "post-1", noticeType: "terms-violation", reason: "Breaches guidelines.",
67
+ submittedAt: submittedAt, submitterType: "individual",
68
+ noticeId: "op-77", actionWindowMs: b.constants.TIME.hours(48),
69
+ });
70
+ check("notice: terms-violation no SoR", nTerms.statementOfReasonsRequired === false);
71
+ check("notice: explicit noticeId honored", nTerms.noticeId === "op-77");
72
+ check("notice: explicit actionWindow honored", nTerms.actionDueBy === submittedAt + b.constants.TIME.hours(48));
73
+
74
+ expectCode("notice: non-object opts throws",
75
+ function () { b.dsa.noticeAndAction("nope"); }, "dsa/bad-opts");
76
+ expectThrows("notice: unknown opt key throws",
77
+ function () { b.dsa.noticeAndAction({ contentId: "c", noticeType: "other", reason: "r", submittedAt: submittedAt, submitterType: "individual", bogus: 1 }); });
78
+ expectCode("notice: missing contentId throws",
79
+ function () { b.dsa.noticeAndAction({ noticeType: "other", reason: "r", submittedAt: submittedAt, submitterType: "individual" }); }, "dsa/bad-content-id");
80
+ expectCode("notice: unknown noticeType throws",
81
+ function () { b.dsa.noticeAndAction({ contentId: "c", noticeType: "wat", reason: "r", submittedAt: submittedAt, submitterType: "individual" }); }, "dsa/unknown-notice-type");
82
+ expectCode("notice: missing submittedAt throws",
83
+ function () { b.dsa.noticeAndAction({ contentId: "c", noticeType: "other", reason: "r", submitterType: "individual" }); }, "dsa/bad-submitted-at");
84
+ expectCode("notice: unknown submitterType throws",
85
+ function () { b.dsa.noticeAndAction({ contentId: "c", noticeType: "other", reason: "r", submittedAt: submittedAt, submitterType: "robot" }); }, "dsa/unknown-submitter-type");
86
+
87
+ // ===== Art. 17 — statementOfReasons =====
88
+ var sEvents = captureAudit(function () {
89
+ var s = b.dsa.statementOfReasons({
90
+ contentId: "post-9931", decision: "content-removed",
91
+ legalGround: "National law prohibiting the depicted sale.",
92
+ facts: "Listing offered a prohibited item for sale.", automated: false,
93
+ redressOptions: ["internal-complaint", "judicial-redress"], noticeId: "dsa-notice-" + submittedAt,
94
+ });
95
+ check("sor: record frozen", Object.isFrozen(s));
96
+ check("sor: default sorId derived", /^dsa-sor-\d+$/.test(s.sorId));
97
+ check("sor: groundType legal", s.groundType === "legal");
98
+ check("sor: contractualGround null", s.contractualGround === null);
99
+ check("sor: redressOptions frozen", Object.isFrozen(s.redressOptions) && s.redressOptions.length === 2);
100
+ check("sor: noticeId linked", s.noticeId === "dsa-notice-" + submittedAt);
101
+ });
102
+ check("sor: emitted one audit event", sEvents.length === 1);
103
+ check("sor: audit action dsa.sor.recorded", sEvents[0] && sEvents[0].action === "dsa.sor.recorded");
104
+
105
+ var sC = b.dsa.statementOfReasons({
106
+ contentId: "post-2", decision: "account-suspended",
107
+ contractualGround: "Terms section 4.2 — repeated spam.", facts: "Five spam posts in 24h.",
108
+ automated: true, redressOptions: ["internal-complaint"],
109
+ });
110
+ check("sor: groundType contractual", sC.groundType === "contractual");
111
+ check("sor: legalGround null", sC.legalGround === null);
112
+ check("sor: automated true", sC.automated === true);
113
+
114
+ expectCode("sor: unknown decision throws",
115
+ function () { b.dsa.statementOfReasons({ contentId: "c", decision: "nuke", legalGround: "x", facts: "f", automated: false, redressOptions: ["internal-complaint"] }); }, "dsa/unknown-decision");
116
+ expectCode("sor: non-boolean automated throws",
117
+ function () { b.dsa.statementOfReasons({ contentId: "c", decision: "no-action", legalGround: "x", facts: "f", automated: "no", redressOptions: ["internal-complaint"] }); }, "dsa/bad-automated");
118
+ expectCode("sor: neither ground throws",
119
+ function () { b.dsa.statementOfReasons({ contentId: "c", decision: "no-action", facts: "f", automated: false, redressOptions: ["internal-complaint"] }); }, "dsa/ground-required");
120
+ expectCode("sor: both grounds throws",
121
+ function () { b.dsa.statementOfReasons({ contentId: "c", decision: "no-action", legalGround: "x", contractualGround: "y", facts: "f", automated: false, redressOptions: ["internal-complaint"] }); }, "dsa/ground-required");
122
+ expectCode("sor: empty redressOptions throws",
123
+ function () { b.dsa.statementOfReasons({ contentId: "c", decision: "content-removed", legalGround: "x", facts: "f", automated: false, redressOptions: [] }); }, "dsa/redress-required");
124
+ expectCode("sor: unknown redress option throws",
125
+ function () { b.dsa.statementOfReasons({ contentId: "c", decision: "content-removed", legalGround: "x", facts: "f", automated: false, redressOptions: ["call-the-mayor"] }); }, "dsa/unknown-redress-option");
126
+
127
+ // ===== Art. 15 / 24(3) — transparencyReport =====
128
+ var metricNames = b.dsa.listTransparencyMetrics();
129
+ check("metrics: frozen list", Array.isArray(metricNames) && Object.isFrozen(metricNames));
130
+ check("metrics: includes noticesReceived", metricNames.indexOf("noticesReceived") !== -1);
131
+
132
+ var period = { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 11, 31) };
133
+ var rEvents = captureAudit(function () {
134
+ var r = b.dsa.transparencyReport({
135
+ period: period,
136
+ metrics: { noticesReceived: 1200, actionsTaken: 940, automatedDecisions: 610, appeals: 75 },
137
+ service: "example-platform",
138
+ });
139
+ check("report: record frozen", Object.isFrozen(r));
140
+ check("report: metrics frozen", Object.isFrozen(r.metrics));
141
+ check("report: period frozen", Object.isFrozen(r.period));
142
+ check("report: supplied metric carried", r.metrics.noticesReceived === 1200);
143
+ check("report: omitted metric defaults 0", r.metrics.statementsOfReasons === 0);
144
+ check("report: all metric fields present", metricNames.every(function (m) { return typeof r.metrics[m] === "number"; }));
145
+ check("report: default reportId derived", r.reportId === "dsa-transparency-" + period.to);
146
+ check("report: nextReportDueBy = to+365d", r.nextReportDueBy === period.to + b.constants.TIME.days(365));
147
+ });
148
+ check("report: emitted one audit event", rEvents.length === 1);
149
+ check("report: audit action transparency event", rEvents[0] && rEvents[0].action === "dsa.transparency_report.generated");
150
+ check("report: audit metadata actionsTaken", rEvents[0] && rEvents[0].metadata.actionsTaken === 940);
151
+
152
+ expectCode("report: missing period throws",
153
+ function () { b.dsa.transparencyReport({ metrics: {} }); }, "dsa/bad-period");
154
+ expectCode("report: from >= to throws",
155
+ function () { b.dsa.transparencyReport({ period: { from: period.to, to: period.from } }); }, "dsa/bad-period-order");
156
+ expectCode("report: unknown metric key throws",
157
+ function () { b.dsa.transparencyReport({ period: period, metrics: { bogusMetric: 1 } }); }, "dsa/unknown-metric");
158
+ expectCode("report: negative metric throws",
159
+ function () { b.dsa.transparencyReport({ period: period, metrics: { appeals: -3 } }); }, "dsa/bad-metric-value");
160
+ expectCode("report: non-integer metric throws",
161
+ function () { b.dsa.transparencyReport({ period: period, metrics: { appeals: 2.5 } }); }, "dsa/bad-metric-value");
162
+ }
163
+
164
+ module.exports = { run: run };
165
+
166
+ if (require.main === module) {
167
+ try { run(); console.log("[dsa] OK"); }
168
+ catch (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
169
+ }
@@ -15,7 +15,9 @@ var helpers = require("../helpers");
15
15
  var b = helpers.b;
16
16
  var check = helpers.check;
17
17
 
18
- var otlp = require("../../lib/observability-otlp-exporter");
18
+ var otlp = require("../../lib/observability-otlp-exporter");
19
+ var otlpLog = require("../../lib/log-stream-otlp");
20
+ var otlpLogGrpc = require("../../lib/log-stream-otlp-grpc");
19
21
 
20
22
  var SECRET_TOKEN = "Bearer eyJSECRETtokenABCdef.ghi.jkl";
21
23
  var SECRET_PW = "hunter2-PLAINTEXT-PASSWORD";
@@ -89,6 +91,43 @@ async function run() {
89
91
  check("protobuf: api key redacted on the OTLP wire", protoWire.indexOf(SECRET_API) === -1);
90
92
  check("protobuf: non-sensitive control attribute survives", protoWire.indexOf(CONTROL_VAL) !== -1);
91
93
 
94
+ // ---- OTLP LOG sinks are EGRESS too: record-meta + resource attrs must redact ----
95
+ // The span/metric exporters redact; the log sinks (HTTP-JSON + gRPC) shipped
96
+ // the same attribute maps to the collector UNREDACTED — a log line carrying a
97
+ // bearer token / password in its meta or a credential in a resource attribute
98
+ // reached the wire verbatim (CWE-532). Drive the serialization seam of both.
99
+ var logRec = otlpLog._toLogRecord({
100
+ ts: 1700000000000, level: "error", message: "boom",
101
+ meta: { "http.method": CONTROL_VAL, authorization: SECRET_TOKEN, password: SECRET_PW },
102
+ });
103
+ var logRecWire = JSON.stringify(logRec);
104
+ check("otlp-log json: record-meta bearer token redacted", logRecWire.indexOf(SECRET_TOKEN) === -1);
105
+ check("otlp-log json: record-meta password redacted", logRecWire.indexOf(SECRET_PW) === -1);
106
+ check("otlp-log json: non-sensitive record-meta survives", logRecWire.indexOf(CONTROL_VAL) !== -1);
107
+
108
+ var logBatchWire = otlpLog._serializeBatch(
109
+ [{ ts: 1700000000000, level: "info", message: "m", meta: { api_key: SECRET_API } }],
110
+ { serviceName: "svc", resourceAttributes: { authorization: SECRET_TOKEN, region: CONTROL_VAL } },
111
+ "1.0.0"
112
+ ).toString("utf8");
113
+ check("otlp-log json: resource-attr bearer token redacted", logBatchWire.indexOf(SECRET_TOKEN) === -1);
114
+ check("otlp-log json: record-meta api key redacted", logBatchWire.indexOf(SECRET_API) === -1);
115
+ check("otlp-log json: non-sensitive resource attr survives", logBatchWire.indexOf(CONTROL_VAL) !== -1);
116
+
117
+ var grpcRec = otlpLogGrpc._encodeLogRecord({
118
+ ts: 1700000000000, level: "error", message: "boom",
119
+ meta: { authorization: SECRET_TOKEN, password: SECRET_PW },
120
+ }).toString("latin1");
121
+ check("otlp-log grpc: record-meta bearer token redacted", grpcRec.indexOf(SECRET_TOKEN) === -1);
122
+ check("otlp-log grpc: record-meta password redacted", grpcRec.indexOf(SECRET_PW) === -1);
123
+
124
+ var grpcReq = otlpLogGrpc._encodeExportRequest(
125
+ [{ ts: 1700000000000, level: "info", message: "m", meta: { api_key: SECRET_API } }],
126
+ { serviceName: "svc", resourceAttributes: { authorization: SECRET_TOKEN } }
127
+ ).toString("latin1");
128
+ check("otlp-log grpc: resource-attr bearer token redacted", grpcReq.indexOf(SECRET_TOKEN) === -1);
129
+ check("otlp-log grpc: record-meta api key redacted", grpcReq.indexOf(SECRET_API) === -1);
130
+
92
131
  b.observability.setRedactor(null); // restore default for other tests
93
132
  process.stdout.write("OK — otlp attribute redaction tests\n");
94
133
  }