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.
- package/CHANGELOG.md +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
package/skills/penthera/SKILL.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: penthera
|
|
3
3
|
description: >
|
|
4
|
-
|
|
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"
|
|
8
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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");
|
package/src/cli/evidence.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/hook.js
ADDED
|
@@ -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/install.js
CHANGED
|
@@ -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
|
|
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.
|
|
42
|
-
run; otherwise the
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|