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