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.
- package/CHANGELOG.md +104 -3
- package/README.md +151 -249
- package/SECURITY.md +28 -6
- package/docs/compliance-nis2.md +166 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- package/docs/index.md +4 -3
- package/lib/compliance/readiness.js +16 -4
- package/lib/injections.js +103 -34
- package/lib/machine.js +39 -5
- package/lib/nuclei-loader.js +56 -5
- package/lib/retirejs.js +36 -12
- package/lib/templates.js +32 -5
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +7 -9
- package/skills/penthera/SKILL.md +2 -2
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- package/src/cli/baseline.js +6 -3
- package/src/cli/merge-results.js +3 -1
- package/src/cli.js +1 -1
- package/src/reporter.js +10 -1
- package/src/scan-url.js +22 -1
- package/scripts/package-skill.sh +0 -16
|
@@ -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
|
-
|
|
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
|
-
|
|
|
37
|
-
|
|
|
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
|
-
|
|
56
|
+
// Nothing measured and nothing attested is not a failure, it is an unknown.
|
|
57
|
+
// Reporting it as a gap told an entity that had simply not filled in the
|
|
58
|
+
// self-assessment that it had failed ten measures, which is both wrong and
|
|
59
|
+
// the opposite of proportionate.
|
|
60
|
+
if (known.length === 0) return attestS === "na" ? "n/a" : "not-assessed";
|
|
57
61
|
if (known.includes("gap")) return "gap";
|
|
58
62
|
if (known.includes("partial")) return "partial";
|
|
59
63
|
return "met";
|
|
@@ -113,7 +117,7 @@ export function buildReadiness(scanResult, opts = {}) {
|
|
|
113
117
|
};
|
|
114
118
|
});
|
|
115
119
|
|
|
116
|
-
const summary = { met: 0, partial: 0, gap: 0, "n/a": 0 };
|
|
120
|
+
const summary = { met: 0, partial: 0, gap: 0, "not-assessed": 0, "n/a": 0 };
|
|
117
121
|
for (const m of measures) summary[m.status] = (summary[m.status] || 0) + 1;
|
|
118
122
|
const assessed = measures.filter((m) => m.basis.attestation !== "none" || m.basis.scan !== "none").length;
|
|
119
123
|
|
|
@@ -132,7 +136,13 @@ export function buildReadiness(scanResult, opts = {}) {
|
|
|
132
136
|
};
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
const STATUS_LABEL = {
|
|
139
|
+
const STATUS_LABEL = {
|
|
140
|
+
met: "Met",
|
|
141
|
+
partial: "Partial",
|
|
142
|
+
gap: "Gap",
|
|
143
|
+
"not-assessed": "Not assessed",
|
|
144
|
+
"n/a": "N/A",
|
|
145
|
+
};
|
|
136
146
|
|
|
137
147
|
/** Render a readiness report as Markdown. */
|
|
138
148
|
export function formatReadinessMarkdown(readiness) {
|
|
@@ -147,7 +157,9 @@ export function formatReadinessMarkdown(readiness) {
|
|
|
147
157
|
L.push("");
|
|
148
158
|
|
|
149
159
|
const s = readiness.summary;
|
|
150
|
-
L.push(`**Summary:** ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["n/a"]} n/a, across ${readiness.totalMeasures} Article 21 measures.`);
|
|
160
|
+
L.push(`**Summary:** ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["not-assessed"]} not assessed · ${s["n/a"]} n/a, across ${readiness.totalMeasures} Article 21 measures.`);
|
|
161
|
+
L.push("");
|
|
162
|
+
L.push("*Not assessed* means neither the scan nor your self-assessment covered that measure yet. It is an unknown, not a failure. Run the self-assessment to turn those into real answers.");
|
|
151
163
|
L.push("");
|
|
152
164
|
L.push("Status combines what Penthera measured (technical) with your self-assessment (organisational). *Proportionality applies: for a small entity, a short policy or process that genuinely exists counts.*");
|
|
153
165
|
L.push("");
|
package/lib/injections.js
CHANGED
|
@@ -38,6 +38,50 @@ async function safeFetch(url, opts = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// ── Timing-based detection helpers ───────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const TIMING_DELAY_MS = 2500; // payload asks the backend to sleep ~3s
|
|
44
|
+
const TIMING_FLOOR_MS = 3000;
|
|
45
|
+
|
|
46
|
+
function median(nums) {
|
|
47
|
+
if (!nums.length) return 0;
|
|
48
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
49
|
+
const mid = Math.floor(s.length / 2);
|
|
50
|
+
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Confirm a suspected time-based injection.
|
|
55
|
+
*
|
|
56
|
+
* A single slow response proves nothing: a GC pause, a cold serverless start,
|
|
57
|
+
* or ordinary network jitter all look identical to an injected sleep. So a
|
|
58
|
+
* candidate is re-sent a few times and only reported when the MEDIAN delay
|
|
59
|
+
* holds, which jitter does not survive.
|
|
60
|
+
*
|
|
61
|
+
* @param {function(): Promise<{elapsed:number}>} send - re-issues the request
|
|
62
|
+
* @param {number} baselineMs
|
|
63
|
+
* @param {number} samples
|
|
64
|
+
* @returns {Promise<{confirmed: boolean, medianMs: number, samples: number[]}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function confirmTimingDelay(send, baselineMs, samples = 3) {
|
|
67
|
+
const timings = [];
|
|
68
|
+
for (let i = 0; i < samples; i++) {
|
|
69
|
+
const res = await send();
|
|
70
|
+
if (!res || !res.ok) return { confirmed: false, medianMs: 0, samples: timings };
|
|
71
|
+
timings.push(res.elapsed);
|
|
72
|
+
// Bail early if a repeat comes back fast; a real sleep is consistent.
|
|
73
|
+
if (res.elapsed < baselineMs + TIMING_DELAY_MS) {
|
|
74
|
+
return { confirmed: false, medianMs: median(timings), samples: timings };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const m = median(timings);
|
|
78
|
+
return {
|
|
79
|
+
confirmed: m > baselineMs + TIMING_DELAY_MS && m > TIMING_FLOOR_MS,
|
|
80
|
+
medianMs: m,
|
|
81
|
+
samples: timings,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
41
85
|
// ── SQL Injection Detection ──────────────────────────────────────────────
|
|
42
86
|
|
|
43
87
|
const SQLI_ERROR_PATTERNS = [
|
|
@@ -128,33 +172,41 @@ export async function probeSqli(baseUrl, path, opts = {}) {
|
|
|
128
172
|
];
|
|
129
173
|
|
|
130
174
|
for (const { payload, db } of timingPayloads) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
175
|
+
const send = () => {
|
|
176
|
+
if (method === "GET") {
|
|
177
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
178
|
+
return safeFetch(`${baseUrl}${path}${sep}id=${encodeURIComponent(payload)}`, {
|
|
179
|
+
method,
|
|
180
|
+
timeout: 8_000,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return safeFetch(`${baseUrl}${path}`, {
|
|
140
184
|
method,
|
|
141
185
|
headers: { "Content-Type": "application/json" },
|
|
142
186
|
body: JSON.stringify({ id: payload }),
|
|
143
187
|
timeout: 8_000,
|
|
144
188
|
});
|
|
145
|
-
}
|
|
189
|
+
};
|
|
146
190
|
|
|
147
|
-
|
|
148
|
-
if (res.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
191
|
+
const res = await send();
|
|
192
|
+
if (!res.ok) continue;
|
|
193
|
+
|
|
194
|
+
// A slow first response is only a candidate. Re-sample before reporting, so
|
|
195
|
+
// jitter and cold starts don't get published as a high-severity finding.
|
|
196
|
+
if (res.elapsed > baseline.elapsed + TIMING_DELAY_MS && res.elapsed > TIMING_FLOOR_MS) {
|
|
197
|
+
const check = await confirmTimingDelay(send, baseline.elapsed);
|
|
198
|
+
if (check.confirmed) {
|
|
199
|
+
findings.push({
|
|
200
|
+
severity: "high",
|
|
201
|
+
confidence: "likely", // inferred from timing, not proven output
|
|
202
|
+
title: `Possible blind SQL injection (time-based, ${db})`,
|
|
203
|
+
description: `Payload: ${payload}. Delay held across ${check.samples.length} repeats (median ${check.medianMs}ms vs baseline ${baseline.elapsed}ms).`,
|
|
204
|
+
url: `${baseUrl}${path}`,
|
|
205
|
+
status: res.status,
|
|
206
|
+
category: "sqli",
|
|
207
|
+
source: "injection-probe",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
158
210
|
}
|
|
159
211
|
}
|
|
160
212
|
|
|
@@ -380,12 +432,13 @@ export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
|
380
432
|
});
|
|
381
433
|
|
|
382
434
|
for (const { payload, timing, pattern } of CMDI_PAYLOADS) {
|
|
383
|
-
const
|
|
435
|
+
const send = () => safeFetch(`${baseUrl}${path}`, {
|
|
384
436
|
method,
|
|
385
437
|
headers: { "Content-Type": "application/json" },
|
|
386
438
|
body: method === "POST" ? JSON.stringify({ input: payload, cmd: payload, command: payload }) : undefined,
|
|
387
439
|
timeout: timing ? 8_000 : INJECT_TIMEOUT,
|
|
388
440
|
});
|
|
441
|
+
const res = await send();
|
|
389
442
|
|
|
390
443
|
if (!res.ok) continue;
|
|
391
444
|
|
|
@@ -402,17 +455,22 @@ export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
|
402
455
|
return findings;
|
|
403
456
|
}
|
|
404
457
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
458
|
+
// Same rule as blind SQLi: one slow response is a candidate, not a finding.
|
|
459
|
+
if (timing && res.elapsed > baseline.elapsed + TIMING_DELAY_MS && res.elapsed > TIMING_FLOOR_MS) {
|
|
460
|
+
const check = await confirmTimingDelay(send, baseline.elapsed);
|
|
461
|
+
if (check.confirmed) {
|
|
462
|
+
findings.push({
|
|
463
|
+
severity: "high",
|
|
464
|
+
confidence: "likely", // inferred from timing, not proven output
|
|
465
|
+
title: "Possible OS command injection (time-based)",
|
|
466
|
+
description: `Payload "${payload}". Delay held across ${check.samples.length} repeats (median ${check.medianMs}ms vs baseline ${baseline.elapsed}ms).`,
|
|
467
|
+
url: `${baseUrl}${path}`,
|
|
468
|
+
status: res.status,
|
|
469
|
+
category: "cmdi",
|
|
470
|
+
source: "injection-probe",
|
|
471
|
+
});
|
|
472
|
+
return findings;
|
|
473
|
+
}
|
|
416
474
|
}
|
|
417
475
|
}
|
|
418
476
|
|
|
@@ -421,7 +479,17 @@ export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
|
421
479
|
|
|
422
480
|
// ── Context-Aware XSS (Dalfox-style) ────────────────────────────────────
|
|
423
481
|
|
|
424
|
-
|
|
482
|
+
/**
|
|
483
|
+
* Per-probe reflection canary.
|
|
484
|
+
*
|
|
485
|
+
* A fixed string is trivially WAF-fingerprintable and can collide with content
|
|
486
|
+
* left behind by an earlier scan, so each run gets a fresh one. It stays
|
|
487
|
+
* alphanumeric and lowercase so no context (attribute, JS string, URL) mangles
|
|
488
|
+
* or encodes it on the way through.
|
|
489
|
+
*/
|
|
490
|
+
export function makeCanary() {
|
|
491
|
+
return "pnth" + Math.random().toString(36).slice(2, 10) + "xss";
|
|
492
|
+
}
|
|
425
493
|
|
|
426
494
|
/**
|
|
427
495
|
* Reflected content is only executable as XSS when the browser renders the
|
|
@@ -445,6 +513,7 @@ export function isHtmlResponse(res) {
|
|
|
445
513
|
*/
|
|
446
514
|
export async function probeXss(baseUrl, path, opts = {}) {
|
|
447
515
|
const findings = [];
|
|
516
|
+
const CANARY = makeCanary();
|
|
448
517
|
const inputParams = ["q", "search", "query", "name", "input", "text", "value", "msg", "error", "redirect", "url", "id"];
|
|
449
518
|
|
|
450
519
|
for (const param of inputParams) {
|
package/lib/machine.js
CHANGED
|
@@ -35,6 +35,27 @@ function run(cmd, opts = {}) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Verify a binary's code signature. Returns true only when codesign exits 0.
|
|
40
|
+
*
|
|
41
|
+
* Uses execFileSync so the path is an argv entry: these paths are read from
|
|
42
|
+
* plists that any local process can write, and interpolating one into a shell
|
|
43
|
+
* string would hand a malicious persistence entry command execution inside the
|
|
44
|
+
* scanner. Anything other than a clean exit counts as unsigned.
|
|
45
|
+
*/
|
|
46
|
+
function verifySignature(program) {
|
|
47
|
+
try {
|
|
48
|
+
execFileSync("codesign", ["-v", "--", program], {
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
timeout: 15_000,
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
function runFile(file, args = [], opts = {}) {
|
|
39
60
|
try {
|
|
40
61
|
return execFileSync(file, args, {
|
|
@@ -140,12 +161,22 @@ function checkFileVault() {
|
|
|
140
161
|
function checkXProtect() {
|
|
141
162
|
const version = run("xprotect version 2>&1");
|
|
142
163
|
const status = run("xprotect status 2>&1");
|
|
143
|
-
|
|
164
|
+
// XProtect ships enabled on modern macOS, so its presence isn't interesting.
|
|
165
|
+
// Pending signature updates are: they mean known malware may go undetected.
|
|
166
|
+
const updatesPending = /updates available/i.test(status);
|
|
144
167
|
return {
|
|
145
168
|
name: "XProtect (Apple Malware Detection)",
|
|
146
169
|
status: version || "unknown",
|
|
147
170
|
detail: status,
|
|
148
|
-
finding:
|
|
171
|
+
finding: updatesPending
|
|
172
|
+
? {
|
|
173
|
+
severity: "low",
|
|
174
|
+
title: "XProtect signature updates pending",
|
|
175
|
+
description: "macOS reports XProtect updates are available. Until they install, recently known malware may not be detected.",
|
|
176
|
+
category: "machine",
|
|
177
|
+
source: "machine-audit",
|
|
178
|
+
}
|
|
179
|
+
: null,
|
|
149
180
|
};
|
|
150
181
|
}
|
|
151
182
|
|
|
@@ -192,11 +223,14 @@ function checkPersistence() {
|
|
|
192
223
|
|
|
193
224
|
const isKnown = KNOWN_PREFIXES.some((p) => label.toLowerCase().startsWith(p));
|
|
194
225
|
|
|
195
|
-
// Check code signature of the binary
|
|
226
|
+
// Check code signature of the binary.
|
|
227
|
+
// `program` comes from an attacker-writable plist, so it is passed as an
|
|
228
|
+
// argv entry, never interpolated into a shell string. Verification uses
|
|
229
|
+
// codesign's exit status rather than string-matching its output, so an
|
|
230
|
+
// unexpected message can't be read as "signed" (fail closed, not open).
|
|
196
231
|
let signed = null;
|
|
197
232
|
if (program !== "unknown" && existsSync(program)) {
|
|
198
|
-
|
|
199
|
-
signed = sigOut === "" || sigOut.includes("valid on disk"); // empty = valid
|
|
233
|
+
signed = verifySignature(program);
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
items.push({
|
package/lib/nuclei-loader.js
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
* Nuclei Community Template Loader
|
|
3
3
|
*
|
|
4
4
|
* Parses and executes community-authored Nuclei YAML templates.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* This is a useful subset of Nuclei, not a reimplementation of it. Supported:
|
|
6
7
|
* - HTTP requests with method, path, headers, body
|
|
7
|
-
* - Matchers: status, word, regex, negative-word, negative-regex
|
|
8
|
-
* - Extractors: regex, json
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
8
|
+
* - Matchers: status, word, regex, negative-word, negative-regex
|
|
9
|
+
* - Extractors: regex, json
|
|
10
|
+
* - Placeholders: {{BaseURL}}, {{Hostname}}, {{RootURL}}
|
|
11
|
+
*
|
|
12
|
+
* Not supported, and reported rather than silently ignored (see
|
|
13
|
+
* collectUnsupported): dsl matchers, kval extractors, dynamic helpers such as
|
|
14
|
+
* {{rand_int}}, payload/attack modes, workflows, and multi-request chaining.
|
|
15
|
+
* Only the first path or raw entry of a request block is executed. A template
|
|
16
|
+
* relying on any of these will not match, so the loader says so instead of
|
|
17
|
+
* letting it look like a clean result.
|
|
11
18
|
*
|
|
12
19
|
* Requires the `yaml` npm package (zero-dependency YAML parser).
|
|
13
20
|
*
|
|
@@ -121,6 +128,9 @@ function parseTemplateFile(filePath, parse) {
|
|
|
121
128
|
},
|
|
122
129
|
variables: doc.variables || {},
|
|
123
130
|
http: http.map(normalizeHttpBlock),
|
|
131
|
+
// Features this loader can't execute. Non-empty means the template will
|
|
132
|
+
// not match even if the target is vulnerable.
|
|
133
|
+
unsupported: collectUnsupported(doc),
|
|
124
134
|
_source: filePath,
|
|
125
135
|
};
|
|
126
136
|
} catch {
|
|
@@ -128,6 +138,47 @@ function parseTemplateFile(filePath, parse) {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Report the Nuclei features a template needs that this loader does not
|
|
143
|
+
* implement. A template using any of them silently fails to match, which reads
|
|
144
|
+
* like "no vulnerability found", so callers surface this instead.
|
|
145
|
+
*
|
|
146
|
+
* @returns {string[]} human-readable reasons, empty when fully supported
|
|
147
|
+
*/
|
|
148
|
+
export function collectUnsupported(template) {
|
|
149
|
+
const reasons = new Set();
|
|
150
|
+
const blocks = template?.http || template?.requests || [];
|
|
151
|
+
|
|
152
|
+
if (Array.isArray(blocks) && blocks.length > 1) {
|
|
153
|
+
reasons.add("multi-request chaining (only the first request runs)");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const block of Array.isArray(blocks) ? blocks : []) {
|
|
157
|
+
if (Array.isArray(block?.path) && block.path.length > 1) {
|
|
158
|
+
reasons.add("multiple paths (only the first is requested)");
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(block?.raw) && block.raw.length > 1) {
|
|
161
|
+
reasons.add("multiple raw requests (only the first is sent)");
|
|
162
|
+
}
|
|
163
|
+
if (block?.payloads || block?.attack) reasons.add("payload/attack modes");
|
|
164
|
+
for (const m of block?.matchers || []) {
|
|
165
|
+
if (m?.type === "dsl") reasons.add("dsl matchers");
|
|
166
|
+
if (m?.part && !["body", "header", "all", "response"].includes(m.part)) {
|
|
167
|
+
reasons.add(`matcher part "${m.part}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const e of block?.extractors || []) {
|
|
171
|
+
if (e?.type === "kval") reasons.add("kval extractors");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const asText = JSON.stringify(blocks || "");
|
|
176
|
+
if (/\{\{\s*rand_/.test(asText)) reasons.add("dynamic helpers ({{rand_*}})");
|
|
177
|
+
if (template?.workflows) reasons.add("workflows");
|
|
178
|
+
|
|
179
|
+
return [...reasons];
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
/**
|
|
132
183
|
* Normalize an HTTP request block to our internal format.
|
|
133
184
|
*/
|