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.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +561 -0
- package/SECURITY.md +31 -0
- package/bin/penthera.js +3 -0
- package/bin/scan.js +11 -0
- package/docs/index.md +60 -0
- package/docs/owasp-wstg-coverage.md +79 -0
- package/lib/adaptive-probes.js +334 -0
- package/lib/attack-surface.js +149 -0
- package/lib/audit-loop.js +170 -0
- package/lib/blackbox/adaptive-scan.js +137 -0
- package/lib/blackbox/client-auth.js +60 -0
- package/lib/blackbox/email-dns.js +135 -0
- package/lib/blackbox/headers.js +91 -0
- package/lib/blackbox/idor.js +113 -0
- package/lib/blackbox/jwt.js +73 -0
- package/lib/blackbox/oauth.js +50 -0
- package/lib/blackbox/openapi.js +227 -0
- package/lib/compliance/incident.js +169 -0
- package/lib/compliance/index.js +163 -0
- package/lib/compliance/policy-pack.js +293 -0
- package/lib/compliance/questionnaire.js +113 -0
- package/lib/compliance/readiness.js +229 -0
- package/lib/compliance/self-assessment.js +66 -0
- package/lib/crawler.js +257 -0
- package/lib/fuzzer.js +233 -0
- package/lib/injections.js +676 -0
- package/lib/jurisdictions/nl.json +48 -0
- package/lib/knowledge-graph.js +232 -0
- package/lib/machine.js +814 -0
- package/lib/nuclei-loader.js +283 -0
- package/lib/owasp-wstg.js +204 -0
- package/lib/params.js +235 -0
- package/lib/plugins.js +70 -0
- package/lib/provenance.js +82 -0
- package/lib/recon.js +193 -0
- package/lib/remediation/index.js +219 -0
- package/lib/retirejs.js +239 -0
- package/lib/supply-chain.js +131 -0
- package/lib/templates.js +364 -0
- package/lib/tls.js +145 -0
- package/lib/whitebox/frameworks.js +139 -0
- package/lib/whitebox/sbom.js +144 -0
- package/lib/whitebox/secrets.js +111 -0
- package/package.json +76 -0
- package/pentest.config.example.js +82 -0
- package/scripts/package-skill.sh +16 -0
- package/skills/penthera/SKILL.md +272 -0
- package/skills/penthera/references/authorization.md +36 -0
- package/skills/penthera/references/output-and-ci.md +109 -0
- package/skills/penthera/references/profiles-and-flags.md +74 -0
- package/skills/penthera/references/remediation.md +267 -0
- package/skills/penthera/references/reporting.md +113 -0
- package/skills/penthera/references/secure-defaults.md +41 -0
- package/skills/penthera/references/troubleshooting.md +100 -0
- package/skills/penthera/scripts/preflight.sh +66 -0
- package/skills/penthera/scripts/validate-report.mjs +53 -0
- package/skills/penthera/scripts/validate-report.sh +8 -0
- package/src/cli/ansi.js +70 -0
- package/src/cli/baseline.js +37 -0
- package/src/cli/merge-results.js +71 -0
- package/src/cli/onboarding.js +216 -0
- package/src/cli/profiles.js +53 -0
- package/src/cli/prompt.js +64 -0
- package/src/cli/run-scan.js +152 -0
- package/src/cli.js +536 -0
- package/src/report/markdown.js +182 -0
- package/src/reporter.js +512 -0
- package/src/scan-repo.js +205 -0
- package/src/scan-url.js +563 -0
- package/src/utils/auth.js +13 -0
- package/src/utils/http.js +38 -0
- package/src/utils/url.js +54 -0
package/lib/templates.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML Template Engine (inspired by Nuclei)
|
|
3
|
+
*
|
|
4
|
+
* Run declarative vulnerability checks defined in YAML files.
|
|
5
|
+
* Compatible with a subset of Nuclei's template format.
|
|
6
|
+
*/
|
|
7
|
+
import { joinUrl } from "../src/utils/url.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a single template check against a target.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} baseUrl - Target URL
|
|
13
|
+
* @param {object} template - Parsed YAML template object
|
|
14
|
+
* @returns {Array<object>} Findings
|
|
15
|
+
*/
|
|
16
|
+
export async function executeTemplate(baseUrl, template) {
|
|
17
|
+
const findings = [];
|
|
18
|
+
|
|
19
|
+
for (const req of template.http || []) {
|
|
20
|
+
const url = joinUrl(baseUrl, req.path.replace("{{BaseURL}}", ""));
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const fetchOpts = {
|
|
26
|
+
method: req.method || "GET",
|
|
27
|
+
headers: req.headers || {},
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
redirect: "manual",
|
|
30
|
+
};
|
|
31
|
+
if (req.body && ["POST", "PUT", "PATCH"].includes(fetchOpts.method)) {
|
|
32
|
+
fetchOpts.body = req.body;
|
|
33
|
+
if (!fetchOpts.headers["Content-Type"]) {
|
|
34
|
+
fetchOpts.headers["Content-Type"] = "application/json";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const res = await fetch(url, fetchOpts);
|
|
39
|
+
const body = await res.text().catch(() => "");
|
|
40
|
+
const headerString = [...res.headers.entries()]
|
|
41
|
+
.map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
42
|
+
|
|
43
|
+
// Run matchers
|
|
44
|
+
const matchResults = (req.matchers || []).map(matcher =>
|
|
45
|
+
runMatcher(matcher, { status: res.status, body, headers: headerString, res }),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Evaluate matchers-condition (default: OR for single matcher, AND for multiple)
|
|
49
|
+
const condition = req["matchers-condition"] || (matchResults.length > 1 ? "and" : "or");
|
|
50
|
+
const matched = condition === "and"
|
|
51
|
+
? matchResults.every(Boolean)
|
|
52
|
+
: matchResults.some(Boolean);
|
|
53
|
+
|
|
54
|
+
if (matched && matchResults.length > 0) {
|
|
55
|
+
// Run extractors
|
|
56
|
+
const extracted = {};
|
|
57
|
+
for (const extractor of req.extractors || []) {
|
|
58
|
+
const result = runExtractor(extractor, { body, headers: headerString });
|
|
59
|
+
if (result) extracted[extractor.name || "extracted"] = result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
findings.push({
|
|
63
|
+
templateId: template.id,
|
|
64
|
+
name: template.info?.name || template.id,
|
|
65
|
+
severity: template.info?.severity || "info",
|
|
66
|
+
tags: template.info?.tags || [],
|
|
67
|
+
description: template.info?.description || "",
|
|
68
|
+
matchedUrl: url,
|
|
69
|
+
status: res.status,
|
|
70
|
+
extracted,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.name !== "AbortError") {
|
|
75
|
+
// Network error, skip
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run a single matcher against response data.
|
|
87
|
+
*/
|
|
88
|
+
function runMatcher(matcher, ctx) {
|
|
89
|
+
const part = matcher.part || "body";
|
|
90
|
+
const target = part === "header" ? ctx.headers : ctx.body;
|
|
91
|
+
|
|
92
|
+
switch (matcher.type) {
|
|
93
|
+
case "status":
|
|
94
|
+
return (matcher.status || []).includes(ctx.status);
|
|
95
|
+
|
|
96
|
+
case "word": {
|
|
97
|
+
const words = matcher.words || [];
|
|
98
|
+
const check = (w) => target.includes(w);
|
|
99
|
+
return matcher.condition === "and"
|
|
100
|
+
? words.every(check)
|
|
101
|
+
: words.some(check);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case "regex": {
|
|
105
|
+
const patterns = matcher.regex || [];
|
|
106
|
+
const check = (p) => new RegExp(p, matcher.flags || "").test(target);
|
|
107
|
+
return matcher.condition === "and"
|
|
108
|
+
? patterns.every(check)
|
|
109
|
+
: patterns.some(check);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case "negative-word": {
|
|
113
|
+
const words = matcher.words || [];
|
|
114
|
+
return words.every(w => !target.includes(w));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case "negative-regex": {
|
|
118
|
+
const patterns = matcher.regex || [];
|
|
119
|
+
return patterns.every(p => !new RegExp(p).test(target));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run a single extractor against response data.
|
|
129
|
+
*/
|
|
130
|
+
function runExtractor(extractor, ctx) {
|
|
131
|
+
const part = extractor.part || "body";
|
|
132
|
+
const target = part === "header" ? ctx.headers : ctx.body;
|
|
133
|
+
|
|
134
|
+
switch (extractor.type) {
|
|
135
|
+
case "regex": {
|
|
136
|
+
for (const pattern of extractor.regex || []) {
|
|
137
|
+
const match = target.match(new RegExp(pattern));
|
|
138
|
+
if (match) return match[extractor.group || 0];
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case "json": {
|
|
144
|
+
try {
|
|
145
|
+
const data = JSON.parse(target);
|
|
146
|
+
for (const path of extractor.json || []) {
|
|
147
|
+
const value = path.split(".").reduce((obj, key) => obj?.[key], data);
|
|
148
|
+
if (value !== undefined) return value;
|
|
149
|
+
}
|
|
150
|
+
} catch { /* not JSON */ }
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
default:
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Built-in security templates (no YAML parser needed).
|
|
161
|
+
* These cover the most common web app vulnerabilities.
|
|
162
|
+
* Inspired by Nuclei's community templates.
|
|
163
|
+
*/
|
|
164
|
+
export const BUILT_IN_TEMPLATES = [
|
|
165
|
+
// ── Exposed sensitive files ──────────────────────────────────────────
|
|
166
|
+
{
|
|
167
|
+
id: "env-file-exposure",
|
|
168
|
+
info: { name: ".env file exposed", severity: "critical", tags: ["exposure", "config"] },
|
|
169
|
+
http: [{
|
|
170
|
+
method: "GET", path: "/.env",
|
|
171
|
+
matchers: [
|
|
172
|
+
{ type: "status", status: [200] },
|
|
173
|
+
{ type: "word", words: ["DB_", "API_KEY", "SECRET", "PASSWORD", "FIREBASE_", "STRIPE_"], condition: "or" },
|
|
174
|
+
],
|
|
175
|
+
"matchers-condition": "and",
|
|
176
|
+
}],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "env-local-exposure",
|
|
180
|
+
info: { name: ".env.local file exposed", severity: "critical", tags: ["exposure", "config"] },
|
|
181
|
+
http: [{
|
|
182
|
+
method: "GET", path: "/.env.local",
|
|
183
|
+
matchers: [
|
|
184
|
+
{ type: "status", status: [200] },
|
|
185
|
+
{ type: "word", words: ["="], condition: "or" },
|
|
186
|
+
],
|
|
187
|
+
"matchers-condition": "and",
|
|
188
|
+
}],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "git-config-exposure",
|
|
192
|
+
info: { name: "Git config exposed", severity: "high", tags: ["exposure", "git"] },
|
|
193
|
+
http: [{
|
|
194
|
+
method: "GET", path: "/.git/config",
|
|
195
|
+
matchers: [
|
|
196
|
+
{ type: "status", status: [200] },
|
|
197
|
+
{ type: "word", words: ["[core]", "[remote"], condition: "or" },
|
|
198
|
+
],
|
|
199
|
+
"matchers-condition": "and",
|
|
200
|
+
}],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "git-head-exposure",
|
|
204
|
+
info: { name: "Git HEAD exposed", severity: "medium", tags: ["exposure", "git"] },
|
|
205
|
+
http: [{
|
|
206
|
+
method: "GET", path: "/.git/HEAD",
|
|
207
|
+
matchers: [
|
|
208
|
+
{ type: "status", status: [200] },
|
|
209
|
+
{ type: "word", words: ["ref: refs/"] },
|
|
210
|
+
],
|
|
211
|
+
"matchers-condition": "and",
|
|
212
|
+
}],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: "service-account-key-exposure",
|
|
216
|
+
info: { name: "Service account key exposed", severity: "critical", tags: ["exposure", "firebase", "gcp"] },
|
|
217
|
+
http: [{
|
|
218
|
+
method: "GET", path: "/serviceAccountKey.json",
|
|
219
|
+
matchers: [
|
|
220
|
+
{ type: "status", status: [200] },
|
|
221
|
+
{ type: "word", words: ["private_key", "client_email"], condition: "and" },
|
|
222
|
+
],
|
|
223
|
+
"matchers-condition": "and",
|
|
224
|
+
}],
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// ── Source map exposure ──────────────────────────────────────────────
|
|
228
|
+
{
|
|
229
|
+
id: "nextjs-sourcemap-exposure",
|
|
230
|
+
info: { name: "Next.js source maps exposed", severity: "medium", tags: ["exposure", "nextjs"],
|
|
231
|
+
description: "Source maps allow attackers to read your original source code" },
|
|
232
|
+
http: [{
|
|
233
|
+
method: "GET", path: "/_next/static/chunks/main.js.map",
|
|
234
|
+
matchers: [
|
|
235
|
+
{ type: "status", status: [200] },
|
|
236
|
+
{ type: "word", words: ["mappings", "sources"], condition: "or" },
|
|
237
|
+
],
|
|
238
|
+
"matchers-condition": "and",
|
|
239
|
+
}],
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// ── Firebase specific ───────────────────────────────────────────────
|
|
243
|
+
{
|
|
244
|
+
id: "firebase-database-open-read",
|
|
245
|
+
info: { name: "Firebase Realtime DB open read", severity: "high", tags: ["firebase", "misconfiguration"] },
|
|
246
|
+
http: [{
|
|
247
|
+
method: "GET", path: "/.json",
|
|
248
|
+
matchers: [
|
|
249
|
+
{ type: "status", status: [200] },
|
|
250
|
+
{ type: "negative-word", words: ["Permission denied", "error"] },
|
|
251
|
+
],
|
|
252
|
+
"matchers-condition": "and",
|
|
253
|
+
}],
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// ── Security headers (also checked by lib/blackbox/headers.js) ────────
|
|
257
|
+
|
|
258
|
+
// ── Debug / internal endpoints ──────────────────────────────────────
|
|
259
|
+
{
|
|
260
|
+
id: "nextjs-debug-endpoint",
|
|
261
|
+
info: { name: "Next.js debug endpoint accessible", severity: "medium", tags: ["nextjs", "debug"] },
|
|
262
|
+
http: [{
|
|
263
|
+
method: "GET", path: "/api/__nextjs_original-stack-frame",
|
|
264
|
+
matchers: [{ type: "status", status: [200, 400] }],
|
|
265
|
+
}],
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: "graphql-introspection",
|
|
269
|
+
info: { name: "GraphQL introspection enabled", severity: "medium", tags: ["graphql", "misconfiguration"] },
|
|
270
|
+
http: [{
|
|
271
|
+
method: "POST", path: "/graphql",
|
|
272
|
+
headers: { "Content-Type": "application/json" },
|
|
273
|
+
body: '{"query":"{ __schema { types { name } } }"}',
|
|
274
|
+
matchers: [
|
|
275
|
+
{ type: "status", status: [200] },
|
|
276
|
+
{ type: "word", words: ["__schema", "__type"], condition: "or" },
|
|
277
|
+
],
|
|
278
|
+
"matchers-condition": "and",
|
|
279
|
+
}],
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "swagger-ui-exposed",
|
|
283
|
+
info: { name: "Swagger UI exposed", severity: "low", tags: ["exposure", "api-docs"] },
|
|
284
|
+
http: [{
|
|
285
|
+
method: "GET", path: "/api/docs",
|
|
286
|
+
matchers: [
|
|
287
|
+
{ type: "status", status: [200] },
|
|
288
|
+
{ type: "word", words: ["swagger", "openapi", "api-docs"], condition: "or" },
|
|
289
|
+
],
|
|
290
|
+
"matchers-condition": "and",
|
|
291
|
+
}],
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// ── CVE checks ────────────��─────────────────────────────────────────
|
|
295
|
+
{
|
|
296
|
+
id: "cve-2025-29927-nextjs-middleware-bypass",
|
|
297
|
+
info: { name: "CVE-2025-29927: Next.js middleware bypass", severity: "critical",
|
|
298
|
+
tags: ["cve", "nextjs", "auth-bypass"],
|
|
299
|
+
description: "x-middleware-subrequest header can bypass Next.js middleware auth checks" },
|
|
300
|
+
http: [{
|
|
301
|
+
method: "GET", path: "/api/admin/users",
|
|
302
|
+
headers: { "x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware" },
|
|
303
|
+
matchers: [{ type: "status", status: [200] }],
|
|
304
|
+
}],
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// ── CORS misconfiguration ───────────────────────────────────────────
|
|
308
|
+
{
|
|
309
|
+
id: "cors-wildcard",
|
|
310
|
+
info: { name: "CORS allows wildcard origin", severity: "medium", tags: ["cors", "misconfiguration"] },
|
|
311
|
+
http: [{
|
|
312
|
+
method: "GET", path: "/api/health",
|
|
313
|
+
headers: { Origin: "https://evil-attacker.com" },
|
|
314
|
+
matchers: [
|
|
315
|
+
{ type: "word", words: ["https://evil-attacker.com"], part: "header" },
|
|
316
|
+
],
|
|
317
|
+
}],
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: "cors-null-origin",
|
|
321
|
+
info: { name: "CORS allows null origin", severity: "high", tags: ["cors", "misconfiguration"] },
|
|
322
|
+
http: [{
|
|
323
|
+
method: "GET", path: "/api/health",
|
|
324
|
+
headers: { Origin: "null" },
|
|
325
|
+
matchers: [
|
|
326
|
+
{ type: "regex", regex: ["access-control-allow-origin:\\s*null"], part: "header" },
|
|
327
|
+
],
|
|
328
|
+
}],
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// (An "open redirect via callback" template lived here but was removed: a
|
|
332
|
+
// declarative word-matcher on the Location header can't tell a real open
|
|
333
|
+
// redirect from a same-site canonicalization redirect that merely preserves
|
|
334
|
+
// the payload in the query string. The code-based OAuth probe covers this
|
|
335
|
+
// path correctly by resolving the redirect's destination origin.)
|
|
336
|
+
|
|
337
|
+
// ── Directory listing ───────────────────────────────────────────────
|
|
338
|
+
{
|
|
339
|
+
id: "directory-listing",
|
|
340
|
+
info: { name: "Directory listing enabled", severity: "low", tags: ["exposure", "misconfiguration"] },
|
|
341
|
+
http: [{
|
|
342
|
+
method: "GET", path: "/api/",
|
|
343
|
+
matchers: [
|
|
344
|
+
{ type: "status", status: [200] },
|
|
345
|
+
{ type: "word", words: ["Index of", "Directory listing", "<pre>"], condition: "or" },
|
|
346
|
+
],
|
|
347
|
+
"matchers-condition": "and",
|
|
348
|
+
}],
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Run all built-in templates against a target.
|
|
354
|
+
* @param {string} baseUrl
|
|
355
|
+
* @returns {Array<object>} All findings
|
|
356
|
+
*/
|
|
357
|
+
export async function runBuiltInTemplates(baseUrl) {
|
|
358
|
+
const allFindings = [];
|
|
359
|
+
for (const template of BUILT_IN_TEMPLATES) {
|
|
360
|
+
const findings = await executeTemplate(baseUrl, template);
|
|
361
|
+
allFindings.push(...findings);
|
|
362
|
+
}
|
|
363
|
+
return allFindings;
|
|
364
|
+
}
|
package/lib/tls.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS/SSL Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks certificate validity, cipher strength, and protocol version
|
|
5
|
+
* using Node.js built-in `tls` module, zero dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Inspired by: httpx (JARM fingerprinting), testssl.sh, ssl-labs
|
|
8
|
+
*/
|
|
9
|
+
import tls from "node:tls";
|
|
10
|
+
|
|
11
|
+
const WEAK_CIPHERS = new Set([
|
|
12
|
+
"DES-CBC3-SHA", "RC4-SHA", "RC4-MD5", "DES-CBC-SHA",
|
|
13
|
+
"EXP-RC4-MD5", "EXP-DES-CBC-SHA", "EXP-RC2-CBC-MD5",
|
|
14
|
+
"NULL-MD5", "NULL-SHA", "NULL-SHA256",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const WEAK_PROTOCOLS = new Set(["TLSv1", "TLSv1.1", "SSLv3"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check TLS configuration for a hostname.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} hostname - Target hostname (no protocol)
|
|
23
|
+
* @param {number} port - Target port (default: 443)
|
|
24
|
+
* @param {number} timeout - Connection timeout in ms
|
|
25
|
+
* @returns {object} TLS scan result
|
|
26
|
+
*/
|
|
27
|
+
export function checkTls(hostname, port = 443, timeout = 10_000) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const socket = tls.connect(
|
|
30
|
+
{ host: hostname, port, servername: hostname, rejectUnauthorized: false },
|
|
31
|
+
() => {
|
|
32
|
+
const cert = socket.getPeerCertificate(true);
|
|
33
|
+
const protocol = socket.getProtocol();
|
|
34
|
+
const cipher = socket.getCipher();
|
|
35
|
+
const authorized = socket.authorized;
|
|
36
|
+
const authError = socket.authorizationError || null;
|
|
37
|
+
socket.end();
|
|
38
|
+
|
|
39
|
+
const now = new Date();
|
|
40
|
+
const validFrom = new Date(cert.valid_from);
|
|
41
|
+
const validTo = new Date(cert.valid_to);
|
|
42
|
+
const daysUntilExpiry = Math.floor((validTo - now) / 86400000);
|
|
43
|
+
|
|
44
|
+
// Extract SAN (Subject Alternative Names)
|
|
45
|
+
const altNames = cert.subjectaltname
|
|
46
|
+
? cert.subjectaltname.split(", ").map((s) => s.replace("DNS:", ""))
|
|
47
|
+
: [];
|
|
48
|
+
|
|
49
|
+
// Determine findings
|
|
50
|
+
const findings = [];
|
|
51
|
+
|
|
52
|
+
if (!authorized) {
|
|
53
|
+
findings.push({
|
|
54
|
+
severity: "high",
|
|
55
|
+
title: "TLS certificate not trusted",
|
|
56
|
+
description: authError || "Certificate validation failed",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (daysUntilExpiry < 0) {
|
|
61
|
+
findings.push({
|
|
62
|
+
severity: "critical",
|
|
63
|
+
title: "TLS certificate expired",
|
|
64
|
+
description: `Expired ${Math.abs(daysUntilExpiry)} days ago (${validTo.toISOString().slice(0, 10)})`,
|
|
65
|
+
});
|
|
66
|
+
} else if (daysUntilExpiry < 30) {
|
|
67
|
+
findings.push({
|
|
68
|
+
severity: "medium",
|
|
69
|
+
title: "TLS certificate expiring soon",
|
|
70
|
+
description: `Expires in ${daysUntilExpiry} days (${validTo.toISOString().slice(0, 10)})`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (WEAK_PROTOCOLS.has(protocol)) {
|
|
75
|
+
findings.push({
|
|
76
|
+
severity: "high",
|
|
77
|
+
title: `Weak TLS protocol: ${protocol}`,
|
|
78
|
+
description: "TLSv1.0 and TLSv1.1 are deprecated. Use TLSv1.2 or TLSv1.3.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (WEAK_CIPHERS.has(cipher?.name)) {
|
|
83
|
+
findings.push({
|
|
84
|
+
severity: "high",
|
|
85
|
+
title: `Weak cipher suite: ${cipher.name}`,
|
|
86
|
+
description: "This cipher is considered insecure.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cipher?.name && !cipher.name.includes("GCM") && !cipher.name.includes("CHACHA")) {
|
|
91
|
+
findings.push({
|
|
92
|
+
severity: "low",
|
|
93
|
+
title: `Non-AEAD cipher: ${cipher.name}`,
|
|
94
|
+
description: "Consider using AEAD ciphers (GCM, ChaCha20-Poly1305) for better security.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
resolve({
|
|
99
|
+
valid: authorized,
|
|
100
|
+
protocol,
|
|
101
|
+
cipher: cipher?.name || "unknown",
|
|
102
|
+
cipherVersion: cipher?.version || "unknown",
|
|
103
|
+
issuer: cert.issuer?.O || cert.issuer?.CN || "unknown",
|
|
104
|
+
subject: cert.subject?.CN || "unknown",
|
|
105
|
+
altNames,
|
|
106
|
+
validFrom: validFrom.toISOString(),
|
|
107
|
+
validTo: validTo.toISOString(),
|
|
108
|
+
daysUntilExpiry,
|
|
109
|
+
serialNumber: cert.serialNumber,
|
|
110
|
+
fingerprint256: cert.fingerprint256,
|
|
111
|
+
findings,
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
socket.setTimeout(timeout);
|
|
117
|
+
socket.on("timeout", () => {
|
|
118
|
+
socket.destroy();
|
|
119
|
+
resolve({
|
|
120
|
+
valid: false,
|
|
121
|
+
protocol: null,
|
|
122
|
+
cipher: null,
|
|
123
|
+
error: "Connection timeout",
|
|
124
|
+
findings: [{
|
|
125
|
+
severity: "medium",
|
|
126
|
+
title: "TLS connection timeout",
|
|
127
|
+
description: `Could not establish TLS connection within ${timeout}ms`,
|
|
128
|
+
}],
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
socket.on("error", (err) => {
|
|
132
|
+
resolve({
|
|
133
|
+
valid: false,
|
|
134
|
+
protocol: null,
|
|
135
|
+
cipher: null,
|
|
136
|
+
error: err.message,
|
|
137
|
+
findings: [{
|
|
138
|
+
severity: "medium",
|
|
139
|
+
title: "TLS connection failed",
|
|
140
|
+
description: err.message,
|
|
141
|
+
}],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover API routes in Express, Hono, and Fastify projects.
|
|
3
|
+
*/
|
|
4
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
|
|
7
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor"]);
|
|
8
|
+
const CODE_EXT = /\.(js|ts|mjs|cjs)$/;
|
|
9
|
+
|
|
10
|
+
const ROUTE_DIRS = ["routes", "src/routes", "api", "src/api", "server/routes", "src/server/routes"];
|
|
11
|
+
|
|
12
|
+
// Express / Hono / Fastify route patterns
|
|
13
|
+
const ROUTE_RES = [
|
|
14
|
+
/(?:app|router|server|api|hono)\.(get|post|put|patch|delete|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
15
|
+
/(?:app|router|server|api|hono)\.(get|post|put|patch|delete|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
16
|
+
/fastify\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
17
|
+
/\.(get|post|put|patch|delete)\s*\(\s*['"`](\/[^'"`]+)['"`]/gi,
|
|
18
|
+
/method:\s*['"](GET|POST|PUT|PATCH|DELETE)['"]\s*,\s*url:\s*['"`]([^'"`]+)['"`]/gi,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function walkCodeFiles(dir, files = []) {
|
|
22
|
+
if (!existsSync(dir)) return files;
|
|
23
|
+
for (const entry of readdirSync(dir)) {
|
|
24
|
+
if (SKIP.has(entry)) continue;
|
|
25
|
+
const full = join(dir, entry);
|
|
26
|
+
if (statSync(full).isDirectory()) {
|
|
27
|
+
walkCodeFiles(full, files);
|
|
28
|
+
} else if (CODE_EXT.test(entry)) {
|
|
29
|
+
files.push(full);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractRoutesFromSource(source, filePath, repoPath) {
|
|
36
|
+
const routes = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
|
|
39
|
+
for (const re of ROUTE_RES) {
|
|
40
|
+
re.lastIndex = 0;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = re.exec(source)) !== null) {
|
|
43
|
+
const method = m[1].toUpperCase();
|
|
44
|
+
let path = m[2];
|
|
45
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
46
|
+
const key = `${method}:${path}`;
|
|
47
|
+
if (seen.has(key)) continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
|
|
50
|
+
routes.push({
|
|
51
|
+
url: path,
|
|
52
|
+
filePath: relative(repoPath, filePath),
|
|
53
|
+
methods: [method],
|
|
54
|
+
auth: classifyAuth(source),
|
|
55
|
+
inputs: [],
|
|
56
|
+
externalCalls: [],
|
|
57
|
+
securityPatterns: detectRisk(source),
|
|
58
|
+
hasParams: path.includes(":") || path.includes("*"),
|
|
59
|
+
framework: detectFramework(source),
|
|
60
|
+
sourceLength: source.length,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return routes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function detectFramework(source) {
|
|
69
|
+
if (/from\s+['"]hono['"]|new Hono/.test(source)) return "hono";
|
|
70
|
+
if (/from\s+['"]fastify['"]|fastify\(/.test(source)) return "fastify";
|
|
71
|
+
if (/from\s+['"]express['"]|require\s*\(\s*['"]express['"]\)/.test(source)) return "express";
|
|
72
|
+
return "node";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function classifyAuth(source) {
|
|
76
|
+
const auth = [];
|
|
77
|
+
if (/verifyAdmin|isAdmin|requireAdmin/.test(source)) auth.push("admin");
|
|
78
|
+
if (/requireAuth|getSession|verifyToken|jwt\.verify/.test(source)) auth.push("user");
|
|
79
|
+
if (/CRON_SECRET|verifyCron/.test(source)) auth.push("cron");
|
|
80
|
+
if (auth.length === 0) auth.push("public");
|
|
81
|
+
return auth;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function detectRisk(source) {
|
|
85
|
+
const patterns = [];
|
|
86
|
+
if (/eval\s*\(/.test(source)) patterns.push("RISK:eval");
|
|
87
|
+
if (/innerHTML/.test(source)) patterns.push("RISK:innerHTML");
|
|
88
|
+
if (/exec\s*\(|spawn\s*\(/.test(source)) patterns.push("RISK:command-injection");
|
|
89
|
+
return patterns;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} repoPath
|
|
94
|
+
* @returns {object[]} routes in attack-surface shape
|
|
95
|
+
*/
|
|
96
|
+
export function discoverFrameworkRoutes(repoPath) {
|
|
97
|
+
const all = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
|
|
100
|
+
const scanFiles = (dir) => {
|
|
101
|
+
for (const file of walkCodeFiles(dir)) {
|
|
102
|
+
let source;
|
|
103
|
+
try {
|
|
104
|
+
source = readFileSync(file, "utf-8");
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!/(express|hono|fastify|Router|\.get\s*\(|\.post\s*\()/i.test(source)) continue;
|
|
109
|
+
|
|
110
|
+
for (const route of extractRoutesFromSource(source, file, repoPath)) {
|
|
111
|
+
const key = `${route.methods[0]}:${route.url}:${route.filePath}`;
|
|
112
|
+
if (seen.has(key)) continue;
|
|
113
|
+
seen.add(key);
|
|
114
|
+
all.push(route);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const dir of ROUTE_DIRS) {
|
|
120
|
+
scanFiles(join(repoPath, dir));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Also scan top-level server entry files
|
|
124
|
+
for (const entry of ["server.js", "server.ts", "index.js", "index.ts", "app.js", "app.ts", "src/index.ts"]) {
|
|
125
|
+
const full = join(repoPath, entry);
|
|
126
|
+
if (!existsSync(full)) continue;
|
|
127
|
+
try {
|
|
128
|
+
const source = readFileSync(full, "utf-8");
|
|
129
|
+
for (const route of extractRoutesFromSource(source, full, repoPath)) {
|
|
130
|
+
const key = `${route.methods[0]}:${route.url}:${route.filePath}`;
|
|
131
|
+
if (seen.has(key)) continue;
|
|
132
|
+
seen.add(key);
|
|
133
|
+
all.push(route);
|
|
134
|
+
}
|
|
135
|
+
} catch { /* skip */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return all.sort((a, b) => a.url.localeCompare(b.url));
|
|
139
|
+
}
|