@pbpyrojust/universal-security-audit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,13 @@ Technical security & attack-surface audit CLI. Companion tool to [universal-seo-
4
4
 
5
5
  Runs a battery of unauthenticated, publicly-visible checks against a website and produces a risk-graded HTML/PDF dashboard, CSV data files, a ticket-ready backlog, and run-over-run comparisons.
6
6
 
7
+ ## What's new in 0.3.0
8
+
9
+ - **Pentest checks need confirmed permission.** The active checks (exposed paths and admin/login pages, GraphQL introspection, subdomain liveness, the port scan, open-redirect/HTTP-method/user-enumeration/error-disclosure probes, and the authenticated admin audit) only run when you confirm you own the site or have the owner's written permission: `--authorized`, or typing "yes" at the prompt. Without it a run is passive only. The confirmation (exact statement, time, user, how) is saved in `summary.json` and `full-report.json`. **Scripts and CI that relied on the active checks need `--authorized` added.**
10
+ - **Open redirect:** only a redirect whose destination is the test host counts. Sites that redirect www → apex (or http → https) keep the query string, which made every redirect parameter a false positive.
11
+ - **WPScan:** a refused key (WPScan answers 403 for a missing or invalid token) now stops the lookups after the first one with one message.
12
+ - **Wordfence Intelligence** as a second source of WordPress core/plugin/theme vulnerabilities, with your own free key (`--wordfence-key` or `WORDFENCE_API_KEY`). Only for sites detected as WordPress. The feed is downloaded at most once a day and kept as a compact index in `~/.cache/universal-security-audit/`; components WPScan already reported aren't repeated. See [WordPress CVE lookups](#wordpress-cve-lookups).
13
+
7
14
  ## What's new in 0.2.0
8
15
 
9
16
  - **security.txt (RFC 9116) validation:** finds it at `/.well-known/security.txt` (or the legacy `/security.txt`) and checks the required Contact and Expires fields, an expiry date that hasn't passed (and isn't more than a year out), HTTPS, `text/plain`, Canonical and Policy; reports PGP signing. A missing or expired file is a finding.
@@ -12,11 +19,22 @@ Runs a battery of unauthenticated, publicly-visible checks against a website and
12
19
 
13
20
  ## Part of Vinny
14
21
 
15
- This scanner is the free, open-source engine behind the security checks in [Vinny](https://vinny.justwhat.net) and the [WordPress plugins](https://wordpress.justwhat.net). Everything here stays free.
22
+ This scanner is the free, open-source engine behind the [security checks in Vinny](https://vinny.justwhat.net/features/#security). Everything here stays free. [Vinny](https://vinny.justwhat.net) runs the same header grading on a schedule for any website, alongside [accessibility](https://vinny.justwhat.net/features/#accessibility), [SEO and rank tracking](https://vinny.justwhat.net/features/#seo), with history, alerts and fix tickets ([free for one site](https://vinny.justwhat.net/pricing/)). For WordPress, the [Universal Security Audit plugin](https://wordpress.justwhat.net/#plugins-heading) is coming soon.
23
+
24
+ ## ⚠️ Authorized use only: pentest checks need written permission
25
+
26
+ **Only run this tool against sites and infrastructure you own, or that you have the owner's explicit written permission to test.** Some of what it does is the recon phase of a penetration test, and in many places requests like these count as security testing that needs permission, even when every request is harmless on its own. Unauthorized scanning can break computer-misuse laws (such as the US CFAA or the UK Computer Misuse Act) and hosting providers' terms. Permission from a site's owner doesn't cover the hosting company's own servers: many hosts forbid port scans of their network, so add `--skip-port-scan` unless the host allows it.
16
27
 
17
- ## ⚠️ Authorized use only
28
+ The checks come in two kinds:
18
29
 
19
- This tool actively sends requests to well-known admin/login/config paths, DNS-probes common subdomains, TCP-connects to common service ports, and queries third-party vulnerability databases. **Only run it against sites/infrastructure you own or have explicit written permission to test.** Every probe is a single unauthenticated GET/OPTIONS/DNS/TCP-connect to a publicly documented path, record, or port — no auth bypass, brute force, fuzzing, or payload injection is performed, and destructive HTTP verbs (PUT/DELETE) are only *introspected* via OPTIONS, never sent. The one exception is the **authenticated admin audit** (see below), which requires you to supply valid login credentials yourself — it only ever reads screens the logged-in account already has access to. Unauthorized scanning of third-party infrastructure may still violate computer-fraud laws or terms of service even when every individual request is "harmless" on its own.
30
+ - **Passive (always on):** what any visitor gets. The pages, response headers and cookies, TLS, DNS and WHOIS, security.txt, the scripts and libraries the pages load, and public vulnerability databases (OSV.dev, WPScan).
31
+ - **Active (pentest-style, need confirmed permission):** exposed config/backup/admin/login/API paths and GraphQL introspection, subdomain liveness, a TCP port scan, open-redirect, HTTP-method, WordPress user-enumeration and error-disclosure probes, and the authenticated WordPress/Drupal admin audit.
32
+
33
+ The active checks only run after you confirm: pass `--authorized` (for scripts and CI), or type "yes" when an interactive run asks. Without that, a run is passive only and says what it skipped. The confirmation is written into the run's `summary.json` and `full-report.json` (`authorization`: the exact statement and its version, the time, your OS user name and how it was given), so each report shows what was agreed.
34
+
35
+ Even the active checks stay gentle: every probe is a single GET, OPTIONS, DNS lookup or TCP connect to a publicly documented path, record or port. No auth bypass, brute force, fuzzing or payload injection; destructive HTTP verbs (PUT/DELETE) are only *introspected* via OPTIONS, never sent. The authenticated admin audit only uses credentials you supply and only reads screens that account can already see.
36
+
37
+ **No warranty, no liability.** This software is provided "as is" under the MIT License (see `LICENSE`): the authors aren't liable for any claim or damage arising from its use. You are solely responsible for having permission for every site you test and for how you use the results.
20
38
 
21
39
  ## What it checks
22
40
 
@@ -123,6 +141,7 @@ node bin/universal-security-audit.mjs audit --site https://www.example.com
123
141
  | Flag | Description |
124
142
  | --- | --- |
125
143
  | `--site <url>` | **(required)** Target website URL |
144
+ | `--authorized` | Confirm you own the site or have the owner's written permission, so the active (pentest-style) checks run; recorded in the report. Without it, an interactive run asks, and a non-interactive one (CI, `--json`, batch) is passive only. Alias `--i-have-permission` |
126
145
  | `--max-pages <n>` | Max pages to scan. Omit it and there's no limit — sitemap discovery pulls every URL it finds, and `--crawl` follows links until it exhausts the site; pass a number to cap either one on large sites |
127
146
  | `--crawl` | Discover pages by following internal links instead of using the sitemap |
128
147
  | `--max-per-url-pattern <n>` | Crawl-trap guard (default 3): caps how many pages matching the same URL "shape" get crawled — path segments that look like IDs/hashes/years (`/participants/mypage/1174017/2026`) are collapsed to a placeholder (`/participants/mypage/:id/:id`) for this comparison, so a site with thousands of per-record pages (fundraiser pages, product pages, forum threads) doesn't turn an uncapped `--crawl` into tens of thousands of near-duplicate requests |
@@ -138,6 +157,7 @@ node bin/universal-security-audit.mjs audit --site https://www.example.com
138
157
  | `--skip-subdomain-enum` | Skip crt.sh subdomain enumeration |
139
158
  | `--skip-whois` | Skip the WHOIS/RDAP domain-registration lookup |
140
159
  | `--skip-admin-audit` | Skip the authenticated admin audit even if credentials are supplied |
160
+ | `--wordfence-key <key>` | Your own Wordfence Intelligence API key (or set `WORDFENCE_API_KEY`): free, commercial use allowed with attribution. WordPress sites only; see [WordPress CVE lookups](#wordpress-cve-lookups) |
141
161
  | `--wpscan-key <key>` | Your own WPScan Vulnerability API token (or set `WPSCAN_API_KEY` env var) — free tier is 25 requests/day; see [WordPress CVE lookups](#wordpress-cve-lookups) |
142
162
  | `--brand-config <path>` | JSON file to white-label the HTML/PDF report (see below) |
143
163
  | `--proxy <url>` | Route all traffic (fetch + browser) through an upstream proxy, e.g. Burp Suite / OWASP ZAP, for manual inspection/replay |
@@ -174,7 +194,10 @@ node scripts/run-security-audit.mjs --site https://www.example.com \
174
194
 
175
195
  ### WordPress CVE lookups
176
196
 
177
- WordPress core/plugin/theme vulnerability matching uses the [WPScan Vulnerability Database API](https://wpscan.com/api), which requires an API key — there's no shared/bundled key in this repo, so each user registers their own for free at wpscan.com. Set it either way:
197
+ WordPress core/plugin/theme vulnerability matching only runs for sites detected as WordPress, and only with a key you supply. Two sources, use either or both:
198
+
199
+ - **[Wordfence Intelligence](https://www.wordfence.com/threat-intel/)** (`WORDFENCE_API_KEY` or `--wordfence-key`): free for commercial and personal use with attribution; create a key at wordfence.com under Account → Integrations. The full feed (~160 MB) is downloaded at most once every 24 hours and kept as a compact index (~18 MB) in `~/.cache/universal-security-audit/wordfence-index.json` (or `$XDG_CACHE_HOME`); the key's download limit is small, so if a refresh is refused the cached copy is used. Findings credit Wordfence and Defiant Inc. as its license requires.
200
+ - **WPScan**: uses the [WPScan Vulnerability Database API](https://wpscan.com/api), which requires an API key — there's no shared/bundled key in this repo, so each user registers their own at wpscan.com. WPScan's free key is for non-commercial use; for client or commercial work use Wordfence or a paid WPScan plan. Set it either way:
178
201
 
179
202
  ```bash
180
203
  export WPSCAN_API_KEY=your-key-here
@@ -307,10 +330,10 @@ node bin/universal-security-audit.mjs audit --site https://www.example.com \
307
330
 
308
331
  ### Quick first-pass on a live WordPress site
309
332
 
310
- Default posture — fast, unauthenticated, safe to run against a client's live production site without asking anyone first (still get permission):
333
+ Fast and unauthenticated, but the active checks still need the client's written permission first (`--authorized` records that you have it):
311
334
 
312
335
  ```bash
313
- node bin/universal-security-audit.mjs audit --site https://www.example.com --intensity light
336
+ node bin/universal-security-audit.mjs audit --site https://www.example.com --intensity light --authorized
314
337
  ```
315
338
 
316
339
  ### Full authenticated WordPress security review
@@ -320,7 +343,7 @@ Agency-style deep audit: create an Application Password in the client's wp-admin
320
343
  ```bash
321
344
  node bin/universal-security-audit.mjs audit --site https://www.example.com \
322
345
  --wp-app-user admin --wp-app-password "xxxx xxxx xxxx xxxx xxxx xxxx" \
323
- --wpscan-key YOUR_WPSCAN_KEY --crawl --max-pages 50
346
+ --wpscan-key YOUR_WPSCAN_KEY --crawl --max-pages 50 --authorized
324
347
  node bin/universal-security-audit.mjs tickets --run-dir ./reports/<run-id>
325
348
  ```
326
349
 
@@ -332,7 +355,7 @@ Same shape, form-login only (no Application Passwords in Drupal core):
332
355
 
333
356
  ```bash
334
357
  node bin/universal-security-audit.mjs audit --site https://www.example.com \
335
- --login-url https://www.example.com/user/login --username admin --password your-password
358
+ --login-url https://www.example.com/user/login --username admin --password your-password --authorized
336
359
  ```
337
360
 
338
361
  ### E-commerce / donation site
@@ -356,7 +379,7 @@ node bin/universal-security-audit.mjs audit --site https://staging.example.com \
356
379
 
357
380
  ```bash
358
381
  node bin/universal-security-audit.mjs audit --site https://www.example.com \
359
- --intensity light --skip-port-scan --skip-subdomain-enum \
382
+ --intensity light --skip-port-scan --skip-subdomain-enum --authorized \
360
383
  --baseline ./security-baseline.json --fail-on high --json > report.json
361
384
  node bin/universal-security-audit.mjs sarif --run-dir "$(ls -td reports/*/ | head -1)" --out results.sarif
362
385
  ```
@@ -367,7 +390,7 @@ Then upload `results.sarif` with `github/codeql-action/upload-sarif` so findings
367
390
 
368
391
  ```bash
369
392
  node bin/universal-security-audit.mjs batch-audit --sites-file ./client-sites.txt \
370
- --intensity light --skip-cve --concurrency 2
393
+ --intensity light --skip-cve --concurrency 2 --authorized
371
394
  ```
372
395
 
373
396
  Pair with a cron/scheduled job and diff each site's `summary.json` week over week (or use `compare` against the prior run per site) to catch regressions before a client notices.
@@ -397,7 +420,7 @@ Maximum coverage, routed through Burp Suite so a human can inspect/replay anythi
397
420
  node bin/universal-security-audit.mjs audit --site https://www.example.com \
398
421
  --intensity aggressive --crawl --max-pages 100 \
399
422
  --wordlist ./my-wordlist.txt --proxy http://127.0.0.1:8080 \
400
- --wp-app-user admin --wp-app-password "xxxx xxxx xxxx xxxx xxxx xxxx" --wpscan-key YOUR_KEY
423
+ --wp-app-user admin --wp-app-password "xxxx xxxx xxxx xxxx xxxx xxxx" --wpscan-key YOUR_KEY --authorized
401
424
  ```
402
425
 
403
426
  ## Risk grading
@@ -36,7 +36,8 @@ Commands:
36
36
  Examples:
37
37
  universal-security-audit audit --site https://www.example.com
38
38
  universal-security-audit audit --site https://www.example.com --crawl --max-pages 25
39
- universal-security-audit audit --site https://www.example.com --intensity aggressive
39
+ universal-security-audit audit --site https://www.example.com --authorized --intensity aggressive
40
+ universal-security-audit audit --site https://www.example.com --wordfence-key YOUR_KEY
40
41
  universal-security-audit audit --site https://www.example.com --wpscan-key YOUR_KEY
41
42
  universal-security-audit audit --site https://www.example.com --skip-exposed-paths --skip-recon
42
43
  universal-security-audit audit --site https://www.example.com --login-url https://www.example.com/wp-login.php --username U --password P
@@ -51,7 +52,11 @@ Examples:
51
52
  universal-security-audit baseline --run-dir ./reports/<run-id> --out ./security-baseline.json
52
53
  universal-security-audit batch-audit --sites-file ./sites.txt --intensity light
53
54
 
55
+ Pentest-style (active) checks need the site owner's written permission: pass --authorized,
56
+ or answer "yes" when asked. Without it a run is passive only.
57
+
54
58
  Key flags (see README for the full list):
59
+ --authorized Confirm written permission; runs the active checks
55
60
  --intensity light|normal|aggressive Concurrency/delay/probe-size dial
56
61
  --max-pages <n> Cap pages scanned (default: no limit)
57
62
  --max-depth <n> Link-hops from homepage to follow during --crawl
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pbpyrojust/universal-security-audit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.8.0",
6
6
  "description": "Technical security & attack-surface audit CLI: security header grading, CMS/hosting fingerprinting, exposed-path & admin/API discovery, third-party script + CVE lookups (OSV.dev/WPScan), PII & payment-processor detection, and a risk-graded HTML/PDF report.",
@@ -0,0 +1,54 @@
1
+ // ── Permission for the active (pentest-style) checks ──
2
+ // Passive checks only read what any visitor gets: the pages, response headers, TLS, DNS/WHOIS,
3
+ // the files and libraries the pages load, and public vulnerability databases. The active checks
4
+ // request what a visitor wouldn't (exposed config/backup paths, admin and login pages, GraphQL
5
+ // introspection, open-redirect and HTTP-method probes, WordPress user enumeration, error
6
+ // disclosure, subdomain liveness, a TCP port scan) or log in with credentials you supply. Those
7
+ // need the site owner's written permission, so they only run once it's confirmed, and the
8
+ // confirmation is written into the run's report.
9
+ import os from 'node:os';
10
+ import readline from 'node:readline/promises';
11
+
12
+ /** Bump the version whenever the wording changes; each report keeps the exact text confirmed. */
13
+ export const AUTHORIZATION_STATEMENT_VERSION = '2026-10-31';
14
+ export const AUTHORIZATION_STATEMENT =
15
+ 'I own this site, or I have the site owner\'s written permission to security-test it, including requests a normal visitor wouldn\'t make (admin, login and configuration paths, port and subdomain checks, redirect and method probes). I am responsible for having that permission. The tool and its authors accept no liability for how it is used.';
16
+
17
+ export const ACTIVE_CHECKS = [
18
+ 'exposed paths, admin/login pages and GraphQL introspection',
19
+ 'subdomain liveness',
20
+ 'TCP port scan',
21
+ 'recon probes (open redirect, HTTP methods, WordPress user enumeration, error disclosure)',
22
+ 'authenticated WordPress/Drupal admin audit',
23
+ ];
24
+
25
+ /**
26
+ * Resolves whether the active checks may run. `--authorized` (or `--i-have-permission`) confirms
27
+ * up front, for scripts and CI. Otherwise an interactive terminal is asked to type "yes"; anything
28
+ * else, or no terminal (CI, --json, batch-audit children), means passive checks only.
29
+ */
30
+ export async function resolveAuthorization(args, site, { interactive = process.stdin.isTTY && process.stdout.isTTY } = {}) {
31
+ const record = (how) => ({
32
+ confirmed: true,
33
+ how,
34
+ site,
35
+ statement: AUTHORIZATION_STATEMENT,
36
+ statementVersion: AUTHORIZATION_STATEMENT_VERSION,
37
+ at: new Date().toISOString(),
38
+ user: safeUser(),
39
+ });
40
+ if (args['authorized'] || args['i-have-permission']) return record('flag');
41
+ if (!interactive || args['json']) return null;
42
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
43
+ try {
44
+ process.stderr.write(`\n Active checks on ${site} need the site owner's written permission:\n "${AUTHORIZATION_STATEMENT}"\n`);
45
+ const answer = (await rl.question(' Type "yes" to confirm and run them, or press Enter for passive checks only: ')).trim().toLowerCase();
46
+ return answer === 'yes' ? record('prompt') : null;
47
+ } finally {
48
+ rl.close();
49
+ }
50
+ }
51
+
52
+ function safeUser() {
53
+ try { return os.userInfo().username; } catch { return null; }
54
+ }
@@ -83,7 +83,12 @@ export async function checkOpenRedirect(origin) {
83
83
  if (!res) continue;
84
84
  if (res.status >= 300 && res.status < 400) {
85
85
  const loc = res.headers.get('location') || '';
86
- if (loc.startsWith(CANARY) || loc.includes('open-redirect-canary.invalid')) {
86
+ // Only a redirect *to* the canary host counts. A site that redirects www → apex (or http →
87
+ // https) keeps the query string, so the canary appears inside a same-site Location; that
88
+ // isn't an open redirect. Resolving against the request also catches //host and \\host.
89
+ let host = '';
90
+ try { host = new URL(loc.replace(/\\/g, '/'), testUrl).hostname; } catch { /* unparseable */ }
91
+ if (host === new URL(CANARY).hostname) {
87
92
  findings.push({ param, testUrl, location: loc });
88
93
  }
89
94
  }
@@ -0,0 +1,112 @@
1
+ // ── Wordfence Intelligence: known WordPress core/plugin/theme vulnerabilities ──
2
+ // Free for commercial use with attribution; each user brings their own API key (wordfence.com →
3
+ // Integrations). The feed is one ~160 MB JSON object, and the key's download limit is small and
4
+ // unpublished, so it's fetched at most once a day and kept as a compact index (slug → ranges) in
5
+ // the user's cache folder; later runs read only the index. Only used for sites detected as
6
+ // WordPress, and only when a key is given.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ const FEED = 'https://www.wordfence.com/api/intelligence/v3/vulnerabilities/production';
12
+ const MAX_AGE_MS = 24 * 3600_000;
13
+
14
+ export const WORDFENCE_ATTRIBUTION = 'Vulnerability data: Wordfence Intelligence (https://www.wordfence.com/threat-intel/). Copyright Defiant Inc., used under the Wordfence Intelligence license (https://www.wordfence.com/wordfence-intelligence-terms-and-conditions/).';
15
+
16
+ function cacheFile() {
17
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
18
+ return path.join(base, 'universal-security-audit', 'wordfence-index.json');
19
+ }
20
+
21
+ /** -1, 0 or 1, comparing dotted versions number by number ("6.10.2" > "6.9.10"). */
22
+ export function compareVersions(a, b) {
23
+ const pa = String(a).split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
24
+ const pb = String(b).split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
25
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
26
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
27
+ if (d) return d < 0 ? -1 : 1;
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ export function isAffected(version, ranges) {
33
+ return ranges.some((r) => {
34
+ const above = r.from === '*' || (r.fi ? compareVersions(version, r.from) >= 0 : compareVersions(version, r.from) > 0);
35
+ const below = r.to === '*' || (r.ti ? compareVersions(version, r.to) <= 0 : compareVersions(version, r.to) < 0);
36
+ return above && below;
37
+ });
38
+ }
39
+
40
+ /** Feed records → { "plugin:slug": [{ id, title, cve, rating, score, ranges, patched, url }] }. Exported for tests. */
41
+ export function buildIndex(feed) {
42
+ const index = {};
43
+ for (const [id, v] of Object.entries(feed)) {
44
+ if (v.informational) continue;
45
+ for (const s of v.software ?? []) {
46
+ const kind = ['core', 'plugin', 'theme'].includes(s.type) ? s.type : null;
47
+ const slug = kind === 'core' ? 'wordpress' : String(s.slug ?? '').toLowerCase();
48
+ if (!kind || !slug) continue;
49
+ const ranges = Object.values(s.affected_versions ?? {}).map((r) => ({ from: String(r.from_version ?? '*'), fi: r.from_inclusive !== false, to: String(r.to_version ?? '*'), ti: r.to_inclusive !== false }));
50
+ if (!ranges.length) continue;
51
+ const key = `${kind}:${slug}`;
52
+ const list = (index[key] ??= []);
53
+ const same = list.find((x) => x.id === id);
54
+ if (same) {
55
+ // Core vulnerabilities list WordPress once per release branch.
56
+ same.ranges.push(...ranges);
57
+ same.patched = [...new Set([...same.patched, ...(s.patched_versions ?? []).map(String)])];
58
+ continue;
59
+ }
60
+ list.push({
61
+ id,
62
+ title: String(v.title ?? ''),
63
+ cve: v.cve ?? null,
64
+ rating: v.cvss?.rating ? String(v.cvss.rating).toLowerCase() : null,
65
+ score: v.cvss?.score ?? null,
66
+ ranges,
67
+ patched: (s.patched_versions ?? []).map(String),
68
+ url: (v.references ?? []).find((u) => /wordfence\.com/.test(u)) ?? `https://www.wordfence.com/threat-intel/vulnerabilities/id/${id}`,
69
+ });
70
+ }
71
+ }
72
+ return index;
73
+ }
74
+
75
+ /** The cached index, refreshed when older than a day. Returns { index, error }. */
76
+ export async function loadIndex(apiKey, { file = cacheFile(), now = Date.now() } = {}) {
77
+ let cached = null;
78
+ try {
79
+ const stat = fs.statSync(file);
80
+ cached = { index: JSON.parse(fs.readFileSync(file, 'utf8')), fresh: now - stat.mtimeMs < MAX_AGE_MS };
81
+ } catch {}
82
+ if (cached?.fresh) return { index: cached.index, error: null };
83
+ try {
84
+ const res = await fetch(FEED, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(300_000) });
85
+ if (!res.ok) {
86
+ const why = res.status === 401 ? 'Wordfence refused the API key (check WORDFENCE_API_KEY)' : res.status === 429 ? 'the Wordfence key\'s download limit was reached; try again in a few hours' : `Wordfence answered HTTP ${res.status}`;
87
+ // An older copy is better than nothing.
88
+ return cached ? { index: cached.index, error: `${why}; used the copy from ${new Date(fs.statSync(file).mtimeMs).toISOString().slice(0, 10)}` } : { index: null, error: why };
89
+ }
90
+ const index = buildIndex(await res.json());
91
+ fs.mkdirSync(path.dirname(file), { recursive: true });
92
+ fs.writeFileSync(file, JSON.stringify(index));
93
+ return { index, error: null };
94
+ } catch (e) {
95
+ return cached ? { index: cached.index, error: `couldn't refresh the Wordfence feed (${e?.message || e}); used the cached copy` } : { index: null, error: `couldn't download the Wordfence feed (${e?.message || e})` };
96
+ }
97
+ }
98
+
99
+ /** Vulnerabilities affecting one component at this exact version, each with the first fixed version above it. */
100
+ export function lookup(index, kind, slug, version) {
101
+ if (!index || !version) return [];
102
+ return (index[`${kind}:${String(slug).toLowerCase()}`] ?? [])
103
+ .filter((v) => isAffected(version, v.ranges))
104
+ .map((v) => ({ ...v, fixedIn: v.patched.filter((p) => compareVersions(p, version) > 0).sort(compareVersions)[0] ?? null }));
105
+ }
106
+
107
+ /** Finding severity from Wordfence's rating, or the CVSS score. */
108
+ export function severityOf(v) {
109
+ if (['critical', 'high', 'medium', 'low'].includes(v.rating)) return v.rating;
110
+ const s = Number(v.score);
111
+ return s >= 9 ? 'critical' : s >= 7 ? 'high' : s >= 4 ? 'medium' : 'low';
112
+ }
@@ -41,6 +41,8 @@ import {
41
41
  import { enumerateTlsProtocols, getWeakProtocolFindings } from './lib/tls-enum.mjs';
42
42
  import { introspectGraphQL } from './lib/graphql-introspect.mjs';
43
43
  import { loadBaseline, applyBaseline } from './lib/baseline.mjs';
44
+ import { resolveAuthorization, ACTIVE_CHECKS } from './lib/authorization.mjs';
45
+ import { loadIndex as loadWordfenceIndex, lookup as wordfenceLookup, severityOf as wordfenceSeverity, WORDFENCE_ATTRIBUTION } from './lib/wordfence.mjs';
44
46
 
45
47
  // ── CLI / generic helpers ───────────────────────────────────────────────
46
48
  function parseArgs(argv) {
@@ -279,14 +281,18 @@ const maxPerTemplate = args['max-per-url-pattern'] ? Number(args['max-per-url-pa
279
281
  const maxDepth = args['max-depth'] ? Number(args['max-depth']) : Infinity;
280
282
  const slowMode = Boolean(args['slow']);
281
283
  const respectRobots = Boolean(args['respect-robots']);
282
- const skipExposedPaths = Boolean(args['skip-exposed-paths']);
284
+ // Active checks only run with the owner's written permission confirmed (lib/authorization.mjs).
285
+ const authorization = await resolveAuthorization(args, site);
286
+ const activeAllowed = Boolean(authorization);
287
+ const skipExposedPaths = Boolean(args['skip-exposed-paths']) || !activeAllowed;
283
288
  const skipCve = Boolean(args['skip-cve']);
284
- const skipRecon = Boolean(args['skip-recon']);
285
- const skipPortScan = Boolean(args['skip-port-scan']);
286
- const skipSubdomainEnum = Boolean(args['skip-subdomain-enum']);
289
+ const skipRecon = Boolean(args['skip-recon']) || !activeAllowed;
290
+ const skipPortScan = Boolean(args['skip-port-scan']) || !activeAllowed;
291
+ const skipSubdomainEnum = Boolean(args['skip-subdomain-enum']) || !activeAllowed;
287
292
  const skipWhois = Boolean(args['skip-whois']);
288
- const skipAdminAudit = Boolean(args['skip-admin-audit']);
293
+ const skipAdminAudit = Boolean(args['skip-admin-audit']) || !activeAllowed;
289
294
  const wpscanApiKey = args['wpscan-key'] || process.env.WPSCAN_API_KEY || '';
295
+ const wordfenceApiKey = args['wordfence-key'] || process.env.WORDFENCE_API_KEY || '';
290
296
  const branding = loadBranding(fs, path, args['brand-config']);
291
297
  const intensity = resolveIntensity(args['intensity']);
292
298
  const proxyUrl = args['proxy'] || '';
@@ -310,10 +316,14 @@ out(` ${rainbow('╚═══════════════════
310
316
  out('');
311
317
  out(` ${c.brightMagenta}🎯${c.reset} ${c.bold}Target:${c.reset} ${c.brightCyan}${site}${c.reset}`);
312
318
  out(` ${c.brightMagenta}📁${c.reset} ${c.bold}Output:${c.reset} ${c.dim}${outDir}${c.reset}`);
313
- out(` ${c.brightMagenta}🔑${c.reset} ${c.bold}WPScan key:${c.reset} ${wpscanApiKey ? `${c.brightGreen}configured${c.reset}` : `${c.dim}not set (WordPress CVE lookups will be skipped)${c.reset}`}`);
319
+ out(` ${c.brightMagenta}🔑${c.reset} ${c.bold}WordPress CVE data:${c.reset} ${[wordfenceApiKey && `${c.brightGreen}Wordfence${c.reset}`, wpscanApiKey && `${c.brightGreen}WPScan${c.reset}`].filter(Boolean).join(' + ') || `${c.dim}no key (set WORDFENCE_API_KEY or WPSCAN_API_KEY to look up WordPress vulnerabilities)${c.reset}`}`);
314
320
  out(` ${c.brightMagenta}⚡${c.reset} ${c.bold}Intensity:${c.reset} ${args['intensity'] || 'normal'}${proxyUrl ? ` ${c.dim}·${c.reset} proxying via ${proxyUrl}` : ''}`);
315
321
  if (authSettings.httpCredentials || authSettings.formAuth) console.log(` ${c.brightMagenta}🔐${c.reset} ${c.bold}Auth:${c.reset} ${authSettings.formAuth ? 'form login configured' : 'HTTP basic credentials configured'}`);
316
- statusMsg('⚠', c.brightYellow, 'Authorized-use only — this tool actively probes admin/config paths and login forms. Only run it against sites you own or have written permission to test.');
322
+ if (activeAllowed) {
323
+ statusMsg('⚠', c.brightYellow, `Active checks ON (permission confirmed ${authorization.how === 'flag' ? 'with --authorized' : 'at the prompt'}; recorded in the report). Only run them on sites you own or have the owner's written permission to test.`);
324
+ } else {
325
+ statusMsg('🔒', c.brightYellow, `Passive checks only. These need the owner's written permission and were skipped: ${ACTIVE_CHECKS.join('; ')}. Confirm with --authorized to run them.`);
326
+ }
317
327
 
318
328
  let robotsCfg = { isAllowedUrl: null };
319
329
  if (respectRobots) robotsCfg = await buildRobotsMatcher(site);
@@ -826,7 +836,7 @@ if (!skipCve) {
826
836
 
827
837
  if (primaryPlatform === 'WordPress') {
828
838
  if (!wpscanApiKey) {
829
- statusMsg('⏭', c.dim, 'No WPSCAN_API_KEY set — skipping live WordPress CVE lookups (core/plugin/theme version + EOL heuristics still reported).');
839
+ if (!wordfenceApiKey) statusMsg('⏭', c.dim, 'No WORDFENCE_API_KEY or WPSCAN_API_KEY set — skipping live WordPress CVE lookups (core/plugin/theme version + EOL heuristics still reported).');
830
840
  } else {
831
841
  let wpscanRateLimited = false;
832
842
  const warnRateLimit = () => {
@@ -834,6 +844,15 @@ if (!skipCve) {
834
844
  wpscanRateLimited = true;
835
845
  statusMsg('⚠', c.brightYellow, 'WPScan free-tier daily limit (25 requests/day) reached — remaining WordPress CVE lookups skipped for this run. Limit resets daily; see https://wpscan.com/api for paid tiers.');
836
846
  };
847
+ // A refused key (WPScan answers 403 for a missing or invalid token) fails every lookup the
848
+ // same way, so stop after the first one instead of repeating the warning per plugin/theme.
849
+ const stopOnRefusal = (res, what) => {
850
+ if (!res.error || res.rateLimited) return false;
851
+ if (!/unauthorized/.test(res.error)) { statusMsg('⚠', c.brightYellow, `WPScan ${what} lookup: ${res.error}`); return false; }
852
+ wpscanRateLimited = true;
853
+ statusMsg('⚠', c.brightYellow, 'WPScan refused the API key (missing, mistyped or revoked) — WordPress CVE lookups skipped for this run. Check WPSCAN_API_KEY / --wpscan-key.');
854
+ return true;
855
+ };
837
856
 
838
857
  if (wpCore.version) {
839
858
  const coreRes = await wpscanCoreLookup(wpCore.version, wpscanApiKey);
@@ -842,7 +861,7 @@ if (!skipCve) {
842
861
  for (const v of coreRes.vulns) addFinding({ category: 'cve', severity: 'critical', title: `WordPress core ${wpCore.version}: ${v.title}`, detail: v.fixedIn ? `Fixed in ${v.fixedIn}.` : 'No fix version listed.', url: Array.isArray(v.references) ? v.references[0] : String(v.references || '') });
843
862
  }
844
863
  if (coreRes.rateLimited) warnRateLimit();
845
- else if (coreRes.error) statusMsg('⚠', c.brightYellow, `WPScan core lookup: ${coreRes.error}`);
864
+ else stopOnRefusal(coreRes, 'core');
846
865
  }
847
866
  for (const p of wpPlugins) {
848
867
  if (wpscanRateLimited) break;
@@ -855,7 +874,7 @@ if (!skipCve) {
855
874
  }
856
875
  }
857
876
  if (res.rateLimited) { warnRateLimit(); break; }
858
- if (res.error) statusMsg('⚠', c.brightYellow, `WPScan plugin lookup (${p.slug}): ${res.error}`);
877
+ if (stopOnRefusal(res, `plugin (${p.slug})`)) break;
859
878
  await sleep(250); // stay well under WPScan free-tier rate limit
860
879
  }
861
880
  for (const t of wpThemes) {
@@ -869,11 +888,36 @@ if (!skipCve) {
869
888
  }
870
889
  }
871
890
  if (res.rateLimited) { warnRateLimit(); break; }
872
- if (res.error) statusMsg('⚠', c.brightYellow, `WPScan theme lookup (${t.slug}): ${res.error}`);
891
+ if (stopOnRefusal(res, `theme (${t.slug})`)) break;
873
892
  await sleep(250);
874
893
  }
875
894
  }
876
895
  }
896
+
897
+ // Wordfence Intelligence, with the user's own key (free, commercial use allowed with attribution).
898
+ if (primaryPlatform === 'WordPress' && wordfenceApiKey) {
899
+ const { index, error } = await loadWordfenceIndex(wordfenceApiKey);
900
+ if (error) statusMsg('⚠', c.brightYellow, `Wordfence: ${error}.`);
901
+ if (index) {
902
+ // Components WPScan already reported aren't repeated.
903
+ const seen = new Set(vulnResults.map((r) => r.component.replace(/@.*$/, '')));
904
+ const components = [
905
+ ...(wpCore.version ? [{ kind: 'core', slug: 'wordpress', version: wpCore.version, label: `WordPress core ${wpCore.version}`, key: `WordPress core ${wpCore.version}` }] : []),
906
+ ...wpPlugins.map((p) => ({ kind: 'plugin', slug: p.slug, version: p.version, label: `WP plugin "${p.slug}"`, key: `plugin:${p.slug}` })),
907
+ ...wpThemes.map((t) => ({ kind: 'theme', slug: t.slug, version: t.version, label: `WP theme "${t.slug}"`, key: `theme:${t.slug}` })),
908
+ ];
909
+ let wfFound = 0;
910
+ for (const comp of components) {
911
+ if (!comp.version || seen.has(comp.key.replace(/@.*$/, ''))) continue;
912
+ const vulns = wordfenceLookup(index, comp.kind, comp.slug, comp.version);
913
+ if (!vulns.length) continue;
914
+ wfFound += 1;
915
+ vulnResults.push({ component: comp.kind === 'core' ? comp.key : `${comp.key}@${comp.version}`, source: 'Wordfence', vulns: vulns.map((v) => ({ id: v.id, title: v.title, fixedIn: v.fixedIn, references: [v.url] })) });
916
+ for (const v of vulns) addFinding({ category: 'cve', severity: wordfenceSeverity(v), title: `${comp.label}: ${v.title}`, detail: `Installed: ${comp.version}. ${v.fixedIn ? `Fixed in ${v.fixedIn}.` : 'No fix yet.'}${v.cve ? ` ${v.cve}.` : ''} ${WORDFENCE_ATTRIBUTION}`, url: v.url });
917
+ }
918
+ statusMsg('🛡️', c.dim, `Wordfence: ${wfFound} component(s) with known vulnerabilities.`);
919
+ }
920
+ }
877
921
  statusMsg('🛡️', c.dim, `${vulnResults.length} component(s) with known vulnerabilities found.`);
878
922
  phaseDone('CVE lookups', Date.now() - cveStart);
879
923
  } else {
@@ -1092,6 +1136,8 @@ const summaryData = {
1092
1136
  wpAdminAudit,
1093
1137
  drupalAdminAudit,
1094
1138
  suppressedFindingsCount: suppressedCount,
1139
+ activeChecks: activeAllowed,
1140
+ authorization,
1095
1141
  };
1096
1142
  fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summaryData, null, 2), 'utf8');
1097
1143