penthera 1.1.0 → 1.1.1
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 +47 -0
- package/README.md +43 -4
- package/lib/compliance/readiness.js +16 -4
- package/lib/injections.js +103 -34
- package/lib/machine.js +39 -5
- package/lib/nuclei-loader.js +56 -5
- package/lib/retirejs.js +36 -12
- package/lib/templates.js +32 -5
- package/package.json +3 -3
- package/skills/penthera/SKILL.md +1 -1
- package/src/cli/baseline.js +6 -3
- package/src/cli/merge-results.js +3 -1
- package/src/cli.js +1 -1
- package/src/reporter.js +10 -1
- package/src/scan-url.js +22 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ All notable changes to Penthera are documented here. Versioning follows [SemVer]
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.1.1] - 2026-08-16
|
|
8
|
+
|
|
9
|
+
A correctness release. One of these was a live outage: if you are on 1.1.0,
|
|
10
|
+
upgrade.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **JS dependency scanning was silently dead.** The Retire.js vulnerability
|
|
15
|
+
database URL pointed at a fork that now returns 404, so every scan reported
|
|
16
|
+
"no vulnerable JS libraries" without ever running the check. It now reads from
|
|
17
|
+
upstream RetireJS with a mirror, validates the payload, and when no source is
|
|
18
|
+
reachable it says the check did not run instead of returning a clean result.
|
|
19
|
+
The probe is also excluded from coverage in that case, so a compliance report
|
|
20
|
+
cannot claim supply-chain evidence that was never collected
|
|
21
|
+
- **Command injection in the machine audit.** Binary paths read from
|
|
22
|
+
attacker-writable plists were interpolated into a shell string, so a malicious
|
|
23
|
+
persistence entry could execute code inside the scanner. Paths are now argv
|
|
24
|
+
entries and signature checks use the exit status, failing closed
|
|
25
|
+
- Two built-in templates fired on any single-page app with catch-all routing:
|
|
26
|
+
the `.env.local` matcher accepted a bare `=` (present in every HTML attribute)
|
|
27
|
+
and the Firebase one accepted any 200 without the word "error", so an
|
|
28
|
+
`index.html` served at those paths was reported as critical
|
|
29
|
+
- Time-based SQL and command injection no longer report on a single slow
|
|
30
|
+
response. A candidate delay is re-sampled and only reported when the median
|
|
31
|
+
holds, which a garbage-collection pause or cold start does not survive
|
|
32
|
+
- Community template regexes ran unbounded against whole response bodies. A
|
|
33
|
+
catastrophically-backtracking pattern would hang the scanner; patterns with
|
|
34
|
+
nested quantifiers are now refused and input is capped
|
|
35
|
+
- Dedupe and baseline comparison used different keys that included severity, so
|
|
36
|
+
re-rating a finding reported it as one resolved plus one new. Both now share
|
|
37
|
+
the audit loop's fingerprint
|
|
38
|
+
- A measure with nothing scanned and nothing attested was scored as a gap,
|
|
39
|
+
telling an entity that had not yet filled in the self-assessment that it had
|
|
40
|
+
failed. That state is now reported as not assessed
|
|
41
|
+
- The XSS canary was a fixed, fingerprintable string; it is now randomised per
|
|
42
|
+
probe
|
|
43
|
+
- XProtect staleness was computed and discarded; it is now reported
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- The Nuclei loader documents the subset it actually implements and reports per
|
|
48
|
+
template which unsupported features it needs, instead of letting a template
|
|
49
|
+
that cannot match look like a clean pass
|
|
50
|
+
- Provenance (confidence, method, collection time) now travels into SARIF, and
|
|
51
|
+
the terminal marks findings a scanner cannot adjudicate
|
|
52
|
+
|
|
7
53
|
## [1.1.0] - 2026-08-16
|
|
8
54
|
|
|
9
55
|
NIS2 / Cyberbeveiligingswet readiness work: a hardening pass over the
|
|
@@ -164,5 +210,6 @@ First production-ready release.
|
|
|
164
210
|
- Initial CLI: URL, repo, and machine scan modes
|
|
165
211
|
- Built-in templates, TLS, CORS, SARIF export
|
|
166
212
|
|
|
213
|
+
[1.1.1]: https://github.com/danoszz/penthera/compare/v1.1.0...v1.1.1
|
|
167
214
|
[1.1.0]: https://github.com/danoszz/penthera/compare/v1.0.0...v1.1.0
|
|
168
215
|
[1.0.0]: https://github.com/danoszz/penthera/compare/v0.2.0...v1.0.0
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br/><br/>
|
|
6
6
|
|
|
7
|
-
](https://www.npmjs.com/package/penthera)
|
|
8
8
|

|
|
9
9
|

|
|
10
10
|
[](https://github.com/danoszz/penthera/actions/workflows/ci.yml)
|
|
@@ -43,6 +43,34 @@ background to use it.
|
|
|
43
43
|
Penthera is for hardening systems you own. It is powerful, so please use it
|
|
44
44
|
carefully. See [Ethical use](#ethical-use--authorization) and the [Disclaimer](#disclaimer).
|
|
45
45
|
|
|
46
|
+
Shipping in the EU? Penthera also generates your
|
|
47
|
+
[NIS2 readiness report, policy pack, incident-report drafts, SBOM, and prefilled
|
|
48
|
+
customer security questionnaires](#compliance-nis2-and-the-cyberwetgeving).
|
|
49
|
+
Offline, no accounts. Readiness tooling, not a certification.
|
|
50
|
+
|
|
51
|
+
### What you get
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Findings
|
|
55
|
+
|
|
56
|
+
HIGH IDOR/BOLA: GET /api/users/{user_id}
|
|
57
|
+
http://localhost:3000/api/users/1 → 200
|
|
58
|
+
HIGH OAuth open redirect
|
|
59
|
+
http://localhost:3000/api/oauth/callback?redirect_uri=... → 302
|
|
60
|
+
MED Missing Content-Security-Policy
|
|
61
|
+
MED No login rate limiting detected
|
|
62
|
+
http://localhost:3000/api/login → 401
|
|
63
|
+
LOW Missing X-Frame-Options
|
|
64
|
+
INFO Missing Referrer-Policy
|
|
65
|
+
|
|
66
|
+
──────────────────────────────────────────────────────────
|
|
67
|
+
2 high · 5 medium · 2 low · 2 info
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every finding carries a confidence label (`confirmed`, `likely`, `potential`,
|
|
71
|
+
`needs-human-review`), so a heuristic never poses as a certainty. Reports come
|
|
72
|
+
out as Markdown, JSON, and SARIF, with the fixes in priority order.
|
|
73
|
+
|
|
46
74
|
---
|
|
47
75
|
|
|
48
76
|
## Run it
|
|
@@ -68,6 +96,12 @@ Then talk to your agent:
|
|
|
68
96
|
"Find hardcoded secrets in this repo before I push"
|
|
69
97
|
```
|
|
70
98
|
|
|
99
|
+
**Or try it with no install at all:**
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx penthera https://your-app.example
|
|
103
|
+
```
|
|
104
|
+
|
|
71
105
|
Prefer the terminal? Run `penthera` with no arguments for a guided wizard.
|
|
72
106
|
|
|
73
107
|
> The skill adds an authorization gate: it instructs your agent to refuse targets
|
|
@@ -139,11 +173,11 @@ auditor, or run in CI so a regression shows up before it ships.
|
|
|
139
173
|
| Art. 21(2) measure | What Penthera does | Coverage |
|
|
140
174
|
|--------------------|--------------------|----------|
|
|
141
175
|
| **(a)** Risk analysis & information-security policy | Findings, severity, and baseline history feed your risk analysis | Evidence |
|
|
142
|
-
| **(b)** Incident handling |
|
|
176
|
+
| **(b)** Incident handling | `--incident` drafts the 24h / 72h / 1-month reports against your authority's required content | Partial |
|
|
143
177
|
| **(c)** Business continuity, backup, crisis management | Organisational; outside a scanner's reach | Out of scope |
|
|
144
|
-
| **(d)** Supply-chain security | Known-CVE checks on JS dependencies (
|
|
178
|
+
| **(d)** Supply-chain security | Known-CVE checks on JS dependencies, leaked credentials, a CycloneDX SBOM (`--sbom`), and passive supplier rating (`--suppliers`) | Partial |
|
|
145
179
|
| **(e)** Security in acquisition, development & maintenance; **vulnerability handling** | The core: black- + white-box scanning mapped to OWASP WSTG, SARIF → GitHub, baseline regression, scan-and-fix loop | **Strong** |
|
|
146
|
-
| **(f)** Assessing the effectiveness of measures | Baseline diff
|
|
180
|
+
| **(f)** Assessing the effectiveness of measures | Baseline diff, re-scan-to-verify, and `--history` tracking of time-to-fix, ageing, and drift | Partial |
|
|
147
181
|
| **(g)** Cyber hygiene & training | Remediation playbook + secure-defaults guidance (guidance, not a training programme) | Partial |
|
|
148
182
|
| **(h)** Cryptography & encryption | TLS protocol/cipher/certificate audit, HSTS, cookie `Secure` (transport layer only) | Partial |
|
|
149
183
|
| **(i)** HR security, **access control**, asset management | Access control tested: JWT, IDOR/BOLA, OAuth redirect, client-side-auth, trust-boundary mapping | Partial |
|
|
@@ -513,6 +547,11 @@ npm run pentest:mock # offline tests against a local mock vuln API
|
|
|
513
547
|
npm run mock-server # start the mock API on port 8765
|
|
514
548
|
```
|
|
515
549
|
|
|
550
|
+
See the [roadmap](ROADMAP.md) for what is next and what is deliberately not planned.
|
|
551
|
+
|
|
552
|
+
See the [roadmap](ROADMAP.md) for what is next and what is deliberately not
|
|
553
|
+
planned.
|
|
554
|
+
|
|
516
555
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the project layout, how to add a probe
|
|
517
556
|
or template, the test matrix, and the release and publishing checklist.
|
|
518
557
|
|
|
@@ -53,7 +53,11 @@ function combine(scanS, attestS) {
|
|
|
53
53
|
const known = [];
|
|
54
54
|
if (scanS === "met" || scanS === "partial") known.push(scanS);
|
|
55
55
|
if (["met", "partial", "gap"].includes(attestS)) known.push(attestS);
|
|
56
|
-
|
|
56
|
+
// Nothing measured and nothing attested is not a failure, it is an unknown.
|
|
57
|
+
// Reporting it as a gap told an entity that had simply not filled in the
|
|
58
|
+
// self-assessment that it had failed ten measures, which is both wrong and
|
|
59
|
+
// the opposite of proportionate.
|
|
60
|
+
if (known.length === 0) return attestS === "na" ? "n/a" : "not-assessed";
|
|
57
61
|
if (known.includes("gap")) return "gap";
|
|
58
62
|
if (known.includes("partial")) return "partial";
|
|
59
63
|
return "met";
|
|
@@ -113,7 +117,7 @@ export function buildReadiness(scanResult, opts = {}) {
|
|
|
113
117
|
};
|
|
114
118
|
});
|
|
115
119
|
|
|
116
|
-
const summary = { met: 0, partial: 0, gap: 0, "n/a": 0 };
|
|
120
|
+
const summary = { met: 0, partial: 0, gap: 0, "not-assessed": 0, "n/a": 0 };
|
|
117
121
|
for (const m of measures) summary[m.status] = (summary[m.status] || 0) + 1;
|
|
118
122
|
const assessed = measures.filter((m) => m.basis.attestation !== "none" || m.basis.scan !== "none").length;
|
|
119
123
|
|
|
@@ -132,7 +136,13 @@ export function buildReadiness(scanResult, opts = {}) {
|
|
|
132
136
|
};
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
const STATUS_LABEL = {
|
|
139
|
+
const STATUS_LABEL = {
|
|
140
|
+
met: "Met",
|
|
141
|
+
partial: "Partial",
|
|
142
|
+
gap: "Gap",
|
|
143
|
+
"not-assessed": "Not assessed",
|
|
144
|
+
"n/a": "N/A",
|
|
145
|
+
};
|
|
136
146
|
|
|
137
147
|
/** Render a readiness report as Markdown. */
|
|
138
148
|
export function formatReadinessMarkdown(readiness) {
|
|
@@ -147,7 +157,9 @@ export function formatReadinessMarkdown(readiness) {
|
|
|
147
157
|
L.push("");
|
|
148
158
|
|
|
149
159
|
const s = readiness.summary;
|
|
150
|
-
L.push(`**Summary:** ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["n/a"]} n/a, across ${readiness.totalMeasures} Article 21 measures.`);
|
|
160
|
+
L.push(`**Summary:** ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["not-assessed"]} not assessed · ${s["n/a"]} n/a, across ${readiness.totalMeasures} Article 21 measures.`);
|
|
161
|
+
L.push("");
|
|
162
|
+
L.push("*Not assessed* means neither the scan nor your self-assessment covered that measure yet. It is an unknown, not a failure. Run the self-assessment to turn those into real answers.");
|
|
151
163
|
L.push("");
|
|
152
164
|
L.push("Status combines what Penthera measured (technical) with your self-assessment (organisational). *Proportionality applies: for a small entity, a short policy or process that genuinely exists counts.*");
|
|
153
165
|
L.push("");
|
package/lib/injections.js
CHANGED
|
@@ -38,6 +38,50 @@ async function safeFetch(url, opts = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// ── Timing-based detection helpers ───────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const TIMING_DELAY_MS = 2500; // payload asks the backend to sleep ~3s
|
|
44
|
+
const TIMING_FLOOR_MS = 3000;
|
|
45
|
+
|
|
46
|
+
function median(nums) {
|
|
47
|
+
if (!nums.length) return 0;
|
|
48
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
49
|
+
const mid = Math.floor(s.length / 2);
|
|
50
|
+
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Confirm a suspected time-based injection.
|
|
55
|
+
*
|
|
56
|
+
* A single slow response proves nothing: a GC pause, a cold serverless start,
|
|
57
|
+
* or ordinary network jitter all look identical to an injected sleep. So a
|
|
58
|
+
* candidate is re-sent a few times and only reported when the MEDIAN delay
|
|
59
|
+
* holds, which jitter does not survive.
|
|
60
|
+
*
|
|
61
|
+
* @param {function(): Promise<{elapsed:number}>} send - re-issues the request
|
|
62
|
+
* @param {number} baselineMs
|
|
63
|
+
* @param {number} samples
|
|
64
|
+
* @returns {Promise<{confirmed: boolean, medianMs: number, samples: number[]}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function confirmTimingDelay(send, baselineMs, samples = 3) {
|
|
67
|
+
const timings = [];
|
|
68
|
+
for (let i = 0; i < samples; i++) {
|
|
69
|
+
const res = await send();
|
|
70
|
+
if (!res || !res.ok) return { confirmed: false, medianMs: 0, samples: timings };
|
|
71
|
+
timings.push(res.elapsed);
|
|
72
|
+
// Bail early if a repeat comes back fast; a real sleep is consistent.
|
|
73
|
+
if (res.elapsed < baselineMs + TIMING_DELAY_MS) {
|
|
74
|
+
return { confirmed: false, medianMs: median(timings), samples: timings };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const m = median(timings);
|
|
78
|
+
return {
|
|
79
|
+
confirmed: m > baselineMs + TIMING_DELAY_MS && m > TIMING_FLOOR_MS,
|
|
80
|
+
medianMs: m,
|
|
81
|
+
samples: timings,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
41
85
|
// ── SQL Injection Detection ──────────────────────────────────────────────
|
|
42
86
|
|
|
43
87
|
const SQLI_ERROR_PATTERNS = [
|
|
@@ -128,33 +172,41 @@ export async function probeSqli(baseUrl, path, opts = {}) {
|
|
|
128
172
|
];
|
|
129
173
|
|
|
130
174
|
for (const { payload, db } of timingPayloads) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
175
|
+
const send = () => {
|
|
176
|
+
if (method === "GET") {
|
|
177
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
178
|
+
return safeFetch(`${baseUrl}${path}${sep}id=${encodeURIComponent(payload)}`, {
|
|
179
|
+
method,
|
|
180
|
+
timeout: 8_000,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return safeFetch(`${baseUrl}${path}`, {
|
|
140
184
|
method,
|
|
141
185
|
headers: { "Content-Type": "application/json" },
|
|
142
186
|
body: JSON.stringify({ id: payload }),
|
|
143
187
|
timeout: 8_000,
|
|
144
188
|
});
|
|
145
|
-
}
|
|
189
|
+
};
|
|
146
190
|
|
|
147
|
-
|
|
148
|
-
if (res.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
191
|
+
const res = await send();
|
|
192
|
+
if (!res.ok) continue;
|
|
193
|
+
|
|
194
|
+
// A slow first response is only a candidate. Re-sample before reporting, so
|
|
195
|
+
// jitter and cold starts don't get published as a high-severity finding.
|
|
196
|
+
if (res.elapsed > baseline.elapsed + TIMING_DELAY_MS && res.elapsed > TIMING_FLOOR_MS) {
|
|
197
|
+
const check = await confirmTimingDelay(send, baseline.elapsed);
|
|
198
|
+
if (check.confirmed) {
|
|
199
|
+
findings.push({
|
|
200
|
+
severity: "high",
|
|
201
|
+
confidence: "likely", // inferred from timing, not proven output
|
|
202
|
+
title: `Possible blind SQL injection (time-based, ${db})`,
|
|
203
|
+
description: `Payload: ${payload}. Delay held across ${check.samples.length} repeats (median ${check.medianMs}ms vs baseline ${baseline.elapsed}ms).`,
|
|
204
|
+
url: `${baseUrl}${path}`,
|
|
205
|
+
status: res.status,
|
|
206
|
+
category: "sqli",
|
|
207
|
+
source: "injection-probe",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
158
210
|
}
|
|
159
211
|
}
|
|
160
212
|
|
|
@@ -380,12 +432,13 @@ export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
|
380
432
|
});
|
|
381
433
|
|
|
382
434
|
for (const { payload, timing, pattern } of CMDI_PAYLOADS) {
|
|
383
|
-
const
|
|
435
|
+
const send = () => safeFetch(`${baseUrl}${path}`, {
|
|
384
436
|
method,
|
|
385
437
|
headers: { "Content-Type": "application/json" },
|
|
386
438
|
body: method === "POST" ? JSON.stringify({ input: payload, cmd: payload, command: payload }) : undefined,
|
|
387
439
|
timeout: timing ? 8_000 : INJECT_TIMEOUT,
|
|
388
440
|
});
|
|
441
|
+
const res = await send();
|
|
389
442
|
|
|
390
443
|
if (!res.ok) continue;
|
|
391
444
|
|
|
@@ -402,17 +455,22 @@ export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
|
402
455
|
return findings;
|
|
403
456
|
}
|
|
404
457
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
458
|
+
// Same rule as blind SQLi: one slow response is a candidate, not a finding.
|
|
459
|
+
if (timing && res.elapsed > baseline.elapsed + TIMING_DELAY_MS && res.elapsed > TIMING_FLOOR_MS) {
|
|
460
|
+
const check = await confirmTimingDelay(send, baseline.elapsed);
|
|
461
|
+
if (check.confirmed) {
|
|
462
|
+
findings.push({
|
|
463
|
+
severity: "high",
|
|
464
|
+
confidence: "likely", // inferred from timing, not proven output
|
|
465
|
+
title: "Possible OS command injection (time-based)",
|
|
466
|
+
description: `Payload "${payload}". Delay held across ${check.samples.length} repeats (median ${check.medianMs}ms vs baseline ${baseline.elapsed}ms).`,
|
|
467
|
+
url: `${baseUrl}${path}`,
|
|
468
|
+
status: res.status,
|
|
469
|
+
category: "cmdi",
|
|
470
|
+
source: "injection-probe",
|
|
471
|
+
});
|
|
472
|
+
return findings;
|
|
473
|
+
}
|
|
416
474
|
}
|
|
417
475
|
}
|
|
418
476
|
|
|
@@ -421,7 +479,17 @@ export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
|
421
479
|
|
|
422
480
|
// ── Context-Aware XSS (Dalfox-style) ────────────────────────────────────
|
|
423
481
|
|
|
424
|
-
|
|
482
|
+
/**
|
|
483
|
+
* Per-probe reflection canary.
|
|
484
|
+
*
|
|
485
|
+
* A fixed string is trivially WAF-fingerprintable and can collide with content
|
|
486
|
+
* left behind by an earlier scan, so each run gets a fresh one. It stays
|
|
487
|
+
* alphanumeric and lowercase so no context (attribute, JS string, URL) mangles
|
|
488
|
+
* or encodes it on the way through.
|
|
489
|
+
*/
|
|
490
|
+
export function makeCanary() {
|
|
491
|
+
return "pnth" + Math.random().toString(36).slice(2, 10) + "xss";
|
|
492
|
+
}
|
|
425
493
|
|
|
426
494
|
/**
|
|
427
495
|
* Reflected content is only executable as XSS when the browser renders the
|
|
@@ -445,6 +513,7 @@ export function isHtmlResponse(res) {
|
|
|
445
513
|
*/
|
|
446
514
|
export async function probeXss(baseUrl, path, opts = {}) {
|
|
447
515
|
const findings = [];
|
|
516
|
+
const CANARY = makeCanary();
|
|
448
517
|
const inputParams = ["q", "search", "query", "name", "input", "text", "value", "msg", "error", "redirect", "url", "id"];
|
|
449
518
|
|
|
450
519
|
for (const param of inputParams) {
|
package/lib/machine.js
CHANGED
|
@@ -35,6 +35,27 @@ function run(cmd, opts = {}) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Verify a binary's code signature. Returns true only when codesign exits 0.
|
|
40
|
+
*
|
|
41
|
+
* Uses execFileSync so the path is an argv entry: these paths are read from
|
|
42
|
+
* plists that any local process can write, and interpolating one into a shell
|
|
43
|
+
* string would hand a malicious persistence entry command execution inside the
|
|
44
|
+
* scanner. Anything other than a clean exit counts as unsigned.
|
|
45
|
+
*/
|
|
46
|
+
function verifySignature(program) {
|
|
47
|
+
try {
|
|
48
|
+
execFileSync("codesign", ["-v", "--", program], {
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
timeout: 15_000,
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
function runFile(file, args = [], opts = {}) {
|
|
39
60
|
try {
|
|
40
61
|
return execFileSync(file, args, {
|
|
@@ -140,12 +161,22 @@ function checkFileVault() {
|
|
|
140
161
|
function checkXProtect() {
|
|
141
162
|
const version = run("xprotect version 2>&1");
|
|
142
163
|
const status = run("xprotect status 2>&1");
|
|
143
|
-
|
|
164
|
+
// XProtect ships enabled on modern macOS, so its presence isn't interesting.
|
|
165
|
+
// Pending signature updates are: they mean known malware may go undetected.
|
|
166
|
+
const updatesPending = /updates available/i.test(status);
|
|
144
167
|
return {
|
|
145
168
|
name: "XProtect (Apple Malware Detection)",
|
|
146
169
|
status: version || "unknown",
|
|
147
170
|
detail: status,
|
|
148
|
-
finding:
|
|
171
|
+
finding: updatesPending
|
|
172
|
+
? {
|
|
173
|
+
severity: "low",
|
|
174
|
+
title: "XProtect signature updates pending",
|
|
175
|
+
description: "macOS reports XProtect updates are available. Until they install, recently known malware may not be detected.",
|
|
176
|
+
category: "machine",
|
|
177
|
+
source: "machine-audit",
|
|
178
|
+
}
|
|
179
|
+
: null,
|
|
149
180
|
};
|
|
150
181
|
}
|
|
151
182
|
|
|
@@ -192,11 +223,14 @@ function checkPersistence() {
|
|
|
192
223
|
|
|
193
224
|
const isKnown = KNOWN_PREFIXES.some((p) => label.toLowerCase().startsWith(p));
|
|
194
225
|
|
|
195
|
-
// Check code signature of the binary
|
|
226
|
+
// Check code signature of the binary.
|
|
227
|
+
// `program` comes from an attacker-writable plist, so it is passed as an
|
|
228
|
+
// argv entry, never interpolated into a shell string. Verification uses
|
|
229
|
+
// codesign's exit status rather than string-matching its output, so an
|
|
230
|
+
// unexpected message can't be read as "signed" (fail closed, not open).
|
|
196
231
|
let signed = null;
|
|
197
232
|
if (program !== "unknown" && existsSync(program)) {
|
|
198
|
-
|
|
199
|
-
signed = sigOut === "" || sigOut.includes("valid on disk"); // empty = valid
|
|
233
|
+
signed = verifySignature(program);
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
items.push({
|
package/lib/nuclei-loader.js
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
* Nuclei Community Template Loader
|
|
3
3
|
*
|
|
4
4
|
* Parses and executes community-authored Nuclei YAML templates.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* This is a useful subset of Nuclei, not a reimplementation of it. Supported:
|
|
6
7
|
* - HTTP requests with method, path, headers, body
|
|
7
|
-
* - Matchers: status, word, regex, negative-word, negative-regex
|
|
8
|
-
* - Extractors: regex, json
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
8
|
+
* - Matchers: status, word, regex, negative-word, negative-regex
|
|
9
|
+
* - Extractors: regex, json
|
|
10
|
+
* - Placeholders: {{BaseURL}}, {{Hostname}}, {{RootURL}}
|
|
11
|
+
*
|
|
12
|
+
* Not supported, and reported rather than silently ignored (see
|
|
13
|
+
* collectUnsupported): dsl matchers, kval extractors, dynamic helpers such as
|
|
14
|
+
* {{rand_int}}, payload/attack modes, workflows, and multi-request chaining.
|
|
15
|
+
* Only the first path or raw entry of a request block is executed. A template
|
|
16
|
+
* relying on any of these will not match, so the loader says so instead of
|
|
17
|
+
* letting it look like a clean result.
|
|
11
18
|
*
|
|
12
19
|
* Requires the `yaml` npm package (zero-dependency YAML parser).
|
|
13
20
|
*
|
|
@@ -121,6 +128,9 @@ function parseTemplateFile(filePath, parse) {
|
|
|
121
128
|
},
|
|
122
129
|
variables: doc.variables || {},
|
|
123
130
|
http: http.map(normalizeHttpBlock),
|
|
131
|
+
// Features this loader can't execute. Non-empty means the template will
|
|
132
|
+
// not match even if the target is vulnerable.
|
|
133
|
+
unsupported: collectUnsupported(doc),
|
|
124
134
|
_source: filePath,
|
|
125
135
|
};
|
|
126
136
|
} catch {
|
|
@@ -128,6 +138,47 @@ function parseTemplateFile(filePath, parse) {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Report the Nuclei features a template needs that this loader does not
|
|
143
|
+
* implement. A template using any of them silently fails to match, which reads
|
|
144
|
+
* like "no vulnerability found", so callers surface this instead.
|
|
145
|
+
*
|
|
146
|
+
* @returns {string[]} human-readable reasons, empty when fully supported
|
|
147
|
+
*/
|
|
148
|
+
export function collectUnsupported(template) {
|
|
149
|
+
const reasons = new Set();
|
|
150
|
+
const blocks = template?.http || template?.requests || [];
|
|
151
|
+
|
|
152
|
+
if (Array.isArray(blocks) && blocks.length > 1) {
|
|
153
|
+
reasons.add("multi-request chaining (only the first request runs)");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const block of Array.isArray(blocks) ? blocks : []) {
|
|
157
|
+
if (Array.isArray(block?.path) && block.path.length > 1) {
|
|
158
|
+
reasons.add("multiple paths (only the first is requested)");
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(block?.raw) && block.raw.length > 1) {
|
|
161
|
+
reasons.add("multiple raw requests (only the first is sent)");
|
|
162
|
+
}
|
|
163
|
+
if (block?.payloads || block?.attack) reasons.add("payload/attack modes");
|
|
164
|
+
for (const m of block?.matchers || []) {
|
|
165
|
+
if (m?.type === "dsl") reasons.add("dsl matchers");
|
|
166
|
+
if (m?.part && !["body", "header", "all", "response"].includes(m.part)) {
|
|
167
|
+
reasons.add(`matcher part "${m.part}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const e of block?.extractors || []) {
|
|
171
|
+
if (e?.type === "kval") reasons.add("kval extractors");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const asText = JSON.stringify(blocks || "");
|
|
176
|
+
if (/\{\{\s*rand_/.test(asText)) reasons.add("dynamic helpers ({{rand_*}})");
|
|
177
|
+
if (template?.workflows) reasons.add("workflows");
|
|
178
|
+
|
|
179
|
+
return [...reasons];
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
/**
|
|
132
183
|
* Normalize an HTTP request block to our internal format.
|
|
133
184
|
*/
|
package/lib/retirejs.js
CHANGED
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Upstream RetireJS repository. This previously pointed at a personal fork that
|
|
18
|
+
// now 404s, which made every scan silently report "no vulnerable libraries".
|
|
19
|
+
// A vulnerability database that fails closed and quietly is worse than none.
|
|
20
|
+
const DB_URLS = [
|
|
21
|
+
"https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/gh/RetireJS/retire.js@master/repository/jsrepository.json",
|
|
23
|
+
];
|
|
19
24
|
const DB_CACHE_MS = 3600_000; // 1 hour
|
|
20
25
|
|
|
21
26
|
let cachedDb = null;
|
|
@@ -38,16 +43,22 @@ async function safeFetch(url, timeout = 15_000) {
|
|
|
38
43
|
async function loadDb() {
|
|
39
44
|
if (cachedDb && Date.now() - cachedAt < DB_CACHE_MS) return cachedDb;
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// Try each mirror in turn so one host being down doesn't disable the check.
|
|
47
|
+
for (const url of DB_URLS) {
|
|
48
|
+
const res = await safeFetch(url);
|
|
49
|
+
if (!res || !res.ok) continue;
|
|
50
|
+
try {
|
|
51
|
+
const db = await res.json();
|
|
52
|
+
if (db && typeof db === "object" && Object.keys(db).length > 0) {
|
|
53
|
+
cachedDb = db;
|
|
54
|
+
cachedAt = Date.now();
|
|
55
|
+
return cachedDb;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Malformed response from this mirror; try the next.
|
|
59
|
+
}
|
|
50
60
|
}
|
|
61
|
+
return null;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
// ── Version comparison ───────────────────────────────────────────────────
|
|
@@ -149,8 +160,21 @@ export async function scanJsLibraries(baseUrl, opts = {}) {
|
|
|
149
160
|
progress("Fetching Retire.js vulnerability database...");
|
|
150
161
|
const db = await loadDb();
|
|
151
162
|
if (!db) {
|
|
152
|
-
return
|
|
163
|
+
// Never return a silent "no vulnerable libraries" when the check could not
|
|
164
|
+
// run. Say so, and tell the caller the probe did not really execute.
|
|
165
|
+
result.dbLoaded = false;
|
|
166
|
+
result.findings.push({
|
|
167
|
+
severity: "info",
|
|
168
|
+
title: "JS dependency check did not run",
|
|
169
|
+
description: "Could not load the Retire.js vulnerability database (offline, or every mirror unreachable). Known-CVE checks on client-side JavaScript were skipped for this scan; this is not a clean result.",
|
|
170
|
+
url: baseUrl,
|
|
171
|
+
category: "config",
|
|
172
|
+
source: "retirejs",
|
|
173
|
+
confidence: "confirmed",
|
|
174
|
+
});
|
|
175
|
+
return result;
|
|
153
176
|
}
|
|
177
|
+
result.dbLoaded = true;
|
|
154
178
|
|
|
155
179
|
// Fetch the target page HTML
|
|
156
180
|
const res = await safeFetch(baseUrl, timeout);
|
package/lib/templates.js
CHANGED
|
@@ -85,6 +85,24 @@ export async function executeTemplate(baseUrl, template) {
|
|
|
85
85
|
/**
|
|
86
86
|
* Run a single matcher against response data.
|
|
87
87
|
*/
|
|
88
|
+
// Community templates are untrusted input: they arrive as YAML from a directory
|
|
89
|
+
// the user points us at, and their regexes run against whole response bodies.
|
|
90
|
+
// A catastrophically-backtracking pattern would hang the scanner, so bound both
|
|
91
|
+
// the input and the pattern. This is a mitigation, not an immunity proof: real
|
|
92
|
+
// guarantees need a non-backtracking engine or a killable worker.
|
|
93
|
+
const REGEX_INPUT_CAP = 256 * 1024; // 256 KB is far past any real matcher's needs
|
|
94
|
+
const NESTED_QUANTIFIER = /(\([^)]*[+*][^)]*\)|\[[^\]]*\][^)]*)[+*]\s*[+*]|\((?:\?:)?[^)]*[+*]\)[+*]/;
|
|
95
|
+
|
|
96
|
+
function safeRegexTest(pattern, target, flags = "") {
|
|
97
|
+
if (NESTED_QUANTIFIER.test(String(pattern))) return false; // refuse the shape that blows up
|
|
98
|
+
try {
|
|
99
|
+
const input = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
100
|
+
return new RegExp(pattern, flags).test(input);
|
|
101
|
+
} catch {
|
|
102
|
+
return false; // invalid pattern in a template must not abort the scan
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
function runMatcher(matcher, ctx) {
|
|
89
107
|
const part = matcher.part || "body";
|
|
90
108
|
const target = part === "header" ? ctx.headers : ctx.body;
|
|
@@ -103,7 +121,7 @@ function runMatcher(matcher, ctx) {
|
|
|
103
121
|
|
|
104
122
|
case "regex": {
|
|
105
123
|
const patterns = matcher.regex || [];
|
|
106
|
-
const check = (p) =>
|
|
124
|
+
const check = (p) => safeRegexTest(p, target, matcher.flags);
|
|
107
125
|
return matcher.condition === "and"
|
|
108
126
|
? patterns.every(check)
|
|
109
127
|
: patterns.some(check);
|
|
@@ -116,7 +134,7 @@ function runMatcher(matcher, ctx) {
|
|
|
116
134
|
|
|
117
135
|
case "negative-regex": {
|
|
118
136
|
const patterns = matcher.regex || [];
|
|
119
|
-
return patterns.every(p => !
|
|
137
|
+
return patterns.every(p => !safeRegexTest(p, target));
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
default:
|
|
@@ -134,7 +152,9 @@ function runExtractor(extractor, ctx) {
|
|
|
134
152
|
switch (extractor.type) {
|
|
135
153
|
case "regex": {
|
|
136
154
|
for (const pattern of extractor.regex || []) {
|
|
137
|
-
|
|
155
|
+
if (NESTED_QUANTIFIER.test(String(pattern))) continue;
|
|
156
|
+
const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
157
|
+
const match = capped.match(new RegExp(pattern));
|
|
138
158
|
if (match) return match[extractor.group || 0];
|
|
139
159
|
}
|
|
140
160
|
return null;
|
|
@@ -182,7 +202,11 @@ export const BUILT_IN_TEMPLATES = [
|
|
|
182
202
|
method: "GET", path: "/.env.local",
|
|
183
203
|
matchers: [
|
|
184
204
|
{ type: "status", status: [200] },
|
|
185
|
-
|
|
205
|
+
// Must look like a dotenv file, not just any 200 containing "=".
|
|
206
|
+
// SPAs serve index.html for unknown paths, and HTML is full of "=",
|
|
207
|
+
// which made this fire critical on healthy sites.
|
|
208
|
+
{ type: "regex", regex: ["(?m)^[A-Z][A-Z0-9_]{2,}="] },
|
|
209
|
+
{ type: "negative-word", words: ["<html", "<!DOCTYPE", "<script"] },
|
|
186
210
|
],
|
|
187
211
|
"matchers-condition": "and",
|
|
188
212
|
}],
|
|
@@ -247,7 +271,10 @@ export const BUILT_IN_TEMPLATES = [
|
|
|
247
271
|
method: "GET", path: "/.json",
|
|
248
272
|
matchers: [
|
|
249
273
|
{ type: "status", status: [200] },
|
|
250
|
-
|
|
274
|
+
// Require an actual JSON body. Without this an SPA's index.html served
|
|
275
|
+
// at /.json counted as an open Firebase database.
|
|
276
|
+
{ type: "regex", regex: ["^\\s*[\\{\\[]"] },
|
|
277
|
+
{ type: "negative-word", words: ["Permission denied", "error", "<html", "<!DOCTYPE"] },
|
|
251
278
|
],
|
|
252
279
|
"matchers-condition": "and",
|
|
253
280
|
}],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "penthera",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lightweight security scanner for web apps — scan URLs or repos from the CLI",
|
|
6
6
|
"repository": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"url": "https://github.com/danoszz/penthera/issues"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"penthera": "
|
|
16
|
-
"penthera-scan": "
|
|
15
|
+
"penthera": "bin/penthera.js",
|
|
16
|
+
"penthera-scan": "bin/scan.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"bin",
|
package/skills/penthera/SKILL.md
CHANGED
package/src/cli/baseline.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Compare scan findings against a previous JSON report.
|
|
3
3
|
*/
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fingerprintFinding } from "../../lib/audit-loop.js";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Shared with dedupe and the audit loop so one finding has one identity
|
|
8
|
+
// everywhere. Deliberately excludes severity: a finding whose severity is
|
|
9
|
+
// re-rated is the same finding, and keying on it made every re-rating show up
|
|
10
|
+
// as one resolved plus one new.
|
|
11
|
+
const findingKey = fingerprintFinding;
|
|
9
12
|
|
|
10
13
|
export function compareWithBaseline(currentFindings, baselinePath) {
|
|
11
14
|
let baseline;
|
package/src/cli/merge-results.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { enrichFindingsWithWstg } from "../../lib/owasp-wstg.js";
|
|
5
5
|
import { stampProvenance } from "../../lib/provenance.js";
|
|
6
|
+
import { fingerprintFinding } from "../../lib/audit-loop.js";
|
|
6
7
|
|
|
7
8
|
export function mergeResults(results) {
|
|
8
9
|
let merged;
|
|
@@ -63,7 +64,8 @@ function mergeEndpoints(results) {
|
|
|
63
64
|
export function dedupeFindings(findings) {
|
|
64
65
|
const seen = new Set();
|
|
65
66
|
return findings.filter((f) => {
|
|
66
|
-
|
|
67
|
+
// Same identity function as the baseline diff and the audit loop.
|
|
68
|
+
const key = fingerprintFinding(f);
|
|
67
69
|
if (seen.has(key)) return false;
|
|
68
70
|
seen.add(key);
|
|
69
71
|
return true;
|
package/src/cli.js
CHANGED
|
@@ -453,7 +453,7 @@ export async function run() {
|
|
|
453
453
|
if (!opts.quiet && !opts.json) {
|
|
454
454
|
const s = merged.readiness.summary;
|
|
455
455
|
process.stderr.write(
|
|
456
|
-
` NIS2 readiness ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["
|
|
456
|
+
` NIS2 readiness ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["not-assessed"]} not assessed\n`,
|
|
457
457
|
);
|
|
458
458
|
process.stderr.write(` Readiness report ${rdPath}\n`);
|
|
459
459
|
}
|
package/src/reporter.js
CHANGED
|
@@ -319,7 +319,11 @@ export function printReport(result, opts = {}) {
|
|
|
319
319
|
for (const f of sorted) {
|
|
320
320
|
const colorFn = SEV_COLOR[f.severity] || c.dim;
|
|
321
321
|
const label = SEV_LABEL[f.severity] || " ";
|
|
322
|
-
|
|
322
|
+
// Flag the ones a scanner genuinely can't adjudicate, so nobody reads a
|
|
323
|
+
// heuristic as a confirmed break. Certainties stay unannotated.
|
|
324
|
+
const needsReview = f.confidence === "needs-human-review" || f.confidence === "potential";
|
|
325
|
+
const tag = needsReview ? c.dim(` [${f.confidence}]`) : "";
|
|
326
|
+
w(` ${colorFn(label)} ${f.title}${tag}`);
|
|
323
327
|
if (f.url) w(` ${" ".repeat(6)} ${c.dim(f.url)}${f.status ? c.dim(` \u2192 ${f.status}`) : ""}`);
|
|
324
328
|
if (f.description && opts.verbose) w(` ${" ".repeat(6)} ${c.dim(f.description)}`);
|
|
325
329
|
}
|
|
@@ -457,7 +461,12 @@ export function writeSarifReport(result, filePath) {
|
|
|
457
461
|
}] : [],
|
|
458
462
|
properties: {
|
|
459
463
|
severity: f.severity,
|
|
464
|
+
// Provenance travels with the finding into GitHub code scanning, so a
|
|
465
|
+
// heuristic result stays labelled as one wherever it is consumed.
|
|
466
|
+
confidence: f.confidence,
|
|
460
467
|
source: f.source,
|
|
468
|
+
method: f.method,
|
|
469
|
+
collectedAt: f.collected_at,
|
|
461
470
|
url: f.url,
|
|
462
471
|
httpStatus: f.status,
|
|
463
472
|
wstg: f.wstg || resolveWstgTags(f),
|
package/src/scan-url.js
CHANGED
|
@@ -76,6 +76,8 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
76
76
|
const startTime = Date.now();
|
|
77
77
|
|
|
78
78
|
const local = isPrivateHost(target);
|
|
79
|
+
// Set false when the Retire.js database can't load, so we don't claim the probe ran.
|
|
80
|
+
let retireJsRan = false;
|
|
79
81
|
const hostname = new URL(target).hostname;
|
|
80
82
|
const isIpHost = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");
|
|
81
83
|
|
|
@@ -385,6 +387,9 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
385
387
|
progress("Scanning for vulnerable JS libraries (Retire.js)...");
|
|
386
388
|
try {
|
|
387
389
|
const jsResult = await scanJsLibraries(target, { onPhase: progress });
|
|
390
|
+
// Only count the probe as run when its database actually loaded, so the
|
|
391
|
+
// WSTG and compliance coverage can't claim a check that was skipped.
|
|
392
|
+
retireJsRan = jsResult.dbLoaded !== false;
|
|
388
393
|
if (jsResult.libraries.length > 0) {
|
|
389
394
|
result.jsLibraries = jsResult.libraries;
|
|
390
395
|
}
|
|
@@ -500,6 +505,22 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
500
505
|
severity: ["critical", "high", "medium"],
|
|
501
506
|
});
|
|
502
507
|
progress(`Loaded ${templates.length} templates, scanning...`);
|
|
508
|
+
// Templates needing Nuclei features this loader doesn't implement won't
|
|
509
|
+
// match even against a vulnerable target. Say so rather than letting the
|
|
510
|
+
// silence read as a clean result.
|
|
511
|
+
const limited = templates.filter((t) => t.unsupported?.length);
|
|
512
|
+
if (limited.length > 0) {
|
|
513
|
+
const reasons = [...new Set(limited.flatMap((t) => t.unsupported))];
|
|
514
|
+
progress(`${limited.length} template(s) use unsupported features and cannot match: ${reasons.join("; ")}`);
|
|
515
|
+
result.findings.push({
|
|
516
|
+
severity: "info",
|
|
517
|
+
title: `${limited.length} community template(s) could not run fully`,
|
|
518
|
+
description: `These templates rely on Nuclei features Penthera does not implement (${reasons.join("; ")}), so they cannot produce a match. Their silence is not a clean result. Templates affected: ${limited.slice(0, 5).map((t) => t.id).join(", ")}${limited.length > 5 ? ", ..." : ""}.`,
|
|
519
|
+
category: "config",
|
|
520
|
+
source: "plugin-loader",
|
|
521
|
+
confidence: "confirmed",
|
|
522
|
+
});
|
|
523
|
+
}
|
|
503
524
|
const pluginFindings = await runTemplateScan(target, templates, { onPhase: progress });
|
|
504
525
|
result.findings.push(...pluginFindings);
|
|
505
526
|
} catch (e) {
|
|
@@ -547,7 +568,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
547
568
|
...(result.tls ? ["tls"] : []),
|
|
548
569
|
...(result.fingerprint?.headers ? ["security-headers"] : []),
|
|
549
570
|
...(auth.bearer ? ["jwt"] : []),
|
|
550
|
-
...(!opts.skipRetireJs ? ["retirejs"] : []),
|
|
571
|
+
...(!opts.skipRetireJs && retireJsRan ? ["retirejs"] : []),
|
|
551
572
|
...(!opts.skipParamDiscovery && hadEndpoints ? ["param-discovery"] : []),
|
|
552
573
|
...(opts.recon && !local ? ["recon"] : []),
|
|
553
574
|
...(opts.deep && hadEndpoints ? ["sqli", "ssti", "ssrf", "xss", "cmdi", "open-redirect"] : []),
|