residoo 0.2.0 → 0.3.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.
package/src/integrity.js CHANGED
@@ -113,7 +113,7 @@ const HOOK_SUSPICION = [
113
113
  // .claude/ and .vscode/ (Wiz IOC list), and the artifact the Mini
114
114
  // Shai-Hulud/Miasma SessionStart hooks execute.
115
115
  re: /\bsetup\.mjs\b/i,
116
- reason: "references setup.mjs the dropper filename the Aug-2026 keyv/ChainDrop wave planted in .claude/ and .vscode/ (Wiz IOC)",
116
+ reason: "references setup.mjs, the dropper filename the Aug-2026 keyv/ChainDrop wave planted in .claude/ and .vscode/ (Wiz IOC)",
117
117
  },
118
118
  {
119
119
  id: "curl-pipe-sh",
@@ -121,12 +121,12 @@ const HOOK_SUSPICION = [
121
121
  // linear-time on adversarial input (see CONTRIBUTING.md on ReDoS) and
122
122
  // stops a curl in one shell statement matching a pipe in the next.
123
123
  re: /\b(?:curl|wget)\b[^|;&\n]{0,200}\|\s*(?:ba|z|da|fi)?sh\b/i,
124
- reason: "downloads from the network and pipes straight into a shell a hook doing this re-fetches its payload on every session",
124
+ reason: "downloads from the network and pipes straight into a shell: a hook doing this re-fetches its payload on every session",
125
125
  },
126
126
  {
127
127
  id: "base64-decode",
128
128
  re: /\bbase64\b\s+(?:-d|-D|--decode)\b/,
129
- reason: "decodes base64 before executing the obfuscation step the 2026 npm campaigns used to hide payloads from exactly this kind of review",
129
+ reason: "decodes base64 before executing: the obfuscation step the 2026 npm campaigns used to hide payloads from exactly this kind of review",
130
130
  },
131
131
  {
132
132
  id: "script-in-dot-dir",
@@ -138,7 +138,7 @@ const HOOK_SUSPICION = [
138
138
  // "confirm you wrote it", not "malware".
139
139
  re: /(?:^|[\s"'/=])\.[A-Za-z0-9_][A-Za-z0-9_.-]*\/[^\s"']*\.(?:mjs|cjs|js)\b/,
140
140
  requireRunner: /\b(?:node|bun|deno)\b/,
141
- reason: "runs a script that lives inside a dot-directory the persistence shape of the Mini Shai-Hulud/Miasma SessionStart plants; confirm you wrote that script",
141
+ reason: "runs a script that lives inside a dot-directory: the persistence shape of the Mini Shai-Hulud/Miasma SessionStart plants; confirm you wrote that script",
142
142
  // Claude Code's own hooks docs suggest keeping hook scripts at
143
143
  // ~/.claude/hooks/<script>.js — warning on the vendor-documented layout
144
144
  // (and failing --fail-on-find CI on it, every scan) is the cry-wolf →
@@ -149,7 +149,7 @@ const HOOK_SUSPICION = [
149
149
  // stays warn. (setup.mjs never reaches here: dropper-name matches
150
150
  // first.)
151
151
  demoteIfHomeAnchored: true,
152
- reasonInfo: "runs a script from a dot-directory under your home directory the vendor-documented hook layout; confirm you wrote that script",
152
+ reasonInfo: "runs a script from a dot-directory under your home directory: the vendor-documented hook layout; confirm you wrote that script",
153
153
  },
154
154
  ];
155
155
 
@@ -384,9 +384,22 @@ function extractHooks(parsed) {
384
384
 
385
385
  /**
386
386
  * `home` and `cwd` are overridable for tests only (a synthetic planted HOME
387
- * beats mutating process.env mid-process); production callers pass nothing.
387
+ * beats mutating process.env mid-process); production callers pass nothing,
388
+ * except the CLI's --project mode, which pins both anchors to the project
389
+ * root AND sets `projectMode: true`. The flag exists because two behaviors
390
+ * must change when the anchors stop meaning "this user's machine":
391
+ *
392
+ * - GEMINI_CLI_HOME is ignored. It is this MACHINE's Gemini root override;
393
+ * honoring it in a project scan would pull a machine-level settings file
394
+ * into a verdict that claims to be about the checkout only, and could
395
+ * fail CI on a clean repo because of the runner's (or a developer's) own
396
+ * environment.
397
+ * - The home-anchored hook demotion (see HOOK_SUSPICION) is suppressed. The
398
+ * demotion exists to avoid crying wolf on the user's own standing config;
399
+ * a hook inside a COMMITTED repo config is not the user's standing config,
400
+ * and demoting it there would hand a hostile repo a warn-tier bypass.
388
401
  */
389
- function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
402
+ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode = false } = {}) {
390
403
  const findings = [];
391
404
  const filesChecked = [];
392
405
  // Home checks and project checks can name the same file (running residoo
@@ -425,9 +438,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
425
438
  const res = readSmallFile(file);
426
439
  mark(file, res.status === "ok" ? "checked" : res.status);
427
440
  if (res.status === "unreadable") {
428
- add("warn", "unreadable-config", file, "exists in an auto-execution location but could not be read its contents are unverified, not clean");
441
+ add("warn", "unreadable-config", file, "exists in an auto-execution location but could not be read; its contents are unverified, not clean");
429
442
  } else if (res.status === "too-large") {
430
- add("warn", "oversized-config", file, `larger than ${MAX_CONFIG_BYTES / 1024 / 1024}MB far beyond any hand-written config (the Miasma payload runner was 4.3MB); not parsed, review it directly`);
443
+ add("warn", "oversized-config", file, `larger than ${MAX_CONFIG_BYTES / 1024 / 1024}MB, far beyond any hand-written config (the Miasma payload runner was 4.3MB); not parsed, review it directly`);
431
444
  }
432
445
  return res.status === "ok" ? res.text : null;
433
446
  };
@@ -437,14 +450,21 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
437
450
  // exact files Miasma planted in cloned repos — a repo-local
438
451
  // .claude/settings.json the user never wrote is the campaign's signature.
439
452
  // GEMINI_CLI_HOME is Gemini CLI's own documented root override (the CLI
440
- // creates `.gemini` INSIDE it) the same resolution agent-configs.js and
453
+ // creates `.gemini` INSIDE it), the same resolution agent-configs.js and
441
454
  // gemini-cli.js use. Hard-coding ~/.gemini here made the two modules
442
455
  // disagree about the same file within one run: agent-configs would find a
443
456
  // secret in the real settings file while integrity called that location
444
- // absent and missed a planted hook in it.
445
- const geminiHome = process.env.GEMINI_CLI_HOME
457
+ // absent and missed a planted hook in it. In project mode the override is
458
+ // a MACHINE-level fact and is deliberately ignored: the anchors point at
459
+ // the checkout, and a project verdict must never be pierced by the
460
+ // invoking environment (see the function docstring).
461
+ const geminiHome = !projectMode && process.env.GEMINI_CLI_HOME
446
462
  ? path.join(process.env.GEMINI_CLI_HOME, ".gemini")
447
463
  : path.join(home, ".gemini");
464
+ // Feeds the home-anchored demotion in suspicionReason: null disables it,
465
+ // which is the correct posture when `home` is a project root rather than
466
+ // anyone's home directory.
467
+ const demotionHome = projectMode ? null : home;
448
468
  const hookFiles = [
449
469
  path.join(home, ".claude", "settings.json"),
450
470
  path.join(home, ".claude", "settings.local.json"),
@@ -460,10 +480,10 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
460
480
 
461
481
  let parsed;
462
482
  try { parsed = JSON.parse(text); } catch {
463
- add("warn", "unparseable-config", file, "exists in an auto-execution location but is not valid JSON the agent's own loader would also choke on it, so corruption or tampering is worth a look; raw text was signature-checked instead");
483
+ add("warn", "unparseable-config", file, "exists in an auto-execution location but is not valid JSON; the agent's own loader would also choke on it, so corruption or tampering is worth a look; raw text was signature-checked instead");
464
484
  // The parse failing must not mean the campaign signatures go
465
485
  // unchecked — grep the raw text for the same shapes.
466
- const hit = suspicionReason(text, home);
486
+ const hit = suspicionReason(text, demotionHome);
467
487
  if (hit) add(hit.severity, "hook", file, `suspicious signature in raw text: ${hit.reason}`);
468
488
  continue;
469
489
  }
@@ -473,31 +493,31 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
473
493
  // No hand-written config is 10k nodes deep/wide — this shape exists
474
494
  // to exhaust a walker. The hooks in it are unverified, said loudly;
475
495
  // the raw text still gets signature-checked below.
476
- add("warn", "unwalkable-config", file, `hooks section exceeds ${MAX_WALK_NODES} nodes far beyond any hand-written config; its hooks are unverified, not clean, review the file directly`);
477
- const hit = suspicionReason(text, home);
496
+ add("warn", "unwalkable-config", file, `hooks section exceeds ${MAX_WALK_NODES} nodes, far beyond any hand-written config; its hooks are unverified, not clean, review the file directly`);
497
+ const hit = suspicionReason(text, demotionHome);
478
498
  if (hit) add(hit.severity, "hook", file, `suspicious signature in raw text: ${hit.reason}`);
479
499
  }
480
500
  for (const u of unrecognized) {
481
501
  // A `command` that isn't a string (e.g. ["node", "x.js"]) is not
482
502
  // extracted or signature-checked — schema drift must degrade to
483
503
  // still-reported, never a silent miss.
484
- add("info", "hook-unrecognized", file, `${u.event ? safePreview(u.event, 40) : "unknown event"} hook entry whose "command" is not a string not extracted or signature-checked; review it manually`);
504
+ add("info", "hook-unrecognized", file, `${u.event ? safePreview(u.event, 40) : "unknown event"} hook entry whose "command" is not a string: not extracted or signature-checked; review it manually`);
485
505
  }
486
506
  if (!truncated && hadHooksKey && hooks.length === 0 && unrecognized.length === 0 && sawLeaf) {
487
507
  // A populated hooks block this walker couldn't pull a command out of
488
508
  // is a coverage gap, and coverage gaps get said out loud. sawLeaf
489
509
  // keeps a legitimately empty {"SessionStart": []} block from tripping
490
510
  // this.
491
- add("info", "hook-unrecognized", file, "has a hooks section whose shape this check doesn't recognize no commands extracted; review it manually");
511
+ add("info", "hook-unrecognized", file, "has a hooks section whose shape this check doesn't recognize: no commands extracted; review it manually");
492
512
  continue;
493
513
  }
494
514
  for (const h of hooks) {
495
515
  const where = `${h.event ? safePreview(h.event, 40) : "unknown event"}${h.matcher ? ` (matcher: ${safePreview(h.matcher, 40)})` : ""}`;
496
- const hit = suspicionReason(h.command, home);
516
+ const hit = suspicionReason(h.command, demotionHome);
497
517
  if (hit) {
498
518
  add(hit.severity, "hook", file, `${where} hook ${hit.reason}: "${safePreview(h.command)}"`);
499
519
  } else {
500
- add("info", "hook", file, `${where} hook runs automatically: "${safePreview(h.command)}" confirm you added this one`);
520
+ add("info", "hook", file, `${where} hook runs automatically: "${safePreview(h.command)}}"; confirm you added this one`);
501
521
  }
502
522
  }
503
523
  }
@@ -520,7 +540,7 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
520
540
  // exists but can't be listed hides whatever is in it.
521
541
  if (!(err && (err.code === "ENOENT" || err.code === "ENOTDIR"))) {
522
542
  mark(dir, "unreadable");
523
- add("warn", "unreadable-config", dir, "directory exists but could not be listed any loose scripts in it are unverified, not clean");
543
+ add("warn", "unreadable-config", dir, "directory exists but could not be listed; any loose scripts in it are unverified, not clean");
524
544
  }
525
545
  continue;
526
546
  }
@@ -531,9 +551,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
531
551
  // finding below always accompanies it (see the module header).
532
552
  mark(file, "checked");
533
553
  if (/^setup\.mjs$/i.test(e.name)) {
534
- add("warn", "dropper-name", file, "matches the exact dropper filename the Aug-2026 keyv/ChainDrop wave planted in .claude/ (Wiz IOC) if you did not create this file, do not run the agent from here until you've read it");
554
+ add("warn", "dropper-name", file, "matches the exact dropper filename the Aug-2026 keyv/ChainDrop wave planted in .claude/ (Wiz IOC); if you did not create this file, do not run the agent from here until you've read it");
535
555
  } else {
536
- add("info", "loose-script", file, "script at the top level of an agent config directory an auto-execution-adjacent location; review it");
556
+ add("info", "loose-script", file, "script at the top level of an agent config directory, an auto-execution-adjacent location; review it");
537
557
  }
538
558
  }
539
559
  }
@@ -553,9 +573,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
553
573
  // absence (EACCES, ELOOP) is unverified, not clean.
554
574
  mark(file, isFile ? "checked" : statFailed ? "unreadable" : "absent");
555
575
  if (statFailed) {
556
- add("warn", "unreadable-config", file, "location could not be examined (stat failed) unverified, not clean");
576
+ add("warn", "unreadable-config", file, "location could not be examined (stat failed): unverified, not clean");
557
577
  } else if (isFile) {
558
- add("warn", "dropper-name", file, "matches the exact dropper filename the Aug-2026 keyv/ChainDrop wave planted in .vscode/ (Wiz IOC) if you did not create this file, review it before opening this folder in VS Code");
578
+ add("warn", "dropper-name", file, "matches the exact dropper filename the Aug-2026 keyv/ChainDrop wave planted in .vscode/ (Wiz IOC); if you did not create this file, review it before opening this folder in VS Code");
559
579
  }
560
580
  }
561
581
  }
@@ -572,10 +592,10 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
572
592
  const bad = hits.filter((h) => h.suspicious);
573
593
  const joiners = hits.filter((h) => !h.suspicious);
574
594
  if (bad.length > 0) {
575
- add("warn", "zero-width", file, `${bad.length} invisible character${bad.length === 1 ? "" : "s"}: ${summarizeZeroWidth(bad)} zero-width Unicode carried hidden agent instructions in the TrapDoor campaign; inspect with a hex viewer before trusting this file`);
595
+ add("warn", "zero-width", file, `${bad.length} invisible character${bad.length === 1 ? "" : "s"}: ${summarizeZeroWidth(bad)} : zero-width Unicode carried hidden agent instructions in the TrapDoor campaign; inspect with a hex viewer before trusting this file`);
576
596
  }
577
597
  if (joiners.length > 0) {
578
- add("info", "zero-width", file, `${summarizeZeroWidth(joiners)} adjacent to non-ASCII text usually legitimate emoji/script joiners; listed so the count above can't quietly absorb them`);
598
+ add("info", "zero-width", file, `${summarizeZeroWidth(joiners)} adjacent to non-ASCII text: usually legitimate emoji/script joiners; listed so the count above can't quietly absorb them`);
579
599
  }
580
600
  };
581
601
  for (const file of zwFiles) {
@@ -593,7 +613,7 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
593
613
  catch (err) {
594
614
  if (!(err && (err.code === "ENOENT" || err.code === "ENOTDIR"))) {
595
615
  mark(rulesDir, "unreadable");
596
- add("warn", "unreadable-config", rulesDir, "rules directory exists but could not be listed its contents are unverified, not clean");
616
+ add("warn", "unreadable-config", rulesDir, "rules directory exists but could not be listed; its contents are unverified, not clean");
597
617
  }
598
618
  }
599
619
  if (entries) {
@@ -607,9 +627,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
607
627
  // filesChecked lie about verification depth.
608
628
  const text = readOrReport(file);
609
629
  if (/^setup\.mdc$/i.test(e.name)) {
610
- add("warn", "dropper-name", file, "matches the rules filename Miasma planted (.cursor/rules/setup.mdc) to prompt-inject Cursor on repo open confirm you created it");
630
+ add("warn", "dropper-name", file, "matches the rules filename Miasma planted (.cursor/rules/setup.mdc) to prompt-inject Cursor on repo open; confirm you created it");
611
631
  } else {
612
- add("info", "cursor-rule", file, "Cursor loads rules files as agent instructions automatically confirm you added this one");
632
+ add("info", "cursor-rule", file, "Cursor loads rules files as agent instructions automatically; confirm you added this one");
613
633
  }
614
634
  if (text !== null) zwCheck(file, text);
615
635
  }
@@ -624,9 +644,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
624
644
  if (text !== null) {
625
645
  let parsed = null;
626
646
  try { parsed = JSON.parse(stripJsonc(text)); } catch {
627
- add("warn", "unparseable-config", file, "not valid JSON even after comment/trailing-comma stripping unverified, not clean; raw text was signature-checked instead");
647
+ add("warn", "unparseable-config", file, "not valid JSON even after comment/trailing-comma stripping: unverified, not clean; raw text was signature-checked instead");
628
648
  if (/"runOn"\s*:\s*"folderOpen"/.test(text)) {
629
- add("warn", "autorun-task", file, 'raw text contains "runOn": "folderOpen" a task that executes on folder open (Mini Shai-Hulud/Miasma persistence); review it');
649
+ add("warn", "autorun-task", file, 'raw text contains "runOn": "folderOpen", a task that executes on folder open (Mini Shai-Hulud/Miasma persistence); review it');
630
650
  }
631
651
  }
632
652
  if (parsed) {
@@ -643,14 +663,14 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
643
663
  if (!node || typeof node !== "object") continue;
644
664
  if (node.runOptions && node.runOptions.runOn === "folderOpen") {
645
665
  const what = node.label || node.command || node.script || "(unnamed task)";
646
- add("warn", "autorun-task", file, `task "${safePreview(what, 60)}" runs on folder open ("runOn": "folderOpen") the Mini Shai-Hulud/Miasma persistence mechanism; confirm you added it`);
666
+ add("warn", "autorun-task", file, `task "${safePreview(what, 60)}" runs on folder open ("runOn": "folderOpen"), the Mini Shai-Hulud/Miasma persistence mechanism; confirm you added it`);
647
667
  }
648
668
  for (const v of Object.values(node)) stack.push(v);
649
669
  }
650
670
  if (truncated) {
651
- add("warn", "unwalkable-config", file, `structure exceeds ${MAX_WALK_NODES} nodes far beyond any hand-written tasks.json; auto-run tasks in it are unverified, not clean, review the file directly`);
671
+ add("warn", "unwalkable-config", file, `structure exceeds ${MAX_WALK_NODES} nodes, far beyond any hand-written tasks.json; auto-run tasks in it are unverified, not clean, review the file directly`);
652
672
  if (/"runOn"\s*:\s*"folderOpen"/.test(text)) {
653
- add("warn", "autorun-task", file, 'raw text contains "runOn": "folderOpen" a task that executes on folder open (Mini Shai-Hulud/Miasma persistence); review it');
673
+ add("warn", "autorun-task", file, 'raw text contains "runOn": "folderOpen", a task that executes on folder open (Mini Shai-Hulud/Miasma persistence); review it');
654
674
  }
655
675
  }
656
676
  }
package/src/patterns.js CHANGED
@@ -26,8 +26,26 @@ const PATTERNS = [
26
26
  re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
27
27
  { id: "slack_token", label: "Slack token", confidence: "high",
28
28
  re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
29
- { id: "stripe_key", label: "Stripe API key", confidence: "high",
29
+ { id: "stripe_key", label: "Stripe API key (live mode)", confidence: "high",
30
30
  re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
31
+ // The sandbox-mode twin of the rule above, same body charset and the same
32
+ // 20-char floor. Format verified against two production detectors plus the
33
+ // vendor (2026-09-02): gitleaks' stripe-access-token rule matches
34
+ // (sk|rk)_(test|live|prod)_[a-zA-Z0-9]{10,99}; trufflehog's Stripe
35
+ // detector is [rs]k_live_[a-zA-Z0-9]{20,247} with an explicit
36
+ // "doesn't include test keys" comment (a scope choice, not a format
37
+ // claim); and Stripe's own docs (docs.stripe.com/keys) name sk_test_ and
38
+ // rk_test_ as the sandbox secret/restricted prefixes. A separate rule
39
+ // rather than a widened live regex so a report can say WHICH mode leaked
40
+ // and rotation guidance can differ. A test key in a transcript is a real
41
+ // finding, not noise: the prefix is vendor-unique, the key grants full
42
+ // API access to the sandbox account (Stripe's docs: a secret key has
43
+ // unrestricted permissions on all Stripe APIs in its mode, and sandbox
44
+ // mode exposes ALL of the account's keys to whoever can call it), and a
45
+ // transcript that pastes sk_test today is the same workflow that will
46
+ // paste sk_live at go-live.
47
+ { id: "stripe_test_key", label: "Stripe API key (test mode)", confidence: "high",
48
+ re: /\b(sk|rk)_test_[A-Za-z0-9]{20,}\b/g },
31
49
  // The negative lookahead keeps this rule mutually exclusive with anthropic_key
32
50
  // and openrouter_key below — without it, "sk-ant-..." or "sk-or-v1-..." match
33
51
  // BOTH this pattern and the more specific one, and get reported twice under
package/src/report.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const path = require("path");
4
+ const { fingerprintFinding, ROTATION_ORDER_ADVISORY } = require("./rotation");
4
5
 
5
6
  // Minimal raw ANSI — no chalk, no deps. A security tool asking you to trust
6
7
  // a pile of third-party packages before it's even scanned anything is a bad
@@ -28,6 +29,90 @@ function ageDays(mtimeMs) {
28
29
  return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86400000));
29
30
  }
30
31
 
32
+ // File NAMES are attacker-controllable text headed for a terminal: in
33
+ // --project mode a hostile checkout chooses its own filenames, and a name
34
+ // carrying raw ESC bytes could clear the screen or overwrite the findings
35
+ // block with a spoofed all-clear. Same discipline integrity.js applies to
36
+ // its displayed paths (its stripControlChars/escapeInvisibles pair, mirrored
37
+ // here rather than exported: patterns.js and integrity.js each keep their own
38
+ // copy for the same shared-file reason): control bytes stripped, invisible
39
+ // code points made visible so a zero-width name cannot render as nothing.
40
+ const INVISIBLES_RE = /[\u200b\u200c\u200d\u2060\ufeff\u{e0000}-\u{e007f}]/gu;
41
+ function safeBasename(file) {
42
+ return path.basename(String(file))
43
+ .replace(/[\x00-\x1f\x7f]/g, "")
44
+ .replace(INVISIBLES_RE, (ch) => "\\u{" + ch.codePointAt(0).toString(16).toUpperCase() + "}");
45
+ }
46
+
47
+ // Plain greedy word wrap for the one long-paragraph string this report prints
48
+ // (the rotation ordering advisory). Continuation lines get the indent.
49
+ function wrapText(s, width, indent) {
50
+ const words = String(s).split(/\s+/);
51
+ const lines = [];
52
+ let cur = "";
53
+ for (const w of words) {
54
+ if (cur && cur.length + 1 + w.length > width) { lines.push(cur); cur = w; }
55
+ else cur = cur ? cur + " " + w : w;
56
+ }
57
+ if (cur) lines.push(cur);
58
+ return lines.map((l, i) => (i === 0 ? l : indent + l));
59
+ }
60
+
61
+ /**
62
+ * The rotation section: what to DO about each distinct finding, fed by
63
+ * src/rotation.js's renderRotation() (pure data) and printed in the same
64
+ * visual language as the rest of the report. Compact on purpose: one status
65
+ * line plus one guidance pointer per distinct value; the full runbook lives
66
+ * behind "residoo explain <rule-id>" so the report stays scannable. The
67
+ * fingerprint is printed in full because it is the exact argument
68
+ * "residoo ack" takes; a truncated one would be prettier and useless.
69
+ *
70
+ * `showAdvisory` is set by render() only when integrity WARNINGS and secret
71
+ * findings coexist in one run: that is the ChainDrop scenario where rotating
72
+ * first can itself trigger the planted payload, so remediation order becomes
73
+ * safety-critical and the report says so before listing anything to rotate.
74
+ */
75
+ function renderRotationSection(rotation, { noColor = false, showAdvisory = false } = {}) {
76
+ const paint = makePaint(noColor);
77
+ const lines = [];
78
+ const push = (s = "") => lines.push(s);
79
+ const { counts, entries } = rotation;
80
+ if (counts.distinct === 0) return "";
81
+
82
+ // "rotations", not "distinct values": the headline's distinct count dedupes
83
+ // raw values, while these entries dedupe fingerprints (which include the
84
+ // basename, so one value in two differently-named files is two rotations to
85
+ // track). Two counts under one word would read as a contradiction.
86
+ push(paint(c.bold, "Rotation:") +
87
+ ` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
88
+ (counts.acked > 0 ? ` (${counts.acked} acknowledged)` : ""));
89
+ if (showAdvisory) {
90
+ const wrapped = wrapText(ROTATION_ORDER_ADVISORY, 72, " ");
91
+ push(` ${paint(c.red + c.bold, "⚠ " + wrapped[0])}`);
92
+ for (const l of wrapped.slice(1)) push(` ${paint(c.red, l)}`);
93
+ }
94
+ // Same anti-flood policy as the by-file table: a report is a summary, not a
95
+ // dump. Everything elided here is in --json in full.
96
+ const MAX_SHOWN = 12;
97
+ const shown = entries.slice(0, MAX_SHOWN);
98
+ for (const e of shown) {
99
+ const tag = e.status === "pending" ? paint(c.yellow, "pending") : paint(c.green, "acked ");
100
+ push(` ${tag} ${e.fingerprint} ${e.label}`);
101
+ if (e.status === "acked") {
102
+ push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""}`));
103
+ } else {
104
+ const g = e.guidance;
105
+ const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
106
+ push(paint(c.dim, ` ${where}`));
107
+ }
108
+ }
109
+ if (entries.length > shown.length) {
110
+ push(paint(c.dim, ` … and ${entries.length - shown.length} more; see --json for the full list`));
111
+ }
112
+ push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · mark one rotated: residoo ack <fingerprint>`));
113
+ return lines.join("\n");
114
+ }
115
+
31
116
  /**
32
117
  * The integrity section — findings from src/integrity.js, rendered in the
33
118
  * same visual language as the scan report. Severity drives everything:
@@ -75,7 +160,7 @@ function renderIntegrity(integrity, { noColor = false } = {}) {
75
160
  return lines.join("\n");
76
161
  }
77
162
 
78
- function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount = 0, distinctCounts = {}, unreadableFiles = [] }, { noColor = false, integrity = null } = {}) {
163
+ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount = 0, distinctCounts = {}, unreadableFiles = [] }, { noColor = false, integrity = null, rotation = null } = {}) {
79
164
  const paint = makePaint(noColor);
80
165
  const lines = [];
81
166
  const push = (s = "") => lines.push(s);
@@ -131,17 +216,32 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
131
216
  const distinctNote = distinct && distinct !== items.length
132
217
  ? paint(c.dim, ` (${distinct} distinct value${distinct === 1 ? "" : "s"}, re-exposed ${items.length - distinct}× across tool output)`)
133
218
  : "";
134
- push(` ${paint(c.bold, String(items.length).padStart(4))} [${tag}] ${label}${distinctNote}`);
219
+ // Flag when a rule's matches came from a decode/reconstruct pass rather
220
+ // than plain text: those would be invisible to a line-oriented scanner,
221
+ // so the reader should know the value was hidden.
222
+ const encoded = items.filter((f) => f.encoding).length;
223
+ const split = items.filter((f) => f.spanLines).length;
224
+ const marks = [];
225
+ if (encoded) marks.push(`${encoded} base64-wrapped`);
226
+ if (split) marks.push(`${split} split across lines`);
227
+ const markNote = marks.length ? paint(c.yellow, ` [${marks.join(", ")}]`) : "";
228
+ push(` ${paint(c.bold, String(items.length).padStart(4))} [${tag}] ${label}${distinctNote}${markNote}`);
135
229
  }
136
230
 
137
231
  push();
138
232
  push(paint(c.bold, "By file:"));
139
233
  const fileRows = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
140
234
  for (const [file, count] of fileRows) {
141
- push(` ${String(count).padStart(4)} ${paint(c.cyan, path.basename(file))}`);
235
+ push(` ${String(count).padStart(4)} ${paint(c.cyan, safeBasename(file))}`);
142
236
  }
143
237
  if (byFile.size > fileRows.length) push(paint(c.dim, ` … and ${byFile.size - fileRows.length} more file(s)`));
144
238
 
239
+ if (rotation && rotation.counts.distinct > 0) {
240
+ const integrityWarns = integrity ? integrity.findings.filter((f) => f.severity === "warn").length : 0;
241
+ push();
242
+ push(renderRotationSection(rotation, { noColor, showAdvisory: integrityWarns > 0 }));
243
+ }
244
+
145
245
  if (integrity) {
146
246
  push();
147
247
  push(renderIntegrity(integrity, { noColor }));
@@ -157,7 +257,12 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
157
257
  // `integrity` is the checkIntegrity() result, or null when --no-integrity
158
258
  // skipped it — the key is always present so a --json consumer can tell
159
259
  // "checked, clean" apart from "never checked" without guessing from absence.
160
- function renderJson(result, integrity = null) {
260
+ // `rotation` is src/rotation.js's renderRotation() result (counts + per-
261
+ // distinct-fingerprint entries with guidance attached), or null for a caller
262
+ // that never computed it; the per-finding `fingerprint` is emitted either
263
+ // way, since it is derived from already-redacted material and is what
264
+ // "residoo ack" takes.
265
+ function renderJson(result, integrity = null, rotation = null) {
161
266
  return JSON.stringify(
162
267
  {
163
268
  summary: {
@@ -172,7 +277,33 @@ function renderJson(result, integrity = null) {
172
277
  findings: result.findings.map((f) => ({
173
278
  rule: f.ruleId, label: f.label, confidence: f.confidence,
174
279
  source: f.source, file: f.relFile, line: f.line, preview: f.preview,
280
+ // Markers for the two decode/reconstruct passes (absent on ordinary
281
+ // findings). `encoding` names how the value was wrapped ("base64" /
282
+ // "base64url"); `spanLines` names the adjacent line pair a split value
283
+ // was reconstructed across.
284
+ ...(f.encoding ? { encoding: f.encoding } : {}),
285
+ ...(f.spanLines ? { spanLines: f.spanLines } : {}),
286
+ fingerprint: fingerprintFinding(f),
287
+ // Only present on an --include-suppressed run: says WHY this finding
288
+ // is low-confidence, so a JSON consumer doesn't have to guess.
289
+ ...(f.suppressedReason ? { suppressedReason: f.suppressedReason } : {}),
175
290
  })),
291
+ // orderAdvisory mirrors the human report's ChainDrop ordering warning:
292
+ // remediation order is safety-critical when planted persistence and
293
+ // leaked credentials coexist, and a --json consumer (a CI summarizer)
294
+ // must not have to re-derive that condition. The advisory text when the
295
+ // condition holds, null otherwise.
296
+ rotation: rotation
297
+ ? {
298
+ counts: rotation.counts,
299
+ entries: rotation.entries,
300
+ orderAdvisory:
301
+ result.findings.length > 0 &&
302
+ integrity && integrity.findings.some((f) => f.severity === "warn")
303
+ ? ROTATION_ORDER_ADVISORY
304
+ : null,
305
+ }
306
+ : null,
176
307
  integrity: integrity
177
308
  ? {
178
309
  warningCount: integrity.findings.filter((f) => f.severity === "warn").length,
@@ -187,4 +318,4 @@ function renderJson(result, integrity = null) {
187
318
  );
188
319
  }
189
320
 
190
- module.exports = { render, renderIntegrity, renderJson };
321
+ module.exports = { render, renderIntegrity, renderRotationSection, renderJson };