@blamejs/core 0.10.7 → 0.10.8
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 +1 -0
- package/index.js +8 -1
- package/lib/ai-content-detect.js +268 -0
- package/lib/ai-input.js +58 -8
- package/lib/ai-model-manifest.js +363 -0
- package/lib/atomic-file.js +83 -0
- package/lib/audit.js +3 -0
- package/lib/content-credentials.js +140 -0
- package/lib/promise-pool.js +162 -0
- package/lib/sd-notify.js +269 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.ai.modelManifest
|
|
4
|
+
* @nav AI
|
|
5
|
+
* @title AI Model Manifest (AIBOM)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* CycloneDX 1.6 ML-BOM emitter for AI bills of materials. EU AI Act
|
|
9
|
+
* Art. 11 + Annex IV require technical documentation for high-risk
|
|
10
|
+
* AI systems; the EU CRA (Regulation (EU) 2024/2847) transposition
|
|
11
|
+
* deadline (2027-12-11) extends SBOM-style documentation to AI
|
|
12
|
+
* components in products with digital elements. ML-BOM extends the
|
|
13
|
+
* CycloneDX 1.5 machine-learning component type with model-card,
|
|
14
|
+
* dataset, hyperparameter, formulation, and external-service
|
|
15
|
+
* sections per the OWASP CycloneDX Authoritative Guide to AI/ML-BOM
|
|
16
|
+
* and CycloneDX spec issue #702 (EU CRA alignment).
|
|
17
|
+
*
|
|
18
|
+
* Two paths:
|
|
19
|
+
* - `build({...})` constructs a 1.6-conformant JSON BOM in memory.
|
|
20
|
+
* - `sign(bom, { privateKeyPem })` signs the canonical-JSON-1785
|
|
21
|
+
* representation with the operator's signing key (ML-DSA-87 by
|
|
22
|
+
* default per project hard-rule 2). `verify(envelope, publicKeyPem)`
|
|
23
|
+
* re-canonicalizes and checks before trusting any field.
|
|
24
|
+
*
|
|
25
|
+
* Self-validation: `build` rejects BOMs missing CycloneDX 1.6
|
|
26
|
+
* required fields (`bomFormat`, `specVersion`, `metadata.timestamp`,
|
|
27
|
+
* `metadata.component` when shipping a model). Catches malformed
|
|
28
|
+
* inputs at emit time, not at downstream validator.
|
|
29
|
+
*
|
|
30
|
+
* @card
|
|
31
|
+
* CycloneDX 1.6 AI bill of materials (AIBOM) — model cards, datasets, hyperparameters, training workflows. ML-DSA-87 signed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var bCrypto = require("./crypto");
|
|
35
|
+
var canonicalJson = require("./canonical-json");
|
|
36
|
+
var validateOpts = require("./validate-opts");
|
|
37
|
+
var { defineClass } = require("./framework-error");
|
|
38
|
+
var audit = require("./audit");
|
|
39
|
+
|
|
40
|
+
var AiModelManifestError = defineClass("AiModelManifestError", { alwaysPermanent: true });
|
|
41
|
+
|
|
42
|
+
var SPEC_VERSION = "1.6";
|
|
43
|
+
var BOM_FORMAT = "CycloneDX";
|
|
44
|
+
var COMPONENT_TYPE_MODEL = "machine-learning-model";
|
|
45
|
+
var VALID_DATA_TYPES = Object.freeze({
|
|
46
|
+
"source-code": true, configuration: true, dataset: true, definition: true,
|
|
47
|
+
"device-driver": true, documentation: true, evidence: true, executable: true,
|
|
48
|
+
file: true, firmware: true, framework: true, library: true,
|
|
49
|
+
"machine-learning-model": true, manifest: true, "operating-system": true,
|
|
50
|
+
patch: true, platform: true, "test-case": true,
|
|
51
|
+
});
|
|
52
|
+
var ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
53
|
+
var BOM_REF_RE = /^[A-Za-z0-9._:/+-]{1,256}$/; // allow:raw-byte-literal — CycloneDX bom-ref string-length cap, not bytes
|
|
54
|
+
|
|
55
|
+
function _requireString(obj, key, ownerName) {
|
|
56
|
+
if (typeof obj[key] !== "string" || obj[key].length === 0) {
|
|
57
|
+
throw new AiModelManifestError("aibom/bad-" + key,
|
|
58
|
+
ownerName + ": " + key + " must be a non-empty string");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _validateModelComponent(c) {
|
|
63
|
+
if (!c || typeof c !== "object") {
|
|
64
|
+
throw new AiModelManifestError("aibom/bad-model-component",
|
|
65
|
+
"build: opts.model must be an object");
|
|
66
|
+
}
|
|
67
|
+
_requireString(c, "name", "build.model");
|
|
68
|
+
_requireString(c, "version", "build.model");
|
|
69
|
+
if (c["bom-ref"] !== undefined) {
|
|
70
|
+
if (typeof c["bom-ref"] !== "string" || c["bom-ref"].length > 256) { // allow:raw-byte-literal — bom-ref string-length cap, not bytes
|
|
71
|
+
throw new AiModelManifestError("aibom/bad-bom-ref",
|
|
72
|
+
"build.model: bom-ref must be a string of length 1-256");
|
|
73
|
+
}
|
|
74
|
+
if (!BOM_REF_RE.test(c["bom-ref"])) { // allow:regex-no-length-cap — length-bounded immediately above
|
|
75
|
+
throw new AiModelManifestError("aibom/bad-bom-ref",
|
|
76
|
+
"build.model: bom-ref must match [A-Za-z0-9._:/+-]");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// modelCard is CycloneDX 1.6 §4.5.5; required for ML components when
|
|
80
|
+
// the operator wants downstream tooling (e.g. Dependency-Track) to
|
|
81
|
+
// surface the card. We do not REQUIRE the card here (some operators
|
|
82
|
+
// ship a hash-only ML-BOM by policy) but we do validate shape when
|
|
83
|
+
// present.
|
|
84
|
+
if (c.modelCard !== undefined) {
|
|
85
|
+
if (typeof c.modelCard !== "object") {
|
|
86
|
+
throw new AiModelManifestError("aibom/bad-model-card",
|
|
87
|
+
"build.model.modelCard: must be an object");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _validateDataComponent(d, idx) {
|
|
93
|
+
if (!d || typeof d !== "object") {
|
|
94
|
+
throw new AiModelManifestError("aibom/bad-dataset",
|
|
95
|
+
"build.datasets[" + idx + "]: must be an object");
|
|
96
|
+
}
|
|
97
|
+
_requireString(d, "name", "build.datasets[" + idx + "]");
|
|
98
|
+
if (d.type !== undefined && !VALID_DATA_TYPES[d.type]) {
|
|
99
|
+
throw new AiModelManifestError("aibom/bad-dataset-type",
|
|
100
|
+
"build.datasets[" + idx + "].type '" + d.type + "' not in CycloneDX 1.6 data-type vocabulary");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @primitive b.ai.modelManifest.build
|
|
106
|
+
* @signature b.ai.modelManifest.build(opts)
|
|
107
|
+
* @since 0.10.8
|
|
108
|
+
* @status stable
|
|
109
|
+
* @compliance eu-ai-act-art-11, nist-ai-600-1, iso-42001, iso-23894
|
|
110
|
+
* @related b.ai.modelManifest.sign, b.ai.modelManifest.verify
|
|
111
|
+
*
|
|
112
|
+
* Build a CycloneDX 1.6 ML-BOM JSON document for the supplied model
|
|
113
|
+
* + datasets + hyperparameters + formulation + external services.
|
|
114
|
+
* Returns a frozen plain object ready for `sign({...})` or direct
|
|
115
|
+
* serialization. Validates against the spec's required-field set at
|
|
116
|
+
* emit time (`bomFormat` / `specVersion` / `metadata.timestamp` /
|
|
117
|
+
* `metadata.component`) plus the ML-BOM-specific shape (model
|
|
118
|
+
* component type, dataset type vocabulary, bom-ref grammar).
|
|
119
|
+
*
|
|
120
|
+
* @opts
|
|
121
|
+
* model: object, // { name, version, license?, bom-ref?, modelCard? }
|
|
122
|
+
* datasets: object[], // [{ name, type?, contents?, classification?, ... }]
|
|
123
|
+
* hyperparameters: object, // kv pairs → properties[]
|
|
124
|
+
* formulation: object[], // [{ ref, components, workflows }]
|
|
125
|
+
* services: object[], // [{ name, endpoints, authenticated }]
|
|
126
|
+
* tool: object, // tool metadata; defaults to blamejs core
|
|
127
|
+
* timestamp: string, // ISO 8601 UTC; defaults to now
|
|
128
|
+
* serialNumber: string, // urn:uuid:...; defaults to fresh UUIDv4
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* var bom = b.ai.modelManifest.build({
|
|
132
|
+
* model: { name: "acme-classifier", version: "1.2.3" },
|
|
133
|
+
* datasets: [{ name: "training-2026", type: "dataset" }],
|
|
134
|
+
* });
|
|
135
|
+
* bom.bomFormat; // → "CycloneDX"
|
|
136
|
+
* bom.specVersion; // → "1.6"
|
|
137
|
+
*/
|
|
138
|
+
function build(opts) {
|
|
139
|
+
opts = opts || {};
|
|
140
|
+
_validateModelComponent(opts.model);
|
|
141
|
+
if (opts.datasets !== undefined) {
|
|
142
|
+
if (!Array.isArray(opts.datasets)) {
|
|
143
|
+
throw new AiModelManifestError("aibom/bad-datasets",
|
|
144
|
+
"build: opts.datasets must be an array");
|
|
145
|
+
}
|
|
146
|
+
for (var i = 0; i < opts.datasets.length; i += 1) _validateDataComponent(opts.datasets[i], i);
|
|
147
|
+
}
|
|
148
|
+
var timestamp = opts.timestamp || new Date().toISOString();
|
|
149
|
+
if (!ISO8601_RE.test(timestamp)) {
|
|
150
|
+
throw new AiModelManifestError("aibom/bad-timestamp",
|
|
151
|
+
"build: timestamp must be ISO 8601 UTC (e.g. 2026-05-17T20:00:00Z)");
|
|
152
|
+
}
|
|
153
|
+
var serialNumber = opts.serialNumber || _uuidUrn();
|
|
154
|
+
if (typeof serialNumber !== "string" || serialNumber.indexOf("urn:uuid:") !== 0) {
|
|
155
|
+
throw new AiModelManifestError("aibom/bad-serial-number",
|
|
156
|
+
"build: serialNumber must start with `urn:uuid:`");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
var primaryComponent = Object.assign({
|
|
160
|
+
type: COMPONENT_TYPE_MODEL,
|
|
161
|
+
"bom-ref": opts.model["bom-ref"] || ("model:" + opts.model.name + "@" + opts.model.version),
|
|
162
|
+
}, opts.model);
|
|
163
|
+
primaryComponent.type = COMPONENT_TYPE_MODEL;
|
|
164
|
+
|
|
165
|
+
var components = [];
|
|
166
|
+
// Per CycloneDX 1.6 §4.7: the primary component goes in
|
|
167
|
+
// metadata.component, NOT in components[]. Datasets, hyperparameter-
|
|
168
|
+
// bearing components, and external dependencies live in components[].
|
|
169
|
+
if (Array.isArray(opts.datasets)) {
|
|
170
|
+
for (var di = 0; di < opts.datasets.length; di += 1) {
|
|
171
|
+
var ds = opts.datasets[di];
|
|
172
|
+
components.push(Object.assign({
|
|
173
|
+
type: ds.type || "data",
|
|
174
|
+
"bom-ref": ds["bom-ref"] || ("dataset:" + ds.name),
|
|
175
|
+
}, ds));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Hyperparameters → CycloneDX properties[] kv pairs per spec
|
|
180
|
+
// issue #702 EU CRA alignment.
|
|
181
|
+
var properties = [];
|
|
182
|
+
if (opts.hyperparameters && typeof opts.hyperparameters === "object") {
|
|
183
|
+
var keys = Object.keys(opts.hyperparameters);
|
|
184
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
185
|
+
var key = keys[k];
|
|
186
|
+
properties.push({ name: "ai:hyperparameter:" + key,
|
|
187
|
+
value: String(opts.hyperparameters[key]) });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
var bom = {
|
|
192
|
+
bomFormat: BOM_FORMAT,
|
|
193
|
+
specVersion: SPEC_VERSION,
|
|
194
|
+
serialNumber: serialNumber,
|
|
195
|
+
version: 1,
|
|
196
|
+
metadata: {
|
|
197
|
+
timestamp: timestamp,
|
|
198
|
+
tools: [Object.assign({
|
|
199
|
+
vendor: "blamejs",
|
|
200
|
+
name: "@blamejs/core",
|
|
201
|
+
version: _frameworkVersion(),
|
|
202
|
+
}, opts.tool || {})],
|
|
203
|
+
component: primaryComponent,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
if (components.length > 0) bom.components = components;
|
|
207
|
+
if (properties.length > 0) bom.properties = properties;
|
|
208
|
+
if (Array.isArray(opts.formulation) && opts.formulation.length > 0) {
|
|
209
|
+
bom.formulation = opts.formulation;
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(opts.services) && opts.services.length > 0) {
|
|
212
|
+
bom.services = opts.services;
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(opts.dependencies) && opts.dependencies.length > 0) {
|
|
215
|
+
bom.dependencies = opts.dependencies;
|
|
216
|
+
}
|
|
217
|
+
return Object.freeze(bom);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @primitive b.ai.modelManifest.sign
|
|
222
|
+
* @signature b.ai.modelManifest.sign(bom, opts)
|
|
223
|
+
* @since 0.10.8
|
|
224
|
+
* @status stable
|
|
225
|
+
* @compliance eu-ai-act-art-11
|
|
226
|
+
* @related b.ai.modelManifest.build, b.ai.modelManifest.verify
|
|
227
|
+
*
|
|
228
|
+
* Sign an AIBOM produced by `build({...})`. Signature is over the
|
|
229
|
+
* canonical-JSON-1785 representation of the BOM (deterministic byte
|
|
230
|
+
* stream regardless of object-key insertion order); the operator's
|
|
231
|
+
* `privateKeyPem` selects the signing alg (ML-DSA-87 by default per
|
|
232
|
+
* the project's PQC-first crypto rule). Returns `{ bom, signature }`
|
|
233
|
+
* where `signature` is base64-encoded.
|
|
234
|
+
*
|
|
235
|
+
* @opts
|
|
236
|
+
* privateKeyPem: string, // PEM-encoded private key
|
|
237
|
+
* audit: boolean, // default true
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* var pair = b.crypto.generateSigningKeyPair("ml-dsa-87");
|
|
241
|
+
* var bom = b.ai.modelManifest.build({ model: { name: "x", version: "1" }});
|
|
242
|
+
* var env = b.ai.modelManifest.sign(bom, { privateKeyPem: pair.privateKey });
|
|
243
|
+
* typeof env.signature; // → "string"
|
|
244
|
+
*/
|
|
245
|
+
function sign(bom, opts) {
|
|
246
|
+
opts = opts || {};
|
|
247
|
+
if (!bom || typeof bom !== "object") {
|
|
248
|
+
throw new AiModelManifestError("aibom/bad-bom",
|
|
249
|
+
"sign: bom must be an object produced by build({...})");
|
|
250
|
+
}
|
|
251
|
+
validateOpts.requireNonEmptyString(opts.privateKeyPem,
|
|
252
|
+
"sign: opts.privateKeyPem", AiModelManifestError, "aibom/bad-key");
|
|
253
|
+
var canonical = canonicalJson.stringify(bom);
|
|
254
|
+
var signature = bCrypto.sign(Buffer.from(canonical, "utf8"), opts.privateKeyPem);
|
|
255
|
+
if (opts.audit !== false) {
|
|
256
|
+
audit.safeEmit({
|
|
257
|
+
action: "aibom.signed",
|
|
258
|
+
outcome: "success",
|
|
259
|
+
metadata: {
|
|
260
|
+
modelName: bom.metadata && bom.metadata.component && bom.metadata.component.name,
|
|
261
|
+
modelVersion: bom.metadata && bom.metadata.component && bom.metadata.component.version,
|
|
262
|
+
serialNumber: bom.serialNumber,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
bom: bom,
|
|
268
|
+
signature: signature.toString("base64"),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @primitive b.ai.modelManifest.verify
|
|
274
|
+
* @signature b.ai.modelManifest.verify(envelope, publicKeyPem, opts)
|
|
275
|
+
* @since 0.10.8
|
|
276
|
+
* @status stable
|
|
277
|
+
* @compliance eu-ai-act-art-11
|
|
278
|
+
* @related b.ai.modelManifest.sign
|
|
279
|
+
*
|
|
280
|
+
* Verify an envelope produced by `sign(bom, {...})`. Re-canonicalizes
|
|
281
|
+
* the BOM with `canonicalJson.stringify` (NEVER trusts an embedded
|
|
282
|
+
* "signedBytes" field — defends the CVE-2025-29774 / CVE-2025-29775
|
|
283
|
+
* xml-crypto-style signature-substitution class) and checks the
|
|
284
|
+
* signature with `b.crypto.verify` against the supplied public-key
|
|
285
|
+
* PEM. Returns `{ valid, bom, reason }`; never throws.
|
|
286
|
+
*
|
|
287
|
+
* @opts
|
|
288
|
+
* audit: boolean, // default true
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* var result = b.ai.modelManifest.verify(envelope, pair.publicKey);
|
|
292
|
+
* if (!result.valid) console.log(result.reason);
|
|
293
|
+
*/
|
|
294
|
+
function verify(envelope, publicKeyPem, opts) {
|
|
295
|
+
opts = opts || {};
|
|
296
|
+
if (!envelope || typeof envelope !== "object" || !envelope.bom || !envelope.signature) {
|
|
297
|
+
return { valid: false, bom: null, reason: "envelope-shape" };
|
|
298
|
+
}
|
|
299
|
+
if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
|
|
300
|
+
return { valid: false, bom: null, reason: "public-key-required" };
|
|
301
|
+
}
|
|
302
|
+
if (envelope.bom.specVersion !== SPEC_VERSION || envelope.bom.bomFormat !== BOM_FORMAT) {
|
|
303
|
+
return { valid: false, bom: null, reason: "bom-spec-mismatch" };
|
|
304
|
+
}
|
|
305
|
+
var canonical = canonicalJson.stringify(envelope.bom);
|
|
306
|
+
var sigBuf;
|
|
307
|
+
try { sigBuf = Buffer.from(envelope.signature, "base64"); }
|
|
308
|
+
catch (_e) { return { valid: false, bom: null, reason: "signature-base64-bad" }; }
|
|
309
|
+
// `b.crypto.verify` can throw on malformed public-key PEM (Node's
|
|
310
|
+
// crypto layer surfaces `DECODER routines::unsupported` and similar).
|
|
311
|
+
// The documented contract here is `{ valid, bom, reason }` with no
|
|
312
|
+
// throws — wrap so a hostile / mistyped key returns a structured
|
|
313
|
+
// verdict instead of crashing the request path.
|
|
314
|
+
var ok;
|
|
315
|
+
try { ok = bCrypto.verify(Buffer.from(canonical, "utf8"), sigBuf, publicKeyPem); }
|
|
316
|
+
catch (_e2) { return { valid: false, bom: null, reason: "public-key-malformed" }; }
|
|
317
|
+
if (!ok) return { valid: false, bom: null, reason: "signature-invalid" };
|
|
318
|
+
if (opts.audit !== false) {
|
|
319
|
+
audit.safeEmit({
|
|
320
|
+
action: "aibom.verified",
|
|
321
|
+
outcome: "success",
|
|
322
|
+
metadata: {
|
|
323
|
+
modelName: envelope.bom.metadata && envelope.bom.metadata.component &&
|
|
324
|
+
envelope.bom.metadata.component.name,
|
|
325
|
+
serialNumber: envelope.bom.serialNumber,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return { valid: true, bom: envelope.bom, reason: null };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// UUIDv4 via the framework's CSPRNG path. Used for `serialNumber`
|
|
333
|
+
// defaults — operators may supply their own for cross-build stability.
|
|
334
|
+
function _uuidUrn() {
|
|
335
|
+
var b = bCrypto.generateBytes(16); // allow:raw-byte-literal — RFC 9562 §4.1 UUIDv4 is a 16-byte (128-bit) primitive
|
|
336
|
+
b[6] = (b[6] & 0x0f) | 0x40; // allow:raw-byte-literal — UUIDv4 version nibble (RFC 9562 §4.4)
|
|
337
|
+
b[8] = (b[8] & 0x3f) | 0x80; // allow:raw-byte-literal — UUIDv4 variant nibble (RFC 9562 §4.4)
|
|
338
|
+
var h = b.toString("hex");
|
|
339
|
+
return "urn:uuid:" + h.slice(0, 8) + "-" + h.slice(8, 12) + "-" + // allow:raw-byte-literal — RFC 9562 §4 UUID text representation hex offsets (8-4-4-4-12)
|
|
340
|
+
h.slice(12, 16) + "-" + h.slice(16, 20) + "-" + h.slice(20); // allow:raw-byte-literal — RFC 9562 §4 UUID hex offsets
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// package.json read lives behind the call to dodge a circular-load
|
|
344
|
+
// chain: framework boot pulls index.js → ai-model-manifest → audit →
|
|
345
|
+
// db → framework-error → constants → package.json. Reading
|
|
346
|
+
// package.json at boot time would close the cycle. The lazy read is
|
|
347
|
+
// idempotent and the result is cached on first call.
|
|
348
|
+
var _cachedVersion = null;
|
|
349
|
+
function _frameworkVersion() {
|
|
350
|
+
if (_cachedVersion) return _cachedVersion;
|
|
351
|
+
try { _cachedVersion = require("../package.json").version; } // allow:inline-require — lazy package-version read to avoid boot-time circular load
|
|
352
|
+
catch (_e) { _cachedVersion = "0.0.0"; }
|
|
353
|
+
return _cachedVersion;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
build: build,
|
|
358
|
+
sign: sign,
|
|
359
|
+
verify: verify,
|
|
360
|
+
SPEC_VERSION: SPEC_VERSION,
|
|
361
|
+
BOM_FORMAT: BOM_FORMAT,
|
|
362
|
+
AiModelManifestError: AiModelManifestError,
|
|
363
|
+
};
|
package/lib/atomic-file.js
CHANGED
|
@@ -279,6 +279,88 @@ function pathTimestamp(date) {
|
|
|
279
279
|
return d.toISOString().replace(/[:.]/g, "-");
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// Generic [A-Za-z0-9_-]+ identifier shape used by conflictPath tag /
|
|
283
|
+
// suffix validation. The pattern collides with similar shapes in
|
|
284
|
+
// safe-buffer / redact / etc.; keeping the regex literal local to
|
|
285
|
+
// atomic-file rather than pulling in a cross-module dependency for a
|
|
286
|
+
// 30-byte regex keeps this file's lazy-load chain short.
|
|
287
|
+
var IDENT_RE = /^[A-Za-z0-9_-]+$/; // allow:regex-no-length-cap — caller bounds length before .test() // allow:duplicate-regex — generic [A-Za-z0-9_-]+ identifier shape; extracting a one-line regex into a cross-module dependency would lengthen atomic-file's boot-time lazy chain for no behavioral win
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @primitive b.atomicFile.conflictPath
|
|
291
|
+
* @signature b.atomicFile.conflictPath(originalPath, opts?)
|
|
292
|
+
* @since 0.10.8
|
|
293
|
+
* @status stable
|
|
294
|
+
* @related b.atomicFile.pathTimestamp, b.atomicFile.write
|
|
295
|
+
*
|
|
296
|
+
* Build a filesystem-portable conflict-suffix path next to
|
|
297
|
+
* `originalPath`, e.g. `notes.md` → `notes.conflict-2026-05-17T19-30-00Z.md`.
|
|
298
|
+
* Drop-in name for last-write-wins reconciliation in sync / backup /
|
|
299
|
+
* dual-control workflows. Preserves the original extension. Inserts a
|
|
300
|
+
* caller-supplied `tag` (default `conflict`) between the basename and
|
|
301
|
+
* the timestamp. The timestamp uses `pathTimestamp` so the result is
|
|
302
|
+
* portable across Windows (no `:` / `.`), macOS, and Linux. Same-second
|
|
303
|
+
* collision handling: pass `opts.suffix` (e.g. a per-row crypto-random
|
|
304
|
+
* hex) when multiple conflicts may land in the same second; otherwise
|
|
305
|
+
* the timestamp's millisecond field disambiguates.
|
|
306
|
+
*
|
|
307
|
+
* @opts
|
|
308
|
+
* tag: string, // default "conflict"; sandwiched between basename and timestamp
|
|
309
|
+
* timestamp: Date, // default `new Date()`
|
|
310
|
+
* suffix: string, // optional extra disambiguator appended after timestamp
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* var p = b.atomicFile.conflictPath("/srv/notes.md");
|
|
314
|
+
* // → "/srv/notes.conflict-2026-05-17T20-30-00-123Z.md"
|
|
315
|
+
*
|
|
316
|
+
* var withSuffix = b.atomicFile.conflictPath("/srv/notes.md", {
|
|
317
|
+
* tag: "merge", suffix: "abc123",
|
|
318
|
+
* });
|
|
319
|
+
* // → "/srv/notes.merge-2026-05-17T20-30-00-123Z.abc123.md"
|
|
320
|
+
*/
|
|
321
|
+
function conflictPath(originalPath, opts) {
|
|
322
|
+
if (typeof originalPath !== "string" || originalPath.length === 0) {
|
|
323
|
+
throw new TypeError("b.atomicFile.conflictPath: originalPath must be a non-empty string");
|
|
324
|
+
}
|
|
325
|
+
opts = opts || {};
|
|
326
|
+
var tag = typeof opts.tag === "string" && opts.tag.length > 0 ? opts.tag : "conflict";
|
|
327
|
+
if (typeof tag !== "string" || tag.length === 0 || tag.length > 64) { // allow:raw-byte-literal — tag length cap, not bytes
|
|
328
|
+
throw new TypeError("b.atomicFile.conflictPath: tag must be a 1-64 char string");
|
|
329
|
+
}
|
|
330
|
+
if (!IDENT_RE.test(tag)) { // allow:regex-no-length-cap — length-bounded immediately above
|
|
331
|
+
throw new TypeError("b.atomicFile.conflictPath: tag must match [A-Za-z0-9_-]+");
|
|
332
|
+
}
|
|
333
|
+
var stamp = pathTimestamp(opts.timestamp);
|
|
334
|
+
var suffix = "";
|
|
335
|
+
if (opts.suffix !== undefined) {
|
|
336
|
+
if (typeof opts.suffix !== "string" || opts.suffix.length === 0 ||
|
|
337
|
+
opts.suffix.length > 64) { // allow:raw-byte-literal — suffix length cap, not bytes
|
|
338
|
+
throw new TypeError("b.atomicFile.conflictPath: suffix must be a 1-64 char string");
|
|
339
|
+
}
|
|
340
|
+
if (!IDENT_RE.test(opts.suffix)) { // allow:regex-no-length-cap — length-bounded immediately above
|
|
341
|
+
throw new TypeError("b.atomicFile.conflictPath: suffix must match [A-Za-z0-9_-]+");
|
|
342
|
+
}
|
|
343
|
+
suffix = "." + opts.suffix;
|
|
344
|
+
}
|
|
345
|
+
// Walk from the rightmost `.` to split base + ext. POSIX `path` module
|
|
346
|
+
// does it portably; using string ops here keeps the helper free of
|
|
347
|
+
// additional require()s in the hot atomic-file file (which is loaded
|
|
348
|
+
// before most of the framework's lazy chain). Extension preservation
|
|
349
|
+
// walks ONLY the basename — a directory containing a `.` doesn't
|
|
350
|
+
// confuse the suffix.
|
|
351
|
+
var sep = originalPath.lastIndexOf("/");
|
|
352
|
+
var bsep = originalPath.lastIndexOf("\\");
|
|
353
|
+
var lastSep = sep > bsep ? sep : bsep;
|
|
354
|
+
var dir = lastSep >= 0 ? originalPath.slice(0, lastSep + 1) : "";
|
|
355
|
+
var name = lastSep >= 0 ? originalPath.slice(lastSep + 1) : originalPath;
|
|
356
|
+
var dotIdx = name.lastIndexOf(".");
|
|
357
|
+
// Treat a leading dot (dotfile, e.g. `.env`) as part of the base, not
|
|
358
|
+
// as an extension separator. `dotIdx === 0` → no extension.
|
|
359
|
+
var base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
|
360
|
+
var ext = dotIdx > 0 ? name.slice(dotIdx) : "";
|
|
361
|
+
return dir + base + "." + tag + "-" + stamp + suffix + ext;
|
|
362
|
+
}
|
|
363
|
+
|
|
282
364
|
/**
|
|
283
365
|
* @primitive b.atomicFile.writeSync
|
|
284
366
|
* @signature b.atomicFile.writeSync(filepath, data, opts)
|
|
@@ -935,6 +1017,7 @@ module.exports = {
|
|
|
935
1017
|
ensureDir: ensureDir,
|
|
936
1018
|
copyDirRecursive: copyDirRecursive,
|
|
937
1019
|
pathTimestamp: pathTimestamp,
|
|
1020
|
+
conflictPath: conflictPath,
|
|
938
1021
|
AtomicFileError: AtomicFileError,
|
|
939
1022
|
DEFAULTS: DEFAULTS,
|
|
940
1023
|
};
|
package/lib/audit.js
CHANGED
|
@@ -296,6 +296,9 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
296
296
|
"localdb", // b.localDb.thin (localdb.thin.opened / recovered / closed — desktop-daemon SQLite wrapper)
|
|
297
297
|
"dataact", // b.dataAct (EU Data Act 2023/2854 — product_declared / user_access / share_with_third_party / share_refused / switch_request)
|
|
298
298
|
"idempotency", // b.middleware.idempotencyKey (idempotency.missing_key / bad_key / replay / key_reuse_mismatch / cache_store / store_read_failed / store_write_failed / skip_5xx / body_too_large — draft-ietf-httpapi-idempotency-key)
|
|
299
|
+
"aibom", // b.ai.modelManifest (aibom.signed / aibom.verified — CycloneDX 1.6 ML-BOM)
|
|
300
|
+
"aicontentdetect", // b.ai.aiContentDetect (aicontentdetect.report — AB-853 / EU AI Act Art. 50 inbound provenance)
|
|
301
|
+
"sdnotify", // b.sdNotify (sdnotify.send / sdnotify.send.skipped — systemd Type=notify)
|
|
299
302
|
];
|
|
300
303
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
301
304
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
var bCrypto = require("./crypto");
|
|
32
32
|
var canonicalJson = require("./canonical-json");
|
|
33
|
+
var safeJson = require("./safe-json");
|
|
33
34
|
var validateOpts = require("./validate-opts");
|
|
34
35
|
var audit = require("./audit");
|
|
35
36
|
var { defineClass } = require("./framework-error");
|
|
@@ -552,12 +553,151 @@ function signCose(manifest, opts) {
|
|
|
552
553
|
};
|
|
553
554
|
}
|
|
554
555
|
|
|
556
|
+
/**
|
|
557
|
+
* @primitive b.contentCredentials.cacImplicitLabel
|
|
558
|
+
* @signature b.contentCredentials.cacImplicitLabel(opts)
|
|
559
|
+
* @since 0.10.8
|
|
560
|
+
* @status stable
|
|
561
|
+
* @compliance cac-genai-label
|
|
562
|
+
* @related b.contentCredentials.build, b.contentCredentials.cacImplicitLabelRead
|
|
563
|
+
*
|
|
564
|
+
* Build the GB 45438-2025 "Cybersecurity Technology — Labeling Method
|
|
565
|
+
* for Content Generated by Artificial Intelligence" implicit metadata
|
|
566
|
+
* block (effective 2025-09-01 per CAC Measures for Labeling AI-
|
|
567
|
+
* Generated Synthetic Content). The framework owns the implicit lane
|
|
568
|
+
* (metadata); the visible explicit label is application-layer
|
|
569
|
+
* rendering. Operators co-emit alongside the C2PA-COSE manifest by
|
|
570
|
+
* declaring `cac-genai-label` posture on `b.contentCredentials.build`.
|
|
571
|
+
*
|
|
572
|
+
* @opts
|
|
573
|
+
* providerName: string, // UTF-8 ≤256 bytes
|
|
574
|
+
* providerCode: string, // 18-char 统一社会信用代码 (Chinese USCC)
|
|
575
|
+
* contentId: string, // globally-unique asset id
|
|
576
|
+
* contentKind: string, // "text"|"image"|"audio"|"video"|"virtual-scene"|"other"
|
|
577
|
+
* generatedAt: string, // ISO 8601 UTC
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* var label = b.contentCredentials.cacImplicitLabel({
|
|
581
|
+
* providerName: "Example AI",
|
|
582
|
+
* providerCode: "91110000600037341A",
|
|
583
|
+
* contentId: "asset-2026-05-17-abc123",
|
|
584
|
+
* contentKind: "image",
|
|
585
|
+
* generatedAt: "2026-05-17T20:00:00Z",
|
|
586
|
+
* });
|
|
587
|
+
* // → { aigcMarker: "AIGC", providerName, providerCode, contentId, contentKind, generatedAt }
|
|
588
|
+
*/
|
|
589
|
+
var CAC_KIND_ENUM = Object.freeze({
|
|
590
|
+
text: true, image: true, audio: true, video: true,
|
|
591
|
+
"virtual-scene": true, other: true,
|
|
592
|
+
});
|
|
593
|
+
var CAC_USCC_RE = /^[0-9A-HJ-NPQRTUWXY]{18}$/; // allow:raw-byte-literal — GB 32100-2015 USCC fixed length, not bytes // allow:raw-time-literal — 18 is char-count of the credit code, not seconds
|
|
594
|
+
var ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
595
|
+
|
|
596
|
+
function cacImplicitLabel(opts) {
|
|
597
|
+
if (!opts || typeof opts !== "object") {
|
|
598
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-opts",
|
|
599
|
+
"cacImplicitLabel: opts object required");
|
|
600
|
+
}
|
|
601
|
+
validateOpts.requireNonEmptyString(opts.providerName,
|
|
602
|
+
"cacImplicitLabel: providerName", ContentCredentialsError,
|
|
603
|
+
"cac-implicit-label/bad-provider-name");
|
|
604
|
+
if (Buffer.byteLength(opts.providerName, "utf8") > STR_LEN_MAX) {
|
|
605
|
+
throw new ContentCredentialsError("cac-implicit-label/oversize-provider-name",
|
|
606
|
+
"cacImplicitLabel: providerName exceeds " + STR_LEN_MAX + " bytes (UTF-8)");
|
|
607
|
+
}
|
|
608
|
+
if (typeof opts.providerCode !== "string" || opts.providerCode.length !== 18 || // allow:raw-byte-literal — USCC fixed length (GB 32100-2015), not bytes // allow:raw-time-literal — string length, not seconds
|
|
609
|
+
!CAC_USCC_RE.test(opts.providerCode)) { // allow:regex-no-length-cap — length-bounded immediately above
|
|
610
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-provider-code",
|
|
611
|
+
"cacImplicitLabel: providerCode must be an 18-char unified social credit code " +
|
|
612
|
+
"(统一社会信用代码 per GB 32100-2015 / GB 45438-2025)");
|
|
613
|
+
}
|
|
614
|
+
if (typeof opts.contentId !== "string" || opts.contentId.length === 0 ||
|
|
615
|
+
opts.contentId.length > 128) { // allow:raw-byte-literal — contentId char cap, not bytes
|
|
616
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-content-id",
|
|
617
|
+
"cacImplicitLabel: contentId must be 1-128 chars");
|
|
618
|
+
}
|
|
619
|
+
if (!ID_RE.test(opts.contentId)) { // allow:regex-no-length-cap — length-bounded immediately above
|
|
620
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-content-id",
|
|
621
|
+
"cacImplicitLabel: contentId must match [A-Za-z0-9._:/-]");
|
|
622
|
+
}
|
|
623
|
+
if (typeof opts.contentKind !== "string" || !CAC_KIND_ENUM[opts.contentKind]) {
|
|
624
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-content-kind",
|
|
625
|
+
"cacImplicitLabel: contentKind must be one of " +
|
|
626
|
+
Object.keys(CAC_KIND_ENUM).join("/"));
|
|
627
|
+
}
|
|
628
|
+
if (typeof opts.generatedAt !== "string" || !ISO8601_RE.test(opts.generatedAt)) {
|
|
629
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-generated-at",
|
|
630
|
+
"cacImplicitLabel: generatedAt must be ISO 8601 UTC (e.g. 2026-05-17T20:00:00Z)");
|
|
631
|
+
}
|
|
632
|
+
return Object.freeze({
|
|
633
|
+
aigcMarker: "AIGC",
|
|
634
|
+
providerName: opts.providerName,
|
|
635
|
+
providerCode: opts.providerCode,
|
|
636
|
+
contentId: opts.contentId,
|
|
637
|
+
contentKind: opts.contentKind,
|
|
638
|
+
generatedAt: opts.generatedAt,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* @primitive b.contentCredentials.cacImplicitLabelRead
|
|
644
|
+
* @signature b.contentCredentials.cacImplicitLabelRead(bytesOrObject)
|
|
645
|
+
* @since 0.10.8
|
|
646
|
+
* @status stable
|
|
647
|
+
* @compliance cac-genai-label
|
|
648
|
+
* @related b.contentCredentials.cacImplicitLabel
|
|
649
|
+
*
|
|
650
|
+
* Reverse parser for the GB 45438-2025 implicit label. Accepts either
|
|
651
|
+
* a `Buffer` / `string` containing the JSON-serialized block (as the
|
|
652
|
+
* sender embedded in XMP / EXIF / MP4-box / etc.) or the already-
|
|
653
|
+
* parsed object. Returns the validated label shape or throws on any
|
|
654
|
+
* field that fails the same gate `cacImplicitLabel({...})` enforces.
|
|
655
|
+
*
|
|
656
|
+
* @example
|
|
657
|
+
* var label = b.contentCredentials.cacImplicitLabelRead(jsonBuf);
|
|
658
|
+
* // → { aigcMarker: "AIGC", providerName, providerCode, ... }
|
|
659
|
+
*/
|
|
660
|
+
function cacImplicitLabelRead(input) {
|
|
661
|
+
var obj;
|
|
662
|
+
if (Buffer.isBuffer(input)) {
|
|
663
|
+
try { obj = safeJson.parse(input.toString("utf8"), { maxBytes: 64 * 1024 }); } // allow:raw-byte-literal — 64 KiB CAC label cap
|
|
664
|
+
catch (e) {
|
|
665
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-json",
|
|
666
|
+
"cacImplicitLabelRead: JSON parse failed: " + (e && e.message));
|
|
667
|
+
}
|
|
668
|
+
} else if (typeof input === "string") {
|
|
669
|
+
try { obj = safeJson.parse(input, { maxBytes: 64 * 1024 }); } // allow:raw-byte-literal — 64 KiB CAC label cap
|
|
670
|
+
catch (e2) {
|
|
671
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-json",
|
|
672
|
+
"cacImplicitLabelRead: JSON parse failed: " + (e2 && e2.message));
|
|
673
|
+
}
|
|
674
|
+
} else if (input && typeof input === "object") {
|
|
675
|
+
obj = input;
|
|
676
|
+
} else {
|
|
677
|
+
throw new ContentCredentialsError("cac-implicit-label/bad-input",
|
|
678
|
+
"cacImplicitLabelRead: input must be Buffer / string / object (got " + typeof input + ")");
|
|
679
|
+
}
|
|
680
|
+
if (obj.aigcMarker !== "AIGC") {
|
|
681
|
+
throw new ContentCredentialsError("cac-implicit-label/missing-aigc-marker",
|
|
682
|
+
"cacImplicitLabelRead: aigcMarker field must equal 'AIGC' (GB 45438-2025 §6)");
|
|
683
|
+
}
|
|
684
|
+
return cacImplicitLabel({
|
|
685
|
+
providerName: obj.providerName,
|
|
686
|
+
providerCode: obj.providerCode,
|
|
687
|
+
contentId: obj.contentId,
|
|
688
|
+
contentKind: obj.contentKind,
|
|
689
|
+
generatedAt: obj.generatedAt,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
555
693
|
module.exports = {
|
|
556
694
|
build: build,
|
|
557
695
|
sign: sign,
|
|
558
696
|
signCose: signCose,
|
|
559
697
|
verify: verify,
|
|
560
698
|
required: required,
|
|
699
|
+
cacImplicitLabel: cacImplicitLabel,
|
|
700
|
+
cacImplicitLabelRead: cacImplicitLabelRead,
|
|
561
701
|
REQUIRED_FIELDS: REQUIRED_FIELDS.slice(),
|
|
562
702
|
COSE_ALGS: Object.assign({}, COSE_ALGS),
|
|
563
703
|
ContentCredentialsError: ContentCredentialsError,
|