@blamejs/core 0.8.9 → 0.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/index.js +6 -1
- package/lib/compliance-eaa.js +204 -0
- package/lib/cra-report.js +195 -0
- package/lib/gdpr-ropa.js +261 -0
- package/lib/middleware/bot-disclose.js +149 -0
- package/lib/middleware/index.js +3 -0
- package/lib/nis2-report.js +181 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **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
|
+
|
|
11
13
|
- **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.
|
|
12
14
|
|
|
13
15
|
- **0.8.8** (2026-05-07) — `b.middleware.requireBoundKey` + `b.audit.rotateSigningKey` / `reSignAll` + `b.circuitBreaker` top-level surface + `b.htmlBalance.checkSafe` + permissions predicate-shape audit + FIPS 140-3 boundary docs + ESLint pin. **`b.middleware.requireBoundKey`** — Bearer-API-key middleware with three-axis binding: required scopes, bound-field equality (operator pulls values from headers / query / body via `getBoundField` getters; bound-fields registered on the key are checked with constant-time match), and peer-cert fingerprint allowlist (composes with v0.8.4's `b.crypto.hashCertFingerprint` / `isCertRevoked`). Operator-supplied async `resolver(apiKey)` returns the registered record `{ id, scopes, boundFields, peerCertFingerprints }` or `null` when revoked. Refusals carry structured reasons (`no-bearer-token`, `key-unknown-or-revoked`, `missing-scope`, `bound-field-missing`, `bound-field-mismatch`, `peer-cert-required`, `peer-cert-not-pinned`); audit chain captures the keyId + reason on every refusal. **`b.audit.rotateSigningKey`** — operator-callable rotation of the audit-signing keypair. Generates (or accepts BYO) a new keypair, copies the existing sealed file to a timestamped history path so historical signatures remain verifiable, re-seals with the operator's passphrase, and atomic-swaps the in-memory keys. Companion `reSignAll(iter)` walks an operator-supplied async iterable of `{ payload, signature, oldPublicKeyPem }` and re-signs each entry with the new key — the audit module's checkpoint store calls this to re-stamp historical checkpoints after a rotation. **`b.circuitBreaker`** — top-level re-export of `b.retry.CircuitBreaker` so operators discover it alongside `b.retry`; same state machine, same `wrap()` API, ergonomic `create(opts)` factory. **`b.htmlBalance.checkSafe(html, opts)`** — combines structural `balance()` check with a `b.guardHtml.gate` security pass under the same `{ profile, posture }` opt shape used by `b.fileUpload({ contentSafety })` / `b.staticServe({ contentSafety })`. **`b.permissions.policy(scope, predicate)`** — emits `permissions.policy_predicate_shape_warning` audit on register-time when the predicate's `.length < 2` (operators commonly forget the `context` argument and ship a predicate that's always-true on the actor parameter). **`SECURITY.md`** — new "FIPS 140-3 cryptographic boundary" section explaining the dual boundary (Node.js OpenSSL FIPS provider for classical primitives, vendored noble-* implementations for PQ algorithms — the latter implement FIPS-published algorithms but the *implementations themselves* are not CMVP-validated). Operator path for FIPS-mandated environments documented. **CI** — eslint pinned to `10.3.0` across `ci.yml` / `npm-publish.yml` / `release-container.yml`; `eslint@latest` was silently letting new rule additions break the publish gate on releases that had passed the day before. The pin moves on operator-confirmed bumps.
|
package/index.js
CHANGED
|
@@ -98,7 +98,9 @@ var safeUrl = require("./lib/safe-url");
|
|
|
98
98
|
var safeRedirect = require("./lib/safe-redirect");
|
|
99
99
|
var pick = require("./lib/pick");
|
|
100
100
|
var dora = require("./lib/dora");
|
|
101
|
-
var compliance = require("./lib/compliance")
|
|
101
|
+
var compliance = Object.assign({}, require("./lib/compliance"), {
|
|
102
|
+
eaa: require("./lib/compliance-eaa"),
|
|
103
|
+
});
|
|
102
104
|
var gateContract = require("./lib/gate-contract");
|
|
103
105
|
var guardCsv = require("./lib/guard-csv");
|
|
104
106
|
var guardHtml = require("./lib/guard-html");
|
|
@@ -249,6 +251,9 @@ module.exports = {
|
|
|
249
251
|
retry: retry,
|
|
250
252
|
circuitBreaker: require("./lib/circuit-breaker"),
|
|
251
253
|
incident: { report: require("./lib/incident-report") },
|
|
254
|
+
cra: { report: require("./lib/cra-report") },
|
|
255
|
+
nis2: { report: require("./lib/nis2-report") },
|
|
256
|
+
gdpr: { ropa: require("./lib/gdpr-ropa") },
|
|
252
257
|
queue: queue,
|
|
253
258
|
logStream: logStream,
|
|
254
259
|
redact: redact,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.compliance.eaa — EU Accessibility Act declared-conformance.
|
|
4
|
+
*
|
|
5
|
+
* Directive (EU) 2019/882 (the European Accessibility Act) requires
|
|
6
|
+
* digital products + services placed on the EU market to meet WCAG
|
|
7
|
+
* 2.1 AA accessibility requirements (extended to 2.2 by national
|
|
8
|
+
* implementing law in many member states). Operators producing a
|
|
9
|
+
* compliant deployment ship a "conformance statement" — a document
|
|
10
|
+
* declaring the product, the assessed standards (WCAG 2.1 / 2.2 /
|
|
11
|
+
* EN 301 549), the scope of testing, and any non-conforming
|
|
12
|
+
* features with operator-supplied justification.
|
|
13
|
+
*
|
|
14
|
+
* var eaa = b.compliance.eaa.create({
|
|
15
|
+
* audit: b.audit,
|
|
16
|
+
* productName: "Acme Customer Portal",
|
|
17
|
+
* productScope: "https://portal.acme.example",
|
|
18
|
+
* standards: ["WCAG 2.2 AA", "EN 301 549 v3.2.1"],
|
|
19
|
+
* });
|
|
20
|
+
* eaa.declareCriterion("1.1.1", { conformance: "supports", note: "..." });
|
|
21
|
+
* eaa.declareCriterion("1.4.3", { conformance: "supports", note: "ratio >= 4.5:1" });
|
|
22
|
+
* eaa.declareNonConformance({
|
|
23
|
+
* criterion: "2.5.5",
|
|
24
|
+
* reason: "legacy desktop-only interaction, replacement Q3 2026",
|
|
25
|
+
* mitigation: "alternative keyboard path documented",
|
|
26
|
+
* });
|
|
27
|
+
* var doc = eaa.export({ format: "markdown" });
|
|
28
|
+
*
|
|
29
|
+
* The exported document goes alongside the operator's product
|
|
30
|
+
* documentation and serves as the "Accessibility Statement" required
|
|
31
|
+
* by Article 13 §3.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var defineClass = require("./framework-error").defineClass;
|
|
35
|
+
var lazyRequire = require("./lazy-require");
|
|
36
|
+
var validateOpts = require("./validate-opts");
|
|
37
|
+
|
|
38
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
39
|
+
|
|
40
|
+
var ComplianceEaaError = defineClass("ComplianceEaaError", { alwaysPermanent: true });
|
|
41
|
+
|
|
42
|
+
var VALID_CONFORMANCE = Object.freeze({
|
|
43
|
+
"supports": 1, // criterion fully met
|
|
44
|
+
"partially-supports": 1, // some content meets, gaps documented
|
|
45
|
+
"does-not-support": 1, // criterion not met (declared non-conformance)
|
|
46
|
+
"not-applicable": 1, // criterion does not apply to product
|
|
47
|
+
"not-evaluated": 1, // outside the assessed scope
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function create(opts) {
|
|
51
|
+
opts = opts || {};
|
|
52
|
+
validateOpts(opts, [
|
|
53
|
+
"audit", "productName", "productScope", "standards",
|
|
54
|
+
"contact", "supervisoryAuthority", "now",
|
|
55
|
+
], "compliance.eaa");
|
|
56
|
+
|
|
57
|
+
validateOpts.requireNonEmptyString(opts.productName,
|
|
58
|
+
"compliance.eaa.create: opts.productName is required (Article 13 §3 requires product identification)",
|
|
59
|
+
ComplianceEaaError, "compliance-eaa/bad-product");
|
|
60
|
+
if (!Array.isArray(opts.standards) || opts.standards.length === 0) {
|
|
61
|
+
throw new ComplianceEaaError("compliance-eaa/bad-standards",
|
|
62
|
+
"compliance.eaa.create: opts.standards is required (e.g. ['WCAG 2.2 AA', 'EN 301 549 v3.2.1'])");
|
|
63
|
+
}
|
|
64
|
+
var productName = opts.productName;
|
|
65
|
+
var productScope = opts.productScope || null;
|
|
66
|
+
var standards = opts.standards.slice();
|
|
67
|
+
var contact = opts.contact || null;
|
|
68
|
+
var supervisoryAuthority = opts.supervisoryAuthority || null;
|
|
69
|
+
var auditOn = opts.audit !== false;
|
|
70
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
71
|
+
|
|
72
|
+
var criteria = new Map();
|
|
73
|
+
var nonConformances = [];
|
|
74
|
+
|
|
75
|
+
function _emitAudit(action, outcome, metadata) {
|
|
76
|
+
if (!auditOn) return;
|
|
77
|
+
try {
|
|
78
|
+
audit().safeEmit({
|
|
79
|
+
action: "compliance.eaa." + action,
|
|
80
|
+
outcome: outcome,
|
|
81
|
+
metadata: metadata || {},
|
|
82
|
+
});
|
|
83
|
+
} catch (_e) { /* drop-silent */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function declareCriterion(id, decl) {
|
|
87
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
88
|
+
throw new ComplianceEaaError("compliance-eaa/bad-criterion-id",
|
|
89
|
+
"compliance.eaa.declareCriterion: id must be a non-empty string (e.g. '1.1.1')");
|
|
90
|
+
}
|
|
91
|
+
if (!decl || typeof decl !== "object") {
|
|
92
|
+
throw new ComplianceEaaError("compliance-eaa/bad-decl",
|
|
93
|
+
"compliance.eaa.declareCriterion: decl must be an object with { conformance, note? }");
|
|
94
|
+
}
|
|
95
|
+
if (!VALID_CONFORMANCE[decl.conformance]) {
|
|
96
|
+
throw new ComplianceEaaError("compliance-eaa/bad-conformance",
|
|
97
|
+
"compliance.eaa.declareCriterion: conformance must be one of " + Object.keys(VALID_CONFORMANCE).join(", "));
|
|
98
|
+
}
|
|
99
|
+
criteria.set(id, {
|
|
100
|
+
criterion: id,
|
|
101
|
+
conformance: decl.conformance,
|
|
102
|
+
note: decl.note || "",
|
|
103
|
+
declaredAt: now(),
|
|
104
|
+
});
|
|
105
|
+
_emitAudit("criterion_declared", "success", { criterion: id, conformance: decl.conformance });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function declareNonConformance(decl) {
|
|
109
|
+
if (!decl || typeof decl !== "object" || !decl.criterion || !decl.reason) {
|
|
110
|
+
throw new ComplianceEaaError("compliance-eaa/bad-non-conformance",
|
|
111
|
+
"compliance.eaa.declareNonConformance: decl must include { criterion, reason, mitigation? }");
|
|
112
|
+
}
|
|
113
|
+
nonConformances.push({
|
|
114
|
+
criterion: decl.criterion,
|
|
115
|
+
reason: decl.reason,
|
|
116
|
+
mitigation: decl.mitigation || null,
|
|
117
|
+
declaredAt: now(),
|
|
118
|
+
});
|
|
119
|
+
criteria.set(decl.criterion, {
|
|
120
|
+
criterion: decl.criterion,
|
|
121
|
+
conformance: "does-not-support",
|
|
122
|
+
note: decl.reason + (decl.mitigation ? " (mitigation: " + decl.mitigation + ")" : ""),
|
|
123
|
+
declaredAt: now(),
|
|
124
|
+
});
|
|
125
|
+
_emitAudit("non_conformance_declared", "warning", { criterion: decl.criterion });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _stats() {
|
|
129
|
+
var counts = { supports: 0, "partially-supports": 0, "does-not-support": 0, "not-applicable": 0, "not-evaluated": 0 };
|
|
130
|
+
criteria.forEach(function (c) { counts[c.conformance] += 1; });
|
|
131
|
+
return counts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _exportJson() {
|
|
135
|
+
var c = []; criteria.forEach(function (rec) { c.push(rec); });
|
|
136
|
+
return {
|
|
137
|
+
directive: "(EU) 2019/882",
|
|
138
|
+
article: "13",
|
|
139
|
+
generatedAt: new Date(now()).toISOString(),
|
|
140
|
+
product: { name: productName, scope: productScope },
|
|
141
|
+
standards: standards,
|
|
142
|
+
contact: contact,
|
|
143
|
+
supervisoryAuthority: supervisoryAuthority,
|
|
144
|
+
criteria: c,
|
|
145
|
+
nonConformances: nonConformances,
|
|
146
|
+
stats: _stats(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function _exportMarkdown() {
|
|
150
|
+
var stats = _stats();
|
|
151
|
+
var c = []; criteria.forEach(function (rec) { c.push(rec); });
|
|
152
|
+
var md = "# Accessibility Statement — " + productName + "\n\n";
|
|
153
|
+
md += "**Standards:** " + standards.join(", ") + "\n\n";
|
|
154
|
+
if (productScope) md += "**Scope:** " + productScope + "\n\n";
|
|
155
|
+
md += "Generated: " + new Date(now()).toISOString() + "\n\n";
|
|
156
|
+
md += "## Conformance summary\n\n";
|
|
157
|
+
md += "- Supports: " + stats.supports + "\n";
|
|
158
|
+
md += "- Partially supports: " + stats["partially-supports"] + "\n";
|
|
159
|
+
md += "- Does not support: " + stats["does-not-support"] + "\n";
|
|
160
|
+
md += "- Not applicable: " + stats["not-applicable"] + "\n\n";
|
|
161
|
+
if (nonConformances.length > 0) {
|
|
162
|
+
md += "## Non-conformances\n\n";
|
|
163
|
+
for (var i = 0; i < nonConformances.length; i++) {
|
|
164
|
+
var nc = nonConformances[i];
|
|
165
|
+
md += "### " + nc.criterion + "\n\n";
|
|
166
|
+
md += "- Reason: " + nc.reason + "\n";
|
|
167
|
+
if (nc.mitigation) md += "- Mitigation: " + nc.mitigation + "\n";
|
|
168
|
+
md += "\n";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
md += "## Per-criterion declarations\n\n";
|
|
172
|
+
for (var ci = 0; ci < c.length; ci++) {
|
|
173
|
+
md += "- **" + c[ci].criterion + "** — " + c[ci].conformance;
|
|
174
|
+
if (c[ci].note) md += " — " + c[ci].note;
|
|
175
|
+
md += "\n";
|
|
176
|
+
}
|
|
177
|
+
if (contact) md += "\n## Contact\n\n" + (contact.name || "") + " (" + (contact.email || "") + ")\n";
|
|
178
|
+
return md;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function exportEaa(eopts) {
|
|
182
|
+
eopts = eopts || {};
|
|
183
|
+
var format = (eopts.format || "json").toLowerCase();
|
|
184
|
+
_emitAudit("exported", "success", { format: format, criteriaCount: criteria.size });
|
|
185
|
+
if (format === "json") return _exportJson();
|
|
186
|
+
if (format === "markdown") return _exportMarkdown();
|
|
187
|
+
throw new ComplianceEaaError("compliance-eaa/bad-format",
|
|
188
|
+
"compliance.eaa.export: format must be 'json' or 'markdown'");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
declareCriterion: declareCriterion,
|
|
193
|
+
declareNonConformance: declareNonConformance,
|
|
194
|
+
"export": exportEaa,
|
|
195
|
+
stats: _stats,
|
|
196
|
+
VALID_CONFORMANCE: Object.keys(VALID_CONFORMANCE),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
create: create,
|
|
202
|
+
ComplianceEaaError: ComplianceEaaError,
|
|
203
|
+
VALID_CONFORMANCE: Object.keys(VALID_CONFORMANCE),
|
|
204
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.cra.report — EU Cyber Resilience Act incident-reporting wrapper.
|
|
4
|
+
*
|
|
5
|
+
* The Cyber Resilience Act (Regulation (EU) 2024/2847) Article 14 §1
|
|
6
|
+
* mandates that manufacturers of digital products report actively-
|
|
7
|
+
* exploited vulnerabilities and severe incidents to ENISA + national
|
|
8
|
+
* authorities. The framework's b.incident.report primitive provides
|
|
9
|
+
* the generic 3-stage shape; this wrapper specializes it with the
|
|
10
|
+
* CRA-specific reporting fields, deadlines (24h early warning / 72h
|
|
11
|
+
* incident notification / 14d final report), and the ENISA single-
|
|
12
|
+
* reporting-point destination.
|
|
13
|
+
*
|
|
14
|
+
* var cra = b.cra.report.create({
|
|
15
|
+
* enisaEndpoint: "https://enisa-spr.europa.eu/api/incidents",
|
|
16
|
+
* httpClient: b.httpClient,
|
|
17
|
+
* audit: b.audit,
|
|
18
|
+
* productId: "blamejs-1.x",
|
|
19
|
+
* manufacturer: { name: "Acme Co", contact: "security@acme.example" },
|
|
20
|
+
* });
|
|
21
|
+
* var inc = await cra.open({
|
|
22
|
+
* detectedAt: Date.now(),
|
|
23
|
+
* vulnerability: { cveId: "CVE-2026-99999", actively_exploited: true },
|
|
24
|
+
* impact: { ... },
|
|
25
|
+
* });
|
|
26
|
+
* await cra.earlyWarning(inc.id, { ... }); // 24h
|
|
27
|
+
* await cra.notification(inc.id, { ... }); // 72h
|
|
28
|
+
* await cra.finalReport(inc.id, { ... }); // 14d
|
|
29
|
+
*
|
|
30
|
+
* The wrapper composes b.incident.report so the audit chain shape,
|
|
31
|
+
* persistence hook, and status surface stay consistent across every
|
|
32
|
+
* regulatory regime. Per-regime CRA semantics:
|
|
33
|
+
* - early warning may be terse ("incident detected, scope unknown")
|
|
34
|
+
* - notification carries impact + mitigation + scope
|
|
35
|
+
* - final report adds root cause + lessons learned
|
|
36
|
+
*
|
|
37
|
+
* Submission to ENISA is opt-in per call (operators may want to
|
|
38
|
+
* batch / approve before submission); pass { submit: true } on each
|
|
39
|
+
* stage call to push through the operator's b.httpClient. The
|
|
40
|
+
* primitive does NOT auto-submit on stage transitions — regulators
|
|
41
|
+
* uniformly require operator review before filing.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
var C = require("./constants");
|
|
45
|
+
var defineClass = require("./framework-error").defineClass;
|
|
46
|
+
var lazyRequire = require("./lazy-require");
|
|
47
|
+
var validateOpts = require("./validate-opts");
|
|
48
|
+
|
|
49
|
+
var incidentReport = lazyRequire(function () { return require("./incident-report"); });
|
|
50
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
51
|
+
|
|
52
|
+
var CraReportError = defineClass("CraReportError", { alwaysPermanent: true });
|
|
53
|
+
|
|
54
|
+
function create(opts) {
|
|
55
|
+
opts = opts || {};
|
|
56
|
+
validateOpts(opts, [
|
|
57
|
+
"audit", "persist", "httpClient", "enisaEndpoint",
|
|
58
|
+
"productId", "manufacturer", "now",
|
|
59
|
+
], "cra.report");
|
|
60
|
+
|
|
61
|
+
validateOpts.requireNonEmptyString(opts.productId,
|
|
62
|
+
"cra.report.create: opts.productId is required (CRA Annex VII §1 requires a stable product identifier)",
|
|
63
|
+
CraReportError, "cra-report/bad-product-id");
|
|
64
|
+
if (!opts.manufacturer || typeof opts.manufacturer !== "object") {
|
|
65
|
+
throw new CraReportError("cra-report/bad-manufacturer",
|
|
66
|
+
"cra.report.create: opts.manufacturer is required (CRA Annex VII §1 requires manufacturer name + contact)");
|
|
67
|
+
}
|
|
68
|
+
var productId = opts.productId;
|
|
69
|
+
var manufacturer = opts.manufacturer;
|
|
70
|
+
var enisaEndpoint = typeof opts.enisaEndpoint === "string" && opts.enisaEndpoint.length > 0
|
|
71
|
+
? opts.enisaEndpoint : null;
|
|
72
|
+
var httpClient = opts.httpClient || null;
|
|
73
|
+
var auditOn = opts.audit !== false;
|
|
74
|
+
|
|
75
|
+
// CRA Article 14 deadlines — operators don't override these without
|
|
76
|
+
// documented regulatory justification (the deadlines are statutory,
|
|
77
|
+
// not operator preference).
|
|
78
|
+
var ir = incidentReport().create({
|
|
79
|
+
audit: opts.audit,
|
|
80
|
+
persist: opts.persist,
|
|
81
|
+
now: opts.now,
|
|
82
|
+
deadlines: {
|
|
83
|
+
// initial = "early warning" per CRA Article 14 §1(a) — 24h
|
|
84
|
+
initial: C.TIME.hours(24),
|
|
85
|
+
// intermediate = "incident notification" per CRA Article 14 §1(b) — 72h
|
|
86
|
+
intermediate: C.TIME.hours(72),
|
|
87
|
+
// final = "final report" per CRA Article 14 §1(c) — 14 days
|
|
88
|
+
final: C.TIME.days(14),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function _emitAudit(action, outcome, metadata) {
|
|
93
|
+
if (!auditOn) return;
|
|
94
|
+
try {
|
|
95
|
+
audit().safeEmit({
|
|
96
|
+
action: "cra.report." + action,
|
|
97
|
+
outcome: outcome,
|
|
98
|
+
metadata: metadata || {},
|
|
99
|
+
});
|
|
100
|
+
} catch (_e) { /* drop-silent */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function _submitToEnisa(payload) {
|
|
104
|
+
if (!enisaEndpoint || !httpClient) {
|
|
105
|
+
_emitAudit("submit_skipped", "warning", { reason: "no-endpoint-or-client" });
|
|
106
|
+
return { submitted: false, reason: "no-endpoint-or-client" };
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
var res = await httpClient.request({
|
|
110
|
+
url: enisaEndpoint,
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: Buffer.from(JSON.stringify(payload), "utf8"),
|
|
114
|
+
responseMode: "always-resolve",
|
|
115
|
+
});
|
|
116
|
+
var ok = res.statusCode >= 200 && res.statusCode < 300; // allow:raw-byte-literal — HTTP status range
|
|
117
|
+
_emitAudit("submitted", ok ? "success" : "failure", {
|
|
118
|
+
statusCode: res.statusCode, productId: productId,
|
|
119
|
+
});
|
|
120
|
+
return { submitted: ok, statusCode: res.statusCode };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
_emitAudit("submit_failed", "failure", { error: (e && e.message) || String(e) });
|
|
123
|
+
return { submitted: false, error: (e && e.message) || String(e) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _craEnvelope(stage, incident, fields) {
|
|
128
|
+
return {
|
|
129
|
+
cra_version: "2024/2847",
|
|
130
|
+
stage: stage, // "early-warning" / "notification" / "final"
|
|
131
|
+
product: { id: productId },
|
|
132
|
+
manufacturer: manufacturer,
|
|
133
|
+
incident: {
|
|
134
|
+
id: incident.id,
|
|
135
|
+
detected_at: new Date(incident.detectedAt).toISOString(),
|
|
136
|
+
scope: incident.scope,
|
|
137
|
+
summary: incident.summary,
|
|
138
|
+
impact: incident.impact,
|
|
139
|
+
},
|
|
140
|
+
fields: fields || {},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function open(spec) {
|
|
145
|
+
spec = Object.assign({}, spec || {}, { regime: "cra" });
|
|
146
|
+
var rec = await ir.open(spec);
|
|
147
|
+
_emitAudit("opened", "success", { incidentId: rec.id, productId: productId });
|
|
148
|
+
return rec;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function earlyWarning(incidentId, fields) {
|
|
152
|
+
var rec = await ir.recordInitial(incidentId, fields || {});
|
|
153
|
+
var result = { record: rec, submitted: null };
|
|
154
|
+
if (fields && fields.submit === true) {
|
|
155
|
+
result.submitted = await _submitToEnisa(_craEnvelope("early-warning", rec, fields));
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function notification(incidentId, fields) {
|
|
161
|
+
var rec = await ir.recordIntermediate(incidentId, fields || {});
|
|
162
|
+
var result = { record: rec, submitted: null };
|
|
163
|
+
if (fields && fields.submit === true) {
|
|
164
|
+
result.submitted = await _submitToEnisa(_craEnvelope("notification", rec, fields));
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function finalReport(incidentId, fields) {
|
|
170
|
+
var rec = await ir.recordFinal(incidentId, fields || {});
|
|
171
|
+
var result = { record: rec, submitted: null };
|
|
172
|
+
if (fields && fields.submit === true) {
|
|
173
|
+
result.submitted = await _submitToEnisa(_craEnvelope("final", rec, fields));
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
open: open,
|
|
180
|
+
earlyWarning: earlyWarning,
|
|
181
|
+
notification: notification,
|
|
182
|
+
finalReport: finalReport,
|
|
183
|
+
// Forward incident.report observability surface
|
|
184
|
+
get: function (id) { return ir.get(id); },
|
|
185
|
+
list: function () { return ir.list(); },
|
|
186
|
+
status: function () { return ir.status(); },
|
|
187
|
+
productId: productId,
|
|
188
|
+
manufacturer: manufacturer,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
create: create,
|
|
194
|
+
CraReportError: CraReportError,
|
|
195
|
+
};
|
package/lib/gdpr-ropa.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.gdpr.ropa — GDPR Article 30 Records of Processing Activities.
|
|
4
|
+
*
|
|
5
|
+
* Article 30 §1 (controller) + §2 (processor) require a written
|
|
6
|
+
* record of processing activities. The framework's existing audit
|
|
7
|
+
* chain captures *what happened* on each request; the RoPA captures
|
|
8
|
+
* *what processing the operator does in general* — purposes, data
|
|
9
|
+
* categories, retention periods, recipients, transfers outside the
|
|
10
|
+
* EEA, security measures.
|
|
11
|
+
*
|
|
12
|
+
* The primitive is a registry + exporter:
|
|
13
|
+
* - register(activity) — add a processing-activity record
|
|
14
|
+
* - update(id, patch) — modify an existing record
|
|
15
|
+
* - remove(id) — soft-delete (operator audit trail)
|
|
16
|
+
* - export({ format }) — emit RoPA as JSON / CSV / Markdown
|
|
17
|
+
*
|
|
18
|
+
* var ropa = b.gdpr.ropa.create({
|
|
19
|
+
* audit: b.audit,
|
|
20
|
+
* controller: { name: "Acme Co", contact: "dpo@acme.example" },
|
|
21
|
+
* });
|
|
22
|
+
* ropa.register({
|
|
23
|
+
* id: "sales-funnel-tracking",
|
|
24
|
+
* name: "Sales-funnel CRM tracking",
|
|
25
|
+
* purposes: ["lead-tracking", "sales-attribution"],
|
|
26
|
+
* legalBasis: "legitimate-interests",
|
|
27
|
+
* dataCategories: ["contact-info", "engagement-history"],
|
|
28
|
+
* dataSubjectCategories: ["prospects", "customers"],
|
|
29
|
+
* recipients: ["analytics-vendor", "sales-team"],
|
|
30
|
+
* thirdCountryTransfers: [{ country: "US", safeguard: "scc-2021" }],
|
|
31
|
+
* retentionPeriod: "5 years post-relationship",
|
|
32
|
+
* securityMeasures: ["encrypted-at-rest", "tls-13", "access-control"],
|
|
33
|
+
* });
|
|
34
|
+
* var json = ropa.export({ format: "json" });
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var defineClass = require("./framework-error").defineClass;
|
|
38
|
+
var lazyRequire = require("./lazy-require");
|
|
39
|
+
var validateOpts = require("./validate-opts");
|
|
40
|
+
|
|
41
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
42
|
+
|
|
43
|
+
var GdprRopaError = defineClass("GdprRopaError", { alwaysPermanent: true });
|
|
44
|
+
|
|
45
|
+
var REQUIRED_ACTIVITY_FIELDS = Object.freeze([
|
|
46
|
+
"id", "name", "purposes", "legalBasis", "dataCategories",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
var VALID_LEGAL_BASES = Object.freeze({
|
|
50
|
+
"consent": 1, // Art 6(1)(a)
|
|
51
|
+
"contract": 1, // Art 6(1)(b)
|
|
52
|
+
"legal-obligation": 1, // Art 6(1)(c)
|
|
53
|
+
"vital-interests": 1, // Art 6(1)(d)
|
|
54
|
+
"public-task": 1, // Art 6(1)(e)
|
|
55
|
+
"legitimate-interests": 1, // Art 6(1)(f)
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function create(opts) {
|
|
59
|
+
opts = opts || {};
|
|
60
|
+
validateOpts(opts, [
|
|
61
|
+
"audit", "controller", "dpo", "supervisoryAuthority", "now",
|
|
62
|
+
], "gdpr.ropa");
|
|
63
|
+
|
|
64
|
+
if (!opts.controller || typeof opts.controller !== "object") {
|
|
65
|
+
throw new GdprRopaError("gdpr-ropa/bad-controller",
|
|
66
|
+
"gdpr.ropa.create: opts.controller is required (Article 30 §1(a) requires controller name + contact)");
|
|
67
|
+
}
|
|
68
|
+
var controller = opts.controller;
|
|
69
|
+
var dpo = opts.dpo || null;
|
|
70
|
+
var supervisoryAuthority = opts.supervisoryAuthority || null;
|
|
71
|
+
var auditOn = opts.audit !== false;
|
|
72
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
73
|
+
|
|
74
|
+
var activities = new Map();
|
|
75
|
+
|
|
76
|
+
function _emitAudit(action, outcome, metadata) {
|
|
77
|
+
if (!auditOn) return;
|
|
78
|
+
try {
|
|
79
|
+
audit().safeEmit({
|
|
80
|
+
action: "gdpr.ropa." + action,
|
|
81
|
+
outcome: outcome,
|
|
82
|
+
metadata: metadata || {},
|
|
83
|
+
});
|
|
84
|
+
} catch (_e) { /* drop-silent */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _validateActivity(activity, op) {
|
|
88
|
+
if (!activity || typeof activity !== "object") {
|
|
89
|
+
throw new GdprRopaError("gdpr-ropa/bad-activity",
|
|
90
|
+
"gdpr.ropa." + op + ": activity must be an object");
|
|
91
|
+
}
|
|
92
|
+
for (var i = 0; i < REQUIRED_ACTIVITY_FIELDS.length; i++) {
|
|
93
|
+
var f = REQUIRED_ACTIVITY_FIELDS[i];
|
|
94
|
+
if (activity[f] === undefined) {
|
|
95
|
+
throw new GdprRopaError("gdpr-ropa/missing-field",
|
|
96
|
+
"gdpr.ropa." + op + ": activity is missing required field '" + f + "' (per Article 30 §1)");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (typeof activity.id !== "string" || activity.id.length === 0) {
|
|
100
|
+
throw new GdprRopaError("gdpr-ropa/bad-id",
|
|
101
|
+
"gdpr.ropa." + op + ": activity.id must be a non-empty string");
|
|
102
|
+
}
|
|
103
|
+
if (!VALID_LEGAL_BASES[activity.legalBasis]) {
|
|
104
|
+
throw new GdprRopaError("gdpr-ropa/bad-legal-basis",
|
|
105
|
+
"gdpr.ropa." + op + ": activity.legalBasis must be one of " + Object.keys(VALID_LEGAL_BASES).join(", "));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function register(activity) {
|
|
110
|
+
_validateActivity(activity, "register");
|
|
111
|
+
if (activities.has(activity.id)) {
|
|
112
|
+
throw new GdprRopaError("gdpr-ropa/duplicate-id",
|
|
113
|
+
"gdpr.ropa.register: activity '" + activity.id + "' already registered");
|
|
114
|
+
}
|
|
115
|
+
var rec = Object.assign({}, activity, {
|
|
116
|
+
registeredAt: now(),
|
|
117
|
+
lastUpdatedAt: now(),
|
|
118
|
+
});
|
|
119
|
+
activities.set(activity.id, rec);
|
|
120
|
+
_emitAudit("registered", "success", { id: activity.id, purposes: activity.purposes });
|
|
121
|
+
return rec;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function update(id, patch) {
|
|
125
|
+
var existing = activities.get(id);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
throw new GdprRopaError("gdpr-ropa/not-found",
|
|
128
|
+
"gdpr.ropa.update: no activity with id '" + id + "'");
|
|
129
|
+
}
|
|
130
|
+
if (!patch || typeof patch !== "object") {
|
|
131
|
+
throw new GdprRopaError("gdpr-ropa/bad-patch",
|
|
132
|
+
"gdpr.ropa.update: patch must be an object");
|
|
133
|
+
}
|
|
134
|
+
var merged = Object.assign({}, existing, patch, {
|
|
135
|
+
id: id, // id is immutable on update
|
|
136
|
+
registeredAt: existing.registeredAt,
|
|
137
|
+
lastUpdatedAt: now(),
|
|
138
|
+
});
|
|
139
|
+
if (patch.legalBasis && !VALID_LEGAL_BASES[merged.legalBasis]) {
|
|
140
|
+
throw new GdprRopaError("gdpr-ropa/bad-legal-basis",
|
|
141
|
+
"gdpr.ropa.update: legalBasis must be one of " + Object.keys(VALID_LEGAL_BASES).join(", "));
|
|
142
|
+
}
|
|
143
|
+
activities.set(id, merged);
|
|
144
|
+
_emitAudit("updated", "success", { id: id, fields: Object.keys(patch) });
|
|
145
|
+
return merged;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function remove(id, info) {
|
|
149
|
+
var existing = activities.get(id);
|
|
150
|
+
if (!existing) {
|
|
151
|
+
throw new GdprRopaError("gdpr-ropa/not-found",
|
|
152
|
+
"gdpr.ropa.remove: no activity with id '" + id + "'");
|
|
153
|
+
}
|
|
154
|
+
activities.delete(id);
|
|
155
|
+
_emitAudit("removed", "success", {
|
|
156
|
+
id: id,
|
|
157
|
+
reason: (info && info.reason) || null,
|
|
158
|
+
actor: (info && info.actor) || null,
|
|
159
|
+
});
|
|
160
|
+
return { removed: true, id: id };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function get(id) { return activities.get(id) || null; }
|
|
164
|
+
function list() {
|
|
165
|
+
var out = [];
|
|
166
|
+
activities.forEach(function (rec) { out.push(rec); });
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Operator-facing exporter — JSON for API integrations / SCC, CSV
|
|
171
|
+
// for spreadsheet handoff to legal, Markdown for human-readable
|
|
172
|
+
// operator documentation. Each shape carries the same fields per
|
|
173
|
+
// Article 30 §1(a-h) / §2(a-d).
|
|
174
|
+
function _exportJson() {
|
|
175
|
+
return {
|
|
176
|
+
controller: controller,
|
|
177
|
+
dpo: dpo,
|
|
178
|
+
supervisoryAuthority: supervisoryAuthority,
|
|
179
|
+
generatedAt: new Date(now()).toISOString(),
|
|
180
|
+
article: "30",
|
|
181
|
+
regulation: "(EU) 2016/679 (GDPR)",
|
|
182
|
+
activities: list(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function _exportCsv() {
|
|
186
|
+
var headers = [
|
|
187
|
+
"id", "name", "purposes", "legalBasis", "dataCategories",
|
|
188
|
+
"dataSubjectCategories", "recipients", "thirdCountryTransfers",
|
|
189
|
+
"retentionPeriod", "securityMeasures",
|
|
190
|
+
];
|
|
191
|
+
var rows = [headers.join(",")];
|
|
192
|
+
var entries = list();
|
|
193
|
+
for (var i = 0; i < entries.length; i++) {
|
|
194
|
+
var e = entries[i];
|
|
195
|
+
var row = headers.map(function (h) {
|
|
196
|
+
var v = e[h];
|
|
197
|
+
if (v === undefined || v === null) return "";
|
|
198
|
+
if (Array.isArray(v)) return JSON.stringify(v).replace(/"/g, "\"\"");
|
|
199
|
+
return String(v).replace(/"/g, "\"\"");
|
|
200
|
+
});
|
|
201
|
+
rows.push(row.map(function (c) { return '"' + c + '"'; }).join(","));
|
|
202
|
+
}
|
|
203
|
+
return rows.join("\n");
|
|
204
|
+
}
|
|
205
|
+
function _exportMarkdown() {
|
|
206
|
+
var entries = list();
|
|
207
|
+
var md = "# GDPR Article 30 Records of Processing Activities\n\n";
|
|
208
|
+
md += "Generated: " + new Date(now()).toISOString() + "\n\n";
|
|
209
|
+
md += "Controller: " + (controller.name || "(unspecified)") + "\n";
|
|
210
|
+
md += "Contact: " + (controller.contact || "(unspecified)") + "\n\n";
|
|
211
|
+
if (dpo) md += "DPO: " + (dpo.name || "(unspecified)") + " (" + (dpo.contact || "") + ")\n\n";
|
|
212
|
+
md += "## Activities (" + entries.length + ")\n\n";
|
|
213
|
+
for (var i = 0; i < entries.length; i++) {
|
|
214
|
+
var e = entries[i];
|
|
215
|
+
md += "### " + (e.name || e.id) + " (`" + e.id + "`)\n\n";
|
|
216
|
+
md += "- Purposes: " + (e.purposes || []).join(", ") + "\n";
|
|
217
|
+
md += "- Legal basis: " + e.legalBasis + "\n";
|
|
218
|
+
md += "- Data categories: " + (e.dataCategories || []).join(", ") + "\n";
|
|
219
|
+
if (e.dataSubjectCategories) md += "- Data subjects: " + e.dataSubjectCategories.join(", ") + "\n";
|
|
220
|
+
if (e.recipients) md += "- Recipients: " + e.recipients.join(", ") + "\n";
|
|
221
|
+
if (e.retentionPeriod) md += "- Retention: " + e.retentionPeriod + "\n";
|
|
222
|
+
if (e.securityMeasures) md += "- Security: " + e.securityMeasures.join(", ") + "\n";
|
|
223
|
+
if (e.thirdCountryTransfers && e.thirdCountryTransfers.length > 0) {
|
|
224
|
+
md += "- Third-country transfers:\n";
|
|
225
|
+
for (var ti = 0; ti < e.thirdCountryTransfers.length; ti++) {
|
|
226
|
+
var t = e.thirdCountryTransfers[ti];
|
|
227
|
+
md += " - " + t.country + " (safeguard: " + (t.safeguard || "n/a") + ")\n";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
md += "\n";
|
|
231
|
+
}
|
|
232
|
+
return md;
|
|
233
|
+
}
|
|
234
|
+
function exportRopa(eopts) {
|
|
235
|
+
eopts = eopts || {};
|
|
236
|
+
var format = (eopts.format || "json").toLowerCase();
|
|
237
|
+
_emitAudit("exported", "success", { format: format, count: activities.size });
|
|
238
|
+
if (format === "csv") return _exportCsv();
|
|
239
|
+
if (format === "markdown") return _exportMarkdown();
|
|
240
|
+
if (format === "json") return _exportJson();
|
|
241
|
+
throw new GdprRopaError("gdpr-ropa/bad-format",
|
|
242
|
+
"gdpr.ropa.export: format must be 'json' / 'csv' / 'markdown'");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
register: register,
|
|
247
|
+
update: update,
|
|
248
|
+
remove: remove,
|
|
249
|
+
get: get,
|
|
250
|
+
list: list,
|
|
251
|
+
"export": exportRopa, // explicit string-key — `export` is reserved
|
|
252
|
+
VALID_LEGAL_BASES: Object.keys(VALID_LEGAL_BASES),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
create: create,
|
|
258
|
+
GdprRopaError: GdprRopaError,
|
|
259
|
+
VALID_LEGAL_BASES: Object.keys(VALID_LEGAL_BASES),
|
|
260
|
+
REQUIRED_ACTIVITY_FIELDS: REQUIRED_ACTIVITY_FIELDS,
|
|
261
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* botDisclose middleware — California SB 1001 bot-disclosure.
|
|
4
|
+
*
|
|
5
|
+
* Cal. Bus. & Prof. Code §17941 (SB 1001, effective 2019) requires
|
|
6
|
+
* that any "bot" used to communicate with persons in California for
|
|
7
|
+
* the purpose of incentivizing a sale or influencing an election
|
|
8
|
+
* disclose its non-human nature. Operators serving an automated
|
|
9
|
+
* conversation surface (LLM-backed chat / IVR / SMS) wire this
|
|
10
|
+
* middleware to:
|
|
11
|
+
*
|
|
12
|
+
* 1. Inject a disclosure banner into HTML responses (server-rendered)
|
|
13
|
+
* 2. Set an X-Bot-Disclosure header for API consumers
|
|
14
|
+
* 3. Emit an audit event for every conversation-initiating request
|
|
15
|
+
*
|
|
16
|
+
* var bot = b.middleware.botDisclose({
|
|
17
|
+
* audit: b.audit,
|
|
18
|
+
* mountPaths: ["/chat", "/api/chat"],
|
|
19
|
+
* bannerHtml: '<div role="status">You are interacting with an automated assistant.</div>',
|
|
20
|
+
* bannerJson: { _bot: true, disclosure: "automated-assistant" },
|
|
21
|
+
* });
|
|
22
|
+
* router.use(bot);
|
|
23
|
+
*
|
|
24
|
+
* Per SB 1001 §17941(a), the disclosure must be "clear, conspicuous,
|
|
25
|
+
* and reasonably designed to inform"; operators with custom UI wire
|
|
26
|
+
* `bannerHtml` to match their visual design.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
var defineClass = require("../framework-error").defineClass;
|
|
30
|
+
var lazyRequire = require("../lazy-require");
|
|
31
|
+
var validateOpts = require("../validate-opts");
|
|
32
|
+
|
|
33
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
34
|
+
|
|
35
|
+
var BotDiscloseError = defineClass("BotDiscloseError", { alwaysPermanent: true });
|
|
36
|
+
|
|
37
|
+
var DEFAULT_BANNER_HTML = '<div role="status" data-bot-disclosure="true" ' +
|
|
38
|
+
'style="border:1px solid #888;padding:8px;margin:8px 0;background:#fff8e1;font-size:14px;">' +
|
|
39
|
+
'<strong>Automated assistant.</strong> ' +
|
|
40
|
+
'You are interacting with an automated agent. ' +
|
|
41
|
+
'For California users: this disclosure is provided per Cal. Bus. & Prof. Code §17941.' +
|
|
42
|
+
'</div>';
|
|
43
|
+
|
|
44
|
+
function create(opts) {
|
|
45
|
+
opts = opts || {};
|
|
46
|
+
validateOpts(opts, [
|
|
47
|
+
"audit", "mountPaths", "bannerHtml", "bannerJson",
|
|
48
|
+
"headerName", "auditAction",
|
|
49
|
+
], "middleware.botDisclose");
|
|
50
|
+
|
|
51
|
+
var mountPaths = Array.isArray(opts.mountPaths) ? opts.mountPaths.slice() : null;
|
|
52
|
+
var bannerHtml = typeof opts.bannerHtml === "string" ? opts.bannerHtml : DEFAULT_BANNER_HTML;
|
|
53
|
+
var bannerJson = (opts.bannerJson && typeof opts.bannerJson === "object")
|
|
54
|
+
? opts.bannerJson : { _bot: true, disclosure: "automated-assistant" };
|
|
55
|
+
var headerName = typeof opts.headerName === "string" && opts.headerName.length > 0
|
|
56
|
+
? opts.headerName : "X-Bot-Disclosure";
|
|
57
|
+
var auditOn = opts.audit !== false;
|
|
58
|
+
var actionBase = typeof opts.auditAction === "string" && opts.auditAction.length > 0
|
|
59
|
+
? opts.auditAction : "middleware.bot_disclose";
|
|
60
|
+
|
|
61
|
+
function _matches(req) {
|
|
62
|
+
if (!mountPaths) return true; // null = match every path
|
|
63
|
+
var p = req.url || "";
|
|
64
|
+
var qpos = p.indexOf("?");
|
|
65
|
+
if (qpos !== -1) p = p.slice(0, qpos);
|
|
66
|
+
for (var i = 0; i < mountPaths.length; i++) {
|
|
67
|
+
var m = mountPaths[i];
|
|
68
|
+
if (typeof m === "string" && (p === m || p.indexOf(m + "/") === 0)) return true;
|
|
69
|
+
if (m instanceof RegExp && m.test(p)) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _emitAudit(action, outcome, metadata) {
|
|
75
|
+
if (!auditOn) return;
|
|
76
|
+
try {
|
|
77
|
+
audit().safeEmit({
|
|
78
|
+
action: actionBase + "." + action,
|
|
79
|
+
outcome: outcome,
|
|
80
|
+
metadata: metadata || {},
|
|
81
|
+
});
|
|
82
|
+
} catch (_e) { /* drop-silent */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return function botDiscloseMiddleware(req, res, next) {
|
|
86
|
+
if (!_matches(req)) return next();
|
|
87
|
+
|
|
88
|
+
// Always set the header (cheap; API consumers see the disclosure
|
|
89
|
+
// even on JSON endpoints).
|
|
90
|
+
if (typeof res.setHeader === "function") {
|
|
91
|
+
res.setHeader(headerName, "automated-assistant");
|
|
92
|
+
}
|
|
93
|
+
_emitAudit("disclosed", "success", { method: req.method, path: req.url });
|
|
94
|
+
|
|
95
|
+
// Patch res.write / res.end so the first text/html response gets
|
|
96
|
+
// the banner injected before <body>'s first child. Operators
|
|
97
|
+
// wanting deeper integration override bannerHtml or set
|
|
98
|
+
// mountPaths to scope where injection happens.
|
|
99
|
+
var origWrite = res.write && res.write.bind(res);
|
|
100
|
+
var origEnd = res.end && res.end.bind(res);
|
|
101
|
+
var injected = false;
|
|
102
|
+
|
|
103
|
+
function _maybeInject(chunk, encoding) {
|
|
104
|
+
if (injected) return chunk;
|
|
105
|
+
var ct = typeof res.getHeader === "function" ? res.getHeader("content-type") : "";
|
|
106
|
+
if (typeof ct !== "string" || ct.indexOf("text/html") === -1) return chunk;
|
|
107
|
+
var body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") :
|
|
108
|
+
(typeof chunk === "string" ? chunk : "");
|
|
109
|
+
// Inject after <body> opening tag if present, else after <html>
|
|
110
|
+
// opening tag, else prepend.
|
|
111
|
+
var bodyMatch = body.match(/<body[^>]*>/i);
|
|
112
|
+
if (bodyMatch) {
|
|
113
|
+
var idx = bodyMatch.index + bodyMatch[0].length;
|
|
114
|
+
body = body.slice(0, idx) + "\n" + bannerHtml + "\n" + body.slice(idx);
|
|
115
|
+
} else {
|
|
116
|
+
body = bannerHtml + "\n" + body;
|
|
117
|
+
}
|
|
118
|
+
injected = true;
|
|
119
|
+
return Buffer.from(body, "utf8");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (origWrite) {
|
|
123
|
+
res.write = function (chunk, encoding, cb) {
|
|
124
|
+
return origWrite(_maybeInject(chunk, encoding), encoding, cb);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (origEnd) {
|
|
128
|
+
res.end = function (chunk, encoding, cb) {
|
|
129
|
+
if (chunk) chunk = _maybeInject(chunk, encoding);
|
|
130
|
+
return origEnd(chunk, encoding, cb);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// For JSON responses, attach the disclosure to res.locals so
|
|
135
|
+
// operator handlers building JSON via res.json(...) can include
|
|
136
|
+
// it explicitly. We don't auto-merge into the JSON body — the
|
|
137
|
+
// header + audit are the load-bearing disclosure surfaces.
|
|
138
|
+
if (res.locals && typeof res.locals === "object") {
|
|
139
|
+
res.locals.botDisclosure = bannerJson;
|
|
140
|
+
}
|
|
141
|
+
return next();
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
create: create,
|
|
147
|
+
BotDiscloseError: BotDiscloseError,
|
|
148
|
+
DEFAULT_BANNER_HTML: DEFAULT_BANNER_HTML,
|
|
149
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -25,6 +25,7 @@ var assetlinks = require("./assetlinks");
|
|
|
25
25
|
var attachUser = require("./attach-user");
|
|
26
26
|
var bearerAuth = require("./bearer-auth");
|
|
27
27
|
var bodyParser = require("./body-parser");
|
|
28
|
+
var botDisclose = require("./bot-disclose");
|
|
28
29
|
var botGuard = require("./bot-guard");
|
|
29
30
|
var compression = require("./compression");
|
|
30
31
|
var cookies = require("./cookies");
|
|
@@ -63,6 +64,7 @@ module.exports = {
|
|
|
63
64
|
requestId: requestId.create,
|
|
64
65
|
securityHeaders: securityHeaders.create,
|
|
65
66
|
errorHandler: errorHandler.create,
|
|
67
|
+
botDisclose: botDisclose.create,
|
|
66
68
|
botGuard: botGuard.create,
|
|
67
69
|
cors: cors.create,
|
|
68
70
|
dailyByteQuota: dailyByteQuota.create,
|
|
@@ -108,6 +110,7 @@ module.exports = {
|
|
|
108
110
|
requestId: requestId,
|
|
109
111
|
securityHeaders: securityHeaders,
|
|
110
112
|
errorHandler: errorHandler,
|
|
113
|
+
botDisclose: botDisclose,
|
|
111
114
|
botGuard: botGuard,
|
|
112
115
|
cors: cors,
|
|
113
116
|
dailyByteQuota: dailyByteQuota,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.nis2.report — NIS2 Directive incident-reporting wrapper.
|
|
4
|
+
*
|
|
5
|
+
* Directive (EU) 2022/2555 (NIS2) Article 23 mandates that essential
|
|
6
|
+
* + important entities report significant incidents to the national
|
|
7
|
+
* CSIRT or competent authority. Three-stage pattern with statutory
|
|
8
|
+
* deadlines:
|
|
9
|
+
* - early warning within 24h of becoming aware (Art. 23 §4(a))
|
|
10
|
+
* - incident notification within 72h, including initial assessment
|
|
11
|
+
* of severity + impact + indicators of compromise (Art. 23 §4(b))
|
|
12
|
+
* - final report within 1 month, with detailed root cause +
|
|
13
|
+
* remediation + cross-border implications (Art. 23 §4(d))
|
|
14
|
+
*
|
|
15
|
+
* var nis2 = b.nis2.report.create({
|
|
16
|
+
* audit: b.audit,
|
|
17
|
+
* entityId: "acme-cloud-1",
|
|
18
|
+
* entityType: "essential", // "essential" | "important"
|
|
19
|
+
* sectorAnnex: "I.6", // NIS2 Annex I/II row id
|
|
20
|
+
* csirtEndpoint: "https://csirt.example/api",
|
|
21
|
+
* httpClient: b.httpClient,
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* Sector annex codes follow NIS2's two annexes:
|
|
25
|
+
* - Annex I (essential): I.1 energy / I.2 transport / I.3 banking /
|
|
26
|
+
* I.4 financial-market-infrastructures / I.5 health /
|
|
27
|
+
* I.6 drinking-water / I.7 wastewater / I.8 digital-infrastructure /
|
|
28
|
+
* I.9 ICT-service-management / I.10 public-administration /
|
|
29
|
+
* I.11 space
|
|
30
|
+
* - Annex II (important): II.1 postal / II.2 waste-management /
|
|
31
|
+
* II.3 chemicals / II.4 food / II.5 manufacturing /
|
|
32
|
+
* II.6 digital-providers / II.7 research
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
var C = require("./constants");
|
|
36
|
+
var defineClass = require("./framework-error").defineClass;
|
|
37
|
+
var lazyRequire = require("./lazy-require");
|
|
38
|
+
var validateOpts = require("./validate-opts");
|
|
39
|
+
|
|
40
|
+
var incidentReport = lazyRequire(function () { return require("./incident-report"); });
|
|
41
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
42
|
+
|
|
43
|
+
var Nis2ReportError = defineClass("Nis2ReportError", { alwaysPermanent: true });
|
|
44
|
+
|
|
45
|
+
var VALID_ENTITY_TYPES = Object.freeze({ essential: 1, important: 1 });
|
|
46
|
+
|
|
47
|
+
function create(opts) {
|
|
48
|
+
opts = opts || {};
|
|
49
|
+
validateOpts(opts, [
|
|
50
|
+
"audit", "persist", "httpClient", "csirtEndpoint",
|
|
51
|
+
"entityId", "entityType", "sectorAnnex", "now",
|
|
52
|
+
], "nis2.report");
|
|
53
|
+
|
|
54
|
+
validateOpts.requireNonEmptyString(opts.entityId,
|
|
55
|
+
"nis2.report.create: opts.entityId is required (NIS2 registration ID)",
|
|
56
|
+
Nis2ReportError, "nis2-report/bad-entity-id");
|
|
57
|
+
if (!VALID_ENTITY_TYPES[opts.entityType]) {
|
|
58
|
+
throw new Nis2ReportError("nis2-report/bad-entity-type",
|
|
59
|
+
"nis2.report.create: opts.entityType must be 'essential' or 'important' (NIS2 Article 3 classification)");
|
|
60
|
+
}
|
|
61
|
+
validateOpts.requireNonEmptyString(opts.sectorAnnex,
|
|
62
|
+
"nis2.report.create: opts.sectorAnnex is required (e.g. 'I.6' for drinking water, 'II.6' for digital-providers)",
|
|
63
|
+
Nis2ReportError, "nis2-report/bad-sector");
|
|
64
|
+
var entityId = opts.entityId;
|
|
65
|
+
var entityType = opts.entityType;
|
|
66
|
+
var sectorAnnex = opts.sectorAnnex;
|
|
67
|
+
var csirtEndpoint = opts.csirtEndpoint || null;
|
|
68
|
+
var httpClient = opts.httpClient || null;
|
|
69
|
+
var auditOn = opts.audit !== false;
|
|
70
|
+
|
|
71
|
+
var ir = incidentReport().create({
|
|
72
|
+
audit: opts.audit,
|
|
73
|
+
persist: opts.persist,
|
|
74
|
+
now: opts.now,
|
|
75
|
+
deadlines: {
|
|
76
|
+
initial: C.TIME.hours(24), // NIS2 Art. 23 §4(a) — early warning
|
|
77
|
+
intermediate: C.TIME.hours(72), // NIS2 Art. 23 §4(b) — incident notification
|
|
78
|
+
final: C.TIME.days(30), // NIS2 Art. 23 §4(d) — final report (1 month)
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function _emitAudit(action, outcome, metadata) {
|
|
83
|
+
if (!auditOn) return;
|
|
84
|
+
try {
|
|
85
|
+
audit().safeEmit({
|
|
86
|
+
action: "nis2.report." + action,
|
|
87
|
+
outcome: outcome,
|
|
88
|
+
metadata: metadata || {},
|
|
89
|
+
});
|
|
90
|
+
} catch (_e) { /* drop-silent */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function _submitToCsirt(payload) {
|
|
94
|
+
if (!csirtEndpoint || !httpClient) {
|
|
95
|
+
_emitAudit("submit_skipped", "warning", { reason: "no-endpoint-or-client" });
|
|
96
|
+
return { submitted: false, reason: "no-endpoint-or-client" };
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
var res = await httpClient.request({
|
|
100
|
+
url: csirtEndpoint, method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
body: Buffer.from(JSON.stringify(payload), "utf8"),
|
|
103
|
+
responseMode: "always-resolve",
|
|
104
|
+
});
|
|
105
|
+
var ok = res.statusCode >= 200 && res.statusCode < 300; // allow:raw-byte-literal — HTTP status range
|
|
106
|
+
_emitAudit("submitted", ok ? "success" : "failure", { statusCode: res.statusCode });
|
|
107
|
+
return { submitted: ok, statusCode: res.statusCode };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
_emitAudit("submit_failed", "failure", { error: (e && e.message) || String(e) });
|
|
110
|
+
return { submitted: false, error: (e && e.message) || String(e) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _envelope(stage, incident, fields) {
|
|
115
|
+
return {
|
|
116
|
+
directive: "(EU) 2022/2555",
|
|
117
|
+
article: "23",
|
|
118
|
+
stage: stage, // "early-warning" / "notification" / "final"
|
|
119
|
+
entity: { id: entityId, type: entityType, sector: sectorAnnex },
|
|
120
|
+
incident: {
|
|
121
|
+
id: incident.id,
|
|
122
|
+
detected_at: new Date(incident.detectedAt).toISOString(),
|
|
123
|
+
scope: incident.scope,
|
|
124
|
+
summary: incident.summary,
|
|
125
|
+
impact: incident.impact,
|
|
126
|
+
},
|
|
127
|
+
fields: fields || {},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function open(spec) {
|
|
132
|
+
spec = Object.assign({}, spec || {}, { regime: "nis2" });
|
|
133
|
+
var rec = await ir.open(spec);
|
|
134
|
+
_emitAudit("opened", "success", { incidentId: rec.id, entityId: entityId, entityType: entityType });
|
|
135
|
+
return rec;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function earlyWarning(incidentId, fields) {
|
|
139
|
+
var rec = await ir.recordInitial(incidentId, fields || {});
|
|
140
|
+
var result = { record: rec, submitted: null };
|
|
141
|
+
if (fields && fields.submit === true) {
|
|
142
|
+
result.submitted = await _submitToCsirt(_envelope("early-warning", rec, fields));
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
async function notification(incidentId, fields) {
|
|
147
|
+
var rec = await ir.recordIntermediate(incidentId, fields || {});
|
|
148
|
+
var result = { record: rec, submitted: null };
|
|
149
|
+
if (fields && fields.submit === true) {
|
|
150
|
+
result.submitted = await _submitToCsirt(_envelope("notification", rec, fields));
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
async function finalReport(incidentId, fields) {
|
|
155
|
+
var rec = await ir.recordFinal(incidentId, fields || {});
|
|
156
|
+
var result = { record: rec, submitted: null };
|
|
157
|
+
if (fields && fields.submit === true) {
|
|
158
|
+
result.submitted = await _submitToCsirt(_envelope("final", rec, fields));
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
open: open,
|
|
165
|
+
earlyWarning: earlyWarning,
|
|
166
|
+
notification: notification,
|
|
167
|
+
finalReport: finalReport,
|
|
168
|
+
get: function (id) { return ir.get(id); },
|
|
169
|
+
list: function () { return ir.list(); },
|
|
170
|
+
status: function () { return ir.status(); },
|
|
171
|
+
entityId: entityId,
|
|
172
|
+
entityType: entityType,
|
|
173
|
+
sectorAnnex: sectorAnnex,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
create: create,
|
|
179
|
+
Nis2ReportError: Nis2ReportError,
|
|
180
|
+
VALID_ENTITY_TYPES: Object.keys(VALID_ENTITY_TYPES),
|
|
181
|
+
};
|
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:4c9e2f2f-795d-4920-a093-90770087d324",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-07T02:
|
|
8
|
+
"timestamp": "2026-05-07T02:38:14.544Z",
|
|
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.10",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.10",
|
|
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.10",
|
|
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.10",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|