penthera 1.4.0 → 1.6.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 (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
package/src/cli/ui.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `penthera ui [--port 0] [--open] [--project <dir>]... [--reports <dir>] [--demo]`
3
+ *
4
+ * Starts the local dashboard on 127.0.0.1 and prints a one-time URL. See
5
+ * docs/dashboard.md for the security model.
6
+ */
7
+ import { parseArgs } from "node:util";
8
+ import { spawn } from "node:child_process";
9
+ import { bold, dim, red, green } from "./ansi.js";
10
+
11
+ const HELP = `
12
+ ${bold("penthera ui")}: the local dashboard, on 127.0.0.1 only
13
+
14
+ ${dim("Usage")}
15
+ $ penthera ui [--open] [--port <n>] [--project <dir>]... [--reports <dir>]
16
+ $ penthera ui --demo Realistic fake data, for a first look or screenshots
17
+
18
+ ${dim("Options")}
19
+ --port <n> Port to listen on (default: 0, a random free port)
20
+ --open Open the one-time URL in your browser
21
+ --project <dir> Folder the dashboard may read and run the agent loop in
22
+ (repeatable; default: the current folder)
23
+ --reports <dir> Report JSON folder (default: <first project>/reports)
24
+ --demo Seed fake data; nothing is read from disk
25
+ -h, --help Show this help
26
+
27
+ The URL holds a one-time token. Opening it sets a session cookie; the token
28
+ then stops working. Nothing outside the project folders and ~/.penthera is
29
+ read, and the server never contacts the network. Ctrl-C stops it.
30
+ `;
31
+
32
+ function openBrowser(url) {
33
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
34
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
35
+ : ["xdg-open", [url]];
36
+ try {
37
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
38
+ } catch { /* the URL is printed anyway */ }
39
+ }
40
+
41
+ export async function runUi(argv, { version }) {
42
+ let parsed;
43
+ try {
44
+ parsed = parseArgs({
45
+ args: argv,
46
+ allowPositionals: false,
47
+ options: {
48
+ port: { type: "string", default: "0" },
49
+ open: { type: "boolean", default: false },
50
+ project: { type: "string", multiple: true },
51
+ reports: { type: "string" },
52
+ demo: { type: "boolean", default: false },
53
+ help: { type: "boolean", short: "h", default: false },
54
+ },
55
+ });
56
+ } catch (e) {
57
+ process.stderr.write(` ${red("Error")} ${e.message}\n${HELP}`);
58
+ return 2;
59
+ }
60
+ const opts = parsed.values;
61
+ if (opts.help) { console.log(HELP); return 0; }
62
+ const port = Number(opts.port);
63
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
64
+ process.stderr.write(` ${red("Error")} --port must be 0-65535\n`);
65
+ return 2;
66
+ }
67
+
68
+ const { startUiServer } = await import("../ui/server.js");
69
+ let ui;
70
+ try {
71
+ ui = await startUiServer({ port, projects: opts.project, reportsDir: opts.reports, demo: opts.demo, version });
72
+ } catch (e) {
73
+ process.stderr.write(` ${red("Not started")} ${e.message}\n`);
74
+ return 2;
75
+ }
76
+
77
+ process.stdout.write(`\n ${bold("penthera ui")}${opts.demo ? dim(" (demo data)") : ""}\n`);
78
+ process.stdout.write(` ${green(ui.url)}\n`);
79
+ process.stdout.write(dim(` one-time link: it signs this browser in once, then stops working\n`));
80
+ process.stdout.write(dim(` listening on 127.0.0.1:${ui.port} only. Ctrl-C to stop\n\n`));
81
+ if (opts.open) openBrowser(ui.url);
82
+
83
+ await new Promise((resolve) => {
84
+ const stop = () => { process.stdout.write("\n Stopped.\n"); ui.close().then(resolve); };
85
+ process.once("SIGINT", stop);
86
+ process.once("SIGTERM", stop);
87
+ });
88
+ return 0;
89
+ }
package/src/cli.js CHANGED
@@ -24,7 +24,9 @@ import { blankAssessment } from "../lib/compliance/self-assessment.js";
24
24
  import { buildPolicyPack, policyPackIndex } from "../lib/compliance/policy-pack.js";
25
25
  import { parseQuestionnaire, answerQuestionnaire, formatQuestionnaireMarkdown } from "../lib/compliance/questionnaire.js";
26
26
  import { buildIncidentReports, formatIncidentMarkdown, incidentTemplate } from "../lib/compliance/incident.js";
27
- 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";
28
30
  import { emptyHistory, updateHistory, buildAuditLoop } from "../lib/audit-loop.js";
29
31
  import { rateSuppliers, parseSupplierList, formatSuppliersMarkdown } from "../lib/supply-chain.js";
30
32
 
@@ -57,12 +59,14 @@ const HELP = `
57
59
  $ penthera --repo <path> Scan a local repo (white-box)
58
60
  $ penthera <url> --repo <path> Full scan (both)
59
61
  $ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
60
- $ penthera deps [path] What your npm dependencies can do (offline)
62
+ $ penthera deps [path] What your npm and Python dependencies can do (offline)
61
63
  $ penthera install <pkg...> Check what an npm install adds before anything runs
64
+ $ penthera hook install Pre-commit hook: staged secrets, agent config, lockfiles
62
65
  $ penthera memory show|verify|forget Local learning store (never leaves the machine)
63
66
  $ penthera fix --plan Plan local fix recipes (penthera fix --help)
64
67
  $ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
65
68
  $ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
69
+ $ penthera ui [--open] [--demo] Local dashboard on 127.0.0.1 (penthera ui --help)
66
70
 
67
71
  ${dim("Scan modes")}
68
72
  --profile <name> quick | standard | deep (default: standard)
@@ -77,22 +81,28 @@ const HELP = `
77
81
  --all Enable all modes (recon + deep + fuzz)
78
82
  --machine macOS machine audit (keyloggers, trojans, rootkits)
79
83
  --offline Contact only the scan target: no recon sources, no
80
- Retire.js download (uses the vendored snapshot)
84
+ Retire.js download (uses the vendored snapshot),
85
+ no DNS lookups for SPF/DMARC
81
86
 
82
87
  ${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
83
88
  --home Also audit ~/.claude, ~/.cursor and Claude Desktop config
84
89
  --pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
85
90
 
86
- ${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json + node_modules, offline)")}
91
+ ${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json, Python lockfiles or a .venv, offline)")}
87
92
  --diff <lock|dir> Capability diff against an old lockfile or installed tree
88
93
  --base <git-ref> Capability diff against the lockfile at a git ref
89
94
  --online Also check npm registry metadata (the only network use)
90
- --prod Skip devDependencies
95
+ --prod Skip dev dependencies
96
+ --venv <path> Python virtualenv to read (default: .venv or venv)
91
97
  --pr-comment <file> Write a short Markdown capability diff for a PR comment
98
+ --license-policy <p> permissive | weak-copyleft-ok | no-agpl, or a policy file
92
99
 
93
100
  ${dim("Install mode")} ${dim("(penthera install [pkg...]; penthera install --help for all options)")}
94
101
  --yes / --dry-run Install only if nothing breaches the policy / report only
95
102
 
103
+ ${dim("Hook mode")} ${dim("(penthera hook install|uninstall|run; penthera hook --help)")}
104
+ Pre-commit: staged secrets, agent config, lockfile diff
105
+
96
106
  ${dim("Options")}
97
107
  -r, --repo <path> Path to repo for source analysis
98
108
  --api-root <path> API routes directory (default: auto-detect)
@@ -102,6 +112,7 @@ const HELP = `
102
112
  --audience <who> developer | owner | auditor report (for --markdown or stdout)
103
113
  --report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
104
114
  --evidence <dir> Evidence bundle: all reports + manifest.json with SHA-256s
115
+ --no-footer Leave the one-line "Checked by Penthera" footer off Markdown output
105
116
  --sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
106
117
  --baseline <file> Compare against previous JSON report
107
118
  --framework <name> Add a compliance mapping to reports (nis2)
@@ -114,6 +125,9 @@ const HELP = `
114
125
  --incident <file> Generate NIS2 incident-report drafts + timeline
115
126
  --incident-init <file> Write a blank incident template and exit
116
127
  --sbom <file> Write a CycloneDX software bill of materials (from --repo)
128
+ --aibom <file> Write a CycloneDX 1.6 AI-BOM: AI SDKs, models, MCP servers, skills, prompts
129
+ --cra <file> Generate CRA Article 14 report drafts + timeline (writes cra-reports.md)
130
+ --cra-init <file> Write a blank CRA reporting template and exit
117
131
  --history <file> Track findings over time (time-to-fix, ageing, drift)
118
132
  --memory Learn from this scan locally; apply suppressions + ranking
119
133
  --suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
@@ -166,6 +180,12 @@ export async function run() {
166
180
  return;
167
181
  }
168
182
 
183
+ if (process.argv[2] === "hook") {
184
+ const { runHookCommand } = await import("./cli/hook.js");
185
+ process.exitCode = await runHookCommand(process.argv.slice(3));
186
+ return;
187
+ }
188
+
169
189
  if (process.argv[2] === "evidence") {
170
190
  const { runEvidence } = await import("./cli/evidence.js");
171
191
  process.exit(runEvidence(process.argv.slice(3)));
@@ -176,6 +196,11 @@ export async function run() {
176
196
  return runFix(process.argv.slice(3));
177
197
  }
178
198
 
199
+ if (process.argv[2] === "ui") {
200
+ const { runUi } = await import("./cli/ui.js");
201
+ process.exit(await runUi(process.argv.slice(3), { version: pkg.version }));
202
+ }
203
+
179
204
  if (process.argv[2] === "agent") {
180
205
  const { runAgent } = await import("./cli/agent.js");
181
206
  process.exit(await runAgent(process.argv.slice(3)));
@@ -192,6 +217,7 @@ export async function run() {
192
217
  markdown: { type: "string" },
193
218
  sarif: { type: "string" },
194
219
  audience: { type: "string" },
220
+ "no-footer": { type: "boolean", default: false },
195
221
  "report-template": { type: "string" },
196
222
  evidence: { type: "string" },
197
223
  "sign-key": { type: "string" },
@@ -206,6 +232,9 @@ export async function run() {
206
232
  incident: { type: "string" },
207
233
  "incident-init": { type: "string" },
208
234
  sbom: { type: "string" },
235
+ aibom: { type: "string" },
236
+ cra: { type: "string" },
237
+ "cra-init": { type: "string" },
209
238
  history: { type: "string" },
210
239
  memory: { type: "boolean", default: false },
211
240
  suppliers: { type: "string" },
@@ -214,6 +243,8 @@ export async function run() {
214
243
  online: { type: "boolean", default: false },
215
244
  "pr-comment": { type: "string" },
216
245
  prod: { type: "boolean", default: false },
246
+ "license-policy": { type: "string" },
247
+ venv: { type: "string" },
217
248
  profile: { type: "string" },
218
249
  "auth-cookie": { type: "string" },
219
250
  "auth-bearer": { type: "string" },
@@ -292,6 +323,32 @@ export async function run() {
292
323
  process.exit(0);
293
324
  }
294
325
 
326
+ if (opts["cra-init"]) {
327
+ const path = resolve(opts["cra-init"]);
328
+ writeFileSync(path, JSON.stringify(craTemplate(), null, 2) + "\n");
329
+ console.log(`Wrote a blank CRA reporting template to ${path}`);
330
+ console.log(`Set type and aware_at (ISO 8601), then run: penthera --cra ${opts["cra-init"]}`);
331
+ process.exit(0);
332
+ }
333
+
334
+ if (opts.cra) {
335
+ let input;
336
+ try {
337
+ input = readCraInput(resolve(opts.cra));
338
+ } catch (e) {
339
+ printError(`Could not read --cra file: ${e.message}`);
340
+ process.exit(2);
341
+ }
342
+ for (const err of input.errors) printError(err);
343
+ const result = buildCraReports(input.cra, loadJurisdiction(), { now: new Date().toISOString(), prefilled: input.prefilled });
344
+ const outPath = resolve("cra-reports.md");
345
+ writeFileSync(outPath, formatCraMarkdown(result));
346
+ console.log(`CRA Article 14 report drafts written to ${outPath}`);
347
+ for (const t of result.timeline) console.log(` ${t.title} (${t.within}): ${t.dueBy ? `due ${t.dueBy}` : t.basis || "n/a"}, ${t.status}`);
348
+ for (const w of result.warnings) console.log(` ⚠️ ${w}`);
349
+ process.exit(0);
350
+ }
351
+
295
352
  if (opts.suppliers && opts.offline) {
296
353
  printError("--suppliers rates third-party domains, which --offline forbids.");
297
354
  process.exit(2);
@@ -326,8 +383,8 @@ export async function run() {
326
383
  await runDeps(positionals.slice(1), opts, { version: pkg.version });
327
384
  return;
328
385
  }
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`.");
386
+ if (opts.diff || opts.base || opts.online || opts["pr-comment"] || opts.prod || opts["license-policy"] || opts.venv) {
387
+ printError("--diff, --base, --online, --prod, --pr-comment, --license-policy and --venv only apply to `penthera deps`.");
331
388
  process.exit(2);
332
389
  }
333
390
 
@@ -352,8 +409,8 @@ export async function run() {
352
409
  const url = positionals[0] && !agentsMode ? normalizeBaseUrl(positionals[0]) : null;
353
410
  const repo = opts.repo || null;
354
411
 
355
- if (!agentsMode && !url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
356
- printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --questionnaire, or combine them.");
412
+ if (!agentsMode && !url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.aibom && !opts.questionnaire) {
413
+ printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --aibom, --questionnaire, or combine them.");
357
414
  console.log(HELP);
358
415
  process.exit(2);
359
416
  }
@@ -537,7 +594,7 @@ export async function run() {
537
594
  }
538
595
  }
539
596
 
540
- const mdOpts = { version: pkg.version, baseline: baselineStats };
597
+ const mdOpts = { version: pkg.version, baseline: baselineStats, footer: !opts["no-footer"] };
541
598
  const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
542
599
 
543
600
  // Output
@@ -609,6 +666,19 @@ export async function run() {
609
666
  }
610
667
  }
611
668
 
669
+ if (opts.aibom) {
670
+ const aibom = buildAiBom(resolve(opts.repo || "."), {
671
+ serialNumber: `urn:uuid:${randomUUID()}`,
672
+ timestamp: new Date().toISOString(),
673
+ toolVersion: pkg.version,
674
+ });
675
+ writeFileSync(resolve(opts.aibom), JSON.stringify(aibom, null, 2) + "\n");
676
+ if (!opts.quiet && !opts.json) {
677
+ const n = aiBomSummary(aibom);
678
+ 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`);
679
+ }
680
+ }
681
+
612
682
  if (opts["policy-pack"]) {
613
683
  const dir = resolve(opts["policy-pack"]);
614
684
  mkdirSync(dir, { recursive: true });
@@ -668,7 +738,7 @@ export async function run() {
668
738
  );
669
739
  }
670
740
 
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) {
741
+ 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) {
672
742
  process.stderr.write("\n");
673
743
  }
674
744
 
@@ -8,6 +8,8 @@
8
8
  * probes, suppressions (who and why), loop timeline, NIS2 and OWASP
9
9
  */
10
10
  import { buildReportModel, CONFIDENCE_LABELS } from "./model.js";
11
+ import { mdInline } from "./md-escape.js";
12
+ import { footerLines } from "./footer.js";
11
13
 
12
14
  export const AUDIENCES = ["developer", "owner", "auditor"];
13
15
 
@@ -24,10 +26,7 @@ const NIS2_STATUS = {
24
26
  };
25
27
 
26
28
  /** 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
- }
29
+ const md = mdInline;
31
30
  /** Penthera's own remediation text (lib/remediation): may hold code spans, so only fold lines. */
32
31
  function prose(s) {
33
32
  return String(s ?? "").replace(/\r?\n|\r/g, " ");
@@ -51,8 +50,9 @@ function header(L, title, m) {
51
50
  L.push(`> ${md(m.target)} · scanned ${when(m.timestamp)} · Penthera v${m.tool.version}`, "");
52
51
  }
53
52
 
54
- function footer(L) {
53
+ function footer(L, m) {
55
54
  L.push("---", "", "*Authorized security testing only. See the Penthera README for ethical use guidelines.*", "");
55
+ L.push(...footerLines(m));
56
56
  }
57
57
 
58
58
  function timelineSection(L, t, { heading = "## Fix timeline" } = {}) {
@@ -111,7 +111,7 @@ export function formatDeveloperReport(m) {
111
111
  for (const x of m.suppressed) L.push(`- ${md(x.title)} (${code(x.fid)}): ${md(x.reason)} (${md(x.actor)}, ${x.by})`);
112
112
  L.push("");
113
113
  }
114
- footer(L);
114
+ footer(L, m);
115
115
  return L.join("\n");
116
116
  }
117
117
 
@@ -167,7 +167,7 @@ export function formatOwnerReport(m) {
167
167
  L.push("## Fixes", "");
168
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
169
  }
170
- footer(L);
170
+ footer(L, m);
171
171
  return L.join("\n");
172
172
  }
173
173
 
@@ -256,15 +256,15 @@ export function formatAuditorReport(m) {
256
256
 
257
257
  L.push("## Verifying this report", "");
258
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);
259
+ footer(L, m);
260
260
  return L.join("\n");
261
261
  }
262
262
 
263
263
  const FORMATTERS = { developer: formatDeveloperReport, owner: formatOwnerReport, auditor: formatAuditorReport };
264
264
 
265
265
  /** Render the report for one audience. Throws on an unknown audience. */
266
- export function formatAudienceReport(result, audience, { version } = {}) {
266
+ export function formatAudienceReport(result, audience, { version, footer = true } = {}) {
267
267
  const fmt = FORMATTERS[audience];
268
268
  if (!fmt) throw new Error(`Unknown --audience "${audience}". Use one of: ${AUDIENCES.join(", ")}.`);
269
- return fmt(buildReportModel(result, { version }));
269
+ return fmt({ ...buildReportModel(result, { version }), footer });
270
270
  }
@@ -9,11 +9,12 @@
9
9
  * See docs/evidence.md.
10
10
  */
11
11
  import { createHash } from "node:crypto";
12
- import { execFileSync, spawnSync } from "node:child_process";
12
+ import { spawnSync } from "node:child_process";
13
13
  import {
14
14
  readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync,
15
15
  } from "node:fs";
16
16
  import { join, resolve, relative, sep } from "node:path";
17
+ import { createGit } from "../agent/git.js";
17
18
 
18
19
  export const SCHEMA = "penthera-evidence/1";
19
20
  export const NAMESPACE = "penthera-evidence";
@@ -75,8 +76,10 @@ function quote(a) {
75
76
  export function gitInfo(dir) {
76
77
  if (!dir) return null;
77
78
  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"] });
79
+ // The scanned repo is untrusted: no fsmonitor, no hooks, no transports.
80
+ const git = createGit(dir);
81
+ const commit = git.run("rev-parse", "HEAD").trim();
82
+ const status = git.run("status", "--porcelain");
80
83
  return /^[0-9a-f]{40,64}$/.test(commit) ? { commit, dirty: status.trim().length > 0 } : null;
81
84
  } catch {
82
85
  return null;
@@ -174,19 +177,28 @@ function sshVerify(args, manifestBuf) {
174
177
  return { ok: r.status === 0, out: `${r.stdout || ""}${r.stderr || ""}`.trim() };
175
178
  }
176
179
 
177
- function checkSignature(root, manifestBuf, allowedSigners) {
180
+ /**
181
+ * Signature status. A bundle that says it is signed (manifest.signature, or a
182
+ * .sig file) only passes when the signature verifies against --allowed-signers:
183
+ * anyone can re-sign a rewritten manifest with their own key, so a signature
184
+ * whose signer was not checked proves nothing and must not read as "verified".
185
+ */
186
+ function checkSignature(root, manifestBuf, manifest, allowedSigners) {
178
187
  const sigPath = join(root, SIGNATURE);
188
+ const declared = manifest.signature != null;
179
189
  if (!existsSync(sigPath)) {
190
+ if (declared) return { status: "missing", ok: false, detail: `the manifest declares a signature but ${SIGNATURE} is missing` };
180
191
  return allowedSigners
181
192
  ? { 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" };
193
+ : { status: "unsigned", ok: true, detail: "UNSIGNED: hashes only prove the bundle is internally consistent, not who wrote it" };
194
+ }
195
+ if (lstatSync(sigPath).isSymbolicLink() || !lstatSync(sigPath).isFile()) {
196
+ return { status: "invalid", ok: false, detail: `${SIGNATURE} is not a regular file` };
183
197
  }
184
198
  if (!allowedSigners) {
185
199
  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 };
200
+ if (!r.missing && !r.ok) return { status: "invalid", ok: false, detail: r.out };
201
+ return { status: "unverified", ok: false, detail: "the bundle is signed, but the signer was not checked: pass --allowed-signers <file> listing the keys you trust" };
190
202
  }
191
203
  const allowed = resolve(allowedSigners);
192
204
  if (!existsSync(allowed)) return { status: "invalid", ok: false, detail: `allowed signers file ${allowedSigners} not found` };
@@ -203,14 +215,16 @@ function checkSignature(root, manifestBuf, allowedSigners) {
203
215
 
204
216
  /**
205
217
  * Verify a bundle. Never throws on a bad bundle; returns { ok, ... }.
218
+ * `warnings` carries the UNSIGNED notice for a bundle that passes without a
219
+ * signature; `requireSignature` turns that into a failure.
206
220
  * @param {string} dir
207
- * @param {{ allowedSigners?: string }} opts
221
+ * @param {{ allowedSigners?: string, requireSignature?: boolean }} opts
208
222
  */
209
- export function verifyEvidence(dir, { allowedSigners = null } = {}) {
223
+ export function verifyEvidence(dir, { allowedSigners = null, requireSignature = false } = {}) {
210
224
  const root = resolve(dir);
211
225
  const problems = [];
212
226
  const files = [];
213
- const fail = (msg) => ({ ok: false, problems: [msg], files, unlisted: [], signature: null, manifest: null });
227
+ const fail = (msg) => ({ ok: false, problems: [msg], warnings: [], files, unlisted: [], signature: null, manifest: null });
214
228
 
215
229
  const manifestPath = join(root, MANIFEST);
216
230
  if (!existsSync(manifestPath)) return fail(`${MANIFEST} not found in ${dir}`);
@@ -246,8 +260,13 @@ export function verifyEvidence(dir, { allowedSigners = null } = {}) {
246
260
  for (const p of unlisted) problems.push(`${p} is in the bundle but not in the manifest`);
247
261
  for (const f of files) if (!f.ok) problems.push(`${f.path}: ${f.reason}`);
248
262
 
249
- const signature = checkSignature(root, manifestBuf, allowedSigners);
263
+ const signature = checkSignature(root, manifestBuf, manifest, allowedSigners);
250
264
  if (!signature.ok) problems.push(`signature: ${signature.detail}`);
265
+ const warnings = [];
266
+ if (signature.status === "unsigned") {
267
+ if (requireSignature) problems.push("signature: --require-signature was given but the bundle is not signed");
268
+ else warnings.push("UNSIGNED bundle: anyone could have written it. Ask for a signed bundle and verify with --allowed-signers --require-signature.");
269
+ }
251
270
 
252
- return { ok: problems.length === 0, problems, files, unlisted, signature, manifest };
271
+ return { ok: problems.length === 0, problems, warnings, files, unlisted, signature, manifest };
253
272
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One-line footer at the end of human-readable Markdown output: the default
3
+ * report, audience reports, the PR comment and the evidence bundle README.
4
+ * Never added to SARIF or JSON. `--no-footer` turns it off.
5
+ */
6
+ export const FOOTER = "Checked by Penthera, open-source security for the apps and AI systems you ship · https://github.com/danoszz/penthera";
7
+
8
+ /** Lines to push at the very end of a Markdown document, or none when opted out. */
9
+ export function footerLines({ footer = true } = {}) {
10
+ return footer === false ? [] : [FOOTER, ""];
11
+ }
@@ -9,6 +9,8 @@ import { buildActionPlan, formatActionPlanMarkdown } from "../../lib/remediation
9
9
  import { formatAuditLoopMarkdown } from "../../lib/audit-loop.js";
10
10
  import { referencesFor, formatSourcesMarkdown } from "../../lib/references.js";
11
11
  import { formatDepsMarkdown } from "../../lib/deps/report.js";
12
+ import { mdInline } from "./md-escape.js";
13
+ import { footerLines } from "./footer.js";
12
14
 
13
15
  const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
14
16
  const SEV_TAG = {
@@ -33,9 +35,7 @@ function countBySeverity(findings) {
33
35
  return counts;
34
36
  }
35
37
 
36
- function mdEscape(s) {
37
- return String(s || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
38
- }
38
+ const mdEscape = mdInline;
39
39
 
40
40
  /**
41
41
  * Generate Markdown report content from scan result.
@@ -43,6 +43,7 @@ function mdEscape(s) {
43
43
  export function formatMarkdownReport(result, opts = {}) {
44
44
  const findings = sortFindings(result.findings);
45
45
  const counts = countBySeverity(findings);
46
+ // TODO(good-first-issue): the "0.2.0" fallback is stale; read the version from package.json instead.
46
47
  const version = opts.version || "0.2.0";
47
48
  const lines = [];
48
49
 
@@ -57,6 +58,7 @@ export function formatMarkdownReport(result, opts = {}) {
57
58
  lines.push(`|---|---|`);
58
59
  lines.push(`| **Target** | \`${result.target || "unknown"}\` |`);
59
60
  lines.push(`| **Scan time** | ${result.timestamp?.replace("T", " ").slice(0, 19) || "-"} UTC |`);
61
+ // TODO(good-first-issue): a missing duration prints ", "; print "-" like the other empty cells and add a test.
60
62
  lines.push(`| **Duration** | ${result.duration ? `${(result.duration / 1000).toFixed(1)}s` : ", "} |`);
61
63
  lines.push(`| **Mode** | ${result.modes?.join(", ") || result.mode || "-"} |`);
62
64
  lines.push(`| **Reachable** | ${result.reachable ? "Yes" : "No"} |`);
@@ -231,6 +233,7 @@ export function formatMarkdownReport(result, opts = {}) {
231
233
  lines.push("");
232
234
  lines.push("*Authorized security testing only. See Penthera README for ethical use guidelines.*");
233
235
  lines.push("");
236
+ lines.push(...footerLines(opts));
234
237
 
235
238
  return lines.join("\n");
236
239
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * One escape for untrusted text placed inline in Markdown (table cells, list
3
+ * items). Finding titles, evidence and questionnaire text come from scanned
4
+ * targets or pasted files, so they must not break the table, inject HTML, or
5
+ * smuggle a backslash that un-escapes our own `\|`. Backslash goes first so the
6
+ * escapes added after it are not themselves doubled.
7
+ */
8
+ export function mdInline(s) {
9
+ return String(s ?? "")
10
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
11
+ .replace(/\r?\n|\r/g, " ")
12
+ .replace(/\\/g, "\\\\")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/\|/g, "\\|");
16
+ }
package/src/reporter.js CHANGED
@@ -15,6 +15,7 @@ const PKG_VERSION = JSON.parse(
15
15
 
16
16
  // ── ANSI helpers ─────────────────────────────────────────────────────────
17
17
 
18
+ // TODO(good-first-issue): also drop colors when stdout is not a TTY (piped output gets escape codes); let FORCE_COLOR=1 override.
18
19
  const NO_COLOR = !!process.env.NO_COLOR;
19
20
 
20
21
  const ansi = (code) => (s) => NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`;
package/src/scan-url.js CHANGED
@@ -386,7 +386,11 @@ async function scanUrlInContext(target, opts) {
386
386
  } catch { /* non-critical */ }
387
387
 
388
388
  // ── Phase 9b: Email authentication posture (SPF/DMARC via DNS) ─────────
389
- if (!local && !isIpHost) {
389
+ // DNS TXT/MX queries go to resolvers, not the target, so --offline skips them.
390
+ if (!local && !isIpHost && opts.offline) {
391
+ progress("Skipping email authentication (--offline: no DNS lookups for SPF, DMARC, MX)...");
392
+ }
393
+ if (!local && !isIpHost && !opts.offline) {
390
394
  progress("Checking email authentication (SPF, DMARC)...");
391
395
  try {
392
396
  const emailResult = await probeEmailDns(extractDomain(target));