macwarden 1.1.0 → 1.1.1

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -5
  3. package/SECURITY.md +2 -2
  4. package/dist/bounded-file.js +1 -1
  5. package/dist/commands/bake/out-errors.js +1 -0
  6. package/dist/commands/bake/write-out.js +1 -1
  7. package/dist/commands/handlers.js +1 -1
  8. package/dist/commands/inspect.js +1 -1
  9. package/dist/commands/inventory.js +1 -1
  10. package/dist/commands/recommend.js +1 -1
  11. package/dist/commands/respond.js +1 -1
  12. package/dist/commands/revoke.js +1 -1
  13. package/dist/commands/rotate.js +1 -1
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/verify.js +1 -1
  16. package/dist/commands/watch.js +1 -1
  17. package/dist/credential-recognition.js +1 -1
  18. package/dist/decode/index.js +1 -1
  19. package/dist/decode/recognition.js +1 -1
  20. package/dist/environment.js +1 -1
  21. package/dist/errors.js +1 -1
  22. package/dist/grade/index.js +1 -1
  23. package/dist/grouped-runs.js +1 -1
  24. package/dist/input/index.js +1 -1
  25. package/dist/input/lndconnect.js +1 -1
  26. package/dist/instant.js +1 -0
  27. package/dist/lnd/grpc.js +1 -1
  28. package/dist/lnd/index.js +1 -1
  29. package/dist/lnd/middleware-proto.js +1 -1
  30. package/dist/lnd/tls-identity.js +1 -1
  31. package/dist/online.js +1 -1
  32. package/dist/output/index.js +1 -1
  33. package/dist/output/scan-formats.js +1 -1
  34. package/dist/process-error-context.js +1 -1
  35. package/dist/protected-path.js +1 -1
  36. package/dist/registry/index.js +1 -1
  37. package/dist/registry/process-domain.js +1 -1
  38. package/dist/report/access-review.js +1 -1
  39. package/dist/root-key-id.js +1 -0
  40. package/dist/runtime.js +1923 -636
  41. package/dist/scan/agent-configs.js +1 -1
  42. package/dist/scan/git-history.js +1 -1
  43. package/dist/scan/ignore.js +1 -1
  44. package/dist/scan/index.js +1 -1
  45. package/dist/scan/mcp-context.js +1 -1
  46. package/dist/scan/nwc-match.js +1 -1
  47. package/dist/scan/patterns.js +1 -1
  48. package/dist/scan/redact.js +1 -1
  49. package/dist/secret.js +1 -1
  50. package/dist/version.js +1 -1
  51. package/dist/watch/alert-throttle.js +1 -1
  52. package/dist/watch/alert.js +1 -1
  53. package/dist/watch/canary.js +1 -1
  54. package/dist/watch/output-queue.js +1 -1
  55. package/dist/watch/usage.js +1 -1
  56. package/package.json +1 -1
  57. package/schemas/output/attenuate.schema.json +28 -1
  58. package/schemas/output/bake.schema.json +28 -1
  59. package/schemas/output/inspect.schema.json +7 -2
  60. package/schemas/output/inventory.schema.json +28 -1
  61. package/schemas/output/macwarden.schema.json +329 -0
  62. package/schemas/output/recommend.schema.json +28 -1
  63. package/schemas/output/respond.schema.json +28 -1
  64. package/schemas/output/revoke.schema.json +28 -1
  65. package/schemas/output/rotate.schema.json +28 -1
  66. package/schemas/output/scan.schema.json +8 -3
  67. package/schemas/output/verify.schema.json +28 -1
  68. package/schemas/output/watch.schema.json +28 -1
package/dist/runtime.js CHANGED
@@ -5,22 +5,23 @@ import * as builtin3 from "node:path";
5
5
  import * as builtin4 from "node:stream";
6
6
  import * as builtin5 from "node:crypto";
7
7
  import * as builtin6 from "node:child_process";
8
- import * as builtin7 from "node:os";
9
- import * as builtin8 from "node:readline";
10
- import * as builtin9 from "node:util";
8
+ import * as builtin7 from "node:readline";
9
+ import * as builtin8 from "node:util";
10
+ import * as builtin9 from "node:os";
11
11
  import * as builtin10 from "node:http2";
12
12
  import * as builtin11 from "node:https";
13
- const builtins = {"node:fs": builtin0,"node:fs/promises": builtin1,"node:async_hooks": builtin2,"node:path": builtin3,"node:stream": builtin4,"node:crypto": builtin5,"node:child_process": builtin6,"node:os": builtin7,"node:readline": builtin8,"node:util": builtin9,"node:http2": builtin10,"node:https": builtin11};
13
+ const builtins = {"node:fs": builtin0,"node:fs/promises": builtin1,"node:async_hooks": builtin2,"node:path": builtin3,"node:stream": builtin4,"node:crypto": builtin5,"node:child_process": builtin6,"node:readline": builtin7,"node:util": builtin8,"node:os": builtin9,"node:http2": builtin10,"node:https": builtin11};
14
14
  const factories = {"bounded-file.js": (module, exports, require) => {
15
15
  "use strict";
16
16
  exports.__esModule = true;
17
17
  exports.LOCK_FILE_LIMIT = exports.DEFAULT_FILE_LIMIT = exports.BoundedFileError = void 0;
18
18
  exports.readBounded = readBounded;
19
19
  exports.readBoundedFile = readBoundedFile;
20
- exports.assertPlainFile = assertPlainFile;
21
- // Bounded regular-file reads for files macwarden reads by path: TLS certificates and the
22
- // sidecars kept next to the registry. The path and its resolved target are checked against
23
- // the protected names (SAFE-02) before anything is opened, the opened file must be the
20
+ exports.readPlainFile = readPlainFile;
21
+ // Bounded regular-file reads for files macwarden reads by path: TLS certificates, the
22
+ // sidecars kept next to the registry and lock owner records. The path (and, except for lock
23
+ // records, which must not be symlinks, its resolved target) is checked against the
24
+ // protected names (SAFE-02) before anything is opened, the opened file must be the
24
25
  // regular file that was checked, and at most `limit` bytes are accepted: a larger file fails
25
26
  // before its content is parsed, and allocation follows the file's size, never the limit.
26
27
  const node_fs_1 = require("node:fs");
@@ -94,18 +95,39 @@ async function readBoundedFile(path, io, limit = exports.DEFAULT_FILE_LIMIT) {
94
95
  /** Largest lock owner record read: a few fields (pid, host name, start time, process domain). */
95
96
  exports.LOCK_FILE_LIMIT = 64 * 1024;
96
97
  /**
97
- * For files read by whole-file reads that must stay in place (lock owner records, whose
98
- * reads are part of the lock protocol): the path itself must be a regular file, not a
99
- * symlink, under no protected name, and at most `limit` bytes. Refuses before any open.
98
+ * Reads a file that must stay in place (lock owner records, whose reads are part of the lock
99
+ * protocol) through one descriptor: the path itself must be a regular file, not a symlink,
100
+ * under no protected name and at most `limit` bytes when checked; the opened file must be
101
+ * that same regular file (device and inode, compared as bigints so Windows file indexes stay
102
+ * exact; no-follow is used where the platform has it, and Windows has none, so the identity
103
+ * check is what binds the read to the checked file), and at most limit + 1 bytes are read
104
+ * from it. A symlink or protected path is refused before any open; a file replaced after the
105
+ * check is refused before its content is read; one that grew past `limit` fails once
106
+ * limit + 1 bytes have been read.
100
107
  */
101
- async function assertPlainFile(path, io, limit) {
108
+ async function readPlainFile(path, io, limit) {
102
109
  if ((0, protected_path_js_1.isProtectedPath)(path))
103
110
  throw new BoundedFileError('protected file');
104
- const meta = await io.lstat(path);
111
+ const meta = await io.lstat(path, { bigint: true });
105
112
  if (!meta.isFile() || meta.isSymbolicLink())
106
113
  throw new BoundedFileError('not a regular file');
107
- if (meta.size > limit)
114
+ if (meta.size > BigInt(limit))
108
115
  throw new BoundedFileError('file too large');
116
+ const handle = await io.open(path, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NONBLOCK ?? 0) | (node_fs_1.constants.O_NOFOLLOW ?? 0));
117
+ try {
118
+ const opened = await handle.stat({ bigint: true });
119
+ if (!opened.isFile() || opened.dev !== meta.dev || opened.ino !== meta.ino)
120
+ throw new BoundedFileError('file changed');
121
+ if (opened.size > BigInt(limit))
122
+ throw new BoundedFileError('file too large');
123
+ const bytes = await readBounded(handle, limit, opened.size);
124
+ if (bytes.length > limit)
125
+ throw new BoundedFileError('file too large');
126
+ return bytes.toString('utf8');
127
+ }
128
+ finally {
129
+ await handle.close();
130
+ }
109
131
  }
110
132
 
111
133
  },
@@ -445,11 +467,28 @@ async function main(argv) {
445
467
  process.stdout.write((0, index_js_1.render)({ command: null, ok: true, warnings: [], result: { text: packageVersion() }, error: null }, 'text'));
446
468
  return 0;
447
469
  }
470
+ if (argv.length === 2 && argv.includes('--help') && argv.includes('--json')) {
471
+ // Top-level help as one JSON document (MW110-10); schemas/output/macwarden.schema.json.
472
+ const help = { command: null, ok: true, warnings: [], result: {
473
+ usage: usage.split('\n').slice(0, 3).map(line => line.replace(/^(?:Usage: | +)/, '')),
474
+ commands: summaries.map(([name, summary]) => ({ name, summary }))
475
+ }, error: null };
476
+ await write(help, 'json');
477
+ return 0;
478
+ }
448
479
  if (argv.length === 1 && argv[0] === '--help') {
449
480
  process.stdout.write((0, index_js_1.render)(textResult(usage), 'text'));
450
481
  return 0;
451
482
  }
452
483
  if (command && argv.slice(1, flagsEnd).includes('--help')) {
484
+ // With --json, help is one JSON document like every other output (MW110-10).
485
+ if (mode === 'json') {
486
+ // Rendered only as JSON, so it goes through the same JSON writer without a per-command result type.
487
+ const help = { command, ok: true, warnings: [], result: { usage: synopses[command].map(line => `macwarden ${line}`) },
488
+ error: null };
489
+ await write(help, 'json');
490
+ return 0;
491
+ }
453
492
  process.stdout.write((0, index_js_1.render)(textResult(commandUsage(command)), 'text'));
454
493
  return 0;
455
494
  }
@@ -562,6 +601,7 @@ const index_js_2 = require("../grade/index.js");
562
601
  const index_js_3 = require("../input/index.js");
563
602
  const protected_path_js_1 = require("../protected-path.js");
564
603
  const write_out_js_1 = require("./bake/write-out.js");
604
+ const out_errors_js_1 = require("./bake/out-errors.js");
565
605
  const valueFlags = new Set(['--out', '--expires', '--ip', '--ip-range']);
566
606
  const switchFlags = new Set(['--dry-run', '--unsafe-arg', '--json']);
567
607
  function fail(code, step, message, warnings = []) {
@@ -670,6 +710,9 @@ async function runSteps(args, deps, state) {
670
710
  // A3: the output must be a new, unprotected path.
671
711
  if ((0, protected_path_js_1.isProtectedPath)(request.out))
672
712
  return fail(3, 'A3', 'protected output path');
713
+ const outName = (0, protected_path_js_1.outputNameProblem)(request.out);
714
+ if (outName)
715
+ return fail(3, 'A3', `output path refused: ${outName}`);
673
716
  let outPath;
674
717
  try {
675
718
  outPath = await (0, protected_path_js_1.resolveUnprotectedPath)(request.out, deps.fs);
@@ -741,8 +784,8 @@ async function runSteps(args, deps, state) {
741
784
  if (reserved.aclWarning)
742
785
  warnings.push('acl-best-effort');
743
786
  }
744
- catch {
745
- return fail(3, 'A6', 'cannot create output file', warnings);
787
+ catch (error) {
788
+ return fail(3, 'A6', (0, out_errors_js_1.reserveFailure)(error, 'cannot create output file'), warnings);
746
789
  }
747
790
  (0, cancellation_js_1.checkCancellation)();
748
791
  try {
@@ -845,6 +888,7 @@ const process_error_context_js_1 = require("../../process-error-context.js");
845
888
  const index_js_4 = require("../../registry/index.js");
846
889
  const errors_js_1 = require("../../errors.js");
847
890
  const write_out_js_1 = require("./write-out.js");
891
+ const out_errors_js_1 = require("./out-errors.js");
848
892
  const allocate_js_1 = require("./allocate.js");
849
893
  const canary_js_1 = require("../../watch/canary.js");
850
894
  // A canary looks like an ordinary read-only credential. Its default permissions cover the
@@ -911,15 +955,15 @@ function parse(args, env) {
911
955
  const raw = get('--permissions');
912
956
  const uri = get('--uri');
913
957
  const expires = get('--expires');
914
- const host = get('--lnd-rest') ?? env.MACWARDEN_LND_REST;
915
- const cert = get('--tls-cert') ?? env.MACWARDEN_TLS_CERT;
916
- const own = get('--macaroon') ?? env.MACWARDEN_MACAROON;
958
+ const { host, cert, own, problem: mixedSource } = (0, online_js_1.connectionSettings)(get, env);
917
959
  if (!label)
918
960
  return '--label is required';
919
961
  if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(label))
920
962
  return `--label ${online_js_1.LABEL_RULE}`;
921
963
  if (!out)
922
964
  return '--out is required: the path of the new macaroon file';
965
+ if (mixedSource)
966
+ return mixedSource;
923
967
  const connection = (0, online_js_1.connectionProblem)(host, cert, own);
924
968
  if (connection)
925
969
  return connection;
@@ -975,6 +1019,9 @@ async function runSteps(args, deps, state) {
975
1019
  return fail(2, 'B3', 'ADMIN permissions require --allow-admin');
976
1020
  if ((0, protected_path_js_1.isProtectedPath)(request.out))
977
1021
  return fail(3, 'B4', 'protected output path');
1022
+ const outName = (0, protected_path_js_1.outputNameProblem)(request.out);
1023
+ if (outName)
1024
+ return fail(3, 'B4', `output path refused: ${outName}`);
978
1025
  let outPath;
979
1026
  try {
980
1027
  outPath = await (0, protected_path_js_1.resolveUnprotectedPath)(request.out, deps.fs);
@@ -1067,8 +1114,8 @@ async function runSteps(args, deps, state) {
1067
1114
  if (reserved.aclWarning)
1068
1115
  warnings.push('acl-best-effort');
1069
1116
  }
1070
- catch {
1071
- return fail(3, 'B7', 'cannot reserve output file', warnings);
1117
+ catch (error) {
1118
+ return fail(3, 'B7', (0, out_errors_js_1.reserveFailure)(error), warnings);
1072
1119
  }
1073
1120
  }
1074
1121
  const adapter = deps.lnd ?? deps.createAdapter?.({ restUrl: request.host, tlsCert: cert, macaroon: own });
@@ -1113,14 +1160,34 @@ async function runSteps(args, deps, state) {
1113
1160
  fingerprint: null, outPath: null, createdAt, verified: false, status: 'active',
1114
1161
  revokedAt: null, revokeEvidence: null, replaces: null, replacedBy: null };
1115
1162
  (0, cancellation_js_1.checkCancellation)();
1163
+ // The pending record may be on disk once save started (a failure after the rename, or a signal
1164
+ // during it), although nothing was sent to LND: it is removed again before reporting, so exit 3
1165
+ // and exit 6 keep meaning "nothing changed"; if the registry on disk still holds it, the exit
1166
+ // is 7 and the message names it with its recovery (RL-08).
1167
+ const unrecord = async (code, step, message) => {
1168
+ try {
1169
+ registry.removePending(pubkey, id);
1170
+ await registry.save();
1171
+ return fail(code, step, message, warnings);
1172
+ }
1173
+ catch { /* Decide from what is on disk. */ }
1174
+ try {
1175
+ if (!(await (0, index_js_4.openRegistry)(request.registry, { fs: deps.fs, env: deps.env })).find(pubkey, id))
1176
+ return fail(code, step, message, warnings);
1177
+ }
1178
+ catch { /* Unreadable: the record may remain. */ }
1179
+ return fail(7, step, `${message}; nothing sent to LND, but the registry still holds (or may hold) the pending record for root key ${id}` +
1180
+ `, which keeps the label in use; run macwarden inventory and, if it lists root key ${id}, ${(0, online_js_1.recoveryCommand)(request, id)}`, warnings);
1181
+ };
1116
1182
  try {
1117
1183
  registry.add(entry);
1118
1184
  await registry.save();
1119
1185
  }
1120
1186
  catch {
1121
- return fail(3, 'B11', 'cannot record pending issued ID; nothing sent to LND', warnings);
1187
+ return await unrecord(3, 'B11', 'cannot record pending issued ID; nothing sent to LND');
1122
1188
  }
1123
- (0, cancellation_js_1.checkCancellation)();
1189
+ if ((0, cancellation_js_1.cancellationSignal)()?.aborted)
1190
+ return await unrecord(6, 'cancelled', 'interrupted');
1124
1191
  let mac;
1125
1192
  state.mutationSent = true;
1126
1193
  (0, process_error_context_js_1.markMutationSent)();
@@ -1274,6 +1341,22 @@ async function bake(args) {
1274
1341
  return run(args, { fs: nodeFs, env: { ...process.env }, clock: () => new Date(), createAdapter: index_js_1.createLndAdapter });
1275
1342
  }
1276
1343
 
1344
+ },
1345
+ "commands/bake/out-errors.js": (module, exports, require) => {
1346
+ "use strict";
1347
+ exports.__esModule = true;
1348
+ exports.OutputReserveError = void 0;
1349
+ exports.reserveFailure = reserveFailure;
1350
+ /** A reservation failure whose message the commands show after "cannot reserve output file". */
1351
+ class OutputReserveError extends Error {
1352
+ constructor(message) { super(message); this.name = 'OutputReserveError'; }
1353
+ }
1354
+ exports.OutputReserveError = OutputReserveError;
1355
+ /** The text a command prints for a failed output reservation. */
1356
+ function reserveFailure(error, fallback = 'cannot reserve output file') {
1357
+ return error instanceof OutputReserveError ? `${fallback}: ${error.message}` : fallback;
1358
+ }
1359
+
1277
1360
  },
1278
1361
  "commands/bake/write-out.js": (module, exports, require) => {
1279
1362
  "use strict";
@@ -1315,60 +1398,32 @@ exports.reserveOut = reserveOut;
1315
1398
  const secret_capability_js_1 = require("../../secret-capability.js");
1316
1399
  const nodeFs = __importStar(require("node:fs/promises"));
1317
1400
  const node_fs_1 = require("node:fs");
1318
- const node_child_process_1 = require("node:child_process");
1319
1401
  const node_path_1 = require("node:path");
1320
- const node_os_1 = require("node:os");
1321
- const SID = /^S-1-\d+(?:-\d+)+$/;
1402
+ const protected_path_js_1 = require("../../protected-path.js");
1403
+ const node_child_process_1 = require("node:child_process");
1404
+ const out_errors_js_1 = require("./out-errors.js");
1322
1405
  // libuv's UV_FS_O_EXLOCK: on Windows the file is opened with share mode 0, so the
1323
- // open fails while any other handle exists and no other handle can be opened while
1324
- // it is held. Node does not export the constant; libuv passes it through.
1406
+ // open fails while any other handle with data access exists, and no such handle can
1407
+ // be opened while it is held. Node does not export the constant; libuv passes it through.
1325
1408
  const UV_FS_O_EXLOCK = 0x10000000;
1326
- class OutputShareError extends Error {
1327
- constructor() { super('output file was opened by another process before it was restricted'); this.name = 'OutputShareError'; }
1328
- }
1329
- function run(file, args) {
1330
- return new Promise(resolve => {
1331
- (0, node_child_process_1.execFile)(file, args, { shell: false, windowsHide: true, timeout: 10_000 }, (error, stdout) => resolve({ ok: error === null, stdout: String(stdout ?? '') }));
1332
- });
1333
- }
1334
- /** The current user's SID from System32 whoami, or null when unavailable or malformed. */
1335
- async function currentUserSid(systemRoot) {
1336
- const result = await run(node_path_1.win32.join(systemRoot, 'System32', 'whoami.exe'), ['/user', '/fo', 'csv', '/nh']);
1337
- if (!result.ok)
1338
- return null;
1339
- const fields = /^"[^"\r\n]*","([^"\r\n]*)"$/.exec(result.stdout.trim());
1340
- return fields && SID.test(fields[1]) ? fields[1] : null;
1409
+ class OutputShareError extends out_errors_js_1.OutputReserveError {
1410
+ constructor() { super('output file was opened or replaced by another process before it was written'); this.name = 'OutputShareError'; }
1341
1411
  }
1412
+ /**
1413
+ * Creates `path` exclusively for one credential (or report) and returns a reservation that
1414
+ * writes it through a handle no other process holds. The file is owner-only before any byte
1415
+ * is written, and that is verified; anything else fails before writing. `aclWarning` is true
1416
+ * only on a Windows volume that stores no permissions (FAT, exFAT).
1417
+ */
1342
1418
  async function reserveOut(path, fs = nodeFs) {
1343
- let handle = await fs.open(path, 'wx', 0o600);
1419
+ const nameProblem = (0, protected_path_js_1.outputNameProblem)(path);
1420
+ if (nameProblem)
1421
+ throw new out_errors_js_1.OutputReserveError(nameProblem);
1422
+ const reserved = process.platform === 'win32' ? await reserveWindows(path, fs) : await reservePosix(path, fs);
1423
+ const handle = reserved.handle;
1344
1424
  let written = false;
1345
- let aclWarning = false;
1346
- if (process.platform === 'win32') {
1347
- try {
1348
- const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
1349
- if (!node_path_1.win32.isAbsolute(systemRoot))
1350
- throw new Error('invalid Windows system directory');
1351
- // Prefer the SID: account names fail to resolve for some account types,
1352
- // such as Microsoft and Entra ID accounts. Without a valid SID, use the
1353
- // account name. One icacls call either way; its failure is the warning.
1354
- const sid = await currentUserSid(systemRoot);
1355
- const user = (0, node_os_1.userInfo)().username;
1356
- const domain = process.env.USERDOMAIN;
1357
- const principal = sid ? `*${sid}:F` : domain ? `${domain}\\${user}:F` : `${user}:F`;
1358
- const applied = await run(node_path_1.win32.join(systemRoot, 'System32', 'icacls.exe'), [path, '/inheritance:r', '/grant:r', principal]);
1359
- aclWarning = !applied.ok;
1360
- }
1361
- catch {
1362
- aclWarning = true;
1363
- }
1364
- // Windows checks access only when a handle is opened, so a handle opened before
1365
- // icacls keeps its access. Replace the creation handle with an exclusive one: it
1366
- // cannot be opened while any other handle exists, so a successful open proves no
1367
- // other process holds the file, and none can open it until the bytes are written.
1368
- handle = await exclusiveReopen(path, handle, fs);
1369
- }
1370
1425
  return {
1371
- aclWarning,
1426
+ aclWarning: reserved.aclWarning,
1372
1427
  reservation: {
1373
1428
  async write(mac) {
1374
1429
  await handle.writeFile((0, secret_capability_js_1.revealSecret)(mac));
@@ -1390,31 +1445,225 @@ async function reserveOut(path, fs = nodeFs) {
1390
1445
  },
1391
1446
  };
1392
1447
  }
1393
- async function exclusiveReopen(path, created, fs) {
1394
- let identity;
1448
+ // POSIX: create 0600 with O_EXCL, then prove the mode through the open descriptor. File
1449
+ // systems that cannot store modes (WSL drvfs, vfat, exFAT, CIFS) report other bits; chmod
1450
+ // is tried once and, if the file is still not private, it is removed and nothing is written.
1451
+ async function reservePosix(path, fs) {
1452
+ const handle = await fs.open(path, 'wx', 0o600);
1453
+ const uid = process.getuid?.();
1454
+ const private_ = async () => {
1455
+ const stat = await handle.stat();
1456
+ return (stat.mode & 0o077) === 0 && (uid === undefined || stat.uid === uid);
1457
+ };
1458
+ try {
1459
+ if (await private_())
1460
+ return { handle, aclWarning: false };
1461
+ await handle.chmod(0o600).catch(() => undefined);
1462
+ if (await private_())
1463
+ return { handle, aclWarning: false };
1464
+ }
1465
+ catch { /* checked below as not private */ }
1395
1466
  try {
1396
- identity = await created.stat({ bigint: true });
1467
+ await handle.close();
1397
1468
  }
1398
1469
  finally {
1399
- await created.close();
1470
+ await fs.unlink(path).catch(() => undefined);
1400
1471
  }
1401
- let exclusive;
1472
+ throw new out_errors_js_1.OutputReserveError('the file system did not keep owner-only mode 0600; nothing was written');
1473
+ }
1474
+ // Windows: the fixed helper creates the file with an owner-only, protected DACL in the same
1475
+ // call that creates it, so no other principal is ever granted access and none can hold a
1476
+ // handle to it (not even one that only changes permissions). The file is then opened with
1477
+ // share mode 0, and the owner and DACL of the object at the path are read back while that
1478
+ // handle is held: the owner must be this user and the DACL must grant only this user.
1479
+ async function reserveWindows(path, fs) {
1480
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
1481
+ if (!node_path_1.win32.isAbsolute(systemRoot))
1482
+ throw new out_errors_js_1.OutputReserveError('invalid Windows system directory');
1483
+ const creation = await createPrivateFile(path, systemRoot);
1484
+ if (!creation)
1485
+ throw new out_errors_js_1.OutputReserveError('could not create the file with owner-only permissions');
1486
+ let handle;
1402
1487
  try {
1403
- exclusive = await fs.open(path, node_fs_1.constants.O_RDWR | UV_FS_O_EXLOCK);
1488
+ handle = await fs.open(path, node_fs_1.constants.O_RDWR | UV_FS_O_EXLOCK);
1404
1489
  }
1405
1490
  catch {
1406
- // Another handle exists (EBUSY) or the file changed: the reservation is unsafe.
1407
- // Nothing has been written; remove the empty file and fail.
1491
+ // Another handle exists (EBUSY) or the file is gone. Nothing has been written.
1492
+ creation.end();
1408
1493
  await fs.unlink(path).catch(() => undefined);
1409
1494
  throw new OutputShareError();
1410
1495
  }
1411
- const reopened = await exclusive.stat({ bigint: true }).catch(() => null);
1412
- if (!reopened || reopened.dev !== identity.dev || reopened.ino !== identity.ino) {
1413
- // A different file now has this name; do not write to it or delete it.
1414
- await exclusive.close();
1496
+ // FAT and exFAT keep no owner and no ACL, so there is nothing to verify.
1497
+ if (creation.noAcl) {
1498
+ creation.end();
1499
+ return { handle, aclWarning: true };
1500
+ }
1501
+ const found = await creation.verify();
1502
+ if (!found || found.owner !== creation.sid) {
1503
+ // Not provably the file the helper created: do not write to it or delete it.
1504
+ await handle.close();
1415
1505
  throw new OutputShareError();
1416
1506
  }
1417
- return exclusive;
1507
+ if (!ownerOnly(found, creation.sid)) {
1508
+ try {
1509
+ await handle.close();
1510
+ }
1511
+ finally {
1512
+ await fs.unlink(path).catch(() => undefined);
1513
+ }
1514
+ throw new out_errors_js_1.OutputReserveError('the file did not keep owner-only permissions (another account has access); nothing was written');
1515
+ }
1516
+ return { handle, aclWarning: false };
1517
+ }
1518
+ const SID = /^S-1-\d+(?:-\d+)+$/;
1519
+ // The fixed helper creates the file with CreateNew and a security descriptor that is
1520
+ // private from the first instant: owner = this user, a protected DACL (nothing inherited)
1521
+ // and one ACE granting this user full control. No other principal is ever granted access,
1522
+ // so none can open a handle to the file, including one that only changes its permissions.
1523
+ // The path arrives through the environment, never through the command text.
1524
+ const helperScript = String.raw `
1525
+ $ErrorActionPreference = 'Stop'
1526
+ $ProgressPreference = 'SilentlyContinue'
1527
+ function Describe($sec) {
1528
+ $owner = $sec.GetOwner([Security.Principal.SecurityIdentifier])
1529
+ $rules = @($sec.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) | ForEach-Object {
1530
+ @{ sid = $_.IdentityReference.Value; allow = ($_.AccessControlType -eq 'Allow'); inherited = $_.IsInherited; rights = [int64]$_.FileSystemRights } })
1531
+ return @{ owner = $(if ($owner) { $owner.Value } else { $null }); protected = $sec.AreAccessRulesProtected; rules = $rules }
1532
+ }
1533
+ try {
1534
+ $path = $env:MACWARDEN_PRIVATE_OUT
1535
+ $me = [Security.Principal.WindowsIdentity]::GetCurrent().User
1536
+ # FAT and exFAT volumes store no Windows permissions at all.
1537
+ $noAcl = $false
1538
+ try { $noAcl = (New-Object IO.DriveInfo([IO.Path]::GetPathRoot($path))).DriveFormat -match '^(FAT|FAT12|FAT16|FAT32|exFAT)$' } catch { $noAcl = $false }
1539
+ if ($noAcl) {
1540
+ $stream = New-Object IO.FileStream($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
1541
+ $stream.Dispose(); $created = $null
1542
+ } else {
1543
+ $sec = New-Object Security.AccessControl.FileSecurity
1544
+ $sec.SetOwner($me)
1545
+ $sec.SetAccessRuleProtection($true, $false)
1546
+ $sec.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($me, [Security.AccessControl.FileSystemRights]::FullControl, [Security.AccessControl.AccessControlType]::Allow)))
1547
+ $stream = New-Object IO.FileStream($path, [IO.FileMode]::CreateNew, [Security.AccessControl.FileSystemRights]::FullControl, [IO.FileShare]::None, 4096, [IO.FileOptions]::None, $sec)
1548
+ try { $created = Describe $stream.GetAccessControl() } catch { $created = @{ unreadable = $true } } finally { $stream.Dispose() }
1549
+ }
1550
+ } catch { [Console]::Out.WriteLine('FAILED'); exit 1 }
1551
+ [Console]::Out.WriteLine('CREATED ' + (@{ sid = $me.Value; noAcl = $noAcl; acl = $created } | ConvertTo-Json -Compress -Depth 4)); [Console]::Out.Flush()
1552
+ if ([Console]::In.ReadLine() -ne 'verify') { exit 0 }
1553
+ try { $found = Describe ([IO.File]::GetAccessControl($path, [Security.AccessControl.AccessControlSections]'Owner, Access')) } catch { $found = $null }
1554
+ [Console]::Out.WriteLine('VERIFIED ' + (@{ acl = $found } | ConvertTo-Json -Compress -Depth 4)); [Console]::Out.Flush()
1555
+ `;
1556
+ function parseAcl(value) {
1557
+ if (!value || typeof value !== 'object')
1558
+ return null;
1559
+ const record = value;
1560
+ const rules = Array.isArray(record.rules) ? record.rules : record.rules && typeof record.rules === 'object' ? [record.rules] : null;
1561
+ if (!rules || typeof record.protected !== 'boolean')
1562
+ return null;
1563
+ const parsed = [];
1564
+ for (const rule of rules) {
1565
+ if (!rule || typeof rule.sid !== 'string' || typeof rule.allow !== 'boolean'
1566
+ || typeof rule.inherited !== 'boolean' || typeof rule.rights !== 'number')
1567
+ return null;
1568
+ parsed.push({ sid: rule.sid, allow: rule.allow, inherited: rule.inherited, rights: rule.rights });
1569
+ }
1570
+ return { owner: typeof record.owner === 'string' ? record.owner : null, protected: record.protected, rules: parsed };
1571
+ }
1572
+ /** True when the ACL grants only `sid`, nothing is inherited, and `sid` owns the object. */
1573
+ function ownerOnly(acl, sid) {
1574
+ return !!acl && acl.owner === sid && acl.protected && acl.rules.length > 0
1575
+ && acl.rules.every(rule => rule.sid === sid && rule.allow && !rule.inherited);
1576
+ }
1577
+ /**
1578
+ * Creates `path` (which must not exist) with an owner-only security descriptor through the
1579
+ * fixed System32 PowerShell helper, then keeps the helper waiting so the caller can verify
1580
+ * the object at the path after opening it exclusively. Null when the helper did not report
1581
+ * a created file (the path already existed, or the helper is unavailable or failed).
1582
+ */
1583
+ function createPrivateFile(path, systemRoot) {
1584
+ const executable = node_path_1.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
1585
+ return new Promise(resolve => {
1586
+ let buffered = '';
1587
+ let exited = false;
1588
+ const waiting = [];
1589
+ const complete = (prefix) => buffered.split(/\r?\n/).slice(0, -1).find(line => line.startsWith(prefix));
1590
+ const deliver = () => {
1591
+ for (const item of [...waiting]) {
1592
+ const line = complete(item.prefix);
1593
+ if (line !== undefined || exited) {
1594
+ waiting.splice(waiting.indexOf(item), 1);
1595
+ item.settle(line === undefined ? null : line.slice(item.prefix.length));
1596
+ }
1597
+ }
1598
+ };
1599
+ const next = (prefix) => new Promise(settle => { waiting.push({ prefix, settle }); deliver(); });
1600
+ let child;
1601
+ try {
1602
+ child = (0, node_child_process_1.execFile)(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand',
1603
+ Buffer.from(helperScript, 'utf16le').toString('base64')], {
1604
+ shell: false, windowsHide: true, timeout: 20_000, maxBuffer: 64 * 1024, encoding: 'utf8',
1605
+ env: { SystemRoot: systemRoot, WINDIR: systemRoot, PATH: node_path_1.win32.join(systemRoot, 'System32'),
1606
+ PSModulePath: node_path_1.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'Modules'),
1607
+ MACWARDEN_PRIVATE_OUT: path },
1608
+ }, (_error, stdout) => {
1609
+ // The callback carries the complete output; streamed chunks may not all have arrived.
1610
+ if (typeof stdout === 'string' && stdout.length > buffered.length)
1611
+ buffered = stdout;
1612
+ buffered += '\n';
1613
+ exited = true;
1614
+ deliver();
1615
+ });
1616
+ }
1617
+ catch {
1618
+ child = undefined;
1619
+ }
1620
+ if (!child?.stdout || !child.stdin) {
1621
+ resolve(null);
1622
+ return;
1623
+ }
1624
+ const stdin = child.stdin;
1625
+ stdin.on('error', () => undefined);
1626
+ child.stdout.on('data', (chunk) => { if (!exited) {
1627
+ buffered += String(chunk);
1628
+ deliver();
1629
+ } });
1630
+ const end = () => { try {
1631
+ stdin.end();
1632
+ }
1633
+ catch { /* the helper already exited */ } };
1634
+ void next('CREATED ').then(line => {
1635
+ let reply;
1636
+ try {
1637
+ reply = line === null ? null : JSON.parse(line);
1638
+ }
1639
+ catch {
1640
+ reply = null;
1641
+ }
1642
+ if (!reply || typeof reply.sid !== 'string' || !SID.test(reply.sid)) {
1643
+ end();
1644
+ resolve(null);
1645
+ return;
1646
+ }
1647
+ let verified;
1648
+ resolve({ sid: reply.sid, noAcl: reply.noAcl === true, created: parseAcl(reply.acl), end,
1649
+ verify: () => verified ??= (async () => {
1650
+ try {
1651
+ stdin.write('verify\n');
1652
+ }
1653
+ catch {
1654
+ return null;
1655
+ }
1656
+ const found = await next('VERIFIED ');
1657
+ end();
1658
+ try {
1659
+ return found === null ? null : parseAcl(JSON.parse(found).acl);
1660
+ }
1661
+ catch {
1662
+ return null;
1663
+ }
1664
+ })() });
1665
+ });
1666
+ });
1418
1667
  }
1419
1668
 
1420
1669
  },
@@ -1624,6 +1873,7 @@ const usage_js_1 = require("../watch/usage.js");
1624
1873
  const access_review_js_1 = require("../report/access-review.js");
1625
1874
  const version_js_1 = require("../version.js");
1626
1875
  const write_out_js_1 = require("./bake/write-out.js");
1876
+ const out_errors_js_1 = require("./bake/out-errors.js");
1627
1877
  const methods = [
1628
1878
  ['/lnrpc.Lightning/GetInfo', 'info:read'],
1629
1879
  ['/lnrpc.Lightning/ListPermissions', 'info:read'],
@@ -1707,9 +1957,14 @@ async function runSteps(args, deps) {
1707
1957
  return fail(2, null, '--out needs --report <format>');
1708
1958
  if (reportOut !== undefined && (0, protected_path_js_1.isProtectedPath)(reportOut))
1709
1959
  return fail(3, 'N7', 'protected report path');
1710
- const host = supplied['--lnd-rest'] ?? deps.env.MACWARDEN_LND_REST;
1711
- const certPath = supplied['--tls-cert'] ?? deps.env.MACWARDEN_TLS_CERT;
1712
- const ownPath = supplied['--macaroon'] ?? deps.env.MACWARDEN_MACAROON;
1960
+ const reportName = reportOut === undefined ? null : (0, protected_path_js_1.outputNameProblem)(reportOut);
1961
+ if (reportName)
1962
+ return fail(3, 'N7', `report path refused: ${reportName}`);
1963
+ const settings = (0, online_js_1.connectionSettings)(flag => supplied[flag], deps.env);
1964
+ if (settings.problem)
1965
+ return fail(2, null, settings.problem);
1966
+ // connectionProblem below refuses a missing setting before either path is used.
1967
+ const host = settings.host, certPath = settings.cert, ownPath = settings.own;
1713
1968
  const connection = (0, online_js_1.connectionProblem)(host, certPath, ownPath);
1714
1969
  if (connection)
1715
1970
  return fail(2, null, connection);
@@ -1935,8 +2190,8 @@ async function runSteps(args, deps) {
1935
2190
  throw error;
1936
2191
  }
1937
2192
  }
1938
- catch {
1939
- return fail(3, 'N7', 'cannot write report file', warnings);
2193
+ catch (error) {
2194
+ return fail(3, 'N7', (0, out_errors_js_1.reserveFailure)(error, 'cannot write report file'), warnings);
1940
2195
  }
1941
2196
  path = (0, node_path_1.resolve)(reportOut);
1942
2197
  }
@@ -2009,6 +2264,7 @@ const online_js_1 = require("../online.js");
2009
2264
  const index_js_3 = require("../output/index.js");
2010
2265
  const protected_path_js_1 = require("../protected-path.js");
2011
2266
  const index_js_4 = require("../registry/index.js");
2267
+ const root_key_id_js_1 = require("../root-key-id.js");
2012
2268
  const usage_js_1 = require("../watch/usage.js");
2013
2269
  function fail(code, step, message, warnings = []) {
2014
2270
  return { schema: 1, command: 'recommend', ok: false, warnings, result: null, error: { code, step, message } };
@@ -2054,12 +2310,12 @@ async function runSteps(args, deps) {
2054
2310
  return fail(2, 'R1', 'give exactly one label, root key ID or credential ID (id:…), or --target-file <file>');
2055
2311
  const selector = operands[0] ?? '';
2056
2312
  const byCredential = /^id:[0-9a-f]{16}$/.test(selector);
2057
- const byId = /^(0|[1-9][0-9]{0,19})$/.test(selector);
2313
+ const byId = (0, root_key_id_js_1.isRootKeyId)(selector);
2058
2314
  if (targetFile === undefined && !byCredential && !byId && !/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(selector))
2059
2315
  return fail(2, 'R1', 'selector must be a label, a root key ID or id:<16 hex>');
2060
- const host = flags.get('--lnd-rest') ?? deps.env.MACWARDEN_LND_REST;
2061
- const certPath = flags.get('--tls-cert') ?? deps.env.MACWARDEN_TLS_CERT;
2062
- const ownPath = flags.get('--macaroon') ?? deps.env.MACWARDEN_MACAROON;
2316
+ const { host, cert: certPath, own: ownPath, problem: mixedSource } = (0, online_js_1.connectionSettings)(flag => flags.get(flag), deps.env);
2317
+ if (mixedSource)
2318
+ return fail(2, 'R1', mixedSource);
2063
2319
  if (!host)
2064
2320
  return fail(2, 'R1', 'missing --lnd-rest (or MACWARDEN_LND_REST)');
2065
2321
  if (!certPath)
@@ -2241,28 +2497,43 @@ async function runSteps(args, deps) {
2241
2497
  // without one, the credential existed no later than its first recorded use. Two bounds,
2242
2498
  // the larger is taken: the recorded spans after creation (from 1.1.0), and the covered
2243
2499
  // total minus the most that can lie before creation (all time from the first registration
2244
- // to it). Files written before 1.0.1 record no coverage.
2500
+ // to it). Files written before 1.0.1 record no coverage. Every step must be finite: the
2501
+ // usage reader refuses impossible or reversed times, and if evidence that cannot be placed
2502
+ // on the clock still got this far, the window is invalid rather than silently long.
2245
2503
  const coverage = usage.coverageFor(pubkey);
2246
2504
  let seconds = null;
2505
+ let invalid = false;
2247
2506
  if (coverage) {
2507
+ const start = Date.parse(coverage.firstStart), end = Date.parse(coverage.lastEnd);
2248
2508
  const firstUse = records.length ? records.reduce((first, item) => Math.min(first, Date.parse(item.firstSeen)), Infinity) : null;
2249
2509
  const created = entry ? Date.parse(entry.createdAt) : firstUse;
2250
- const since = Math.max(Date.parse(coverage.firstStart), created ?? -Infinity);
2251
- const before = Math.ceil((since - Date.parse(coverage.firstStart)) / 1_000);
2252
- const lower = Math.max(coverage.coveredSeconds - before, usage.coveredSince(pubkey, since) ?? 0);
2253
- seconds = Math.max(0, Math.min(lower, Math.floor((Date.parse(coverage.lastEnd) - since) / 1_000)));
2254
- }
2255
- const short = seconds === null || seconds < exports.SHORT_OBSERVATION_SECONDS;
2510
+ const since = Math.max(start, created ?? -Infinity);
2511
+ const before = Math.ceil((since - start) / 1_000);
2512
+ const spanned = usage.coveredSince(pubkey, since);
2513
+ const lower = Math.max(coverage.coveredSeconds - before, spanned ?? 0);
2514
+ const bounded = Math.min(lower, Math.floor((end - since) / 1_000));
2515
+ invalid = ![start, end, since, before, lower, bounded, firstUse ?? 0, spanned ?? 0].every(Number.isFinite) || end < start;
2516
+ if (!invalid)
2517
+ seconds = Math.max(0, bounded);
2518
+ }
2519
+ // An invalid window is never accepted: --accept-short-observation accepts a short window,
2520
+ // not one that cannot be computed.
2521
+ const short = invalid || seconds === null || seconds < exports.SHORT_OBSERVATION_SECONDS;
2256
2522
  const observation = { seconds, since: coverage?.firstStart ?? null, until: coverage?.lastEnd ?? null,
2257
- sessions: coverage?.sessions ?? 0, short, accepted: short && acceptShort };
2258
- if (short && observed.length)
2523
+ sessions: coverage?.sessions ?? 0, short, accepted: short && acceptShort && !invalid };
2524
+ if (invalid && observed.length)
2525
+ warnings.push({ code: 'observation-invalid', message: 'the observation window cannot be computed '
2526
+ + 'from the coverage record in the usage file (its times are not valid instants in order); no narrowing command is printed, even with '
2527
+ + '--accept-short-observation. Stop watch, move the usage file aside and run watch again' });
2528
+ else if (short && observed.length)
2259
2529
  warnings.push({ code: 'short-observation', message: `usage was observed for ${seconds === null
2260
2530
  ? 'an unknown time (this usage file has no watch coverage record)' : (0, index_js_3.formatDuration)(seconds)}, less than ${(0, index_js_3.formatDuration)(exports.SHORT_OBSERVATION_SECONDS)}; `
2261
2531
  + 'methods the app calls rarely (such as payments) may be missing, and a credential narrowed to this list would fail on them. '
2262
2532
  + (acceptShort ? 'The narrowing command is printed because --accept-short-observation was given'
2263
2533
  : 'Keep watch running longer, or pass --accept-short-observation to print the narrowing command anyway') });
2264
2534
  const commands = [];
2265
- if (observed.length && !unmapped.length && !incomplete && !unverified && !beyond.length && (!short || acceptShort)) {
2535
+ if (observed.length && !unmapped.length && !incomplete && !unverified && !beyond.length && !invalid
2536
+ && (!short || acceptShort)) {
2266
2537
  const label = entry?.label ? narrowLabel(entry.label) : 'narrowed';
2267
2538
  const connection = `--lnd-rest ${quote(host)} --tls-cert ${quote((0, online_js_1.recoveryPath)(certPath))} --macaroon ${quote((0, online_js_1.recoveryPath)(ownPath))}` +
2268
2539
  (registryPath ? ` --registry ${quote((0, online_js_1.recoveryPath)(registryPath))}` : '');
@@ -2337,13 +2608,17 @@ const index_js_1 = require("../lnd/index.js");
2337
2608
  const index_js_2 = require("../decode/index.js");
2338
2609
  const protected_path_js_1 = require("../protected-path.js");
2339
2610
  const environment_js_1 = require("../environment.js");
2340
- const index_js_3 = require("../registry/index.js");
2341
- const index_js_4 = require("../output/index.js");
2611
+ const index_js_3 = require("../grade/index.js");
2612
+ const index_js_4 = require("../registry/index.js");
2613
+ const index_js_5 = require("../output/index.js");
2342
2614
  const rotate_js_1 = require("./rotate.js");
2343
2615
  const valueFlags = new Set(['--registry', '--lnd-rest', '--tls-cert', '--macaroon', '--out-dir']);
2344
2616
  const switchFlags = new Set(['--finish', '--dry-run', '--yes', '--allow-admin', '--break-lock', '--json']);
2345
2617
  const methods = [
2346
2618
  ['/lnrpc.Lightning/GetInfo', 'info:read'],
2619
+ // Snapshot drift, as on every online command, checked by respond itself (MW110-09): nested
2620
+ // rotations cannot supply it when nothing is rotated (a dry run, an empty batch).
2621
+ ['/lnrpc.Lightning/ListPermissions', 'info:read'],
2347
2622
  ['/lnrpc.Lightning/ListMacaroonIDs', 'macaroon:read'],
2348
2623
  ['/lnrpc.Lightning/CheckMacaroonPermissions', 'macaroon:read'],
2349
2624
  ];
@@ -2357,7 +2632,7 @@ exports.ROOT_KEY_0_GUIDANCE = [
2357
2632
  exports.OWN_ON_ROOT_KEY_0 = 'macwarden\'s own credential is on root key ID 0: after the change it stops working too. Bake a new own macaroon from the regenerated admin.macaroon before running macwarden again.';
2358
2633
  exports.REVOKED_BUT_PRESENT_GUIDANCE = 'The registry records these root key IDs as revoked, but LND still holds them. Either an earlier revoke did not complete, or someone holding a credential that can bake (macaroon:generate) created credentials on them again. Treat them as live and revoke each: macwarden revoke <id>.';
2359
2634
  exports.BAKING_RISK_GUIDANCE = [
2360
- 'These credentials can bake new macaroons (macaroon:generate). If one leaked, its holder can create credentials on any root key ID, including the replacements respond stages and macwarden\'s own ID. Revoking the old root key IDs does not remove credentials baked that way.',
2635
+ 'These credentials can bake new macaroons (macaroon:generate or uri:/lnrpc.Lightning/BakeMacaroon), or hold permissions macwarden could not assess. If one leaked, its holder can create credentials on any root key ID, including the replacements respond stages and macwarden\'s own ID. Revoking the old root key IDs does not remove credentials baked that way.',
2361
2636
  'respond --finish revokes these first; keep the time between staging and finishing short. If a credential that can bake may have leaked, regenerate all root keys instead (see the root key ID 0 guidance): only that invalidates everything an attacker could have baked.',
2362
2637
  'Each PROVEN result covers that credential only: its old root key ID is gone and its replacement is accepted. It does not prove that no other credential exists.',
2363
2638
  ];
@@ -2390,9 +2665,9 @@ function parse(args, env) {
2390
2665
  const finish = flags.has('--finish');
2391
2666
  if (finish && (flags.has('--out-dir') || flags.has('--allow-admin')))
2392
2667
  return '--finish takes neither --out-dir nor --allow-admin';
2393
- const hostArg = get('--lnd-rest') ?? env.MACWARDEN_LND_REST;
2394
- const cert = get('--tls-cert') ?? env.MACWARDEN_TLS_CERT;
2395
- const own = get('--macaroon') ?? env.MACWARDEN_MACAROON;
2668
+ const { host: hostArg, cert, own, problem: mixedSource } = (0, online_js_1.connectionSettings)(get, env);
2669
+ if (mixedSource)
2670
+ return mixedSource;
2396
2671
  if (!hostArg)
2397
2672
  return 'missing --lnd-rest (or MACWARDEN_LND_REST)';
2398
2673
  if (!cert)
@@ -2412,10 +2687,9 @@ function parse(args, env) {
2412
2687
  allowAdmin: flags.has('--allow-admin'), breakLock: flags.has('--break-lock') };
2413
2688
  }
2414
2689
  const byId = (left, right) => { const a = BigInt(left), b = BigInt(right); return a < b ? -1 : a > b ? 1 : 0; };
2415
- /** Active entries that are not themselves a staged replacement of another active entry. */
2690
+ /** Active entries that are not themselves a staged replacement of another active entry (as rotate S3; RL-07). */
2416
2691
  function originals(entries) {
2417
- return entries.filter(entry => entry.status === 'active' && !(entry.replaces !== null
2418
- && entries.some(old => old.status === 'active' && old.rootKeyId === entry.replaces)));
2692
+ return entries.filter(entry => entry.status === 'active' && !(0, index_js_4.isStagedReplacement)(entries, entry));
2419
2693
  }
2420
2694
  function stagedReplacement(entries, old) {
2421
2695
  return entries.find(entry => entry.status === 'active' && entry.replaces === old.rootKeyId);
@@ -2496,7 +2770,7 @@ async function runSteps(args, deps) {
2496
2770
  try {
2497
2771
  if (request.registry && (0, protected_path_js_1.isProtectedPath)(request.registry))
2498
2772
  return fail(3, 'P3', 'protected registry path');
2499
- entries = (await (0, index_js_3.openRegistry)(request.registry, { fs: deps.fs, env: deps.env })).entries;
2773
+ entries = (await (0, index_js_4.openRegistry)(request.registry, { fs: deps.fs, env: deps.env })).entries;
2500
2774
  }
2501
2775
  catch {
2502
2776
  return fail(3, 'P3', 'unreadable or invalid registry', warnings);
@@ -2509,6 +2783,8 @@ async function runSteps(args, deps) {
2509
2783
  let ids;
2510
2784
  try {
2511
2785
  node = (0, online_js_1.nodeInfo)(await lnd.getInfo());
2786
+ if ((0, online_js_1.drift)(await lnd.listPermissions()))
2787
+ warnings.push('snapshot-drift');
2512
2788
  ids = await (0, online_js_1.listIds)(lnd);
2513
2789
  }
2514
2790
  catch (error) {
@@ -2523,14 +2799,14 @@ async function runSteps(args, deps) {
2523
2799
  // A staged replacement is believable only if macwarden could have written it; anything else
2524
2800
  // is tampering and is reported instead of trusted (it could hide a credential from the batch).
2525
2801
  // A chain or cycle of links would otherwise make originals() drop live credentials.
2526
- const tampered = (0, index_js_3.replacementLinkProblems)(mine)[0];
2802
+ const tampered = (0, index_js_4.replacementLinkProblems)(mine)[0];
2527
2803
  if (tampered)
2528
2804
  return fail(3, 'P5', `registry tampering: root key ID ${tampered.rootKeyId} claims to replace ${tampered.replaces}, but ${tampered.problem}; restore the registry from a trusted copy`, warnings);
2529
2805
  // Whether a credential is a canary is decided by its file, not by the editable registry caveat list:
2530
2806
  // a custom caveat the registry does not record would let the batch rotate a canary into an
2531
2807
  // ordinary credential. Refused before any mutation, like a forged replacement link.
2532
2808
  for (const entry of originals(mine)) {
2533
- if ((0, index_js_3.hasCustomCaveat)(entry))
2809
+ if ((0, index_js_4.hasCustomCaveat)(entry))
2534
2810
  continue;
2535
2811
  const file = await readCredential(entry.outPath, deps);
2536
2812
  if (file && file.facts.fingerprint === entry.fingerprint && file.facts.caveats.some(caveat => caveat.type === 'lnd-custom'))
@@ -2540,7 +2816,12 @@ async function runSteps(args, deps) {
2540
2816
  // Only active entries cover an ID LND holds; a revoked entry for a present ID needs action.
2541
2817
  const active = new Set(mine.filter(entry => entry.status === 'active').map(entry => entry.rootKeyId));
2542
2818
  const revokedIds = new Set(mine.filter(entry => entry.status === 'revoked').map(entry => entry.rootKeyId));
2543
- const canBake = (entry) => entry.grade === 'ADMIN' || entry.permissions.includes('macaroon:generate');
2819
+ // Baking authority, not the ADMIN grade (MW110-06): macaroon:generate or the BakeMacaroon URI. The
2820
+ // recorded grade is editable, so the permissions are also assessed here; anything unassessed may bake.
2821
+ const canBake = (entry) => {
2822
+ const assessed = (0, index_js_3.grade)(entry.permissions);
2823
+ return (0, index_js_3.canBake)(entry.permissions, entry.grade === 'UNASSESSED' ? 'UNASSESSED' : assessed.grade, assessed.warnings);
2824
+ };
2544
2825
  // revoke resolves a label among this node's active entries; where that is ambiguous, advice names the ID.
2545
2826
  const target = (entry) => entry.label !== null
2546
2827
  && mine.filter(other => other.status === 'active' && other.label === entry.label).length === 1 ? entry.label : entry.rootKeyId;
@@ -2554,7 +2835,7 @@ async function runSteps(args, deps) {
2554
2835
  items.push({ ...base, action: 'manual', status: 'manual', note: entry.rootKeyId === ownId
2555
2836
  ? 'macwarden\'s own credential: bake a new own macaroon, switch MACWARDEN_MACAROON to it, then revoke this ID'
2556
2837
  : entry.rootKeyId === '0' ? 'root key ID 0: see the root key ID 0 guidance' : 'no label: revoke it by root key ID, then bake a replacement' });
2557
- else if ((0, index_js_3.hasCustomCaveat)(entry))
2838
+ else if ((0, index_js_4.hasCustomCaveat)(entry))
2558
2839
  items.push({ ...base, action: 'manual', status: 'manual', note: exports.CUSTOM_CAVEAT_NOTE.replace('<label>', target(entry)) });
2559
2840
  // A replacement an earlier stage allocated but never verified or wrote (an uncertain bake, a failed
2560
2841
  // verification or write, a failed registry update) is unresolved work, not a usable stage. It is
@@ -2642,6 +2923,12 @@ async function runSteps(args, deps) {
2642
2923
  const outDir = (0, node_path_1.resolve)(request.outDir);
2643
2924
  if ((0, protected_path_js_1.isProtectedPath)(outDir))
2644
2925
  return fail(3, 'P6', 'protected output directory', warnings);
2926
+ // Every replacement path is checked before anything is created, so no label can fail
2927
+ // after earlier replacements were written (a label such as CON maps to a Windows device).
2928
+ const outName = (0, protected_path_js_1.outputNameProblem)(outDir)
2929
+ ?? selected.map(item => (0, protected_path_js_1.outputNameProblem)((0, node_path_1.join)(outDir, `${item.label}.macaroon`))).find(problem => problem !== null) ?? null;
2930
+ if (outName)
2931
+ return fail(3, 'P6', `output path refused: ${outName}`, warnings);
2645
2932
  try {
2646
2933
  await deps.fs.mkdir(outDir, { mode: 0o700 });
2647
2934
  }
@@ -2706,7 +2993,7 @@ async function runSteps(args, deps) {
2706
2993
  // P9: independent proof after every finish: the old ID is gone and the replacement is accepted.
2707
2994
  if (request.finish && !interrupted) {
2708
2995
  const after = await (0, online_js_1.listIds)(lnd).then(list => new Set(list.map(id => id.toString())), () => null);
2709
- const current = await (0, index_js_3.openRegistry)(request.registry, { fs: deps.fs, env: deps.env }).then(view => view.entries).catch(() => mine);
2996
+ const current = await (0, index_js_4.openRegistry)(request.registry, { fs: deps.fs, env: deps.env }).then(view => view.entries).catch(() => mine);
2710
2997
  for (const item of selected.filter(entry => entry.status === 'finished')) {
2711
2998
  const old = current.find(entry => entry.nodePubkey.toLowerCase() === pubkey && entry.rootKeyId === item.rootKeyId);
2712
2999
  const next = current.find(entry => entry.nodePubkey.toLowerCase() === pubkey && entry.rootKeyId === item.replacementRootKeyId);
@@ -2744,7 +3031,7 @@ async function respond(args) {
2744
3031
  return run(args, { fs: nodeFs, env: { ...process.env }, clock: () => new Date(), createAdapter: index_js_1.createLndAdapter,
2745
3032
  stdin: process.stdin,
2746
3033
  prompt: async (question) => {
2747
- (0, index_js_4.writeRotatePrompt)(question);
3034
+ (0, index_js_5.writeRotatePrompt)(question);
2748
3035
  const input = (0, node_readline_1.createInterface)({ input: process.stdin, terminal: false });
2749
3036
  const signal = (0, cancellation_js_1.cancellationSignal)();
2750
3037
  const close = () => input.close();
@@ -2814,8 +3101,8 @@ const index_js_4 = require("../grade/index.js");
2814
3101
  const protected_path_js_1 = require("../protected-path.js");
2815
3102
  const environment_js_1 = require("../environment.js");
2816
3103
  const process_error_context_js_1 = require("../process-error-context.js");
3104
+ const root_key_id_js_1 = require("../root-key-id.js");
2817
3105
  const index_js_5 = require("../output/index.js");
2818
- const maxId = (1n << 64n) - 1n;
2819
3106
  const valueFlags = new Set(['--target-file', '--registry', '--lnd-rest', '--tls-cert', '--macaroon']);
2820
3107
  const switchFlags = new Set(['--unregistered', '--yes', '--break-lock', '--dry-run', '--json']);
2821
3108
  const requiredMethods = [
@@ -2861,11 +3148,9 @@ function parse(args, env) {
2861
3148
  if (target && /^[0-9]+$/.test(target)) {
2862
3149
  if (/^0+$/.test(target))
2863
3150
  return 'root key ID 0 cannot be revoked: it is shared by the default macaroons (SAFE-03)';
2864
- if (!/^[1-9][0-9]{0,19}$/.test(target))
3151
+ if (!(0, root_key_id_js_1.isRootKeyId)(target))
2865
3152
  return targetRule;
2866
3153
  id = BigInt(target);
2867
- if (id > maxId)
2868
- return targetRule;
2869
3154
  }
2870
3155
  else if (!target || !/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(target))
2871
3156
  return targetRule;
@@ -2873,10 +3158,10 @@ function parse(args, env) {
2873
3158
  const value = flags.get(name);
2874
3159
  return typeof value === 'string' ? value : undefined;
2875
3160
  };
2876
- const host = get('--lnd-rest') ?? env.MACWARDEN_LND_REST;
2877
- const cert = get('--tls-cert') ?? env.MACWARDEN_TLS_CERT;
2878
- const own = get('--macaroon') ?? env.MACWARDEN_MACAROON;
3161
+ const { host, cert, own, problem: mixedSource } = (0, online_js_1.connectionSettings)(get, env);
2879
3162
  const targetFile = get('--target-file');
3163
+ if (mixedSource)
3164
+ return mixedSource;
2880
3165
  const connection = (0, online_js_1.connectionProblem)(host, cert, own);
2881
3166
  if (connection)
2882
3167
  return connection;
@@ -3007,10 +3292,14 @@ async function runSteps(args, deps, warnings, state) {
3007
3292
  }
3008
3293
  else {
3009
3294
  entry = local.find(row => BigInt(row.rootKeyId) === request.id);
3010
- if (!entry && entries.some(row => BigInt(row.rootKeyId) === request.id))
3011
- return fail(2, 'V6', 'root key ID is registered only for another node', warnings);
3295
+ // Root key IDs are per node (small IDs from lncli bakemacaroon --root_key_id exist on every
3296
+ // node), so an entry for another node says nothing about this node's ID: without
3297
+ // --unregistered the operator may have meant that node and is told so; with it, this node's
3298
+ // ID is revoked and recorded under this node's pubkey (RL-04).
3012
3299
  if (!entry && !request.unregistered)
3013
- return fail(2, 'V6', 'unregistered root key ID requires --unregistered; other tools and other operators share the same LND store, including Lightning Terminal', warnings);
3300
+ return fail(2, 'V6', entries.some(row => BigInt(row.rootKeyId) === request.id)
3301
+ ? 'root key ID is registered only for another node; check --lnd-rest, or pass --unregistered to revoke this node\'s root key ID'
3302
+ : 'unregistered root key ID requires --unregistered; other tools and other operators share the same LND store, including Lightning Terminal', warnings);
3014
3303
  }
3015
3304
  const id = entry ? BigInt(entry.rootKeyId) : request.id;
3016
3305
  const inspection = `root key ID ${id}; run \`${(0, online_js_1.inventoryRecoveryCommand)(request)}\` ` +
@@ -3029,9 +3318,13 @@ async function runSteps(args, deps, warnings, state) {
3029
3318
  catch {
3030
3319
  target = undefined;
3031
3320
  }
3032
- if (target && target.facts.rootKeyId !== id)
3033
- return fail(3, 'V6', 'default target file root key ID differs from the registry entry', warnings);
3034
- if (target && target.facts.fingerprint !== entry.fingerprint)
3321
+ const relation = target && (0, index_js_3.recordedFileRelation)(entry, target.facts);
3322
+ if (relation === 'tampered')
3323
+ return fail(3, 'V6', 'default target file carries the registry entry\'s fingerprint ' +
3324
+ 'on another root key ID: the registry entry was edited; restore the registry from a trusted copy', warnings);
3325
+ // Another credential at a reused path, or another macaroon on this root key ID, is not
3326
+ // the target: the revocation is proven by ID only (RL-01).
3327
+ if (relation !== 'match')
3035
3328
  target = undefined;
3036
3329
  }
3037
3330
  let ids;
@@ -3079,8 +3372,8 @@ async function runSteps(args, deps, warnings, state) {
3079
3372
  try {
3080
3373
  const current = await (0, index_js_3.openRegistry)(request.registry, { fs: deps.fs, env: deps.env });
3081
3374
  const resolved = current.find(pubkey, id.toString());
3082
- if (!(0, node_util_1.isDeepStrictEqual)(resolved, entry)
3083
- || (!entry && current.entries.some(row => BigInt(row.rootKeyId) === id)))
3375
+ // Node-scoped like V6: an entry for this ID on another node is not this target (RL-04).
3376
+ if (!(0, node_util_1.isDeepStrictEqual)(resolved, entry))
3084
3377
  return fail(3, 'V10', 'resolved registry entry changed since V6; nothing sent to LND', warnings);
3085
3378
  // Retain the lock owner while preserving unrelated entries from the reread.
3086
3379
  registry.reload(current.entries);
@@ -3128,6 +3421,10 @@ async function runSteps(args, deps, warnings, state) {
3128
3421
  return fail(7, 'V12', `root key ID still present after deletion; registry unchanged; ${inspection}`, warnings);
3129
3422
  }
3130
3423
  if (proof === 'rejected') {
3424
+ // A signature mismatch alone is no proof (RT-01: an altered copy gets it too). Here it is
3425
+ // corroborating evidence only: both branches above return unless ListMacaroonIDs shows the
3426
+ // ID absent, and proof 'rejected' means V8 saw these exact bytes accepted in this run, so a
3427
+ // mismatch now can only come from LND's state for this ID, not from altered bytes.
3131
3428
  try {
3132
3429
  const checked = await lnd.checkMacaroon(target.secret, target.facts.permissions);
3133
3430
  if (checked.valid !== false || (checked.reason !== 'root-key-missing' && checked.reason !== 'signature-mismatch'))
@@ -3264,6 +3561,7 @@ const index_js_4 = require("../registry/index.js");
3264
3561
  const errors_js_1 = require("../errors.js");
3265
3562
  const index_js_5 = require("../output/index.js");
3266
3563
  const write_out_js_1 = require("./bake/write-out.js");
3564
+ const out_errors_js_1 = require("./bake/out-errors.js");
3267
3565
  const allocate_js_1 = require("./bake/allocate.js");
3268
3566
  const valueFlags = new Set(['--out', '--registry', '--lnd-rest', '--tls-cert', '--macaroon']);
3269
3567
  const switchFlags = new Set(['--allow-admin', '--break-lock', '--dry-run', '--json', '--finish', '--yes']);
@@ -3323,9 +3621,9 @@ function parse(args, env) {
3323
3621
  return '--yes is only used with --finish; the stage run asks no confirmation it can skip';
3324
3622
  if (!label)
3325
3623
  return 'give exactly one label';
3326
- const host = get('--lnd-rest') ?? env.MACWARDEN_LND_REST;
3327
- const cert = get('--tls-cert') ?? env.MACWARDEN_TLS_CERT;
3328
- const own = get('--macaroon') ?? env.MACWARDEN_MACAROON;
3624
+ const { host, cert, own, problem: mixedSource } = (0, online_js_1.connectionSettings)(get, env);
3625
+ if (mixedSource)
3626
+ return mixedSource;
3329
3627
  const connection = (0, online_js_1.connectionProblem)(host, cert, own);
3330
3628
  if (connection)
3331
3629
  return connection;
@@ -3353,10 +3651,54 @@ function tampering(entries, old) {
3353
3651
  const CUSTOM_CAVEAT_REFUSAL = 'this credential carries a custom caveat (a canary, or another lnd-custom caveat); a rotation ' +
3354
3652
  'would replace it with an ordinary credential. Revoke it with macwarden revoke <label>, then bake a new canary with ' +
3355
3653
  'macwarden bake --canary and restart macwarden watch';
3654
+ const OLD_FILE_TAMPERED = 'old credential file carries the registry entry\'s fingerprint on another root key ID: ' +
3655
+ 'the registry entry was edited; restore the registry from a trusted copy';
3656
+ /** Active entries with this label that are not a staged replacement (SPEC 6 S3; RL-07). */
3356
3657
  function originals(entries, label) {
3357
- return entries.filter(entry => entry.label === label && entry.status === 'active'
3358
- && (entry.replaces === null || entries.some(old => old.status === 'revoked'
3359
- && old.nodePubkey.toLowerCase() === entry.nodePubkey.toLowerCase() && old.rootKeyId === entry.replaces)));
3658
+ return entries.filter(entry => entry.label === label && entry.status === 'active' && !(0, index_js_4.isStagedReplacement)(entries, entry));
3659
+ }
3660
+ /**
3661
+ * The original for this label (RL-04). One registry may hold several nodes, and bake allows the
3662
+ * same label on each, so a label active on several nodes is resolved against the node macwarden
3663
+ * is connected to: its own credential and TLS certificate are read and GetInfo (read-only) names
3664
+ * the node. Returns the node's candidates, or a failure at `step`.
3665
+ */
3666
+ async function nodeCandidates(candidates, request, deps, step) {
3667
+ if (new Set(candidates.map(entry => entry.nodePubkey.toLowerCase())).size < 2)
3668
+ return candidates;
3669
+ let cert;
3670
+ let own;
3671
+ try {
3672
+ if ((0, protected_path_js_1.isProtectedPath)(request.cert) || (0, protected_path_js_1.isProtectedPath)(await deps.fs.realpath(request.cert)))
3673
+ return fail(4, step, 'unreadable TLS certificate');
3674
+ cert = await (0, online_js_1.readBoundedFile)(request.cert, deps.fs);
3675
+ }
3676
+ catch {
3677
+ return fail(4, step, 'unreadable TLS certificate');
3678
+ }
3679
+ try {
3680
+ own = (await (0, online_js_1.readOnlineCredential)(request.own, { stdin: deps.stdin ?? process.stdin, fs: deps.fs })).secret;
3681
+ }
3682
+ catch {
3683
+ return fail(4, step, 'unreadable own macaroon');
3684
+ }
3685
+ try {
3686
+ (0, index_js_2.decodeMacaroon)(own);
3687
+ }
3688
+ catch {
3689
+ return fail(4, step, 'undecodable own macaroon');
3690
+ }
3691
+ const adapter = deps.lnd ?? deps.createAdapter?.({ restUrl: request.host, tlsCert: cert, macaroon: own });
3692
+ if (!adapter)
3693
+ throw new Error('missing LND adapter');
3694
+ let pubkey;
3695
+ try {
3696
+ pubkey = (0, online_js_1.nodePubkey)(await (0, online_js_1.onlineAdapter)(adapter).getInfo());
3697
+ }
3698
+ catch (error) {
3699
+ return lndFailure(error, step, []);
3700
+ }
3701
+ return candidates.filter(entry => entry.nodePubkey.toLowerCase() === pubkey);
3360
3702
  }
3361
3703
  async function readTarget(path, deps) {
3362
3704
  if ((0, protected_path_js_1.isProtectedPath)(path) || (0, protected_path_js_1.isProtectedPath)(await deps.fs.realpath(path)))
@@ -3396,9 +3738,9 @@ async function finishSteps(request, deps, state) {
3396
3738
  catch {
3397
3739
  return fail(3, 'F3', 'unreadable or invalid registry');
3398
3740
  }
3399
- const candidates = originals(registry.entries, request.label);
3400
- if (candidates.length > 1)
3401
- return fail(2, 'F3', 'label is active on several nodes; use one --registry per node');
3741
+ const candidates = await nodeCandidates(originals(registry.entries, request.label), request, deps, 'F3');
3742
+ if (!Array.isArray(candidates))
3743
+ return candidates;
3402
3744
  const old = candidates[0];
3403
3745
  const forged = old && tampering(registry.entries, old);
3404
3746
  if (forged)
@@ -3504,9 +3846,12 @@ async function finishSteps(request, deps, state) {
3504
3846
  target = await readTarget(old.outPath, deps);
3505
3847
  }
3506
3848
  catch { /* Without a usable old file, ID absence is the proof. */ }
3507
- if (target && target.facts.rootKeyId !== id)
3508
- return fail(3, 'F7', 'old credential file root key ID differs from the registry entry', warnings);
3509
- if (target && target.facts.fingerprint !== old.fingerprint)
3849
+ const relation = target && (0, index_js_4.recordedFileRelation)(old, target.facts);
3850
+ if (relation === 'tampered')
3851
+ return fail(3, 'F7', OLD_FILE_TAMPERED, warnings);
3852
+ // A replacement staged at O's old path, or another macaroon on O's root key ID, is not O's
3853
+ // file: the old credential is then proven revoked by ID only, as in revoke (RL-01).
3854
+ if (relation !== 'match')
3510
3855
  target = undefined;
3511
3856
  if (target && usable(target, deps.clock())) {
3512
3857
  try {
@@ -3635,9 +3980,9 @@ async function runSteps(args, deps, state) {
3635
3980
  catch {
3636
3981
  return fail(3, 'S3', 'unreadable or invalid registry');
3637
3982
  }
3638
- const candidates = originals(registry.entries, request.label);
3639
- if (candidates.length > 1)
3640
- return fail(2, 'S3', 'label is active on several nodes; use one --registry per node');
3983
+ const candidates = await nodeCandidates(originals(registry.entries, request.label), request, deps, 'S3');
3984
+ if (!Array.isArray(candidates))
3985
+ return candidates;
3641
3986
  if (!candidates.length)
3642
3987
  return fail(2, 'S3', 'no eligible active entry for this label');
3643
3988
  const old = candidates[0];
@@ -3663,10 +4008,15 @@ async function runSteps(args, deps, state) {
3663
4008
  }
3664
4009
  catch { /* An unusable old file falls back to the recorded permissions. */ }
3665
4010
  }
4011
+ const relation = oldTarget && (0, index_js_4.recordedFileRelation)(old, oldTarget.facts);
4012
+ if (relation === 'tampered')
4013
+ return fail(3, 'S4', OLD_FILE_TAMPERED);
4014
+ // Another credential at a reused path proves nothing about O: O's recorded permissions then
4015
+ // need the informed approval below, as for a missing file (RL-01).
4016
+ if (relation === 'other-credential')
4017
+ oldTarget = undefined;
3666
4018
  const oldFacts = oldTarget?.facts;
3667
- if (oldFacts && oldFacts.rootKeyId !== BigInt(old.rootKeyId))
3668
- return fail(3, 'S4', 'old credential file root key ID differs from the registry entry');
3669
- if (oldFacts && oldFacts.fingerprint !== old.fingerprint)
4019
+ if (relation === 'same-root-key')
3670
4020
  return fail(3, 'S4', 'old credential fingerprint differs from the registry entry');
3671
4021
  // The registry's caveat list is editable metadata: the credential itself decides whether it is a
3672
4022
  // canary. A custom caveat the registry does not record is tampering, refused before any mutation.
@@ -3699,8 +4049,22 @@ async function runSteps(args, deps, state) {
3699
4049
  return fail(2, 'S5', `unassessed permissions: ${assessment.unassessed.join(', ')}`);
3700
4050
  if (assessment.grade === 'ADMIN' && !request.allowAdmin)
3701
4051
  return fail(2, 'S5', 'ADMIN permissions require --allow-admin');
4052
+ // S11 reuses O's recorded expiry duration: it is checked here, before any lock or LND call,
4053
+ // so a bad value can never leave a baked root key behind (RL-03).
4054
+ const expires = old.caveats.find(caveat => caveat.type === 'time-before' && caveat.requested !== undefined)?.requested;
4055
+ if (expires !== undefined) {
4056
+ try {
4057
+ (0, index_js_2.timeBeforeCondition)(expires, deps.clock);
4058
+ }
4059
+ catch {
4060
+ return fail(3, 'S5', 'registry entry records an invalid expiry duration; restore the registry from a trusted copy');
4061
+ }
4062
+ }
3702
4063
  if ((0, protected_path_js_1.isProtectedPath)(request.out))
3703
4064
  return fail(3, 'S5', 'protected output path');
4065
+ const outName = (0, protected_path_js_1.outputNameProblem)(request.out);
4066
+ if (outName)
4067
+ return fail(3, 'S5', `output path refused: ${outName}`);
3704
4068
  let outPath;
3705
4069
  try {
3706
4070
  outPath = await (0, protected_path_js_1.resolveUnprotectedPath)(request.out, deps.fs);
@@ -3777,8 +4141,8 @@ async function runSteps(args, deps, state) {
3777
4141
  if (reserved.aclWarning)
3778
4142
  warnings.push('acl-best-effort');
3779
4143
  }
3780
- catch {
3781
- return fail(3, 'S7', 'cannot reserve output file', warnings);
4144
+ catch (error) {
4145
+ return fail(3, 'S7', (0, out_errors_js_1.reserveFailure)(error), warnings);
3782
4146
  }
3783
4147
  }
3784
4148
  const adapter = deps.lnd ?? deps.createAdapter?.({ restUrl: request.host, tlsCert: cert, macaroon: own });
@@ -3835,20 +4199,42 @@ async function runSteps(args, deps, state) {
3835
4199
  const id = rootKeyId.toString();
3836
4200
  state.inspection = `new root key ID ${id}; old credential unchanged; ` +
3837
4201
  `run \`${(0, online_js_1.inventoryRecoveryCommand)(request)}\` to inspect state; recover with ${(0, online_js_1.recoveryCommand)(request, id)}`;
3838
- const createdAt = deps.clock().toISOString();
4202
+ // Never before O (RL-02): a clock behind O's recorded creation time must not make this
4203
+ // link look like tampering to the finish, respond and inventory checks.
4204
+ const createdAt = new Date(Math.max(deps.clock().getTime(), Date.parse(old.createdAt))).toISOString();
3839
4205
  const entry = { nodePubkey: pubkey, label: request.label, rootKeyId: id,
3840
4206
  permissions: [...permissions], caveats: [], grade: assessment.grade, fingerprint: null,
3841
4207
  outPath: null, createdAt, verified: false, status: 'active', revokedAt: null, revokeEvidence: null,
3842
4208
  replaces: old.rootKeyId, replacedBy: null };
3843
4209
  (0, cancellation_js_1.checkCancellation)();
4210
+ // The pending record may be on disk once save started (a failure after the rename, or a signal
4211
+ // during it), although nothing was sent to LND: it is removed again before reporting, so exit 3
4212
+ // and exit 6 keep meaning "nothing changed"; if the registry on disk still holds it, the exit
4213
+ // is 7 and the message names it with its recovery (RL-08).
4214
+ const unrecord = async (code, step, message) => {
4215
+ try {
4216
+ registry.removePending(pubkey, id);
4217
+ await registry.save();
4218
+ return fail(code, step, message, warnings);
4219
+ }
4220
+ catch { /* Decide from what is on disk. */ }
4221
+ try {
4222
+ if (!(await (0, index_js_4.openRegistry)(request.registry, { fs: deps.fs, env: deps.env })).find(pubkey, id))
4223
+ return fail(code, step, message, warnings);
4224
+ }
4225
+ catch { /* Unreadable: the record may remain. */ }
4226
+ return fail(7, step, `${message}; nothing sent to LND, but the registry still holds (or may hold) the pending record for root key ${id}` +
4227
+ `; run \`${(0, online_js_1.inventoryRecoveryCommand)(request)}\` and, if it lists root key ${id}, ${(0, online_js_1.recoveryCommand)(request, id)}`, warnings);
4228
+ };
3844
4229
  try {
3845
4230
  registry.add(entry);
3846
4231
  await registry.save();
3847
4232
  }
3848
4233
  catch {
3849
- return fail(3, 'S10', 'cannot record pending issued ID; nothing sent to LND', warnings);
4234
+ return await unrecord(3, 'S10', 'cannot record pending issued ID; nothing sent to LND');
3850
4235
  }
3851
- (0, cancellation_js_1.checkCancellation)();
4236
+ if ((0, cancellation_js_1.cancellationSignal)()?.aborted)
4237
+ return await unrecord(6, 'cancelled', 'interrupted');
3852
4238
  let mac;
3853
4239
  state.mutationSent = true;
3854
4240
  (0, process_error_context_js_1.markMutationSent)();
@@ -3907,7 +4293,6 @@ async function runSteps(args, deps, state) {
3907
4293
  return await recover('S11', 'bake response root key ID or permissions differ from the request');
3908
4294
  entry.permissions = [...finalFacts.permissions];
3909
4295
  entry.caveats = finalFacts.caveats.map(({ type, value }) => ({ type, value }));
3910
- const expires = old.caveats.find(caveat => caveat.type === 'time-before' && caveat.requested !== undefined)?.requested;
3911
4296
  try {
3912
4297
  mac = expires !== undefined ? (0, index_js_2.addFirstPartyCaveat)(mac, (0, index_js_2.timeBeforeCondition)(expires, deps.clock))
3913
4298
  : (0, index_js_2.encodeMacaroon)(finalFacts);
@@ -4211,7 +4596,7 @@ var __importStar = (this && this.__importStar) || (function () {
4211
4596
  };
4212
4597
  })();
4213
4598
  exports.__esModule = true;
4214
- exports.CUSTOM_CAVEAT_REJECTED = exports.REJECTED_INCONCLUSIVE = exports.OWN_ROOT_KEY_ACCEPTED = exports.CAN_BAKE_ROOT_KEY_0 = exports.CAN_BAKE_ACCEPTED = exports.ROOT_KEY_0_ACCEPTED = void 0;
4599
+ exports.CUSTOM_CAVEAT_REJECTED = exports.ROOT_KEY_0_INCONCLUSIVE = exports.OWN_ROOT_KEY_INCONCLUSIVE = exports.SIGNATURE_MISMATCH_ROOT_KEY_0 = exports.SIGNATURE_MISMATCH_HOW_TO_CONCLUDE = exports.SIGNATURE_MISMATCH_INCONCLUSIVE = exports.LOCAL_EXPIRY_UNCONFIRMED = exports.REJECTED_INCONCLUSIVE = exports.OWN_ROOT_KEY_ACCEPTED = exports.CAN_BAKE_ROOT_KEY_0 = exports.CAN_BAKE_ACCEPTED = exports.ROOT_KEY_0_ACCEPTED = void 0;
4215
4600
  exports.run = run;
4216
4601
  exports.verify = verify;
4217
4602
  const nodeFs = __importStar(require("node:fs/promises"));
@@ -4221,8 +4606,9 @@ const environment_js_1 = require("../environment.js");
4221
4606
  const protected_path_js_1 = require("../protected-path.js");
4222
4607
  const index_js_1 = require("../decode/index.js");
4223
4608
  const index_js_2 = require("../input/index.js");
4224
- const index_js_3 = require("../lnd/index.js");
4225
- const index_js_4 = require("../registry/index.js");
4609
+ const index_js_3 = require("../grade/index.js");
4610
+ const index_js_4 = require("../lnd/index.js");
4611
+ const index_js_5 = require("../registry/index.js");
4226
4612
  const inspect_js_1 = require("./inspect.js");
4227
4613
  const valueFlags = ['--registry', '--lnd-rest', '--tls-cert', '--macaroon'];
4228
4614
  // GetInfo (node identity for the registry match), ListPermissions (snapshot drift, as on every
@@ -4240,10 +4626,16 @@ exports.ROOT_KEY_0_ACCEPTED = [
4240
4626
  "That also invalidates every other macaroon on the node at once: every app's credential, macwarden's own and any replacement staged with macwarden respond. So regenerate first, in a maintenance window, then bake a fresh per-app macaroon for each app (macwarden bake) and re-pair it. Run macwarden inventory beforehand to list the apps that will need one; staging replacements before the regeneration would only see them invalidated too.",
4241
4627
  'Procedure: docs/incident-playbook.md and docs/replacing-default-macaroons.md.',
4242
4628
  ];
4243
- exports.CAN_BAKE_ACCEPTED = 'This credential can bake macaroons (macaroon:generate). If it leaked, its holder may have created credentials on other root key IDs, and revoking this one does not remove them. Replace and revoke every registered credential with macwarden respond, list unknown root key IDs with macwarden inventory, and if this credential may have been used, regenerate all root keys (see docs/incident-playbook.md).';
4244
- exports.CAN_BAKE_ROOT_KEY_0 = 'It can also bake macaroons (macaroon:generate): its holder may have created credentials on other root key IDs. The regeneration invalidates those too; afterwards, revoke any root key ID macwarden inventory still shows as unknown that no app of yours uses.';
4629
+ exports.CAN_BAKE_ACCEPTED = 'This credential can bake macaroons (macaroon:generate or uri:/lnrpc.Lightning/BakeMacaroon), or macwarden could not assess all of its permissions. If it leaked, its holder may have created credentials on other root key IDs, and revoking this one does not remove them. Replace and revoke every registered credential with macwarden respond, list unknown root key IDs with macwarden inventory, and if this credential may have been used, regenerate all root keys (see docs/incident-playbook.md).';
4630
+ exports.CAN_BAKE_ROOT_KEY_0 = 'It can also bake macaroons (macaroon:generate or uri:/lnrpc.Lightning/BakeMacaroon), or may (not all of its permissions could be assessed): its holder may have created credentials on other root key IDs. The regeneration invalidates those too; afterwards, revoke any root key ID macwarden inventory still shows as unknown that no app of yours uses.';
4245
4631
  exports.OWN_ROOT_KEY_ACCEPTED = "This credential shares the root key ID of the credential macwarden is running with, and macwarden revoke refuses its own root key ID. Revoke it with another own credential, or delete that root key ID with lncli deletemacaroonid after switching macwarden to a new session macaroon.";
4246
- exports.REJECTED_INCONCLUSIVE = 'LND rejected this check, but not because the root key ID is gone, the root key was regenerated or the credential expired. A caveat that depends on the caller (an IP lock, or a custom caveat such as a canary\'s) can make LND accept it from another address or while a middleware runs. Treat it as live until its root key ID is revoked.';
4632
+ exports.REJECTED_INCONCLUSIVE = 'LND rejected this check, but did not report that the root key ID is gone or that the credential expired. A caveat that depends on the caller (an IP lock, or a custom caveat such as a canary\'s) can make LND accept it from another address or while a middleware runs. Treat it as live until its root key ID is revoked.';
4633
+ exports.LOCAL_EXPIRY_UNCONFIRMED = "By this machine's clock a time-before caveat has passed, but LND did not report the expiry: the clocks may differ, or LND refused an earlier caveat first. A local clock is no proof that the credential is dead.";
4634
+ exports.SIGNATURE_MISMATCH_INCONCLUSIVE = 'LND reports a signature mismatch, but the root key ID is still listed (or could not be listed). This copy is not the credential LND issued: either the root keys were regenerated, or this copy was altered (a flipped byte, a mangled paste, a decoy) and the original it came from may still be accepted. Treat the original as live until its root key ID is revoked.';
4635
+ exports.SIGNATURE_MISMATCH_HOW_TO_CONCLUDE = 'For a conclusive answer, check a copy whose bytes are known to be unaltered: macwarden verify answers conclusively for the exact file it recorded in the registry (same fingerprint). Otherwise compare the fingerprint of this file (above) with one recorded before the incident, such as an inventory --report export or a backup: if they are equal, the bytes are unaltered and the mismatch means the root key was regenerated.';
4636
+ exports.SIGNATURE_MISMATCH_ROOT_KEY_0 = 'On root key ID 0 nothing can be revoked on its own: if you have not regenerated every root key (lncli changepassword --new_mac_root_key, which macwarden never runs), assume the original is still live.';
4637
+ exports.OWN_ROOT_KEY_INCONCLUSIVE = "This credential is on the root key ID of the credential macwarden is running with, which macwarden revoke refuses. To invalidate it: bake a new session credential on its own root key ID, switch MACWARDEN_MACAROON (or --macaroon) to it, then revoke this root key ID from the new session with macwarden revoke <id> --unregistered (or lncli deletemacaroonid <id>). That also invalidates the old session credential.";
4638
+ exports.ROOT_KEY_0_INCONCLUSIVE = 'It is on root key ID 0, which LND cannot revoke on its own: if it may be live, only regenerating every root key invalidates it (lncli changepassword --new_mac_root_key, which macwarden never runs; see docs/incident-playbook.md section 5).';
4247
4639
  exports.CUSTOM_CAVEAT_REJECTED = 'It carries a custom caveat (a canary, or another lnd-custom caveat): LND accepts it only while a middleware for that caveat, such as macwarden watch, is registered.';
4248
4640
  function fail(code, step, message, warnings = []) {
4249
4641
  return { schema: 1, command: 'verify', ok: false, warnings, result: null, error: { code, step, message } };
@@ -4291,9 +4683,9 @@ function parse(args, env) {
4291
4683
  }
4292
4684
  if (inputs.length !== 1)
4293
4685
  return 'expected one input: a credential file, or - for stdin';
4294
- const host = supplied.get('--lnd-rest') ?? env.MACWARDEN_LND_REST;
4295
- const cert = supplied.get('--tls-cert') ?? env.MACWARDEN_TLS_CERT;
4296
- const own = supplied.get('--macaroon') ?? env.MACWARDEN_MACAROON;
4686
+ const { host, cert, own, problem: mixedSource } = (0, online_js_1.connectionSettings)(flag => supplied.get(flag), env);
4687
+ if (mixedSource)
4688
+ return mixedSource;
4297
4689
  const connection = (0, online_js_1.connectionProblem)(host, cert, own);
4298
4690
  if (connection)
4299
4691
  return connection;
@@ -4317,7 +4709,7 @@ function registryMatch(entries, pubkey, facts) {
4317
4709
  const related = pick(local.filter(sameId));
4318
4710
  return related ? { label: related.label, status: related.status, match: 'root-key-id' } : null;
4319
4711
  }
4320
- function guidance(result) {
4712
+ function guidance(result, localExpiry) {
4321
4713
  const id = result.rootKeyId;
4322
4714
  if (result.status === 'accepted') {
4323
4715
  const lines = ['LND accepts this credential now: whoever holds a copy can use it against this node.'];
@@ -4338,15 +4730,31 @@ function guidance(result) {
4338
4730
  return lines;
4339
4731
  }
4340
4732
  if (!result.conclusive) {
4341
- const lines = [exports.REJECTED_INCONCLUSIVE];
4733
+ const lines = [result.rejection === 'signature-mismatch' ? exports.SIGNATURE_MISMATCH_INCONCLUSIVE : exports.REJECTED_INCONCLUSIVE];
4734
+ if (result.rejection === 'signature-mismatch')
4735
+ lines.push(exports.SIGNATURE_MISMATCH_HOW_TO_CONCLUDE);
4736
+ if (result.rejection === 'signature-mismatch' && id === '0')
4737
+ lines.push(exports.SIGNATURE_MISMATCH_ROOT_KEY_0);
4342
4738
  if (result.caveats.some(caveat => caveat.type === 'lnd-custom'))
4343
4739
  lines.push(exports.CUSTOM_CAVEAT_REJECTED);
4344
- if (id !== '0')
4740
+ if (localExpiry)
4741
+ lines.push(exports.LOCAL_EXPIRY_UNCONFIRMED);
4742
+ // Never a revoke command macwarden would refuse (RL-06): not for root key ID 0 (SAFE-03),
4743
+ // not for macwarden's own root key ID (revoke V4).
4744
+ if (id === '0') {
4745
+ if (result.rejection !== 'signature-mismatch')
4746
+ lines.push(exports.ROOT_KEY_0_INCONCLUSIVE);
4747
+ }
4748
+ else if (result.ownCredential)
4749
+ lines.push(exports.OWN_ROOT_KEY_INCONCLUSIVE);
4750
+ else
4345
4751
  lines.push(`To make sure: macwarden revoke ${id}${result.registry === null ? ' --unregistered' : ''}.`);
4346
4752
  return lines;
4347
4753
  }
4348
4754
  const why = result.rejection === 'root-key-missing' ? 'its root key ID no longer exists'
4349
- : result.rejection === 'signature-mismatch' ? 'its root key was regenerated'
4755
+ : result.rejection === 'signature-mismatch' ? (result.rootKeyListed === false
4756
+ ? 'its signature does not match and its root key ID is no longer listed'
4757
+ : `its root key was regenerated: these are the exact bytes macwarden recorded for root key ID ${id}, and LND no longer accepts their signature`)
4350
4758
  : result.rejection === 'expired' ? 'a time-before caveat has passed' : 'its root key ID is no longer listed';
4351
4759
  const lines = [`LND rejects this credential now: ${why}. It can no longer be used against this node.`];
4352
4760
  if (result.rejection === 'expired' && result.rootKeyListed !== false && id !== '0')
@@ -4420,7 +4828,7 @@ async function runSteps(args, deps, warnings) {
4420
4828
  try {
4421
4829
  if (request.registry && (0, protected_path_js_1.isProtectedPath)(request.registry))
4422
4830
  return fail(3, 'C5', 'protected registry path', warnings);
4423
- entries = (await (0, index_js_4.openRegistry)(request.registry, { fs: deps.fs, env: deps.env })).entries;
4831
+ entries = (await (0, index_js_5.openRegistry)(request.registry, { fs: deps.fs, env: deps.env })).entries;
4424
4832
  }
4425
4833
  catch {
4426
4834
  return fail(3, 'C5', 'unreadable or invalid registry', warnings);
@@ -4461,22 +4869,38 @@ async function runSteps(args, deps, warnings) {
4461
4869
  catch (error) {
4462
4870
  return lndFailure(error, 'C7', warnings);
4463
4871
  }
4464
- const expired = offline.caveats.some(caveat => caveat.type === 'time-before' && caveat.expired === true);
4872
+ // Only node-side evidence makes a rejection conclusive: LND's own expiry error naming one of
4873
+ // this credential's time-before caveats (judged by the node's clock), or a missing root key ID
4874
+ // (reported by LND or absent from ListMacaroonIDs). The local clock never does (MW110-02): it can be skewed, and an earlier
4875
+ // caveat that depends on the caller (IP lock, custom caveat) may be what LND refused.
4876
+ const nodeExpired = !checked.valid && checked.reason === 'expired'
4877
+ && facts.caveats.some(caveat => caveat.type === 'time-before' && caveat.value === checked.caveat);
4465
4878
  const rejection = checked.valid ? null
4466
- : checked.reason === 'other' && expired ? 'expired' : checked.reason;
4467
- // A rejection holds everywhere when the root key is gone or regenerated, or the credential
4468
- // expired; any other rejection may depend on the caller (IP lock, custom caveat).
4469
- const conclusive = checked.valid || rejection !== 'other' || rootKeyListed === false;
4879
+ : checked.reason === 'expired' ? (nodeExpired ? 'expired' : 'other') : checked.reason;
4880
+ // A rejection holds everywhere when the root key ID is gone or the credential expired; any other
4881
+ // rejection may depend on the caller (IP lock, custom caveat) or on the copy itself.
4882
+ // A signature mismatch is what LND answers both after a root key regeneration and for any
4883
+ // altered copy (RT-01): a regeneration keeps every root key ID listed, so a mismatch alone never
4884
+ // proves the original dead. It is conclusive once the root key ID is no longer listed, or when
4885
+ // these are the exact bytes macwarden recorded for this root key ID on this node (registry
4886
+ // fingerprint match): known-genuine bytes can only mismatch because the root key changed.
4887
+ // The own credential is no such evidence: it authenticating proves only that its own bytes are
4888
+ // valid, not whether the target's differ by alteration or by a regeneration since it was baked.
4889
+ const registry = registryMatch(entries, info.pubkey, facts);
4890
+ const recordedBytes = registry?.match === 'fingerprint';
4891
+ const conclusive = checked.valid || rejection === 'root-key-missing' || rejection === 'expired'
4892
+ || rootKeyListed === false || (rejection === 'signature-mismatch' && recordedBytes);
4893
+ const localExpiry = offline.caveats.some(caveat => caveat.type === 'time-before' && caveat.expired === true);
4470
4894
  const partial = {
4471
4895
  ...offline,
4472
4896
  node: { pubkey: info.pubkey, alias: info.alias, network: info.network },
4473
4897
  status: checked.valid ? 'accepted' : 'rejected',
4474
4898
  rejection, conclusive, rootKeyListed,
4475
4899
  ownCredential: facts.rootKeyId === ownFacts.rootKeyId,
4476
- canBake: offline.grade === 'ADMIN' || facts.permissions.includes('macaroon:generate'),
4477
- registry: registryMatch(entries, info.pubkey, facts),
4900
+ canBake: (0, index_js_3.canBake)(facts.permissions, offline.grade, offline.warnings),
4901
+ registry,
4478
4902
  };
4479
- const result = { ...partial, guidance: guidance(partial) };
4903
+ const result = { ...partial, guidance: guidance(partial, localExpiry) };
4480
4904
  // Exit 1 is a finding, as with scan --fail-on: the credential is live, or may be.
4481
4905
  return { schema: 1, command: 'verify', ok: true, warnings, result, error: null,
4482
4906
  ...(result.status === 'accepted' || !result.conclusive ? { exitCode: 1 } : {}) };
@@ -4493,7 +4917,7 @@ async function run(args, deps) {
4493
4917
  }
4494
4918
  }
4495
4919
  async function verify(args) {
4496
- return run(args, { createAdapter: index_js_3.createLndAdapter, fs: nodeFs, env: { ...process.env },
4920
+ return run(args, { createAdapter: index_js_4.createLndAdapter, fs: nodeFs, env: { ...process.env },
4497
4921
  clock: () => new Date(), stdin: process.stdin });
4498
4922
  }
4499
4923
 
@@ -4557,6 +4981,7 @@ const protected_path_js_1 = require("../protected-path.js");
4557
4981
  const bounded_file_js_1 = require("../bounded-file.js");
4558
4982
  const index_js_3 = require("../registry/index.js");
4559
4983
  const process_domain_js_1 = require("../registry/process-domain.js");
4984
+ const root_key_id_js_1 = require("../root-key-id.js");
4560
4985
  const index_js_4 = require("../output/index.js");
4561
4986
  const canary_js_1 = require("../watch/canary.js");
4562
4987
  const usage_js_1 = require("../watch/usage.js");
@@ -4632,9 +5057,9 @@ function parse(args, env) {
4632
5057
  return 'unknown flag';
4633
5058
  }
4634
5059
  const get = (flag) => { const value = flags.get(flag); return typeof value === 'string' ? value : undefined; };
4635
- const host = get('--lnd-rest') ?? env.MACWARDEN_LND_REST;
4636
- const cert = get('--tls-cert') ?? env.MACWARDEN_TLS_CERT;
4637
- const own = get('--macaroon') ?? env.MACWARDEN_MACAROON;
5060
+ const { host, cert, own, grpc: grpcValue, problem: mixedSource } = (0, online_js_1.connectionSettings)(get, env, true);
5061
+ if (mixedSource)
5062
+ return mixedSource;
4638
5063
  if (!host)
4639
5064
  return 'missing --lnd-rest (or MACWARDEN_LND_REST)';
4640
5065
  if (!cert)
@@ -4646,9 +5071,8 @@ function parse(args, env) {
4646
5071
  const rest = hostPort(host);
4647
5072
  if (!rest)
4648
5073
  return 'invalid LND REST endpoint';
4649
- const grpcValue = get('--lnd-grpc') ?? env.MACWARDEN_LND_GRPC;
4650
5074
  const grpcSource = get('--lnd-grpc') !== undefined ? '--lnd-grpc'
4651
- : env.MACWARDEN_LND_GRPC !== undefined ? 'MACWARDEN_LND_GRPC' : 'the default port 10009 on the --lnd-rest host';
5075
+ : grpcValue !== undefined ? 'MACWARDEN_LND_GRPC' : 'the default port 10009 on the --lnd-rest host';
4652
5076
  const grpc = grpcValue === undefined ? { host: rest.host, port: 10009 } : hostPort(grpcValue);
4653
5077
  if (!grpc)
4654
5078
  return 'invalid LND gRPC endpoint';
@@ -4753,7 +5177,7 @@ async function takeOverStaleLock(path, stale, pid, deps) {
4753
5177
  }
4754
5178
  let moved = '';
4755
5179
  try {
4756
- moved = await deps.fs.readFile(quarantine, 'utf8');
5180
+ moved = await (0, bounded_file_js_1.readPlainFile)(quarantine, deps.fs, bounded_file_js_1.LOCK_FILE_LIMIT);
4757
5181
  }
4758
5182
  catch { /* treated as not ours */ }
4759
5183
  if (moved === stale) {
@@ -4797,7 +5221,7 @@ async function acquireLock(path, deps) {
4797
5221
  try {
4798
5222
  const current = await deps.fs.lstat(path, { bigint: true });
4799
5223
  if (current.dev === identity.dev && current.ino === identity.ino
4800
- && await deps.fs.readFile(path, 'utf8') === body)
5224
+ && await (0, bounded_file_js_1.readPlainFile)(path, deps.fs, bounded_file_js_1.LOCK_FILE_LIMIT) === body)
4801
5225
  await deps.fs.unlink(path);
4802
5226
  }
4803
5227
  catch { /* already gone */ }
@@ -4809,9 +5233,9 @@ async function acquireLock(path, deps) {
4809
5233
  let owner = {};
4810
5234
  let stale;
4811
5235
  try {
4812
- // The owner record is read only as a plain file: never through a symlink (SAFE-02).
4813
- await (0, bounded_file_js_1.assertPlainFile)(path, deps.fs, bounded_file_js_1.LOCK_FILE_LIMIT);
4814
- stale = await deps.fs.readFile(path, 'utf8');
5236
+ // The owner record is read only as a plain file: never through a symlink (SAFE-02),
5237
+ // through one descriptor bound to the checked file and at most LOCK_FILE_LIMIT + 1 bytes.
5238
+ stale = await (0, bounded_file_js_1.readPlainFile)(path, deps.fs, bounded_file_js_1.LOCK_FILE_LIMIT);
4815
5239
  owner = JSON.parse(stale);
4816
5240
  }
4817
5241
  catch {
@@ -4848,7 +5272,6 @@ async function alertCommandProblem(command, deps) {
4848
5272
  return null;
4849
5273
  }
4850
5274
  const CREDENTIAL_ID = /^id:[0-9a-f]{16}$/;
4851
- const ROOT_KEY_ID = /^(?:0|[1-9][0-9]{0,19})$/;
4852
5275
  function labels(entries, pubkey) {
4853
5276
  const byRootKey = new Map();
4854
5277
  for (const entry of entries) {
@@ -5114,7 +5537,7 @@ async function runSteps(args, deps) {
5114
5537
  }
5115
5538
  requestKeys.delete(id);
5116
5539
  counters.errors += 1;
5117
- if (CREDENTIAL_ID.test(key.credential) && ROOT_KEY_ID.test(key.rootKeyId))
5540
+ if (CREDENTIAL_ID.test(key.credential) && (0, root_key_id_js_1.isRootKeyId)(key.rootKeyId))
5118
5541
  usage.record(pubkey, key.credential, key.rootKeyId, key.method, at, true);
5119
5542
  emit({ event: 'error', time: at.toISOString(), method: key.method, rootKeyId: key.rootKeyId,
5120
5543
  credential: key.credential, label: labelFor(key.rootKeyId) });
@@ -5141,7 +5564,7 @@ async function runSteps(args, deps) {
5141
5564
  counters.calls += 1;
5142
5565
  if (counters.credentials.size < CREDENTIAL_COUNT_LIMIT)
5143
5566
  counters.credentials.add(credential);
5144
- if (CREDENTIAL_ID.test(credential) && ROOT_KEY_ID.test(rootKeyId))
5567
+ if (CREDENTIAL_ID.test(credential) && (0, root_key_id_js_1.isRootKeyId)(rootKeyId))
5145
5568
  usage.record(pubkey, credential, rootKeyId, call.method, at);
5146
5569
  emit({ event: 'call', time: at.toISOString(), method: call.method, rootKeyId, credential,
5147
5570
  label: labelFor(rootKeyId) });
@@ -5530,6 +5953,75 @@ function literalHexSpans(text) {
5530
5953
  hexSpans(value, starts, ends, found);
5531
5954
  return found;
5532
5955
  }
5956
+ // Raw bytes rendered as UTF-8 (Buffer#toString('utf8'), TextDecoder, Go's string printing)
5957
+ // keep every byte below 0x80 and every valid sequence, but replace each ill-formed sequence
5958
+ // of one to three bytes with U+FFFD. Lengths and offsets of a binary credential are lost, so
5959
+ // it is recognised by the framing that survives instead. Every such rendering of a credential
5960
+ // the structural decoder accepts starts with U+0002 and a header packet kind (1 or 2; a
5961
+ // non-minimal varint kind renders as U+FFFD... U+0000), and holds, after an end-of-section
5962
+ // U+0000, its signature packet header (kind 6, length 32, each minimal or as U+FFFD... U+0000)
5963
+ // followed by 32 bytes. The span runs from the first such start to the end of the last
5964
+ // signature that can follow it, counting each U+FFFD as at least one byte: never shorter than
5965
+ // the credential's rendering. Bytes 0x80 and above never render as ASCII, so this is sound;
5966
+ // it can only redact more, and only text that already holds these control characters.
5967
+ const NON_ASCII = /[\u0080-￿]/;
5968
+ const SIGNATURE_BYTES = 32;
5969
+ const MAX_VARINT = 10;
5970
+ /** End (exclusive) of a small varint at `at` rendered as UTF-8, or -1: `minimal` or U+FFFD{1,9} U+0000. */
5971
+ function renderedVarint(text, at, minimal) {
5972
+ if (text[at] === minimal)
5973
+ return at + 1;
5974
+ let cursor = at;
5975
+ while (cursor < text.length && cursor - at < MAX_VARINT - 1 && text[cursor] === '�')
5976
+ cursor++;
5977
+ return cursor > at && text[cursor] === '\u0000' ? cursor + 1 : -1;
5978
+ }
5979
+ /** Fewest and most UTF-8 bytes the code unit at `at` can stand for in a rendering. */
5980
+ function renderedBytes(text, at) {
5981
+ const unit = text.charCodeAt(at);
5982
+ if (unit < 0x80)
5983
+ return [1, 1];
5984
+ if (unit < 0x800)
5985
+ return [2, 2];
5986
+ if (unit === 0xfffd)
5987
+ return [1, 3];
5988
+ if (unit >= 0xdc00 && unit <= 0xdfff)
5989
+ return [0, 0];
5990
+ return unit >= 0xd800 && unit <= 0xdbff ? [4, 4] : [3, 3];
5991
+ }
5992
+ function utf8RenderingSpans(text, starts, ends, found) {
5993
+ let first = -1;
5994
+ for (let at = text.indexOf('\u0002'); at >= 0 && at + 1 < text.length; at = text.indexOf('\u0002', at + 1)) {
5995
+ if (text[at + 1] === '\u0001' || renderedVarint(text, at + 1, '\u0002') !== -1) {
5996
+ first = at;
5997
+ break;
5998
+ }
5999
+ }
6000
+ if (first < 0)
6001
+ return;
6002
+ // The last signature header after the start whose 32 bytes can still follow it.
6003
+ for (let at = text.length - 1; at > first; at--) {
6004
+ if (text[at] !== '\u0000')
6005
+ continue;
6006
+ const kind = renderedVarint(text, at + 1, '\u0006');
6007
+ const body = kind < 0 ? -1 : renderedVarint(text, kind, ' ');
6008
+ if (body < 0)
6009
+ continue;
6010
+ let fewest = 0, most = 0, end = body;
6011
+ while (end < text.length && fewest < SIGNATURE_BYTES) {
6012
+ const [low, high] = renderedBytes(text, end++);
6013
+ fewest += low;
6014
+ most += high;
6015
+ }
6016
+ if (most < SIGNATURE_BYTES)
6017
+ continue;
6018
+ // Keep a surrogate pair whole.
6019
+ if (end < text.length && renderedBytes(text, end)[1] === 0)
6020
+ end++;
6021
+ found.push([starts[first], ends[end - 1]]);
6022
+ return;
6023
+ }
6024
+ }
5533
6025
  function spans(text, source, backslashPairs = true) {
5534
6026
  const { value, starts, ends } = normalized(text, true, source, true, backslashPairs);
5535
6027
  const found = [];
@@ -5580,6 +6072,9 @@ function spans(text, source, backslashPairs = true) {
5580
6072
  found.push([raw.starts[pos], raw.ends[pos + length - 1]]);
5581
6073
  pos += length - 1;
5582
6074
  }
6075
+ // A UTF-8 rendering differs from the latin1 one only when a character is not ASCII.
6076
+ if (NON_ASCII.test(raw.value))
6077
+ utf8RenderingSpans(raw.value, raw.starts, raw.ends, found);
5583
6078
  // UTF-16 byte strings can carry raw credentials with zero-byte padding.
5584
6079
  for (const phase of [0, 1])
5585
6080
  for (const order of [0, 1]) {
@@ -6522,9 +7017,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6522
7017
  return (mod && mod.__esModule) ? mod : { "default": mod };
6523
7018
  };
6524
7019
  exports.__esModule = true;
7020
+ exports.BAKE_MACAROON_URI = void 0;
6525
7021
  exports.grade = grade;
6526
7022
  exports.gradeFacts = gradeFacts;
6527
7023
  exports.describe = describe;
7024
+ exports.canBake = canBake;
6528
7025
  const lnd_permissions_json_1 = __importDefault(require("./lnd-permissions.json"));
6529
7026
  const classes = ['READ', 'RECEIVE', 'OPERATE', 'SPEND', 'ADMIN'];
6530
7027
  const knownEntities = new Set([
@@ -6616,6 +7113,19 @@ function describe(facts) {
6616
7113
  warnings.add('root-key-0');
6617
7114
  return byteSort(warnings);
6618
7115
  }
7116
+ /** The exact URI permission LND accepts for BakeMacaroon, besides macaroon:generate. */
7117
+ exports.BAKE_MACAROON_URI = 'uri:/lnrpc.Lightning/BakeMacaroon';
7118
+ const INCOMPLETE = ['identifier-exceeds-bounds', 'caveats-exceed-bounds', 'unassessed-permission', 'unassessed-uri'];
7119
+ /**
7120
+ * Whether a credential can, or may, bake macaroons (MW110-06). Baking needs macaroon:generate or
7121
+ * the BakeMacaroon URI; the ADMIN grade alone does not imply it (macaroon:write is ADMIN, and
7122
+ * LND refuses BakeMacaroon with it). A decode or assessment that is incomplete (truncated
7123
+ * identifier or caveats, unassessed permissions, grade UNASSESSED) fails closed: it may bake.
7124
+ */
7125
+ function canBake(permissions, assessedGrade, warnings = []) {
7126
+ return permissions.includes('macaroon:generate') || permissions.includes(exports.BAKE_MACAROON_URI)
7127
+ || assessedGrade === 'UNASSESSED' || warnings.some(warning => INCOMPLETE.includes(warning));
7128
+ }
6619
7129
 
6620
7130
  },
6621
7131
  "grouped-runs.js": (module, exports, require) => {
@@ -6892,7 +7402,11 @@ exports.__esModule = true;
6892
7402
  exports.parseLndconnect = parseLndconnect;
6893
7403
  // Parse the raw query value so URLSearchParams cannot turn a literal plus into
6894
7404
  // a space or silently repair malformed percent escapes.
6895
- function parseLndconnect(value) {
7405
+ /**
7406
+ * `standardBase64`: also accept the macaroon in the standard base64 alphabet (`+`, `/`), as
7407
+ * some generators write it. Scan detection uses it; credential input keeps base64url only.
7408
+ */
7409
+ function parseLndconnect(value, { standardBase64 = false } = {}) {
6896
7410
  try {
6897
7411
  const fragmentStart = value.indexOf('#');
6898
7412
  const queryStart = value.indexOf('?');
@@ -6908,7 +7422,9 @@ function parseLndconnect(value) {
6908
7422
  const uri = new URL(value);
6909
7423
  if (uri.protocol.toLowerCase() !== 'lndconnect:' || !uri.hostname)
6910
7424
  return null;
6911
- const form = decodeURIComponent(encoded);
7425
+ let form = decodeURIComponent(encoded);
7426
+ if (standardBase64 && /^[A-Za-z0-9+/]+={0,2}$/.test(form))
7427
+ form = form.replace(/\+/g, '-').replace(/\//g, '_');
6912
7428
  if (!/^[A-Za-z0-9_-]+={0,2}$/.test(form) || form.length % 4 === 1)
6913
7429
  return null;
6914
7430
  const bare = form.replace(/=+$/, '');
@@ -6923,6 +7439,36 @@ function parseLndconnect(value) {
6923
7439
  }
6924
7440
  }
6925
7441
 
7442
+ },
7443
+ "instant.js": (module, exports, require) => {
7444
+ "use strict";
7445
+ // UTC instants as macwarden writes them (Date.prototype.toISOString, or the same without the
7446
+ // fraction), read back from the registry and the watch usage file.
7447
+ exports.__esModule = true;
7448
+ exports.isInstant = isInstant;
7449
+ const SHAPE = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d+)?Z$/;
7450
+ function daysIn(year, month) {
7451
+ if (month === 2)
7452
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
7453
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
7454
+ }
7455
+ /**
7456
+ * True for `YYYY-MM-DDTHH:MM:SS[.fraction]Z` naming a real instant: a month 1 to 12, a day that
7457
+ * exists in that month, hours 0 to 23, minutes and seconds 0 to 59. The shape alone is not
7458
+ * enough: Date.parse gives NaN for month 99 and silently rolls 30 February or 24:00 into
7459
+ * another day, and either would corrupt time arithmetic on the value.
7460
+ */
7461
+ function isInstant(value) {
7462
+ if (typeof value !== 'string')
7463
+ return false;
7464
+ const match = SHAPE.exec(value);
7465
+ if (!match)
7466
+ return false;
7467
+ const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
7468
+ return month >= 1 && month <= 12 && day >= 1 && day <= daysIn(year, month)
7469
+ && hour <= 23 && minute <= 59 && second <= 59 && Number.isFinite(Date.parse(value));
7470
+ }
7471
+
6926
7472
  },
6927
7473
  "lnd/grpc.js": (module, exports, require) => {
6928
7474
  "use strict";
@@ -7250,6 +7796,13 @@ const MAX_RESPONSE = 1_048_576;
7250
7796
  const TIMEOUT_MS = 10_000;
7251
7797
  const ROOT_MISSING = /root key with id [0-9]+ doesn't exist/i;
7252
7798
  const SIGNATURE_MISMATCH = /signature mismatch/i;
7799
+ // LND v0.21.4 CheckMacaroonPermissions (rpcserver.go) returns gRPC InvalidArgument with err.Error() of
7800
+ // macaroons/service.go CheckMacAuth. Its bakery (macaroon-bakery v2.0.1) checks every first-party caveat
7801
+ // in order and reports the first that fails as `caveat %q not satisfied: <reason>` (checkers.go), and
7802
+ // for an expired time-before the reason is `macaroon has expired` (checkers/time.go), judged by the
7803
+ // node's clock. Only that exact whole message is expiry evidence; the caveat value is returned so the
7804
+ // caller can match it against the credential's own decoded caveats.
7805
+ const NODE_EXPIRED = /^caveat "time-before ([^"\\]*)" not satisfied: macaroon has expired$/;
7253
7806
  const MAX_ROOT_KEY_ID = (1n << 64n) - 1n;
7254
7807
  function ownAuthenticationFailure(response) {
7255
7808
  if (response.status === 401 || response.status === 403)
@@ -7517,6 +8070,9 @@ function createLndAdapter(config) {
7517
8070
  throw new errors_js_1.LndError(5, stage, 'invalid LND response');
7518
8071
  const missing = response.status === 400 && ROOT_MISSING.test(message);
7519
8072
  const mismatch = response.status === 400 && SIGNATURE_MISMATCH.test(message);
8073
+ const expired = NODE_EXPIRED.exec(message);
8074
+ if (expired && !missing && !mismatch)
8075
+ return { valid: false, reason: 'expired', caveat: expired[1], message: 'LND rejected macaroon' };
7520
8076
  const reason = missing && !mismatch ? 'root-key-missing'
7521
8077
  : mismatch && !missing
7522
8078
  ? 'signature-mismatch' : 'other';
@@ -7909,6 +8465,7 @@ exports.readBoundedFile = exports.LABEL_RULE = void 0;
7909
8465
  exports.endpoint = endpoint;
7910
8466
  exports.credentialText = credentialText;
7911
8467
  exports.connectionProblem = connectionProblem;
8468
+ exports.connectionSettings = connectionSettings;
7912
8469
  exports.flagProblem = flagProblem;
7913
8470
  exports.nodeInfo = nodeInfo;
7914
8471
  exports.nodePubkey = nodePubkey;
@@ -7966,6 +8523,37 @@ function connectionProblem(host, cert, own) {
7966
8523
  return '--lnd-rest (or MACWARDEN_LND_REST) must be host:port, without scheme, path, query or user information';
7967
8524
  return null;
7968
8525
  }
8526
+ const CONNECTION = [
8527
+ { key: 'host', flag: '--lnd-rest', variable: 'MACWARDEN_LND_REST' },
8528
+ { key: 'cert', flag: '--tls-cert', variable: 'MACWARDEN_TLS_CERT' },
8529
+ { key: 'own', flag: '--macaroon', variable: 'MACWARDEN_MACAROON' },
8530
+ { key: 'grpc', flag: '--lnd-grpc', variable: 'MACWARDEN_LND_GRPC' },
8531
+ ];
8532
+ /**
8533
+ * The node address, its TLS certificate and the credential macwarden presents come from one
8534
+ * source: when any of them is given as a flag, all of them must be flags, so a credential
8535
+ * exported for one node is never sent to a node named on the command line (and the reverse).
8536
+ * With no connection flag, all come from the environment. `grpc` (watch) joins the set when
8537
+ * `withGrpc` is true. A mixed invocation sets `problem`, naming the variables, never values.
8538
+ */
8539
+ function connectionSettings(flag, env, withGrpc = false) {
8540
+ const items = CONNECTION.filter(item => withGrpc || item.key !== 'grpc');
8541
+ const flagged = items.filter(item => flag(item.flag) !== undefined);
8542
+ const settings = { problem: null };
8543
+ if (!flagged.length) {
8544
+ for (const item of items)
8545
+ settings[item.key] = env[item.variable];
8546
+ return settings;
8547
+ }
8548
+ for (const item of items)
8549
+ settings[item.key] = flag(item.flag);
8550
+ const mixed = items.filter(item => flag(item.flag) === undefined && env[item.variable] !== undefined && env[item.variable] !== '');
8551
+ if (mixed.length)
8552
+ settings.problem = `${flagged.map(item => item.flag).join(', ')} given on the command line, so ` +
8553
+ `${mixed.map(item => item.variable).join(', ')} from the environment is not used: connection settings come all from flags ` +
8554
+ `or all from the environment; give ${mixed.map(item => item.flag).join(', ')} too, or unset ${mixed.map(item => item.variable).join(', ')}`;
8555
+ return settings;
8556
+ }
7969
8557
  /** Wording for a value flag given twice or without a value. */
7970
8558
  function flagProblem(flag, kind) {
7971
8559
  return kind === 'repeated' ? `${flag} was given more than once` : `${flag} needs a value`;
@@ -8042,7 +8630,7 @@ function readOnlineCredential(path, io) {
8042
8630
  }
8043
8631
  function onlineWarnings(result) {
8044
8632
  const messages = {
8045
- 'acl-best-effort': "the file inherits the folder's permissions",
8633
+ 'acl-best-effort': "the volume stores no Windows permissions (FAT or exFAT): anyone with access to the volume can read the file",
8046
8634
  'unsafe-arg': 'literal credentials can appear in shell history and process listings',
8047
8635
  'own-root-key-0': 'own credential uses root key ID 0',
8048
8636
  'snapshot-drift': 'LND permissions differ from the bundled snapshot',
@@ -8080,6 +8668,8 @@ const patterns_js_1 = require("../scan/patterns.js");
8080
8668
  const nwc_match_js_1 = require("../scan/nwc-match.js");
8081
8669
  const credential_recognition_js_1 = require("../credential-recognition.js");
8082
8670
  const redact_js_1 = require("../scan/redact.js");
8671
+ const online_js_1 = require("../online.js");
8672
+ const node_path_1 = require("node:path");
8083
8673
  function rotatePlanResult(plan, next, warnings, repaired) {
8084
8674
  return { schema: 1, command: 'rotate', ok: true, warnings, result: plan, error: null, display: { finish: { next, repaired } } };
8085
8675
  }
@@ -8168,16 +8758,22 @@ function jsonRedactor() {
8168
8758
  return safe;
8169
8759
  };
8170
8760
  }
8761
+ // eslint-disable-next-line no-control-regex, no-misleading-character-class
8762
+ const UNSAFE_TEXT = /[\x00-\x1f\x7f-\xa0\u034f\u115f\u1160\u1680\u17b4\u17b5\u180b-\u180f\u2000-\u200f\u2028-\u202f\u205f-\u206f\u3000\u3164\ufe00-\ufe0f\uffa0\u{e0000}-\u{e0fff}\p{Cf}]/gu;
8171
8763
  function safeText(value) {
8172
8764
  // Control characters, the Unicode bidirectional controls that can make a terminal show
8173
8765
  // text in a different order than it is stored (Trojan Source), and Unicode whitespace
8174
8766
  // other than the plain space are escaped. A CI runner trims Unicode whitespace before it
8175
8767
  // looks for a '::' workflow command, so none may reach a line start unescaped. The
8176
8768
  // legacy '##[' command marker is recognised anywhere in a line, so it is escaped too.
8177
- // eslint-disable-next-line no-control-regex
8178
- return redact(value).replace(/[\x00-\x1f\x7f-\xa0\u061c\u1680\u2000-\u200f\u2028-\u202f\u205f\u2066-\u2069\u3000\ufeff]/g, character => character.charCodeAt(0) < 0x100 ? `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`
8179
- : `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`)
8180
- .replace(/##\[/g, '##\\x5b');
8769
+ // Invisible characters are escaped as well, so a name cannot look like a different file:
8770
+ // every format character (Unicode Cf: soft hyphen, zero-width characters, word joiner,
8771
+ // tags), the Hangul fillers, the combining grapheme joiner and the variation selectors.
8772
+ return redact(value).replace(UNSAFE_TEXT, character => {
8773
+ const code = character.codePointAt(0);
8774
+ return code < 0x100 ? `\\x${code.toString(16).padStart(2, '0')}`
8775
+ : code <= 0xffff ? `\\u${code.toString(16).padStart(4, '0')}` : `\\u{${code.toString(16)}}`;
8776
+ }).replace(/##\[/g, '##\\x5b');
8181
8777
  }
8182
8778
  /**
8183
8779
  * Text output is often read by CI runners. No line may start, after spaces, with '::'
@@ -8194,7 +8790,7 @@ const warningMessages = {
8194
8790
  'unsafe-arg': 'literal credentials can appear in shell history and process listings',
8195
8791
  'own-root-key-0': 'own credential uses root key ID 0',
8196
8792
  'snapshot-drift': 'LND method permissions differ from the bundled snapshot',
8197
- 'acl-best-effort': "the file inherits the folder's permissions",
8793
+ 'acl-best-effort': "the volume stores no Windows permissions (FAT or exFAT): anyone with access to the volume can read the file",
8198
8794
  'usage-unreadable': 'the watch usage file could not be read; last-used data is omitted',
8199
8795
  'usage-write-failed': 'the watch usage file could not be written; usage since the last save is lost',
8200
8796
  'canary-requires-watch': 'LND accepts this canary only while macwarden watch runs; a running watch picks it up within 60 seconds (or at its next reconnect), then denies and alerts on every use its permissions allow',
@@ -8214,7 +8810,12 @@ function* scanTextLines(result) {
8214
8810
  // relative path is printed as ./path; it names the same file.
8215
8811
  const path = /^[\s\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*:/.test(finding.path)
8216
8812
  ? `./${finding.path}` : finding.path;
8217
- const also = finding.alsoAt ? ` (same content also added at ${finding.alsoAt.count} other path${finding.alsoAt.count === 1 ? '' : 's'})` : '';
8813
+ const others = finding.alsoAt ? `${finding.alsoAt.count} other path${finding.alsoAt.count === 1 ? '' : 's'}` : '';
8814
+ // A work-tree finding's alsoAt lists the file's other hard-link names: they are named, since
8815
+ // removing the credential from one name leaves it readable under the others.
8816
+ const also = !finding.alsoAt ? '' : finding.commit !== undefined ? ` (same content also added at ${others})`
8817
+ : ` (same file also at ${others}: ${finding.alsoAt.paths.map(safeText).join(', ')}`
8818
+ + `${finding.alsoAt.count > finding.alsoAt.paths.length ? ', ...' : ''})`;
8218
8819
  const location = `${finding.commit === undefined ? '' : `${finding.commit.slice(0, 7)}:`}${path}` +
8219
8820
  (finding.line === null ? '' : `:${finding.line}:${finding.column}`);
8220
8821
  const counted = finding.kind === 'nwc' ? ' (counted as SPEND)'
@@ -8352,6 +8953,39 @@ function alignedRows(table) {
8352
8953
  return table.map(cells => cells.map((cell, column) => column === cells.length - 1 ? cell
8353
8954
  : cell + ' '.repeat(widths[column] - [...cell].length)).join(' ').trimEnd());
8354
8955
  }
8956
+ /**
8957
+ * The argument `revoke` resolves to this row's credential (RL-05): its label only when that label
8958
+ * names exactly one active entry on the node (as revoke V6 requires), otherwise its root key ID.
8959
+ * Null when revoke refuses the row (root key ID 0, macwarden's own credential).
8960
+ */
8961
+ function revokeTarget(rows, row) {
8962
+ if (row.rootKeyId === '0' || row.flags.includes('default-root-key') || row.flags.includes('own-credential'))
8963
+ return null;
8964
+ const label = row.entry?.label ?? null;
8965
+ return label !== null && rows.filter(other => other.entry?.status === 'active' && other.entry.label === label).length === 1
8966
+ ? label : row.rootKeyId;
8967
+ }
8968
+ /**
8969
+ * The rotate command for this row, or null when rotate would refuse it (RL-05): no label, root
8970
+ * key ID 0, macwarden's own credential, a staged replacement or an original that already has one,
8971
+ * an invalid link, a custom caveat (a canary) or unassessed permissions. `--out` defaults to
8972
+ * `<label>-<YYYY-MM-DD>.macaroon` next to the recorded file (or in the current directory when no
8973
+ * file is recorded); ADMIN adds `--allow-admin`, which rotate requires.
8974
+ */
8975
+ function rotateCommand(row, now) {
8976
+ const entry = row.entry;
8977
+ if (!entry || entry.status !== 'active' || entry.label === null || row.rootKeyId === '0')
8978
+ return null;
8979
+ if (['own-credential', 'default-root-key', 'replacement', 'pending-replacement', 'invalid-replacement']
8980
+ .some(flag => row.flags.includes(flag)))
8981
+ return null;
8982
+ if (entry.caveats.some(caveat => caveat.type === 'lnd-custom') || entry.grade === 'UNASSESSED')
8983
+ return null;
8984
+ const name = `${entry.label}-${new Date(now).toISOString().slice(0, 10)}.macaroon`;
8985
+ const out = entry.outPath ? (0, node_path_1.join)((0, node_path_1.dirname)(entry.outPath), name) : name;
8986
+ return ['macwarden', 'rotate', entry.label, '--out', out, ...(entry.grade === 'ADMIN' ? ['--allow-admin'] : [])]
8987
+ .map(value => (0, online_js_1.shellQuote)(value)).join(' ');
8988
+ }
8355
8989
  function inventoryText(result, now) {
8356
8990
  const { node, rootKeys: rows } = result;
8357
8991
  const lines = [`node ${safeText(node.pubkey)} (${safeText(node.alias)}) ${safeText(node.network)}`];
@@ -8387,13 +9021,16 @@ function inventoryText(result, now) {
8387
9021
  status.push('canary: verifiable only while watch runs');
8388
9022
  else if (row.verify !== 'not-checked')
8389
9023
  status.push(row.verify);
8390
- const target = entry?.label ?? row.rootKeyId;
8391
- if (row.flags.includes('expired'))
8392
- status.push(`expired; revoke with: macwarden revoke ${target}`);
9024
+ // Every printed command runs as printed with the same connection and --registry flags (RL-05).
9025
+ if (row.flags.includes('expired')) {
9026
+ const target = revokeTarget(rows, row);
9027
+ status.push(target === null ? 'expired' : `expired; revoke with: macwarden revoke ${(0, online_js_1.shellQuote)(target)}`);
9028
+ }
8393
9029
  else if (row.flags.includes('expiring') && row.expires) {
8394
9030
  const left = Date.parse(row.expires) - now;
8395
9031
  const span = left >= 86_400_000 ? `${Math.floor(left / 86_400_000)}d` : `${Math.max(1, Math.ceil(left / 3_600_000))}h`;
8396
- status.push(`expires in ${span}; rotate with: macwarden rotate ${target}`);
9032
+ const command = rotateCommand(row, now);
9033
+ status.push(command === null ? `expires in ${span}` : `expires in ${span}; rotate with: ${command}`);
8397
9034
  }
8398
9035
  if (row.flags.includes('pending-replacement')) {
8399
9036
  const replacement = rows.find(candidate => candidate.entry?.status === 'active'
@@ -8457,6 +9094,9 @@ function formatDuration(seconds) {
8457
9094
  return `${rest}s`;
8458
9095
  }
8459
9096
  function observationLine(observation) {
9097
+ // seconds null with a coverage record: the record's times could not be evaluated.
9098
+ if (observation.seconds === null && observation.since !== null)
9099
+ return 'window observation window cannot be computed (invalid watch coverage record); narrowing command withheld';
8460
9100
  const span = observation.seconds === null ? 'observed for an unknown time (no watch coverage recorded)'
8461
9101
  : `observed for ${formatDuration(observation.seconds)} (${observation.sessions} watch session${observation.sessions === 1 ? '' : 's'} since ${safeText(observation.since ?? '')})`;
8462
9102
  if (!observation.short)
@@ -8482,8 +9122,10 @@ function recommendText(result) {
8482
9122
  }
8483
9123
  function bakeText(result, warnings) {
8484
9124
  const aclBestEffort = warnings.some(item => (typeof item === "string" ? item : item.code) === "acl-best-effort");
8485
- const protection = process.platform !== "win32" ? "0600"
8486
- : aclBestEffort ? "ACL best effort; file inherits folder permissions" : "restricted Windows ACL";
9125
+ // Printed only after reserveOut verified the result: mode 0600 read back from the open
9126
+ // descriptor, or the owner-only Windows ACL read back while the exclusive handle was held.
9127
+ const protection = process.platform !== "win32" ? "0600, verified"
9128
+ : aclBestEffort ? "no Windows permissions on this volume" : "owner-only Windows ACL, verified";
8487
9129
  const { label, permissions, grade, caveats } = result;
8488
9130
  if (!('dryRun' in result))
8489
9131
  return [
@@ -8726,7 +9368,7 @@ function* renderChunks(result, mode) {
8726
9368
  const lines = batched(format === 'sarif'
8727
9369
  ? (0, scan_formats_js_1.sarifPieces)(result.result, root, result.display?.toolVersion ?? '0.0.0', jsonRedactor())
8728
9370
  : format === 'github'
8729
- ? (0, scan_formats_js_1.githubLines)(result.result, root, safeText, [...scanSummaryLines(result.result)].join(''))
9371
+ ? (0, scan_formats_js_1.githubLines)(result.result, root, safeText, [...scanSummaryLines(result.result)].join(''), redact)
8730
9372
  : scanTextLines(result.result));
8731
9373
  // Text and annotation output both go through the line guard; a github annotation's
8732
9374
  // own '::' starts its line on purpose and is produced only by githubLines.
@@ -8858,22 +9500,46 @@ function findingSeverity(finding) {
8858
9500
  default: return { level: 'note', score: '3.0' };
8859
9501
  }
8860
9502
  }
9503
+ // alsoAt on a history finding: other paths that added the content; on a work-tree finding:
9504
+ // the file's other hard-link names.
9505
+ const alsoText = (finding) => finding.commit === undefined ? 'same file also at' : 'same content also added at';
8861
9506
  const gradeLabel = (finding) => finding.kind === 'nwc' ? 'SPEND (NWC)'
8862
9507
  : finding.kind === 'undecodable' || finding.kind === 'undecodable-candidate' ? 'ADMIN (undecodable)'
8863
9508
  : finding.grade ?? 'UNASSESSED';
8864
- /** A finding's path as SARIF/GitHub expect it: relative to the scan root with forward slashes when inside it. */
9509
+ /**
9510
+ * A finding's path as SARIF/GitHub expect it: relative to the scan root with forward slashes
9511
+ * when inside it, otherwise absolute. A relative path (scan paths are relative to the working
9512
+ * directory, the scan root) is resolved first, so '../x' is outside the root.
9513
+ */
8865
9514
  function locationOf(path, root) {
8866
- const rel = (0, node_path_1.isAbsolute)(path) ? (0, node_path_1.relative)(root, path) : path;
8867
- if ((0, node_path_1.isAbsolute)(rel) || rel === '..' || rel.startsWith(`..${node_path_1.sep}`) || rel.startsWith('../'))
8868
- return { absolute: path };
8869
- return { relative: rel.split(node_path_1.sep).join('/').replace(/^\.\//, '') };
9515
+ const absolute = (0, node_path_1.resolve)(root, path);
9516
+ const rel = (0, node_path_1.relative)(root, absolute);
9517
+ if (rel === '' || (0, node_path_1.isAbsolute)(rel) || rel === '..' || rel.startsWith(`..${node_path_1.sep}`))
9518
+ return { absolute };
9519
+ return { relative: rel.split(node_path_1.sep).join('/') };
8870
9520
  }
8871
- /** An absolute path as a file URI (RFC 8089), each segment percent-encoded. */
9521
+ const encodeSegments = (parts) => parts.map(encodeURIComponent).join('/');
9522
+ /**
9523
+ * An absolute path as a file URI (RFC 8089), each segment percent-encoded as UTF-8. A Windows
9524
+ * path (drive letter, UNC, or the \\?\ and \\.\ forms) takes '\' and '/' as separators; in a
9525
+ * POSIX path '\' is part of a name and is encoded.
9526
+ */
8872
9527
  function fileUri(path) {
8873
- const parts = path.replace(/\\/g, '/').split('/');
8874
- const drive = /^[A-Za-z]:$/.test(parts[0] ?? '') ? parts.shift() : null;
8875
- const encoded = parts.filter((part, index) => index > 0 || part !== '').map(encodeURIComponent).join('/');
8876
- return drive ? `file:///${drive}/${encoded}` : `file:///${encoded}`;
9528
+ let local = path;
9529
+ let windows = /^[A-Za-z]:[\\/]/.test(local) || /^[\\/]{2}[^\\/]/.test(local);
9530
+ if (/^[\\/]{2}[?.][\\/]/.test(local)) {
9531
+ local = local.slice(4);
9532
+ if (/^UNC[\\/]/i.test(local))
9533
+ local = `\\\\${local.slice(4)}`;
9534
+ windows = true;
9535
+ }
9536
+ if (!windows)
9537
+ return `file://${encodeSegments(local.split('/'))}`;
9538
+ const parts = local.split(/[\\/]/);
9539
+ // UNC \\server\share\x is file://server/share/x; a drive path keeps its colon.
9540
+ if (parts[0] === '' && parts[1] === '')
9541
+ return `file://${encodeURIComponent(parts[2] ?? '')}/${encodeSegments(parts.slice(3))}`;
9542
+ return `file:///${parts[0]}/${encodeSegments(parts.slice(1))}`;
8877
9543
  }
8878
9544
  function artifactLocation(path, root) {
8879
9545
  const where = locationOf(path, root);
@@ -8896,7 +9562,11 @@ function* sarifPieces(result, root, version, replacer) {
8896
9562
  const driver = { name: 'macwarden', version, semanticVersion: version, informationUri: HELP, rules };
8897
9563
  const headJson = JSON.stringify(head, replacer);
8898
9564
  // Columns are Unicode code points from the line start (SPEC, scan); SARIF defaults to UTF-16 units.
8899
- yield `${headJson.slice(0, -1)},"runs":[{"tool":${JSON.stringify({ driver }, replacer)},"columnKind":"unicodeCodePoints","results":[`;
9565
+ // %SRCROOT% is declared as the scan root, so every relative result URI resolves to its file.
9566
+ const rootUri = fileUri((0, node_path_1.resolve)(root));
9567
+ const baseIds = { '%SRCROOT%': { uri: rootUri.endsWith('/') ? rootUri : `${rootUri}/` } };
9568
+ yield `${headJson.slice(0, -1)},"runs":[{"tool":${JSON.stringify({ driver }, replacer)},`
9569
+ + `"originalUriBaseIds":${JSON.stringify(baseIds, replacer)},"columnKind":"unicodeCodePoints","results":[`;
8900
9570
  let first = true;
8901
9571
  let emitted = 0;
8902
9572
  for (const finding of result.findings) {
@@ -8914,14 +9584,14 @@ function* sarifPieces(result, root, version, replacer) {
8914
9584
  location.logicalLocations = [{ name: finding.commit, kind: 'commit' }];
8915
9585
  const related = finding.alsoAt?.paths.map((path, index) => ({ id: index + 1,
8916
9586
  physicalLocation: { artifactLocation: artifactLocation(path, root) },
8917
- message: { text: 'same content also added here' } }));
9587
+ message: { text: finding.commit === undefined ? 'same file (hard link) also here' : 'same content also added here' } }));
8918
9588
  const sarifResult = {
8919
9589
  ruleId, ruleIndex, level,
8920
9590
  message: { text: `${finding.kind} credential, grade ${gradeLabel(finding)}, ${finding.fingerprint}`
8921
9591
  + (finding.rootKeyId === null ? '' : `, root key ${finding.rootKeyId}`)
8922
9592
  + (finding.commit === undefined ? '' : `, added in commit ${finding.commit}`)
8923
9593
  + (finding.context === 'mcp-config' ? ', configured for an AI agent (MCP server)' : '')
8924
- + (finding.alsoAt ? `, same content also added at ${finding.alsoAt.count} other path(s)` : '') },
9594
+ + (finding.alsoAt ? `, ${alsoText(finding)} ${finding.alsoAt.count} other path(s)` : '') },
8925
9595
  locations: [location],
8926
9596
  ...(related?.length ? { relatedLocations: related } : {}),
8927
9597
  partialFingerprints: { 'macwardenFingerprint/v1': finding.fingerprint },
@@ -8952,14 +9622,18 @@ function escapeData(value) {
8952
9622
  function escapeProperty(value) {
8953
9623
  return escapeData(value).replace(/:/g, '%3A').replace(/,/g, '%2C');
8954
9624
  }
8955
- /** GitHub Actions annotations, one per finding, then the text summary line. */
8956
- function* githubLines(result, root, clean, summary) {
9625
+ /**
9626
+ * GitHub Actions annotations, one per finding, then the text summary line. `clean` escapes
9627
+ * the message for display; the file property carries the real path (credentials redacted by
9628
+ * `redact`) with GitHub's own property escaping, so the annotation names the actual file.
9629
+ */
9630
+ function* githubLines(result, root, clean, summary, redact = text => text) {
8957
9631
  for (const finding of result.findings) {
8958
9632
  const { level } = findingSeverity(finding);
8959
9633
  const command = level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'notice';
8960
9634
  const where = locationOf(finding.path, root);
8961
9635
  const file = 'relative' in where ? where.relative : where.absolute;
8962
- const properties = [`file=${escapeProperty(clean(file))}`];
9636
+ const properties = [`file=${escapeProperty(redact(file))}`];
8963
9637
  if (finding.line !== null)
8964
9638
  properties.push(`line=${finding.line}`);
8965
9639
  if (finding.column !== null)
@@ -8969,7 +9643,7 @@ function* githubLines(result, root, clean, summary) {
8969
9643
  + (finding.rootKeyId === null ? '' : ` root key ${finding.rootKeyId}`)
8970
9644
  + (finding.commit === undefined ? '' : ` in commit ${finding.commit.slice(0, 12)}`)
8971
9645
  + (finding.context === 'mcp-config' ? ' (MCP server config)' : '')
8972
- + (finding.alsoAt ? ` (same content also added at ${finding.alsoAt.count} other path(s))` : '');
9646
+ + (finding.alsoAt ? ` (${alsoText(finding)} ${finding.alsoAt.count} other path(s))` : '');
8973
9647
  yield `::${command} ${properties.join(',')}::${escapeData(clean(message))}\n`;
8974
9648
  }
8975
9649
  yield summary;
@@ -9002,6 +9676,7 @@ function internalErrorMessage(state) {
9002
9676
  "use strict";
9003
9677
  exports.__esModule = true;
9004
9678
  exports.isProtectedPath = isProtectedPath;
9679
+ exports.outputNameProblem = outputNameProblem;
9005
9680
  exports.resolveUnprotectedPath = resolveUnprotectedPath;
9006
9681
  const node_path_1 = require("node:path");
9007
9682
  // SAFE-02 names are refused as complete path components before opening them.
@@ -9014,6 +9689,38 @@ function isProtectedPath(path) {
9014
9689
  /^(?:wallet[-_.]?password(?:\..+)?|seeds?(?:\..+)?|tls[-_.]?(?:key|privkey)(?:\..+)?)$/.test(name);
9015
9690
  });
9016
9691
  }
9692
+ // Names Win32 path normalisation changes or maps to a device. Node opens the literal
9693
+ // \\?\ object while Win32 tools (and other programs) open the normalised name, so a
9694
+ // credential file must never carry one. COM/LPT also match the superscript digits.
9695
+ const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3]|conin\$|conout\$)(?:[ .]|$)/i;
9696
+ // eslint-disable-next-line no-control-regex
9697
+ const WINDOWS_INVALID = /[<>:"|?*\u0000-\u001f]/;
9698
+ /**
9699
+ * Why an output path cannot be written as given on Windows, or null. Every component
9700
+ * after the drive or UNC share is checked: a trailing dot or space, a reserved device
9701
+ * name (with or without an extension, any case), ':' (an alternate data stream) or a
9702
+ * character Windows forbids. Device namespace paths (\\.\) are refused. Other
9703
+ * platforms accept these names, so the check applies only on Windows.
9704
+ */
9705
+ function outputNameProblem(path, platform = process.platform) {
9706
+ if (platform !== 'win32')
9707
+ return null;
9708
+ const absolute = node_path_1.win32.resolve(path);
9709
+ if (/^[\\/]{2}\.[\\/]/.test(absolute))
9710
+ return 'a Windows device namespace path (\\\\.\\) is refused';
9711
+ const root = node_path_1.win32.parse(absolute).root;
9712
+ for (const component of absolute.slice(root.length).split('\\')) {
9713
+ if (!component)
9714
+ continue;
9715
+ if (WINDOWS_INVALID.test(component))
9716
+ return 'a path component contains ":" (an alternate data stream) or a character Windows does not allow in names';
9717
+ if (/[. ]$/.test(component))
9718
+ return 'a path component ends with a dot or a space, which Windows removes';
9719
+ if (WINDOWS_DEVICE.test(component))
9720
+ return 'a path component is a reserved Windows device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9)';
9721
+ }
9722
+ return null;
9723
+ }
9017
9724
  async function resolveUnprotectedPath(path, fs) {
9018
9725
  if (isProtectedPath(path))
9019
9726
  throw new Error('protected path');
@@ -9093,6 +9800,8 @@ exports.Registry = exports.RegistryUsageError = exports.RegistryInputError = voi
9093
9800
  exports.openRegistry = openRegistry;
9094
9801
  exports.replacementProblem = replacementProblem;
9095
9802
  exports.replacementLinkProblems = replacementLinkProblems;
9803
+ exports.recordedFileRelation = recordedFileRelation;
9804
+ exports.isStagedReplacement = isStagedReplacement;
9096
9805
  exports.hasCustomCaveat = hasCustomCaveat;
9097
9806
  const process_domain_js_1 = require("./process-domain.js");
9098
9807
  const nodeFs = __importStar(require("node:fs/promises"));
@@ -9101,7 +9810,8 @@ const node_path_1 = require("node:path");
9101
9810
  const node_crypto_1 = require("node:crypto");
9102
9811
  const protected_path_js_1 = require("../protected-path.js");
9103
9812
  const bounded_file_js_1 = require("../bounded-file.js");
9104
- const maxRootKeyId = 18446744073709551615n;
9813
+ const instant_js_1 = require("../instant.js");
9814
+ const root_key_id_js_1 = require("../root-key-id.js");
9105
9815
  const fields = ['nodePubkey', 'label', 'rootKeyId', 'permissions', 'caveats', 'grade',
9106
9816
  'fingerprint', 'outPath', 'createdAt', 'verified', 'status', 'revokedAt',
9107
9817
  'revokeEvidence', 'replaces', 'replacedBy'];
@@ -9136,13 +9846,12 @@ function exactFields(value, expected) {
9136
9846
  const keys = Object.keys(value);
9137
9847
  return keys.length === expected.length && keys.every(key => expected.includes(key));
9138
9848
  }
9139
- function decimalId(value) {
9140
- return typeof value === 'string' && /^(0|[1-9][0-9]*)$/.test(value)
9141
- && BigInt(value) <= maxRootKeyId;
9142
- }
9143
- function timestamp(value) {
9144
- return typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(value)
9145
- && !Number.isNaN(Date.parse(value));
9849
+ const decimalId = root_key_id_js_1.isRootKeyId;
9850
+ // A real instant, not only its shape: 30 February or 24:00 would be read as another day.
9851
+ const timestamp = instant_js_1.isInstant;
9852
+ /** A recorded expiry duration, as `bake --expires` accepts it (RL-03): rotate S11 reuses it. */
9853
+ function duration(value) {
9854
+ return typeof value === 'string' && /^[1-9][0-9]{0,4}[mhd]$/.test(value);
9146
9855
  }
9147
9856
  function validEntry(value) {
9148
9857
  if (!isRecord(value) || !exactFields(value, fields))
@@ -9158,7 +9867,7 @@ function validEntry(value) {
9158
9867
  && Object.hasOwn(caveat, 'type') && Object.hasOwn(caveat, 'value')
9159
9868
  && typeof caveat.type === 'string' && caveatTypes.has(caveat.type)
9160
9869
  && typeof caveat.value === 'string'
9161
- && (caveat.requested === undefined || typeof caveat.requested === 'string')))
9870
+ && (caveat.requested === undefined || (caveat.type === 'time-before' && duration(caveat.requested)))))
9162
9871
  return false;
9163
9872
  return typeof value.grade === 'string' && grades.has(value.grade)
9164
9873
  && ((typeof value.fingerprint === 'string' && /^fp:[0-9a-f]{16}$/.test(value.fingerprint))
@@ -9228,9 +9937,14 @@ function alive(pid) {
9228
9937
  return error.code !== 'ESRCH';
9229
9938
  }
9230
9939
  }
9940
+ const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9941
+ // `token` (from 1.1.1) is a random per-acquisition nonce that binds release to this owner's
9942
+ // record; records written without it by earlier versions stay valid.
9231
9943
  function validLock(value) {
9232
9944
  return isRecord(value) && (exactFields(value, ['pid', 'hostname', 'startedAt'])
9233
- || exactFields(value, ['pid', 'hostname', 'startedAt', 'processDomain']))
9945
+ || exactFields(value, ['pid', 'hostname', 'startedAt', 'processDomain'])
9946
+ || exactFields(value, ['pid', 'hostname', 'startedAt', 'processDomain', 'token']))
9947
+ && (value.token === undefined || (typeof value.token === 'string' && LOCK_TOKEN.test(value.token)))
9234
9948
  && Number.isSafeInteger(value.pid) && value.pid > 0
9235
9949
  && typeof value.hostname === 'string' && /^[A-Za-z0-9_.-]+$/.test(value.hostname)
9236
9950
  && timestamp(value.startedAt)
@@ -9240,6 +9954,7 @@ class Registry {
9240
9954
  fs;
9241
9955
  deps;
9242
9956
  path;
9957
+ /** The lock this registry holds: its file identity and the exact owner record written. */
9243
9958
  ownedLock;
9244
9959
  entries;
9245
9960
  originalText;
@@ -9446,11 +10161,12 @@ class Registry {
9446
10161
  try {
9447
10162
  const handle = await this.fs.open(lockPath, 'wx', 0o600);
9448
10163
  try {
9449
- await handle.writeFile(JSON.stringify({ pid: process.pid,
9450
- hostname: (this.deps.hostname ?? node_os_1.hostname)(), startedAt: new Date().toISOString(), processDomain }));
9451
- const stat = await handle.stat();
10164
+ const record = JSON.stringify({ pid: process.pid, hostname: (this.deps.hostname ?? node_os_1.hostname)(),
10165
+ startedAt: new Date().toISOString(), processDomain, token: (0, node_crypto_1.randomUUID)() });
10166
+ await handle.writeFile(record);
10167
+ const stat = await handle.stat({ bigint: true });
9452
10168
  await handle.close();
9453
- this.ownedLock = { dev: stat.dev, ino: stat.ino };
10169
+ this.ownedLock = { dev: stat.dev, ino: stat.ino, birth: stat.birthtimeNs, record };
9454
10170
  }
9455
10171
  catch {
9456
10172
  await handle.close().catch(() => { });
@@ -9465,9 +10181,9 @@ class Registry {
9465
10181
  }
9466
10182
  let lock;
9467
10183
  try {
9468
- // The lock is ours to read only as a plain file: never through a symlink (SAFE-02).
9469
- await (0, bounded_file_js_1.assertPlainFile)(lockPath, this.fs, bounded_file_js_1.LOCK_FILE_LIMIT);
9470
- const text = await this.fs.readFile(lockPath, 'utf8');
10184
+ // The lock is ours to read only as a plain file: never through a symlink (SAFE-02),
10185
+ // through one descriptor bound to the checked file and at most LOCK_FILE_LIMIT + 1 bytes.
10186
+ const text = await (0, bounded_file_js_1.readPlainFile)(lockPath, this.fs, bounded_file_js_1.LOCK_FILE_LIMIT);
9471
10187
  lock = JSON.parse(text);
9472
10188
  }
9473
10189
  catch {
@@ -9492,9 +10208,10 @@ class Registry {
9492
10208
  before = await this.fs.lstat(lockPath);
9493
10209
  if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.ino === 0)
9494
10210
  input('registry-lock', 'stale lock ownership ambiguous; stop writers and recover offline');
9495
- if (await this.fs.readFile(lockPath, 'utf8') !== JSON.stringify(lock)) {
10211
+ const text = await (0, bounded_file_js_1.readPlainFile)(lockPath, this.fs, bounded_file_js_1.LOCK_FILE_LIMIT);
10212
+ if (text !== JSON.stringify(lock)) {
9496
10213
  // Whitespace is allowed, but the owner must still be identical.
9497
- const reread = JSON.parse(await this.fs.readFile(lockPath, 'utf8'));
10214
+ const reread = JSON.parse(text);
9498
10215
  if (!validLock(reread) || JSON.stringify(reread) !== JSON.stringify(lock))
9499
10216
  input('registry-lock', 'registry lock changed; stop writers and recover offline');
9500
10217
  }
@@ -9505,7 +10222,7 @@ class Registry {
9505
10222
  await this.fs.rename(lockPath, quarantine);
9506
10223
  quarantined = true;
9507
10224
  const moved = await this.fs.lstat(quarantine);
9508
- const movedOwner = JSON.parse(await this.fs.readFile(quarantine, 'utf8'));
10225
+ const movedOwner = JSON.parse(await (0, bounded_file_js_1.readPlainFile)(quarantine, this.fs, bounded_file_js_1.LOCK_FILE_LIMIT));
9509
10226
  if (moved.dev !== before.dev || moved.ino !== before.ino || !validLock(movedOwner)
9510
10227
  || JSON.stringify(movedOwner) !== JSON.stringify(lock) || (this.deps.pidAlive ?? alive)(lock.pid)) {
9511
10228
  // Never overwrite a replacement writer's pathname. Hard-link restoration
@@ -9585,8 +10302,14 @@ class Registry {
9585
10302
  return;
9586
10303
  try {
9587
10304
  const lockPath = `${this.path}.lock`;
9588
- const stat = await this.fs.lstat(lockPath);
9589
- if (!stat.isFile() || stat.dev !== owned.dev || stat.ino !== owned.ino)
10305
+ const stat = await this.fs.lstat(lockPath, { bigint: true });
10306
+ // Birth time (0 where the file system has none) tells most recycled inodes apart too.
10307
+ if (!stat.isFile() || stat.dev !== owned.dev || stat.ino !== owned.ino || stat.birthtimeNs !== owned.birth)
10308
+ input('registry-lock', 'registry lock owner changed; preserved competing lock');
10309
+ // A file system may hand a freed inode number to the next file at once, so identity
10310
+ // alone cannot tell a replaced lock apart: the record, with this acquisition's token,
10311
+ // must be exactly the one written.
10312
+ if (await (0, bounded_file_js_1.readPlainFile)(lockPath, this.fs, bounded_file_js_1.LOCK_FILE_LIMIT) !== owned.record)
9590
10313
  input('registry-lock', 'registry lock owner changed; preserved competing lock');
9591
10314
  await this.fs.unlink(lockPath);
9592
10315
  }
@@ -9640,10 +10363,34 @@ function replacementProblem(old, next) {
9640
10363
  return 'its label differs';
9641
10364
  if (next.rootKeyId === old.rootKeyId)
9642
10365
  return 'it has the same root key ID';
9643
- if (Date.parse(next.createdAt) < Date.parse(old.createdAt))
9644
- return 'it was created before the credential it replaces';
10366
+ if (Date.parse(next.createdAt) < Date.parse(old.createdAt) && !rotateWrittenShape(old, next))
10367
+ return 'it was created before the credential it replaces (if macwarden staged it while this machine\'s clock ' +
10368
+ `was behind, abandon it with macwarden revoke ${next.rootKeyId} and stage again)`;
9645
10369
  return null;
9646
10370
  }
10371
+ /**
10372
+ * True when a replacement recorded as created before `old` is the link 1.1.0's own stage wrote
10373
+ * under a clock behind O's recorded creation time (RL-02): 1.1.0 recorded N.createdAt as the
10374
+ * local clock at S10, so an NTP step back, a resumed VM or O baked while the clock ran ahead made
10375
+ * macwarden's own link look "created before" O, and finish, respond and inventory then refused
10376
+ * it as tampering. Such a link has exactly the shape rotate writes: O's permissions (as a set)
10377
+ * and grade, at most one caveat and only `time-before` (S11), no `replacedBy`, `createdAt` as
10378
+ * `Date.toISOString()` writes it, and a gap no larger than a clock error (24 hours). The time
10379
+ * order is a plausibility check, not a boundary (whoever can edit the registry can edit
10380
+ * createdAt too), and a link of this shape grants no more than O; a link that is older, broader
10381
+ * or carries anything rotate would not write stays tampering. 1.1.1 records N.createdAt as
10382
+ * max(now, O.createdAt), so it no longer writes such links.
10383
+ */
10384
+ const CLOCK_ERROR_MS = 86_400_000;
10385
+ function rotateWrittenShape(old, next) {
10386
+ const permissions = new Set(old.permissions);
10387
+ return next.permissions.length === permissions.size && next.permissions.every(permission => permissions.has(permission))
10388
+ && new Set(next.permissions).size === next.permissions.length
10389
+ && next.grade === old.grade && next.replacedBy === null
10390
+ && next.caveats.length <= 1 && next.caveats.every(caveat => caveat.type === 'time-before')
10391
+ && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(next.createdAt)
10392
+ && Date.parse(old.createdAt) - Date.parse(next.createdAt) <= CLOCK_ERROR_MS;
10393
+ }
9647
10394
  /**
9648
10395
  * Every active staged-replacement link that macwarden could not have written. A link is an
9649
10396
  * active entry whose `replaces` names an active entry on the same node (a rotation that
@@ -9676,6 +10423,36 @@ function replacementLinkProblems(entries) {
9676
10423
  }
9677
10424
  return problems;
9678
10425
  }
10426
+ /**
10427
+ * How the credential file found at an entry's recorded `outPath` relates to that entry (RL-01).
10428
+ * - `match`: the entry's credential (same root key ID and fingerprint).
10429
+ * - `other-credential`: a credential on another root key ID. The path was reused, for example
10430
+ * when the operator baked or staged a replacement where the app reads its file. It says
10431
+ * nothing about this entry, so the entry is handled as having no usable file (proof by ID).
10432
+ * - `same-root-key`: the entry's root key ID with another fingerprint (another macaroon on that
10433
+ * root key, such as an attenuated copy, or an edited fingerprint).
10434
+ * - `tampered`: the entry's fingerprint on another root key ID. A fingerprint covers the
10435
+ * identifier, which carries the root key ID, so the entry's root key ID or fingerprint was edited.
10436
+ */
10437
+ function recordedFileRelation(entry, facts) {
10438
+ const sameId = facts.rootKeyId === BigInt(entry.rootKeyId);
10439
+ const sameFingerprint = facts.fingerprint === entry.fingerprint;
10440
+ if (sameId)
10441
+ return sameFingerprint ? 'match' : 'same-root-key';
10442
+ return sameFingerprint ? 'tampered' : 'other-credential';
10443
+ }
10444
+ /**
10445
+ * Whether `entry` is a staged (pending) replacement: an active entry whose `replaces` names an
10446
+ * active entry on the same node (SPEC 6). One definition for rotate, respond and inventory
10447
+ * (RL-07): a `replaces` naming a revoked entry (a finished rotation) or an entry no longer in
10448
+ * the registry (pruned) does not make the entry a replacement, so it is an original that can
10449
+ * be rotated again.
10450
+ */
10451
+ function isStagedReplacement(entries, entry) {
10452
+ const node = entry.nodePubkey.toLowerCase();
10453
+ return entry.status === 'active' && entry.replaces !== null && entries.some(old => old.status === 'active'
10454
+ && old.nodePubkey.toLowerCase() === node && old.rootKeyId === entry.replaces);
10455
+ }
9679
10456
  /** An lnd-custom caveat (a canary, or any custom caveat) that a rotation cannot reproduce. */
9680
10457
  function hasCustomCaveat(entry) {
9681
10458
  return entry.caveats.some(caveat => caveat.type === 'lnd-custom');
@@ -10013,6 +10790,23 @@ function formatAccessReview(review, format) {
10013
10790
  return lines.join('\n');
10014
10791
  }
10015
10792
 
10793
+ },
10794
+ "root-key-id.js": (module, exports, require) => {
10795
+ "use strict";
10796
+ // Root key IDs are uint64 and always canonical decimal strings in JSON (SPEC, Output).
10797
+ exports.__esModule = true;
10798
+ exports.MAX_ROOT_KEY_ID = void 0;
10799
+ exports.isRootKeyId = isRootKeyId;
10800
+ exports.MAX_ROOT_KEY_ID = 18446744073709551615n;
10801
+ /**
10802
+ * True for a canonical decimal uint64: digits only, no leading zero except "0" itself, at most
10803
+ * 18446744073709551615. The one rule for every root key ID macwarden reads (registry, usage
10804
+ * and canary records, command selectors), so no reader accepts an ID another one refuses.
10805
+ */
10806
+ function isRootKeyId(value) {
10807
+ return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,19})$/.test(value) && BigInt(value) <= exports.MAX_ROOT_KEY_ID;
10808
+ }
10809
+
10016
10810
  },
10017
10811
  "scan/agent-configs.js": (module, exports, require) => {
10018
10812
  "use strict";
@@ -10165,11 +10959,15 @@ exports.readBlobs = readBlobs;
10165
10959
  // make an offline scan open SMB connections. locateRepository() resolves all of them in
10166
10960
  // JavaScript first, refuses any that are not on a local file system before touching them,
10167
10961
  // and the git processes then run with GIT_DIR and GIT_WORK_TREE pinned to the result.
10962
+ // Every location is also checked against the protected names (SAFE-02) on its supplied path
10963
+ // and resolved target before it is opened, because git would read the same files.
10168
10964
  const node_child_process_1 = require("node:child_process");
10169
10965
  const fs = __importStar(require("node:fs/promises"));
10170
10966
  const node_os_1 = require("node:os");
10171
10967
  const node_path_1 = require("node:path");
10968
+ const bounded_file_js_1 = require("../bounded-file.js");
10172
10969
  const cancellation_js_1 = require("../cancellation.js");
10970
+ const protected_path_js_1 = require("../protected-path.js");
10173
10971
  class GitHistoryError extends Error {
10174
10972
  constructor(message) { super(message); this.name = 'GitHistoryError'; }
10175
10973
  }
@@ -10192,6 +10990,7 @@ exports.PINNED_CONFIG = [
10192
10990
  'log.mailmap=false', 'mailmap.file=', 'mailmap.blob=',
10193
10991
  ].flatMap(setting => ['-c', setting]);
10194
10992
  const NOT_LOCAL = 'git history requires a repository on a local file system';
10993
+ const PROTECTED = 'git history would open a protected file';
10195
10994
  const NOT_WORK_TREE = 'path is not inside a git work tree';
10196
10995
  /**
10197
10996
  * True when `path` (absolute) names a network or device location: on Windows a UNC
@@ -10214,25 +11013,35 @@ const MAX_LOCATION_FILE = 64 * 1024;
10214
11013
  function absolute(base, value) {
10215
11014
  return (0, node_path_1.isAbsolute)(value) || (process.platform === 'win32' && node_path_1.win32.isAbsolute(value)) ? (0, node_path_1.resolve)(value) : (0, node_path_1.resolve)(base, value);
10216
11015
  }
10217
- /** Resolves a path named inside the repository; refuses non-local ones before any I/O. */
10218
- async function local(base, value) {
11016
+ /**
11017
+ * Resolves a path named inside the repository. Refuses non-local ones before any I/O, and
11018
+ * (SAFE-02) one whose supplied path or resolved target has a protected file or directory
11019
+ * component before anything there is opened.
11020
+ */
11021
+ async function local(io, base, value) {
10219
11022
  const path = absolute(base, value);
10220
11023
  if (nonLocalPath(path))
10221
11024
  throw new GitHistoryError(NOT_LOCAL);
10222
- const real = await fs.realpath(path).catch(() => null);
11025
+ if ((0, protected_path_js_1.isProtectedPath)(path))
11026
+ throw new GitHistoryError(PROTECTED);
11027
+ const real = await io.realpath(path).catch(() => null);
10223
11028
  if (real !== null && nonLocalPath(real))
10224
11029
  throw new GitHistoryError(NOT_LOCAL);
11030
+ if (real !== null && (0, protected_path_js_1.isProtectedPath)(real))
11031
+ throw new GitHistoryError(PROTECTED);
10225
11032
  return real ?? path;
10226
11033
  }
10227
11034
  exports.PREFLIGHT_LIMITS = { files: 256, bytes: 1024 * 1024, edges: 4096 };
10228
11035
  /** One preflight: budgets, counters, memoized results and the chain currently being walked. */
10229
11036
  class Preflight {
11037
+ io;
10230
11038
  stats;
10231
11039
  limits;
10232
11040
  /** Canonical location → its expansion, once validated. Each location is read at most once. */
10233
11041
  done = new Map();
10234
11042
  walking = new Set();
10235
- constructor(limits = {}, stats) {
11043
+ constructor(io, limits = {}, stats) {
11044
+ this.io = io;
10236
11045
  this.limits = { ...exports.PREFLIGHT_LIMITS, ...limits };
10237
11046
  this.stats = Object.assign(stats ?? {}, { files: 0, bytes: 0, edges: 0, expandedEdges: 0, expandedBytes: 0 });
10238
11047
  }
@@ -10246,18 +11055,34 @@ class Preflight {
10246
11055
  throw new GitHistoryError(UNREADABLE);
10247
11056
  }
10248
11057
  edge() { this.stats.edges++; this.step(); }
11058
+ /**
11059
+ * Reads one location file, or null when it is absent or not a regular file. A protected
11060
+ * path or resolved target (SAFE-02) is refused before it is opened; the content is read
11061
+ * through one descriptor checked to be the regular file that was resolved, and at most
11062
+ * MAX_LOCATION_FILE + 1 bytes are ever read.
11063
+ */
10249
11064
  async small(path) {
10250
11065
  this.stats.files++;
10251
11066
  this.step();
10252
- const stat = await fs.stat(path).catch(() => null);
10253
- if (!stat?.isFile())
10254
- return null;
10255
- if (stat.size > MAX_LOCATION_FILE)
10256
- throw new GitHistoryError(UNREADABLE);
10257
- const bytes = await fs.readFile(path);
10258
- // The file may have grown between stat and read: count and bound what was actually read.
10259
- if (bytes.length > MAX_LOCATION_FILE)
11067
+ if ((0, protected_path_js_1.isProtectedPath)(path))
11068
+ throw new GitHistoryError(PROTECTED);
11069
+ let bytes;
11070
+ try {
11071
+ bytes = await (0, bounded_file_js_1.readBoundedFile)(path, this.io, MAX_LOCATION_FILE);
11072
+ }
11073
+ catch (error) {
11074
+ if (error instanceof bounded_file_js_1.BoundedFileError) {
11075
+ if (error.message === 'protected file')
11076
+ throw new GitHistoryError(PROTECTED);
11077
+ if (error.message === 'not a regular file')
11078
+ return null;
11079
+ throw new GitHistoryError(UNREADABLE);
11080
+ }
11081
+ // Absent or inaccessible: git would not read it either.
11082
+ if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'ELOOP'].includes(error.code ?? ''))
11083
+ return null;
10260
11084
  throw new GitHistoryError(UNREADABLE);
11085
+ }
10261
11086
  this.stats.bytes += bytes.length;
10262
11087
  this.step();
10263
11088
  return bytes.toString('utf8');
@@ -10331,7 +11156,7 @@ function checkAlternates(run, objects, depth) {
10331
11156
  if (!line || line.startsWith('#'))
10332
11157
  continue;
10333
11158
  run.edge();
10334
- run.follow(own, await checkAlternates(run, await local(objects, unquoteAlternate(line)), depth + 1));
11159
+ run.follow(own, await checkAlternates(run, await local(run.io, objects, unquoteAlternate(line)), depth + 1));
10335
11160
  }
10336
11161
  return own;
10337
11162
  });
@@ -10380,7 +11205,7 @@ function checkIncludes(run, file, depth) {
10380
11205
  run.follow(own, NOTHING);
10381
11206
  continue;
10382
11207
  }
10383
- run.follow(own, await checkIncludes(run, await local((0, node_path_1.dirname)(file), named), depth + 1));
11208
+ run.follow(own, await checkIncludes(run, await local(run.io, (0, node_path_1.dirname)(file), named), depth + 1));
10384
11209
  }
10385
11210
  return own;
10386
11211
  });
@@ -10396,17 +11221,18 @@ function ceilings() {
10396
11221
  * directory or gitfile in it or a parent) and validates every location git would read.
10397
11222
  */
10398
11223
  async function locateRepository(directory, options = {}) {
10399
- const run = new Preflight(options.limits, options.stats);
11224
+ const io = options.io ?? fs;
11225
+ const run = new Preflight(io, options.limits, options.stats);
10400
11226
  if (nonLocalPath((0, node_path_1.resolve)(directory)))
10401
11227
  throw new GitHistoryError(NOT_LOCAL);
10402
- let current = await fs.realpath(directory).catch(() => { throw new GitHistoryError(NOT_WORK_TREE); });
11228
+ let current = await io.realpath(directory).catch(() => { throw new GitHistoryError(NOT_WORK_TREE); });
10403
11229
  if (nonLocalPath(current))
10404
11230
  throw new GitHistoryError(NOT_LOCAL);
10405
11231
  const ceiling = ceilings();
10406
11232
  const key = (path) => process.platform === 'win32' ? path.toLowerCase() : path;
10407
11233
  for (;;) {
10408
11234
  const candidate = (0, node_path_1.join)(current, '.git');
10409
- const stat = await fs.lstat(candidate).catch(() => null);
11235
+ const stat = await io.lstat(candidate).catch(() => null);
10410
11236
  if (stat) {
10411
11237
  let gitDir;
10412
11238
  if (stat.isFile()) {
@@ -10414,15 +11240,15 @@ async function locateRepository(directory, options = {}) {
10414
11240
  const target = text === null ? null : /^gitdir:\s*(.+?)\s*$/m.exec(text)?.[1];
10415
11241
  if (!target)
10416
11242
  throw new GitHistoryError(NOT_WORK_TREE);
10417
- gitDir = await local(current, target);
11243
+ gitDir = await local(io, current, target);
10418
11244
  }
10419
11245
  else
10420
- gitDir = await local(current, candidate);
10421
- const gitStat = await fs.stat(gitDir).catch(() => null);
11246
+ gitDir = await local(io, current, candidate);
11247
+ const gitStat = await io.stat(gitDir).catch(() => null);
10422
11248
  if (!gitStat?.isDirectory())
10423
11249
  throw new GitHistoryError(NOT_WORK_TREE);
10424
11250
  const common = await run.small((0, node_path_1.join)(gitDir, 'commondir'));
10425
- const commonDir = common === null ? gitDir : await local(gitDir, common.trim());
11251
+ const commonDir = common === null ? gitDir : await local(io, gitDir, common.trim());
10426
11252
  run.root(await checkAlternates(run, (0, node_path_1.join)(commonDir, 'objects'), 0));
10427
11253
  run.root(await checkIncludes(run, (0, node_path_1.join)(commonDir, 'config'), 0));
10428
11254
  run.root(await checkIncludes(run, (0, node_path_1.join)(gitDir, 'config.worktree'), 0));
@@ -10858,7 +11684,20 @@ function parseIgnoreFile(content) {
10858
11684
  const match = /^(fp:[0-9a-fA-F]{16})(?:\s+(\S+))?$/.exec(line);
10859
11685
  if (!match)
10860
11686
  throw new IgnoreFileError();
10861
- const matches = match[2] === undefined ? () => true : globMatcher(match[2]);
11687
+ const glob = match[2];
11688
+ let matches;
11689
+ if (glob === undefined)
11690
+ matches = () => true;
11691
+ else if (glob.startsWith('/')) {
11692
+ // As in .gitignore, a leading '/' anchors the glob at the ignore file's directory. Globs
11693
+ // already match whole relative paths, so the '/' is dropped for them; an absolute
11694
+ // reported path (--ignore-file) is matched against the glob as written.
11695
+ const anchored = globMatcher(glob.slice(1));
11696
+ const absolute = globMatcher(glob);
11697
+ matches = path => path.startsWith('/') ? absolute(path) : anchored(path);
11698
+ }
11699
+ else
11700
+ matches = globMatcher(glob);
10862
11701
  if (entries.length >= 1024)
10863
11702
  throw new IgnoreFileError();
10864
11703
  entries.push({ fingerprint: match[1].toLowerCase(), matches });
@@ -10903,7 +11742,7 @@ var __importStar = (this && this.__importStar) || (function () {
10903
11742
  };
10904
11743
  })();
10905
11744
  exports.__esModule = true;
10906
- exports.readBounded = exports.SCAN_CONCURRENCY = exports.FindingsLimitError = exports.HISTORY_DEDUP_WORK_LIMIT = exports.ALSO_AT_EXAMPLES = exports.FINDINGS_LIMIT = exports.GitHistoryError = exports.IgnoreFileError = void 0;
11745
+ exports.SCAN_OPEN_FLAGS = exports.readBounded = exports.SCAN_CONCURRENCY = exports.FindingsLimitError = exports.HISTORY_DEDUP_WORK_LIMIT = exports.ALSO_AT_EXAMPLES = exports.FINDINGS_LIMIT = exports.CANDIDATE_LIMIT = exports.GitHistoryError = exports.IgnoreFileError = void 0;
10907
11746
  exports.scan = scan;
10908
11747
  const mcp_context_js_1 = require("./mcp-context.js");
10909
11748
  const node_crypto_1 = require("node:crypto");
@@ -10952,14 +11791,61 @@ const detectors = [
10952
11791
  { kind: 'nwc', pattern: patterns_js_1.nwc },
10953
11792
  { kind: 'macaroon-hex', pattern: patterns_js_1.macaroonHex },
10954
11793
  { kind: 'macaroon-base64', pattern: patterns_js_1.macaroonBase64 },
11794
+ // RR-10, scan only (not part of the published patterns): hex with a separator between every
11795
+ // byte (colon hex, xxd -g1, spaced bytes) is decoded; the prefix of an xxd default dump
11796
+ // (two-byte groups, either alignment) is a candidate, since a dump spans several lines.
11797
+ { kind: 'macaroon-hex', pattern: /02[: -]01[: -]03[: -]6c[: -]6e[: -]64[: -]02(?:[: -][0-9a-f]{2}){20,}/i },
11798
+ { kind: 'macaroon-hex', pattern: /0201 036c 6e64 02(?:[0-9a-f]{2})?|02 0103 6c6e 6402/i },
11799
+ ];
11800
+ // RR-10: a prefix whose continuation is shorter than the published patterns need and that
11801
+ // ends its line may be a credential wrapped narrower than 49 columns. It is joined with the
11802
+ // following lines of the same alphabet and reported when the joined text decodes.
11803
+ const NARROW_WRAP = [
11804
+ { kind: 'macaroon-base64', start: /AgEDbG5kA[A-Za-z0-9+/_-]{0,39}\s*$/, more: /^[A-Za-z0-9+/_-]+={0,2}$/ },
11805
+ { kind: 'macaroon-hex', start: /0201036c6e6402[0-9a-f]{0,39}\s*$/i, more: /^[0-9a-f]+$/i },
10955
11806
  ];
10956
11807
  const detectorRegexes = detectors.map(({ kind, pattern }) => ({ kind, regex: new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`) }));
10957
11808
  // Every detector match contains one of these (case-insensitive superset), so a line
10958
11809
  // without any of them cannot produce a hit and is not split into code points.
10959
11810
  // "walletconnect%3a" covers percent-encoded NWC URIs, which are matched on a normalised copy.
10960
- const MAY_MATCH = /lndconnect:\/\/|walletconnect:|walletconnect%3a|0201036c6e6402|AgEDbG5kA/i;
10961
- const WINDOW = 65_536;
10962
- const STEP = 32_768;
11811
+ const PLAIN_MAY_MATCH = /lndconnect:\/\/|walletconnect:|walletconnect%3a|0201036c6e6402|AgEDbG5kA|02[: -]01[: -]03[: -]6c|0201 036c 6e64 02|02 0103 6c6e 6402/i;
11812
+ /**
11813
+ * The part of base64(prefix) that does not depend on the bytes before or after the prefix,
11814
+ * for each of the three byte alignments: text that holds the base64 of a text credential
11815
+ * (a Kubernetes Secret's data, Helm values) holds one of these.
11816
+ */
11817
+ function encodedCores(prefix) {
11818
+ return [0, 1, 2].map(lead => {
11819
+ const encode = (fill) => Buffer.concat([Buffer.alloc(lead, fill), Buffer.from(prefix), Buffer.alloc(3, fill)]).toString('base64');
11820
+ const [low, high] = [encode(0), encode(0xff)];
11821
+ let best = '';
11822
+ for (let at = 0, run = 0; at < low.length; at++) {
11823
+ run = low[at] === high[at] ? run + 1 : 0;
11824
+ if (run > best.length)
11825
+ best = low.slice(at - run + 1, at + 1);
11826
+ }
11827
+ return best;
11828
+ });
11829
+ }
11830
+ const ENCODED_PREFIXES = ['0201036c6e6402', '0201036C6E6402', 'AgEDbG5kA', 'lndconnect://'];
11831
+ // Base64 that may hold a text credential. Case matters: it is matched on the encoded text.
11832
+ const ENCODED_CANDIDATE = new RegExp(ENCODED_PREFIXES.flatMap(encodedCores)
11833
+ .map(core => core.replace(/[+/]/g, match => `\\${match}`)).join('|'));
11834
+ // A line that cannot produce a hit, plain or encoded, is not split into code points.
11835
+ const MAY_MATCH = { test: (text) => PLAIN_MAY_MATCH.test(text) || ENCODED_CANDIDATE.test(text) };
11836
+ /**
11837
+ * Every detector match of up to this many code points is matched whole wherever it starts in
11838
+ * a line. It covers the largest supported credential encodings with room to spare: the hex of
11839
+ * a 64 KiB credential (131,072), its base64 or base64url (87,382) with lndconnect host and
11840
+ * certificate parameters, and NWC URIs. A match still cut by a window end is reported as an
11841
+ * undecodable-candidate, never dropped.
11842
+ */
11843
+ exports.CANDIDATE_LIMIT = 262_144;
11844
+ // Windows start every CANDIDATE_LIMIT code points and are long enough that a match starting
11845
+ // anywhere in the first CANDIDATE_LIMIT code points has CANDIDATE_LIMIT code points of room.
11846
+ // Each code point is matched at most twice, so time and memory stay linear in the line.
11847
+ const STEP = exports.CANDIDATE_LIMIT;
11848
+ const WINDOW = STEP + exports.CANDIDATE_LIMIT - 1;
10963
11849
  function fingerprint(data) {
10964
11850
  return `fp:${(0, node_crypto_1.createHash)('sha256').update(data).digest('hex').slice(0, 16)}`;
10965
11851
  }
@@ -11014,31 +11900,69 @@ function decodeBase64(value) {
11014
11900
  return null;
11015
11901
  return bytes;
11016
11902
  }
11903
+ function decoded(bytes) {
11904
+ try {
11905
+ return (0, index_js_1.decodeMacaroon)(new secret_js_1.Secret(bytes));
11906
+ }
11907
+ catch (error) {
11908
+ if (!(error instanceof index_js_1.DecodeError))
11909
+ throw error;
11910
+ return null;
11911
+ }
11912
+ }
11017
11913
  function credentialBytes(hit) {
11018
11914
  if (hit.kind === 'macaroon-hex') {
11019
- if (hit.text.length % 2 !== 0)
11915
+ // Byte separators (RR-10) carry no data.
11916
+ const digits = hit.text.replace(/[: -]/g, '');
11917
+ if (digits.length % 2 !== 0)
11020
11918
  return null;
11021
- return Buffer.from(hit.text, 'hex');
11919
+ return Buffer.from(digits, 'hex');
11022
11920
  }
11023
11921
  if (hit.kind === 'macaroon-base64')
11024
11922
  return decodeBase64(hit.text);
11025
- if (hit.kind !== 'lndconnect')
11026
- return null;
11027
- return (0, lndconnect_js_1.parseLndconnect)(hit.text);
11923
+ return null;
11924
+ }
11925
+ /**
11926
+ * Whether a match that ends at its window's end would be longer in the whole line: the same
11927
+ * detector, anchored at the match, matches more of the match followed by the next code point.
11928
+ */
11929
+ function continues(kind, text, next, regex) {
11930
+ if (kind === 'nwc')
11931
+ return false;
11932
+ const sticky = new RegExp(regex.source, `${regex.flags.replace('g', '')}y`);
11933
+ const longer = sticky.exec(text + next);
11934
+ return longer !== null && longer[0].length > text.length;
11028
11935
  }
11029
11936
  function collectHits(points, only) {
11030
11937
  const hits = new Map();
11938
+ const truncated = new Map();
11031
11939
  for (let offset = 0; offset < points.length; offset += STEP) {
11032
11940
  const window = points.slice(offset, offset + WINDOW).join('');
11941
+ // The line continues past this window: a match reaching its end may be cut.
11942
+ const cut = offset + WINDOW < points.length ? points[offset + WINDOW] : null;
11943
+ // This window has more room for any match starting in it than the earlier ones had, so
11944
+ // it decides again about every earlier cut match that starts here.
11945
+ for (const [key, hit] of truncated) {
11946
+ if (hit.start >= offset) {
11947
+ hits.delete(key);
11948
+ truncated.delete(key);
11949
+ }
11950
+ }
11033
11951
  for (const { kind, regex } of detectorRegexes) {
11034
- if (only !== undefined && kind !== only)
11952
+ if (only !== undefined && !only.includes(kind))
11035
11953
  continue;
11036
11954
  regex.lastIndex = 0;
11037
11955
  let codeUnit = 0;
11038
11956
  let codePoint = 0;
11039
11957
  // NWC uses a linear matcher equivalent to the published regex (src/scan/nwc-match.ts).
11040
- const found = kind === 'nwc' ? (0, nwc_match_js_1.nwcMatches)(window).map(item => ({ index: item.index, 0: item.text }))
11041
- : window.matchAll(regex);
11958
+ // A prefix whose URI runs to the window end without a secret may be cut by the window.
11959
+ const open = [];
11960
+ const found = kind === 'nwc'
11961
+ ? (0, nwc_match_js_1.nwcMatches)(window, cut === null ? undefined : open).map(item => ({ index: item.index, 0: item.text }))
11962
+ : [...window.matchAll(regex)];
11963
+ for (const index of open)
11964
+ found.push({ index, 0: window.slice(index), truncated: true });
11965
+ found.sort((a, b) => a.index - b.index);
11042
11966
  for (const match of found) {
11043
11967
  // Regex offsets count UTF-16 code units; scan columns count code points.
11044
11968
  // Advance once through the window instead of recounting every prefix.
@@ -11048,26 +11972,238 @@ function collectHits(points, only) {
11048
11972
  }
11049
11973
  const start = offset + codePoint;
11050
11974
  const key = `${kind}:${start}`;
11975
+ const cutHere = match.truncated ?? (cut !== null && match.index + match[0].length === window.length &&
11976
+ continues(kind, window.slice(match.index), cut, regex));
11051
11977
  const prior = hits.get(key);
11052
- if (!prior || match[0].length > prior.text.length)
11053
- hits.set(key, { kind, start, text: match[0] });
11978
+ if (prior && match[0].length <= prior.text.length)
11979
+ continue;
11980
+ const hit = cutHere ? { kind, start, text: match[0], truncated: true } : { kind, start, text: match[0] };
11981
+ hits.set(key, hit);
11982
+ if (cutHere)
11983
+ truncated.set(key, hit);
11984
+ else
11985
+ truncated.delete(key);
11054
11986
  }
11055
11987
  }
11988
+ // This window reached the line end: a later one would only rescan its tail.
11989
+ if (cut === null)
11990
+ break;
11991
+ }
11992
+ return [...hits.values()].sort(byStartThenDetector);
11993
+ }
11994
+ /** The line's code points with every "\/" read as "/"; origin and escaped map back to the line. */
11995
+ function slashUnescaped(points) {
11996
+ const out = [];
11997
+ const origin = [];
11998
+ const escaped = [];
11999
+ for (let i = 0; i < points.length; i++) {
12000
+ const pair = points[i] === '\\' && points[i + 1] === '/';
12001
+ out.push(pair ? '/' : points[i]);
12002
+ origin.push(i);
12003
+ escaped.push(pair);
12004
+ if (pair)
12005
+ i++;
12006
+ }
12007
+ return { points: out, origin, escaped };
12008
+ }
12009
+ function byStartThenDetector(a, b) {
12010
+ return a.start - b.start || detectors.findIndex(d => d.kind === a.kind) - detectors.findIndex(d => d.kind === b.kind);
12011
+ }
12012
+ // Punctuation that commonly follows a URI in text (markdown, prose, YAML flow, shell) but is
12013
+ // matched by the lndconnect pattern. It is removed only when the URI as matched does not parse.
12014
+ const TRAILING_PUNCTUATION = /[`)\]}>.,;:!?'"*]+$/;
12015
+ /** The credential an lndconnect match carries, and the match's length in code points once confirmed. */
12016
+ function lndconnectCredential(text) {
12017
+ const bytes = (0, lndconnect_js_1.parseLndconnect)(text, { standardBase64: true });
12018
+ if (bytes)
12019
+ return { bytes, length: Array.from(text).length };
12020
+ const trimmed = text.replace(TRAILING_PUNCTUATION, '');
12021
+ if (trimmed === text)
12022
+ return null;
12023
+ const retried = (0, lndconnect_js_1.parseLndconnect)(trimmed, { standardBase64: true });
12024
+ return retried ? { bytes: retried, length: Array.from(trimmed).length } : null;
12025
+ }
12026
+ /**
12027
+ * The findings of one line (or of one decoded text, see scanEncoded). `column` maps a code
12028
+ * point offset in `text` to the 1-based column reported.
12029
+ */
12030
+ function scanLine(path, line, text, now, ctx, column) {
12031
+ const findings = [];
12032
+ let dropped = 0;
12033
+ const points = Array.from(text);
12034
+ let hits = collectHits(points);
12035
+ // NWC URIs in common escaped forms (JSON-escaped slashes, percent-encoding, "+" as a
12036
+ // space) are matched on one normalised copy of the line; a URI the line already matched
12037
+ // at the same column is not reported twice (a whole match replaces one cut by a window).
12038
+ const unescaped = (0, nwc_match_js_1.nwcUnescaped)(points, text);
12039
+ if (unescaped) {
12040
+ const seen = new Map(hits.filter(hit => hit.kind === 'nwc').map(hit => [hit.start, hit]));
12041
+ const replaced = new Set();
12042
+ for (const hit of collectHits(unescaped.points, ['nwc'])) {
12043
+ const start = unescaped.origin[hit.start];
12044
+ const prior = seen.get(start);
12045
+ if (prior && !(prior.truncated && !hit.truncated))
12046
+ continue;
12047
+ if (prior)
12048
+ replaced.add(prior);
12049
+ const moved = { ...hit, start };
12050
+ seen.set(start, moved);
12051
+ hits.push(moved);
12052
+ }
12053
+ if (replaced.size)
12054
+ hits = hits.filter(hit => !replaced.has(hit));
12055
+ hits.sort((a, b) => a.start - b.start);
12056
+ }
12057
+ // JSON-escaped slashes ("\/", PHP json_encode by default and some Java and Go encoders) would
12058
+ // stop a base64 macaroon or an lndconnect URI at the backslash: both are also matched on a
12059
+ // copy of the line with "\/" read as "/". A match there replaces the one at the same column
12060
+ // only when it covers more of the line, so an unescaped line yields the same hits.
12061
+ const slashes = text.includes('\\/') ? slashUnescaped(points) : null;
12062
+ if (slashes) {
12063
+ const byKey = new Map(hits.map(hit => [`${hit.kind}:${hit.start}`, hit]));
12064
+ const replaced = new Set();
12065
+ for (const hit of collectHits(slashes.points, ['lndconnect', 'macaroon-base64'])) {
12066
+ const start = slashes.origin[hit.start];
12067
+ const last = hit.start + Array.from(hit.text).length - 1;
12068
+ const extent = slashes.origin[last] + (slashes.escaped[last] ? 2 : 1) - start;
12069
+ const key = `${hit.kind}:${start}`;
12070
+ const prior = byKey.get(key);
12071
+ if (prior && extent <= (prior.extent ?? Array.from(prior.text).length))
12072
+ continue;
12073
+ if (prior)
12074
+ replaced.add(prior);
12075
+ const moved = { ...hit, start, extent };
12076
+ byKey.set(key, moved);
12077
+ hits.push(moved);
12078
+ }
12079
+ if (replaced.size)
12080
+ hits = hits.filter(hit => !replaced.has(hit));
12081
+ hits.sort(byStartThenDetector);
12082
+ }
12083
+ // An lndconnect URI is confirmed first. Only a confirmed URI hides the matches inside it:
12084
+ // a URI that does not parse never hides the credential it carries (fail closed).
12085
+ const lineFindings = [];
12086
+ const spans = [];
12087
+ for (const hit of hits) {
12088
+ if (hit.kind !== 'lndconnect' || hit.truncated)
12089
+ continue;
12090
+ const confirmed = lndconnectCredential(hit.text);
12091
+ const facts = confirmed && decoded(confirmed.bytes);
12092
+ if (!confirmed || !facts) {
12093
+ dropped++;
12094
+ continue;
12095
+ }
12096
+ const length = Array.from(hit.text).length;
12097
+ spans.push({ start: hit.start, end: hit.start + (hit.extent ?? length) - (length - confirmed.length) });
12098
+ lineFindings.push({ hit, finding: withFacts(path, line, column(hit.start), 'lndconnect', facts, now, ctx) });
11056
12099
  }
11057
- const all = [...hits.values()].sort((a, b) => a.start - b.start ||
11058
- detectors.findIndex(d => d.kind === a.kind) - detectors.findIndex(d => d.kind === b.kind));
11059
- const spans = all.filter(hit => hit.kind === 'lndconnect').map(hit => ({
11060
- start: hit.start, end: hit.start + Array.from(hit.text).length,
11061
- }));
11062
12100
  let spanIndex = 0;
11063
12101
  let coveredUntil = 0;
11064
- return all.filter(hit => {
12102
+ for (const hit of hits) {
11065
12103
  while (spanIndex < spans.length && spans[spanIndex].start <= hit.start) {
11066
12104
  coveredUntil = Math.max(coveredUntil, spans[spanIndex].end);
11067
12105
  spanIndex++;
11068
12106
  }
11069
- return hit.kind === 'lndconnect' || hit.start >= coveredUntil;
11070
- });
12107
+ const at = column(hit.start);
12108
+ // A match cut by its window's end may continue past it: report it, never decode a part.
12109
+ if (hit.truncated) {
12110
+ lineFindings.push({ hit, finding: nullFinding(path, line, at, 'undecodable-candidate', fingerprint(hit.text)) });
12111
+ continue;
12112
+ }
12113
+ if (hit.kind === 'lndconnect' || hit.start < coveredUntil)
12114
+ continue;
12115
+ if (hit.kind === 'nwc') {
12116
+ // The matcher ends every match with secret=<64 hex> and at most one delimiter. Any
12117
+ // match is reported (fail closed): "&amp;secret=" and other separators included.
12118
+ const secret = /secret=([0-9a-f]{64})[^0-9a-f]?$/i.exec(hit.text)[1].toLowerCase();
12119
+ ctx.secrets.add(secret);
12120
+ lineFindings.push({ hit, finding: nullFinding(path, line, at, 'nwc', fingerprint(Buffer.from(secret, 'hex'))) });
12121
+ continue;
12122
+ }
12123
+ const bytes = credentialBytes(hit);
12124
+ const facts = bytes && decoded(bytes);
12125
+ lineFindings.push({ hit, finding: facts ? withFacts(path, line, at, hit.kind, facts, now, ctx)
12126
+ : nullFinding(path, line, at, 'undecodable-candidate', fingerprint(hit.text)) });
12127
+ }
12128
+ lineFindings.sort((a, b) => byStartThenDetector(a.hit, b.hit));
12129
+ for (const { finding } of lineFindings)
12130
+ findings.push(finding);
12131
+ return { findings, dropped };
12132
+ }
12133
+ /**
12134
+ * RR-02: credentials stored as the base64 of their text (Kubernetes Secret data, Helm values).
12135
+ * Each base64 run of the line holding an encoded prefix is decoded once at each of the four
12136
+ * character phases, and the decoded text is scanned as a line, one level deep: decoded text
12137
+ * is never decoded again. A finding is reported at the column of its first encoded character.
12138
+ */
12139
+ function scanEncoded(path, line, text, now, ctx) {
12140
+ const findings = [];
12141
+ const reported = new Set();
12142
+ let dropped = 0;
12143
+ const runs = /[A-Za-z0-9+/]{16,}={0,2}/g;
12144
+ // Columns count code points; base64 is ASCII, so advance a code point counter once.
12145
+ let unit = 0;
12146
+ let point = 0;
12147
+ for (let run = runs.exec(text); run; run = runs.exec(text)) {
12148
+ while (unit < run.index) {
12149
+ unit += text.codePointAt(unit) > 0xffff ? 2 : 1;
12150
+ point++;
12151
+ }
12152
+ if (!ENCODED_CANDIDATE.test(run[0]))
12153
+ continue;
12154
+ const runStart = point;
12155
+ for (let phase = 0; phase < 4 && phase < run[0].length; phase++) {
12156
+ const usable = run[0].slice(phase).replace(/=+$/, '');
12157
+ const decodedText = Buffer.from(usable.slice(0, usable.length - (usable.length % 4 === 1 ? 1 : 0)), 'base64').toString('latin1');
12158
+ if (!PLAIN_MAY_MATCH.test(decodedText))
12159
+ continue;
12160
+ // Decoded byte b starts in encoded character floor(b * 4 / 3) of this phase.
12161
+ const result = scanLine(path, line, decodedText, now, ctx, start => runStart + phase + Math.floor(start * 4 / 3) + 1);
12162
+ dropped += result.dropped;
12163
+ for (const finding of result.findings) {
12164
+ const key = `${finding.column}:${finding.kind}:${finding.fingerprint}`;
12165
+ if (!reported.has(key)) {
12166
+ reported.add(key);
12167
+ findings.push(finding);
12168
+ }
12169
+ }
12170
+ }
12171
+ }
12172
+ return { findings, dropped };
12173
+ }
12174
+ /**
12175
+ * RR-10: a credential wrapped narrower than 49 columns. When line `index` ends with a prefix
12176
+ * too short for the published patterns, the following lines that hold only the same alphabet
12177
+ * (indentation and trailing whitespace aside, at most CANDIDATE_LIMIT characters in all) are
12178
+ * joined to it; the credential is reported only when the joined text decodes. Text after the
12179
+ * credential is ignored, as the decoder ignores trailing bytes.
12180
+ */
12181
+ function narrowWrap(path, lines, index, found, now, ctx) {
12182
+ let last = index;
12183
+ for (const { kind, start, more } of NARROW_WRAP) {
12184
+ const tail = start.exec(lines[index]);
12185
+ if (!tail)
12186
+ continue;
12187
+ const column = Array.from(lines[index].slice(0, tail.index)).length + 1;
12188
+ if (found.some(finding => finding.column === column))
12189
+ continue;
12190
+ let joined = tail[0].trimEnd();
12191
+ for (let next = index + 1; next < lines.length && joined.length < exports.CANDIDATE_LIMIT; next++) {
12192
+ const part = lines[next].trim();
12193
+ if (!more.test(part))
12194
+ break;
12195
+ joined += part;
12196
+ last = Math.max(last, next);
12197
+ if (part.endsWith('='))
12198
+ break;
12199
+ }
12200
+ const bytes = kind === 'macaroon-hex' ? Buffer.from(joined.slice(0, joined.length - joined.length % 2), 'hex')
12201
+ : Buffer.from(joined.slice(0, exports.CANDIDATE_LIMIT), joined.includes('-') || joined.includes('_') ? 'base64url' : 'base64');
12202
+ const facts = decoded(bytes);
12203
+ if (facts)
12204
+ return { finding: withFacts(path, index + 1, column, kind, facts, now, ctx), last };
12205
+ }
12206
+ return { finding: null, last };
11071
12207
  }
11072
12208
  function scanText(path, text, now, ctx) {
11073
12209
  const findings = [];
@@ -11075,54 +12211,24 @@ function scanText(path, text, now, ctx) {
11075
12211
  if (!MAY_MATCH.test(text))
11076
12212
  return { findings, dropped };
11077
12213
  const lines = text.split(/\r\n|\n|\r/);
12214
+ let joinedUntil = -1;
11078
12215
  for (let i = 0; i < lines.length; i++) {
11079
12216
  if (!MAY_MATCH.test(lines[i]))
11080
12217
  continue;
11081
- const points = Array.from(lines[i]);
11082
- const hits = collectHits(points);
11083
- // NWC URIs in common escaped forms (JSON-escaped slashes, percent-encoding, "+" as a
11084
- // space) are matched on one normalised copy of the line; a URI the line already matched
11085
- // at the same column is not reported twice.
11086
- const unescaped = (0, nwc_match_js_1.nwcUnescaped)(points, lines[i]);
11087
- if (unescaped) {
11088
- const seen = new Set(hits.filter(hit => hit.kind === 'nwc').map(hit => hit.start));
11089
- for (const hit of collectHits(unescaped.points, 'nwc')) {
11090
- const start = unescaped.origin[hit.start];
11091
- if (!seen.has(start)) {
11092
- seen.add(start);
11093
- hits.push({ ...hit, start });
11094
- }
11095
- }
11096
- hits.sort((a, b) => a.start - b.start);
11097
- }
11098
- for (const hit of hits) {
11099
- const column = hit.start + 1;
11100
- const line = i + 1;
11101
- if (hit.kind === 'nwc') {
11102
- // The matcher ends every match with secret=<64 hex> and at most one delimiter. Any
11103
- // match is reported (fail closed): "&amp;secret=" and other separators included.
11104
- const secret = /secret=([0-9a-f]{64})[^0-9a-f]?$/i.exec(hit.text)[1].toLowerCase();
11105
- ctx.secrets.add(secret);
11106
- findings.push(nullFinding(path, line, column, 'nwc', fingerprint(Buffer.from(secret, 'hex'))));
11107
- continue;
11108
- }
11109
- const bytes = credentialBytes(hit);
11110
- if (bytes) {
11111
- try {
11112
- const facts = (0, index_js_1.decodeMacaroon)(new secret_js_1.Secret(bytes));
11113
- findings.push(withFacts(path, line, column, hit.kind, facts, now, ctx));
11114
- continue;
11115
- }
11116
- catch (error) {
11117
- if (!(error instanceof index_js_1.DecodeError))
11118
- throw error;
11119
- }
11120
- }
11121
- if (hit.kind === 'lndconnect')
11122
- dropped++;
11123
- else
11124
- findings.push(nullFinding(path, line, column, 'undecodable-candidate', fingerprint(hit.text)));
11125
- }
12218
+ const line = scanLine(path, i + 1, lines[i], now, ctx, start => start + 1);
12219
+ // Each line is joined into at most one wrap, so joining stays linear in the text.
12220
+ if (i > joinedUntil) {
12221
+ const wrapped = narrowWrap(path, lines, i, line.findings, now, ctx);
12222
+ joinedUntil = wrapped.last;
12223
+ if (wrapped.finding)
12224
+ line.findings.push(wrapped.finding);
12225
+ }
12226
+ const encoded = ENCODED_CANDIDATE.test(lines[i]) ? scanEncoded(path, i + 1, lines[i], now, ctx) : null;
12227
+ const lineFindings = encoded?.findings.length ? [...line.findings, ...encoded.findings].sort((a, b) => a.column - b.column)
12228
+ : line.findings;
12229
+ for (const finding of lineFindings)
12230
+ findings.push(finding);
12231
+ dropped += line.dropped + (encoded?.dropped ?? 0);
11126
12232
  }
11127
12233
  return { findings, dropped };
11128
12234
  }
@@ -11144,8 +12250,16 @@ function reportedPath(path) {
11144
12250
  const root = (0, node_path_1.parse)(path).root;
11145
12251
  const trailingSeparators = node_path_1.sep === '\\' ? /[\\/]+$/ : /\/+$/;
11146
12252
  const trimmed = path.length > root.length ? path.replace(trailingSeparators, '') : path;
11147
- return trimmed.replace(/\\/g, '/');
12253
+ // Only Windows uses '\' as a separator; elsewhere it is an ordinary file name character.
12254
+ return node_path_1.sep === '\\' ? trimmed.replace(/\\/g, '/') : trimmed;
11148
12255
  }
12256
+ // Windows and macOS file systems compare names case-insensitively by default, so '.GIT' there
12257
+ // is the '.git' directory. Elsewhere names are case-sensitive and '.GIT' is an ordinary directory.
12258
+ const CASE_INSENSITIVE_NAMES = process.platform === 'win32' || process.platform === 'darwin';
12259
+ function namedAs(name, names) {
12260
+ return names.includes(CASE_INSENSITIVE_NAMES ? name.toLowerCase() : name);
12261
+ }
12262
+ const EXCLUDED_DIRS = ['.git', 'node_modules'];
11149
12263
  function isSystemDirectoryPath(path) {
11150
12264
  if (!path.startsWith('/'))
11151
12265
  return false;
@@ -11171,6 +12285,7 @@ class FindingsLimitError extends Error {
11171
12285
  }
11172
12286
  }
11173
12287
  exports.FindingsLimitError = FindingsLimitError;
12288
+ const NOT_SCANNABLE = 'scan path is not a regular file or directory';
11174
12289
  /** At most this many files are open and read at once during a directory walk. */
11175
12290
  exports.SCAN_CONCURRENCY = 16;
11176
12291
  /** Bound on dispatched but not yet committed file results (memory bound). */
@@ -11247,13 +12362,54 @@ function tagMcpContext(path, text, findings) {
11247
12362
  finding.context = 'mcp-config';
11248
12363
  }
11249
12364
  }
12365
+ /**
12366
+ * Opens a file for scanning: read-only, no-follow where the platform has it, and non-blocking,
12367
+ * so a FIFO swapped in after the walk's lstat cannot block open(2) waiting for a writer; the
12368
+ * caller then fstats the descriptor and reads only a regular file.
12369
+ */
12370
+ exports.SCAN_OPEN_FLAGS = node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NONBLOCK ?? 0) | (node_fs_1.constants.O_NOFOLLOW ?? 0);
12371
+ /**
12372
+ * The scan's file system with every operation raced against the interrupt (SIGINT, SIGTERM,
12373
+ * SIGHUP): an operation still pending when the scan is interrupted rejects with Cancelled at
12374
+ * once, so a slow or stuck file system call cannot hold the scan. A handle opened after the
12375
+ * interrupt is closed when its open completes; a close never waits for a stuck read.
12376
+ */
12377
+ function cancellableFs(raw) {
12378
+ const wrap = (operation) => (...args) => (0, cancellation_js_1.cancellable)(() => operation(...args));
12379
+ const handle = (opened) => {
12380
+ const close = () => { const closing = opened.close(); closing.catch(() => { }); return (0, cancellation_js_1.cancellable)(() => closing); };
12381
+ // The scan uses only these three operations of a handle.
12382
+ return { stat: wrap(opened.stat.bind(opened)), read: wrap(opened.read.bind(opened)),
12383
+ close };
12384
+ };
12385
+ const open = async (...args) => {
12386
+ (0, cancellation_js_1.checkCancellation)();
12387
+ const pending = raw.open(...args);
12388
+ try {
12389
+ return handle(await (0, cancellation_js_1.cancellable)(() => pending));
12390
+ }
12391
+ catch (error) {
12392
+ if (error instanceof cancellation_js_1.Cancelled)
12393
+ pending.then(late => late.close()).catch(() => { });
12394
+ throw error;
12395
+ }
12396
+ };
12397
+ return { realpath: wrap(raw.realpath.bind(raw)),
12398
+ lstat: wrap(raw.lstat.bind(raw)),
12399
+ stat: wrap(raw.stat.bind(raw)),
12400
+ readdir: wrap(raw.readdir.bind(raw)),
12401
+ open };
12402
+ }
11250
12403
  async function scan(paths, opts = {}) {
11251
- const io = opts.fs ?? fs;
12404
+ const io = cancellableFs(opts.fs ?? fs);
11252
12405
  const now = opts.clock?.() ?? new Date();
11253
12406
  const findings = [];
11254
12407
  const skipped = [];
11255
12408
  const skippedPaths = new Set();
11256
12409
  const scannedFiles = new Set();
12410
+ // Files with findings by identity, with the index of the path argument that scanned them.
12411
+ const owners = new Map();
12412
+ let currentArg = -1;
11257
12413
  const secrets = new Set();
11258
12414
  const ctx = { secrets, facts: new Map() };
11259
12415
  let filesScanned = 0;
@@ -11270,7 +12426,7 @@ async function scan(paths, opts = {}) {
11270
12426
  const target = await io.realpath(path);
11271
12427
  if ((0, protected_path_js_1.isProtectedPath)(target) || isSystemDirectoryPath(target))
11272
12428
  throw new index_js_3.InputError('input-read', 'cannot read ignore file');
11273
- const handle = await io.open(target, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW ?? 0));
12429
+ const handle = await io.open(target, exports.SCAN_OPEN_FLAGS);
11274
12430
  let content;
11275
12431
  try {
11276
12432
  const openedStat = await handle.stat({ bigint: true });
@@ -11284,7 +12440,9 @@ async function scan(paths, opts = {}) {
11284
12440
  const bytes = await (0, bounded_file_js_1.readBounded)(handle, TEXT_LIMIT, openedStat.size);
11285
12441
  if (bytes.length > TEXT_LIMIT)
11286
12442
  throw new index_js_3.InputError('input-read', 'cannot read ignore file');
11287
- content = bytes.toString('utf8');
12443
+ // Same encodings as scanned text: UTF-8 with or without BOM, UTF-16 LE or BE with a BOM
12444
+ // (what Windows PowerShell 5.1 writes with '>' and Out-File).
12445
+ content = decodeText(bytes);
11288
12446
  }
11289
12447
  finally {
11290
12448
  await handle.close();
@@ -11294,7 +12452,7 @@ async function scan(paths, opts = {}) {
11294
12452
  catch (error) {
11295
12453
  if (optional && error.code === 'ENOENT')
11296
12454
  return [];
11297
- if (error instanceof index_js_3.InputError || error instanceof ignore_js_1.IgnoreFileError)
12455
+ if (error instanceof index_js_3.InputError || error instanceof ignore_js_1.IgnoreFileError || error instanceof cancellation_js_1.Cancelled)
11298
12456
  throw error;
11299
12457
  throw new index_js_3.InputError('input-read', 'cannot read ignore file');
11300
12458
  }
@@ -11307,31 +12465,63 @@ async function scan(paths, opts = {}) {
11307
12465
  skipped.push({ path: safePath, reason });
11308
12466
  }
11309
12467
  };
11310
- async function scanFileTask(path, sourceStat, explicit) {
12468
+ // True when `real` still names the directory the walk listed. lstat resolves every component
12469
+ // of the path, so a component replaced by a symlink (whose own identity differs) or by any
12470
+ // other directory since then shows up as a different identity. One lstat costs O(path
12471
+ // length), never a resolution per component.
12472
+ async function unchanged(parent) {
12473
+ return sameFileIdentity(await io.lstat(parent.real, { bigint: true }), parent.stat);
12474
+ }
12475
+ /**
12476
+ * Scans one file. An explicit argument is resolved with realpath. A walked file is not: its
12477
+ * resolved path is its parent's resolved path joined with its name, because the walk never
12478
+ * follows a symlink. Instead its own lstat, and its parent's identity, are checked again just
12479
+ * before and after the open; only an entry that changed since the walk's lstat is resolved
12480
+ * (realpath), to tell a swapped-in link to a protected file (never-open) from other changes.
12481
+ */
12482
+ async function scanFileTask(path, sourceStat, explicit, parent) {
11311
12483
  let identity;
11312
12484
  try {
11313
- const target = await io.realpath(path);
11314
- if ((0, protected_path_js_1.isProtectedPath)(target))
11315
- return { type: 'skip', path, reason: 'never-open' };
12485
+ let target;
12486
+ if (parent === undefined) {
12487
+ target = await io.realpath(path);
12488
+ if ((0, protected_path_js_1.isProtectedPath)(target))
12489
+ return { type: 'skip', path, reason: 'never-open' };
12490
+ }
12491
+ else {
12492
+ target = (0, node_path_1.join)(parent.real, (0, node_path_1.basename)(path));
12493
+ const current = await io.lstat(target, { bigint: true });
12494
+ if (current.isSymbolicLink() || !current.isFile()) {
12495
+ if ((0, protected_path_js_1.isProtectedPath)(await io.realpath(target)))
12496
+ return { type: 'skip', path, reason: 'never-open' };
12497
+ return { type: 'skip', path, reason: current.isSymbolicLink() ? 'symlink' : 'not-regular' };
12498
+ }
12499
+ if (!await unchanged(parent))
12500
+ throw new index_js_3.InputError('input-read');
12501
+ }
11316
12502
  const macaroonFile = /\.macaroon$/i.test((0, node_path_1.basename)(path));
11317
12503
  if (!macaroonFile && BigInt(sourceStat.size) > BigInt(TEXT_LIMIT))
11318
12504
  return { type: 'skip', path, reason: 'size' };
11319
12505
  let handle;
11320
12506
  let bytes;
11321
12507
  try {
11322
- handle = await io.open(target, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW ?? 0));
12508
+ handle = await io.open(target, exports.SCAN_OPEN_FLAGS);
11323
12509
  const openedStat = await handle.stat({ bigint: true });
11324
- if (!openedStat.isFile())
12510
+ if (!openedStat.isFile()) {
12511
+ if (explicit)
12512
+ return { type: 'error', error: new index_js_3.InputError('input-read', NOT_SCANNABLE) };
11325
12513
  return { type: 'skip', path, reason: 'not-regular' };
11326
- // Check the resolved path again after open, including platforms without O_NOFOLLOW.
11327
- const openedPath = await io.realpath(target);
12514
+ }
12515
+ // Check the path again after open, including platforms without O_NOFOLLOW: it must
12516
+ // still be the regular file that was opened, under the same resolved path.
11328
12517
  const openedPathStat = await io.lstat(target, { bigint: true });
11329
- if ((0, protected_path_js_1.isProtectedPath)(openedPath) || openedPath !== target || !openedPathStat.isFile() ||
12518
+ const samePath = parent === undefined ? await io.realpath(target).then(openedPath => !(0, protected_path_js_1.isProtectedPath)(openedPath) && openedPath === target) : await unchanged(parent);
12519
+ if (!samePath || !openedPathStat.isFile() ||
11330
12520
  openedPathStat.isSymbolicLink() || !sameFileIdentity(openedPathStat, openedStat))
11331
12521
  throw new index_js_3.InputError('input-read');
11332
12522
  identity = fileIdentity(openedStat, target);
11333
12523
  if (scannedFiles.has(identity))
11334
- return { type: 'dup' };
12524
+ return { type: 'dup', path, identity };
11335
12525
  bytes = await (0, bounded_file_js_1.readBounded)(handle, macaroonFile ? MACAROON_LIMIT : TEXT_LIMIT, openedStat.size);
11336
12526
  }
11337
12527
  finally {
@@ -11346,9 +12536,11 @@ async function scan(paths, opts = {}) {
11346
12536
  for (const finding of outcome.findings)
11347
12537
  finding.path = safePath;
11348
12538
  }
11349
- return { type: 'file', identity, findings: outcome.findings, dropped: outcome.dropped };
12539
+ return { type: 'file', path, identity, findings: outcome.findings, dropped: outcome.dropped };
11350
12540
  }
11351
- catch {
12541
+ catch (error) {
12542
+ if (error instanceof cancellation_js_1.Cancelled)
12543
+ throw error;
11352
12544
  if (explicit)
11353
12545
  return { type: 'error', error: new index_js_3.InputError('input-read', 'cannot read scan path') };
11354
12546
  return { type: 'skip', path, reason: 'unreadable', identity };
@@ -11359,15 +12551,34 @@ async function scan(paths, opts = {}) {
11359
12551
  throw new FindingsLimitError();
11360
12552
  findings.push(finding);
11361
12553
  }
12554
+ // Another name of a file already scanned under the same path argument (a hard link) is
12555
+ // added to the alsoAt of that file's findings, so every path holding the credential is
12556
+ // reported. Names reached through a different argument stay silent (SPEC: several arguments).
12557
+ function alias(identity, path) {
12558
+ const owner = owners.get(identity);
12559
+ if (!owner || owner.arg !== currentArg)
12560
+ return;
12561
+ const safePath = (0, redact_js_1.redactPath)(reportedPath(path));
12562
+ for (const finding of owner.findings) {
12563
+ const also = finding.alsoAt ??= { count: 0, paths: [] };
12564
+ also.count++;
12565
+ if (also.paths.length < exports.ALSO_AT_EXAMPLES)
12566
+ also.paths.push(safePath);
12567
+ }
12568
+ }
11362
12569
  // Applies one event. A file whose identity was already committed is a
11363
12570
  // duplicate, exactly as the sequential walk's check before reading.
11364
12571
  function commit(event) {
11365
12572
  if (event.type === 'error')
11366
12573
  throw event.error;
11367
- if (event.type === 'dup')
12574
+ if (event.type === 'dup') {
12575
+ alias(event.identity, event.path);
11368
12576
  return;
11369
- if (event.identity !== undefined && scannedFiles.has(event.identity))
12577
+ }
12578
+ if (event.identity !== undefined && scannedFiles.has(event.identity)) {
12579
+ alias(event.identity, event.path);
11370
12580
  return;
12581
+ }
11371
12582
  if (event.type === 'skip') {
11372
12583
  skip(event.path, event.reason);
11373
12584
  return;
@@ -11377,6 +12588,8 @@ async function scan(paths, opts = {}) {
11377
12588
  dropped += event.dropped;
11378
12589
  filesScanned++;
11379
12590
  scannedFiles.add(event.identity);
12591
+ if (event.findings.length)
12592
+ owners.set(event.identity, { arg: currentArg, findings: event.findings });
11380
12593
  }
11381
12594
  // Ordered, bounded dispatch: at most SCAN_CONCURRENCY file tasks run, and at
11382
12595
  // most SCAN_QUEUE results wait for earlier ones before being committed.
@@ -11405,12 +12618,12 @@ async function scan(paths, opts = {}) {
11405
12618
  ready.set(dispatched++, event);
11406
12619
  drainReady();
11407
12620
  }
11408
- async function dispatch(path, stat) {
12621
+ async function dispatch(path, stat, parent) {
11409
12622
  (0, cancellation_js_1.checkCancellation)();
11410
12623
  await waitFor(() => running < exports.SCAN_CONCURRENCY && dispatched - committed < SCAN_QUEUE);
11411
12624
  const sequence = dispatched++;
11412
12625
  running++;
11413
- void scanFileTask(path, stat, false)
12626
+ void scanFileTask(path, stat, false, parent)
11414
12627
  .catch((error) => ({ type: 'error', error }))
11415
12628
  .then(event => { running--; ready.set(sequence, event); signal(); });
11416
12629
  }
@@ -11422,7 +12635,13 @@ async function scan(paths, opts = {}) {
11422
12635
  await settle();
11423
12636
  commit(await scanFileTask(path, sourceStat, explicit));
11424
12637
  }
11425
- async function walk(path, isRoot, rootStat, known) {
12638
+ // An interrupt ends the scan; it is never turned into an unreadable skip or exit 3.
12639
+ const rethrowCancelled = (error) => { if (error instanceof cancellation_js_1.Cancelled)
12640
+ throw error; };
12641
+ // `parent` is the walked directory that listed `path` (absent for a path argument). Resolved
12642
+ // paths are carried down the walk instead of calling realpath per entry: realpath resolves
12643
+ // every component, so per entry it made the walk's cost grow with the cube of its depth.
12644
+ async function walk(path, isRoot, rootStat, known, parent, rootReal) {
11426
12645
  (0, cancellation_js_1.checkCancellation)();
11427
12646
  const note = (target, reason) => emit({ type: 'skip', path: target, reason });
11428
12647
  if ((0, protected_path_js_1.isProtectedPath)(path)) {
@@ -11441,7 +12660,8 @@ async function scan(paths, opts = {}) {
11441
12660
  try {
11442
12661
  entry = await io.lstat(path, { bigint: true });
11443
12662
  }
11444
- catch {
12663
+ catch (error) {
12664
+ rethrowCancelled(error);
11445
12665
  if (isRoot)
11446
12666
  throw new index_js_3.InputError('input-read', 'cannot read scan path');
11447
12667
  await note(path, 'unreadable');
@@ -11455,14 +12675,23 @@ async function scan(paths, opts = {}) {
11455
12675
  const stat = rootStat ?? entry;
11456
12676
  if (stat.isDirectory()) {
11457
12677
  let target;
11458
- try {
11459
- target = await io.realpath(path);
12678
+ if (rootReal !== undefined)
12679
+ target = rootReal;
12680
+ else if (parent === undefined) {
12681
+ try {
12682
+ target = await io.realpath(path);
12683
+ }
12684
+ catch (error) {
12685
+ rethrowCancelled(error);
12686
+ if (isRoot)
12687
+ throw new index_js_3.InputError('input-read', 'cannot read scan path');
12688
+ await note(path, 'unreadable');
12689
+ return;
12690
+ }
11460
12691
  }
11461
- catch {
11462
- if (isRoot)
11463
- throw new index_js_3.InputError('input-read', 'cannot read scan path');
11464
- await note(path, 'unreadable');
11465
- return;
12692
+ else {
12693
+ // Not a symlink (refused above), so it resolves to its parent's resolved path plus its name.
12694
+ target = (0, node_path_1.join)(parent.real, (0, node_path_1.basename)(path));
11466
12695
  }
11467
12696
  if ((0, protected_path_js_1.isProtectedPath)(target)) {
11468
12697
  await note(path, 'never-open');
@@ -11472,8 +12701,7 @@ async function scan(paths, opts = {}) {
11472
12701
  await note(path, 'excluded-dir');
11473
12702
  return;
11474
12703
  }
11475
- const name = (0, node_path_1.basename)(path).toLowerCase();
11476
- if (!opts.includeDirs && (name === '.git' || name === 'node_modules')) {
12704
+ if (!opts.includeDirs && namedAs((0, node_path_1.basename)(path), EXCLUDED_DIRS)) {
11477
12705
  await note(path, 'excluded-dir');
11478
12706
  return;
11479
12707
  }
@@ -11481,43 +12709,62 @@ async function scan(paths, opts = {}) {
11481
12709
  try {
11482
12710
  names = await io.readdir(path);
11483
12711
  }
11484
- catch {
12712
+ catch (error) {
12713
+ rethrowCancelled(error);
11485
12714
  if (isRoot)
11486
12715
  throw new index_js_3.InputError('input-read', 'cannot read scan path');
11487
12716
  await note(path, 'unreadable');
11488
12717
  return;
11489
12718
  }
11490
12719
  const rootListing = isRoot && reportedPath(path) === '/';
12720
+ const self = { real: target, stat };
11491
12721
  // Stat children in bounded batches, then visit them in listing order.
11492
12722
  // Protected and excluded names are never stat'ed, as before.
11493
12723
  for (let offset = 0; offset < names.length; offset += exports.SCAN_CONCURRENCY) {
11494
12724
  const batch = names.slice(offset, offset + exports.SCAN_CONCURRENCY).map(child => (0, node_path_1.join)(path, child));
11495
12725
  const entries = await Promise.all(batch.map(async (child) => {
11496
- if (rootListing && ['proc', 'sys', 'dev'].includes((0, node_path_1.basename)(child).toLowerCase()))
12726
+ if (rootListing && namedAs((0, node_path_1.basename)(child), ['proc', 'sys', 'dev']))
11497
12727
  return { excluded: true };
11498
12728
  if ((0, protected_path_js_1.isProtectedPath)(child))
11499
12729
  return { protected: true };
11500
12730
  try {
11501
12731
  return { stat: await io.lstat(child, { bigint: true }) };
11502
12732
  }
11503
- catch {
12733
+ catch (error) {
12734
+ rethrowCancelled(error);
11504
12735
  return { error: true };
11505
12736
  }
11506
12737
  }));
12738
+ // The children's stats count only if this directory is still the one listed: a path
12739
+ // component swapped for a symlink would otherwise lead the walk outside the tree.
12740
+ let same;
12741
+ try {
12742
+ same = await unchanged(self);
12743
+ }
12744
+ catch (error) {
12745
+ rethrowCancelled(error);
12746
+ same = false;
12747
+ }
12748
+ if (!same) {
12749
+ if (isRoot)
12750
+ throw new index_js_3.InputError('input-read', 'cannot read scan path');
12751
+ await note(path, 'unreadable');
12752
+ return;
12753
+ }
11507
12754
  for (let i = 0; i < batch.length; i++) {
11508
12755
  const known = entries[i];
11509
12756
  if ('excluded' in known)
11510
12757
  await note(batch[i], 'excluded-dir');
11511
12758
  else
11512
- await walk(batch[i], false, undefined, known);
12759
+ await walk(batch[i], false, undefined, known, self);
11513
12760
  }
11514
12761
  }
11515
12762
  }
11516
12763
  else if (stat.isFile()) {
11517
- if (isRoot)
12764
+ if (isRoot || parent === undefined)
11518
12765
  await scanFile(path, stat, true);
11519
12766
  else
11520
- await dispatch(path, stat);
12767
+ await dispatch(path, stat, parent);
11521
12768
  }
11522
12769
  else {
11523
12770
  await note(path, 'not-regular');
@@ -11546,7 +12793,9 @@ async function scan(paths, opts = {}) {
11546
12793
  shallowHistory = true;
11547
12794
  }
11548
12795
  // A credential still present in the work tree is already reported by the walk.
11549
- const workTree = new Set(findings.map(finding => `${finding.path}\0${finding.fingerprint}`));
12796
+ // That includes a work-tree file's other hard-link names (its alsoAt).
12797
+ const workTree = new Set(findings.flatMap(finding => [finding.path, ...(finding.commit === undefined ? finding.alsoAt?.paths ?? [] : [])]
12798
+ .map(path => `${path}\0${finding.fingerprint}`)));
11550
12799
  const table = await (0, git_history_js_1.historyEntries)(git, directory, repository, pathspec);
11551
12800
  // Per distinct path: CLASSIFIED, MACAROON (*.macaroon), EXCLUDED (protected or in an
11552
12801
  // excluded directory), and whether a skip was already reported for it (skips are
@@ -11565,8 +12814,8 @@ async function scan(paths, opts = {}) {
11565
12814
  pathState[p] |= CLASSIFIED;
11566
12815
  const relativePath = table.paths[p];
11567
12816
  const parents = relativePath.split('/').slice(0, -1);
11568
- // Same comparison as the file walk (case-insensitive), so history and work tree agree.
11569
- const excluded = parents.findIndex(segment => ['.git', 'node_modules'].includes(segment.toLowerCase()));
12817
+ // Same comparison as the file walk, so history and work tree agree.
12818
+ const excluded = parents.findIndex(segment => namedAs(segment, EXCLUDED_DIRS));
11570
12819
  if ((0, protected_path_js_1.isProtectedPath)(joined(p))) {
11571
12820
  skip(joined(p), 'never-open');
11572
12821
  pathState[p] |= EXCLUDED;
@@ -11726,13 +12975,15 @@ async function scan(paths, opts = {}) {
11726
12975
  }
11727
12976
  }
11728
12977
  for (const arg of paths) {
12978
+ currentArg++;
11729
12979
  if ((0, protected_path_js_1.isProtectedPath)(arg)) {
11730
12980
  skip(arg, 'never-open');
11731
12981
  continue;
11732
12982
  }
11733
12983
  let stat;
12984
+ let target;
11734
12985
  try {
11735
- const target = await io.realpath(arg);
12986
+ target = await io.realpath(arg);
11736
12987
  if ((0, protected_path_js_1.isProtectedPath)(target)) {
11737
12988
  skip(arg, 'never-open');
11738
12989
  continue;
@@ -11743,12 +12994,19 @@ async function scan(paths, opts = {}) {
11743
12994
  }
11744
12995
  stat = await io.stat(arg, { bigint: true });
11745
12996
  }
11746
- catch {
12997
+ catch (error) {
12998
+ if (error instanceof cancellation_js_1.Cancelled)
12999
+ throw error;
11747
13000
  throw new index_js_3.InputError('input-read', 'cannot read scan path');
11748
13001
  }
13002
+ // A named FIFO, device or socket cannot be scanned. Skipping it would let a CI gate pass
13003
+ // having scanned nothing, so it fails like an unreadable path.
13004
+ if (!stat.isFile() && !stat.isDirectory())
13005
+ throw new index_js_3.InputError('input-read', NOT_SCANNABLE);
11749
13006
  const entries = explicitIgnore ?? (stat.isDirectory() ? await readIgnoreFile((0, node_path_1.join)(arg, '.macwardenignore'), true) : []);
11750
13007
  const firstFinding = findings.length;
11751
- await walk(arg, true, stat);
13008
+ // The argument was resolved above; the walk starts from that resolved path.
13009
+ await walk(arg, true, stat, undefined, undefined, target);
11752
13010
  await settle();
11753
13011
  if (opts.gitHistory)
11754
13012
  await scanHistory(arg, stat);
@@ -11770,7 +13028,7 @@ async function scan(paths, opts = {}) {
11770
13028
  let drop = false;
11771
13029
  if (candidates) {
11772
13030
  const ignoredAt = (path) => {
11773
- const matchPath = explicitIgnore === null ? (0, node_path_1.relative)(arg, path).replace(/\\/g, '/') : path;
13031
+ const matchPath = explicitIgnore === null ? (0, node_path_1.relative)(arg, path).split(node_path_1.sep).join('/') : path;
11774
13032
  const key = `${finding.fingerprint}\0${matchPath}`;
11775
13033
  const known = decided.get(key);
11776
13034
  const result = known ?? candidates.some(entry => entry.matches(matchPath));
@@ -12045,7 +13303,12 @@ const PREFIX = /(?:nostr\+walletconnect|nostrwalletconnect):(?:\/\/)?[0-9a-f]{64
12045
13303
  const BOUNDARY = /[\s"'<>]/;
12046
13304
  const HEX = /[0-9a-f]/i;
12047
13305
  const SECRET = 'secret=';
12048
- function nwcMatches(text) {
13306
+ /**
13307
+ * `open`, when given and empty, receives the index of the first prefix (outside an earlier
13308
+ * match) whose middle runs to the end of `text` without a boundary or a valid secret: in a
13309
+ * window cut from a longer line, such a URI may continue past the window end.
13310
+ */
13311
+ function nwcMatches(text, open) {
12049
13312
  const length = text.length;
12050
13313
  // nextValid[i]: smallest p >= i where "secret=" + 64 hex + (non-hex | end) starts.
12051
13314
  // nextBoundary[i]: smallest p >= i holding a boundary character. Both scanned right to left.
@@ -12074,6 +13337,9 @@ function nwcMatches(text) {
12074
13337
  const middle = head.index + head[0].length;
12075
13338
  const secretAt = nextValid[middle];
12076
13339
  const boundaryAt = nextBoundary[middle];
13340
+ // Every later prefix lies in the same open run, so only the first is reported.
13341
+ if (secretAt === -1 && boundaryAt === -1 && open?.length === 0)
13342
+ open.push(head.index);
12077
13343
  if (secretAt === -1 || (boundaryAt !== -1 && boundaryAt < secretAt))
12078
13344
  continue;
12079
13345
  const afterHex = secretAt + SECRET.length + 64;
@@ -13081,6 +14347,8 @@ const nodeFs = __importStar(require("node:fs/promises"));
13081
14347
  const node_crypto_1 = require("node:crypto");
13082
14348
  const node_path_1 = require("node:path");
13083
14349
  const bounded_file_js_1 = require("../bounded-file.js");
14350
+ const instant_js_1 = require("../instant.js");
14351
+ const root_key_id_js_1 = require("../root-key-id.js");
13084
14352
  // Callers choose how many distinct (credential, method) pairs exist, so the store is
13085
14353
  // bounded. At capacity the least recently seen record is evicted (O(1): the Map keeps
13086
14354
  // records in recency order), and the eviction is remembered so `recommend` can tell
@@ -13113,9 +14381,12 @@ class UsageFileError extends Error {
13113
14381
  }
13114
14382
  exports.UsageFileError = UsageFileError;
13115
14383
  const fields = ['nodePubkey', 'credential', 'rootKeyId', 'method', 'calls', 'errors', 'firstSeen', 'lastSeen'];
13116
- const time = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/;
14384
+ // Timestamps must be real instants (isInstant) and each pair in order, compared as instants:
14385
+ // evidence with an impossible or reversed time is refused, never used for time arithmetic.
14386
+ const ordered = (first, last) => (0, instant_js_1.isInstant)(first) && (0, instant_js_1.isInstant)(last) && Date.parse(first) <= Date.parse(last);
14387
+ /** The later of two instants (a clock stepping back never moves a "last" time before its "first"). */
14388
+ const later = (current, now) => Date.parse(now) >= Date.parse(current) ? now : current;
13117
14389
  const NODE = /^0[23][0-9a-f]{64}$/;
13118
- const ROOT = /^(0|[1-9][0-9]{0,19})$/;
13119
14390
  const exactKeys = (record, names) => {
13120
14391
  const keys = Object.keys(record);
13121
14392
  return keys.length === names.length && keys.every(key => names.includes(key));
@@ -13125,15 +14396,13 @@ const object = (value) => !!value && typeof value === 'object' && !Array.isArray
13125
14396
  function validCanaryUse(value) {
13126
14397
  return object(value) && exactKeys(value, ['nodePubkey', 'rootKeyId', 'uses', 'firstUse', 'lastUse'])
13127
14398
  && typeof value.nodePubkey === 'string' && NODE.test(value.nodePubkey)
13128
- && typeof value.rootKeyId === 'string' && ROOT.test(value.rootKeyId)
13129
- && count(value.uses) && typeof value.firstUse === 'string' && time.test(value.firstUse)
13130
- && typeof value.lastUse === 'string' && time.test(value.lastUse);
14399
+ && (0, root_key_id_js_1.isRootKeyId)(value.rootKeyId)
14400
+ && count(value.uses) && ordered(value.firstUse, value.lastUse);
13131
14401
  }
13132
14402
  function validCoverage(value) {
13133
14403
  return object(value) && exactKeys(value, ['nodePubkey', 'firstStart', 'lastEnd', 'coveredSeconds', 'sessions'])
13134
14404
  && typeof value.nodePubkey === 'string' && NODE.test(value.nodePubkey)
13135
- && typeof value.firstStart === 'string' && time.test(value.firstStart)
13136
- && typeof value.lastEnd === 'string' && time.test(value.lastEnd)
14405
+ && ordered(value.firstStart, value.lastEnd)
13137
14406
  && count(value.coveredSeconds) && count(value.sessions);
13138
14407
  }
13139
14408
  function validSpans(value) {
@@ -13141,7 +14410,7 @@ function validSpans(value) {
13141
14410
  && typeof value.nodePubkey === 'string' && NODE.test(value.nodePubkey)
13142
14411
  && Array.isArray(value.spans) && value.spans.length <= exports.SPAN_LIMIT
13143
14412
  && value.spans.every(span => Array.isArray(span) && span.length === 2
13144
- && span.every(at => typeof at === 'string' && time.test(at)) && Date.parse(span[0]) <= Date.parse(span[1]));
14413
+ && ordered(span[0], span[1]));
13145
14414
  }
13146
14415
  /** The union of spans: overlapping or touching ones merged, so no time is counted twice. */
13147
14416
  function union(spans) {
@@ -13163,12 +14432,11 @@ function validRecord(value) {
13163
14432
  return keys.length === fields.length && keys.every(key => fields.includes(key))
13164
14433
  && typeof record.nodePubkey === 'string' && /^0[23][0-9a-f]{64}$/.test(record.nodePubkey)
13165
14434
  && typeof record.credential === 'string' && /^id:[0-9a-f]{16}$/.test(record.credential)
13166
- && typeof record.rootKeyId === 'string' && /^(0|[1-9][0-9]{0,19})$/.test(record.rootKeyId)
14435
+ && (0, root_key_id_js_1.isRootKeyId)(record.rootKeyId)
13167
14436
  && typeof record.method === 'string' && /^\/[A-Za-z0-9_.]+\/[A-Za-z0-9_]+$/.test(record.method)
13168
14437
  && Number.isSafeInteger(record.calls) && record.calls >= 0
13169
14438
  && Number.isSafeInteger(record.errors) && record.errors >= 0
13170
- && typeof record.firstSeen === 'string' && time.test(record.firstSeen)
13171
- && typeof record.lastSeen === 'string' && time.test(record.lastSeen);
14439
+ && ordered(record.firstSeen, record.lastSeen);
13172
14440
  }
13173
14441
  class UsageStore {
13174
14442
  limit;
@@ -13201,7 +14469,7 @@ class UsageStore {
13201
14469
  touch() { this.changed = true; this.version += 1; }
13202
14470
  /** Counts denied uses of a registered canary. */
13203
14471
  recordCanary(nodePubkey, rootKeyId, uses, at) {
13204
- if (!NODE.test(nodePubkey) || !ROOT.test(rootKeyId) || !Number.isSafeInteger(uses) || uses < 1)
14472
+ if (!NODE.test(nodePubkey) || !(0, root_key_id_js_1.isRootKeyId)(rootKeyId) || !Number.isSafeInteger(uses) || uses < 1)
13205
14473
  return;
13206
14474
  const key = `${nodePubkey}\0${rootKeyId}`;
13207
14475
  const now = at.toISOString();
@@ -13210,7 +14478,7 @@ class UsageStore {
13210
14478
  if (!existing && this.canaryUses.size >= exports.CANARY_LIMIT)
13211
14479
  this.canaryUses.delete(this.canaryUses.keys().next().value);
13212
14480
  this.canaryUses.set(key, existing
13213
- ? { ...existing, uses: Math.min(existing.uses + uses, Number.MAX_SAFE_INTEGER), lastUse: now }
14481
+ ? { ...existing, uses: Math.min(existing.uses + uses, Number.MAX_SAFE_INTEGER), lastUse: later(existing.lastUse, now) }
13214
14482
  : { nodePubkey, rootKeyId, uses, firstUse: now, lastUse: now });
13215
14483
  this.touch();
13216
14484
  }
@@ -13230,7 +14498,7 @@ class UsageStore {
13230
14498
  this.coverages.delete(oldest);
13231
14499
  this.spans.delete(oldest);
13232
14500
  }
13233
- this.coverages.set(nodePubkey, existing ? { ...existing, sessions: existing.sessions + 1, lastEnd: now }
14501
+ this.coverages.set(nodePubkey, existing ? { ...existing, sessions: existing.sessions + 1, lastEnd: later(existing.lastEnd, now) }
13234
14502
  : { nodePubkey, firstStart: now, lastEnd: now, coveredSeconds: 0, sessions: 1 });
13235
14503
  this.covering.set(nodePubkey, at.getTime());
13236
14504
  // A new span, starting no earlier than the last one ended (a clock stepping back never
@@ -13264,7 +14532,7 @@ class UsageStore {
13264
14532
  const span = this.spans.get(nodePubkey)?.at(-1);
13265
14533
  if (span && until > span[1])
13266
14534
  span[1] = until;
13267
- record.lastEnd = at.toISOString();
14535
+ record.lastEnd = later(record.lastEnd, at.toISOString());
13268
14536
  this.touch();
13269
14537
  }
13270
14538
  /** The registration ended (stream lost, run stopped). */
@@ -13301,7 +14569,7 @@ class UsageStore {
13301
14569
  */
13302
14570
  record(nodePubkey, credential, rootKeyId, method, at, error = false) {
13303
14571
  if (!/^0[23][0-9a-f]{64}$/.test(nodePubkey) || !/^id:[0-9a-f]{16}$/.test(credential)
13304
- || !/^(0|[1-9][0-9]{0,19})$/.test(rootKeyId) || BigInt(rootKeyId) > 18446744073709551615n) {
14572
+ || !(0, root_key_id_js_1.isRootKeyId)(rootKeyId)) {
13305
14573
  this.refused += 1;
13306
14574
  return false;
13307
14575
  }
@@ -13314,7 +14582,7 @@ class UsageStore {
13314
14582
  existing.errors += 1;
13315
14583
  else
13316
14584
  existing.calls += 1;
13317
- existing.lastSeen = now;
14585
+ existing.lastSeen = later(existing.lastSeen, now);
13318
14586
  // Move to the end: the first record in the Map is always the least recently seen.
13319
14587
  this.records.delete(key);
13320
14588
  this.records.set(key, existing);
@@ -13388,7 +14656,7 @@ class UsageStore {
13388
14656
  // are required; known later fields are validated when present; unknown fields are
13389
14657
  // ignored, so a newer file stays readable by this version.
13390
14658
  const count = (field) => field === undefined || (Number.isSafeInteger(field) && field >= 0);
13391
- const instant = (field) => field === undefined || field === null || (typeof field === 'string' && time.test(field));
14659
+ const instant = (field) => field === undefined || field === null || (0, instant_js_1.isInstant)(field);
13392
14660
  if (value.schema !== 1 || !Number.isSafeInteger(value.dropped) || value.dropped < 0
13393
14661
  || !count(value.evictions) || !instant(value.lastEviction)
13394
14662
  || !count(value.droppedAttributions) || !instant(value.lastAttributionDrop)
@@ -14018,7 +15286,7 @@ function load(id) { if (id === 'grade/lnd-permissions.json') return snapshot; if
14018
15286
  } return load(parts.join('/')); }); return module.exports; }
14019
15287
  export const api0_readBounded = load("bounded-file.js").readBounded;
14020
15288
  export const api0_readBoundedFile = load("bounded-file.js").readBoundedFile;
14021
- export const api0_assertPlainFile = load("bounded-file.js").assertPlainFile;
15289
+ export const api0_readPlainFile = load("bounded-file.js").readPlainFile;
14022
15290
  export const api0_BoundedFileError = load("bounded-file.js").BoundedFileError;
14023
15291
  export const api0_DEFAULT_FILE_LIMIT = load("bounded-file.js").DEFAULT_FILE_LIMIT;
14024
15292
  export const api0_LOCK_FILE_LIMIT = load("bounded-file.js").LOCK_FILE_LIMIT;
@@ -14040,235 +15308,254 @@ export const api5_attenuate = load("commands/attenuate.js").attenuate;
14040
15308
  export const api6_allocateRootKeyId = load("commands/bake/allocate.js").allocateRootKeyId;
14041
15309
  export const api7_run = load("commands/bake/index.js").run;
14042
15310
  export const api7_bake = load("commands/bake/index.js").bake;
14043
- export const api8_reserveOut = load("commands/bake/write-out.js").reserveOut;
14044
- export const api9_handlers = load("commands/handlers.js").handlers;
14045
- export const api10_inspectResult = load("commands/inspect.js").inspectResult;
14046
- export const api10_run = load("commands/inspect.js").run;
14047
- export const api10_inspect = load("commands/inspect.js").inspect;
14048
- export const api11_run = load("commands/inventory.js").run;
14049
- export const api11_inventory = load("commands/inventory.js").inventory;
14050
- export const api12_narrowLabel = load("commands/recommend.js").narrowLabel;
14051
- export const api12_run = load("commands/recommend.js").run;
14052
- export const api12_recommend = load("commands/recommend.js").recommend;
14053
- export const api12_SHORT_OBSERVATION_SECONDS = load("commands/recommend.js").SHORT_OBSERVATION_SECONDS;
14054
- export const api13_aggregateExit = load("commands/respond.js").aggregateExit;
14055
- export const api13_run = load("commands/respond.js").run;
14056
- export const api13_respond = load("commands/respond.js").respond;
14057
- export const api13_ROOT_KEY_0_GUIDANCE = load("commands/respond.js").ROOT_KEY_0_GUIDANCE;
14058
- export const api13_OWN_ON_ROOT_KEY_0 = load("commands/respond.js").OWN_ON_ROOT_KEY_0;
14059
- export const api13_REVOKED_BUT_PRESENT_GUIDANCE = load("commands/respond.js").REVOKED_BUT_PRESENT_GUIDANCE;
14060
- export const api13_BAKING_RISK_GUIDANCE = load("commands/respond.js").BAKING_RISK_GUIDANCE;
14061
- export const api13_CUSTOM_CAVEAT_NOTE = load("commands/respond.js").CUSTOM_CAVEAT_NOTE;
14062
- export const api13_UNKNOWN_GUIDANCE = load("commands/respond.js").UNKNOWN_GUIDANCE;
14063
- export const api14_run = load("commands/revoke.js").run;
14064
- export const api14_revoke = load("commands/revoke.js").revoke;
14065
- export const api15_run = load("commands/rotate.js").run;
14066
- export const api15_rotate = load("commands/rotate.js").rotate;
14067
- export const api16_run = load("commands/scan.js").run;
14068
- export const api17_run = load("commands/verify.js").run;
14069
- export const api17_verify = load("commands/verify.js").verify;
14070
- export const api17_ROOT_KEY_0_ACCEPTED = load("commands/verify.js").ROOT_KEY_0_ACCEPTED;
14071
- export const api17_CAN_BAKE_ACCEPTED = load("commands/verify.js").CAN_BAKE_ACCEPTED;
14072
- export const api17_CAN_BAKE_ROOT_KEY_0 = load("commands/verify.js").CAN_BAKE_ROOT_KEY_0;
14073
- export const api17_OWN_ROOT_KEY_ACCEPTED = load("commands/verify.js").OWN_ROOT_KEY_ACCEPTED;
14074
- export const api17_REJECTED_INCONCLUSIVE = load("commands/verify.js").REJECTED_INCONCLUSIVE;
14075
- export const api17_CUSTOM_CAVEAT_REJECTED = load("commands/verify.js").CUSTOM_CAVEAT_REJECTED;
14076
- export const api18_scheduleDeadline = load("commands/watch.js").scheduleDeadline;
14077
- export const api18_lockOwnerGone = load("commands/watch.js").lockOwnerGone;
14078
- export const api18_acquireLock = load("commands/watch.js").acquireLock;
14079
- export const api18_run = load("commands/watch.js").run;
14080
- export const api18_stdoutEvents = load("commands/watch.js").stdoutEvents;
14081
- export const api18_watch = load("commands/watch.js").watch;
14082
- export const api18_CANARY_RELOAD_MS = load("commands/watch.js").CANARY_RELOAD_MS;
14083
- export const api18_MAX_TIMER_MS = load("commands/watch.js").MAX_TIMER_MS;
14084
- export const api19_redactEncodedCredentials = load("credential-recognition.js").redactEncodedCredentials;
14085
- export const api20_macaroonEosEnd = load("decode/index.js").macaroonEosEnd;
14086
- export const api20_macaroonPacketRange = load("decode/index.js").macaroonPacketRange;
14087
- export const api20_macaroonSectionRange = load("decode/index.js").macaroonSectionRange;
14088
- export const api20_parseTimeBefore = load("decode/index.js").parseTimeBefore;
14089
- export const api20_canonicalMacaroonForm = load("decode/index.js").canonicalMacaroonForm;
14090
- export const api20_decodeMacaroon = load("decode/index.js").decodeMacaroon;
14091
- export const api20_macaroonId = load("decode/index.js").macaroonId;
14092
- export const api20_encodeMacaroon = load("decode/index.js").encodeMacaroon;
14093
- export const api20_addFirstPartyCaveat = load("decode/index.js").addFirstPartyCaveat;
14094
- export const api20_timeBeforeCondition = load("decode/index.js").timeBeforeCondition;
14095
- export const api20_validMacaroonIdentifier = load("decode/index.js").validMacaroonIdentifier;
14096
- export const api20_isNwcContainer = load("decode/index.js").isNwcContainer;
14097
- export const api20_decodeNwc = load("decode/index.js").decodeNwc;
14098
- export const api20_DecodeError = load("decode/index.js").DecodeError;
14099
- export const api21_macaroonEnds = load("decode/recognition.js").macaroonEnds;
14100
- export const api22_unsafeNodeDiagnostics = load("environment.js").unsafeNodeDiagnostics;
14101
- export const api23_UsageError = load("errors.js").UsageError;
14102
- export const api23_InputError = load("errors.js").InputError;
14103
- export const api23_DecodeError = load("errors.js").DecodeError;
14104
- export const api23_LndError = load("errors.js").LndError;
14105
- export const api23_LndUncertainError = load("errors.js").LndUncertainError;
14106
- export const api24_grade = load("grade/index.js").grade;
14107
- export const api24_gradeFacts = load("grade/index.js").gradeFacts;
14108
- export const api24_describe = load("grade/index.js").describe;
14109
- export const api25_groupedRuns = load("grouped-runs.js").groupedRuns;
14110
- export const api25_groupedBase64 = load("grouped-runs.js").groupedBase64;
14111
- export const api26_readCredential = load("input/index.js").readCredential;
14112
- export const api26_InputError = load("input/index.js").InputError;
14113
- export const api26_UsageError = load("input/index.js").UsageError;
14114
- export const api27_parseLndconnect = load("input/lndconnect.js").parseLndconnect;
14115
- export const api28_openGrpcStream = load("lnd/grpc.js").openGrpcStream;
14116
- export const api28_MAX_GRPC_MESSAGE = load("lnd/grpc.js").MAX_GRPC_MESSAGE;
14117
- export const api28_STALL_TIMEOUT_MS = load("lnd/grpc.js").STALL_TIMEOUT_MS;
14118
- export const api28_MAX_PENDING_WRITE_BYTES = load("lnd/grpc.js").MAX_PENDING_WRITE_BYTES;
14119
- export const api28_WRITE_OVERHEAD_BYTES = load("lnd/grpc.js").WRITE_OVERHEAD_BYTES;
14120
- export const api28_PING_INTERVAL_MS = load("lnd/grpc.js").PING_INTERVAL_MS;
14121
- export const api28_PING_TIMEOUT_MS = load("lnd/grpc.js").PING_TIMEOUT_MS;
14122
- export const api29_createLndAdapter = load("lnd/index.js").createLndAdapter;
14123
- export const api29_registerMiddleware = load("lnd/index.js").registerMiddleware;
14124
- export const api30_decodeMiddlewareRequest = load("lnd/middleware-proto.js").decodeMiddlewareRequest;
14125
- export const api30_encodeRegistration = load("lnd/middleware-proto.js").encodeRegistration;
14126
- export const api30_encodeFeedback = load("lnd/middleware-proto.js").encodeFeedback;
14127
- export const api30_MAX_MACAROON_BYTES = load("lnd/middleware-proto.js").MAX_MACAROON_BYTES;
14128
- export const api30_MAX_METHOD_BYTES = load("lnd/middleware-proto.js").MAX_METHOD_BYTES;
14129
- export const api30_MAX_CONDITION_BYTES = load("lnd/middleware-proto.js").MAX_CONDITION_BYTES;
14130
- export const api30_UNKNOWN_METHOD = load("lnd/middleware-proto.js").UNKNOWN_METHOD;
14131
- export const api31_identityCheck = load("lnd/tls-identity.js").identityCheck;
14132
- export const api32_endpoint = load("online.js").endpoint;
14133
- export const api32_credentialText = load("online.js").credentialText;
14134
- export const api32_connectionProblem = load("online.js").connectionProblem;
14135
- export const api32_flagProblem = load("online.js").flagProblem;
14136
- export const api32_nodeInfo = load("online.js").nodeInfo;
14137
- export const api32_nodePubkey = load("online.js").nodePubkey;
14138
- export const api32_drift = load("online.js").drift;
14139
- export const api32_samePermissions = load("online.js").samePermissions;
14140
- export const api32_listIds = load("online.js").listIds;
14141
- export const api32_failureDetails = load("online.js").failureDetails;
14142
- export const api32_onlineAdapter = load("online.js").onlineAdapter;
14143
- export const api32_shellQuote = load("online.js").shellQuote;
14144
- export const api32_recoveryPath = load("online.js").recoveryPath;
14145
- export const api32_recoveryCommand = load("online.js").recoveryCommand;
14146
- export const api32_inventoryRecoveryCommand = load("online.js").inventoryRecoveryCommand;
14147
- export const api32_readOnlineCredential = load("online.js").readOnlineCredential;
14148
- export const api32_onlineWarnings = load("online.js").onlineWarnings;
14149
- export const api32_LABEL_RULE = load("online.js").LABEL_RULE;
14150
- export const api32_readBoundedFile = load("online.js").readBoundedFile;
14151
- export const api33_rotatePlanResult = load("output/index.js").rotatePlanResult;
14152
- export const api33_revokePlanResult = load("output/index.js").revokePlanResult;
14153
- export const api33_error = load("output/index.js").error;
14154
- export const api33_warning = load("output/index.js").warning;
14155
- export const api33_renderWatchEvent = load("output/index.js").renderWatchEvent;
14156
- export const api33_formatDuration = load("output/index.js").formatDuration;
14157
- export const api33_revokeConfirmation = load("output/index.js").revokeConfirmation;
14158
- export const api33_writeRevokePrompt = load("output/index.js").writeRevokePrompt;
14159
- export const api33_writeRotateFinishPlan = load("output/index.js").writeRotateFinishPlan;
14160
- export const api33_rotateConfirmation = load("output/index.js").rotateConfirmation;
14161
- export const api33_writeRotatePrompt = load("output/index.js").writeRotatePrompt;
14162
- export const api33_render = load("output/index.js").render;
14163
- export const api33_renderChunks = load("output/index.js").renderChunks;
14164
- export const api33_inventoryResult = load("output/index.js").inventoryResult;
14165
- export const api33_writeRotateStagePlan = load("output/index.js").writeRotateStagePlan;
14166
- export const api34_ruleIdFor = load("output/scan-formats.js").ruleIdFor;
14167
- export const api34_findingSeverity = load("output/scan-formats.js").findingSeverity;
14168
- export const api34_locationOf = load("output/scan-formats.js").locationOf;
14169
- export const api34_fileUri = load("output/scan-formats.js").fileUri;
14170
- export const api34_sarifPieces = load("output/scan-formats.js").sarifPieces;
14171
- export const api34_escapeData = load("output/scan-formats.js").escapeData;
14172
- export const api34_escapeProperty = load("output/scan-formats.js").escapeProperty;
14173
- export const api34_githubLines = load("output/scan-formats.js").githubLines;
14174
- export const api34_SARIF_MAX_RESULTS = load("output/scan-formats.js").SARIF_MAX_RESULTS;
14175
- export const api35_withProcessErrorContext = load("process-error-context.js").withProcessErrorContext;
14176
- export const api35_markMutationSent = load("process-error-context.js").markMutationSent;
14177
- export const api35_internalErrorMessage = load("process-error-context.js").internalErrorMessage;
14178
- export const api36_isProtectedPath = load("protected-path.js").isProtectedPath;
14179
- export const api36_resolveUnprotectedPath = load("protected-path.js").resolveUnprotectedPath;
14180
- export const api37_openRegistry = load("registry/index.js").openRegistry;
14181
- export const api37_replacementProblem = load("registry/index.js").replacementProblem;
14182
- export const api37_replacementLinkProblems = load("registry/index.js").replacementLinkProblems;
14183
- export const api37_hasCustomCaveat = load("registry/index.js").hasCustomCaveat;
14184
- export const api37_RegistryInputError = load("registry/index.js").RegistryInputError;
14185
- export const api37_RegistryUsageError = load("registry/index.js").RegistryUsageError;
14186
- export const api37_Registry = load("registry/index.js").Registry;
14187
- export const api38_validDomain = load("registry/process-domain.js").validDomain;
14188
- export const api38_ownProcessDomain = load("registry/process-domain.js").ownProcessDomain;
14189
- export const api39_buildAccessReview = load("report/access-review.js").buildAccessReview;
14190
- export const api39_csvField = load("report/access-review.js").csvField;
14191
- export const api39_formatAccessReview = load("report/access-review.js").formatAccessReview;
14192
- export const api39_REPORT_FORMATS = load("report/access-review.js").REPORT_FORMATS;
14193
- export const api39_LIMITATIONS = load("report/access-review.js").LIMITATIONS;
14194
- export const api40_agentConfigCandidates = load("scan/agent-configs.js").agentConfigCandidates;
14195
- export const api40_discoverAgentConfigs = load("scan/agent-configs.js").discoverAgentConfigs;
14196
- export const api40_defaultAgentConfigEnvironment = load("scan/agent-configs.js").defaultAgentConfigEnvironment;
14197
- export const api41_nonLocalPath = load("scan/git-history.js").nonLocalPath;
14198
- export const api41_locateRepository = load("scan/git-history.js").locateRepository;
14199
- export const api41_gitEnvironment = load("scan/git-history.js").gitEnvironment;
14200
- export const api41_assertWorkTree = load("scan/git-history.js").assertWorkTree;
14201
- export const api41_isShallowRepository = load("scan/git-history.js").isShallowRepository;
14202
- export const api41_historyEntries = load("scan/git-history.js").historyEntries;
14203
- export const api41_blobSizes = load("scan/git-history.js").blobSizes;
14204
- export const api41_readBlobs = load("scan/git-history.js").readBlobs;
14205
- export const api41_GitHistoryError = load("scan/git-history.js").GitHistoryError;
14206
- export const api41_MODE_REGULAR = load("scan/git-history.js").MODE_REGULAR;
14207
- export const api41_MODE_SYMLINK = load("scan/git-history.js").MODE_SYMLINK;
14208
- export const api41_MODE_OTHER = load("scan/git-history.js").MODE_OTHER;
14209
- export const api41_GIT_IDLE_TIMEOUT_MS = load("scan/git-history.js").GIT_IDLE_TIMEOUT_MS;
14210
- export const api41_MAX_HISTORY_ENTRIES = load("scan/git-history.js").MAX_HISTORY_ENTRIES;
14211
- export const api41_PINNED_CONFIG = load("scan/git-history.js").PINNED_CONFIG;
14212
- export const api41_PREFLIGHT_LIMITS = load("scan/git-history.js").PREFLIGHT_LIMITS;
14213
- export const api42_parseIgnoreFile = load("scan/ignore.js").parseIgnoreFile;
14214
- export const api42_IgnoreFileError = load("scan/ignore.js").IgnoreFileError;
14215
- export const api43_scan = load("scan/index.js").scan;
14216
- export const api43_IgnoreFileError = load("scan/index.js").IgnoreFileError;
14217
- export const api43_GitHistoryError = load("scan/index.js").GitHistoryError;
14218
- export const api43_FINDINGS_LIMIT = load("scan/index.js").FINDINGS_LIMIT;
14219
- export const api43_ALSO_AT_EXAMPLES = load("scan/index.js").ALSO_AT_EXAMPLES;
14220
- export const api43_HISTORY_DEDUP_WORK_LIMIT = load("scan/index.js").HISTORY_DEDUP_WORK_LIMIT;
14221
- export const api43_FindingsLimitError = load("scan/index.js").FindingsLimitError;
14222
- export const api43_SCAN_CONCURRENCY = load("scan/index.js").SCAN_CONCURRENCY;
14223
- export const api43_readBounded = load("scan/index.js").readBounded;
14224
- export const api44_mcpServerStringSpans = load("scan/mcp-context.js").mcpServerStringSpans;
14225
- export const api44_withinSpans = load("scan/mcp-context.js").withinSpans;
14226
- export const api44_MAX_DEPTH = load("scan/mcp-context.js").MAX_DEPTH;
14227
- export const api45_nwcMatches = load("scan/nwc-match.js").nwcMatches;
14228
- export const api45_replaceNwc = load("scan/nwc-match.js").replaceNwc;
14229
- export const api45_nwcUnescaped = load("scan/nwc-match.js").nwcUnescaped;
14230
- export const api46_macaroonHex = load("scan/patterns.js").macaroonHex;
14231
- export const api46_macaroonBase64 = load("scan/patterns.js").macaroonBase64;
14232
- export const api46_lndconnect = load("scan/patterns.js").lndconnect;
14233
- export const api46_nwc = load("scan/patterns.js").nwc;
14234
- export const api47_redactDecoded = load("scan/redact.js").redactDecoded;
14235
- export const api47_redactPath = load("scan/redact.js").redactPath;
14236
- export const api47_collectNwcSecrets = load("scan/redact.js").collectNwcSecrets;
14237
- export const api47_knownSecretRedactor = load("scan/redact.js").knownSecretRedactor;
14238
- export const api47_redactKnownSecrets = load("scan/redact.js").redactKnownSecrets;
14239
- export const api48_Secret = load("secret.js").Secret;
14240
- export const api49_macwardenVersion = load("version.js").macwardenVersion;
14241
- export const api50_createAlertThrottle = load("watch/alert-throttle.js").createAlertThrottle;
14242
- export const api50_createAlertRunner = load("watch/alert-throttle.js").createAlertRunner;
14243
- export const api50_ALERT_WINDOW_MS = load("watch/alert-throttle.js").ALERT_WINDOW_MS;
14244
- export const api50_MAX_TRACKED_CANARIES = load("watch/alert-throttle.js").MAX_TRACKED_CANARIES;
14245
- export const api51_reapAlertGroups = load("watch/alert.js").reapAlertGroups;
14246
- export const api51_alertCleanupDone = load("watch/alert.js").alertCleanupDone;
14247
- export const api51_runAlertCommand = load("watch/alert.js").runAlertCommand;
14248
- export const api51_ALERT_TIMEOUT_MS = load("watch/alert.js").ALERT_TIMEOUT_MS;
14249
- export const api51_ALERT_KILL_MS = load("watch/alert.js").ALERT_KILL_MS;
14250
- export const api51_ALERT_GRACE_MS = load("watch/alert.js").ALERT_GRACE_MS;
14251
- export const api51_ALERT_CLEANUP_MS = load("watch/alert.js").ALERT_CLEANUP_MS;
14252
- export const api52_canaryConfigPath = load("watch/canary.js").canaryConfigPath;
14253
- export const api52_newCaveatName = load("watch/canary.js").newCaveatName;
14254
- export const api52_newCondition = load("watch/canary.js").newCondition;
14255
- export const api52_canaryCondition = load("watch/canary.js").canaryCondition;
14256
- export const api52_readCanaryConfig = load("watch/canary.js").readCanaryConfig;
14257
- export const api52_ensureCanaryConfig = load("watch/canary.js").ensureCanaryConfig;
14258
- export const api52_canaryEntries = load("watch/canary.js").canaryEntries;
14259
- export const api52_isCanary = load("watch/canary.js").isCanary;
14260
- export const api52_CANARY_FILE_LIMIT = load("watch/canary.js").CANARY_FILE_LIMIT;
14261
- export const api53_retryable = load("watch/output-queue.js").retryable;
14262
- export const api53_fdWriter = load("watch/output-queue.js").fdWriter;
14263
- export const api53_createOutputQueue = load("watch/output-queue.js").createOutputQueue;
14264
- export const api53_OUTPUT_LIMIT_BYTES = load("watch/output-queue.js").OUTPUT_LIMIT_BYTES;
14265
- export const api54_usagePath = load("watch/usage.js").usagePath;
14266
- export const api54_readUsage = load("watch/usage.js").readUsage;
14267
- export const api54_writeUsage = load("watch/usage.js").writeUsage;
14268
- export const api54_USAGE_LIMIT = load("watch/usage.js").USAGE_LIMIT;
14269
- export const api54_CANARY_LIMIT = load("watch/usage.js").CANARY_LIMIT;
14270
- export const api54_COVERAGE_LIMIT = load("watch/usage.js").COVERAGE_LIMIT;
14271
- export const api54_SPAN_LIMIT = load("watch/usage.js").SPAN_LIMIT;
14272
- export const api54_USAGE_FILE_LIMIT = load("watch/usage.js").USAGE_FILE_LIMIT;
14273
- export const api54_UsageFileError = load("watch/usage.js").UsageFileError;
14274
- export const api54_UsageStore = load("watch/usage.js").UsageStore;
15311
+ export const api8_reserveFailure = load("commands/bake/out-errors.js").reserveFailure;
15312
+ export const api8_OutputReserveError = load("commands/bake/out-errors.js").OutputReserveError;
15313
+ export const api9_reserveOut = load("commands/bake/write-out.js").reserveOut;
15314
+ export const api10_handlers = load("commands/handlers.js").handlers;
15315
+ export const api11_inspectResult = load("commands/inspect.js").inspectResult;
15316
+ export const api11_run = load("commands/inspect.js").run;
15317
+ export const api11_inspect = load("commands/inspect.js").inspect;
15318
+ export const api12_run = load("commands/inventory.js").run;
15319
+ export const api12_inventory = load("commands/inventory.js").inventory;
15320
+ export const api13_narrowLabel = load("commands/recommend.js").narrowLabel;
15321
+ export const api13_run = load("commands/recommend.js").run;
15322
+ export const api13_recommend = load("commands/recommend.js").recommend;
15323
+ export const api13_SHORT_OBSERVATION_SECONDS = load("commands/recommend.js").SHORT_OBSERVATION_SECONDS;
15324
+ export const api14_aggregateExit = load("commands/respond.js").aggregateExit;
15325
+ export const api14_run = load("commands/respond.js").run;
15326
+ export const api14_respond = load("commands/respond.js").respond;
15327
+ export const api14_ROOT_KEY_0_GUIDANCE = load("commands/respond.js").ROOT_KEY_0_GUIDANCE;
15328
+ export const api14_OWN_ON_ROOT_KEY_0 = load("commands/respond.js").OWN_ON_ROOT_KEY_0;
15329
+ export const api14_REVOKED_BUT_PRESENT_GUIDANCE = load("commands/respond.js").REVOKED_BUT_PRESENT_GUIDANCE;
15330
+ export const api14_BAKING_RISK_GUIDANCE = load("commands/respond.js").BAKING_RISK_GUIDANCE;
15331
+ export const api14_CUSTOM_CAVEAT_NOTE = load("commands/respond.js").CUSTOM_CAVEAT_NOTE;
15332
+ export const api14_UNKNOWN_GUIDANCE = load("commands/respond.js").UNKNOWN_GUIDANCE;
15333
+ export const api15_run = load("commands/revoke.js").run;
15334
+ export const api15_revoke = load("commands/revoke.js").revoke;
15335
+ export const api16_run = load("commands/rotate.js").run;
15336
+ export const api16_rotate = load("commands/rotate.js").rotate;
15337
+ export const api17_run = load("commands/scan.js").run;
15338
+ export const api18_run = load("commands/verify.js").run;
15339
+ export const api18_verify = load("commands/verify.js").verify;
15340
+ export const api18_ROOT_KEY_0_ACCEPTED = load("commands/verify.js").ROOT_KEY_0_ACCEPTED;
15341
+ export const api18_CAN_BAKE_ACCEPTED = load("commands/verify.js").CAN_BAKE_ACCEPTED;
15342
+ export const api18_CAN_BAKE_ROOT_KEY_0 = load("commands/verify.js").CAN_BAKE_ROOT_KEY_0;
15343
+ export const api18_OWN_ROOT_KEY_ACCEPTED = load("commands/verify.js").OWN_ROOT_KEY_ACCEPTED;
15344
+ export const api18_REJECTED_INCONCLUSIVE = load("commands/verify.js").REJECTED_INCONCLUSIVE;
15345
+ export const api18_LOCAL_EXPIRY_UNCONFIRMED = load("commands/verify.js").LOCAL_EXPIRY_UNCONFIRMED;
15346
+ export const api18_SIGNATURE_MISMATCH_INCONCLUSIVE = load("commands/verify.js").SIGNATURE_MISMATCH_INCONCLUSIVE;
15347
+ export const api18_SIGNATURE_MISMATCH_HOW_TO_CONCLUDE = load("commands/verify.js").SIGNATURE_MISMATCH_HOW_TO_CONCLUDE;
15348
+ export const api18_SIGNATURE_MISMATCH_ROOT_KEY_0 = load("commands/verify.js").SIGNATURE_MISMATCH_ROOT_KEY_0;
15349
+ export const api18_OWN_ROOT_KEY_INCONCLUSIVE = load("commands/verify.js").OWN_ROOT_KEY_INCONCLUSIVE;
15350
+ export const api18_ROOT_KEY_0_INCONCLUSIVE = load("commands/verify.js").ROOT_KEY_0_INCONCLUSIVE;
15351
+ export const api18_CUSTOM_CAVEAT_REJECTED = load("commands/verify.js").CUSTOM_CAVEAT_REJECTED;
15352
+ export const api19_scheduleDeadline = load("commands/watch.js").scheduleDeadline;
15353
+ export const api19_lockOwnerGone = load("commands/watch.js").lockOwnerGone;
15354
+ export const api19_acquireLock = load("commands/watch.js").acquireLock;
15355
+ export const api19_run = load("commands/watch.js").run;
15356
+ export const api19_stdoutEvents = load("commands/watch.js").stdoutEvents;
15357
+ export const api19_watch = load("commands/watch.js").watch;
15358
+ export const api19_CANARY_RELOAD_MS = load("commands/watch.js").CANARY_RELOAD_MS;
15359
+ export const api19_MAX_TIMER_MS = load("commands/watch.js").MAX_TIMER_MS;
15360
+ export const api20_redactEncodedCredentials = load("credential-recognition.js").redactEncodedCredentials;
15361
+ export const api21_macaroonEosEnd = load("decode/index.js").macaroonEosEnd;
15362
+ export const api21_macaroonPacketRange = load("decode/index.js").macaroonPacketRange;
15363
+ export const api21_macaroonSectionRange = load("decode/index.js").macaroonSectionRange;
15364
+ export const api21_parseTimeBefore = load("decode/index.js").parseTimeBefore;
15365
+ export const api21_canonicalMacaroonForm = load("decode/index.js").canonicalMacaroonForm;
15366
+ export const api21_decodeMacaroon = load("decode/index.js").decodeMacaroon;
15367
+ export const api21_macaroonId = load("decode/index.js").macaroonId;
15368
+ export const api21_encodeMacaroon = load("decode/index.js").encodeMacaroon;
15369
+ export const api21_addFirstPartyCaveat = load("decode/index.js").addFirstPartyCaveat;
15370
+ export const api21_timeBeforeCondition = load("decode/index.js").timeBeforeCondition;
15371
+ export const api21_validMacaroonIdentifier = load("decode/index.js").validMacaroonIdentifier;
15372
+ export const api21_isNwcContainer = load("decode/index.js").isNwcContainer;
15373
+ export const api21_decodeNwc = load("decode/index.js").decodeNwc;
15374
+ export const api21_DecodeError = load("decode/index.js").DecodeError;
15375
+ export const api22_macaroonEnds = load("decode/recognition.js").macaroonEnds;
15376
+ export const api23_unsafeNodeDiagnostics = load("environment.js").unsafeNodeDiagnostics;
15377
+ export const api24_UsageError = load("errors.js").UsageError;
15378
+ export const api24_InputError = load("errors.js").InputError;
15379
+ export const api24_DecodeError = load("errors.js").DecodeError;
15380
+ export const api24_LndError = load("errors.js").LndError;
15381
+ export const api24_LndUncertainError = load("errors.js").LndUncertainError;
15382
+ export const api25_grade = load("grade/index.js").grade;
15383
+ export const api25_gradeFacts = load("grade/index.js").gradeFacts;
15384
+ export const api25_describe = load("grade/index.js").describe;
15385
+ export const api25_canBake = load("grade/index.js").canBake;
15386
+ export const api25_BAKE_MACAROON_URI = load("grade/index.js").BAKE_MACAROON_URI;
15387
+ export const api26_groupedRuns = load("grouped-runs.js").groupedRuns;
15388
+ export const api26_groupedBase64 = load("grouped-runs.js").groupedBase64;
15389
+ export const api27_readCredential = load("input/index.js").readCredential;
15390
+ export const api27_InputError = load("input/index.js").InputError;
15391
+ export const api27_UsageError = load("input/index.js").UsageError;
15392
+ export const api28_parseLndconnect = load("input/lndconnect.js").parseLndconnect;
15393
+ export const api29_isInstant = load("instant.js").isInstant;
15394
+ export const api30_openGrpcStream = load("lnd/grpc.js").openGrpcStream;
15395
+ export const api30_MAX_GRPC_MESSAGE = load("lnd/grpc.js").MAX_GRPC_MESSAGE;
15396
+ export const api30_STALL_TIMEOUT_MS = load("lnd/grpc.js").STALL_TIMEOUT_MS;
15397
+ export const api30_MAX_PENDING_WRITE_BYTES = load("lnd/grpc.js").MAX_PENDING_WRITE_BYTES;
15398
+ export const api30_WRITE_OVERHEAD_BYTES = load("lnd/grpc.js").WRITE_OVERHEAD_BYTES;
15399
+ export const api30_PING_INTERVAL_MS = load("lnd/grpc.js").PING_INTERVAL_MS;
15400
+ export const api30_PING_TIMEOUT_MS = load("lnd/grpc.js").PING_TIMEOUT_MS;
15401
+ export const api31_createLndAdapter = load("lnd/index.js").createLndAdapter;
15402
+ export const api31_registerMiddleware = load("lnd/index.js").registerMiddleware;
15403
+ export const api32_decodeMiddlewareRequest = load("lnd/middleware-proto.js").decodeMiddlewareRequest;
15404
+ export const api32_encodeRegistration = load("lnd/middleware-proto.js").encodeRegistration;
15405
+ export const api32_encodeFeedback = load("lnd/middleware-proto.js").encodeFeedback;
15406
+ export const api32_MAX_MACAROON_BYTES = load("lnd/middleware-proto.js").MAX_MACAROON_BYTES;
15407
+ export const api32_MAX_METHOD_BYTES = load("lnd/middleware-proto.js").MAX_METHOD_BYTES;
15408
+ export const api32_MAX_CONDITION_BYTES = load("lnd/middleware-proto.js").MAX_CONDITION_BYTES;
15409
+ export const api32_UNKNOWN_METHOD = load("lnd/middleware-proto.js").UNKNOWN_METHOD;
15410
+ export const api33_identityCheck = load("lnd/tls-identity.js").identityCheck;
15411
+ export const api34_endpoint = load("online.js").endpoint;
15412
+ export const api34_credentialText = load("online.js").credentialText;
15413
+ export const api34_connectionProblem = load("online.js").connectionProblem;
15414
+ export const api34_connectionSettings = load("online.js").connectionSettings;
15415
+ export const api34_flagProblem = load("online.js").flagProblem;
15416
+ export const api34_nodeInfo = load("online.js").nodeInfo;
15417
+ export const api34_nodePubkey = load("online.js").nodePubkey;
15418
+ export const api34_drift = load("online.js").drift;
15419
+ export const api34_samePermissions = load("online.js").samePermissions;
15420
+ export const api34_listIds = load("online.js").listIds;
15421
+ export const api34_failureDetails = load("online.js").failureDetails;
15422
+ export const api34_onlineAdapter = load("online.js").onlineAdapter;
15423
+ export const api34_shellQuote = load("online.js").shellQuote;
15424
+ export const api34_recoveryPath = load("online.js").recoveryPath;
15425
+ export const api34_recoveryCommand = load("online.js").recoveryCommand;
15426
+ export const api34_inventoryRecoveryCommand = load("online.js").inventoryRecoveryCommand;
15427
+ export const api34_readOnlineCredential = load("online.js").readOnlineCredential;
15428
+ export const api34_onlineWarnings = load("online.js").onlineWarnings;
15429
+ export const api34_LABEL_RULE = load("online.js").LABEL_RULE;
15430
+ export const api34_readBoundedFile = load("online.js").readBoundedFile;
15431
+ export const api35_rotatePlanResult = load("output/index.js").rotatePlanResult;
15432
+ export const api35_revokePlanResult = load("output/index.js").revokePlanResult;
15433
+ export const api35_error = load("output/index.js").error;
15434
+ export const api35_warning = load("output/index.js").warning;
15435
+ export const api35_renderWatchEvent = load("output/index.js").renderWatchEvent;
15436
+ export const api35_formatDuration = load("output/index.js").formatDuration;
15437
+ export const api35_revokeConfirmation = load("output/index.js").revokeConfirmation;
15438
+ export const api35_writeRevokePrompt = load("output/index.js").writeRevokePrompt;
15439
+ export const api35_writeRotateFinishPlan = load("output/index.js").writeRotateFinishPlan;
15440
+ export const api35_rotateConfirmation = load("output/index.js").rotateConfirmation;
15441
+ export const api35_writeRotatePrompt = load("output/index.js").writeRotatePrompt;
15442
+ export const api35_render = load("output/index.js").render;
15443
+ export const api35_renderChunks = load("output/index.js").renderChunks;
15444
+ export const api35_inventoryResult = load("output/index.js").inventoryResult;
15445
+ export const api35_writeRotateStagePlan = load("output/index.js").writeRotateStagePlan;
15446
+ export const api36_ruleIdFor = load("output/scan-formats.js").ruleIdFor;
15447
+ export const api36_findingSeverity = load("output/scan-formats.js").findingSeverity;
15448
+ export const api36_locationOf = load("output/scan-formats.js").locationOf;
15449
+ export const api36_fileUri = load("output/scan-formats.js").fileUri;
15450
+ export const api36_sarifPieces = load("output/scan-formats.js").sarifPieces;
15451
+ export const api36_escapeData = load("output/scan-formats.js").escapeData;
15452
+ export const api36_escapeProperty = load("output/scan-formats.js").escapeProperty;
15453
+ export const api36_githubLines = load("output/scan-formats.js").githubLines;
15454
+ export const api36_SARIF_MAX_RESULTS = load("output/scan-formats.js").SARIF_MAX_RESULTS;
15455
+ export const api37_withProcessErrorContext = load("process-error-context.js").withProcessErrorContext;
15456
+ export const api37_markMutationSent = load("process-error-context.js").markMutationSent;
15457
+ export const api37_internalErrorMessage = load("process-error-context.js").internalErrorMessage;
15458
+ export const api38_isProtectedPath = load("protected-path.js").isProtectedPath;
15459
+ export const api38_outputNameProblem = load("protected-path.js").outputNameProblem;
15460
+ export const api38_resolveUnprotectedPath = load("protected-path.js").resolveUnprotectedPath;
15461
+ export const api39_openRegistry = load("registry/index.js").openRegistry;
15462
+ export const api39_replacementProblem = load("registry/index.js").replacementProblem;
15463
+ export const api39_replacementLinkProblems = load("registry/index.js").replacementLinkProblems;
15464
+ export const api39_recordedFileRelation = load("registry/index.js").recordedFileRelation;
15465
+ export const api39_isStagedReplacement = load("registry/index.js").isStagedReplacement;
15466
+ export const api39_hasCustomCaveat = load("registry/index.js").hasCustomCaveat;
15467
+ export const api39_RegistryInputError = load("registry/index.js").RegistryInputError;
15468
+ export const api39_RegistryUsageError = load("registry/index.js").RegistryUsageError;
15469
+ export const api39_Registry = load("registry/index.js").Registry;
15470
+ export const api40_validDomain = load("registry/process-domain.js").validDomain;
15471
+ export const api40_ownProcessDomain = load("registry/process-domain.js").ownProcessDomain;
15472
+ export const api41_buildAccessReview = load("report/access-review.js").buildAccessReview;
15473
+ export const api41_csvField = load("report/access-review.js").csvField;
15474
+ export const api41_formatAccessReview = load("report/access-review.js").formatAccessReview;
15475
+ export const api41_REPORT_FORMATS = load("report/access-review.js").REPORT_FORMATS;
15476
+ export const api41_LIMITATIONS = load("report/access-review.js").LIMITATIONS;
15477
+ export const api42_isRootKeyId = load("root-key-id.js").isRootKeyId;
15478
+ export const api42_MAX_ROOT_KEY_ID = load("root-key-id.js").MAX_ROOT_KEY_ID;
15479
+ export const api43_agentConfigCandidates = load("scan/agent-configs.js").agentConfigCandidates;
15480
+ export const api43_discoverAgentConfigs = load("scan/agent-configs.js").discoverAgentConfigs;
15481
+ export const api43_defaultAgentConfigEnvironment = load("scan/agent-configs.js").defaultAgentConfigEnvironment;
15482
+ export const api44_nonLocalPath = load("scan/git-history.js").nonLocalPath;
15483
+ export const api44_locateRepository = load("scan/git-history.js").locateRepository;
15484
+ export const api44_gitEnvironment = load("scan/git-history.js").gitEnvironment;
15485
+ export const api44_assertWorkTree = load("scan/git-history.js").assertWorkTree;
15486
+ export const api44_isShallowRepository = load("scan/git-history.js").isShallowRepository;
15487
+ export const api44_historyEntries = load("scan/git-history.js").historyEntries;
15488
+ export const api44_blobSizes = load("scan/git-history.js").blobSizes;
15489
+ export const api44_readBlobs = load("scan/git-history.js").readBlobs;
15490
+ export const api44_GitHistoryError = load("scan/git-history.js").GitHistoryError;
15491
+ export const api44_MODE_REGULAR = load("scan/git-history.js").MODE_REGULAR;
15492
+ export const api44_MODE_SYMLINK = load("scan/git-history.js").MODE_SYMLINK;
15493
+ export const api44_MODE_OTHER = load("scan/git-history.js").MODE_OTHER;
15494
+ export const api44_GIT_IDLE_TIMEOUT_MS = load("scan/git-history.js").GIT_IDLE_TIMEOUT_MS;
15495
+ export const api44_MAX_HISTORY_ENTRIES = load("scan/git-history.js").MAX_HISTORY_ENTRIES;
15496
+ export const api44_PINNED_CONFIG = load("scan/git-history.js").PINNED_CONFIG;
15497
+ export const api44_PREFLIGHT_LIMITS = load("scan/git-history.js").PREFLIGHT_LIMITS;
15498
+ export const api45_parseIgnoreFile = load("scan/ignore.js").parseIgnoreFile;
15499
+ export const api45_IgnoreFileError = load("scan/ignore.js").IgnoreFileError;
15500
+ export const api46_scan = load("scan/index.js").scan;
15501
+ export const api46_IgnoreFileError = load("scan/index.js").IgnoreFileError;
15502
+ export const api46_GitHistoryError = load("scan/index.js").GitHistoryError;
15503
+ export const api46_CANDIDATE_LIMIT = load("scan/index.js").CANDIDATE_LIMIT;
15504
+ export const api46_FINDINGS_LIMIT = load("scan/index.js").FINDINGS_LIMIT;
15505
+ export const api46_ALSO_AT_EXAMPLES = load("scan/index.js").ALSO_AT_EXAMPLES;
15506
+ export const api46_HISTORY_DEDUP_WORK_LIMIT = load("scan/index.js").HISTORY_DEDUP_WORK_LIMIT;
15507
+ export const api46_FindingsLimitError = load("scan/index.js").FindingsLimitError;
15508
+ export const api46_SCAN_CONCURRENCY = load("scan/index.js").SCAN_CONCURRENCY;
15509
+ export const api46_readBounded = load("scan/index.js").readBounded;
15510
+ export const api46_SCAN_OPEN_FLAGS = load("scan/index.js").SCAN_OPEN_FLAGS;
15511
+ export const api47_mcpServerStringSpans = load("scan/mcp-context.js").mcpServerStringSpans;
15512
+ export const api47_withinSpans = load("scan/mcp-context.js").withinSpans;
15513
+ export const api47_MAX_DEPTH = load("scan/mcp-context.js").MAX_DEPTH;
15514
+ export const api48_nwcMatches = load("scan/nwc-match.js").nwcMatches;
15515
+ export const api48_replaceNwc = load("scan/nwc-match.js").replaceNwc;
15516
+ export const api48_nwcUnescaped = load("scan/nwc-match.js").nwcUnescaped;
15517
+ export const api49_macaroonHex = load("scan/patterns.js").macaroonHex;
15518
+ export const api49_macaroonBase64 = load("scan/patterns.js").macaroonBase64;
15519
+ export const api49_lndconnect = load("scan/patterns.js").lndconnect;
15520
+ export const api49_nwc = load("scan/patterns.js").nwc;
15521
+ export const api50_redactDecoded = load("scan/redact.js").redactDecoded;
15522
+ export const api50_redactPath = load("scan/redact.js").redactPath;
15523
+ export const api50_collectNwcSecrets = load("scan/redact.js").collectNwcSecrets;
15524
+ export const api50_knownSecretRedactor = load("scan/redact.js").knownSecretRedactor;
15525
+ export const api50_redactKnownSecrets = load("scan/redact.js").redactKnownSecrets;
15526
+ export const api51_Secret = load("secret.js").Secret;
15527
+ export const api52_macwardenVersion = load("version.js").macwardenVersion;
15528
+ export const api53_createAlertThrottle = load("watch/alert-throttle.js").createAlertThrottle;
15529
+ export const api53_createAlertRunner = load("watch/alert-throttle.js").createAlertRunner;
15530
+ export const api53_ALERT_WINDOW_MS = load("watch/alert-throttle.js").ALERT_WINDOW_MS;
15531
+ export const api53_MAX_TRACKED_CANARIES = load("watch/alert-throttle.js").MAX_TRACKED_CANARIES;
15532
+ export const api54_reapAlertGroups = load("watch/alert.js").reapAlertGroups;
15533
+ export const api54_alertCleanupDone = load("watch/alert.js").alertCleanupDone;
15534
+ export const api54_runAlertCommand = load("watch/alert.js").runAlertCommand;
15535
+ export const api54_ALERT_TIMEOUT_MS = load("watch/alert.js").ALERT_TIMEOUT_MS;
15536
+ export const api54_ALERT_KILL_MS = load("watch/alert.js").ALERT_KILL_MS;
15537
+ export const api54_ALERT_GRACE_MS = load("watch/alert.js").ALERT_GRACE_MS;
15538
+ export const api54_ALERT_CLEANUP_MS = load("watch/alert.js").ALERT_CLEANUP_MS;
15539
+ export const api55_canaryConfigPath = load("watch/canary.js").canaryConfigPath;
15540
+ export const api55_newCaveatName = load("watch/canary.js").newCaveatName;
15541
+ export const api55_newCondition = load("watch/canary.js").newCondition;
15542
+ export const api55_canaryCondition = load("watch/canary.js").canaryCondition;
15543
+ export const api55_readCanaryConfig = load("watch/canary.js").readCanaryConfig;
15544
+ export const api55_ensureCanaryConfig = load("watch/canary.js").ensureCanaryConfig;
15545
+ export const api55_canaryEntries = load("watch/canary.js").canaryEntries;
15546
+ export const api55_isCanary = load("watch/canary.js").isCanary;
15547
+ export const api55_CANARY_FILE_LIMIT = load("watch/canary.js").CANARY_FILE_LIMIT;
15548
+ export const api56_retryable = load("watch/output-queue.js").retryable;
15549
+ export const api56_fdWriter = load("watch/output-queue.js").fdWriter;
15550
+ export const api56_createOutputQueue = load("watch/output-queue.js").createOutputQueue;
15551
+ export const api56_OUTPUT_LIMIT_BYTES = load("watch/output-queue.js").OUTPUT_LIMIT_BYTES;
15552
+ export const api57_usagePath = load("watch/usage.js").usagePath;
15553
+ export const api57_readUsage = load("watch/usage.js").readUsage;
15554
+ export const api57_writeUsage = load("watch/usage.js").writeUsage;
15555
+ export const api57_USAGE_LIMIT = load("watch/usage.js").USAGE_LIMIT;
15556
+ export const api57_CANARY_LIMIT = load("watch/usage.js").CANARY_LIMIT;
15557
+ export const api57_COVERAGE_LIMIT = load("watch/usage.js").COVERAGE_LIMIT;
15558
+ export const api57_SPAN_LIMIT = load("watch/usage.js").SPAN_LIMIT;
15559
+ export const api57_USAGE_FILE_LIMIT = load("watch/usage.js").USAGE_FILE_LIMIT;
15560
+ export const api57_UsageFileError = load("watch/usage.js").UsageFileError;
15561
+ export const api57_UsageStore = load("watch/usage.js").UsageStore;