@blamejs/core 0.14.6 → 0.14.7
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/README.md +3 -2
- package/lib/agent-event-bus.js +4 -4
- package/lib/agent-idempotency.js +6 -6
- package/lib/agent-orchestrator.js +9 -9
- package/lib/agent-posture-chain.js +10 -10
- package/lib/agent-saga.js +6 -7
- package/lib/agent-snapshot.js +8 -8
- package/lib/agent-stream.js +3 -3
- package/lib/agent-tenant.js +4 -4
- package/lib/agent-trace.js +5 -5
- package/lib/ai-disclosure.js +3 -3
- package/lib/app.js +2 -2
- package/lib/archive-read.js +1 -1
- package/lib/archive-tar-read.js +1 -1
- package/lib/archive-wrap.js +5 -5
- package/lib/audit-tools.js +65 -5
- package/lib/audit.js +2 -2
- package/lib/auth/ciba.js +1 -1
- package/lib/auth/dpop.js +1 -1
- package/lib/auth/fal.js +1 -1
- package/lib/auth/fido-mds3.js +2 -3
- package/lib/auth/jwt-external.js +2 -2
- package/lib/auth/oauth.js +9 -9
- package/lib/auth/oid4vci.js +7 -7
- package/lib/auth/oid4vp.js +1 -1
- package/lib/auth/openid-federation.js +5 -5
- package/lib/auth/passkey.js +6 -6
- package/lib/auth/saml.js +1 -1
- package/lib/auth/sd-jwt-vc.js +3 -6
- package/lib/backup/index.js +18 -18
- package/lib/cache.js +4 -4
- package/lib/calendar.js +5 -5
- package/lib/circuit-breaker.js +1 -1
- package/lib/cms-codec.js +2 -2
- package/lib/compliance.js +14 -14
- package/lib/crypto-field.js +58 -21
- package/lib/crypto.js +5 -6
- package/lib/db-query.js +131 -9
- package/lib/db.js +106 -22
- package/lib/external-db.js +64 -16
- package/lib/framework-schema.js +4 -4
- package/lib/guard-list-id.js +2 -2
- package/lib/guard-list-unsubscribe.js +1 -2
- package/lib/incident-report.js +150 -0
- package/lib/mail-crypto-smime.js +1 -1
- package/lib/mail-deploy.js +3 -3
- package/lib/mail-server-managesieve.js +2 -2
- package/lib/mail-server-pop3.js +2 -2
- package/lib/mail-store.js +1 -1
- package/lib/metrics.js +8 -8
- package/lib/middleware/csrf-protect.js +1 -1
- package/lib/middleware/dpop.js +5 -5
- package/lib/middleware/idempotency-key.js +21 -22
- package/lib/middleware/protected-resource-metadata.js +2 -2
- package/lib/network-dns-resolver.js +2 -2
- package/lib/network-dns.js +1 -2
- package/lib/network-tls.js +0 -1
- package/lib/outbox.js +1 -1
- package/lib/pqc-agent.js +1 -1
- package/lib/retention.js +1 -1
- package/lib/retry.js +1 -1
- package/lib/safe-archive.js +2 -2
- package/lib/safe-ical.js +2 -2
- package/lib/safe-mime.js +1 -1
- package/lib/self-update-standalone-verifier.js +1 -1
- package/lib/self-update.js +2 -2
- package/lib/static.js +1 -1
- package/lib/subject.js +2 -2
- package/lib/vault/index.js +64 -1
- package/lib/vault/rotate.js +19 -0
- package/lib/vendor-data.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/external-db.js
CHANGED
|
@@ -57,7 +57,7 @@ function _emitMetric(name, value, labels) {
|
|
|
57
57
|
catch (_e) { /* hot-path observability sink — drop silent by design */ }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Statement-class classifier for auth-failure forensics
|
|
60
|
+
// Statement-class classifier for auth-failure forensics. Inspects
|
|
61
61
|
// the leading keyword only so an attacker-controlled trailing fragment
|
|
62
62
|
// can't smuggle a false classification. Skips leading whitespace plus
|
|
63
63
|
// SQL line / block comments before reading the keyword.
|
|
@@ -83,8 +83,49 @@ function _classifyStatement(sql) {
|
|
|
83
83
|
return _STATEMENT_CLASS_MAP[m[1].toUpperCase()] || "OTHER";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Best-effort target-relation extractor for auth-failure forensics: the
|
|
87
|
+
// table the denied role attempted to touch, so the audit row records
|
|
88
|
+
// the OBJECT (SOC2 CC7.2 / NIST SP 800-53 AU-3 "what was accessed"),
|
|
89
|
+
// not just the statement class. Defensive reader — returns null on
|
|
90
|
+
// anything unparseable and NEVER throws: it runs in the live failure
|
|
91
|
+
// path and must not mask the real 28000 / 42501 error. The extracted
|
|
92
|
+
// identifier is captured into audit METADATA (a JSON string, never
|
|
93
|
+
// re-executed as SQL), so the only sink risk is log-injection: any
|
|
94
|
+
// segment carrying a control / NUL character is refused. Spaces and
|
|
95
|
+
// ordinary punctuation inside a quoted identifier are kept so a
|
|
96
|
+
// legitimately-quoted relation name still surfaces in the audit row.
|
|
97
|
+
var _RELATION_RE = /\b(?:FROM|INTO|UPDATE|JOIN|TABLE|COPY)\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)/ig;
|
|
98
|
+
function _hasControlChar(s) {
|
|
99
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
100
|
+
var c = s.charCodeAt(i);
|
|
101
|
+
if (c < 0x20 || c === 0x7f) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
function _extractTargetRelation(sql) {
|
|
106
|
+
if (typeof sql !== "string" || sql.length === 0) return null;
|
|
107
|
+
var clean = sql.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
108
|
+
_RELATION_RE.lastIndex = 0;
|
|
109
|
+
var m = _RELATION_RE.exec(clean);
|
|
110
|
+
if (!m) return null;
|
|
111
|
+
var segs = m[1].split(".").map(function (s) { return s.replace(/^["`]|["`]$/g, ""); });
|
|
112
|
+
for (var i = 0; i < segs.length; i += 1) {
|
|
113
|
+
if (segs[i].length === 0 || _hasControlChar(segs[i])) return null;
|
|
114
|
+
}
|
|
115
|
+
return segs.join(".");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _countTargetRelations(sql) {
|
|
119
|
+
if (typeof sql !== "string") return 0;
|
|
120
|
+
var clean = sql.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
121
|
+
_RELATION_RE.lastIndex = 0;
|
|
122
|
+
var n = 0;
|
|
123
|
+
while (_RELATION_RE.exec(clean) !== null) n += 1;
|
|
124
|
+
return n;
|
|
125
|
+
}
|
|
126
|
+
|
|
86
127
|
// Postgres SQLSTATE classes that indicate authentication / authorization
|
|
87
|
-
// failure at the DB level. SOC2 forensic gap
|
|
128
|
+
// failure at the DB level. SOC2 forensic gap — every match emits
|
|
88
129
|
// db.auth.failed with the SQL identity attempted, the database, and
|
|
89
130
|
// the statement class.
|
|
90
131
|
var _AUTH_FAILURE_CODES = Object.freeze({
|
|
@@ -97,18 +138,24 @@ function _emitAuthFailureAudit(backend, role, sql, e) {
|
|
|
97
138
|
if (!e || !e.code) return;
|
|
98
139
|
var kind = _AUTH_FAILURE_CODES[e.code];
|
|
99
140
|
if (!kind) return;
|
|
141
|
+
var attemptedTable = _extractTargetRelation(sql);
|
|
142
|
+
var relationCount = _countTargetRelations(sql);
|
|
143
|
+
var resource = { kind: "db.backend", id: backend.name };
|
|
144
|
+
if (attemptedTable !== null) resource.attemptedTable = attemptedTable;
|
|
100
145
|
audit().safeEmit({
|
|
101
146
|
action: "db.auth.failed",
|
|
102
147
|
actor: {},
|
|
103
|
-
resource:
|
|
148
|
+
resource: resource,
|
|
104
149
|
outcome: "denied",
|
|
105
150
|
reason: kind,
|
|
106
151
|
metadata: {
|
|
107
|
-
backend:
|
|
108
|
-
dialect:
|
|
109
|
-
sqlIdentity:
|
|
110
|
-
sqlstate:
|
|
111
|
-
statementClass:
|
|
152
|
+
backend: backend.name,
|
|
153
|
+
dialect: backend.dialect,
|
|
154
|
+
sqlIdentity: role || null,
|
|
155
|
+
sqlstate: e.code,
|
|
156
|
+
statementClass: _classifyStatement(sql),
|
|
157
|
+
attemptedTable: attemptedTable,
|
|
158
|
+
attemptedRelationCount: relationCount,
|
|
112
159
|
},
|
|
113
160
|
});
|
|
114
161
|
_emitMetric("db.auth.failed", 1, {
|
|
@@ -118,7 +165,7 @@ function _emitAuthFailureAudit(backend, role, sql, e) {
|
|
|
118
165
|
});
|
|
119
166
|
}
|
|
120
167
|
|
|
121
|
-
// Slow-query bucket emitter
|
|
168
|
+
// Slow-query bucket emitter. Single-shot per query — highest
|
|
122
169
|
// matched bucket wins. Operators dashboard on the `bucket` label
|
|
123
170
|
// rather than separate counters per threshold.
|
|
124
171
|
var _SLOW_QUERY_BUCKETS = Object.freeze([
|
|
@@ -628,7 +675,7 @@ async function query(sql, params, opts) {
|
|
|
628
675
|
_emitMetric("db.role.denied", 1,
|
|
629
676
|
{ backend: b.name, role: role || "(none)" });
|
|
630
677
|
}
|
|
631
|
-
//
|
|
678
|
+
// DB-auth audit visibility. Every 28000 / 28P01 / 42501
|
|
632
679
|
// surfaces an auditable db.auth.failed row tagged with the SQL
|
|
633
680
|
// identity and the statement class so SOC2 reviewers can
|
|
634
681
|
// reconstruct the denial timeline.
|
|
@@ -693,13 +740,13 @@ async function transaction(fn, opts) {
|
|
|
693
740
|
var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
|
|
694
741
|
|
|
695
742
|
var t0 = Date.now();
|
|
696
|
-
//
|
|
697
|
-
// the query-cancel ceiling to this transaction;
|
|
743
|
+
// Per-statement timeout. SET LOCAL statement_timeout binds
|
|
744
|
+
// the query-cancel ceiling to this transaction; this wires
|
|
698
745
|
// idle_in_transaction_session_timeout from the same opt. Both
|
|
699
746
|
// emit at SET LOCAL scope so the next pool checkout starts clean.
|
|
700
747
|
var stmtTimeoutMs = opts.statementTimeoutMs;
|
|
701
748
|
var idleTimeoutMs = opts.idleInTransactionTimeoutMs;
|
|
702
|
-
//
|
|
749
|
+
// Deadlock-retry policy. 40P01 (deadlock_detected) and 40001
|
|
703
750
|
// (serialization_failure) are transient — retry with capped attempts
|
|
704
751
|
// and a small jittered backoff. Operators tune retries via opts.deadlockRetries (default 3).
|
|
705
752
|
// numeric-bounds doesn't have a non-negative-int helper; use a
|
|
@@ -771,7 +818,7 @@ async function transaction(fn, opts) {
|
|
|
771
818
|
_emitMetric("db.role.denied", 1,
|
|
772
819
|
{ backend: b.name, role: role || "(none)" });
|
|
773
820
|
}
|
|
774
|
-
//
|
|
821
|
+
// DB-auth audit visibility on transaction-shaped denials.
|
|
775
822
|
// Statement class always reads as "TX" since the failure
|
|
776
823
|
// surface inside a transaction body could be any statement;
|
|
777
824
|
// operators correlate via the transaction's audit row.
|
|
@@ -1017,7 +1064,7 @@ function _requireInit() {
|
|
|
1017
1064
|
|
|
1018
1065
|
var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
|
|
1019
1066
|
|
|
1020
|
-
//
|
|
1067
|
+
// Replica residency-tag compatibility.
|
|
1021
1068
|
//
|
|
1022
1069
|
// A primary tagged "EU" replicating to a "US" replica is a GDPR
|
|
1023
1070
|
// Article 46 cross-border transfer; without an explicit operator
|
|
@@ -1194,7 +1241,7 @@ async function _readQuery(sql, params, opts) {
|
|
|
1194
1241
|
_emitMetric("db.role.denied", 1,
|
|
1195
1242
|
{ backend: b.name, role: role || "(none)" });
|
|
1196
1243
|
}
|
|
1197
|
-
//
|
|
1244
|
+
// DB-auth audit visibility for read-replica denials too.
|
|
1198
1245
|
_emitAuthFailureAudit(b, role, sql, e);
|
|
1199
1246
|
// Fallback to primary on a failed replica read when allowed.
|
|
1200
1247
|
if (b.replicaFallbackToPrimary) {
|
|
@@ -1874,4 +1921,5 @@ module.exports = {
|
|
|
1874
1921
|
migrate: externalDbMigrate,
|
|
1875
1922
|
Pool: Pool,
|
|
1876
1923
|
_resetForTest: _resetForTest,
|
|
1924
|
+
_extractTargetRelation: _extractTargetRelation,
|
|
1877
1925
|
};
|
package/lib/framework-schema.js
CHANGED
|
@@ -648,8 +648,8 @@ function _breakGlassPoliciesDDL(dialect) {
|
|
|
648
648
|
}
|
|
649
649
|
|
|
650
650
|
// _blamejs_break_glass_grants — issued grants. One row per successful
|
|
651
|
-
// step-up. Default maxRowsPerGrant=1 enforces row-by-row auth
|
|
652
|
-
//
|
|
651
|
+
// step-up. Default maxRowsPerGrant=1 enforces row-by-row auth
|
|
652
|
+
// ("each row access = its own grant").
|
|
653
653
|
function _breakGlassGrantsDDL(dialect) {
|
|
654
654
|
var t = _types(dialect);
|
|
655
655
|
var name = LOCAL_TO_EXTERNAL._blamejs_break_glass_grants;
|
|
@@ -766,7 +766,7 @@ async function ensureSchema(opts) {
|
|
|
766
766
|
created.push(d.create.match(/CREATE TABLE IF NOT EXISTS\s+(\S+)/)[1]);
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
-
//
|
|
769
|
+
// Append-only WORM enforcement on audit_log / consent_log /
|
|
770
770
|
// audit_checkpoints in cluster mode. Local-SQLite path already
|
|
771
771
|
// installs CREATE TRIGGER IF NOT EXISTS via lib/db.js's
|
|
772
772
|
// _installAppendOnlyTriggers; Postgres needs equivalent rules
|
|
@@ -779,7 +779,7 @@ async function ensureSchema(opts) {
|
|
|
779
779
|
return { tables: created };
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
-
//
|
|
782
|
+
// WORM enforcement helper. Idempotent: rebuilding triggers
|
|
783
783
|
// per boot is cheap and any operator-applied DROP TRIGGER is restored
|
|
784
784
|
// at the next ensureSchema pass.
|
|
785
785
|
async function _installWormTriggers(backend, dialect) {
|
package/lib/guard-list-id.js
CHANGED
|
@@ -203,8 +203,8 @@ function validate(headerValue, opts) {
|
|
|
203
203
|
// recover the boundary without Public Suffix List awareness
|
|
204
204
|
// (`team.example.com` could be label=team / ns=example.com OR
|
|
205
205
|
// label=team.example / ns=com). The earlier last-2-segment
|
|
206
|
-
// heuristic produced empty `label` for 2-label IDs
|
|
207
|
-
//
|
|
206
|
+
// heuristic produced empty `label` for 2-label IDs
|
|
207
|
+
// which violates RFC 2919 §2's required label "."
|
|
208
208
|
// namespace decomposition.
|
|
209
209
|
//
|
|
210
210
|
// Drop the heuristic split — surface only the raw `listId` (and
|
|
@@ -349,8 +349,7 @@ function _extractUris(raw, maxUris) {
|
|
|
349
349
|
// bracket pairs directly via String.matchAll so URIs containing
|
|
350
350
|
// commas (legitimate, e.g. `<https://x/u?tags=a,b>`) parse
|
|
351
351
|
// correctly. Earlier split(",")-based scan misclassified such
|
|
352
|
-
// URIs as "no <URI> elements" and refused legitimate mail
|
|
353
|
-
// (Codex P1 on PR #63).
|
|
352
|
+
// URIs as "no <URI> elements" and refused legitimate mail.
|
|
354
353
|
var matches = raw.matchAll(/<([^<>]*)>/g); // allow:regex-no-length-cap — input length-bounded by maxBytes check upstream
|
|
355
354
|
var uris = [];
|
|
356
355
|
for (var m of matches) {
|
package/lib/incident-report.js
CHANGED
|
@@ -305,8 +305,158 @@ function create(opts) {
|
|
|
305
305
|
};
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Breach detection -> notification running clock. The reporter
|
|
309
|
+
// (`create`) computes the static per-stage deadlines; this clock turns
|
|
310
|
+
// them into a live escalation loop: it tracks open incident records and,
|
|
311
|
+
// on each tick, fires "approaching" warnings as a stage's deadline nears
|
|
312
|
+
// and a "passed" alert when it elapses — once per (incident, stage,
|
|
313
|
+
// state) so a busy tick interval can't storm the operator. It re-uses
|
|
314
|
+
// the reporter's REGIME_DEADLINES / dueBy timestamps and re-encodes no
|
|
315
|
+
// jurisdiction hour-counts (GDPR Art.33 72h, NIS2 Art.23(4) 24h, DORA
|
|
316
|
+
// Art.19 + RTS 2024/1772 4h, CRA Art.14, HIPAA 45 CFR 164.404/408).
|
|
317
|
+
// `approachThresholds` are unitless proportions of detected-to-due.
|
|
318
|
+
function createDeadlineClock(opts) {
|
|
319
|
+
opts = opts || {};
|
|
320
|
+
validateOpts(opts, [
|
|
321
|
+
"audit", "notify", "approachThresholds", "intervalMs", "autoStart", "now",
|
|
322
|
+
], "incident.report.createDeadlineClock");
|
|
323
|
+
|
|
324
|
+
var auditOn = opts.audit !== false;
|
|
325
|
+
var notify = (opts.notify && typeof opts.notify.send === "function") ? opts.notify : null;
|
|
326
|
+
var thresholds = Array.isArray(opts.approachThresholds) ? opts.approachThresholds.slice() : [0.5, 0.75, 0.9];
|
|
327
|
+
for (var ti = 0; ti < thresholds.length; ti += 1) {
|
|
328
|
+
if (typeof thresholds[ti] !== "number" || !(thresholds[ti] > 0 && thresholds[ti] < 1)) {
|
|
329
|
+
throw new IncidentReportError("incident-report/bad-threshold",
|
|
330
|
+
"createDeadlineClock: approachThresholds must be numbers strictly between 0 and 1");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
thresholds.sort(function (a, b) { return a - b; });
|
|
334
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
335
|
+
var intervalMs = (typeof opts.intervalMs === "number" && isFinite(opts.intervalMs) && opts.intervalMs > 0)
|
|
336
|
+
? opts.intervalMs : C.TIME.minutes(1);
|
|
337
|
+
var autoStart = opts.autoStart !== false;
|
|
338
|
+
|
|
339
|
+
var tracked = new Map(); // incidentId -> { detectedAt, dueBy, regime, acked, fired }
|
|
340
|
+
var timer = null;
|
|
341
|
+
|
|
342
|
+
function _emit(action, outcome, metadata) {
|
|
343
|
+
if (!auditOn) return;
|
|
344
|
+
try {
|
|
345
|
+
audit().safeEmit({ action: "incident.report.clock." + action, outcome: outcome, metadata: metadata || {} });
|
|
346
|
+
} catch (_e) { /* drop-silent — by design */ }
|
|
347
|
+
}
|
|
348
|
+
function _notify(payload) {
|
|
349
|
+
if (!notify) return;
|
|
350
|
+
try {
|
|
351
|
+
var r = notify.send(payload);
|
|
352
|
+
if (r && typeof r.then === "function") r.then(null, function () {});
|
|
353
|
+
} catch (_e) { /* drop-silent — escalation is best-effort, never crashes a tick */ }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function track(record) {
|
|
357
|
+
if (!record || typeof record !== "object" || typeof record.id !== "string" || record.id.length === 0) {
|
|
358
|
+
throw new IncidentReportError("incident-report/bad-record",
|
|
359
|
+
"createDeadlineClock.track: record must be an incident.report record with a string id");
|
|
360
|
+
}
|
|
361
|
+
if (!record.dueBy || typeof record.dueBy !== "object" ||
|
|
362
|
+
typeof record.detectedAt !== "number") {
|
|
363
|
+
throw new IncidentReportError("incident-report/bad-record",
|
|
364
|
+
"createDeadlineClock.track: record must carry detectedAt + dueBy { initial, intermediate, final }");
|
|
365
|
+
}
|
|
366
|
+
tracked.set(record.id, {
|
|
367
|
+
detectedAt: record.detectedAt,
|
|
368
|
+
dueBy: record.dueBy,
|
|
369
|
+
regime: record.regime || null,
|
|
370
|
+
acked: {},
|
|
371
|
+
fired: {},
|
|
372
|
+
});
|
|
373
|
+
return record.id;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function untrack(id) { return tracked.delete(id); }
|
|
377
|
+
|
|
378
|
+
function acknowledgeSubmission(id, stage, info) {
|
|
379
|
+
if (!VALID_STAGES[stage]) {
|
|
380
|
+
throw new IncidentReportError("incident-report/bad-stage",
|
|
381
|
+
"createDeadlineClock.acknowledgeSubmission: stage must be one of " + Object.keys(VALID_STAGES).join(", "));
|
|
382
|
+
}
|
|
383
|
+
var t = tracked.get(id);
|
|
384
|
+
if (!t) {
|
|
385
|
+
throw new IncidentReportError("incident-report/unknown-incident",
|
|
386
|
+
"createDeadlineClock.acknowledgeSubmission: no tracked incident '" + id + "'");
|
|
387
|
+
}
|
|
388
|
+
t.acked[stage] = true;
|
|
389
|
+
_emit("submission_acknowledged", "success", { incidentId: id, regime: t.regime, stage: stage, info: info || null });
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Pure evaluation seam — operators (and tests) can pass an explicit
|
|
394
|
+
// nowMs. Each (incident, stage, state) fires AT MOST once; a stage
|
|
395
|
+
// that has been acknowledged is skipped entirely.
|
|
396
|
+
function tick(nowMsArg) {
|
|
397
|
+
var nowMs = typeof nowMsArg === "number" ? nowMsArg : now();
|
|
398
|
+
tracked.forEach(function (t, id) {
|
|
399
|
+
var stages = ["initial", "intermediate", "final"];
|
|
400
|
+
for (var si = 0; si < stages.length; si += 1) {
|
|
401
|
+
var stage = stages[si];
|
|
402
|
+
if (t.acked[stage]) continue;
|
|
403
|
+
var due = t.dueBy[stage];
|
|
404
|
+
if (typeof due !== "number") continue;
|
|
405
|
+
var span = due - t.detectedAt;
|
|
406
|
+
if (span <= 0) continue;
|
|
407
|
+
if (nowMs >= due) {
|
|
408
|
+
var pk = stage + ":passed";
|
|
409
|
+
if (!t.fired[pk]) {
|
|
410
|
+
t.fired[pk] = true;
|
|
411
|
+
_emit("deadline_passed", "failure", { incidentId: id, regime: t.regime, stage: stage, dueBy: due });
|
|
412
|
+
_notify({ kind: "deadline_passed", incidentId: id, regime: t.regime, stage: stage, dueBy: due });
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
var proportion = (nowMs - t.detectedAt) / span;
|
|
417
|
+
for (var thi = thresholds.length - 1; thi >= 0; thi -= 1) {
|
|
418
|
+
if (proportion >= thresholds[thi]) {
|
|
419
|
+
var ak = stage + ":approaching:" + thresholds[thi];
|
|
420
|
+
if (!t.fired[ak]) {
|
|
421
|
+
t.fired[ak] = true;
|
|
422
|
+
_emit("deadline_approaching", "warning",
|
|
423
|
+
{ incidentId: id, regime: t.regime, stage: stage, dueBy: due, threshold: thresholds[thi] });
|
|
424
|
+
_notify({ kind: "deadline_approaching", incidentId: id, regime: t.regime, stage: stage, dueBy: due, threshold: thresholds[thi] });
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function start() {
|
|
434
|
+
if (timer) return;
|
|
435
|
+
timer = setInterval(function () { tick(); }, intervalMs);
|
|
436
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
437
|
+
}
|
|
438
|
+
function stop() {
|
|
439
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
440
|
+
}
|
|
441
|
+
function status() {
|
|
442
|
+
return { tracked: tracked.size, running: timer !== null, intervalMs: intervalMs };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (autoStart) start();
|
|
446
|
+
return {
|
|
447
|
+
track: track,
|
|
448
|
+
untrack: untrack,
|
|
449
|
+
acknowledgeSubmission: acknowledgeSubmission,
|
|
450
|
+
tick: tick,
|
|
451
|
+
start: start,
|
|
452
|
+
stop: stop,
|
|
453
|
+
status: status,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
308
457
|
module.exports = {
|
|
309
458
|
create: create,
|
|
459
|
+
createDeadlineClock: createDeadlineClock,
|
|
310
460
|
IncidentReportError: IncidentReportError,
|
|
311
461
|
REGIME_DEADLINES: REGIME_DEADLINES,
|
|
312
462
|
DEFAULT_DEADLINES: DEFAULT_DEADLINES,
|
package/lib/mail-crypto-smime.js
CHANGED
|
@@ -763,7 +763,7 @@ function checkCert(opts) {
|
|
|
763
763
|
}
|
|
764
764
|
|
|
765
765
|
// Validity window — refuse certs outside their notBefore / notAfter
|
|
766
|
-
// window.
|
|
766
|
+
// window. checkCert's docstring promises this throws
|
|
767
767
|
// `mail-crypto/smime/expired-cert` but the impl was missing, letting
|
|
768
768
|
// expired or not-yet-valid signing certs pass boot-time preflight
|
|
769
769
|
// and fail interop later when peers verify signatures against the
|
package/lib/mail-deploy.js
CHANGED
|
@@ -720,7 +720,7 @@ function _validateTlsRptReport(raw, ctx) {
|
|
|
720
720
|
"parseTlsRptReport: report has " + policies.length +
|
|
721
721
|
" policies (cap " + TLSRPT_MAX_POLICIES + ")");
|
|
722
722
|
}
|
|
723
|
-
//
|
|
723
|
+
// Validate summary counts as finite non-negative
|
|
724
724
|
// integers before summing. `Number(...) || 0` would accept
|
|
725
725
|
// `Infinity` (from JSON literal `1e309` or string "Infinity"),
|
|
726
726
|
// negative values, and arbitrary strings (coerced to NaN→0). Each
|
|
@@ -882,7 +882,7 @@ function tlsRptReportSchema() {
|
|
|
882
882
|
* posture-aware payload (organization-name, report-id,
|
|
883
883
|
* policy-domain set, session totals).
|
|
884
884
|
*
|
|
885
|
-
* Authentication discipline
|
|
885
|
+
* Authentication discipline:
|
|
886
886
|
* - `trustedReporters` is a CONTENT-SIDE soft filter — it compares
|
|
887
887
|
* the reporter's self-declared `organization-name` field (the
|
|
888
888
|
* report body, operator-untrusted) against the operator's
|
|
@@ -962,7 +962,7 @@ function tlsRptIngestHttp(opts) {
|
|
|
962
962
|
res.end("RFC 8460 §6.4-6.5 media types required\n");
|
|
963
963
|
return;
|
|
964
964
|
}
|
|
965
|
-
//
|
|
965
|
+
// Real-authentication boundary BEFORE body
|
|
966
966
|
// collection. The operator-supplied `authenticate(req)` hook
|
|
967
967
|
// routes to mTLS peer-cert / IP-allowlist / signed-header /
|
|
968
968
|
// reverse-proxy header inspection. Sync-or-async; falsy → 401.
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* `opts.auth.mechanisms`. The framework hardcodes no defaults; an
|
|
44
44
|
* operator who omits `mechanisms` gets a listener that refuses
|
|
45
45
|
* every AUTHENTICATE attempt with "mechanism not advertised"
|
|
46
|
-
* (
|
|
46
|
+
* (otherwise advertising AUTH=PLAIN
|
|
47
47
|
* when authConfig is null sets clients up to attempt PLAIN against
|
|
48
48
|
* a listener that hasn't wired the verifier).
|
|
49
49
|
*
|
|
@@ -482,7 +482,7 @@ function create(opts) {
|
|
|
482
482
|
}
|
|
483
483
|
socket.write('"SIEVE" "' + sieveCaps.join(" ") + '"\r\n');
|
|
484
484
|
// Advertise SASL mechanisms — ONLY the mechs the operator wired
|
|
485
|
-
// in opts.auth.mechanisms (
|
|
485
|
+
// in opts.auth.mechanisms (do not hardcode the advertised mechanisms).
|
|
486
486
|
if (authConfig && Array.isArray(authConfig.mechanisms) && authConfig.mechanisms.length > 0) {
|
|
487
487
|
var mechs = authConfig.mechanisms.map(function (m) {
|
|
488
488
|
return String(m).toUpperCase();
|
package/lib/mail-server-pop3.js
CHANGED
|
@@ -368,8 +368,8 @@ function create(opts) {
|
|
|
368
368
|
socket.write("UIDL\r\n");
|
|
369
369
|
socket.write("RESP-CODES\r\n");
|
|
370
370
|
if (!state.tls) socket.write("STLS\r\n");
|
|
371
|
-
// Advertise AUTH mechanisms ONLY when wired
|
|
372
|
-
//
|
|
371
|
+
// Advertise AUTH mechanisms ONLY when wired
|
|
372
|
+
// (do not hardcode SASL mechs in caps).
|
|
373
373
|
if (authConfig && Array.isArray(authConfig.mechanisms) && authConfig.mechanisms.length > 0) {
|
|
374
374
|
var mechs = authConfig.mechanisms.map(function (m) {
|
|
375
375
|
return String(m).toUpperCase();
|
package/lib/mail-store.js
CHANGED
|
@@ -753,7 +753,7 @@ function _setFlags(args) {
|
|
|
753
753
|
// Per-message modseq bump — without this, queryByModseq filters
|
|
754
754
|
// `messages.modseq > sinceModseq` and misses the flag change. CONDSTORE
|
|
755
755
|
// (RFC 7162) / JMAP Email/changes both depend on the per-message
|
|
756
|
-
// modseq being current.
|
|
756
|
+
// modseq being current.
|
|
757
757
|
if (args.objectids.length > 0 && (setFlags.length > 0 || unsetFlags.length > 0)) {
|
|
758
758
|
// Bulk-update via IN-clause. SQLite caps IN-clause at 32766 (max
|
|
759
759
|
// bound parameters); chunk for very large operands.
|
package/lib/metrics.js
CHANGED
|
@@ -143,7 +143,7 @@ function _normalizeLabelArg(callLabels, value, defaultValue) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
//
|
|
146
|
+
// Credential-shape detector. Operators routinely tap their
|
|
147
147
|
// own observability with `{ token: req.headers.authorization }` or
|
|
148
148
|
// `{ apiKey: req.headers["x-api-key"] }`, which then leak through the
|
|
149
149
|
// /metrics scrape surface to any reader of the metrics endpoint. The
|
|
@@ -199,7 +199,7 @@ function _validateLabelValue(value) {
|
|
|
199
199
|
// counters indexed by various input types still work.
|
|
200
200
|
if (value === null || value === undefined) return "";
|
|
201
201
|
var coerced = String(value);
|
|
202
|
-
//
|
|
202
|
+
// Credential-shape detector. Operators who tap their
|
|
203
203
|
// observability with raw header values leak bearer tokens / API
|
|
204
204
|
// keys through /metrics to every scrape reader. Refuse the value
|
|
205
205
|
// and surface a redaction marker so the metric still labels (so
|
|
@@ -642,8 +642,8 @@ function create(opts) {
|
|
|
642
642
|
// `Accept: application/openmetrics-text; version=1.0.0`. The
|
|
643
643
|
// handler returns the OpenMetrics-1.0 wire format when that
|
|
644
644
|
// media type has the highest q-value among supported types;
|
|
645
|
-
// defaults to Prometheus 0.0.4 otherwise.
|
|
646
|
-
//
|
|
645
|
+
// defaults to Prometheus 0.0.4 otherwise.
|
|
646
|
+
// Honor RFC 9110 §12.5.1 weighted negotiation: a client that
|
|
647
647
|
// sends `Accept: text/plain;q=1.0, application/openmetrics-
|
|
648
648
|
// text;q=0.5` (or `;q=0`) gets text/plain back, even though
|
|
649
649
|
// both media types are supported.
|
|
@@ -719,7 +719,7 @@ function create(opts) {
|
|
|
719
719
|
// (Prometheus 2.x, Grafana exemplar-renderer) can pivot
|
|
720
720
|
// from a slow-request bucket to the trace that produced it.
|
|
721
721
|
//
|
|
722
|
-
//
|
|
722
|
+
// The span_id MUST be the server-handling
|
|
723
723
|
// span (created by b.middleware.spanHttpServer + stamped on
|
|
724
724
|
// req.span), not the upstream `traceparent`'s parent-id.
|
|
725
725
|
// The parent-id points at the CALLER's span (or nothing for
|
|
@@ -986,7 +986,7 @@ function snapshotStartWriter(opts) {
|
|
|
986
986
|
var fieldsFn = opts.fields;
|
|
987
987
|
var registry = opts.registry || null;
|
|
988
988
|
var intervalMs = opts.intervalMs;
|
|
989
|
-
//
|
|
989
|
+
// File mode for the atomic write. Default 0o640
|
|
990
990
|
// (owner rw, group r, world none). Operators with a sidecar
|
|
991
991
|
// reader in a different group override to 0o644; multi-tenant
|
|
992
992
|
// hosts may even tighten to 0o600.
|
|
@@ -1019,7 +1019,7 @@ function snapshotStartWriter(opts) {
|
|
|
1019
1019
|
catch (e2) { log("snapshot.metrics serialize failed: " + ((e2 && e2.message) || String(e2))); }
|
|
1020
1020
|
}
|
|
1021
1021
|
try {
|
|
1022
|
-
//
|
|
1022
|
+
// Default 0o640 (owner rw, group r, world none) so
|
|
1023
1023
|
// operator-supplied snapshot fields aren't world-readable on a
|
|
1024
1024
|
// multi-tenant host. Operators with a sidecar reader running as
|
|
1025
1025
|
// a different group override via opts.fileMode at startWriter
|
|
@@ -1078,7 +1078,7 @@ function snapshotRead(p) {
|
|
|
1078
1078
|
// is well above the framework's expected snapshot size (~5-50 KiB)
|
|
1079
1079
|
// and the safeJson absolute cap stays within reach.
|
|
1080
1080
|
try {
|
|
1081
|
-
//
|
|
1081
|
+
// Route through C.BYTES.mib(4); the raw byte literal
|
|
1082
1082
|
// was a drift smell flagged by codebase-patterns.
|
|
1083
1083
|
parsed = safeJson.parse(raw, { maxBytes: C.BYTES.mib(4) });
|
|
1084
1084
|
} catch (e) {
|
|
@@ -316,7 +316,7 @@ function create(opts) {
|
|
|
316
316
|
// refuse before the token check.
|
|
317
317
|
//
|
|
318
318
|
// Default: enabled (defense-in-depth — same shape as bot-guard /
|
|
319
|
-
// rate-limit / CSP nonce — every default ON
|
|
319
|
+
// rate-limit / CSP nonce — every default ON).
|
|
320
320
|
// Operator opt-out: opts.checkOrigin = false.
|
|
321
321
|
// Operator allowlist: opts.allowedOrigins = ["https://app.example.com"].
|
|
322
322
|
var checkOrigin = opts.checkOrigin !== false;
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -119,7 +119,7 @@ function _nonceManager(rotateSec) {
|
|
|
119
119
|
if (previous && bCrypto.timingSafeEqual(n, previous.nonce)) return true;
|
|
120
120
|
return false;
|
|
121
121
|
},
|
|
122
|
-
//
|
|
122
|
+
// Hot-reload coexistence. Operators redeploying without
|
|
123
123
|
// a clean process restart need a way to drain in-flight clients
|
|
124
124
|
// before swapping the middleware instance. shutdown() returns no
|
|
125
125
|
// fresh nonces and refuses every presented nonce, so the
|
|
@@ -156,7 +156,7 @@ function _reconstructHtu(req, mopts) {
|
|
|
156
156
|
//
|
|
157
157
|
// Default: ignore X-Forwarded-* and derive proto/host from the
|
|
158
158
|
// socket. Operators with a confirmed-trusted front proxy opt in
|
|
159
|
-
// via opts.trustForwardedHeaders: true.
|
|
159
|
+
// via opts.trustForwardedHeaders: true.
|
|
160
160
|
mopts = mopts || {};
|
|
161
161
|
var trustForwarded = mopts.trustForwardedHeaders === true;
|
|
162
162
|
var proto;
|
|
@@ -229,7 +229,7 @@ function create(opts) {
|
|
|
229
229
|
"getAccessToken", "getNonce", "getHtu", "audit",
|
|
230
230
|
"nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
|
|
231
231
|
// v0.9.4 — opt-in trust gate for X-Forwarded-Proto/Host when
|
|
232
|
-
// reconstructing htu. Default off
|
|
232
|
+
// reconstructing htu. Default off; operators
|
|
233
233
|
// with a confirmed-trusted front proxy set this to `true`.
|
|
234
234
|
"trustForwardedHeaders", "onDeny", "problemDetails",
|
|
235
235
|
], "middleware.dpop");
|
|
@@ -287,7 +287,7 @@ function create(opts) {
|
|
|
287
287
|
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
288
288
|
"multiple DPoP headers are not allowed", null, onDeny, problemMode);
|
|
289
289
|
}
|
|
290
|
-
//
|
|
290
|
+
// RFC 9449 §4.1 single-value invariant. node:http
|
|
291
291
|
// collapses repeated headers into a comma-joined string when the
|
|
292
292
|
// client ships `DPoP: proof1, DPoP: proof2`; the Array.isArray
|
|
293
293
|
// check above catches the multi-value array shape but a
|
|
@@ -399,7 +399,7 @@ function create(opts) {
|
|
|
399
399
|
return next();
|
|
400
400
|
};
|
|
401
401
|
|
|
402
|
-
//
|
|
402
|
+
// Surface the nonce manager's lifecycle hooks on the
|
|
403
403
|
// returned middleware so hot-reload deploys can drain in-flight
|
|
404
404
|
// clients before swapping instances. shutdown() refuses every
|
|
405
405
|
// subsequent proof + issues no fresh nonces; revoke() rotates the
|