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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence bundle (--evidence <dir>): the reports for one scan, a manifest.json
|
|
3
|
+
* with the SHA-256 of every artifact, and optionally manifest.json.sig, an SSH
|
|
4
|
+
* signature (`ssh-keygen -Y sign -n penthera-evidence`) over the manifest.
|
|
5
|
+
*
|
|
6
|
+
* verifyEvidence() recomputes every hash, rejects files the manifest does not
|
|
7
|
+
* list, and checks the signature with ssh-keygen. No hosted service and no new
|
|
8
|
+
* dependency: an auditor needs Node (or sha256sum and jq) and OpenSSH 8+.
|
|
9
|
+
* See docs/evidence.md.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import {
|
|
14
|
+
readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join, resolve, relative, sep } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const SCHEMA = "penthera-evidence/1";
|
|
19
|
+
export const NAMESPACE = "penthera-evidence";
|
|
20
|
+
export const MANIFEST = "manifest.json";
|
|
21
|
+
export const SIGNATURE = "manifest.json.sig";
|
|
22
|
+
|
|
23
|
+
const SAFE_PATH = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
|
|
24
|
+
const SECRET_FLAG = /^--?(?:auth-cookie|auth-bearer|cookie|[\w-]*(?:token|secret|password|passwd|api-key|apikey)[\w-]*)$/i;
|
|
25
|
+
const SECRET_PARAM = /^(?:[\w-]*(?:token|secret|password|passwd|key|sig|signature|auth|session|code)[\w-]*)$/i;
|
|
26
|
+
|
|
27
|
+
export function sha256(buf) {
|
|
28
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mask credentials in a URL: user:pass@ and secret-looking query values. */
|
|
32
|
+
function redactUrl(s) {
|
|
33
|
+
let u;
|
|
34
|
+
try {
|
|
35
|
+
u = new URL(s);
|
|
36
|
+
} catch {
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
if (!/^https?:$/.test(u.protocol)) return s;
|
|
40
|
+
let changed = false;
|
|
41
|
+
if (u.username || u.password) { u.username = "REDACTED"; u.password = ""; changed = true; }
|
|
42
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
43
|
+
if (SECRET_PARAM.test(k)) { u.searchParams.set(k, "REDACTED"); changed = true; }
|
|
44
|
+
}
|
|
45
|
+
return changed ? u.toString() : s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Redact secrets from a command line: the value of --auth-cookie, --auth-bearer
|
|
50
|
+
* and any flag named like a token or password (both `--flag v` and
|
|
51
|
+
* `--flag=v`), plus credentials inside URLs.
|
|
52
|
+
*/
|
|
53
|
+
export function redactArgv(argv) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const a = String(argv[i]);
|
|
57
|
+
const eq = a.indexOf("=");
|
|
58
|
+
if (a.startsWith("-") && eq > 0 && SECRET_FLAG.test(a.slice(0, eq))) {
|
|
59
|
+
out.push(`${a.slice(0, eq)}=[REDACTED]`);
|
|
60
|
+
} else if (SECRET_FLAG.test(a) && i + 1 < argv.length) {
|
|
61
|
+
out.push(a, "[REDACTED]");
|
|
62
|
+
i++;
|
|
63
|
+
} else {
|
|
64
|
+
out.push(redactUrl(a));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function quote(a) {
|
|
71
|
+
return /^[\w@%+=:,./[\]-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** { commit, dirty } for a git checkout, or null. */
|
|
75
|
+
export function gitInfo(dir) {
|
|
76
|
+
if (!dir) return null;
|
|
77
|
+
try {
|
|
78
|
+
const commit = execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
79
|
+
const status = execFileSync("git", ["-C", dir, "status", "--porcelain"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
80
|
+
return /^[0-9a-f]{40,64}$/.test(commit) ? { commit, dirty: status.trim().length > 0 } : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when ssh-keygen is on PATH. */
|
|
87
|
+
export function hasSshKeygen() {
|
|
88
|
+
const r = spawnSync("ssh-keygen", ["-?"], { stdio: "ignore" });
|
|
89
|
+
return !r.error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function publicKeyFor(keyPath) {
|
|
93
|
+
const pub = keyPath.endsWith(".pub") ? keyPath : `${keyPath}.pub`;
|
|
94
|
+
if (!existsSync(pub)) return null;
|
|
95
|
+
const line = readFileSync(pub, "utf-8").trim().split("\n")[0];
|
|
96
|
+
return /^(ssh-|ecdsa-|sk-)[\w@.-]+ [A-Za-z0-9+/=]+/.test(line) ? line.split(" ").slice(0, 2).join(" ") : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Throws unless `dir` is missing or empty, so a bundle never mixes runs. */
|
|
100
|
+
export function assertEmptyDir(dir) {
|
|
101
|
+
if (!existsSync(dir)) return;
|
|
102
|
+
if (!lstatSync(dir).isDirectory()) throw new Error(`--evidence ${dir} exists and is not a directory`);
|
|
103
|
+
if (readdirSync(dir).length) throw new Error(`--evidence ${dir} is not empty; use a new directory per run`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Write a bundle.
|
|
108
|
+
* @param {string} dir
|
|
109
|
+
* @param {{ path: string, content: string|Buffer, kind?: string }[]} artifacts
|
|
110
|
+
* @param {{ version: string, argv: string[], target: string, generatedAt: string,
|
|
111
|
+
* scan?: object, repo?: string|null, signKey?: string|null }} meta
|
|
112
|
+
* @returns {{ manifest: object, signed: boolean, dir: string }}
|
|
113
|
+
*/
|
|
114
|
+
export function writeEvidenceBundle(dir, artifacts, meta) {
|
|
115
|
+
const root = resolve(dir);
|
|
116
|
+
assertEmptyDir(root);
|
|
117
|
+
mkdirSync(root, { recursive: true });
|
|
118
|
+
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const a of artifacts) {
|
|
121
|
+
if (!SAFE_PATH.test(a.path) || a.path === MANIFEST || a.path === SIGNATURE) throw new Error(`Bad artifact path ${a.path}`);
|
|
122
|
+
const buf = Buffer.isBuffer(a.content) ? a.content : Buffer.from(String(a.content), "utf-8");
|
|
123
|
+
const file = join(root, ...a.path.split("/"));
|
|
124
|
+
mkdirSync(resolve(file, ".."), { recursive: true });
|
|
125
|
+
writeFileSync(file, buf);
|
|
126
|
+
entries.push({ path: a.path, kind: a.kind || null, bytes: buf.length, sha256: sha256(buf) });
|
|
127
|
+
}
|
|
128
|
+
entries.sort((x, y) => x.path.localeCompare(y.path));
|
|
129
|
+
|
|
130
|
+
const argv = redactArgv(meta.argv || []);
|
|
131
|
+
const signKey = meta.signKey ? resolve(meta.signKey) : null;
|
|
132
|
+
const manifest = {
|
|
133
|
+
schema: SCHEMA,
|
|
134
|
+
tool: { name: "penthera", version: meta.version, node: process.version },
|
|
135
|
+
generated_at: meta.generatedAt,
|
|
136
|
+
command: ["penthera", ...argv].map(quote).join(" "),
|
|
137
|
+
argv,
|
|
138
|
+
target: meta.target,
|
|
139
|
+
scan: meta.scan || null,
|
|
140
|
+
git: gitInfo(meta.repo),
|
|
141
|
+
artifacts: entries,
|
|
142
|
+
signature: signKey ? { namespace: NAMESPACE, file: SIGNATURE, public_key: publicKeyFor(signKey) } : null,
|
|
143
|
+
};
|
|
144
|
+
const manifestPath = join(root, MANIFEST);
|
|
145
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
146
|
+
|
|
147
|
+
if (signKey) signManifest(manifestPath, signKey);
|
|
148
|
+
return { manifest, signed: Boolean(signKey), dir: root };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** ssh-keygen -Y sign; writes <manifest>.sig. A passphrase prompt goes to the terminal. */
|
|
152
|
+
export function signManifest(manifestPath, keyPath) {
|
|
153
|
+
if (!existsSync(keyPath)) throw new Error(`--sign-key ${keyPath} not found`);
|
|
154
|
+
const r = spawnSync("ssh-keygen", ["-Y", "sign", "-f", keyPath, "-n", NAMESPACE, manifestPath], {
|
|
155
|
+
stdio: ["inherit", "pipe", "pipe"], encoding: "utf-8",
|
|
156
|
+
});
|
|
157
|
+
if (r.error) throw new Error(`Signing needs ssh-keygen (OpenSSH 8.0+): ${r.error.message}`);
|
|
158
|
+
if (r.status !== 0) throw new Error(`ssh-keygen -Y sign failed: ${(r.stderr || "").trim()}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function listFiles(root, dir = root, out = []) {
|
|
162
|
+
for (const name of readdirSync(dir)) {
|
|
163
|
+
const p = join(dir, name);
|
|
164
|
+
const st = lstatSync(p);
|
|
165
|
+
if (st.isDirectory()) listFiles(root, p, out);
|
|
166
|
+
else out.push(relative(root, p).split(sep).join("/"));
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sshVerify(args, manifestBuf) {
|
|
172
|
+
const r = spawnSync("ssh-keygen", ["-Y", ...args], { input: manifestBuf, encoding: "utf-8" });
|
|
173
|
+
if (r.error) return { ok: false, missing: true, out: r.error.message };
|
|
174
|
+
return { ok: r.status === 0, out: `${r.stdout || ""}${r.stderr || ""}`.trim() };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function checkSignature(root, manifestBuf, allowedSigners) {
|
|
178
|
+
const sigPath = join(root, SIGNATURE);
|
|
179
|
+
if (!existsSync(sigPath)) {
|
|
180
|
+
return allowedSigners
|
|
181
|
+
? { status: "missing", ok: false, detail: `--allowed-signers was given but there is no ${SIGNATURE}` }
|
|
182
|
+
: { status: "unsigned", ok: true, detail: "bundle is not signed; hashes only prove it is internally consistent" };
|
|
183
|
+
}
|
|
184
|
+
if (!allowedSigners) {
|
|
185
|
+
const r = sshVerify(["check-novalidate", "-n", NAMESPACE, "-s", sigPath], manifestBuf);
|
|
186
|
+
if (r.missing) return { status: "unchecked", ok: true, detail: "ssh-keygen not found; signature not checked" };
|
|
187
|
+
return r.ok
|
|
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 };
|
|
190
|
+
}
|
|
191
|
+
const allowed = resolve(allowedSigners);
|
|
192
|
+
if (!existsSync(allowed)) return { status: "invalid", ok: false, detail: `allowed signers file ${allowedSigners} not found` };
|
|
193
|
+
const found = spawnSync("ssh-keygen", ["-Y", "find-principals", "-f", allowed, "-s", sigPath], { encoding: "utf-8" });
|
|
194
|
+
if (found.error) return { status: "invalid", ok: false, detail: "ssh-keygen not found; cannot check the signature" };
|
|
195
|
+
const principals = (found.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
|
196
|
+
if (!principals.length) return { status: "invalid", ok: false, detail: "no key in the allowed signers file made this signature" };
|
|
197
|
+
for (const principal of principals) {
|
|
198
|
+
const r = sshVerify(["verify", "-f", allowed, "-I", principal, "-n", NAMESPACE, "-s", sigPath], manifestBuf);
|
|
199
|
+
if (r.ok) return { status: "valid", ok: true, principal, detail: `signed by ${principal}` };
|
|
200
|
+
}
|
|
201
|
+
return { status: "invalid", ok: false, detail: "signature does not verify against the allowed signers" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Verify a bundle. Never throws on a bad bundle; returns { ok, ... }.
|
|
206
|
+
* @param {string} dir
|
|
207
|
+
* @param {{ allowedSigners?: string }} opts
|
|
208
|
+
*/
|
|
209
|
+
export function verifyEvidence(dir, { allowedSigners = null } = {}) {
|
|
210
|
+
const root = resolve(dir);
|
|
211
|
+
const problems = [];
|
|
212
|
+
const files = [];
|
|
213
|
+
const fail = (msg) => ({ ok: false, problems: [msg], files, unlisted: [], signature: null, manifest: null });
|
|
214
|
+
|
|
215
|
+
const manifestPath = join(root, MANIFEST);
|
|
216
|
+
if (!existsSync(manifestPath)) return fail(`${MANIFEST} not found in ${dir}`);
|
|
217
|
+
const manifestBuf = readFileSync(manifestPath);
|
|
218
|
+
let manifest;
|
|
219
|
+
try {
|
|
220
|
+
manifest = JSON.parse(manifestBuf.toString("utf-8"));
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return fail(`${MANIFEST} is not valid JSON: ${e.message}`);
|
|
223
|
+
}
|
|
224
|
+
if (manifest?.schema !== SCHEMA || !Array.isArray(manifest.artifacts)) return fail(`${MANIFEST} is not a ${SCHEMA} manifest`);
|
|
225
|
+
|
|
226
|
+
const listed = new Set();
|
|
227
|
+
for (const a of manifest.artifacts) {
|
|
228
|
+
const path = a?.path;
|
|
229
|
+
if (typeof path !== "string" || !SAFE_PATH.test(path) || path === MANIFEST || path === SIGNATURE || listed.has(path)) {
|
|
230
|
+
problems.push(`unsafe or duplicate artifact path ${JSON.stringify(path)}`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
listed.add(path);
|
|
234
|
+
const file = join(root, ...path.split("/"));
|
|
235
|
+
const st = existsSync(file) ? lstatSync(file) : null;
|
|
236
|
+
if (!st) { files.push({ path, ok: false, reason: "missing" }); continue; }
|
|
237
|
+
if (!st.isFile()) { files.push({ path, ok: false, reason: "not a regular file" }); continue; }
|
|
238
|
+
const actual = sha256(readFileSync(file));
|
|
239
|
+
if (typeof a.sha256 !== "string" || actual !== a.sha256.toLowerCase()) {
|
|
240
|
+
files.push({ path, ok: false, reason: "hash mismatch", expected: a.sha256, actual });
|
|
241
|
+
} else {
|
|
242
|
+
files.push({ path, ok: true, sha256: actual });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const unlisted = listFiles(root).filter((p) => p !== MANIFEST && p !== SIGNATURE && !listed.has(p));
|
|
246
|
+
for (const p of unlisted) problems.push(`${p} is in the bundle but not in the manifest`);
|
|
247
|
+
for (const f of files) if (!f.ok) problems.push(`${f.path}: ${f.reason}`);
|
|
248
|
+
|
|
249
|
+
const signature = checkSignature(root, manifestBuf, allowedSigners);
|
|
250
|
+
if (!signature.ok) problems.push(`signature: ${signature.detail}`);
|
|
251
|
+
|
|
252
|
+
return { ok: problems.length === 0, problems, files, unlisted, signature, manifest };
|
|
253
|
+
}
|
package/src/report/markdown.js
CHANGED
|
@@ -7,6 +7,8 @@ import { buildWstgCoverage, formatWstgMarkdown } from "../../lib/owasp-wstg.js";
|
|
|
7
7
|
import { formatComplianceMarkdown } from "../../lib/compliance/index.js";
|
|
8
8
|
import { buildActionPlan, formatActionPlanMarkdown } from "../../lib/remediation/index.js";
|
|
9
9
|
import { formatAuditLoopMarkdown } from "../../lib/audit-loop.js";
|
|
10
|
+
import { referencesFor, formatSourcesMarkdown } from "../../lib/references.js";
|
|
11
|
+
import { formatDepsMarkdown } from "../../lib/deps/report.js";
|
|
10
12
|
|
|
11
13
|
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
12
14
|
const SEV_TAG = {
|
|
@@ -59,6 +61,12 @@ export function formatMarkdownReport(result, opts = {}) {
|
|
|
59
61
|
lines.push(`| **Mode** | ${result.modes?.join(", ") || result.mode || "-"} |`);
|
|
60
62
|
lines.push(`| **Reachable** | ${result.reachable ? "Yes" : "No"} |`);
|
|
61
63
|
if (result.profile) lines.push(`| **Profile** | ${result.profile} |`);
|
|
64
|
+
if (result.agents) {
|
|
65
|
+
lines.push(`| **Agent config grade** | **${result.agents.grade}** (${result.agents.score}/100) |`);
|
|
66
|
+
const inv = result.agents.inventory || {};
|
|
67
|
+
lines.push(`| **Agent config found** | ${inv.skills || 0} skills, ${inv.mcpServers || 0} MCP servers, ${inv.hooks || 0} hooks, ${inv.plugins || 0} plugins |`);
|
|
68
|
+
if (result.agents.lock) lines.push(`| **penthera.lock** | ${result.agents.lock.status} |`);
|
|
69
|
+
}
|
|
62
70
|
lines.push("");
|
|
63
71
|
|
|
64
72
|
const total = findings.length;
|
|
@@ -98,6 +106,10 @@ export function formatMarkdownReport(result, opts = {}) {
|
|
|
98
106
|
lines.push(formatComplianceMarkdown(result.compliance));
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
if (result.deps) {
|
|
110
|
+
lines.push(formatDepsMarkdown(result.deps));
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
if (result.fingerprint) {
|
|
102
114
|
lines.push("## Technology fingerprint");
|
|
103
115
|
lines.push("");
|
|
@@ -144,17 +156,68 @@ export function formatMarkdownReport(result, opts = {}) {
|
|
|
144
156
|
lines.push("");
|
|
145
157
|
|
|
146
158
|
for (const f of group) {
|
|
147
|
-
|
|
159
|
+
const refs = referencesFor(f);
|
|
160
|
+
if (!f.description && refs.length === 0) continue;
|
|
148
161
|
lines.push(`**${f.title}**`);
|
|
149
162
|
lines.push("");
|
|
150
|
-
lines.push(f.description);
|
|
163
|
+
if (f.description) lines.push(f.description);
|
|
151
164
|
if (f.url) lines.push(`\n- URL: ${f.url}`);
|
|
152
165
|
if (f.status) lines.push(`- HTTP status: ${f.status}`);
|
|
166
|
+
if (refs.length) lines.push(`- Why this matters / Sources: ${formatSourcesMarkdown(refs)}`);
|
|
153
167
|
lines.push("");
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
if (result.suppressed?.length) {
|
|
173
|
+
lines.push("## Suppressed findings");
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push("Hidden from the findings above by `--memory`. Review these: a suppression is a decision someone made, not a fix.");
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push("| Finding | Severity | Suppressed by | Reason | Id |");
|
|
178
|
+
lines.push("|---------|----------|---------------|--------|----|");
|
|
179
|
+
for (const f of result.suppressed) {
|
|
180
|
+
const by = f.suppression?.source === "repo-file" ? "**suppressed by repo file** (`penthera.suppress.json`)" : "suppressed by local memory";
|
|
181
|
+
lines.push(`| ${mdEscape(f.title)} | ${f.severity || "-"} | ${by} | ${mdEscape(f.suppression?.reason)} | \`${f.fid}\` |`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const errors = result.errors || [];
|
|
187
|
+
if (errors.length > 0) {
|
|
188
|
+
const probes = new Set(errors.map((e) => e.probe));
|
|
189
|
+
lines.push("## Inconclusive probes");
|
|
190
|
+
lines.push("");
|
|
191
|
+
lines.push(`${probes.size} probe(s) could not complete. Their silence is not a clean result.`);
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push("| Probe | URL | Error | Count |");
|
|
194
|
+
lines.push("|-------|-----|-------|-------|");
|
|
195
|
+
for (const e of errors) {
|
|
196
|
+
lines.push(`| ${mdEscape(e.probe)} | \`${mdEscape(e.url)}\` | ${mdEscape(e.message)} | ${e.count || 1} |`);
|
|
197
|
+
}
|
|
198
|
+
lines.push("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (result.egress) {
|
|
202
|
+
const e = result.egress;
|
|
203
|
+
lines.push("## Network egress");
|
|
204
|
+
lines.push("");
|
|
205
|
+
lines.push(`- **Mode:** ${e.offline ? "--offline (scan target only)" : "online"}`);
|
|
206
|
+
lines.push(`- **User-Agent:** \`${mdEscape(e.userAgent)}\``);
|
|
207
|
+
if (e.thirdPartyHosts.length === 0) {
|
|
208
|
+
lines.push(`- **Third-party hosts contacted:** none`);
|
|
209
|
+
} else {
|
|
210
|
+
lines.push(`- **Third-party hosts contacted:** ${e.thirdPartyHosts.map((h) => `\`${h.host}\` (${h.requests})`).join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
if (e.blocked.length > 0) {
|
|
213
|
+
lines.push(`- **Blocked by --offline:** ${e.blocked.map((h) => `\`${h.host}\` (${h.requests})`).join(", ")}`);
|
|
214
|
+
}
|
|
215
|
+
if (result.retireDb) {
|
|
216
|
+
lines.push(`- **Retire.js database:** ${result.retireDb.source === "snapshot" ? `vendored snapshot from ${result.retireDb.snapshotDate}` : "live"}`);
|
|
217
|
+
}
|
|
218
|
+
lines.push("");
|
|
219
|
+
}
|
|
220
|
+
|
|
158
221
|
const actionPlan = result.actionPlan || buildActionPlan(findings);
|
|
159
222
|
if (actionPlan.length > 0) {
|
|
160
223
|
lines.push(formatActionPlanMarkdown(actionPlan));
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report model: one plain-data view of a scan result that the audience
|
|
3
|
+
* reports (developer, owner, auditor) and user templates (--report-template)
|
|
4
|
+
* render from. Everything here is strings, numbers, booleans, arrays and plain
|
|
5
|
+
* objects, so a template can only print data, never reach code.
|
|
6
|
+
*/
|
|
7
|
+
import { referencesFor } from "../../lib/references.js";
|
|
8
|
+
import { resolveWstgTags } from "../../lib/owasp-wstg.js";
|
|
9
|
+
import { buildComplianceCoverage } from "../../lib/compliance/index.js";
|
|
10
|
+
import { buildActionPlan } from "../../lib/remediation/index.js";
|
|
11
|
+
import { confidenceFor } from "../../lib/provenance.js";
|
|
12
|
+
import { RECIPES } from "../../lib/fix/index.js";
|
|
13
|
+
import { OWASP_NAMES } from "../../lib/agents/owasp.js";
|
|
14
|
+
import { findingId } from "../../lib/memory/views.js";
|
|
15
|
+
import { buildTimeline, formatDuration } from "./timeline.js";
|
|
16
|
+
|
|
17
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
18
|
+
|
|
19
|
+
export const CONFIDENCE_LABELS = [
|
|
20
|
+
{ label: "confirmed", meaning: "Deterministic evidence: a header is absent, a certificate has expired, a secret is in the file." },
|
|
21
|
+
{ label: "likely", meaning: "A strong signal with a small chance of a false positive." },
|
|
22
|
+
{ label: "potential", meaning: "A heuristic or timing signal. It needs a second source before anyone acts on it." },
|
|
23
|
+
{ label: "needs-human-review", meaning: "A scanner cannot judge exploitability here (access control, IDOR). A person has to look." },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// OWASP Top 10 (2021) category per finding category. Categories without a
|
|
27
|
+
// clean match (email DNS, fuzzing noise, machine audit) are left unmapped.
|
|
28
|
+
export const OWASP_TOP10 = {
|
|
29
|
+
"A01:2021": "Broken Access Control",
|
|
30
|
+
"A02:2021": "Cryptographic Failures",
|
|
31
|
+
"A03:2021": "Injection",
|
|
32
|
+
"A05:2021": "Security Misconfiguration",
|
|
33
|
+
"A06:2021": "Vulnerable and Outdated Components",
|
|
34
|
+
"A07:2021": "Identification and Authentication Failures",
|
|
35
|
+
"A10:2021": "Server-Side Request Forgery",
|
|
36
|
+
};
|
|
37
|
+
const CATEGORY_TOP10 = {
|
|
38
|
+
"auth-bypass": "A01:2021", "open-redirect": "A01:2021", permissions: "A01:2021",
|
|
39
|
+
tls: "A02:2021", transport: "A02:2021",
|
|
40
|
+
sqli: "A03:2021", xss: "A03:2021", ssti: "A03:2021", cmdi: "A03:2021", injection: "A03:2021",
|
|
41
|
+
headers: "A05:2021", cors: "A05:2021", "cors-misconfiguration": "A05:2021", cookie: "A05:2021",
|
|
42
|
+
config: "A05:2021", exposure: "A05:2021", "info-leak": "A05:2021", disclosure: "A05:2021",
|
|
43
|
+
"method-confusion": "A05:2021", "content-type-confusion": "A05:2021",
|
|
44
|
+
cve: "A06:2021", "js-vulnerability": "A06:2021", "dependency-risk": "A06:2021",
|
|
45
|
+
auth: "A07:2021", secrets: "A07:2021", "rate-limiting": "A07:2021",
|
|
46
|
+
ssrf: "A10:2021",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const PLAIN = { critical: "serious", high: "serious", medium: "moderate", low: "minor", info: "note" };
|
|
50
|
+
|
|
51
|
+
function isHttp(u) {
|
|
52
|
+
return typeof u === "string" && /^https?:\/\//i.test(u);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function locationOf(f) {
|
|
56
|
+
if (isHttp(f.url)) return { file: null, line: null, url: f.url, location: f.url };
|
|
57
|
+
const file = f.file || f.url || null;
|
|
58
|
+
const line = Number.isInteger(f.line) && f.line > 0 ? f.line : null;
|
|
59
|
+
return { file, line, url: null, location: file ? (line ? `${file}:${line}` : file) : null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function recipeFor(f) {
|
|
63
|
+
for (const r of RECIPES) {
|
|
64
|
+
try {
|
|
65
|
+
if (r.matches(f)) return r.id;
|
|
66
|
+
} catch {
|
|
67
|
+
// a recipe that cannot judge this finding does not claim it
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const keyOf = (f) => `${f.category || ""}|${f.title || ""}|${f.url || ""}`;
|
|
74
|
+
|
|
75
|
+
/** Map each finding key to a fix plan (from result.fixPlans, e.g. an agent loop). */
|
|
76
|
+
function plansByFinding(plans) {
|
|
77
|
+
const map = new Map();
|
|
78
|
+
for (const p of Array.isArray(plans) ? plans : []) {
|
|
79
|
+
for (const f of p?.findings || []) map.set(keyOf(f), p);
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build the report model for a scan result. */
|
|
85
|
+
export function buildReportModel(result, { version = "0.0.0" } = {}) {
|
|
86
|
+
const raw = [...(result?.findings || [])].sort((a, b) => (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5));
|
|
87
|
+
const actionPlan = result?.actionPlan || buildActionPlan(raw);
|
|
88
|
+
const plans = plansByFinding(result?.fixPlans);
|
|
89
|
+
const timeline = buildTimeline(result);
|
|
90
|
+
const loopRecipe = new Map((timeline?.entries || []).filter((e) => e.recipe).map((e) => [e.fid, e.recipe]));
|
|
91
|
+
|
|
92
|
+
const findings = raw.map((f, i) => {
|
|
93
|
+
const loc = locationOf(f);
|
|
94
|
+
const plan = plans.get(keyOf(f));
|
|
95
|
+
const action = actionPlan.find((a) => a.findings.some((x) => x.title === f.title && (x.url || null) === (f.url || null)));
|
|
96
|
+
const top10 = CATEGORY_TOP10[f.category] || null;
|
|
97
|
+
return {
|
|
98
|
+
n: i + 1,
|
|
99
|
+
fid: f.fid || findingId(f),
|
|
100
|
+
title: String(f.title || "Untitled finding"),
|
|
101
|
+
severity: f.severity || "info",
|
|
102
|
+
plainSeverity: PLAIN[f.severity] || "note",
|
|
103
|
+
category: f.category || null,
|
|
104
|
+
confidence: confidenceFor(f),
|
|
105
|
+
source: f.source || null,
|
|
106
|
+
method: f.method || f.source || null,
|
|
107
|
+
collectedAt: f.collected_at || result?.timestamp || null,
|
|
108
|
+
description: f.description || null,
|
|
109
|
+
...loc,
|
|
110
|
+
recipe: plan?.recipe || loopRecipe.get(f.fid) || recipeFor(f),
|
|
111
|
+
diff: plan?.diff || null,
|
|
112
|
+
fixText: action?.fix || null,
|
|
113
|
+
sources: referencesFor(f).map((r) => ({ cite: r.cite, url: r.url })),
|
|
114
|
+
owasp: [
|
|
115
|
+
...(top10 ? [`${top10} ${OWASP_TOP10[top10]}`] : []),
|
|
116
|
+
...(Array.isArray(f.owasp) ? f.owasp.map((id) => OWASP_NAMES[id] || id) : []),
|
|
117
|
+
],
|
|
118
|
+
wstg: resolveWstgTags(f),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
123
|
+
for (const f of findings) if (counts[f.severity] != null) counts[f.severity]++;
|
|
124
|
+
|
|
125
|
+
const suppressed = (result?.suppressed || []).map((f) => ({
|
|
126
|
+
fid: f.fid || findingId(f),
|
|
127
|
+
title: String(f.title || ""),
|
|
128
|
+
severity: f.severity || "info",
|
|
129
|
+
reason: f.suppression?.reason || "",
|
|
130
|
+
actor: f.suppression?.actor || "unknown",
|
|
131
|
+
by: f.suppression?.source === "repo-file" ? "repo file (penthera.suppress.json)" : "local memory",
|
|
132
|
+
expires: f.suppression?.expires || null,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const inconclusive = (result?.errors || []).map((e) => ({
|
|
136
|
+
probe: e.probe || "", url: e.url || "", message: e.message || "", count: e.count || 1,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const e = result?.egress || null;
|
|
140
|
+
const egress = e ? {
|
|
141
|
+
mode: e.offline ? "offline (scan target only)" : "online",
|
|
142
|
+
userAgent: e.userAgent || "",
|
|
143
|
+
thirdPartyHosts: (e.thirdPartyHosts || []).map((h) => ({ host: h.host, requests: h.requests })),
|
|
144
|
+
blocked: (e.blocked || []).map((h) => ({ host: h.host, requests: h.requests })),
|
|
145
|
+
} : null;
|
|
146
|
+
|
|
147
|
+
const actions = actionPlan.map((a, i) => ({
|
|
148
|
+
n: i + 1, id: a.id, action: a.action, why: a.why, fix: a.fix, effort: a.effort,
|
|
149
|
+
severity: a.severity, ownerAction: Boolean(a.ownerAction), findingCount: a.findings.length,
|
|
150
|
+
}));
|
|
151
|
+
const ownerActions = actions.filter((a) => a.ownerAction);
|
|
152
|
+
for (const p of Array.isArray(result?.fixPlans) ? result.fixPlans : []) {
|
|
153
|
+
for (const text of p?.ownerActions || []) {
|
|
154
|
+
if (typeof text === "string") ownerActions.push({ n: ownerActions.length + 1, id: p.recipe, action: text, why: "", fix: "", effort: "", severity: "", ownerAction: true, findingCount: (p.findings || []).length });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const coverage = result?.compliance?.framework === "nis2" ? result.compliance : buildComplianceCoverage(result || {}, "nis2");
|
|
159
|
+
const nis2 = coverage.measures.map((m) => ({ id: m.id, title: m.title, status: m.status, findings: m.findings || 0, note: m.note || "" }));
|
|
160
|
+
|
|
161
|
+
const owaspMap = new Map();
|
|
162
|
+
for (const f of findings) {
|
|
163
|
+
for (const o of f.owasp) {
|
|
164
|
+
if (!owaspMap.has(o)) owaspMap.set(o, []);
|
|
165
|
+
owaspMap.get(o).push(f.fid || `#${f.n}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const owasp = [...owaspMap].sort(([a], [b]) => a.localeCompare(b)).map(([id, ids]) => ({ id, count: ids.length, findings: ids.join(", ") }));
|
|
169
|
+
|
|
170
|
+
const trend = (result?.memory?.history?.trend || []).map((t) => ({
|
|
171
|
+
date: String(t.ts).slice(0, 10), serious: t.critical + t.high, critical: t.critical, high: t.high,
|
|
172
|
+
medium: t.medium, low: t.low, total: t.total, suppressed: t.suppressed,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
tool: { name: "penthera", version },
|
|
177
|
+
target: result?.target || "unknown",
|
|
178
|
+
timestamp: result?.timestamp || null,
|
|
179
|
+
duration: result?.duration ? formatDuration(result.duration) : null,
|
|
180
|
+
modes: (result?.modes || (result?.mode ? [result.mode] : [])).join(", "),
|
|
181
|
+
profile: result?.profile || null,
|
|
182
|
+
offline: Boolean(e?.offline),
|
|
183
|
+
probes: [...(result?.executedProbes || [])].sort(),
|
|
184
|
+
summary: {
|
|
185
|
+
...counts,
|
|
186
|
+
serious: counts.critical + counts.high,
|
|
187
|
+
total: findings.length,
|
|
188
|
+
suppressed: suppressed.length,
|
|
189
|
+
inconclusive: new Set(inconclusive.map((x) => x.probe)).size,
|
|
190
|
+
target: result?.target || "unknown",
|
|
191
|
+
timestamp: result?.timestamp || null,
|
|
192
|
+
},
|
|
193
|
+
findings,
|
|
194
|
+
suppressed,
|
|
195
|
+
inconclusive,
|
|
196
|
+
egress,
|
|
197
|
+
actions,
|
|
198
|
+
topActions: actions.slice(0, 3),
|
|
199
|
+
ownerActions,
|
|
200
|
+
nis2,
|
|
201
|
+
owasp,
|
|
202
|
+
trend,
|
|
203
|
+
timeline: timeline ? {
|
|
204
|
+
source: timeline.source,
|
|
205
|
+
verified: timeline.verifiedCount,
|
|
206
|
+
failed: timeline.failedCount,
|
|
207
|
+
open: timeline.openCount,
|
|
208
|
+
medianTimeToFix: formatDuration(timeline.medianTimeToFixMs),
|
|
209
|
+
entries: timeline.entries.map((x) => ({ ...x, timeToFix: formatDuration(x.timeToFixMs) })),
|
|
210
|
+
} : null,
|
|
211
|
+
};
|
|
212
|
+
}
|