penthera 1.1.0 → 1.2.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.
@@ -0,0 +1,166 @@
1
+ # Compliance: NIS2 and the cyberwetgeving
2
+
3
+ Back to the [README](../README.md).
4
+
5
+ The EU NIS2 directive and its national transpositions (the Cyberbeveiligingswet
6
+ in the Netherlands, the NIS2-wet in Belgium) put a duty of care, the *zorgplicht*,
7
+ on in-scope organisations. That duty is the set of risk-management measures in
8
+ Article 21(2) of the directive. Most of them are organisational. Some are
9
+ technical and testable, and that is the part Penthera helps with.
10
+
11
+ Penthera does not make you compliant and does not certify anything. What it gives
12
+ you is automated, repeatable technical evidence for the measures a scanner can
13
+ honestly verify: the kind of artefact you can attach to a risk file, show an
14
+ auditor, or run in CI so a regression shows up before it ships.
15
+
16
+ | Art. 21(2) measure | What Penthera does | Coverage |
17
+ |--------------------|--------------------|----------|
18
+ | **(a)** Risk analysis & information-security policy | Findings, severity, and baseline history feed your risk analysis | Evidence |
19
+ | **(b)** Incident handling | `--incident` drafts the 24h / 72h / 1-month reports against your authority's required content | Partial |
20
+ | **(c)** Business continuity, backup, crisis management | Organisational; outside a scanner's reach | Out of scope |
21
+ | **(d)** Supply-chain security | Known-CVE checks on JS dependencies, leaked credentials, a CycloneDX SBOM (`--sbom`), and passive supplier rating (`--suppliers`) | Partial |
22
+ | **(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** |
23
+ | **(f)** Assessing the effectiveness of measures | Baseline diff, re-scan-to-verify, and `--history` tracking of time-to-fix, ageing, and drift | Partial |
24
+ | **(g)** Cyber hygiene & training | Remediation playbook + secure-defaults guidance (guidance, not a training programme) | Partial |
25
+ | **(h)** Cryptography & encryption | TLS protocol/cipher/certificate audit, HSTS, cookie `Secure` (transport layer only) | Partial |
26
+ | **(i)** HR security, **access control**, asset management | Access control tested: JWT, IDOR/BOLA, OAuth redirect, client-side-auth, trust-boundary mapping | Partial |
27
+ | **(j)** Multi-factor authentication & secure communications | MFA probe (on the roadmap) | Planned |
28
+
29
+ **Coverage key:** *Strong* = automated technical testing · *Partial* = part of the
30
+ measure · *Evidence* = output feeds the measure, no direct test · *Planned* = on the
31
+ roadmap · *Out of scope* = organisational or physical.
32
+
33
+ In practice, Penthera today gives you solid automated evidence for the
34
+ secure-development and vulnerability-handling duty (e), which is the technical
35
+ heart of the zorgplicht, plus meaningful coverage of supply chain (d),
36
+ cryptography in transit (h), access control (i), and effectiveness testing (f).
37
+ That is not the whole duty of care, but it is a real, defensible start you can run
38
+ today and re-run on every deploy.
39
+
40
+ > **Not legal advice, not a certification, not a guarantee of compliance.**
41
+ > Penthera automates the technical-testing part of the duty of care. The
42
+ > directive's organisational duties (governance, risk-management policy,
43
+ > incident-notification processes, supply-chain contracts, staff training) need
44
+ > human process and, where it matters, review by a qualified advisor. For an
45
+ > authoritative reading, consult your national authority (the NCSC in the
46
+ > Netherlands, the CCB and its CyFun framework in Belgium) or a specialised jurist.
47
+
48
+ Generate this mapping for your own scan with `--framework nis2`. It adds a
49
+ per-run coverage table (which measures *this* scan actually exercised, driven by
50
+ the probes that ran) to the terminal, Markdown, and JSON reports:
51
+
52
+ ```bash
53
+ penthera https://myapp.com --framework nis2 -o reports/scan.json
54
+ ```
55
+
56
+ ### Readiness report (clone → report in two commands)
57
+
58
+ Go further than the mapping: combine the scan with a short offline
59
+ self-assessment (the organisational measures a scanner can't test, like policies,
60
+ backups, training, and MFA) into a full NIS2 readiness report. Every Article 21
61
+ measure is marked `met / partial / gap / n-a`, with proportionality notes, your
62
+ incident-reporting deadlines, and provenance on every piece of evidence.
63
+
64
+ ```bash
65
+ # 1. Create your self-assessment (offline, one-time). Fill in yes | partial | no | na.
66
+ penthera --assessment-init assessment.json
67
+
68
+ # 2. Scan + self-assessment → readiness report (writes reports/scan-readiness.md)
69
+ penthera https://myapp.com --repo . --readiness --assessment assessment.json -o reports/scan.json
70
+ ```
71
+
72
+ The readiness report is **evidence and self-assessment tooling, not a
73
+ certification**. It is provenance-linked (`source, method, collected_at,
74
+ confidence`) and reproducible, and it lists exactly what only you can attest to.
75
+ Jurisdiction specifics live in [`lib/jurisdictions/nl.json`](../lib/jurisdictions/nl.json) (NL today; BE and
76
+ others are just added config).
77
+
78
+ ### Answer a customer's security questionnaire
79
+
80
+ The questionnaire blocking your deal, drafted from evidence you already have:
81
+
82
+ ```bash
83
+ # one question per line, pasted from the customer's form
84
+ penthera https://myapp.com --readiness --assessment assessment.json --questionnaire questions.txt
85
+ ```
86
+
87
+ Each question is mapped to the Article 21 measure it belongs to and answered
88
+ from the scan and your self-assessment, with the backing evidence cited.
89
+ Anything Penthera can't map is marked `NEEDS HUMAN INPUT` instead of guessed.
90
+ Review every answer before you send it.
91
+
92
+ ### Policy pack
93
+
94
+ Generate the proportionate written policies NIS2 expects, pre-filled from your
95
+ readiness gaps and the NL incident deadlines:
96
+
97
+ ```bash
98
+ penthera --policy-pack ./policies --org "Your Company" --readiness --assessment assessment.json
99
+ ```
100
+
101
+ Writes editable Markdown starting points: information security, access control,
102
+ incident response, backup/continuity, acceptable use, supplier security, and
103
+ vulnerability management. **Templates, not legal advice.** Keep them short and
104
+ used. A two-page policy that genuinely exists is what "proportionate" means.
105
+
106
+ ### Incident-report helper
107
+
108
+ NIS2 has hard reporting deadlines (24h early warning, 72h notification, 1-month
109
+ final report). If something happens, get the timeline and draft reports:
110
+
111
+ ```bash
112
+ penthera --incident-init incident.json # fill in what happened (set aware_at)
113
+ penthera --incident incident.json # writes incident-reports.md
114
+ ```
115
+
116
+ It computes your three deadlines from when you became aware, flags anything
117
+ overdue, and drafts the early-warning, notification, and final report against
118
+ the required content, with `NEEDS INPUT` where you still have to fill in facts.
119
+ **These are drafts to help you meet the deadlines, not a substitute for your
120
+ authority's official form.**
121
+
122
+ ### Software bill of materials (SBOM)
123
+
124
+ Emit a standard CycloneDX SBOM of your dependencies. It's what enterprises
125
+ increasingly require of suppliers, and direct evidence for supply-chain security
126
+ (measure d):
127
+
128
+ ```bash
129
+ penthera --repo . --sbom sbom.cdx.json
130
+ ```
131
+
132
+ Parses `package-lock.json` / `package.json` / `requirements.txt` offline (no
133
+ network), with resolved versions and package URLs (purls).
134
+
135
+ ### Remediation tracking (the audit loop)
136
+
137
+ Closing findings matters more than finding them. Track them over time:
138
+ time-to-fix, ageing of open items, and what changed since the last scan (evidence
139
+ for measure f, effectiveness):
140
+
141
+ ```bash
142
+ penthera https://myapp.com --history reports/history.json -o reports/scan.json
143
+ ```
144
+
145
+ Each run folds the current findings into a small history keyed by a **stable
146
+ fingerprint** (so a re-worded or re-counted finding isn't mistaken for a new
147
+ one), then reports open vs resolved, median time-to-fix, and ageing buckets.
148
+ Re-run on a cadence to watch the trend.
149
+
150
+ ### Supplier rating (passive)
151
+
152
+ Assess your suppliers' public security posture for supply-chain risk (measure d):
153
+
154
+ ```bash
155
+ printf 'supplier1.com\nsupplier2.com\n' > suppliers.txt
156
+ penthera --suppliers suppliers.txt
157
+ ```
158
+
159
+ **Strictly passive and public-data-only.** One homepage GET (security headers),
160
+ a TLS handshake (certificate), and public DNS lookups (SPF/DMARC/MX) per domain.
161
+ No endpoint discovery, no auth probes, no intrusive testing. You get a
162
+ red/amber/green rating per supplier. It's a screening aid for your own vendor
163
+ risk assessment, **not** an authorised penetration test of the supplier.
164
+
165
+ Because the mapping targets the NIS2 directive itself, it applies across the
166
+ national transpositions; jurisdiction-specific profiles (NL, BE) are on the roadmap.
@@ -0,0 +1,55 @@
1
+ # Example: scan your staging URL with Penthera on a schedule.
2
+ #
3
+ # Copy this file into YOUR app's repo as `.github/workflows/penthera.yml` and add
4
+ # a `PENTEST_STAGING_URL` repository secret. Only point it at a URL you own or are
5
+ # authorized to test. Findings appear under Security > Code scanning.
6
+ #
7
+ # Pin the Penthera version (and, ideally, the actions below to commit SHAs) so a
8
+ # scheduled run never picks up code you have not reviewed.
9
+
10
+ name: Penthera security scan
11
+
12
+ on:
13
+ workflow_dispatch:
14
+ schedule:
15
+ - cron: "0 6 * * 1" # weekly, Monday 06:00 UTC
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ penthera:
22
+ runs-on: ubuntu-latest
23
+ permissions:
24
+ contents: read
25
+ security-events: write
26
+ steps:
27
+ - uses: actions/setup-node@v6
28
+ with:
29
+ node-version: 22
30
+
31
+ - name: Scan staging URL
32
+ env:
33
+ PENTEST_URL: ${{ secrets.PENTEST_STAGING_URL }}
34
+ run: |
35
+ mkdir -p reports
36
+ set +e
37
+ npx --yes penthera@1.2.0 "$PENTEST_URL" \
38
+ --profile standard \
39
+ -o reports/scan.json \
40
+ --sarif reports/scan.sarif
41
+ code=$?
42
+ set -e
43
+ # Exit 1 = critical or high findings (reported via SARIF); only 2 (scan error) fails the job.
44
+ if [ "$code" -eq 2 ]; then exit 2; fi
45
+
46
+ - uses: github/codeql-action/upload-sarif@v4
47
+ if: always()
48
+ with:
49
+ sarif_file: reports/scan.sarif
50
+
51
+ - uses: actions/upload-artifact@v7
52
+ if: always()
53
+ with:
54
+ name: penthera-reports
55
+ path: reports/
package/docs/index.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Penthera documentation
2
2
 
3
- Lightweight security scanner for developers, scan live URLs, local repos, or macOS machines from the CLI.
3
+ Security scanner for developers: scan live URLs, local repos, or macOS machines from your AI agent or the CLI.
4
4
 
5
5
  ## Getting started
6
6
 
@@ -33,8 +33,9 @@ Lightweight security scanner for developers, scan live URLs, local repos, or mac
33
33
  | Topic | Link |
34
34
  |-------|------|
35
35
  | OWASP WSTG mapping | [owasp-wstg-coverage.md](./owasp-wstg-coverage.md) |
36
- | GitHub Actions CI | `.github/workflows/ci.yml` (mock scan + SARIF) |
37
- | Staging scans | `.github/workflows/scan.yml`, set `PENTEST_STAGING_URL` secret |
36
+ | NIS2 compliance guide | [compliance-nis2.md](./compliance-nis2.md) |
37
+ | Scan your app in GitHub Actions | [examples/github-actions-scan.yml](./examples/github-actions-scan.yml), set `PENTEST_STAGING_URL` |
38
+ | Penthera's own CI | `.github/workflows/ci.yml` (full tests + mock scan), `codeql.yml`, `scorecard.yml` |
38
39
  | Docker | `docker build -t penthera . && docker run penthera https://localhost:3000` |
39
40
 
40
41
  ## Agent skill
@@ -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
  */