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 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
- ![version](https://img.shields.io/badge/version-1.1.0-0000ed)
7
+ [![npm](https://img.shields.io/npm/v/penthera?color=0000ed)](https://www.npmjs.com/package/penthera)
8
8
  ![node](https://img.shields.io/badge/node-%E2%89%A518-0000ed)
9
9
  ![license](https://img.shields.io/badge/license-MIT-0000ed)
10
10
  [![CI](https://github.com/danoszz/penthera/actions/workflows/ci.yml/badge.svg)](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 | Report scaffolding (on the roadmap) | Planned |
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 (Retire.js); leaked third-party credentials (secret scan) | Partial |
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 + re-scan-to-verify is a repeatable effectiveness check | Partial |
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
- if (known.length === 0) return attestS === "na" ? "n/a" : "gap";
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 = { met: "Met", partial: "Partial", gap: "Gap", "n/a": "N/A" };
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
- let res;
132
- if (method === "GET") {
133
- const sep = path.includes("?") ? "&" : "?";
134
- res = await safeFetch(`${baseUrl}${path}${sep}id=${encodeURIComponent(payload)}`, {
135
- method,
136
- timeout: 8_000,
137
- });
138
- } else {
139
- res = await safeFetch(`${baseUrl}${path}`, {
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
- // If response took significantly longer than baseline, possible blind SQLi
148
- if (res.elapsed > baseline.elapsed + 2500 && res.elapsed > 3000) {
149
- findings.push({
150
- severity: "high",
151
- title: `Possible blind SQL injection (time-based, ${db})`,
152
- description: `Payload: ${payload}, response took ${res.elapsed}ms vs baseline ${baseline.elapsed}ms`,
153
- url: `${baseUrl}${path}`,
154
- status: res.status,
155
- category: "sqli",
156
- source: "injection-probe",
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 res = await safeFetch(`${baseUrl}${path}`, {
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
- if (timing && res.elapsed > baseline.elapsed + 2500 && res.elapsed > 3000) {
406
- findings.push({
407
- severity: "high",
408
- title: "Possible OS command injection (time-based)",
409
- description: `Payload "${payload}", ${res.elapsed}ms vs baseline ${baseline.elapsed}ms`,
410
- url: `${baseUrl}${path}`,
411
- status: res.status,
412
- category: "cmdi",
413
- source: "injection-probe",
414
- });
415
- return findings;
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
- const CANARY = "pnth3r4xss";
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
- const upToDate = !status.includes("updates available");
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: null, // XProtect is always on in modern macOS
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
- const sigOut = run(`codesign -v "${program}" 2>&1`);
199
- signed = sigOut === "" || sigOut.includes("valid on disk"); // empty = valid
233
+ signed = verifySignature(program);
200
234
  }
201
235
 
202
236
  items.push({
@@ -2,12 +2,19 @@
2
2
  * Nuclei Community Template Loader
3
3
  *
4
4
  * Parses and executes community-authored Nuclei YAML templates.
5
- * Supports the most common Nuclei template features:
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, dsl
8
- * - Extractors: regex, json, kval
9
- * - Variables and dynamic helpers ({{BaseURL}}, {{Hostname}}, {{rand_int}})
10
- * - Multi-request templates with matchers-condition
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
- const DB_URL =
18
- "https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json";
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
- const res = await safeFetch(DB_URL);
42
- if (!res || !res.ok) return null;
43
-
44
- try {
45
- cachedDb = await res.json();
46
- cachedAt = Date.now();
47
- return cachedDb;
48
- } catch {
49
- return null;
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 result; // Can't check without DB
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) => new RegExp(p, matcher.flags || "").test(target);
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 => !new RegExp(p).test(target));
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
- const match = target.match(new RegExp(pattern));
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
- { type: "word", words: ["="], condition: "or" },
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
- { type: "negative-word", words: ["Permission denied", "error"] },
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.0",
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": "./bin/penthera.js",
16
- "penthera-scan": "./bin/scan.js"
15
+ "penthera": "bin/penthera.js",
16
+ "penthera-scan": "bin/scan.js"
17
17
  },
18
18
  "files": [
19
19
  "bin",
@@ -13,7 +13,7 @@ compatibility: Node.js 18+, network access to target, Penthera CLI (npm link or
13
13
  allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
14
14
  metadata:
15
15
  author: danoszz
16
- version: 1.1.0
16
+ version: 1.1.1
17
17
  category: security
18
18
  ---
19
19
 
@@ -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
- function findingKey(f) {
7
- return `${f.severity}::${f.title}::${f.url || ""}::${f.source || ""}`;
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;
@@ -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
- const key = `${f.severity}::${f.title}::${f.url || ""}::${f.source || ""}`;
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["n/a"]} n/a\n`,
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
- w(` ${colorFn(label)} ${f.title}`);
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"] : []),