@blamejs/blamejs-shop 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +157 -117
- package/lib/asset-manifest.json +1 -1
- package/lib/currency-rounding.js +2 -14
- package/lib/index.js +1 -0
- package/lib/text-guard.js +227 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +3 -2
- package/lib/vendor/blamejs/SECURITY.md +3 -0
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/agent-event-bus.js +4 -4
- package/lib/vendor/blamejs/lib/agent-idempotency.js +6 -6
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +9 -9
- package/lib/vendor/blamejs/lib/agent-posture-chain.js +10 -10
- package/lib/vendor/blamejs/lib/agent-saga.js +6 -7
- package/lib/vendor/blamejs/lib/agent-snapshot.js +8 -8
- package/lib/vendor/blamejs/lib/agent-stream.js +3 -3
- package/lib/vendor/blamejs/lib/agent-tenant.js +4 -4
- package/lib/vendor/blamejs/lib/agent-trace.js +5 -5
- package/lib/vendor/blamejs/lib/ai-disclosure.js +3 -3
- package/lib/vendor/blamejs/lib/app.js +2 -2
- package/lib/vendor/blamejs/lib/archive-read.js +1 -1
- package/lib/vendor/blamejs/lib/archive-tar-read.js +1 -1
- package/lib/vendor/blamejs/lib/archive-wrap.js +5 -5
- package/lib/vendor/blamejs/lib/audit-tools.js +65 -5
- package/lib/vendor/blamejs/lib/audit.js +2 -2
- package/lib/vendor/blamejs/lib/auth/ciba.js +1 -1
- package/lib/vendor/blamejs/lib/auth/dpop.js +1 -1
- package/lib/vendor/blamejs/lib/auth/fal.js +1 -1
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +2 -3
- package/lib/vendor/blamejs/lib/auth/jwt-external.js +2 -2
- package/lib/vendor/blamejs/lib/auth/oauth.js +9 -9
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +7 -7
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +1 -1
- package/lib/vendor/blamejs/lib/auth/openid-federation.js +5 -5
- package/lib/vendor/blamejs/lib/auth/passkey.js +6 -6
- package/lib/vendor/blamejs/lib/auth/saml.js +1 -1
- package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +3 -6
- package/lib/vendor/blamejs/lib/backup/index.js +18 -18
- package/lib/vendor/blamejs/lib/cache.js +4 -4
- package/lib/vendor/blamejs/lib/calendar.js +5 -5
- package/lib/vendor/blamejs/lib/circuit-breaker.js +1 -1
- package/lib/vendor/blamejs/lib/cms-codec.js +2 -2
- package/lib/vendor/blamejs/lib/compliance.js +14 -14
- package/lib/vendor/blamejs/lib/crypto-field.js +58 -21
- package/lib/vendor/blamejs/lib/crypto.js +5 -6
- package/lib/vendor/blamejs/lib/db-query.js +131 -9
- package/lib/vendor/blamejs/lib/db.js +106 -22
- package/lib/vendor/blamejs/lib/external-db.js +64 -16
- package/lib/vendor/blamejs/lib/framework-schema.js +4 -4
- package/lib/vendor/blamejs/lib/guard-list-id.js +2 -2
- package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -2
- package/lib/vendor/blamejs/lib/incident-report.js +150 -0
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
- package/lib/vendor/blamejs/lib/mail-deploy.js +3 -3
- package/lib/vendor/blamejs/lib/mail-server-managesieve.js +2 -2
- package/lib/vendor/blamejs/lib/mail-server-pop3.js +2 -2
- package/lib/vendor/blamejs/lib/mail-store.js +1 -1
- package/lib/vendor/blamejs/lib/metrics.js +8 -8
- package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/dpop.js +5 -5
- package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +21 -22
- package/lib/vendor/blamejs/lib/middleware/protected-resource-metadata.js +2 -2
- package/lib/vendor/blamejs/lib/network-dns-resolver.js +2 -2
- package/lib/vendor/blamejs/lib/network-dns.js +1 -2
- package/lib/vendor/blamejs/lib/network-tls.js +0 -1
- package/lib/vendor/blamejs/lib/outbox.js +1 -1
- package/lib/vendor/blamejs/lib/pqc-agent.js +1 -1
- package/lib/vendor/blamejs/lib/retention.js +1 -1
- package/lib/vendor/blamejs/lib/retry.js +1 -1
- package/lib/vendor/blamejs/lib/safe-archive.js +2 -2
- package/lib/vendor/blamejs/lib/safe-ical.js +2 -2
- package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
- package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +1 -1
- package/lib/vendor/blamejs/lib/self-update.js +2 -2
- package/lib/vendor/blamejs/lib/static.js +1 -1
- package/lib/vendor/blamejs/lib/subject.js +2 -2
- package/lib/vendor/blamejs/lib/vault/index.js +64 -1
- package/lib/vendor/blamejs/lib/vault/rotate.js +19 -0
- package/lib/vendor/blamejs/lib/vendor-data.js +1 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.7.json +77 -0
- package/lib/vendor/blamejs/scripts/release.js +28 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools-dual-control.test.js +115 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +163 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-column-gate.test.js +150 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-key-aad.test.js +109 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +53 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/incident-report.test.js +65 -0
- package/lib/webhook-subscriptions.js +11 -24
- package/lib/webhooks.js +12 -33
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.cryptoField derived-hash modes.
|
|
4
|
+
*
|
|
5
|
+
* Equality-lookup ("derived") hashes for sealed columns can be computed
|
|
6
|
+
* two ways:
|
|
7
|
+
* - salted-sha3 (default) — SHA3-512 over a per-deployment salt.
|
|
8
|
+
* - hmac-shake256 (opt-in) — keyed MAC off vault.getDerivedHashMacKey,
|
|
9
|
+
* so the hash is unforgeable without the
|
|
10
|
+
* per-deployment MAC key.
|
|
11
|
+
*
|
|
12
|
+
* The mode is chosen per-table (derivedHashMode) or per-column
|
|
13
|
+
* (spec.mode). The decision lives in _computeDerivedHash; call sites
|
|
14
|
+
* must never hand-roll the hash.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
var helpers = require("../helpers");
|
|
18
|
+
var b = helpers.b;
|
|
19
|
+
var check = helpers.check;
|
|
20
|
+
var fs = require("fs");
|
|
21
|
+
var os = require("os");
|
|
22
|
+
var path = require("path");
|
|
23
|
+
|
|
24
|
+
async function run() {
|
|
25
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cf-dh-"));
|
|
26
|
+
await b.vault.init({ mode: "plaintext", dataDir: dir });
|
|
27
|
+
|
|
28
|
+
// ---- salted-sha3 is the default: SHA3-512 → 128 hex chars ----
|
|
29
|
+
b.cryptoField.registerTable("cf_dh_t1", {
|
|
30
|
+
sealedFields: ["email"],
|
|
31
|
+
derivedHashes: { emailHash: { from: "email" } },
|
|
32
|
+
});
|
|
33
|
+
var saltedH = b.cryptoField.lookupHash("cf_dh_t1", "email", "a@b.com").value;
|
|
34
|
+
check("salted-sha3 default is 128 hex (SHA3-512)", saltedH.length === 128);
|
|
35
|
+
check("salted-sha3 is deterministic",
|
|
36
|
+
saltedH === b.cryptoField.lookupHash("cf_dh_t1", "email", "a@b.com").value);
|
|
37
|
+
|
|
38
|
+
// ---- hmac-shake256 table mode: SHAKE256/32 → 64 hex chars ----
|
|
39
|
+
b.cryptoField.registerTable("cf_dh_t2", {
|
|
40
|
+
sealedFields: ["email"],
|
|
41
|
+
derivedHashes: { emailHash: { from: "email" } },
|
|
42
|
+
derivedHashMode: "hmac-shake256",
|
|
43
|
+
});
|
|
44
|
+
var keyedH = b.cryptoField.lookupHash("cf_dh_t2", "email", "a@b.com").value;
|
|
45
|
+
check("hmac-shake256 is 64 hex (SHAKE256/32)", keyedH.length === 64);
|
|
46
|
+
check("hmac-shake256 is deterministic",
|
|
47
|
+
keyedH === b.cryptoField.lookupHash("cf_dh_t2", "email", "a@b.com").value);
|
|
48
|
+
check("keyed hash differs from salted hash", keyedH !== saltedH);
|
|
49
|
+
|
|
50
|
+
// ---- per-column mode override on a salted-default table ----
|
|
51
|
+
b.cryptoField.registerTable("cf_dh_t3", {
|
|
52
|
+
sealedFields: ["ssn"],
|
|
53
|
+
derivedHashes: { ssnHash: { from: "ssn", mode: "hmac-shake256" } },
|
|
54
|
+
});
|
|
55
|
+
check("per-column hmac-shake256 override → 64 hex",
|
|
56
|
+
b.cryptoField.lookupHash("cf_dh_t3", "ssn", "x").value.length === 64);
|
|
57
|
+
|
|
58
|
+
// ---- MAC key surface: 32 bytes, per-deployment ----
|
|
59
|
+
var macKey = b.vault.getDerivedHashMacKey();
|
|
60
|
+
check("getDerivedHashMacKey is a 32-byte Buffer",
|
|
61
|
+
Buffer.isBuffer(macKey) && macKey.length === 32);
|
|
62
|
+
|
|
63
|
+
// A fresh deployment (new vault dir + MAC key) yields a different
|
|
64
|
+
// keyed hash for the same input — the keyed hash is bound to the MAC
|
|
65
|
+
// key, not just the input.
|
|
66
|
+
var dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cf-dh2-"));
|
|
67
|
+
b.vault._resetForTest();
|
|
68
|
+
await b.vault.init({ mode: "plaintext", dataDir: dir2 });
|
|
69
|
+
b.cryptoField.registerTable("cf_dh_t2", {
|
|
70
|
+
sealedFields: ["email"],
|
|
71
|
+
derivedHashes: { emailHash: { from: "email" } },
|
|
72
|
+
derivedHashMode: "hmac-shake256",
|
|
73
|
+
});
|
|
74
|
+
var keyedH2 = b.cryptoField.lookupHash("cf_dh_t2", "email", "a@b.com").value;
|
|
75
|
+
check("new deployment MAC key → different keyed hash", keyedH2 !== keyedH);
|
|
76
|
+
|
|
77
|
+
// ---- input validation: unknown modes are rejected at registerTable ----
|
|
78
|
+
var badTableMode = false;
|
|
79
|
+
try { b.cryptoField.registerTable("cf_dh_bad", { derivedHashMode: "md5" }); }
|
|
80
|
+
catch (e) { badTableMode = /derivedHashMode must be/.test(e.message); }
|
|
81
|
+
check("bad derivedHashMode throws at registerTable", badTableMode);
|
|
82
|
+
|
|
83
|
+
var badColMode = false;
|
|
84
|
+
try {
|
|
85
|
+
b.cryptoField.registerTable("cf_dh_bad2", {
|
|
86
|
+
derivedHashes: { h: { from: "x", mode: "rot13" } },
|
|
87
|
+
});
|
|
88
|
+
} catch (e) { badColMode = /mode must be/.test(e.message); }
|
|
89
|
+
check("bad per-column mode throws at registerTable", badColMode);
|
|
90
|
+
|
|
91
|
+
console.log("OK — crypto-field derived-hash tests");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { run: run };
|
|
95
|
+
if (require.main === module) {
|
|
96
|
+
// Rethrow on failure so Node surfaces the error and exits non-zero,
|
|
97
|
+
// instead of logging the caught error object — a taint analyzer traces
|
|
98
|
+
// a logged error back to the test passphrase fixture (a non-secret
|
|
99
|
+
// constant) and raises a false clear-text-logging alert.
|
|
100
|
+
run().then(function () { process.exit(0); })
|
|
101
|
+
.catch(function (err) { process.exitCode = 1; throw err; });
|
|
102
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.db column-membership gate + whereRaw string-literal refusal.
|
|
4
|
+
*
|
|
5
|
+
* Column gate: a Query may only reference columns the table declared in
|
|
6
|
+
* its schema. The default mode is "reject" (throw), so a typo'd or
|
|
7
|
+
* attacker-influenced column name fails closed rather than silently
|
|
8
|
+
* matching nothing (or, for a raw fragment, opening an injection path).
|
|
9
|
+
* Modes: reject (default) | warn (audit, allow) | off (no gate).
|
|
10
|
+
* .allowedColumns([...]) narrows further and is ALWAYS enforced.
|
|
11
|
+
*
|
|
12
|
+
* whereRaw / WhereBuilder.raw refuse an embedded string literal
|
|
13
|
+
* ('...') unless { allowLiterals: true } — every value must bind
|
|
14
|
+
* through the params array.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
var helpers = require("../helpers");
|
|
18
|
+
var b = helpers.b;
|
|
19
|
+
var check = helpers.check;
|
|
20
|
+
var fs = helpers.fs;
|
|
21
|
+
var os = helpers.os;
|
|
22
|
+
var path = helpers.path;
|
|
23
|
+
|
|
24
|
+
var SCHEMA = [{
|
|
25
|
+
name: "things",
|
|
26
|
+
columns: {
|
|
27
|
+
_id: "TEXT PRIMARY KEY",
|
|
28
|
+
name: "TEXT",
|
|
29
|
+
status: "TEXT DEFAULT 'active'",
|
|
30
|
+
},
|
|
31
|
+
indexes: ["status"],
|
|
32
|
+
}];
|
|
33
|
+
|
|
34
|
+
async function initDb(tmpDir, columnGate) {
|
|
35
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
36
|
+
helpers.setTestPassphraseEnv();
|
|
37
|
+
b.cluster._resetForTest();
|
|
38
|
+
b.audit._resetForTest();
|
|
39
|
+
b.vault._resetForTest();
|
|
40
|
+
b.db._resetForTest();
|
|
41
|
+
await b.vault.init({ dataDir: tmpDir });
|
|
42
|
+
var opts = { dataDir: tmpDir, tmpDir: path.join(tmpDir, "tmpfs"), schema: SCHEMA };
|
|
43
|
+
if (columnGate !== undefined) opts.columnGate = columnGate;
|
|
44
|
+
await b.db.init(opts);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function threwMatching(fn, pattern) {
|
|
48
|
+
try { fn(); } catch (e) { return pattern.test(e.message) ? e : null; }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function run() {
|
|
53
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-colgate-"));
|
|
54
|
+
|
|
55
|
+
// ---- default mode is reject ----
|
|
56
|
+
await initDb(tmp);
|
|
57
|
+
b.db.from("things").insertOne({ _id: "t1", name: "a", status: "active" });
|
|
58
|
+
|
|
59
|
+
// declared columns pass
|
|
60
|
+
check("declared column where() passes",
|
|
61
|
+
b.db.from("things").where({ name: "a" }).first().status === "active");
|
|
62
|
+
check("declared column select() passes",
|
|
63
|
+
b.db.from("things").select(["name", "status"]).where({ _id: "t1" }).first().name === "a");
|
|
64
|
+
|
|
65
|
+
// undeclared column is rejected (fail-closed)
|
|
66
|
+
check("undeclared where() column rejected",
|
|
67
|
+
!!threwMatching(function () { b.db.from("things").where({ nope: 1 }); },
|
|
68
|
+
/not a declared column of 'things'/));
|
|
69
|
+
check("undeclared orderBy() column rejected",
|
|
70
|
+
!!threwMatching(function () { b.db.from("things").orderBy("bogus"); },
|
|
71
|
+
/not a declared column/));
|
|
72
|
+
check("undeclared select() column rejected",
|
|
73
|
+
!!threwMatching(function () { b.db.from("things").select(["ghost"]); },
|
|
74
|
+
/not a declared column/));
|
|
75
|
+
|
|
76
|
+
// ---- getDeclaredColumns ----
|
|
77
|
+
check("b.db.getDeclaredColumns is fn", typeof b.db.getDeclaredColumns === "function");
|
|
78
|
+
var declared = b.db.getDeclaredColumns("things");
|
|
79
|
+
check("getDeclaredColumns lists schema columns",
|
|
80
|
+
declared.indexOf("_id") !== -1 && declared.indexOf("name") !== -1 && declared.indexOf("status") !== -1);
|
|
81
|
+
check("getDeclaredColumns on unknown table → null",
|
|
82
|
+
b.db.getDeclaredColumns("no_such_table") === null);
|
|
83
|
+
|
|
84
|
+
// ---- allowedColumns() narrows + is always enforced ----
|
|
85
|
+
check("allowedColumns() allows a member",
|
|
86
|
+
b.db.from("things").allowedColumns(["name"]).where({ name: "a" }).first().name === "a");
|
|
87
|
+
check("allowedColumns() rejects a declared-but-not-allowed column",
|
|
88
|
+
!!threwMatching(function () {
|
|
89
|
+
b.db.from("things").allowedColumns(["name"]).where({ status: "active" });
|
|
90
|
+
}, /not in the allowedColumns\(\) set/));
|
|
91
|
+
check("allowedColumns([]) rejected (non-empty array required)",
|
|
92
|
+
!!threwMatching(function () { b.db.from("things").allowedColumns([]); },
|
|
93
|
+
/non-empty array/));
|
|
94
|
+
|
|
95
|
+
// ---- A5: whereRaw refuses embedded string literals ----
|
|
96
|
+
var litErr = threwMatching(function () { b.db.from("things").whereRaw("status = 'active'"); },
|
|
97
|
+
/string literal/);
|
|
98
|
+
check("whereRaw refuses embedded string literal", !!litErr);
|
|
99
|
+
check("whereRaw literal error code is sql/raw-literal", litErr && litErr.code === "sql/raw-literal");
|
|
100
|
+
check("whereRaw with bound params is accepted",
|
|
101
|
+
b.db.from("things").whereRaw("status = ?", ["active"]).first().name === "a");
|
|
102
|
+
check("whereRaw allowLiterals opt-in is accepted",
|
|
103
|
+
b.db.from("things").whereRaw("status = 'active'", [], { allowLiterals: true }).first().name === "a");
|
|
104
|
+
|
|
105
|
+
b.db.close();
|
|
106
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
107
|
+
|
|
108
|
+
// ---- warn mode: audited, not thrown ----
|
|
109
|
+
var tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-colgate-warn-"));
|
|
110
|
+
await initDb(tmp2, "warn");
|
|
111
|
+
var warnThrew = false;
|
|
112
|
+
try { b.db.from("things").where({ nope: 1 }); } catch (_e) { warnThrew = true; }
|
|
113
|
+
check("warn mode does not throw on undeclared column", warnThrew === false);
|
|
114
|
+
b.db.close();
|
|
115
|
+
fs.rmSync(tmp2, { recursive: true, force: true });
|
|
116
|
+
|
|
117
|
+
// ---- off mode: no gate ----
|
|
118
|
+
var tmp3 = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-colgate-off-"));
|
|
119
|
+
await initDb(tmp3, "off");
|
|
120
|
+
var offThrew = false;
|
|
121
|
+
try { b.db.from("things").where({ nope: 1 }); } catch (_e) { offThrew = true; }
|
|
122
|
+
check("off mode does not gate undeclared column", offThrew === false);
|
|
123
|
+
b.db.close();
|
|
124
|
+
fs.rmSync(tmp3, { recursive: true, force: true });
|
|
125
|
+
|
|
126
|
+
// ---- bad columnGate value rejected at db.init ----
|
|
127
|
+
var tmp4 = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-colgate-bad-"));
|
|
128
|
+
var initThrew = false;
|
|
129
|
+
try { await initDb(tmp4, "loud"); } catch (e) { initThrew = /columnGate/.test(e.message); }
|
|
130
|
+
check("db.init rejects unknown columnGate mode", initThrew);
|
|
131
|
+
try { b.db.close(); } catch (_e) {}
|
|
132
|
+
fs.rmSync(tmp4, { recursive: true, force: true });
|
|
133
|
+
|
|
134
|
+
b.audit._resetForTest();
|
|
135
|
+
b.db._resetForTest();
|
|
136
|
+
b.vault._resetForTest();
|
|
137
|
+
b.cluster._resetForTest();
|
|
138
|
+
|
|
139
|
+
console.log("OK — db column-gate tests");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { run: run };
|
|
143
|
+
if (require.main === module) {
|
|
144
|
+
// Rethrow on failure so Node surfaces the error and exits non-zero,
|
|
145
|
+
// instead of logging the caught error object — a taint analyzer traces
|
|
146
|
+
// a logged error back to the test passphrase fixture (a non-secret
|
|
147
|
+
// constant) and raises a false clear-text-logging alert.
|
|
148
|
+
run().then(function () { process.exit(0); })
|
|
149
|
+
.catch(function (err) { process.exitCode = 1; throw err; });
|
|
150
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.db encryption-key AAD binding.
|
|
4
|
+
*
|
|
5
|
+
* The database encryption key (db.key.enc) is sealed with additional
|
|
6
|
+
* authenticated data bound to its purpose, data directory, and key
|
|
7
|
+
* path. That binding means a sealed key blob cannot be silently
|
|
8
|
+
* relocated to a different deployment / path and unsealed there — the
|
|
9
|
+
* AEAD authentication fails. A legacy plain-sealed key (pre-binding)
|
|
10
|
+
* is transparently upgraded to the AAD-sealed format on next load.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
var helpers = require("../helpers");
|
|
14
|
+
var b = helpers.b;
|
|
15
|
+
var check = helpers.check;
|
|
16
|
+
var fs = helpers.fs;
|
|
17
|
+
var os = helpers.os;
|
|
18
|
+
var path = helpers.path;
|
|
19
|
+
var setupTestDb = helpers.setupTestDb;
|
|
20
|
+
|
|
21
|
+
var DB_KEY_PURPOSE = "blamejs/db-encryption-key/v1";
|
|
22
|
+
var SCHEMA = [{
|
|
23
|
+
name: "things",
|
|
24
|
+
columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" },
|
|
25
|
+
}];
|
|
26
|
+
|
|
27
|
+
function dbKeyAad(tmp, keyPath) {
|
|
28
|
+
return b.vault.aad.buildContextAad({
|
|
29
|
+
purpose: DB_KEY_PURPOSE,
|
|
30
|
+
dataDir: path.resolve(tmp),
|
|
31
|
+
keyPath: path.resolve(keyPath),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function reinitDbOnly(tmp) {
|
|
36
|
+
helpers.setTestPassphraseEnv();
|
|
37
|
+
b.db._resetForTest();
|
|
38
|
+
await b.db.init({ dataDir: tmp, tmpDir: path.join(tmp, "tmpfs"), schema: SCHEMA });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function run() {
|
|
42
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-dbkey-aad-"));
|
|
43
|
+
// setupTestDb resets + inits vault and db; vault stays initialized
|
|
44
|
+
// through every phase below (we only reset db between phases).
|
|
45
|
+
await setupTestDb(tmp, SCHEMA);
|
|
46
|
+
b.db.from("things").insertOne({ _id: "x", v: "secret" });
|
|
47
|
+
|
|
48
|
+
var keyPath = path.join(tmp, "db.key.enc");
|
|
49
|
+
var sealed = fs.readFileSync(keyPath, "utf8");
|
|
50
|
+
|
|
51
|
+
// ---- on-disk key is AAD-sealed ----
|
|
52
|
+
check("db.key.enc is AAD-sealed", b.vault.aad.isAadSealed(sealed) === true);
|
|
53
|
+
|
|
54
|
+
// ---- correct AAD unseals to a non-empty key ----
|
|
55
|
+
var b64 = b.vault.aad.unseal(sealed, dbKeyAad(tmp, keyPath));
|
|
56
|
+
check("correct AAD unseals db key to non-empty base64", typeof b64 === "string" && b64.length > 0);
|
|
57
|
+
|
|
58
|
+
// ---- binding: a different keyPath / dataDir fails to unseal ----
|
|
59
|
+
var bindKeyPathThrew = false;
|
|
60
|
+
try { b.vault.aad.unseal(sealed, dbKeyAad(tmp, path.join(tmp, "elsewhere.enc"))); }
|
|
61
|
+
catch (_e) { bindKeyPathThrew = true; }
|
|
62
|
+
check("db key won't unseal under a different keyPath", bindKeyPathThrew);
|
|
63
|
+
|
|
64
|
+
var bindDirThrew = false;
|
|
65
|
+
try {
|
|
66
|
+
b.vault.aad.unseal(sealed, b.vault.aad.buildContextAad({
|
|
67
|
+
purpose: DB_KEY_PURPOSE,
|
|
68
|
+
dataDir: path.resolve(tmp, "..", "some-other-deployment"),
|
|
69
|
+
keyPath: path.resolve(keyPath),
|
|
70
|
+
}));
|
|
71
|
+
} catch (_e) { bindDirThrew = true; }
|
|
72
|
+
check("db key won't unseal under a different dataDir", bindDirThrew);
|
|
73
|
+
|
|
74
|
+
// ---- round-trip: re-init at the same dir decrypts the row ----
|
|
75
|
+
b.db.close();
|
|
76
|
+
await reinitDbOnly(tmp);
|
|
77
|
+
check("re-init at same dir decrypts row through AAD-sealed key",
|
|
78
|
+
b.db.from("things").where({ _id: "x" }).first().v === "secret");
|
|
79
|
+
|
|
80
|
+
// ---- legacy plain-sealed key is upgraded to AAD on next load ----
|
|
81
|
+
b.db.close();
|
|
82
|
+
fs.writeFileSync(keyPath, b.vault.seal(b64), { mode: 0o600 });
|
|
83
|
+
check("legacy seed: db.key.enc is NOT AAD-sealed",
|
|
84
|
+
b.vault.aad.isAadSealed(fs.readFileSync(keyPath, "utf8")) === false);
|
|
85
|
+
await reinitDbOnly(tmp);
|
|
86
|
+
check("legacy plain-sealed key upgraded to AAD on load",
|
|
87
|
+
b.vault.aad.isAadSealed(fs.readFileSync(keyPath, "utf8")) === true);
|
|
88
|
+
check("row still decrypts after legacy key upgrade",
|
|
89
|
+
b.db.from("things").where({ _id: "x" }).first().v === "secret");
|
|
90
|
+
|
|
91
|
+
b.db.close();
|
|
92
|
+
b.audit._resetForTest();
|
|
93
|
+
b.db._resetForTest();
|
|
94
|
+
b.vault._resetForTest();
|
|
95
|
+
b.cluster._resetForTest();
|
|
96
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_e) {}
|
|
97
|
+
|
|
98
|
+
console.log("OK — db-key AAD tests");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { run: run };
|
|
102
|
+
if (require.main === module) {
|
|
103
|
+
// Rethrow on failure so Node surfaces the error and exits non-zero,
|
|
104
|
+
// instead of logging the caught error object — a taint analyzer traces
|
|
105
|
+
// a logged error back to the test passphrase fixture (a non-secret
|
|
106
|
+
// constant) and raises a false clear-text-logging alert.
|
|
107
|
+
run().then(function () { process.exit(0); })
|
|
108
|
+
.catch(function (err) { process.exitCode = 1; throw err; });
|
|
109
|
+
}
|
|
@@ -168,6 +168,59 @@ async function run() {
|
|
|
168
168
|
await new Promise(function (r) { setImmediate(r); });
|
|
169
169
|
check("D-M2: 42501 emit completes without crashing", true);
|
|
170
170
|
|
|
171
|
+
// ---- attempted-relation extraction for auth-failure audits ----
|
|
172
|
+
// A rejected credential's audit row records WHICH relation it tried
|
|
173
|
+
// to reach, so triage can scope blast radius without the raw SQL log.
|
|
174
|
+
// _extractTargetRelation is the defensive parser behind that field.
|
|
175
|
+
var xr = b.externalDb._extractTargetRelation;
|
|
176
|
+
check("extractRelation: bare table after FROM",
|
|
177
|
+
xr("SELECT * FROM accounts WHERE id = $1") === "accounts");
|
|
178
|
+
check("extractRelation: INTO target",
|
|
179
|
+
xr("INSERT INTO audit_log (a) VALUES ($1)") === "audit_log");
|
|
180
|
+
check("extractRelation: UPDATE target",
|
|
181
|
+
xr("UPDATE users SET x = 1") === "users");
|
|
182
|
+
check("extractRelation: schema-qualified",
|
|
183
|
+
xr("SELECT * FROM public.secrets") === "public.secrets");
|
|
184
|
+
check("extractRelation: double-quoted identifier (quotes stripped)",
|
|
185
|
+
xr('SELECT * FROM "Order Items"') === "Order Items");
|
|
186
|
+
check("extractRelation: backtick-quoted identifier (ticks stripped)",
|
|
187
|
+
xr("SELECT * FROM `weird table`") === "weird table");
|
|
188
|
+
check("extractRelation: JOIN target picks first relation",
|
|
189
|
+
xr("SELECT * FROM a JOIN b ON a.id = b.id") === "a");
|
|
190
|
+
// Defensive: unparseable / control-char input returns null rather
|
|
191
|
+
// than leaking a partial fragment into the audit metadata.
|
|
192
|
+
check("extractRelation: no relation keyword returns null", xr("SELECT 1") === null);
|
|
193
|
+
check("extractRelation: non-SQL input returns null",
|
|
194
|
+
xr("just some words here") === null);
|
|
195
|
+
check("extractRelation: empty input returns null", xr("") === null);
|
|
196
|
+
|
|
197
|
+
// The audit hook stamps attemptedTable from the parser. Drive a
|
|
198
|
+
// rejection through the query path and confirm the field is present
|
|
199
|
+
// on the captured audit row.
|
|
200
|
+
var auditRows = [];
|
|
201
|
+
var origEmit = b.audit && b.audit.safeEmit;
|
|
202
|
+
if (origEmit) {
|
|
203
|
+
b.audit.safeEmit = function (rec) {
|
|
204
|
+
if (rec && rec.action === "db.auth.failed") auditRows.push(rec);
|
|
205
|
+
return origEmit.apply(b.audit, arguments);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
var d6 = _instrumentingDriver({
|
|
210
|
+
failOnce: { match: /^SELECT \* FROM payroll/i, code: "28000", message: "auth failed" },
|
|
211
|
+
});
|
|
212
|
+
b.externalDb._resetForTest();
|
|
213
|
+
b.externalDb.init({ backends: { main: { connect: d6.connect, query: d6.query, close: d6.close } } });
|
|
214
|
+
try { await b.externalDb.query("SELECT * FROM payroll WHERE id = $1", [1]); } catch (_e) { /* expected */ }
|
|
215
|
+
await new Promise(function (r) { setImmediate(r); });
|
|
216
|
+
} finally {
|
|
217
|
+
if (origEmit) b.audit.safeEmit = origEmit;
|
|
218
|
+
}
|
|
219
|
+
var payrollRow = auditRows.filter(function (r) {
|
|
220
|
+
return r.metadata && r.metadata.attemptedTable === "payroll";
|
|
221
|
+
});
|
|
222
|
+
check("auth-failure audit carries attemptedTable", payrollRow.length >= 1);
|
|
223
|
+
|
|
171
224
|
b.externalDb._resetForTest();
|
|
172
225
|
}
|
|
173
226
|
|
|
@@ -25,5 +25,70 @@ var check = helpers.check;
|
|
|
25
25
|
try { await ir2.open({}); } catch (e) { threwBadSpec = e.code === "incident-report/bad-regime"; }
|
|
26
26
|
check("incident.open refuses bad spec", threwBadSpec);
|
|
27
27
|
|
|
28
|
+
// ---- createDeadlineClock — running-clock approaching/passed alerts ----
|
|
29
|
+
// Manual-tick mode (autoStart:false) keeps the test deterministic and
|
|
30
|
+
// sleep-free; thresholds fire as the injected "now" crosses fractions
|
|
31
|
+
// of each stage deadline.
|
|
32
|
+
check("createDeadlineClock is fn", typeof b.incident.report.createDeadlineClock === "function");
|
|
33
|
+
|
|
34
|
+
var sent = [];
|
|
35
|
+
var clk = b.incident.report.createDeadlineClock({
|
|
36
|
+
notify: { send: function (m) { sent.push(m); } },
|
|
37
|
+
autoStart: false,
|
|
38
|
+
approachThresholds: [0.5, 0.9],
|
|
39
|
+
});
|
|
40
|
+
// detectedAt=0; stage deadlines initial=100, intermediate=200, final=300.
|
|
41
|
+
clk.track({ id: "inc-1", detectedAt: 0, regime: "gdpr", dueBy: { initial: 100, intermediate: 200, final: 300 } });
|
|
42
|
+
check("deadline clock: tracked=1", clk.status().tracked === 1);
|
|
43
|
+
|
|
44
|
+
clk.tick(50); // 50% of initial → approaching@0.5
|
|
45
|
+
check("tick@50: one approaching@0.5 (initial)",
|
|
46
|
+
sent.length === 1 && sent[0].kind === "deadline_approaching" &&
|
|
47
|
+
sent[0].stage === "initial" && sent[0].threshold === 0.5);
|
|
48
|
+
|
|
49
|
+
clk.tick(55); // still <0.9 of initial → dedupe, no new alert
|
|
50
|
+
check("tick@55: dedupe (no new alert)", sent.length === 1);
|
|
51
|
+
|
|
52
|
+
clk.tick(95); // 95% of initial → approaching@0.9
|
|
53
|
+
check("tick@95: approaching@0.9 fires", sent.length === 2 && sent[1].threshold === 0.9);
|
|
54
|
+
|
|
55
|
+
clk.tick(150); // initial(100) passed
|
|
56
|
+
check("tick@150: initial passed fires once",
|
|
57
|
+
sent.filter(function (m) { return m.kind === "deadline_passed" && m.stage === "initial"; }).length === 1);
|
|
58
|
+
|
|
59
|
+
clk.tick(160); // no duplicate passed for initial
|
|
60
|
+
check("tick@160: no duplicate passed",
|
|
61
|
+
sent.filter(function (m) { return m.kind === "deadline_passed" && m.stage === "initial"; }).length === 1);
|
|
62
|
+
|
|
63
|
+
// Acknowledging a stage suppresses its further alerts.
|
|
64
|
+
clk.acknowledgeSubmission("inc-1", "intermediate");
|
|
65
|
+
clk.tick(250); // intermediate(200) would be passed, but acked → suppressed
|
|
66
|
+
check("acknowledged stage: no passed alert",
|
|
67
|
+
sent.filter(function (m) { return m.stage === "intermediate" && m.kind === "deadline_passed"; }).length === 0);
|
|
68
|
+
|
|
69
|
+
// Construction-time + track-time input validation.
|
|
70
|
+
var badThresh = false;
|
|
71
|
+
try { b.incident.report.createDeadlineClock({ approachThresholds: [1.5] }); }
|
|
72
|
+
catch (e) { badThresh = /between 0 and 1/.test(e.message); }
|
|
73
|
+
check("createDeadlineClock rejects threshold > 1", badThresh);
|
|
74
|
+
|
|
75
|
+
var badTrack = false;
|
|
76
|
+
try { clk.track({ id: "x" }); } catch (e) { badTrack = /dueBy/.test(e.message); }
|
|
77
|
+
check("deadline clock: track without dueBy rejected", badTrack);
|
|
78
|
+
|
|
79
|
+
// Faster regime crosses before slower one on the same tick
|
|
80
|
+
// (registry windows differ — DORA vs GDPR here).
|
|
81
|
+
var sent2 = [];
|
|
82
|
+
var clk2 = b.incident.report.createDeadlineClock({
|
|
83
|
+
notify: { send: function (m) { sent2.push(m); } }, autoStart: false,
|
|
84
|
+
});
|
|
85
|
+
clk2.track({ id: "dora-1", detectedAt: 0, regime: "dora", dueBy: { initial: 4, intermediate: 8, final: 12 } });
|
|
86
|
+
clk2.track({ id: "gdpr-1", detectedAt: 0, regime: "gdpr", dueBy: { initial: 72, intermediate: 100, final: 200 } });
|
|
87
|
+
clk2.tick(5); // DORA initial(4) passed; GDPR initial(72) only ~7% → nothing
|
|
88
|
+
check("deadline clock: faster regime crosses first",
|
|
89
|
+
sent2.filter(function (m) { return m.incidentId === "dora-1" && m.kind === "deadline_passed"; }).length === 1 &&
|
|
90
|
+
sent2.filter(function (m) { return m.incidentId === "gdpr-1"; }).length === 0);
|
|
91
|
+
clk2.stop();
|
|
92
|
+
|
|
28
93
|
console.log("OK — incident.report tests");
|
|
29
94
|
})().catch(function (e) { console.error(e); process.exit(1); });
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
51
|
var b = require("./vendor/blamejs");
|
|
52
|
+
var textGuard = require("./text-guard");
|
|
52
53
|
|
|
53
54
|
var OWNER_TYPES = Object.freeze(["operator", "app", "customer"]);
|
|
54
55
|
var MAX_OWNER_ID_LEN = 200;
|
|
@@ -116,15 +117,6 @@ function _ownerId(s) {
|
|
|
116
117
|
return s;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
// Hostnames that name an internal/metadata destination by name rather
|
|
120
|
-
// than by IP literal — the same denylist the delivery primitive
|
|
121
|
-
// (lib/webhooks.js) applies. A literal-IP host is classified directly via
|
|
122
|
-
// b.ssrfGuard.classify (no DNS); these names cover the by-name reach.
|
|
123
|
-
var SUBSCRIPTION_BLOCKED_HOSTS = Object.freeze({
|
|
124
|
-
"localhost": 1,
|
|
125
|
-
"metadata.google.internal": 1,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
120
|
function _endpointUrl(url) {
|
|
129
121
|
if (typeof url !== "string" || url.length === 0) {
|
|
130
122
|
throw new TypeError("webhookSubscriptions: endpoint_url must be a non-empty string");
|
|
@@ -143,21 +135,16 @@ function _endpointUrl(url) {
|
|
|
143
135
|
}
|
|
144
136
|
// SSRF: refuse internal / loopback / link-local / cloud-metadata
|
|
145
137
|
// destinations so a registered subscription can't probe the host's own
|
|
146
|
-
// network or the instance-credential metadata service.
|
|
147
|
-
//
|
|
148
|
-
// against the by-name metadata/loopback denylist plus
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// dial.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// literal-IP classifier ("169.254.169.254." won't parse as an IP).
|
|
157
|
-
var host = (parsed.hostname || "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
|
|
158
|
-
if (b.ssrfGuard.classify(host) ||
|
|
159
|
-
SUBSCRIPTION_BLOCKED_HOSTS[host] ||
|
|
160
|
-
host === "internal" || /\.internal$/.test(host)) {
|
|
138
|
+
// network or the instance-credential metadata service. textGuard.hostLabel
|
|
139
|
+
// classifies a literal-IP host via the framework SSRF guard (no DNS) and
|
|
140
|
+
// matches a hostname against the by-name metadata / loopback denylist plus
|
|
141
|
+
// the *.internal suffix, after stripping a trailing FQDN dot + IPv6
|
|
142
|
+
// brackets so neither check is evaded. DNS-rebinding (a public name
|
|
143
|
+
// resolving to a private IP) is out of scope here by design — the
|
|
144
|
+
// resolving guard belongs on the delivery dial.
|
|
145
|
+
try {
|
|
146
|
+
textGuard.hostLabel(parsed.hostname, "webhookSubscriptions: endpoint_url host");
|
|
147
|
+
} catch (_e) {
|
|
161
148
|
throw new TypeError("webhookSubscriptions: endpoint_url host is not allowed (internal/loopback/metadata address)");
|
|
162
149
|
}
|
|
163
150
|
return url;
|
package/lib/webhooks.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
var b = require("./vendor/blamejs");
|
|
40
|
+
var textGuard = require("./text-guard");
|
|
40
41
|
|
|
41
42
|
var C = b.constants;
|
|
42
43
|
|
|
@@ -85,16 +86,6 @@ var RATE_WINDOW_MS = C.TIME.minutes(1);
|
|
|
85
86
|
|
|
86
87
|
function _now() { return Date.now(); }
|
|
87
88
|
|
|
88
|
-
// Hostnames that name an internal/metadata destination by name rather
|
|
89
|
-
// than by IP literal. The cloud-metadata services answer on a hostname
|
|
90
|
-
// as well as the 169.254.169.254 link-local IP; localhost resolves to
|
|
91
|
-
// loopback. A literal-IP host is classified directly via b.ssrfGuard
|
|
92
|
-
// (no DNS); these names cover the by-name reach of the same targets.
|
|
93
|
-
var WEBHOOK_BLOCKED_HOSTS = Object.freeze({
|
|
94
|
-
"localhost": 1,
|
|
95
|
-
"metadata.google.internal": 1,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
89
|
function _validateUrl(url) {
|
|
99
90
|
if (typeof url !== "string" || url.length === 0) {
|
|
100
91
|
throw new TypeError("webhooks: url must be a non-empty string");
|
|
@@ -110,29 +101,17 @@ function _validateUrl(url) {
|
|
|
110
101
|
// SSRF: refuse any internal / loopback / link-local / cloud-metadata
|
|
111
102
|
// destination so a registered receiver can't be turned into a probe of
|
|
112
103
|
// the host's own network or the instance-credential metadata service.
|
|
113
|
-
//
|
|
114
|
-
// (loopback / private / link-local / reserved /
|
|
115
|
-
//
|
|
116
|
-
// the by-name metadata/loopback denylist plus the *.internal
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// by-name denylist ("localhost." / "metadata.google.internal.") and the
|
|
125
|
-
// literal-IP classifier ("169.254.169.254." won't parse as an IP).
|
|
126
|
-
var host = (parsed.hostname || "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
|
|
127
|
-
var isBlocked = false;
|
|
128
|
-
if (b.ssrfGuard.classify(host)) {
|
|
129
|
-
isBlocked = true; // literal IP in a refused range
|
|
130
|
-
} else if (WEBHOOK_BLOCKED_HOSTS[host]) {
|
|
131
|
-
isBlocked = true; // localhost / metadata.google.internal
|
|
132
|
-
} else if (host === "internal" || /\.internal$/.test(host)) {
|
|
133
|
-
isBlocked = true; // any *.internal cloud-private name
|
|
134
|
-
}
|
|
135
|
-
if (isBlocked) {
|
|
104
|
+
// textGuard.hostLabel classifies a literal-IP host directly via the
|
|
105
|
+
// framework SSRF guard (loopback / private / link-local / reserved /
|
|
106
|
+
// cloud-metadata, IPv4 + IPv6 + IPv4-mapped) and matches a hostname
|
|
107
|
+
// against the by-name metadata / loopback denylist plus the *.internal
|
|
108
|
+
// suffix, after stripping a trailing FQDN dot + IPv6 brackets so neither
|
|
109
|
+
// check is evaded. DNS-rebinding (a public name that resolves to a
|
|
110
|
+
// private IP) is OUT OF SCOPE here by design — the resolving guard
|
|
111
|
+
// belongs on the delivery dial.
|
|
112
|
+
try {
|
|
113
|
+
textGuard.hostLabel(parsed.hostname, "webhooks: url host");
|
|
114
|
+
} catch (_e) {
|
|
136
115
|
throw new TypeError("webhooks: url host is not allowed (internal/loopback/metadata address)");
|
|
137
116
|
}
|
|
138
117
|
}
|
package/package.json
CHANGED