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
@@ -1,11 +1,14 @@
1
1
  ---
2
2
  name: penthera
3
3
  description: >
4
- The routine work of a security team, run locally with the Penthera CLI. Scans the
4
+ Open-source security for the apps and AI systems the user ships, run locally with
5
+ the Penthera CLI. Scans the
5
6
  user's own live URL and repo (TLS, headers, auth, OpenAPI, secrets incl. AI keys,
6
- opt-in injection); checks npm dependencies offline ("is this dependency safe?",
7
- "what changed in my deps?", "install X safely"); audits AI tooling ("audit my MCP
8
- servers / skills / hooks", "check this skill before I install it"); fixes findings
7
+ opt-in injection); checks npm and Python dependencies offline ("is this dependency safe?",
8
+ "what changed in my deps?", "install X safely", "any AGPL in our deps?"); blocks
9
+ secrets and risky agent config before they are committed ("add a pre-commit hook");
10
+ audits AI tooling ("audit my MCP servers / skills / hooks", "check this skill
11
+ before I install it"); fixes findings
9
12
  on a local branch and re-scans to verify ("scan and fix my app"); and writes
10
13
  reports and evidence ("report for my auditor / manager", NIS2, SARIF, baseline
11
14
  diff). Use when the user asks to scan, audit, harden, fix, or review the security of
@@ -16,7 +19,7 @@ compatibility: Node.js 22+, network access to target, Penthera CLI (npm link or
16
19
  allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
17
20
  metadata:
18
21
  author: danoszz
19
- version: 1.4.0
22
+ version: 1.6.0
20
23
  category: security
21
24
  ---
22
25
 
@@ -68,6 +71,8 @@ All examples below use `penthera`; substitute `node bin/penthera.js` when needed
68
71
  | Scan repo only (secrets, routes) | `penthera --repo . -o reports/repo-scan.json` |
69
72
  | Is this dependency safe? / what changed in my deps? | `penthera deps` or `penthera deps --base origin/main` (see **Dependency risk scan**) |
70
73
  | Install X safely / is it safe to add this package? | `penthera install <pkg> --dry-run`, then `penthera install <pkg>` (see **Install firewall**) |
74
+ | Are our dependency licenses OK? / any AGPL? | `penthera deps --license-policy permissive` (see **License policy**) |
75
+ | Add a pre-commit hook / stop me committing secrets | `penthera hook install` (see **Pre-commit hook**) |
71
76
  | URL + source combined | `penthera <url> --repo . -o reports/scan.json --sarif reports/scan.sarif` |
72
77
  | Scan and fix my app | **Workflow 4**: `penthera agent --loop` (scan, fix, verify, commit locally), then hand-fix what is left from [references/remediation.md](references/remediation.md) |
73
78
  | Compare to previous scan | `penthera <url> -o reports/scan.json --baseline reports/previous.json` |
@@ -75,6 +80,7 @@ All examples below use `penthera`; substitute `node bin/penthera.js` when needed
75
80
  | macOS machine audit | `penthera --machine` |
76
81
  | Audit MCP servers, skills, hooks, agent config | `penthera agents . --home -o reports/agents.json` (see **Workflow 6**) |
77
82
  | Report for my auditor / manager / developers | `--audience auditor\|owner\|developer --markdown reports/<who>.md`, or `--evidence <dir>` (see **Workflow 7**) |
83
+ | Open the penthera dashboard / show me my security posture | `penthera ui --open` (see **Dashboard**) |
78
84
 
79
85
  ## Default safe behavior
80
86
 
@@ -265,19 +271,20 @@ Summarize secret findings; remind user to rotate any exposed credentials.
265
271
 
266
272
  ## Dependency risk scan
267
273
 
268
- **Triggers:** "is this dependency safe?", "what changed in my deps?", "review this lockfile change / dependency upgrade", "does this package have install scripts?", "is this a typosquat?"
274
+ **Triggers:** "is this dependency safe?", "what changed in my deps?", "review this lockfile change / dependency upgrade", "does this package have install scripts?", "is this a typosquat?", "is this pip / PyPI package safe?", "check my requirements.txt / uv.lock / poetry.lock / venv", "does this package have a .pth file or setup.py hook?"
269
275
 
270
- Offline and read-only: it reads `package-lock.json` and `node_modules`, runs no package code, and sends nothing anywhere, so no target authorization is needed. It never installs anything; if `node_modules` is missing, tell the user to run `npm ci --ignore-scripts` first.
276
+ Offline and read-only: it reads `package-lock.json` and `node_modules`, or for Python `uv.lock` / `poetry.lock` / `pdm.lock` / `Pipfile.lock` / `requirements*.txt` and the `.venv` (or `--venv <path>`), runs no package code (not even pip), and sends nothing anywhere, so no target authorization is needed. It never installs anything; if `node_modules` is missing, tell the user to run `npm ci --ignore-scripts` first. For Python without a venv, suggest `uv sync` or `pip install -r requirements.txt` into a `.venv` (wheels only, `--only-binary :all:`, if they are wary of setup.py).
271
277
 
272
278
  ```bash
273
279
  penthera deps # what every installed package can do
274
280
  penthera deps --base origin/main # capability diff vs the lockfile on main
275
281
  penthera deps --diff ../base-checkout # diff vs another installed tree
276
282
  penthera deps --prod -o reports/deps.json # skip devDependencies, write JSON + Markdown
283
+ penthera deps --venv path/to/venv # Python: a virtualenv that is not .venv/venv
277
284
  ```
278
285
 
279
286
  1. Lead with the diff: which packages are **new** or **gained** capabilities (network, shell, install script, whole environment, dynamic code), each with its `file:line` evidence.
280
- 2. Then critical/high risk signals (AI-config writes, AI CLI invocation, GitHub token harvesting, obfuscation, env exfiltration). Open the evidence file and read the lines before calling anything malicious: a capability is what code *can* do, not a verdict.
287
+ 2. Then critical/high risk signals (AI-config writes, AI CLI invocation, GitHub token harvesting, obfuscation, env exfiltration; for Python also `.pth` startup hooks that run risky code, unowned `.pth` files, files changed since install). Open the evidence file and read the lines before calling anything malicious: a capability is what code *can* do, not a verdict.
281
288
  3. If old versions were "not compared", say so; suggest installing the base tree and passing it to `--diff`.
282
289
  4. `--online` is the only network use (npm registry metadata: publish age, publisher change, deprecation). Only add it if the user agrees.
283
290
 
@@ -296,6 +303,46 @@ penthera install <pkg> --yes # install only if nothing breaches the
296
303
  2. Exit 1 means blocked: the project is untouched. Never add a package to `allow` in `penthera.policy.json`, or approve install scripts, without the user saying so after seeing the evidence.
297
304
  3. If the user approves, run `penthera install <pkg>` in their terminal (the prompt is theirs to answer), or `--yes` after they have added an `allow` entry.
298
305
 
306
+ ## License policy
307
+
308
+ **Triggers:** "are our licenses OK?", "do we ship anything GPL / AGPL?", "license check before release", "add a license policy"
309
+
310
+ ```bash
311
+ penthera deps --license-policy permissive --json # presets: permissive, weak-copyleft-ok, no-agpl
312
+ ```
313
+
314
+ 1. Findings have category `license`: a breach or `UNLICENSED` is high; a missing, custom or non-SPDX license is medium.
315
+ 2. Without the flag, the `licenses` section of `penthera.policy.json` applies if present. Never add packages to `allowPackages` without the user's say-so; that is a legal decision, not a technical one.
316
+
317
+ ## Pre-commit hook
318
+
319
+ **Triggers:** "add a pre-commit hook", "stop me committing secrets / API keys", "check before I commit", "block bad MCP config in commits"
320
+
321
+ ```bash
322
+ penthera hook install # .git/hooks/pre-commit; an existing hook is kept and runs first
323
+ penthera hook run # check what is staged now (exit 1 = blocked)
324
+ ```
325
+
326
+ 1. It checks staged content only: secrets and AI provider keys, `penthera agents` rules on staged agent config, and the capability diff plus license policy when `package-lock.json` is staged. Blocks on high and critical by default (`hook.blockOn` in `penthera.policy.json`).
327
+ 2. If `core.hooksPath` is set (husky, lefthook), do not pass `--force`; add `npx --no-install penthera hook run` to that tool's pre-commit config instead.
328
+ 3. Never suggest `git commit --no-verify` to get past a finding unless the user asks how to skip it; suggest fixing it or a `penthera.suppress.json` entry with a reason.
329
+
330
+ ## Dashboard
331
+
332
+ **Triggers:** "open the penthera dashboard", "show me the dashboard", "show my security posture", "run the agent from the UI".
333
+
334
+ ```bash
335
+ penthera ui --open # this folder, reports in ./reports
336
+ penthera ui --project <dir> --reports <dir> --open # other folders
337
+ penthera ui --demo --open # fake data, to look around first
338
+ ```
339
+
340
+ 1. Start it in the user's terminal (it keeps running until Ctrl-C). It binds 127.0.0.1 on a random port and prints a one-time URL: tell the user to open that link (or pass `--open`). Never paste the token anywhere else.
341
+ 2. It reads only the `--project` folders, the reports folder and `~/.penthera`. If the screens are empty, scan with `-o reports/<name>.json` first (and `penthera deps -o reports/deps.json`, `penthera agents . -o reports/agents.json`).
342
+ 3. The Run screen starts scans, dependency checks, AI tooling audits and the fix loop. The fix loop is `penthera agent --loop` with the same rules: the user approves every diff in the page, nothing is pushed. A URL that is not on this machine needs its hostname typed to confirm; the authorization gate above still applies.
343
+
344
+ See [docs/dashboard.md](../../docs/dashboard.md).
345
+
299
346
  ## Examples
300
347
 
301
348
  **Example 1: Localhost quick check**
package/src/cli/deps.js CHANGED
@@ -2,14 +2,45 @@
2
2
  * `penthera deps [path]`: CLI runner for the dependency capability scan.
3
3
  */
4
4
  import { resolve } from "node:path";
5
- import { existsSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  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"];
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
+ ];
26
+
27
+ /**
28
+ * The license policy for this run: `--license-policy <preset|file>`, else the
29
+ * `licenses` section of penthera.policy.json, else none.
30
+ */
31
+ async function resolveLicensePolicy(root, flag) {
32
+ const { normalizeLicensePolicy, LICENSE_PRESETS } = await import("../../lib/deps/license.js");
33
+ if (flag) {
34
+ if (LICENSE_PRESETS[flag]) return normalizeLicensePolicy({ preset: flag }, "--license-policy");
35
+ const path = resolve(flag);
36
+ if (!existsSync(path)) throw new Error(`--license-policy: "${flag}" is neither a preset (${Object.keys(LICENSE_PRESETS).join(", ")}) nor a file`);
37
+ let raw;
38
+ try { raw = JSON.parse(readFileSync(path, "utf-8")); } catch (e) { throw new Error(`${flag}: ${e.message}`); }
39
+ return normalizeLicensePolicy(raw && typeof raw === "object" && "licenses" in raw ? raw.licenses : raw, `${flag}: licenses`);
40
+ }
41
+ const { loadPolicy } = await import("../../lib/install/policy.js");
42
+ return loadPolicy(root).policy.licenses;
43
+ }
13
44
 
14
45
  export async function runDeps(args, opts, { version }) {
15
46
  if (args.length > 1) {
@@ -20,11 +51,23 @@ export async function runDeps(args, opts, { version }) {
20
51
  printError("Use --diff <lockfile|dir> or --base <git-ref>, not both.");
21
52
  process.exit(2);
22
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
+ }
23
59
  const root = resolve(args[0] || ".");
24
60
  if (!existsSync(root)) {
25
61
  printError(`Path not found: ${root}`);
26
62
  process.exit(2);
27
63
  }
64
+ let reportCtx;
65
+ try {
66
+ reportCtx = prepareReportOptions(opts);
67
+ } catch (e) {
68
+ printError(e.message);
69
+ process.exit(2);
70
+ }
28
71
  const quiet = opts.quiet || opts.json;
29
72
  if (opts.online && !quiet) {
30
73
  process.stderr.write("\n \x1b[33mNOTE\x1b[0m --online fetches public package metadata from the npm registry.\n" +
@@ -34,11 +77,14 @@ export async function runDeps(args, opts, { version }) {
34
77
  const { scanDeps } = await import("../../lib/deps/index.js");
35
78
  let result;
36
79
  try {
80
+ const licensePolicy = await resolveLicensePolicy(root, opts["license-policy"]);
37
81
  result = await scanDeps(root, {
82
+ licensePolicy,
38
83
  diff: opts.diff || null,
39
84
  base: opts.base || null,
40
85
  online: !!opts.online,
41
86
  prod: !!opts.prod,
87
+ venv: opts.venv || null,
42
88
  onPhase: createProgress(quiet),
43
89
  });
44
90
  } catch (e) {
@@ -49,11 +95,30 @@ export async function runDeps(args, opts, { version }) {
49
95
  const merged = mergeResults([result]);
50
96
  merged.actionPlan = buildActionPlan(merged.findings);
51
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
+
52
115
  if (opts.json) console.log(JSON.stringify(merged, null, 2));
116
+ else if (custom && !opts.markdown) process.stdout.write(custom);
53
117
  else if (opts.quiet) printFindings(merged.findings);
54
118
  else printReport(merged, { verbose: opts.verbose });
55
119
 
56
- const mdOpts = { version };
120
+ const footer = !opts["no-footer"];
121
+ const mdOpts = { version, baseline: baselineStats, footer };
57
122
  const note = (s) => { if (!quiet) process.stderr.write(s); };
58
123
  if (opts.output) {
59
124
  writeJsonReport(merged, opts.output);
@@ -62,7 +127,8 @@ export async function runDeps(args, opts, { version }) {
62
127
  note(` JSON report ${opts.output}\n Markdown report ${md}\n`);
63
128
  }
64
129
  if (opts.markdown) {
65
- writeMarkdownReport(merged, opts.markdown, mdOpts);
130
+ if (custom) writeFileSync(resolve(opts.markdown), custom);
131
+ else writeMarkdownReport(merged, opts.markdown, mdOpts);
66
132
  note(` Markdown report ${opts.markdown}\n`);
67
133
  }
68
134
  if (opts.sarif) {
@@ -73,10 +139,19 @@ export async function runDeps(args, opts, { version }) {
73
139
  const { formatPrComment } = await import("../../lib/deps/report.js");
74
140
  const path = resolve(opts["pr-comment"]);
75
141
  mkdirSync(dirname(path), { recursive: true });
76
- writeFileSync(path, formatPrComment(merged, { version }));
142
+ writeFileSync(path, formatPrComment(merged, { version, footer }));
77
143
  note(` PR comment ${opts["pr-comment"]}\n`);
78
144
  }
79
- 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");
80
155
 
81
156
  // exitCode, not exit(): large --json output to a pipe must drain first.
82
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
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `penthera hook install|uninstall|run`: the pre-commit hook (lib/hook/).
3
+ * Exit codes for `run`: 0 commit may go ahead, 1 blocked, 2 error (also blocks).
4
+ */
5
+ import { parseArgs } from "node:util";
6
+ import { relative, resolve } from "node:path";
7
+ import { runHook, installHook, uninstallHook, fixHint } from "../../lib/hook/index.js";
8
+ import { printError } from "../reporter.js";
9
+ import { bold, dim, green, red, yellow, cyan } from "./ansi.js";
10
+
11
+ const HELP = `
12
+ ${bold("penthera hook")} — block secrets, malicious agent config and risky lockfile changes before they are committed
13
+
14
+ ${dim("Usage")}
15
+ $ penthera hook install Write .git/hooks/pre-commit (an existing hook is kept and runs first)
16
+ $ penthera hook uninstall Remove it and restore the hook it replaced
17
+ $ penthera hook run Check what is staged now (what the hook runs)
18
+
19
+ ${dim("Options")}
20
+ --policy <file> Policy file (default: ./penthera.policy.json)
21
+ --force install: write into core.hooksPath even when another tool manages it
22
+ --json run: print the result as JSON
23
+ -v, --verbose run: also list findings below the blocking severity
24
+ -h, --help Show this help
25
+
26
+ ${dim("What it checks (staged content only, offline)")}
27
+ Secrets and AI provider keys in staged files; \`penthera agents\` rules on staged
28
+ .mcp.json, CLAUDE.md, AGENTS.md, .claude/, .cursor/ and skills/; and, when
29
+ package-lock.json is staged, the capability diff against HEAD plus the license
30
+ policy. Blocks on high and critical by default.
31
+
32
+ ${dim("Policy")} ${dim("(penthera.policy.json)")}
33
+ { "hook": { "blockOn": "high", "ignore": ["tests/fixtures/**"],
34
+ "checks": { "secrets": true, "agents": true, "deps": true, "licenses": true } } }
35
+
36
+ ${dim("Other hook managers")}
37
+ pre-commit: repo: https://github.com/danoszz/penthera, hooks: [{ id: penthera }]
38
+ husky: echo "npx --no-install penthera hook run" >> .husky/pre-commit
39
+ lefthook: pre-commit: { commands: { penthera: { run: npx --no-install penthera hook run } } }
40
+ `;
41
+
42
+ const SEV = { critical: red, high: red, medium: yellow, low: dim, info: dim };
43
+ const where = (f) => `${f.url}${f.line ? `:${f.line}` : ""}`;
44
+
45
+ function findingLine(f, mark) {
46
+ const sev = (SEV[f.severity] || dim)(f.severity.toUpperCase().padEnd(8));
47
+ const ev = f.evidence ? ` ${dim(f.evidence)}` : "";
48
+ return ` ${mark} ${sev} ${cyan(where(f))} ${f.title}${ev} ${dim(`id ${f.id}`)}\n ${dim("→")} ${fixHint(f)}`;
49
+ }
50
+
51
+ function printRun(r, { verbose }) {
52
+ const L = [];
53
+ const secs = (r.duration / 1000).toFixed(1);
54
+ const what = [`${r.staged} staged file${r.staged === 1 ? "" : "s"}`, r.checked.agents && `${r.checked.agents} agent config`, r.checked.lockfiles && `${r.checked.lockfiles} lockfile`].filter(Boolean).join(", ");
55
+ for (const w of r.warnings) L.push(` ${yellow("!")} ${w}`);
56
+ if (!r.blocking.length) {
57
+ const below = r.below.length ? dim(` · ${r.below.length} below ${r.blockOn}${verbose ? "" : " (-v to list)"}`) : "";
58
+ const sup = r.suppressed.length ? dim(` · ${r.suppressed.length} suppressed`) : "";
59
+ L.push(` ${green("✓")} ${bold("penthera")} ${what} checked in ${secs}s${below}${sup}`);
60
+ if (verbose) for (const f of r.below) L.push(findingLine(f, yellow("!")));
61
+ process.stderr.write(L.join("\n") + "\n");
62
+ return;
63
+ }
64
+ L.push("");
65
+ L.push(` ${red("✗")} ${bold("penthera")} blocked this commit: ${r.blocking.length} finding${r.blocking.length === 1 ? "" : "s"} at ${r.blockOn} or above ${dim(`(${what}, ${secs}s)`)}`);
66
+ L.push("");
67
+ for (const f of r.blocking) L.push(findingLine(f, red("✗")));
68
+ if (verbose) for (const f of r.below) L.push(findingLine(f, yellow("!")));
69
+ else if (r.below.length) L.push(dim(` + ${r.below.length} below ${r.blockOn}, not blocking (-v to list)`));
70
+ L.push("");
71
+ L.push(` Fix and \`git add\` again. A false positive? Add it to penthera.suppress.json:`);
72
+ L.push(dim(` { "version": 1, "suppress": [{ "id": "${r.blocking[0].id}", "reason": "why this is safe" }] }`));
73
+ L.push(` To skip the check for one commit: ${bold("git commit --no-verify")} ${dim("(the finding still ships)")}`);
74
+ L.push("");
75
+ process.stderr.write(L.join("\n") + "\n");
76
+ }
77
+
78
+ export async function runHookCommand(argv) {
79
+ let parsed;
80
+ try {
81
+ parsed = parseArgs({
82
+ args: argv,
83
+ allowPositionals: true,
84
+ options: {
85
+ policy: { type: "string" },
86
+ force: { type: "boolean", default: false },
87
+ json: { type: "boolean", default: false },
88
+ verbose: { type: "boolean", short: "v", default: false },
89
+ help: { type: "boolean", short: "h", default: false },
90
+ },
91
+ });
92
+ } catch (e) {
93
+ printError(e.message);
94
+ return 2;
95
+ }
96
+ const { values: opts, positionals } = parsed;
97
+ const sub = positionals[0];
98
+ if (opts.help || !sub) {
99
+ console.log(HELP);
100
+ return sub || opts.help ? 0 : 2;
101
+ }
102
+ const cwd = process.cwd();
103
+ const rel = (p) => relative(cwd, p) || p;
104
+ try {
105
+ if (sub === "install") {
106
+ const r = installHook(cwd, { force: opts.force });
107
+ console.log(` ${green("✓")} ${r.updated ? "Updated" : "Installed"} ${rel(r.path)}`);
108
+ if (r.chained) console.log(` ${dim("▸")} Your existing hook moved to ${rel(r.chained)} and runs first; \`penthera hook uninstall\` puts it back.`);
109
+ console.log(` ${dim("▸")} Every commit now checks staged secrets, agent config and lockfile changes. Skip once with git commit --no-verify.`);
110
+ return 0;
111
+ }
112
+ if (sub === "uninstall") {
113
+ const r = uninstallHook(cwd);
114
+ if (!r.removed) console.log(` ${dim("No penthera pre-commit hook installed.")}`);
115
+ else console.log(` ${green("✓")} Removed ${rel(r.path)}${r.restored ? `; your previous hook is back in place` : ""}`);
116
+ return 0;
117
+ }
118
+ if (sub === "run") {
119
+ const r = await runHook(cwd, { policyPath: opts.policy ? resolve(opts.policy) : null });
120
+ if (opts.json) console.log(JSON.stringify(r, null, 2));
121
+ else printRun(r, { verbose: opts.verbose });
122
+ return r.blocking.length ? 1 : 0;
123
+ }
124
+ } catch (e) {
125
+ printError(e.message);
126
+ if (sub === "run") process.stderr.write(` ${dim("The commit is blocked because the check could not run. Skip it once with git commit --no-verify.")}\n`);
127
+ return 2;
128
+ }
129
+ printError(`Unknown hook command "${sub}". Use install, uninstall or run.`);
130
+ return 2;
131
+ }
@@ -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
+ }