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/params.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Parameter Discovery (Arjun-style)
|
|
3
|
+
*
|
|
4
|
+
* Discovers active HTTP parameters via batched differential analysis.
|
|
5
|
+
* Instead of testing 1 parameter per request (slow), we send batches of
|
|
6
|
+
* 50+ parameters and use response differences to detect active ones.
|
|
7
|
+
*
|
|
8
|
+
* Algorithm:
|
|
9
|
+
* 1. Establish baseline response (no params)
|
|
10
|
+
* 2. Send batches of 50 candidate params
|
|
11
|
+
* 3. If response differs from baseline → active params in this batch
|
|
12
|
+
* 4. Binary-search the batch to isolate individual active params
|
|
13
|
+
*
|
|
14
|
+
* Inspired by: Arjun (6.2k stars), ParamSpider
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PARAM_TIMEOUT = 8_000;
|
|
18
|
+
|
|
19
|
+
async function safeFetch(url, opts = {}) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeout || PARAM_TIMEOUT);
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
|
|
24
|
+
const body = await res.text().catch(() => "");
|
|
25
|
+
return { status: res.status, body, size: body.length, ok: true };
|
|
26
|
+
} catch {
|
|
27
|
+
return { status: 0, body: "", size: 0, ok: false };
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function simpleHash(str) {
|
|
34
|
+
let hash = 0;
|
|
35
|
+
for (let i = 0; i < str.length; i++) {
|
|
36
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
37
|
+
}
|
|
38
|
+
return hash;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isDifferent(baseline, response, tolerance = 0.05) {
|
|
42
|
+
if (baseline.status !== response.status) return true;
|
|
43
|
+
const sizeDiff = Math.abs(baseline.size - response.size);
|
|
44
|
+
if (sizeDiff > Math.max(50, baseline.size * tolerance)) return true;
|
|
45
|
+
if (simpleHash(baseline.body) !== simpleHash(response.body)) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Common parameter wordlist ────────────────────────────────────────────
|
|
50
|
+
// Curated from SecLists, Arjun defaults, and CommonCrawl analysis.
|
|
51
|
+
|
|
52
|
+
const COMMON_PARAMS = [
|
|
53
|
+
// Auth / identity
|
|
54
|
+
"id", "user", "username", "email", "password", "token", "key", "api_key",
|
|
55
|
+
"apikey", "auth", "session", "sid", "uid", "user_id", "userId",
|
|
56
|
+
// Pagination / filtering
|
|
57
|
+
"page", "limit", "offset", "sort", "order", "filter", "search", "query",
|
|
58
|
+
"q", "keyword", "per_page", "perPage", "size", "start", "end",
|
|
59
|
+
// Content / data
|
|
60
|
+
"name", "title", "body", "content", "text", "message", "description",
|
|
61
|
+
"comment", "note", "data", "value", "type", "status", "state",
|
|
62
|
+
// URLs / redirects
|
|
63
|
+
"url", "link", "href", "src", "redirect", "redirect_uri", "return",
|
|
64
|
+
"return_to", "next", "callback", "redir", "destination", "go", "target",
|
|
65
|
+
// Files / paths
|
|
66
|
+
"file", "filename", "path", "dir", "folder", "upload", "image", "img",
|
|
67
|
+
"photo", "avatar", "attachment", "document", "doc",
|
|
68
|
+
// Actions
|
|
69
|
+
"action", "cmd", "command", "method", "do", "func", "function",
|
|
70
|
+
"handler", "operation", "task", "step", "mode",
|
|
71
|
+
// Formatting
|
|
72
|
+
"format", "output", "view", "template", "layout", "theme", "lang",
|
|
73
|
+
"language", "locale", "timezone", "tz",
|
|
74
|
+
// Database / technical
|
|
75
|
+
"table", "column", "field", "select", "where", "from", "join",
|
|
76
|
+
"group", "having", "debug", "verbose", "trace", "log", "level",
|
|
77
|
+
// Common API
|
|
78
|
+
"version", "v", "format", "fields", "include", "exclude", "expand",
|
|
79
|
+
"embed", "populate", "with", "scope", "context",
|
|
80
|
+
// Security-relevant
|
|
81
|
+
"admin", "role", "permission", "access", "grant", "deny", "allow",
|
|
82
|
+
"block", "ban", "delete", "remove", "drop", "reset", "confirm",
|
|
83
|
+
// Payment / e-commerce
|
|
84
|
+
"amount", "price", "quantity", "qty", "total", "discount", "coupon",
|
|
85
|
+
"code", "promo", "plan", "subscription", "invoice", "order_id",
|
|
86
|
+
// Misc
|
|
87
|
+
"category", "tag", "label", "ref", "source", "medium", "campaign",
|
|
88
|
+
"from", "to", "date", "timestamp", "created", "updated",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// ── Batched parameter discovery ──────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Discover active HTTP parameters on an endpoint.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} baseUrl
|
|
97
|
+
* @param {string} path - Endpoint path
|
|
98
|
+
* @param {object} opts - { method, batchSize, extraParams, timeout }
|
|
99
|
+
* @returns {{ params: string[], method: string, path: string }}
|
|
100
|
+
*/
|
|
101
|
+
export async function discoverParams(baseUrl, path, opts = {}) {
|
|
102
|
+
const method = opts.method || "GET";
|
|
103
|
+
const batchSize = opts.batchSize || 50;
|
|
104
|
+
const wordlist = [...COMMON_PARAMS, ...(opts.extraParams || [])];
|
|
105
|
+
const canary = "pnth3r4_" + Math.random().toString(36).slice(2, 8);
|
|
106
|
+
|
|
107
|
+
// 1. Baseline (no params)
|
|
108
|
+
const baselineUrl = `${baseUrl}${path}`;
|
|
109
|
+
const baseline = method === "GET"
|
|
110
|
+
? await safeFetch(baselineUrl)
|
|
111
|
+
: await safeFetch(baselineUrl, {
|
|
112
|
+
method,
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!baseline.ok) return { params: [], method, path };
|
|
118
|
+
|
|
119
|
+
// Also get a "noise" baseline (with a definitely-fake param)
|
|
120
|
+
const noiseUrl = `${baseUrl}${path}?__pnth_noise__=${canary}`;
|
|
121
|
+
const noise = await safeFetch(noiseUrl);
|
|
122
|
+
|
|
123
|
+
// If adding a random param changes the response, the endpoint is too dynamic
|
|
124
|
+
const tooNoisy = isDifferent(baseline, noise, 0.01);
|
|
125
|
+
|
|
126
|
+
const activeParams = [];
|
|
127
|
+
|
|
128
|
+
// 2. Batch testing
|
|
129
|
+
for (let i = 0; i < wordlist.length; i += batchSize) {
|
|
130
|
+
const batch = wordlist.slice(i, i + batchSize);
|
|
131
|
+
|
|
132
|
+
let batchResponse;
|
|
133
|
+
if (method === "GET") {
|
|
134
|
+
const qs = batch.map((p) => `${p}=${canary}`).join("&");
|
|
135
|
+
batchResponse = await safeFetch(`${baseUrl}${path}?${qs}`);
|
|
136
|
+
} else {
|
|
137
|
+
const body = {};
|
|
138
|
+
for (const p of batch) body[p] = canary;
|
|
139
|
+
batchResponse = await safeFetch(`${baseUrl}${path}`, {
|
|
140
|
+
method,
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify(body),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!batchResponse.ok) continue;
|
|
147
|
+
|
|
148
|
+
// If batch response equals baseline, no active params here
|
|
149
|
+
if (!isDifferent(baseline, batchResponse)) continue;
|
|
150
|
+
|
|
151
|
+
// If endpoint is noisy, skip individual testing (too many false positives)
|
|
152
|
+
if (tooNoisy) continue;
|
|
153
|
+
|
|
154
|
+
// 3. Narrow down: test each param individually
|
|
155
|
+
for (const param of batch) {
|
|
156
|
+
let singleResponse;
|
|
157
|
+
if (method === "GET") {
|
|
158
|
+
singleResponse = await safeFetch(`${baseUrl}${path}?${param}=${canary}`);
|
|
159
|
+
} else {
|
|
160
|
+
singleResponse = await safeFetch(`${baseUrl}${path}`, {
|
|
161
|
+
method,
|
|
162
|
+
headers: { "Content-Type": "application/json" },
|
|
163
|
+
body: JSON.stringify({ [param]: canary }),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (singleResponse.ok && isDifferent(baseline, singleResponse)) {
|
|
168
|
+
activeParams.push(param);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { params: activeParams, method, path };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Discover parameters across multiple endpoints.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} baseUrl
|
|
180
|
+
* @param {Array<{ path, status }>} endpoints
|
|
181
|
+
* @param {object} opts - { onPhase, maxEndpoints, extraParams }
|
|
182
|
+
* @returns {{ results: object[], findings: object[] }}
|
|
183
|
+
*/
|
|
184
|
+
export async function discoverAllParams(baseUrl, endpoints, opts = {}) {
|
|
185
|
+
const progress = opts.onPhase || (() => {});
|
|
186
|
+
const max = opts.maxEndpoints || 5;
|
|
187
|
+
const findings = [];
|
|
188
|
+
const results = [];
|
|
189
|
+
|
|
190
|
+
const candidates = endpoints
|
|
191
|
+
.filter((ep) => ep.status !== 404 && ep.status < 500)
|
|
192
|
+
.filter((ep) => ep.path.includes("/api/") || ep.path.includes("?"))
|
|
193
|
+
.slice(0, max);
|
|
194
|
+
|
|
195
|
+
if (candidates.length === 0) return { results, findings };
|
|
196
|
+
|
|
197
|
+
for (const ep of candidates) {
|
|
198
|
+
progress(`Discovering parameters on ${ep.path}...`);
|
|
199
|
+
const result = await discoverParams(baseUrl, ep.path, {
|
|
200
|
+
extraParams: opts.extraParams,
|
|
201
|
+
});
|
|
202
|
+
results.push(result);
|
|
203
|
+
|
|
204
|
+
if (result.params.length > 0) {
|
|
205
|
+
// Flag security-sensitive parameters
|
|
206
|
+
const sensitiveParams = result.params.filter((p) =>
|
|
207
|
+
/admin|role|permission|debug|token|secret|password|key|cmd|exec|file|path|redirect|url/i.test(p),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (sensitiveParams.length > 0) {
|
|
211
|
+
findings.push({
|
|
212
|
+
severity: "medium",
|
|
213
|
+
title: `Sensitive parameters found on ${ep.path}`,
|
|
214
|
+
description: `Active params: ${sensitiveParams.join(", ")}`,
|
|
215
|
+
url: `${baseUrl}${ep.path}`,
|
|
216
|
+
category: "parameter-discovery",
|
|
217
|
+
source: "param-discovery",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (result.params.length > 0) {
|
|
222
|
+
findings.push({
|
|
223
|
+
severity: "info",
|
|
224
|
+
title: `${result.params.length} active params on ${ep.path}`,
|
|
225
|
+
description: result.params.join(", "),
|
|
226
|
+
url: `${baseUrl}${ep.path}`,
|
|
227
|
+
category: "parameter-discovery",
|
|
228
|
+
source: "param-discovery",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { results, findings };
|
|
235
|
+
}
|
package/lib/plugins.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin / templates API, load and run custom YAML security templates.
|
|
3
|
+
*
|
|
4
|
+
* Programmatic usage:
|
|
5
|
+
* import { loadTemplatesFromPaths, runTemplateScan } from "penthera/plugins";
|
|
6
|
+
* const templates = await loadTemplatesFromPaths(["./my-templates"]);
|
|
7
|
+
* const findings = await runTemplateScan("https://app.example", templates);
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, statSync } from "node:fs";
|
|
10
|
+
import { extname, resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
loadTemplatesFromDir,
|
|
13
|
+
executeTemplates,
|
|
14
|
+
} from "./nuclei-loader.js";
|
|
15
|
+
import {
|
|
16
|
+
runBuiltInTemplates,
|
|
17
|
+
executeTemplate,
|
|
18
|
+
BUILT_IN_TEMPLATES,
|
|
19
|
+
} from "./templates.js";
|
|
20
|
+
|
|
21
|
+
export { loadTemplatesFromDir, executeTemplates, runBuiltInTemplates, executeTemplate, BUILT_IN_TEMPLATES };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load templates from one or more directories (Nuclei-compatible YAML).
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} paths
|
|
27
|
+
* @param {object} opts - passed to loadTemplatesFromDir
|
|
28
|
+
*/
|
|
29
|
+
export async function loadTemplatesFromPaths(paths, opts = {}) {
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const templates = [];
|
|
32
|
+
|
|
33
|
+
for (const raw of paths) {
|
|
34
|
+
const dir = resolve(raw);
|
|
35
|
+
if (!existsSync(dir)) {
|
|
36
|
+
throw new Error(`Template path not found: ${dir}`);
|
|
37
|
+
}
|
|
38
|
+
if (!statSync(dir).isDirectory()) {
|
|
39
|
+
throw new Error(`Template path must be a directory: ${dir}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const loaded = await loadTemplatesFromDir(dir, opts);
|
|
43
|
+
for (const t of loaded) {
|
|
44
|
+
if (seen.has(t.id)) continue;
|
|
45
|
+
seen.add(t.id);
|
|
46
|
+
templates.push(t);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return templates;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Execute templates against a target URL.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} baseUrl
|
|
57
|
+
* @param {object[]} templates
|
|
58
|
+
* @param {object} opts
|
|
59
|
+
*/
|
|
60
|
+
export async function runTemplateScan(baseUrl, templates, opts = {}) {
|
|
61
|
+
return executeTemplates(baseUrl, templates, opts);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse --templates CLI value (comma-separated paths).
|
|
66
|
+
*/
|
|
67
|
+
export function parseTemplatePaths(value) {
|
|
68
|
+
if (!value) return [];
|
|
69
|
+
return value.split(",").map((p) => p.trim()).filter(Boolean);
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance and honest-confidence labelling for findings (principles #1 and #3).
|
|
3
|
+
*
|
|
4
|
+
* Every finding carries { source, method, collected_at, confidence }:
|
|
5
|
+
* - source / method: which probe produced it,
|
|
6
|
+
* - collected_at: when the scan ran,
|
|
7
|
+
* - confidence: how sure we are it is real and reachable, not just its severity.
|
|
8
|
+
*
|
|
9
|
+
* Confidence is deliberately honest about what a scanner can and cannot judge:
|
|
10
|
+
* confirmed deterministic evidence (a header is absent, a cert expired)
|
|
11
|
+
* likely a strong signal with a small false-positive chance
|
|
12
|
+
* potential a heuristic or timing signal that needs corroboration
|
|
13
|
+
* needs-human-review exploitability can't be judged from a scan (e.g. IDOR)
|
|
14
|
+
*
|
|
15
|
+
* A scanner must not present a guess as a fact, so heuristic probes are labelled
|
|
16
|
+
* accordingly rather than being dressed up as certainties.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Probe source -> default confidence.
|
|
20
|
+
const SOURCE_CONFIDENCE = {
|
|
21
|
+
"tls-check": "confirmed",
|
|
22
|
+
"header-audit": "confirmed",
|
|
23
|
+
"cookie-audit": "confirmed",
|
|
24
|
+
"secret-scan": "confirmed",
|
|
25
|
+
"sensitive-file-check": "confirmed",
|
|
26
|
+
"openapi-scan": "confirmed",
|
|
27
|
+
"email-dns-probe": "confirmed",
|
|
28
|
+
"framework-check": "likely",
|
|
29
|
+
"static-analysis": "likely",
|
|
30
|
+
"trust-boundary": "likely",
|
|
31
|
+
"auth-probe": "likely",
|
|
32
|
+
"oauth-probe": "likely",
|
|
33
|
+
"jwt-probe": "likely",
|
|
34
|
+
"injection-probe": "likely",
|
|
35
|
+
"template": "likely",
|
|
36
|
+
"nuclei-community": "likely",
|
|
37
|
+
"client-auth-probe": "needs-human-review",
|
|
38
|
+
"idor-probe": "needs-human-review",
|
|
39
|
+
"fuzzer": "potential",
|
|
40
|
+
"adaptive-probe": "potential",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Category fallback when the source is unknown.
|
|
44
|
+
const CATEGORY_CONFIDENCE = {
|
|
45
|
+
headers: "confirmed",
|
|
46
|
+
transport: "confirmed",
|
|
47
|
+
tls: "confirmed",
|
|
48
|
+
cookie: "confirmed",
|
|
49
|
+
secrets: "confirmed",
|
|
50
|
+
cve: "confirmed",
|
|
51
|
+
"js-vulnerability": "confirmed",
|
|
52
|
+
"email-auth": "confirmed",
|
|
53
|
+
auth: "likely",
|
|
54
|
+
"auth-bypass": "needs-human-review",
|
|
55
|
+
ssti: "likely",
|
|
56
|
+
sqli: "likely",
|
|
57
|
+
xss: "likely",
|
|
58
|
+
cmdi: "likely",
|
|
59
|
+
ssrf: "likely",
|
|
60
|
+
"open-redirect": "likely",
|
|
61
|
+
fuzzing: "potential",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Honest-confidence label for a finding. An explicit finding.confidence wins. */
|
|
65
|
+
export function confidenceFor(f) {
|
|
66
|
+
if (f.confidence) return f.confidence;
|
|
67
|
+
return SOURCE_CONFIDENCE[f.source] || CATEGORY_CONFIDENCE[f.category] || "likely";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stamp { source, method, collected_at, confidence } onto every finding. */
|
|
71
|
+
export function stampProvenance(findings, collectedAt) {
|
|
72
|
+
return (findings || []).map((f) => {
|
|
73
|
+
const source = f.source || "penthera";
|
|
74
|
+
return {
|
|
75
|
+
...f,
|
|
76
|
+
source,
|
|
77
|
+
method: f.method || source,
|
|
78
|
+
collected_at: f.collected_at || collectedAt || null,
|
|
79
|
+
confidence: confidenceFor(f),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
package/lib/recon.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSINT Reconnaissance Module
|
|
3
|
+
*
|
|
4
|
+
* Passive information gathering from public sources, no interaction with the target.
|
|
5
|
+
*
|
|
6
|
+
* Sources:
|
|
7
|
+
* - crt.sh (Certificate Transparency logs), subdomain discovery
|
|
8
|
+
* - Wayback Machine CDX API, historical URL enumeration
|
|
9
|
+
* - AlienVault OTX, threat intelligence URL list
|
|
10
|
+
*
|
|
11
|
+
* All sources are free, require no API key, and return JSON.
|
|
12
|
+
* Inspired by: subfinder, gau, waybackurls, ParamSpider
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const RECON_TIMEOUT = 15_000;
|
|
16
|
+
|
|
17
|
+
async function safeFetch(url, opts = {}) {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeout || RECON_TIMEOUT);
|
|
20
|
+
try {
|
|
21
|
+
return await fetch(url, { signal: controller.signal, ...opts });
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Subdomain Discovery via Certificate Transparency ─────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Query crt.sh for all certificates issued to *.domain.
|
|
33
|
+
* Returns unique subdomains sorted alphabetically.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} domain - e.g. "example.com"
|
|
36
|
+
* @returns {string[]} Discovered subdomains
|
|
37
|
+
*/
|
|
38
|
+
export async function discoverSubdomains(domain) {
|
|
39
|
+
const res = await safeFetch(
|
|
40
|
+
`https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`,
|
|
41
|
+
{ timeout: 20_000 },
|
|
42
|
+
);
|
|
43
|
+
if (!res || !res.ok) return [];
|
|
44
|
+
|
|
45
|
+
let data;
|
|
46
|
+
try {
|
|
47
|
+
data = await res.json();
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const subdomains = new Set();
|
|
53
|
+
for (const entry of data) {
|
|
54
|
+
if (!entry.name_value) continue;
|
|
55
|
+
for (const name of entry.name_value.split("\n")) {
|
|
56
|
+
const clean = name.trim().toLowerCase().replace(/^\*\./, "");
|
|
57
|
+
if (clean.endsWith(domain) && clean !== domain) {
|
|
58
|
+
subdomains.add(clean);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [...subdomains].sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Historical URL Enumeration ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fetch known URLs from the Wayback Machine CDX API.
|
|
70
|
+
* Collapses by URL key to avoid duplicates.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} domain - e.g. "example.com"
|
|
73
|
+
* @param {object} opts - { limit }
|
|
74
|
+
* @returns {string[]} Historical URLs
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchWaybackUrls(domain, opts = {}) {
|
|
77
|
+
const limit = opts.limit || 1000;
|
|
78
|
+
const res = await safeFetch(
|
|
79
|
+
`https://web.archive.org/cdx/search/cdx?url=*.${encodeURIComponent(domain)}/*&output=json&fl=original&collapse=urlkey&limit=${limit}`,
|
|
80
|
+
{ timeout: 20_000 },
|
|
81
|
+
);
|
|
82
|
+
if (!res || !res.ok) return [];
|
|
83
|
+
|
|
84
|
+
let rows;
|
|
85
|
+
try {
|
|
86
|
+
rows = await res.json();
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// First row is the header ["original"]
|
|
92
|
+
return rows.slice(1).map((r) => r[0]).filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch known URLs from AlienVault OTX.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} domain
|
|
99
|
+
* @param {object} opts - { limit }
|
|
100
|
+
* @returns {string[]}
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchOtxUrls(domain, opts = {}) {
|
|
103
|
+
const limit = opts.limit || 200;
|
|
104
|
+
const res = await safeFetch(
|
|
105
|
+
`https://otx.alienvault.com/api/v1/indicators/domain/${encodeURIComponent(domain)}/url_list?limit=${limit}`,
|
|
106
|
+
);
|
|
107
|
+
if (!res || !res.ok) return [];
|
|
108
|
+
|
|
109
|
+
let data;
|
|
110
|
+
try {
|
|
111
|
+
data = await res.json();
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (data.url_list || []).map((e) => e.url).filter(Boolean);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Aggregate Recon ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run full OSINT recon for a domain.
|
|
123
|
+
* Returns subdomains, historical URLs, and extracted parameters.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} domain - Target domain (e.g. "example.com")
|
|
126
|
+
* @param {object} opts - { onPhase }
|
|
127
|
+
* @returns {{ subdomains, urls, parameters, endpoints }}
|
|
128
|
+
*/
|
|
129
|
+
export async function runRecon(domain, opts = {}) {
|
|
130
|
+
const progress = opts.onPhase || (() => {});
|
|
131
|
+
|
|
132
|
+
progress("Querying Certificate Transparency logs (crt.sh)...");
|
|
133
|
+
const subdomains = await discoverSubdomains(domain);
|
|
134
|
+
|
|
135
|
+
progress("Fetching historical URLs (Wayback Machine)...");
|
|
136
|
+
const [waybackUrls, otxUrls] = await Promise.all([
|
|
137
|
+
fetchWaybackUrls(domain),
|
|
138
|
+
fetchOtxUrls(domain),
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
// Merge and deduplicate all discovered URLs
|
|
142
|
+
const allUrls = [...new Set([...waybackUrls, ...otxUrls])];
|
|
143
|
+
|
|
144
|
+
// Extract unique URL paths (for active scanning)
|
|
145
|
+
const endpoints = new Set();
|
|
146
|
+
const parameters = new Set();
|
|
147
|
+
for (const url of allUrls) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = new URL(url);
|
|
150
|
+
// Normalize path
|
|
151
|
+
const path = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
152
|
+
if (path !== "/" && !path.match(/\.(css|js|png|jpg|gif|svg|ico|woff|ttf|eot)$/i)) {
|
|
153
|
+
endpoints.add(path);
|
|
154
|
+
}
|
|
155
|
+
// Extract parameter names
|
|
156
|
+
for (const key of parsed.searchParams.keys()) {
|
|
157
|
+
parameters.add(key);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Invalid URL, skip
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
subdomains,
|
|
166
|
+
urls: allUrls,
|
|
167
|
+
endpoints: [...endpoints].sort(),
|
|
168
|
+
parameters: [...parameters].sort(),
|
|
169
|
+
sources: {
|
|
170
|
+
crtsh: subdomains.length,
|
|
171
|
+
wayback: waybackUrls.length,
|
|
172
|
+
otx: otxUrls.length,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract the root domain from a URL.
|
|
179
|
+
* e.g. "https://app.staging.example.com/path" → "example.com"
|
|
180
|
+
*/
|
|
181
|
+
export function extractDomain(urlString) {
|
|
182
|
+
try {
|
|
183
|
+
const hostname = new URL(urlString).hostname;
|
|
184
|
+
const parts = hostname.split(".");
|
|
185
|
+
// Handle two-part TLDs like .co.uk, .com.au
|
|
186
|
+
if (parts.length >= 3 && parts[parts.length - 2].length <= 3) {
|
|
187
|
+
return parts.slice(-3).join(".");
|
|
188
|
+
}
|
|
189
|
+
return parts.slice(-2).join(".");
|
|
190
|
+
} catch {
|
|
191
|
+
return urlString;
|
|
192
|
+
}
|
|
193
|
+
}
|