@blamejs/core 0.10.7 → 0.10.9
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 +10 -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 +5 -0
- package/lib/boot-gates.js +174 -0
- package/lib/content-credentials.js +140 -0
- package/lib/metrics.js +352 -18
- package/lib/pqc-agent.js +70 -1
- package/lib/promise-pool.js +162 -0
- package/lib/safe-path.js +254 -0
- package/lib/sd-notify.js +269 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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,
|
package/lib/metrics.js
CHANGED
|
@@ -970,6 +970,68 @@ function snapshotRead(p) {
|
|
|
970
970
|
* res.setHeader("Content-Type", "text/plain; version=0.0.4");
|
|
971
971
|
* res.end(b.metrics.snapshot.render(snap, { format: "prometheus", prefix: "myapp" }));
|
|
972
972
|
*/
|
|
973
|
+
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; // allow:duplicate-regex — ISO-8601 instant shape ships in three primitives (metrics text-render, content-credentials, mail-server-imap APPEND); each is bounded by its own caller and the regex itself is 50 bytes — extracting into a cross-module dep wouldn't carry its weight
|
|
974
|
+
|
|
975
|
+
// Formats a single field value for the text renderer. ISO-date-shaped
|
|
976
|
+
// strings render verbatim (with millisecond precision) so the human
|
|
977
|
+
// operator reads them as timestamps; everything else degrades to the
|
|
978
|
+
// existing number / string / boolean / JSON formatting.
|
|
979
|
+
function _formatTextValue(v) {
|
|
980
|
+
if (typeof v === "number") return String(v);
|
|
981
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
982
|
+
if (typeof v === "string") {
|
|
983
|
+
if (ISO_DATE_RE.test(v) && isFinite(Date.parse(v))) return v; // allow:regex-no-length-cap — ISO-date shape, length-bounded by the anchored pattern
|
|
984
|
+
return v;
|
|
985
|
+
}
|
|
986
|
+
return JSON.stringify(v);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Internal text-format renderer extracted from snapshotRender so the
|
|
990
|
+
// E.grouped-text + H.iso-date paths share one code path.
|
|
991
|
+
function _renderText(fields, snap, opts) {
|
|
992
|
+
var lines = ["snapshot written-at: " + snap.writtenAt];
|
|
993
|
+
// E. operator-supplied group map. Group ordering follows the
|
|
994
|
+
// insertion order of the `opts.groups` object; fields not named in
|
|
995
|
+
// any group fall to the bottom under `== Other ==`.
|
|
996
|
+
if (opts.groups && typeof opts.groups === "object" && !Array.isArray(opts.groups)) {
|
|
997
|
+
var groupNames = Object.keys(opts.groups);
|
|
998
|
+
var named = Object.create(null);
|
|
999
|
+
for (var gi = 0; gi < groupNames.length; gi += 1) {
|
|
1000
|
+
var gName = groupNames[gi];
|
|
1001
|
+
var fieldNames = opts.groups[gName];
|
|
1002
|
+
if (!Array.isArray(fieldNames)) continue;
|
|
1003
|
+
lines.push("");
|
|
1004
|
+
lines.push("== " + gName + " ==");
|
|
1005
|
+
for (var fi = 0; fi < fieldNames.length; fi += 1) {
|
|
1006
|
+
var fn = fieldNames[fi];
|
|
1007
|
+
named[fn] = true;
|
|
1008
|
+
if (Object.prototype.hasOwnProperty.call(fields, fn)) {
|
|
1009
|
+
lines.push(" " + fn + ": " + _formatTextValue(fields[fn]));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Stable order for the unnamed remainder.
|
|
1014
|
+
// allow:bare-canonicalize-walk — operator-facing display ordering
|
|
1015
|
+
var remainder = Object.keys(fields).sort().filter(function (k) { return !named[k]; });
|
|
1016
|
+
if (remainder.length > 0) {
|
|
1017
|
+
lines.push("");
|
|
1018
|
+
lines.push("== Other ==");
|
|
1019
|
+
for (var ri = 0; ri < remainder.length; ri += 1) {
|
|
1020
|
+
lines.push(" " + remainder[ri] + ": " + _formatTextValue(fields[remainder[ri]]));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return lines.join("\n") + "\n";
|
|
1024
|
+
}
|
|
1025
|
+
// Default flat rendering.
|
|
1026
|
+
// allow:bare-canonicalize-walk — operator-facing display ordering
|
|
1027
|
+
var keys = Object.keys(fields).sort();
|
|
1028
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
1029
|
+
var k = keys[i];
|
|
1030
|
+
lines.push(" " + k + ": " + _formatTextValue(fields[k]));
|
|
1031
|
+
}
|
|
1032
|
+
return lines.join("\n") + "\n";
|
|
1033
|
+
}
|
|
1034
|
+
|
|
973
1035
|
function snapshotRender(snap, opts) {
|
|
974
1036
|
opts = opts || {};
|
|
975
1037
|
var format = opts.format || "text";
|
|
@@ -979,21 +1041,7 @@ function snapshotRender(snap, opts) {
|
|
|
979
1041
|
}
|
|
980
1042
|
var fields = snap.fields;
|
|
981
1043
|
if (format === "text") {
|
|
982
|
-
|
|
983
|
-
// allow:bare-canonicalize-walk — sort is for stable human-readable
|
|
984
|
-
// output ordering, not canonicalize-for-hashing
|
|
985
|
-
var keys = Object.keys(fields).sort();
|
|
986
|
-
for (var i = 0; i < keys.length; i++) {
|
|
987
|
-
var k = keys[i];
|
|
988
|
-
var v = fields[k];
|
|
989
|
-
var s;
|
|
990
|
-
if (typeof v === "number") s = String(v);
|
|
991
|
-
else if (typeof v === "string") s = v;
|
|
992
|
-
else if (typeof v === "boolean") s = v ? "true" : "false";
|
|
993
|
-
else s = JSON.stringify(v);
|
|
994
|
-
lines.push(" " + k + ": " + s);
|
|
995
|
-
}
|
|
996
|
-
return lines.join("\n") + "\n";
|
|
1044
|
+
return _renderText(fields, snap, opts);
|
|
997
1045
|
}
|
|
998
1046
|
if (format === "prometheus") {
|
|
999
1047
|
var prefix = opts.prefix || "blamejs";
|
|
@@ -1032,16 +1080,302 @@ function snapshotRender(snap, opts) {
|
|
|
1032
1080
|
out.push("# TYPE " + metric + " " + fieldType);
|
|
1033
1081
|
out.push(metric + " " + v2);
|
|
1034
1082
|
}
|
|
1083
|
+
// ISO-date string fields → parallel `<name>_epoch_ms` gauge per
|
|
1084
|
+
// OpenMetrics 1.0 §3.4 (Timestamps MUST be float64 Unix-epoch). The
|
|
1085
|
+
// operator-facing text format renders the ISO string verbatim; the
|
|
1086
|
+
// Prometheus / OpenMetrics format gets the epoch-ms equivalent so
|
|
1087
|
+
// downstream alerting can compute durations.
|
|
1088
|
+
for (var jd = 0; jd < keys2.length; jd += 1) {
|
|
1089
|
+
var kd = keys2[jd];
|
|
1090
|
+
var vd = fields[kd];
|
|
1091
|
+
if (typeof vd !== "string") continue;
|
|
1092
|
+
if (vd.length > 64) continue; // allow:raw-byte-literal — ISO 8601 max length cap, not bytes
|
|
1093
|
+
if (!ISO_DATE_RE.test(vd)) continue; // allow:regex-no-length-cap — length-bounded immediately above
|
|
1094
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(kd)) continue; // allow:regex-no-length-cap — field-name shape, length-bounded by snap field naming
|
|
1095
|
+
var ms = Date.parse(vd);
|
|
1096
|
+
if (!isFinite(ms)) continue;
|
|
1097
|
+
var emName = prefix + "_" + kd + "_epoch_ms";
|
|
1098
|
+
out.push("# TYPE " + emName + " gauge");
|
|
1099
|
+
out.push(emName + " " + ms);
|
|
1100
|
+
}
|
|
1035
1101
|
return out.join("\n") + "\n";
|
|
1036
1102
|
}
|
|
1037
1103
|
throw new MetricsError("metrics-snapshot/bad-format",
|
|
1038
1104
|
"metrics.snapshot.render: format must be 'text' or 'prometheus', got '" + format + "'");
|
|
1039
1105
|
}
|
|
1040
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* @primitive b.metrics.snapshot.shadowRegistry
|
|
1109
|
+
* @signature b.metrics.snapshot.shadowRegistry(opts)
|
|
1110
|
+
* @since 0.10.9
|
|
1111
|
+
* @status stable
|
|
1112
|
+
* @related b.metrics.snapshot.render, b.metrics.create
|
|
1113
|
+
*
|
|
1114
|
+
* Build a namespaced shadow metrics registry that mirrors a subset of
|
|
1115
|
+
* a primary registry's counters / gauges / info for export to systems
|
|
1116
|
+
* needing isolated views (sidecar / per-tenant scrape endpoint /
|
|
1117
|
+
* compliance-tagged subset). Cardinality cap closes the
|
|
1118
|
+
* [client_golang CVE-2022-21698](https://nvd.nist.gov/vuln/detail/CVE-2022-21698)
|
|
1119
|
+
* unbounded-cardinality DoS class. Returns
|
|
1120
|
+
* `{ inc, set, setInfo, snapshot, render, reset }`.
|
|
1121
|
+
*
|
|
1122
|
+
* @opts
|
|
1123
|
+
* namespace: string, // identifier prefix; required
|
|
1124
|
+
* counters: string[], // counter names to mirror
|
|
1125
|
+
* gauges: string[], // gauge names to mirror
|
|
1126
|
+
* info: string[], // info names to mirror
|
|
1127
|
+
* cardinalityCap: number, // default 10000 per metric name
|
|
1128
|
+
* onCardinalityExceeded: "drop" | "audit-only" | "refuse", // default "drop"
|
|
1129
|
+
*
|
|
1130
|
+
* @example
|
|
1131
|
+
* var shadow = b.metrics.snapshot.shadowRegistry({
|
|
1132
|
+
* namespace: "tenant_a",
|
|
1133
|
+
* counters: ["requests_total", "errors_total"],
|
|
1134
|
+
* gauges: ["queue_depth"],
|
|
1135
|
+
* });
|
|
1136
|
+
* shadow.inc("requests_total");
|
|
1137
|
+
* shadow.set("queue_depth", 42);
|
|
1138
|
+
* shadow.snapshot();
|
|
1139
|
+
*/
|
|
1140
|
+
var SHADOW_DEFAULT_CARDINALITY = 10000; // allow:raw-byte-literal — cardinality cap, not bytes
|
|
1141
|
+
function shadowRegistry(opts) {
|
|
1142
|
+
if (!opts || typeof opts !== "object") {
|
|
1143
|
+
throw new MetricsError("metrics-shadow/bad-opts",
|
|
1144
|
+
"shadowRegistry: opts object required");
|
|
1145
|
+
}
|
|
1146
|
+
validateOpts.requireNonEmptyString(opts.namespace,
|
|
1147
|
+
"shadowRegistry: opts.namespace", MetricsError, "metrics-shadow/bad-namespace");
|
|
1148
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(opts.namespace)) { // allow:regex-no-length-cap — OpenMetrics name-shape, length-bounded by namespace
|
|
1149
|
+
throw new MetricsError("metrics-shadow/bad-namespace",
|
|
1150
|
+
"shadowRegistry: namespace must match [a-zA-Z_][a-zA-Z0-9_]*");
|
|
1151
|
+
}
|
|
1152
|
+
var counterSet = _shadowSetOf(opts.counters, "counters");
|
|
1153
|
+
var gaugeSet = _shadowSetOf(opts.gauges, "gauges");
|
|
1154
|
+
var infoSet = _shadowSetOf(opts.info, "info");
|
|
1155
|
+
var cap = opts.cardinalityCap === undefined ? SHADOW_DEFAULT_CARDINALITY : opts.cardinalityCap;
|
|
1156
|
+
if (typeof cap !== "number" || !isFinite(cap) || cap < 1 || Math.floor(cap) !== cap) {
|
|
1157
|
+
throw new MetricsError("metrics-shadow/bad-cap",
|
|
1158
|
+
"shadowRegistry: cardinalityCap must be a positive integer");
|
|
1159
|
+
}
|
|
1160
|
+
var policy = opts.onCardinalityExceeded || "drop";
|
|
1161
|
+
if (policy !== "drop" && policy !== "audit-only" && policy !== "refuse") {
|
|
1162
|
+
throw new MetricsError("metrics-shadow/bad-policy",
|
|
1163
|
+
"shadowRegistry: onCardinalityExceeded must be 'drop', 'audit-only', or 'refuse'");
|
|
1164
|
+
}
|
|
1165
|
+
var counters = Object.create(null);
|
|
1166
|
+
var gauges = Object.create(null);
|
|
1167
|
+
var info = Object.create(null);
|
|
1168
|
+
var lastCardinalityAuditMs = 0;
|
|
1169
|
+
|
|
1170
|
+
function _cardinalityHit(metric) {
|
|
1171
|
+
var now = Date.now();
|
|
1172
|
+
// Rate-limit cardinality audit emissions to once per second per
|
|
1173
|
+
// shadow registry so a hostile label flood doesn't fan out into
|
|
1174
|
+
// the audit log.
|
|
1175
|
+
if (now - lastCardinalityAuditMs >= C.TIME.seconds(1)) {
|
|
1176
|
+
lastCardinalityAuditMs = now;
|
|
1177
|
+
try {
|
|
1178
|
+
require("./audit").safeEmit({
|
|
1179
|
+
action: "metrics.shadow.cardinality_dropped",
|
|
1180
|
+
outcome: policy === "refuse" ? "denied" : "denied",
|
|
1181
|
+
metadata: { namespace: opts.namespace, metric: metric, cap: cap, policy: policy },
|
|
1182
|
+
});
|
|
1183
|
+
} catch (_e) { /* drop-silent */ }
|
|
1184
|
+
}
|
|
1185
|
+
if (policy === "refuse") {
|
|
1186
|
+
throw new MetricsError("metrics-shadow/cardinality-exceeded",
|
|
1187
|
+
"shadowRegistry.inc/set: '" + metric + "' cardinality exceeds cap=" + cap);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function _labelKey(labels) {
|
|
1192
|
+
if (!labels || typeof labels !== "object") return "";
|
|
1193
|
+
var keys = Object.keys(labels).sort(); // allow:bare-canonicalize-walk — label-set canonicalization for cardinality keying
|
|
1194
|
+
var parts = [];
|
|
1195
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
1196
|
+
parts.push(keys[i] + "=" + String(labels[keys[i]]));
|
|
1197
|
+
}
|
|
1198
|
+
return parts.join(",");
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function inc(name, labels) {
|
|
1202
|
+
if (!counterSet[name]) return;
|
|
1203
|
+
var lk = _labelKey(labels);
|
|
1204
|
+
if (!counters[name]) counters[name] = Object.create(null);
|
|
1205
|
+
var current = counters[name][lk];
|
|
1206
|
+
if (current === undefined) {
|
|
1207
|
+
if (Object.keys(counters[name]).length >= cap) {
|
|
1208
|
+
_cardinalityHit(name);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
counters[name][lk] = 1;
|
|
1212
|
+
} else {
|
|
1213
|
+
counters[name][lk] = current + 1;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function set(name, value, labels) {
|
|
1218
|
+
if (!gaugeSet[name]) return;
|
|
1219
|
+
if (typeof value !== "number" || !isFinite(value)) {
|
|
1220
|
+
throw new MetricsError("metrics-shadow/bad-gauge-value",
|
|
1221
|
+
"shadowRegistry.set: '" + name + "' value must be a finite number");
|
|
1222
|
+
}
|
|
1223
|
+
var lk = _labelKey(labels);
|
|
1224
|
+
if (!gauges[name]) gauges[name] = Object.create(null);
|
|
1225
|
+
if (gauges[name][lk] === undefined && Object.keys(gauges[name]).length >= cap) {
|
|
1226
|
+
_cardinalityHit(name);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
gauges[name][lk] = value;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function setInfo(name, value) {
|
|
1233
|
+
if (!infoSet[name]) return;
|
|
1234
|
+
info[name] = value;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function snapshotShadow() {
|
|
1238
|
+
return Object.freeze({
|
|
1239
|
+
namespace: opts.namespace,
|
|
1240
|
+
counters: _shallowClone(counters),
|
|
1241
|
+
gauges: _shallowClone(gauges),
|
|
1242
|
+
info: Object.assign({}, info),
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function renderShadow(renderOpts) {
|
|
1247
|
+
renderOpts = renderOpts || {};
|
|
1248
|
+
var format = renderOpts.format || "text";
|
|
1249
|
+
// Prometheus / OpenMetrics — emit labeled metric lines directly so
|
|
1250
|
+
// counters / gauges with label sets survive the export. Routing
|
|
1251
|
+
// through `snapshotRender` would have filtered synthetic
|
|
1252
|
+
// `name{labelKey=value}` field names against the Prometheus
|
|
1253
|
+
// metric-name shape `[a-zA-Z_][a-zA-Z0-9_]*` and dropped them all.
|
|
1254
|
+
if (format === "prometheus" || format === "openmetrics") {
|
|
1255
|
+
var out = [];
|
|
1256
|
+
var prefix = opts.namespace;
|
|
1257
|
+
function _emitLabeled(name, labelMap, kind) {
|
|
1258
|
+
var metric = prefix + "_" + name;
|
|
1259
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(metric)) return; // allow:regex-no-length-cap — Prometheus name-shape; metric length bounded by namespace + name caps
|
|
1260
|
+
out.push("# TYPE " + metric + " " + kind);
|
|
1261
|
+
var lks = Object.keys(labelMap);
|
|
1262
|
+
for (var li = 0; li < lks.length; li += 1) {
|
|
1263
|
+
var lk = lks[li];
|
|
1264
|
+
if (lk === "") { out.push(metric + " " + labelMap[lk]); continue; }
|
|
1265
|
+
// The label-key string was assembled by `_labelKey` from a
|
|
1266
|
+
// single shadow-registry call's `labels` object — values
|
|
1267
|
+
// are framework-internal (operator code that supplied them
|
|
1268
|
+
// is bounded by guards upstream); split on `,` is safe.
|
|
1269
|
+
// Not a header-value parse (which would need a quoted-
|
|
1270
|
+
// string aware split per RFC 9110).
|
|
1271
|
+
var lpairs = lk.split(","); // allow:bare-split-on-quoted-header — framework-internal label-key (assembled by _labelKey), not an HTTP header parse
|
|
1272
|
+
var formatted = [];
|
|
1273
|
+
for (var pi = 0; pi < lpairs.length; pi += 1) {
|
|
1274
|
+
var eqIdx = lpairs[pi].indexOf("=");
|
|
1275
|
+
if (eqIdx === -1) continue;
|
|
1276
|
+
var lname = lpairs[pi].slice(0, eqIdx);
|
|
1277
|
+
var lvalue = lpairs[pi].slice(eqIdx + 1);
|
|
1278
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lname)) continue; // allow:regex-no-length-cap — Prometheus label-name shape
|
|
1279
|
+
// Prometheus exposition: escape `\`, `"`, `\n` in label values.
|
|
1280
|
+
lvalue = String(lvalue).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
|
|
1281
|
+
formatted.push(lname + '="' + lvalue + '"');
|
|
1282
|
+
}
|
|
1283
|
+
out.push(metric + "{" + formatted.join(",") + "} " + labelMap[lk]);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
var cn2 = Object.keys(counters);
|
|
1287
|
+
for (var ci = 0; ci < cn2.length; ci += 1) {
|
|
1288
|
+
_emitLabeled(cn2[ci], counters[cn2[ci]], /_total$/.test(cn2[ci]) ? "counter" : "gauge"); // allow:regex-no-length-cap — name-suffix check
|
|
1289
|
+
}
|
|
1290
|
+
var gn2 = Object.keys(gauges);
|
|
1291
|
+
for (var ggi = 0; ggi < gn2.length; ggi += 1) {
|
|
1292
|
+
_emitLabeled(gn2[ggi], gauges[gn2[ggi]], "gauge");
|
|
1293
|
+
}
|
|
1294
|
+
return out.join("\n") + (out.length ? "\n" : "");
|
|
1295
|
+
}
|
|
1296
|
+
// Text format — route through snapshotRender via synthetic field
|
|
1297
|
+
// names. The text-format renderer accepts arbitrary field names so
|
|
1298
|
+
// labeled series survive here.
|
|
1299
|
+
var snap = { writtenAt: new Date().toISOString(), fields: {} };
|
|
1300
|
+
var cn = Object.keys(counters);
|
|
1301
|
+
for (var i = 0; i < cn.length; i += 1) {
|
|
1302
|
+
var labels = counters[cn[i]];
|
|
1303
|
+
var labelKeys = Object.keys(labels);
|
|
1304
|
+
if (labelKeys.length === 1 && labelKeys[0] === "") {
|
|
1305
|
+
snap.fields[cn[i]] = labels[""];
|
|
1306
|
+
} else {
|
|
1307
|
+
for (var j = 0; j < labelKeys.length; j += 1) {
|
|
1308
|
+
var key = labelKeys[j] === "" ? cn[i] : cn[i] + "{" + labelKeys[j] + "}";
|
|
1309
|
+
snap.fields[key] = labels[labelKeys[j]];
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
var gn = Object.keys(gauges);
|
|
1314
|
+
for (var gi = 0; gi < gn.length; gi += 1) {
|
|
1315
|
+
var glabels = gauges[gn[gi]];
|
|
1316
|
+
var glk = Object.keys(glabels);
|
|
1317
|
+
if (glk.length === 1 && glk[0] === "") {
|
|
1318
|
+
snap.fields[gn[gi]] = glabels[""];
|
|
1319
|
+
} else {
|
|
1320
|
+
for (var gj = 0; gj < glk.length; gj += 1) {
|
|
1321
|
+
var gkey = glk[gj] === "" ? gn[gi] : gn[gi] + "{" + glk[gj] + "}";
|
|
1322
|
+
snap.fields[gkey] = glabels[glk[gj]];
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
var inames = Object.keys(info);
|
|
1327
|
+
for (var ii = 0; ii < inames.length; ii += 1) snap.fields[inames[ii]] = info[inames[ii]];
|
|
1328
|
+
return snapshotRender(snap, Object.assign({ prefix: opts.namespace }, renderOpts));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function reset() {
|
|
1332
|
+
counters = Object.create(null);
|
|
1333
|
+
gauges = Object.create(null);
|
|
1334
|
+
info = Object.create(null);
|
|
1335
|
+
lastCardinalityAuditMs = 0;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return {
|
|
1339
|
+
inc: inc,
|
|
1340
|
+
set: set,
|
|
1341
|
+
setInfo: setInfo,
|
|
1342
|
+
snapshot: snapshotShadow,
|
|
1343
|
+
render: renderShadow,
|
|
1344
|
+
reset: reset,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function _shadowSetOf(arr, label) {
|
|
1349
|
+
if (arr === undefined) return Object.create(null);
|
|
1350
|
+
if (!Array.isArray(arr)) {
|
|
1351
|
+
throw new MetricsError("metrics-shadow/bad-" + label,
|
|
1352
|
+
"shadowRegistry: opts." + label + " must be an array of metric names");
|
|
1353
|
+
}
|
|
1354
|
+
var set = Object.create(null);
|
|
1355
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
1356
|
+
if (typeof arr[i] !== "string" || arr[i].length === 0) {
|
|
1357
|
+
throw new MetricsError("metrics-shadow/bad-" + label,
|
|
1358
|
+
"shadowRegistry: opts." + label + "[" + i + "] must be a non-empty string");
|
|
1359
|
+
}
|
|
1360
|
+
set[arr[i]] = true;
|
|
1361
|
+
}
|
|
1362
|
+
return set;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function _shallowClone(obj) {
|
|
1366
|
+
var out = Object.create(null);
|
|
1367
|
+
var keys = Object.keys(obj);
|
|
1368
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
1369
|
+
out[keys[i]] = Object.assign(Object.create(null), obj[keys[i]]);
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1041
1374
|
var snapshot = {
|
|
1042
|
-
startWriter:
|
|
1043
|
-
read:
|
|
1044
|
-
render:
|
|
1375
|
+
startWriter: snapshotStartWriter,
|
|
1376
|
+
read: snapshotRead,
|
|
1377
|
+
render: snapshotRender,
|
|
1378
|
+
shadowRegistry: shadowRegistry,
|
|
1045
1379
|
};
|
|
1046
1380
|
|
|
1047
1381
|
module.exports = {
|
package/lib/pqc-agent.js
CHANGED
|
@@ -32,6 +32,10 @@ var C = require("./constants");
|
|
|
32
32
|
var lazyRequire = require("./lazy-require");
|
|
33
33
|
var networkTls = require("./network-tls");
|
|
34
34
|
var safeBuffer = require("./safe-buffer");
|
|
35
|
+
var validateOpts = require("./validate-opts");
|
|
36
|
+
var { defineClass } = require("./framework-error");
|
|
37
|
+
|
|
38
|
+
var PqcAgentError = defineClass("PqcAgentError", { alwaysPermanent: true });
|
|
35
39
|
|
|
36
40
|
// audit imports crypto/handlers transitively — lazy to avoid load
|
|
37
41
|
// cycles when pqc-agent is required during framework bootstrap.
|
|
@@ -179,7 +183,72 @@ function _buildAgentOpts(opts) {
|
|
|
179
183
|
* req.end();
|
|
180
184
|
*/
|
|
181
185
|
function create(opts) {
|
|
182
|
-
|
|
186
|
+
var built = _buildAgentOpts(opts);
|
|
187
|
+
var agent = new https.Agent(built);
|
|
188
|
+
agent._builtOpts = built;
|
|
189
|
+
// Per-instance cert rotation. The pre-v0.10.9 path required process
|
|
190
|
+
// restart for cert rotation on agents built via explicit `create()`
|
|
191
|
+
// (only the framework's lazy default had `b.pqcAgent.reload()`).
|
|
192
|
+
// Attach `reloadCerts` so long-running daemons can pivot in place.
|
|
193
|
+
agent.reloadCerts = function (newMaterial) {
|
|
194
|
+
return _reloadCertsOnAgent(agent, opts, newMaterial);
|
|
195
|
+
};
|
|
196
|
+
return agent;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _reloadCertsOnAgent(agent, originalOpts, newMaterial) {
|
|
200
|
+
validateOpts.requireObject(newMaterial, "agent.reloadCerts",
|
|
201
|
+
PqcAgentError, "pqcagent/reload-bad-opts");
|
|
202
|
+
if (typeof newMaterial.cert !== "string" || newMaterial.cert.length === 0 ||
|
|
203
|
+
typeof newMaterial.key !== "string" || newMaterial.key.length === 0) {
|
|
204
|
+
throw new PqcAgentError("pqcagent/reload-missing-material",
|
|
205
|
+
"agent.reloadCerts: both cert and key are required (non-empty PEM strings)");
|
|
206
|
+
}
|
|
207
|
+
// Compound on the AGENT's last-known-good builtOpts (which start as
|
|
208
|
+
// the create-time opts but are updated on each successful reload).
|
|
209
|
+
// A sequence like "reload with new ca once, then reload only
|
|
210
|
+
// cert/key" preserves the new ca because the previous successful
|
|
211
|
+
// reload wrote it into agent._builtOpts.
|
|
212
|
+
var nextOpts = Object.assign({}, agent._builtOpts, {
|
|
213
|
+
cert: newMaterial.cert,
|
|
214
|
+
key: newMaterial.key,
|
|
215
|
+
});
|
|
216
|
+
if (newMaterial.ca !== undefined) nextOpts.ca = newMaterial.ca;
|
|
217
|
+
var t0 = Date.now();
|
|
218
|
+
try {
|
|
219
|
+
// tls.createSecureContext throws on mismatched cert/key — surface
|
|
220
|
+
// as a typed framework error with the underlying OpenSSL chain.
|
|
221
|
+
require("node:tls").createSecureContext({ // allow:inline-require — node:tls only needed during cert rotation (a non-hot path); a top-level require would pull TLS into the boot graph of every process that never reaches reloadCerts
|
|
222
|
+
cert: nextOpts.cert,
|
|
223
|
+
key: nextOpts.key,
|
|
224
|
+
ca: nextOpts.ca,
|
|
225
|
+
});
|
|
226
|
+
} catch (e) {
|
|
227
|
+
var errMsg = (e && e.message) ? e.message : String(e);
|
|
228
|
+
if (/ca\b/i.test(errMsg)) { // allow:regex-no-length-cap — error-message shape match; error text owned by Node, not adversarial input
|
|
229
|
+
throw new PqcAgentError("pqcagent/reload-bad-ca",
|
|
230
|
+
"agent.reloadCerts: ca bundle failed to parse: " + errMsg);
|
|
231
|
+
}
|
|
232
|
+
throw new PqcAgentError("pqcagent/reload-mismatch",
|
|
233
|
+
"agent.reloadCerts: cert/key mismatch or malformed PEM (" + errMsg + ")");
|
|
234
|
+
}
|
|
235
|
+
agent.options = Object.assign({}, agent.options, {
|
|
236
|
+
cert: nextOpts.cert,
|
|
237
|
+
key: nextOpts.key,
|
|
238
|
+
ca: nextOpts.ca,
|
|
239
|
+
});
|
|
240
|
+
agent._builtOpts = nextOpts;
|
|
241
|
+
// Close idle keep-alive sockets so the next request uses the new
|
|
242
|
+
// material. In-flight sockets complete naturally.
|
|
243
|
+
try { agent.destroy(); } catch (_e) { /* best-effort */ }
|
|
244
|
+
try {
|
|
245
|
+
audit.safeEmit({
|
|
246
|
+
action: "pqcagent.reloadCerts",
|
|
247
|
+
outcome: "success",
|
|
248
|
+
metadata: { durationMs: Date.now() - t0 },
|
|
249
|
+
});
|
|
250
|
+
} catch (_e2) { /* drop-silent */ }
|
|
251
|
+
return { reloaded: true, durationMs: Date.now() - t0 };
|
|
183
252
|
}
|
|
184
253
|
|
|
185
254
|
/**
|