penthera 1.2.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/CHANGELOG.md +269 -0
- package/README.md +493 -45
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +12 -1
- package/docs/local-loop.md +276 -0
- package/docs/plan.md +100 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +440 -0
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +109 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +190 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/hook/index.js +312 -0
- package/lib/hook/policy.js +62 -0
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +125 -0
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +11 -1
- package/package.json +17 -4
- package/skills/penthera/SKILL.md +132 -22
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +104 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +212 -8
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
package/src/cli.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { resolve, dirname } from "node:path";
|
|
8
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { scanUrl } from "./scan-url.js";
|
|
11
11
|
import { scanRepo } from "./scan-repo.js";
|
|
@@ -15,6 +15,7 @@ import { compareWithBaseline } from "./cli/baseline.js";
|
|
|
15
15
|
import { normalizeBaseUrl } from "./utils/url.js";
|
|
16
16
|
import { printReport, printFindings, writeJsonReport, writeSarifReport, createProgress, printError } from "./reporter.js";
|
|
17
17
|
import { writeMarkdownReport, markdownPathFromJson } from "./report/markdown.js";
|
|
18
|
+
import { prepareReportOptions, customReport, writeEvidence } from "./cli/report-outputs.js";
|
|
18
19
|
import { parseTemplatePaths } from "../lib/plugins.js";
|
|
19
20
|
import { buildComplianceCoverage, listFrameworks } from "../lib/compliance/index.js";
|
|
20
21
|
import { buildActionPlan } from "../lib/remediation/index.js";
|
|
@@ -23,7 +24,9 @@ import { blankAssessment } from "../lib/compliance/self-assessment.js";
|
|
|
23
24
|
import { buildPolicyPack, policyPackIndex } from "../lib/compliance/policy-pack.js";
|
|
24
25
|
import { parseQuestionnaire, answerQuestionnaire, formatQuestionnaireMarkdown } from "../lib/compliance/questionnaire.js";
|
|
25
26
|
import { buildIncidentReports, formatIncidentMarkdown, incidentTemplate } from "../lib/compliance/incident.js";
|
|
26
|
-
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";
|
|
27
30
|
import { emptyHistory, updateHistory, buildAuditLoop } from "../lib/audit-loop.js";
|
|
28
31
|
import { rateSuppliers, parseSupplierList, formatSuppliersMarkdown } from "../lib/supply-chain.js";
|
|
29
32
|
|
|
@@ -55,6 +58,13 @@ const HELP = `
|
|
|
55
58
|
$ penthera <url> Scan a live URL (black-box)
|
|
56
59
|
$ penthera --repo <path> Scan a local repo (white-box)
|
|
57
60
|
$ penthera <url> --repo <path> Full scan (both)
|
|
61
|
+
$ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
|
|
62
|
+
$ penthera deps [path] What your npm dependencies can do (offline)
|
|
63
|
+
$ penthera install <pkg...> Check what an npm install adds before anything runs
|
|
64
|
+
$ penthera memory show|verify|forget Local learning store (never leaves the machine)
|
|
65
|
+
$ penthera fix --plan Plan local fix recipes (penthera fix --help)
|
|
66
|
+
$ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
|
|
67
|
+
$ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
|
|
58
68
|
|
|
59
69
|
${dim("Scan modes")}
|
|
60
70
|
--profile <name> quick | standard | deep (default: standard)
|
|
@@ -68,6 +78,26 @@ const HELP = `
|
|
|
68
78
|
--adaptive Knowledge-graph adaptive probes on discovered routes
|
|
69
79
|
--all Enable all modes (recon + deep + fuzz)
|
|
70
80
|
--machine macOS machine audit (keyloggers, trojans, rootkits)
|
|
81
|
+
--offline Contact only the scan target: no recon sources, no
|
|
82
|
+
Retire.js download (uses the vendored snapshot)
|
|
83
|
+
|
|
84
|
+
${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
|
|
85
|
+
--home Also audit ~/.claude, ~/.cursor and Claude Desktop config
|
|
86
|
+
--pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
|
|
87
|
+
|
|
88
|
+
${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json + node_modules, offline)")}
|
|
89
|
+
--diff <lock|dir> Capability diff against an old lockfile or installed tree
|
|
90
|
+
--base <git-ref> Capability diff against the lockfile at a git ref
|
|
91
|
+
--online Also check npm registry metadata (the only network use)
|
|
92
|
+
--prod Skip devDependencies
|
|
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
|
|
95
|
+
|
|
96
|
+
${dim("Install mode")} ${dim("(penthera install [pkg...]; penthera install --help for all options)")}
|
|
97
|
+
--yes / --dry-run Install only if nothing breaches the policy / report only
|
|
98
|
+
|
|
99
|
+
${dim("Hook mode")} ${dim("(penthera hook install|uninstall|run; penthera hook --help)")}
|
|
100
|
+
Pre-commit: staged secrets, agent config, lockfile diff
|
|
71
101
|
|
|
72
102
|
${dim("Options")}
|
|
73
103
|
-r, --repo <path> Path to repo for source analysis
|
|
@@ -75,6 +105,10 @@ const HELP = `
|
|
|
75
105
|
-o, --output <file> Write JSON report (+ companion .md if .json)
|
|
76
106
|
--markdown <file> Write Markdown report (human-readable)
|
|
77
107
|
--sarif <file> Write SARIF report (GitHub Security tab)
|
|
108
|
+
--audience <who> developer | owner | auditor report (for --markdown or stdout)
|
|
109
|
+
--report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
|
|
110
|
+
--evidence <dir> Evidence bundle: all reports + manifest.json with SHA-256s
|
|
111
|
+
--sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
|
|
78
112
|
--baseline <file> Compare against previous JSON report
|
|
79
113
|
--framework <name> Add a compliance mapping to reports (nis2)
|
|
80
114
|
--readiness NIS2 readiness report (scan + self-assessment)
|
|
@@ -86,13 +120,18 @@ const HELP = `
|
|
|
86
120
|
--incident <file> Generate NIS2 incident-report drafts + timeline
|
|
87
121
|
--incident-init <file> Write a blank incident template and exit
|
|
88
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
|
|
89
126
|
--history <file> Track findings over time (time-to-fix, ageing, drift)
|
|
127
|
+
--memory Learn from this scan locally; apply suppressions + ranking
|
|
90
128
|
--suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
|
|
91
129
|
--auth-cookie <v> Cookie header for authenticated scans
|
|
92
130
|
--auth-bearer <v> Bearer token for authenticated scans
|
|
93
131
|
--json Output JSON to stdout
|
|
94
132
|
--timeout <ms> Request timeout (default: 10000)
|
|
95
133
|
--concurrency <n> Concurrent requests (default: 15)
|
|
134
|
+
--user-agent <ua> User-Agent header (default: penthera/${pkg.version} (+https://github.com/danoszz/penthera))
|
|
96
135
|
-v, --verbose Detailed output
|
|
97
136
|
-q, --quiet Findings only (for piping)
|
|
98
137
|
-h, --help Show this help
|
|
@@ -101,6 +140,7 @@ const HELP = `
|
|
|
101
140
|
${dim("Examples")}
|
|
102
141
|
$ penthera https://myapp.com
|
|
103
142
|
$ penthera https://myapp.com --recon --deep
|
|
143
|
+
$ penthera http://localhost:3000 --offline
|
|
104
144
|
$ penthera https://myapp.com --profile quick -o report.json
|
|
105
145
|
$ penthera https://myapp.com --profile deep -o report.json --markdown report.md
|
|
106
146
|
$ penthera https://myapp.com --baseline reports/previous.json
|
|
@@ -109,6 +149,10 @@ const HELP = `
|
|
|
109
149
|
$ penthera https://myapp.com --adaptive -o report.json
|
|
110
150
|
$ penthera https://myapp.com --templates ./my-templates -o report.json
|
|
111
151
|
$ penthera https://myapp.com --sarif results.sarif --json | jq '.findings[]'
|
|
152
|
+
$ penthera agents . --home
|
|
153
|
+
$ penthera agents --pin
|
|
154
|
+
$ penthera deps --base origin/main
|
|
155
|
+
$ penthera deps --diff ../base-checkout --pr-comment deps.md
|
|
112
156
|
|
|
113
157
|
${dim("Exit codes")}
|
|
114
158
|
0 No critical or high findings
|
|
@@ -120,6 +164,38 @@ function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
|
|
|
120
164
|
function dim(s) { return `\x1b[2m${s}\x1b[0m`; }
|
|
121
165
|
|
|
122
166
|
export async function run() {
|
|
167
|
+
if (process.argv[2] === "memory") {
|
|
168
|
+
const { runMemoryCommand } = await import("./cli/memory.js");
|
|
169
|
+
process.exit(await runMemoryCommand(process.argv.slice(3)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (process.argv[2] === "install") {
|
|
173
|
+
const { runInstall } = await import("./cli/install.js");
|
|
174
|
+
process.exitCode = await runInstall(process.argv.slice(3));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
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
|
+
|
|
184
|
+
if (process.argv[2] === "evidence") {
|
|
185
|
+
const { runEvidence } = await import("./cli/evidence.js");
|
|
186
|
+
process.exit(runEvidence(process.argv.slice(3)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (process.argv[2] === "fix") {
|
|
190
|
+
const { runFix } = await import("./cli/fix.js");
|
|
191
|
+
return runFix(process.argv.slice(3));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (process.argv[2] === "agent") {
|
|
195
|
+
const { runAgent } = await import("./cli/agent.js");
|
|
196
|
+
process.exit(await runAgent(process.argv.slice(3)));
|
|
197
|
+
}
|
|
198
|
+
|
|
123
199
|
let parsed;
|
|
124
200
|
try {
|
|
125
201
|
parsed = parseArgs({
|
|
@@ -130,6 +206,10 @@ export async function run() {
|
|
|
130
206
|
output: { type: "string", short: "o" },
|
|
131
207
|
markdown: { type: "string" },
|
|
132
208
|
sarif: { type: "string" },
|
|
209
|
+
audience: { type: "string" },
|
|
210
|
+
"report-template": { type: "string" },
|
|
211
|
+
evidence: { type: "string" },
|
|
212
|
+
"sign-key": { type: "string" },
|
|
133
213
|
baseline: { type: "string" },
|
|
134
214
|
framework: { type: "string" },
|
|
135
215
|
readiness: { type: "boolean", default: false },
|
|
@@ -141,8 +221,18 @@ export async function run() {
|
|
|
141
221
|
incident: { type: "string" },
|
|
142
222
|
"incident-init": { type: "string" },
|
|
143
223
|
sbom: { type: "string" },
|
|
224
|
+
aibom: { type: "string" },
|
|
225
|
+
cra: { type: "string" },
|
|
226
|
+
"cra-init": { type: "string" },
|
|
144
227
|
history: { type: "string" },
|
|
228
|
+
memory: { type: "boolean", default: false },
|
|
145
229
|
suppliers: { type: "string" },
|
|
230
|
+
diff: { type: "string" },
|
|
231
|
+
base: { type: "string" },
|
|
232
|
+
online: { type: "boolean", default: false },
|
|
233
|
+
"pr-comment": { type: "string" },
|
|
234
|
+
prod: { type: "boolean", default: false },
|
|
235
|
+
"license-policy": { type: "string" },
|
|
146
236
|
profile: { type: "string" },
|
|
147
237
|
"auth-cookie": { type: "string" },
|
|
148
238
|
"auth-bearer": { type: "string" },
|
|
@@ -155,6 +245,10 @@ export async function run() {
|
|
|
155
245
|
adaptive: { type: "boolean", default: false },
|
|
156
246
|
all: { type: "boolean", default: false },
|
|
157
247
|
machine: { type: "boolean", default: false },
|
|
248
|
+
home: { type: "boolean", default: false },
|
|
249
|
+
pin: { type: "boolean", default: false },
|
|
250
|
+
offline: { type: "boolean", default: false },
|
|
251
|
+
"user-agent": { type: "string" },
|
|
158
252
|
timeout: { type: "string", default: "10000" },
|
|
159
253
|
concurrency: { type: "string", default: "15" },
|
|
160
254
|
verbose: { type: "boolean", short: "v", default: false },
|
|
@@ -217,6 +311,37 @@ export async function run() {
|
|
|
217
311
|
process.exit(0);
|
|
218
312
|
}
|
|
219
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
|
+
|
|
340
|
+
if (opts.suppliers && opts.offline) {
|
|
341
|
+
printError("--suppliers rates third-party domains, which --offline forbids.");
|
|
342
|
+
process.exit(2);
|
|
343
|
+
}
|
|
344
|
+
|
|
220
345
|
if (opts.suppliers) {
|
|
221
346
|
let text;
|
|
222
347
|
try {
|
|
@@ -240,21 +365,52 @@ export async function run() {
|
|
|
240
365
|
process.exit(0);
|
|
241
366
|
}
|
|
242
367
|
|
|
368
|
+
// `penthera deps [path]`: offline dependency capability scan and diff.
|
|
369
|
+
if (positionals[0] === "deps") {
|
|
370
|
+
const { runDeps } = await import("./cli/deps.js");
|
|
371
|
+
await runDeps(positionals.slice(1), opts, { version: pkg.version });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
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`.");
|
|
376
|
+
process.exit(2);
|
|
377
|
+
}
|
|
378
|
+
|
|
243
379
|
if (shouldRunOnboarding(positionals, opts)) {
|
|
244
380
|
const { runOnboarding } = await import("./cli/onboarding.js");
|
|
245
381
|
await runOnboarding();
|
|
246
382
|
return;
|
|
247
383
|
}
|
|
248
384
|
|
|
249
|
-
|
|
385
|
+
// `penthera agents [path]`: offline audit of skills, MCP servers, hooks, plugins.
|
|
386
|
+
const agentsMode = positionals[0] === "agents";
|
|
387
|
+
const agentsRoot = agentsMode ? resolve(positionals[1] || ".") : null;
|
|
388
|
+
if (agentsMode && positionals.length > 2) {
|
|
389
|
+
printError(`agents takes one path, got: ${positionals.slice(1).join(" ")}`);
|
|
390
|
+
process.exit(2);
|
|
391
|
+
}
|
|
392
|
+
if ((opts.home || opts.pin) && !agentsMode) {
|
|
393
|
+
printError("--home and --pin only apply to `penthera agents`.");
|
|
394
|
+
process.exit(2);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const url = positionals[0] && !agentsMode ? normalizeBaseUrl(positionals[0]) : null;
|
|
250
398
|
const repo = opts.repo || null;
|
|
251
399
|
|
|
252
|
-
if (!url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
|
|
253
|
-
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.");
|
|
254
402
|
console.log(HELP);
|
|
255
403
|
process.exit(2);
|
|
256
404
|
}
|
|
257
405
|
|
|
406
|
+
let reportCtx;
|
|
407
|
+
try {
|
|
408
|
+
reportCtx = prepareReportOptions(opts);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
printError(e.message);
|
|
411
|
+
process.exit(2);
|
|
412
|
+
}
|
|
413
|
+
|
|
258
414
|
// Resolve scan profile
|
|
259
415
|
let profileOpts;
|
|
260
416
|
try {
|
|
@@ -297,6 +453,8 @@ export async function run() {
|
|
|
297
453
|
verbose: opts.verbose,
|
|
298
454
|
authCookie: opts["auth-cookie"] || null,
|
|
299
455
|
authBearer: opts["auth-bearer"] || null,
|
|
456
|
+
offline: opts.offline,
|
|
457
|
+
userAgent: opts["user-agent"] || null,
|
|
300
458
|
onPhase: createProgress(opts.quiet || opts.json),
|
|
301
459
|
};
|
|
302
460
|
|
|
@@ -321,6 +479,15 @@ export async function run() {
|
|
|
321
479
|
results.push(repoResult);
|
|
322
480
|
}
|
|
323
481
|
|
|
482
|
+
if (agentsMode) {
|
|
483
|
+
if (!existsSync(agentsRoot)) {
|
|
484
|
+
printError(`Path not found: ${agentsRoot}`);
|
|
485
|
+
process.exit(2);
|
|
486
|
+
}
|
|
487
|
+
const { scanAgents } = await import("../lib/agents/index.js");
|
|
488
|
+
results.push(await scanAgents(agentsRoot, { home: opts.home, pin: opts.pin, onPhase: scanOpts.onPhase }));
|
|
489
|
+
}
|
|
490
|
+
|
|
324
491
|
if (opts.machine) {
|
|
325
492
|
const { auditMachine } = await import("../lib/machine.js");
|
|
326
493
|
const machineResult = await auditMachine({ onPhase: scanOpts.onPhase });
|
|
@@ -342,6 +509,13 @@ export async function run() {
|
|
|
342
509
|
const merged = mergeResults(results);
|
|
343
510
|
merged.profile = profileOpts.profile;
|
|
344
511
|
|
|
512
|
+
// Local learning store (opt-in): suppress + rank from ~/.penthera/memory, then record this scan.
|
|
513
|
+
if (opts.memory && (url || repo)) {
|
|
514
|
+
const { applyMemoryToScan } = await import("../lib/memory/index.js");
|
|
515
|
+
const { warnings } = applyMemoryToScan(merged, { repo: repo ? resolve(repo) : null, url });
|
|
516
|
+
for (const w of warnings) process.stderr.write(` \x1b[33mWARN\x1b[0m ${w}\n`);
|
|
517
|
+
}
|
|
518
|
+
|
|
345
519
|
// Compliance framework mapping (e.g. --framework nis2)
|
|
346
520
|
if (opts.framework) {
|
|
347
521
|
const coverage = buildComplianceCoverage(merged, opts.framework);
|
|
@@ -409,10 +583,13 @@ export async function run() {
|
|
|
409
583
|
}
|
|
410
584
|
|
|
411
585
|
const mdOpts = { version: pkg.version, baseline: baselineStats };
|
|
586
|
+
const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
|
|
412
587
|
|
|
413
588
|
// Output
|
|
414
589
|
if (opts.json) {
|
|
415
590
|
console.log(JSON.stringify(merged, null, 2));
|
|
591
|
+
} else if (custom && !opts.markdown) {
|
|
592
|
+
process.stdout.write(custom);
|
|
416
593
|
} else if (opts.quiet) {
|
|
417
594
|
printFindings(merged.findings);
|
|
418
595
|
} else {
|
|
@@ -430,7 +607,8 @@ export async function run() {
|
|
|
430
607
|
}
|
|
431
608
|
|
|
432
609
|
if (opts.markdown) {
|
|
433
|
-
|
|
610
|
+
if (custom) writeFileSync(resolve(opts.markdown), custom);
|
|
611
|
+
else writeMarkdownReport(merged, opts.markdown, mdOpts);
|
|
434
612
|
if (!opts.quiet && !opts.json) {
|
|
435
613
|
process.stderr.write(` Markdown report ${opts.markdown}\n`);
|
|
436
614
|
}
|
|
@@ -459,9 +637,10 @@ export async function run() {
|
|
|
459
637
|
}
|
|
460
638
|
}
|
|
461
639
|
|
|
640
|
+
let sbom = null;
|
|
462
641
|
if (opts.sbom) {
|
|
463
642
|
const sbomRepo = resolve(opts.repo || ".");
|
|
464
|
-
|
|
643
|
+
sbom = buildSbom(sbomRepo, {
|
|
465
644
|
serialNumber: `urn:uuid:${randomUUID()}`,
|
|
466
645
|
timestamp: new Date().toISOString(),
|
|
467
646
|
});
|
|
@@ -475,6 +654,19 @@ export async function run() {
|
|
|
475
654
|
}
|
|
476
655
|
}
|
|
477
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
|
+
|
|
478
670
|
if (opts["policy-pack"]) {
|
|
479
671
|
const dir = resolve(opts["policy-pack"]);
|
|
480
672
|
mkdirSync(dir, { recursive: true });
|
|
@@ -514,6 +706,18 @@ export async function run() {
|
|
|
514
706
|
}
|
|
515
707
|
}
|
|
516
708
|
|
|
709
|
+
if (opts.evidence) {
|
|
710
|
+
try {
|
|
711
|
+
const bundle = writeEvidence(merged, opts, { version: pkg.version, template: reportCtx.template, mdOpts, sbom, argv: process.argv.slice(2) });
|
|
712
|
+
if (!opts.quiet && !opts.json) {
|
|
713
|
+
process.stderr.write(` Evidence bundle ${bundle.manifest.artifacts.length} files${bundle.signed ? ", signed" : ""} → ${opts.evidence}/ (penthera evidence verify ${opts.evidence})\n`);
|
|
714
|
+
}
|
|
715
|
+
} catch (e) {
|
|
716
|
+
printError(`Could not write the evidence bundle: ${e.message}`);
|
|
717
|
+
process.exit(2);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
517
721
|
if (merged.auditLoop && !opts.quiet && !opts.json) {
|
|
518
722
|
const a = merged.auditLoop;
|
|
519
723
|
process.stderr.write(
|
|
@@ -522,7 +726,7 @@ export async function run() {
|
|
|
522
726
|
);
|
|
523
727
|
}
|
|
524
728
|
|
|
525
|
-
if ((opts.output || opts.markdown || opts.sarif || 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) {
|
|
526
730
|
process.stderr.write("\n");
|
|
527
731
|
}
|
|
528
732
|
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audience reports (--audience developer | owner | auditor), rendered from the
|
|
3
|
+
* report model. The default report (src/report/markdown.js) is unchanged.
|
|
4
|
+
*
|
|
5
|
+
* developer findings with file:line, fix recipe or diff, how to fix, sources
|
|
6
|
+
* owner plain-language summary, top three things to do, owner actions, trend
|
|
7
|
+
* auditor scope and method, confidence and provenance, egress, inconclusive
|
|
8
|
+
* probes, suppressions (who and why), loop timeline, NIS2 and OWASP
|
|
9
|
+
*/
|
|
10
|
+
import { buildReportModel, CONFIDENCE_LABELS } from "./model.js";
|
|
11
|
+
|
|
12
|
+
export const AUDIENCES = ["developer", "owner", "auditor"];
|
|
13
|
+
|
|
14
|
+
const METHODOLOGY_URL = "https://github.com/danoszz/penthera/blob/main/docs/METHODOLOGY.md";
|
|
15
|
+
const EVIDENCE_URL = "https://github.com/danoszz/penthera/blob/main/docs/evidence.md";
|
|
16
|
+
const SEV_TAG = { critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW", info: "INFO" };
|
|
17
|
+
const NIS2_STATUS = {
|
|
18
|
+
"tested-clean": "Tested, no gaps found",
|
|
19
|
+
"tested-findings": "Tested, gaps found",
|
|
20
|
+
"not-run": "Not run this scan",
|
|
21
|
+
evidence: "Evidence only",
|
|
22
|
+
planned: "Planned",
|
|
23
|
+
"out-of-scope": "Out of scope",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Inline text: one line, no table breaks, no raw HTML. */
|
|
27
|
+
function md(s) {
|
|
28
|
+
return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/\r?\n|\r/g, " ")
|
|
29
|
+
.replace(/\\/g, "\\\\").replace(/</g, "<").replace(/>/g, ">").replace(/\|/g, "\\|");
|
|
30
|
+
}
|
|
31
|
+
/** Penthera's own remediation text (lib/remediation): may hold code spans, so only fold lines. */
|
|
32
|
+
function prose(s) {
|
|
33
|
+
return String(s ?? "").replace(/\r?\n|\r/g, " ");
|
|
34
|
+
}
|
|
35
|
+
/** A code span that its content cannot close. */
|
|
36
|
+
function code(s) {
|
|
37
|
+
return `\`${String(s ?? "").replace(/[`\r\n]/g, " ")}\``;
|
|
38
|
+
}
|
|
39
|
+
function when(iso) {
|
|
40
|
+
return iso ? `${String(iso).replace("T", " ").slice(0, 19)} UTC` : "-";
|
|
41
|
+
}
|
|
42
|
+
function plural(n, word) {
|
|
43
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
44
|
+
}
|
|
45
|
+
function sources(list) {
|
|
46
|
+
return list.map((r) => `[${md(r.cite)}](${r.url})`).join(" · ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function header(L, title, m) {
|
|
50
|
+
L.push(`# ${title}`, "");
|
|
51
|
+
L.push(`> ${md(m.target)} · scanned ${when(m.timestamp)} · Penthera v${m.tool.version}`, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function footer(L) {
|
|
55
|
+
L.push("---", "", "*Authorized security testing only. See the Penthera README for ethical use guidelines.*", "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function timelineSection(L, t, { heading = "## Fix timeline" } = {}) {
|
|
59
|
+
if (!t) return;
|
|
60
|
+
L.push(heading, "");
|
|
61
|
+
L.push(`Source: ${t.source}. ${t.verified} verified · ${t.failed} failed · ${t.open} open · median time to fix ${t.medianTimeToFix}.`, "");
|
|
62
|
+
L.push("| Finding | Found | Fixed | Verified | Time to fix |");
|
|
63
|
+
L.push("|---------|-------|-------|----------|-------------|");
|
|
64
|
+
for (const e of t.entries) {
|
|
65
|
+
const name = e.title ? `${md(e.title)} (${code(e.fid)})` : code(e.fid);
|
|
66
|
+
const fixed = e.fixed ? `${md(e.fixed)}${e.recipe ? ` (${code(e.recipe)})` : ""}` : "-";
|
|
67
|
+
const verified = e.verified ? md(e.verified) : e.failed ? `**failed** ${md(e.failed)}` : "-";
|
|
68
|
+
L.push(`| ${name} | ${md(e.found || "-")} | ${fixed} | ${verified} | ${e.timeToFix} |`);
|
|
69
|
+
}
|
|
70
|
+
L.push("");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inconclusiveSection(L, m) {
|
|
74
|
+
if (!m.inconclusive.length) return;
|
|
75
|
+
L.push("## Inconclusive probes", "");
|
|
76
|
+
L.push(`${plural(m.summary.inconclusive, "probe")} could not complete. Their silence is not a clean result.`, "");
|
|
77
|
+
L.push("| Probe | URL | Error | Count |");
|
|
78
|
+
L.push("|-------|-----|-------|-------|");
|
|
79
|
+
for (const e of m.inconclusive) L.push(`| ${md(e.probe)} | ${code(e.url)} | ${md(e.message)} | ${e.count} |`);
|
|
80
|
+
L.push("");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatDeveloperReport(m) {
|
|
84
|
+
const L = [];
|
|
85
|
+
header(L, "Security findings for developers", m);
|
|
86
|
+
const s = m.summary;
|
|
87
|
+
L.push(`${plural(s.total, "finding")}: ${s.critical} critical · ${s.high} high · ${s.medium} medium · ${s.low} low · ${s.info} info.`);
|
|
88
|
+
if (s.suppressed) L.push(`${s.suppressed} suppressed (listed at the end).`);
|
|
89
|
+
L.push("");
|
|
90
|
+
if (m.findings.some((f) => f.recipe && !f.diff)) {
|
|
91
|
+
L.push(`Findings with a recipe can be fixed locally: ${code("penthera fix --plan")} shows the diff, ${code("penthera fix --apply <id>")} applies it.`, "");
|
|
92
|
+
}
|
|
93
|
+
for (const f of m.findings) {
|
|
94
|
+
L.push(`### ${f.n}. [${SEV_TAG[f.severity] || "INFO"}] ${md(f.title)}`, "");
|
|
95
|
+
if (f.location) L.push(`- **Where:** ${f.url ? md(f.location) : code(f.location)}`);
|
|
96
|
+
L.push(`- **Confidence:** ${f.confidence}${f.source ? ` (${md(f.source)})` : ""}${f.fid ? ` · id ${code(f.fid)}` : ""}`);
|
|
97
|
+
if (f.recipe) L.push(`- **Fix recipe:** ${code(f.recipe)}`);
|
|
98
|
+
if (f.fixText) L.push(`- **How to fix:** ${prose(f.fixText)}`);
|
|
99
|
+
if (f.sources.length) L.push(`- **Sources:** ${sources(f.sources)}`);
|
|
100
|
+
if (f.description) L.push("", md(f.description));
|
|
101
|
+
if (f.diff) {
|
|
102
|
+
const fence = "`".repeat(Math.max(3, ...[...String(f.diff).matchAll(/`+/g)].map((x) => x[0].length + 1)));
|
|
103
|
+
L.push("", `${fence}diff`, String(f.diff).replace(/\n$/, ""), fence);
|
|
104
|
+
}
|
|
105
|
+
L.push("");
|
|
106
|
+
}
|
|
107
|
+
inconclusiveSection(L, m);
|
|
108
|
+
timelineSection(L, m.timeline);
|
|
109
|
+
if (m.suppressed.length) {
|
|
110
|
+
L.push("## Suppressed", "");
|
|
111
|
+
for (const x of m.suppressed) L.push(`- ${md(x.title)} (${code(x.fid)}): ${md(x.reason)} (${md(x.actor)}, ${x.by})`);
|
|
112
|
+
L.push("");
|
|
113
|
+
}
|
|
114
|
+
footer(L);
|
|
115
|
+
return L.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatOwnerReport(m) {
|
|
119
|
+
const L = [];
|
|
120
|
+
header(L, "Security summary", m);
|
|
121
|
+
const s = m.summary;
|
|
122
|
+
L.push("## In short", "");
|
|
123
|
+
if (s.total === 0) {
|
|
124
|
+
L.push(`Penthera checked ${md(m.target)} and found no problems in the areas it tested.`);
|
|
125
|
+
} else {
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (s.serious) parts.push(`**${plural(s.serious, "serious problem")}** to fix this week`);
|
|
128
|
+
if (s.medium) parts.push(`${s.medium} moderate`);
|
|
129
|
+
if (s.low) parts.push(`${s.low} minor`);
|
|
130
|
+
if (s.info) parts.push(`${plural(s.info, "note")}`);
|
|
131
|
+
L.push(`Penthera checked ${md(m.target)} and found ${parts.join(", ")}.`);
|
|
132
|
+
L.push(s.serious ? "The serious ones are things an attacker could use today." : "Nothing here needs an emergency response.");
|
|
133
|
+
}
|
|
134
|
+
if (s.inconclusive) L.push(`${plural(s.inconclusive, "check")} could not finish, so those areas are untested, not clean.`);
|
|
135
|
+
if (s.suppressed) L.push(`${plural(s.suppressed, "known issue")} ${s.suppressed === 1 ? "was" : "were"} set aside on purpose by someone on the team (the auditor report lists who and why).`);
|
|
136
|
+
L.push("");
|
|
137
|
+
|
|
138
|
+
if (m.topActions.length) {
|
|
139
|
+
L.push("## What to do first", "");
|
|
140
|
+
m.topActions.forEach((a, i) => {
|
|
141
|
+
L.push(`${i + 1}. **${prose(a.action)}** (${a.effort}${a.ownerAction ? ", needs you" : ", a developer can do this"}). ${prose(a.why)}`);
|
|
142
|
+
});
|
|
143
|
+
L.push("");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (m.ownerActions.length) {
|
|
147
|
+
L.push("## Things only you can do", "");
|
|
148
|
+
L.push("A tool or a developer cannot do these for you: they need account access, a provider dashboard or a DNS change.", "");
|
|
149
|
+
for (const a of m.ownerActions) L.push(`- **${prose(a.action)}**${a.fix ? `: ${prose(a.fix)}` : ""}`);
|
|
150
|
+
L.push("");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (m.trend.length > 1) {
|
|
154
|
+
const first = m.trend[0];
|
|
155
|
+
const last = m.trend[m.trend.length - 1];
|
|
156
|
+
const dir = last.serious < first.serious ? "down" : last.serious > first.serious ? "up" : "flat";
|
|
157
|
+
L.push("## Trend", "");
|
|
158
|
+
L.push(`Serious problems went from ${first.serious} to ${last.serious} over the last ${m.trend.length} scans (${dir}).`, "");
|
|
159
|
+
L.push("| Scan | Serious | Moderate | Minor | Total |");
|
|
160
|
+
L.push("|------|---------|----------|-------|-------|");
|
|
161
|
+
for (const t of m.trend) L.push(`| ${t.date} | ${t.serious} | ${t.medium} | ${t.low} | ${t.total} |`);
|
|
162
|
+
L.push("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (m.timeline) {
|
|
166
|
+
const t = m.timeline;
|
|
167
|
+
L.push("## Fixes", "");
|
|
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
|
+
}
|
|
170
|
+
footer(L);
|
|
171
|
+
return L.join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function formatAuditorReport(m) {
|
|
175
|
+
const L = [];
|
|
176
|
+
header(L, "Security evidence report", m);
|
|
177
|
+
L.push("> Automated technical evidence. It is not an audit opinion, a compliance determination or a certification.", "");
|
|
178
|
+
|
|
179
|
+
L.push("## 1. Scope and method", "");
|
|
180
|
+
L.push("| | |", "|---|---|");
|
|
181
|
+
L.push(`| **Target** | ${code(m.target)} |`);
|
|
182
|
+
L.push(`| **Scan time** | ${when(m.timestamp)} |`);
|
|
183
|
+
if (m.duration) L.push(`| **Duration** | ${m.duration} |`);
|
|
184
|
+
L.push(`| **Modes** | ${md(m.modes || "-")} |`);
|
|
185
|
+
if (m.profile) L.push(`| **Profile** | ${md(m.profile)} |`);
|
|
186
|
+
L.push(`| **Network** | ${m.egress ? md(m.egress.mode) : "not recorded"} |`);
|
|
187
|
+
L.push(`| **Tool** | Penthera v${m.tool.version} |`);
|
|
188
|
+
L.push("");
|
|
189
|
+
if (m.probes.length) L.push(`Probes that ran: ${m.probes.map(code).join(", ")}.`, "");
|
|
190
|
+
L.push(`Method, threat model and limits: [docs/METHODOLOGY.md](${METHODOLOGY_URL}). A probe that is not listed did not run, and an area it covers is untested.`, "");
|
|
191
|
+
|
|
192
|
+
L.push("## 2. Confidence and provenance", "");
|
|
193
|
+
L.push("Every finding carries a confidence label that says how sure the scanner is that the issue is real, separate from its severity.", "");
|
|
194
|
+
L.push("| Label | Meaning | This scan |");
|
|
195
|
+
L.push("|-------|---------|-----------|");
|
|
196
|
+
for (const c of CONFIDENCE_LABELS) {
|
|
197
|
+
L.push(`| ${code(c.label)} | ${c.meaning} | ${m.findings.filter((f) => f.confidence === c.label).length} |`);
|
|
198
|
+
}
|
|
199
|
+
L.push("");
|
|
200
|
+
if (m.findings.length) {
|
|
201
|
+
L.push("| # | Id | Finding | Severity | Confidence | Source / method | Collected | Location |");
|
|
202
|
+
L.push("|---|----|---------|----------|------------|-----------------|-----------|----------|");
|
|
203
|
+
for (const f of m.findings) {
|
|
204
|
+
L.push(`| ${f.n} | ${f.fid ? code(f.fid) : "-"} | ${md(f.title)} | ${f.severity} | ${f.confidence} | ${md(f.source || "-")} / ${md(f.method || "-")} | ${when(f.collectedAt)} | ${f.location ? code(f.location) : "-"} |`);
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
L.push("No findings in the tested areas.");
|
|
208
|
+
}
|
|
209
|
+
L.push("");
|
|
210
|
+
|
|
211
|
+
L.push("## 3. Network egress", "");
|
|
212
|
+
if (m.egress) {
|
|
213
|
+
L.push(`- **Mode:** ${md(m.egress.mode)}`);
|
|
214
|
+
L.push(`- **User-Agent:** ${code(m.egress.userAgent)}`);
|
|
215
|
+
L.push(`- **Third-party hosts contacted:** ${m.egress.thirdPartyHosts.length ? m.egress.thirdPartyHosts.map((h) => `${code(h.host)} (${h.requests})`).join(", ") : "none"}`);
|
|
216
|
+
if (m.egress.blocked.length) L.push(`- **Blocked by --offline:** ${m.egress.blocked.map((h) => `${code(h.host)} (${h.requests})`).join(", ")}`);
|
|
217
|
+
} else {
|
|
218
|
+
L.push("No egress record (a repo-only or machine scan makes no network requests to the target).");
|
|
219
|
+
}
|
|
220
|
+
L.push("");
|
|
221
|
+
inconclusiveSection(L, m);
|
|
222
|
+
|
|
223
|
+
L.push("## 4. Suppressions", "");
|
|
224
|
+
if (m.suppressed.length) {
|
|
225
|
+
L.push("These findings were hidden from the results by a person's decision. Each needs a reason; review them.", "");
|
|
226
|
+
L.push("| Id | Finding | Severity | Who | Why | Recorded in | Expires |");
|
|
227
|
+
L.push("|----|---------|----------|-----|-----|-------------|---------|");
|
|
228
|
+
for (const x of m.suppressed) {
|
|
229
|
+
L.push(`| ${code(x.fid)} | ${md(x.title)} | ${x.severity} | ${md(x.actor)} | ${md(x.reason)} | ${x.by} | ${x.expires ? md(x.expires) : "never"} |`);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
L.push("None. No finding was hidden from this report.");
|
|
233
|
+
}
|
|
234
|
+
L.push("");
|
|
235
|
+
|
|
236
|
+
timelineSection(L, m.timeline, { heading: "## 5. Fix timeline" });
|
|
237
|
+
|
|
238
|
+
L.push(`## ${m.timeline ? 6 : 5}. NIS2 Article 21(2) mapping`, "");
|
|
239
|
+
L.push("Technical evidence toward each measure, based on the probes that ran. Organisational measures need human process.", "");
|
|
240
|
+
L.push("| Art. 21(2) | Measure | This scan | Findings |");
|
|
241
|
+
L.push("|-----------|---------|-----------|----------|");
|
|
242
|
+
for (const x of m.nis2) L.push(`| (${x.id}) | ${md(x.title)} | ${NIS2_STATUS[x.status] || x.status} | ${x.findings || "-"} |`);
|
|
243
|
+
L.push("");
|
|
244
|
+
|
|
245
|
+
L.push(`## ${m.timeline ? 7 : 6}. OWASP mapping`, "");
|
|
246
|
+
if (m.owasp.length) {
|
|
247
|
+
L.push("| OWASP category | Findings | Ids |");
|
|
248
|
+
L.push("|----------------|----------|-----|");
|
|
249
|
+
for (const o of m.owasp) L.push(`| ${md(o.id)} | ${o.count} | ${md(o.findings)} |`);
|
|
250
|
+
} else {
|
|
251
|
+
L.push("No finding maps to an OWASP Top 10 category.");
|
|
252
|
+
}
|
|
253
|
+
L.push("");
|
|
254
|
+
const wstg = [...new Set(m.findings.flatMap((f) => f.wstg))].sort();
|
|
255
|
+
if (wstg.length) L.push(`OWASP WSTG test areas with findings: ${wstg.map(code).join(", ")}.`, "");
|
|
256
|
+
|
|
257
|
+
L.push("## Verifying this report", "");
|
|
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);
|
|
260
|
+
return L.join("\n");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const FORMATTERS = { developer: formatDeveloperReport, owner: formatOwnerReport, auditor: formatAuditorReport };
|
|
264
|
+
|
|
265
|
+
/** Render the report for one audience. Throws on an unknown audience. */
|
|
266
|
+
export function formatAudienceReport(result, audience, { version } = {}) {
|
|
267
|
+
const fmt = FORMATTERS[audience];
|
|
268
|
+
if (!fmt) throw new Error(`Unknown --audience "${audience}". Use one of: ${AUDIENCES.join(", ")}.`);
|
|
269
|
+
return fmt(buildReportModel(result, { version }));
|
|
270
|
+
}
|