@blamejs/core 0.6.13 → 0.6.21
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 +8 -0
- package/NOTICE +16 -0
- package/README.md +27 -18
- package/index.js +12 -0
- package/lib/archive.js +8 -7
- package/lib/audit.js +4 -0
- package/lib/auth/password.js +449 -4
- package/lib/bundler.js +8 -8
- package/lib/cache.js +105 -20
- package/lib/cli.js +598 -4
- package/lib/config-drift.js +309 -0
- package/lib/crypto-field.js +37 -0
- package/lib/crypto.js +8 -0
- package/lib/db-query.js +21 -2
- package/lib/db.js +32 -2
- package/lib/dual-control.js +475 -0
- package/lib/file-type.js +265 -0
- package/lib/framework-schema.js +38 -6
- package/lib/http-client-cookie-jar.js +117 -17
- package/lib/http-client.js +81 -3
- package/lib/internal-sha1-hibp.js +34 -0
- package/lib/mail.js +5 -4
- package/lib/middleware/csp-nonce.js +7 -4
- package/lib/middleware/index.js +2 -0
- package/lib/middleware/network-allowlist.js +199 -0
- package/lib/network-dns.js +564 -0
- package/lib/network-heartbeat.js +290 -0
- package/lib/network-nts.js +552 -0
- package/lib/network-proxy.js +246 -0
- package/lib/network-tls.js +326 -0
- package/lib/network.js +233 -0
- package/lib/ntp-check.js +50 -4
- package/lib/object-store/azure-blob.js +16 -42
- package/lib/pagination.js +136 -76
- package/lib/parsers/index.js +16 -2
- package/lib/parsers/safe-ini.js +273 -0
- package/lib/permissions.js +223 -9
- package/lib/pqc-agent.js +4 -4
- package/lib/retention.js +439 -0
- package/lib/security-assert.js +368 -0
- package/lib/session.js +138 -8
- package/lib/ssrf-guard.js +9 -0
- package/lib/vault/index.js +3 -3
- package/lib/vendor/MANIFEST.json +12 -0
- package/lib/vendor/common-passwords-top-10000.txt +10000 -0
- package/package.json +3 -2
- package/sbom.cyclonedx.json +61 -0
package/lib/cli.js
CHANGED
|
@@ -1283,6 +1283,572 @@ async function _runVault(args, ctx) {
|
|
|
1283
1283
|
return 2;
|
|
1284
1284
|
}
|
|
1285
1285
|
|
|
1286
|
+
// ---- Subcommand: security ----------------------------------------
|
|
1287
|
+
//
|
|
1288
|
+
// Runs b.security.assertProduction against the live framework. Useful
|
|
1289
|
+
// out-of-band: ops can re-check posture without rebooting the app.
|
|
1290
|
+
// Boots the framework so resolvers see the actual vault / db / audit-
|
|
1291
|
+
// signing modes; reports pass / fail with the failure-code list.
|
|
1292
|
+
|
|
1293
|
+
var SECURITY_USAGE = [
|
|
1294
|
+
"Usage: blamejs security <subcommand> [flags]",
|
|
1295
|
+
"",
|
|
1296
|
+
"Subcommands:",
|
|
1297
|
+
" assert Run b.security.assertProduction against the framework's",
|
|
1298
|
+
" live posture (vault / dbAtRest / auditSigning / NTP / env",
|
|
1299
|
+
" vars / dataDir POSIX-mode / NODE_ENV). Exit 0 = clean,",
|
|
1300
|
+
" exit 1 = one or more assertions failed.",
|
|
1301
|
+
"",
|
|
1302
|
+
"Flags:",
|
|
1303
|
+
" --data-dir <path> Required for `assert` (the boot dir)",
|
|
1304
|
+
" --vault-mode <mode> plaintext | wrapped (default wrapped)",
|
|
1305
|
+
" --no-ntp-strict Skip the BLAMEJS_NTP_STRICT check",
|
|
1306
|
+
" --require-env <KEY,KEY,...> Comma-separated env vars that MUST be set",
|
|
1307
|
+
" --forbid-env <KEY,KEY,...> Comma-separated env vars that must NOT be set",
|
|
1308
|
+
" --no-vault Skip vault posture check",
|
|
1309
|
+
" --no-db-at-rest Skip dbAtRest posture check",
|
|
1310
|
+
" --no-audit-signing Skip auditSigning posture check",
|
|
1311
|
+
].join("\n");
|
|
1312
|
+
|
|
1313
|
+
async function _runSecurity(args, ctx) {
|
|
1314
|
+
if (args.pos.length === 0) {
|
|
1315
|
+
return cliHelpers.makeReporter(ctx, "blamejs security").usage(SECURITY_USAGE);
|
|
1316
|
+
}
|
|
1317
|
+
var sub = args.pos[0];
|
|
1318
|
+
var report = cliHelpers.makeReporter(ctx, "blamejs security " + sub);
|
|
1319
|
+
if (sub === "help" || args.flags.help || args.flags.h) {
|
|
1320
|
+
return report.helpStdout(SECURITY_USAGE);
|
|
1321
|
+
}
|
|
1322
|
+
if (sub !== "assert") {
|
|
1323
|
+
cliHelpers.makeReporter(ctx, "blamejs security").error("unknown subcommand '" + sub + "'", 2);
|
|
1324
|
+
return cliHelpers.makeReporter(ctx, "blamejs security").usage(SECURITY_USAGE);
|
|
1325
|
+
}
|
|
1326
|
+
var dataDirFlag = args.flags["data-dir"];
|
|
1327
|
+
if (!dataDirFlag || dataDirFlag === true) {
|
|
1328
|
+
return report.error("--data-dir <path> is required", 2);
|
|
1329
|
+
}
|
|
1330
|
+
var dataDir = _resolvePath(String(dataDirFlag), ctx.cwd);
|
|
1331
|
+
var vaultMode = args.flags["vault-mode"] || "wrapped";
|
|
1332
|
+
if (vaultMode !== "wrapped" && vaultMode !== "plaintext") {
|
|
1333
|
+
return report.error("--vault-mode must be 'wrapped' or 'plaintext'", 2);
|
|
1334
|
+
}
|
|
1335
|
+
var booted;
|
|
1336
|
+
try {
|
|
1337
|
+
booted = await cliHelpers.bootApp({ dataDir: dataDir, vaultMode: vaultMode, env: ctx.env });
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
return report.error("boot failed: " + ((e && e.message) || String(e)));
|
|
1340
|
+
}
|
|
1341
|
+
try {
|
|
1342
|
+
var assertOpts = {
|
|
1343
|
+
audit: booted.b.audit,
|
|
1344
|
+
dataDir: dataDir,
|
|
1345
|
+
vault: args.flags["no-vault"] ? false : "wrapped",
|
|
1346
|
+
dbAtRest: args.flags["no-db-at-rest"] ? false : "encrypted",
|
|
1347
|
+
auditSigning: args.flags["no-audit-signing"] ? false : "wrapped",
|
|
1348
|
+
ntpStrict: !args.flags["no-ntp-strict"],
|
|
1349
|
+
};
|
|
1350
|
+
if (args.flags["require-env"] && args.flags["require-env"] !== true) {
|
|
1351
|
+
assertOpts.requireEnv = requestHelpers.parseListHeader(args.flags["require-env"]);
|
|
1352
|
+
}
|
|
1353
|
+
if (args.flags["forbid-env"] && args.flags["forbid-env"] !== true) {
|
|
1354
|
+
assertOpts.forbidEnv = requestHelpers.parseListHeader(args.flags["forbid-env"]);
|
|
1355
|
+
}
|
|
1356
|
+
try {
|
|
1357
|
+
await booted.b.security.assertProduction(assertOpts);
|
|
1358
|
+
return report.ok("production posture clean");
|
|
1359
|
+
} catch (e) {
|
|
1360
|
+
if (e && e.isSecurityAssertError && Array.isArray(e.failures)) {
|
|
1361
|
+
report.write("FAIL: " + e.failures.length + " assertion(s):");
|
|
1362
|
+
for (var fi = 0; fi < e.failures.length; fi++) {
|
|
1363
|
+
report.write(" - " + e.failures[fi].code + ": " + e.failures[fi].message);
|
|
1364
|
+
}
|
|
1365
|
+
return 1;
|
|
1366
|
+
}
|
|
1367
|
+
return report.error((e && e.message) || String(e));
|
|
1368
|
+
}
|
|
1369
|
+
} finally {
|
|
1370
|
+
try { await booted.app.shutdown(); } catch (_e) { /* best-effort */ }
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// ---- Subcommand: config-drift -------------------------------------
|
|
1375
|
+
//
|
|
1376
|
+
// Inspect / verify the b.configDrift sidecar without rebooting. The
|
|
1377
|
+
// sidecar is signed with the audit-signing key, so we boot the
|
|
1378
|
+
// framework to access it. `inspect` prints the full snapshot;
|
|
1379
|
+
// `verify` only reports the verified flag (exit 0 = signed +
|
|
1380
|
+
// untampered, exit 1 = tampered or missing).
|
|
1381
|
+
|
|
1382
|
+
var CONFIG_DRIFT_USAGE = [
|
|
1383
|
+
"Usage: blamejs config-drift <subcommand> [flags]",
|
|
1384
|
+
"",
|
|
1385
|
+
"Subcommands:",
|
|
1386
|
+
" inspect Print the sidecar's snapshot + capturedAt + verified flag",
|
|
1387
|
+
" verify Verify the sidecar's signature (exit code reflects status)",
|
|
1388
|
+
"",
|
|
1389
|
+
"Flags:",
|
|
1390
|
+
" --data-dir <path> Required (sidecar location)",
|
|
1391
|
+
" --baseline <name> Multi-baseline name (default: \"default\")",
|
|
1392
|
+
" --vault-mode <mode> plaintext | wrapped (default wrapped)",
|
|
1393
|
+
" --json inspect: print machine-readable JSON",
|
|
1394
|
+
].join("\n");
|
|
1395
|
+
|
|
1396
|
+
async function _runConfigDrift(args, ctx) {
|
|
1397
|
+
if (args.pos.length === 0) {
|
|
1398
|
+
return cliHelpers.makeReporter(ctx, "blamejs config-drift").usage(CONFIG_DRIFT_USAGE);
|
|
1399
|
+
}
|
|
1400
|
+
var sub = args.pos[0];
|
|
1401
|
+
var report = cliHelpers.makeReporter(ctx, "blamejs config-drift " + sub);
|
|
1402
|
+
if (sub === "help" || args.flags.help || args.flags.h) {
|
|
1403
|
+
return report.helpStdout(CONFIG_DRIFT_USAGE);
|
|
1404
|
+
}
|
|
1405
|
+
if (["inspect", "verify"].indexOf(sub) === -1) {
|
|
1406
|
+
cliHelpers.makeReporter(ctx, "blamejs config-drift").error("unknown subcommand '" + sub + "'", 2);
|
|
1407
|
+
return cliHelpers.makeReporter(ctx, "blamejs config-drift").usage(CONFIG_DRIFT_USAGE);
|
|
1408
|
+
}
|
|
1409
|
+
var dataDirFlag = args.flags["data-dir"];
|
|
1410
|
+
if (!dataDirFlag || dataDirFlag === true) {
|
|
1411
|
+
return report.error("--data-dir <path> is required", 2);
|
|
1412
|
+
}
|
|
1413
|
+
var dataDir = _resolvePath(String(dataDirFlag), ctx.cwd);
|
|
1414
|
+
var vaultMode = args.flags["vault-mode"] || "wrapped";
|
|
1415
|
+
var booted;
|
|
1416
|
+
try {
|
|
1417
|
+
booted = await cliHelpers.bootApp({ dataDir: dataDir, vaultMode: vaultMode, env: ctx.env });
|
|
1418
|
+
} catch (e) {
|
|
1419
|
+
return report.error("boot failed: " + ((e && e.message) || String(e)));
|
|
1420
|
+
}
|
|
1421
|
+
try {
|
|
1422
|
+
var driftOpts = { dataDir: dataDir, audit: booted.b.audit };
|
|
1423
|
+
if (args.flags.baseline && args.flags.baseline !== true) {
|
|
1424
|
+
driftOpts.baseline = String(args.flags.baseline);
|
|
1425
|
+
}
|
|
1426
|
+
var drift = booted.b.configDrift.create(driftOpts);
|
|
1427
|
+
var sidecar = drift.read();
|
|
1428
|
+
if (!sidecar) {
|
|
1429
|
+
report.write("no sidecar present at " + drift.sidecarPath);
|
|
1430
|
+
return sub === "verify" ? 1 : 0;
|
|
1431
|
+
}
|
|
1432
|
+
if (sub === "verify") {
|
|
1433
|
+
if (sidecar.verified) {
|
|
1434
|
+
return report.ok("sidecar verified at " + new Date(sidecar.capturedAt).toISOString());
|
|
1435
|
+
}
|
|
1436
|
+
report.error("sidecar tamper detected: " + sidecar.tamperReason);
|
|
1437
|
+
return 1;
|
|
1438
|
+
}
|
|
1439
|
+
if (args.flags.json) {
|
|
1440
|
+
report.write(JSON.stringify({
|
|
1441
|
+
capturedAt: sidecar.capturedAt,
|
|
1442
|
+
digestHex: sidecar.digestHex,
|
|
1443
|
+
verified: sidecar.verified,
|
|
1444
|
+
tamperReason: sidecar.tamperReason,
|
|
1445
|
+
snapshot: sidecar.snapshot,
|
|
1446
|
+
}, null, 2));
|
|
1447
|
+
} else {
|
|
1448
|
+
report.write("sidecar: " + drift.sidecarPath);
|
|
1449
|
+
report.write("capturedAt: " + new Date(sidecar.capturedAt).toISOString());
|
|
1450
|
+
report.write("digestHex: " + sidecar.digestHex);
|
|
1451
|
+
report.write("verified: " + (sidecar.verified ? "yes" : "no — " + sidecar.tamperReason));
|
|
1452
|
+
report.write("snapshot:");
|
|
1453
|
+
report.write(JSON.stringify(sidecar.snapshot, null, 2));
|
|
1454
|
+
}
|
|
1455
|
+
return sidecar.verified ? 0 : 1;
|
|
1456
|
+
} finally {
|
|
1457
|
+
try { await booted.app.shutdown(); } catch (_e) { /* best-effort */ }
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ---- Subcommand: file-type ---------------------------------------
|
|
1462
|
+
//
|
|
1463
|
+
// Pure utility — magic-byte content classification. No framework
|
|
1464
|
+
// boot. Useful for upload debugging ("the user said this was a PDF
|
|
1465
|
+
// but is it really?") and as a CI-side sanity check.
|
|
1466
|
+
|
|
1467
|
+
var FILE_TYPE_USAGE = [
|
|
1468
|
+
"Usage: blamejs file-type detect <file> [flags]",
|
|
1469
|
+
"",
|
|
1470
|
+
"Inspects the leading bytes of <file> against the b.fileType signature",
|
|
1471
|
+
"registry. Prints the detected mime / extension / category; exit 1 when",
|
|
1472
|
+
"no signature matches (the actual format is unknown to the registry).",
|
|
1473
|
+
"",
|
|
1474
|
+
"Flags:",
|
|
1475
|
+
" --json Print machine-readable JSON",
|
|
1476
|
+
" --allowlist <list> Comma-separated mime / category list — exits 1",
|
|
1477
|
+
" when the detected type is NOT in the allowlist",
|
|
1478
|
+
" (mirrors b.fileType.assertOneOf).",
|
|
1479
|
+
].join("\n");
|
|
1480
|
+
|
|
1481
|
+
function _runFileType(args, ctx) {
|
|
1482
|
+
if (args.pos.length === 0) {
|
|
1483
|
+
return cliHelpers.makeReporter(ctx, "blamejs file-type").usage(FILE_TYPE_USAGE);
|
|
1484
|
+
}
|
|
1485
|
+
var sub = args.pos[0];
|
|
1486
|
+
var report = cliHelpers.makeReporter(ctx, "blamejs file-type " + sub);
|
|
1487
|
+
if (sub === "help" || args.flags.help || args.flags.h) {
|
|
1488
|
+
return report.helpStdout(FILE_TYPE_USAGE);
|
|
1489
|
+
}
|
|
1490
|
+
if (sub !== "detect") {
|
|
1491
|
+
cliHelpers.makeReporter(ctx, "blamejs file-type").error("unknown subcommand '" + sub + "'", 2);
|
|
1492
|
+
return cliHelpers.makeReporter(ctx, "blamejs file-type").usage(FILE_TYPE_USAGE);
|
|
1493
|
+
}
|
|
1494
|
+
var file = args.pos[1];
|
|
1495
|
+
if (!file) return report.error("file path is required (positional arg after 'detect')", 2);
|
|
1496
|
+
var resolved = _resolvePath(String(file), ctx.cwd);
|
|
1497
|
+
var buf;
|
|
1498
|
+
try { buf = fs.readFileSync(resolved); }
|
|
1499
|
+
catch (e) {
|
|
1500
|
+
return report.error("read failed: " + ((e && e.message) || String(e)));
|
|
1501
|
+
}
|
|
1502
|
+
var fileType = require("./file-type");
|
|
1503
|
+
if (args.flags.allowlist && args.flags.allowlist !== true) {
|
|
1504
|
+
var allowed = requestHelpers.parseListHeader(args.flags.allowlist);
|
|
1505
|
+
try {
|
|
1506
|
+
var det = fileType.assertOneOf(buf, allowed);
|
|
1507
|
+
if (args.flags.json) report.write(JSON.stringify(det));
|
|
1508
|
+
else {
|
|
1509
|
+
report.write("mime: " + det.mime);
|
|
1510
|
+
report.write("extension: " + det.extension);
|
|
1511
|
+
report.write("category: " + det.category);
|
|
1512
|
+
report.write("name: " + det.name);
|
|
1513
|
+
}
|
|
1514
|
+
return report.ok("matched allowlist " + JSON.stringify(allowed));
|
|
1515
|
+
} catch (e) {
|
|
1516
|
+
return report.error(e.code + ": " + e.message);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
var detected = fileType.detect(buf);
|
|
1520
|
+
if (!detected) {
|
|
1521
|
+
if (args.flags.json) report.write("null");
|
|
1522
|
+
return report.error("unknown — no signature matched the leading bytes (" +
|
|
1523
|
+
buf.length + " bytes read)");
|
|
1524
|
+
}
|
|
1525
|
+
if (args.flags.json) {
|
|
1526
|
+
report.write(JSON.stringify(detected));
|
|
1527
|
+
} else {
|
|
1528
|
+
report.write("mime: " + detected.mime);
|
|
1529
|
+
report.write("extension: " + detected.extension);
|
|
1530
|
+
report.write("category: " + detected.category);
|
|
1531
|
+
report.write("name: " + detected.name);
|
|
1532
|
+
}
|
|
1533
|
+
return report.ok();
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ---- Subcommand: password ----------------------------------------
|
|
1537
|
+
//
|
|
1538
|
+
// Test b.auth.password.policy from the CLI without rebooting the app.
|
|
1539
|
+
// Useful for ops dashboards that want to surface "your policy
|
|
1540
|
+
// requires X" or for CI checks of operator-supplied passwords.
|
|
1541
|
+
|
|
1542
|
+
var PASSWORD_USAGE = [
|
|
1543
|
+
"Usage: blamejs password check [flags]",
|
|
1544
|
+
"",
|
|
1545
|
+
"Runs b.auth.password.policy({ profile, ... }).check(plaintext, context).",
|
|
1546
|
+
"Exit 0 = ok, exit 1 = policy rejected, exit 2 = bad args.",
|
|
1547
|
+
"",
|
|
1548
|
+
"Flags:",
|
|
1549
|
+
" --plaintext <s> REQUIRED — the plaintext to test (or use stdin)",
|
|
1550
|
+
" --stdin Read the plaintext from stdin (newline-trimmed)",
|
|
1551
|
+
" --profile <name> Named profile: nist-aal2 | pci-4.0 | hipaa-aal2",
|
|
1552
|
+
" --min-length <n> Override the profile minLength",
|
|
1553
|
+
" --max-length <n> Override the profile maxLength",
|
|
1554
|
+
" --breach-check Enable HaveIBeenPwned k-anonymity check (NETWORK)",
|
|
1555
|
+
" --fail-closed With --breach-check: HIBP outage → fail (default ok)",
|
|
1556
|
+
" --email <addr> Context for the deny-context-substrings check",
|
|
1557
|
+
" --username <name> Context for the deny-context-substrings check",
|
|
1558
|
+
" --json Print machine-readable JSON",
|
|
1559
|
+
].join("\n");
|
|
1560
|
+
|
|
1561
|
+
async function _runPassword(args, ctx) {
|
|
1562
|
+
if (args.pos.length === 0) {
|
|
1563
|
+
return cliHelpers.makeReporter(ctx, "blamejs password").usage(PASSWORD_USAGE);
|
|
1564
|
+
}
|
|
1565
|
+
var sub = args.pos[0];
|
|
1566
|
+
var report = cliHelpers.makeReporter(ctx, "blamejs password " + sub);
|
|
1567
|
+
if (sub === "help" || args.flags.help || args.flags.h) {
|
|
1568
|
+
return report.helpStdout(PASSWORD_USAGE);
|
|
1569
|
+
}
|
|
1570
|
+
if (sub !== "check") {
|
|
1571
|
+
cliHelpers.makeReporter(ctx, "blamejs password").error("unknown subcommand '" + sub + "'", 2);
|
|
1572
|
+
return cliHelpers.makeReporter(ctx, "blamejs password").usage(PASSWORD_USAGE);
|
|
1573
|
+
}
|
|
1574
|
+
var plaintext;
|
|
1575
|
+
if (args.flags.stdin) {
|
|
1576
|
+
try { plaintext = fs.readFileSync(0, "utf8").replace(/\r?\n$/, ""); }
|
|
1577
|
+
catch (e) { return report.error("stdin read failed: " + ((e && e.message) || String(e))); }
|
|
1578
|
+
} else if (args.flags.plaintext && args.flags.plaintext !== true) {
|
|
1579
|
+
plaintext = String(args.flags.plaintext);
|
|
1580
|
+
} else {
|
|
1581
|
+
return report.error("--plaintext <s> or --stdin is required", 2);
|
|
1582
|
+
}
|
|
1583
|
+
var policyOpts = {};
|
|
1584
|
+
if (args.flags.profile && args.flags.profile !== true) policyOpts.profile = String(args.flags.profile);
|
|
1585
|
+
if (args.flags["min-length"] && args.flags["min-length"] !== true) {
|
|
1586
|
+
policyOpts.minLength = Number(args.flags["min-length"]);
|
|
1587
|
+
}
|
|
1588
|
+
if (args.flags["max-length"] && args.flags["max-length"] !== true) {
|
|
1589
|
+
policyOpts.maxLength = Number(args.flags["max-length"]);
|
|
1590
|
+
}
|
|
1591
|
+
if (args.flags["breach-check"]) {
|
|
1592
|
+
policyOpts.breachCheck = "haveibeenpwned";
|
|
1593
|
+
if (args.flags["fail-closed"]) policyOpts.failClosed = true;
|
|
1594
|
+
}
|
|
1595
|
+
var passwordModule = require("./auth/password");
|
|
1596
|
+
var policy;
|
|
1597
|
+
try { policy = passwordModule.policy(policyOpts); }
|
|
1598
|
+
catch (e) { return report.error("bad policy: " + ((e && e.message) || String(e)), 2); }
|
|
1599
|
+
var context = {};
|
|
1600
|
+
if (args.flags.email && args.flags.email !== true) context.email = String(args.flags.email);
|
|
1601
|
+
if (args.flags.username && args.flags.username !== true) context.username = String(args.flags.username);
|
|
1602
|
+
var verdict;
|
|
1603
|
+
try { verdict = await policy.check(plaintext, context); }
|
|
1604
|
+
catch (e) { return report.error("check threw: " + ((e && e.message) || String(e))); }
|
|
1605
|
+
if (args.flags.json) {
|
|
1606
|
+
report.write(JSON.stringify(verdict));
|
|
1607
|
+
return verdict.ok ? 0 : 1;
|
|
1608
|
+
}
|
|
1609
|
+
if (verdict.ok) {
|
|
1610
|
+
report.write("ok");
|
|
1611
|
+
if (verdict.breachCheckSkipped) {
|
|
1612
|
+
report.write(" (HIBP skipped: " + verdict.breachCheckSkipReason + ")");
|
|
1613
|
+
} else if (typeof verdict.breachCheckCount === "number") {
|
|
1614
|
+
report.write(" (HIBP breach count: " + verdict.breachCheckCount + ")");
|
|
1615
|
+
}
|
|
1616
|
+
return 0;
|
|
1617
|
+
}
|
|
1618
|
+
report.write("REJECTED: " + verdict.code);
|
|
1619
|
+
report.write(" " + verdict.message);
|
|
1620
|
+
return 1;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// ---- Subcommand: erase -------------------------------------------
|
|
1624
|
+
//
|
|
1625
|
+
// Cryptographic-erasure of a single row. The right tool for one-off
|
|
1626
|
+
// GDPR Art. 17 / right-to-erasure flows that don't fit the periodic
|
|
1627
|
+
// retention sweep. Replaces every sealed column + derived hash with
|
|
1628
|
+
// NULL, sets __erasedAt, and writes the row back. Cleartext is
|
|
1629
|
+
// unrecoverable even with the vault key.
|
|
1630
|
+
|
|
1631
|
+
var ERASE_USAGE = [
|
|
1632
|
+
"Usage: blamejs erase --table <table> --row-id <id> [flags]",
|
|
1633
|
+
"",
|
|
1634
|
+
"Tombstones a single row's sealed columns + derived hashes. Used for",
|
|
1635
|
+
"one-off compliance erasure (GDPR Art. 17). The row stays in the table",
|
|
1636
|
+
"(referential integrity) but the cleartext is unrecoverable.",
|
|
1637
|
+
"",
|
|
1638
|
+
"Flags:",
|
|
1639
|
+
" --data-dir <path> Required",
|
|
1640
|
+
" --table <name> Required — the table containing the row",
|
|
1641
|
+
" --row-id <id> Required — the _id of the row to erase",
|
|
1642
|
+
" --vault-mode <mode> plaintext | wrapped (default wrapped)",
|
|
1643
|
+
" --reason <text> Reason recorded on the audit row (recommended)",
|
|
1644
|
+
" --confirm Required — confirms the erase is intentional",
|
|
1645
|
+
].join("\n");
|
|
1646
|
+
|
|
1647
|
+
async function _runErase(args, ctx) {
|
|
1648
|
+
var report = cliHelpers.makeReporter(ctx, "blamejs erase");
|
|
1649
|
+
if (args.flags.help || args.flags.h) {
|
|
1650
|
+
return report.helpStdout(ERASE_USAGE);
|
|
1651
|
+
}
|
|
1652
|
+
var table = args.flags.table;
|
|
1653
|
+
var rowId = args.flags["row-id"];
|
|
1654
|
+
if (!table || table === true) return report.error("--table <name> is required", 2);
|
|
1655
|
+
if (!rowId || rowId === true) return report.error("--row-id <id> is required", 2);
|
|
1656
|
+
if (!args.flags.confirm) {
|
|
1657
|
+
return report.error("--confirm is required (this operation is irreversible)", 2);
|
|
1658
|
+
}
|
|
1659
|
+
var dataDirFlag = args.flags["data-dir"];
|
|
1660
|
+
if (!dataDirFlag || dataDirFlag === true) {
|
|
1661
|
+
return report.error("--data-dir <path> is required", 2);
|
|
1662
|
+
}
|
|
1663
|
+
var dataDir = _resolvePath(String(dataDirFlag), ctx.cwd);
|
|
1664
|
+
var vaultMode = args.flags["vault-mode"] || "wrapped";
|
|
1665
|
+
var reason = (args.flags.reason && args.flags.reason !== true) ? String(args.flags.reason) : null;
|
|
1666
|
+
var booted;
|
|
1667
|
+
try {
|
|
1668
|
+
booted = await cliHelpers.bootApp({ dataDir: dataDir, vaultMode: vaultMode, env: ctx.env });
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
return report.error("boot failed: " + ((e && e.message) || String(e)));
|
|
1671
|
+
}
|
|
1672
|
+
try {
|
|
1673
|
+
var b = booted.b;
|
|
1674
|
+
var safeTable = String(table).replace(/[^A-Za-z0-9_]/g, "");
|
|
1675
|
+
if (safeTable !== String(table) || safeTable.length === 0) {
|
|
1676
|
+
return report.error("--table must be a valid identifier (got " + JSON.stringify(table) + ")", 2);
|
|
1677
|
+
}
|
|
1678
|
+
var row;
|
|
1679
|
+
try {
|
|
1680
|
+
row = b.db.prepare("SELECT * FROM \"" + safeTable + "\" WHERE _id = ?").get(String(rowId));
|
|
1681
|
+
} catch (e) {
|
|
1682
|
+
return report.error("row lookup failed: " + ((e && e.message) || String(e)));
|
|
1683
|
+
}
|
|
1684
|
+
if (!row) {
|
|
1685
|
+
return report.error("no row with _id=" + JSON.stringify(rowId) + " in table " + safeTable);
|
|
1686
|
+
}
|
|
1687
|
+
var schema = b.cryptoField.getSchema(safeTable);
|
|
1688
|
+
var sealedFields = schema && Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
|
|
1689
|
+
var derivedHashes = schema && schema.derivedHashes ? Object.keys(schema.derivedHashes) : [];
|
|
1690
|
+
if (sealedFields.length === 0 && derivedHashes.length === 0) {
|
|
1691
|
+
return report.error("table " + safeTable + " has no sealed columns or derived hashes; " +
|
|
1692
|
+
"use a regular DELETE for non-sealed rows");
|
|
1693
|
+
}
|
|
1694
|
+
var setClauses = [];
|
|
1695
|
+
var values = [];
|
|
1696
|
+
for (var si = 0; si < sealedFields.length; si++) {
|
|
1697
|
+
setClauses.push('"' + sealedFields[si] + '" = ?');
|
|
1698
|
+
values.push(null);
|
|
1699
|
+
}
|
|
1700
|
+
for (var di = 0; di < derivedHashes.length; di++) {
|
|
1701
|
+
setClauses.push('"' + derivedHashes[di] + '" = ?');
|
|
1702
|
+
values.push(null);
|
|
1703
|
+
}
|
|
1704
|
+
values.push(String(rowId));
|
|
1705
|
+
try {
|
|
1706
|
+
var upd = b.db.prepare("UPDATE \"" + safeTable + "\" SET " + setClauses.join(", ") + " WHERE _id = ?");
|
|
1707
|
+
upd.run.apply(upd, values);
|
|
1708
|
+
} catch (e) {
|
|
1709
|
+
return report.error("UPDATE failed: " + ((e && e.message) || String(e)));
|
|
1710
|
+
}
|
|
1711
|
+
try {
|
|
1712
|
+
b.audit.safeEmit({
|
|
1713
|
+
action: "system.erase",
|
|
1714
|
+
outcome: "success",
|
|
1715
|
+
resource: { kind: "row.erase", id: safeTable + "/" + String(rowId) },
|
|
1716
|
+
reason: reason,
|
|
1717
|
+
metadata: {
|
|
1718
|
+
table: safeTable, rowId: String(rowId),
|
|
1719
|
+
sealedFieldCount: sealedFields.length,
|
|
1720
|
+
derivedHashCount: derivedHashes.length,
|
|
1721
|
+
},
|
|
1722
|
+
});
|
|
1723
|
+
} catch (_e) { /* audit best-effort */ }
|
|
1724
|
+
report.write("erased: " + safeTable + "/" + String(rowId));
|
|
1725
|
+
report.write(" sealed columns nulled: " + sealedFields.join(", "));
|
|
1726
|
+
if (derivedHashes.length > 0) {
|
|
1727
|
+
report.write(" derived hashes nulled: " + derivedHashes.join(", "));
|
|
1728
|
+
}
|
|
1729
|
+
return report.ok();
|
|
1730
|
+
} finally {
|
|
1731
|
+
try { await booted.app.shutdown(); } catch (_e) { /* best-effort */ }
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// ---- Subcommand: retention ---------------------------------------
|
|
1736
|
+
//
|
|
1737
|
+
// Run / preview an ad-hoc retention rule from the CLI. Operators
|
|
1738
|
+
// supply the rule shape on the command line — table + ageField +
|
|
1739
|
+
// ttlMs + action — for one-off cleanups outside the scheduler-
|
|
1740
|
+
// driven sweep wired in lib code.
|
|
1741
|
+
|
|
1742
|
+
var RETENTION_USAGE = [
|
|
1743
|
+
"Usage: blamejs retention <subcommand> [flags]",
|
|
1744
|
+
"",
|
|
1745
|
+
"Subcommands:",
|
|
1746
|
+
" preview Dry-run: report what WOULD be processed without acting",
|
|
1747
|
+
" run Run the rule once (acts on the rows it finds)",
|
|
1748
|
+
"",
|
|
1749
|
+
"Flags (all subcommands):",
|
|
1750
|
+
" --data-dir <path> Required",
|
|
1751
|
+
" --vault-mode <mode> plaintext | wrapped (default wrapped)",
|
|
1752
|
+
" --table <name> Required — table to sweep",
|
|
1753
|
+
" --age-field <col> Required — column holding the ms-epoch timestamp",
|
|
1754
|
+
" --ttl-ms <ms> Required — rows older than (now - ttlMs) are matched",
|
|
1755
|
+
" --action <a> erase (default) | delete | soft-delete",
|
|
1756
|
+
" --soft-delete-field <col>",
|
|
1757
|
+
" For action=soft-delete — column to write the",
|
|
1758
|
+
" deletion timestamp into (required when action=soft-delete)",
|
|
1759
|
+
" --legal-hold-field <col>",
|
|
1760
|
+
" Per-row legal-hold exemption column; rows where this",
|
|
1761
|
+
" is truthy are skipped",
|
|
1762
|
+
" --batch-size <n> Rows per batch (default 500)",
|
|
1763
|
+
" --json Print machine-readable JSON summary",
|
|
1764
|
+
].join("\n");
|
|
1765
|
+
|
|
1766
|
+
async function _runRetention(args, ctx) {
|
|
1767
|
+
if (args.pos.length === 0) {
|
|
1768
|
+
return cliHelpers.makeReporter(ctx, "blamejs retention").usage(RETENTION_USAGE);
|
|
1769
|
+
}
|
|
1770
|
+
var sub = args.pos[0];
|
|
1771
|
+
var report = cliHelpers.makeReporter(ctx, "blamejs retention " + sub);
|
|
1772
|
+
if (sub === "help" || args.flags.help || args.flags.h) {
|
|
1773
|
+
return report.helpStdout(RETENTION_USAGE);
|
|
1774
|
+
}
|
|
1775
|
+
if (["preview", "run"].indexOf(sub) === -1) {
|
|
1776
|
+
cliHelpers.makeReporter(ctx, "blamejs retention").error("unknown subcommand '" + sub + "'", 2);
|
|
1777
|
+
return cliHelpers.makeReporter(ctx, "blamejs retention").usage(RETENTION_USAGE);
|
|
1778
|
+
}
|
|
1779
|
+
var dataDirFlag = args.flags["data-dir"];
|
|
1780
|
+
if (!dataDirFlag || dataDirFlag === true) return report.error("--data-dir <path> is required", 2);
|
|
1781
|
+
var table = args.flags.table;
|
|
1782
|
+
var ageField = args.flags["age-field"];
|
|
1783
|
+
var ttlMs = args.flags["ttl-ms"];
|
|
1784
|
+
if (!table || table === true) return report.error("--table <name> is required", 2);
|
|
1785
|
+
if (!ageField || ageField === true) return report.error("--age-field <col> is required", 2);
|
|
1786
|
+
if (!ttlMs || ttlMs === true) return report.error("--ttl-ms <ms> is required", 2);
|
|
1787
|
+
var ttlMsNum = Number(ttlMs);
|
|
1788
|
+
if (!isFinite(ttlMsNum) || ttlMsNum <= 0) {
|
|
1789
|
+
return report.error("--ttl-ms must be a positive finite number", 2);
|
|
1790
|
+
}
|
|
1791
|
+
var action = args.flags.action ? String(args.flags.action) : "erase";
|
|
1792
|
+
if (["erase", "delete", "soft-delete"].indexOf(action) === -1) {
|
|
1793
|
+
return report.error("--action must be erase / delete / soft-delete", 2);
|
|
1794
|
+
}
|
|
1795
|
+
if (action === "soft-delete" &&
|
|
1796
|
+
(!args.flags["soft-delete-field"] || args.flags["soft-delete-field"] === true)) {
|
|
1797
|
+
return report.error("--soft-delete-field <col> required when --action=soft-delete", 2);
|
|
1798
|
+
}
|
|
1799
|
+
var batchSize = args.flags["batch-size"] && args.flags["batch-size"] !== true
|
|
1800
|
+
? Number(args.flags["batch-size"]) : 500;
|
|
1801
|
+
var dataDir = _resolvePath(String(dataDirFlag), ctx.cwd);
|
|
1802
|
+
var vaultMode = args.flags["vault-mode"] || "wrapped";
|
|
1803
|
+
var booted;
|
|
1804
|
+
try {
|
|
1805
|
+
booted = await cliHelpers.bootApp({ dataDir: dataDir, vaultMode: vaultMode, env: ctx.env });
|
|
1806
|
+
} catch (e) {
|
|
1807
|
+
return report.error("boot failed: " + ((e && e.message) || String(e)));
|
|
1808
|
+
}
|
|
1809
|
+
try {
|
|
1810
|
+
var rules = booted.b.retention.create({ db: booted.b.db, audit: booted.b.audit });
|
|
1811
|
+
var ruleSpec = {
|
|
1812
|
+
name: "cli-" + String(table) + "-" + Date.now(),
|
|
1813
|
+
table: String(table),
|
|
1814
|
+
ageField: String(ageField),
|
|
1815
|
+
ttlMs: ttlMsNum,
|
|
1816
|
+
action: action,
|
|
1817
|
+
batchSize: batchSize,
|
|
1818
|
+
};
|
|
1819
|
+
if (args.flags["soft-delete-field"] && args.flags["soft-delete-field"] !== true) {
|
|
1820
|
+
ruleSpec.softDeleteField = String(args.flags["soft-delete-field"]);
|
|
1821
|
+
}
|
|
1822
|
+
if (args.flags["legal-hold-field"] && args.flags["legal-hold-field"] !== true) {
|
|
1823
|
+
ruleSpec.legalHoldField = String(args.flags["legal-hold-field"]);
|
|
1824
|
+
}
|
|
1825
|
+
rules.declare(ruleSpec);
|
|
1826
|
+
var summary = await rules.run(ruleSpec.name, { dryRun: sub === "preview" });
|
|
1827
|
+
if (args.flags.json) {
|
|
1828
|
+
report.write(JSON.stringify(summary, null, 2));
|
|
1829
|
+
} else {
|
|
1830
|
+
report.write((sub === "preview" ? "DRY-RUN " : "") + "rule: " + ruleSpec.name);
|
|
1831
|
+
report.write("table: " + ruleSpec.table);
|
|
1832
|
+
report.write("scanned: " + summary.scanned);
|
|
1833
|
+
report.write("processed:" + summary.processed);
|
|
1834
|
+
report.write("skipped: " + summary.skipped);
|
|
1835
|
+
report.write("legalHoldsHonored: " + summary.legalHoldsHonored);
|
|
1836
|
+
if (summary.errors && summary.errors.length > 0) {
|
|
1837
|
+
report.write("errors: " + summary.errors.length);
|
|
1838
|
+
for (var ei = 0; ei < Math.min(summary.errors.length, 5); ei++) {
|
|
1839
|
+
report.write(" - " + (summary.errors[ei].rowId || "?") + ": " + summary.errors[ei].reason);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
report.write("durationMs: " + summary.durationMs);
|
|
1843
|
+
}
|
|
1844
|
+
return summary.errors && summary.errors.length > 0 ? 1 : 0;
|
|
1845
|
+
} catch (e) {
|
|
1846
|
+
return report.error((e && e.message) || String(e));
|
|
1847
|
+
} finally {
|
|
1848
|
+
try { await booted.app.shutdown(); } catch (_e) { /* best-effort */ }
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1286
1852
|
var TOP_USAGE = [
|
|
1287
1853
|
"Usage: blamejs <command> [args]",
|
|
1288
1854
|
"",
|
|
@@ -1296,6 +1862,12 @@ var TOP_USAGE = [
|
|
|
1296
1862
|
" backup Inspect / verify / extract a backup bundle from disk",
|
|
1297
1863
|
" mtls Inspect or generate the in-box mTLS CA + leaf certs (status / show-cert / init / issue / issue-p12)",
|
|
1298
1864
|
" vault Seal / unseal / rotate the on-disk vault keypair (plaintext ↔ wrapped)",
|
|
1865
|
+
" security Run b.security.assertProduction against the live framework",
|
|
1866
|
+
" config-drift Inspect / verify the b.configDrift signed sidecar",
|
|
1867
|
+
" file-type Magic-byte content classification (b.fileType.detect)",
|
|
1868
|
+
" password Test b.auth.password.policy against a candidate plaintext",
|
|
1869
|
+
" erase Cryptographically erase a single row's sealed columns (GDPR Art. 17)",
|
|
1870
|
+
" retention Run / preview an ad-hoc b.retention rule",
|
|
1299
1871
|
" version Print framework version",
|
|
1300
1872
|
" help [<command>] Show this message or details for a command",
|
|
1301
1873
|
].join("\n");
|
|
@@ -1320,14 +1892,24 @@ async function main(argv, opts) {
|
|
|
1320
1892
|
_writeLine(ctx.stdout, constants.version);
|
|
1321
1893
|
return 0;
|
|
1322
1894
|
}
|
|
1323
|
-
if (args.flags.help || args.flags.h) {
|
|
1324
|
-
_printTopHelp(ctx);
|
|
1325
|
-
return 0;
|
|
1326
|
-
}
|
|
1327
1895
|
|
|
1328
1896
|
var cmd = args.pos[0];
|
|
1329
1897
|
|
|
1898
|
+
// --help routing:
|
|
1899
|
+
// `blamejs --help` → top-level help
|
|
1900
|
+
// `blamejs <cmd> --help` → that command's USAGE (via the
|
|
1901
|
+
// existing `help <cmd>` dispatch
|
|
1902
|
+
// block below). Synthesizing the
|
|
1903
|
+
// positional form keeps every
|
|
1904
|
+
// subcommand's per-command usage
|
|
1905
|
+
// reachable via `--help` without
|
|
1906
|
+
// each handler having to special-case
|
|
1907
|
+
// the flag-only-no-subcommand path.
|
|
1330
1908
|
if (cmd === undefined) { _printTopHelp(ctx); return 0; }
|
|
1909
|
+
if (args.flags.help || args.flags.h) {
|
|
1910
|
+
args = _parseArgs(["help", cmd]);
|
|
1911
|
+
cmd = "help";
|
|
1912
|
+
}
|
|
1331
1913
|
if (cmd === "help") {
|
|
1332
1914
|
var subTopic = args.pos[1];
|
|
1333
1915
|
if (subTopic === "migrate") { _writeLine(ctx.stdout, MIGRATE_USAGE); return 0; }
|
|
@@ -1339,6 +1921,12 @@ async function main(argv, opts) {
|
|
|
1339
1921
|
if (subTopic === "backup") { _writeLine(ctx.stdout, BACKUP_USAGE); return 0; }
|
|
1340
1922
|
if (subTopic === "mtls") { _writeLine(ctx.stdout, MTLS_USAGE); return 0; }
|
|
1341
1923
|
if (subTopic === "vault") { _writeLine(ctx.stdout, VAULT_USAGE); return 0; }
|
|
1924
|
+
if (subTopic === "security") { _writeLine(ctx.stdout, SECURITY_USAGE); return 0; }
|
|
1925
|
+
if (subTopic === "config-drift") { _writeLine(ctx.stdout, CONFIG_DRIFT_USAGE); return 0; }
|
|
1926
|
+
if (subTopic === "file-type") { _writeLine(ctx.stdout, FILE_TYPE_USAGE); return 0; }
|
|
1927
|
+
if (subTopic === "password") { _writeLine(ctx.stdout, PASSWORD_USAGE); return 0; }
|
|
1928
|
+
if (subTopic === "erase") { _writeLine(ctx.stdout, ERASE_USAGE); return 0; }
|
|
1929
|
+
if (subTopic === "retention") { _writeLine(ctx.stdout, RETENTION_USAGE); return 0; }
|
|
1342
1930
|
_printTopHelp(ctx);
|
|
1343
1931
|
return 0;
|
|
1344
1932
|
}
|
|
@@ -1354,6 +1942,12 @@ async function main(argv, opts) {
|
|
|
1354
1942
|
if (cmd === "backup") return await _runBackup(rest, ctx);
|
|
1355
1943
|
if (cmd === "mtls") return await _runMtls(rest, ctx);
|
|
1356
1944
|
if (cmd === "vault") return await _runVault(rest, ctx);
|
|
1945
|
+
if (cmd === "security") return await _runSecurity(rest, ctx);
|
|
1946
|
+
if (cmd === "config-drift") return await _runConfigDrift(rest, ctx);
|
|
1947
|
+
if (cmd === "file-type") return _runFileType(rest, ctx);
|
|
1948
|
+
if (cmd === "password") return await _runPassword(rest, ctx);
|
|
1949
|
+
if (cmd === "erase") return await _runErase(rest, ctx);
|
|
1950
|
+
if (cmd === "retention") return await _runRetention(rest, ctx);
|
|
1357
1951
|
|
|
1358
1952
|
_writeLine(ctx.stderr, "blamejs: unknown command '" + cmd + "'");
|
|
1359
1953
|
_printTopHelp(ctx);
|