@sikkuumiii/defuse 0.0.0-stage → 0.5.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/LICENSE +235 -0
- package/README.md +561 -2
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.js +619 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/analyze.d.ts +178 -0
- package/dist/src/core/analyze.js +834 -0
- package/dist/src/core/analyze.js.map +1 -0
- package/dist/src/core/coverage.d.ts +87 -0
- package/dist/src/core/coverage.js +181 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/finding.d.ts +196 -0
- package/dist/src/core/finding.js +178 -0
- package/dist/src/core/finding.js.map +1 -0
- package/dist/src/core/licence.d.ts +50 -0
- package/dist/src/core/licence.js +56 -0
- package/dist/src/core/licence.js.map +1 -0
- package/dist/src/core/paths.d.ts +21 -0
- package/dist/src/core/paths.js +58 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/score.d.ts +137 -0
- package/dist/src/core/score.js +232 -0
- package/dist/src/core/score.js.map +1 -0
- package/dist/src/dast/targets.d.ts +30 -0
- package/dist/src/dast/targets.js +79 -0
- package/dist/src/dast/targets.js.map +1 -0
- package/dist/src/dast/zap.d.ts +42 -0
- package/dist/src/dast/zap.js +184 -0
- package/dist/src/dast/zap.js.map +1 -0
- package/dist/src/engine/scan.d.ts +80 -0
- package/dist/src/engine/scan.js +129 -0
- package/dist/src/engine/scan.js.map +1 -0
- package/dist/src/engine/shapes.d.ts +47 -0
- package/dist/src/engine/shapes.js +328 -0
- package/dist/src/engine/shapes.js.map +1 -0
- package/dist/src/engine/walk.d.ts +60 -0
- package/dist/src/engine/walk.js +156 -0
- package/dist/src/engine/walk.js.map +1 -0
- package/dist/src/parse/astDump.d.ts +31 -0
- package/dist/src/parse/astDump.js +66 -0
- package/dist/src/parse/astDump.js.map +1 -0
- package/dist/src/parse/languages.d.ts +70 -0
- package/dist/src/parse/languages.js +174 -0
- package/dist/src/parse/languages.js.map +1 -0
- package/dist/src/parse/location.d.ts +17 -0
- package/dist/src/parse/location.js +38 -0
- package/dist/src/parse/location.js.map +1 -0
- package/dist/src/parse/node-grammars.d.ts +12 -0
- package/dist/src/parse/node-grammars.js +35 -0
- package/dist/src/parse/node-grammars.js.map +1 -0
- package/dist/src/parse/parser.d.ts +126 -0
- package/dist/src/parse/parser.js +202 -0
- package/dist/src/parse/parser.js.map +1 -0
- package/dist/src/parse/tree-store.d.ts +139 -0
- package/dist/src/parse/tree-store.js +236 -0
- package/dist/src/parse/tree-store.js.map +1 -0
- package/dist/src/platform/dependency-rule.d.ts +27 -0
- package/dist/src/platform/dependency-rule.js +21 -0
- package/dist/src/platform/dependency-rule.js.map +1 -0
- package/dist/src/platform/deps.d.ts +86 -0
- package/dist/src/platform/deps.js +304 -0
- package/dist/src/platform/deps.js.map +1 -0
- package/dist/src/platform/php-facts.d.ts +49 -0
- package/dist/src/platform/php-facts.js +67 -0
- package/dist/src/platform/php-facts.js.map +1 -0
- package/dist/src/platform/runtime-version.d.ts +78 -0
- package/dist/src/platform/runtime-version.js +573 -0
- package/dist/src/platform/runtime-version.js.map +1 -0
- package/dist/src/report/colors.d.ts +50 -0
- package/dist/src/report/colors.js +97 -0
- package/dist/src/report/colors.js.map +1 -0
- package/dist/src/report/coverage-table.d.ts +90 -0
- package/dist/src/report/coverage-table.js +291 -0
- package/dist/src/report/coverage-table.js.map +1 -0
- package/dist/src/report/html-report.d.ts +66 -0
- package/dist/src/report/html-report.js +489 -0
- package/dist/src/report/html-report.js.map +1 -0
- package/dist/src/report/human.d.ts +44 -0
- package/dist/src/report/human.js +575 -0
- package/dist/src/report/human.js.map +1 -0
- package/dist/src/report/json.d.ts +24 -0
- package/dist/src/report/json.js +178 -0
- package/dist/src/report/json.js.map +1 -0
- package/dist/src/report/mark.d.ts +17 -0
- package/dist/src/report/mark.js +39 -0
- package/dist/src/report/mark.js.map +1 -0
- package/dist/src/report/pricing.d.ts +33 -0
- package/dist/src/report/pricing.js +343 -0
- package/dist/src/report/pricing.js.map +1 -0
- package/dist/src/report/sarif.d.ts +39 -0
- package/dist/src/report/sarif.js +219 -0
- package/dist/src/report/sarif.js.map +1 -0
- package/dist/src/report/site.d.ts +87 -0
- package/dist/src/report/site.js +742 -0
- package/dist/src/report/site.js.map +1 -0
- package/dist/src/rules/code-injection.d.ts +47 -0
- package/dist/src/rules/code-injection.js +326 -0
- package/dist/src/rules/code-injection.js.map +1 -0
- package/dist/src/rules/command-injection.d.ts +30 -0
- package/dist/src/rules/command-injection.js +286 -0
- package/dist/src/rules/command-injection.js.map +1 -0
- package/dist/src/rules/contract.d.ts +198 -0
- package/dist/src/rules/contract.js +39 -0
- package/dist/src/rules/contract.js.map +1 -0
- package/dist/src/rules/format-string.d.ts +46 -0
- package/dist/src/rules/format-string.js +146 -0
- package/dist/src/rules/format-string.js.map +1 -0
- package/dist/src/rules/hardcoded-secret.d.ts +38 -0
- package/dist/src/rules/hardcoded-secret.js +440 -0
- package/dist/src/rules/hardcoded-secret.js.map +1 -0
- package/dist/src/rules/lib/guards.d.ts +168 -0
- package/dist/src/rules/lib/guards.js +1179 -0
- package/dist/src/rules/lib/guards.js.map +1 -0
- package/dist/src/rules/lib/html-safety.d.ts +75 -0
- package/dist/src/rules/lib/html-safety.js +755 -0
- package/dist/src/rules/lib/html-safety.js.map +1 -0
- package/dist/src/rules/lib/local-build.d.ts +59 -0
- package/dist/src/rules/lib/local-build.js +320 -0
- package/dist/src/rules/lib/local-build.js.map +1 -0
- package/dist/src/rules/lib/strings.d.ts +208 -0
- package/dist/src/rules/lib/strings.js +1970 -0
- package/dist/src/rules/lib/strings.js.map +1 -0
- package/dist/src/rules/path-traversal.d.ts +33 -0
- package/dist/src/rules/path-traversal.js +96 -0
- package/dist/src/rules/path-traversal.js.map +1 -0
- package/dist/src/rules/registry.d.ts +19 -0
- package/dist/src/rules/registry.js +77 -0
- package/dist/src/rules/registry.js.map +1 -0
- package/dist/src/rules/sql-injection.d.ts +36 -0
- package/dist/src/rules/sql-injection.js +299 -0
- package/dist/src/rules/sql-injection.js.map +1 -0
- package/dist/src/rules/ssrf.d.ts +39 -0
- package/dist/src/rules/ssrf.js +111 -0
- package/dist/src/rules/ssrf.js.map +1 -0
- package/dist/src/rules/unbounded-copy.d.ts +37 -0
- package/dist/src/rules/unbounded-copy.js +147 -0
- package/dist/src/rules/unbounded-copy.js.map +1 -0
- package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
- package/dist/src/rules/unsafe-deserialization.js +276 -0
- package/dist/src/rules/unsafe-deserialization.js.map +1 -0
- package/dist/src/rules/weak-hash.d.ts +37 -0
- package/dist/src/rules/weak-hash.js +197 -0
- package/dist/src/rules/weak-hash.js.map +1 -0
- package/dist/src/rules/xss.d.ts +38 -0
- package/dist/src/rules/xss.js +527 -0
- package/dist/src/rules/xss.js.map +1 -0
- package/dist/src/taint/content-type.d.ts +19 -0
- package/dist/src/taint/content-type.js +20 -0
- package/dist/src/taint/content-type.js.map +1 -0
- package/dist/src/taint/dictionaries.d.ts +23 -0
- package/dist/src/taint/dictionaries.js +1778 -0
- package/dist/src/taint/dictionaries.js.map +1 -0
- package/dist/src/taint/fold.d.ts +59 -0
- package/dist/src/taint/fold.js +1177 -0
- package/dist/src/taint/fold.js.map +1 -0
- package/dist/src/taint/java-scope.d.ts +61 -0
- package/dist/src/taint/java-scope.js +212 -0
- package/dist/src/taint/java-scope.js.map +1 -0
- package/dist/src/taint/local-map.d.ts +79 -0
- package/dist/src/taint/local-map.js +603 -0
- package/dist/src/taint/local-map.js.map +1 -0
- package/dist/src/taint/project.d.ts +172 -0
- package/dist/src/taint/project.js +617 -0
- package/dist/src/taint/project.js.map +1 -0
- package/dist/src/taint/tracer.d.ts +265 -0
- package/dist/src/taint/tracer.js +3817 -0
- package/dist/src/taint/tracer.js.map +1 -0
- package/dist/src/taint/types.d.ts +484 -0
- package/dist/src/taint/types.js +47 -0
- package/dist/src/taint/types.js.map +1 -0
- package/dist/src/taint/visibility.d.ts +25 -0
- package/dist/src/taint/visibility.js +96 -0
- package/dist/src/taint/visibility.js.map +1 -0
- package/docs/examples/defuse.json +20 -0
- package/docs/examples/dvwa-urls.json +14 -0
- package/docs/examples/github-code-scanning.yml +54 -0
- package/docs/examples/tiny.js +4 -0
- package/package.json +73 -4
- package/src/cli.ts +747 -0
- package/src/core/analyze.ts +1072 -0
- package/src/core/coverage.ts +269 -0
- package/src/core/finding.ts +311 -0
- package/src/core/licence.ts +61 -0
- package/src/core/paths.ts +59 -0
- package/src/core/score.ts +353 -0
- package/src/dast/targets.ts +121 -0
- package/src/dast/zap.ts +246 -0
- package/src/engine/scan.ts +200 -0
- package/src/engine/shapes.ts +348 -0
- package/src/engine/walk.ts +196 -0
- package/src/parse/astDump.ts +85 -0
- package/src/parse/languages.ts +232 -0
- package/src/parse/location.ts +44 -0
- package/src/parse/node-grammars.ts +41 -0
- package/src/parse/parser.ts +262 -0
- package/src/parse/tree-store.ts +301 -0
- package/src/platform/dependency-rule.ts +33 -0
- package/src/platform/deps.ts +374 -0
- package/src/platform/php-facts.ts +85 -0
- package/src/platform/runtime-version.ts +710 -0
- package/src/report/colors.ts +107 -0
- package/src/report/coverage-table.ts +359 -0
- package/src/report/html-report.ts +601 -0
- package/src/report/human.ts +811 -0
- package/src/report/json.ts +189 -0
- package/src/report/mark.ts +47 -0
- package/src/report/pricing.ts +374 -0
- package/src/report/sarif.ts +235 -0
- package/src/report/site.ts +760 -0
- package/src/rules/code-injection.ts +346 -0
- package/src/rules/command-injection.ts +296 -0
- package/src/rules/contract.ts +220 -0
- package/src/rules/format-string.ts +158 -0
- package/src/rules/hardcoded-secret.ts +485 -0
- package/src/rules/lib/guards.ts +1235 -0
- package/src/rules/lib/html-safety.ts +793 -0
- package/src/rules/lib/local-build.ts +329 -0
- package/src/rules/lib/strings.ts +2034 -0
- package/src/rules/path-traversal.ts +101 -0
- package/src/rules/registry.ts +90 -0
- package/src/rules/sql-injection.ts +311 -0
- package/src/rules/ssrf.ts +118 -0
- package/src/rules/unbounded-copy.ts +162 -0
- package/src/rules/unsafe-deserialization.ts +291 -0
- package/src/rules/weak-hash.ts +226 -0
- package/src/rules/xss.ts +572 -0
- package/src/taint/content-type.ts +20 -0
- package/src/taint/dictionaries.ts +1855 -0
- package/src/taint/fold.ts +1206 -0
- package/src/taint/java-scope.ts +200 -0
- package/src/taint/local-map.ts +594 -0
- package/src/taint/project.ts +774 -0
- package/src/taint/tracer.ts +4298 -0
- package/src/taint/types.ts +511 -0
- package/src/taint/visibility.ts +100 -0
- package/tsconfig.json +61 -0
package/src/dast/zap.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { buildTargets, type Target, type UrlConfig } from './targets.js';
|
|
4
|
+
import type { Finding } from '../core/finding.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* STEP TWO OF THE DAST ADD-ON: hand the targets to ZAP, put each verdict next to
|
|
8
|
+
* the finding it came from.
|
|
9
|
+
*
|
|
10
|
+
* targets.ts turned Defuse's proven (flow-verified) findings into a list of
|
|
11
|
+
* addresses to probe. This file drives OWASP ZAP's active scanner against each
|
|
12
|
+
* one, restricted to the single ZAP rule that matches the finding's category,
|
|
13
|
+
* and reports ONE verdict per target:
|
|
14
|
+
*
|
|
15
|
+
* confirmed ZAP reproduced the bug at that URL with its own payloads.
|
|
16
|
+
* not-confirmed ZAP ran the rule and raised nothing. THE FINDING STANDS.
|
|
17
|
+
* could-not-test ZAP never got to render a verdict (input isn't a request
|
|
18
|
+
* parameter, the scan errored, or it timed out).
|
|
19
|
+
*
|
|
20
|
+
* The honesty rule from finding.ts carries over: not-confirmed is NOT cleared.
|
|
21
|
+
* A dynamic scanner that can't reach a code path proves nothing about it, so a
|
|
22
|
+
* finding Defuse traced by data flow keeps its status unless ZAP positively
|
|
23
|
+
* reproduces it. ZAP only ever ADDS confidence; it never subtracts a finding.
|
|
24
|
+
*
|
|
25
|
+
* Every target appears in the output exactly once. The run asserts
|
|
26
|
+
* results.length === targets.length so nothing is dropped silently.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
type Verdict = 'confirmed' | 'not-confirmed' | 'could-not-test';
|
|
30
|
+
|
|
31
|
+
interface Result {
|
|
32
|
+
id: string;
|
|
33
|
+
rule: string;
|
|
34
|
+
zapRule: string;
|
|
35
|
+
url: string;
|
|
36
|
+
from: string;
|
|
37
|
+
param: string;
|
|
38
|
+
zap: Verdict;
|
|
39
|
+
detail: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Outcome {
|
|
43
|
+
zap: Verdict;
|
|
44
|
+
detail: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ZAP's one-call active scan needs the whole request, not just the param, because
|
|
48
|
+
// the scanner replays it and only mutates the one field. DVWA hides each bug behind
|
|
49
|
+
// its submit button (Submit=Submit), so without that in the seed the vulnerable
|
|
50
|
+
// branch never runs. `trigger` carries those extra seed fields; it is calibration
|
|
51
|
+
// the generic model can't see, so it lives in the config, not here.
|
|
52
|
+
interface Config extends UrlConfig {
|
|
53
|
+
trigger?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Which HTTP methods carry the tainted input. $_REQUEST is GET or POST, and a page
|
|
57
|
+
// often guards one while reading the other ($_REQUEST['ip'] behind an isset($_POST['Submit'])),
|
|
58
|
+
// so a REQUEST source has to be tried both ways. A COOKIE source is not a request
|
|
59
|
+
// parameter this harness can mutate (the Cookie header is pinned for auth), so it is
|
|
60
|
+
// reported could-not-test, never not-confirmed.
|
|
61
|
+
function methods(from: string): ('GET' | 'POST')[] {
|
|
62
|
+
if (from === 'POST') return ['POST'];
|
|
63
|
+
if (from === 'GET') return ['GET'];
|
|
64
|
+
if (from === 'REQUEST') return ['GET', 'POST'];
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One scan covers every target that attacks the same spot the same way.
|
|
69
|
+
export function groupKey(t: Target): string {
|
|
70
|
+
return [t.zapRule, t.from, t.url, t.param, t.cookie, t.trigger ?? '', t.untestable ?? ''].join('|');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// confirmed iff ZAP raised at least one alert of the finding's own rule.
|
|
74
|
+
export function classify(zapRule: string, alertPluginIds: string[]): Verdict {
|
|
75
|
+
return alertPluginIds.includes(zapRule) ? 'confirmed' : 'not-confirmed';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Put each ZAP outcome next to its target by id. A target with no outcome (a
|
|
79
|
+
// group that never ran) becomes could-not-test, never a dropped row.
|
|
80
|
+
export function joinResults(targets: Target[], byKey: Map<string, Outcome>): Result[] {
|
|
81
|
+
return targets.map((t) => {
|
|
82
|
+
const outcome = byKey.get(groupKey(t)) ?? { zap: 'could-not-test' as const, detail: 'no scan ran for this target' };
|
|
83
|
+
return { id: t.id, rule: t.rule, zapRule: t.zapRule, url: t.url, from: t.from, param: t.param, ...outcome };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ZAP = process.env.ZAP ?? 'http://localhost:8080';
|
|
88
|
+
const AUTH = process.env.DAST_COOKIE ?? ''; // e.g. "PHPSESSID=abc"; combined with each target's security cookie
|
|
89
|
+
const POLL_MS = 3000;
|
|
90
|
+
const SCAN_TIMEOUT_MS = Number(process.env.DAST_SCAN_TIMEOUT_MS ?? 300_000);
|
|
91
|
+
const RULE_DESC = 'defuse-dast-cookie';
|
|
92
|
+
|
|
93
|
+
async function zap(apiPath: string, params: Record<string, string> = {}): Promise<Record<string, unknown>> {
|
|
94
|
+
const url = new URL(`/JSON/${apiPath}/`, ZAP);
|
|
95
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
96
|
+
const res = await fetch(url);
|
|
97
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
98
|
+
if (typeof body.code === 'string' && typeof body.message === 'string') {
|
|
99
|
+
throw new Error(`ZAP ${apiPath}: ${body.message} (${body.code})`);
|
|
100
|
+
}
|
|
101
|
+
return body;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
105
|
+
|
|
106
|
+
async function setCookie(cookie: string): Promise<void> {
|
|
107
|
+
await zap('replacer/action/removeRule', { description: RULE_DESC }).catch(() => undefined);
|
|
108
|
+
if (cookie === '') return;
|
|
109
|
+
await zap('replacer/action/addRule', {
|
|
110
|
+
description: RULE_DESC,
|
|
111
|
+
enabled: 'true',
|
|
112
|
+
matchType: 'REQ_HEADER',
|
|
113
|
+
matchString: 'Cookie',
|
|
114
|
+
matchRegex: 'false',
|
|
115
|
+
replacement: cookie,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One method against one target. confirmed / not-confirmed come from ZAP; a thrown
|
|
120
|
+
// ZAP error becomes could-not-test.
|
|
121
|
+
async function scanOnce(t: Target, verb: 'GET' | 'POST', trigger: string): Promise<Outcome> {
|
|
122
|
+
try {
|
|
123
|
+
const cookie = [AUTH, t.cookie].filter(Boolean).join('; ');
|
|
124
|
+
await setCookie(cookie);
|
|
125
|
+
const seedParams = [`${t.param}=test`, trigger].filter(Boolean).join('&');
|
|
126
|
+
|
|
127
|
+
// Seed the request so the URL is in ZAP's tree (the scan errors otherwise) and so
|
|
128
|
+
// the vulnerable branch actually runs before the scanner starts mutating the param.
|
|
129
|
+
let scanUrl = t.url;
|
|
130
|
+
let postData = '';
|
|
131
|
+
if (verb === 'GET') {
|
|
132
|
+
scanUrl = `${t.url}?${seedParams}`;
|
|
133
|
+
await zap('core/action/accessUrl', { url: scanUrl, followRedirects: 'false' });
|
|
134
|
+
} else {
|
|
135
|
+
postData = seedParams;
|
|
136
|
+
const { pathname, host } = new URL(t.url);
|
|
137
|
+
const req =
|
|
138
|
+
`POST ${pathname} HTTP/1.1\r\n` +
|
|
139
|
+
`Host: ${host}\r\n` +
|
|
140
|
+
(cookie ? `Cookie: ${cookie}\r\n` : '') +
|
|
141
|
+
`Content-Type: application/x-www-form-urlencoded\r\n` +
|
|
142
|
+
`Content-Length: ${Buffer.byteLength(postData)}\r\n\r\n${postData}`;
|
|
143
|
+
await zap('core/action/sendRequest', { request: req, followRedirects: 'false' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Only the finding's own rule runs. The policy is global state, so groups go
|
|
147
|
+
// one at a time. ponytail: sequential scans, parallelise per-rule policies if it's ever too slow.
|
|
148
|
+
await zap('ascan/action/disableAllScanners', { scanPolicyName: '' });
|
|
149
|
+
await zap('ascan/action/enableScanners', { ids: t.zapRule, scanPolicyName: '' });
|
|
150
|
+
|
|
151
|
+
const started = await zap('ascan/action/scan', {
|
|
152
|
+
url: scanUrl,
|
|
153
|
+
method: verb,
|
|
154
|
+
postData,
|
|
155
|
+
recurse: 'false',
|
|
156
|
+
inScopeOnly: 'false',
|
|
157
|
+
});
|
|
158
|
+
const scanId = String(started.scan);
|
|
159
|
+
|
|
160
|
+
const deadline = Date.now() + SCAN_TIMEOUT_MS;
|
|
161
|
+
for (;;) {
|
|
162
|
+
const status = String((await zap('ascan/view/status', { scanId })).status);
|
|
163
|
+
if (status === '100') break;
|
|
164
|
+
if (Date.now() > deadline) {
|
|
165
|
+
await zap('ascan/action/stop', { scanId }).catch(() => undefined);
|
|
166
|
+
return { zap: 'could-not-test', detail: `scan timed out after ${SCAN_TIMEOUT_MS / 1000}s` };
|
|
167
|
+
}
|
|
168
|
+
await sleep(POLL_MS);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const alertIds = (await zap('ascan/view/alertsIds', { scanId })).alertsIds as string[];
|
|
172
|
+
const pluginIds: string[] = [];
|
|
173
|
+
for (const id of alertIds) {
|
|
174
|
+
const alert = (await zap('core/view/alert', { id })).alert as { pluginId?: string | number } | undefined;
|
|
175
|
+
if (alert?.pluginId !== undefined) pluginIds.push(String(alert.pluginId));
|
|
176
|
+
}
|
|
177
|
+
const verdict = classify(t.zapRule, pluginIds);
|
|
178
|
+
const hits = pluginIds.filter((p) => p === t.zapRule).length;
|
|
179
|
+
return {
|
|
180
|
+
zap: verdict,
|
|
181
|
+
detail: verdict === 'confirmed' ? `ZAP rule ${t.zapRule} raised ${hits} alert(s) via ${verb}` : `ZAP rule ${t.zapRule} ran via ${verb} and raised none`,
|
|
182
|
+
};
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { zap: 'could-not-test', detail: `${verb}: ${(e as Error).message}` };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A target may need more than one method (REQUEST). confirmed wins; otherwise
|
|
189
|
+
// not-confirmed if any method actually ran; could-not-test only if none could.
|
|
190
|
+
async function scanGroup(t: Target, trigger: string): Promise<Outcome> {
|
|
191
|
+
// Not "not-confirmed": that would say ZAP tried and failed, when it never got in.
|
|
192
|
+
if (t.untestable !== undefined) return { zap: 'could-not-test', detail: t.untestable };
|
|
193
|
+
const verbs = methods(t.from);
|
|
194
|
+
if (verbs.length === 0) {
|
|
195
|
+
return { zap: 'could-not-test', detail: `input comes from a ${t.from.toLowerCase()}, not a request parameter this scan can mutate` };
|
|
196
|
+
}
|
|
197
|
+
const outcomes: Outcome[] = [];
|
|
198
|
+
for (const verb of verbs) {
|
|
199
|
+
const outcome = await scanOnce(t, verb, trigger);
|
|
200
|
+
if (outcome.zap === 'confirmed') return outcome;
|
|
201
|
+
outcomes.push(outcome);
|
|
202
|
+
}
|
|
203
|
+
return outcomes.find((o) => o.zap === 'not-confirmed') ?? outcomes[0]!;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function main(): Promise<void> {
|
|
207
|
+
const [reportPath, configPath] = process.argv.slice(2);
|
|
208
|
+
if (reportPath === undefined || configPath === undefined) {
|
|
209
|
+
console.error('usage: DAST_COOKIE=PHPSESSID=... node dist/src/dast/zap.js report.json urls.json');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { findings: Finding[] };
|
|
213
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8')) as Config;
|
|
214
|
+
const { targets, skipped, noUrl } = buildTargets(report, config);
|
|
215
|
+
const trigger = config.trigger ?? '';
|
|
216
|
+
|
|
217
|
+
// One scan per group; its outcome fills every target in the group.
|
|
218
|
+
const byKey = new Map<string, Outcome>();
|
|
219
|
+
let done = 0;
|
|
220
|
+
for (const t of targets) {
|
|
221
|
+
const key = groupKey(t);
|
|
222
|
+
if (byKey.has(key)) continue;
|
|
223
|
+
process.stderr.write(`[${++done}] ${t.rule} ${t.from} ${t.url} (${t.param}) ... `);
|
|
224
|
+
const outcome = await scanGroup(t, t.trigger ?? trigger);
|
|
225
|
+
byKey.set(key, outcome);
|
|
226
|
+
process.stderr.write(`${outcome.zap}\n`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const results = joinResults(targets, byKey);
|
|
230
|
+
if (results.length !== targets.length) {
|
|
231
|
+
throw new Error(`dropped targets: ${targets.length} in, ${results.length} out`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log(JSON.stringify(results, null, 2));
|
|
235
|
+
|
|
236
|
+
const n = (v: Verdict): number => results.filter((r) => r.zap === v).length;
|
|
237
|
+
console.error(
|
|
238
|
+
`\n${results.length} findings tested: ${n('confirmed')} confirmed, ${n('not-confirmed')} not confirmed, ${n('could-not-test')} could not test.` +
|
|
239
|
+
`\nBefore ZAP: ${skipped} proven findings had no ZAP rule or named input, ${noUrl} had no URL.` +
|
|
240
|
+
`\nA not-confirmed finding stays a finding: ZAP reproducing nothing does not clear what Defuse traced.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
245
|
+
void main();
|
|
246
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NODE SHELL
|
|
3
|
+
* ==============
|
|
4
|
+
*
|
|
5
|
+
* Everything that only makes sense on a machine with a filesystem: walking a
|
|
6
|
+
* directory, honouring --exclude, reading bytes, and shortening paths against
|
|
7
|
+
* the working directory so terminal output stays readable.
|
|
8
|
+
*
|
|
9
|
+
* The analysis itself is NOT here. It moved to src/core/analyze.ts when the
|
|
10
|
+
* browser UI arrived, so the web version could run the same code rather than a
|
|
11
|
+
* lookalike. What is left is the pipeline seam:
|
|
12
|
+
*
|
|
13
|
+
* collectFiles -> read text -> analyze() -> a ScanResult
|
|
14
|
+
*
|
|
15
|
+
* If you find yourself wanting to change how a FINDING is produced, this is the
|
|
16
|
+
* wrong file - that belongs in core/analyze.ts, where the browser sees it too.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { analyze, type AnalysisResult, type SourceFile } from '../core/analyze.js';
|
|
22
|
+
import { buildCoverageReport, type CoverageReport } from '../core/coverage.js';
|
|
23
|
+
import type { Finding } from '../core/finding.js';
|
|
24
|
+
import { SCANNABLE_EXTENSIONS } from '../parse/languages.js';
|
|
25
|
+
import { useNodeGrammars } from '../parse/node-grammars.js';
|
|
26
|
+
import { checkRuntimeVersions, RUNTIME_VERSION_RULE, type RuntimeCheckResult } from '../platform/runtime-version.js';
|
|
27
|
+
import type { DependencyReport } from '../platform/dependency-rule.js';
|
|
28
|
+
import { collectFiles } from './walk.js';
|
|
29
|
+
|
|
30
|
+
export type { FileParseProblem, Suppression } from '../core/analyze.js';
|
|
31
|
+
|
|
32
|
+
export interface ScanOptions {
|
|
33
|
+
/** Only run these rule ids. Empty = all. */
|
|
34
|
+
readonly only?: readonly string[];
|
|
35
|
+
/** Glob-ish paths to skip entirely. Reported, never silently applied. */
|
|
36
|
+
readonly exclude?: readonly string[];
|
|
37
|
+
/** Turn off cross-file resolution (faster, less memory, fewer findings). */
|
|
38
|
+
readonly noCrossFile?: boolean;
|
|
39
|
+
/** Print progress to stderr. */
|
|
40
|
+
readonly onProgress?: (done: number, total: number, file: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ScanResult {
|
|
44
|
+
readonly target: string;
|
|
45
|
+
readonly findings: readonly Finding[];
|
|
46
|
+
/** Lines the tracer proved safe. Reported so "clean" can mean "checked". */
|
|
47
|
+
readonly verifiedClean: AnalysisResult['verifiedClean'];
|
|
48
|
+
/** Branches and cases not followed because they cannot run, with the reason. */
|
|
49
|
+
readonly unreachableCode: AnalysisResult['unreachableCode'];
|
|
50
|
+
/** Map and list reads answered by key or position, with the write that answers each. */
|
|
51
|
+
readonly collectionReadsResolved: AnalysisResult['collectionReadsResolved'];
|
|
52
|
+
/** Calls where a trace ended because the result is new data, not the argument. */
|
|
53
|
+
readonly tracesStoppedAtNewData: AnalysisResult['tracesStoppedAtNewData'];
|
|
54
|
+
/**
|
|
55
|
+
* Which PHP the project declares, and what php.net's facts say about it -
|
|
56
|
+
* platform/runtime-version.ts. Null when --only left the check out.
|
|
57
|
+
*/
|
|
58
|
+
readonly runtimeVersions: RuntimeCheckResult | null;
|
|
59
|
+
/**
|
|
60
|
+
* What `scan --deps` read and asked osv.dev. Absent when the check did not run -
|
|
61
|
+
* scan() never sets it; the CLI adds it, because it is the one network call.
|
|
62
|
+
*/
|
|
63
|
+
readonly dependencies?: DependencyReport;
|
|
64
|
+
/** Per-file participation, so a file a flow passes through is never "clean". */
|
|
65
|
+
readonly fileRoles: AnalysisResult['fileRoles'];
|
|
66
|
+
/** Where the tracer ran out, counted for this scan. */
|
|
67
|
+
readonly traceLimits: AnalysisResult['traceLimits'];
|
|
68
|
+
readonly stats: AnalysisResult['stats'] & {
|
|
69
|
+
readonly filesFound: number;
|
|
70
|
+
readonly filesWithParseErrors: number;
|
|
71
|
+
};
|
|
72
|
+
readonly byLanguage: AnalysisResult['byLanguage'];
|
|
73
|
+
readonly parseProblems: AnalysisResult['parseProblems'];
|
|
74
|
+
readonly oversizedFiles: readonly string[];
|
|
75
|
+
/** What --exclude removed. A gap the user chose is still a gap worth printing. */
|
|
76
|
+
readonly excluded: {
|
|
77
|
+
readonly patterns: readonly string[];
|
|
78
|
+
readonly fileCount: number;
|
|
79
|
+
readonly directories: readonly string[];
|
|
80
|
+
};
|
|
81
|
+
/** Findings the code explicitly asked us to ignore. Reported, never hidden. */
|
|
82
|
+
readonly suppressions: AnalysisResult['suppressions'];
|
|
83
|
+
readonly coverage: CoverageReport;
|
|
84
|
+
/** How cross-file resolution went. */
|
|
85
|
+
readonly crossFile: AnalysisResult['crossFile'];
|
|
86
|
+
/** What the scan spent on syntax trees, and whether it had to spill. */
|
|
87
|
+
readonly treeMemory: AnalysisResult['treeMemory'];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function scan(target: string, options: ScanOptions = {}): Promise<ScanResult> {
|
|
91
|
+
// Tell the parser it is running on Node before anything loads a grammar.
|
|
92
|
+
// The browser worker makes the equivalent call with its own loader.
|
|
93
|
+
useNodeGrammars();
|
|
94
|
+
|
|
95
|
+
const walk = await collectFiles(target, {
|
|
96
|
+
scannable: SCANNABLE_EXTENSIONS,
|
|
97
|
+
exclude: options.exclude ?? [],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Read the text. A file we cannot read simply never reaches the analyzer;
|
|
101
|
+
// it shows up as the gap between filesFound and filesParsed.
|
|
102
|
+
const inputs: SourceFile[] = [];
|
|
103
|
+
for (const filePath of walk.files) {
|
|
104
|
+
try {
|
|
105
|
+
inputs.push({ path: filePath, source: await readFile(filePath, 'utf8') });
|
|
106
|
+
} catch {
|
|
107
|
+
// permissions, a broken symlink, a race with a build tool
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* DEFUSE_TREE_BUDGET_MB - how much WebAssembly heap the scan may hold in syntax
|
|
113
|
+
* trees before it starts freeing the least recently used one.
|
|
114
|
+
*
|
|
115
|
+
* An environment variable rather than a flag, deliberately. It exists to be
|
|
116
|
+
* turned down hard in testing: the interesting question about the tree store
|
|
117
|
+
* is not "does it work when nothing is ever evicted" but "are the findings
|
|
118
|
+
* still identical when almost everything is". A budget of 1 MB forces a
|
|
119
|
+
* re-parse for nearly every cross-file hop, which is exactly the condition
|
|
120
|
+
* the identity check should be run under.
|
|
121
|
+
*
|
|
122
|
+
* Anyone who genuinely needs it in production can set it, and the value used
|
|
123
|
+
* is printed in the report either way.
|
|
124
|
+
*/
|
|
125
|
+
const budgetMb = Number(process.env['DEFUSE_TREE_BUDGET_MB']);
|
|
126
|
+
const treeBudget =
|
|
127
|
+
Number.isFinite(budgetMb) && budgetMb > 0 ? Math.round(budgetMb * 1024 * 1024) : undefined;
|
|
128
|
+
|
|
129
|
+
const result = await analyze(inputs, {
|
|
130
|
+
...(options.only ? { only: options.only } : {}),
|
|
131
|
+
...(options.noCrossFile !== undefined ? { noCrossFile: options.noCrossFile } : {}),
|
|
132
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
133
|
+
...(treeBudget !== undefined ? { treeBudgetBytes: treeBudget } : {}),
|
|
134
|
+
toDisplay: (absolutePath) => path.relative(process.cwd(), absolutePath) || absolutePath,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* THE RUNTIME VERSION CHECK reads declarations, not code, so it runs beside
|
|
139
|
+
* the analysis rather than inside it. Its findings join the same list - a
|
|
140
|
+
* PHP release with a known critical flaw belongs next to the code problems,
|
|
141
|
+
* not in a footnote - and they are all signature-based by construction.
|
|
142
|
+
*/
|
|
143
|
+
const runtimeVersions =
|
|
144
|
+
!options.only || options.only.length === 0 || options.only.includes(RUNTIME_VERSION_RULE.id)
|
|
145
|
+
? await (async () => {
|
|
146
|
+
const files = [];
|
|
147
|
+
for (const filePath of walk.runtimeFiles) {
|
|
148
|
+
try {
|
|
149
|
+
files.push({
|
|
150
|
+
path: path.relative(process.cwd(), filePath) || filePath,
|
|
151
|
+
text: await readFile(filePath, 'utf8'),
|
|
152
|
+
});
|
|
153
|
+
} catch {
|
|
154
|
+
// unreadable - same treatment as a source file
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return checkRuntimeVersions(files, {
|
|
158
|
+
today: new Date().toISOString().slice(0, 10),
|
|
159
|
+
phpFilesScanned: result.byLanguage['php'] ?? 0,
|
|
160
|
+
});
|
|
161
|
+
})()
|
|
162
|
+
: null;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
target,
|
|
166
|
+
findings: [...result.findings, ...(runtimeVersions?.findings ?? [])],
|
|
167
|
+
verifiedClean: result.verifiedClean,
|
|
168
|
+
unreachableCode: result.unreachableCode,
|
|
169
|
+
collectionReadsResolved: result.collectionReadsResolved,
|
|
170
|
+
tracesStoppedAtNewData: result.tracesStoppedAtNewData,
|
|
171
|
+
runtimeVersions,
|
|
172
|
+
fileRoles: result.fileRoles,
|
|
173
|
+
traceLimits: result.traceLimits,
|
|
174
|
+
stats: {
|
|
175
|
+
...result.stats,
|
|
176
|
+
filesFound: walk.files.length,
|
|
177
|
+
filesSkipped: walk.files.length - result.stats.filesParsed,
|
|
178
|
+
filesWithParseErrors: result.parseProblems.length,
|
|
179
|
+
},
|
|
180
|
+
byLanguage: result.byLanguage,
|
|
181
|
+
parseProblems: result.parseProblems,
|
|
182
|
+
oversizedFiles: walk.oversizedFiles,
|
|
183
|
+
excluded: {
|
|
184
|
+
patterns: options.exclude ?? [],
|
|
185
|
+
fileCount: walk.excludedCount,
|
|
186
|
+
directories: walk.excludedDirectories,
|
|
187
|
+
},
|
|
188
|
+
suppressions: result.suppressions,
|
|
189
|
+
crossFile: result.crossFile,
|
|
190
|
+
treeMemory: result.treeMemory,
|
|
191
|
+
// Rebuilt here because only the walker knows which file types it passed
|
|
192
|
+
// over - the analyzer never saw them.
|
|
193
|
+
coverage: buildCoverageReport(
|
|
194
|
+
[...walk.skippedByExtension.entries()]
|
|
195
|
+
.sort((a, b) => b[1] - a[1])
|
|
196
|
+
.slice(0, 12)
|
|
197
|
+
.map(([ext, count]) => `${ext} (${count} file${count === 1 ? '' : 's'})`),
|
|
198
|
+
),
|
|
199
|
+
};
|
|
200
|
+
}
|