@blamejs/core 0.7.107 → 0.8.4
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 +41 -1
- package/NOTICE +17 -1
- package/README.md +4 -3
- package/index.js +15 -0
- package/lib/asyncapi-bindings.js +160 -0
- package/lib/asyncapi-traits.js +143 -0
- package/lib/asyncapi.js +531 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +68 -2
- package/lib/auth/acr-vocabulary.js +265 -0
- package/lib/auth/auth-time-tracker.js +111 -0
- package/lib/auth/elevation-grant.js +306 -0
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/auth/step-up-policy.js +335 -0
- package/lib/auth/step-up.js +445 -0
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +190 -0
- package/lib/compliance-ai-act-prohibited.js +205 -0
- package/lib/compliance-ai-act-risk.js +189 -0
- package/lib/compliance-ai-act-transparency.js +200 -0
- package/lib/compliance-ai-act.js +558 -0
- package/lib/compliance.js +12 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +114 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag-cache.js +136 -0
- package/lib/flag-evaluation-context.js +135 -0
- package/lib/flag-providers.js +279 -0
- package/lib/flag-targeting.js +210 -0
- package/lib/flag.js +284 -0
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +391 -0
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail-arc-sign.js +372 -0
- package/lib/mail-auth.js +2 -0
- package/lib/mail.js +40 -0
- package/lib/middleware/ai-act-disclosure.js +166 -0
- package/lib/middleware/asyncapi-serve.js +136 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/flag-context.js +76 -0
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/index.js +15 -0
- package/lib/middleware/openapi-serve.js +143 -0
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/require-step-up.js +186 -0
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/openapi-paths-builder.js +248 -0
- package/lib/openapi-schema-walk.js +192 -0
- package/lib/openapi-security.js +169 -0
- package/lib/openapi-yaml.js +154 -0
- package/lib/openapi.js +443 -0
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pqc-software.js +195 -0
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/vault/index.js +3 -0
- package/lib/vault-aad.js +259 -0
- package/lib/vendor/MANIFEST.json +29 -0
- package/lib/vendor/noble-post-quantum.cjs +18 -0
- package/lib/ws-client.js +978 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/crypto.js
CHANGED
|
@@ -164,7 +164,26 @@ function verify(data, signature, publicKeyPem) {
|
|
|
164
164
|
function encrypt(plaintext, publicKeys) {
|
|
165
165
|
var mlkemPubPem = typeof publicKeys === "string" ? publicKeys : publicKeys.publicKey;
|
|
166
166
|
var ecPubPem = typeof publicKeys === "string" ? null : publicKeys.ecPublicKey;
|
|
167
|
-
if (!ecPubPem)
|
|
167
|
+
if (!ecPubPem) {
|
|
168
|
+
// Operator passed only an ML-KEM public key — silently dropping
|
|
169
|
+
// the P-384 hybrid leg means the operator's defense-in-depth
|
|
170
|
+
// posture (classical ECDH backstop on top of PQC KEM) is gone
|
|
171
|
+
// without any signal. Emit on next tick (crypto must not import
|
|
172
|
+
// audit synchronously — audit imports crypto for chain hashing).
|
|
173
|
+
// Operators who genuinely want KEM-only should call
|
|
174
|
+
// encryptMlkemOnly explicitly so this audit doesn't fire.
|
|
175
|
+
setImmediate(function () {
|
|
176
|
+
try {
|
|
177
|
+
var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports crypto)
|
|
178
|
+
auditMod.safeEmit({
|
|
179
|
+
action: "system.crypto.hybrid_disabled",
|
|
180
|
+
outcome: "success",
|
|
181
|
+
metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent — call encryptMlkemOnly explicitly to silence" },
|
|
182
|
+
});
|
|
183
|
+
} catch (_e) { /* drop-silent — best-effort */ }
|
|
184
|
+
});
|
|
185
|
+
return encryptMlkemOnly(plaintext, mlkemPubPem);
|
|
186
|
+
}
|
|
168
187
|
|
|
169
188
|
var mlkemPub = nodeCrypto.createPublicKey(mlkemPubPem);
|
|
170
189
|
var kem = nodeCrypto.encapsulate(mlkemPub);
|
|
@@ -361,6 +380,37 @@ function encryptMlkem768X25519(plaintext, recipient) {
|
|
|
361
380
|
]).toString("base64");
|
|
362
381
|
}
|
|
363
382
|
|
|
383
|
+
// Symmetric named-pair to encryptMlkem768X25519. Operators wiring the
|
|
384
|
+
// IETF / Cloudflare / Chrome TLS-1.3 hybrid (codepoint 0x11EC) want
|
|
385
|
+
// the encrypt + decrypt halves under symmetric, discoverable names.
|
|
386
|
+
//
|
|
387
|
+
// The generic b.crypto.decrypt already dispatches by KEM ID and
|
|
388
|
+
// handles ML_KEM_768_X25519 envelopes correctly; this helper REJECTS
|
|
389
|
+
// any other KEM ID at the head, so an operator who calls
|
|
390
|
+
// decryptMlkem768X25519 with a ciphertext sealed under a different
|
|
391
|
+
// algorithm gets a clear error rather than the generic "unsupported
|
|
392
|
+
// KEM ID" path.
|
|
393
|
+
//
|
|
394
|
+
// recipient: { privateKey, x25519PrivateKey } — operator's keys
|
|
395
|
+
// ciphertext: base64 envelope from encryptMlkem768X25519
|
|
396
|
+
function decryptMlkem768X25519(ciphertext, recipient) {
|
|
397
|
+
if (!recipient || typeof recipient !== "object" ||
|
|
398
|
+
!recipient.privateKey || !recipient.x25519PrivateKey) {
|
|
399
|
+
throw new Error("decryptMlkem768X25519 requires { privateKey, x25519PrivateKey } " +
|
|
400
|
+
"(privateKey is the ML-KEM-768 PEM, x25519PrivateKey is the X25519 PEM)");
|
|
401
|
+
}
|
|
402
|
+
var packed = Buffer.from(ciphertext, "base64");
|
|
403
|
+
if (packed[0] !== C.ENVELOPE_MAGIC) {
|
|
404
|
+
throw new Error("decryptMlkem768X25519: invalid envelope (bad magic byte)");
|
|
405
|
+
}
|
|
406
|
+
if (packed[1] !== C.KEM_IDS.ML_KEM_768_X25519) {
|
|
407
|
+
throw new Error("decryptMlkem768X25519: envelope KEM ID is " + packed[1] +
|
|
408
|
+
", expected " + C.KEM_IDS.ML_KEM_768_X25519 +
|
|
409
|
+
" (ML_KEM_768_X25519). Use b.crypto.decrypt for KEM-id dispatch.");
|
|
410
|
+
}
|
|
411
|
+
return decryptEnvelope(packed, recipient);
|
|
412
|
+
}
|
|
413
|
+
|
|
364
414
|
// ---- Cert-peer envelope primitives ----
|
|
365
415
|
//
|
|
366
416
|
// The framework's default `encrypt` / `decrypt` source the recipient
|
|
@@ -487,6 +537,65 @@ function decryptEnvelopeAsCertPeer(envelope, opts) {
|
|
|
487
537
|
// Operator-audit accessor — exposes every supported KEM hybrid for
|
|
488
538
|
// compliance audit visibility ("which envelopes does this deploy
|
|
489
539
|
// accept on decrypt?").
|
|
540
|
+
// ---- Certificate fingerprint helpers ----
|
|
541
|
+
//
|
|
542
|
+
// Operators pinning peer-cert fingerprints (mtls bootstrap, webhook
|
|
543
|
+
// verification, certificate transparency cross-checks) want a stable
|
|
544
|
+
// SHA3-512 hash of the DER bytes plus a colon-separated hex form that
|
|
545
|
+
// matches what most operator tooling renders for X.509 fingerprints.
|
|
546
|
+
// hashCertFingerprint accepts either a Buffer (DER) or a PEM string;
|
|
547
|
+
// if PEM, the BEGIN/END envelope is stripped and the base64 body is
|
|
548
|
+
// decoded before hashing. The hash is the framework's standard SHA3-
|
|
549
|
+
// 512 (not SHA-256 — operators using OpenSSL's `-sha256` defaults can
|
|
550
|
+
// keep their own SHA-256 hashes, this primitive is the framework-
|
|
551
|
+
// canonical form). Returns { hex, colon } so callers can compare
|
|
552
|
+
// against either rendering.
|
|
553
|
+
function _pemToDer(pemOrDer) {
|
|
554
|
+
if (Buffer.isBuffer(pemOrDer)) return pemOrDer;
|
|
555
|
+
if (typeof pemOrDer !== "string") {
|
|
556
|
+
throw new TypeError("crypto.hashCertFingerprint: input must be a Buffer (DER) or a PEM-encoded string");
|
|
557
|
+
}
|
|
558
|
+
var match = pemOrDer.match(/-----BEGIN [A-Z0-9 ]+-----([\s\S]+?)-----END [A-Z0-9 ]+-----/);
|
|
559
|
+
if (!match) {
|
|
560
|
+
throw new TypeError("crypto.hashCertFingerprint: PEM input lacks BEGIN/END markers");
|
|
561
|
+
}
|
|
562
|
+
return Buffer.from(match[1].replace(/\s+/g, ""), "base64");
|
|
563
|
+
}
|
|
564
|
+
function hashCertFingerprint(pemOrDer) {
|
|
565
|
+
var der = _pemToDer(pemOrDer);
|
|
566
|
+
var digest = hash(der, "sha3-512");
|
|
567
|
+
var hex = digest.toString("hex");
|
|
568
|
+
// Colon-separated, uppercase — matches openssl x509 -fingerprint
|
|
569
|
+
// output style (which is SHA-1 by default, but the rendering shape
|
|
570
|
+
// operators expect is the same).
|
|
571
|
+
var colon = hex.toUpperCase().match(/.{2}/g).join(":");
|
|
572
|
+
return { hex: hex, colon: colon };
|
|
573
|
+
}
|
|
574
|
+
// Compares a peer's PEM/DER cert against an allowlist of pinned
|
|
575
|
+
// fingerprints. Allowlist entries may be the colon form, the lower-
|
|
576
|
+
// case hex form, or both — every comparison runs through
|
|
577
|
+
// timingSafeEqual to avoid leaking which entry matched.
|
|
578
|
+
function isCertRevoked(pemOrDer, denyList) {
|
|
579
|
+
if (!Array.isArray(denyList)) {
|
|
580
|
+
throw new TypeError("crypto.isCertRevoked: denyList must be an array of fingerprint strings");
|
|
581
|
+
}
|
|
582
|
+
var fp = hashCertFingerprint(pemOrDer);
|
|
583
|
+
var fpHex = Buffer.from(fp.hex, "hex");
|
|
584
|
+
var fpColon = Buffer.from(fp.colon);
|
|
585
|
+
for (var i = 0; i < denyList.length; i++) {
|
|
586
|
+
var entry = denyList[i];
|
|
587
|
+
if (typeof entry !== "string" || entry.length === 0) continue;
|
|
588
|
+
var normalized = entry.indexOf(":") !== -1 ? entry.toUpperCase() : entry.toLowerCase();
|
|
589
|
+
var normalizedBuf = entry.indexOf(":") !== -1 ? Buffer.from(normalized) : Buffer.from(normalized, "hex");
|
|
590
|
+
var compareBuf = entry.indexOf(":") !== -1 ? fpColon : fpHex;
|
|
591
|
+
if (normalizedBuf.length === compareBuf.length &&
|
|
592
|
+
nodeCrypto.timingSafeEqual(normalizedBuf, compareBuf)) {
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
490
599
|
var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
|
|
491
600
|
{ id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
|
|
492
601
|
{ id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
|
|
@@ -501,6 +610,9 @@ module.exports = {
|
|
|
501
610
|
kdf: kdf,
|
|
502
611
|
// Comparison
|
|
503
612
|
timingSafeEqual: timingSafeEqual,
|
|
613
|
+
// Cert fingerprint helpers
|
|
614
|
+
hashCertFingerprint: hashCertFingerprint,
|
|
615
|
+
isCertRevoked: isCertRevoked,
|
|
504
616
|
// Random
|
|
505
617
|
generateBytes: generateBytes,
|
|
506
618
|
generateToken: generateToken,
|
|
@@ -515,6 +627,7 @@ module.exports = {
|
|
|
515
627
|
encrypt: encrypt,
|
|
516
628
|
decrypt: decrypt,
|
|
517
629
|
encryptMlkem768X25519: encryptMlkem768X25519,
|
|
630
|
+
decryptMlkem768X25519: decryptMlkem768X25519,
|
|
518
631
|
encryptEnvelopeAsCertPeer: encryptEnvelopeAsCertPeer,
|
|
519
632
|
decryptEnvelopeAsCertPeer: decryptEnvelopeAsCertPeer,
|
|
520
633
|
SUPPORTED_KEM_ALGORITHMS: SUPPORTED_KEM_ALGORITHMS,
|
package/lib/db.js
CHANGED
|
@@ -585,7 +585,21 @@ function decryptToTmp() {
|
|
|
585
585
|
}
|
|
586
586
|
var packed = fs.readFileSync(encPath);
|
|
587
587
|
if (packed.length < 26) return; // too short to be a valid envelope
|
|
588
|
-
|
|
588
|
+
// AAD binds the envelope to this deployment's data dir so two
|
|
589
|
+
// installs sharing the same operator passphrase can't swap each
|
|
590
|
+
// other's db.enc files. Backwards-compat: if the AAD-bound decrypt
|
|
591
|
+
// fails, retry without AAD for envelopes written by pre-AAD
|
|
592
|
+
// versions (one-release transition window).
|
|
593
|
+
var aad = _dbEncAad(dataDir);
|
|
594
|
+
try {
|
|
595
|
+
atomicFile.writeSync(dbPath, decryptPacked(packed, encKey, aad));
|
|
596
|
+
} catch (_e) {
|
|
597
|
+
atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function _dbEncAad(dir) {
|
|
602
|
+
return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
|
|
589
603
|
}
|
|
590
604
|
|
|
591
605
|
function encryptToDisk() {
|
|
@@ -593,7 +607,7 @@ function encryptToDisk() {
|
|
|
593
607
|
// Force WAL checkpoint so the .db file holds all committed transactions.
|
|
594
608
|
try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
|
|
595
609
|
if (!fs.existsSync(dbPath)) return;
|
|
596
|
-
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
|
|
610
|
+
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
|
|
597
611
|
}
|
|
598
612
|
|
|
599
613
|
// Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
|
|
@@ -691,6 +705,23 @@ async function init(opts) {
|
|
|
691
705
|
// dominates with audit-chain emissions and cascade fan-out.
|
|
692
706
|
runSql(database, "PRAGMA secure_delete=ON");
|
|
693
707
|
|
|
708
|
+
// Boot-time integrity check — refuse to boot on B-tree corruption.
|
|
709
|
+
// SQLite normally surfaces corruption only when a query stumbles on
|
|
710
|
+
// a bad page; that's a "first failure during request handling"
|
|
711
|
+
// surface, not a clean fail-closed boot. integrity_check is fast on
|
|
712
|
+
// the freshly-decrypted-into-tmpfs file (<1 second on a typical
|
|
713
|
+
// multi-MB DB) and the result is "ok" or a list of issues.
|
|
714
|
+
if (opts.skipBootIntegrityCheck !== true) {
|
|
715
|
+
var ic = database.prepare("PRAGMA integrity_check").all();
|
|
716
|
+
var icIssues = ic.map(function (r) { return r && r.integrity_check; })
|
|
717
|
+
.filter(function (s) { return s && s !== "ok"; });
|
|
718
|
+
if (icIssues.length > 0) {
|
|
719
|
+
throw new DbError("db/integrity-check-failed",
|
|
720
|
+
"PRAGMA integrity_check at boot reported " + icIssues.length +
|
|
721
|
+
" issue(s): " + icIssues.slice(0, 3).join("; "));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
694
725
|
// PRAGMA integrity_check — refuse boot on B-tree corruption (per
|
|
695
726
|
// audit-batch finding). SQLite returns "ok" for a healthy database;
|
|
696
727
|
// any other result means corruption. Catching it at boot beats
|
|
@@ -1264,7 +1295,7 @@ module.exports = {
|
|
|
1264
1295
|
catch (_e) { /* drop-silent */ }
|
|
1265
1296
|
if (auditOn) {
|
|
1266
1297
|
try { audit.safeEmit({
|
|
1267
|
-
action: "system.db.integrity_ok", outcome: "
|
|
1298
|
+
action: "system.db.integrity_ok", outcome: "success", metadata: {},
|
|
1268
1299
|
}); } catch (_e) { /* drop-silent */ }
|
|
1269
1300
|
}
|
|
1270
1301
|
return;
|
|
@@ -1274,7 +1305,7 @@ module.exports = {
|
|
|
1274
1305
|
catch (_e) { /* drop-silent */ }
|
|
1275
1306
|
if (auditOn) {
|
|
1276
1307
|
try { audit.safeEmit({
|
|
1277
|
-
action: "system.db.integrity_corrupt", outcome: "
|
|
1308
|
+
action: "system.db.integrity_corrupt", outcome: "failure",
|
|
1278
1309
|
metadata: { issueCount: issues.length },
|
|
1279
1310
|
}); } catch (_e) { /* drop-silent */ }
|
|
1280
1311
|
}
|
package/lib/dev.js
CHANGED
|
@@ -48,13 +48,24 @@
|
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
50
|
var path = require("path");
|
|
51
|
-
var childProcess = require("child_process");
|
|
52
51
|
var fs = require("fs");
|
|
52
|
+
var lazyRequire = require("./lazy-require");
|
|
53
53
|
var logModule = require("./log");
|
|
54
54
|
var nb = require("./numeric-bounds");
|
|
55
|
+
var safeEnv = require("./parsers/safe-env");
|
|
55
56
|
var validateOpts = require("./validate-opts");
|
|
56
57
|
var { FrameworkError } = require("./framework-error");
|
|
57
58
|
|
|
59
|
+
// child_process is required ONLY when dev.create() is actually
|
|
60
|
+
// called — not at module load. The dev primitive spawns subprocesses
|
|
61
|
+
// (nodemon-style restart loop) by design; isolating the dependency
|
|
62
|
+
// behind lazy-load means a production process that never calls
|
|
63
|
+
// b.dev.create() never loads child_process, and supply-chain scanners
|
|
64
|
+
// inspecting a deployed bundle don't see it as a top-level dep of an
|
|
65
|
+
// otherwise hermetic framework. Production deployments additionally
|
|
66
|
+
// refuse to construct dev.create() — see _refuseInProduction below.
|
|
67
|
+
var childProcess = lazyRequire(function () { return require("child_process"); });
|
|
68
|
+
|
|
58
69
|
class DevError extends FrameworkError {
|
|
59
70
|
constructor(code, message) {
|
|
60
71
|
super(message, code);
|
|
@@ -123,9 +134,25 @@ function create(opts) {
|
|
|
123
134
|
var env = opts.env || process.env;
|
|
124
135
|
var cwd = opts.cwd || process.cwd();
|
|
125
136
|
|
|
126
|
-
//
|
|
137
|
+
// dev.create() is intended for development-mode use only —
|
|
138
|
+
// restarting subprocesses on file change is a feature operators
|
|
139
|
+
// run on their laptop, never in production. Refusing here means a
|
|
140
|
+
// mis-configured production deployment that accidentally wires the
|
|
141
|
+
// dev primitive crashes loudly at boot rather than spawning shells
|
|
142
|
+
// on every save. Operators with a legitimate cross-cutting need
|
|
143
|
+
// (e.g. a CI runner that uses dev() to drive end-to-end tests)
|
|
144
|
+
// explicitly opt in via opts.allowProduction with an audited reason.
|
|
145
|
+
if (safeEnv.readVar("NODE_ENV") === "production" && !opts.allowProduction) {
|
|
146
|
+
throw new DevError("dev/refused-in-production",
|
|
147
|
+
"b.dev.create: dev mode refuses to load when NODE_ENV=production. " +
|
|
148
|
+
"Set opts.allowProduction:true with an audited reason if a non-dev " +
|
|
149
|
+
"context legitimately needs subprocess spawn-on-watch behaviour.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Test seams. childProcess is lazily-required; calling spawn here
|
|
153
|
+
// pulls in node's child_process module on first dev.create() use.
|
|
127
154
|
var spawnFn = opts._spawn || function (cmd, sargs, sopts) {
|
|
128
|
-
return childProcess.spawn(cmd, sargs, sopts);
|
|
155
|
+
return childProcess().spawn(cmd, sargs, sopts);
|
|
129
156
|
};
|
|
130
157
|
var watchFn = opts._watch || function (dir, wopts, listener) {
|
|
131
158
|
return fs.watch(dir, wopts, listener);
|
package/lib/dual-control.js
CHANGED
|
@@ -186,7 +186,25 @@ function create(opts) {
|
|
|
186
186
|
if (!approverRoles) return true;
|
|
187
187
|
if (!actor || !Array.isArray(actor.roles)) return false;
|
|
188
188
|
for (var i = 0; i < approverRoles.length; i++) {
|
|
189
|
-
|
|
189
|
+
var required = approverRoles[i];
|
|
190
|
+
// Wildcard match — actor's "security:*" satisfies a required
|
|
191
|
+
// "security:officer" (matching the b.permissions.match
|
|
192
|
+
// semantics elsewhere in the framework). Without this, an
|
|
193
|
+
// operator with a wildcard-shaped role can't approve dual-
|
|
194
|
+
// control flows even when b.permissions would consider the
|
|
195
|
+
// role assignment satisfied.
|
|
196
|
+
for (var j = 0; j < actor.roles.length; j++) {
|
|
197
|
+
var actorRole = actor.roles[j];
|
|
198
|
+
if (actorRole === required) return true;
|
|
199
|
+
if (typeof actorRole === "string" &&
|
|
200
|
+
actorRole.length > 0 &&
|
|
201
|
+
actorRole.charAt(actorRole.length - 1) === "*") {
|
|
202
|
+
var prefix = actorRole.slice(0, -1);
|
|
203
|
+
if (typeof required === "string" && required.indexOf(prefix) === 0) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
190
208
|
}
|
|
191
209
|
return false;
|
|
192
210
|
}
|
package/lib/external-db.js
CHANGED
|
@@ -570,6 +570,16 @@ function _buildSessionGucsStatements(sessionGucs) {
|
|
|
570
570
|
// Postgres SET accepts on/off/true/false — render true/false.
|
|
571
571
|
literal = value ? "true" : "false";
|
|
572
572
|
} else if (typeof value === "string") {
|
|
573
|
+
// Cap the value length so an operator-controlled tenant_id of
|
|
574
|
+
// 100 KB doesn't hit Postgres' SET LOCAL parser with payload
|
|
575
|
+
// that bloats query logs and consumes max_stack_depth. The cap
|
|
576
|
+
// is generous for legitimate tenant identifiers but rejects
|
|
577
|
+
// amplification.
|
|
578
|
+
if (value.length > C.BYTES.kib(4)) {
|
|
579
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
580
|
+
"sessionGucs['" + name + "']: value exceeds 4 KiB cap (got " +
|
|
581
|
+
value.length + " chars)", true);
|
|
582
|
+
}
|
|
573
583
|
literal = "'" + value.replace(/'/g, "''") + "'";
|
|
574
584
|
} else if (value === null || value === undefined) {
|
|
575
585
|
throw _err("INVALID_SESSION_GUCS",
|
package/lib/file-upload.js
CHANGED
|
@@ -763,7 +763,7 @@ function create(opts) {
|
|
|
763
763
|
return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
-
function _checkAllowedFileType(firstChunkBody) {
|
|
766
|
+
function _checkAllowedFileType(firstChunkBody, claimedMime) {
|
|
767
767
|
if (!allowedFileTypes || allowedFileTypes.length === 0) return;
|
|
768
768
|
if (!fileType) return; // create() guards this; defensive
|
|
769
769
|
var detected = fileType.detect(firstChunkBody);
|
|
@@ -787,6 +787,30 @@ function create(opts) {
|
|
|
787
787
|
"fileUpload.finalize: detected MIME '" + detectedMime +
|
|
788
788
|
"' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
|
|
789
789
|
}
|
|
790
|
+
// Cross-check claimed MIME (Content-Type header) against detected
|
|
791
|
+
// magic bytes. When both exist and disagree, refuse — downstream
|
|
792
|
+
// renderers / storage layers that trust metadata.contentType
|
|
793
|
+
// (CDN cache routing, MIME-sniff fallbacks, attachment-disposition
|
|
794
|
+
// decisions) will mis-handle the file. The strict-MIME check is
|
|
795
|
+
// load-bearing for image-pipelines that pass to image processors:
|
|
796
|
+
// a "image/png" claim with PDF magic bytes lands in image-rendering
|
|
797
|
+
// code-paths that will exec PDF parsers with surprising semantics.
|
|
798
|
+
if (claimedMime && typeof claimedMime === "string" && claimedMime.indexOf("/") !== -1) {
|
|
799
|
+
var claimedNormalized = claimedMime.split(";")[0].trim().toLowerCase();
|
|
800
|
+
if (claimedNormalized && claimedNormalized !== detectedMime) {
|
|
801
|
+
// Wildcard / family acceptance: "image/jpeg" claim + "image/jpg"
|
|
802
|
+
// detect (synonyms) is OK; "image/png" claim + "image/jpeg"
|
|
803
|
+
// detect is NOT.
|
|
804
|
+
var claimedFamily = claimedNormalized.split("/")[0];
|
|
805
|
+
var detectedFamily = detectedMime.split("/")[0];
|
|
806
|
+
if (claimedFamily !== detectedFamily) {
|
|
807
|
+
throw _err("MIME_CLAIM_MISMATCH",
|
|
808
|
+
"fileUpload.finalize: claimed Content-Type '" + claimedNormalized +
|
|
809
|
+
"' disagrees with detected magic-byte MIME '" + detectedMime +
|
|
810
|
+
"'. Refusing to proceed with mis-typed file.");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
790
814
|
}
|
|
791
815
|
|
|
792
816
|
function _streamFromChunkPaths(paths /* totalBytes */) {
|
|
@@ -842,8 +866,11 @@ function create(opts) {
|
|
|
842
866
|
firstChunk = pieces[0];
|
|
843
867
|
}
|
|
844
868
|
|
|
845
|
-
// MIME allowlist gate (if configured).
|
|
846
|
-
|
|
869
|
+
// MIME allowlist gate (if configured). Pass the operator-supplied
|
|
870
|
+
// contentType from upload metadata so the cross-check can refuse
|
|
871
|
+
// claimed-vs-detected mismatches.
|
|
872
|
+
var claimedMime = (meta && meta.metadata && meta.metadata.contentType) || null;
|
|
873
|
+
try { _checkAllowedFileType(firstChunk, claimedMime); }
|
|
847
874
|
catch (e) {
|
|
848
875
|
_emitObs("fileUpload.mime_rejected", 1);
|
|
849
876
|
_emitAudit("fileUpload.finalize", {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Flag-evaluation cache — per-targetingKey TTL'd cache wrapping a
|
|
4
|
+
* downstream provider so a high-traffic request path does not hit
|
|
5
|
+
* the provider on every flag read.
|
|
6
|
+
*
|
|
7
|
+
* var raw = b.flag.providers.localFile({ path: "./flags.json", watch: true });
|
|
8
|
+
* var cached = b.flag.cache(raw, { ttlMs: 60_000, maxEntries: 10000 });
|
|
9
|
+
*
|
|
10
|
+
* var flag = b.flag.create({ provider: cached });
|
|
11
|
+
*
|
|
12
|
+
* Cache key: `${targetingKey}::${flagKey}`. Entries TTL out after
|
|
13
|
+
* `ttlMs` (default 30 s). When the cache hits its `maxEntries` cap,
|
|
14
|
+
* oldest entries are evicted (insertion-order via a Map).
|
|
15
|
+
*
|
|
16
|
+
* Cache is bypassed for evaluation contexts without a `targetingKey`
|
|
17
|
+
* (flag value depends on every attribute, not a stable key).
|
|
18
|
+
*
|
|
19
|
+
* Operators with a hot-reload need pass `bustOn: "flag-reload"` and
|
|
20
|
+
* call `cached.bust()` from their reload handler — clears the entire
|
|
21
|
+
* map.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
var validateOpts = require("./validate-opts");
|
|
25
|
+
var lazyRequire = require("./lazy-require");
|
|
26
|
+
var C = require("./constants");
|
|
27
|
+
var { defineClass } = require("./framework-error");
|
|
28
|
+
var FlagError = defineClass("FlagError", { alwaysPermanent: true });
|
|
29
|
+
|
|
30
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
31
|
+
|
|
32
|
+
function cache(downstream, opts) {
|
|
33
|
+
opts = opts || {};
|
|
34
|
+
validateOpts(opts, ["ttlMs", "maxEntries", "audit"], "flag.cache");
|
|
35
|
+
if (!downstream || typeof downstream.evaluate !== "function") {
|
|
36
|
+
throw new FlagError("flag/bad-cache",
|
|
37
|
+
"cache: downstream provider must implement .evaluate()");
|
|
38
|
+
}
|
|
39
|
+
// allow:numeric-opt-Infinity — defaults clamped + ttl-floor enforced below
|
|
40
|
+
var ttlMs = (typeof opts.ttlMs === "number" && opts.ttlMs > 0)
|
|
41
|
+
? opts.ttlMs
|
|
42
|
+
: C.TIME.seconds(30);
|
|
43
|
+
if (ttlMs < C.TIME.seconds(1)) {
|
|
44
|
+
throw new FlagError("flag/bad-cache",
|
|
45
|
+
"cache: ttlMs must be >= 1000ms - got " + ttlMs);
|
|
46
|
+
}
|
|
47
|
+
// allow:numeric-opt-Infinity — maxEntries default + Math.floor coerce; throws on bad type at config time
|
|
48
|
+
var maxEntries = (typeof opts.maxEntries === "number" && opts.maxEntries > 0)
|
|
49
|
+
? Math.floor(opts.maxEntries)
|
|
50
|
+
: 10000; // allow:raw-byte-literal — entry-count default
|
|
51
|
+
var auditOn = opts.audit === true; // off by default — too chatty
|
|
52
|
+
var entries = new Map();
|
|
53
|
+
var hits = 0;
|
|
54
|
+
var misses = 0;
|
|
55
|
+
var evictions = 0;
|
|
56
|
+
|
|
57
|
+
function _evictExpired(nowMs) {
|
|
58
|
+
var iter = entries.entries();
|
|
59
|
+
var step = iter.next();
|
|
60
|
+
while (!step.done) {
|
|
61
|
+
if (step.value[1].expiresAt <= nowMs) {
|
|
62
|
+
entries.delete(step.value[0]);
|
|
63
|
+
evictions += 1;
|
|
64
|
+
}
|
|
65
|
+
step = iter.next();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _evictOldest() {
|
|
70
|
+
var first = entries.keys().next();
|
|
71
|
+
if (!first.done) {
|
|
72
|
+
entries.delete(first.value);
|
|
73
|
+
evictions += 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
kind: "cache:" + (downstream.kind || "unknown"),
|
|
79
|
+
list: typeof downstream.list === "function"
|
|
80
|
+
? function () { return downstream.list(); }
|
|
81
|
+
: function () { return []; },
|
|
82
|
+
evaluate: function (flagKey, ctx) {
|
|
83
|
+
var tk = (ctx && typeof ctx.targetingKey === "string") ? ctx.targetingKey : null;
|
|
84
|
+
if (!tk) {
|
|
85
|
+
misses += 1;
|
|
86
|
+
return downstream.evaluate(flagKey, ctx);
|
|
87
|
+
}
|
|
88
|
+
var key = tk + "::" + flagKey;
|
|
89
|
+
var now = Date.now();
|
|
90
|
+
var entry = entries.get(key);
|
|
91
|
+
if (entry && entry.expiresAt > now) {
|
|
92
|
+
hits += 1;
|
|
93
|
+
return entry.value;
|
|
94
|
+
}
|
|
95
|
+
if (entry) entries.delete(key);
|
|
96
|
+
var freshResult = downstream.evaluate(flagKey, ctx);
|
|
97
|
+
misses += 1;
|
|
98
|
+
// Don't cache flag-not-found — operator might add it later.
|
|
99
|
+
if (freshResult && freshResult.reason !== "flag_not_found") {
|
|
100
|
+
if (entries.size >= maxEntries) _evictOldest();
|
|
101
|
+
entries.set(key, { value: freshResult, expiresAt: now + ttlMs });
|
|
102
|
+
}
|
|
103
|
+
// Periodic sweep — evict expired on every 100th miss.
|
|
104
|
+
if (misses % 100 === 0) _evictExpired(now);
|
|
105
|
+
return freshResult;
|
|
106
|
+
},
|
|
107
|
+
bust: function () {
|
|
108
|
+
var prevSize = entries.size;
|
|
109
|
+
entries.clear();
|
|
110
|
+
if (auditOn) {
|
|
111
|
+
try {
|
|
112
|
+
audit().safeEmit({
|
|
113
|
+
action: "flag.cache.bust",
|
|
114
|
+
outcome: "success",
|
|
115
|
+
actor: null,
|
|
116
|
+
metadata: { prevSize: prevSize },
|
|
117
|
+
});
|
|
118
|
+
} catch (_e) { /* drop-silent */ }
|
|
119
|
+
}
|
|
120
|
+
return prevSize;
|
|
121
|
+
},
|
|
122
|
+
stats: function () {
|
|
123
|
+
return {
|
|
124
|
+
size: entries.size,
|
|
125
|
+
hits: hits,
|
|
126
|
+
misses: misses,
|
|
127
|
+
evictions: evictions,
|
|
128
|
+
hitRatio: (hits + misses) === 0 ? 0 : hits / (hits + misses),
|
|
129
|
+
ttlMs: ttlMs,
|
|
130
|
+
maxEntries: maxEntries,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { cache: cache, FlagError: FlagError };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Flag evaluation context — the operator-supplied object describing
|
|
4
|
+
* the subject of a flag evaluation: targeting key, user attributes,
|
|
5
|
+
* tenant id, environment, custom attributes.
|
|
6
|
+
*
|
|
7
|
+
* Per the OpenFeature specification:
|
|
8
|
+
* - `targetingKey` is the canonical identity for percentage-bucket
|
|
9
|
+
* stickiness (so a 50% rollout consistently picks the SAME 50%
|
|
10
|
+
* of users across re-evaluations).
|
|
11
|
+
* - All other attributes flow through targeting rules.
|
|
12
|
+
*
|
|
13
|
+
* The framework's helper produces a frozen, normalised context object.
|
|
14
|
+
* Operators compose contexts incrementally: start from `fromRequest`
|
|
15
|
+
* (extracts user / tenant / locale from req), augment with `merge`,
|
|
16
|
+
* then evaluate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
var nodeCrypto = require("crypto");
|
|
20
|
+
var validateOpts = require("./validate-opts");
|
|
21
|
+
var lazyRequire = require("./lazy-require");
|
|
22
|
+
var { defineClass } = require("./framework-error");
|
|
23
|
+
var FlagError = defineClass("FlagError", { alwaysPermanent: true });
|
|
24
|
+
|
|
25
|
+
var fwCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
26
|
+
|
|
27
|
+
function _normalize(input, label) {
|
|
28
|
+
if (input == null) return {};
|
|
29
|
+
if (typeof input !== "object" || Array.isArray(input)) {
|
|
30
|
+
throw new FlagError("flag/bad-context",
|
|
31
|
+
(label || "context") + ": must be a plain object");
|
|
32
|
+
}
|
|
33
|
+
var out = {};
|
|
34
|
+
for (var key in input) {
|
|
35
|
+
if (!Object.prototype.hasOwnProperty.call(input, key)) continue;
|
|
36
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
37
|
+
continue; // poisoned-keys defense
|
|
38
|
+
}
|
|
39
|
+
out[key] = input[key];
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function create(input) {
|
|
45
|
+
var normalised = _normalize(input, "create");
|
|
46
|
+
if (normalised.targetingKey != null &&
|
|
47
|
+
typeof normalised.targetingKey !== "string") {
|
|
48
|
+
throw new FlagError("flag/bad-context",
|
|
49
|
+
"create: targetingKey must be a string");
|
|
50
|
+
}
|
|
51
|
+
return Object.freeze(normalised);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function merge(base, overlay) {
|
|
55
|
+
var b = _normalize(base, "merge.base");
|
|
56
|
+
var o = _normalize(overlay, "merge.overlay");
|
|
57
|
+
var out = {};
|
|
58
|
+
for (var k1 in b) {
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(b, k1)) out[k1] = b[k1];
|
|
60
|
+
}
|
|
61
|
+
for (var k2 in o) {
|
|
62
|
+
if (Object.prototype.hasOwnProperty.call(o, k2)) out[k2] = o[k2];
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze(out);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fromRequest(req, opts) {
|
|
68
|
+
opts = opts || {};
|
|
69
|
+
validateOpts(opts, ["userKey", "tenantKey", "extra"], "flag.context.fromRequest");
|
|
70
|
+
if (!req || typeof req !== "object") {
|
|
71
|
+
return create({});
|
|
72
|
+
}
|
|
73
|
+
var ctx = {};
|
|
74
|
+
if (req.user) {
|
|
75
|
+
if (typeof req.user.id === "string") ctx.userId = req.user.id;
|
|
76
|
+
if (typeof req.user.role === "string") ctx.role = req.user.role;
|
|
77
|
+
if (typeof req.user.email === "string") ctx.email = req.user.email;
|
|
78
|
+
if (req.user.tenantId != null) ctx.tenantId = req.user.tenantId;
|
|
79
|
+
}
|
|
80
|
+
var headers = req.headers || {};
|
|
81
|
+
if (typeof headers["accept-language"] === "string") {
|
|
82
|
+
ctx.locale = headers["accept-language"].split(",")[0].split(";")[0].trim();
|
|
83
|
+
}
|
|
84
|
+
if (typeof headers["user-agent"] === "string") {
|
|
85
|
+
ctx.userAgent = headers["user-agent"];
|
|
86
|
+
}
|
|
87
|
+
// Targeting key: prefer explicit userKey opt, then user.id, then a
|
|
88
|
+
// request-stable hash of (clientIp + userAgent) for anonymous flows.
|
|
89
|
+
var tk = null;
|
|
90
|
+
if (typeof opts.userKey === "string" && opts.userKey.length > 0) {
|
|
91
|
+
tk = opts.userKey;
|
|
92
|
+
} else if (req.user && typeof req.user.id === "string") {
|
|
93
|
+
tk = req.user.id;
|
|
94
|
+
} else {
|
|
95
|
+
var ip = (typeof headers["x-forwarded-for"] === "string" &&
|
|
96
|
+
headers["x-forwarded-for"].split(",")[0].trim()) ||
|
|
97
|
+
(req.connection && req.connection.remoteAddress) || "";
|
|
98
|
+
var ua = headers["user-agent"] || "";
|
|
99
|
+
tk = "anon:" + fwCrypto().sha3Hash(ip + ":" + ua).slice(0, 16); // allow:raw-byte-literal — base16 prefix len
|
|
100
|
+
}
|
|
101
|
+
ctx.targetingKey = tk;
|
|
102
|
+
|
|
103
|
+
if (opts.extra && typeof opts.extra === "object") {
|
|
104
|
+
for (var k in opts.extra) {
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(opts.extra, k)) {
|
|
106
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
|
|
107
|
+
ctx[k] = opts.extra[k];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return create(ctx);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Percentage-bucket helper — deterministic hash of (targetingKey +
|
|
115
|
+
// flagKey) into [0, 100) for percentage-based rollouts.
|
|
116
|
+
function bucketOf(targetingKey, flagKey) {
|
|
117
|
+
if (typeof targetingKey !== "string" || typeof flagKey !== "string" ||
|
|
118
|
+
targetingKey.length === 0 || flagKey.length === 0) {
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
var digest = nodeCrypto.createHash("sha3-512")
|
|
122
|
+
.update(flagKey + ":" + targetingKey).digest();
|
|
123
|
+
// Use first 4 bytes as a uint32, then mod 10000 → 0.00-99.99 with
|
|
124
|
+
// sub-percent granularity.
|
|
125
|
+
var n = digest.readUInt32BE(0);
|
|
126
|
+
return (n % 10000) / 100; // allow:raw-byte-literal — bucket-precision divisor
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
create: create,
|
|
131
|
+
merge: merge,
|
|
132
|
+
fromRequest: fromRequest,
|
|
133
|
+
bucketOf: bucketOf,
|
|
134
|
+
FlagError: FlagError,
|
|
135
|
+
};
|