penthera 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/deps.js CHANGED
@@ -2,14 +2,32 @@
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
11
 
12
- export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod"];
12
+ export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy"];
13
+
14
+ /**
15
+ * The license policy for this run: `--license-policy <preset|file>`, else the
16
+ * `licenses` section of penthera.policy.json, else none.
17
+ */
18
+ async function resolveLicensePolicy(root, flag) {
19
+ const { normalizeLicensePolicy, LICENSE_PRESETS } = await import("../../lib/deps/license.js");
20
+ if (flag) {
21
+ if (LICENSE_PRESETS[flag]) return normalizeLicensePolicy({ preset: flag }, "--license-policy");
22
+ const path = resolve(flag);
23
+ if (!existsSync(path)) throw new Error(`--license-policy: "${flag}" is neither a preset (${Object.keys(LICENSE_PRESETS).join(", ")}) nor a file`);
24
+ let raw;
25
+ try { raw = JSON.parse(readFileSync(path, "utf-8")); } catch (e) { throw new Error(`${flag}: ${e.message}`); }
26
+ return normalizeLicensePolicy(raw && typeof raw === "object" && "licenses" in raw ? raw.licenses : raw, `${flag}: licenses`);
27
+ }
28
+ const { loadPolicy } = await import("../../lib/install/policy.js");
29
+ return loadPolicy(root).policy.licenses;
30
+ }
13
31
 
14
32
  export async function runDeps(args, opts, { version }) {
15
33
  if (args.length > 1) {
@@ -34,7 +52,9 @@ export async function runDeps(args, opts, { version }) {
34
52
  const { scanDeps } = await import("../../lib/deps/index.js");
35
53
  let result;
36
54
  try {
55
+ const licensePolicy = await resolveLicensePolicy(root, opts["license-policy"]);
37
56
  result = await scanDeps(root, {
57
+ licensePolicy,
38
58
  diff: opts.diff || null,
39
59
  base: opts.base || null,
40
60
  online: !!opts.online,
@@ -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
+ }
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
 
@@ -89,10 +91,14 @@ const HELP = `
89
91
  --online Also check npm registry metadata (the only network use)
90
92
  --prod Skip devDependencies
91
93
  --pr-comment <file> Write a short Markdown capability diff for a PR comment
94
+ --license-policy <p> permissive | weak-copyleft-ok | no-agpl, or a policy file
92
95
 
93
96
  ${dim("Install mode")} ${dim("(penthera install [pkg...]; penthera install --help for all options)")}
94
97
  --yes / --dry-run Install only if nothing breaches the policy / report only
95
98
 
99
+ ${dim("Hook mode")} ${dim("(penthera hook install|uninstall|run; penthera hook --help)")}
100
+ Pre-commit: staged secrets, agent config, lockfile diff
101
+
96
102
  ${dim("Options")}
97
103
  -r, --repo <path> Path to repo for source analysis
98
104
  --api-root <path> API routes directory (default: auto-detect)
@@ -114,6 +120,9 @@ const HELP = `
114
120
  --incident <file> Generate NIS2 incident-report drafts + timeline
115
121
  --incident-init <file> Write a blank incident template and exit
116
122
  --sbom <file> Write a CycloneDX software bill of materials (from --repo)
123
+ --aibom <file> Write a CycloneDX 1.6 AI-BOM: AI SDKs, models, MCP servers, skills, prompts
124
+ --cra <file> Generate CRA Article 14 report drafts + timeline (writes cra-reports.md)
125
+ --cra-init <file> Write a blank CRA reporting template and exit
117
126
  --history <file> Track findings over time (time-to-fix, ageing, drift)
118
127
  --memory Learn from this scan locally; apply suppressions + ranking
119
128
  --suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
@@ -166,6 +175,12 @@ export async function run() {
166
175
  return;
167
176
  }
168
177
 
178
+ if (process.argv[2] === "hook") {
179
+ const { runHookCommand } = await import("./cli/hook.js");
180
+ process.exitCode = await runHookCommand(process.argv.slice(3));
181
+ return;
182
+ }
183
+
169
184
  if (process.argv[2] === "evidence") {
170
185
  const { runEvidence } = await import("./cli/evidence.js");
171
186
  process.exit(runEvidence(process.argv.slice(3)));
@@ -206,6 +221,9 @@ export async function run() {
206
221
  incident: { type: "string" },
207
222
  "incident-init": { type: "string" },
208
223
  sbom: { type: "string" },
224
+ aibom: { type: "string" },
225
+ cra: { type: "string" },
226
+ "cra-init": { type: "string" },
209
227
  history: { type: "string" },
210
228
  memory: { type: "boolean", default: false },
211
229
  suppliers: { type: "string" },
@@ -214,6 +232,7 @@ export async function run() {
214
232
  online: { type: "boolean", default: false },
215
233
  "pr-comment": { type: "string" },
216
234
  prod: { type: "boolean", default: false },
235
+ "license-policy": { type: "string" },
217
236
  profile: { type: "string" },
218
237
  "auth-cookie": { type: "string" },
219
238
  "auth-bearer": { type: "string" },
@@ -292,6 +311,32 @@ export async function run() {
292
311
  process.exit(0);
293
312
  }
294
313
 
314
+ if (opts["cra-init"]) {
315
+ const path = resolve(opts["cra-init"]);
316
+ writeFileSync(path, JSON.stringify(craTemplate(), null, 2) + "\n");
317
+ console.log(`Wrote a blank CRA reporting template to ${path}`);
318
+ console.log(`Set type and aware_at (ISO 8601), then run: penthera --cra ${opts["cra-init"]}`);
319
+ process.exit(0);
320
+ }
321
+
322
+ if (opts.cra) {
323
+ let input;
324
+ try {
325
+ input = readCraInput(resolve(opts.cra));
326
+ } catch (e) {
327
+ printError(`Could not read --cra file: ${e.message}`);
328
+ process.exit(2);
329
+ }
330
+ for (const err of input.errors) printError(err);
331
+ const result = buildCraReports(input.cra, loadJurisdiction(), { now: new Date().toISOString(), prefilled: input.prefilled });
332
+ const outPath = resolve("cra-reports.md");
333
+ writeFileSync(outPath, formatCraMarkdown(result));
334
+ console.log(`CRA Article 14 report drafts written to ${outPath}`);
335
+ for (const t of result.timeline) console.log(` ${t.title} (${t.within}): ${t.dueBy ? `due ${t.dueBy}` : t.basis || "n/a"}, ${t.status}`);
336
+ for (const w of result.warnings) console.log(` ⚠️ ${w}`);
337
+ process.exit(0);
338
+ }
339
+
295
340
  if (opts.suppliers && opts.offline) {
296
341
  printError("--suppliers rates third-party domains, which --offline forbids.");
297
342
  process.exit(2);
@@ -326,8 +371,8 @@ export async function run() {
326
371
  await runDeps(positionals.slice(1), opts, { version: pkg.version });
327
372
  return;
328
373
  }
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`.");
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`.");
331
376
  process.exit(2);
332
377
  }
333
378
 
@@ -352,8 +397,8 @@ export async function run() {
352
397
  const url = positionals[0] && !agentsMode ? normalizeBaseUrl(positionals[0]) : null;
353
398
  const repo = opts.repo || null;
354
399
 
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.");
400
+ if (!agentsMode && !url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.aibom && !opts.questionnaire) {
401
+ printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --aibom, --questionnaire, or combine them.");
357
402
  console.log(HELP);
358
403
  process.exit(2);
359
404
  }
@@ -609,6 +654,19 @@ export async function run() {
609
654
  }
610
655
  }
611
656
 
657
+ if (opts.aibom) {
658
+ const aibom = buildAiBom(resolve(opts.repo || "."), {
659
+ serialNumber: `urn:uuid:${randomUUID()}`,
660
+ timestamp: new Date().toISOString(),
661
+ toolVersion: pkg.version,
662
+ });
663
+ writeFileSync(resolve(opts.aibom), JSON.stringify(aibom, null, 2) + "\n");
664
+ if (!opts.quiet && !opts.json) {
665
+ const n = aiBomSummary(aibom);
666
+ process.stderr.write(` AI-BOM (CycloneDX 1.6) ${n.sdks} SDKs, ${n.models} models, ${n.mcpServers} MCP servers, ${n.skills} skills, ${n.prompts} prompts → ${opts.aibom}\n`);
667
+ }
668
+ }
669
+
612
670
  if (opts["policy-pack"]) {
613
671
  const dir = resolve(opts["policy-pack"]);
614
672
  mkdirSync(dir, { recursive: true });
@@ -668,7 +726,7 @@ export async function run() {
668
726
  );
669
727
  }
670
728
 
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) {
729
+ if ((opts.output || opts.markdown || opts.sarif || opts.evidence || opts.readiness || opts["policy-pack"] || opts.sbom || opts.aibom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
672
730
  process.stderr.write("\n");
673
731
  }
674
732