penthera 1.1.1 → 1.4.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 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- 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 +15 -3
- package/docs/local-loop.md +276 -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/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 +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -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/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -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/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- 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 +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -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 +152 -6
- 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/scripts/package-skill.sh +0 -16
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";
|
|
@@ -55,6 +56,13 @@ const HELP = `
|
|
|
55
56
|
$ penthera <url> Scan a live URL (black-box)
|
|
56
57
|
$ penthera --repo <path> Scan a local repo (white-box)
|
|
57
58
|
$ penthera <url> --repo <path> Full scan (both)
|
|
59
|
+
$ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
|
|
60
|
+
$ penthera deps [path] What your npm dependencies can do (offline)
|
|
61
|
+
$ penthera install <pkg...> Check what an npm install adds before anything runs
|
|
62
|
+
$ penthera memory show|verify|forget Local learning store (never leaves the machine)
|
|
63
|
+
$ penthera fix --plan Plan local fix recipes (penthera fix --help)
|
|
64
|
+
$ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
|
|
65
|
+
$ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
|
|
58
66
|
|
|
59
67
|
${dim("Scan modes")}
|
|
60
68
|
--profile <name> quick | standard | deep (default: standard)
|
|
@@ -68,6 +76,22 @@ const HELP = `
|
|
|
68
76
|
--adaptive Knowledge-graph adaptive probes on discovered routes
|
|
69
77
|
--all Enable all modes (recon + deep + fuzz)
|
|
70
78
|
--machine macOS machine audit (keyloggers, trojans, rootkits)
|
|
79
|
+
--offline Contact only the scan target: no recon sources, no
|
|
80
|
+
Retire.js download (uses the vendored snapshot)
|
|
81
|
+
|
|
82
|
+
${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
|
|
83
|
+
--home Also audit ~/.claude, ~/.cursor and Claude Desktop config
|
|
84
|
+
--pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
|
|
85
|
+
|
|
86
|
+
${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json + node_modules, offline)")}
|
|
87
|
+
--diff <lock|dir> Capability diff against an old lockfile or installed tree
|
|
88
|
+
--base <git-ref> Capability diff against the lockfile at a git ref
|
|
89
|
+
--online Also check npm registry metadata (the only network use)
|
|
90
|
+
--prod Skip devDependencies
|
|
91
|
+
--pr-comment <file> Write a short Markdown capability diff for a PR comment
|
|
92
|
+
|
|
93
|
+
${dim("Install mode")} ${dim("(penthera install [pkg...]; penthera install --help for all options)")}
|
|
94
|
+
--yes / --dry-run Install only if nothing breaches the policy / report only
|
|
71
95
|
|
|
72
96
|
${dim("Options")}
|
|
73
97
|
-r, --repo <path> Path to repo for source analysis
|
|
@@ -75,6 +99,10 @@ const HELP = `
|
|
|
75
99
|
-o, --output <file> Write JSON report (+ companion .md if .json)
|
|
76
100
|
--markdown <file> Write Markdown report (human-readable)
|
|
77
101
|
--sarif <file> Write SARIF report (GitHub Security tab)
|
|
102
|
+
--audience <who> developer | owner | auditor report (for --markdown or stdout)
|
|
103
|
+
--report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
|
|
104
|
+
--evidence <dir> Evidence bundle: all reports + manifest.json with SHA-256s
|
|
105
|
+
--sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
|
|
78
106
|
--baseline <file> Compare against previous JSON report
|
|
79
107
|
--framework <name> Add a compliance mapping to reports (nis2)
|
|
80
108
|
--readiness NIS2 readiness report (scan + self-assessment)
|
|
@@ -87,12 +115,14 @@ const HELP = `
|
|
|
87
115
|
--incident-init <file> Write a blank incident template and exit
|
|
88
116
|
--sbom <file> Write a CycloneDX software bill of materials (from --repo)
|
|
89
117
|
--history <file> Track findings over time (time-to-fix, ageing, drift)
|
|
118
|
+
--memory Learn from this scan locally; apply suppressions + ranking
|
|
90
119
|
--suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
|
|
91
120
|
--auth-cookie <v> Cookie header for authenticated scans
|
|
92
121
|
--auth-bearer <v> Bearer token for authenticated scans
|
|
93
122
|
--json Output JSON to stdout
|
|
94
123
|
--timeout <ms> Request timeout (default: 10000)
|
|
95
124
|
--concurrency <n> Concurrent requests (default: 15)
|
|
125
|
+
--user-agent <ua> User-Agent header (default: penthera/${pkg.version} (+https://github.com/danoszz/penthera))
|
|
96
126
|
-v, --verbose Detailed output
|
|
97
127
|
-q, --quiet Findings only (for piping)
|
|
98
128
|
-h, --help Show this help
|
|
@@ -101,6 +131,7 @@ const HELP = `
|
|
|
101
131
|
${dim("Examples")}
|
|
102
132
|
$ penthera https://myapp.com
|
|
103
133
|
$ penthera https://myapp.com --recon --deep
|
|
134
|
+
$ penthera http://localhost:3000 --offline
|
|
104
135
|
$ penthera https://myapp.com --profile quick -o report.json
|
|
105
136
|
$ penthera https://myapp.com --profile deep -o report.json --markdown report.md
|
|
106
137
|
$ penthera https://myapp.com --baseline reports/previous.json
|
|
@@ -109,6 +140,10 @@ const HELP = `
|
|
|
109
140
|
$ penthera https://myapp.com --adaptive -o report.json
|
|
110
141
|
$ penthera https://myapp.com --templates ./my-templates -o report.json
|
|
111
142
|
$ penthera https://myapp.com --sarif results.sarif --json | jq '.findings[]'
|
|
143
|
+
$ penthera agents . --home
|
|
144
|
+
$ penthera agents --pin
|
|
145
|
+
$ penthera deps --base origin/main
|
|
146
|
+
$ penthera deps --diff ../base-checkout --pr-comment deps.md
|
|
112
147
|
|
|
113
148
|
${dim("Exit codes")}
|
|
114
149
|
0 No critical or high findings
|
|
@@ -120,6 +155,32 @@ function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
|
|
|
120
155
|
function dim(s) { return `\x1b[2m${s}\x1b[0m`; }
|
|
121
156
|
|
|
122
157
|
export async function run() {
|
|
158
|
+
if (process.argv[2] === "memory") {
|
|
159
|
+
const { runMemoryCommand } = await import("./cli/memory.js");
|
|
160
|
+
process.exit(await runMemoryCommand(process.argv.slice(3)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (process.argv[2] === "install") {
|
|
164
|
+
const { runInstall } = await import("./cli/install.js");
|
|
165
|
+
process.exitCode = await runInstall(process.argv.slice(3));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (process.argv[2] === "evidence") {
|
|
170
|
+
const { runEvidence } = await import("./cli/evidence.js");
|
|
171
|
+
process.exit(runEvidence(process.argv.slice(3)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (process.argv[2] === "fix") {
|
|
175
|
+
const { runFix } = await import("./cli/fix.js");
|
|
176
|
+
return runFix(process.argv.slice(3));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (process.argv[2] === "agent") {
|
|
180
|
+
const { runAgent } = await import("./cli/agent.js");
|
|
181
|
+
process.exit(await runAgent(process.argv.slice(3)));
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
let parsed;
|
|
124
185
|
try {
|
|
125
186
|
parsed = parseArgs({
|
|
@@ -130,6 +191,10 @@ export async function run() {
|
|
|
130
191
|
output: { type: "string", short: "o" },
|
|
131
192
|
markdown: { type: "string" },
|
|
132
193
|
sarif: { type: "string" },
|
|
194
|
+
audience: { type: "string" },
|
|
195
|
+
"report-template": { type: "string" },
|
|
196
|
+
evidence: { type: "string" },
|
|
197
|
+
"sign-key": { type: "string" },
|
|
133
198
|
baseline: { type: "string" },
|
|
134
199
|
framework: { type: "string" },
|
|
135
200
|
readiness: { type: "boolean", default: false },
|
|
@@ -142,7 +207,13 @@ export async function run() {
|
|
|
142
207
|
"incident-init": { type: "string" },
|
|
143
208
|
sbom: { type: "string" },
|
|
144
209
|
history: { type: "string" },
|
|
210
|
+
memory: { type: "boolean", default: false },
|
|
145
211
|
suppliers: { type: "string" },
|
|
212
|
+
diff: { type: "string" },
|
|
213
|
+
base: { type: "string" },
|
|
214
|
+
online: { type: "boolean", default: false },
|
|
215
|
+
"pr-comment": { type: "string" },
|
|
216
|
+
prod: { type: "boolean", default: false },
|
|
146
217
|
profile: { type: "string" },
|
|
147
218
|
"auth-cookie": { type: "string" },
|
|
148
219
|
"auth-bearer": { type: "string" },
|
|
@@ -155,6 +226,10 @@ export async function run() {
|
|
|
155
226
|
adaptive: { type: "boolean", default: false },
|
|
156
227
|
all: { type: "boolean", default: false },
|
|
157
228
|
machine: { type: "boolean", default: false },
|
|
229
|
+
home: { type: "boolean", default: false },
|
|
230
|
+
pin: { type: "boolean", default: false },
|
|
231
|
+
offline: { type: "boolean", default: false },
|
|
232
|
+
"user-agent": { type: "string" },
|
|
158
233
|
timeout: { type: "string", default: "10000" },
|
|
159
234
|
concurrency: { type: "string", default: "15" },
|
|
160
235
|
verbose: { type: "boolean", short: "v", default: false },
|
|
@@ -217,6 +292,11 @@ export async function run() {
|
|
|
217
292
|
process.exit(0);
|
|
218
293
|
}
|
|
219
294
|
|
|
295
|
+
if (opts.suppliers && opts.offline) {
|
|
296
|
+
printError("--suppliers rates third-party domains, which --offline forbids.");
|
|
297
|
+
process.exit(2);
|
|
298
|
+
}
|
|
299
|
+
|
|
220
300
|
if (opts.suppliers) {
|
|
221
301
|
let text;
|
|
222
302
|
try {
|
|
@@ -240,21 +320,52 @@ export async function run() {
|
|
|
240
320
|
process.exit(0);
|
|
241
321
|
}
|
|
242
322
|
|
|
323
|
+
// `penthera deps [path]`: offline dependency capability scan and diff.
|
|
324
|
+
if (positionals[0] === "deps") {
|
|
325
|
+
const { runDeps } = await import("./cli/deps.js");
|
|
326
|
+
await runDeps(positionals.slice(1), opts, { version: pkg.version });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (opts.diff || opts.base || opts.online || opts["pr-comment"] || opts.prod) {
|
|
330
|
+
printError("--diff, --base, --online, --prod and --pr-comment only apply to `penthera deps`.");
|
|
331
|
+
process.exit(2);
|
|
332
|
+
}
|
|
333
|
+
|
|
243
334
|
if (shouldRunOnboarding(positionals, opts)) {
|
|
244
335
|
const { runOnboarding } = await import("./cli/onboarding.js");
|
|
245
336
|
await runOnboarding();
|
|
246
337
|
return;
|
|
247
338
|
}
|
|
248
339
|
|
|
249
|
-
|
|
340
|
+
// `penthera agents [path]`: offline audit of skills, MCP servers, hooks, plugins.
|
|
341
|
+
const agentsMode = positionals[0] === "agents";
|
|
342
|
+
const agentsRoot = agentsMode ? resolve(positionals[1] || ".") : null;
|
|
343
|
+
if (agentsMode && positionals.length > 2) {
|
|
344
|
+
printError(`agents takes one path, got: ${positionals.slice(1).join(" ")}`);
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
if ((opts.home || opts.pin) && !agentsMode) {
|
|
348
|
+
printError("--home and --pin only apply to `penthera agents`.");
|
|
349
|
+
process.exit(2);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const url = positionals[0] && !agentsMode ? normalizeBaseUrl(positionals[0]) : null;
|
|
250
353
|
const repo = opts.repo || null;
|
|
251
354
|
|
|
252
|
-
if (!url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
|
|
355
|
+
if (!agentsMode && !url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
|
|
253
356
|
printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --questionnaire, or combine them.");
|
|
254
357
|
console.log(HELP);
|
|
255
358
|
process.exit(2);
|
|
256
359
|
}
|
|
257
360
|
|
|
361
|
+
let reportCtx;
|
|
362
|
+
try {
|
|
363
|
+
reportCtx = prepareReportOptions(opts);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
printError(e.message);
|
|
366
|
+
process.exit(2);
|
|
367
|
+
}
|
|
368
|
+
|
|
258
369
|
// Resolve scan profile
|
|
259
370
|
let profileOpts;
|
|
260
371
|
try {
|
|
@@ -297,6 +408,8 @@ export async function run() {
|
|
|
297
408
|
verbose: opts.verbose,
|
|
298
409
|
authCookie: opts["auth-cookie"] || null,
|
|
299
410
|
authBearer: opts["auth-bearer"] || null,
|
|
411
|
+
offline: opts.offline,
|
|
412
|
+
userAgent: opts["user-agent"] || null,
|
|
300
413
|
onPhase: createProgress(opts.quiet || opts.json),
|
|
301
414
|
};
|
|
302
415
|
|
|
@@ -321,6 +434,15 @@ export async function run() {
|
|
|
321
434
|
results.push(repoResult);
|
|
322
435
|
}
|
|
323
436
|
|
|
437
|
+
if (agentsMode) {
|
|
438
|
+
if (!existsSync(agentsRoot)) {
|
|
439
|
+
printError(`Path not found: ${agentsRoot}`);
|
|
440
|
+
process.exit(2);
|
|
441
|
+
}
|
|
442
|
+
const { scanAgents } = await import("../lib/agents/index.js");
|
|
443
|
+
results.push(await scanAgents(agentsRoot, { home: opts.home, pin: opts.pin, onPhase: scanOpts.onPhase }));
|
|
444
|
+
}
|
|
445
|
+
|
|
324
446
|
if (opts.machine) {
|
|
325
447
|
const { auditMachine } = await import("../lib/machine.js");
|
|
326
448
|
const machineResult = await auditMachine({ onPhase: scanOpts.onPhase });
|
|
@@ -342,6 +464,13 @@ export async function run() {
|
|
|
342
464
|
const merged = mergeResults(results);
|
|
343
465
|
merged.profile = profileOpts.profile;
|
|
344
466
|
|
|
467
|
+
// Local learning store (opt-in): suppress + rank from ~/.penthera/memory, then record this scan.
|
|
468
|
+
if (opts.memory && (url || repo)) {
|
|
469
|
+
const { applyMemoryToScan } = await import("../lib/memory/index.js");
|
|
470
|
+
const { warnings } = applyMemoryToScan(merged, { repo: repo ? resolve(repo) : null, url });
|
|
471
|
+
for (const w of warnings) process.stderr.write(` \x1b[33mWARN\x1b[0m ${w}\n`);
|
|
472
|
+
}
|
|
473
|
+
|
|
345
474
|
// Compliance framework mapping (e.g. --framework nis2)
|
|
346
475
|
if (opts.framework) {
|
|
347
476
|
const coverage = buildComplianceCoverage(merged, opts.framework);
|
|
@@ -409,10 +538,13 @@ export async function run() {
|
|
|
409
538
|
}
|
|
410
539
|
|
|
411
540
|
const mdOpts = { version: pkg.version, baseline: baselineStats };
|
|
541
|
+
const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
|
|
412
542
|
|
|
413
543
|
// Output
|
|
414
544
|
if (opts.json) {
|
|
415
545
|
console.log(JSON.stringify(merged, null, 2));
|
|
546
|
+
} else if (custom && !opts.markdown) {
|
|
547
|
+
process.stdout.write(custom);
|
|
416
548
|
} else if (opts.quiet) {
|
|
417
549
|
printFindings(merged.findings);
|
|
418
550
|
} else {
|
|
@@ -430,7 +562,8 @@ export async function run() {
|
|
|
430
562
|
}
|
|
431
563
|
|
|
432
564
|
if (opts.markdown) {
|
|
433
|
-
|
|
565
|
+
if (custom) writeFileSync(resolve(opts.markdown), custom);
|
|
566
|
+
else writeMarkdownReport(merged, opts.markdown, mdOpts);
|
|
434
567
|
if (!opts.quiet && !opts.json) {
|
|
435
568
|
process.stderr.write(` Markdown report ${opts.markdown}\n`);
|
|
436
569
|
}
|
|
@@ -459,9 +592,10 @@ export async function run() {
|
|
|
459
592
|
}
|
|
460
593
|
}
|
|
461
594
|
|
|
595
|
+
let sbom = null;
|
|
462
596
|
if (opts.sbom) {
|
|
463
597
|
const sbomRepo = resolve(opts.repo || ".");
|
|
464
|
-
|
|
598
|
+
sbom = buildSbom(sbomRepo, {
|
|
465
599
|
serialNumber: `urn:uuid:${randomUUID()}`,
|
|
466
600
|
timestamp: new Date().toISOString(),
|
|
467
601
|
});
|
|
@@ -514,6 +648,18 @@ export async function run() {
|
|
|
514
648
|
}
|
|
515
649
|
}
|
|
516
650
|
|
|
651
|
+
if (opts.evidence) {
|
|
652
|
+
try {
|
|
653
|
+
const bundle = writeEvidence(merged, opts, { version: pkg.version, template: reportCtx.template, mdOpts, sbom, argv: process.argv.slice(2) });
|
|
654
|
+
if (!opts.quiet && !opts.json) {
|
|
655
|
+
process.stderr.write(` Evidence bundle ${bundle.manifest.artifacts.length} files${bundle.signed ? ", signed" : ""} → ${opts.evidence}/ (penthera evidence verify ${opts.evidence})\n`);
|
|
656
|
+
}
|
|
657
|
+
} catch (e) {
|
|
658
|
+
printError(`Could not write the evidence bundle: ${e.message}`);
|
|
659
|
+
process.exit(2);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
517
663
|
if (merged.auditLoop && !opts.quiet && !opts.json) {
|
|
518
664
|
const a = merged.auditLoop;
|
|
519
665
|
process.stderr.write(
|
|
@@ -522,7 +668,7 @@ export async function run() {
|
|
|
522
668
|
);
|
|
523
669
|
}
|
|
524
670
|
|
|
525
|
-
if ((opts.output || opts.markdown || opts.sarif || opts.readiness || opts["policy-pack"] || opts.sbom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
|
|
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) {
|
|
526
672
|
process.stderr.write("\n");
|
|
527
673
|
}
|
|
528
674
|
|
|
@@ -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
|
+
}
|