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.
Files changed (52) hide show
  1. package/README.md +334 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +249 -17
  5. package/src/integrity.js +689 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +188 -8
  8. package/src/rotation.js +834 -0
  9. package/src/sources/agent-configs.js +308 -0
  10. package/src/sources/aider.js +361 -0
  11. package/src/sources/amazon-q.js +199 -0
  12. package/src/sources/antigravity-cli.js +155 -0
  13. package/src/sources/cline.js +208 -0
  14. package/src/sources/codebuff.js +295 -0
  15. package/src/sources/codex-cli.js +258 -0
  16. package/src/sources/cody.js +325 -0
  17. package/src/sources/continue.js +408 -0
  18. package/src/sources/copilot-chat.js +272 -0
  19. package/src/sources/copilot-cli.js +300 -0
  20. package/src/sources/crush.js +364 -0
  21. package/src/sources/cursor.js +374 -0
  22. package/src/sources/devin-cli.js +241 -0
  23. package/src/sources/factory-droid.js +153 -0
  24. package/src/sources/fx.js +136 -0
  25. package/src/sources/gemini-cli.js +242 -0
  26. package/src/sources/goose.js +366 -0
  27. package/src/sources/grok-cli.js +267 -0
  28. package/src/sources/hermes.js +282 -0
  29. package/src/sources/index.js +172 -8
  30. package/src/sources/jetbrains-ai-assistant.js +343 -0
  31. package/src/sources/jetbrains-junie.js +292 -0
  32. package/src/sources/kilo-code.js +430 -0
  33. package/src/sources/kimi-code.js +147 -0
  34. package/src/sources/kiro-cli.js +393 -0
  35. package/src/sources/kiro-ide.js +230 -0
  36. package/src/sources/llm.js +328 -0
  37. package/src/sources/mentat.js +143 -0
  38. package/src/sources/open-interpreter.js +224 -0
  39. package/src/sources/openclaw.js +218 -0
  40. package/src/sources/opencode.js +379 -0
  41. package/src/sources/openhands.js +181 -0
  42. package/src/sources/pearai.js +151 -0
  43. package/src/sources/pi-agent.js +130 -0
  44. package/src/sources/project-artifacts.js +355 -0
  45. package/src/sources/qodo-gen.js +189 -0
  46. package/src/sources/qwen-code.js +244 -0
  47. package/src/sources/roo-code.js +239 -0
  48. package/src/sources/trae.js +294 -0
  49. package/src/sources/void.js +273 -0
  50. package/src/sources/warp.js +395 -0
  51. package/src/sources/windsurf.js +256 -0
  52. 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 and anthropic_key mutually exclusive —
32
- // without it, "sk-ant-..." matches BOTH patterns and gets reported twice under
33
- // two different (one wrong) provider labels. Verified: both regexes independently
34
- // matched a synthetic sk-ant- key before this fix.
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
- function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount = 0, distinctCounts = {}, unreadableFiles = [] }, { noColor = false } = {}) {
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 — see --include-suppressed)`)
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 — see --json for which and why.`)
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
- ` — ${filesScanned} file${filesScanned === 1 ? "" : "s"} scanned across ${sourcesScanned.join(", ") || "no sources"}.` +
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, path.basename(file))}`);
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 — first/last 4 characters only. Nothing scanned"));
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
- function renderJson(result) {
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 };