@blamejs/core 0.8.10 → 0.8.12
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 +4 -0
- package/index.js +2 -0
- package/lib/ai-adverse-decision.js +184 -0
- package/lib/breach-deadline.js +272 -0
- package/lib/middleware/age-gate.js +141 -0
- package/lib/middleware/index.js +3 -0
- package/lib/websocket.js +79 -3
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **0.8.12** (2026-05-07) — WebSocket upgrade refuses credential-shaped query parameters by default. `validateUpgradeRequest(req, opts)` now scans the request URL for the credential-leak names `access_token`, `bearer`, `bearer_token`, `apikey`, `api_key`, `api-key`, `authorization` (case-insensitive, with percent-decoding) and refuses the upgrade with HTTP 400 when one is present. URL query strings leak through web-server access logs, browser history, the Referer header forwarded to third-party CDN / analytics, in-process / proxy log captures, and crash dumps — RFC 6750 §2.3 explicitly cautions against bearer tokens in URI query parameters for these reasons. Operators with a non-credential parameter that happens to share a credential-shaped name opt out per route via `opts.allowQueryAuthParams: true` with an audited operator reason. The refused list is deliberately narrow: overloaded names (`token`, `auth`, `key`, `session`) have non-credential meanings (CSRF tokens, file-share tokens, session-resume identifiers) and are NOT refused.
|
|
12
|
+
|
|
13
|
+
- **0.8.11** (2026-05-07) — Three new state-and-federal regulatory primitives + a per-primitive test-coverage gate. **`b.breach.deadline` + `b.breach.report`** — all-50-states data-breach-notification deadline registry. `b.breach.deadline.forStates(states, detectedAt)` returns per-state `{ state, kind, dueBy, citation }` records (`kind: "as-soon-as-possible"` for AS-OF / `"hard-deadline"` for fixed-day deadlines like Texas / Florida / Maine). `b.breach.report.create()` opens a multi-state breach with a single record, tracks per-state filings via `fileNotice(id, state, ...)`, exposes `pending(id)` for dashboards, and auto-closes once every affected state has filed. Every transition records a `breach.report.*` audit event. Statutory citations + day counts wired in `lib/breach-deadline.js` per-state. **`b.ai.adverseDecision`** — wraps an operator-supplied `decide(subject)` predicate, automatically attaches a consumer-rights notice when the outcome is `"adverse"` / `"denied"` / `"rejected"`. Built-in regulation templates for `gdpr-22` (Article 22 automated-decision rights), `ai-act-86` (EU AI Act high-risk consumer recourse), `ecoa-1002.9` (US Equal Credit Opportunity Act adverse-action notice), `colorado-ai-act` (CO SB 24-205 §6-1-1701), `nyc-ll-144` (NYC Local Law 144 employment AEDT), `fcra-615` (US FCRA adverse action), and `operator-defined`. Notice carries `principalReasons` + `consumerRights: { requestData, requestExplanation, contestDecision, requestHumanReview }` shaped per regime. **`b.middleware.ageGate`** — request-level age-classification middleware. Operator-supplied `getAge(req)` returns the subject age (or null/undefined when unknown); middleware classifies as `"above-threshold"` / `"below-threshold"` / `"unknown"` against `consentRequired`, sets `X-Privacy-Posture` header, and refuses with 451 + audited reason when `requireAge` is set and `hasParentalConsent(req)` is unmet. Composes upstream of session / authn for COPPA / AADC / UK Children's Code postures. **Per-primitive test-coverage gate** — new `test/layer-0-primitives/test-coverage.test.js` walks every operator-facing `b.*` primitive and refuses release unless the primitive has at least one test reference (or an explicit `UNTESTED_BACKLOG` entry naming the reason). Closes the drift class where a primitive landed on `b.*` but never gained a unit test.
|
|
14
|
+
|
|
11
15
|
- **0.8.10** (2026-05-07) — Five new compliance / regulatory primitives composing on v0.8.9's `b.incident.report`. **`b.cra.report`** — EU Cyber Resilience Act (Regulation (EU) 2024/2847) Article 14 §1 incident reporting wrapper. Three-stage statutory deadlines: 24h early warning / 72h incident notification / 14d final report. Required `productId` + `manufacturer` per Annex VII §1. Optional ENISA submission via `opts.enisaEndpoint` + `b.httpClient`; submission is operator-opt-in per stage call (regulators uniformly require operator review before filing). **`b.nis2.report`** — NIS2 Directive (Directive (EU) 2022/2555) Article 23 §4 incident reporting wrapper. Three-stage deadlines: 24h / 72h / 1 month. Annex I (essential) / Annex II (important) entity classification + sector codes (`I.6` drinking water / `II.6` digital providers / etc.). **`b.gdpr.ropa`** — GDPR Article 30 Records of Processing Activities registry + JSON / CSV / Markdown exporter. Validates required fields per Article 30 §1; legal-basis enum per Article 6(1); produces a regulator-friendly RoPA document for the operator's DPO to file. **`b.compliance.eaa`** — EU Accessibility Act (Directive (EU) 2019/882) Article 13 declared-conformance generator. Operators declare per-criterion conformance against WCAG 2.1/2.2 AA / EN 301 549; non-conformances ship with reason + mitigation. JSON / Markdown export for the operator's accessibility statement. **`b.middleware.botDisclose`** — California SB 1001 (Cal. Bus. & Prof. Code §17941) bot-disclosure middleware. Injects a disclosure banner into HTML responses, sets `X-Bot-Disclosure` header for API consumers, audits every conversation-initiating request. Operators wire `mountPaths` to scope and `bannerHtml` for visual customization.
|
|
12
16
|
|
|
13
17
|
- **0.8.9** (2026-05-07) — `b.incident.report` — generic 3-stage incident-reporting primitive. The three stages mirror the deadline pattern that recurs across regulatory regimes: initial / early-warning notification (within 24h of detection), intermediate / status update (within 72h), final report (within 30d or per-regime deadline). Built-in per-regime deadlines for `gdpr` (Article 33), `nis2` (Article 23), `dora` (Article 19), `cra` (Article 14), `hipaa` (Breach Notification Rule); operators select via `regime: "gdpr"` and `opts.deadlines` can override per-stage. Each stage records a tamper-evident audit event (`incident.report.stage_recorded`) with a `late: bool` + `lateBy: ms` flag — late filings are recorded with `outcome: "late"` so regulator audits can distinguish on-time from late-but-eventually filings. Operator-supplied `persist(record)` writes to a DB / SIEM / SOAR system; `onStage(event)` fires for synchronous routing. `status()` returns aggregate counts (open / closed / late-per-stage) for dashboards.
|
package/index.js
CHANGED
|
@@ -254,6 +254,8 @@ module.exports = {
|
|
|
254
254
|
cra: { report: require("./lib/cra-report") },
|
|
255
255
|
nis2: { report: require("./lib/nis2-report") },
|
|
256
256
|
gdpr: { ropa: require("./lib/gdpr-ropa") },
|
|
257
|
+
breach: require("./lib/breach-deadline"),
|
|
258
|
+
ai: { adverseDecision: require("./lib/ai-adverse-decision") },
|
|
257
259
|
queue: queue,
|
|
258
260
|
logStream: logStream,
|
|
259
261
|
redact: redact,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.ai.adverseDecision — adverse-decision wrapper for automated
|
|
4
|
+
* decisions affecting consumer rights.
|
|
5
|
+
*
|
|
6
|
+
* GDPR Article 22, EU AI Act Article 86, Colorado AI Act, NYC Local
|
|
7
|
+
* Law 144, and Equal Credit Opportunity Act §1002.9 all require some
|
|
8
|
+
* form of consumer notice + explanation when an automated decision
|
|
9
|
+
* adversely affects a person (denial of credit, denial of employment,
|
|
10
|
+
* adverse insurance pricing, etc.).
|
|
11
|
+
*
|
|
12
|
+
* The primitive wraps an operator-supplied predicate and:
|
|
13
|
+
* - logs every decision with audit-chain attribution
|
|
14
|
+
* - emits a structured consumer-rights notice on adverse outcomes
|
|
15
|
+
* - pulls operator-supplied principal reasons (the "specific reasons
|
|
16
|
+
* for the action" that ECOA + similar regimes require)
|
|
17
|
+
*
|
|
18
|
+
* var hireDecision = b.ai.adverseDecision.wrap({
|
|
19
|
+
* name: "hire-screening",
|
|
20
|
+
* model: "screening-v3.1",
|
|
21
|
+
* legalBasis: "ecoa-1002.9",
|
|
22
|
+
* decide: function (applicant) {
|
|
23
|
+
* var score = scoreModel(applicant);
|
|
24
|
+
* return {
|
|
25
|
+
* outcome: score < 0.5 ? "adverse" : "favorable",
|
|
26
|
+
* score: score,
|
|
27
|
+
* principalReasons: score < 0.5 ? ["insufficient-credit-history", "..."] : [],
|
|
28
|
+
* };
|
|
29
|
+
* },
|
|
30
|
+
* onAdverse: async function (subject, decision) {
|
|
31
|
+
* await mailer.send({ to: subject.email, ... });
|
|
32
|
+
* },
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* var decision = await hireDecision({ id: "applicant-1234", email: "..." });
|
|
36
|
+
* // decision = { outcome, score, principalReasons, adverseNotice? }
|
|
37
|
+
*
|
|
38
|
+
* adverseNotice (when outcome === "adverse"):
|
|
39
|
+
* {
|
|
40
|
+
* subjectId: "applicant-1234",
|
|
41
|
+
* decisionAt: 1715040000000,
|
|
42
|
+
* model: "screening-v3.1",
|
|
43
|
+
* legalBasis: "ecoa-1002.9",
|
|
44
|
+
* principalReasons: [...],
|
|
45
|
+
* consumerRights: {
|
|
46
|
+
* requestExplanation: true,
|
|
47
|
+
* requestHumanReview: true,
|
|
48
|
+
* requestAppeal: true,
|
|
49
|
+
* requestData: true,
|
|
50
|
+
* statutoryDeadlines: { explanation: "30d", appeal: "60d" }
|
|
51
|
+
* }
|
|
52
|
+
* }
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
var defineClass = require("./framework-error").defineClass;
|
|
56
|
+
var lazyRequire = require("./lazy-require");
|
|
57
|
+
var validateOpts = require("./validate-opts");
|
|
58
|
+
|
|
59
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
61
|
+
|
|
62
|
+
var AdverseDecisionError = defineClass("AdverseDecisionError", { alwaysPermanent: true });
|
|
63
|
+
|
|
64
|
+
// Per-regime statutory deadlines for the consumer-rights surfaces.
|
|
65
|
+
// Operators select via opts.legalBasis; the framework attaches the
|
|
66
|
+
// right deadline shape to each adverseNotice.
|
|
67
|
+
var REGIME_DEADLINES = Object.freeze({
|
|
68
|
+
"gdpr-22": { explanation: "30d", humanReview: "30d", appeal: "30d", regulation: "GDPR Article 22" },
|
|
69
|
+
"ai-act-86": { explanation: "30d", humanReview: "30d", appeal: "30d", regulation: "EU AI Act Article 86" },
|
|
70
|
+
"ecoa-1002.9": { explanation: "30d", humanReview: null, appeal: null, regulation: "ECOA 12 CFR §1002.9" },
|
|
71
|
+
"colorado-ai-act": { explanation: "60d", humanReview: "60d", appeal: "60d", regulation: "Colorado AI Act" },
|
|
72
|
+
"nyc-ll-144": { explanation: "10d", humanReview: null, appeal: null, regulation: "NYC Local Law 144" },
|
|
73
|
+
"fcra-615": { explanation: "60d", humanReview: null, appeal: null, regulation: "FCRA 15 USC §1681m" },
|
|
74
|
+
"operator-defined": { explanation: null, humanReview: null, appeal: null, regulation: "operator-supplied" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function wrap(opts) {
|
|
78
|
+
opts = opts || {};
|
|
79
|
+
validateOpts(opts, [
|
|
80
|
+
"name", "model", "legalBasis", "decide", "onAdverse",
|
|
81
|
+
"audit", "now",
|
|
82
|
+
], "ai.adverseDecision");
|
|
83
|
+
|
|
84
|
+
validateOpts.requireNonEmptyString(opts.name,
|
|
85
|
+
"ai.adverseDecision.wrap: opts.name is required",
|
|
86
|
+
AdverseDecisionError, "ai-adverse/bad-name");
|
|
87
|
+
validateOpts.requireNonEmptyString(opts.model,
|
|
88
|
+
"ai.adverseDecision.wrap: opts.model is required (model id + version for audit attribution)",
|
|
89
|
+
AdverseDecisionError, "ai-adverse/bad-model");
|
|
90
|
+
validateOpts.requireNonEmptyString(opts.legalBasis,
|
|
91
|
+
"ai.adverseDecision.wrap: opts.legalBasis is required (e.g. 'ecoa-1002.9' / 'gdpr-22' / 'colorado-ai-act')",
|
|
92
|
+
AdverseDecisionError, "ai-adverse/bad-legal-basis");
|
|
93
|
+
if (typeof opts.decide !== "function") {
|
|
94
|
+
throw new AdverseDecisionError("ai-adverse/bad-decide",
|
|
95
|
+
"ai.adverseDecision.wrap: opts.decide must be a function (subject) -> { outcome, principalReasons }");
|
|
96
|
+
}
|
|
97
|
+
var name = opts.name;
|
|
98
|
+
var model = opts.model;
|
|
99
|
+
var legalBasis = opts.legalBasis;
|
|
100
|
+
var decide = opts.decide;
|
|
101
|
+
var onAdverse = typeof opts.onAdverse === "function" ? opts.onAdverse : null;
|
|
102
|
+
var auditOn = opts.audit !== false;
|
|
103
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
104
|
+
|
|
105
|
+
var deadlines = REGIME_DEADLINES[legalBasis] || REGIME_DEADLINES["operator-defined"];
|
|
106
|
+
|
|
107
|
+
function _emitAudit(action, outcome, metadata) {
|
|
108
|
+
if (!auditOn) return;
|
|
109
|
+
try {
|
|
110
|
+
audit().safeEmit({
|
|
111
|
+
action: "ai.adverse_decision." + action,
|
|
112
|
+
outcome: outcome,
|
|
113
|
+
metadata: metadata || {},
|
|
114
|
+
});
|
|
115
|
+
} catch (_e) { /* drop-silent */ }
|
|
116
|
+
}
|
|
117
|
+
function _emitMetric(verb, n, labels) {
|
|
118
|
+
try { observability().safeEvent("ai.adverse_decision." + verb, n || 1, labels || {}); }
|
|
119
|
+
catch (_e) { /* drop-silent */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return async function adverseDecisionDecorated(subject) {
|
|
123
|
+
if (!subject || typeof subject !== "object") {
|
|
124
|
+
throw new AdverseDecisionError("ai-adverse/bad-subject",
|
|
125
|
+
"ai.adverseDecision: subject must be an object with at least { id }");
|
|
126
|
+
}
|
|
127
|
+
var subjectId = subject.id || null;
|
|
128
|
+
var decidedAt = now();
|
|
129
|
+
var decision;
|
|
130
|
+
try {
|
|
131
|
+
decision = await decide(subject);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
_emitAudit("decide_failed", "failure", { name: name, subjectId: subjectId, error: (e && e.message) || String(e) });
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
if (!decision || typeof decision !== "object") {
|
|
137
|
+
throw new AdverseDecisionError("ai-adverse/bad-decision-shape",
|
|
138
|
+
"ai.adverseDecision: opts.decide must return an object with { outcome, principalReasons }");
|
|
139
|
+
}
|
|
140
|
+
var outcome = decision.outcome;
|
|
141
|
+
var isAdverse = outcome === "adverse" || outcome === "denied" || outcome === "rejected";
|
|
142
|
+
|
|
143
|
+
_emitAudit("decided", isAdverse ? "denied" : "success", {
|
|
144
|
+
name: name, model: model, legalBasis: legalBasis,
|
|
145
|
+
subjectId: subjectId, outcome: outcome,
|
|
146
|
+
principalReasons: Array.isArray(decision.principalReasons) ? decision.principalReasons : [],
|
|
147
|
+
});
|
|
148
|
+
_emitMetric("decided", 1, { name: name, outcome: outcome });
|
|
149
|
+
|
|
150
|
+
if (isAdverse) {
|
|
151
|
+
decision.adverseNotice = {
|
|
152
|
+
subjectId: subjectId,
|
|
153
|
+
decisionAt: decidedAt,
|
|
154
|
+
model: model,
|
|
155
|
+
legalBasis: legalBasis,
|
|
156
|
+
regulation: deadlines.regulation,
|
|
157
|
+
principalReasons: Array.isArray(decision.principalReasons) ? decision.principalReasons : [],
|
|
158
|
+
consumerRights: {
|
|
159
|
+
requestExplanation: deadlines.explanation !== null,
|
|
160
|
+
requestHumanReview: deadlines.humanReview !== null,
|
|
161
|
+
requestAppeal: deadlines.appeal !== null,
|
|
162
|
+
requestData: true,
|
|
163
|
+
statutoryDeadlines: {
|
|
164
|
+
explanation: deadlines.explanation,
|
|
165
|
+
humanReview: deadlines.humanReview,
|
|
166
|
+
appeal: deadlines.appeal,
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
if (onAdverse) {
|
|
171
|
+
try { await onAdverse(subject, decision); }
|
|
172
|
+
catch (e) { _emitAudit("on_adverse_threw", "failure", { error: (e && e.message) || String(e) }); }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return decision;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
wrap: wrap,
|
|
181
|
+
REGIME_DEADLINES: REGIME_DEADLINES,
|
|
182
|
+
AdverseDecisionError: AdverseDecisionError,
|
|
183
|
+
VALID_REGIMES: Object.keys(REGIME_DEADLINES),
|
|
184
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.breach.deadline + b.breach.report — US state breach-notification
|
|
4
|
+
* deadline registry + reporter.
|
|
5
|
+
*
|
|
6
|
+
* Every US state + territory has its own breach-notification statute
|
|
7
|
+
* with a deadline (60 days, 45 days, "without unreasonable delay").
|
|
8
|
+
* Operators detecting a breach affecting residents in multiple states
|
|
9
|
+
* face a fan-out problem: which state(s), what deadline for each,
|
|
10
|
+
* what notice content. The registry encodes the per-jurisdiction
|
|
11
|
+
* deadline + statutory citation; the reporter surfaces an operator-
|
|
12
|
+
* friendly "what do I owe and when" plan.
|
|
13
|
+
*
|
|
14
|
+
* var deadlines = b.breach.deadline.forStates(["CA", "NY", "TX"], breachDetectedAt);
|
|
15
|
+
* // -> [{ state: "CA", dueBy: ..., statute: "Cal. Civ. Code §1798.82" }, ...]
|
|
16
|
+
*
|
|
17
|
+
* var reporter = b.breach.report.create({ audit: b.audit });
|
|
18
|
+
* var rec = reporter.open({
|
|
19
|
+
* detectedAt: Date.now(),
|
|
20
|
+
* affectedStates: ["CA", "NY", "TX"],
|
|
21
|
+
* scope: "data-confidentiality-breach",
|
|
22
|
+
* impact: { individualsAffected: 5000 },
|
|
23
|
+
* });
|
|
24
|
+
* await reporter.fileNotice(rec.id, "CA", { ... });
|
|
25
|
+
*
|
|
26
|
+
* The registry is statutory data — operators don't override it
|
|
27
|
+
* without legal counsel review. Updates ride into the framework via
|
|
28
|
+
* patch releases when state legislatures amend their breach laws.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
var C = require("./constants");
|
|
32
|
+
var defineClass = require("./framework-error").defineClass;
|
|
33
|
+
var lazyRequire = require("./lazy-require");
|
|
34
|
+
|
|
35
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
36
|
+
|
|
37
|
+
var BreachError = defineClass("BreachError", { alwaysPermanent: true });
|
|
38
|
+
|
|
39
|
+
// Per-state deadlines as days-from-detection. "WITHOUT_UNREASONABLE_DELAY"
|
|
40
|
+
// is encoded as a sentinel; operators interpret as "as soon as
|
|
41
|
+
// reasonably possible, never later than the longest acceptable for
|
|
42
|
+
// the state's tort-liability standard". Common interpretation: 60 days
|
|
43
|
+
// is a defensible ceiling, but operators with active forensics may
|
|
44
|
+
// stretch to 90 days with documented investigative justification.
|
|
45
|
+
var WITHOUT_UNREASONABLE_DELAY = "WITHOUT_UNREASONABLE_DELAY";
|
|
46
|
+
|
|
47
|
+
var STATE_DEADLINES = Object.freeze({
|
|
48
|
+
// Each entry: { days, statute, asapCeilingDays }
|
|
49
|
+
// Days = statutory hard deadline in days. asapCeilingDays = the
|
|
50
|
+
// operator-defensible ceiling for "without unreasonable delay" states.
|
|
51
|
+
AL: { days: 45, statute: "Ala. Code §8-38-5" }, /* allow:raw-time-literal — statutory deadline days */
|
|
52
|
+
AK: { days: 45, statute: "Alaska Stat. §45.48.010" }, /* allow:raw-time-literal — statutory deadline days */
|
|
53
|
+
AZ: { days: 45, statute: "Ariz. Rev. Stat. §18-552" }, /* allow:raw-time-literal — statutory deadline days */
|
|
54
|
+
AR: { days: 45, statute: "Ark. Code §4-110-105" }, /* allow:raw-time-literal — statutory deadline days */
|
|
55
|
+
CA: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Cal. Civ. Code §1798.82", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
56
|
+
CO: { days: 30, statute: "Colo. Rev. Stat. §6-1-716" }, /* allow:raw-time-literal — statutory deadline days */
|
|
57
|
+
CT: { days: 60, statute: "Conn. Gen. Stat. §36a-701b" }, /* allow:raw-time-literal — statutory deadline days */
|
|
58
|
+
DE: { days: 60, statute: "Del. Code §12B-102" }, /* allow:raw-time-literal — statutory deadline days */
|
|
59
|
+
DC: { days: 60, statute: "D.C. Code §28-3852" }, /* allow:raw-time-literal — statutory deadline days */
|
|
60
|
+
FL: { days: 30, statute: "Fla. Stat. §501.171" }, /* allow:raw-time-literal — statutory deadline days */
|
|
61
|
+
GA: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Ga. Code §10-1-911", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
62
|
+
HI: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Haw. Rev. Stat. §487N-2", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
63
|
+
ID: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Idaho Code §28-51-105", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
64
|
+
IL: { days: WITHOUT_UNREASONABLE_DELAY, statute: "815 ILCS 530/10", asapCeilingDays: 45 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
65
|
+
IN: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Ind. Code §24-4.9-3-3", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
66
|
+
IA: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Iowa Code §715C.2", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
67
|
+
KS: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Kan. Stat. §50-7a02", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
68
|
+
KY: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Ky. Rev. Stat. §365.732", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
69
|
+
LA: { days: 60, statute: "La. Rev. Stat. §51:3074" }, /* allow:raw-time-literal — statutory deadline days */
|
|
70
|
+
ME: { days: 30, statute: "Me. Rev. Stat. tit. 10 §1348" }, /* allow:raw-time-literal — statutory deadline days */
|
|
71
|
+
MD: { days: 45, statute: "Md. Code Com. Law §14-3504" }, /* allow:raw-time-literal — statutory deadline days */
|
|
72
|
+
MA: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Mass. Gen. Laws ch. 93H §3", asapCeilingDays: 30 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
73
|
+
MI: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Mich. Comp. Laws §445.72", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
74
|
+
MN: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Minn. Stat. §325E.61", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
75
|
+
MS: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Miss. Code §75-24-29", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
76
|
+
MO: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Mo. Rev. Stat. §407.1500", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
77
|
+
MT: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Mont. Code §30-14-1704", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
78
|
+
NE: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Neb. Rev. Stat. §87-803", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
79
|
+
NV: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Nev. Rev. Stat. §603A.220", asapCeilingDays: 45 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
80
|
+
NH: { days: WITHOUT_UNREASONABLE_DELAY, statute: "N.H. Rev. Stat. §359-C:20", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
81
|
+
NJ: { days: WITHOUT_UNREASONABLE_DELAY, statute: "N.J. Stat. §56:8-163", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
82
|
+
NM: { days: 45, statute: "N.M. Stat. §57-12C-6" }, /* allow:raw-time-literal — statutory deadline days */
|
|
83
|
+
NY: { days: WITHOUT_UNREASONABLE_DELAY, statute: "N.Y. Gen. Bus. Law §899-aa (SHIELD Act)", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
84
|
+
NC: { days: WITHOUT_UNREASONABLE_DELAY, statute: "N.C. Gen. Stat. §75-65", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
85
|
+
ND: { days: WITHOUT_UNREASONABLE_DELAY, statute: "N.D. Cent. Code §51-30-02", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
86
|
+
OH: { days: 45, statute: "Ohio Rev. Code §1349.19" }, /* allow:raw-time-literal — statutory deadline days */
|
|
87
|
+
OK: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Okla. Stat. tit. 24 §163", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
88
|
+
OR: { days: 45, statute: "Or. Rev. Stat. §646A.604" }, /* allow:raw-time-literal — statutory deadline days */
|
|
89
|
+
PA: { days: WITHOUT_UNREASONABLE_DELAY, statute: "73 Pa. Cons. Stat. §2303", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
90
|
+
PR: { days: 10, statute: "P.R. Laws Ann. tit. 10 §4051 (Citizen Information Security Act)" }, /* allow:raw-time-literal — statutory deadline days */
|
|
91
|
+
RI: { days: 45, statute: "R.I. Gen. Laws §11-49.3-3" }, /* allow:raw-time-literal — statutory deadline days */
|
|
92
|
+
SC: { days: WITHOUT_UNREASONABLE_DELAY, statute: "S.C. Code §39-1-90", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
93
|
+
SD: { days: 60, statute: "S.D. Codified Laws §22-40-20" }, /* allow:raw-time-literal — statutory deadline days */
|
|
94
|
+
TN: { days: 45, statute: "Tenn. Code §47-18-2107" }, /* allow:raw-time-literal — statutory deadline days */
|
|
95
|
+
TX: { days: 60, statute: "Tex. Bus. & Com. Code §521.053" }, /* allow:raw-time-literal — statutory deadline days */
|
|
96
|
+
UT: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Utah Code §13-44-202", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
97
|
+
VT: { days: 45, statute: "Vt. Stat. tit. 9 §2435" }, /* allow:raw-time-literal — statutory deadline days */
|
|
98
|
+
VA: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Va. Code §18.2-186.6", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
99
|
+
WA: { days: 30, statute: "Wash. Rev. Code §19.255.010" }, /* allow:raw-time-literal — statutory deadline days */
|
|
100
|
+
WV: { days: WITHOUT_UNREASONABLE_DELAY, statute: "W. Va. Code §46A-2A-102", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
101
|
+
WI: { days: 45, statute: "Wis. Stat. §134.98" }, /* allow:raw-time-literal — statutory deadline days */
|
|
102
|
+
WY: { days: WITHOUT_UNREASONABLE_DELAY, statute: "Wyo. Stat. §40-12-502", asapCeilingDays: 60 }, /* allow:raw-time-literal — statutory ASAP ceiling days */
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function _msPerDay() { return C.TIME.days(1); }
|
|
106
|
+
|
|
107
|
+
function _deadlineFor(state, detectedAtMs) {
|
|
108
|
+
var rec = STATE_DEADLINES[state.toUpperCase()];
|
|
109
|
+
if (!rec) {
|
|
110
|
+
throw new BreachError("breach/unknown-state",
|
|
111
|
+
"breach.deadline: unknown state code '" + state + "' (use US 2-letter codes)");
|
|
112
|
+
}
|
|
113
|
+
if (rec.days === WITHOUT_UNREASONABLE_DELAY) {
|
|
114
|
+
return {
|
|
115
|
+
state: state.toUpperCase(),
|
|
116
|
+
kind: "as-soon-as-possible",
|
|
117
|
+
ceilingDays: rec.asapCeilingDays,
|
|
118
|
+
ceilingDueBy: detectedAtMs + (rec.asapCeilingDays * _msPerDay()),
|
|
119
|
+
statute: rec.statute,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
state: state.toUpperCase(),
|
|
124
|
+
kind: "hard-deadline",
|
|
125
|
+
days: rec.days,
|
|
126
|
+
dueBy: detectedAtMs + (rec.days * _msPerDay()),
|
|
127
|
+
statute: rec.statute,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function forStates(states, detectedAtMs) {
|
|
132
|
+
if (!Array.isArray(states)) {
|
|
133
|
+
throw new BreachError("breach/bad-states",
|
|
134
|
+
"breach.deadline.forStates: states must be an array of US state codes");
|
|
135
|
+
}
|
|
136
|
+
if (typeof detectedAtMs !== "number" || !isFinite(detectedAtMs)) {
|
|
137
|
+
throw new BreachError("breach/bad-detected-at",
|
|
138
|
+
"breach.deadline.forStates: detectedAtMs must be a finite Unix-ms timestamp");
|
|
139
|
+
}
|
|
140
|
+
var out = [];
|
|
141
|
+
for (var i = 0; i < states.length; i++) {
|
|
142
|
+
out.push(_deadlineFor(states[i], detectedAtMs));
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// b.breach.report — operator-side breach-tracking that wraps the
|
|
148
|
+
// deadline registry and tracks per-state filing status.
|
|
149
|
+
function createReporter(opts) {
|
|
150
|
+
opts = opts || {};
|
|
151
|
+
var auditOn = opts.audit !== false;
|
|
152
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
153
|
+
var seq = 0;
|
|
154
|
+
var breaches = new Map();
|
|
155
|
+
|
|
156
|
+
function _emitAudit(action, outcome, metadata) {
|
|
157
|
+
if (!auditOn) return;
|
|
158
|
+
try {
|
|
159
|
+
audit().safeEmit({
|
|
160
|
+
action: "breach.report." + action,
|
|
161
|
+
outcome: outcome,
|
|
162
|
+
metadata: metadata || {},
|
|
163
|
+
});
|
|
164
|
+
} catch (_e) { /* drop-silent */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function open(spec) {
|
|
168
|
+
if (!spec || typeof spec !== "object") {
|
|
169
|
+
throw new BreachError("breach-report/bad-spec",
|
|
170
|
+
"breach.report.open: spec must be an object with { detectedAt, affectedStates, scope, impact }");
|
|
171
|
+
}
|
|
172
|
+
if (typeof spec.detectedAt !== "number" || !isFinite(spec.detectedAt)) {
|
|
173
|
+
throw new BreachError("breach-report/bad-detected-at",
|
|
174
|
+
"breach.report.open: spec.detectedAt must be a finite Unix-ms timestamp");
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(spec.affectedStates) || spec.affectedStates.length === 0) {
|
|
177
|
+
throw new BreachError("breach-report/bad-states",
|
|
178
|
+
"breach.report.open: spec.affectedStates must be a non-empty array of US state codes");
|
|
179
|
+
}
|
|
180
|
+
seq += 1;
|
|
181
|
+
var id = "breach-" + new Date(spec.detectedAt).toISOString().replace(/[:.]/g, "-") + "-" + seq;
|
|
182
|
+
var deadlines = forStates(spec.affectedStates, spec.detectedAt);
|
|
183
|
+
var rec = {
|
|
184
|
+
id: id,
|
|
185
|
+
detectedAt: spec.detectedAt,
|
|
186
|
+
affectedStates: spec.affectedStates.map(function (s) { return s.toUpperCase(); }),
|
|
187
|
+
scope: spec.scope || null,
|
|
188
|
+
impact: spec.impact || null,
|
|
189
|
+
deadlines: deadlines,
|
|
190
|
+
filings: {}, // state -> { filedAt, late }
|
|
191
|
+
openedAt: now(),
|
|
192
|
+
closedAt: null,
|
|
193
|
+
};
|
|
194
|
+
breaches.set(id, rec);
|
|
195
|
+
_emitAudit("opened", "success", {
|
|
196
|
+
breachId: id,
|
|
197
|
+
states: rec.affectedStates,
|
|
198
|
+
detectedAt: spec.detectedAt,
|
|
199
|
+
});
|
|
200
|
+
return rec;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function fileNotice(breachId, state, fields) {
|
|
204
|
+
var rec = breaches.get(breachId);
|
|
205
|
+
if (!rec) {
|
|
206
|
+
throw new BreachError("breach-report/unknown-breach",
|
|
207
|
+
"breach.report.fileNotice: no breach with id '" + breachId + "'");
|
|
208
|
+
}
|
|
209
|
+
var stateUp = state.toUpperCase();
|
|
210
|
+
if (rec.affectedStates.indexOf(stateUp) === -1) {
|
|
211
|
+
throw new BreachError("breach-report/state-not-tracked",
|
|
212
|
+
"breach.report.fileNotice: state '" + stateUp + "' is not in this breach's affectedStates");
|
|
213
|
+
}
|
|
214
|
+
if (rec.filings[stateUp]) {
|
|
215
|
+
throw new BreachError("breach-report/already-filed",
|
|
216
|
+
"breach.report.fileNotice: filing for state '" + stateUp + "' is already recorded");
|
|
217
|
+
}
|
|
218
|
+
var deadline = rec.deadlines.filter(function (d) { return d.state === stateUp; })[0];
|
|
219
|
+
var filedAt = now();
|
|
220
|
+
var dueBy = deadline.kind === "hard-deadline" ? deadline.dueBy : deadline.ceilingDueBy;
|
|
221
|
+
var late = filedAt > dueBy;
|
|
222
|
+
rec.filings[stateUp] = {
|
|
223
|
+
filedAt: filedAt,
|
|
224
|
+
dueBy: dueBy,
|
|
225
|
+
late: late,
|
|
226
|
+
lateBy: late ? (filedAt - dueBy) : 0,
|
|
227
|
+
payload: fields || {},
|
|
228
|
+
};
|
|
229
|
+
if (Object.keys(rec.filings).length === rec.affectedStates.length) {
|
|
230
|
+
rec.closedAt = filedAt;
|
|
231
|
+
}
|
|
232
|
+
_emitAudit("notice_filed", late ? "late" : "success", {
|
|
233
|
+
breachId: breachId, state: stateUp, dueBy: dueBy, late: late,
|
|
234
|
+
lateBy: rec.filings[stateUp].lateBy,
|
|
235
|
+
});
|
|
236
|
+
return rec;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function get(id) { return breaches.get(id) || null; }
|
|
240
|
+
function list() { var out = []; breaches.forEach(function (rec) { out.push(rec); }); return out; }
|
|
241
|
+
function pending(breachId) {
|
|
242
|
+
var rec = breaches.get(breachId);
|
|
243
|
+
if (!rec) return [];
|
|
244
|
+
var pendingStates = [];
|
|
245
|
+
for (var i = 0; i < rec.affectedStates.length; i++) {
|
|
246
|
+
if (!rec.filings[rec.affectedStates[i]]) {
|
|
247
|
+
pendingStates.push(rec.deadlines.filter(function (d) { return d.state === rec.affectedStates[i]; })[0]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return pendingStates;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
open: open,
|
|
255
|
+
fileNotice: fileNotice,
|
|
256
|
+
get: get,
|
|
257
|
+
list: list,
|
|
258
|
+
pending: pending,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
// b.breach.deadline.* — registry lookups
|
|
264
|
+
deadline: {
|
|
265
|
+
forStates: forStates,
|
|
266
|
+
STATE_DEADLINES: STATE_DEADLINES,
|
|
267
|
+
WITHOUT_UNREASONABLE_DELAY: WITHOUT_UNREASONABLE_DELAY,
|
|
268
|
+
},
|
|
269
|
+
// b.breach.report.create(...) — per-incident reporter
|
|
270
|
+
report: { create: createReporter },
|
|
271
|
+
BreachError: BreachError,
|
|
272
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ageGate middleware — request-level age classification + high-privacy
|
|
4
|
+
* default headers for routes that need stricter handling for users
|
|
5
|
+
* below an operator-configured age threshold.
|
|
6
|
+
*
|
|
7
|
+
* COPPA (US, 13 and under), UK Children's Code (16 and under),
|
|
8
|
+
* California AADC (18 and under), and similar regimes require
|
|
9
|
+
* operators to apply heightened privacy protections when serving
|
|
10
|
+
* users below a regulatory age. The middleware:
|
|
11
|
+
*
|
|
12
|
+
* 1. Reads the operator's `getAge(req)` predicate to classify the
|
|
13
|
+
* request as "above-threshold" / "below-threshold" / "unknown"
|
|
14
|
+
* 2. Sets high-privacy defaults on below-threshold + unknown
|
|
15
|
+
* responses:
|
|
16
|
+
* - `Cache-Control: private, no-store`
|
|
17
|
+
* - `Referrer-Policy: no-referrer`
|
|
18
|
+
* - `X-Privacy-Posture: below-threshold`
|
|
19
|
+
* 3. Refuses with 451 (Unavailable For Legal Reasons) when the
|
|
20
|
+
* operator-supplied requireAge: 18 is set and the request is
|
|
21
|
+
* below threshold without a parental-consent record
|
|
22
|
+
* 4. Audits the classification decision
|
|
23
|
+
*
|
|
24
|
+
* var gate = b.middleware.ageGate({
|
|
25
|
+
* getAge: function (req) {
|
|
26
|
+
* if (req.user && typeof req.user.age === "number") return req.user.age;
|
|
27
|
+
* return null; // unknown
|
|
28
|
+
* },
|
|
29
|
+
* requireAge: null, // null = don't gate, just headers
|
|
30
|
+
* consentRequired: 18, // require parental consent below this
|
|
31
|
+
* hasParentalConsent: function (req) {
|
|
32
|
+
* return req.user && req.user.parentalConsent === true;
|
|
33
|
+
* },
|
|
34
|
+
* });
|
|
35
|
+
* router.use(gate);
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var defineClass = require("../framework-error").defineClass;
|
|
39
|
+
var lazyRequire = require("../lazy-require");
|
|
40
|
+
var validateOpts = require("../validate-opts");
|
|
41
|
+
|
|
42
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
43
|
+
|
|
44
|
+
var AgeGateError = defineClass("AgeGateError", { alwaysPermanent: true });
|
|
45
|
+
|
|
46
|
+
function create(opts) {
|
|
47
|
+
opts = opts || {};
|
|
48
|
+
validateOpts(opts, [
|
|
49
|
+
"audit", "getAge", "requireAge", "consentRequired",
|
|
50
|
+
"hasParentalConsent", "skipPaths", "errorMessage",
|
|
51
|
+
], "middleware.ageGate");
|
|
52
|
+
|
|
53
|
+
if (typeof opts.getAge !== "function") {
|
|
54
|
+
throw new AgeGateError("age-gate/bad-get-age",
|
|
55
|
+
"middleware.ageGate: opts.getAge must be a function (req) -> number | null");
|
|
56
|
+
}
|
|
57
|
+
var getAge = opts.getAge;
|
|
58
|
+
var requireAge = (typeof opts.requireAge === "number" && opts.requireAge > 0) // allow:numeric-opt-Infinity — age is operator domain, not a bytes/time-shaped opt
|
|
59
|
+
? opts.requireAge : null;
|
|
60
|
+
var consentRequired = (typeof opts.consentRequired === "number" && opts.consentRequired > 0) // allow:numeric-opt-Infinity — age threshold, not a bytes/time-shaped opt
|
|
61
|
+
? opts.consentRequired : null;
|
|
62
|
+
var hasParentalConsent = typeof opts.hasParentalConsent === "function" ? opts.hasParentalConsent : null;
|
|
63
|
+
var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
|
|
64
|
+
var auditOn = opts.audit !== false;
|
|
65
|
+
var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
|
|
66
|
+
? opts.errorMessage : "service unavailable without parental consent";
|
|
67
|
+
|
|
68
|
+
function _shouldSkip(req) {
|
|
69
|
+
if (skipPaths.length === 0) return false;
|
|
70
|
+
var p = req.url || "";
|
|
71
|
+
var qpos = p.indexOf("?");
|
|
72
|
+
if (qpos !== -1) p = p.slice(0, qpos);
|
|
73
|
+
for (var i = 0; i < skipPaths.length; i++) {
|
|
74
|
+
var s = skipPaths[i];
|
|
75
|
+
if (typeof s === "string" && (p === s || p.indexOf(s + "/") === 0)) return true;
|
|
76
|
+
if (s instanceof RegExp && s.test(p)) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _emitAudit(action, outcome, metadata) {
|
|
82
|
+
if (!auditOn) return;
|
|
83
|
+
try {
|
|
84
|
+
audit().safeEmit({
|
|
85
|
+
action: "middleware.age_gate." + action,
|
|
86
|
+
outcome: outcome,
|
|
87
|
+
metadata: metadata || {},
|
|
88
|
+
});
|
|
89
|
+
} catch (_e) { /* drop-silent */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return function ageGateMiddleware(req, res, next) {
|
|
93
|
+
if (_shouldSkip(req)) return next();
|
|
94
|
+
|
|
95
|
+
var age;
|
|
96
|
+
try { age = getAge(req); }
|
|
97
|
+
catch (e) {
|
|
98
|
+
_emitAudit("get_age_failed", "failure", { error: (e && e.message) || String(e) });
|
|
99
|
+
age = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var classification;
|
|
103
|
+
if (age === null || typeof age !== "number") classification = "unknown";
|
|
104
|
+
else if (consentRequired !== null && age < consentRequired) classification = "below-threshold";
|
|
105
|
+
else classification = "above-threshold";
|
|
106
|
+
|
|
107
|
+
if (classification !== "above-threshold") {
|
|
108
|
+
if (typeof res.setHeader === "function") {
|
|
109
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
110
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
111
|
+
res.setHeader("X-Privacy-Posture", classification);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (requireAge !== null && classification === "below-threshold" && (age === null || age < requireAge)) {
|
|
116
|
+
var hasConsent = hasParentalConsent ? !!hasParentalConsent(req) : false;
|
|
117
|
+
if (!hasConsent) {
|
|
118
|
+
_emitAudit("refused", "denied", { age: age, classification: classification, requireAge: requireAge });
|
|
119
|
+
if (!res.writableEnded && typeof res.writeHead === "function") {
|
|
120
|
+
res.writeHead(451, { // allow:raw-byte-literal — HTTP 451 Unavailable For Legal Reasons
|
|
121
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
122
|
+
"Cache-Control": "no-store, private",
|
|
123
|
+
});
|
|
124
|
+
res.end(JSON.stringify({ error: errorMessage, requireAge: requireAge, parentalConsent: false }));
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (req.locals && typeof req.locals === "object") {
|
|
131
|
+
req.locals.ageGateClassification = classification;
|
|
132
|
+
}
|
|
133
|
+
_emitAudit("classified", "success", { classification: classification, age: age });
|
|
134
|
+
return next();
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
create: create,
|
|
140
|
+
AgeGateError: AgeGateError,
|
|
141
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -48,6 +48,7 @@ var requestLog = require("./request-log");
|
|
|
48
48
|
var requireAal = require("./require-aal");
|
|
49
49
|
var requireAuth = require("./require-auth");
|
|
50
50
|
var requireContentType = require("./require-content-type");
|
|
51
|
+
var ageGate = require("./age-gate");
|
|
51
52
|
var requireMethods = require("./require-methods");
|
|
52
53
|
var requireMtls = require("./require-mtls");
|
|
53
54
|
var requireStepUp = require("./require-step-up");
|
|
@@ -74,6 +75,7 @@ module.exports = {
|
|
|
74
75
|
requireAal: requireAal.create,
|
|
75
76
|
requireAuth: requireAuth.create,
|
|
76
77
|
requireContentType: requireContentType.create,
|
|
78
|
+
ageGate: ageGate.create,
|
|
77
79
|
requireMethods: requireMethods.create,
|
|
78
80
|
requireMtls: requireMtls.create,
|
|
79
81
|
requireStepUp: requireStepUp.create,
|
|
@@ -120,6 +122,7 @@ module.exports = {
|
|
|
120
122
|
requireAal: requireAal,
|
|
121
123
|
requireAuth: requireAuth,
|
|
122
124
|
requireContentType: requireContentType,
|
|
125
|
+
ageGate: ageGate,
|
|
123
126
|
requireMethods: requireMethods,
|
|
124
127
|
requireMtls: requireMtls,
|
|
125
128
|
requireStepUp: requireStepUp,
|
package/lib/websocket.js
CHANGED
|
@@ -108,6 +108,36 @@ var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
108
108
|
// catches the typo class.
|
|
109
109
|
var GUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
110
110
|
|
|
111
|
+
// Credential-shaped query parameter names refused at upgrade time. URL
|
|
112
|
+
// query strings end up in: web-server access logs, the browser's
|
|
113
|
+
// history + Referer header forwarded to third-party CDN / analytics
|
|
114
|
+
// requests, in-process / proxy log captures, and crash dumps. Any
|
|
115
|
+
// authentication credential placed in the query string is leaked
|
|
116
|
+
// through one of those channels by default. RFC 6750 §2.3 explicitly
|
|
117
|
+
// cautions against bearer tokens in URI query parameters for exactly
|
|
118
|
+
// these reasons.
|
|
119
|
+
//
|
|
120
|
+
// Operators with a non-credential query parameter that happens to
|
|
121
|
+
// match one of these names (e.g. an "apikey" field passed to a
|
|
122
|
+
// downstream tenant API by mistake) opt out per route via
|
|
123
|
+
// `opts.allowQueryAuthParams: true` with an audited operator reason —
|
|
124
|
+
// the lift exists, but the operator owns the audit trail.
|
|
125
|
+
//
|
|
126
|
+
// The list is deliberately narrow — overloaded names like `token`,
|
|
127
|
+
// `auth`, `key`, `session` have non-credential meanings (CSRF tokens,
|
|
128
|
+
// file-share tokens, ICE candidates, session-resume identifiers) and
|
|
129
|
+
// would create false-positive friction without closing a genuine
|
|
130
|
+
// leak vector. The names below are unambiguously credential-shaped.
|
|
131
|
+
var REFUSED_AUTH_QUERY_PARAMS = Object.freeze([
|
|
132
|
+
"access_token", // OAuth 2.0 bearer (RFC 6750)
|
|
133
|
+
"bearer", // synonym
|
|
134
|
+
"bearer_token", // synonym
|
|
135
|
+
"apikey", // common convention
|
|
136
|
+
"api_key", // common convention
|
|
137
|
+
"api-key", // common convention
|
|
138
|
+
"authorization", // literal Authorization-header value
|
|
139
|
+
]);
|
|
140
|
+
|
|
111
141
|
var OPCODE_CONTINUATION = 0x0;
|
|
112
142
|
var OPCODE_TEXT = 0x1;
|
|
113
143
|
var OPCODE_BINARY = 0x2;
|
|
@@ -186,7 +216,7 @@ function computeAcceptKey(secWebSocketKey, handshakeGuid) {
|
|
|
186
216
|
return hash.digest("base64");
|
|
187
217
|
}
|
|
188
218
|
|
|
189
|
-
function validateUpgradeRequest(req) {
|
|
219
|
+
function validateUpgradeRequest(req, opts) {
|
|
190
220
|
if (req.method !== "GET") {
|
|
191
221
|
return { ok: false, status: HTTP.METHOD_NOT_ALLOWED, reason: "method must be GET" };
|
|
192
222
|
}
|
|
@@ -205,9 +235,54 @@ function validateUpgradeRequest(req) {
|
|
|
205
235
|
if (h["sec-websocket-version"] !== "13") {
|
|
206
236
|
return { ok: false, status: HTTP.BAD_REQUEST, reason: "Sec-WebSocket-Version must be 13" };
|
|
207
237
|
}
|
|
238
|
+
if (!(opts && opts.allowQueryAuthParams === true)) {
|
|
239
|
+
var leaked = _findCredentialQueryParam(req.url);
|
|
240
|
+
if (leaked) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: HTTP.BAD_REQUEST,
|
|
244
|
+
reason: "credential-shaped query parameter '" + leaked +
|
|
245
|
+
"' refused — query strings leak via logs / Referer / history. " +
|
|
246
|
+
"Move the credential to the Authorization header, or set " +
|
|
247
|
+
"opts.allowQueryAuthParams: true with an audited operator reason " +
|
|
248
|
+
"if this parameter is not actually a credential.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
208
252
|
return { ok: true };
|
|
209
253
|
}
|
|
210
254
|
|
|
255
|
+
// _findCredentialQueryParam walks the request's query string and
|
|
256
|
+
// returns the first credential-shaped parameter name it finds, or
|
|
257
|
+
// null. Comparison is case-insensitive; an attacker who URL-encodes
|
|
258
|
+
// the parameter name (e.g. "%41ccess_token") still hits the check
|
|
259
|
+
// because URL parsing decodes the name before comparison.
|
|
260
|
+
function _findCredentialQueryParam(reqUrl) {
|
|
261
|
+
if (typeof reqUrl !== "string" || reqUrl.length === 0) return null;
|
|
262
|
+
var qIdx = reqUrl.indexOf("?");
|
|
263
|
+
if (qIdx === -1) return null;
|
|
264
|
+
var query = reqUrl.slice(qIdx + 1);
|
|
265
|
+
// Strip a fragment if any (defensive — real HTTP requests don't carry
|
|
266
|
+
// one, but req.url has been observed with appended fragments behind
|
|
267
|
+
// misconfigured proxies).
|
|
268
|
+
var fIdx = query.indexOf("#");
|
|
269
|
+
if (fIdx !== -1) query = query.slice(0, fIdx);
|
|
270
|
+
if (query.length === 0) return null;
|
|
271
|
+
var pairs = query.split("&");
|
|
272
|
+
for (var p = 0; p < pairs.length; p++) {
|
|
273
|
+
var eqIdx = pairs[p].indexOf("=");
|
|
274
|
+
var rawName = eqIdx === -1 ? pairs[p] : pairs[p].slice(0, eqIdx);
|
|
275
|
+
if (rawName.length === 0) continue;
|
|
276
|
+
var name;
|
|
277
|
+
try { name = decodeURIComponent(rawName).toLowerCase(); }
|
|
278
|
+
catch (_e) { name = rawName.toLowerCase(); }
|
|
279
|
+
for (var r = 0; r < REFUSED_AUTH_QUERY_PARAMS.length; r++) {
|
|
280
|
+
if (name === REFUSED_AUTH_QUERY_PARAMS[r]) return name;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
211
286
|
function negotiateSubprotocol(req, supported) {
|
|
212
287
|
if (!supported || supported.length === 0) return null;
|
|
213
288
|
var raw = (req.headers || {})["sec-websocket-protocol"] || "";
|
|
@@ -885,9 +960,9 @@ function handleUpgrade(req, socket, head, opts) {
|
|
|
885
960
|
// Validate handshake first — refusing here writes a plain HTTP/1.1
|
|
886
961
|
// response and closes the socket, matching what the upgrade-event
|
|
887
962
|
// consumer would expect for a malformed request.
|
|
888
|
-
var v = validateUpgradeRequest(req);
|
|
963
|
+
var v = validateUpgradeRequest(req, opts);
|
|
889
964
|
if (!v.ok) {
|
|
890
|
-
_refuseUpgrade(socket, v.status || 400, v.reason);
|
|
965
|
+
_refuseUpgrade(socket, v.status || 400, v.reason); // allow:raw-byte-literal — HTTP 400 fallback
|
|
891
966
|
return null;
|
|
892
967
|
}
|
|
893
968
|
|
|
@@ -1047,6 +1122,7 @@ module.exports = {
|
|
|
1047
1122
|
handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
|
|
1048
1123
|
// Constants
|
|
1049
1124
|
GUID: GUID,
|
|
1125
|
+
REFUSED_AUTH_QUERY_PARAMS: REFUSED_AUTH_QUERY_PARAMS,
|
|
1050
1126
|
OPCODE_CONTINUATION: OPCODE_CONTINUATION,
|
|
1051
1127
|
OPCODE_TEXT: OPCODE_TEXT,
|
|
1052
1128
|
OPCODE_BINARY: OPCODE_BINARY,
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:02642e79-38a6-4075-930b-85e7d621de64",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-07T04:37:53.397Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.12",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.12",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.12",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.12",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|