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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IDOR / BOLA probes, test object-level authorization on parameterized routes.
|
|
3
|
+
*/
|
|
4
|
+
import { joinUrl } from "../../src/utils/url.js";
|
|
5
|
+
import { probeFetch } from "../../src/utils/http.js";
|
|
6
|
+
|
|
7
|
+
const ID_PARAM_RE = /\{([^}]+)\}/g;
|
|
8
|
+
const ID_LIKE_NAMES = /^(id|uuid|uid|user_?id|account_?id|resource_?id|item_?id|order_?id|seat_?id|project_?id)$/i;
|
|
9
|
+
const PROBE_IDS = ["1", "2", "999", "0", "admin"];
|
|
10
|
+
const SENSITIVE_BODY_RE = /email|password|role|admin|token|secret|billing|ssn|phone|address|user_id/i;
|
|
11
|
+
|
|
12
|
+
export function extractIdRoutes(spec, paths = []) {
|
|
13
|
+
const routes = [];
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
|
|
16
|
+
const add = (template, method = "GET") => {
|
|
17
|
+
if (!template.includes("{")) return;
|
|
18
|
+
const params = [...template.matchAll(ID_PARAM_RE)].map((m) => m[1]);
|
|
19
|
+
if (!params.some((p) => ID_LIKE_NAMES.test(p) || p.toLowerCase().includes("id"))) return;
|
|
20
|
+
const key = `${method}:${template}`;
|
|
21
|
+
if (seen.has(key)) return;
|
|
22
|
+
seen.add(key);
|
|
23
|
+
routes.push({ template, method, params });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (spec?.paths) {
|
|
27
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
28
|
+
for (const method of Object.keys(methods)) {
|
|
29
|
+
if (["get", "put", "patch", "delete"].includes(method)) add(path, method.toUpperCase());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const path of paths) {
|
|
35
|
+
if (path.includes("{")) add(path, "GET");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const path of paths) {
|
|
39
|
+
const m = path.match(/^(.+\/)([a-zA-Z0-9-]{1,64})$/);
|
|
40
|
+
if (!m) continue;
|
|
41
|
+
const prefix = m[1].replace(/\/$/, "");
|
|
42
|
+
if (!/\/(users|accounts|orders|items|projects|resources|seats|documents)/i.test(prefix)) continue;
|
|
43
|
+
add(`${prefix}/{id}`, "GET");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return routes.slice(0, 12);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fillTemplate(template, values) {
|
|
50
|
+
let path = template;
|
|
51
|
+
for (const [key, val] of Object.entries(values)) {
|
|
52
|
+
path = path.replace(`{${key}}`, encodeURIComponent(val));
|
|
53
|
+
}
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function probeIdorBola(baseUrl, opts = {}) {
|
|
58
|
+
const timeout = opts.timeout || 8_000;
|
|
59
|
+
const authHeaders = opts.authHeaders || {};
|
|
60
|
+
const findings = [];
|
|
61
|
+
const routes = extractIdRoutes(opts.spec, opts.paths || []);
|
|
62
|
+
|
|
63
|
+
if (routes.length === 0) return findings;
|
|
64
|
+
|
|
65
|
+
for (const route of routes.slice(0, opts.maxRoutes || 8)) {
|
|
66
|
+
const results = [];
|
|
67
|
+
|
|
68
|
+
for (const id of PROBE_IDS) {
|
|
69
|
+
const values = {};
|
|
70
|
+
for (const p of route.params) values[p] = id;
|
|
71
|
+
|
|
72
|
+
const path = fillTemplate(route.template, values);
|
|
73
|
+
const res = await probeFetch(joinUrl(baseUrl, path), {
|
|
74
|
+
timeout,
|
|
75
|
+
method: route.method,
|
|
76
|
+
headers: authHeaders,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!res || res.status >= 400 || res.body.length < 3) continue;
|
|
80
|
+
results.push({ id, path, status: res.status, body: res.body });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sensitive = results.filter((r) => SENSITIVE_BODY_RE.test(r.body));
|
|
84
|
+
if (sensitive.length >= 2) {
|
|
85
|
+
findings.push({
|
|
86
|
+
severity: "high",
|
|
87
|
+
title: `IDOR/BOLA: ${route.method} ${route.template}`,
|
|
88
|
+
description:
|
|
89
|
+
`Multiple object IDs (${sensitive.map((r) => r.id).join(", ")}) returned HTTP 200 with sensitive fields.`,
|
|
90
|
+
url: joinUrl(baseUrl, sensitive[0].path),
|
|
91
|
+
status: 200,
|
|
92
|
+
category: "auth",
|
|
93
|
+
source: "idor-probe",
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const adminHit = results.find((r) => /999|admin/.test(r.id) && SENSITIVE_BODY_RE.test(r.body));
|
|
99
|
+
if (adminHit && !Object.keys(authHeaders).length) {
|
|
100
|
+
findings.push({
|
|
101
|
+
severity: "high",
|
|
102
|
+
title: `BOLA: privileged ID accessible on ${route.template}`,
|
|
103
|
+
description: `ID "${adminHit.id}" returned sensitive data without authorization.`,
|
|
104
|
+
url: joinUrl(baseUrl, adminHit.path),
|
|
105
|
+
status: 200,
|
|
106
|
+
category: "auth",
|
|
107
|
+
source: "idor-probe",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT misconfiguration probes.
|
|
3
|
+
*/
|
|
4
|
+
import { joinUrl } from "../../src/utils/url.js";
|
|
5
|
+
import { probeFetch } from "../../src/utils/http.js";
|
|
6
|
+
|
|
7
|
+
function decodeJwtPayload(token) {
|
|
8
|
+
try {
|
|
9
|
+
const parts = token.split(".");
|
|
10
|
+
if (parts.length !== 3) return null;
|
|
11
|
+
const payload = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
12
|
+
return JSON.parse(payload);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function probeJwt(baseUrl, opts = {}) {
|
|
19
|
+
const findings = [];
|
|
20
|
+
const timeout = opts.timeout || 8_000;
|
|
21
|
+
const token = opts.bearerToken;
|
|
22
|
+
|
|
23
|
+
if (!token) return findings;
|
|
24
|
+
|
|
25
|
+
const payload = decodeJwtPayload(token);
|
|
26
|
+
if (!payload) {
|
|
27
|
+
findings.push({
|
|
28
|
+
severity: "low",
|
|
29
|
+
title: "Configured bearer token is not a valid JWT",
|
|
30
|
+
description: "Token could not be decoded, verify auth configuration.",
|
|
31
|
+
url: baseUrl,
|
|
32
|
+
category: "auth",
|
|
33
|
+
source: "jwt-probe",
|
|
34
|
+
});
|
|
35
|
+
return findings;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (payload.exp && new Date(payload.exp * 1000) < new Date()) {
|
|
39
|
+
findings.push({
|
|
40
|
+
severity: "medium",
|
|
41
|
+
title: "Configured JWT is expired",
|
|
42
|
+
description: "Scans with this token may not reflect authenticated routes.",
|
|
43
|
+
url: baseUrl,
|
|
44
|
+
category: "auth",
|
|
45
|
+
source: "jwt-probe",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (opts.testPath) {
|
|
50
|
+
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
|
51
|
+
const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url");
|
|
52
|
+
const forged = `${header}.${body}.`;
|
|
53
|
+
|
|
54
|
+
const res = await probeFetch(joinUrl(baseUrl, opts.testPath), {
|
|
55
|
+
timeout,
|
|
56
|
+
headers: { Authorization: `Bearer ${forged}` },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (res?.status === 200) {
|
|
60
|
+
findings.push({
|
|
61
|
+
severity: "critical",
|
|
62
|
+
title: "JWT alg:none bypass accepted",
|
|
63
|
+
description: `Forged unsigned JWT accepted on ${opts.testPath}.`,
|
|
64
|
+
url: joinUrl(baseUrl, opts.testPath),
|
|
65
|
+
status: res.status,
|
|
66
|
+
category: "auth",
|
|
67
|
+
source: "jwt-probe",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth / open redirect misconfiguration probes.
|
|
3
|
+
*/
|
|
4
|
+
import { joinUrl, redirectsToHost } from "../../src/utils/url.js";
|
|
5
|
+
import { probeFetch } from "../../src/utils/http.js";
|
|
6
|
+
|
|
7
|
+
const OAUTH_PATHS = [
|
|
8
|
+
"/api/auth/callback",
|
|
9
|
+
"/api/oauth/callback",
|
|
10
|
+
"/auth/callback",
|
|
11
|
+
"/oauth/callback",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const EVIL = "https://evil-attacker.example/capture";
|
|
15
|
+
|
|
16
|
+
export async function probeOAuthMisconfig(baseUrl, opts = {}) {
|
|
17
|
+
const timeout = opts.timeout || 8_000;
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
for (const path of OAUTH_PATHS) {
|
|
21
|
+
for (const qs of [
|
|
22
|
+
`redirect_uri=${encodeURIComponent(EVIL)}`,
|
|
23
|
+
`redirect=${encodeURIComponent(EVIL)}`,
|
|
24
|
+
]) {
|
|
25
|
+
const url = joinUrl(baseUrl, `${path}?${qs}`);
|
|
26
|
+
const res = await probeFetch(url, { timeout });
|
|
27
|
+
|
|
28
|
+
if (!res) continue;
|
|
29
|
+
|
|
30
|
+
const location = res.headers.location || "";
|
|
31
|
+
// Only a real open redirect if the Location resolves to the attacker's
|
|
32
|
+
// host, not if the attacker URL merely rides along in the query of a
|
|
33
|
+
// same-site canonicalization redirect (e.g. apex → www).
|
|
34
|
+
if (redirectsToHost(location, url, "evil-attacker.example")) {
|
|
35
|
+
findings.push({
|
|
36
|
+
severity: "high",
|
|
37
|
+
title: "OAuth open redirect",
|
|
38
|
+
description: `${path} redirects to attacker-controlled URL.`,
|
|
39
|
+
url,
|
|
40
|
+
status: res.status,
|
|
41
|
+
category: "auth",
|
|
42
|
+
source: "oauth-probe",
|
|
43
|
+
});
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return findings;
|
|
50
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI / Swagger discovery and misconfiguration checks.
|
|
3
|
+
*/
|
|
4
|
+
import { joinUrl } from "../../src/utils/url.js";
|
|
5
|
+
import { probeFetch } from "../../src/utils/http.js";
|
|
6
|
+
|
|
7
|
+
const SPEC_CANDIDATES = [
|
|
8
|
+
"/openapi.json",
|
|
9
|
+
"/swagger.json",
|
|
10
|
+
"/api/openapi.json",
|
|
11
|
+
"/openapi.yaml",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const DOC_CANDIDATES = [
|
|
15
|
+
{ path: "/docs", name: "FastAPI/Swagger UI" },
|
|
16
|
+
{ path: "/redoc", name: "ReDoc API docs" },
|
|
17
|
+
{ path: "/swagger", name: "Swagger UI" },
|
|
18
|
+
{ path: "/api/docs", name: "API docs" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} baseUrl
|
|
23
|
+
* @returns {Promise<{ spec: object|null, specUrl: string|null, paths: string[], findings: object[] }>}
|
|
24
|
+
*/
|
|
25
|
+
export async function scanOpenApi(baseUrl, opts = {}) {
|
|
26
|
+
const timeout = opts.timeout || 8_000;
|
|
27
|
+
const findings = [];
|
|
28
|
+
let spec = null;
|
|
29
|
+
let specUrl = null;
|
|
30
|
+
|
|
31
|
+
for (const path of SPEC_CANDIDATES) {
|
|
32
|
+
const res = await probeFetch(joinUrl(baseUrl, path), { timeout });
|
|
33
|
+
if (!res || res.status !== 200) continue;
|
|
34
|
+
try {
|
|
35
|
+
spec = JSON.parse(res.body);
|
|
36
|
+
specUrl = joinUrl(baseUrl, path);
|
|
37
|
+
break;
|
|
38
|
+
} catch {
|
|
39
|
+
// not JSON, skip
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const doc of DOC_CANDIDATES) {
|
|
44
|
+
const res = await probeFetch(joinUrl(baseUrl, doc.path), { timeout });
|
|
45
|
+
if (!res || res.status !== 200) continue;
|
|
46
|
+
const looksLikeDocs = /swagger|openapi|redoc|rapidoc/i.test(res.body);
|
|
47
|
+
if (!looksLikeDocs && doc.path !== "/docs") continue;
|
|
48
|
+
|
|
49
|
+
findings.push({
|
|
50
|
+
severity: "medium",
|
|
51
|
+
title: `${doc.name} exposed`,
|
|
52
|
+
description: `API documentation is publicly accessible at ${doc.path}. Disable in production or protect with authentication.`,
|
|
53
|
+
url: joinUrl(baseUrl, doc.path),
|
|
54
|
+
status: res.status,
|
|
55
|
+
category: "exposure",
|
|
56
|
+
source: "openapi-scan",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (specUrl) {
|
|
61
|
+
findings.push({
|
|
62
|
+
severity: "medium",
|
|
63
|
+
title: "OpenAPI specification exposed",
|
|
64
|
+
description: "Machine-readable API spec reveals all routes, parameters, and schemas to attackers.",
|
|
65
|
+
url: specUrl,
|
|
66
|
+
status: 200,
|
|
67
|
+
category: "exposure",
|
|
68
|
+
source: "openapi-scan",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const paths = spec?.paths ? Object.keys(spec.paths) : [];
|
|
73
|
+
const unprotectedRoutes = [];
|
|
74
|
+
|
|
75
|
+
for (const path of paths) {
|
|
76
|
+
const methods = spec.paths[path] || {};
|
|
77
|
+
const hasSecurity = Object.values(methods).some((op) => op?.security?.length > 0);
|
|
78
|
+
const globalSecurity = spec.security?.length > 0;
|
|
79
|
+
if (hasSecurity || globalSecurity) continue;
|
|
80
|
+
|
|
81
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
82
|
+
if (!["get", "post", "put", "delete", "patch"].includes(method)) continue;
|
|
83
|
+
unprotectedRoutes.push({ path, method: method.toUpperCase(), summary: op.summary || path });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const route of unprotectedRoutes
|
|
88
|
+
.filter((r) => r.method === "GET")
|
|
89
|
+
.sort((a, b) => sensitivePathScore(a.path) - sensitivePathScore(b.path))) {
|
|
90
|
+
const res = await probeFetch(joinUrl(baseUrl, route.path), { timeout });
|
|
91
|
+
if (!res || res.status >= 400) continue;
|
|
92
|
+
|
|
93
|
+
const hasBody = res.body && res.body.length > 2;
|
|
94
|
+
const looksSensitive = /password|secret|token|user_id|email|seat|participant/i.test(res.body);
|
|
95
|
+
|
|
96
|
+
if (hasBody) {
|
|
97
|
+
findings.push({
|
|
98
|
+
severity: looksSensitive ? "high" : "medium",
|
|
99
|
+
title: `Unauthenticated endpoint: ${route.method} ${route.path}`,
|
|
100
|
+
description: route.summary
|
|
101
|
+
? `${route.summary}, responds HTTP ${res.status} without authentication.`
|
|
102
|
+
: `Responds HTTP ${res.status} without authentication.`,
|
|
103
|
+
url: joinUrl(baseUrl, route.path),
|
|
104
|
+
status: res.status,
|
|
105
|
+
category: "auth",
|
|
106
|
+
source: "openapi-scan",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
spec,
|
|
113
|
+
specUrl,
|
|
114
|
+
paths,
|
|
115
|
+
unprotectedRoutes,
|
|
116
|
+
findings,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Does an invalid-credential response actually look like a *successful* login?
|
|
122
|
+
* A bare HTTP 200 is not enough, SPA/JSON login endpoints routinely return 200
|
|
123
|
+
* with `{ "success": false }` or an error body. Only flag when there's a real
|
|
124
|
+
* success signal (an auth cookie or a token/success body) and no failure signal.
|
|
125
|
+
*/
|
|
126
|
+
export function looksAuthenticated(res) {
|
|
127
|
+
if (!res || res.status !== 200) return false;
|
|
128
|
+
const body = (res.body || "").toLowerCase();
|
|
129
|
+
const failure = /invalid|incorrect|unauthor|denied|forbidden|"success"\s*:\s*false|\berror\b|\bfail|wrong|not found|missing|require/;
|
|
130
|
+
if (failure.test(body)) return false;
|
|
131
|
+
const setCookie = (res.headers?.["set-cookie"] || "").toLowerCase();
|
|
132
|
+
const authCookie = /(sess|token|auth|jwt|sid)[\w-]*=[^;\s]+/.test(setCookie);
|
|
133
|
+
const successBody = /"?(access_token|refresh_token|\btoken\b|jwt|session)"?\s*[:=]|"success"\s*:\s*true|authenticated|welcome/.test(body);
|
|
134
|
+
return authCookie || successBody;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Basic authentication hardening checks for login endpoints.
|
|
139
|
+
*/
|
|
140
|
+
export async function probeAuthEndpoints(baseUrl, opts = {}) {
|
|
141
|
+
const timeout = opts.timeout || 8_000;
|
|
142
|
+
const findings = [];
|
|
143
|
+
const loginUrl = joinUrl(baseUrl, "/api/login");
|
|
144
|
+
|
|
145
|
+
const validAttempt = await probeFetch(loginUrl, {
|
|
146
|
+
timeout,
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ username: "penthera-probe-invalid", password: "invalid-password-12345" }),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!validAttempt) return findings;
|
|
153
|
+
// A redirect (e.g. apex→www), 404, or 405 means there's no real POST login
|
|
154
|
+
// handler at this path, don't run the arbitrary-creds / rate-limit / SQLi
|
|
155
|
+
// checks against a non-endpoint (avoids a false "no rate limiting" on a 308).
|
|
156
|
+
if (validAttempt.status === 404 || validAttempt.status === 405 ||
|
|
157
|
+
(validAttempt.status >= 300 && validAttempt.status < 400)) {
|
|
158
|
+
return findings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (looksAuthenticated(validAttempt)) {
|
|
162
|
+
findings.push({
|
|
163
|
+
severity: "critical",
|
|
164
|
+
title: "Login accepts arbitrary credentials",
|
|
165
|
+
description: "POST /api/login returned a successful, authenticated response (auth cookie or token) for invalid test credentials.",
|
|
166
|
+
url: loginUrl,
|
|
167
|
+
status: validAttempt.status,
|
|
168
|
+
category: "auth",
|
|
169
|
+
source: "auth-probe",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Rate limiting, 8 rapid attempts
|
|
174
|
+
let rateLimited = false;
|
|
175
|
+
for (let i = 0; i < 8; i++) {
|
|
176
|
+
const res = await probeFetch(loginUrl, {
|
|
177
|
+
timeout,
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({ username: "rate-test", password: `attempt-${i}` }),
|
|
181
|
+
});
|
|
182
|
+
if (res && (res.status === 429 || /rate.?limit/i.test(res.body))) {
|
|
183
|
+
rateLimited = true;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!rateLimited && validAttempt.status !== 404) {
|
|
189
|
+
findings.push({
|
|
190
|
+
severity: "medium",
|
|
191
|
+
title: "No login rate limiting detected",
|
|
192
|
+
description: "8 rapid login attempts did not trigger HTTP 429 or a rate-limit response.",
|
|
193
|
+
url: loginUrl,
|
|
194
|
+
status: validAttempt.status,
|
|
195
|
+
category: "auth",
|
|
196
|
+
source: "auth-probe",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// SQL injection smoke test, error-based
|
|
201
|
+
const sqli = await probeFetch(loginUrl, {
|
|
202
|
+
timeout,
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({ username: "' OR '1'='1", password: "' OR '1'='1" }),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (sqli && /sql|syntax error|sqlite|postgres|mysql|ORA-/i.test(sqli.body)) {
|
|
209
|
+
findings.push({
|
|
210
|
+
severity: "critical",
|
|
211
|
+
title: "SQL error leaked on login",
|
|
212
|
+
description: "Login endpoint returned a database error message for SQLi probe payload.",
|
|
213
|
+
url: loginUrl,
|
|
214
|
+
status: sqli.status,
|
|
215
|
+
category: "injection",
|
|
216
|
+
source: "auth-probe",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return findings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sensitivePathScore(path) {
|
|
224
|
+
if (/data|json|user|admin|config|secret|env|export/i.test(path)) return 0;
|
|
225
|
+
if (path === "/" || path.includes("{")) return 2;
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incident-report helper (NIS2 measure b).
|
|
3
|
+
*
|
|
4
|
+
* Given the facts of an incident, lay out the reporting timeline (the 24h early
|
|
5
|
+
* warning, 72h notification, and 1-month final report) with real due dates, and
|
|
6
|
+
* produce a draft for each against the jurisdiction's required content. Fields
|
|
7
|
+
* the user hasn't provided are marked NEEDS INPUT, never invented.
|
|
8
|
+
*
|
|
9
|
+
* Offline. Drafts are starting points to send to your CSIRT / authority, not a
|
|
10
|
+
* substitute for the authority's own form or for legal advice.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Fields collected about an incident (all optional; blanks become NEEDS INPUT). */
|
|
14
|
+
export function incidentTemplate() {
|
|
15
|
+
return {
|
|
16
|
+
_about: "Penthera incident-report helper. Fill in what you know; leave the rest blank. `aware_at` is when you became aware (ISO 8601, e.g. 2026-08-15T09:30:00Z), it drives the deadlines. Offline; nothing is sent anywhere.",
|
|
17
|
+
title: "",
|
|
18
|
+
aware_at: "",
|
|
19
|
+
detected_at: "",
|
|
20
|
+
description: "",
|
|
21
|
+
systems_affected: "",
|
|
22
|
+
data_affected: "",
|
|
23
|
+
users_affected: "",
|
|
24
|
+
severity: "",
|
|
25
|
+
unlawful_or_malicious: "",
|
|
26
|
+
cross_border: "",
|
|
27
|
+
indicators: "",
|
|
28
|
+
mitigations: "",
|
|
29
|
+
root_cause: "",
|
|
30
|
+
contact: "",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function addOffset(date, { offsetHours, offsetMonths }) {
|
|
35
|
+
const x = new Date(date.getTime());
|
|
36
|
+
if (offsetHours) x.setTime(x.getTime() + offsetHours * 3600 * 1000);
|
|
37
|
+
if (offsetMonths) x.setUTCMonth(x.getUTCMonth() + offsetMonths);
|
|
38
|
+
return x;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the reporting timeline and per-deadline drafts.
|
|
43
|
+
* @param {object} incident - fields from incidentTemplate()
|
|
44
|
+
* @param {object} jurisdiction - nl.json (uses incidentDeadlines, authorities)
|
|
45
|
+
*/
|
|
46
|
+
export function buildIncidentReports(incident = {}, jurisdiction = {}) {
|
|
47
|
+
const deadlines = jurisdiction.incidentDeadlines || [];
|
|
48
|
+
const awareValid = incident.aware_at && !Number.isNaN(new Date(incident.aware_at).getTime());
|
|
49
|
+
const awareness = awareValid ? new Date(incident.aware_at) : null;
|
|
50
|
+
|
|
51
|
+
const notif = deadlines.find((d) => d.id === "notification");
|
|
52
|
+
const notifDue = awareness && notif ? addOffset(awareness, notif) : null;
|
|
53
|
+
|
|
54
|
+
const dueFor = (d) => {
|
|
55
|
+
if (!awareness) return null;
|
|
56
|
+
const base = d.from === "notification" ? (notifDue || awareness) : awareness;
|
|
57
|
+
return addOffset(base, d).toISOString();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const timeline = deadlines.map((d) => ({
|
|
61
|
+
id: d.id,
|
|
62
|
+
within: d.within,
|
|
63
|
+
dueBy: dueFor(d),
|
|
64
|
+
to: d.to,
|
|
65
|
+
content: d.content,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const v = (field) => {
|
|
69
|
+
const val = incident[field];
|
|
70
|
+
return val && String(val).trim() ? String(val).trim() : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Which incident fields each report should surface.
|
|
74
|
+
const REPORT_FIELDS = {
|
|
75
|
+
"early-warning": [
|
|
76
|
+
["unlawful_or_malicious", "Suspected unlawful or malicious"],
|
|
77
|
+
["cross_border", "Possible cross-border impact"],
|
|
78
|
+
["description", "What we know so far"],
|
|
79
|
+
],
|
|
80
|
+
notification: [
|
|
81
|
+
["description", "Description"],
|
|
82
|
+
["severity", "Severity"],
|
|
83
|
+
["systems_affected", "Systems affected"],
|
|
84
|
+
["data_affected", "Data affected"],
|
|
85
|
+
["users_affected", "Users affected"],
|
|
86
|
+
["cross_border", "Cross-border impact"],
|
|
87
|
+
["indicators", "Indicators of compromise"],
|
|
88
|
+
["mitigations", "Mitigations so far"],
|
|
89
|
+
],
|
|
90
|
+
"final-report": [
|
|
91
|
+
["description", "Detailed description"],
|
|
92
|
+
["root_cause", "Root cause"],
|
|
93
|
+
["mitigations", "Mitigations applied and ongoing"],
|
|
94
|
+
["cross_border", "Cross-border impact"],
|
|
95
|
+
["severity", "Final severity assessment"],
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const reports = deadlines.map((d) => ({
|
|
100
|
+
id: d.id,
|
|
101
|
+
within: d.within,
|
|
102
|
+
dueBy: dueFor(d),
|
|
103
|
+
content: d.content,
|
|
104
|
+
fields: (REPORT_FIELDS[d.id] || []).map(([field, label]) => ({
|
|
105
|
+
label,
|
|
106
|
+
value: v(field),
|
|
107
|
+
})),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
incident: {
|
|
112
|
+
title: v("title"),
|
|
113
|
+
aware_at: awareValid ? incident.aware_at : null,
|
|
114
|
+
contact: v("contact"),
|
|
115
|
+
},
|
|
116
|
+
authority: jurisdiction.authorities || null,
|
|
117
|
+
timeline,
|
|
118
|
+
reports,
|
|
119
|
+
generated_at: null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Render the timeline + drafts as Markdown. */
|
|
124
|
+
export function formatIncidentMarkdown(result, opts = {}) {
|
|
125
|
+
if (!result) return "";
|
|
126
|
+
const now = opts.now ? new Date(opts.now) : null;
|
|
127
|
+
const L = [];
|
|
128
|
+
L.push(`# Incident report drafts${result.incident?.title ? `, ${result.incident.title}` : ""}`);
|
|
129
|
+
L.push("");
|
|
130
|
+
L.push("> **Draft reports to help you meet NIS2 deadlines, not a substitute for your authority's official form or for legal advice.** Fields marked NEEDS INPUT must be completed before sending.");
|
|
131
|
+
L.push("");
|
|
132
|
+
if (!result.incident?.aware_at) {
|
|
133
|
+
L.push("> ⚠️ No valid `aware_at` timestamp provided, so deadlines can't be computed. Set when you became aware (ISO 8601) and re-run.");
|
|
134
|
+
L.push("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
L.push("## Reporting timeline");
|
|
138
|
+
L.push("");
|
|
139
|
+
L.push(`Report to **${result.authority?.csirt || "your CSIRT"}** (supervisor: ${result.authority?.supervisor || "your supervisor"}).`);
|
|
140
|
+
L.push("");
|
|
141
|
+
L.push("| Report | Due by | Status |");
|
|
142
|
+
L.push("|--------|--------|--------|");
|
|
143
|
+
for (const t of result.timeline) {
|
|
144
|
+
let status = ", ";
|
|
145
|
+
if (t.dueBy && now) {
|
|
146
|
+
const due = new Date(t.dueBy);
|
|
147
|
+
status = now > due ? "⚠️ OVERDUE" : "on track";
|
|
148
|
+
}
|
|
149
|
+
L.push(`| ${t.within}, ${t.id} | ${t.dueBy || "set aware_at"} | ${status} |`);
|
|
150
|
+
}
|
|
151
|
+
L.push("");
|
|
152
|
+
|
|
153
|
+
for (const r of result.reports) {
|
|
154
|
+
L.push(`## Draft: ${r.id} (within ${r.within}${r.dueBy ? `, by ${r.dueBy}` : ""})`);
|
|
155
|
+
L.push("");
|
|
156
|
+
L.push(`_${r.content}_`);
|
|
157
|
+
L.push("");
|
|
158
|
+
for (const f of r.fields) {
|
|
159
|
+
L.push(`- **${f.label}:** ${f.value || "**NEEDS INPUT**"}`);
|
|
160
|
+
}
|
|
161
|
+
L.push("");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
L.push("---");
|
|
165
|
+
L.push("");
|
|
166
|
+
L.push("*Generated by Penthera. Complete every NEEDS INPUT field, confirm the authority and its official channel, and send within the deadlines above.*");
|
|
167
|
+
L.push("");
|
|
168
|
+
return L.join("\n");
|
|
169
|
+
}
|