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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remediation throughput / audit-loop tracking (NIS2 measure f).
|
|
3
|
+
*
|
|
4
|
+
* More findings is not more security, what matters is closing them. This module
|
|
5
|
+
* turns repeated scans into an audit-loop artifact: time-to-fix, ageing of open
|
|
6
|
+
* items, and drift since last scan. It persists a small history keyed by a
|
|
7
|
+
* STABLE finding fingerprint (severity- and count-independent) so a re-worded or
|
|
8
|
+
* re-counted finding isn't seen as "resolved + new" churn.
|
|
9
|
+
*
|
|
10
|
+
* Pure functions take `now` (ISO string) so they're deterministic and testable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stable fingerprint for a finding: source + category + normalized url + title,
|
|
17
|
+
* with digit runs collapsed so ids/counts (e.g. "5 active params", "/users/1")
|
|
18
|
+
* don't create spurious new/resolved churn. Severity is intentionally excluded.
|
|
19
|
+
*/
|
|
20
|
+
export function fingerprintFinding(f) {
|
|
21
|
+
// Collapse only path segments that are entirely numeric (IDOR object ids like
|
|
22
|
+
// /users/1 → /users/#), keeping version-like segments (v1, v2) distinct.
|
|
23
|
+
const normUrl = (u) => String(u || "").toLowerCase().trim()
|
|
24
|
+
.split("/").map((seg) => (/^\d+$/.test(seg) ? "#" : seg)).join("/");
|
|
25
|
+
// Keep digits in the title: versions, ciphers, CVEs, protocols, and IP:port
|
|
26
|
+
// pairs are exactly what tell otherwise-similar findings apart.
|
|
27
|
+
const normTitle = (t) => String(t || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
28
|
+
// Dependency-CVE findings can share one title (the CVE lives in the
|
|
29
|
+
// description), so fold in a short description discriminator for those.
|
|
30
|
+
const disc = (f.category === "cve" || f.category === "js-vulnerability")
|
|
31
|
+
? "|" + String(f.description || "").toLowerCase().replace(/\s+/g, " ").slice(0, 80)
|
|
32
|
+
: "";
|
|
33
|
+
return [f.source || "", f.category || "", normUrl(f.url), normTitle(f.title)].join("|") + disc;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fresh, empty history store. */
|
|
37
|
+
export function emptyHistory() {
|
|
38
|
+
return { version: 1, findings: {}, scans: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fold the current findings into the history. Returns a new history object.
|
|
43
|
+
* - a finding seen now that wasn't open before → firstSeen = now
|
|
44
|
+
* - a finding previously resolved but seen again → reopened (resolvedAt cleared)
|
|
45
|
+
* - a tracked finding not seen now → resolvedAt = now
|
|
46
|
+
*/
|
|
47
|
+
export function updateHistory(history, findings, now) {
|
|
48
|
+
// Deep-copy records so we never mutate the caller's history object.
|
|
49
|
+
const h = {
|
|
50
|
+
version: 1,
|
|
51
|
+
findings: Object.fromEntries(
|
|
52
|
+
Object.entries(history?.findings || {}).map(([k, v]) => [k, { ...v }]),
|
|
53
|
+
),
|
|
54
|
+
scans: [...(history?.scans || [])],
|
|
55
|
+
};
|
|
56
|
+
const current = new Map();
|
|
57
|
+
for (const f of findings || []) {
|
|
58
|
+
if (f.severity === "info") continue; // track actionable findings only
|
|
59
|
+
current.set(fingerprintFinding(f), f);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let newCount = 0;
|
|
63
|
+
let reopened = 0;
|
|
64
|
+
for (const [fp, f] of current) {
|
|
65
|
+
const rec = h.findings[fp];
|
|
66
|
+
if (!rec) {
|
|
67
|
+
h.findings[fp] = {
|
|
68
|
+
firstSeen: now, lastSeen: now, resolvedAt: null,
|
|
69
|
+
severity: f.severity, title: f.title, url: f.url || null, category: f.category || null,
|
|
70
|
+
};
|
|
71
|
+
newCount++;
|
|
72
|
+
} else {
|
|
73
|
+
rec.lastSeen = now;
|
|
74
|
+
rec.severity = f.severity; // keep latest severity
|
|
75
|
+
if (rec.resolvedAt) { rec.resolvedAt = null; reopened++; } // reappeared
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let resolvedCount = 0;
|
|
80
|
+
for (const [fp, rec] of Object.entries(h.findings)) {
|
|
81
|
+
if (!current.has(fp) && !rec.resolvedAt) {
|
|
82
|
+
rec.resolvedAt = now;
|
|
83
|
+
resolvedCount++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const open = Object.values(h.findings).filter((r) => !r.resolvedAt).length;
|
|
88
|
+
h.scans.push({ at: now, open, new: newCount, resolved: resolvedCount, reopened });
|
|
89
|
+
return h;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function daysBetween(aIso, bIso) {
|
|
93
|
+
const a = new Date(aIso).getTime();
|
|
94
|
+
const b = new Date(bIso).getTime();
|
|
95
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
|
96
|
+
return Math.max(0, Math.round((b - a) / DAY_MS));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function median(nums) {
|
|
100
|
+
if (!nums.length) return null;
|
|
101
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
102
|
+
const mid = Math.floor(s.length / 2);
|
|
103
|
+
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Compute the audit-loop artifact from a history store. */
|
|
107
|
+
export function buildAuditLoop(history, now) {
|
|
108
|
+
const recs = Object.values(history?.findings || {});
|
|
109
|
+
const open = recs.filter((r) => !r.resolvedAt);
|
|
110
|
+
const resolved = recs.filter((r) => r.resolvedAt);
|
|
111
|
+
|
|
112
|
+
const openBySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
113
|
+
for (const r of open) if (openBySeverity[r.severity] != null) openBySeverity[r.severity]++;
|
|
114
|
+
|
|
115
|
+
const ageOf = (r) => daysBetween(r.firstSeen, now);
|
|
116
|
+
const ttf = resolved.map((r) => daysBetween(r.firstSeen, r.resolvedAt)).filter((n) => n != null);
|
|
117
|
+
|
|
118
|
+
const ageing = { "0-7": 0, "8-30": 0, "31-90": 0, "90+": 0 };
|
|
119
|
+
for (const r of open) {
|
|
120
|
+
const d = ageOf(r) ?? 0;
|
|
121
|
+
if (d <= 7) ageing["0-7"]++;
|
|
122
|
+
else if (d <= 30) ageing["8-30"]++;
|
|
123
|
+
else if (d <= 90) ageing["31-90"]++;
|
|
124
|
+
else ageing["90+"]++;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const openList = open
|
|
128
|
+
.map((r) => ({ title: r.title, url: r.url, severity: r.severity, ageDays: ageOf(r), firstSeen: r.firstSeen }))
|
|
129
|
+
.sort((a, b) => (b.ageDays ?? 0) - (a.ageDays ?? 0));
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
generated_at: now,
|
|
133
|
+
scansRecorded: (history?.scans || []).length,
|
|
134
|
+
openCount: open.length,
|
|
135
|
+
resolvedCount: resolved.length,
|
|
136
|
+
openBySeverity,
|
|
137
|
+
medianTimeToFixDays: median(ttf),
|
|
138
|
+
oldestOpenDays: openList.length ? (openList[0].ageDays ?? 0) : 0,
|
|
139
|
+
ageing,
|
|
140
|
+
open: openList,
|
|
141
|
+
lastScan: (history?.scans || []).slice(-1)[0] || null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Render the audit loop as a Markdown section. */
|
|
146
|
+
export function formatAuditLoopMarkdown(a) {
|
|
147
|
+
if (!a) return "";
|
|
148
|
+
const L = [];
|
|
149
|
+
L.push("## Remediation & audit loop (measure f)");
|
|
150
|
+
L.push("");
|
|
151
|
+
L.push(`Across **${a.scansRecorded}** recorded scan${a.scansRecorded === 1 ? "" : "s"}: **${a.openCount}** open · **${a.resolvedCount}** resolved.`);
|
|
152
|
+
L.push("");
|
|
153
|
+
L.push(`- **Open by severity:** ${a.openBySeverity.critical} critical · ${a.openBySeverity.high} high · ${a.openBySeverity.medium} medium · ${a.openBySeverity.low} low`);
|
|
154
|
+
L.push(`- **Median time-to-fix:** ${a.medianTimeToFixDays == null ? "-" : `${a.medianTimeToFixDays} day(s)`}`);
|
|
155
|
+
L.push(`- **Oldest open finding:** ${a.oldestOpenDays} day(s)`);
|
|
156
|
+
L.push(`- **Ageing of open items:** 0-7d: ${a.ageing["0-7"]} · 8-30d: ${a.ageing["8-30"]} · 31-90d: ${a.ageing["31-90"]} · 90d+: ${a.ageing["90+"]}`);
|
|
157
|
+
if (a.lastScan) L.push(`- **Since last scan:** ${a.lastScan.new} new · ${a.lastScan.resolved} resolved${a.lastScan.reopened ? ` · ${a.lastScan.reopened} reopened` : ""}`);
|
|
158
|
+
L.push("");
|
|
159
|
+
if (a.open.length) {
|
|
160
|
+
L.push("| Open finding | Severity | Age (days) |");
|
|
161
|
+
L.push("|--------------|----------|-----------|");
|
|
162
|
+
for (const o of a.open.slice(0, 20)) {
|
|
163
|
+
L.push(`| ${o.title}${o.url ? ` (${o.url})` : ""} | ${o.severity} | ${o.ageDays ?? "-"} |`);
|
|
164
|
+
}
|
|
165
|
+
L.push("");
|
|
166
|
+
}
|
|
167
|
+
L.push("> Measure f is about closing the loop, not lengthening the backlog. Track time-to-fix and ageing over time; re-run on a cadence.");
|
|
168
|
+
L.push("");
|
|
169
|
+
return L.join("\n");
|
|
170
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive black-box probes for live URL scans.
|
|
3
|
+
* Uses OpenAPI paths and discovered endpoints as the attack surface.
|
|
4
|
+
*/
|
|
5
|
+
import { SecurityKnowledgeGraph } from "../knowledge-graph.js";
|
|
6
|
+
import {
|
|
7
|
+
probeAuthEscalation,
|
|
8
|
+
probeMethodConfusion,
|
|
9
|
+
probeInfoLeakage,
|
|
10
|
+
probeCors,
|
|
11
|
+
probePrototypePollution,
|
|
12
|
+
probeContentTypeConfusion,
|
|
13
|
+
probeTimingAnalysis,
|
|
14
|
+
} from "../adaptive-probes.js";
|
|
15
|
+
import { normalizeBaseUrl } from "../../src/utils/url.js";
|
|
16
|
+
|
|
17
|
+
function graphFindingToPenthera(base, finding) {
|
|
18
|
+
return {
|
|
19
|
+
severity: finding.severity,
|
|
20
|
+
title: finding.title,
|
|
21
|
+
description: finding.detail || finding.title,
|
|
22
|
+
url: finding.routeUrl ? `${base}${finding.routeUrl}` : base,
|
|
23
|
+
category: finding.category,
|
|
24
|
+
source: "adaptive-probe",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build route objects from OpenAPI paths. */
|
|
29
|
+
export function routesFromOpenApi(spec, paths = []) {
|
|
30
|
+
if (!spec?.paths && paths.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
const pathMap = spec?.paths || {};
|
|
33
|
+
const entries = paths.length > 0 ? paths : Object.keys(pathMap);
|
|
34
|
+
|
|
35
|
+
return entries.map((path) => {
|
|
36
|
+
const ops = pathMap[path] || {};
|
|
37
|
+
const methods = Object.keys(ops)
|
|
38
|
+
.filter((m) => ["get", "post", "put", "patch", "delete", "options"].includes(m))
|
|
39
|
+
.map((m) => m.toUpperCase());
|
|
40
|
+
const security = ops.get?.security || ops.post?.security || spec?.security;
|
|
41
|
+
const auth = security?.length ? ["user"] : ["public"];
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
url: path.startsWith("/") ? path : `/${path}`,
|
|
45
|
+
methods: methods.length ? methods : ["GET"],
|
|
46
|
+
auth,
|
|
47
|
+
inputs: [],
|
|
48
|
+
externalCalls: [],
|
|
49
|
+
securityPatterns: [],
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build routes from discovered endpoint list. */
|
|
55
|
+
export function routesFromEndpoints(endpoints = []) {
|
|
56
|
+
return endpoints
|
|
57
|
+
.filter((ep) => ep.path && ep.status !== 404)
|
|
58
|
+
.slice(0, 25)
|
|
59
|
+
.map((ep) => ({
|
|
60
|
+
url: ep.path.startsWith("/") ? ep.path : `/${ep.path}`,
|
|
61
|
+
methods: ["GET"],
|
|
62
|
+
auth: ["public"],
|
|
63
|
+
inputs: [],
|
|
64
|
+
externalCalls: [],
|
|
65
|
+
securityPatterns: [],
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run adaptive probe chains against a live target.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} target
|
|
73
|
+
* @param {object} opts - { spec, paths, endpoints, maxRoutes, onPhase }
|
|
74
|
+
*/
|
|
75
|
+
export async function runAdaptiveProbes(target, opts = {}) {
|
|
76
|
+
const base = normalizeBaseUrl(target);
|
|
77
|
+
const progress = opts.onPhase || (() => {});
|
|
78
|
+
const graph = new SecurityKnowledgeGraph();
|
|
79
|
+
|
|
80
|
+
let routes = routesFromOpenApi(opts.spec, opts.paths);
|
|
81
|
+
if (routes.length === 0) {
|
|
82
|
+
routes = routesFromEndpoints(opts.endpoints);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const maxRoutes = opts.maxRoutes || 15;
|
|
86
|
+
routes = routes.slice(0, maxRoutes);
|
|
87
|
+
|
|
88
|
+
if (routes.length === 0) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
progress(`Running adaptive probes on ${routes.length} routes...`);
|
|
93
|
+
|
|
94
|
+
const allFindings = [];
|
|
95
|
+
const probes = [
|
|
96
|
+
probeAuthEscalation,
|
|
97
|
+
probeMethodConfusion,
|
|
98
|
+
probeInfoLeakage,
|
|
99
|
+
probeCors,
|
|
100
|
+
probePrototypePollution,
|
|
101
|
+
probeContentTypeConfusion,
|
|
102
|
+
probeTimingAnalysis,
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
for (const route of routes) {
|
|
106
|
+
graph.addRoute(route);
|
|
107
|
+
for (const probe of probes) {
|
|
108
|
+
try {
|
|
109
|
+
const findings = await probe(base, route, graph);
|
|
110
|
+
allFindings.push(...findings.map((f) => graphFindingToPenthera(base, f)));
|
|
111
|
+
} catch {
|
|
112
|
+
// Skip failing probe on this route
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Surface correlated attack chains the knowledge graph found (e.g. an auth
|
|
118
|
+
// bypass that combines with an IDOR into cross-user access). This was computed
|
|
119
|
+
// nowhere before; it's the highest-value output of the graph. Best-effort.
|
|
120
|
+
try {
|
|
121
|
+
for (const chain of graph.discoverChains()) {
|
|
122
|
+
allFindings.push({
|
|
123
|
+
severity: chain.severity,
|
|
124
|
+
title: `Attack chain: ${chain.type}`,
|
|
125
|
+
description: chain.description,
|
|
126
|
+
url: base,
|
|
127
|
+
category: "attack-chain",
|
|
128
|
+
source: "adaptive-probe",
|
|
129
|
+
chain: chain.path || [],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Chain discovery is best-effort; never fail the scan over it.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return allFindings;
|
|
137
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect client-side-only authentication (UI hides data but API is open).
|
|
3
|
+
*/
|
|
4
|
+
import { joinUrl } from "../../src/utils/url.js";
|
|
5
|
+
import { probeFetch } from "../../src/utils/http.js";
|
|
6
|
+
|
|
7
|
+
const SCRIPT_CANDIDATES = ["/script.js", "/app.js", "/main.js", "/static/script.js"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} baseUrl
|
|
11
|
+
*/
|
|
12
|
+
export async function probeClientSideAuth(baseUrl, opts = {}) {
|
|
13
|
+
const timeout = opts.timeout || 8_000;
|
|
14
|
+
const findings = [];
|
|
15
|
+
|
|
16
|
+
for (const path of SCRIPT_CANDIDATES) {
|
|
17
|
+
const res = await probeFetch(joinUrl(baseUrl, path), { timeout });
|
|
18
|
+
if (!res || res.status !== 200 || !/function|const|fetch/i.test(res.body)) continue;
|
|
19
|
+
|
|
20
|
+
const loadsDataOnBoot = /DOMContentLoaded|initApp|document\.ready/i.test(res.body)
|
|
21
|
+
&& /loadAndRender|fetch\s*\(\s*['"][^'"]+\.json/i.test(res.body);
|
|
22
|
+
const hasLoginGate = /doLogin|loginScreen|Sign In/i.test(res.body);
|
|
23
|
+
|
|
24
|
+
if (loadsDataOnBoot && hasLoginGate) {
|
|
25
|
+
findings.push({
|
|
26
|
+
severity: "high",
|
|
27
|
+
title: "Client-side-only authentication",
|
|
28
|
+
description:
|
|
29
|
+
`${path} loads dashboard data on page init before login completes. ` +
|
|
30
|
+
"If API endpoints are unauthenticated, all data is accessible without credentials.",
|
|
31
|
+
url: joinUrl(baseUrl, path),
|
|
32
|
+
status: 200,
|
|
33
|
+
category: "auth",
|
|
34
|
+
source: "client-auth-probe",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Extract JSON data URLs referenced in script
|
|
39
|
+
const dataUrls = [...res.body.matchAll(/['"]([^'"]+\.json)['"]/g)].map((m) => m[1]);
|
|
40
|
+
for (const rel of [...new Set(dataUrls)].slice(0, 5)) {
|
|
41
|
+
const dataPath = rel.startsWith("/") ? rel : `/${rel}`;
|
|
42
|
+
const dataRes = await probeFetch(joinUrl(baseUrl, dataPath), { timeout });
|
|
43
|
+
if (dataRes && dataRes.status === 200 && dataRes.body.length > 10) {
|
|
44
|
+
findings.push({
|
|
45
|
+
severity: "high",
|
|
46
|
+
title: `Sensitive data exposed: GET ${dataPath}`,
|
|
47
|
+
description: "JSON data endpoint responds without authentication.",
|
|
48
|
+
url: joinUrl(baseUrl, dataPath),
|
|
49
|
+
status: 200,
|
|
50
|
+
category: "auth",
|
|
51
|
+
source: "client-auth-probe",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email authentication posture, SPF, DMARC, and MX via DNS.
|
|
3
|
+
*
|
|
4
|
+
* Spoofed email is one of the most common attack vectors, and every security
|
|
5
|
+
* questionnaire asks about it, yet most vibecoded apps ship without SPF or
|
|
6
|
+
* DMARC. These are passive DNS lookups against the domain you're scanning, no
|
|
7
|
+
* traffic to the app itself.
|
|
8
|
+
*
|
|
9
|
+
* DKIM needs an organisation-specific selector, which can't be enumerated
|
|
10
|
+
* reliably, so we flag it as a manual follow-up rather than guessing selectors.
|
|
11
|
+
*
|
|
12
|
+
* The resolver is injectable so the evaluation logic is unit-testable without
|
|
13
|
+
* live DNS.
|
|
14
|
+
*/
|
|
15
|
+
import { resolveTxt as dnsResolveTxt, resolveMx as dnsResolveMx } from "node:dns/promises";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_RESOLVER = { resolveTxt: dnsResolveTxt, resolveMx: dnsResolveMx };
|
|
18
|
+
|
|
19
|
+
async function safeTxt(resolver, name) {
|
|
20
|
+
try {
|
|
21
|
+
const records = await resolver.resolveTxt(name);
|
|
22
|
+
// resolveTxt returns string[][]; each record's chunks must be joined.
|
|
23
|
+
return records.map((chunks) => (Array.isArray(chunks) ? chunks.join("") : String(chunks)));
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function safeMx(resolver, name) {
|
|
30
|
+
try {
|
|
31
|
+
return await resolver.resolveMx(name);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Evaluate SPF from a domain's root TXT records. */
|
|
38
|
+
export function evaluateSpf(txtRecords) {
|
|
39
|
+
const spf = (txtRecords || []).find((r) => /^v=spf1\b/i.test(r.trim()));
|
|
40
|
+
if (!spf) {
|
|
41
|
+
return { present: false, record: null, finding: {
|
|
42
|
+
severity: "medium",
|
|
43
|
+
title: "No SPF record",
|
|
44
|
+
description: "No `v=spf1` TXT record found. Anyone can send email that appears to come from this domain.",
|
|
45
|
+
category: "email-auth",
|
|
46
|
+
source: "email-dns-probe",
|
|
47
|
+
} };
|
|
48
|
+
}
|
|
49
|
+
if (/[+]all\b/i.test(spf)) {
|
|
50
|
+
return { present: true, record: spf, finding: {
|
|
51
|
+
severity: "high",
|
|
52
|
+
title: "SPF allows all senders (+all)",
|
|
53
|
+
description: `SPF record ends in \`+all\`, which authorises any server to send as this domain: \`${spf.slice(0, 140)}\``,
|
|
54
|
+
category: "email-auth",
|
|
55
|
+
source: "email-dns-probe",
|
|
56
|
+
} };
|
|
57
|
+
}
|
|
58
|
+
if (/[-~]all\b/i.test(spf) || /\bredirect=/i.test(spf)) {
|
|
59
|
+
return { present: true, record: spf, finding: null }; // -all / ~all / delegated
|
|
60
|
+
}
|
|
61
|
+
return { present: true, record: spf, finding: {
|
|
62
|
+
severity: "medium",
|
|
63
|
+
title: "SPF does not restrict senders",
|
|
64
|
+
description: `SPF record has no \`-all\` or \`~all\` mechanism, so spoofing is not restricted: \`${spf.slice(0, 140)}\``,
|
|
65
|
+
category: "email-auth",
|
|
66
|
+
source: "email-dns-probe",
|
|
67
|
+
} };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Evaluate DMARC from the `_dmarc.<domain>` TXT records. */
|
|
71
|
+
export function evaluateDmarc(txtRecords) {
|
|
72
|
+
const dmarc = (txtRecords || []).find((r) => /^v=DMARC1\b/i.test(r.trim()));
|
|
73
|
+
if (!dmarc) {
|
|
74
|
+
return { present: false, record: null, policy: null, finding: {
|
|
75
|
+
severity: "medium",
|
|
76
|
+
title: "No DMARC record",
|
|
77
|
+
description: "No `v=DMARC1` record at `_dmarc.<domain>`. Without DMARC, receivers can't act on or report spoofed mail.",
|
|
78
|
+
category: "email-auth",
|
|
79
|
+
source: "email-dns-probe",
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
const policy = (dmarc.match(/\bp=(none|quarantine|reject)\b/i)?.[1] || "none").toLowerCase();
|
|
83
|
+
if (policy === "none") {
|
|
84
|
+
return { present: true, record: dmarc, policy, finding: {
|
|
85
|
+
severity: "low",
|
|
86
|
+
title: "DMARC policy is monitoring-only (p=none)",
|
|
87
|
+
description: "DMARC is published but `p=none` only monitors, it does not block spoofed mail. Move to `quarantine` or `reject` once your legitimate senders are aligned.",
|
|
88
|
+
category: "email-auth",
|
|
89
|
+
source: "email-dns-probe",
|
|
90
|
+
} };
|
|
91
|
+
}
|
|
92
|
+
return { present: true, record: dmarc, policy, finding: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Probe a domain's email authentication posture.
|
|
97
|
+
* @param {string} domain
|
|
98
|
+
* @param {object} opts - { resolver }
|
|
99
|
+
*/
|
|
100
|
+
export async function probeEmailDns(domain, opts = {}) {
|
|
101
|
+
const resolver = opts.resolver || DEFAULT_RESOLVER;
|
|
102
|
+
const findings = [];
|
|
103
|
+
|
|
104
|
+
const [rootTxt, dmarcTxt, mx] = await Promise.all([
|
|
105
|
+
safeTxt(resolver, domain),
|
|
106
|
+
safeTxt(resolver, `_dmarc.${domain}`),
|
|
107
|
+
safeMx(resolver, domain),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const spf = evaluateSpf(rootTxt);
|
|
111
|
+
const dmarc = evaluateDmarc(dmarcTxt);
|
|
112
|
+
if (spf.finding) findings.push(spf.finding);
|
|
113
|
+
if (dmarc.finding) findings.push(dmarc.finding);
|
|
114
|
+
|
|
115
|
+
// DKIM: selector is provider-specific and can't be enumerated. Only nudge when
|
|
116
|
+
// the domain clearly handles mail (has MX) and email auth is already imperfect.
|
|
117
|
+
if (mx.length > 0 && (spf.finding || dmarc.finding)) {
|
|
118
|
+
findings.push({
|
|
119
|
+
severity: "info",
|
|
120
|
+
title: "Verify DKIM is signing outbound mail",
|
|
121
|
+
description: "This domain has MX records (it handles mail) and its SPF/DMARC is incomplete. Also confirm DKIM signing, the selector is provider-specific, so Penthera does not auto-check it.",
|
|
122
|
+
category: "email-auth",
|
|
123
|
+
source: "email-dns-probe",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
domain,
|
|
129
|
+
spf: spf.record,
|
|
130
|
+
dmarc: dmarc.record,
|
|
131
|
+
dmarcPolicy: dmarc.policy || null,
|
|
132
|
+
mxCount: mx.length,
|
|
133
|
+
findings,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP security header audit.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const HEADER_CHECKS = [
|
|
6
|
+
{
|
|
7
|
+
header: "x-frame-options",
|
|
8
|
+
severity: "low",
|
|
9
|
+
title: "Missing X-Frame-Options",
|
|
10
|
+
description: "Page can be embedded in iframes (clickjacking risk).",
|
|
11
|
+
category: "headers",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
header: "content-security-policy",
|
|
15
|
+
severity: "medium",
|
|
16
|
+
title: "Missing Content-Security-Policy",
|
|
17
|
+
description: "No CSP, inline scripts and third-party resources are unrestricted.",
|
|
18
|
+
category: "headers",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
header: "x-content-type-options",
|
|
22
|
+
severity: "low",
|
|
23
|
+
title: "Missing X-Content-Type-Options",
|
|
24
|
+
description: "Browser may MIME-sniff responses (XSS aid).",
|
|
25
|
+
category: "headers",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
header: "referrer-policy",
|
|
29
|
+
severity: "info",
|
|
30
|
+
title: "Missing Referrer-Policy",
|
|
31
|
+
description: "Referrer headers may leak internal URLs to third parties.",
|
|
32
|
+
category: "headers",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
header: "permissions-policy",
|
|
36
|
+
severity: "info",
|
|
37
|
+
title: "Missing Permissions-Policy",
|
|
38
|
+
description: "Browser features (camera, geolocation) are not restricted by policy header.",
|
|
39
|
+
category: "headers",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Record<string, string>} headers - lowercase header map
|
|
45
|
+
* @param {string} url
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
*/
|
|
48
|
+
export function auditSecurityHeaders(headers = {}, url, opts = {}) {
|
|
49
|
+
const findings = [];
|
|
50
|
+
const local = opts.local || false;
|
|
51
|
+
const isHttps = url.startsWith("https://");
|
|
52
|
+
|
|
53
|
+
for (const check of HEADER_CHECKS) {
|
|
54
|
+
const present = Object.keys(headers).some((h) => h.toLowerCase() === check.header);
|
|
55
|
+
if (present) continue;
|
|
56
|
+
|
|
57
|
+
findings.push({
|
|
58
|
+
severity: check.severity,
|
|
59
|
+
title: check.title,
|
|
60
|
+
description: check.description,
|
|
61
|
+
url,
|
|
62
|
+
category: check.category,
|
|
63
|
+
source: "header-audit",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!isHttps && !local) {
|
|
68
|
+
findings.push({
|
|
69
|
+
severity: "high",
|
|
70
|
+
title: "No HTTPS, traffic sent in cleartext",
|
|
71
|
+
description: "Credentials and session data can be intercepted on the network. Use TLS in production.",
|
|
72
|
+
url,
|
|
73
|
+
category: "transport",
|
|
74
|
+
source: "header-audit",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const server = headers.server || headers["x-powered-by"];
|
|
79
|
+
if (server && /uvicorn|gunicorn|werkzeug/i.test(server)) {
|
|
80
|
+
findings.push({
|
|
81
|
+
severity: "info",
|
|
82
|
+
title: "Server version disclosed",
|
|
83
|
+
description: `Response exposes server header: ${server}`,
|
|
84
|
+
url,
|
|
85
|
+
category: "disclosure",
|
|
86
|
+
source: "header-audit",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return findings;
|
|
91
|
+
}
|