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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny report template language for `--report-template file.md`.
|
|
3
|
+
*
|
|
4
|
+
* {{summary.critical}} a value, HTML- and Markdown-escaped
|
|
5
|
+
* {{#each findings}}…{{/each}} loop over an array; inside, `this` is the
|
|
6
|
+
* item, `@index` is 0-based, and names fall
|
|
7
|
+
* back to the enclosing scopes
|
|
8
|
+
* {{#if x}}…{{else}}…{{/if}} truthy test (an empty array is false)
|
|
9
|
+
*
|
|
10
|
+
* That is the whole language. There are no helpers, no expressions, no raw
|
|
11
|
+
* output, no partials or file includes, and no way to reach a prototype: paths
|
|
12
|
+
* are dotted identifiers, `__proto__`, `constructor` and `prototype` are
|
|
13
|
+
* rejected when the template is parsed, and lookups only read own properties.
|
|
14
|
+
* Values are never parsed as template text, so a finding title that contains
|
|
15
|
+
* `{{…}}` prints as text.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class TemplateError extends Error {
|
|
19
|
+
constructor(message, line) {
|
|
20
|
+
super(line ? `${message} (line ${line})` : message);
|
|
21
|
+
this.name = "TemplateError";
|
|
22
|
+
this.line = line || null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAX_TEMPLATE = 256 * 1024;
|
|
27
|
+
const MAX_DEPTH = 32;
|
|
28
|
+
const MAX_OUTPUT = 8 * 1024 * 1024;
|
|
29
|
+
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
|
|
30
|
+
const SEGMENT = /^(?:[A-Za-z_][A-Za-z0-9_-]*|\d+)$/;
|
|
31
|
+
|
|
32
|
+
function parsePath(raw, line) {
|
|
33
|
+
if (raw === "this" || raw === "@index") return [raw];
|
|
34
|
+
const parts = raw.split(".");
|
|
35
|
+
const start = parts[0] === "this" ? 1 : 0;
|
|
36
|
+
for (let i = 0; i < parts.length; i++) {
|
|
37
|
+
const p = parts[i];
|
|
38
|
+
if (FORBIDDEN.has(p)) throw new TemplateError(`"${p}" is not allowed in a template path`, line);
|
|
39
|
+
if (i >= start && !SEGMENT.test(p)) throw new TemplateError(`Bad template path "${raw}"`, line);
|
|
40
|
+
}
|
|
41
|
+
return parts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse a template into a tree. Throws TemplateError on any syntax it does not know. */
|
|
45
|
+
export function parseTemplate(src) {
|
|
46
|
+
if (typeof src !== "string") throw new TemplateError("Template must be a string");
|
|
47
|
+
if (src.length > MAX_TEMPLATE) throw new TemplateError(`Template is larger than ${MAX_TEMPLATE} bytes`);
|
|
48
|
+
const root = { type: "root", body: [] };
|
|
49
|
+
const stack = [root];
|
|
50
|
+
let target = root.body;
|
|
51
|
+
let line = 1;
|
|
52
|
+
let last = 0;
|
|
53
|
+
const re = /\{\{([\s\S]*?)\}\}/g;
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = re.exec(src))) {
|
|
56
|
+
if (m.index > last) target.push({ type: "text", value: src.slice(last, m.index) });
|
|
57
|
+
line += countLines(src.slice(last, m.index));
|
|
58
|
+
last = re.lastIndex;
|
|
59
|
+
const tag = m[1].trim();
|
|
60
|
+
const top = stack[stack.length - 1];
|
|
61
|
+
let mm;
|
|
62
|
+
if ((mm = /^#(each|if)\s+(\S+)$/.exec(tag))) {
|
|
63
|
+
if (stack.length > MAX_DEPTH) throw new TemplateError("Blocks are nested too deeply", line);
|
|
64
|
+
const node = { type: mm[1], path: parsePath(mm[2], line), body: [], alt: null, line };
|
|
65
|
+
target.push(node);
|
|
66
|
+
stack.push(node);
|
|
67
|
+
target = node.body;
|
|
68
|
+
} else if (tag === "else") {
|
|
69
|
+
if (top.type !== "if" || top.alt) throw new TemplateError("{{else}} outside {{#if}}", line);
|
|
70
|
+
top.alt = [];
|
|
71
|
+
target = top.alt;
|
|
72
|
+
} else if ((mm = /^\/(each|if)$/.exec(tag))) {
|
|
73
|
+
if (top.type !== mm[1]) throw new TemplateError(`Unexpected {{/${mm[1]}}}`, line);
|
|
74
|
+
stack.pop();
|
|
75
|
+
const parent = stack[stack.length - 1];
|
|
76
|
+
target = parent.alt ?? parent.body;
|
|
77
|
+
} else if (/^[A-Za-z_@][\w.@-]*$/.test(tag)) {
|
|
78
|
+
target.push({ type: "var", path: parsePath(tag, line), line });
|
|
79
|
+
} else {
|
|
80
|
+
throw new TemplateError(`Unsupported tag {{${tag.slice(0, 40)}}}`, line);
|
|
81
|
+
}
|
|
82
|
+
line += countLines(m[0]);
|
|
83
|
+
}
|
|
84
|
+
if (stack.length > 1) {
|
|
85
|
+
const open = stack[stack.length - 1];
|
|
86
|
+
throw new TemplateError(`Unclosed {{#${open.type}}}`, open.line);
|
|
87
|
+
}
|
|
88
|
+
if (last < src.length) target.push({ type: "text", value: src.slice(last) });
|
|
89
|
+
return root;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function countLines(s) {
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function own(obj, key) {
|
|
99
|
+
if (obj === null || typeof obj !== "object") return undefined;
|
|
100
|
+
if (Array.isArray(obj) && !/^\d+$/.test(key)) return key === "length" ? obj.length : undefined;
|
|
101
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function lookup(scopes, path) {
|
|
105
|
+
if (path[0] === "@index") return scopes[scopes.length - 1].index;
|
|
106
|
+
if (path[0] === "this") {
|
|
107
|
+
let v = scopes[scopes.length - 1].value;
|
|
108
|
+
for (let i = 1; i < path.length && v !== undefined; i++) v = own(v, path[i]);
|
|
109
|
+
return v;
|
|
110
|
+
}
|
|
111
|
+
for (let s = scopes.length - 1; s >= 0; s--) {
|
|
112
|
+
const first = own(scopes[s].value, path[0]);
|
|
113
|
+
if (first === undefined) continue;
|
|
114
|
+
let v = first;
|
|
115
|
+
for (let i = 1; i < path.length && v !== undefined; i++) v = own(v, path[i]);
|
|
116
|
+
return v;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const HTML = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Escape a value for Markdown that may also be rendered as HTML: HTML entities
|
|
125
|
+
* for & < > " ', a backslash before Markdown punctuation, newlines folded to a
|
|
126
|
+
* space (so a value cannot start a heading, list or table row), and control
|
|
127
|
+
* characters dropped. Non-primitive values render as nothing.
|
|
128
|
+
*/
|
|
129
|
+
export function escapeValue(v) {
|
|
130
|
+
if (v === null || v === undefined) return "";
|
|
131
|
+
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") return "";
|
|
132
|
+
return String(v)
|
|
133
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f\u2028\u2029]/g, "")
|
|
134
|
+
.replace(/\r?\n|\r/g, " ")
|
|
135
|
+
.replace(/[\\`*_[\]{}()#|!~]/g, "\\$&")
|
|
136
|
+
.replace(/[&<>"']/g, (c) => HTML[c])
|
|
137
|
+
.replace(/^([-+.=])/, "\\$1")
|
|
138
|
+
.replace(/^(\d+)\./, "$1\\.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function truthy(v) {
|
|
142
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
143
|
+
return Boolean(v);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function emit(out, s) {
|
|
147
|
+
out.size += s.length;
|
|
148
|
+
if (out.size > MAX_OUTPUT) throw new TemplateError(`Rendered report is larger than ${MAX_OUTPUT} bytes`);
|
|
149
|
+
out.parts.push(s);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderNodes(nodes, scopes, out) {
|
|
153
|
+
for (const n of nodes) {
|
|
154
|
+
if (n.type === "text") emit(out, n.value);
|
|
155
|
+
else if (n.type === "var") emit(out, escapeValue(lookup(scopes, n.path)));
|
|
156
|
+
else if (n.type === "if") renderNodes(truthy(lookup(scopes, n.path)) ? n.body : (n.alt || []), scopes, out);
|
|
157
|
+
else if (n.type === "each") {
|
|
158
|
+
const list = lookup(scopes, n.path);
|
|
159
|
+
if (!Array.isArray(list)) continue;
|
|
160
|
+
for (let i = 0; i < list.length; i++) {
|
|
161
|
+
scopes.push({ value: list[i], index: i });
|
|
162
|
+
renderNodes(n.body, scopes, out);
|
|
163
|
+
scopes.pop();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Render a template string (or a tree from parseTemplate) against a data object. */
|
|
170
|
+
export function renderTemplate(template, data) {
|
|
171
|
+
const tree = typeof template === "string" ? parseTemplate(template) : template;
|
|
172
|
+
const out = { parts: [], size: 0 };
|
|
173
|
+
renderNodes(tree.body, [{ value: data, index: 0 }], out);
|
|
174
|
+
return out.parts.join("");
|
|
175
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop timeline: found → fixed → verified per finding, with time-to-fix.
|
|
3
|
+
*
|
|
4
|
+
* Two sources, in this order:
|
|
5
|
+
* result.loopRun an agent loop run (`penthera agent`), either
|
|
6
|
+
* { iterations: [{ n, found:[fid], fixed:[{fid, recipe}],
|
|
7
|
+
* verified:[fid], failed:[fid], ms }] }
|
|
8
|
+
* or the penthera.loopRun/1 shape
|
|
9
|
+
* { iterations: [{ n, durationMs, fixes: [{ recipe,
|
|
10
|
+
* status, findings: [{ fid, title }] }] }] }
|
|
11
|
+
* result.memory.history fix events from the local learning store (--memory)
|
|
12
|
+
*
|
|
13
|
+
* Returns null when neither has anything to show.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fidOf = (x) => (typeof x === "string" ? x : typeof x?.fid === "string" ? x.fid : typeof x?.id === "string" ? x.id : null);
|
|
17
|
+
const list = (x) => (Array.isArray(x) ? x : []);
|
|
18
|
+
|
|
19
|
+
function median(nums) {
|
|
20
|
+
if (!nums.length) return null;
|
|
21
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
22
|
+
const mid = Math.floor(s.length / 2);
|
|
23
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function titles(result) {
|
|
27
|
+
const map = new Map();
|
|
28
|
+
for (const f of [...list(result?.findings), ...list(result?.suppressed)]) {
|
|
29
|
+
if (typeof f?.fid === "string" && !map.has(f.fid)) map.set(f.fid, f.title || "");
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** penthera.loopRun/1 iteration → the found/fixed/verified/failed lists. */
|
|
35
|
+
function normalizeIteration(it, names) {
|
|
36
|
+
if (!Array.isArray(it?.fixes)) return it;
|
|
37
|
+
const found = [];
|
|
38
|
+
const fixed = [];
|
|
39
|
+
const verified = [];
|
|
40
|
+
const failed = [];
|
|
41
|
+
for (const fx of it.fixes) {
|
|
42
|
+
const applied = fx?.status === "verified" || fx?.status === "failed";
|
|
43
|
+
for (const f of list(fx?.findings)) {
|
|
44
|
+
const fid = fidOf(f);
|
|
45
|
+
if (!fid) continue;
|
|
46
|
+
if (typeof f.title === "string" && !names.has(fid)) names.set(fid, f.title);
|
|
47
|
+
found.push(fid);
|
|
48
|
+
if (applied) fixed.push({ fid, recipe: typeof fx.recipe === "string" ? fx.recipe : null });
|
|
49
|
+
if (fx.status === "verified") verified.push(fid);
|
|
50
|
+
if (fx.status === "failed") failed.push(fid);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { n: it.n, ms: Number.isFinite(it.ms) ? it.ms : it.durationMs, found, fixed, verified, failed };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fromLoop(loopRun, names) {
|
|
57
|
+
const its = list(loopRun?.iterations);
|
|
58
|
+
if (!its.length) return null;
|
|
59
|
+
const rows = new Map();
|
|
60
|
+
const row = (fid) => {
|
|
61
|
+
if (!rows.has(fid)) rows.set(fid, { fid, title: names.get(fid) || "", found: null, fixed: null, recipe: null, verified: null, failed: null, timeToFixMs: null, _start: null });
|
|
62
|
+
return rows.get(fid);
|
|
63
|
+
};
|
|
64
|
+
let elapsed = 0;
|
|
65
|
+
its.map((x) => normalizeIteration(x, names)).forEach((it, i) => {
|
|
66
|
+
const n = Number.isInteger(it?.n) ? it.n : i + 1;
|
|
67
|
+
const startMs = elapsed;
|
|
68
|
+
elapsed += Number.isFinite(it?.ms) && it.ms > 0 ? it.ms : 0;
|
|
69
|
+
for (const x of list(it?.found)) {
|
|
70
|
+
const fid = fidOf(x);
|
|
71
|
+
if (!fid) continue;
|
|
72
|
+
const r = row(fid);
|
|
73
|
+
if (r.found == null) { r.found = `iteration ${n}`; r._start = startMs; }
|
|
74
|
+
}
|
|
75
|
+
for (const x of list(it?.fixed)) {
|
|
76
|
+
const fid = fidOf(x);
|
|
77
|
+
if (!fid) continue;
|
|
78
|
+
const r = row(fid);
|
|
79
|
+
r.fixed = `iteration ${n}`;
|
|
80
|
+
r.recipe = typeof x?.recipe === "string" ? x.recipe : r.recipe;
|
|
81
|
+
if (r.found == null) { r.found = `iteration ${n}`; r._start = startMs; }
|
|
82
|
+
}
|
|
83
|
+
for (const x of list(it?.verified)) {
|
|
84
|
+
const fid = fidOf(x);
|
|
85
|
+
if (!fid) continue;
|
|
86
|
+
const r = row(fid);
|
|
87
|
+
r.verified = `iteration ${n}`;
|
|
88
|
+
r.failed = null;
|
|
89
|
+
r.timeToFixMs = elapsed - (r._start ?? startMs);
|
|
90
|
+
}
|
|
91
|
+
for (const x of list(it?.failed)) {
|
|
92
|
+
const fid = fidOf(x);
|
|
93
|
+
if (!fid) continue;
|
|
94
|
+
const r = row(fid);
|
|
95
|
+
if (!r.verified) r.failed = `iteration ${n}`;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const entries = [...rows.values()].map(({ _start, ...r }) => r);
|
|
99
|
+
return { source: "agent loop", iterations: its.length, totalMs: elapsed, entries };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fromMemory(history, names) {
|
|
103
|
+
const fixes = list(history?.fixes);
|
|
104
|
+
if (!fixes.length) return null;
|
|
105
|
+
const entries = fixes.map((f) => {
|
|
106
|
+
const t0 = Date.parse(f.firstSeen);
|
|
107
|
+
const t1 = Date.parse(f.verified);
|
|
108
|
+
return {
|
|
109
|
+
fid: f.id,
|
|
110
|
+
title: names.get(f.id) || "",
|
|
111
|
+
found: f.firstSeen || null,
|
|
112
|
+
fixed: f.applied || null,
|
|
113
|
+
recipe: f.recipe || null,
|
|
114
|
+
verified: f.verified || null,
|
|
115
|
+
failed: f.verified ? null : (f.failed || null),
|
|
116
|
+
timeToFixMs: Number.isFinite(t0) && Number.isFinite(t1) ? Math.max(0, t1 - t0) : null,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
return { source: "memory", iterations: null, totalMs: null, entries };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Build the timeline for a scan result, or null. */
|
|
123
|
+
export function buildTimeline(result) {
|
|
124
|
+
const names = titles(result);
|
|
125
|
+
const t = fromLoop(result?.loopRun, names) || fromMemory(result?.memory?.history, names);
|
|
126
|
+
if (!t || !t.entries.length) return null;
|
|
127
|
+
const times = t.entries.map((e) => e.timeToFixMs).filter((x) => x != null);
|
|
128
|
+
return {
|
|
129
|
+
...t,
|
|
130
|
+
verifiedCount: t.entries.filter((e) => e.verified).length,
|
|
131
|
+
failedCount: t.entries.filter((e) => e.failed).length,
|
|
132
|
+
openCount: t.entries.filter((e) => !e.verified && !e.failed).length,
|
|
133
|
+
medianTimeToFixMs: median(times),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** "350 ms", "4.2 s", "12 min", "3.5 h", "2.1 days". */
|
|
138
|
+
export function formatDuration(ms) {
|
|
139
|
+
if (ms == null || !Number.isFinite(ms)) return "-";
|
|
140
|
+
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
141
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
|
142
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)} min`;
|
|
143
|
+
if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(1)} h`;
|
|
144
|
+
return `${(ms / 86_400_000).toFixed(1)} days`;
|
|
145
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { dirname } from "node:path";
|
|
8
8
|
import { resolveWstgTags } from "../lib/owasp-wstg.js";
|
|
9
|
+
import { referencesFor } from "../lib/references.js";
|
|
10
|
+
import { formatDepsTerminal } from "../lib/deps/report.js";
|
|
9
11
|
|
|
10
12
|
const PKG_VERSION = JSON.parse(
|
|
11
13
|
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
|
@@ -258,6 +260,15 @@ export function printReport(result, opts = {}) {
|
|
|
258
260
|
w("");
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
// Dependencies (penthera deps)
|
|
264
|
+
if (result.deps) {
|
|
265
|
+
w(` ${c.dim(line())}`);
|
|
266
|
+
w(` ${c.bold("Dependencies")}`);
|
|
267
|
+
w("");
|
|
268
|
+
for (const l of formatDepsTerminal(result.deps, c, opts)) w(l);
|
|
269
|
+
w("");
|
|
270
|
+
}
|
|
271
|
+
|
|
261
272
|
// Cookies
|
|
262
273
|
if (result.cookies && result.cookies.length > 0) {
|
|
263
274
|
w(` ${c.dim(line())}`);
|
|
@@ -305,6 +316,26 @@ export function printReport(result, opts = {}) {
|
|
|
305
316
|
w("");
|
|
306
317
|
}
|
|
307
318
|
|
|
319
|
+
// Agent configuration (penthera agents)
|
|
320
|
+
if (result.agents) {
|
|
321
|
+
const a = result.agents;
|
|
322
|
+
const inv = a.inventory || {};
|
|
323
|
+
const gradeColor = { A: c.green, B: c.green, C: c.yellow, D: c.red, F: c.bgRed }[a.grade] || ((s) => s);
|
|
324
|
+
w(` ${c.dim(line())}`);
|
|
325
|
+
w(` ${c.bold("Agent Config")}${a.home ? c.dim(" repo + home") : c.dim(" repo")}`);
|
|
326
|
+
w("");
|
|
327
|
+
w(` Grade ${gradeColor(` ${a.grade} `)} ${c.dim(`(${a.score}/100)`)}`);
|
|
328
|
+
w(` Found ${inv.skills || 0} skills · ${inv.mcpServers || 0} MCP servers · ${inv.hooks || 0} hooks · ${inv.plugins || 0} plugins · ${inv.instructionFiles || 0} rule files`);
|
|
329
|
+
if (a.lock) {
|
|
330
|
+
const l = a.lock;
|
|
331
|
+
const status = l.status === "pinned" ? c.green(`pinned ${l.entries} entries → ${l.path}`)
|
|
332
|
+
: l.status === "checked" ? (l.changed || l.added ? c.yellow(`${l.changed} changed · ${l.added} new · ${l.removed} removed`) : c.green(`all ${l.entries} entries match`))
|
|
333
|
+
: l.status === "corrupt" ? c.red("corrupt") : c.dim("none (run with --pin)");
|
|
334
|
+
w(` Lock ${status}`);
|
|
335
|
+
}
|
|
336
|
+
w("");
|
|
337
|
+
}
|
|
338
|
+
|
|
308
339
|
// Findings
|
|
309
340
|
if (result.findings && result.findings.length > 0) {
|
|
310
341
|
w(` ${c.dim(line())}`);
|
|
@@ -323,8 +354,8 @@ export function printReport(result, opts = {}) {
|
|
|
323
354
|
// heuristic as a confirmed break. Certainties stay unannotated.
|
|
324
355
|
const needsReview = f.confidence === "needs-human-review" || f.confidence === "potential";
|
|
325
356
|
const tag = needsReview ? c.dim(` [${f.confidence}]`) : "";
|
|
326
|
-
w(` ${colorFn(label)} ${f.title}${tag}`);
|
|
327
|
-
if (f.url) w(` ${" ".repeat(6)} ${c.dim(f.url)}${f.status ? c.dim(` \u2192 ${f.status}`) : ""}`);
|
|
357
|
+
w(` ${colorFn(label)} ${f.title}${tag}${f.fid ? c.dim(` ${f.fid}`) : ""}`);
|
|
358
|
+
if (f.url) w(` ${" ".repeat(6)} ${c.dim(f.url + (f.line ? `:${f.line}` : ""))}${f.status ? c.dim(` \u2192 ${f.status}`) : ""}`);
|
|
328
359
|
if (f.description && opts.verbose) w(` ${" ".repeat(6)} ${c.dim(f.description)}`);
|
|
329
360
|
}
|
|
330
361
|
w("");
|
|
@@ -334,6 +365,19 @@ export function printReport(result, opts = {}) {
|
|
|
334
365
|
w("");
|
|
335
366
|
}
|
|
336
367
|
|
|
368
|
+
// Suppressed by local memory or the repo's penthera.suppress.json (--memory).
|
|
369
|
+
// Always listed with who suppressed it and why, so nothing disappears silently.
|
|
370
|
+
if (result.suppressed?.length) {
|
|
371
|
+
w(` ${c.dim(line())}`);
|
|
372
|
+
w(` ${c.bold("Suppressed")} ${c.dim(`(${result.suppressed.length}, not counted above)`)}`);
|
|
373
|
+
w("");
|
|
374
|
+
for (const f of result.suppressed) {
|
|
375
|
+
w(` ${c.dim(SEV_LABEL[f.severity] || " ")} ${f.title}${c.dim(` ${f.fid}`)}`);
|
|
376
|
+
w(` ${" ".repeat(6)} ${c.yellow(suppressionLabel(f.suppression))}: ${c.dim(stripControl(f.suppression?.reason))}`);
|
|
377
|
+
}
|
|
378
|
+
w("");
|
|
379
|
+
}
|
|
380
|
+
|
|
337
381
|
// Compliance summary
|
|
338
382
|
if (result.compliance) {
|
|
339
383
|
const s = result.compliance.summary;
|
|
@@ -345,12 +389,55 @@ export function printReport(result, opts = {}) {
|
|
|
345
389
|
w("");
|
|
346
390
|
}
|
|
347
391
|
|
|
392
|
+
// Network egress: which hosts this scan talked to besides the target
|
|
393
|
+
if (result.egress) {
|
|
394
|
+
const e = result.egress;
|
|
395
|
+
w(` ${c.dim(line())}`);
|
|
396
|
+
w(` ${c.bold("Network")}${e.offline ? c.dim(" --offline") : ""}`);
|
|
397
|
+
w("");
|
|
398
|
+
if (e.thirdPartyHosts.length === 0) {
|
|
399
|
+
w(` Contacted ${c.green("scan target only")} ${c.dim(`(${e.target})`)}`);
|
|
400
|
+
} else {
|
|
401
|
+
w(` Contacted ${e.thirdPartyHosts.length} third-party host${e.thirdPartyHosts.length > 1 ? "s" : ""} besides the target`);
|
|
402
|
+
for (const h of e.thirdPartyHosts) w(` ${c.dim(pad(h.host, 36))} ${h.requests} request${h.requests > 1 ? "s" : ""}`);
|
|
403
|
+
}
|
|
404
|
+
if (e.blocked.length > 0) {
|
|
405
|
+
w(` Blocked ${e.blocked.map((h) => h.host).join(", ")}`);
|
|
406
|
+
}
|
|
407
|
+
if (result.retireDb) {
|
|
408
|
+
w(` Retire.js ${result.retireDb.source === "snapshot" ? `vendored snapshot ${c.dim(`(${String(result.retireDb.snapshotDate).slice(0, 10)})`)}` : "live database"}`);
|
|
409
|
+
}
|
|
410
|
+
w("");
|
|
411
|
+
}
|
|
412
|
+
|
|
348
413
|
// Summary
|
|
349
414
|
w(` ${c.dim(line())}`);
|
|
350
415
|
printSummaryLine(result, w);
|
|
416
|
+
printInconclusiveLine(result, w, opts);
|
|
351
417
|
w("");
|
|
352
418
|
}
|
|
353
419
|
|
|
420
|
+
export function suppressionLabel(s) {
|
|
421
|
+
return s?.source === "repo-file" ? "suppressed by repo file (penthera.suppress.json)" : "suppressed by local memory";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function stripControl(str) {
|
|
425
|
+
return String(str ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Probes whose requests failed: the scan could not decide, so say so. */
|
|
429
|
+
function printInconclusiveLine(result, w, opts = {}) {
|
|
430
|
+
const errors = result.errors || [];
|
|
431
|
+
if (errors.length === 0) return;
|
|
432
|
+
const probes = [...new Set(errors.map((e) => e.probe))];
|
|
433
|
+
w(` ${c.yellow(`${probes.length} probe${probes.length > 1 ? "s" : ""} inconclusive`)} ${c.dim(`(${probes.join(", ")})`)}`);
|
|
434
|
+
if (opts.verbose) {
|
|
435
|
+
for (const e of errors) {
|
|
436
|
+
w(` ${c.dim(`${e.probe}: ${e.message}${e.count > 1 ? ` (x${e.count})` : ""}, ${e.url}`)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
354
441
|
function printSummaryLine(result, w) {
|
|
355
442
|
const findings = result.findings || [];
|
|
356
443
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
@@ -424,24 +511,42 @@ function toSarifUri(location) {
|
|
|
424
511
|
* Uploads to GitHub via: gh api repos/{owner}/{repo}/code-scanning/sarifs
|
|
425
512
|
*/
|
|
426
513
|
export function writeSarifReport(result, filePath) {
|
|
514
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
515
|
+
writeFileSync(filePath, JSON.stringify(formatSarif(result), null, 2) + "\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The SARIF 2.1.0 object for a scan result. */
|
|
519
|
+
export function formatSarif(result) {
|
|
427
520
|
const rules = new Map();
|
|
428
521
|
const results = [];
|
|
429
522
|
|
|
430
523
|
for (const f of result.findings || []) {
|
|
431
524
|
const ruleId = f.templateId || f.category || "unknown";
|
|
432
525
|
|
|
526
|
+
const refs = referencesFor(f);
|
|
527
|
+
|
|
433
528
|
if (!rules.has(ruleId)) {
|
|
434
529
|
rules.set(ruleId, {
|
|
435
530
|
id: ruleId,
|
|
436
531
|
name: f.title,
|
|
437
532
|
shortDescription: { text: f.title },
|
|
438
533
|
fullDescription: { text: f.description || f.title },
|
|
534
|
+
// Research and standards behind the rule (lib/references.json), so a
|
|
535
|
+
// reviewer in GitHub code scanning can see why it is reported.
|
|
536
|
+
...(refs.length && {
|
|
537
|
+
helpUri: refs[0].url,
|
|
538
|
+
help: {
|
|
539
|
+
text: `Sources: ${refs.map((r) => `${r.cite}, ${r.title} (${r.url})`).join("; ")}`,
|
|
540
|
+
markdown: `**Sources:** ${refs.map((r) => `[${r.cite}](${r.url}), *${r.title}*`).join("; ")}`,
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
439
543
|
defaultConfiguration: {
|
|
440
544
|
level: SARIF_SEV_MAP[f.severity] || "note",
|
|
441
545
|
},
|
|
442
546
|
properties: {
|
|
443
547
|
"security-severity": sevToScore(f.severity),
|
|
444
548
|
tags: ["security", f.category || "general", ...(f.wstg || resolveWstgTags(f))].filter(Boolean),
|
|
549
|
+
...(refs.length && { references: refs.map((r) => r.key) }),
|
|
445
550
|
},
|
|
446
551
|
});
|
|
447
552
|
}
|
|
@@ -457,6 +562,7 @@ export function writeSarifReport(result, filePath) {
|
|
|
457
562
|
artifactLocation: {
|
|
458
563
|
uri: toSarifUri(f.url),
|
|
459
564
|
},
|
|
565
|
+
...(Number.isInteger(f.line) && f.line > 0 ? { region: { startLine: f.line } } : {}),
|
|
460
566
|
},
|
|
461
567
|
}] : [],
|
|
462
568
|
properties: {
|
|
@@ -494,9 +600,7 @@ export function writeSarifReport(result, filePath) {
|
|
|
494
600
|
}],
|
|
495
601
|
}],
|
|
496
602
|
};
|
|
497
|
-
|
|
498
|
-
mkdirSync(dirname(filePath), { recursive: true });
|
|
499
|
-
writeFileSync(filePath, JSON.stringify(sarif, null, 2) + "\n");
|
|
603
|
+
return sarif;
|
|
500
604
|
}
|
|
501
605
|
|
|
502
606
|
function sevToScore(severity) {
|
package/src/scan-url.js
CHANGED
|
@@ -56,7 +56,8 @@ import { probeJwt } from "../lib/blackbox/jwt.js";
|
|
|
56
56
|
import { probeEmailDns } from "../lib/blackbox/email-dns.js";
|
|
57
57
|
import { resolveAuth } from "./utils/auth.js";
|
|
58
58
|
import { normalizeBaseUrl, isPrivateHost, joinUrl } from "./utils/url.js";
|
|
59
|
-
import { safeFetch } from "./utils/http.js";
|
|
59
|
+
import { safeFetch, createEgressContext, runWithEgress, egressSummary, probeErrors } from "./utils/http.js";
|
|
60
|
+
import { RECON_HOSTS } from "../lib/recon.js";
|
|
60
61
|
import { dedupeFindings } from "./cli/merge-results.js";
|
|
61
62
|
|
|
62
63
|
const SENSITIVE_RE = /private_key|SECRET|PASSWORD|API_KEY|FIREBASE_|STRIPE_|DATABASE_URL|client_email|\[core\]|\[remote/i;
|
|
@@ -70,6 +71,21 @@ const SENSITIVE_RE = /private_key|SECRET|PASSWORD|API_KEY|FIREBASE_|STRIPE_|DATA
|
|
|
70
71
|
*/
|
|
71
72
|
export async function scanUrl(rawTarget, opts = {}) {
|
|
72
73
|
const target = normalizeBaseUrl(rawTarget);
|
|
74
|
+
// Every request in this scan goes through one egress context: it enforces
|
|
75
|
+
// --offline, sets the User-Agent, and records hosts contacted and probe errors.
|
|
76
|
+
const ctx = createEgressContext({
|
|
77
|
+
target,
|
|
78
|
+
offline: opts.offline,
|
|
79
|
+
userAgent: opts.userAgent,
|
|
80
|
+
allowHosts: opts.allowHosts,
|
|
81
|
+
});
|
|
82
|
+
const result = await runWithEgress(ctx, () => scanUrlInContext(target, opts));
|
|
83
|
+
result.egress = egressSummary(ctx);
|
|
84
|
+
result.errors = probeErrors(ctx);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function scanUrlInContext(target, opts) {
|
|
73
89
|
const timeout = opts.timeout || 10_000;
|
|
74
90
|
const concurrency = opts.concurrency || 15;
|
|
75
91
|
const progress = opts.onPhase || (() => {});
|
|
@@ -104,7 +120,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
104
120
|
// ── Phase 1: Reachability ──────────────────────────────────────────────
|
|
105
121
|
progress("Checking target reachability...");
|
|
106
122
|
try {
|
|
107
|
-
const res = await safeFetch(target, { timeout });
|
|
123
|
+
const res = await safeFetch(target, { timeout, probe: "reachability" });
|
|
108
124
|
result.reachable = res != null && res.status < 500;
|
|
109
125
|
} catch {
|
|
110
126
|
result.reachable = false;
|
|
@@ -243,7 +259,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
243
259
|
|
|
244
260
|
await Promise.allSettled(
|
|
245
261
|
unchecked.map(async (file) => {
|
|
246
|
-
const res = await safeFetch(joinUrl(target, file.path), { timeout: 5_000 });
|
|
262
|
+
const res = await safeFetch(joinUrl(target, file.path), { timeout: 5_000, probe: "sensitive-files" });
|
|
247
263
|
if (!res || res.status !== 200) return;
|
|
248
264
|
const text = await res.text().catch(() => "");
|
|
249
265
|
|
|
@@ -281,6 +297,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
281
297
|
const res = await safeFetch(joinUrl(target, corsEndpoint), {
|
|
282
298
|
headers: { Origin: evilOrigin },
|
|
283
299
|
timeout: 5_000,
|
|
300
|
+
probe: "cors",
|
|
284
301
|
});
|
|
285
302
|
if (!res) continue;
|
|
286
303
|
const acao = res.headers.get("access-control-allow-origin");
|
|
@@ -308,6 +325,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
308
325
|
"x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware",
|
|
309
326
|
},
|
|
310
327
|
timeout: 5_000,
|
|
328
|
+
probe: "framework-check",
|
|
311
329
|
});
|
|
312
330
|
if (res && res.status === 200) {
|
|
313
331
|
const alreadyFound = result.findings.some(
|
|
@@ -330,7 +348,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
330
348
|
// ── Phase 9: Cookie Security Audit ──────────────────────────────────────
|
|
331
349
|
progress("Auditing cookie security...");
|
|
332
350
|
try {
|
|
333
|
-
const cookieRes = await safeFetch(target, { timeout });
|
|
351
|
+
const cookieRes = await safeFetch(target, { timeout, probe: "cookie" });
|
|
334
352
|
if (cookieRes) {
|
|
335
353
|
const setCookies = cookieRes.headers.getSetCookie?.() || [];
|
|
336
354
|
if (setCookies.length > 0) {
|
|
@@ -390,6 +408,9 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
390
408
|
// Only count the probe as run when its database actually loaded, so the
|
|
391
409
|
// WSTG and compliance coverage can't claim a check that was skipped.
|
|
392
410
|
retireJsRan = jsResult.dbLoaded !== false;
|
|
411
|
+
if (jsResult.dbSource) {
|
|
412
|
+
result.retireDb = { source: jsResult.dbSource, snapshotDate: jsResult.dbSnapshotDate || null };
|
|
413
|
+
}
|
|
393
414
|
if (jsResult.libraries.length > 0) {
|
|
394
415
|
result.jsLibraries = jsResult.libraries;
|
|
395
416
|
}
|
|
@@ -416,7 +437,10 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
416
437
|
if (opts.recon && local) {
|
|
417
438
|
progress("Skipping OSINT recon (localhost target)...");
|
|
418
439
|
}
|
|
419
|
-
if (opts.recon && !local) {
|
|
440
|
+
if (opts.recon && !local && opts.offline) {
|
|
441
|
+
progress(`Skipping OSINT recon (--offline: no queries to ${RECON_HOSTS.join(", ")})...`);
|
|
442
|
+
}
|
|
443
|
+
if (opts.recon && !local && !opts.offline) {
|
|
420
444
|
const domain = extractDomain(target);
|
|
421
445
|
progress(`Running OSINT recon on ${domain}...`);
|
|
422
446
|
try {
|
|
@@ -426,7 +450,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
426
450
|
if (result.recon.endpoints.length > 0) {
|
|
427
451
|
progress(`Probing ${result.recon.endpoints.length} historically known endpoints...`);
|
|
428
452
|
for (const path of result.recon.endpoints.slice(0, 50)) {
|
|
429
|
-
const res = await safeFetch(`${target}${path}`, { timeout: 5_000 });
|
|
453
|
+
const res = await safeFetch(`${target}${path}`, { timeout: 5_000, probe: "recon" });
|
|
430
454
|
if (res && res.status !== 404 && res.status < 500) {
|
|
431
455
|
const alreadyKnown = filtered.some((ep) => ep.path === path);
|
|
432
456
|
if (!alreadyKnown) {
|
|
@@ -570,7 +594,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
570
594
|
...(auth.bearer ? ["jwt"] : []),
|
|
571
595
|
...(!opts.skipRetireJs && retireJsRan ? ["retirejs"] : []),
|
|
572
596
|
...(!opts.skipParamDiscovery && hadEndpoints ? ["param-discovery"] : []),
|
|
573
|
-
...(opts.recon && !local ? ["recon"] : []),
|
|
597
|
+
...(opts.recon && !local && !opts.offline ? ["recon"] : []),
|
|
574
598
|
...(opts.deep && hadEndpoints ? ["sqli", "ssti", "ssrf", "xss", "cmdi", "open-redirect"] : []),
|
|
575
599
|
...(opts.fuzz && filtered.some((ep) => ep.path.includes("/api/") || ep.path.includes("/graphql")) ? ["api-fuzzing"] : []),
|
|
576
600
|
...(opts.adaptive ? ["adaptive"] : []),
|