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.
Files changed (95) hide show
  1. package/CHANGELOG.md +269 -3
  2. package/README.md +489 -286
  3. package/SECURITY.md +28 -6
  4. package/docs/METHODOLOGY.md +321 -0
  5. package/docs/compliance-nis2.md +166 -0
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +55 -0
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +15 -3
  15. package/docs/local-loop.md +276 -0
  16. package/docs/references.bib +539 -0
  17. package/docs/references.md +77 -0
  18. package/docs/release-plan.md +228 -0
  19. package/lib/adaptive-probes.js +8 -5
  20. package/lib/agents/discover.js +384 -0
  21. package/lib/agents/hidden-content.js +387 -0
  22. package/lib/agents/index.js +286 -0
  23. package/lib/agents/lock.js +115 -0
  24. package/lib/agents/owasp.js +83 -0
  25. package/lib/agents/rules.js +376 -0
  26. package/lib/crawler.js +5 -15
  27. package/lib/data/retire-jsrepository.json.gz +0 -0
  28. package/lib/data/retire-jsrepository.meta.json +7 -0
  29. package/lib/deps/capabilities.js +523 -0
  30. package/lib/deps/diff.js +84 -0
  31. package/lib/deps/index.js +416 -0
  32. package/lib/deps/lockfile.js +106 -0
  33. package/lib/deps/online.js +132 -0
  34. package/lib/deps/popular-packages.json +1 -0
  35. package/lib/deps/report.js +173 -0
  36. package/lib/deps/source.js +171 -0
  37. package/lib/deps/typosquat.js +113 -0
  38. package/lib/fix/apply.js +95 -0
  39. package/lib/fix/diff.js +199 -0
  40. package/lib/fix/index.js +129 -0
  41. package/lib/fix/model.js +108 -0
  42. package/lib/fix/recipes/cookies.js +130 -0
  43. package/lib/fix/recipes/cors.js +116 -0
  44. package/lib/fix/recipes/headers.js +250 -0
  45. package/lib/fix/recipes/rate-limit.js +211 -0
  46. package/lib/fix/recipes/secrets.js +110 -0
  47. package/lib/fix/util.js +231 -0
  48. package/lib/fuzzer.js +14 -8
  49. package/lib/injections.js +19 -15
  50. package/lib/install/index.js +271 -0
  51. package/lib/install/policy.js +117 -0
  52. package/lib/memory/history.js +52 -0
  53. package/lib/memory/index.js +111 -0
  54. package/lib/memory/repo-suppress.js +64 -0
  55. package/lib/memory/store.js +325 -0
  56. package/lib/memory/views.js +198 -0
  57. package/lib/params.js +4 -12
  58. package/lib/provenance.js +2 -0
  59. package/lib/recon.js +12 -20
  60. package/lib/references.js +31 -0
  61. package/lib/references.json +374 -0
  62. package/lib/remediation/index.js +28 -0
  63. package/lib/retirejs.js +50 -22
  64. package/lib/templates.js +7 -10
  65. package/lib/whitebox/frameworks.js +1 -1
  66. package/lib/whitebox/secrets.js +49 -19
  67. package/package.json +19 -9
  68. package/skills/penthera/SKILL.md +104 -23
  69. package/skills/penthera/references/output-and-ci.md +12 -50
  70. package/skills/penthera/references/troubleshooting.md +1 -1
  71. package/skills/penthera/scripts/preflight.sh +4 -4
  72. package/src/agent/git.js +87 -0
  73. package/src/agent/loop.js +519 -0
  74. package/src/agent/tests.js +48 -0
  75. package/src/cli/agent.js +183 -0
  76. package/src/cli/deps.js +84 -0
  77. package/src/cli/evidence.js +67 -0
  78. package/src/cli/fix.js +191 -0
  79. package/src/cli/install.js +225 -0
  80. package/src/cli/memory.js +146 -0
  81. package/src/cli/merge-results.js +2 -0
  82. package/src/cli/report-outputs.js +72 -0
  83. package/src/cli/run-scan.js +2 -0
  84. package/src/cli.js +152 -6
  85. package/src/report/audience.js +270 -0
  86. package/src/report/evidence.js +253 -0
  87. package/src/report/markdown.js +65 -2
  88. package/src/report/model.js +212 -0
  89. package/src/report/template.js +175 -0
  90. package/src/report/timeline.js +145 -0
  91. package/src/reporter.js +109 -5
  92. package/src/scan-url.js +31 -7
  93. package/src/utils/http.js +252 -11
  94. package/src/utils/url.js +23 -0
  95. 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
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"] : []),