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,219 @@
1
+ /**
2
+ * Remediation knowledge base + action-plan builder.
3
+ *
4
+ * Turns raw findings into a prioritised, owner-actionable plan: what to do, why
5
+ * it matters, how to fix it, rough effort, and whether it's a code change or an
6
+ * owner action (rotate a secret, renew a cert, change DNS) that a tool must not
7
+ * do for you. This is the structured data the report renders and that the Agent
8
+ * Skill uses to draft questionnaire answers and remediation documents.
9
+ */
10
+
11
+ // Each rule matches a finding and yields remediation guidance. First match wins,
12
+ // so order specific rules (by title) before broad ones (by category).
13
+ const RULES = [
14
+ {
15
+ id: "email-spf",
16
+ match: (f) => f.category === "email-auth" && /SPF/i.test(f.title),
17
+ action: "Publish (or tighten) an SPF record",
18
+ why: "Without a restrictive SPF record, anyone can send email that appears to come from your domain, a common phishing and business-email-compromise vector.",
19
+ fix: "Add a DNS TXT record at the root: `v=spf1 include:<your-mail-provider> -all`. End with `-all` (hard fail), not `~all` or `+all`.",
20
+ effort: "quick",
21
+ ownerAction: true, // DNS change
22
+ },
23
+ {
24
+ id: "email-dmarc",
25
+ match: (f) => f.category === "email-auth" && /DMARC/i.test(f.title),
26
+ action: "Publish and enforce a DMARC policy",
27
+ why: "DMARC tells receivers what to do with mail that fails SPF/DKIM and gives you reporting. `p=none` only monitors, it does not block spoofing.",
28
+ fix: "Add a TXT record at `_dmarc.<domain>`: start with `v=DMARC1; p=none; rua=mailto:you@domain` to gather reports, then move to `p=quarantine` and finally `p=reject` once your legitimate senders are aligned.",
29
+ effort: "moderate",
30
+ ownerAction: true,
31
+ },
32
+ {
33
+ id: "email-dkim",
34
+ match: (f) => f.category === "email-auth" && /DKIM/i.test(f.title),
35
+ action: "Confirm DKIM is signing outbound mail",
36
+ why: "DKIM cryptographically signs your mail so receivers can verify it wasn't forged or altered. It's the third leg of email authentication with SPF and DMARC.",
37
+ fix: "Enable DKIM in your email provider and publish the provider's public key at `<selector>._domainkey.<domain>`. Verify a signed test message passes DKIM.",
38
+ effort: "moderate",
39
+ ownerAction: true,
40
+ },
41
+ {
42
+ id: "header-csp",
43
+ match: (f) => /content-security-policy/i.test(f.title),
44
+ action: "Add a Content-Security-Policy header",
45
+ why: "CSP is the strongest defence-in-depth against cross-site scripting and data injection, it limits what scripts and resources the browser will load.",
46
+ fix: "Start in report-only mode to avoid breakage, then enforce. On Vercel/Next.js set it in `next.config.js` headers or `vercel.json`; on Express use `helmet`'s `contentSecurityPolicy`. Begin with `default-src 'self'` and add sources as needed.",
47
+ effort: "moderate",
48
+ ownerAction: false,
49
+ },
50
+ {
51
+ id: "header-generic",
52
+ match: (f) => f.category === "headers" || /x-frame-options|x-content-type-options|referrer-policy|permissions-policy|strict-transport-security|hsts/i.test(f.title),
53
+ action: "Add the missing security response headers",
54
+ why: "Headers like X-Frame-Options (clickjacking), X-Content-Type-Options (MIME sniffing), Referrer-Policy, Permissions-Policy, and HSTS are low-effort, high-value hardening.",
55
+ fix: "Set them once at the edge/framework layer: `helmet()` on Express, a `headers()` entry in `next.config.js`, or a `headers` block in `vercel.json`. Include `Strict-Transport-Security: max-age=63072000; includeSubDomains`.",
56
+ effort: "quick",
57
+ ownerAction: false,
58
+ },
59
+ {
60
+ id: "tls",
61
+ match: (f) => f.category === "tls" || /TLS|certificate|cipher|expir/i.test(f.title),
62
+ action: "Fix the TLS configuration",
63
+ why: "Weak protocols/ciphers or an expiring certificate undermine transport security and can break trust or enable downgrade attacks.",
64
+ fix: "Renew the certificate before expiry (automate with Let's Encrypt/ACME), disable TLS < 1.2, and prefer AEAD ciphers. Renewal is an operations action, not a code change.",
65
+ effort: "moderate",
66
+ ownerAction: true, // cert renewal
67
+ },
68
+ {
69
+ id: "secrets",
70
+ match: (f) => f.category === "secrets",
71
+ action: "Rotate the exposed secret and move it to a secret store",
72
+ why: "A hardcoded credential in source is compromised the moment the repo is shared or leaked; attackers scan public and private repos for exactly this.",
73
+ fix: "Treat the secret as burned: rotate/revoke it at the provider first, then remove it from source, load it from an environment variable or secret manager, and purge it from git history. Rotation is an owner action a tool must not perform for you.",
74
+ effort: "moderate",
75
+ ownerAction: true,
76
+ },
77
+ {
78
+ id: "injection",
79
+ match: (f) => ["sqli", "ssti", "ssrf", "xss", "cmdi", "injection", "open-redirect"].includes(f.category),
80
+ action: "Fix the injection / input-handling flaw",
81
+ why: "Injection flaws (SQLi, SSTI, SSRF, XSS, command injection, open redirect) let an attacker run code, read data, or redirect users. These are among the highest-impact web vulnerabilities.",
82
+ fix: "Never build queries/commands/markup from raw input: use parameterised queries, context-aware output encoding, an allow-list for redirect targets and outbound URLs (SSRF), and a sandboxed template engine. Validate and canonicalise all user input.",
83
+ effort: "involved",
84
+ ownerAction: false,
85
+ },
86
+ {
87
+ id: "auth",
88
+ match: (f) => f.category === "auth" || f.category === "auth-bypass",
89
+ action: "Harden authentication and access control",
90
+ why: "Broken auth or object-level access control (IDOR/BOLA, weak login, OAuth open redirect) lets attackers reach data or actions that aren't theirs.",
91
+ fix: "Enforce authorization server-side on every request (never trust the client), check that the authenticated user owns the object being accessed, validate OAuth `redirect_uri` against an allow-list, and add rate limiting and lockout on login.",
92
+ effort: "involved",
93
+ ownerAction: false,
94
+ },
95
+ {
96
+ id: "rate-limiting",
97
+ match: (f) => f.category === "rate-limiting" || /rate limit/i.test(f.title),
98
+ action: "Add rate limiting to sensitive endpoints",
99
+ why: "Without rate limiting, login and expensive endpoints are open to brute force, credential stuffing, and abuse/cost attacks.",
100
+ fix: "Apply per-IP and per-account rate limits on auth and costly routes (e.g. `express-rate-limit`, an edge/WAF rule, or your platform's built-in limiter). Return 429 with backoff.",
101
+ effort: "moderate",
102
+ ownerAction: false,
103
+ },
104
+ {
105
+ id: "cve",
106
+ match: (f) => f.category === "cve" || f.category === "js-vulnerability",
107
+ action: "Update the vulnerable dependency",
108
+ why: "A dependency with a known CVE is a documented, exploitable path in, attackers scan for exactly these versions.",
109
+ fix: "Upgrade to a patched version, run `npm audit fix` (or your ecosystem's equivalent), and add dependency scanning to CI so regressions are caught.",
110
+ effort: "quick",
111
+ ownerAction: false,
112
+ },
113
+ {
114
+ id: "cors",
115
+ match: (f) => f.category === "cors",
116
+ action: "Restrict CORS to explicit origins",
117
+ why: "A permissive CORS policy (reflecting arbitrary or `null` origins) can let malicious sites read authenticated responses.",
118
+ fix: "Replace origin reflection with an explicit allow-list of trusted origins, and never combine `Access-Control-Allow-Origin: *` with credentials.",
119
+ effort: "quick",
120
+ ownerAction: false,
121
+ },
122
+ {
123
+ id: "cookie",
124
+ match: (f) => f.category === "cookie",
125
+ action: "Set secure cookie flags",
126
+ why: "Session cookies without HttpOnly, Secure, and SameSite are exposed to theft via XSS and CSRF.",
127
+ fix: "Set `HttpOnly`, `Secure`, and `SameSite=Lax` (or `Strict`) on session/auth cookies. Most frameworks set these via the session middleware config.",
128
+ effort: "quick",
129
+ ownerAction: false,
130
+ },
131
+ {
132
+ id: "exposure",
133
+ match: (f) => f.category === "exposure" || f.category === "disclosure" || f.category === "config",
134
+ action: "Remove the exposed file or interface from production",
135
+ why: "Exposed configs, docs, admin interfaces, or backup files hand attackers a map of your system and sometimes credentials.",
136
+ fix: "Block or remove the path in production (API docs, `.env`, actuator/status endpoints, source maps). Serve them only in non-production or behind authentication.",
137
+ effort: "quick",
138
+ ownerAction: false,
139
+ },
140
+ ];
141
+
142
+ const FALLBACK = {
143
+ id: "review",
144
+ action: "Review and remediate this finding",
145
+ why: "This finding needs a manual review to determine impact and the right fix.",
146
+ fix: "Confirm the finding, assess exploitability in your context, apply the appropriate fix, and re-scan to verify.",
147
+ effort: "moderate",
148
+ ownerAction: false,
149
+ };
150
+
151
+ const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
152
+ const EFFORT_ORDER = { quick: 0, moderate: 1, involved: 2 };
153
+
154
+ function ruleFor(finding) {
155
+ return RULES.find((r) => r.match(finding)) || FALLBACK;
156
+ }
157
+
158
+ /**
159
+ * Build a prioritised action plan from findings. Groups findings by remediation,
160
+ * carries the highest severity in each group, and sorts by severity then effort.
161
+ */
162
+ export function buildActionPlan(findings) {
163
+ const groups = new Map();
164
+ for (const f of findings || []) {
165
+ if (f.severity === "info") continue; // info items aren't action items
166
+ const rule = ruleFor(f);
167
+ if (!groups.has(rule.id)) {
168
+ groups.set(rule.id, {
169
+ id: rule.id,
170
+ action: rule.action,
171
+ why: rule.why,
172
+ fix: rule.fix,
173
+ effort: rule.effort,
174
+ ownerAction: rule.ownerAction,
175
+ severity: f.severity,
176
+ findings: [],
177
+ });
178
+ }
179
+ const g = groups.get(rule.id);
180
+ if ((SEV_ORDER[f.severity] ?? 5) < (SEV_ORDER[g.severity] ?? 5)) g.severity = f.severity;
181
+ g.findings.push({ title: f.title, url: f.url || null, severity: f.severity });
182
+ }
183
+
184
+ return [...groups.values()].sort((a, b) => {
185
+ const s = (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5);
186
+ if (s !== 0) return s;
187
+ return (EFFORT_ORDER[a.effort] ?? 3) - (EFFORT_ORDER[b.effort] ?? 3);
188
+ });
189
+ }
190
+
191
+ const SEV_TAG = { critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW", info: "INFO" };
192
+
193
+ /** Render an action plan as a Markdown section. */
194
+ export function formatActionPlanMarkdown(plan) {
195
+ if (!plan || plan.length === 0) return "";
196
+ const findingCount = plan.reduce((n, a) => n + a.findings.length, 0);
197
+ const L = [];
198
+ L.push("## Action plan");
199
+ L.push("");
200
+ L.push(`Prioritised fixes for the owner, **${plan.length}** action${plan.length > 1 ? "s" : ""} covering **${findingCount}** finding${findingCount > 1 ? "s" : ""}, highest severity first.`);
201
+ L.push("");
202
+ plan.forEach((a, i) => {
203
+ const tag = SEV_TAG[a.severity] || "";
204
+ const kind = a.ownerAction ? " · owner action" : "";
205
+ L.push(`### ${i + 1}. ${a.action}`);
206
+ L.push("");
207
+ L.push(`**${tag}** · ${a.effort}${kind} · resolves ${a.findings.length} finding${a.findings.length > 1 ? "s" : ""}`);
208
+ L.push("");
209
+ L.push(`**Why:** ${a.why}`);
210
+ L.push("");
211
+ L.push(`**Fix:** ${a.fix}`);
212
+ L.push("");
213
+ if (a.ownerAction) {
214
+ L.push("> This is an owner action (e.g. rotate a secret, renew a certificate, change DNS). Penthera flags it but will not do it for you.");
215
+ L.push("");
216
+ }
217
+ });
218
+ return L.join("\n");
219
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Retire.js Integration, Client-Side JS Library Vulnerability Detection
3
+ *
4
+ * Fetches the Retire.js vulnerability database (open JSON, maintained by
5
+ * the Retire.js community on GitHub) and checks discovered JS files.
6
+ *
7
+ * Detection cascade (same 4-layer approach as Retire.js itself):
8
+ * 1. Filename/URL pattern matching (jquery-3.5.1.min.js)
9
+ * 2. File content pattern matching (/*! jQuery v3.5.1 *​/)
10
+ * 3. Hash matching for minified files (sha1)
11
+ * 4. URI path matching (/3.5.1/jquery.min.js)
12
+ *
13
+ * Database source:
14
+ * https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json
15
+ */
16
+
17
+ const DB_URL =
18
+ "https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json";
19
+ const DB_CACHE_MS = 3600_000; // 1 hour
20
+
21
+ let cachedDb = null;
22
+ let cachedAt = 0;
23
+
24
+ async function safeFetch(url, timeout = 15_000) {
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), timeout);
27
+ try {
28
+ return await fetch(url, { signal: controller.signal });
29
+ } catch {
30
+ return null;
31
+ } finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+
36
+ // ── Database loading ─────────────────────────────────────────────────────
37
+
38
+ async function loadDb() {
39
+ if (cachedDb && Date.now() - cachedAt < DB_CACHE_MS) return cachedDb;
40
+
41
+ const res = await safeFetch(DB_URL);
42
+ if (!res || !res.ok) return null;
43
+
44
+ try {
45
+ cachedDb = await res.json();
46
+ cachedAt = Date.now();
47
+ return cachedDb;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ // ── Version comparison ───────────────────────────────────────────────────
54
+
55
+ function parseVersion(v) {
56
+ return String(v).split(/[.\-]/).map((p) => {
57
+ const n = parseInt(p, 10);
58
+ return isNaN(n) ? 0 : n;
59
+ });
60
+ }
61
+
62
+ function isBelow(detected, threshold) {
63
+ const a = parseVersion(detected);
64
+ const b = parseVersion(threshold);
65
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
66
+ const va = a[i] || 0;
67
+ const vb = b[i] || 0;
68
+ if (va < vb) return true;
69
+ if (va > vb) return false;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function isAtOrAbove(detected, threshold) {
75
+ return !isBelow(detected, threshold);
76
+ }
77
+
78
+ function isVulnerable(version, vuln) {
79
+ if (vuln.below && !isBelow(version, vuln.below)) return false;
80
+ if (vuln.atOrAbove && !isAtOrAbove(version, vuln.atOrAbove)) return false;
81
+ return true;
82
+ }
83
+
84
+ // ── Extractor engine ─────────────────────────────────────────────────────
85
+
86
+ const VERSION_PLACEHOLDER = /§§version§§/g;
87
+
88
+ function extractVersion(text, patterns) {
89
+ if (!patterns || !Array.isArray(patterns)) return null;
90
+ for (const pattern of patterns) {
91
+ const regexStr = pattern.replace(VERSION_PLACEHOLDER, "([\\d][\\d.a-z\\-]+)");
92
+ try {
93
+ const match = text.match(new RegExp(regexStr, "i"));
94
+ if (match && match[1]) return match[1];
95
+ } catch {
96
+ // Invalid regex in DB entry, skip
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // ── Script discovery ─────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Extract <script src="..."> URLs and inline script blocks from HTML.
106
+ */
107
+ export function extractScripts(html, baseUrl) {
108
+ const scripts = { external: [], inline: [] };
109
+
110
+ // External scripts
111
+ const srcRe = /<script[^>]+src=["']([^"']+)["']/gi;
112
+ let match;
113
+ while ((match = srcRe.exec(html))) {
114
+ let src = match[1];
115
+ // Resolve relative URLs
116
+ if (src.startsWith("//")) src = "https:" + src;
117
+ else if (src.startsWith("/")) src = baseUrl + src;
118
+ else if (!src.startsWith("http")) src = baseUrl + "/" + src;
119
+ scripts.external.push(src);
120
+ }
121
+
122
+ // Inline scripts (for version detection)
123
+ const inlineRe = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
124
+ while ((match = inlineRe.exec(html))) {
125
+ if (match[1].trim().length > 10) {
126
+ scripts.inline.push(match[1]);
127
+ }
128
+ }
129
+
130
+ return scripts;
131
+ }
132
+
133
+ // ── Main scanner ─────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Scan a target for vulnerable JavaScript libraries.
137
+ *
138
+ * @param {string} baseUrl - Target base URL
139
+ * @param {object} opts - { onPhase, timeout, maxScripts }
140
+ * @returns {{ libraries: object[], findings: object[] }}
141
+ */
142
+ export async function scanJsLibraries(baseUrl, opts = {}) {
143
+ const progress = opts.onPhase || (() => {});
144
+ const maxScripts = opts.maxScripts || 20;
145
+ const timeout = opts.timeout || 8_000;
146
+ const result = { libraries: [], findings: [] };
147
+
148
+ // Load Retire.js database
149
+ progress("Fetching Retire.js vulnerability database...");
150
+ const db = await loadDb();
151
+ if (!db) {
152
+ return result; // Can't check without DB
153
+ }
154
+
155
+ // Fetch the target page HTML
156
+ const res = await safeFetch(baseUrl, timeout);
157
+ if (!res || !res.ok) return result;
158
+ const html = await res.text().catch(() => "");
159
+
160
+ // Extract script URLs and inline scripts
161
+ const scripts = extractScripts(html, baseUrl);
162
+
163
+ // Check each library in the DB
164
+ for (const [libName, libData] of Object.entries(db)) {
165
+ if (!libData.extractors) continue;
166
+ let detectedVersion = null;
167
+
168
+ // 1. Check against inline script content + HTML
169
+ if (libData.extractors.filecontent) {
170
+ detectedVersion = extractVersion(html, libData.extractors.filecontent);
171
+ }
172
+
173
+ // 2. Check against external script URLs (filename + uri patterns)
174
+ if (!detectedVersion) {
175
+ for (const scriptUrl of scripts.external) {
176
+ if (libData.extractors.filename) {
177
+ const filename = scriptUrl.split("/").pop().split("?")[0];
178
+ detectedVersion = extractVersion(filename, libData.extractors.filename);
179
+ if (detectedVersion) break;
180
+ }
181
+ if (libData.extractors.uri) {
182
+ detectedVersion = extractVersion(scriptUrl, libData.extractors.uri);
183
+ if (detectedVersion) break;
184
+ }
185
+ }
186
+ }
187
+
188
+ // 3. If found in URL but no version, try fetching the file content
189
+ if (!detectedVersion && libData.extractors.filecontent) {
190
+ for (const scriptUrl of scripts.external.slice(0, maxScripts)) {
191
+ // Only fetch if URL looks like it could be this library
192
+ const lower = scriptUrl.toLowerCase();
193
+ if (!lower.includes(libName.toLowerCase().replace(/\./g, ""))) continue;
194
+
195
+ const jsRes = await safeFetch(scriptUrl, 5_000);
196
+ if (!jsRes || !jsRes.ok) continue;
197
+ const jsContent = await jsRes.text().catch(() => "");
198
+ if (jsContent.length > 0) {
199
+ detectedVersion = extractVersion(jsContent, libData.extractors.filecontent);
200
+ if (detectedVersion) break;
201
+ }
202
+ }
203
+ }
204
+
205
+ if (!detectedVersion) continue;
206
+
207
+ // Record the detected library
208
+ const lib = { name: libName, version: detectedVersion, vulnerabilities: [] };
209
+
210
+ // Check against known vulnerabilities
211
+ for (const vuln of libData.vulnerabilities || []) {
212
+ if (isVulnerable(detectedVersion, vuln)) {
213
+ const cves = vuln.identifiers?.CVE || [];
214
+ const severity = vuln.severity || "medium";
215
+ lib.vulnerabilities.push({
216
+ severity,
217
+ below: vuln.below,
218
+ cves,
219
+ info: vuln.info || [],
220
+ });
221
+
222
+ result.findings.push({
223
+ severity,
224
+ title: `Vulnerable JS library: ${libName} ${detectedVersion}`,
225
+ description: cves.length > 0
226
+ ? `${cves.join(", ")}, upgrade to ${vuln.below || "latest"}`
227
+ : `Known vulnerability, upgrade to ${vuln.below || "latest"}`,
228
+ url: baseUrl,
229
+ category: "js-vulnerability",
230
+ source: "retirejs",
231
+ });
232
+ }
233
+ }
234
+
235
+ result.libraries.push(lib);
236
+ }
237
+
238
+ return result;
239
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Supplier passive rating (NIS2 measure d, supply-chain security).
3
+ *
4
+ * NIS2 pulls smaller suppliers into scope through supply-chain risk assessment.
5
+ * This lets a company point Penthera's PASSIVE checks at their own suppliers'
6
+ * public domains and get a red/amber/green posture per supplier.
7
+ *
8
+ * STRICTLY PASSIVE and public-data-only, equivalent to visiting the public
9
+ * website and looking up public DNS. It performs exactly three non-intrusive
10
+ * actions per domain:
11
+ * 1. one HTTPS GET of the homepage (security headers),
12
+ * 2. a TLS handshake to read the certificate (protocol/expiry),
13
+ * 3. public DNS lookups for SPF/DMARC/MX (email authentication).
14
+ * No endpoint discovery, no auth probes, no injection, no fuzzing. This is the
15
+ * only third-party-facing feature, and it is deliberately kept passive.
16
+ */
17
+ import { checkTls } from "./tls.js";
18
+ import { auditSecurityHeaders } from "./blackbox/headers.js";
19
+ import { probeEmailDns } from "./blackbox/email-dns.js";
20
+ import { safeFetch } from "../src/utils/http.js";
21
+
22
+ /** Parse a domain from a line/entry: strip scheme, path, and whitespace. */
23
+ export function parseDomain(entry) {
24
+ const s = String(entry).trim();
25
+ if (!s || s.startsWith("#")) return null;
26
+ return s.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").replace(/:\d+$/, "").trim() || null;
27
+ }
28
+
29
+ /** Parse a supplier list: newline- or comma-separated domains, # comments ok. */
30
+ export function parseSupplierList(text) {
31
+ return [...new Set(
32
+ String(text).split(/[\n,]+/).map(parseDomain).filter(Boolean),
33
+ )];
34
+ }
35
+
36
+ const PENALTY = { critical: 40, high: 25, medium: 12, low: 5, info: 1 };
37
+
38
+ /** Score and RAG-rate a supplier from its passive findings. */
39
+ export function scoreSupplier(result) {
40
+ if (!result.reachable) return { score: null, rating: "unknown" };
41
+ let penalty = 0;
42
+ for (const f of result.findings) penalty += PENALTY[f.severity] || 0;
43
+ const score = Math.max(0, Math.min(100, 100 - penalty));
44
+ const rating = score >= 80 ? "green" : score >= 50 ? "amber" : "red";
45
+ return { score, rating };
46
+ }
47
+
48
+ /**
49
+ * Rate one supplier domain with passive checks only.
50
+ */
51
+ export async function rateSupplier(domain, opts = {}) {
52
+ const timeout = opts.timeout || 8000;
53
+ const httpsUrl = `https://${domain}`;
54
+ const result = { domain, reachable: false, tls: null, email: null, findings: [] };
55
+
56
+ // 1. Passive homepage GET (follow redirects to reach the real homepage headers)
57
+ let res = null;
58
+ try {
59
+ res = await safeFetch(httpsUrl, { timeout, redirect: "follow" });
60
+ } catch { /* unreachable */ }
61
+ result.reachable = !!res;
62
+
63
+ // 2. Security headers from the homepage response
64
+ if (res) {
65
+ const headerMap = Object.fromEntries(res.headers.entries());
66
+ result.findings.push(...auditSecurityHeaders(headerMap, httpsUrl, { local: false }));
67
+ }
68
+
69
+ // 3. TLS certificate / protocol (passive handshake)
70
+ try {
71
+ const tls = await checkTls(domain, 443);
72
+ result.tls = { protocol: tls.protocol, valid: tls.valid, daysUntilExpiry: tls.daysUntilExpiry };
73
+ for (const f of tls.findings || []) {
74
+ result.findings.push({ ...f, category: "tls", source: "supplier-tls" });
75
+ }
76
+ } catch { /* no TLS */ }
77
+
78
+ // 4. Email authentication (public DNS)
79
+ try {
80
+ const email = await probeEmailDns(domain);
81
+ result.email = { spf: !!email.spf, dmarc: !!email.dmarc, dmarcPolicy: email.dmarcPolicy, mxCount: email.mxCount };
82
+ result.findings.push(...email.findings);
83
+ } catch { /* no DNS */ }
84
+
85
+ Object.assign(result, scoreSupplier(result));
86
+ result.keyIssues = result.findings
87
+ .filter((f) => f.severity === "critical" || f.severity === "high" || f.severity === "medium")
88
+ .map((f) => f.title);
89
+ return result;
90
+ }
91
+
92
+ /** Rate a list of supplier domains (small concurrency, passive). */
93
+ export async function rateSuppliers(domains, opts = {}) {
94
+ const concurrency = opts.concurrency || 5;
95
+ const results = [];
96
+ for (let i = 0; i < domains.length; i += concurrency) {
97
+ const batch = domains.slice(i, i + concurrency);
98
+ const settled = await Promise.all(batch.map((d) => rateSupplier(d, opts).catch(() => ({
99
+ domain: d, reachable: false, findings: [], score: null, rating: "unknown", keyIssues: [],
100
+ }))));
101
+ results.push(...settled);
102
+ }
103
+ const summary = { green: 0, amber: 0, red: 0, unknown: 0 };
104
+ for (const r of results) summary[r.rating] = (summary[r.rating] || 0) + 1;
105
+ return { generated_at: opts.generatedAt || null, count: results.length, summary, suppliers: results };
106
+ }
107
+
108
+ const RATING_LABEL = { green: "🟢 Green", amber: "🟡 Amber", red: "🔴 Red", unknown: "⚪ Unknown" };
109
+
110
+ /** Render supplier ratings as Markdown. */
111
+ export function formatSuppliersMarkdown(report) {
112
+ if (!report) return "";
113
+ const L = [];
114
+ L.push("# Supplier security ratings");
115
+ L.push("");
116
+ L.push("> **Passive, public-data-only assessment**, equivalent to visiting each supplier's public website and looking up public DNS. No intrusive testing was performed. This is a screening aid for supply-chain risk (NIS2 measure d), **not** an authorised penetration test of the supplier and **not** a certification of them.");
117
+ L.push("");
118
+ const s = report.summary;
119
+ L.push(`**${report.count}** suppliers: ${s.green} green · ${s.amber} amber · ${s.red} red · ${s.unknown} unknown.`);
120
+ L.push("");
121
+ L.push("| Supplier | Rating | Score | Key issues |");
122
+ L.push("|----------|--------|-------|-----------|");
123
+ for (const r of report.suppliers) {
124
+ const issues = r.keyIssues?.length ? r.keyIssues.slice(0, 4).join("; ") : (r.rating === "unknown" ? "unreachable" : "none found (passive)");
125
+ L.push(`| ${r.domain} | ${RATING_LABEL[r.rating] || r.rating} | ${r.score ?? "-"} | ${issues} |`);
126
+ }
127
+ L.push("");
128
+ L.push("Methodology: one HTTPS homepage GET (security headers), a TLS handshake (certificate/protocol), and public DNS lookups (SPF/DMARC/MX). Ratings: green ≥ 80, amber 50-79, red < 50; unknown = unreachable.");
129
+ L.push("");
130
+ return L.join("\n");
131
+ }