@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)
|
|
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
|
-
|
|
28
|
+
The checks come in two kinds:
|
|
18
29
|
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|