penthera 1.2.0 → 1.5.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 (101) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +493 -45
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/compliance-cra.md +141 -0
  5. package/docs/compliance-nis2.md +3 -1
  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 +1 -1
  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 +12 -1
  15. package/docs/local-loop.md +276 -0
  16. package/docs/plan.md +100 -0
  17. package/docs/references.bib +539 -0
  18. package/docs/references.md +77 -0
  19. package/docs/release-plan.md +228 -0
  20. package/lib/adaptive-probes.js +8 -5
  21. package/lib/agents/discover.js +384 -0
  22. package/lib/agents/hidden-content.js +387 -0
  23. package/lib/agents/index.js +286 -0
  24. package/lib/agents/lock.js +115 -0
  25. package/lib/agents/owasp.js +83 -0
  26. package/lib/agents/rules.js +376 -0
  27. package/lib/compliance/cra.js +525 -0
  28. package/lib/crawler.js +5 -15
  29. package/lib/data/retire-jsrepository.json.gz +0 -0
  30. package/lib/data/retire-jsrepository.meta.json +7 -0
  31. package/lib/deps/capabilities.js +523 -0
  32. package/lib/deps/diff.js +84 -0
  33. package/lib/deps/index.js +440 -0
  34. package/lib/deps/license.js +329 -0
  35. package/lib/deps/lockfile.js +109 -0
  36. package/lib/deps/online.js +132 -0
  37. package/lib/deps/popular-packages.json +1 -0
  38. package/lib/deps/report.js +190 -0
  39. package/lib/deps/source.js +171 -0
  40. package/lib/deps/spdx.json +1 -0
  41. package/lib/deps/typosquat.js +113 -0
  42. package/lib/fix/apply.js +95 -0
  43. package/lib/fix/diff.js +199 -0
  44. package/lib/fix/index.js +129 -0
  45. package/lib/fix/model.js +108 -0
  46. package/lib/fix/recipes/cookies.js +130 -0
  47. package/lib/fix/recipes/cors.js +116 -0
  48. package/lib/fix/recipes/headers.js +250 -0
  49. package/lib/fix/recipes/rate-limit.js +211 -0
  50. package/lib/fix/recipes/secrets.js +110 -0
  51. package/lib/fix/util.js +231 -0
  52. package/lib/fuzzer.js +14 -8
  53. package/lib/hook/index.js +312 -0
  54. package/lib/hook/policy.js +62 -0
  55. package/lib/injections.js +19 -15
  56. package/lib/install/index.js +271 -0
  57. package/lib/install/policy.js +125 -0
  58. package/lib/jurisdictions/nl.json +9 -0
  59. package/lib/memory/history.js +52 -0
  60. package/lib/memory/index.js +111 -0
  61. package/lib/memory/repo-suppress.js +64 -0
  62. package/lib/memory/store.js +325 -0
  63. package/lib/memory/views.js +198 -0
  64. package/lib/params.js +4 -12
  65. package/lib/provenance.js +2 -0
  66. package/lib/recon.js +12 -20
  67. package/lib/references.js +31 -0
  68. package/lib/references.json +374 -0
  69. package/lib/remediation/index.js +28 -0
  70. package/lib/retirejs.js +50 -22
  71. package/lib/templates.js +7 -10
  72. package/lib/whitebox/aibom.js +384 -0
  73. package/lib/whitebox/frameworks.js +1 -1
  74. package/lib/whitebox/sbom.js +45 -10
  75. package/lib/whitebox/secrets.js +11 -1
  76. package/package.json +17 -4
  77. package/skills/penthera/SKILL.md +132 -22
  78. package/src/agent/git.js +87 -0
  79. package/src/agent/loop.js +519 -0
  80. package/src/agent/tests.js +48 -0
  81. package/src/cli/agent.js +183 -0
  82. package/src/cli/deps.js +104 -0
  83. package/src/cli/evidence.js +67 -0
  84. package/src/cli/fix.js +191 -0
  85. package/src/cli/hook.js +131 -0
  86. package/src/cli/install.js +225 -0
  87. package/src/cli/memory.js +146 -0
  88. package/src/cli/merge-results.js +2 -0
  89. package/src/cli/report-outputs.js +72 -0
  90. package/src/cli/run-scan.js +2 -0
  91. package/src/cli.js +212 -8
  92. package/src/report/audience.js +270 -0
  93. package/src/report/evidence.js +253 -0
  94. package/src/report/markdown.js +65 -2
  95. package/src/report/model.js +212 -0
  96. package/src/report/template.js +175 -0
  97. package/src/report/timeline.js +145 -0
  98. package/src/reporter.js +109 -5
  99. package/src/scan-url.js +31 -7
  100. package/src/utils/http.js +252 -11
  101. 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";
@@ -23,7 +24,9 @@ import { blankAssessment } from "../lib/compliance/self-assessment.js";
23
24
  import { buildPolicyPack, policyPackIndex } from "../lib/compliance/policy-pack.js";
24
25
  import { parseQuestionnaire, answerQuestionnaire, formatQuestionnaireMarkdown } from "../lib/compliance/questionnaire.js";
25
26
  import { buildIncidentReports, formatIncidentMarkdown, incidentTemplate } from "../lib/compliance/incident.js";
26
- import { buildSbom } from "../lib/whitebox/sbom.js";
27
+ import { buildSbom, buildAiBom } from "../lib/whitebox/sbom.js";
28
+ import { aiBomSummary } from "../lib/whitebox/aibom.js";
29
+ import { buildCraReports, craTemplate, formatCraMarkdown, readCraInput } from "../lib/compliance/cra.js";
27
30
  import { emptyHistory, updateHistory, buildAuditLoop } from "../lib/audit-loop.js";
28
31
  import { rateSuppliers, parseSupplierList, formatSuppliersMarkdown } from "../lib/supply-chain.js";
29
32
 
@@ -55,6 +58,13 @@ const HELP = `
55
58
  $ penthera <url> Scan a live URL (black-box)
56
59
  $ penthera --repo <path> Scan a local repo (white-box)
57
60
  $ penthera <url> --repo <path> Full scan (both)
61
+ $ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
62
+ $ penthera deps [path] What your npm dependencies can do (offline)
63
+ $ penthera install <pkg...> Check what an npm install adds before anything runs
64
+ $ penthera memory show|verify|forget Local learning store (never leaves the machine)
65
+ $ penthera fix --plan Plan local fix recipes (penthera fix --help)
66
+ $ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
67
+ $ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
58
68
 
59
69
  ${dim("Scan modes")}
60
70
  --profile <name> quick | standard | deep (default: standard)
@@ -68,6 +78,26 @@ const HELP = `
68
78
  --adaptive Knowledge-graph adaptive probes on discovered routes
69
79
  --all Enable all modes (recon + deep + fuzz)
70
80
  --machine macOS machine audit (keyloggers, trojans, rootkits)
81
+ --offline Contact only the scan target: no recon sources, no
82
+ Retire.js download (uses the vendored snapshot)
83
+
84
+ ${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
85
+ --home Also audit ~/.claude, ~/.cursor and Claude Desktop config
86
+ --pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
87
+
88
+ ${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json + node_modules, offline)")}
89
+ --diff <lock|dir> Capability diff against an old lockfile or installed tree
90
+ --base <git-ref> Capability diff against the lockfile at a git ref
91
+ --online Also check npm registry metadata (the only network use)
92
+ --prod Skip devDependencies
93
+ --pr-comment <file> Write a short Markdown capability diff for a PR comment
94
+ --license-policy <p> permissive | weak-copyleft-ok | no-agpl, or a policy file
95
+
96
+ ${dim("Install mode")} ${dim("(penthera install [pkg...]; penthera install --help for all options)")}
97
+ --yes / --dry-run Install only if nothing breaches the policy / report only
98
+
99
+ ${dim("Hook mode")} ${dim("(penthera hook install|uninstall|run; penthera hook --help)")}
100
+ Pre-commit: staged secrets, agent config, lockfile diff
71
101
 
72
102
  ${dim("Options")}
73
103
  -r, --repo <path> Path to repo for source analysis
@@ -75,6 +105,10 @@ const HELP = `
75
105
  -o, --output <file> Write JSON report (+ companion .md if .json)
76
106
  --markdown <file> Write Markdown report (human-readable)
77
107
  --sarif <file> Write SARIF report (GitHub Security tab)
108
+ --audience <who> developer | owner | auditor report (for --markdown or stdout)
109
+ --report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
110
+ --evidence <dir> Evidence bundle: all reports + manifest.json with SHA-256s
111
+ --sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
78
112
  --baseline <file> Compare against previous JSON report
79
113
  --framework <name> Add a compliance mapping to reports (nis2)
80
114
  --readiness NIS2 readiness report (scan + self-assessment)
@@ -86,13 +120,18 @@ const HELP = `
86
120
  --incident <file> Generate NIS2 incident-report drafts + timeline
87
121
  --incident-init <file> Write a blank incident template and exit
88
122
  --sbom <file> Write a CycloneDX software bill of materials (from --repo)
123
+ --aibom <file> Write a CycloneDX 1.6 AI-BOM: AI SDKs, models, MCP servers, skills, prompts
124
+ --cra <file> Generate CRA Article 14 report drafts + timeline (writes cra-reports.md)
125
+ --cra-init <file> Write a blank CRA reporting template and exit
89
126
  --history <file> Track findings over time (time-to-fix, ageing, drift)
127
+ --memory Learn from this scan locally; apply suppressions + ranking
90
128
  --suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
91
129
  --auth-cookie <v> Cookie header for authenticated scans
92
130
  --auth-bearer <v> Bearer token for authenticated scans
93
131
  --json Output JSON to stdout
94
132
  --timeout <ms> Request timeout (default: 10000)
95
133
  --concurrency <n> Concurrent requests (default: 15)
134
+ --user-agent <ua> User-Agent header (default: penthera/${pkg.version} (+https://github.com/danoszz/penthera))
96
135
  -v, --verbose Detailed output
97
136
  -q, --quiet Findings only (for piping)
98
137
  -h, --help Show this help
@@ -101,6 +140,7 @@ const HELP = `
101
140
  ${dim("Examples")}
102
141
  $ penthera https://myapp.com
103
142
  $ penthera https://myapp.com --recon --deep
143
+ $ penthera http://localhost:3000 --offline
104
144
  $ penthera https://myapp.com --profile quick -o report.json
105
145
  $ penthera https://myapp.com --profile deep -o report.json --markdown report.md
106
146
  $ penthera https://myapp.com --baseline reports/previous.json
@@ -109,6 +149,10 @@ const HELP = `
109
149
  $ penthera https://myapp.com --adaptive -o report.json
110
150
  $ penthera https://myapp.com --templates ./my-templates -o report.json
111
151
  $ penthera https://myapp.com --sarif results.sarif --json | jq '.findings[]'
152
+ $ penthera agents . --home
153
+ $ penthera agents --pin
154
+ $ penthera deps --base origin/main
155
+ $ penthera deps --diff ../base-checkout --pr-comment deps.md
112
156
 
113
157
  ${dim("Exit codes")}
114
158
  0 No critical or high findings
@@ -120,6 +164,38 @@ function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
120
164
  function dim(s) { return `\x1b[2m${s}\x1b[0m`; }
121
165
 
122
166
  export async function run() {
167
+ if (process.argv[2] === "memory") {
168
+ const { runMemoryCommand } = await import("./cli/memory.js");
169
+ process.exit(await runMemoryCommand(process.argv.slice(3)));
170
+ }
171
+
172
+ if (process.argv[2] === "install") {
173
+ const { runInstall } = await import("./cli/install.js");
174
+ process.exitCode = await runInstall(process.argv.slice(3));
175
+ return;
176
+ }
177
+
178
+ if (process.argv[2] === "hook") {
179
+ const { runHookCommand } = await import("./cli/hook.js");
180
+ process.exitCode = await runHookCommand(process.argv.slice(3));
181
+ return;
182
+ }
183
+
184
+ if (process.argv[2] === "evidence") {
185
+ const { runEvidence } = await import("./cli/evidence.js");
186
+ process.exit(runEvidence(process.argv.slice(3)));
187
+ }
188
+
189
+ if (process.argv[2] === "fix") {
190
+ const { runFix } = await import("./cli/fix.js");
191
+ return runFix(process.argv.slice(3));
192
+ }
193
+
194
+ if (process.argv[2] === "agent") {
195
+ const { runAgent } = await import("./cli/agent.js");
196
+ process.exit(await runAgent(process.argv.slice(3)));
197
+ }
198
+
123
199
  let parsed;
124
200
  try {
125
201
  parsed = parseArgs({
@@ -130,6 +206,10 @@ export async function run() {
130
206
  output: { type: "string", short: "o" },
131
207
  markdown: { type: "string" },
132
208
  sarif: { type: "string" },
209
+ audience: { type: "string" },
210
+ "report-template": { type: "string" },
211
+ evidence: { type: "string" },
212
+ "sign-key": { type: "string" },
133
213
  baseline: { type: "string" },
134
214
  framework: { type: "string" },
135
215
  readiness: { type: "boolean", default: false },
@@ -141,8 +221,18 @@ export async function run() {
141
221
  incident: { type: "string" },
142
222
  "incident-init": { type: "string" },
143
223
  sbom: { type: "string" },
224
+ aibom: { type: "string" },
225
+ cra: { type: "string" },
226
+ "cra-init": { type: "string" },
144
227
  history: { type: "string" },
228
+ memory: { type: "boolean", default: false },
145
229
  suppliers: { type: "string" },
230
+ diff: { type: "string" },
231
+ base: { type: "string" },
232
+ online: { type: "boolean", default: false },
233
+ "pr-comment": { type: "string" },
234
+ prod: { type: "boolean", default: false },
235
+ "license-policy": { type: "string" },
146
236
  profile: { type: "string" },
147
237
  "auth-cookie": { type: "string" },
148
238
  "auth-bearer": { type: "string" },
@@ -155,6 +245,10 @@ export async function run() {
155
245
  adaptive: { type: "boolean", default: false },
156
246
  all: { type: "boolean", default: false },
157
247
  machine: { type: "boolean", default: false },
248
+ home: { type: "boolean", default: false },
249
+ pin: { type: "boolean", default: false },
250
+ offline: { type: "boolean", default: false },
251
+ "user-agent": { type: "string" },
158
252
  timeout: { type: "string", default: "10000" },
159
253
  concurrency: { type: "string", default: "15" },
160
254
  verbose: { type: "boolean", short: "v", default: false },
@@ -217,6 +311,37 @@ export async function run() {
217
311
  process.exit(0);
218
312
  }
219
313
 
314
+ if (opts["cra-init"]) {
315
+ const path = resolve(opts["cra-init"]);
316
+ writeFileSync(path, JSON.stringify(craTemplate(), null, 2) + "\n");
317
+ console.log(`Wrote a blank CRA reporting template to ${path}`);
318
+ console.log(`Set type and aware_at (ISO 8601), then run: penthera --cra ${opts["cra-init"]}`);
319
+ process.exit(0);
320
+ }
321
+
322
+ if (opts.cra) {
323
+ let input;
324
+ try {
325
+ input = readCraInput(resolve(opts.cra));
326
+ } catch (e) {
327
+ printError(`Could not read --cra file: ${e.message}`);
328
+ process.exit(2);
329
+ }
330
+ for (const err of input.errors) printError(err);
331
+ const result = buildCraReports(input.cra, loadJurisdiction(), { now: new Date().toISOString(), prefilled: input.prefilled });
332
+ const outPath = resolve("cra-reports.md");
333
+ writeFileSync(outPath, formatCraMarkdown(result));
334
+ console.log(`CRA Article 14 report drafts written to ${outPath}`);
335
+ for (const t of result.timeline) console.log(` ${t.title} (${t.within}): ${t.dueBy ? `due ${t.dueBy}` : t.basis || "n/a"}, ${t.status}`);
336
+ for (const w of result.warnings) console.log(` ⚠️ ${w}`);
337
+ process.exit(0);
338
+ }
339
+
340
+ if (opts.suppliers && opts.offline) {
341
+ printError("--suppliers rates third-party domains, which --offline forbids.");
342
+ process.exit(2);
343
+ }
344
+
220
345
  if (opts.suppliers) {
221
346
  let text;
222
347
  try {
@@ -240,21 +365,52 @@ export async function run() {
240
365
  process.exit(0);
241
366
  }
242
367
 
368
+ // `penthera deps [path]`: offline dependency capability scan and diff.
369
+ if (positionals[0] === "deps") {
370
+ const { runDeps } = await import("./cli/deps.js");
371
+ await runDeps(positionals.slice(1), opts, { version: pkg.version });
372
+ return;
373
+ }
374
+ if (opts.diff || opts.base || opts.online || opts["pr-comment"] || opts.prod || opts["license-policy"]) {
375
+ printError("--diff, --base, --online, --prod, --pr-comment and --license-policy only apply to `penthera deps`.");
376
+ process.exit(2);
377
+ }
378
+
243
379
  if (shouldRunOnboarding(positionals, opts)) {
244
380
  const { runOnboarding } = await import("./cli/onboarding.js");
245
381
  await runOnboarding();
246
382
  return;
247
383
  }
248
384
 
249
- const url = positionals[0] ? normalizeBaseUrl(positionals[0]) : null;
385
+ // `penthera agents [path]`: offline audit of skills, MCP servers, hooks, plugins.
386
+ const agentsMode = positionals[0] === "agents";
387
+ const agentsRoot = agentsMode ? resolve(positionals[1] || ".") : null;
388
+ if (agentsMode && positionals.length > 2) {
389
+ printError(`agents takes one path, got: ${positionals.slice(1).join(" ")}`);
390
+ process.exit(2);
391
+ }
392
+ if ((opts.home || opts.pin) && !agentsMode) {
393
+ printError("--home and --pin only apply to `penthera agents`.");
394
+ process.exit(2);
395
+ }
396
+
397
+ const url = positionals[0] && !agentsMode ? normalizeBaseUrl(positionals[0]) : null;
250
398
  const repo = opts.repo || null;
251
399
 
252
- if (!url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
253
- printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --questionnaire, or combine them.");
400
+ if (!agentsMode && !url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.aibom && !opts.questionnaire) {
401
+ printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --aibom, --questionnaire, or combine them.");
254
402
  console.log(HELP);
255
403
  process.exit(2);
256
404
  }
257
405
 
406
+ let reportCtx;
407
+ try {
408
+ reportCtx = prepareReportOptions(opts);
409
+ } catch (e) {
410
+ printError(e.message);
411
+ process.exit(2);
412
+ }
413
+
258
414
  // Resolve scan profile
259
415
  let profileOpts;
260
416
  try {
@@ -297,6 +453,8 @@ export async function run() {
297
453
  verbose: opts.verbose,
298
454
  authCookie: opts["auth-cookie"] || null,
299
455
  authBearer: opts["auth-bearer"] || null,
456
+ offline: opts.offline,
457
+ userAgent: opts["user-agent"] || null,
300
458
  onPhase: createProgress(opts.quiet || opts.json),
301
459
  };
302
460
 
@@ -321,6 +479,15 @@ export async function run() {
321
479
  results.push(repoResult);
322
480
  }
323
481
 
482
+ if (agentsMode) {
483
+ if (!existsSync(agentsRoot)) {
484
+ printError(`Path not found: ${agentsRoot}`);
485
+ process.exit(2);
486
+ }
487
+ const { scanAgents } = await import("../lib/agents/index.js");
488
+ results.push(await scanAgents(agentsRoot, { home: opts.home, pin: opts.pin, onPhase: scanOpts.onPhase }));
489
+ }
490
+
324
491
  if (opts.machine) {
325
492
  const { auditMachine } = await import("../lib/machine.js");
326
493
  const machineResult = await auditMachine({ onPhase: scanOpts.onPhase });
@@ -342,6 +509,13 @@ export async function run() {
342
509
  const merged = mergeResults(results);
343
510
  merged.profile = profileOpts.profile;
344
511
 
512
+ // Local learning store (opt-in): suppress + rank from ~/.penthera/memory, then record this scan.
513
+ if (opts.memory && (url || repo)) {
514
+ const { applyMemoryToScan } = await import("../lib/memory/index.js");
515
+ const { warnings } = applyMemoryToScan(merged, { repo: repo ? resolve(repo) : null, url });
516
+ for (const w of warnings) process.stderr.write(` \x1b[33mWARN\x1b[0m ${w}\n`);
517
+ }
518
+
345
519
  // Compliance framework mapping (e.g. --framework nis2)
346
520
  if (opts.framework) {
347
521
  const coverage = buildComplianceCoverage(merged, opts.framework);
@@ -409,10 +583,13 @@ export async function run() {
409
583
  }
410
584
 
411
585
  const mdOpts = { version: pkg.version, baseline: baselineStats };
586
+ const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
412
587
 
413
588
  // Output
414
589
  if (opts.json) {
415
590
  console.log(JSON.stringify(merged, null, 2));
591
+ } else if (custom && !opts.markdown) {
592
+ process.stdout.write(custom);
416
593
  } else if (opts.quiet) {
417
594
  printFindings(merged.findings);
418
595
  } else {
@@ -430,7 +607,8 @@ export async function run() {
430
607
  }
431
608
 
432
609
  if (opts.markdown) {
433
- writeMarkdownReport(merged, opts.markdown, mdOpts);
610
+ if (custom) writeFileSync(resolve(opts.markdown), custom);
611
+ else writeMarkdownReport(merged, opts.markdown, mdOpts);
434
612
  if (!opts.quiet && !opts.json) {
435
613
  process.stderr.write(` Markdown report ${opts.markdown}\n`);
436
614
  }
@@ -459,9 +637,10 @@ export async function run() {
459
637
  }
460
638
  }
461
639
 
640
+ let sbom = null;
462
641
  if (opts.sbom) {
463
642
  const sbomRepo = resolve(opts.repo || ".");
464
- const sbom = buildSbom(sbomRepo, {
643
+ sbom = buildSbom(sbomRepo, {
465
644
  serialNumber: `urn:uuid:${randomUUID()}`,
466
645
  timestamp: new Date().toISOString(),
467
646
  });
@@ -475,6 +654,19 @@ export async function run() {
475
654
  }
476
655
  }
477
656
 
657
+ if (opts.aibom) {
658
+ const aibom = buildAiBom(resolve(opts.repo || "."), {
659
+ serialNumber: `urn:uuid:${randomUUID()}`,
660
+ timestamp: new Date().toISOString(),
661
+ toolVersion: pkg.version,
662
+ });
663
+ writeFileSync(resolve(opts.aibom), JSON.stringify(aibom, null, 2) + "\n");
664
+ if (!opts.quiet && !opts.json) {
665
+ const n = aiBomSummary(aibom);
666
+ process.stderr.write(` AI-BOM (CycloneDX 1.6) ${n.sdks} SDKs, ${n.models} models, ${n.mcpServers} MCP servers, ${n.skills} skills, ${n.prompts} prompts → ${opts.aibom}\n`);
667
+ }
668
+ }
669
+
478
670
  if (opts["policy-pack"]) {
479
671
  const dir = resolve(opts["policy-pack"]);
480
672
  mkdirSync(dir, { recursive: true });
@@ -514,6 +706,18 @@ export async function run() {
514
706
  }
515
707
  }
516
708
 
709
+ if (opts.evidence) {
710
+ try {
711
+ const bundle = writeEvidence(merged, opts, { version: pkg.version, template: reportCtx.template, mdOpts, sbom, argv: process.argv.slice(2) });
712
+ if (!opts.quiet && !opts.json) {
713
+ process.stderr.write(` Evidence bundle ${bundle.manifest.artifacts.length} files${bundle.signed ? ", signed" : ""} → ${opts.evidence}/ (penthera evidence verify ${opts.evidence})\n`);
714
+ }
715
+ } catch (e) {
716
+ printError(`Could not write the evidence bundle: ${e.message}`);
717
+ process.exit(2);
718
+ }
719
+ }
720
+
517
721
  if (merged.auditLoop && !opts.quiet && !opts.json) {
518
722
  const a = merged.auditLoop;
519
723
  process.stderr.write(
@@ -522,7 +726,7 @@ export async function run() {
522
726
  );
523
727
  }
524
728
 
525
- if ((opts.output || opts.markdown || opts.sarif || opts.readiness || opts["policy-pack"] || opts.sbom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
729
+ if ((opts.output || opts.markdown || opts.sarif || opts.evidence || opts.readiness || opts["policy-pack"] || opts.sbom || opts.aibom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
526
730
  process.stderr.write("\n");
527
731
  }
528
732
 
@@ -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
+ }