residoo 0.1.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 +334 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +249 -17
- package/src/integrity.js +689 -0
- package/src/patterns.js +78 -5
- package/src/report.js +188 -8
- package/src/rotation.js +834 -0
- package/src/sources/agent-configs.js +308 -0
- package/src/sources/aider.js +361 -0
- package/src/sources/amazon-q.js +199 -0
- package/src/sources/antigravity-cli.js +155 -0
- package/src/sources/cline.js +208 -0
- package/src/sources/codebuff.js +295 -0
- package/src/sources/codex-cli.js +258 -0
- package/src/sources/cody.js +325 -0
- package/src/sources/continue.js +408 -0
- package/src/sources/copilot-chat.js +272 -0
- package/src/sources/copilot-cli.js +300 -0
- package/src/sources/crush.js +364 -0
- package/src/sources/cursor.js +374 -0
- package/src/sources/devin-cli.js +241 -0
- package/src/sources/factory-droid.js +153 -0
- package/src/sources/fx.js +136 -0
- package/src/sources/gemini-cli.js +242 -0
- package/src/sources/goose.js +366 -0
- package/src/sources/grok-cli.js +267 -0
- package/src/sources/hermes.js +282 -0
- package/src/sources/index.js +172 -8
- package/src/sources/jetbrains-ai-assistant.js +343 -0
- package/src/sources/jetbrains-junie.js +292 -0
- package/src/sources/kilo-code.js +430 -0
- package/src/sources/kimi-code.js +147 -0
- package/src/sources/kiro-cli.js +393 -0
- package/src/sources/kiro-ide.js +230 -0
- package/src/sources/llm.js +328 -0
- package/src/sources/mentat.js +143 -0
- package/src/sources/open-interpreter.js +224 -0
- package/src/sources/openclaw.js +218 -0
- package/src/sources/opencode.js +379 -0
- package/src/sources/openhands.js +181 -0
- package/src/sources/pearai.js +151 -0
- package/src/sources/pi-agent.js +130 -0
- package/src/sources/project-artifacts.js +355 -0
- package/src/sources/qodo-gen.js +189 -0
- package/src/sources/qwen-code.js +244 -0
- package/src/sources/roo-code.js +239 -0
- package/src/sources/trae.js +294 -0
- package/src/sources/void.js +273 -0
- package/src/sources/warp.js +395 -0
- package/src/sources/windsurf.js +256 -0
- package/src/sources/zed.js +374 -0
package/src/patterns.js
CHANGED
|
@@ -28,12 +28,13 @@ const PATTERNS = [
|
|
|
28
28
|
re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
|
|
29
29
|
{ id: "stripe_key", label: "Stripe API key", confidence: "high",
|
|
30
30
|
re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
|
|
31
|
-
// The negative lookahead keeps this rule
|
|
32
|
-
// without it, "sk-ant-..."
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
// The negative lookahead keeps this rule mutually exclusive with anthropic_key
|
|
32
|
+
// and openrouter_key below — without it, "sk-ant-..." or "sk-or-v1-..." match
|
|
33
|
+
// BOTH this pattern and the more specific one, and get reported twice under
|
|
34
|
+
// two different (one wrong) provider labels. Verified: all three regexes
|
|
35
|
+
// independently matched their overlapping synthetic keys before this fix.
|
|
35
36
|
{ id: "openai_key", label: "OpenAI API key", confidence: "high",
|
|
36
|
-
re: /\bsk-(?!ant-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
37
|
+
re: /\bsk-(?!ant-|or-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
37
38
|
{ id: "anthropic_key", label: "Anthropic API key", confidence: "high",
|
|
38
39
|
re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
39
40
|
{ id: "google_api_key", label: "Google / Firebase API key", confidence: "high",
|
|
@@ -54,6 +55,78 @@ const PATTERNS = [
|
|
|
54
55
|
re: /"refresh_token"\s*:\s*"[^"\s]{20,}"/gi },
|
|
55
56
|
{ id: "access_token_field", label: "access_token field", confidence: "medium",
|
|
56
57
|
re: /"access_token"\s*:\s*"[^"\s]{20,}"/gi },
|
|
58
|
+
|
|
59
|
+
// ── AI / LLM providers (added: competitive gap-close, see project history) ─
|
|
60
|
+
// Every regex body below was checked against a production, field-tested
|
|
61
|
+
// detector — trufflehog's (github.com/trufflesecurity/trufflehog,
|
|
62
|
+
// pkg/detectors/<vendor>) — not guessed from a blog post, as of 2026-09.
|
|
63
|
+
// Cohere, Mistral, Together AI, Fireworks and DeepSeek were researched too
|
|
64
|
+
// and deliberately left out: none has a trufflehog detector, official docs
|
|
65
|
+
// describe them as unprefixed/opaque tokens, and DeepSeek's "sk-" prefix is
|
|
66
|
+
// provably indistinguishable from OpenAI's (trufflehog's own DeepSeek
|
|
67
|
+
// detector only fires with a nearby "deepseek" keyword as extra context,
|
|
68
|
+
// which this flat-regex model doesn't have) — exactly the shaky-prefix case
|
|
69
|
+
// this file's own header comment says to leave out rather than force.
|
|
70
|
+
{ id: "groq_key", label: "Groq API key", confidence: "high",
|
|
71
|
+
re: /\bgsk_[a-zA-Z0-9]{52}\b/g },
|
|
72
|
+
{ id: "xai_key", label: "xAI (Grok) API key", confidence: "high",
|
|
73
|
+
re: /\bxai-[0-9a-zA-Z_]{80}\b/g },
|
|
74
|
+
{ id: "openrouter_key", label: "OpenRouter API key", confidence: "high",
|
|
75
|
+
re: /\bsk-or-v1-[0-9a-f]{64}\b/g },
|
|
76
|
+
{ id: "huggingface_token", label: "Hugging Face access token", confidence: "high",
|
|
77
|
+
re: /\b(?:hf_|api_org_)[a-zA-Z0-9]{34}\b/g },
|
|
78
|
+
{ id: "pinecone_key", label: "Pinecone API key", confidence: "high",
|
|
79
|
+
re: /\bpcsk_[A-Za-z0-9]{5,6}_[A-Za-z0-9]{63}\b/g },
|
|
80
|
+
// No trufflehog detector exists for this one, so it leans on a second,
|
|
81
|
+
// independent signal instead: Perplexity's own product is literally named
|
|
82
|
+
// "pplx-api" (see their "Introducing pplx-api" launch post), and every
|
|
83
|
+
// integration doc that shows a real key (liteLLM, apideck, etc.) agrees on
|
|
84
|
+
// "pplx-" + a >=40-char body — consistent across independent sources even
|
|
85
|
+
// without one canonical spec page.
|
|
86
|
+
{ id: "perplexity_key", label: "Perplexity API key", confidence: "high",
|
|
87
|
+
re: /\bpplx-[A-Za-z0-9]{40,}\b/g },
|
|
88
|
+
{ id: "replicate_token", label: "Replicate API token", confidence: "high",
|
|
89
|
+
re: /\br8_[0-9A-Za-z_-]{37}\b/g },
|
|
90
|
+
|
|
91
|
+
// ── Cloud / infra ──────────────────────────────────────────────────────
|
|
92
|
+
{ id: "digitalocean_token", label: "DigitalOcean access token", confidence: "high",
|
|
93
|
+
re: /\b(?:dop|doo|dor)_v1_[a-f0-9]{64}\b/g },
|
|
94
|
+
{ id: "supabase_token", label: "Supabase personal access token", confidence: "high",
|
|
95
|
+
re: /\bsbp_[a-z0-9]{40}\b/g },
|
|
96
|
+
{ id: "vault_token", label: "HashiCorp Vault service token", confidence: "high",
|
|
97
|
+
// Vault 1.10+ format only (hvs.<90-120 chars>). The pre-1.10 legacy
|
|
98
|
+
// format is a bare "s." + 18-40 chars — "s." is nowhere near specific
|
|
99
|
+
// enough to be a vendor prefix, so that older shape is deliberately left
|
|
100
|
+
// out rather than turned into a noisy 2-character trigger.
|
|
101
|
+
re: /\bhvs\.[A-Za-z0-9_-]{90,120}\b/g },
|
|
102
|
+
{ id: "onepassword_service_token", label: "1Password service account token", confidence: "high",
|
|
103
|
+
// Confirmed against 1Password's own developer docs (developer.1password.com
|
|
104
|
+
// -> 1password.dev/service-accounts/security): the token is "ops_" plus a
|
|
105
|
+
// base64-encoded JWT, so it always continues "eyJ" (base64 of `{"`).
|
|
106
|
+
re: /\bops_eyJ[A-Za-z0-9+/=_-]{40,}\b/g },
|
|
107
|
+
|
|
108
|
+
// ── Comms / SaaS ───────────────────────────────────────────────────────
|
|
109
|
+
{ id: "discord_webhook", label: "Discord webhook URL", confidence: "high",
|
|
110
|
+
re: /\bhttps:\/\/discord\.com\/api\/webhooks\/[0-9]{18,19}\/[0-9a-zA-Z_-]{68}\b/g },
|
|
111
|
+
{ id: "telegram_bot_token", label: "Telegram bot token", confidence: "high",
|
|
112
|
+
re: /\b[0-9]{8,10}:[a-zA-Z0-9_-]{35}\b/g },
|
|
113
|
+
{ id: "mailgun_key", label: "Mailgun API key", confidence: "high",
|
|
114
|
+
re: /\bkey-[a-z0-9]{32}\b/g },
|
|
115
|
+
// Notion's own docs explicitly warn against regex-matching its tokens,
|
|
116
|
+
// since the format has changed before and may again — noted, not ignored,
|
|
117
|
+
// and worth restating here rather than treating this as equally solid as
|
|
118
|
+
// the others. secret_ (legacy, exactly 43 chars) is trufflehog-verified;
|
|
119
|
+
// ntn_ (current format since 2024-09-25) is vendor-confirmed as a prefix
|
|
120
|
+
// but Notion has not published an exact body length for it, so its bound
|
|
121
|
+
// below is a floor, not a verified exact count.
|
|
122
|
+
{ id: "notion_token", label: "Notion integration token", confidence: "high",
|
|
123
|
+
re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{20,})\b/g },
|
|
124
|
+
{ id: "linear_key", label: "Linear API key", confidence: "high",
|
|
125
|
+
re: /\blin_api_[0-9A-Za-z]{40}\b/g },
|
|
126
|
+
{ id: "sentry_token", label: "Sentry auth token", confidence: "high",
|
|
127
|
+
// Covers both current Sentry token shapes: org-scoped (sntrys_, base64
|
|
128
|
+
// JWT-like body) and user-scoped (sntryu_, hex body).
|
|
129
|
+
re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,}|sntryu_[a-f0-9]{64})\b/g },
|
|
57
130
|
];
|
|
58
131
|
|
|
59
132
|
/**
|
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,13 +29,144 @@ function ageDays(mtimeMs) {
|
|
|
28
29
|
return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86400000));
|
|
29
30
|
}
|
|
30
31
|
|
|
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
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The integrity section — findings from src/integrity.js, rendered in the
|
|
118
|
+
* same visual language as the scan report. Severity drives everything:
|
|
119
|
+
* "warn" (a verified campaign signature, or a location that exists but
|
|
120
|
+
* couldn't be verified) paints red and counts toward --fail-on-find; "info"
|
|
121
|
+
* (a hook/rules file that runs automatically and merely deserves the user's
|
|
122
|
+
* confirmation) stays dim. Every finding is printed — integrity findings are
|
|
123
|
+
* few by construction (checkIntegrity caps its own noise), so unlike scan
|
|
124
|
+
* findings they are never truncated to a top-N.
|
|
125
|
+
*
|
|
126
|
+
* Exported separately because cli.js's "no transcript sources on this
|
|
127
|
+
* machine" path still runs the integrity checks — a planted repo-level hook
|
|
128
|
+
* is exactly as dangerous on a machine with no transcripts to scan.
|
|
129
|
+
*/
|
|
130
|
+
function renderIntegrity(integrity, { noColor = false } = {}) {
|
|
131
|
+
const paint = makePaint(noColor);
|
|
132
|
+
const lines = [];
|
|
133
|
+
const push = (s = "") => lines.push(s);
|
|
134
|
+
|
|
135
|
+
const warns = integrity.findings.filter((f) => f.severity === "warn");
|
|
136
|
+
const infos = integrity.findings.filter((f) => f.severity === "info");
|
|
137
|
+
const checked = integrity.filesChecked.filter((f) => f.status === "checked").length;
|
|
138
|
+
const absent = integrity.filesChecked.filter((f) => f.status === "absent").length;
|
|
139
|
+
|
|
140
|
+
if (integrity.findings.length === 0) {
|
|
141
|
+
push(paint(c.green + c.bold, "✓ Integrity: no planted hooks, droppers, or hidden instructions detected") +
|
|
142
|
+
`: ${checked} location${checked === 1 ? "" : "s"} checked, ${absent} absent.`);
|
|
143
|
+
} else if (warns.length > 0) {
|
|
144
|
+
push(paint(c.red + c.bold, `⚠ Integrity: ${warns.length} warning${warns.length === 1 ? "" : "s"}`) +
|
|
145
|
+
(infos.length > 0 ? ` + ${infos.length} item${infos.length === 1 ? "" : "s"} to review` : "") +
|
|
146
|
+
paint(c.dim, ` · ${checked} location${checked === 1 ? "" : "s"} checked`));
|
|
147
|
+
} else {
|
|
148
|
+
push(paint(c.bold, `Integrity: ${infos.length} item${infos.length === 1 ? "" : "s"} to review`) +
|
|
149
|
+
paint(c.dim, ` (nothing matching a known campaign signature) · ${checked} location${checked === 1 ? "" : "s"} checked`));
|
|
150
|
+
}
|
|
151
|
+
for (const f of warns) {
|
|
152
|
+
push(` ${paint(c.red, "warn")} ${paint(c.cyan, f.file)}`);
|
|
153
|
+
push(` ${f.detail}`);
|
|
154
|
+
}
|
|
155
|
+
for (const f of infos) {
|
|
156
|
+
push(` ${paint(c.dim, "info")} ${paint(c.cyan, f.file)}`);
|
|
157
|
+
push(paint(c.dim, ` ${f.detail}`));
|
|
158
|
+
}
|
|
159
|
+
push(paint(c.dim, ` ${integrity.scopeNote}`));
|
|
160
|
+
return lines.join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount = 0, distinctCounts = {}, unreadableFiles = [] }, { noColor = false, integrity = null, rotation = null } = {}) {
|
|
32
164
|
const paint = makePaint(noColor);
|
|
33
165
|
const lines = [];
|
|
34
166
|
const push = (s = "") => lines.push(s);
|
|
35
167
|
|
|
36
168
|
const suppressedNote = suppressedCount > 0
|
|
37
|
-
? paint(c.dim, ` (${suppressedCount} more matched but looked like placeholder/example text
|
|
169
|
+
? paint(c.dim, ` (${suppressedCount} more matched but looked like placeholder/example text; see --include-suppressed)`)
|
|
38
170
|
: "";
|
|
39
171
|
// Surfaced, not silent: a file that couldn't be (fully) read was not fully
|
|
40
172
|
// scanned, and a report must not read as "checked and found nothing" for
|
|
@@ -43,14 +175,18 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
43
175
|
// project-name-derived directory slug, which is exactly the kind of thing
|
|
44
176
|
// every other line in this report is careful to redact down from.
|
|
45
177
|
const unreadableNote = unreadableFiles.length > 0
|
|
46
|
-
? paint(c.yellow, `⚠ ${unreadableFiles.length} file(s) not fully scanned
|
|
178
|
+
? paint(c.yellow, `⚠ ${unreadableFiles.length} file(s) not fully scanned. See --json for which and why.`)
|
|
47
179
|
: null;
|
|
48
180
|
|
|
49
181
|
if (findings.length === 0) {
|
|
50
182
|
push(paint(c.green + c.bold, "✓ No exposed secrets found") +
|
|
51
|
-
|
|
183
|
+
`: ${filesScanned} file${filesScanned === 1 ? "" : "s"} scanned across ${sourcesScanned.join(", ") || "no sources"}.` +
|
|
52
184
|
suppressedNote);
|
|
53
185
|
if (unreadableNote) push(unreadableNote);
|
|
186
|
+
if (integrity) {
|
|
187
|
+
push();
|
|
188
|
+
push(renderIntegrity(integrity, { noColor }));
|
|
189
|
+
}
|
|
54
190
|
return lines.join("\n");
|
|
55
191
|
}
|
|
56
192
|
|
|
@@ -87,18 +223,37 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
87
223
|
push(paint(c.bold, "By file:"));
|
|
88
224
|
const fileRows = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
89
225
|
for (const [file, count] of fileRows) {
|
|
90
|
-
push(` ${String(count).padStart(4)} ${paint(c.cyan,
|
|
226
|
+
push(` ${String(count).padStart(4)} ${paint(c.cyan, safeBasename(file))}`);
|
|
91
227
|
}
|
|
92
228
|
if (byFile.size > fileRows.length) push(paint(c.dim, ` … and ${byFile.size - fileRows.length} more file(s)`));
|
|
93
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
|
+
|
|
236
|
+
if (integrity) {
|
|
237
|
+
push();
|
|
238
|
+
push(renderIntegrity(integrity, { noColor }));
|
|
239
|
+
}
|
|
240
|
+
|
|
94
241
|
push();
|
|
95
|
-
push(paint(c.dim, "Values are redacted in this report
|
|
242
|
+
push(paint(c.dim, "Values are redacted in this report (first/last 4 characters only). Nothing scanned"));
|
|
96
243
|
push(paint(c.dim, "here left your machine; residoo makes no network calls. Run with --json for full detail."));
|
|
97
244
|
|
|
98
245
|
return lines.join("\n");
|
|
99
246
|
}
|
|
100
247
|
|
|
101
|
-
|
|
248
|
+
// `integrity` is the checkIntegrity() result, or null when --no-integrity
|
|
249
|
+
// skipped it — the key is always present so a --json consumer can tell
|
|
250
|
+
// "checked, clean" apart from "never checked" without guessing from absence.
|
|
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) {
|
|
102
257
|
return JSON.stringify(
|
|
103
258
|
{
|
|
104
259
|
summary: {
|
|
@@ -113,11 +268,36 @@ function renderJson(result) {
|
|
|
113
268
|
findings: result.findings.map((f) => ({
|
|
114
269
|
rule: f.ruleId, label: f.label, confidence: f.confidence,
|
|
115
270
|
source: f.source, file: f.relFile, line: f.line, preview: f.preview,
|
|
271
|
+
fingerprint: fingerprintFinding(f),
|
|
116
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,
|
|
289
|
+
integrity: integrity
|
|
290
|
+
? {
|
|
291
|
+
warningCount: integrity.findings.filter((f) => f.severity === "warn").length,
|
|
292
|
+
findings: integrity.findings,
|
|
293
|
+
filesChecked: integrity.filesChecked,
|
|
294
|
+
scopeNote: integrity.scopeNote,
|
|
295
|
+
}
|
|
296
|
+
: null,
|
|
117
297
|
},
|
|
118
298
|
null,
|
|
119
299
|
2
|
|
120
300
|
);
|
|
121
301
|
}
|
|
122
302
|
|
|
123
|
-
module.exports = { render, renderJson };
|
|
303
|
+
module.exports = { render, renderIntegrity, renderRotationSection, renderJson };
|