@blamejs/core 0.8.9 → 0.8.11
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 +8 -1
- package/lib/ai-adverse-decision.js +184 -0
- package/lib/breach-deadline.js +272 -0
- package/lib/compliance-eaa.js +204 -0
- package/lib/cra-report.js +195 -0
- package/lib/gdpr-ropa.js +261 -0
- package/lib/middleware/age-gate.js +141 -0
- package/lib/middleware/bot-disclose.js +149 -0
- package/lib/middleware/index.js +6 -0
- package/lib/nis2-report.js +181 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -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
|
+
};
|