penthera 1.1.1 → 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 (95) hide show
  1. package/CHANGELOG.md +269 -3
  2. package/README.md +489 -286
  3. package/SECURITY.md +28 -6
  4. package/docs/METHODOLOGY.md +321 -0
  5. package/docs/compliance-nis2.md +166 -0
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +55 -0
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +15 -3
  15. package/docs/local-loop.md +276 -0
  16. package/docs/references.bib +539 -0
  17. package/docs/references.md +77 -0
  18. package/docs/release-plan.md +228 -0
  19. package/lib/adaptive-probes.js +8 -5
  20. package/lib/agents/discover.js +384 -0
  21. package/lib/agents/hidden-content.js +387 -0
  22. package/lib/agents/index.js +286 -0
  23. package/lib/agents/lock.js +115 -0
  24. package/lib/agents/owasp.js +83 -0
  25. package/lib/agents/rules.js +376 -0
  26. package/lib/crawler.js +5 -15
  27. package/lib/data/retire-jsrepository.json.gz +0 -0
  28. package/lib/data/retire-jsrepository.meta.json +7 -0
  29. package/lib/deps/capabilities.js +523 -0
  30. package/lib/deps/diff.js +84 -0
  31. package/lib/deps/index.js +416 -0
  32. package/lib/deps/lockfile.js +106 -0
  33. package/lib/deps/online.js +132 -0
  34. package/lib/deps/popular-packages.json +1 -0
  35. package/lib/deps/report.js +173 -0
  36. package/lib/deps/source.js +171 -0
  37. package/lib/deps/typosquat.js +113 -0
  38. package/lib/fix/apply.js +95 -0
  39. package/lib/fix/diff.js +199 -0
  40. package/lib/fix/index.js +129 -0
  41. package/lib/fix/model.js +108 -0
  42. package/lib/fix/recipes/cookies.js +130 -0
  43. package/lib/fix/recipes/cors.js +116 -0
  44. package/lib/fix/recipes/headers.js +250 -0
  45. package/lib/fix/recipes/rate-limit.js +211 -0
  46. package/lib/fix/recipes/secrets.js +110 -0
  47. package/lib/fix/util.js +231 -0
  48. package/lib/fuzzer.js +14 -8
  49. package/lib/injections.js +19 -15
  50. package/lib/install/index.js +271 -0
  51. package/lib/install/policy.js +117 -0
  52. package/lib/memory/history.js +52 -0
  53. package/lib/memory/index.js +111 -0
  54. package/lib/memory/repo-suppress.js +64 -0
  55. package/lib/memory/store.js +325 -0
  56. package/lib/memory/views.js +198 -0
  57. package/lib/params.js +4 -12
  58. package/lib/provenance.js +2 -0
  59. package/lib/recon.js +12 -20
  60. package/lib/references.js +31 -0
  61. package/lib/references.json +374 -0
  62. package/lib/remediation/index.js +28 -0
  63. package/lib/retirejs.js +50 -22
  64. package/lib/templates.js +7 -10
  65. package/lib/whitebox/frameworks.js +1 -1
  66. package/lib/whitebox/secrets.js +49 -19
  67. package/package.json +19 -9
  68. package/skills/penthera/SKILL.md +104 -23
  69. package/skills/penthera/references/output-and-ci.md +12 -50
  70. package/skills/penthera/references/troubleshooting.md +1 -1
  71. package/skills/penthera/scripts/preflight.sh +4 -4
  72. package/src/agent/git.js +87 -0
  73. package/src/agent/loop.js +519 -0
  74. package/src/agent/tests.js +48 -0
  75. package/src/cli/agent.js +183 -0
  76. package/src/cli/deps.js +84 -0
  77. package/src/cli/evidence.js +67 -0
  78. package/src/cli/fix.js +191 -0
  79. package/src/cli/install.js +225 -0
  80. package/src/cli/memory.js +146 -0
  81. package/src/cli/merge-results.js +2 -0
  82. package/src/cli/report-outputs.js +72 -0
  83. package/src/cli/run-scan.js +2 -0
  84. package/src/cli.js +152 -6
  85. package/src/report/audience.js +270 -0
  86. package/src/report/evidence.js +253 -0
  87. package/src/report/markdown.js +65 -2
  88. package/src/report/model.js +212 -0
  89. package/src/report/template.js +175 -0
  90. package/src/report/timeline.js +145 -0
  91. package/src/reporter.js +109 -5
  92. package/src/scan-url.js +31 -7
  93. package/src/utils/http.js +252 -11
  94. package/src/utils/url.js +23 -0
  95. package/scripts/package-skill.sh +0 -16
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Evidence bundle (--evidence <dir>): the reports for one scan, a manifest.json
3
+ * with the SHA-256 of every artifact, and optionally manifest.json.sig, an SSH
4
+ * signature (`ssh-keygen -Y sign -n penthera-evidence`) over the manifest.
5
+ *
6
+ * verifyEvidence() recomputes every hash, rejects files the manifest does not
7
+ * list, and checks the signature with ssh-keygen. No hosted service and no new
8
+ * dependency: an auditor needs Node (or sha256sum and jq) and OpenSSH 8+.
9
+ * See docs/evidence.md.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
+ import {
14
+ readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync,
15
+ } from "node:fs";
16
+ import { join, resolve, relative, sep } from "node:path";
17
+
18
+ export const SCHEMA = "penthera-evidence/1";
19
+ export const NAMESPACE = "penthera-evidence";
20
+ export const MANIFEST = "manifest.json";
21
+ export const SIGNATURE = "manifest.json.sig";
22
+
23
+ const SAFE_PATH = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
24
+ const SECRET_FLAG = /^--?(?:auth-cookie|auth-bearer|cookie|[\w-]*(?:token|secret|password|passwd|api-key|apikey)[\w-]*)$/i;
25
+ const SECRET_PARAM = /^(?:[\w-]*(?:token|secret|password|passwd|key|sig|signature|auth|session|code)[\w-]*)$/i;
26
+
27
+ export function sha256(buf) {
28
+ return createHash("sha256").update(buf).digest("hex");
29
+ }
30
+
31
+ /** Mask credentials in a URL: user:pass@ and secret-looking query values. */
32
+ function redactUrl(s) {
33
+ let u;
34
+ try {
35
+ u = new URL(s);
36
+ } catch {
37
+ return s;
38
+ }
39
+ if (!/^https?:$/.test(u.protocol)) return s;
40
+ let changed = false;
41
+ if (u.username || u.password) { u.username = "REDACTED"; u.password = ""; changed = true; }
42
+ for (const k of [...u.searchParams.keys()]) {
43
+ if (SECRET_PARAM.test(k)) { u.searchParams.set(k, "REDACTED"); changed = true; }
44
+ }
45
+ return changed ? u.toString() : s;
46
+ }
47
+
48
+ /**
49
+ * Redact secrets from a command line: the value of --auth-cookie, --auth-bearer
50
+ * and any flag named like a token or password (both `--flag v` and
51
+ * `--flag=v`), plus credentials inside URLs.
52
+ */
53
+ export function redactArgv(argv) {
54
+ const out = [];
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = String(argv[i]);
57
+ const eq = a.indexOf("=");
58
+ if (a.startsWith("-") && eq > 0 && SECRET_FLAG.test(a.slice(0, eq))) {
59
+ out.push(`${a.slice(0, eq)}=[REDACTED]`);
60
+ } else if (SECRET_FLAG.test(a) && i + 1 < argv.length) {
61
+ out.push(a, "[REDACTED]");
62
+ i++;
63
+ } else {
64
+ out.push(redactUrl(a));
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function quote(a) {
71
+ return /^[\w@%+=:,./[\]-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`;
72
+ }
73
+
74
+ /** { commit, dirty } for a git checkout, or null. */
75
+ export function gitInfo(dir) {
76
+ if (!dir) return null;
77
+ try {
78
+ const commit = execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
79
+ const status = execFileSync("git", ["-C", dir, "status", "--porcelain"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
80
+ return /^[0-9a-f]{40,64}$/.test(commit) ? { commit, dirty: status.trim().length > 0 } : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** True when ssh-keygen is on PATH. */
87
+ export function hasSshKeygen() {
88
+ const r = spawnSync("ssh-keygen", ["-?"], { stdio: "ignore" });
89
+ return !r.error;
90
+ }
91
+
92
+ function publicKeyFor(keyPath) {
93
+ const pub = keyPath.endsWith(".pub") ? keyPath : `${keyPath}.pub`;
94
+ if (!existsSync(pub)) return null;
95
+ const line = readFileSync(pub, "utf-8").trim().split("\n")[0];
96
+ return /^(ssh-|ecdsa-|sk-)[\w@.-]+ [A-Za-z0-9+/=]+/.test(line) ? line.split(" ").slice(0, 2).join(" ") : null;
97
+ }
98
+
99
+ /** Throws unless `dir` is missing or empty, so a bundle never mixes runs. */
100
+ export function assertEmptyDir(dir) {
101
+ if (!existsSync(dir)) return;
102
+ if (!lstatSync(dir).isDirectory()) throw new Error(`--evidence ${dir} exists and is not a directory`);
103
+ if (readdirSync(dir).length) throw new Error(`--evidence ${dir} is not empty; use a new directory per run`);
104
+ }
105
+
106
+ /**
107
+ * Write a bundle.
108
+ * @param {string} dir
109
+ * @param {{ path: string, content: string|Buffer, kind?: string }[]} artifacts
110
+ * @param {{ version: string, argv: string[], target: string, generatedAt: string,
111
+ * scan?: object, repo?: string|null, signKey?: string|null }} meta
112
+ * @returns {{ manifest: object, signed: boolean, dir: string }}
113
+ */
114
+ export function writeEvidenceBundle(dir, artifacts, meta) {
115
+ const root = resolve(dir);
116
+ assertEmptyDir(root);
117
+ mkdirSync(root, { recursive: true });
118
+
119
+ const entries = [];
120
+ for (const a of artifacts) {
121
+ if (!SAFE_PATH.test(a.path) || a.path === MANIFEST || a.path === SIGNATURE) throw new Error(`Bad artifact path ${a.path}`);
122
+ const buf = Buffer.isBuffer(a.content) ? a.content : Buffer.from(String(a.content), "utf-8");
123
+ const file = join(root, ...a.path.split("/"));
124
+ mkdirSync(resolve(file, ".."), { recursive: true });
125
+ writeFileSync(file, buf);
126
+ entries.push({ path: a.path, kind: a.kind || null, bytes: buf.length, sha256: sha256(buf) });
127
+ }
128
+ entries.sort((x, y) => x.path.localeCompare(y.path));
129
+
130
+ const argv = redactArgv(meta.argv || []);
131
+ const signKey = meta.signKey ? resolve(meta.signKey) : null;
132
+ const manifest = {
133
+ schema: SCHEMA,
134
+ tool: { name: "penthera", version: meta.version, node: process.version },
135
+ generated_at: meta.generatedAt,
136
+ command: ["penthera", ...argv].map(quote).join(" "),
137
+ argv,
138
+ target: meta.target,
139
+ scan: meta.scan || null,
140
+ git: gitInfo(meta.repo),
141
+ artifacts: entries,
142
+ signature: signKey ? { namespace: NAMESPACE, file: SIGNATURE, public_key: publicKeyFor(signKey) } : null,
143
+ };
144
+ const manifestPath = join(root, MANIFEST);
145
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
146
+
147
+ if (signKey) signManifest(manifestPath, signKey);
148
+ return { manifest, signed: Boolean(signKey), dir: root };
149
+ }
150
+
151
+ /** ssh-keygen -Y sign; writes <manifest>.sig. A passphrase prompt goes to the terminal. */
152
+ export function signManifest(manifestPath, keyPath) {
153
+ if (!existsSync(keyPath)) throw new Error(`--sign-key ${keyPath} not found`);
154
+ const r = spawnSync("ssh-keygen", ["-Y", "sign", "-f", keyPath, "-n", NAMESPACE, manifestPath], {
155
+ stdio: ["inherit", "pipe", "pipe"], encoding: "utf-8",
156
+ });
157
+ if (r.error) throw new Error(`Signing needs ssh-keygen (OpenSSH 8.0+): ${r.error.message}`);
158
+ if (r.status !== 0) throw new Error(`ssh-keygen -Y sign failed: ${(r.stderr || "").trim()}`);
159
+ }
160
+
161
+ function listFiles(root, dir = root, out = []) {
162
+ for (const name of readdirSync(dir)) {
163
+ const p = join(dir, name);
164
+ const st = lstatSync(p);
165
+ if (st.isDirectory()) listFiles(root, p, out);
166
+ else out.push(relative(root, p).split(sep).join("/"));
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function sshVerify(args, manifestBuf) {
172
+ const r = spawnSync("ssh-keygen", ["-Y", ...args], { input: manifestBuf, encoding: "utf-8" });
173
+ if (r.error) return { ok: false, missing: true, out: r.error.message };
174
+ return { ok: r.status === 0, out: `${r.stdout || ""}${r.stderr || ""}`.trim() };
175
+ }
176
+
177
+ function checkSignature(root, manifestBuf, allowedSigners) {
178
+ const sigPath = join(root, SIGNATURE);
179
+ if (!existsSync(sigPath)) {
180
+ return allowedSigners
181
+ ? { status: "missing", ok: false, detail: `--allowed-signers was given but there is no ${SIGNATURE}` }
182
+ : { status: "unsigned", ok: true, detail: "bundle is not signed; hashes only prove it is internally consistent" };
183
+ }
184
+ if (!allowedSigners) {
185
+ const r = sshVerify(["check-novalidate", "-n", NAMESPACE, "-s", sigPath], manifestBuf);
186
+ if (r.missing) return { status: "unchecked", ok: true, detail: "ssh-keygen not found; signature not checked" };
187
+ return r.ok
188
+ ? { status: "valid-unknown-signer", ok: true, detail: "signature is valid, but the signer was not checked: pass --allowed-signers" }
189
+ : { status: "invalid", ok: false, detail: r.out };
190
+ }
191
+ const allowed = resolve(allowedSigners);
192
+ if (!existsSync(allowed)) return { status: "invalid", ok: false, detail: `allowed signers file ${allowedSigners} not found` };
193
+ const found = spawnSync("ssh-keygen", ["-Y", "find-principals", "-f", allowed, "-s", sigPath], { encoding: "utf-8" });
194
+ if (found.error) return { status: "invalid", ok: false, detail: "ssh-keygen not found; cannot check the signature" };
195
+ const principals = (found.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
196
+ if (!principals.length) return { status: "invalid", ok: false, detail: "no key in the allowed signers file made this signature" };
197
+ for (const principal of principals) {
198
+ const r = sshVerify(["verify", "-f", allowed, "-I", principal, "-n", NAMESPACE, "-s", sigPath], manifestBuf);
199
+ if (r.ok) return { status: "valid", ok: true, principal, detail: `signed by ${principal}` };
200
+ }
201
+ return { status: "invalid", ok: false, detail: "signature does not verify against the allowed signers" };
202
+ }
203
+
204
+ /**
205
+ * Verify a bundle. Never throws on a bad bundle; returns { ok, ... }.
206
+ * @param {string} dir
207
+ * @param {{ allowedSigners?: string }} opts
208
+ */
209
+ export function verifyEvidence(dir, { allowedSigners = null } = {}) {
210
+ const root = resolve(dir);
211
+ const problems = [];
212
+ const files = [];
213
+ const fail = (msg) => ({ ok: false, problems: [msg], files, unlisted: [], signature: null, manifest: null });
214
+
215
+ const manifestPath = join(root, MANIFEST);
216
+ if (!existsSync(manifestPath)) return fail(`${MANIFEST} not found in ${dir}`);
217
+ const manifestBuf = readFileSync(manifestPath);
218
+ let manifest;
219
+ try {
220
+ manifest = JSON.parse(manifestBuf.toString("utf-8"));
221
+ } catch (e) {
222
+ return fail(`${MANIFEST} is not valid JSON: ${e.message}`);
223
+ }
224
+ if (manifest?.schema !== SCHEMA || !Array.isArray(manifest.artifacts)) return fail(`${MANIFEST} is not a ${SCHEMA} manifest`);
225
+
226
+ const listed = new Set();
227
+ for (const a of manifest.artifacts) {
228
+ const path = a?.path;
229
+ if (typeof path !== "string" || !SAFE_PATH.test(path) || path === MANIFEST || path === SIGNATURE || listed.has(path)) {
230
+ problems.push(`unsafe or duplicate artifact path ${JSON.stringify(path)}`);
231
+ continue;
232
+ }
233
+ listed.add(path);
234
+ const file = join(root, ...path.split("/"));
235
+ const st = existsSync(file) ? lstatSync(file) : null;
236
+ if (!st) { files.push({ path, ok: false, reason: "missing" }); continue; }
237
+ if (!st.isFile()) { files.push({ path, ok: false, reason: "not a regular file" }); continue; }
238
+ const actual = sha256(readFileSync(file));
239
+ if (typeof a.sha256 !== "string" || actual !== a.sha256.toLowerCase()) {
240
+ files.push({ path, ok: false, reason: "hash mismatch", expected: a.sha256, actual });
241
+ } else {
242
+ files.push({ path, ok: true, sha256: actual });
243
+ }
244
+ }
245
+ const unlisted = listFiles(root).filter((p) => p !== MANIFEST && p !== SIGNATURE && !listed.has(p));
246
+ for (const p of unlisted) problems.push(`${p} is in the bundle but not in the manifest`);
247
+ for (const f of files) if (!f.ok) problems.push(`${f.path}: ${f.reason}`);
248
+
249
+ const signature = checkSignature(root, manifestBuf, allowedSigners);
250
+ if (!signature.ok) problems.push(`signature: ${signature.detail}`);
251
+
252
+ return { ok: problems.length === 0, problems, files, unlisted, signature, manifest };
253
+ }
@@ -7,6 +7,8 @@ import { buildWstgCoverage, formatWstgMarkdown } from "../../lib/owasp-wstg.js";
7
7
  import { formatComplianceMarkdown } from "../../lib/compliance/index.js";
8
8
  import { buildActionPlan, formatActionPlanMarkdown } from "../../lib/remediation/index.js";
9
9
  import { formatAuditLoopMarkdown } from "../../lib/audit-loop.js";
10
+ import { referencesFor, formatSourcesMarkdown } from "../../lib/references.js";
11
+ import { formatDepsMarkdown } from "../../lib/deps/report.js";
10
12
 
11
13
  const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
12
14
  const SEV_TAG = {
@@ -59,6 +61,12 @@ export function formatMarkdownReport(result, opts = {}) {
59
61
  lines.push(`| **Mode** | ${result.modes?.join(", ") || result.mode || "-"} |`);
60
62
  lines.push(`| **Reachable** | ${result.reachable ? "Yes" : "No"} |`);
61
63
  if (result.profile) lines.push(`| **Profile** | ${result.profile} |`);
64
+ if (result.agents) {
65
+ lines.push(`| **Agent config grade** | **${result.agents.grade}** (${result.agents.score}/100) |`);
66
+ const inv = result.agents.inventory || {};
67
+ lines.push(`| **Agent config found** | ${inv.skills || 0} skills, ${inv.mcpServers || 0} MCP servers, ${inv.hooks || 0} hooks, ${inv.plugins || 0} plugins |`);
68
+ if (result.agents.lock) lines.push(`| **penthera.lock** | ${result.agents.lock.status} |`);
69
+ }
62
70
  lines.push("");
63
71
 
64
72
  const total = findings.length;
@@ -98,6 +106,10 @@ export function formatMarkdownReport(result, opts = {}) {
98
106
  lines.push(formatComplianceMarkdown(result.compliance));
99
107
  }
100
108
 
109
+ if (result.deps) {
110
+ lines.push(formatDepsMarkdown(result.deps));
111
+ }
112
+
101
113
  if (result.fingerprint) {
102
114
  lines.push("## Technology fingerprint");
103
115
  lines.push("");
@@ -144,17 +156,68 @@ export function formatMarkdownReport(result, opts = {}) {
144
156
  lines.push("");
145
157
 
146
158
  for (const f of group) {
147
- if (!f.description) continue;
159
+ const refs = referencesFor(f);
160
+ if (!f.description && refs.length === 0) continue;
148
161
  lines.push(`**${f.title}**`);
149
162
  lines.push("");
150
- lines.push(f.description);
163
+ if (f.description) lines.push(f.description);
151
164
  if (f.url) lines.push(`\n- URL: ${f.url}`);
152
165
  if (f.status) lines.push(`- HTTP status: ${f.status}`);
166
+ if (refs.length) lines.push(`- Why this matters / Sources: ${formatSourcesMarkdown(refs)}`);
153
167
  lines.push("");
154
168
  }
155
169
  }
156
170
  }
157
171
 
172
+ if (result.suppressed?.length) {
173
+ lines.push("## Suppressed findings");
174
+ lines.push("");
175
+ lines.push("Hidden from the findings above by `--memory`. Review these: a suppression is a decision someone made, not a fix.");
176
+ lines.push("");
177
+ lines.push("| Finding | Severity | Suppressed by | Reason | Id |");
178
+ lines.push("|---------|----------|---------------|--------|----|");
179
+ for (const f of result.suppressed) {
180
+ const by = f.suppression?.source === "repo-file" ? "**suppressed by repo file** (`penthera.suppress.json`)" : "suppressed by local memory";
181
+ lines.push(`| ${mdEscape(f.title)} | ${f.severity || "-"} | ${by} | ${mdEscape(f.suppression?.reason)} | \`${f.fid}\` |`);
182
+ }
183
+ lines.push("");
184
+ }
185
+
186
+ const errors = result.errors || [];
187
+ if (errors.length > 0) {
188
+ const probes = new Set(errors.map((e) => e.probe));
189
+ lines.push("## Inconclusive probes");
190
+ lines.push("");
191
+ lines.push(`${probes.size} probe(s) could not complete. Their silence is not a clean result.`);
192
+ lines.push("");
193
+ lines.push("| Probe | URL | Error | Count |");
194
+ lines.push("|-------|-----|-------|-------|");
195
+ for (const e of errors) {
196
+ lines.push(`| ${mdEscape(e.probe)} | \`${mdEscape(e.url)}\` | ${mdEscape(e.message)} | ${e.count || 1} |`);
197
+ }
198
+ lines.push("");
199
+ }
200
+
201
+ if (result.egress) {
202
+ const e = result.egress;
203
+ lines.push("## Network egress");
204
+ lines.push("");
205
+ lines.push(`- **Mode:** ${e.offline ? "--offline (scan target only)" : "online"}`);
206
+ lines.push(`- **User-Agent:** \`${mdEscape(e.userAgent)}\``);
207
+ if (e.thirdPartyHosts.length === 0) {
208
+ lines.push(`- **Third-party hosts contacted:** none`);
209
+ } else {
210
+ lines.push(`- **Third-party hosts contacted:** ${e.thirdPartyHosts.map((h) => `\`${h.host}\` (${h.requests})`).join(", ")}`);
211
+ }
212
+ if (e.blocked.length > 0) {
213
+ lines.push(`- **Blocked by --offline:** ${e.blocked.map((h) => `\`${h.host}\` (${h.requests})`).join(", ")}`);
214
+ }
215
+ if (result.retireDb) {
216
+ lines.push(`- **Retire.js database:** ${result.retireDb.source === "snapshot" ? `vendored snapshot from ${result.retireDb.snapshotDate}` : "live"}`);
217
+ }
218
+ lines.push("");
219
+ }
220
+
158
221
  const actionPlan = result.actionPlan || buildActionPlan(findings);
159
222
  if (actionPlan.length > 0) {
160
223
  lines.push(formatActionPlanMarkdown(actionPlan));
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The report model: one plain-data view of a scan result that the audience
3
+ * reports (developer, owner, auditor) and user templates (--report-template)
4
+ * render from. Everything here is strings, numbers, booleans, arrays and plain
5
+ * objects, so a template can only print data, never reach code.
6
+ */
7
+ import { referencesFor } from "../../lib/references.js";
8
+ import { resolveWstgTags } from "../../lib/owasp-wstg.js";
9
+ import { buildComplianceCoverage } from "../../lib/compliance/index.js";
10
+ import { buildActionPlan } from "../../lib/remediation/index.js";
11
+ import { confidenceFor } from "../../lib/provenance.js";
12
+ import { RECIPES } from "../../lib/fix/index.js";
13
+ import { OWASP_NAMES } from "../../lib/agents/owasp.js";
14
+ import { findingId } from "../../lib/memory/views.js";
15
+ import { buildTimeline, formatDuration } from "./timeline.js";
16
+
17
+ const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
18
+
19
+ export const CONFIDENCE_LABELS = [
20
+ { label: "confirmed", meaning: "Deterministic evidence: a header is absent, a certificate has expired, a secret is in the file." },
21
+ { label: "likely", meaning: "A strong signal with a small chance of a false positive." },
22
+ { label: "potential", meaning: "A heuristic or timing signal. It needs a second source before anyone acts on it." },
23
+ { label: "needs-human-review", meaning: "A scanner cannot judge exploitability here (access control, IDOR). A person has to look." },
24
+ ];
25
+
26
+ // OWASP Top 10 (2021) category per finding category. Categories without a
27
+ // clean match (email DNS, fuzzing noise, machine audit) are left unmapped.
28
+ export const OWASP_TOP10 = {
29
+ "A01:2021": "Broken Access Control",
30
+ "A02:2021": "Cryptographic Failures",
31
+ "A03:2021": "Injection",
32
+ "A05:2021": "Security Misconfiguration",
33
+ "A06:2021": "Vulnerable and Outdated Components",
34
+ "A07:2021": "Identification and Authentication Failures",
35
+ "A10:2021": "Server-Side Request Forgery",
36
+ };
37
+ const CATEGORY_TOP10 = {
38
+ "auth-bypass": "A01:2021", "open-redirect": "A01:2021", permissions: "A01:2021",
39
+ tls: "A02:2021", transport: "A02:2021",
40
+ sqli: "A03:2021", xss: "A03:2021", ssti: "A03:2021", cmdi: "A03:2021", injection: "A03:2021",
41
+ headers: "A05:2021", cors: "A05:2021", "cors-misconfiguration": "A05:2021", cookie: "A05:2021",
42
+ config: "A05:2021", exposure: "A05:2021", "info-leak": "A05:2021", disclosure: "A05:2021",
43
+ "method-confusion": "A05:2021", "content-type-confusion": "A05:2021",
44
+ cve: "A06:2021", "js-vulnerability": "A06:2021", "dependency-risk": "A06:2021",
45
+ auth: "A07:2021", secrets: "A07:2021", "rate-limiting": "A07:2021",
46
+ ssrf: "A10:2021",
47
+ };
48
+
49
+ const PLAIN = { critical: "serious", high: "serious", medium: "moderate", low: "minor", info: "note" };
50
+
51
+ function isHttp(u) {
52
+ return typeof u === "string" && /^https?:\/\//i.test(u);
53
+ }
54
+
55
+ function locationOf(f) {
56
+ if (isHttp(f.url)) return { file: null, line: null, url: f.url, location: f.url };
57
+ const file = f.file || f.url || null;
58
+ const line = Number.isInteger(f.line) && f.line > 0 ? f.line : null;
59
+ return { file, line, url: null, location: file ? (line ? `${file}:${line}` : file) : null };
60
+ }
61
+
62
+ function recipeFor(f) {
63
+ for (const r of RECIPES) {
64
+ try {
65
+ if (r.matches(f)) return r.id;
66
+ } catch {
67
+ // a recipe that cannot judge this finding does not claim it
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ const keyOf = (f) => `${f.category || ""}|${f.title || ""}|${f.url || ""}`;
74
+
75
+ /** Map each finding key to a fix plan (from result.fixPlans, e.g. an agent loop). */
76
+ function plansByFinding(plans) {
77
+ const map = new Map();
78
+ for (const p of Array.isArray(plans) ? plans : []) {
79
+ for (const f of p?.findings || []) map.set(keyOf(f), p);
80
+ }
81
+ return map;
82
+ }
83
+
84
+ /** Build the report model for a scan result. */
85
+ export function buildReportModel(result, { version = "0.0.0" } = {}) {
86
+ const raw = [...(result?.findings || [])].sort((a, b) => (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5));
87
+ const actionPlan = result?.actionPlan || buildActionPlan(raw);
88
+ const plans = plansByFinding(result?.fixPlans);
89
+ const timeline = buildTimeline(result);
90
+ const loopRecipe = new Map((timeline?.entries || []).filter((e) => e.recipe).map((e) => [e.fid, e.recipe]));
91
+
92
+ const findings = raw.map((f, i) => {
93
+ const loc = locationOf(f);
94
+ const plan = plans.get(keyOf(f));
95
+ const action = actionPlan.find((a) => a.findings.some((x) => x.title === f.title && (x.url || null) === (f.url || null)));
96
+ const top10 = CATEGORY_TOP10[f.category] || null;
97
+ return {
98
+ n: i + 1,
99
+ fid: f.fid || findingId(f),
100
+ title: String(f.title || "Untitled finding"),
101
+ severity: f.severity || "info",
102
+ plainSeverity: PLAIN[f.severity] || "note",
103
+ category: f.category || null,
104
+ confidence: confidenceFor(f),
105
+ source: f.source || null,
106
+ method: f.method || f.source || null,
107
+ collectedAt: f.collected_at || result?.timestamp || null,
108
+ description: f.description || null,
109
+ ...loc,
110
+ recipe: plan?.recipe || loopRecipe.get(f.fid) || recipeFor(f),
111
+ diff: plan?.diff || null,
112
+ fixText: action?.fix || null,
113
+ sources: referencesFor(f).map((r) => ({ cite: r.cite, url: r.url })),
114
+ owasp: [
115
+ ...(top10 ? [`${top10} ${OWASP_TOP10[top10]}`] : []),
116
+ ...(Array.isArray(f.owasp) ? f.owasp.map((id) => OWASP_NAMES[id] || id) : []),
117
+ ],
118
+ wstg: resolveWstgTags(f),
119
+ };
120
+ });
121
+
122
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
123
+ for (const f of findings) if (counts[f.severity] != null) counts[f.severity]++;
124
+
125
+ const suppressed = (result?.suppressed || []).map((f) => ({
126
+ fid: f.fid || findingId(f),
127
+ title: String(f.title || ""),
128
+ severity: f.severity || "info",
129
+ reason: f.suppression?.reason || "",
130
+ actor: f.suppression?.actor || "unknown",
131
+ by: f.suppression?.source === "repo-file" ? "repo file (penthera.suppress.json)" : "local memory",
132
+ expires: f.suppression?.expires || null,
133
+ }));
134
+
135
+ const inconclusive = (result?.errors || []).map((e) => ({
136
+ probe: e.probe || "", url: e.url || "", message: e.message || "", count: e.count || 1,
137
+ }));
138
+
139
+ const e = result?.egress || null;
140
+ const egress = e ? {
141
+ mode: e.offline ? "offline (scan target only)" : "online",
142
+ userAgent: e.userAgent || "",
143
+ thirdPartyHosts: (e.thirdPartyHosts || []).map((h) => ({ host: h.host, requests: h.requests })),
144
+ blocked: (e.blocked || []).map((h) => ({ host: h.host, requests: h.requests })),
145
+ } : null;
146
+
147
+ const actions = actionPlan.map((a, i) => ({
148
+ n: i + 1, id: a.id, action: a.action, why: a.why, fix: a.fix, effort: a.effort,
149
+ severity: a.severity, ownerAction: Boolean(a.ownerAction), findingCount: a.findings.length,
150
+ }));
151
+ const ownerActions = actions.filter((a) => a.ownerAction);
152
+ for (const p of Array.isArray(result?.fixPlans) ? result.fixPlans : []) {
153
+ for (const text of p?.ownerActions || []) {
154
+ if (typeof text === "string") ownerActions.push({ n: ownerActions.length + 1, id: p.recipe, action: text, why: "", fix: "", effort: "", severity: "", ownerAction: true, findingCount: (p.findings || []).length });
155
+ }
156
+ }
157
+
158
+ const coverage = result?.compliance?.framework === "nis2" ? result.compliance : buildComplianceCoverage(result || {}, "nis2");
159
+ const nis2 = coverage.measures.map((m) => ({ id: m.id, title: m.title, status: m.status, findings: m.findings || 0, note: m.note || "" }));
160
+
161
+ const owaspMap = new Map();
162
+ for (const f of findings) {
163
+ for (const o of f.owasp) {
164
+ if (!owaspMap.has(o)) owaspMap.set(o, []);
165
+ owaspMap.get(o).push(f.fid || `#${f.n}`);
166
+ }
167
+ }
168
+ const owasp = [...owaspMap].sort(([a], [b]) => a.localeCompare(b)).map(([id, ids]) => ({ id, count: ids.length, findings: ids.join(", ") }));
169
+
170
+ const trend = (result?.memory?.history?.trend || []).map((t) => ({
171
+ date: String(t.ts).slice(0, 10), serious: t.critical + t.high, critical: t.critical, high: t.high,
172
+ medium: t.medium, low: t.low, total: t.total, suppressed: t.suppressed,
173
+ }));
174
+
175
+ return {
176
+ tool: { name: "penthera", version },
177
+ target: result?.target || "unknown",
178
+ timestamp: result?.timestamp || null,
179
+ duration: result?.duration ? formatDuration(result.duration) : null,
180
+ modes: (result?.modes || (result?.mode ? [result.mode] : [])).join(", "),
181
+ profile: result?.profile || null,
182
+ offline: Boolean(e?.offline),
183
+ probes: [...(result?.executedProbes || [])].sort(),
184
+ summary: {
185
+ ...counts,
186
+ serious: counts.critical + counts.high,
187
+ total: findings.length,
188
+ suppressed: suppressed.length,
189
+ inconclusive: new Set(inconclusive.map((x) => x.probe)).size,
190
+ target: result?.target || "unknown",
191
+ timestamp: result?.timestamp || null,
192
+ },
193
+ findings,
194
+ suppressed,
195
+ inconclusive,
196
+ egress,
197
+ actions,
198
+ topActions: actions.slice(0, 3),
199
+ ownerActions,
200
+ nis2,
201
+ owasp,
202
+ trend,
203
+ timeline: timeline ? {
204
+ source: timeline.source,
205
+ verified: timeline.verifiedCount,
206
+ failed: timeline.failedCount,
207
+ open: timeline.openCount,
208
+ medianTimeToFix: formatDuration(timeline.medianTimeToFixMs),
209
+ entries: timeline.entries.map((x) => ({ ...x, timeToFix: formatDuration(x.timeToFixMs) })),
210
+ } : null,
211
+ };
212
+ }