residoo 0.2.0 → 0.3.0
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/README.md +111 -2
- package/package.json +1 -1
- package/src/cli.js +171 -5
- package/src/integrity.js +55 -35
- package/src/report.js +117 -4
- package/src/rotation.js +834 -0
- package/src/sources/project-artifacts.js +355 -0
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
477
|
-
const hit = suspicionReason(text,
|
|
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
|
|
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
|
|
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,
|
|
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)}"
|
|
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
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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)}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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")
|
|
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
|
|
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"
|
|
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/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);
|
|
@@ -138,10 +223,16 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
138
223
|
push(paint(c.bold, "By file:"));
|
|
139
224
|
const fileRows = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
140
225
|
for (const [file, count] of fileRows) {
|
|
141
|
-
push(` ${String(count).padStart(4)} ${paint(c.cyan,
|
|
226
|
+
push(` ${String(count).padStart(4)} ${paint(c.cyan, safeBasename(file))}`);
|
|
142
227
|
}
|
|
143
228
|
if (byFile.size > fileRows.length) push(paint(c.dim, ` … and ${byFile.size - fileRows.length} more file(s)`));
|
|
144
229
|
|
|
230
|
+
if (rotation && rotation.counts.distinct > 0) {
|
|
231
|
+
const integrityWarns = integrity ? integrity.findings.filter((f) => f.severity === "warn").length : 0;
|
|
232
|
+
push();
|
|
233
|
+
push(renderRotationSection(rotation, { noColor, showAdvisory: integrityWarns > 0 }));
|
|
234
|
+
}
|
|
235
|
+
|
|
145
236
|
if (integrity) {
|
|
146
237
|
push();
|
|
147
238
|
push(renderIntegrity(integrity, { noColor }));
|
|
@@ -157,7 +248,12 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
157
248
|
// `integrity` is the checkIntegrity() result, or null when --no-integrity
|
|
158
249
|
// skipped it — the key is always present so a --json consumer can tell
|
|
159
250
|
// "checked, clean" apart from "never checked" without guessing from absence.
|
|
160
|
-
|
|
251
|
+
// `rotation` is src/rotation.js's renderRotation() result (counts + per-
|
|
252
|
+
// distinct-fingerprint entries with guidance attached), or null for a caller
|
|
253
|
+
// that never computed it; the per-finding `fingerprint` is emitted either
|
|
254
|
+
// way, since it is derived from already-redacted material and is what
|
|
255
|
+
// "residoo ack" takes.
|
|
256
|
+
function renderJson(result, integrity = null, rotation = null) {
|
|
161
257
|
return JSON.stringify(
|
|
162
258
|
{
|
|
163
259
|
summary: {
|
|
@@ -172,7 +268,24 @@ function renderJson(result, integrity = null) {
|
|
|
172
268
|
findings: result.findings.map((f) => ({
|
|
173
269
|
rule: f.ruleId, label: f.label, confidence: f.confidence,
|
|
174
270
|
source: f.source, file: f.relFile, line: f.line, preview: f.preview,
|
|
271
|
+
fingerprint: fingerprintFinding(f),
|
|
175
272
|
})),
|
|
273
|
+
// orderAdvisory mirrors the human report's ChainDrop ordering warning:
|
|
274
|
+
// remediation order is safety-critical when planted persistence and
|
|
275
|
+
// leaked credentials coexist, and a --json consumer (a CI summarizer)
|
|
276
|
+
// must not have to re-derive that condition. The advisory text when the
|
|
277
|
+
// condition holds, null otherwise.
|
|
278
|
+
rotation: rotation
|
|
279
|
+
? {
|
|
280
|
+
counts: rotation.counts,
|
|
281
|
+
entries: rotation.entries,
|
|
282
|
+
orderAdvisory:
|
|
283
|
+
result.findings.length > 0 &&
|
|
284
|
+
integrity && integrity.findings.some((f) => f.severity === "warn")
|
|
285
|
+
? ROTATION_ORDER_ADVISORY
|
|
286
|
+
: null,
|
|
287
|
+
}
|
|
288
|
+
: null,
|
|
176
289
|
integrity: integrity
|
|
177
290
|
? {
|
|
178
291
|
warningCount: integrity.findings.filter((f) => f.severity === "warn").length,
|
|
@@ -187,4 +300,4 @@ function renderJson(result, integrity = null) {
|
|
|
187
300
|
);
|
|
188
301
|
}
|
|
189
302
|
|
|
190
|
-
module.exports = { render, renderIntegrity, renderJson };
|
|
303
|
+
module.exports = { render, renderIntegrity, renderRotationSection, renderJson };
|