penthera 1.1.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 (74) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/SECURITY.md +31 -0
  5. package/bin/penthera.js +3 -0
  6. package/bin/scan.js +11 -0
  7. package/docs/index.md +60 -0
  8. package/docs/owasp-wstg-coverage.md +79 -0
  9. package/lib/adaptive-probes.js +334 -0
  10. package/lib/attack-surface.js +149 -0
  11. package/lib/audit-loop.js +170 -0
  12. package/lib/blackbox/adaptive-scan.js +137 -0
  13. package/lib/blackbox/client-auth.js +60 -0
  14. package/lib/blackbox/email-dns.js +135 -0
  15. package/lib/blackbox/headers.js +91 -0
  16. package/lib/blackbox/idor.js +113 -0
  17. package/lib/blackbox/jwt.js +73 -0
  18. package/lib/blackbox/oauth.js +50 -0
  19. package/lib/blackbox/openapi.js +227 -0
  20. package/lib/compliance/incident.js +169 -0
  21. package/lib/compliance/index.js +163 -0
  22. package/lib/compliance/policy-pack.js +293 -0
  23. package/lib/compliance/questionnaire.js +113 -0
  24. package/lib/compliance/readiness.js +229 -0
  25. package/lib/compliance/self-assessment.js +66 -0
  26. package/lib/crawler.js +257 -0
  27. package/lib/fuzzer.js +233 -0
  28. package/lib/injections.js +676 -0
  29. package/lib/jurisdictions/nl.json +48 -0
  30. package/lib/knowledge-graph.js +232 -0
  31. package/lib/machine.js +814 -0
  32. package/lib/nuclei-loader.js +283 -0
  33. package/lib/owasp-wstg.js +204 -0
  34. package/lib/params.js +235 -0
  35. package/lib/plugins.js +70 -0
  36. package/lib/provenance.js +82 -0
  37. package/lib/recon.js +193 -0
  38. package/lib/remediation/index.js +219 -0
  39. package/lib/retirejs.js +239 -0
  40. package/lib/supply-chain.js +131 -0
  41. package/lib/templates.js +364 -0
  42. package/lib/tls.js +145 -0
  43. package/lib/whitebox/frameworks.js +139 -0
  44. package/lib/whitebox/sbom.js +144 -0
  45. package/lib/whitebox/secrets.js +111 -0
  46. package/package.json +76 -0
  47. package/pentest.config.example.js +82 -0
  48. package/scripts/package-skill.sh +16 -0
  49. package/skills/penthera/SKILL.md +272 -0
  50. package/skills/penthera/references/authorization.md +36 -0
  51. package/skills/penthera/references/output-and-ci.md +109 -0
  52. package/skills/penthera/references/profiles-and-flags.md +74 -0
  53. package/skills/penthera/references/remediation.md +267 -0
  54. package/skills/penthera/references/reporting.md +113 -0
  55. package/skills/penthera/references/secure-defaults.md +41 -0
  56. package/skills/penthera/references/troubleshooting.md +100 -0
  57. package/skills/penthera/scripts/preflight.sh +66 -0
  58. package/skills/penthera/scripts/validate-report.mjs +53 -0
  59. package/skills/penthera/scripts/validate-report.sh +8 -0
  60. package/src/cli/ansi.js +70 -0
  61. package/src/cli/baseline.js +37 -0
  62. package/src/cli/merge-results.js +71 -0
  63. package/src/cli/onboarding.js +216 -0
  64. package/src/cli/profiles.js +53 -0
  65. package/src/cli/prompt.js +64 -0
  66. package/src/cli/run-scan.js +152 -0
  67. package/src/cli.js +536 -0
  68. package/src/report/markdown.js +182 -0
  69. package/src/reporter.js +512 -0
  70. package/src/scan-repo.js +205 -0
  71. package/src/scan-url.js +563 -0
  72. package/src/utils/auth.js +13 -0
  73. package/src/utils/http.js +38 -0
  74. package/src/utils/url.js +54 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Human-readable Markdown security report.
3
+ */
4
+ import { writeFileSync, mkdirSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { buildWstgCoverage, formatWstgMarkdown } from "../../lib/owasp-wstg.js";
7
+ import { formatComplianceMarkdown } from "../../lib/compliance/index.js";
8
+ import { buildActionPlan, formatActionPlanMarkdown } from "../../lib/remediation/index.js";
9
+ import { formatAuditLoopMarkdown } from "../../lib/audit-loop.js";
10
+
11
+ const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
12
+ const SEV_TAG = {
13
+ critical: "[CRIT]",
14
+ high: "[HIGH]",
15
+ medium: "[MED]",
16
+ low: "[LOW]",
17
+ info: "[INFO]",
18
+ };
19
+
20
+ function sortFindings(findings) {
21
+ return [...(findings || [])].sort(
22
+ (a, b) => (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5),
23
+ );
24
+ }
25
+
26
+ function countBySeverity(findings) {
27
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
28
+ for (const f of findings || []) {
29
+ if (counts[f.severity] != null) counts[f.severity]++;
30
+ }
31
+ return counts;
32
+ }
33
+
34
+ function mdEscape(s) {
35
+ return String(s || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
36
+ }
37
+
38
+ /**
39
+ * Generate Markdown report content from scan result.
40
+ */
41
+ export function formatMarkdownReport(result, opts = {}) {
42
+ const findings = sortFindings(result.findings);
43
+ const counts = countBySeverity(findings);
44
+ const version = opts.version || "0.2.0";
45
+ const lines = [];
46
+
47
+ lines.push(`# Penthera Security Report`);
48
+ lines.push("");
49
+ lines.push(`> Generated by [Penthera](https://github.com/danoszz/penthera) v${version}`);
50
+ lines.push("");
51
+
52
+ lines.push("## Executive summary");
53
+ lines.push("");
54
+ lines.push(`| | |`);
55
+ lines.push(`|---|---|`);
56
+ lines.push(`| **Target** | \`${result.target || "unknown"}\` |`);
57
+ lines.push(`| **Scan time** | ${result.timestamp?.replace("T", " ").slice(0, 19) || "-"} UTC |`);
58
+ lines.push(`| **Duration** | ${result.duration ? `${(result.duration / 1000).toFixed(1)}s` : ", "} |`);
59
+ lines.push(`| **Mode** | ${result.modes?.join(", ") || result.mode || "-"} |`);
60
+ lines.push(`| **Reachable** | ${result.reachable ? "Yes" : "No"} |`);
61
+ if (result.profile) lines.push(`| **Profile** | ${result.profile} |`);
62
+ lines.push("");
63
+
64
+ const total = findings.length;
65
+ if (total === 0) {
66
+ lines.push("**No security findings detected.**");
67
+ lines.push("");
68
+ } else {
69
+ lines.push(
70
+ `Found **${total}** issue(s): ` +
71
+ `${counts.critical} critical · ${counts.high} high · ${counts.medium} medium · ` +
72
+ `${counts.low} low · ${counts.info} info`,
73
+ );
74
+ lines.push("");
75
+
76
+ if (counts.critical > 0 || counts.high > 0) {
77
+ lines.push("> **Action required**, critical or high severity issues should be fixed before production deployment.");
78
+ lines.push("");
79
+ }
80
+ }
81
+
82
+ if (opts.baseline) {
83
+ lines.push("## Baseline comparison");
84
+ lines.push("");
85
+ lines.push(`Compared against: \`${opts.baseline.path}\``);
86
+ lines.push("");
87
+ lines.push(`| Status | Count |`);
88
+ lines.push(`|--------|-------|`);
89
+ lines.push(`| New findings | ${opts.baseline.newCount} |`);
90
+ lines.push(`| Resolved since baseline | ${opts.baseline.resolvedCount} |`);
91
+ lines.push(`| Unchanged | ${opts.baseline.unchangedCount} |`);
92
+ lines.push("");
93
+ }
94
+
95
+ lines.push(formatWstgMarkdown(buildWstgCoverage(result, result.profile || opts.profile || "standard")));
96
+
97
+ if (result.compliance) {
98
+ lines.push(formatComplianceMarkdown(result.compliance));
99
+ }
100
+
101
+ if (result.fingerprint) {
102
+ lines.push("## Technology fingerprint");
103
+ lines.push("");
104
+ const fp = result.fingerprint;
105
+ if (fp.framework) lines.push(`- **Framework:** ${fp.framework}`);
106
+ if (fp.server) lines.push(`- **Server:** ${fp.server}`);
107
+ if (fp.cdn) lines.push(`- **CDN:** ${fp.cdn}`);
108
+ lines.push(`- **Security headers:** ${fp.security?.length ? fp.security.join(", ") : "none detected"}`);
109
+ lines.push("");
110
+ }
111
+
112
+ if (result.openapi?.specUrl) {
113
+ lines.push("## API surface");
114
+ lines.push("");
115
+ lines.push(`- **OpenAPI spec:** ${result.openapi.specUrl}`);
116
+ lines.push(`- **Paths documented:** ${result.openapi.pathCount || "-"}`);
117
+ lines.push(`- **Unprotected routes (spec):** ${result.openapi.unprotectedRoutes || "-"}`);
118
+ lines.push("");
119
+ }
120
+
121
+ if (total > 0) {
122
+ lines.push("## Findings");
123
+ lines.push("");
124
+
125
+ const grouped = {};
126
+ for (const f of findings) {
127
+ const sev = f.severity || "info";
128
+ if (!grouped[sev]) grouped[sev] = [];
129
+ grouped[sev].push(f);
130
+ }
131
+
132
+ for (const sev of ["critical", "high", "medium", "low", "info"]) {
133
+ const group = grouped[sev];
134
+ if (!group?.length) continue;
135
+
136
+ lines.push(`### ${SEV_TAG[sev]} ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${group.length})`);
137
+ lines.push("");
138
+ lines.push("| Finding | URL | Confidence | Source |");
139
+ lines.push("|---------|-----|-----------|--------|");
140
+
141
+ for (const f of group) {
142
+ lines.push(`| **${mdEscape(f.title)}** | ${f.url ? `[link](${f.url})` : "-"} | ${f.confidence || "-"} | ${mdEscape(f.source || "-")} |`);
143
+ }
144
+ lines.push("");
145
+
146
+ for (const f of group) {
147
+ if (!f.description) continue;
148
+ lines.push(`**${f.title}**`);
149
+ lines.push("");
150
+ lines.push(f.description);
151
+ if (f.url) lines.push(`\n- URL: ${f.url}`);
152
+ if (f.status) lines.push(`- HTTP status: ${f.status}`);
153
+ lines.push("");
154
+ }
155
+ }
156
+ }
157
+
158
+ const actionPlan = result.actionPlan || buildActionPlan(findings);
159
+ if (actionPlan.length > 0) {
160
+ lines.push(formatActionPlanMarkdown(actionPlan));
161
+ }
162
+
163
+ if (result.auditLoop) {
164
+ lines.push(formatAuditLoopMarkdown(result.auditLoop));
165
+ }
166
+
167
+ lines.push("---");
168
+ lines.push("");
169
+ lines.push("*Authorized security testing only. See Penthera README for ethical use guidelines.*");
170
+ lines.push("");
171
+
172
+ return lines.join("\n");
173
+ }
174
+
175
+ export function writeMarkdownReport(result, filePath, opts = {}) {
176
+ mkdirSync(dirname(filePath), { recursive: true });
177
+ writeFileSync(filePath, formatMarkdownReport(result, opts));
178
+ }
179
+
180
+ export function markdownPathFromJson(jsonPath) {
181
+ return jsonPath.replace(/\.json$/i, ".md");
182
+ }
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Penthera, Report Formatter
3
+ *
4
+ * Terminal output with ANSI colors, JSON export, SARIF export.
5
+ */
6
+ import { writeFileSync, readFileSync, mkdirSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { resolveWstgTags } from "../lib/owasp-wstg.js";
9
+
10
+ const PKG_VERSION = JSON.parse(
11
+ readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
12
+ ).version;
13
+
14
+ // ── ANSI helpers ─────────────────────────────────────────────────────────
15
+
16
+ const NO_COLOR = !!process.env.NO_COLOR;
17
+
18
+ const ansi = (code) => (s) => NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`;
19
+
20
+ const c = {
21
+ bold: ansi("1"),
22
+ dim: ansi("2"),
23
+ red: ansi("31"),
24
+ green: ansi("32"),
25
+ yellow: ansi("33"),
26
+ cyan: ansi("36"),
27
+ magenta: ansi("35"),
28
+ bgRed: (s) => NO_COLOR ? s : `\x1b[41;37;1m${s}\x1b[0m`,
29
+ bgYellow:(s) => NO_COLOR ? s : `\x1b[43;30;1m${s}\x1b[0m`,
30
+ };
31
+
32
+ const SEV_COLOR = {
33
+ critical: c.bgRed,
34
+ high: c.red,
35
+ medium: c.yellow,
36
+ low: c.cyan,
37
+ info: c.dim,
38
+ };
39
+
40
+ const SEV_LABEL = {
41
+ critical: "CRIT",
42
+ high: "HIGH",
43
+ medium: "MED ",
44
+ low: "LOW ",
45
+ info: "INFO",
46
+ };
47
+
48
+ function pad(s, n) { return (s + " ".repeat(n)).slice(0, n); }
49
+ function line(char = "\u2500", len = 58) { return char.repeat(len); }
50
+
51
+ // ── Terminal report ──────────────────────────────────────────────────────
52
+
53
+ export function printReport(result, opts = {}) {
54
+ const out = process.stdout;
55
+ const w = (s) => out.write(s + "\n");
56
+
57
+ w("");
58
+ w(` ${c.bold("penthera")} ${c.dim("security scanner")}`);
59
+ w("");
60
+
61
+ // Target info
62
+ if (result.target) w(` Target ${c.cyan(result.target)}`);
63
+ if (result.timestamp) w(` Started ${result.timestamp.replace("T", " ").slice(0, 19)}`);
64
+ if (result.duration) w(` Duration ${(result.duration / 1000).toFixed(1)}s`);
65
+ if (result.modes) w(` Modes ${result.modes.join(", ")}`);
66
+ else if (result.mode) w(` Mode ${result.mode}`);
67
+ w("");
68
+
69
+ // TLS
70
+ if (result.tls && result.tls.protocol) {
71
+ w(` ${c.dim(line())}`);
72
+ w(` ${c.bold("TLS/SSL")}`);
73
+ w("");
74
+ const t = result.tls;
75
+ w(` Protocol ${t.protocol === "TLSv1.3" ? c.green(t.protocol) : t.protocol}`);
76
+ w(` Cipher ${t.cipher}`);
77
+ w(` Issuer ${t.issuer}`);
78
+ w(` Subject ${t.subject}`);
79
+ w(` Expires ${t.validTo?.slice(0, 10) || "unknown"} ${t.daysUntilExpiry != null ? c.dim(`(${t.daysUntilExpiry}d)`) : ""}`);
80
+ w(` Valid ${t.valid ? c.green("yes") : c.red("no" + (t.error ? `, ${t.error}` : ""))}`);
81
+ w("");
82
+ }
83
+
84
+ // Fingerprint
85
+ if (result.fingerprint) {
86
+ w(` ${c.dim(line())}`);
87
+ w(` ${c.bold("Fingerprint")}`);
88
+ w("");
89
+ const fp = result.fingerprint;
90
+ if (fp.framework) w(` Framework ${fp.framework}`);
91
+ if (fp.server) w(` Server ${fp.server}`);
92
+ if (fp.cdn) w(` CDN ${fp.cdn}`);
93
+ if (fp.security?.length > 0) {
94
+ w(` Headers ${c.green(fp.security.join(", "))}`);
95
+ } else {
96
+ w(` Headers ${c.yellow("no security headers detected")}`);
97
+ }
98
+ w("");
99
+ }
100
+
101
+ // OSINT Recon
102
+ if (result.recon) {
103
+ w(` ${c.dim(line())}`);
104
+ w(` ${c.bold("OSINT Recon")}`);
105
+ w("");
106
+ const r = result.recon;
107
+ if (r.subdomains?.length > 0) {
108
+ w(` Subdomains ${c.bold(String(r.subdomains.length))} discovered ${c.dim("(crt.sh)")}`);
109
+ if (opts.verbose) {
110
+ for (const sub of r.subdomains.slice(0, 20)) {
111
+ w(` ${c.dim(sub)}`);
112
+ }
113
+ if (r.subdomains.length > 20) w(` ${c.dim(`... and ${r.subdomains.length - 20} more`)}`);
114
+ }
115
+ }
116
+ if (r.endpoints?.length > 0) {
117
+ w(` Endpoints ${c.bold(String(r.endpoints.length))} historical paths ${c.dim("(Wayback + OTX)")}`);
118
+ }
119
+ if (r.parameters?.length > 0) {
120
+ w(` Parameters ${r.parameters.length} unique names ${c.dim(r.parameters.slice(0, 8).join(", "))}`);
121
+ }
122
+ const src = r.sources || {};
123
+ w(` Sources crt.sh:${src.crtsh || 0} wayback:${src.wayback || 0} otx:${src.otx || 0}`);
124
+ w("");
125
+ }
126
+
127
+ // Endpoints
128
+ if (result.endpoints && result.endpoints.discovered > 0) {
129
+ w(` ${c.dim(line())}`);
130
+ w(` ${c.bold("Discovery")}`);
131
+ w("");
132
+ w(` Probed ${result.endpoints.total} paths`);
133
+ w(` Found ${c.bold(String(result.endpoints.discovered))} live endpoints`);
134
+ const byStatus = result.endpoints.byStatus || {};
135
+ const statusLine = Object.entries(byStatus)
136
+ .sort(([a], [b]) => Number(a) - Number(b))
137
+ .map(([s, n]) => `${s}:${n}`)
138
+ .join(" ");
139
+ if (statusLine) w(` Status ${c.dim(statusLine)}`);
140
+ w("");
141
+ }
142
+
143
+ // Attack surface (repo scan)
144
+ if (result.attackSurface && result.attackSurface.length > 0) {
145
+ w(` ${c.dim(line())}`);
146
+ w(` ${c.bold("Attack Surface")}`);
147
+ w("");
148
+ const b = result.boundaries || {};
149
+ w(` Routes ${c.bold(String(result.attackSurface.length))} discovered`);
150
+ if (b.public) w(` Public ${b.public.length}`);
151
+ if (b.userAuth) w(` User auth ${b.userAuth.length}`);
152
+ if (b.adminAuth) w(` Admin auth ${b.adminAuth.length}`);
153
+ if (b.cronAuth) w(` Cron auth ${b.cronAuth.length}`);
154
+
155
+ if (opts.verbose && result.attackSurface.length > 0) {
156
+ w("");
157
+ for (const r of result.attackSurface) {
158
+ const methods = r.methods.join(",");
159
+ const auth = r.auth.join(",");
160
+ w(` ${c.dim(pad(methods, 12))} ${pad(r.url, 30)} ${c.dim(auth)}`);
161
+ }
162
+ }
163
+ w("");
164
+ }
165
+
166
+ // Machine Audit
167
+ if (result.machine) {
168
+ const m = result.machine;
169
+ w(` ${c.dim(line())}`);
170
+ w(` ${c.bold("Machine Audit")}`);
171
+ w("");
172
+
173
+ // System posture
174
+ for (const check of m.checks || []) {
175
+ if (check.status) {
176
+ const ok = check.status === "enabled" || check.status === "on" || check.status.startsWith("Version");
177
+ const display = check.status.length > 30 ? check.status.slice(0, 30) + "..." : check.status;
178
+ w(` ${pad(check.name, 36)} ${ok ? c.green(display) : c.red(display)}`);
179
+ }
180
+ }
181
+ w("");
182
+
183
+ // Persistence
184
+ if (m.persistence?.length > 0) {
185
+ const unknown = m.persistence.filter((p) => !p.known);
186
+ const known = m.persistence.filter((p) => p.known);
187
+ w(` Persistence ${c.bold(String(m.persistence.length))} items ${c.dim(`(${known.length} known, ${unknown.length} unknown)`)}`);
188
+ if (opts.verbose) {
189
+ for (const p of unknown) {
190
+ const sig = p.signed === false ? c.red(" UNSIGNED") : p.signed ? c.green(" signed") : "";
191
+ w(` ${c.yellow("?")} ${p.label}${sig}`);
192
+ w(` ${c.dim(p.program)}`);
193
+ }
194
+ }
195
+ }
196
+
197
+ // Network
198
+ if (m.network?.length > 0) {
199
+ w(` Connections ${c.bold(String(m.network.length))} established outbound`);
200
+ if (opts.verbose) {
201
+ const byProcess = {};
202
+ for (const conn of m.network) {
203
+ byProcess[conn.process] = (byProcess[conn.process] || 0) + 1;
204
+ }
205
+ const top = Object.entries(byProcess).sort((a, b) => b[1] - a[1]).slice(0, 10);
206
+ for (const [proc, count] of top) {
207
+ w(` ${c.dim(pad(proc, 20))} ${count} connection${count > 1 ? "s" : ""}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ // Browser extensions
213
+ if (m.browserExtensions?.length > 0) {
214
+ w(` Extensions ${c.bold(String(m.browserExtensions.length))} Chrome extensions`);
215
+ if (opts.verbose) {
216
+ for (const ext of m.browserExtensions) {
217
+ w(` ${c.dim(pad(ext.name, 30))} v${ext.version}`);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Login items
223
+ if (m.loginItems?.length > 0) {
224
+ w(` Login items ${m.loginItems.join(", ")}`);
225
+ }
226
+
227
+ // rkhunter stats
228
+ const rkCheck = (m.checks || []).find((c) => c.name === "rkhunter");
229
+ if (rkCheck) {
230
+ const fp = rkCheck.filteredFalsePositives || 0;
231
+ const real = rkCheck.realWarnings || 0;
232
+ w(` rkhunter ${real === 0 ? c.green("clean") : c.yellow(real + " warning" + (real > 1 ? "s" : ""))}${fp > 0 ? c.dim(` (${fp} macOS false positives filtered)`) : ""}`);
233
+ }
234
+
235
+ // ClamAV stats
236
+ const clamCheck = (m.checks || []).find((c) => c.name === "ClamAV");
237
+ if (clamCheck) {
238
+ w(` ClamAV ${clamCheck.infected === 0 ? c.green("clean") : c.red(clamCheck.infected + " infected")} ${c.dim(`(scanned ${clamCheck.scanned})`)}`);
239
+ }
240
+
241
+ // Optional tools status
242
+ if (m.tools) {
243
+ const toolStatus = Object.entries(m.tools)
244
+ .map(([name, ok]) => ok ? c.green(name) : c.dim(name))
245
+ .join(c.dim(" · "));
246
+ w(` Tools ${toolStatus}`);
247
+ }
248
+
249
+ // Missing tools advisory
250
+ if (m.missing?.length > 0) {
251
+ w("");
252
+ w(` ${c.dim("Install for deeper scans:")}`);
253
+ for (const t of m.missing) {
254
+ w(` ${c.dim("brew install " + t.split("(")[0].trim().toLowerCase())}`);
255
+ }
256
+ }
257
+
258
+ w("");
259
+ }
260
+
261
+ // Cookies
262
+ if (result.cookies && result.cookies.length > 0) {
263
+ w(` ${c.dim(line())}`);
264
+ w(` ${c.bold("Cookies")}`);
265
+ w("");
266
+ for (const ck of result.cookies) {
267
+ const flags = [];
268
+ flags.push(ck.httpOnly ? c.green("HttpOnly") : c.red("HttpOnly"));
269
+ flags.push(ck.secure ? c.green("Secure") : (result.local ? c.dim("Secure") : c.red("Secure")));
270
+ flags.push(ck.sameSite ? c.green(`SameSite=${ck.sameSite}`) : c.yellow("SameSite"));
271
+ w(` ${pad(ck.name, 24)} ${flags.join(c.dim(" · "))}`);
272
+ }
273
+ w("");
274
+ }
275
+
276
+ // JS Libraries (Retire.js)
277
+ if (result.jsLibraries && result.jsLibraries.length > 0) {
278
+ w(` ${c.dim(line())}`);
279
+ w(` ${c.bold("JS Libraries")}`);
280
+ w("");
281
+ for (const lib of result.jsLibraries) {
282
+ const vulnCount = lib.vulnerabilities?.length || 0;
283
+ const status = vulnCount > 0
284
+ ? c.red(`${vulnCount} known vuln${vulnCount > 1 ? "s" : ""}`)
285
+ : c.green("no known vulns");
286
+ w(` ${pad(lib.name, 20)} ${c.dim("v")}${lib.version} ${status}`);
287
+ if (opts.verbose && lib.vulnerabilities) {
288
+ for (const v of lib.vulnerabilities) {
289
+ const cves = v.cves?.join(", ") || "no CVE";
290
+ w(` ${c.dim(`↳ ${v.severity}, ${cves} (< ${v.below || "latest"})`)}`);
291
+ }
292
+ }
293
+ }
294
+ w("");
295
+ }
296
+
297
+ // Parameter Discovery
298
+ if (result.paramDiscovery && result.paramDiscovery.length > 0) {
299
+ w(` ${c.dim(line())}`);
300
+ w(` ${c.bold("Parameters")}`);
301
+ w("");
302
+ for (const pd of result.paramDiscovery) {
303
+ w(` ${pad(pd.path, 30)} ${c.bold(String(pd.params.length))} active ${c.dim(pd.params.slice(0, 6).join(", "))}`);
304
+ }
305
+ w("");
306
+ }
307
+
308
+ // Findings
309
+ if (result.findings && result.findings.length > 0) {
310
+ w(` ${c.dim(line())}`);
311
+ w(` ${c.bold("Findings")}`);
312
+ w("");
313
+
314
+ const sorted = [...result.findings].sort((a, b) => {
315
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
316
+ return (order[a.severity] ?? 5) - (order[b.severity] ?? 5);
317
+ });
318
+
319
+ for (const f of sorted) {
320
+ const colorFn = SEV_COLOR[f.severity] || c.dim;
321
+ const label = SEV_LABEL[f.severity] || " ";
322
+ w(` ${colorFn(label)} ${f.title}`);
323
+ if (f.url) w(` ${" ".repeat(6)} ${c.dim(f.url)}${f.status ? c.dim(` \u2192 ${f.status}`) : ""}`);
324
+ if (f.description && opts.verbose) w(` ${" ".repeat(6)} ${c.dim(f.description)}`);
325
+ }
326
+ w("");
327
+ } else if (result.findings) {
328
+ w(` ${c.dim(line())}`);
329
+ w(` ${c.green("No findings")} \u2014 looking good.`);
330
+ w("");
331
+ }
332
+
333
+ // Compliance summary
334
+ if (result.compliance) {
335
+ const s = result.compliance.summary;
336
+ w(` ${c.dim(line())}`);
337
+ w(` ${c.bold("Compliance")} ${c.dim(result.compliance.name)}`);
338
+ w("");
339
+ w(` Coverage ${c.bold(`${s.coveredThisScan}/${s.testable}`)} testable Article 21 measures exercised this scan`);
340
+ w(` ${c.dim("Technical evidence toward the duty of care, not a certification. Full mapping in the Markdown report.")}`);
341
+ w("");
342
+ }
343
+
344
+ // Summary
345
+ w(` ${c.dim(line())}`);
346
+ printSummaryLine(result, w);
347
+ w("");
348
+ }
349
+
350
+ function printSummaryLine(result, w) {
351
+ const findings = result.findings || [];
352
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
353
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
354
+
355
+ const parts = [];
356
+ if (counts.critical) parts.push(c.bgRed(` ${counts.critical} critical `));
357
+ if (counts.high) parts.push(c.red(`${counts.high} high`));
358
+ if (counts.medium) parts.push(c.yellow(`${counts.medium} medium`));
359
+ if (counts.low) parts.push(c.cyan(`${counts.low} low`));
360
+ if (counts.info) parts.push(c.dim(`${counts.info} info`));
361
+
362
+ if (parts.length === 0) {
363
+ w(` ${c.green("\u2713")} Clean scan \u2014 no issues found`);
364
+ } else {
365
+ w(` ${parts.join(c.dim(" \u00b7 "))}`);
366
+ }
367
+ }
368
+
369
+ // ── Findings-only output (--quiet) ───────────────────────────────────────
370
+
371
+ export function printFindings(findings) {
372
+ if (!findings || findings.length === 0) return;
373
+ const sorted = [...findings].sort((a, b) => {
374
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
375
+ return (order[a.severity] ?? 5) - (order[b.severity] ?? 5);
376
+ });
377
+ for (const f of sorted) {
378
+ const label = (SEV_LABEL[f.severity] || "INFO").trim();
379
+ console.log(`[${label}] ${f.title}${f.url ? ` (${f.url})` : ""}`);
380
+ }
381
+ }
382
+
383
+ // ── JSON export ──────────────────────────────────────────────────────────
384
+
385
+ export function writeJsonReport(result, filePath) {
386
+ mkdirSync(dirname(filePath), { recursive: true });
387
+ writeFileSync(filePath, JSON.stringify(result, null, 2) + "\n");
388
+ }
389
+
390
+ // ── SARIF export (GitHub Security tab integration) ───────────────────────
391
+
392
+ const SARIF_SEV_MAP = {
393
+ critical: "error",
394
+ high: "error",
395
+ medium: "warning",
396
+ low: "note",
397
+ info: "note",
398
+ };
399
+
400
+ /**
401
+ * Convert a finding location (target URL or path) into a SARIF-safe relative
402
+ * URI. GitHub Code Scanning rejects absolute http(s) URIs ("scheme http did
403
+ * not match checkout scheme file"), so strip the scheme and port and keep a
404
+ * host/path string. The full original URL is preserved in properties.url.
405
+ */
406
+ function toSarifUri(location) {
407
+ if (!location) return undefined;
408
+ try {
409
+ const u = new URL(location);
410
+ const path = `${u.hostname}${u.pathname}`.replace(/\/+$/, "").replace(/^\/+/, "");
411
+ return path || u.hostname;
412
+ } catch {
413
+ return String(location).replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^\/+/, "");
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Generate SARIF 2.1.0 report from scan results.
419
+ * SARIF = Static Analysis Results Interchange Format
420
+ * Uploads to GitHub via: gh api repos/{owner}/{repo}/code-scanning/sarifs
421
+ */
422
+ export function writeSarifReport(result, filePath) {
423
+ const rules = new Map();
424
+ const results = [];
425
+
426
+ for (const f of result.findings || []) {
427
+ const ruleId = f.templateId || f.category || "unknown";
428
+
429
+ if (!rules.has(ruleId)) {
430
+ rules.set(ruleId, {
431
+ id: ruleId,
432
+ name: f.title,
433
+ shortDescription: { text: f.title },
434
+ fullDescription: { text: f.description || f.title },
435
+ defaultConfiguration: {
436
+ level: SARIF_SEV_MAP[f.severity] || "note",
437
+ },
438
+ properties: {
439
+ "security-severity": sevToScore(f.severity),
440
+ tags: ["security", f.category || "general", ...(f.wstg || resolveWstgTags(f))].filter(Boolean),
441
+ },
442
+ });
443
+ }
444
+
445
+ results.push({
446
+ ruleId,
447
+ level: SARIF_SEV_MAP[f.severity] || "note",
448
+ message: {
449
+ text: `${f.title}${f.description ? `: ${f.description}` : ""}`,
450
+ },
451
+ locations: f.url ? [{
452
+ physicalLocation: {
453
+ artifactLocation: {
454
+ uri: toSarifUri(f.url),
455
+ },
456
+ },
457
+ }] : [],
458
+ properties: {
459
+ severity: f.severity,
460
+ source: f.source,
461
+ url: f.url,
462
+ httpStatus: f.status,
463
+ wstg: f.wstg || resolveWstgTags(f),
464
+ },
465
+ });
466
+ }
467
+
468
+ const sarif = {
469
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
470
+ version: "2.1.0",
471
+ runs: [{
472
+ tool: {
473
+ driver: {
474
+ name: "penthera",
475
+ version: PKG_VERSION,
476
+ informationUri: "https://github.com/danoszz/penthera",
477
+ rules: [...rules.values()],
478
+ },
479
+ },
480
+ results,
481
+ invocations: [{
482
+ executionSuccessful: true,
483
+ startTimeUtc: result.timestamp,
484
+ endTimeUtc: new Date().toISOString(),
485
+ }],
486
+ }],
487
+ };
488
+
489
+ mkdirSync(dirname(filePath), { recursive: true });
490
+ writeFileSync(filePath, JSON.stringify(sarif, null, 2) + "\n");
491
+ }
492
+
493
+ function sevToScore(severity) {
494
+ switch (severity) {
495
+ case "critical": return "9.5";
496
+ case "high": return "7.5";
497
+ case "medium": return "5.0";
498
+ case "low": return "2.5";
499
+ default: return "1.0";
500
+ }
501
+ }
502
+
503
+ // ── Progress logger ──────────────────────────────────────────────────────
504
+
505
+ export function createProgress(quiet) {
506
+ if (quiet) return () => {};
507
+ return (msg) => process.stderr.write(` ${c.dim("\u25b8")} ${msg}\n`);
508
+ }
509
+
510
+ export function printError(msg) {
511
+ process.stderr.write(`\n ${c.red("error")} ${msg}\n\n`);
512
+ }