penthera 1.2.0 → 1.4.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 (88) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +379 -39
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/evidence.md +158 -0
  5. package/docs/examples/github-actions-deps.yml +105 -0
  6. package/docs/examples/github-actions-scan.yml +1 -1
  7. package/docs/examples/pr-dependency-review.yml +56 -0
  8. package/docs/funding/due-diligence-checklist.md +79 -0
  9. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  10. package/docs/funding/partner-one-pager.md +84 -0
  11. package/docs/funding/sponsor-tiers.md +81 -0
  12. package/docs/index.md +12 -1
  13. package/docs/local-loop.md +276 -0
  14. package/docs/references.bib +539 -0
  15. package/docs/references.md +77 -0
  16. package/docs/release-plan.md +228 -0
  17. package/lib/adaptive-probes.js +8 -5
  18. package/lib/agents/discover.js +384 -0
  19. package/lib/agents/hidden-content.js +387 -0
  20. package/lib/agents/index.js +286 -0
  21. package/lib/agents/lock.js +115 -0
  22. package/lib/agents/owasp.js +83 -0
  23. package/lib/agents/rules.js +376 -0
  24. package/lib/crawler.js +5 -15
  25. package/lib/data/retire-jsrepository.json.gz +0 -0
  26. package/lib/data/retire-jsrepository.meta.json +7 -0
  27. package/lib/deps/capabilities.js +523 -0
  28. package/lib/deps/diff.js +84 -0
  29. package/lib/deps/index.js +416 -0
  30. package/lib/deps/lockfile.js +106 -0
  31. package/lib/deps/online.js +132 -0
  32. package/lib/deps/popular-packages.json +1 -0
  33. package/lib/deps/report.js +173 -0
  34. package/lib/deps/source.js +171 -0
  35. package/lib/deps/typosquat.js +113 -0
  36. package/lib/fix/apply.js +95 -0
  37. package/lib/fix/diff.js +199 -0
  38. package/lib/fix/index.js +129 -0
  39. package/lib/fix/model.js +108 -0
  40. package/lib/fix/recipes/cookies.js +130 -0
  41. package/lib/fix/recipes/cors.js +116 -0
  42. package/lib/fix/recipes/headers.js +250 -0
  43. package/lib/fix/recipes/rate-limit.js +211 -0
  44. package/lib/fix/recipes/secrets.js +110 -0
  45. package/lib/fix/util.js +231 -0
  46. package/lib/fuzzer.js +14 -8
  47. package/lib/injections.js +19 -15
  48. package/lib/install/index.js +271 -0
  49. package/lib/install/policy.js +117 -0
  50. package/lib/memory/history.js +52 -0
  51. package/lib/memory/index.js +111 -0
  52. package/lib/memory/repo-suppress.js +64 -0
  53. package/lib/memory/store.js +325 -0
  54. package/lib/memory/views.js +198 -0
  55. package/lib/params.js +4 -12
  56. package/lib/provenance.js +2 -0
  57. package/lib/recon.js +12 -20
  58. package/lib/references.js +31 -0
  59. package/lib/references.json +374 -0
  60. package/lib/remediation/index.js +28 -0
  61. package/lib/retirejs.js +50 -22
  62. package/lib/templates.js +7 -10
  63. package/lib/whitebox/frameworks.js +1 -1
  64. package/package.json +16 -4
  65. package/skills/penthera/SKILL.md +103 -22
  66. package/src/agent/git.js +87 -0
  67. package/src/agent/loop.js +519 -0
  68. package/src/agent/tests.js +48 -0
  69. package/src/cli/agent.js +183 -0
  70. package/src/cli/deps.js +84 -0
  71. package/src/cli/evidence.js +67 -0
  72. package/src/cli/fix.js +191 -0
  73. package/src/cli/install.js +225 -0
  74. package/src/cli/memory.js +146 -0
  75. package/src/cli/merge-results.js +2 -0
  76. package/src/cli/report-outputs.js +72 -0
  77. package/src/cli/run-scan.js +2 -0
  78. package/src/cli.js +152 -6
  79. package/src/report/audience.js +270 -0
  80. package/src/report/evidence.js +253 -0
  81. package/src/report/markdown.js +65 -2
  82. package/src/report/model.js +212 -0
  83. package/src/report/template.js +175 -0
  84. package/src/report/timeline.js +145 -0
  85. package/src/reporter.js +109 -5
  86. package/src/scan-url.js +31 -7
  87. package/src/utils/http.js +252 -11
  88. package/src/utils/url.js +23 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `penthera install [pkg...]`: the install firewall CLI (lib/install/).
3
+ *
4
+ * Shows what an install would change (new packages, capabilities gained, new
5
+ * install scripts and their commands), applies penthera.policy.json, and only
6
+ * then runs the real `npm install`. Exit codes: 0 installed (or a clean
7
+ * --dry-run), 1 blocked or aborted (project untouched), 2 error.
8
+ */
9
+ import { parseArgs } from "node:util";
10
+ import { resolve, relative } from "node:path";
11
+ import { existsSync } from "node:fs";
12
+ import { planInstall, applyInstall, scriptChanges, npmInfo } from "../../lib/install/index.js";
13
+ import { loadPolicy, RULE_LABELS, POLICY_FILE } from "../../lib/install/policy.js";
14
+ import { createProgress, printError } from "../reporter.js";
15
+ import { initPrompt, ask, closePrompt } from "./prompt.js";
16
+ import { bold, dim, green, red, yellow, cyan } from "./ansi.js";
17
+
18
+ const HELP = `
19
+ ${bold("penthera install")} — install npm packages through a capability firewall
20
+
21
+ ${dim("Usage")}
22
+ $ penthera install <pkg> [<pkg>...] Check, then install (asks when the policy says so)
23
+ $ penthera install Same, for what package.json already lists
24
+
25
+ ${dim("Options")}
26
+ -y, --yes Never prompt: install only if nothing breaches the policy (CI)
27
+ --dry-run Report only; never install
28
+ --policy <file> Policy file (default: ./${POLICY_FILE}, else built-in defaults)
29
+ --dir <path> Project folder (default: .)
30
+ -D, --save-dev Save as devDependencies
31
+ -E, --save-exact Save exact versions
32
+ --offline npm resolves from its cache only (no registry traffic at all)
33
+ --json Print the plan as JSON
34
+ -v, --verbose Show more evidence per capability
35
+ -h, --help Show this help
36
+
37
+ ${dim("How it works")}
38
+ npm resolves the new tree in a temporary copy with scripts disabled, new
39
+ tarballs go into npm's cache (integrity-verified, never unpacked or run),
40
+ and the \`penthera deps\` engine diffs capabilities against your lockfile.
41
+ Nothing in your project changes until you approve. Approved install scripts
42
+ run; otherwise the install uses --ignore-scripts.
43
+
44
+ ${dim("Policy")} ${dim(`(${POLICY_FILE})`)}
45
+ { "allow": ["@acme/*", "esbuild"], "deny": ["token-harvest"],
46
+ "requireApproval": ["install-scripts", "ai-config-write", "ai-cli-invoke",
47
+ "token-harvest", "obfuscated", "typosquat", "unscanned"] }
48
+ The requireApproval list above is the default.
49
+ `;
50
+
51
+ const label = (id) => RULE_LABELS[id] || id;
52
+ const at = (i) => `${i.name}@${i.version}`;
53
+
54
+ function printPlan(plan, { policyPath, verbose }) {
55
+ const L = [];
56
+ const { items, denied, needsApproval } = plan.verdict;
57
+ const added = items.filter((i) => i.change === "added");
58
+ const upgraded = items.filter((i) => i.change === "upgraded");
59
+ L.push("");
60
+ L.push(` ${bold("Changes")} ${added.length} added · ${upgraded.length} upgraded · ${plan.removed.length} removed`);
61
+ L.push("");
62
+ if (items.length === 0 && plan.removed.length === 0) L.push(` ${green("✓")} Nothing changes: the lockfile already has this.`);
63
+ for (const i of [...upgraded, ...added]) {
64
+ const head = i.change === "upgraded"
65
+ ? `${yellow("▲")} ${bold(i.name)} ${i.from} → ${i.version}`
66
+ : `${cyan("+")} ${bold(i.name)} ${i.version} ${dim("(new)")}`;
67
+ const shown = i.flags.length ? i.flags : i.capabilities;
68
+ const note = shown.length
69
+ ? (i.change === "upgraded" ? red(`+ ${shown.map(label).join(", ")}`) : shown.map(label).join(", "))
70
+ : dim(i.change === "upgraded" ? (i.compared ? "no new capabilities" : "old version not readable") : "no notable capabilities");
71
+ L.push(` ${head} ${note}`);
72
+ if (i.typosquatOf) L.push(` ${yellow(`looks like ${i.typosquatOf}`)}: check this is the package you meant`);
73
+ if (i.flags.includes("install-scripts")) {
74
+ for (const [hook, cmd] of Object.entries(i.installScripts || {})) {
75
+ const was = i.previousInstallScripts?.[hook];
76
+ L.push(` ${red(hook)}: ${cmd}${was && was !== cmd ? dim(` (was: ${was})`) : ""}`);
77
+ }
78
+ }
79
+ const ev = plan.evidence?.[at(i)] || {};
80
+ for (const id of i.flags.filter((f) => f !== "install-scripts")) {
81
+ for (const e of (ev[id] || []).slice(0, verbose ? 3 : 1)) L.push(` ${dim(`${e.file}:${e.line}`)} ${e.snippet}`);
82
+ }
83
+ }
84
+ for (const r of plan.removed) L.push(` ${dim("−")} ${r.name} ${r.version} ${dim("(removed)")}`);
85
+
86
+ L.push("");
87
+ L.push(` ${bold("Policy")} ${policyPath ? relative(process.cwd(), policyPath) || policyPath : dim(`defaults (no ${POLICY_FILE})`)}`);
88
+ for (const i of denied) L.push(` ${red("✗")} ${at(i)} ${red("denied")}: ${i.deny.map(label).join(", ")}`);
89
+ for (const i of needsApproval) L.push(` ${yellow("!")} ${at(i)} needs approval: ${i.approval.map(label).join(", ")}`);
90
+ for (const i of items.filter((x) => x.allowedBy && x.flags.length)) L.push(` ${green("✓")} ${at(i)} allowed by "${i.allowedBy}"`);
91
+ if (plan.verdict.clean) L.push(` ${green("✓")} Nothing breaches the policy.`);
92
+ L.push("");
93
+ process.stdout.write(L.join("\n") + "\n");
94
+ }
95
+
96
+ function planJson(plan, extra) {
97
+ return {
98
+ root: plan.root,
99
+ specs: plan.specs,
100
+ npm: { version: plan.npm.version, registry: plan.npm.registry },
101
+ clean: plan.verdict.clean,
102
+ changes: plan.verdict.items,
103
+ removed: plan.removed,
104
+ typosquats: plan.typosquats,
105
+ ...extra,
106
+ };
107
+ }
108
+
109
+ export async function runInstall(argv) {
110
+ let parsed;
111
+ try {
112
+ parsed = parseArgs({
113
+ args: argv,
114
+ allowPositionals: true,
115
+ options: {
116
+ yes: { type: "boolean", short: "y", default: false },
117
+ "dry-run": { type: "boolean", default: false },
118
+ policy: { type: "string" },
119
+ dir: { type: "string" },
120
+ "save-dev": { type: "boolean", short: "D", default: false },
121
+ "save-exact": { type: "boolean", short: "E", default: false },
122
+ offline: { type: "boolean", default: false },
123
+ json: { type: "boolean", default: false },
124
+ verbose: { type: "boolean", short: "v", default: false },
125
+ help: { type: "boolean", short: "h", default: false },
126
+ },
127
+ });
128
+ } catch (e) {
129
+ printError(e.message);
130
+ return 2;
131
+ }
132
+ const { values: opts, positionals: specs } = parsed;
133
+ if (opts.help) {
134
+ console.log(HELP);
135
+ return 0;
136
+ }
137
+ const root = resolve(opts.dir || ".");
138
+ if (!existsSync(root)) {
139
+ printError(`Path not found: ${root}`);
140
+ return 2;
141
+ }
142
+ const json = opts.json;
143
+ const out = (s) => { if (!json) process.stdout.write(s); };
144
+
145
+ let policy;
146
+ let policyPath;
147
+ try {
148
+ ({ policy, path: policyPath } = loadPolicy(root, opts.policy ? resolve(opts.policy) : null));
149
+ } catch (e) {
150
+ printError(e.message);
151
+ return 2;
152
+ }
153
+
154
+ let plan;
155
+ try {
156
+ const info = await npmInfo(root);
157
+ out(`\n ${bold("penthera install")} ${specs.length ? specs.join(" ") : dim("(package.json)")} ${dim(`npm ${info.version}`)}\n`);
158
+ out(` ${bold("Network")} ${opts.offline
159
+ ? "npm runs with --offline (its cache only). Penthera makes no network calls."
160
+ : `npm contacts ${cyan(info.registry)} to resolve and download. That is the only egress; Penthera itself makes no network calls.`}\n\n`);
161
+ plan = await planInstall(root, specs, {
162
+ policy,
163
+ info,
164
+ saveDev: opts["save-dev"],
165
+ saveExact: opts["save-exact"],
166
+ offline: opts.offline,
167
+ onPhase: createProgress(json),
168
+ });
169
+ } catch (e) {
170
+ printError(e.message);
171
+ return 2;
172
+ }
173
+
174
+ if (!json) printPlan(plan, { policyPath, verbose: opts.verbose });
175
+ const { denied, needsApproval, clean } = plan.verdict;
176
+ const untouched = "Project untouched.";
177
+ const finish = (code, action, extra = {}) => {
178
+ if (json) console.log(JSON.stringify(planJson(plan, { action, ...extra }), null, 2));
179
+ return code;
180
+ };
181
+
182
+ if (opts["dry-run"]) {
183
+ out(` ${dim("--dry-run: nothing installed.")}\n\n`);
184
+ return finish(clean ? 0 : 1, "dry-run");
185
+ }
186
+ if (denied.length) {
187
+ out(` ${red("Blocked")}: ${denied.length} package(s) denied by policy. ${untouched}\n Add them to "allow" in ${POLICY_FILE} if you trust them.\n\n`);
188
+ return finish(1, "blocked");
189
+ }
190
+
191
+ const scripts = scriptChanges(plan);
192
+ let runScripts = true;
193
+ if (!clean) {
194
+ if (opts.yes) {
195
+ out(` ${red("Blocked")}: ${needsApproval.length} package(s) need approval and --yes only installs when nothing breaches the policy. ${untouched}\n\n`);
196
+ return finish(1, "blocked");
197
+ }
198
+ const withScripts = scripts.length > 0;
199
+ await initPrompt();
200
+ const answer = (await ask(withScripts
201
+ ? ` Install anyway? [y]es, run the install scripts / [s]kip scripts / [N]o: `
202
+ : ` Install anyway? [y/N]: `)).toLowerCase();
203
+ closePrompt();
204
+ if (answer === "y" || answer === "yes") runScripts = true;
205
+ else if (withScripts && (answer === "s" || answer === "skip")) runScripts = false;
206
+ else {
207
+ out(`\n Aborted. ${untouched}\n\n`);
208
+ return finish(1, "aborted");
209
+ }
210
+ }
211
+
212
+ out(`\n ${dim("▸")} npm install${runScripts ? "" : " --ignore-scripts"} ${dim("(installs the reviewed lockfile)")}\n`);
213
+ let applied;
214
+ try {
215
+ applied = await applyInstall(plan, { runScripts, offline: opts.offline });
216
+ } catch (e) {
217
+ printError(e.message);
218
+ return finish(2, "failed");
219
+ }
220
+ if (applied.allowScriptsAdded.length) out(` ${dim("▸")} Approved install scripts pinned in package.json allowScripts: ${applied.allowScriptsAdded.join(", ")}\n`);
221
+ if (!runScripts && scripts.length) out(` ${yellow("!")} Install scripts skipped for ${scripts.map(at).join(", ")}. Run \`npm rebuild <pkg>\` later if a package needs them.\n`);
222
+ if (applied.drift.length) out(` ${yellow("!")} npm's lockfile differs from the reviewed one: ${applied.drift.slice(0, 5).join(", ")}. Run \`penthera deps --base HEAD\` to review.\n`);
223
+ out(` ${green("✓")} Installed.\n\n`);
224
+ return finish(0, "installed", { scriptsRan: runScripts, allowScriptsAdded: applied.allowScriptsAdded, drift: applied.drift });
225
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `penthera memory show | verify | forget [--all] | suppress <id> --reason <text>`
3
+ *
4
+ * Selects the project the same way a scan does: --repo <path> (default: the
5
+ * current directory) or --url <url> for URL-only scans.
6
+ */
7
+ import { parseArgs } from "node:util";
8
+ import { resolve } from "node:path";
9
+ import {
10
+ openMemory, projectIdFor, memoryRoot, forgetAll, buildViews,
11
+ } from "../../lib/memory/index.js";
12
+
13
+ const USAGE = `
14
+ Usage
15
+ $ penthera memory show [--repo <path> | --url <url>] [--json]
16
+ $ penthera memory verify [--repo <path> | --url <url>]
17
+ $ penthera memory forget [--repo <path> | --url <url>] [--all]
18
+ $ penthera memory suppress <finding-id> --reason <text> [--expires <date>]
19
+
20
+ Memory lives in ${memoryRoot()} (override with PENTHERA_HOME).
21
+ It is never read from the scanned repo, and nothing in it is ever executed.
22
+ `;
23
+
24
+ // Memory content is data from earlier runs: strip control characters so a
25
+ // stored string cannot drive the terminal (ANSI escapes, cursor moves).
26
+ const clean = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
27
+
28
+ export async function runMemoryCommand(argv, { out = (s) => process.stdout.write(s + "\n"), err = (s) => process.stderr.write(s + "\n") } = {}) {
29
+ let parsed;
30
+ try {
31
+ parsed = parseArgs({
32
+ args: argv,
33
+ allowPositionals: true,
34
+ options: {
35
+ repo: { type: "string", short: "r" },
36
+ url: { type: "string" },
37
+ all: { type: "boolean", default: false },
38
+ json: { type: "boolean", default: false },
39
+ reason: { type: "string" },
40
+ expires: { type: "string" },
41
+ help: { type: "boolean", short: "h", default: false },
42
+ },
43
+ });
44
+ } catch (e) {
45
+ err(` ${e.message}`);
46
+ return 2;
47
+ }
48
+ const { values: opts, positionals } = parsed;
49
+ const cmd = positionals[0];
50
+ if (opts.help || !cmd) { out(USAGE); return cmd || opts.help ? 0 : 2; }
51
+
52
+ if (cmd === "forget" && opts.all) {
53
+ const root = memoryRoot();
54
+ const existed = forgetAll({ root });
55
+ out(existed ? ` Removed all Penthera memory in ${root}` : ` No memory at ${root}`);
56
+ return 0;
57
+ }
58
+
59
+ let project;
60
+ try {
61
+ project = opts.url ? projectIdFor({ url: opts.url }) : projectIdFor({ repo: resolve(opts.repo || ".") });
62
+ } catch (e) {
63
+ err(` ${e.message}`);
64
+ return 2;
65
+ }
66
+ const mem = openMemory(project.id, { label: project.label });
67
+
68
+ switch (cmd) {
69
+ case "verify": {
70
+ const r = mem.verify();
71
+ if (r.ok) {
72
+ out(` OK ${r.count} event(s), chain intact${r.count ? `, tip ${r.tip.slice(0, 16)}` : ""}`);
73
+ out(` ${mem.file}`);
74
+ return 0;
75
+ }
76
+ err(` BROKEN line ${r.brokenAt}: ${r.reason}`);
77
+ err(` ${mem.file}`);
78
+ err(` Memory is ignored by scans until this is fixed. \`penthera memory forget\` starts over.`);
79
+ return 1;
80
+ }
81
+
82
+ case "show": {
83
+ const r = mem.verify();
84
+ const v = r.ok ? buildViews(mem.events()) : null;
85
+ if (opts.json) {
86
+ out(JSON.stringify({ projectId: project.id, file: mem.file, chain: r, views: v }, null, 2));
87
+ return r.ok ? 0 : 1;
88
+ }
89
+ out(` Project ${clean(project.label)}`);
90
+ out(` Id ${project.id}`);
91
+ out(` Log ${mem.file}`);
92
+ out(` Chain ${r.ok ? `ok, ${r.count} event(s)` : `BROKEN at line ${r.brokenAt}: ${r.reason}`}`);
93
+ if (!v) return r.ok ? 0 : 1;
94
+ out(` Scans ${v.scans}`);
95
+ out(` Median time to fix ${v.medianTimeToFixDays == null ? "-" : `${v.medianTimeToFixDays}d`}`);
96
+ out("");
97
+ out(" Suppressions");
98
+ if (!v.suppressions.length) out(" none");
99
+ for (const s of v.suppressions) {
100
+ out(` ${clean(s.id)} ${clean(s.reason)} (by ${clean(s.actor)}${s.expires ? `, until ${clean(s.expires)}` : ""})`);
101
+ }
102
+ out("");
103
+ out(" Recipe success rate");
104
+ if (!v.recipeSuccess.length) out(" none yet");
105
+ for (const x of v.recipeSuccess) {
106
+ const rate = x.rate == null ? "-" : `${Math.round(x.rate * 100)}%`;
107
+ out(` ${clean(x.framework).padEnd(12)} ${clean(x.recipe).padEnd(28)} ${rate.padStart(4)} (${x.verified} ok / ${x.failed} failed / ${x.applied} applied)`);
108
+ }
109
+ out("");
110
+ out(" Probe yield per stack");
111
+ const stacks = Object.keys(v.probeRanking);
112
+ if (!stacks.length) out(" none yet");
113
+ for (const st of stacks) {
114
+ out(` ${clean(st)}`);
115
+ for (const p of v.probeRanking[st].slice(0, 10)) {
116
+ out(` ${clean(p.probe).padEnd(24)} ${p.yield.toFixed(2)} per run (${p.findings} in ${p.runs})`);
117
+ }
118
+ }
119
+ return 0;
120
+ }
121
+
122
+ case "forget": {
123
+ const existed = mem.forget();
124
+ out(existed ? ` Removed memory for ${clean(project.label)}` : ` No memory for ${clean(project.label)}`);
125
+ return 0;
126
+ }
127
+
128
+ case "suppress": {
129
+ const id = positionals[1];
130
+ if (!id || !/^[0-9a-f]{16}$/.test(id)) { err(" suppress needs a finding id (16 hex chars, the `fid` in --memory reports)"); return 2; }
131
+ const reason = (opts.reason || "").trim();
132
+ if (!reason) { err(" suppress needs --reason <text>"); return 2; }
133
+ if (opts.expires && Number.isNaN(Date.parse(opts.expires))) { err(" --expires is not a date"); return 2; }
134
+ const r = mem.verify();
135
+ if (!r.ok) { err(` Memory chain is broken at line ${r.brokenAt}; not appending. Run \`penthera memory verify\`.`); return 1; }
136
+ mem.append({ type: "fp_suppressed", data: { id, reason, actor: "user", ...(opts.expires ? { expires: opts.expires } : {}) } });
137
+ out(` Suppressed ${id} for ${clean(project.label)}`);
138
+ return 0;
139
+ }
140
+
141
+ default:
142
+ err(` Unknown memory command "${clean(cmd)}"`);
143
+ out(USAGE);
144
+ return 2;
145
+ }
146
+ }
@@ -31,6 +31,8 @@ export function mergeResults(results) {
31
31
  apiRoot: results.find((r) => r.apiRoot)?.apiRoot || null,
32
32
  machine: results.find((r) => r.machine)?.machine || null,
33
33
  executedProbes: results.flatMap((r) => r.executedProbes || []),
34
+ egress: results.find((r) => r.egress)?.egress || null,
35
+ errors: results.flatMap((r) => r.errors || []),
34
36
  findings: dedupeFindings(results.flatMap((r) => r.findings || [])),
35
37
  };
36
38
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * --audience, --report-template and --evidence for the scan command. Kept out
3
+ * of src/cli.js: prepareReportOptions() runs before the scan (so a bad
4
+ * template or a non-empty evidence directory fails fast), customReport()
5
+ * renders the chosen report, writeEvidence() writes the bundle.
6
+ */
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { formatAudienceReport, AUDIENCES } from "../report/audience.js";
10
+ import { buildReportModel } from "../report/model.js";
11
+ import { parseTemplate, renderTemplate } from "../report/template.js";
12
+ import { formatMarkdownReport } from "../report/markdown.js";
13
+ import { formatSarif } from "../reporter.js";
14
+ import { formatReadinessMarkdown } from "../../lib/compliance/readiness.js";
15
+ import { writeEvidenceBundle, assertEmptyDir } from "../report/evidence.js";
16
+
17
+ /** Validate the report flags. Returns { template } or throws with a user-facing message. */
18
+ export function prepareReportOptions(opts) {
19
+ if (opts.audience && !AUDIENCES.includes(opts.audience)) {
20
+ throw new Error(`Unknown --audience "${opts.audience}". Use one of: ${AUDIENCES.join(", ")}.`);
21
+ }
22
+ if (opts.audience && opts["report-template"]) throw new Error("Use --audience or --report-template, not both.");
23
+ if (opts["sign-key"] && !opts.evidence) throw new Error("--sign-key signs an evidence bundle; add --evidence <dir>.");
24
+ if (opts["sign-key"] && !existsSync(resolve(opts["sign-key"]))) throw new Error(`--sign-key ${opts["sign-key"]} not found.`);
25
+ if (opts.evidence) assertEmptyDir(resolve(opts.evidence));
26
+ let template = null;
27
+ if (opts["report-template"]) {
28
+ const src = readFileSync(resolve(opts["report-template"]), "utf-8");
29
+ template = parseTemplate(src); // throws TemplateError with a line number
30
+ }
31
+ return { template };
32
+ }
33
+
34
+ /** The --audience or --report-template report as Markdown, or null when neither is set. */
35
+ export function customReport(result, opts, { version, template }) {
36
+ if (template) return renderTemplate(template, buildReportModel(result, { version }));
37
+ if (opts.audience) return formatAudienceReport(result, opts.audience, { version });
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Write the evidence bundle: JSON, Markdown, SARIF, the three audience reports,
43
+ * the custom template report, readiness and SBOM when they were produced.
44
+ */
45
+ export function writeEvidence(result, opts, { version, template, mdOpts, sbom, argv, now = new Date().toISOString() }) {
46
+ const artifacts = [
47
+ { path: "report.json", kind: "json", content: JSON.stringify(result, null, 2) + "\n" },
48
+ { path: "report.md", kind: "markdown", content: formatMarkdownReport(result, mdOpts) },
49
+ { path: "report.sarif", kind: "sarif", content: JSON.stringify(formatSarif(result), null, 2) + "\n" },
50
+ ...AUDIENCES.map((a) => ({ path: `report-${a}.md`, kind: `audience:${a}`, content: formatAudienceReport(result, a, { version }) })),
51
+ ];
52
+ if (template) artifacts.push({ path: "report-custom.md", kind: "template", content: renderTemplate(template, buildReportModel(result, { version })) });
53
+ if (result.readiness) artifacts.push({ path: "nis2-readiness.md", kind: "readiness", content: formatReadinessMarkdown(result.readiness) });
54
+ if (sbom) artifacts.push({ path: "sbom.cdx.json", kind: "sbom", content: JSON.stringify(sbom, null, 2) + "\n" });
55
+
56
+ return writeEvidenceBundle(opts.evidence, artifacts, {
57
+ version,
58
+ argv,
59
+ target: result.target || "unknown",
60
+ generatedAt: now,
61
+ scan: {
62
+ timestamp: result.timestamp || null,
63
+ modes: result.modes || (result.mode ? [result.mode] : []),
64
+ profile: result.profile || null,
65
+ offline: Boolean(result.egress?.offline),
66
+ findings: (result.findings || []).length,
67
+ suppressed: (result.suppressed || []).length,
68
+ },
69
+ repo: opts.repo ? resolve(opts.repo) : null,
70
+ signKey: opts["sign-key"] || null,
71
+ });
72
+ }
@@ -59,6 +59,8 @@ export async function executeScan(config) {
59
59
  verbose,
60
60
  authCookie,
61
61
  authBearer,
62
+ offline: config.offline || false,
63
+ userAgent: config.userAgent || null,
62
64
  onPhase: onPhase || createProgress(quiet || json),
63
65
  };
64
66