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
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { parseArgs } from "node:util";
7
7
  import { resolve, dirname } from "node:path";
8
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { scanUrl } from "./scan-url.js";
11
11
  import { scanRepo } from "./scan-repo.js";
@@ -15,6 +15,7 @@ import { compareWithBaseline } from "./cli/baseline.js";
15
15
  import { normalizeBaseUrl } from "./utils/url.js";
16
16
  import { printReport, printFindings, writeJsonReport, writeSarifReport, createProgress, printError } from "./reporter.js";
17
17
  import { writeMarkdownReport, markdownPathFromJson } from "./report/markdown.js";
18
+ import { prepareReportOptions, customReport, writeEvidence } from "./cli/report-outputs.js";
18
19
  import { parseTemplatePaths } from "../lib/plugins.js";
19
20
  import { buildComplianceCoverage, listFrameworks } from "../lib/compliance/index.js";
20
21
  import { buildActionPlan } from "../lib/remediation/index.js";
@@ -55,6 +56,13 @@ const HELP = `
55
56
  $ penthera <url> Scan a live URL (black-box)
56
57
  $ penthera --repo <path> Scan a local repo (white-box)
57
58
  $ penthera <url> --repo <path> Full scan (both)
59
+ $ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
60
+ $ penthera deps [path] What your npm dependencies can do (offline)
61
+ $ penthera install <pkg...> Check what an npm install adds before anything runs
62
+ $ penthera memory show|verify|forget Local learning store (never leaves the machine)
63
+ $ penthera fix --plan Plan local fix recipes (penthera fix --help)
64
+ $ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
65
+ $ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
58
66
 
59
67
  ${dim("Scan modes")}
60
68
  --profile <name> quick | standard | deep (default: standard)
@@ -68,6 +76,22 @@ const HELP = `
68
76
  --adaptive Knowledge-graph adaptive probes on discovered routes
69
77
  --all Enable all modes (recon + deep + fuzz)
70
78
  --machine macOS machine audit (keyloggers, trojans, rootkits)
79
+ --offline Contact only the scan target: no recon sources, no
80
+ Retire.js download (uses the vendored snapshot)
81
+
82
+ ${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
83
+ --home Also audit ~/.claude, ~/.cursor and Claude Desktop config
84
+ --pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
85
+
86
+ ${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json + node_modules, offline)")}
87
+ --diff <lock|dir> Capability diff against an old lockfile or installed tree
88
+ --base <git-ref> Capability diff against the lockfile at a git ref
89
+ --online Also check npm registry metadata (the only network use)
90
+ --prod Skip devDependencies
91
+ --pr-comment <file> Write a short Markdown capability diff for a PR comment
92
+
93
+ ${dim("Install mode")} ${dim("(penthera install [pkg...]; penthera install --help for all options)")}
94
+ --yes / --dry-run Install only if nothing breaches the policy / report only
71
95
 
72
96
  ${dim("Options")}
73
97
  -r, --repo <path> Path to repo for source analysis
@@ -75,6 +99,10 @@ const HELP = `
75
99
  -o, --output <file> Write JSON report (+ companion .md if .json)
76
100
  --markdown <file> Write Markdown report (human-readable)
77
101
  --sarif <file> Write SARIF report (GitHub Security tab)
102
+ --audience <who> developer | owner | auditor report (for --markdown or stdout)
103
+ --report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
104
+ --evidence <dir> Evidence bundle: all reports + manifest.json with SHA-256s
105
+ --sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
78
106
  --baseline <file> Compare against previous JSON report
79
107
  --framework <name> Add a compliance mapping to reports (nis2)
80
108
  --readiness NIS2 readiness report (scan + self-assessment)
@@ -87,12 +115,14 @@ const HELP = `
87
115
  --incident-init <file> Write a blank incident template and exit
88
116
  --sbom <file> Write a CycloneDX software bill of materials (from --repo)
89
117
  --history <file> Track findings over time (time-to-fix, ageing, drift)
118
+ --memory Learn from this scan locally; apply suppressions + ranking
90
119
  --suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
91
120
  --auth-cookie <v> Cookie header for authenticated scans
92
121
  --auth-bearer <v> Bearer token for authenticated scans
93
122
  --json Output JSON to stdout
94
123
  --timeout <ms> Request timeout (default: 10000)
95
124
  --concurrency <n> Concurrent requests (default: 15)
125
+ --user-agent <ua> User-Agent header (default: penthera/${pkg.version} (+https://github.com/danoszz/penthera))
96
126
  -v, --verbose Detailed output
97
127
  -q, --quiet Findings only (for piping)
98
128
  -h, --help Show this help
@@ -101,6 +131,7 @@ const HELP = `
101
131
  ${dim("Examples")}
102
132
  $ penthera https://myapp.com
103
133
  $ penthera https://myapp.com --recon --deep
134
+ $ penthera http://localhost:3000 --offline
104
135
  $ penthera https://myapp.com --profile quick -o report.json
105
136
  $ penthera https://myapp.com --profile deep -o report.json --markdown report.md
106
137
  $ penthera https://myapp.com --baseline reports/previous.json
@@ -109,6 +140,10 @@ const HELP = `
109
140
  $ penthera https://myapp.com --adaptive -o report.json
110
141
  $ penthera https://myapp.com --templates ./my-templates -o report.json
111
142
  $ penthera https://myapp.com --sarif results.sarif --json | jq '.findings[]'
143
+ $ penthera agents . --home
144
+ $ penthera agents --pin
145
+ $ penthera deps --base origin/main
146
+ $ penthera deps --diff ../base-checkout --pr-comment deps.md
112
147
 
113
148
  ${dim("Exit codes")}
114
149
  0 No critical or high findings
@@ -120,6 +155,32 @@ function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
120
155
  function dim(s) { return `\x1b[2m${s}\x1b[0m`; }
121
156
 
122
157
  export async function run() {
158
+ if (process.argv[2] === "memory") {
159
+ const { runMemoryCommand } = await import("./cli/memory.js");
160
+ process.exit(await runMemoryCommand(process.argv.slice(3)));
161
+ }
162
+
163
+ if (process.argv[2] === "install") {
164
+ const { runInstall } = await import("./cli/install.js");
165
+ process.exitCode = await runInstall(process.argv.slice(3));
166
+ return;
167
+ }
168
+
169
+ if (process.argv[2] === "evidence") {
170
+ const { runEvidence } = await import("./cli/evidence.js");
171
+ process.exit(runEvidence(process.argv.slice(3)));
172
+ }
173
+
174
+ if (process.argv[2] === "fix") {
175
+ const { runFix } = await import("./cli/fix.js");
176
+ return runFix(process.argv.slice(3));
177
+ }
178
+
179
+ if (process.argv[2] === "agent") {
180
+ const { runAgent } = await import("./cli/agent.js");
181
+ process.exit(await runAgent(process.argv.slice(3)));
182
+ }
183
+
123
184
  let parsed;
124
185
  try {
125
186
  parsed = parseArgs({
@@ -130,6 +191,10 @@ export async function run() {
130
191
  output: { type: "string", short: "o" },
131
192
  markdown: { type: "string" },
132
193
  sarif: { type: "string" },
194
+ audience: { type: "string" },
195
+ "report-template": { type: "string" },
196
+ evidence: { type: "string" },
197
+ "sign-key": { type: "string" },
133
198
  baseline: { type: "string" },
134
199
  framework: { type: "string" },
135
200
  readiness: { type: "boolean", default: false },
@@ -142,7 +207,13 @@ export async function run() {
142
207
  "incident-init": { type: "string" },
143
208
  sbom: { type: "string" },
144
209
  history: { type: "string" },
210
+ memory: { type: "boolean", default: false },
145
211
  suppliers: { type: "string" },
212
+ diff: { type: "string" },
213
+ base: { type: "string" },
214
+ online: { type: "boolean", default: false },
215
+ "pr-comment": { type: "string" },
216
+ prod: { type: "boolean", default: false },
146
217
  profile: { type: "string" },
147
218
  "auth-cookie": { type: "string" },
148
219
  "auth-bearer": { type: "string" },
@@ -155,6 +226,10 @@ export async function run() {
155
226
  adaptive: { type: "boolean", default: false },
156
227
  all: { type: "boolean", default: false },
157
228
  machine: { type: "boolean", default: false },
229
+ home: { type: "boolean", default: false },
230
+ pin: { type: "boolean", default: false },
231
+ offline: { type: "boolean", default: false },
232
+ "user-agent": { type: "string" },
158
233
  timeout: { type: "string", default: "10000" },
159
234
  concurrency: { type: "string", default: "15" },
160
235
  verbose: { type: "boolean", short: "v", default: false },
@@ -217,6 +292,11 @@ export async function run() {
217
292
  process.exit(0);
218
293
  }
219
294
 
295
+ if (opts.suppliers && opts.offline) {
296
+ printError("--suppliers rates third-party domains, which --offline forbids.");
297
+ process.exit(2);
298
+ }
299
+
220
300
  if (opts.suppliers) {
221
301
  let text;
222
302
  try {
@@ -240,21 +320,52 @@ export async function run() {
240
320
  process.exit(0);
241
321
  }
242
322
 
323
+ // `penthera deps [path]`: offline dependency capability scan and diff.
324
+ if (positionals[0] === "deps") {
325
+ const { runDeps } = await import("./cli/deps.js");
326
+ await runDeps(positionals.slice(1), opts, { version: pkg.version });
327
+ return;
328
+ }
329
+ if (opts.diff || opts.base || opts.online || opts["pr-comment"] || opts.prod) {
330
+ printError("--diff, --base, --online, --prod and --pr-comment only apply to `penthera deps`.");
331
+ process.exit(2);
332
+ }
333
+
243
334
  if (shouldRunOnboarding(positionals, opts)) {
244
335
  const { runOnboarding } = await import("./cli/onboarding.js");
245
336
  await runOnboarding();
246
337
  return;
247
338
  }
248
339
 
249
- const url = positionals[0] ? normalizeBaseUrl(positionals[0]) : null;
340
+ // `penthera agents [path]`: offline audit of skills, MCP servers, hooks, plugins.
341
+ const agentsMode = positionals[0] === "agents";
342
+ const agentsRoot = agentsMode ? resolve(positionals[1] || ".") : null;
343
+ if (agentsMode && positionals.length > 2) {
344
+ printError(`agents takes one path, got: ${positionals.slice(1).join(" ")}`);
345
+ process.exit(2);
346
+ }
347
+ if ((opts.home || opts.pin) && !agentsMode) {
348
+ printError("--home and --pin only apply to `penthera agents`.");
349
+ process.exit(2);
350
+ }
351
+
352
+ const url = positionals[0] && !agentsMode ? normalizeBaseUrl(positionals[0]) : null;
250
353
  const repo = opts.repo || null;
251
354
 
252
- if (!url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
355
+ if (!agentsMode && !url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
253
356
  printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --questionnaire, or combine them.");
254
357
  console.log(HELP);
255
358
  process.exit(2);
256
359
  }
257
360
 
361
+ let reportCtx;
362
+ try {
363
+ reportCtx = prepareReportOptions(opts);
364
+ } catch (e) {
365
+ printError(e.message);
366
+ process.exit(2);
367
+ }
368
+
258
369
  // Resolve scan profile
259
370
  let profileOpts;
260
371
  try {
@@ -297,6 +408,8 @@ export async function run() {
297
408
  verbose: opts.verbose,
298
409
  authCookie: opts["auth-cookie"] || null,
299
410
  authBearer: opts["auth-bearer"] || null,
411
+ offline: opts.offline,
412
+ userAgent: opts["user-agent"] || null,
300
413
  onPhase: createProgress(opts.quiet || opts.json),
301
414
  };
302
415
 
@@ -321,6 +434,15 @@ export async function run() {
321
434
  results.push(repoResult);
322
435
  }
323
436
 
437
+ if (agentsMode) {
438
+ if (!existsSync(agentsRoot)) {
439
+ printError(`Path not found: ${agentsRoot}`);
440
+ process.exit(2);
441
+ }
442
+ const { scanAgents } = await import("../lib/agents/index.js");
443
+ results.push(await scanAgents(agentsRoot, { home: opts.home, pin: opts.pin, onPhase: scanOpts.onPhase }));
444
+ }
445
+
324
446
  if (opts.machine) {
325
447
  const { auditMachine } = await import("../lib/machine.js");
326
448
  const machineResult = await auditMachine({ onPhase: scanOpts.onPhase });
@@ -342,6 +464,13 @@ export async function run() {
342
464
  const merged = mergeResults(results);
343
465
  merged.profile = profileOpts.profile;
344
466
 
467
+ // Local learning store (opt-in): suppress + rank from ~/.penthera/memory, then record this scan.
468
+ if (opts.memory && (url || repo)) {
469
+ const { applyMemoryToScan } = await import("../lib/memory/index.js");
470
+ const { warnings } = applyMemoryToScan(merged, { repo: repo ? resolve(repo) : null, url });
471
+ for (const w of warnings) process.stderr.write(` \x1b[33mWARN\x1b[0m ${w}\n`);
472
+ }
473
+
345
474
  // Compliance framework mapping (e.g. --framework nis2)
346
475
  if (opts.framework) {
347
476
  const coverage = buildComplianceCoverage(merged, opts.framework);
@@ -409,10 +538,13 @@ export async function run() {
409
538
  }
410
539
 
411
540
  const mdOpts = { version: pkg.version, baseline: baselineStats };
541
+ const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
412
542
 
413
543
  // Output
414
544
  if (opts.json) {
415
545
  console.log(JSON.stringify(merged, null, 2));
546
+ } else if (custom && !opts.markdown) {
547
+ process.stdout.write(custom);
416
548
  } else if (opts.quiet) {
417
549
  printFindings(merged.findings);
418
550
  } else {
@@ -430,7 +562,8 @@ export async function run() {
430
562
  }
431
563
 
432
564
  if (opts.markdown) {
433
- writeMarkdownReport(merged, opts.markdown, mdOpts);
565
+ if (custom) writeFileSync(resolve(opts.markdown), custom);
566
+ else writeMarkdownReport(merged, opts.markdown, mdOpts);
434
567
  if (!opts.quiet && !opts.json) {
435
568
  process.stderr.write(` Markdown report ${opts.markdown}\n`);
436
569
  }
@@ -459,9 +592,10 @@ export async function run() {
459
592
  }
460
593
  }
461
594
 
595
+ let sbom = null;
462
596
  if (opts.sbom) {
463
597
  const sbomRepo = resolve(opts.repo || ".");
464
- const sbom = buildSbom(sbomRepo, {
598
+ sbom = buildSbom(sbomRepo, {
465
599
  serialNumber: `urn:uuid:${randomUUID()}`,
466
600
  timestamp: new Date().toISOString(),
467
601
  });
@@ -514,6 +648,18 @@ export async function run() {
514
648
  }
515
649
  }
516
650
 
651
+ if (opts.evidence) {
652
+ try {
653
+ const bundle = writeEvidence(merged, opts, { version: pkg.version, template: reportCtx.template, mdOpts, sbom, argv: process.argv.slice(2) });
654
+ if (!opts.quiet && !opts.json) {
655
+ process.stderr.write(` Evidence bundle ${bundle.manifest.artifacts.length} files${bundle.signed ? ", signed" : ""} → ${opts.evidence}/ (penthera evidence verify ${opts.evidence})\n`);
656
+ }
657
+ } catch (e) {
658
+ printError(`Could not write the evidence bundle: ${e.message}`);
659
+ process.exit(2);
660
+ }
661
+ }
662
+
517
663
  if (merged.auditLoop && !opts.quiet && !opts.json) {
518
664
  const a = merged.auditLoop;
519
665
  process.stderr.write(
@@ -522,7 +668,7 @@ export async function run() {
522
668
  );
523
669
  }
524
670
 
525
- if ((opts.output || opts.markdown || opts.sarif || opts.readiness || opts["policy-pack"] || opts.sbom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
671
+ if ((opts.output || opts.markdown || opts.sarif || opts.evidence || opts.readiness || opts["policy-pack"] || opts.sbom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
526
672
  process.stderr.write("\n");
527
673
  }
528
674
 
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Audience reports (--audience developer | owner | auditor), rendered from the
3
+ * report model. The default report (src/report/markdown.js) is unchanged.
4
+ *
5
+ * developer findings with file:line, fix recipe or diff, how to fix, sources
6
+ * owner plain-language summary, top three things to do, owner actions, trend
7
+ * auditor scope and method, confidence and provenance, egress, inconclusive
8
+ * probes, suppressions (who and why), loop timeline, NIS2 and OWASP
9
+ */
10
+ import { buildReportModel, CONFIDENCE_LABELS } from "./model.js";
11
+
12
+ export const AUDIENCES = ["developer", "owner", "auditor"];
13
+
14
+ const METHODOLOGY_URL = "https://github.com/danoszz/penthera/blob/main/docs/METHODOLOGY.md";
15
+ const EVIDENCE_URL = "https://github.com/danoszz/penthera/blob/main/docs/evidence.md";
16
+ const SEV_TAG = { critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW", info: "INFO" };
17
+ const NIS2_STATUS = {
18
+ "tested-clean": "Tested, no gaps found",
19
+ "tested-findings": "Tested, gaps found",
20
+ "not-run": "Not run this scan",
21
+ evidence: "Evidence only",
22
+ planned: "Planned",
23
+ "out-of-scope": "Out of scope",
24
+ };
25
+
26
+ /** Inline text: one line, no table breaks, no raw HTML. */
27
+ function md(s) {
28
+ return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/\r?\n|\r/g, " ")
29
+ .replace(/\\/g, "\\\\").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\|/g, "\\|");
30
+ }
31
+ /** Penthera's own remediation text (lib/remediation): may hold code spans, so only fold lines. */
32
+ function prose(s) {
33
+ return String(s ?? "").replace(/\r?\n|\r/g, " ");
34
+ }
35
+ /** A code span that its content cannot close. */
36
+ function code(s) {
37
+ return `\`${String(s ?? "").replace(/[`\r\n]/g, " ")}\``;
38
+ }
39
+ function when(iso) {
40
+ return iso ? `${String(iso).replace("T", " ").slice(0, 19)} UTC` : "-";
41
+ }
42
+ function plural(n, word) {
43
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
44
+ }
45
+ function sources(list) {
46
+ return list.map((r) => `[${md(r.cite)}](${r.url})`).join(" · ");
47
+ }
48
+
49
+ function header(L, title, m) {
50
+ L.push(`# ${title}`, "");
51
+ L.push(`> ${md(m.target)} · scanned ${when(m.timestamp)} · Penthera v${m.tool.version}`, "");
52
+ }
53
+
54
+ function footer(L) {
55
+ L.push("---", "", "*Authorized security testing only. See the Penthera README for ethical use guidelines.*", "");
56
+ }
57
+
58
+ function timelineSection(L, t, { heading = "## Fix timeline" } = {}) {
59
+ if (!t) return;
60
+ L.push(heading, "");
61
+ L.push(`Source: ${t.source}. ${t.verified} verified · ${t.failed} failed · ${t.open} open · median time to fix ${t.medianTimeToFix}.`, "");
62
+ L.push("| Finding | Found | Fixed | Verified | Time to fix |");
63
+ L.push("|---------|-------|-------|----------|-------------|");
64
+ for (const e of t.entries) {
65
+ const name = e.title ? `${md(e.title)} (${code(e.fid)})` : code(e.fid);
66
+ const fixed = e.fixed ? `${md(e.fixed)}${e.recipe ? ` (${code(e.recipe)})` : ""}` : "-";
67
+ const verified = e.verified ? md(e.verified) : e.failed ? `**failed** ${md(e.failed)}` : "-";
68
+ L.push(`| ${name} | ${md(e.found || "-")} | ${fixed} | ${verified} | ${e.timeToFix} |`);
69
+ }
70
+ L.push("");
71
+ }
72
+
73
+ function inconclusiveSection(L, m) {
74
+ if (!m.inconclusive.length) return;
75
+ L.push("## Inconclusive probes", "");
76
+ L.push(`${plural(m.summary.inconclusive, "probe")} could not complete. Their silence is not a clean result.`, "");
77
+ L.push("| Probe | URL | Error | Count |");
78
+ L.push("|-------|-----|-------|-------|");
79
+ for (const e of m.inconclusive) L.push(`| ${md(e.probe)} | ${code(e.url)} | ${md(e.message)} | ${e.count} |`);
80
+ L.push("");
81
+ }
82
+
83
+ export function formatDeveloperReport(m) {
84
+ const L = [];
85
+ header(L, "Security findings for developers", m);
86
+ const s = m.summary;
87
+ L.push(`${plural(s.total, "finding")}: ${s.critical} critical · ${s.high} high · ${s.medium} medium · ${s.low} low · ${s.info} info.`);
88
+ if (s.suppressed) L.push(`${s.suppressed} suppressed (listed at the end).`);
89
+ L.push("");
90
+ if (m.findings.some((f) => f.recipe && !f.diff)) {
91
+ L.push(`Findings with a recipe can be fixed locally: ${code("penthera fix --plan")} shows the diff, ${code("penthera fix --apply <id>")} applies it.`, "");
92
+ }
93
+ for (const f of m.findings) {
94
+ L.push(`### ${f.n}. [${SEV_TAG[f.severity] || "INFO"}] ${md(f.title)}`, "");
95
+ if (f.location) L.push(`- **Where:** ${f.url ? md(f.location) : code(f.location)}`);
96
+ L.push(`- **Confidence:** ${f.confidence}${f.source ? ` (${md(f.source)})` : ""}${f.fid ? ` · id ${code(f.fid)}` : ""}`);
97
+ if (f.recipe) L.push(`- **Fix recipe:** ${code(f.recipe)}`);
98
+ if (f.fixText) L.push(`- **How to fix:** ${prose(f.fixText)}`);
99
+ if (f.sources.length) L.push(`- **Sources:** ${sources(f.sources)}`);
100
+ if (f.description) L.push("", md(f.description));
101
+ if (f.diff) {
102
+ const fence = "`".repeat(Math.max(3, ...[...String(f.diff).matchAll(/`+/g)].map((x) => x[0].length + 1)));
103
+ L.push("", `${fence}diff`, String(f.diff).replace(/\n$/, ""), fence);
104
+ }
105
+ L.push("");
106
+ }
107
+ inconclusiveSection(L, m);
108
+ timelineSection(L, m.timeline);
109
+ if (m.suppressed.length) {
110
+ L.push("## Suppressed", "");
111
+ for (const x of m.suppressed) L.push(`- ${md(x.title)} (${code(x.fid)}): ${md(x.reason)} (${md(x.actor)}, ${x.by})`);
112
+ L.push("");
113
+ }
114
+ footer(L);
115
+ return L.join("\n");
116
+ }
117
+
118
+ export function formatOwnerReport(m) {
119
+ const L = [];
120
+ header(L, "Security summary", m);
121
+ const s = m.summary;
122
+ L.push("## In short", "");
123
+ if (s.total === 0) {
124
+ L.push(`Penthera checked ${md(m.target)} and found no problems in the areas it tested.`);
125
+ } else {
126
+ const parts = [];
127
+ if (s.serious) parts.push(`**${plural(s.serious, "serious problem")}** to fix this week`);
128
+ if (s.medium) parts.push(`${s.medium} moderate`);
129
+ if (s.low) parts.push(`${s.low} minor`);
130
+ if (s.info) parts.push(`${plural(s.info, "note")}`);
131
+ L.push(`Penthera checked ${md(m.target)} and found ${parts.join(", ")}.`);
132
+ L.push(s.serious ? "The serious ones are things an attacker could use today." : "Nothing here needs an emergency response.");
133
+ }
134
+ if (s.inconclusive) L.push(`${plural(s.inconclusive, "check")} could not finish, so those areas are untested, not clean.`);
135
+ if (s.suppressed) L.push(`${plural(s.suppressed, "known issue")} ${s.suppressed === 1 ? "was" : "were"} set aside on purpose by someone on the team (the auditor report lists who and why).`);
136
+ L.push("");
137
+
138
+ if (m.topActions.length) {
139
+ L.push("## What to do first", "");
140
+ m.topActions.forEach((a, i) => {
141
+ L.push(`${i + 1}. **${prose(a.action)}** (${a.effort}${a.ownerAction ? ", needs you" : ", a developer can do this"}). ${prose(a.why)}`);
142
+ });
143
+ L.push("");
144
+ }
145
+
146
+ if (m.ownerActions.length) {
147
+ L.push("## Things only you can do", "");
148
+ L.push("A tool or a developer cannot do these for you: they need account access, a provider dashboard or a DNS change.", "");
149
+ for (const a of m.ownerActions) L.push(`- **${prose(a.action)}**${a.fix ? `: ${prose(a.fix)}` : ""}`);
150
+ L.push("");
151
+ }
152
+
153
+ if (m.trend.length > 1) {
154
+ const first = m.trend[0];
155
+ const last = m.trend[m.trend.length - 1];
156
+ const dir = last.serious < first.serious ? "down" : last.serious > first.serious ? "up" : "flat";
157
+ L.push("## Trend", "");
158
+ L.push(`Serious problems went from ${first.serious} to ${last.serious} over the last ${m.trend.length} scans (${dir}).`, "");
159
+ L.push("| Scan | Serious | Moderate | Minor | Total |");
160
+ L.push("|------|---------|----------|-------|-------|");
161
+ for (const t of m.trend) L.push(`| ${t.date} | ${t.serious} | ${t.medium} | ${t.low} | ${t.total} |`);
162
+ L.push("");
163
+ }
164
+
165
+ if (m.timeline) {
166
+ const t = m.timeline;
167
+ L.push("## Fixes", "");
168
+ L.push(`${t.verified} ${t.verified === 1 ? "fix" : "fixes"} verified by a re-scan, ${t.failed} failed, ${t.open} still open. Typical time to fix: ${t.medianTimeToFix}.`, "");
169
+ }
170
+ footer(L);
171
+ return L.join("\n");
172
+ }
173
+
174
+ export function formatAuditorReport(m) {
175
+ const L = [];
176
+ header(L, "Security evidence report", m);
177
+ L.push("> Automated technical evidence. It is not an audit opinion, a compliance determination or a certification.", "");
178
+
179
+ L.push("## 1. Scope and method", "");
180
+ L.push("| | |", "|---|---|");
181
+ L.push(`| **Target** | ${code(m.target)} |`);
182
+ L.push(`| **Scan time** | ${when(m.timestamp)} |`);
183
+ if (m.duration) L.push(`| **Duration** | ${m.duration} |`);
184
+ L.push(`| **Modes** | ${md(m.modes || "-")} |`);
185
+ if (m.profile) L.push(`| **Profile** | ${md(m.profile)} |`);
186
+ L.push(`| **Network** | ${m.egress ? md(m.egress.mode) : "not recorded"} |`);
187
+ L.push(`| **Tool** | Penthera v${m.tool.version} |`);
188
+ L.push("");
189
+ if (m.probes.length) L.push(`Probes that ran: ${m.probes.map(code).join(", ")}.`, "");
190
+ L.push(`Method, threat model and limits: [docs/METHODOLOGY.md](${METHODOLOGY_URL}). A probe that is not listed did not run, and an area it covers is untested.`, "");
191
+
192
+ L.push("## 2. Confidence and provenance", "");
193
+ L.push("Every finding carries a confidence label that says how sure the scanner is that the issue is real, separate from its severity.", "");
194
+ L.push("| Label | Meaning | This scan |");
195
+ L.push("|-------|---------|-----------|");
196
+ for (const c of CONFIDENCE_LABELS) {
197
+ L.push(`| ${code(c.label)} | ${c.meaning} | ${m.findings.filter((f) => f.confidence === c.label).length} |`);
198
+ }
199
+ L.push("");
200
+ if (m.findings.length) {
201
+ L.push("| # | Id | Finding | Severity | Confidence | Source / method | Collected | Location |");
202
+ L.push("|---|----|---------|----------|------------|-----------------|-----------|----------|");
203
+ for (const f of m.findings) {
204
+ L.push(`| ${f.n} | ${f.fid ? code(f.fid) : "-"} | ${md(f.title)} | ${f.severity} | ${f.confidence} | ${md(f.source || "-")} / ${md(f.method || "-")} | ${when(f.collectedAt)} | ${f.location ? code(f.location) : "-"} |`);
205
+ }
206
+ } else {
207
+ L.push("No findings in the tested areas.");
208
+ }
209
+ L.push("");
210
+
211
+ L.push("## 3. Network egress", "");
212
+ if (m.egress) {
213
+ L.push(`- **Mode:** ${md(m.egress.mode)}`);
214
+ L.push(`- **User-Agent:** ${code(m.egress.userAgent)}`);
215
+ L.push(`- **Third-party hosts contacted:** ${m.egress.thirdPartyHosts.length ? m.egress.thirdPartyHosts.map((h) => `${code(h.host)} (${h.requests})`).join(", ") : "none"}`);
216
+ if (m.egress.blocked.length) L.push(`- **Blocked by --offline:** ${m.egress.blocked.map((h) => `${code(h.host)} (${h.requests})`).join(", ")}`);
217
+ } else {
218
+ L.push("No egress record (a repo-only or machine scan makes no network requests to the target).");
219
+ }
220
+ L.push("");
221
+ inconclusiveSection(L, m);
222
+
223
+ L.push("## 4. Suppressions", "");
224
+ if (m.suppressed.length) {
225
+ L.push("These findings were hidden from the results by a person's decision. Each needs a reason; review them.", "");
226
+ L.push("| Id | Finding | Severity | Who | Why | Recorded in | Expires |");
227
+ L.push("|----|---------|----------|-----|-----|-------------|---------|");
228
+ for (const x of m.suppressed) {
229
+ L.push(`| ${code(x.fid)} | ${md(x.title)} | ${x.severity} | ${md(x.actor)} | ${md(x.reason)} | ${x.by} | ${x.expires ? md(x.expires) : "never"} |`);
230
+ }
231
+ } else {
232
+ L.push("None. No finding was hidden from this report.");
233
+ }
234
+ L.push("");
235
+
236
+ timelineSection(L, m.timeline, { heading: "## 5. Fix timeline" });
237
+
238
+ L.push(`## ${m.timeline ? 6 : 5}. NIS2 Article 21(2) mapping`, "");
239
+ L.push("Technical evidence toward each measure, based on the probes that ran. Organisational measures need human process.", "");
240
+ L.push("| Art. 21(2) | Measure | This scan | Findings |");
241
+ L.push("|-----------|---------|-----------|----------|");
242
+ for (const x of m.nis2) L.push(`| (${x.id}) | ${md(x.title)} | ${NIS2_STATUS[x.status] || x.status} | ${x.findings || "-"} |`);
243
+ L.push("");
244
+
245
+ L.push(`## ${m.timeline ? 7 : 6}. OWASP mapping`, "");
246
+ if (m.owasp.length) {
247
+ L.push("| OWASP category | Findings | Ids |");
248
+ L.push("|----------------|----------|-----|");
249
+ for (const o of m.owasp) L.push(`| ${md(o.id)} | ${o.count} | ${md(o.findings)} |`);
250
+ } else {
251
+ L.push("No finding maps to an OWASP Top 10 category.");
252
+ }
253
+ L.push("");
254
+ const wstg = [...new Set(m.findings.flatMap((f) => f.wstg))].sort();
255
+ if (wstg.length) L.push(`OWASP WSTG test areas with findings: ${wstg.map(code).join(", ")}.`, "");
256
+
257
+ L.push("## Verifying this report", "");
258
+ L.push(`If this report came in an evidence bundle, check that nothing in it changed since it was produced: ${code("penthera evidence verify <dir> --allowed-signers <file>")}. See [docs/evidence.md](${EVIDENCE_URL}).`, "");
259
+ footer(L);
260
+ return L.join("\n");
261
+ }
262
+
263
+ const FORMATTERS = { developer: formatDeveloperReport, owner: formatOwnerReport, auditor: formatAuditorReport };
264
+
265
+ /** Render the report for one audience. Throws on an unknown audience. */
266
+ export function formatAudienceReport(result, audience, { version } = {}) {
267
+ const fmt = FORMATTERS[audience];
268
+ if (!fmt) throw new Error(`Unknown --audience "${audience}". Use one of: ${AUDIENCES.join(", ")}.`);
269
+ return fmt(buildReportModel(result, { version }));
270
+ }