@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,1072 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ENGINE, WITH NO PLATFORM ATTACHED
|
|
3
|
+
* =====================================
|
|
4
|
+
*
|
|
5
|
+
* The browser UI was promised, from the first day of this project, to run THIS
|
|
6
|
+
* engine rather than a second implementation of it. Two scanners that drift
|
|
7
|
+
* apart is the failure mode: the web one quietly becoming more optimistic than
|
|
8
|
+
* the CLI, or the reverse, and nobody noticing until a finding disagrees with
|
|
9
|
+
* itself.
|
|
10
|
+
*
|
|
11
|
+
* So the analysis lives here and touches nothing platform-specific - no
|
|
12
|
+
* `node:fs`, no `process`, no file walking. It takes text that is already in
|
|
13
|
+
* memory and returns findings.
|
|
14
|
+
*
|
|
15
|
+
* Two thin shells wrap it:
|
|
16
|
+
* src/engine/scan.ts walks a directory and reads files (the CLI)
|
|
17
|
+
* ui/worker.js receives dropped files in a Web Worker (the browser)
|
|
18
|
+
*
|
|
19
|
+
* If either shell ever needs to change how a FINDING is produced, that is the
|
|
20
|
+
* signal that the change belongs in here instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Node } from 'web-tree-sitter';
|
|
24
|
+
import { buildCoverageReport, type CoverageReport } from './coverage.js';
|
|
25
|
+
import {
|
|
26
|
+
flowVerifiedFinding,
|
|
27
|
+
signatureFinding,
|
|
28
|
+
type Finding,
|
|
29
|
+
type Severity,
|
|
30
|
+
SEVERITY_ORDER,
|
|
31
|
+
} from './finding.js';
|
|
32
|
+
import { extractShapes } from '../engine/shapes.js';
|
|
33
|
+
import { nodeLocation } from '../parse/location.js';
|
|
34
|
+
import { initEngine, parseSourceFile, type ParsedFile, type ParseIssue } from '../parse/parser.js';
|
|
35
|
+
import {
|
|
36
|
+
createTreeStore,
|
|
37
|
+
DEFAULT_TREE_BUDGET_BYTES,
|
|
38
|
+
type TreeStoreStats,
|
|
39
|
+
} from '../parse/tree-store.js';
|
|
40
|
+
import type { RuleContext, Shape } from '../rules/contract.js';
|
|
41
|
+
import { isTestPath } from '../rules/lib/strings.js';
|
|
42
|
+
import { getRule, rulesForLanguage, validateRegistry } from '../rules/registry.js';
|
|
43
|
+
import {
|
|
44
|
+
buildProjectIndex,
|
|
45
|
+
importSpecifiers,
|
|
46
|
+
indexFunctions,
|
|
47
|
+
javaTypeFacts,
|
|
48
|
+
type IndexedFileInput,
|
|
49
|
+
type ProjectIndexStats,
|
|
50
|
+
} from '../taint/project.js';
|
|
51
|
+
import { CLEAN_PART, traceFile, type TraceLimits } from '../taint/tracer.js';
|
|
52
|
+
|
|
53
|
+
/** A file's text, already loaded by whichever shell is in charge of loading. */
|
|
54
|
+
export interface SourceFile {
|
|
55
|
+
readonly path: string;
|
|
56
|
+
readonly source: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AnalyzeOptions {
|
|
60
|
+
readonly only?: readonly string[];
|
|
61
|
+
readonly noCrossFile?: boolean;
|
|
62
|
+
readonly onProgress?: (done: number, total: number, file: string) => void;
|
|
63
|
+
/** Absolute path -> the path a human should see. Defaults to unchanged. */
|
|
64
|
+
readonly toDisplay?: (absolutePath: string) => string;
|
|
65
|
+
/**
|
|
66
|
+
* How much WebAssembly heap the scan may spend on syntax trees before it
|
|
67
|
+
* starts freeing the least recently used one and parsing it again on demand.
|
|
68
|
+
* See src/parse/tree-store.ts. Bigger is faster; smaller finishes on machines
|
|
69
|
+
* where the default would be killed.
|
|
70
|
+
*/
|
|
71
|
+
readonly treeBudgetBytes?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FileParseProblem {
|
|
75
|
+
readonly file: string;
|
|
76
|
+
readonly issues: readonly ParseIssue[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Suppression {
|
|
80
|
+
readonly file: string;
|
|
81
|
+
readonly line: number;
|
|
82
|
+
readonly ruleId: string;
|
|
83
|
+
readonly comment: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AnalysisStats {
|
|
87
|
+
readonly filesParsed: number;
|
|
88
|
+
readonly filesSkipped: number;
|
|
89
|
+
readonly shapesExamined: number;
|
|
90
|
+
readonly flowsVerified: number;
|
|
91
|
+
readonly signaturesUpgraded: number;
|
|
92
|
+
readonly signaturesRetracted: number;
|
|
93
|
+
/** Signature guesses a rule withdrew because every spliced value is provably fixed. */
|
|
94
|
+
readonly signaturesProvedConstant: number;
|
|
95
|
+
/** Branches and cases the tracer did not walk because they cannot run. */
|
|
96
|
+
readonly regionsProvedUnreachable: number;
|
|
97
|
+
/** Reads from a local map or list answered by key or position rather than by the whole collection. */
|
|
98
|
+
readonly collectionReadsResolved: number;
|
|
99
|
+
/** Traces ended at a read whose result is new data, not the argument (FreshResultSpec). */
|
|
100
|
+
readonly tracesStoppedAtNewData: number;
|
|
101
|
+
readonly durationMs: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A place where the tracer followed attacker data to a dangerous call and
|
|
106
|
+
* proved a sanitiser covers it. Not a finding - the opposite of one.
|
|
107
|
+
*
|
|
108
|
+
* Until now these were only COUNTED (`signaturesRetracted`). Naming the lines
|
|
109
|
+
* lets a reader see the difference between "we found nothing here" and "we
|
|
110
|
+
* checked here and it is genuinely fine", which are very different facts.
|
|
111
|
+
*/
|
|
112
|
+
/** A branch or case skipped as unreachable, and the decision that made it so. */
|
|
113
|
+
export interface UnreachableCode {
|
|
114
|
+
readonly file: string;
|
|
115
|
+
readonly line: number;
|
|
116
|
+
readonly why: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface VerifiedClean {
|
|
120
|
+
readonly file: string;
|
|
121
|
+
readonly line: number;
|
|
122
|
+
readonly ruleId: string;
|
|
123
|
+
readonly kind: string;
|
|
124
|
+
/**
|
|
125
|
+
* Why the line is clean. A signature rule's reasoned proof (see
|
|
126
|
+
* RuleContext.noteClean), or the tracer's receipt: the step that washed the
|
|
127
|
+
* value for this kind of sink, and the line it was on (see cleanReason in
|
|
128
|
+
* taint/tracer.ts). Optional only for records written before receipts.
|
|
129
|
+
*/
|
|
130
|
+
readonly reason?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Functions Defuse does not model that the cleaned value went through after
|
|
133
|
+
* it was cleaned, each assumed to hand back what it was given. Present only
|
|
134
|
+
* when there were any: a receipt resting on an assumption says so.
|
|
135
|
+
*/
|
|
136
|
+
readonly assumes?: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How one file participates in the results.
|
|
141
|
+
*
|
|
142
|
+
* This exists because of a real way the report could mislead. A finding is
|
|
143
|
+
* reported at its SINK - the line where the damage happens - which is correct:
|
|
144
|
+
* that is where the untrusted value is executed. But in a cross-file flow the
|
|
145
|
+
* sink is often innocent plumbing. Given
|
|
146
|
+
*
|
|
147
|
+
* handler.js:6 runQuery("SELECT ... " + req.query.id) <- the actual bug
|
|
148
|
+
* db.js:4 conn.query(sqlText) <- reported here
|
|
149
|
+
*
|
|
150
|
+
* `db.js` gets the finding and `handler.js` gets nothing. A file list built
|
|
151
|
+
* only from finding locations would show handler.js as unremarkable - and if
|
|
152
|
+
* it also happened to contain a sanitised line, it would show as *green*. The
|
|
153
|
+
* file a developer has to edit would be the one marked safe.
|
|
154
|
+
*
|
|
155
|
+
* So participation is reported explicitly, and a surface that lists files can
|
|
156
|
+
* say "attacker data passes through here" as its own state - neither a finding
|
|
157
|
+
* nor a clean bill.
|
|
158
|
+
*/
|
|
159
|
+
export interface FileRole {
|
|
160
|
+
readonly file: string;
|
|
161
|
+
/** Findings whose reported location is in this file. */
|
|
162
|
+
readonly findingsReported: number;
|
|
163
|
+
/**
|
|
164
|
+
* Findings reported in a DIFFERENT file whose traced path passes through
|
|
165
|
+
* this one. Not a finding here, but emphatically not clean either.
|
|
166
|
+
*/
|
|
167
|
+
readonly onPathOf: number;
|
|
168
|
+
/** Lines here the tracer reached and proved a sanitiser covers. */
|
|
169
|
+
readonly verifiedCleanLines: number;
|
|
170
|
+
/** Lines here that are a hop on some traced path. Ordered, deduplicated. */
|
|
171
|
+
readonly pathLines: readonly number[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface AnalysisResult {
|
|
175
|
+
readonly findings: readonly Finding[];
|
|
176
|
+
/** Lines proven safe by a sanitiser the tracer actually saw run. */
|
|
177
|
+
readonly verifiedClean: readonly VerifiedClean[];
|
|
178
|
+
/** Code the tracer did not walk, because a fixed condition means it cannot run. */
|
|
179
|
+
readonly unreachableCode: readonly UnreachableCode[];
|
|
180
|
+
/** Map and list reads answered by key or position, each with the write that answers it. */
|
|
181
|
+
readonly collectionReadsResolved: readonly UnreachableCode[];
|
|
182
|
+
/**
|
|
183
|
+
* Calls where a trace ended because the function returns new data - a
|
|
184
|
+
* database row, a file's contents - rather than its argument. Not a claim of
|
|
185
|
+
* safety: nothing is cleared by it. See FreshResultSpec in taint/types.ts.
|
|
186
|
+
*/
|
|
187
|
+
readonly tracesStoppedAtNewData: readonly UnreachableCode[];
|
|
188
|
+
/** Per-file participation, so "not listed" is never read as "clean". */
|
|
189
|
+
readonly fileRoles: readonly FileRole[];
|
|
190
|
+
/** Where the tracer ran out, counted for THIS scan rather than disclaimed. */
|
|
191
|
+
readonly traceLimits: TraceLimits;
|
|
192
|
+
readonly stats: AnalysisStats;
|
|
193
|
+
readonly byLanguage: Readonly<Record<string, number>>;
|
|
194
|
+
readonly parseProblems: readonly FileParseProblem[];
|
|
195
|
+
readonly suppressions: readonly Suppression[];
|
|
196
|
+
readonly coverage: CoverageReport;
|
|
197
|
+
readonly crossFile:
|
|
198
|
+
| (ProjectIndexStats & { readonly enabled: true })
|
|
199
|
+
| { readonly enabled: false; readonly reason: string };
|
|
200
|
+
/**
|
|
201
|
+
* What the scan spent on syntax trees, and whether it had to trade time for
|
|
202
|
+
* memory to finish. Reported rather than assumed: a scan that spilled gave
|
|
203
|
+
* the same answers more slowly, and the user is entitled to know which of
|
|
204
|
+
* those two things happened to them.
|
|
205
|
+
*/
|
|
206
|
+
readonly treeMemory: TreeStoreStats;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A line ending in `defuse:ignore <rule-id> - <reason>` suppresses that rule
|
|
211
|
+
* on that line. Suppressions are COUNTED AND LISTED in the report: silencing a
|
|
212
|
+
* finding is a decision someone made, and the report should show it, not erase it.
|
|
213
|
+
*/
|
|
214
|
+
const SUPPRESSION = /defuse:ignore\s+([a-z0-9-]+)(?:\s*[-:]\s*(.*))?/i;
|
|
215
|
+
|
|
216
|
+
function suppressionOnLine(
|
|
217
|
+
sourceLines: readonly string[],
|
|
218
|
+
line: number,
|
|
219
|
+
): { ruleId: string; comment: string } | null {
|
|
220
|
+
// Check the finding's own line and the line above it - both are common.
|
|
221
|
+
for (const candidate of [line - 1, line - 2]) {
|
|
222
|
+
const text = sourceLines[candidate];
|
|
223
|
+
if (!text) continue;
|
|
224
|
+
const match = SUPPRESSION.exec(text);
|
|
225
|
+
if (match?.[1]) return { ruleId: match[1], comment: (match[2] ?? '').trim() };
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* `if '../' in bar: return` before the open. BenchmarkPython marks seven such
|
|
232
|
+
* cases safe and the first run proved all seven. A substring test is read
|
|
233
|
+
* from the text of the function above the sink, in every spelling this
|
|
234
|
+
* project scans: `'..' in x`, `.includes('..')`, `.contains("..")`,
|
|
235
|
+
* `strings.Contains(x, "..")`, `strpos($x, '..')`.
|
|
236
|
+
*/
|
|
237
|
+
const DOT_DOT_CHECK =
|
|
238
|
+
/(["'])\.\.[\\/]*\1\s+(?:not\s+)?in\b|\b(?:includes|indexOf|contains|Contains|strpos|stripos|str_contains)\s*\([^()]*?(["'])\.\.[\\/]*\2|\.(?:startsWith|startswith)\s*\(|\b(?:HasPrefix|str_starts_with)\s*\(/;
|
|
239
|
+
// The last two alternatives are the containment check - resolve the path, then
|
|
240
|
+
// `startsWith(base)` - which is the right fix, done right or not (Juice Shop's
|
|
241
|
+
// /infrastructure route does it right). What it compares is not read.
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* `if (snippet == null) { res.status(404)... return }` before the open. Juice
|
|
245
|
+
* Shop's code-challenge routes look the key up first and answer 404 for one
|
|
246
|
+
* that is not listed, so only listed names reach the read - and both were
|
|
247
|
+
* proven, wrongly. An `if` that answers "not found" on its own line or the next.
|
|
248
|
+
*/
|
|
249
|
+
const NOT_FOUND_EXIT =
|
|
250
|
+
/\bif\b[^\n]*(?:\n[^\n]*)?(?:\b(?:status|sendStatus|abort|http_response_code)\s*\(\s*404\b|\bHttp404\b|\bhttp\.(?:NotFound|StatusNotFound)\b)/;
|
|
251
|
+
|
|
252
|
+
/** The `..` check or not-found exit in the function before this sink, as written, or undefined. */
|
|
253
|
+
function dotDotCheckBefore(sink: Node): string | undefined {
|
|
254
|
+
// ponytail: any such test above the sink in the function counts, whatever
|
|
255
|
+
// variable it reads and whatever it does on failure. It only ever turns a
|
|
256
|
+
// proof into "to review", never clears a line, so a loose match costs a proof.
|
|
257
|
+
let fn: Node | null = sink.parent;
|
|
258
|
+
while (fn && !/function|method|lambda|arrow/.test(fn.type)) fn = fn.parent;
|
|
259
|
+
if (!fn) return undefined;
|
|
260
|
+
const before = (fn.text ?? '').slice(0, sink.startIndex - fn.startIndex);
|
|
261
|
+
return (DOT_DOT_CHECK.exec(before) ?? NOT_FOUND_EXIT.exec(before))?.[0].replace(/\s+/g, ' ');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Findings at the same rule+file+line are the same finding reported twice. */
|
|
265
|
+
function dedupeKey(finding: Finding): string {
|
|
266
|
+
return `${finding.ruleId}:${finding.location.file}:${finding.location.startLine}:${finding.location.startColumn}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function analyze(
|
|
270
|
+
inputs: readonly SourceFile[],
|
|
271
|
+
options: AnalyzeOptions = {},
|
|
272
|
+
): Promise<AnalysisResult> {
|
|
273
|
+
const started = Date.now();
|
|
274
|
+
|
|
275
|
+
// Refuse to run at all if a rule breaks the honesty contract.
|
|
276
|
+
validateRegistry();
|
|
277
|
+
await initEngine();
|
|
278
|
+
|
|
279
|
+
const findings: Finding[] = [];
|
|
280
|
+
const seenFindings = new Set<string>();
|
|
281
|
+
// Verified flows are collected separately so that, at the end, we can let a
|
|
282
|
+
// confirmed finding SUPERSEDE the unverified guess at the same place rather
|
|
283
|
+
// than reporting the same bug twice at two different confidence levels.
|
|
284
|
+
const flowFindings: Finding[] = [];
|
|
285
|
+
const seenFlows = new Set<string>();
|
|
286
|
+
const flowRanges: Array<{ ruleId: string; file: string; start: number; end: number }> = [];
|
|
287
|
+
const cleanRanges: Array<{ ruleId: string; file: string; start: number; end: number }> = [];
|
|
288
|
+
const verifiedClean: VerifiedClean[] = [];
|
|
289
|
+
const unreachableCode: UnreachableCode[] = [];
|
|
290
|
+
const collectionReadsResolved: UnreachableCode[] = [];
|
|
291
|
+
const tracesStoppedAtNewData: UnreachableCode[] = [];
|
|
292
|
+
/** One receipt per line per rule, however many shapes asked the same question. */
|
|
293
|
+
const provedConstantKeys = new Set<string>();
|
|
294
|
+
/** Summed across every file, so the report can say where the trace ran out. */
|
|
295
|
+
const traceLimits = {
|
|
296
|
+
astTruncations: 0, depthTruncations: 0, recursionStops: 0, unmodelledHops: 0,
|
|
297
|
+
sourcesFound: 0,
|
|
298
|
+
};
|
|
299
|
+
const suppressions: Suppression[] = [];
|
|
300
|
+
const parseProblems: FileParseProblem[] = [];
|
|
301
|
+
const byLanguage: Record<string, number> = {};
|
|
302
|
+
|
|
303
|
+
let filesParsed = 0;
|
|
304
|
+
let filesSkipped = 0;
|
|
305
|
+
let shapesExamined = 0;
|
|
306
|
+
|
|
307
|
+
const only = options.only && options.only.length > 0 ? new Set(options.only) : null;
|
|
308
|
+
|
|
309
|
+
/* ---------------------------------------------------------------------- *
|
|
310
|
+
* PASS 1 - parse everything, index it, and hand the tree to the store.
|
|
311
|
+
*
|
|
312
|
+
* Analysis cannot begin until the import graph exists, and the import graph
|
|
313
|
+
* cannot exist until every file has been read, so this remains two passes.
|
|
314
|
+
* What CHANGED is what survives in between.
|
|
315
|
+
*
|
|
316
|
+
* It used to be every syntax tree in the project, all at once. A tree lives
|
|
317
|
+
* in WebAssembly memory that the garbage collector cannot reclaim, and
|
|
318
|
+
* nothing ever called delete(), so a scan cost about sixty times the size of
|
|
319
|
+
* the source it was scanning - a hard ceiling around 44 MB of source on an
|
|
320
|
+
* 8 GB machine, past which the operating system killed the process.
|
|
321
|
+
*
|
|
322
|
+
* The comment that used to sit here blamed cross-file tracing and pointed at
|
|
323
|
+
* --no-cross-file as the escape hatch. Measured, that was simply untrue:
|
|
324
|
+
* cross-file OFF used 535.8 MB against 528.8 MB with it ON. The trees were
|
|
325
|
+
* never freed in either mode.
|
|
326
|
+
*
|
|
327
|
+
* Now what survives PASS 1 is a RECORD per file - its text, its parse
|
|
328
|
+
* problems, its functions as byte offsets, its import specifiers as strings -
|
|
329
|
+
* and the tree goes to the store, which keeps as many as the budget allows
|
|
330
|
+
* and re-parses the rest on demand. See src/parse/tree-store.ts.
|
|
331
|
+
* ---------------------------------------------------------------------- */
|
|
332
|
+
const store = createTreeStore(options.treeBudgetBytes ?? DEFAULT_TREE_BUDGET_BYTES);
|
|
333
|
+
|
|
334
|
+
/** Everything about a parsed file that does NOT keep its tree alive. */
|
|
335
|
+
interface FileRecord extends IndexedFileInput {
|
|
336
|
+
readonly parsed: Omit<ParsedFile, 'tree' | 'root'>;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const fileRecords: FileRecord[] = [];
|
|
340
|
+
for (let index = 0; index < inputs.length; index++) {
|
|
341
|
+
const input = inputs[index];
|
|
342
|
+
if (!input) continue;
|
|
343
|
+
options.onProgress?.(index + 1, inputs.length * 2, input.path);
|
|
344
|
+
|
|
345
|
+
const outcome = await parseSourceFile(input.path, input.source);
|
|
346
|
+
if (outcome.status === 'skipped') {
|
|
347
|
+
filesSkipped++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const { tree, root, ...rest } = outcome.file;
|
|
351
|
+
const languageId = outcome.file.language.id;
|
|
352
|
+
|
|
353
|
+
// Everything the index needs is read out HERE, while the tree is in hand.
|
|
354
|
+
// After this line nothing but the store may hold it.
|
|
355
|
+
fileRecords.push({
|
|
356
|
+
path: outcome.file.path,
|
|
357
|
+
language: languageId,
|
|
358
|
+
functions: indexFunctions(root, languageId),
|
|
359
|
+
importSpecifiers: importSpecifiers(root, languageId),
|
|
360
|
+
javaTypes: languageId === 'java' ? javaTypeFacts(root) : undefined,
|
|
361
|
+
parsed: rest,
|
|
362
|
+
});
|
|
363
|
+
store.admit(outcome.file.path, outcome.file.source, outcome.file.grammar, tree);
|
|
364
|
+
|
|
365
|
+
filesParsed++;
|
|
366
|
+
byLanguage[languageId] = (byLanguage[languageId] ?? 0) + 1;
|
|
367
|
+
if (outcome.file.parseIssues.length > 0) {
|
|
368
|
+
parseProblems.push({ file: input.path, issues: outcome.file.parseIssues });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// How an absolute path should be shown to a human. The CLI shortens paths
|
|
373
|
+
// against the working directory; a browser has no working directory, so it
|
|
374
|
+
// passes them straight through. Everything downstream uses whatever this says.
|
|
375
|
+
const toDisplay = options.toDisplay ?? ((absolutePath: string): string => absolutePath);
|
|
376
|
+
|
|
377
|
+
/* ---------------------------------------------------------------------- *
|
|
378
|
+
* PASS 2 - build the import graph, then analyse.
|
|
379
|
+
* ---------------------------------------------------------------------- */
|
|
380
|
+
/**
|
|
381
|
+
* The cross-file ceiling, which is now about the INDEX rather than the trees.
|
|
382
|
+
*
|
|
383
|
+
* It used to be justified by "cross-file analysis needs every syntax tree in
|
|
384
|
+
* memory at once, and a tree costs roughly ten times its source" - two claims,
|
|
385
|
+
* both wrong. The trees were held whether or not cross-file was on, and the
|
|
386
|
+
* cost is nearer sixty times than ten. What the index genuinely holds is a
|
|
387
|
+
* name, a parameter list and two integers per function, which is a few
|
|
388
|
+
* megabytes on a project of this size.
|
|
389
|
+
*
|
|
390
|
+
* The limit stays because an index over an enormous project is still real
|
|
391
|
+
* memory and a resolution over it is still real time, and because a stated
|
|
392
|
+
* limit that fires is better than an unstated one that kills the process. It
|
|
393
|
+
* is now what it always should have been: a threshold with a reason, not a
|
|
394
|
+
* consequence of a leak.
|
|
395
|
+
*/
|
|
396
|
+
const MAX_CROSS_FILE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
397
|
+
const totalSourceBytes = fileRecords.reduce((sum, f) => sum + f.parsed.source.length, 0);
|
|
398
|
+
const tooBig = totalSourceBytes > MAX_CROSS_FILE_SOURCE_BYTES;
|
|
399
|
+
|
|
400
|
+
const crossFileEnabled = !options.noCrossFile && !tooBig;
|
|
401
|
+
const crossFileOffReason = options.noCrossFile
|
|
402
|
+
? 'cross-file resolution was switched off'
|
|
403
|
+
: `project is ${(totalSourceBytes / 1048576).toFixed(0)}MB of source, over the ` +
|
|
404
|
+
`${MAX_CROSS_FILE_SOURCE_BYTES / 1048576}MB ceiling for indexing every function in the project`;
|
|
405
|
+
const projectIndex = crossFileEnabled ? buildProjectIndex(fileRecords, store) : undefined;
|
|
406
|
+
|
|
407
|
+
// A finding can now land in a file other than the one being scanned, so the
|
|
408
|
+
// suppression check needs any file's lines on demand. Splitting every source
|
|
409
|
+
// eagerly doubled peak memory on a large tree for data most scans never read,
|
|
410
|
+
// so it is computed lazily and cached.
|
|
411
|
+
const sourceByDisplayPath = new Map<string, string>();
|
|
412
|
+
for (const record of fileRecords) {
|
|
413
|
+
sourceByDisplayPath.set(toDisplay(record.path), record.parsed.source);
|
|
414
|
+
}
|
|
415
|
+
const lineCache = new Map<string, string[]>();
|
|
416
|
+
const linesOf = (displayPath: string): string[] => {
|
|
417
|
+
const cached = lineCache.get(displayPath);
|
|
418
|
+
if (cached) return cached;
|
|
419
|
+
const lines = (sourceByDisplayPath.get(displayPath) ?? '').split('\n');
|
|
420
|
+
lineCache.set(displayPath, lines);
|
|
421
|
+
return lines;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
for (let index = 0; index < fileRecords.length; index++) {
|
|
425
|
+
const record = fileRecords[index];
|
|
426
|
+
if (!record) continue;
|
|
427
|
+
const filePath = record.path;
|
|
428
|
+
options.onProgress?.(inputs.length + index + 1, inputs.length * 2, filePath);
|
|
429
|
+
|
|
430
|
+
/* ---- the tree comes back here, and is pinned for this iteration ----
|
|
431
|
+
*
|
|
432
|
+
* Usually it was never gone: the store holds everything that fits in the
|
|
433
|
+
* budget, so a normal project parses each file exactly once and this is a
|
|
434
|
+
* lookup. On a project too big for the budget it is a re-parse, which costs
|
|
435
|
+
* time the old code did not spend - and the old code, on a project that
|
|
436
|
+
* size, did not finish at all.
|
|
437
|
+
*
|
|
438
|
+
* The pin is what makes it safe. Every Node produced below points into this
|
|
439
|
+
* tree's memory; freeing it while a rule or the tracer still holds one would
|
|
440
|
+
* not raise an error, it would read whatever moved in afterwards. */
|
|
441
|
+
store.pin(filePath);
|
|
442
|
+
const tree = store.get(filePath);
|
|
443
|
+
const file: ParsedFile = { ...record.parsed, tree, root: tree.rootNode };
|
|
444
|
+
|
|
445
|
+
const displayPath = toDisplay(filePath);
|
|
446
|
+
const sourceLines = linesOf(displayPath);
|
|
447
|
+
|
|
448
|
+
const context: RuleContext = {
|
|
449
|
+
file,
|
|
450
|
+
language: file.language.id,
|
|
451
|
+
text: (node: Node) => (node.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
452
|
+
noteClean: (ruleId: string, node: Node, reason: string) => {
|
|
453
|
+
const line = node.startPosition.row + 1;
|
|
454
|
+
const key = `${displayPath}:${line}:${ruleId}`;
|
|
455
|
+
if (provedConstantKeys.has(key)) return;
|
|
456
|
+
provedConstantKeys.add(key);
|
|
457
|
+
verifiedClean.push({ file: displayPath, line, ruleId, kind: 'constant', reason });
|
|
458
|
+
},
|
|
459
|
+
staticMethod: projectIndex
|
|
460
|
+
? (receiver: string, method: string, supertypes?: readonly string[], allowInstance?: boolean) => {
|
|
461
|
+
const hit = projectIndex.staticMethod(filePath, receiver, method, supertypes, allowInstance);
|
|
462
|
+
return hit && { path: toDisplay(hit.path), method: hit.method };
|
|
463
|
+
}
|
|
464
|
+
: undefined,
|
|
465
|
+
javaTypeInScan: projectIndex ? (name: string) => projectIndex.javaTypeInScan(name) : undefined,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const shapes = await extractShapes(file, file.language.id);
|
|
469
|
+
shapesExamined += shapes.length;
|
|
470
|
+
|
|
471
|
+
const rules = rulesForLanguage(file.language.id).filter((r) => !only || only.has(r.id));
|
|
472
|
+
|
|
473
|
+
for (const shape of shapes) {
|
|
474
|
+
for (const rule of rules) {
|
|
475
|
+
if (!rule.shapes.includes(shape.kind as Shape['kind'])) continue;
|
|
476
|
+
|
|
477
|
+
let hit;
|
|
478
|
+
try {
|
|
479
|
+
hit = rule.check(shape, context);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
// A crashing rule must not take the scan down, and must not be silent.
|
|
482
|
+
parseProblems.push({
|
|
483
|
+
file: filePath,
|
|
484
|
+
issues: [
|
|
485
|
+
{
|
|
486
|
+
kind: 'ERROR',
|
|
487
|
+
line: shape.node.startPosition.row + 1,
|
|
488
|
+
column: shape.node.startPosition.column + 1,
|
|
489
|
+
text: `rule '${rule.id}' threw: ${(error as Error).message}`.slice(0, 120),
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (!hit) continue;
|
|
496
|
+
|
|
497
|
+
const location = nodeLocation(hit.node, displayPath);
|
|
498
|
+
|
|
499
|
+
const suppressed = suppressionOnLine(sourceLines, location.startLine);
|
|
500
|
+
if (suppressed && (suppressed.ruleId === rule.id || suppressed.ruleId === 'all')) {
|
|
501
|
+
suppressions.push({
|
|
502
|
+
file: displayPath,
|
|
503
|
+
line: location.startLine,
|
|
504
|
+
ruleId: rule.id,
|
|
505
|
+
comment: suppressed.comment || '(no reason given)',
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/*
|
|
511
|
+
* TEST-FILE SEVERITY, DECIDED IN ONE PLACE FOR EVERY RULE.
|
|
512
|
+
*
|
|
513
|
+
* This policy used to live inside hardcoded-secret alone, which meant a
|
|
514
|
+
* fake password in a test dropped to low while an `eval()` in the file
|
|
515
|
+
* next to it stayed critical. Scanning pandas made the cost visible: 41
|
|
516
|
+
* critical findings, 33 of them inside the test suite, burying the eight
|
|
517
|
+
* in shipped code that were the actual answer.
|
|
518
|
+
*
|
|
519
|
+
* The reason to discount a test file is not that the finding is wrong -
|
|
520
|
+
* `eval()` in a test IS Python's eval - it is that the attacker position
|
|
521
|
+
* does not exist. Nobody reaches your test suite over the network. So
|
|
522
|
+
* the finding is kept, always, and only its rank changes.
|
|
523
|
+
*
|
|
524
|
+
* Doing it here rather than in each rule means a rule added next year
|
|
525
|
+
* inherits it without anyone remembering to.
|
|
526
|
+
*
|
|
527
|
+
* NOT APPLIED TO FLOW-VERIFIED FINDINGS, deliberately. isTestPath is a
|
|
528
|
+
* guess about a path, and it is wrong sometimes in the expensive
|
|
529
|
+
* direction: pandas SHIPS `pandas/_testing/`, and plenty of projects
|
|
530
|
+
* ship an `examples/` directory that real users copy from. A traced
|
|
531
|
+
* source-to-sink path is the strongest evidence this tool produces, and
|
|
532
|
+
* a filename pattern is not good enough grounds to quiet it.
|
|
533
|
+
*/
|
|
534
|
+
const inTestFile = isTestPath(file.path);
|
|
535
|
+
const testRank = (s: Severity): Severity =>
|
|
536
|
+
!inTestFile ? s : s === 'critical' ? 'medium' : s === 'high' ? 'low' : s;
|
|
537
|
+
|
|
538
|
+
const finding = signatureFinding({
|
|
539
|
+
ruleId: rule.id,
|
|
540
|
+
ruleName: rule.name,
|
|
541
|
+
cwe: rule.cwe,
|
|
542
|
+
owasp: rule.owasp,
|
|
543
|
+
severity: testRank(hit.severity ?? rule.severity),
|
|
544
|
+
message: hit.message,
|
|
545
|
+
location,
|
|
546
|
+
reasoning:
|
|
547
|
+
hit.reasoning +
|
|
548
|
+
(inTestFile
|
|
549
|
+
? ' NOTE: this file looks like a test, fixture or example. The finding is ' +
|
|
550
|
+
'accurate about the code, but a test suite is not reachable by an attacker, ' +
|
|
551
|
+
'so severity is lowered rather than the finding dropped - test code does get ' +
|
|
552
|
+
'copied into production, and this is still in git history either way.'
|
|
553
|
+
: ''),
|
|
554
|
+
limitations: hit.limitations ?? rule.limitations,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const key = dedupeKey(finding);
|
|
558
|
+
if (seenFindings.has(key)) continue;
|
|
559
|
+
seenFindings.add(key);
|
|
560
|
+
findings.push(finding);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* ------------------------------------------------------------------ *
|
|
565
|
+
* PHASE 3a: data-flow verification.
|
|
566
|
+
*
|
|
567
|
+
* The signature pass above asked "does this code have the SHAPE of a bug?".
|
|
568
|
+
* This pass asks the harder question: "did attacker-controlled data
|
|
569
|
+
* actually get here?" Only this pass may produce flow-verified findings,
|
|
570
|
+
* and only because it can hand over the path that proves it.
|
|
571
|
+
* ------------------------------------------------------------------ */
|
|
572
|
+
const trace = traceFile(file, file.language.id, projectIndex, toDisplay);
|
|
573
|
+
traceLimits.astTruncations += trace.limits.astTruncations;
|
|
574
|
+
traceLimits.depthTruncations += trace.limits.depthTruncations;
|
|
575
|
+
traceLimits.recursionStops += trace.limits.recursionStops;
|
|
576
|
+
traceLimits.unmodelledHops += trace.limits.unmodelledHops;
|
|
577
|
+
traceLimits.sourcesFound += trace.limits.sourcesFound;
|
|
578
|
+
|
|
579
|
+
for (const read of trace.resolvedReads) {
|
|
580
|
+
collectionReadsResolved.push({
|
|
581
|
+
file: read.filePath || displayPath,
|
|
582
|
+
line: read.node.startPosition.row + 1,
|
|
583
|
+
why: read.why,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
for (const stop of trace.freshStops) {
|
|
587
|
+
tracesStoppedAtNewData.push({
|
|
588
|
+
file: stop.filePath || displayPath,
|
|
589
|
+
line: stop.node.startPosition.row + 1,
|
|
590
|
+
why: stop.why,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
for (const region of trace.unreachable) {
|
|
594
|
+
unreachableCode.push({
|
|
595
|
+
file: region.filePath || displayPath,
|
|
596
|
+
line: region.node.startPosition.row + 1,
|
|
597
|
+
why: region.why,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Lines where a tainted value reached a sink but was properly sanitised.
|
|
602
|
+
// The signature pass cannot see a sanitiser, so it guessed; the tracer can,
|
|
603
|
+
// so the guess is withdrawn rather than left to nag.
|
|
604
|
+
for (const clean of trace.sanitized) {
|
|
605
|
+
verifiedClean.push({
|
|
606
|
+
file: clean.filePath || displayPath,
|
|
607
|
+
line: clean.sinkNode.startPosition.row + 1,
|
|
608
|
+
ruleId: clean.ruleId,
|
|
609
|
+
kind: clean.kind,
|
|
610
|
+
reason: clean.reason,
|
|
611
|
+
...(clean.assumes ? { assumes: clean.assumes } : {}),
|
|
612
|
+
});
|
|
613
|
+
cleanRanges.push({
|
|
614
|
+
ruleId: clean.ruleId,
|
|
615
|
+
file: clean.filePath || displayPath,
|
|
616
|
+
start: clean.sinkNode.startPosition.row + 1,
|
|
617
|
+
end: clean.sinkNode.endPosition.row + 1,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Supersede the signature pass everywhere this flow touched. */
|
|
622
|
+
const recordFlowRanges = (
|
|
623
|
+
f: (typeof trace.flows)[number],
|
|
624
|
+
file: string,
|
|
625
|
+
): void => {
|
|
626
|
+
flowRanges.push({
|
|
627
|
+
ruleId: f.ruleId,
|
|
628
|
+
file,
|
|
629
|
+
start: f.sinkNode.startPosition.row + 1,
|
|
630
|
+
end: f.sinkNode.endPosition.row + 1,
|
|
631
|
+
});
|
|
632
|
+
for (const hop of f.path) {
|
|
633
|
+
flowRanges.push({
|
|
634
|
+
ruleId: f.ruleId,
|
|
635
|
+
file: hop.location.file,
|
|
636
|
+
start: hop.location.startLine,
|
|
637
|
+
end: hop.location.endLine,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
for (const flow of trace.flows) {
|
|
643
|
+
if (only && !only.has(flow.ruleId)) continue;
|
|
644
|
+
const rule = getRule(flow.ruleId);
|
|
645
|
+
if (!rule) continue;
|
|
646
|
+
|
|
647
|
+
// The sink may be in a different file from the one we are scanning.
|
|
648
|
+
const flowFile = flow.filePath || displayPath;
|
|
649
|
+
const location = nodeLocation(flow.node, flowFile);
|
|
650
|
+
const suppressed = suppressionOnLine(linesOf(flowFile), location.startLine);
|
|
651
|
+
if (suppressed && (suppressed.ruleId === flow.ruleId || suppressed.ruleId === 'all')) {
|
|
652
|
+
suppressions.push({
|
|
653
|
+
file: flowFile,
|
|
654
|
+
line: location.startLine,
|
|
655
|
+
ruleId: flow.ruleId,
|
|
656
|
+
comment: suppressed.comment || '(no reason given)',
|
|
657
|
+
});
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const hops = flow.path.length;
|
|
662
|
+
const unmodelled = [...new Set(flow.unknownHops)];
|
|
663
|
+
const unmodelledNote =
|
|
664
|
+
unmodelled.length > 0
|
|
665
|
+
? ` THIS PATH PASSES THROUGH ${unmodelled.length} FUNCTION(S) WE DO NOT MODEL ` +
|
|
666
|
+
`(${unmodelled.map((n) => `\`${n}()\``).join(', ')}). We assumed each one preserves ` +
|
|
667
|
+
`the value. If any of them sanitises it, this finding is wrong - check those hops first.`
|
|
668
|
+
: '';
|
|
669
|
+
/*
|
|
670
|
+
* A BRANCH WAS ASSUMED, SO THIS IS A GUESS - AND IT IS STILL REPORTED.
|
|
671
|
+
*
|
|
672
|
+
* The tracer keeps taint through a clean write it cannot prove happens,
|
|
673
|
+
* which is deliberately cautious and right: it errs toward reporting a
|
|
674
|
+
* value that might be clean rather than staying silent about one that
|
|
675
|
+
* might be dirty. What it may not do is hand that assumption to
|
|
676
|
+
* flowVerifiedFinding(), which prints "this is not a pattern guess - the
|
|
677
|
+
* data flow was followed". At that point nothing was followed.
|
|
678
|
+
*
|
|
679
|
+
* Django found it. In contrib/admin/options.py a tainted `msg` is set in
|
|
680
|
+
* an `except` block and a clean literal `msg` is set in an `elif` forty
|
|
681
|
+
* lines later; the sink is reachable only through the second. Seven
|
|
682
|
+
* correct-looking hops, across two files, proving a flow that cannot run.
|
|
683
|
+
*
|
|
684
|
+
* DROPPING IT WAS THE WRONG FIX, and the benchmark said so within a
|
|
685
|
+
* minute: recall fell 89.3% to 75.5%, because 89 real vulnerabilities in
|
|
686
|
+
* BenchmarkJava sit behind exactly this shape and the signature rules did
|
|
687
|
+
* not cover those lines. Silence about a real bug is the failure this
|
|
688
|
+
* scanner cannot detect in itself.
|
|
689
|
+
*
|
|
690
|
+
* So the finding survives with the label it has earned. The path is still
|
|
691
|
+
* printed - a reader can check the branch themselves - and the reasoning
|
|
692
|
+
* says which assumption it rests on.
|
|
693
|
+
*/
|
|
694
|
+
/*
|
|
695
|
+
* THE SECOND REASON A TRACE CANNOT CALL ITSELF PROVEN.
|
|
696
|
+
*
|
|
697
|
+
* branchAssumed is "we did not evaluate the branch". containerGuess is
|
|
698
|
+
* "we did not track which element". Both are the same failure in the same
|
|
699
|
+
* place - the engine wrote its own imprecision into the path and then
|
|
700
|
+
* labelled the result proven anyway - so both get the same treatment: the
|
|
701
|
+
* finding survives, the path is still printed, and the label tells the
|
|
702
|
+
* truth about what was and was not followed.
|
|
703
|
+
*
|
|
704
|
+
* Measured on OWASP BenchmarkJava: flow-verified paths that collect into a
|
|
705
|
+
* container scored 50.5% precision against 71.5% for paths that do not.
|
|
706
|
+
* Half the bucket was a coin flip wearing a certificate.
|
|
707
|
+
*/
|
|
708
|
+
/*
|
|
709
|
+
* THE THIRD: ESCAPING THAT DEPENDS ON QUOTES WE COULD NOT SEE.
|
|
710
|
+
*
|
|
711
|
+
* The value went through an escape-only function (mysqli_real_escape_string,
|
|
712
|
+
* escapeSql) and was put into the query by something this engine does not
|
|
713
|
+
* read - sprintf, a builder. Inside quotes that escaping is a defence;
|
|
714
|
+
* outside, none. Clearing the line would claim the first, proving it would
|
|
715
|
+
* claim the second, and neither was seen. So it is reported as a guess.
|
|
716
|
+
*/
|
|
717
|
+
/*
|
|
718
|
+
* THE FOURTH: A REQUEST THAT CANNOT GO WHERE THE ATTACKER CHOOSES.
|
|
719
|
+
*
|
|
720
|
+
* Server-side request forgery is the attacker picking the server a
|
|
721
|
+
* request goes to. When the value sits after a fixed start of the
|
|
722
|
+
* address, or the code runs in the user's own browser, that choice is
|
|
723
|
+
* not theirs. An AI review of DVWA's proven SSRF called all eight wrong
|
|
724
|
+
* for the first reason, and Juice Shop's Angular services were four more
|
|
725
|
+
* for the second. What is left - part of a path, a request from a
|
|
726
|
+
* browser - can still matter, so it is reported, as what it is.
|
|
727
|
+
*/
|
|
728
|
+
if (flow.fixedDestination && !flow.branchAssumed && !flow.containerGuess && !flow.quoteUnknown) {
|
|
729
|
+
const shown = flow.fixedDestination.before.split(CLEAN_PART).join('...');
|
|
730
|
+
const browser = flow.fixedDestination.kind === 'browser';
|
|
731
|
+
const downgraded = signatureFinding({
|
|
732
|
+
ruleId: rule.id,
|
|
733
|
+
ruleName: rule.name,
|
|
734
|
+
cwe: rule.cwe,
|
|
735
|
+
owasp: rule.owasp,
|
|
736
|
+
severity: browser ? 'low' : 'medium',
|
|
737
|
+
message: browser
|
|
738
|
+
? `Data from \`${flow.origin}\` steers a request the user's own browser makes - not server-side request forgery`
|
|
739
|
+
: `Data from \`${flow.origin}\` sets part of the path of a request whose address is fixed (\`${shown}\`)`,
|
|
740
|
+
location,
|
|
741
|
+
reasoning: browser
|
|
742
|
+
? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but that source only exists ` +
|
|
743
|
+
`in a web browser, so this code runs in the user's own browser and the request is sent by the ` +
|
|
744
|
+
`user's browser, not by a server. Server-side request forgery needs a server to make the ` +
|
|
745
|
+
`request. Someone who can set this value - through a crafted link, or a script already on the ` +
|
|
746
|
+
`page - can still point the user's own requests somewhere else; check whether that matters here.`
|
|
747
|
+
: `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but only AFTER a fixed start ` +
|
|
748
|
+
`of the address: \`${shown}\` (\`...\` is a value from the code, not from the request). The ` +
|
|
749
|
+
`request still goes where the code says, so this is not a request to a server the attacker picks. ` +
|
|
750
|
+
`What the attacker does control is the rest of the path or query: with \`../\` or an encoded ` +
|
|
751
|
+
`slash it may reach another endpoint on the same server, or another file for a file read. ` +
|
|
752
|
+
`Worth a look, not a proof of server-side request forgery.`,
|
|
753
|
+
limitations: rule.limitations,
|
|
754
|
+
});
|
|
755
|
+
const key = dedupeKey(downgraded);
|
|
756
|
+
if (!seenFlows.has(key)) {
|
|
757
|
+
seenFlows.add(key);
|
|
758
|
+
flowFindings.push(downgraded);
|
|
759
|
+
recordFlowRanges(flow, flowFile);
|
|
760
|
+
}
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
/*
|
|
764
|
+
* A FILE THE PERSON RUNNING THE PROGRAM NAMED.
|
|
765
|
+
*
|
|
766
|
+
* `open(sys.argv[1])` is what a command-line tool is for. Whoever typed
|
|
767
|
+
* the path can already read that file, so proving "path traversal" there
|
|
768
|
+
* would be a confident wrong answer about every CLI and game mod written.
|
|
769
|
+
* It matters only when the program runs with more rights than the person
|
|
770
|
+
* starting it, which this engine cannot see - so it is a question to
|
|
771
|
+
* review, at low severity, not a proof.
|
|
772
|
+
*/
|
|
773
|
+
const dotDotCheck = flow.kind === 'path' ? dotDotCheckBefore(flow.sinkNode) : undefined;
|
|
774
|
+
if ((flow.localSource || dotDotCheck) && !flow.branchAssumed && !flow.containerGuess && !flow.quoteUnknown) {
|
|
775
|
+
const downgraded = signatureFinding({
|
|
776
|
+
ruleId: rule.id,
|
|
777
|
+
ruleName: rule.name,
|
|
778
|
+
cwe: rule.cwe,
|
|
779
|
+
owasp: rule.owasp,
|
|
780
|
+
severity: flow.localSource ? 'low' : rule.severity,
|
|
781
|
+
message: flow.localSource
|
|
782
|
+
? `A file path from \`${flow.origin}\` - chosen by whoever runs this program - is opened`
|
|
783
|
+
: `A file path from \`${flow.origin}\` is opened after a check on it`,
|
|
784
|
+
location,
|
|
785
|
+
reasoning: flow.localSource
|
|
786
|
+
? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but that source is the ` +
|
|
787
|
+
`program's own command line, environment or terminal. The person who supplies it can usually ` +
|
|
788
|
+
`read that file already, so this is not path traversal by itself. It is one when the program ` +
|
|
789
|
+
`runs with more rights than that person: a setuid binary, a service, a CGI program reading its ` +
|
|
790
|
+
`environment, or a script run on other people's input. Check how this program is started.`
|
|
791
|
+
: /starts?with|HasPrefix|starts_with/i.test(dotDotCheck ?? '')
|
|
792
|
+
? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, and a prefix check ` +
|
|
793
|
+
`(\`${dotDotCheck}\`) runs first. Resolving the path and then checking it still starts with ` +
|
|
794
|
+
`the intended folder is the right fix, so this is not proven - but what the check compares is ` +
|
|
795
|
+
`not read here. Confirm it tests the RESOLVED path against the folder, with a separator after ` +
|
|
796
|
+
`it (\`/srv/files\` also prefixes \`/srv/files-old\`).`
|
|
797
|
+
: NOT_FOUND_EXIT.test(dotDotCheck ?? '')
|
|
798
|
+
? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but a "not found" answer ` +
|
|
799
|
+
`(\`${dotDotCheck}\`) can end the request first. If that comes from looking the name up in a ` +
|
|
800
|
+
`fixed list, only listed names reach the open and \`../\` never does - but what the lookup ` +
|
|
801
|
+
`accepts is not read here. Confirm it matches whole names exactly, or keep only the file name.`
|
|
802
|
+
: `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, and \`${dotDotCheck}\` ` +
|
|
803
|
+
`runs first. A check for \`../\` stops the usual climb, so this is not proven. It is not a fix ` +
|
|
804
|
+
`either: it misses \`..\\\` on Windows, a path with no folder in front of it, and a bare ` +
|
|
805
|
+
`trailing \`..\`. Confirm what the check rejects, or keep only the file name instead.`,
|
|
806
|
+
limitations: rule.limitations,
|
|
807
|
+
});
|
|
808
|
+
const key = dedupeKey(downgraded);
|
|
809
|
+
if (!seenFlows.has(key)) {
|
|
810
|
+
seenFlows.add(key);
|
|
811
|
+
flowFindings.push(downgraded);
|
|
812
|
+
recordFlowRanges(flow, flowFile);
|
|
813
|
+
}
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
/*
|
|
817
|
+
* THE FIFTH: A HOP WHOSE OWN NAME SAYS IT CLEANS THE VALUE.
|
|
818
|
+
*
|
|
819
|
+
* An unmodelled function is assumed to pass the value through - right for
|
|
820
|
+
* decode(), and a coin flip for escape_for_html(). BenchmarkPython runs
|
|
821
|
+
* every printed value through its own HTML escaper (helpers/utils.py),
|
|
822
|
+
* and 60 Flask routes were "proven" XSS through it. A name is no proof
|
|
823
|
+
* that it cleans either, so the finding stays - as a possibility.
|
|
824
|
+
*/
|
|
825
|
+
// b64decode(b64encode(x)) is x again: an encode undone later cleaned nothing.
|
|
826
|
+
// The decode may be a function we DO model (Java's decodeBase64), so look
|
|
827
|
+
// for it in the path itself, after the step that encoded.
|
|
828
|
+
const undone = (n: string): boolean => {
|
|
829
|
+
const inverse = n.replace(/encode/i, 'decode').replace(/escape/i, 'unescape');
|
|
830
|
+
const at = flow.path.findIndex((s) => s.location.snippet.includes(`${n}(`));
|
|
831
|
+
return inverse !== n && at >= 0 && flow.path.slice(at + 1).some((s) => s.location.snippet.includes(`${inverse}(`));
|
|
832
|
+
};
|
|
833
|
+
// An HTML escaper is no defence for pickle or a shell, so it only counts for XSS.
|
|
834
|
+
const cleanByName = unmodelled.find((n) => /(^|[^n])(escape|encode)|saniti[sz]|clean|purif|strip_?tags/i.test(n) && !undone(n) && (rule.id === 'xss' || !/html/i.test(n)));
|
|
835
|
+
if (flow.branchAssumed || flow.containerGuess || flow.quoteUnknown || cleanByName) {
|
|
836
|
+
const downgraded = signatureFinding({
|
|
837
|
+
ruleId: rule.id,
|
|
838
|
+
ruleName: rule.name,
|
|
839
|
+
cwe: rule.cwe,
|
|
840
|
+
owasp: rule.owasp,
|
|
841
|
+
severity: rule.severity,
|
|
842
|
+
message: `Possibly attacker-controlled data from \`${flow.origin}\` reaches ${flow.sinkDescription}`,
|
|
843
|
+
location,
|
|
844
|
+
reasoning: flow.quoteUnknown && !flow.branchAssumed && !flow.containerGuess
|
|
845
|
+
? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription} after an escaping ` +
|
|
846
|
+
`function that only protects a value placed INSIDE QUOTES in the query. The query ` +
|
|
847
|
+
`is put together somewhere this engine does not read, so whether the value sits ` +
|
|
848
|
+
`inside quotes is not known. If it does, this line is safe; if it does not, it is ` +
|
|
849
|
+
`SQL injection. Check the quotes around it.`
|
|
850
|
+
: cleanByName && !flow.branchAssumed && !flow.containerGuess
|
|
851
|
+
? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but on the way it passes ` +
|
|
852
|
+
`through \`${cleanByName}()\`, a function this engine does not model and whose name says it ` +
|
|
853
|
+
`escapes or cleans its input. We could not check that it does, so this is not proven; if it ` +
|
|
854
|
+
`does the right escaping for this sink, this line is safe. Read \`${cleanByName}()\` first.`
|
|
855
|
+
: flow.branchAssumed
|
|
856
|
+
? `A value from \`${flow.origin}\` can reach ${flow.sinkDescription}, but the ` +
|
|
857
|
+
`path depends on a BRANCH THIS ENGINE DOES NOT EVALUATE: somewhere along it a ` +
|
|
858
|
+
`clean assignment sits inside an if/else, and we keep the taint rather than ` +
|
|
859
|
+
`assume the clean write always happens. That is the cautious reading, not a ` +
|
|
860
|
+
`proven one - if the two writes are in mutually exclusive branches, this path ` +
|
|
861
|
+
`cannot actually run. Check the branch before acting on it.`
|
|
862
|
+
: `A value from \`${flow.origin}\` can reach ${flow.sinkDescription}, but the ` +
|
|
863
|
+
`path runs through a COLLECTION THAT TOOK MORE THAN ONE ELEMENT. We taint a ` +
|
|
864
|
+
`container as a whole and do not model which index or key comes back out, so ` +
|
|
865
|
+
`the value printed here may be one of the clean ones that went in. The dirty ` +
|
|
866
|
+
`value really is in that container - this is worth checking - but the trace ` +
|
|
867
|
+
`does not prove it is the one that arrives.`,
|
|
868
|
+
limitations: rule.limitations,
|
|
869
|
+
});
|
|
870
|
+
const key = dedupeKey(downgraded);
|
|
871
|
+
if (!seenFlows.has(key)) {
|
|
872
|
+
seenFlows.add(key);
|
|
873
|
+
flowFindings.push(downgraded);
|
|
874
|
+
/*
|
|
875
|
+
* RECORD THE RANGES EVEN THOUGH NOTHING WAS PROVEN.
|
|
876
|
+
*
|
|
877
|
+
* A downgraded flow and the signature rule that fired on the same
|
|
878
|
+
* line are ONE bug described twice, exactly as in the verified case,
|
|
879
|
+
* and before this the downgraded branch skipped straight past the
|
|
880
|
+
* bookkeeping - so branch-assumed.py has been reporting line 46
|
|
881
|
+
* twice, once from the rule and once from the tracer, for as long as
|
|
882
|
+
* branchAssumed has existed. It was invisible because so few lines hit
|
|
883
|
+
* both paths; containerGuess routes 183 more BenchmarkJava cases
|
|
884
|
+
* through here and would have made it loud.
|
|
885
|
+
*
|
|
886
|
+
* The tracer's version wins because it carries the path: same
|
|
887
|
+
* confidence, strictly more for a reader to check.
|
|
888
|
+
*/
|
|
889
|
+
recordFlowRanges(flow, flowFile);
|
|
890
|
+
}
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const finding = flowVerifiedFinding({
|
|
895
|
+
unmodelledHops: flow.unknownHops,
|
|
896
|
+
ruleId: rule.id,
|
|
897
|
+
ruleName: rule.name,
|
|
898
|
+
cwe: rule.cwe,
|
|
899
|
+
owasp: rule.owasp,
|
|
900
|
+
severity: rule.severity,
|
|
901
|
+
message: `Attacker-controlled data from \`${flow.origin}\` reaches ${flow.sinkDescription}`,
|
|
902
|
+
location,
|
|
903
|
+
reasoning:
|
|
904
|
+
`Traced ${hops} step${hops === 1 ? '' : 's'}${
|
|
905
|
+
new Set(flow.path.map((step) => step.location.file)).size > 1
|
|
906
|
+
? ' across more than one file'
|
|
907
|
+
: ''
|
|
908
|
+
}: the value starts at ` +
|
|
909
|
+
`\`${flow.origin}\`, which is attacker-controlled, and arrives at ${flow.sinkDescription} ` +
|
|
910
|
+
`with no sanitiser for a ${flow.kind} sink applied on the way. Every hop is listed below. ` +
|
|
911
|
+
`This is not a pattern guess - the data flow was followed.`,
|
|
912
|
+
limitations:
|
|
913
|
+
'FLOW-VERIFIED: the value was followed hop by hop, across function boundaries and - ' +
|
|
914
|
+
'where imports resolve - across files. Only files included in this scan are resolved: ' +
|
|
915
|
+
'a call into a third-party package, a dynamic import, or a name several modules define ' +
|
|
916
|
+
'all end the trace. The tracer identifies sources by expression shape (a `req.query`-' +
|
|
917
|
+
'looking expression is assumed to be a real request), reads statements in order without ' +
|
|
918
|
+
'evaluating branch conditions, and does not track the interior of objects. It confirms ' +
|
|
919
|
+
'the data flow, not exploitability in production - authentication, a WAF or ' +
|
|
920
|
+
'framework-level escaping may still stand in the way.' + unmodelledNote,
|
|
921
|
+
flowPath: flow.path,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const key = dedupeKey(finding);
|
|
925
|
+
if (seenFlows.has(key)) continue;
|
|
926
|
+
seenFlows.add(key);
|
|
927
|
+
flowFindings.push(finding);
|
|
928
|
+
// Record EVERY line the verified flow passes through, not just the sink.
|
|
929
|
+
// `const sql = "..." + userId;` on one line and `db.query(sql)` on the
|
|
930
|
+
// next are one bug, and the signature pass reports the first line while
|
|
931
|
+
// the tracer reports the second. Superseding along the whole path stops
|
|
932
|
+
// the same bug appearing twice at two confidence levels.
|
|
933
|
+
flowRanges.push({
|
|
934
|
+
ruleId: flow.ruleId,
|
|
935
|
+
file: flowFile,
|
|
936
|
+
start: flow.sinkNode.startPosition.row + 1,
|
|
937
|
+
end: flow.sinkNode.endPosition.row + 1,
|
|
938
|
+
});
|
|
939
|
+
for (const hop of flow.path) {
|
|
940
|
+
flowRanges.push({
|
|
941
|
+
ruleId: flow.ruleId,
|
|
942
|
+
file: hop.location.file,
|
|
943
|
+
start: hop.location.startLine,
|
|
944
|
+
end: hop.location.endLine,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* ---- the only safe place to let trees go ----
|
|
950
|
+
*
|
|
951
|
+
* Everything above has finished with this file: the rules have run, the
|
|
952
|
+
* tracer has returned, and every finding has been copied into plain data
|
|
953
|
+
* (paths, line numbers, text) rather than nodes. No Node from this file or
|
|
954
|
+
* from any file the tracer descended into is reachable any more, so the
|
|
955
|
+
* pins can be dropped and the store can reclaim whatever it needs to.
|
|
956
|
+
*
|
|
957
|
+
* Releasing anywhere INSIDE the loop body would be a use-after-free waiting
|
|
958
|
+
* for a big enough project to expose it. */
|
|
959
|
+
store.releaseAll();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Findings are plain data by now; nothing below reads a syntax node.
|
|
963
|
+
const treeStoreStats = store.stats();
|
|
964
|
+
store.disposeAll();
|
|
965
|
+
|
|
966
|
+
/* -------------------------------------------------------------------- *
|
|
967
|
+
* MERGE. A signature finding at a place we have now verified is not a
|
|
968
|
+
* second bug - it is the same bug, guessed at. The verified one wins and
|
|
969
|
+
* the guess is dropped. Everything the tracer could NOT confirm stays
|
|
970
|
+
* exactly as it was, still labelled signature-based. That is the whole
|
|
971
|
+
* point: precision where we earned it, recall everywhere else, and a
|
|
972
|
+
* report that shows you the split.
|
|
973
|
+
* -------------------------------------------------------------------- */
|
|
974
|
+
const supersededBy = (finding: Finding): boolean =>
|
|
975
|
+
flowRanges.some(
|
|
976
|
+
(range) =>
|
|
977
|
+
range.ruleId === finding.ruleId &&
|
|
978
|
+
range.file === finding.location.file &&
|
|
979
|
+
finding.location.startLine >= range.start &&
|
|
980
|
+
finding.location.startLine <= range.end,
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
const provenClean = (finding: Finding): boolean =>
|
|
984
|
+
cleanRanges.some(
|
|
985
|
+
(range) =>
|
|
986
|
+
range.ruleId === finding.ruleId &&
|
|
987
|
+
range.file === finding.location.file &&
|
|
988
|
+
finding.location.startLine >= range.start &&
|
|
989
|
+
finding.location.startLine <= range.end,
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
const afterUpgrade = findings.filter((f) => !supersededBy(f));
|
|
993
|
+
const signaturesUpgraded = findings.length - afterUpgrade.length;
|
|
994
|
+
const survivingSignatures = afterUpgrade.filter((f) => !provenClean(f));
|
|
995
|
+
const signaturesRetracted = afterUpgrade.length - survivingSignatures.length;
|
|
996
|
+
const allFindings: Finding[] = [...flowFindings, ...survivingSignatures];
|
|
997
|
+
|
|
998
|
+
allFindings.sort((a, b) => {
|
|
999
|
+
const bySeverity = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
|
|
1000
|
+
if (bySeverity !== 0) return bySeverity;
|
|
1001
|
+
const byFile = a.location.file.localeCompare(b.location.file);
|
|
1002
|
+
if (byFile !== 0) return byFile;
|
|
1003
|
+
return a.location.startLine - b.location.startLine;
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// Participation is derived here, once, from the finished finding list - so
|
|
1007
|
+
// the terminal and the browser cannot disagree about which files a flow
|
|
1008
|
+
// touches, and neither one has to work it out for itself.
|
|
1009
|
+
const roles = new Map<string, { reported: number; via: number; clean: number; lines: Set<number> }>();
|
|
1010
|
+
const roleFor = (file: string) => {
|
|
1011
|
+
let entry = roles.get(file);
|
|
1012
|
+
if (!entry) {
|
|
1013
|
+
entry = { reported: 0, via: 0, clean: 0, lines: new Set() };
|
|
1014
|
+
roles.set(file, entry);
|
|
1015
|
+
}
|
|
1016
|
+
return entry;
|
|
1017
|
+
};
|
|
1018
|
+
for (const finding of allFindings) {
|
|
1019
|
+
roleFor(finding.location.file).reported++;
|
|
1020
|
+
for (const file of new Set((finding.flowPath ?? []).map((s) => s.location.file))) {
|
|
1021
|
+
// Counted only when the finding is reported SOMEWHERE ELSE - otherwise
|
|
1022
|
+
// every flow-verified finding would also mark its own file as "on path",
|
|
1023
|
+
// which says nothing.
|
|
1024
|
+
if (file !== finding.location.file) roleFor(file).via++;
|
|
1025
|
+
}
|
|
1026
|
+
for (const step of finding.flowPath ?? []) {
|
|
1027
|
+
roleFor(step.location.file).lines.add(step.location.startLine);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
for (const clean of verifiedClean) roleFor(clean.file).clean++;
|
|
1031
|
+
|
|
1032
|
+
const fileRoles: FileRole[] = [...roles.entries()]
|
|
1033
|
+
.map(([file, entry]) => ({
|
|
1034
|
+
file,
|
|
1035
|
+
findingsReported: entry.reported,
|
|
1036
|
+
onPathOf: entry.via,
|
|
1037
|
+
verifiedCleanLines: entry.clean,
|
|
1038
|
+
pathLines: [...entry.lines].sort((a, b) => a - b),
|
|
1039
|
+
}))
|
|
1040
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
findings: allFindings,
|
|
1044
|
+
verifiedClean,
|
|
1045
|
+
unreachableCode,
|
|
1046
|
+
collectionReadsResolved,
|
|
1047
|
+
tracesStoppedAtNewData,
|
|
1048
|
+
fileRoles,
|
|
1049
|
+
traceLimits,
|
|
1050
|
+
stats: {
|
|
1051
|
+
filesParsed,
|
|
1052
|
+
filesSkipped,
|
|
1053
|
+
shapesExamined,
|
|
1054
|
+
flowsVerified: flowFindings.length,
|
|
1055
|
+
signaturesUpgraded,
|
|
1056
|
+
signaturesRetracted,
|
|
1057
|
+
signaturesProvedConstant: provedConstantKeys.size,
|
|
1058
|
+
regionsProvedUnreachable: unreachableCode.length,
|
|
1059
|
+
collectionReadsResolved: collectionReadsResolved.length,
|
|
1060
|
+
tracesStoppedAtNewData: tracesStoppedAtNewData.length,
|
|
1061
|
+
durationMs: Date.now() - started,
|
|
1062
|
+
},
|
|
1063
|
+
byLanguage,
|
|
1064
|
+
parseProblems,
|
|
1065
|
+
suppressions,
|
|
1066
|
+
crossFile: projectIndex
|
|
1067
|
+
? { enabled: true as const, ...projectIndex.stats() }
|
|
1068
|
+
: { enabled: false as const, reason: crossFileOffReason },
|
|
1069
|
+
treeMemory: treeStoreStats,
|
|
1070
|
+
coverage: buildCoverageReport(),
|
|
1071
|
+
};
|
|
1072
|
+
}
|