@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH HELPERS WITH NO PLATFORM ATTACHED
|
|
3
|
+
*
|
|
4
|
+
* `node:path` was the last thing standing between the analysis code and a
|
|
5
|
+
* browser. The import graph needs to join and normalise paths, and Node's
|
|
6
|
+
* version of that pulls in a runtime the browser does not have.
|
|
7
|
+
*
|
|
8
|
+
* These are the four operations the resolver actually uses, written in plain
|
|
9
|
+
* JavaScript over forward slashes. Windows paths are normalised on the way in,
|
|
10
|
+
* so `C:\\src\\db.ts` and `C:/src/db.ts` compare equal - which matters, because
|
|
11
|
+
* a path that fails to match is an import edge silently not resolved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Backslashes to forward slashes. Everything else here assumes this ran. */
|
|
15
|
+
export function toPosix(filePath: string): string {
|
|
16
|
+
return filePath.replace(/\\/g, '/');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Collapse `.` and `..` segments. Keeps a leading `/` or a `C:` drive. */
|
|
20
|
+
export function normalizePath(filePath: string): string {
|
|
21
|
+
const posix = toPosix(filePath);
|
|
22
|
+
const absolute = posix.startsWith('/');
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const segment of posix.split('/')) {
|
|
26
|
+
if (!segment || segment === '.') continue;
|
|
27
|
+
if (segment === '..') {
|
|
28
|
+
const last = parts[parts.length - 1];
|
|
29
|
+
if (parts.length > 0 && last !== '..') parts.pop();
|
|
30
|
+
else if (!absolute) parts.push('..');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
parts.push(segment);
|
|
34
|
+
}
|
|
35
|
+
return (absolute ? '/' : '') + parts.join('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function dirnameOf(filePath: string): string {
|
|
39
|
+
const posix = toPosix(filePath);
|
|
40
|
+
const cut = posix.lastIndexOf('/');
|
|
41
|
+
if (cut < 0) return '';
|
|
42
|
+
return cut === 0 ? '/' : posix.slice(0, cut);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function basenameOf(filePath: string): string {
|
|
46
|
+
const posix = toPosix(filePath);
|
|
47
|
+
return posix.slice(posix.lastIndexOf('/') + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function joinPath(...parts: readonly string[]): string {
|
|
51
|
+
return normalizePath(parts.filter(Boolean).join('/'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve a relative specifier against a directory. */
|
|
55
|
+
export function resolveFrom(directory: string, relative: string): string {
|
|
56
|
+
const posix = toPosix(relative);
|
|
57
|
+
if (posix.startsWith('/')) return normalizePath(posix);
|
|
58
|
+
return normalizePath(`${toPosix(directory)}/${posix}`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAUGES
|
|
3
|
+
* ==========
|
|
4
|
+
*
|
|
5
|
+
* Three needles. They are never added together, and this file will not let
|
|
6
|
+
* them be - there is no `overallScore` export, and there never should be.
|
|
7
|
+
*
|
|
8
|
+
* WHY THAT MATTERS. The comment at the top of finding.ts says it best:
|
|
9
|
+
*
|
|
10
|
+
* "Severity = how bad is this if it turns out to be real? Deliberately
|
|
11
|
+
* separate from confidence, which is how sure are we it's real?
|
|
12
|
+
* Conflating those two numbers is the single most common lie in SAST
|
|
13
|
+
* tooling: a tool shows one 'risk' bar and you cannot tell whether it
|
|
14
|
+
* means 'probably harmless but catastrophic if not' or 'definitely real
|
|
15
|
+
* but minor'."
|
|
16
|
+
*
|
|
17
|
+
* A single 0-500 risk dial IS that bar. So the dial got split along the exact
|
|
18
|
+
* line the project already draws:
|
|
19
|
+
*
|
|
20
|
+
* VERIFIED EXPOSURE severity, over findings we PROVED reach a sink
|
|
21
|
+
* UNVERIFIED SURFACE severity, over findings we only pattern-matched
|
|
22
|
+
* ANALYSIS COVERAGE how much of the code we could actually reason about
|
|
23
|
+
*
|
|
24
|
+
* Two of those are the same weights over two different confidence levels, kept
|
|
25
|
+
* side by side so you can see the shape of what is proven against the shape of
|
|
26
|
+
* what is merely suspected. Reading them together is the point; averaging them
|
|
27
|
+
* would destroy it.
|
|
28
|
+
*
|
|
29
|
+
* Every reading carries its own arithmetic in `working`, so the number can be
|
|
30
|
+
* CHECKED rather than believed. That is the whole standard here: a score you
|
|
31
|
+
* cannot recompute by hand is a vibe with a decimal point on it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Finding, Severity } from './finding.js';
|
|
35
|
+
import type { AnalysisResult } from './analyze.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How much one finding contributes, by severity.
|
|
39
|
+
*
|
|
40
|
+
* These are a judgement, not a measurement, and calling them anything else
|
|
41
|
+
* would be the first lie. What makes them defensible is that they are FIXED,
|
|
42
|
+
* PUBLISHED and applied identically to both severity gauges - so while the
|
|
43
|
+
* absolute number is arbitrary, every comparison made with it is real: two
|
|
44
|
+
* criticals always outweigh three mediums, in both directions, in both shells.
|
|
45
|
+
*/
|
|
46
|
+
export const SEVERITY_WEIGHT: Readonly<Record<Severity, number>> = {
|
|
47
|
+
critical: 25,
|
|
48
|
+
high: 15,
|
|
49
|
+
medium: 7,
|
|
50
|
+
low: 3,
|
|
51
|
+
info: 1,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The top of the severity dials. A sum past this is reported, not hidden. */
|
|
55
|
+
export const GAUGE_MAX = 500;
|
|
56
|
+
|
|
57
|
+
export interface GaugeReading {
|
|
58
|
+
readonly id: 'verified-exposure' | 'unverified-surface' | 'analysis-coverage';
|
|
59
|
+
readonly label: string;
|
|
60
|
+
/** Where the needle sits. Already clamped to [0, max]. */
|
|
61
|
+
readonly value: number;
|
|
62
|
+
readonly max: number;
|
|
63
|
+
readonly unit: '' | '%';
|
|
64
|
+
/** One line under the needle: what it means, and what it does NOT mean. */
|
|
65
|
+
readonly meaning: string;
|
|
66
|
+
/** The arithmetic. Anyone can recompute the value from these lines. */
|
|
67
|
+
readonly working: readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* The raw total before clamping. Equal to `value` unless the scan blew past
|
|
70
|
+
* the top of the dial, in which case the dial is pinned and this is the
|
|
71
|
+
* truth. A pinned needle that quietly stops counting is a lie by omission.
|
|
72
|
+
*/
|
|
73
|
+
readonly rawTotal: number;
|
|
74
|
+
readonly capped: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* True when there was nothing to measure, as distinct from measuring zero.
|
|
77
|
+
*
|
|
78
|
+
* These are not the same fact and must never be drawn the same way. A scan of
|
|
79
|
+
* an empty file and a scan of ten lines of careful, correctly parameterised
|
|
80
|
+
* code both produced "0 findings, 94% coverage, no blind spots" - identical
|
|
81
|
+
* dashboards for "we checked and found nothing" and "there was nothing to
|
|
82
|
+
* check". The second is a result; the first is the absence of one.
|
|
83
|
+
*/
|
|
84
|
+
readonly noReading: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* What the total is MADE OF, largest severity first.
|
|
87
|
+
*
|
|
88
|
+
* A single needle throws this away, and on real code it throws away almost
|
|
89
|
+
* everything: across the corpus the severity dials sit between 0% and 24% of
|
|
90
|
+
* their scale, so the arc is a stub and the stub is all you get. "75" cannot
|
|
91
|
+
* tell you whether that is three criticals or twenty-five lows, and those are
|
|
92
|
+
* different afternoons.
|
|
93
|
+
*
|
|
94
|
+
* Empty for the coverage gauge, which is a ratio and has no composition.
|
|
95
|
+
*/
|
|
96
|
+
readonly segments: readonly {
|
|
97
|
+
readonly severity: Severity;
|
|
98
|
+
readonly count: number;
|
|
99
|
+
readonly weight: number;
|
|
100
|
+
/** Share of this gauge's own total, 0-1. Zero when the total is zero. */
|
|
101
|
+
readonly share: number;
|
|
102
|
+
}[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ScoreReport {
|
|
106
|
+
readonly gauges: readonly GaugeReading[];
|
|
107
|
+
/**
|
|
108
|
+
* Nothing in this scan could be analysed - no statement, call or assignment
|
|
109
|
+
* ever reached a rule. Every reading below is vacuous when this is true, and
|
|
110
|
+
* any surface showing them has to say so rather than printing zeroes.
|
|
111
|
+
*/
|
|
112
|
+
readonly examinedNothing: boolean;
|
|
113
|
+
/** Code constructs the rules actually looked at. The denominator of trust. */
|
|
114
|
+
readonly shapesExamined: number;
|
|
115
|
+
/**
|
|
116
|
+
* Places this scan is known to have stopped early. Not a gauge, because a
|
|
117
|
+
* count of blind spots is not a measurement of risk - but printed beside the
|
|
118
|
+
* gauges, because it is what tells you how much of the dial to trust.
|
|
119
|
+
*/
|
|
120
|
+
readonly blindSpots: readonly { readonly label: string; readonly count: number }[];
|
|
121
|
+
/**
|
|
122
|
+
* How many attacker-controlled SOURCES the tracer recognised in this scan.
|
|
123
|
+
*
|
|
124
|
+
* Reported ALWAYS, including - especially - when it is zero, which is why it
|
|
125
|
+
* cannot live in blindSpots above: that list drops anything with a count of
|
|
126
|
+
* zero, and zero is the interesting reading here.
|
|
127
|
+
*
|
|
128
|
+
* Elasticsearch is why. 3,999 Java files, a REST API over the network, and
|
|
129
|
+
* zero flow-verified findings. It contains 0 `@RequestParam` and 0
|
|
130
|
+
* `getParameter()` - it has its own REST layer, which the dictionaries do not
|
|
131
|
+
* model, so there was never a source to start a trace from. "0 flows proven"
|
|
132
|
+
* read like good news and was actually "we could not begin".
|
|
133
|
+
*
|
|
134
|
+
* Every flow number in a report is downstream of this one.
|
|
135
|
+
*/
|
|
136
|
+
readonly sourcesFound: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function weigh(findings: readonly Finding[]): { total: number; bySeverity: Map<Severity, number> } {
|
|
140
|
+
const bySeverity = new Map<Severity, number>();
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (const finding of findings) {
|
|
143
|
+
bySeverity.set(finding.severity, (bySeverity.get(finding.severity) ?? 0) + 1);
|
|
144
|
+
total += SEVERITY_WEIGHT[finding.severity];
|
|
145
|
+
}
|
|
146
|
+
return { total, bySeverity };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ORDER: readonly Severity[] = ['critical', 'high', 'medium', 'low', 'info'];
|
|
150
|
+
|
|
151
|
+
function showWorking(bySeverity: Map<Severity, number>, total: number): string[] {
|
|
152
|
+
const lines = ORDER.filter((s) => bySeverity.get(s)).map((s) => {
|
|
153
|
+
const n = bySeverity.get(s) ?? 0;
|
|
154
|
+
return `${n} ${s} x ${SEVERITY_WEIGHT[s]} = ${n * SEVERITY_WEIGHT[s]}`;
|
|
155
|
+
});
|
|
156
|
+
if (lines.length === 0) return ['nothing in this category'];
|
|
157
|
+
if (lines.length > 1) lines.push(`total = ${total}`);
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function severityGauge(
|
|
162
|
+
id: 'verified-exposure' | 'unverified-surface',
|
|
163
|
+
label: string,
|
|
164
|
+
meaning: string,
|
|
165
|
+
findings: readonly Finding[],
|
|
166
|
+
examinedNothing: boolean,
|
|
167
|
+
): GaugeReading {
|
|
168
|
+
const { total, bySeverity } = weigh(findings);
|
|
169
|
+
const working = showWorking(bySeverity, total);
|
|
170
|
+
if (total > GAUGE_MAX) {
|
|
171
|
+
working.push(`past the top of the dial by ${total - GAUGE_MAX} - needle pinned at ${GAUGE_MAX}`);
|
|
172
|
+
}
|
|
173
|
+
const segments = ORDER.filter((s) => bySeverity.get(s)).map((severity) => {
|
|
174
|
+
const count = bySeverity.get(severity) ?? 0;
|
|
175
|
+
const weight = count * SEVERITY_WEIGHT[severity];
|
|
176
|
+
return { severity, count, weight, share: total > 0 ? weight / total : 0 };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
label,
|
|
182
|
+
value: Math.min(total, GAUGE_MAX),
|
|
183
|
+
max: GAUGE_MAX,
|
|
184
|
+
unit: '',
|
|
185
|
+
meaning,
|
|
186
|
+
working,
|
|
187
|
+
rawTotal: total,
|
|
188
|
+
capped: total > GAUGE_MAX,
|
|
189
|
+
noReading: examinedNothing,
|
|
190
|
+
segments,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* How much of the analysis actually completed.
|
|
196
|
+
*
|
|
197
|
+
* This is the gauge no other scanner shows, and it is the one that tells you
|
|
198
|
+
* how to read the other two. A verified-exposure of 0 means something very
|
|
199
|
+
* different at 95% coverage than at 30%: the first is reassuring, the second
|
|
200
|
+
* means we barely looked.
|
|
201
|
+
*
|
|
202
|
+
* It is an average of the ratios we can genuinely compute, and each one is
|
|
203
|
+
* listed in `working` so an average that hides a bad component cannot pass
|
|
204
|
+
* unnoticed. Ratios we cannot compute are OMITTED rather than assumed to be
|
|
205
|
+
* 100% - an unmeasured thing is not a passing thing.
|
|
206
|
+
*/
|
|
207
|
+
function coverageGauge(result: AnalysisResult, filesFound: number, examinedNothing: boolean): GaugeReading {
|
|
208
|
+
const working: string[] = [];
|
|
209
|
+
const parts: number[] = [];
|
|
210
|
+
|
|
211
|
+
const found = filesFound || result.stats.filesParsed;
|
|
212
|
+
if (found > 0) {
|
|
213
|
+
const rate = result.stats.filesParsed / found;
|
|
214
|
+
parts.push(rate);
|
|
215
|
+
working.push(`files read: ${result.stats.filesParsed}/${found} = ${Math.round(rate * 100)}%`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { rules, languages, implemented, partial } = result.coverage.counts;
|
|
219
|
+
const cells = rules * languages;
|
|
220
|
+
if (cells > 0) {
|
|
221
|
+
// A partial rule counts as half. It does something; it does not do the job.
|
|
222
|
+
const rate = (implemented + partial * 0.5) / cells;
|
|
223
|
+
parts.push(rate);
|
|
224
|
+
working.push(
|
|
225
|
+
`rules available: ${implemented} full + ${partial} partial of ${cells} = ${Math.round(rate * 100)}%`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (result.crossFile.enabled) {
|
|
230
|
+
/*
|
|
231
|
+
* A local import that resolved to nothing is a module the analysis never
|
|
232
|
+
* entered. Leaving it out of the mean let a scan that linked 0 of 31 imports
|
|
233
|
+
* read 82%, because no call could even be attempted across the gap.
|
|
234
|
+
*/
|
|
235
|
+
const imports = result.crossFile.importEdges + result.crossFile.importsUnresolved;
|
|
236
|
+
if (imports > 0) {
|
|
237
|
+
const rate = result.crossFile.importEdges / imports;
|
|
238
|
+
parts.push(rate);
|
|
239
|
+
working.push(
|
|
240
|
+
`local imports resolved: ${result.crossFile.importEdges}/${imports} = ${Math.round(rate * 100)}%`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const attempts = result.crossFile.resolved + result.crossFile.ambiguous;
|
|
244
|
+
if (attempts > 0) {
|
|
245
|
+
const rate = result.crossFile.resolved / attempts;
|
|
246
|
+
parts.push(rate);
|
|
247
|
+
working.push(
|
|
248
|
+
`cross-file calls followed: ${result.crossFile.resolved}/${attempts} = ${Math.round(rate * 100)}%`,
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
working.push('cross-file calls followed: none attempted, so not counted');
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
working.push('cross-file resolution was OFF, so it is not counted here');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Stated first, because it is the denominator everything else divides into.
|
|
258
|
+
// "94% of the analysis completed" is not a useful sentence when the analysis
|
|
259
|
+
// had nothing to run on.
|
|
260
|
+
working.unshift(`code constructs examined: ${result.stats.shapesExamined}`);
|
|
261
|
+
|
|
262
|
+
const value = parts.length > 0 ? Math.round((parts.reduce((a, b) => a + b, 0) / parts.length) * 100) : 0;
|
|
263
|
+
if (parts.length > 1) working.push(`mean of ${parts.length} measured ratios = ${value}%`);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
id: 'analysis-coverage',
|
|
267
|
+
label: 'Analysis coverage',
|
|
268
|
+
value,
|
|
269
|
+
max: 100,
|
|
270
|
+
unit: '%',
|
|
271
|
+
meaning: examinedNothing
|
|
272
|
+
? 'Nothing in this scan could be analysed - not one statement, call or ' +
|
|
273
|
+
'assignment reached a rule. There is no coverage figure to give, because ' +
|
|
274
|
+
'there was nothing to cover. This is not a clean result; it is no result.'
|
|
275
|
+
: 'How much of the analysis actually completed. This is not a security score - ' +
|
|
276
|
+
'it is how much weight the other two dials can carry. Low coverage with no ' +
|
|
277
|
+
'findings means we barely looked, not that the code is fine.',
|
|
278
|
+
working,
|
|
279
|
+
rawTotal: value,
|
|
280
|
+
capped: false,
|
|
281
|
+
noReading: examinedNothing,
|
|
282
|
+
// A ratio has no composition to show. Empty rather than faked.
|
|
283
|
+
segments: [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Build all three readings.
|
|
289
|
+
*
|
|
290
|
+
* `filesFound` comes from the walker (Node) or the drop handler (browser), so
|
|
291
|
+
* it is passed in rather than read off the result - the analyzer only ever
|
|
292
|
+
* sees the files that made it that far.
|
|
293
|
+
*/
|
|
294
|
+
export function scoreAnalysis(result: AnalysisResult, filesFound = 0): ScoreReport {
|
|
295
|
+
const verified = result.findings.filter((f) => f.confidence === 'flow-verified');
|
|
296
|
+
const signature = result.findings.filter((f) => f.confidence === 'signature-based');
|
|
297
|
+
// Not one statement, call or assignment reached a rule. Parsing a file is not
|
|
298
|
+
// the same as analysing it: a file of whitespace parses perfectly.
|
|
299
|
+
const examinedNothing = result.stats.shapesExamined === 0;
|
|
300
|
+
|
|
301
|
+
const gauges: GaugeReading[] = [
|
|
302
|
+
severityGauge(
|
|
303
|
+
'verified-exposure',
|
|
304
|
+
'Verified exposure',
|
|
305
|
+
'Severity weight of findings where attacker data was TRACED into the sink. ' +
|
|
306
|
+
'Every point here is backed by a printed path. Zero means no flow was proven - ' +
|
|
307
|
+
'it does not mean none exists.',
|
|
308
|
+
verified,
|
|
309
|
+
examinedNothing,
|
|
310
|
+
),
|
|
311
|
+
severityGauge(
|
|
312
|
+
'unverified-surface',
|
|
313
|
+
'Unverified surface',
|
|
314
|
+
'The same weights over findings we only pattern-matched. Deliberately a ' +
|
|
315
|
+
'separate needle: these may be real or may be false positives, and averaging ' +
|
|
316
|
+
'them into the proven ones would hide which is which.',
|
|
317
|
+
signature,
|
|
318
|
+
examinedNothing,
|
|
319
|
+
),
|
|
320
|
+
coverageGauge(result, filesFound, examinedNothing),
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
// These are SCAN-WIDE counts, not a tally of what appears in the findings
|
|
324
|
+
// above. A trace can pass through an unmodelled function and then never reach
|
|
325
|
+
// a sink, so this number is legitimately larger than the unmodelled hops you
|
|
326
|
+
// can see in the printed paths. Saying "3" while the paths show 2 looks like
|
|
327
|
+
// an error until the label admits which population it is counting.
|
|
328
|
+
const blindSpots = [
|
|
329
|
+
{
|
|
330
|
+
label: 'expressions too deeply nested to walk (generated or minified code)',
|
|
331
|
+
count: result.traceLimits.astTruncations,
|
|
332
|
+
},
|
|
333
|
+
{ label: 'call chains cut at the depth limit', count: result.traceLimits.depthTruncations },
|
|
334
|
+
{ label: 'recursive calls not re-entered', count: result.traceLimits.recursionStops },
|
|
335
|
+
{
|
|
336
|
+
label: 'unmodelled functions stepped through (scan-wide, not only on reported paths)',
|
|
337
|
+
count: result.traceLimits.unmodelledHops,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
label: 'cross-file calls declined as ambiguous',
|
|
341
|
+
count: result.crossFile.enabled ? result.crossFile.ambiguous : 0,
|
|
342
|
+
},
|
|
343
|
+
{ label: 'files that did not fully parse', count: result.parseProblems.length },
|
|
344
|
+
].filter((spot) => spot.count > 0);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
gauges,
|
|
348
|
+
examinedNothing,
|
|
349
|
+
shapesExamined: result.stats.shapesExamined,
|
|
350
|
+
blindSpots,
|
|
351
|
+
sourcesFound: result.traceLimits.sourcesFound,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import type { Finding } from '../core/finding.js';
|
|
5
|
+
|
|
6
|
+
export const ZAP_RULE: Record<string, string> = {
|
|
7
|
+
'sql-injection': '40018',
|
|
8
|
+
'xss': '40012',
|
|
9
|
+
'command-injection': '90020',
|
|
10
|
+
'code-injection': '90019',
|
|
11
|
+
'ssrf': '40046',
|
|
12
|
+
'path-traversal': '6',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface Target {
|
|
16
|
+
id: string;
|
|
17
|
+
rule: string;
|
|
18
|
+
zapRule: string;
|
|
19
|
+
file: string;
|
|
20
|
+
url: string;
|
|
21
|
+
cookie: string;
|
|
22
|
+
from: string;
|
|
23
|
+
param: string;
|
|
24
|
+
trigger?: string; // extra form fields this page needs before its code runs
|
|
25
|
+
untestable?: string; // why ZAP cannot reach this page's code at all
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Where the site runs, where its code sits on disk, and which file is served at which address.
|
|
29
|
+
// Each rule is [pattern, url, cookie?, fields?] on the file path; the first rule that matches wins.
|
|
30
|
+
// A pattern should match the WHOLE path (^...$): the url, cookie and fields are built by replacing what it matched.
|
|
31
|
+
// fields is what the page checks for before its code runs (DVWA's brute page wants Login=Login, not
|
|
32
|
+
// the config-wide trigger), or {untestable: reason} when that check is one the scan cannot pass.
|
|
33
|
+
export type Fields = string | { untestable: string };
|
|
34
|
+
export interface UrlConfig {
|
|
35
|
+
base: string;
|
|
36
|
+
root: string;
|
|
37
|
+
rules: [string, string, string?, Fields?][];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BuiltTargets {
|
|
41
|
+
targets: Target[];
|
|
42
|
+
skipped: number; // proven findings with no ZAP rule or no named request input
|
|
43
|
+
noUrl: number; // proven, testable findings whose file matched no URL rule
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// No matching rule means no URL: a file that is only included by other files has no address of its own.
|
|
47
|
+
function toAddress(
|
|
48
|
+
config: UrlConfig,
|
|
49
|
+
file: string,
|
|
50
|
+
): { url: string; cookie: string; trigger?: string; untestable?: string } | undefined {
|
|
51
|
+
const relative = path.relative(config.root, file).replaceAll('\\', '/');
|
|
52
|
+
for (const [pattern, url, cookie, fields] of config.rules) {
|
|
53
|
+
const rule = new RegExp(pattern);
|
|
54
|
+
if (rule.test(relative)) {
|
|
55
|
+
return {
|
|
56
|
+
url: new URL(relative.replace(rule, url), config.base).href,
|
|
57
|
+
cookie: cookie === undefined ? '' : relative.replace(rule, cookie),
|
|
58
|
+
...(typeof fields === 'string' ? { trigger: relative.replace(rule, fields) } : {}),
|
|
59
|
+
...(typeof fields === 'object' ? { untestable: fields.untestable } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Defuse's proven findings -> the list of addresses ZAP should try. A finding is a target only
|
|
67
|
+
// when it is flow-verified, has a ZAP rule, names a request input, and its file has a URL.
|
|
68
|
+
export function buildTargets(report: { findings: Finding[] }, config: UrlConfig): BuiltTargets {
|
|
69
|
+
const targets: Target[] = [];
|
|
70
|
+
let skipped = 0;
|
|
71
|
+
let noUrl = 0;
|
|
72
|
+
|
|
73
|
+
for (const f of report.findings) {
|
|
74
|
+
if (f.confidence !== 'flow-verified') continue;
|
|
75
|
+
const zapRule = ZAP_RULE[f.ruleId];
|
|
76
|
+
const source = f.flowPath.find((step) => step.kind === 'source');
|
|
77
|
+
const match = source && /\$_(GET|POST|REQUEST|COOKIE)\[\s*['"]([^'"]+)['"]\s*\]/.exec(source.location.snippet);
|
|
78
|
+
if (zapRule === undefined || !match) {
|
|
79
|
+
skipped++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const address = toAddress(config, f.location.file);
|
|
83
|
+
if (address === undefined) {
|
|
84
|
+
noUrl++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
targets.push({
|
|
88
|
+
id: `${f.ruleId}:${f.location.file}:${f.location.startLine}`,
|
|
89
|
+
rule: f.ruleId,
|
|
90
|
+
zapRule,
|
|
91
|
+
file: f.location.file,
|
|
92
|
+
url: address.url,
|
|
93
|
+
cookie: address.cookie,
|
|
94
|
+
from: match[1] ?? '',
|
|
95
|
+
param: match[2] ?? '',
|
|
96
|
+
...(address.trigger !== undefined ? { trigger: address.trigger } : {}),
|
|
97
|
+
...(address.untestable !== undefined ? { untestable: address.untestable } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { targets, skipped, noUrl };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function main(): void {
|
|
105
|
+
const [reportPath, configPath] = process.argv.slice(2);
|
|
106
|
+
if (reportPath === undefined || configPath === undefined) {
|
|
107
|
+
console.error('usage: node dist/src/dast/targets.js report.json urls.json');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { findings: Finding[] };
|
|
111
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8')) as UrlConfig;
|
|
112
|
+
const { targets, skipped, noUrl } = buildTargets(report, config);
|
|
113
|
+
console.log(JSON.stringify(targets, null, 2));
|
|
114
|
+
console.error(
|
|
115
|
+
`${targets.length} targets; ${skipped} proven findings skipped (no ZAP rule or no named request input); ${noUrl} skipped (no URL rule matched the file)`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
120
|
+
main();
|
|
121
|
+
}
|