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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nuclei Community Template Loader
|
|
3
|
+
*
|
|
4
|
+
* Parses and executes community-authored Nuclei YAML templates.
|
|
5
|
+
* Supports the most common Nuclei template features:
|
|
6
|
+
* - HTTP requests with method, path, headers, body
|
|
7
|
+
* - Matchers: status, word, regex, negative-word, negative-regex, dsl
|
|
8
|
+
* - Extractors: regex, json, kval
|
|
9
|
+
* - Variables and dynamic helpers ({{BaseURL}}, {{Hostname}}, {{rand_int}})
|
|
10
|
+
* - Multi-request templates with matchers-condition
|
|
11
|
+
*
|
|
12
|
+
* Requires the `yaml` npm package (zero-dependency YAML parser).
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* import { loadTemplatesFromDir, executeTemplates } from "./lib/nuclei-loader.js";
|
|
16
|
+
* const templates = await loadTemplatesFromDir("/path/to/nuclei-templates/http");
|
|
17
|
+
* const findings = await executeTemplates("https://target.com", templates);
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
20
|
+
import { join, extname } from "node:path";
|
|
21
|
+
import { executeTemplate } from "./templates.js";
|
|
22
|
+
|
|
23
|
+
let yamlParse = null;
|
|
24
|
+
|
|
25
|
+
/** Lazy-load YAML parser (fails gracefully if not installed) */
|
|
26
|
+
async function getYamlParser() {
|
|
27
|
+
if (yamlParse) return yamlParse;
|
|
28
|
+
try {
|
|
29
|
+
const mod = await import("yaml");
|
|
30
|
+
yamlParse = mod.parse || mod.default?.parse;
|
|
31
|
+
return yamlParse;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load Nuclei YAML templates from a directory (recursively).
|
|
39
|
+
*
|
|
40
|
+
* @param {string} dir - Path to templates directory
|
|
41
|
+
* @param {object} opts - { maxDepth, tags, severity, exclude }
|
|
42
|
+
* @returns {object[]} Parsed template objects
|
|
43
|
+
*/
|
|
44
|
+
export async function loadTemplatesFromDir(dir, opts = {}) {
|
|
45
|
+
const parse = await getYamlParser();
|
|
46
|
+
if (!parse) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'YAML parser not installed. Run: npm install yaml\n' +
|
|
49
|
+
'The "yaml" package is zero-dependency and needed to parse Nuclei community templates.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!existsSync(dir)) {
|
|
54
|
+
throw new Error(`Template directory not found: ${dir}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const templates = [];
|
|
58
|
+
const maxDepth = opts.maxDepth || 5;
|
|
59
|
+
const tags = opts.tags ? new Set(opts.tags) : null;
|
|
60
|
+
const severity = opts.severity ? new Set(opts.severity) : null;
|
|
61
|
+
const exclude = opts.exclude ? new Set(opts.exclude) : null;
|
|
62
|
+
|
|
63
|
+
function walk(currentDir, depth = 0) {
|
|
64
|
+
if (depth > maxDepth) return;
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(currentDir);
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const full = join(currentDir, entry);
|
|
74
|
+
try {
|
|
75
|
+
const stat = statSync(full);
|
|
76
|
+
if (stat.isDirectory()) {
|
|
77
|
+
walk(full, depth + 1);
|
|
78
|
+
} else if (extname(entry) === ".yaml" || extname(entry) === ".yml") {
|
|
79
|
+
const template = parseTemplateFile(full, parse);
|
|
80
|
+
if (template && shouldInclude(template, { tags, severity, exclude })) {
|
|
81
|
+
templates.push(template);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Skip unreadable files
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
walk(dir);
|
|
91
|
+
return templates;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a single Nuclei YAML template file.
|
|
96
|
+
*/
|
|
97
|
+
function parseTemplateFile(filePath, parse) {
|
|
98
|
+
try {
|
|
99
|
+
const content = readFileSync(filePath, "utf-8");
|
|
100
|
+
const doc = parse(content);
|
|
101
|
+
|
|
102
|
+
if (!doc || !doc.id || !doc.info) return null;
|
|
103
|
+
|
|
104
|
+
// Only support HTTP templates for now
|
|
105
|
+
if (!doc.http && !doc.requests) return null;
|
|
106
|
+
|
|
107
|
+
// Normalize: some templates use "requests" instead of "http"
|
|
108
|
+
const http = doc.http || doc.requests || [];
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: doc.id,
|
|
112
|
+
info: {
|
|
113
|
+
name: doc.info.name || doc.id,
|
|
114
|
+
severity: doc.info.severity || "info",
|
|
115
|
+
tags: typeof doc.info.tags === "string"
|
|
116
|
+
? doc.info.tags.split(",").map((t) => t.trim())
|
|
117
|
+
: (doc.info.tags || []),
|
|
118
|
+
description: doc.info.description || "",
|
|
119
|
+
reference: doc.info.reference || [],
|
|
120
|
+
classification: doc.info.classification || {},
|
|
121
|
+
},
|
|
122
|
+
variables: doc.variables || {},
|
|
123
|
+
http: http.map(normalizeHttpBlock),
|
|
124
|
+
_source: filePath,
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return null; // Skip unparseable templates
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Normalize an HTTP request block to our internal format.
|
|
133
|
+
*/
|
|
134
|
+
function normalizeHttpBlock(block) {
|
|
135
|
+
const normalized = {
|
|
136
|
+
method: block.method || "GET",
|
|
137
|
+
path: Array.isArray(block.path) ? block.path[0] : (block.path || "/"),
|
|
138
|
+
headers: block.headers || {},
|
|
139
|
+
body: block.body || null,
|
|
140
|
+
matchers: (block.matchers || []).map(normalizeMatcher),
|
|
141
|
+
"matchers-condition": block["matchers-condition"] || "or",
|
|
142
|
+
extractors: block.extractors || [],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Handle raw requests
|
|
146
|
+
if (block.raw && Array.isArray(block.raw)) {
|
|
147
|
+
const rawReq = block.raw[0];
|
|
148
|
+
const methodMatch = rawReq.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S+)/);
|
|
149
|
+
if (methodMatch) {
|
|
150
|
+
normalized.method = methodMatch[1];
|
|
151
|
+
normalized.path = methodMatch[2];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Normalize a matcher to our internal format.
|
|
160
|
+
*/
|
|
161
|
+
function normalizeMatcher(matcher) {
|
|
162
|
+
const norm = { ...matcher };
|
|
163
|
+
|
|
164
|
+
// Nuclei uses "status" as an array, we do too
|
|
165
|
+
if (norm.type === "status" && typeof norm.status === "number") {
|
|
166
|
+
norm.status = [norm.status];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Handle "negative" flag on word/regex matchers
|
|
170
|
+
if (norm.negative && norm.type === "word") {
|
|
171
|
+
norm.type = "negative-word";
|
|
172
|
+
delete norm.negative;
|
|
173
|
+
}
|
|
174
|
+
if (norm.negative && norm.type === "regex") {
|
|
175
|
+
norm.type = "negative-regex";
|
|
176
|
+
delete norm.negative;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return norm;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a template should be included based on filters.
|
|
184
|
+
*/
|
|
185
|
+
function shouldInclude(template, filters) {
|
|
186
|
+
const { tags, severity, exclude } = filters;
|
|
187
|
+
|
|
188
|
+
if (exclude && exclude.has(template.id)) return false;
|
|
189
|
+
|
|
190
|
+
if (severity) {
|
|
191
|
+
const sev = template.info.severity?.toLowerCase();
|
|
192
|
+
if (!severity.has(sev)) return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (tags) {
|
|
196
|
+
const templateTags = template.info.tags || [];
|
|
197
|
+
if (!templateTags.some((t) => tags.has(t))) return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Execute loaded community templates against a target.
|
|
205
|
+
* Uses the same execution engine as built-in templates.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} baseUrl
|
|
208
|
+
* @param {object[]} templates - Parsed template objects
|
|
209
|
+
* @param {object} opts - { onPhase, concurrency }
|
|
210
|
+
* @returns {object[]} Findings
|
|
211
|
+
*/
|
|
212
|
+
export async function executeTemplates(baseUrl, templates, opts = {}) {
|
|
213
|
+
const progress = opts.onPhase || (() => {});
|
|
214
|
+
const findings = [];
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < templates.length; i++) {
|
|
217
|
+
const template = templates[i];
|
|
218
|
+
|
|
219
|
+
if (i % 50 === 0) {
|
|
220
|
+
progress(`Running community templates... ${i}/${templates.length}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Resolve {{BaseURL}} and {{Hostname}} in paths
|
|
225
|
+
const hostname = new URL(baseUrl).hostname;
|
|
226
|
+
const resolved = {
|
|
227
|
+
...template,
|
|
228
|
+
http: template.http.map((block) => ({
|
|
229
|
+
...block,
|
|
230
|
+
path: block.path
|
|
231
|
+
.replace(/\{\{BaseURL\}\}/g, "")
|
|
232
|
+
.replace(/\{\{Hostname\}\}/g, hostname)
|
|
233
|
+
.replace(/\{\{RootURL\}\}/g, ""),
|
|
234
|
+
})),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const templateFindings = await executeTemplate(baseUrl, resolved);
|
|
238
|
+
for (const f of templateFindings) {
|
|
239
|
+
findings.push({
|
|
240
|
+
severity: f.severity,
|
|
241
|
+
title: f.name,
|
|
242
|
+
description: f.description || "",
|
|
243
|
+
url: f.matchedUrl,
|
|
244
|
+
status: f.status,
|
|
245
|
+
category: f.tags?.[0] || "community-template",
|
|
246
|
+
source: "nuclei-community",
|
|
247
|
+
templateId: f.templateId,
|
|
248
|
+
classification: template.info.classification,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// Skip templates that error during execution
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return findings;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get stats about a template directory.
|
|
261
|
+
*/
|
|
262
|
+
export async function getTemplateStats(dir) {
|
|
263
|
+
const templates = await loadTemplatesFromDir(dir);
|
|
264
|
+
const bySeverity = {};
|
|
265
|
+
const byTag = {};
|
|
266
|
+
|
|
267
|
+
for (const t of templates) {
|
|
268
|
+
const sev = t.info.severity || "unknown";
|
|
269
|
+
bySeverity[sev] = (bySeverity[sev] || 0) + 1;
|
|
270
|
+
for (const tag of t.info.tags) {
|
|
271
|
+
byTag[tag] = (byTag[tag] || 0) + 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
total: templates.length,
|
|
277
|
+
bySeverity,
|
|
278
|
+
topTags: Object.entries(byTag)
|
|
279
|
+
.sort(([, a], [, b]) => b - a)
|
|
280
|
+
.slice(0, 20)
|
|
281
|
+
.map(([tag, count]) => ({ tag, count })),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OWASP Web Security Testing Guide (WSTG) coverage mapping.
|
|
3
|
+
*
|
|
4
|
+
* Maps Penthera probes and finding categories to WSTG v4.2 test IDs.
|
|
5
|
+
* @see https://owasp.org/www-project-web-security-testing-guide/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Penthera probe → WSTG tests exercised when the probe runs. */
|
|
9
|
+
export const PROBE_WSTG = [
|
|
10
|
+
{ id: "reachability", probe: "Reachability & fingerprint", wstg: ["WSTG-INFO-02", "WSTG-INFO-04"], profiles: ["quick", "standard", "deep"], modes: ["url"] },
|
|
11
|
+
{ id: "endpoint-discovery", probe: "Endpoint discovery", wstg: ["WSTG-INFO-06", "WSTG-INFO-07"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
12
|
+
{ id: "tls", probe: "TLS / certificate audit", wstg: ["WSTG-CRYP-01"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
13
|
+
{ id: "security-headers", probe: "Security headers", wstg: ["WSTG-CONF-12", "WSTG-CONF-14"], profiles: ["quick", "standard", "deep"], modes: ["url"] },
|
|
14
|
+
{ id: "openapi", probe: "OpenAPI / Swagger exposure", wstg: ["WSTG-INFO-09", "WSTG-CONF-02"], profiles: ["quick", "standard", "deep"], modes: ["url"] },
|
|
15
|
+
{ id: "sensitive-files", probe: "Sensitive file templates", wstg: ["WSTG-CONF-02", "WSTG-CONF-04"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
16
|
+
{ id: "cors", probe: "CORS validation", wstg: ["WSTG-CLNT-07"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
17
|
+
{ id: "cookie", probe: "Cookie security", wstg: ["WSTG-SESS-02"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
18
|
+
{ id: "auth-hardening", probe: "Auth endpoint hardening", wstg: ["WSTG-ATHN-03", "WSTG-ATHN-10"], profiles: ["quick", "standard", "deep"], modes: ["url"] },
|
|
19
|
+
{ id: "client-auth", probe: "Client-side-only auth detection", wstg: ["WSTG-ATHZ-02", "WSTG-CLNT-01"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
20
|
+
{ id: "jwt", probe: "JWT probes", wstg: ["WSTG-ATHN-04", "WSTG-SESS-10"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
21
|
+
{ id: "idor", probe: "IDOR / BOLA", wstg: ["WSTG-ATHZ-04", "WSTG-ATHZ-05"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
22
|
+
{ id: "oauth", probe: "OAuth open redirect", wstg: ["WSTG-ATHN-11", "WSTG-CLNT-04"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
23
|
+
{ id: "adaptive", probe: "Adaptive escalation probes", wstg: ["WSTG-ATHZ-02", "WSTG-ATHZ-04", "WSTG-CLNT-07"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
24
|
+
{ id: "retirejs", probe: "Retire.js (JS CVEs)", wstg: ["WSTG-CONF-04", "WSTG-CLNT-02"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
25
|
+
{ id: "param-discovery", probe: "Parameter discovery", wstg: ["WSTG-INFO-06"], profiles: ["standard", "deep"], modes: ["url"] },
|
|
26
|
+
{ id: "recon", probe: "OSINT recon", wstg: ["WSTG-INFO-01", "WSTG-INFO-07"], profiles: ["deep"], modes: ["url"] },
|
|
27
|
+
{ id: "sqli", probe: "SQL injection", wstg: ["WSTG-INPV-05"], profiles: ["deep"], modes: ["url"] },
|
|
28
|
+
{ id: "ssti", probe: "SSTI", wstg: ["WSTG-INPV-18"], profiles: ["deep"], modes: ["url"] },
|
|
29
|
+
{ id: "ssrf", probe: "SSRF", wstg: ["WSTG-INPV-19"], profiles: ["deep"], modes: ["url"] },
|
|
30
|
+
{ id: "xss", probe: "Reflected XSS", wstg: ["WSTG-INPV-01", "WSTG-CLNT-01"], profiles: ["deep"], modes: ["url"] },
|
|
31
|
+
{ id: "cmdi", probe: "Command injection", wstg: ["WSTG-INPV-12"], profiles: ["deep"], modes: ["url"] },
|
|
32
|
+
{ id: "open-redirect", probe: "Open redirect (injection suite)", wstg: ["WSTG-CLNT-04"], profiles: ["deep"], modes: ["url"] },
|
|
33
|
+
{ id: "api-fuzzing", probe: "API fuzzing", wstg: ["WSTG-INPV-11", "WSTG-BUSL-05"], profiles: ["deep"], modes: ["url"] },
|
|
34
|
+
{ id: "secret-scanning", probe: "Secret scanning", wstg: ["WSTG-CONF-02", "WSTG-CONF-05"], profiles: ["quick", "standard", "deep"], modes: ["repo"] },
|
|
35
|
+
{ id: "trust-boundary", probe: "Route / trust-boundary analysis", wstg: ["WSTG-ATHZ-02", "WSTG-CONF-02"], profiles: ["quick", "standard", "deep"], modes: ["repo"] },
|
|
36
|
+
{ id: "risky-patterns", probe: "Risky code patterns", wstg: ["WSTG-INPV-01", "WSTG-INPV-12"], profiles: ["standard", "deep"], modes: ["repo"] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Finding category/source → WSTG tags for individual findings. */
|
|
40
|
+
export const FINDING_WSTG = {
|
|
41
|
+
tls: ["WSTG-CRYP-01"],
|
|
42
|
+
exposure: ["WSTG-CONF-02", "WSTG-CONF-04"],
|
|
43
|
+
cors: ["WSTG-CLNT-07"],
|
|
44
|
+
cookie: ["WSTG-SESS-02"],
|
|
45
|
+
auth: ["WSTG-ATHN-03", "WSTG-ATHN-04"],
|
|
46
|
+
"auth-bypass": ["WSTG-ATHZ-02", "WSTG-ATHN-04"],
|
|
47
|
+
secrets: ["WSTG-CONF-02", "WSTG-CONF-05"],
|
|
48
|
+
sqli: ["WSTG-INPV-05"],
|
|
49
|
+
ssti: ["WSTG-INPV-18"],
|
|
50
|
+
ssrf: ["WSTG-INPV-19"],
|
|
51
|
+
xss: ["WSTG-INPV-01", "WSTG-CLNT-01"],
|
|
52
|
+
cmdi: ["WSTG-INPV-12"],
|
|
53
|
+
"open-redirect": ["WSTG-CLNT-04", "WSTG-ATHN-11"],
|
|
54
|
+
fuzzing: ["WSTG-INPV-11"],
|
|
55
|
+
recon: ["WSTG-INFO-01"],
|
|
56
|
+
cve: ["WSTG-CONF-04"],
|
|
57
|
+
"rate-limiting": ["WSTG-ATHN-10"],
|
|
58
|
+
"code-pattern": ["WSTG-INPV-01", "WSTG-INPV-12"],
|
|
59
|
+
config: ["WSTG-CONF-02"],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const SOURCE_WSTG = {
|
|
63
|
+
"header-audit": ["WSTG-CONF-12"],
|
|
64
|
+
"openapi-scan": ["WSTG-INFO-09", "WSTG-CONF-02"],
|
|
65
|
+
"auth-probe": ["WSTG-ATHN-03", "WSTG-ATHN-10"],
|
|
66
|
+
"client-auth-probe": ["WSTG-ATHZ-02", "WSTG-CLNT-01"],
|
|
67
|
+
"jwt-probe": ["WSTG-ATHN-04", "WSTG-SESS-10"],
|
|
68
|
+
"idor-probe": ["WSTG-ATHZ-04", "WSTG-ATHZ-05"],
|
|
69
|
+
"oauth-probe": ["WSTG-ATHN-11", "WSTG-CLNT-04"],
|
|
70
|
+
"adaptive-probe": ["WSTG-ATHZ-02", "WSTG-ATHZ-04"],
|
|
71
|
+
"secret-scan": ["WSTG-CONF-02", "WSTG-CONF-05"],
|
|
72
|
+
"trust-boundary": ["WSTG-ATHZ-02"],
|
|
73
|
+
"static-analysis": ["WSTG-INPV-01"],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const WSTG_NAMES = {
|
|
77
|
+
"WSTG-INFO-01": "Conduct Search Engine Discovery",
|
|
78
|
+
"WSTG-INFO-02": "Fingerprint Web Server",
|
|
79
|
+
"WSTG-INFO-04": "Enumerate Applications on Webserver",
|
|
80
|
+
"WSTG-INFO-06": "Identify Application Entry Points",
|
|
81
|
+
"WSTG-INFO-07": "Map Execution Paths Through Application",
|
|
82
|
+
"WSTG-INFO-09": "Fingerprint Web Application",
|
|
83
|
+
"WSTG-CONF-02": "Test Application Platform Configuration",
|
|
84
|
+
"WSTG-CONF-04": "Review Old Backup and Unreferenced Files",
|
|
85
|
+
"WSTG-CONF-05": "Enumerate Infrastructure and Application Admin Interfaces",
|
|
86
|
+
"WSTG-CONF-12": "Test for Content Security Policy",
|
|
87
|
+
"WSTG-CONF-13": "Test for Path Confusion",
|
|
88
|
+
"WSTG-CONF-14": "Test Other HTTP Security Header Misconfigurations",
|
|
89
|
+
"WSTG-CRYP-01": "Testing for Weak Transport Layer Security",
|
|
90
|
+
"WSTG-CRYP-02": "Testing for Padding Oracle",
|
|
91
|
+
"WSTG-ATHN-03": "Testing for Weak Lock Out Mechanism",
|
|
92
|
+
"WSTG-ATHN-04": "Testing for Bypassing Authentication Schema",
|
|
93
|
+
"WSTG-ATHN-10": "Testing for Weaker Authentication in Alternative Channel",
|
|
94
|
+
"WSTG-ATHN-11": "Testing Multi-Factor Authentication",
|
|
95
|
+
"WSTG-ATHZ-02": "Testing for Bypassing Authorization Schema",
|
|
96
|
+
"WSTG-ATHZ-04": "Testing for Insecure Direct Object References",
|
|
97
|
+
"WSTG-ATHZ-05": "Testing for OAuth Weaknesses",
|
|
98
|
+
"WSTG-SESS-02": "Testing for Cookies Attributes",
|
|
99
|
+
"WSTG-SESS-10": "Testing JSON Web Tokens",
|
|
100
|
+
"WSTG-INPV-01": "Testing for Reflected Cross Site Scripting",
|
|
101
|
+
"WSTG-INPV-05": "Testing for SQL Injection",
|
|
102
|
+
"WSTG-INPV-11": "Testing for Code Injection",
|
|
103
|
+
"WSTG-INPV-12": "Testing for Command Injection",
|
|
104
|
+
"WSTG-INPV-18": "Testing for Server-Side Template Injection",
|
|
105
|
+
"WSTG-INPV-19": "Testing for Server-Side Request Forgery",
|
|
106
|
+
"WSTG-CLNT-01": "Testing for DOM-Based Cross Site Scripting",
|
|
107
|
+
"WSTG-CLNT-02": "Testing for JavaScript Execution",
|
|
108
|
+
"WSTG-CLNT-04": "Testing for Client-Side URL Redirect",
|
|
109
|
+
"WSTG-CLNT-07": "Testing Cross Origin Resource Sharing",
|
|
110
|
+
"WSTG-BUSL-05": "Test Number of Times a Function Can Be Used Limits",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function unique(arr) {
|
|
114
|
+
return [...new Set(arr)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Resolve WSTG tags for a single finding. */
|
|
118
|
+
export function resolveWstgTags(finding) {
|
|
119
|
+
const tags = [];
|
|
120
|
+
if (finding?.category && FINDING_WSTG[finding.category]) {
|
|
121
|
+
tags.push(...FINDING_WSTG[finding.category]);
|
|
122
|
+
}
|
|
123
|
+
if (finding?.source && SOURCE_WSTG[finding.source]) {
|
|
124
|
+
tags.push(...SOURCE_WSTG[finding.source]);
|
|
125
|
+
}
|
|
126
|
+
const blob = `${finding?.title || ""} ${finding?.description || ""}`.toLowerCase();
|
|
127
|
+
if (/security header|content-security-policy|x-frame-options/i.test(blob)) {
|
|
128
|
+
tags.push("WSTG-CONF-12", "WSTG-CONF-14");
|
|
129
|
+
}
|
|
130
|
+
if (/openapi|swagger|\/docs|redoc/i.test(blob)) tags.push("WSTG-INFO-09");
|
|
131
|
+
if (/idor|bola|direct object/i.test(blob)) tags.push("WSTG-ATHZ-04");
|
|
132
|
+
if (/oauth|redirect_uri/i.test(blob)) tags.push("WSTG-ATHZ-05", "WSTG-CLNT-04");
|
|
133
|
+
return unique(tags);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** WSTG tests exercised for a given scan profile and modes. */
|
|
137
|
+
export function getProbesForScan(result, profile = "standard") {
|
|
138
|
+
// When the scan recorded which probes actually executed, trust that, the
|
|
139
|
+
// coverage report must never claim a probe ran when it didn't (e.g. injection
|
|
140
|
+
// probes that early-returned because no endpoints were discovered, or probes
|
|
141
|
+
// skipped by profile). Fall back to the profile/mode catalog only when no
|
|
142
|
+
// execution record is present (older callers, unit tests).
|
|
143
|
+
const executed = Array.isArray(result?.executedProbes) ? new Set(result.executedProbes) : null;
|
|
144
|
+
if (executed) {
|
|
145
|
+
return PROBE_WSTG.filter((p) => executed.has(p.id));
|
|
146
|
+
}
|
|
147
|
+
const modes = result?.modes || (result?.mode ? [result.mode] : ["url"]);
|
|
148
|
+
return PROBE_WSTG.filter((p) => {
|
|
149
|
+
if (!p.profiles.includes(profile)) return false;
|
|
150
|
+
return p.modes.some((m) => modes.includes(m));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build coverage summary for reports. */
|
|
155
|
+
export function buildWstgCoverage(result, profile = "standard") {
|
|
156
|
+
const probes = getProbesForScan(result, profile);
|
|
157
|
+
const exercised = unique(probes.flatMap((p) => p.wstg));
|
|
158
|
+
const triggered = unique(
|
|
159
|
+
(result?.findings || []).flatMap((f) => resolveWstgTags(f)),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
profile,
|
|
164
|
+
probesRun: probes.length,
|
|
165
|
+
wstgExercised: exercised.sort(),
|
|
166
|
+
wstgTriggered: triggered.sort(),
|
|
167
|
+
wstgNames: WSTG_NAMES,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Markdown section for reports. */
|
|
172
|
+
export function formatWstgMarkdown(coverage) {
|
|
173
|
+
const lines = [];
|
|
174
|
+
lines.push("## OWASP WSTG coverage");
|
|
175
|
+
lines.push("");
|
|
176
|
+
lines.push(
|
|
177
|
+
`This scan exercised **${coverage.probesRun}** Penthera probes mapped to ` +
|
|
178
|
+
`**${coverage.wstgExercised.length}** OWASP Web Security Testing Guide (WSTG v4.2) test areas.`,
|
|
179
|
+
);
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push("| WSTG ID | Test name | Status |");
|
|
182
|
+
lines.push("|---------|-----------|--------|");
|
|
183
|
+
|
|
184
|
+
for (const id of coverage.wstgExercised) {
|
|
185
|
+
const name = coverage.wstgNames[id] || "See OWASP WSTG";
|
|
186
|
+
const status = coverage.wstgTriggered.includes(id) ? "Finding detected" : "Probed, no finding";
|
|
187
|
+
lines.push(`| \`${id}\` | ${name} | ${status} |`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
lines.push("");
|
|
191
|
+
lines.push(
|
|
192
|
+
"> Full mapping: [docs/owasp-wstg-coverage.md](https://github.com/danoszz/penthera/blob/main/docs/owasp-wstg-coverage.md)",
|
|
193
|
+
);
|
|
194
|
+
lines.push("");
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Enrich findings with wstg tags (returns new array). */
|
|
199
|
+
export function enrichFindingsWithWstg(findings) {
|
|
200
|
+
return (findings || []).map((f) => ({
|
|
201
|
+
...f,
|
|
202
|
+
wstg: resolveWstgTags(f),
|
|
203
|
+
}));
|
|
204
|
+
}
|