penthera 1.5.0 → 1.6.1

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 (104) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +128 -663
  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/dashboard.md +244 -0
  20. package/docs/evidence.md +8 -4
  21. package/docs/examples/github-actions-deps.yml +4 -4
  22. package/docs/examples/github-actions-scan.yml +4 -4
  23. package/docs/index.md +9 -0
  24. package/docs/internal-audit.md +83 -0
  25. package/docs/marketing.md +151 -0
  26. package/docs/plan.md +4 -3
  27. package/docs/privacy.md +38 -0
  28. package/docs/references.bib +10 -0
  29. package/docs/references.md +1 -0
  30. package/docs/research/socket-positioning-notes.md +183 -0
  31. package/docs/use-cases/README.md +14 -0
  32. package/docs/use-cases/agencies.md +71 -0
  33. package/docs/use-cases/ai-coding-teams.md +81 -0
  34. package/docs/use-cases/eu-sme-compliance.md +68 -0
  35. package/docs/use-cases/open-source-maintainers.md +75 -0
  36. package/docs/use-cases/solo-founder.md +65 -0
  37. package/lib/blackbox/email-dns.js +5 -0
  38. package/lib/compliance/questionnaire.js +4 -2
  39. package/lib/deps/capabilities.js +49 -12
  40. package/lib/deps/index.js +290 -30
  41. package/lib/deps/lockfile.js +25 -2
  42. package/lib/deps/popular-pypi.json +1 -0
  43. package/lib/deps/python/capabilities.js +447 -0
  44. package/lib/deps/python/index.js +234 -0
  45. package/lib/deps/python/lockfiles.js +359 -0
  46. package/lib/deps/python/source.js +396 -0
  47. package/lib/deps/python/toml.js +219 -0
  48. package/lib/deps/report.js +37 -6
  49. package/lib/deps/source.js +149 -24
  50. package/lib/deps/typosquat.js +24 -13
  51. package/lib/fix/apply.js +83 -19
  52. package/lib/hook/index.js +41 -8
  53. package/lib/install/index.js +59 -18
  54. package/lib/install/policy.js +17 -5
  55. package/lib/memory/repo-suppress.js +11 -3
  56. package/lib/references.json +77 -0
  57. package/lib/remediation/index.js +9 -0
  58. package/lib/retirejs.js +69 -17
  59. package/lib/rules/regex-guard.js +368 -0
  60. package/lib/templates.js +13 -6
  61. package/lib/tls.js +7 -0
  62. package/lib/whitebox/frameworks.js +8 -5
  63. package/lib/whitebox/secrets.js +20 -7
  64. package/package.json +7 -3
  65. package/skills/penthera/SKILL.md +23 -5
  66. package/src/cli/deps.js +60 -5
  67. package/src/cli/evidence.js +14 -4
  68. package/src/cli/install.js +14 -5
  69. package/src/cli/report-outputs.js +25 -2
  70. package/src/cli/ui.js +89 -0
  71. package/src/cli.js +19 -7
  72. package/src/report/audience.js +10 -10
  73. package/src/report/evidence.js +33 -14
  74. package/src/report/footer.js +11 -0
  75. package/src/report/markdown.js +6 -3
  76. package/src/report/md-escape.js +16 -0
  77. package/src/reporter.js +1 -0
  78. package/src/scan-url.js +5 -1
  79. package/src/ui/api/data.js +309 -0
  80. package/src/ui/api/demo.js +464 -0
  81. package/src/ui/api/routes.js +134 -0
  82. package/src/ui/api/runs.js +243 -0
  83. package/src/ui/api/summarize.js +451 -0
  84. package/src/ui/security.js +153 -0
  85. package/src/ui/server.js +186 -0
  86. package/src/ui/static/app.css +723 -0
  87. package/src/ui/static/favicon.svg +1 -0
  88. package/src/ui/static/index.html +57 -0
  89. package/src/ui/static/js/api.js +29 -0
  90. package/src/ui/static/js/app.js +138 -0
  91. package/src/ui/static/js/charts.js +207 -0
  92. package/src/ui/static/js/copy.js +79 -0
  93. package/src/ui/static/js/dom.js +120 -0
  94. package/src/ui/static/js/format.js +42 -0
  95. package/src/ui/static/js/theme.js +10 -0
  96. package/src/ui/static/js/typer.js +99 -0
  97. package/src/ui/static/js/views/ai.js +68 -0
  98. package/src/ui/static/js/views/deps.js +96 -0
  99. package/src/ui/static/js/views/evidence.js +115 -0
  100. package/src/ui/static/js/views/findings.js +165 -0
  101. package/src/ui/static/js/views/history.js +92 -0
  102. package/src/ui/static/js/views/overview.js +184 -0
  103. package/src/ui/static/js/views/run.js +349 -0
  104. package/src/ui/static/js/views/settings.js +67 -0
package/src/cli/deps.js CHANGED
@@ -8,8 +8,21 @@ import { mergeResults } from "./merge-results.js";
8
8
  import { buildActionPlan } from "../../lib/remediation/index.js";
9
9
  import { printReport, printFindings, writeJsonReport, writeSarifReport, createProgress, printError } from "../reporter.js";
10
10
  import { writeMarkdownReport, markdownPathFromJson } from "../report/markdown.js";
11
+ import { prepareReportOptions, customReport, writeEvidence } from "./report-outputs.js";
12
+ import { compareWithBaseline } from "./baseline.js";
11
13
 
12
- export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy"];
14
+ export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy", "venv"];
15
+
16
+ /**
17
+ * Scan-command flags that mean nothing for a dependency scan. Rejected rather
18
+ * than ignored: a flag that silently does nothing reads as a check that ran.
19
+ * (--audience, --report-template, --evidence, --sign-key and --baseline work.)
20
+ */
21
+ export const DEPS_UNSUPPORTED = [
22
+ "memory", "framework", "readiness", "assessment", "questionnaire", "policy-pack", "org", "history",
23
+ "sbom", "aibom", "repo", "api-root", "recon", "deep", "fuzz", "nuclei", "templates", "adaptive", "all",
24
+ "machine", "home", "pin", "profile", "auth-cookie", "auth-bearer",
25
+ ];
13
26
 
14
27
  /**
15
28
  * The license policy for this run: `--license-policy <preset|file>`, else the
@@ -38,11 +51,23 @@ export async function runDeps(args, opts, { version }) {
38
51
  printError("Use --diff <lockfile|dir> or --base <git-ref>, not both.");
39
52
  process.exit(2);
40
53
  }
54
+ const unsupported = DEPS_UNSUPPORTED.filter((f) => opts[f] !== undefined && opts[f] !== false);
55
+ if (unsupported.length) {
56
+ printError(`${unsupported.map((f) => `--${f}`).join(", ")} ${unsupported.length > 1 ? "do" : "does"} not apply to \`penthera deps\` (URL and --repo scans only). Deps supports --diff, --base, --online, --prod, --venv, --pr-comment, --license-policy, -o, --markdown, --sarif, --audience, --report-template, --evidence, --sign-key and --baseline.`);
57
+ process.exit(2);
58
+ }
41
59
  const root = resolve(args[0] || ".");
42
60
  if (!existsSync(root)) {
43
61
  printError(`Path not found: ${root}`);
44
62
  process.exit(2);
45
63
  }
64
+ let reportCtx;
65
+ try {
66
+ reportCtx = prepareReportOptions(opts);
67
+ } catch (e) {
68
+ printError(e.message);
69
+ process.exit(2);
70
+ }
46
71
  const quiet = opts.quiet || opts.json;
47
72
  if (opts.online && !quiet) {
48
73
  process.stderr.write("\n \x1b[33mNOTE\x1b[0m --online fetches public package metadata from the npm registry.\n" +
@@ -59,6 +84,7 @@ export async function runDeps(args, opts, { version }) {
59
84
  base: opts.base || null,
60
85
  online: !!opts.online,
61
86
  prod: !!opts.prod,
87
+ venv: opts.venv || null,
62
88
  onPhase: createProgress(quiet),
63
89
  });
64
90
  } catch (e) {
@@ -69,11 +95,30 @@ export async function runDeps(args, opts, { version }) {
69
95
  const merged = mergeResults([result]);
70
96
  merged.actionPlan = buildActionPlan(merged.findings);
71
97
 
98
+ let baselineStats = null;
99
+ if (opts.baseline) {
100
+ let comparison;
101
+ try {
102
+ comparison = compareWithBaseline(merged.findings, resolve(opts.baseline));
103
+ } catch (e) {
104
+ printError(`--baseline: ${e.message}`);
105
+ process.exit(2);
106
+ }
107
+ baselineStats = comparison.stats;
108
+ if (opts.quiet) merged.findings = comparison.newFindings;
109
+ else if (!opts.json) {
110
+ process.stderr.write(`\n Baseline: ${comparison.stats.newCount} new, ${comparison.stats.resolvedCount} resolved, ${comparison.stats.unchangedCount} unchanged\n\n`);
111
+ }
112
+ }
113
+ const custom = customReport(merged, opts, { version, template: reportCtx.template });
114
+
72
115
  if (opts.json) console.log(JSON.stringify(merged, null, 2));
116
+ else if (custom && !opts.markdown) process.stdout.write(custom);
73
117
  else if (opts.quiet) printFindings(merged.findings);
74
118
  else printReport(merged, { verbose: opts.verbose });
75
119
 
76
- const mdOpts = { version };
120
+ const footer = !opts["no-footer"];
121
+ const mdOpts = { version, baseline: baselineStats, footer };
77
122
  const note = (s) => { if (!quiet) process.stderr.write(s); };
78
123
  if (opts.output) {
79
124
  writeJsonReport(merged, opts.output);
@@ -82,7 +127,8 @@ export async function runDeps(args, opts, { version }) {
82
127
  note(` JSON report ${opts.output}\n Markdown report ${md}\n`);
83
128
  }
84
129
  if (opts.markdown) {
85
- writeMarkdownReport(merged, opts.markdown, mdOpts);
130
+ if (custom) writeFileSync(resolve(opts.markdown), custom);
131
+ else writeMarkdownReport(merged, opts.markdown, mdOpts);
86
132
  note(` Markdown report ${opts.markdown}\n`);
87
133
  }
88
134
  if (opts.sarif) {
@@ -93,10 +139,19 @@ export async function runDeps(args, opts, { version }) {
93
139
  const { formatPrComment } = await import("../../lib/deps/report.js");
94
140
  const path = resolve(opts["pr-comment"]);
95
141
  mkdirSync(dirname(path), { recursive: true });
96
- writeFileSync(path, formatPrComment(merged, { version }));
142
+ writeFileSync(path, formatPrComment(merged, { version, footer }));
97
143
  note(` PR comment ${opts["pr-comment"]}\n`);
98
144
  }
99
- if (opts.output || opts.markdown || opts.sarif || opts["pr-comment"]) note("\n");
145
+ if (opts.evidence) {
146
+ try {
147
+ const bundle = writeEvidence(merged, { ...opts, repo: root }, { version, template: reportCtx.template, mdOpts, sbom: null, argv: process.argv.slice(2) });
148
+ note(` Evidence bundle ${bundle.manifest.artifacts.length} files${bundle.signed ? ", signed" : ""} → ${opts.evidence}/ (penthera evidence verify ${opts.evidence})\n`);
149
+ } catch (e) {
150
+ printError(`Could not write the evidence bundle: ${e.message}`);
151
+ process.exit(2);
152
+ }
153
+ }
154
+ if (opts.output || opts.markdown || opts.sarif || opts["pr-comment"] || opts.evidence) note("\n");
100
155
 
101
156
  // exitCode, not exit(): large --json output to a pipe must drain first.
102
157
  const hasSevere = merged.findings.some((f) => f.severity === "critical" || f.severity === "high");
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `penthera evidence verify <dir> [--allowed-signers <file>] [--json]`
2
+ * `penthera evidence verify <dir> [--allowed-signers <file>] [--require-signature] [--json]`
3
3
  *
4
4
  * Recomputes every artifact hash in an evidence bundle and checks the SSH
5
5
  * signature over manifest.json. Exit 0 when the bundle verifies, 1 when it
@@ -9,12 +9,16 @@ import { parseArgs } from "node:util";
9
9
  import { verifyEvidence } from "../report/evidence.js";
10
10
 
11
11
  const HELP = `
12
- penthera evidence verify <dir> [--allowed-signers <file>] [--json]
12
+ penthera evidence verify <dir> [--allowed-signers <file>] [--require-signature] [--json]
13
13
 
14
14
  Recomputes the SHA-256 of every file in an evidence bundle (written with
15
15
  --evidence <dir>), fails on any changed, missing or extra file, and checks
16
16
  manifest.json.sig with ssh-keygen against the allowed signers file.
17
17
 
18
+ A signed bundle only verifies with --allowed-signers (a valid signature by
19
+ an unknown key proves nothing). An unsigned bundle passes with an UNSIGNED
20
+ warning; --require-signature makes it fail.
21
+
18
22
  Exit codes: 0 verified, 1 verification failed, 2 usage error.
19
23
  See docs/evidence.md.
20
24
  `;
@@ -27,6 +31,7 @@ export function runEvidence(argv, { out = process.stdout } = {}) {
27
31
  allowPositionals: true,
28
32
  options: {
29
33
  "allowed-signers": { type: "string" },
34
+ "require-signature": { type: "boolean", default: false },
30
35
  json: { type: "boolean", default: false },
31
36
  help: { type: "boolean", short: "h", default: false },
32
37
  },
@@ -41,7 +46,11 @@ export function runEvidence(argv, { out = process.stdout } = {}) {
41
46
  return values.help ? 0 : 2;
42
47
  }
43
48
 
44
- const r = verifyEvidence(positionals[1], { allowedSigners: values["allowed-signers"] || null });
49
+ if (values["require-signature"] && !values["allowed-signers"]) {
50
+ process.stderr.write(` --require-signature needs --allowed-signers <file>: a signature is only worth something against keys you trust.\n${HELP}`);
51
+ return 2;
52
+ }
53
+ const r = verifyEvidence(positionals[1], { allowedSigners: values["allowed-signers"] || null, requireSignature: values["require-signature"] });
45
54
  if (values.json) {
46
55
  const { manifest, ...rest } = r;
47
56
  out.write(JSON.stringify({ ...rest, target: manifest?.target || null, generated_at: manifest?.generated_at || null }, null, 2) + "\n");
@@ -62,6 +71,7 @@ export function runEvidence(argv, { out = process.stdout } = {}) {
62
71
  const label = r.signature.ok ? (r.signature.status === "valid" ? "ok " : "warn") : "FAIL";
63
72
  out.write(` ${label} signature: ${r.signature.detail}\n`);
64
73
  }
65
- out.write(r.ok ? "\n Verified: every artifact matches the manifest.\n\n" : `\n NOT VERIFIED:\n${r.problems.map((p) => ` - ${p}\n`).join("")}\n`);
74
+ for (const w of r.warnings || []) process.stderr.write(`\n WARNING ${w}\n`);
75
+ out.write(r.ok ? `\n Verified: every artifact matches the manifest${r.signature?.status === "valid" ? `, signed by ${r.signature.principal}` : " (UNSIGNED)"}.\n\n` : `\n NOT VERIFIED:\n${r.problems.map((p) => ` - ${p}\n`).join("")}\n`);
66
76
  return r.ok ? 0 : 1;
67
77
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Shows what an install would change (new packages, capabilities gained, new
5
5
  * install scripts and their commands), applies penthera.policy.json, and only
6
- * then runs the real `npm install`. Exit codes: 0 installed (or a clean
6
+ * then runs `npm ci` on the reviewed lockfile. Exit codes: 0 installed (or a clean
7
7
  * --dry-run), 1 blocked or aborted (project untouched), 2 error.
8
8
  */
9
9
  import { parseArgs } from "node:util";
@@ -30,6 +30,7 @@ const HELP = `
30
30
  -D, --save-dev Save as devDependencies
31
31
  -E, --save-exact Save exact versions
32
32
  --offline npm resolves from its cache only (no registry traffic at all)
33
+ --allow-non-registry Accept git and tarball-URL packages (refused by default)
33
34
  --json Print the plan as JSON
34
35
  -v, --verbose Show more evidence per capability
35
36
  -h, --help Show this help
@@ -38,13 +39,17 @@ const HELP = `
38
39
  npm resolves the new tree in a temporary copy with scripts disabled, new
39
40
  tarballs go into npm's cache (integrity-verified, never unpacked or run),
40
41
  and the \`penthera deps\` engine diffs capabilities against your lockfile.
41
- Nothing in your project changes until you approve. Approved install scripts
42
- run; otherwise the install uses --ignore-scripts.
42
+ Nothing in your project changes until you approve. Then \`npm ci\` installs
43
+ exactly the reviewed lockfile. Approved install scripts run; otherwise the
44
+ install uses --ignore-scripts. A package Penthera could not read is held for
45
+ approval whatever the policy says, and one whose lockfile claims an install
46
+ script the scan did not find is always refused.
43
47
 
44
48
  ${dim("Policy")} ${dim(`(${POLICY_FILE})`)}
45
49
  { "allow": ["@acme/*", "esbuild"], "deny": ["token-harvest"],
46
50
  "requireApproval": ["install-scripts", "ai-config-write", "ai-cli-invoke",
47
51
  "token-harvest", "obfuscated", "typosquat", "unscanned"] }
52
+ "script-mismatch" is always denied and "unscanned" always needs approval.
48
53
  The requireApproval list above is the default.
49
54
  `;
50
55
 
@@ -70,6 +75,7 @@ function printPlan(plan, { policyPath, verbose }) {
70
75
  : dim(i.change === "upgraded" ? (i.compared ? "no new capabilities" : "old version not readable") : "no notable capabilities");
71
76
  L.push(` ${head} ${note}`);
72
77
  if (i.typosquatOf) L.push(` ${yellow(`looks like ${i.typosquatOf}`)}: check this is the package you meant`);
78
+ if (i.unscanned) L.push(` ${red("not scanned")}: ${i.unscanned}`);
73
79
  if (i.flags.includes("install-scripts")) {
74
80
  for (const [hook, cmd] of Object.entries(i.installScripts || {})) {
75
81
  const was = i.previousInstallScripts?.[hook];
@@ -120,6 +126,7 @@ export async function runInstall(argv) {
120
126
  "save-dev": { type: "boolean", short: "D", default: false },
121
127
  "save-exact": { type: "boolean", short: "E", default: false },
122
128
  offline: { type: "boolean", default: false },
129
+ "allow-non-registry": { type: "boolean", default: false },
123
130
  json: { type: "boolean", default: false },
124
131
  verbose: { type: "boolean", short: "v", default: false },
125
132
  help: { type: "boolean", short: "h", default: false },
@@ -164,6 +171,7 @@ export async function runInstall(argv) {
164
171
  saveDev: opts["save-dev"],
165
172
  saveExact: opts["save-exact"],
166
173
  offline: opts.offline,
174
+ allowNonRegistry: opts["allow-non-registry"],
167
175
  onPhase: createProgress(json),
168
176
  });
169
177
  } catch (e) {
@@ -195,7 +203,8 @@ export async function runInstall(argv) {
195
203
  out(` ${red("Blocked")}: ${needsApproval.length} package(s) need approval and --yes only installs when nothing breaches the policy. ${untouched}\n\n`);
196
204
  return finish(1, "blocked");
197
205
  }
198
- const withScripts = scripts.length > 0;
206
+ // A package that could not be read may have install scripts nobody saw.
207
+ const withScripts = scripts.length > 0 || needsApproval.some((i) => i.approval.includes("unscanned"));
199
208
  await initPrompt();
200
209
  const answer = (await ask(withScripts
201
210
  ? ` Install anyway? [y]es, run the install scripts / [s]kip scripts / [N]o: `
@@ -209,7 +218,7 @@ export async function runInstall(argv) {
209
218
  }
210
219
  }
211
220
 
212
- out(`\n ${dim("▸")} npm install${runScripts ? "" : " --ignore-scripts"} ${dim("(installs the reviewed lockfile)")}\n`);
221
+ out(`\n ${dim("▸")} npm ci${runScripts ? "" : " --ignore-scripts"} ${dim("(installs exactly the reviewed lockfile)")}\n`);
213
222
  let applied;
214
223
  try {
215
224
  applied = await applyInstall(plan, { runScripts, offline: opts.offline });
@@ -13,6 +13,7 @@ import { formatMarkdownReport } from "../report/markdown.js";
13
13
  import { formatSarif } from "../reporter.js";
14
14
  import { formatReadinessMarkdown } from "../../lib/compliance/readiness.js";
15
15
  import { writeEvidenceBundle, assertEmptyDir } from "../report/evidence.js";
16
+ import { footerLines } from "../report/footer.js";
16
17
 
17
18
  /** Validate the report flags. Returns { template } or throws with a user-facing message. */
18
19
  export function prepareReportOptions(opts) {
@@ -34,7 +35,7 @@ export function prepareReportOptions(opts) {
34
35
  /** The --audience or --report-template report as Markdown, or null when neither is set. */
35
36
  export function customReport(result, opts, { version, template }) {
36
37
  if (template) return renderTemplate(template, buildReportModel(result, { version }));
37
- if (opts.audience) return formatAudienceReport(result, opts.audience, { version });
38
+ if (opts.audience) return formatAudienceReport(result, opts.audience, { version, footer: !opts["no-footer"] });
38
39
  return null;
39
40
  }
40
41
 
@@ -43,11 +44,13 @@ export function customReport(result, opts, { version, template }) {
43
44
  * the custom template report, readiness and SBOM when they were produced.
44
45
  */
45
46
  export function writeEvidence(result, opts, { version, template, mdOpts, sbom, argv, now = new Date().toISOString() }) {
47
+ const footer = !opts["no-footer"];
46
48
  const artifacts = [
49
+ { path: "README.md", kind: "readme", content: evidenceReadme(result, { version, footer }) },
47
50
  { path: "report.json", kind: "json", content: JSON.stringify(result, null, 2) + "\n" },
48
51
  { path: "report.md", kind: "markdown", content: formatMarkdownReport(result, mdOpts) },
49
52
  { path: "report.sarif", kind: "sarif", content: JSON.stringify(formatSarif(result), null, 2) + "\n" },
50
- ...AUDIENCES.map((a) => ({ path: `report-${a}.md`, kind: `audience:${a}`, content: formatAudienceReport(result, a, { version }) })),
53
+ ...AUDIENCES.map((a) => ({ path: `report-${a}.md`, kind: `audience:${a}`, content: formatAudienceReport(result, a, { version, footer }) })),
51
54
  ];
52
55
  if (template) artifacts.push({ path: "report-custom.md", kind: "template", content: renderTemplate(template, buildReportModel(result, { version })) });
53
56
  if (result.readiness) artifacts.push({ path: "nis2-readiness.md", kind: "readiness", content: formatReadinessMarkdown(result.readiness) });
@@ -70,3 +73,23 @@ export function writeEvidence(result, opts, { version, template, mdOpts, sbom, a
70
73
  signKey: opts["sign-key"] || null,
71
74
  });
72
75
  }
76
+
77
+ /** README.md at the root of an evidence bundle: what is in it and how to check it. */
78
+ export function evidenceReadme(result, { version, footer = true } = {}) {
79
+ return [
80
+ "# Penthera evidence bundle",
81
+ "",
82
+ `Target: \`${String(result.target || "unknown").replace(/`/g, "")}\` · Penthera v${version}`,
83
+ "",
84
+ "- `manifest.json` lists every file with its SHA-256; `manifest.json.sig` is its signature when the bundle was signed.",
85
+ "- `report.json` and `report.sarif` are the machine-readable results; `report.md` and `report-<audience>.md` are for people.",
86
+ "",
87
+ "Check that nothing changed since the bundle was written:",
88
+ "",
89
+ "```",
90
+ "penthera evidence verify <this-dir> --allowed-signers <file>",
91
+ "```",
92
+ "",
93
+ ...footerLines({ footer }),
94
+ ].join("\n");
95
+ }
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
@@ -59,12 +59,14 @@ const HELP = `
59
59
  $ penthera --repo <path> Scan a local repo (white-box)
60
60
  $ penthera <url> --repo <path> Full scan (both)
61
61
  $ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
62
- $ penthera deps [path] What your npm dependencies can do (offline)
62
+ $ penthera deps [path] What your npm and Python dependencies can do (offline)
63
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
64
65
  $ penthera memory show|verify|forget Local learning store (never leaves the machine)
65
66
  $ penthera fix --plan Plan local fix recipes (penthera fix --help)
66
67
  $ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
67
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)
68
70
 
69
71
  ${dim("Scan modes")}
70
72
  --profile <name> quick | standard | deep (default: standard)
@@ -79,17 +81,19 @@ const HELP = `
79
81
  --all Enable all modes (recon + deep + fuzz)
80
82
  --machine macOS machine audit (keyloggers, trojans, rootkits)
81
83
  --offline Contact only the scan target: no recon sources, no
82
- Retire.js download (uses the vendored snapshot)
84
+ Retire.js download (uses the vendored snapshot),
85
+ no DNS lookups for SPF/DMARC
83
86
 
84
87
  ${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
85
88
  --home Also audit ~/.claude, ~/.cursor and Claude Desktop config
86
89
  --pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
87
90
 
88
- ${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)")}
89
92
  --diff <lock|dir> Capability diff against an old lockfile or installed tree
90
93
  --base <git-ref> Capability diff against the lockfile at a git ref
91
94
  --online Also check npm registry metadata (the only network use)
92
- --prod Skip devDependencies
95
+ --prod Skip dev dependencies
96
+ --venv <path> Python virtualenv to read (default: .venv or venv)
93
97
  --pr-comment <file> Write a short Markdown capability diff for a PR comment
94
98
  --license-policy <p> permissive | weak-copyleft-ok | no-agpl, or a policy file
95
99
 
@@ -108,6 +112,7 @@ const HELP = `
108
112
  --audience <who> developer | owner | auditor report (for --markdown or stdout)
109
113
  --report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
110
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
111
116
  --sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
112
117
  --baseline <file> Compare against previous JSON report
113
118
  --framework <name> Add a compliance mapping to reports (nis2)
@@ -191,6 +196,11 @@ export async function run() {
191
196
  return runFix(process.argv.slice(3));
192
197
  }
193
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
+
194
204
  if (process.argv[2] === "agent") {
195
205
  const { runAgent } = await import("./cli/agent.js");
196
206
  process.exit(await runAgent(process.argv.slice(3)));
@@ -207,6 +217,7 @@ export async function run() {
207
217
  markdown: { type: "string" },
208
218
  sarif: { type: "string" },
209
219
  audience: { type: "string" },
220
+ "no-footer": { type: "boolean", default: false },
210
221
  "report-template": { type: "string" },
211
222
  evidence: { type: "string" },
212
223
  "sign-key": { type: "string" },
@@ -233,6 +244,7 @@ export async function run() {
233
244
  "pr-comment": { type: "string" },
234
245
  prod: { type: "boolean", default: false },
235
246
  "license-policy": { type: "string" },
247
+ venv: { type: "string" },
236
248
  profile: { type: "string" },
237
249
  "auth-cookie": { type: "string" },
238
250
  "auth-bearer": { type: "string" },
@@ -371,8 +383,8 @@ export async function run() {
371
383
  await runDeps(positionals.slice(1), opts, { version: pkg.version });
372
384
  return;
373
385
  }
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`.");
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`.");
376
388
  process.exit(2);
377
389
  }
378
390
 
@@ -582,7 +594,7 @@ export async function run() {
582
594
  }
583
595
  }
584
596
 
585
- const mdOpts = { version: pkg.version, baseline: baselineStats };
597
+ const mdOpts = { version: pkg.version, baseline: baselineStats, footer: !opts["no-footer"] };
586
598
  const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
587
599
 
588
600
  // Output
@@ -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
  }