@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,811 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HUMAN-READABLE REPORT
|
|
3
|
+
*
|
|
4
|
+
* Design brief: someone should be able to skim this in a terminal and know
|
|
5
|
+
* three things without reading carefully -
|
|
6
|
+
* 1. where the problem is,
|
|
7
|
+
* 2. how bad it would be,
|
|
8
|
+
* 3. HOW SURE WE ARE.
|
|
9
|
+
*
|
|
10
|
+
* Point 3 is the one other tools bury. It gets its own column here, on every
|
|
11
|
+
* single line, and it gets a summary block that states the count of unverified
|
|
12
|
+
* findings in plain words. If this report ever looks more confident than the
|
|
13
|
+
* engine is, the report is wrong.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { SEVERITY_ORDER, type Finding, type FlowStep, type Severity } from '../core/finding.js';
|
|
17
|
+
import type { CoverageReport } from '../core/coverage.js';
|
|
18
|
+
import { scoreAnalysis } from '../core/score.js';
|
|
19
|
+
import type { ScanResult } from '../engine/scan.js';
|
|
20
|
+
import { RUNTIME_VERSION_RULE } from '../platform/runtime-version.js';
|
|
21
|
+
import { DEPENDENCY_RULE } from '../platform/dependency-rule.js';
|
|
22
|
+
import { getRule } from '../rules/registry.js';
|
|
23
|
+
import { color, g, wrapText } from './colors.js';
|
|
24
|
+
|
|
25
|
+
const WIDTH = 92;
|
|
26
|
+
|
|
27
|
+
const SEVERITY_STYLE: Record<Severity, (text: string) => string> = {
|
|
28
|
+
critical: (t) => color.bold(color.red(t)),
|
|
29
|
+
high: (t) => color.red(t),
|
|
30
|
+
medium: (t) => color.yellow(t),
|
|
31
|
+
low: (t) => color.blue(t),
|
|
32
|
+
info: (t) => color.gray(t),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function severityLabel(severity: Severity): string {
|
|
36
|
+
return SEVERITY_STYLE[severity](severity.toUpperCase().padEnd(8));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function heading(title: string): string {
|
|
40
|
+
const line = g('rule').repeat(Math.max(0, WIDTH - title.length - 3));
|
|
41
|
+
return color.dim(`\n${g('rule')}${g('rule')} ${color.bold(title)} ${line}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HumanReportOptions {
|
|
45
|
+
readonly showSuppressed?: boolean;
|
|
46
|
+
readonly showCoverageMatrix?: boolean;
|
|
47
|
+
readonly minSeverity?: Severity;
|
|
48
|
+
/**
|
|
49
|
+
* Print everything: the gauge arithmetic, each finding's reasoning and
|
|
50
|
+
* limits, OWASP and the full "not checked" list. Off by default because a
|
|
51
|
+
* first-time tester read 200 lines for one finding and called it
|
|
52
|
+
* overwhelming. Nothing is dropped - the short report names every gap in a
|
|
53
|
+
* line and points here, and --json and --report always carry all of it.
|
|
54
|
+
*/
|
|
55
|
+
readonly full?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* What the scan was actually pointed at, as resolved from the command line.
|
|
58
|
+
*
|
|
59
|
+
* This is here because of a real misread. `npm run scan --exclude tests`
|
|
60
|
+
* looks like "scan everything except tests"; npm swallows `--exclude` as one
|
|
61
|
+
* of its own config flags and forwards only `tests`, so the tool scanned the
|
|
62
|
+
* fixture folder and NOTHING else. The report was correct - 75 findings in
|
|
63
|
+
* files written to be vulnerable - and completely misleading, because the one
|
|
64
|
+
* fact that would have explained it was the one fact not printed.
|
|
65
|
+
*
|
|
66
|
+
* A report that says how much it examined but never says WHAT is describing a
|
|
67
|
+
* measurement without its units.
|
|
68
|
+
*/
|
|
69
|
+
readonly target?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Directory names that mean "these findings are probably meant to be here". */
|
|
73
|
+
const FIXTURE_DIRECTORY = /(^|[/\\])(tests?|fixtures?|specs?|__tests__|testdata|examples?)([/\\]|$)/i;
|
|
74
|
+
|
|
75
|
+
export function renderHuman(result: ScanResult, options: HumanReportOptions = {}): string {
|
|
76
|
+
const out: string[] = [];
|
|
77
|
+
const min = options.minSeverity ? SEVERITY_ORDER[options.minSeverity] : -1;
|
|
78
|
+
const shown = result.findings.filter((f) => SEVERITY_ORDER[f.severity] >= min);
|
|
79
|
+
const hiddenBySeverity = result.findings.length - shown.length;
|
|
80
|
+
|
|
81
|
+
/* ---------------- header ---------------- */
|
|
82
|
+
out.push('');
|
|
83
|
+
out.push(
|
|
84
|
+
`${color.bold('Defuse')} ${color.dim(`v${result.coverage.engine.version}`)} ` +
|
|
85
|
+
color.bgYellow(color.bold(` ${result.coverage.engine.analysisLabel} `)),
|
|
86
|
+
);
|
|
87
|
+
out.push(color.dim(` target: ${result.target}`));
|
|
88
|
+
|
|
89
|
+
/* ---------------- gauges ----------------
|
|
90
|
+
* Same module the browser calls, so the terminal cannot show a different
|
|
91
|
+
* number from the web page for the same scan. Rendered as bars rather than
|
|
92
|
+
* dials for obvious reasons, but the READING is identical. */
|
|
93
|
+
const score = scoreAnalysis(result, result.stats.filesFound);
|
|
94
|
+
const full = options.full ?? false;
|
|
95
|
+
const present = Object.keys(result.byLanguage).sort();
|
|
96
|
+
out.push(heading('Summary'));
|
|
97
|
+
if (!score.examinedNothing) {
|
|
98
|
+
/*
|
|
99
|
+
* Plain sentences first. The gauges below are exact but a tester read
|
|
100
|
+
* "82%/100%" next to "64%" next to "mean of 2 measured ratios" and could
|
|
101
|
+
* not say what any of them meant. These lines use the same counts and say
|
|
102
|
+
* them in words; --full keeps the arithmetic.
|
|
103
|
+
*/
|
|
104
|
+
const flowCount = result.findings.filter((f) => f.confidence === 'flow-verified').length;
|
|
105
|
+
const guessCount = result.findings.length - flowCount;
|
|
106
|
+
out.push(
|
|
107
|
+
` ${color.red('proven ')} ${color.bold(String(flowCount))} ` +
|
|
108
|
+
color.dim('attacker input was traced into a dangerous call - every step is printed'),
|
|
109
|
+
);
|
|
110
|
+
out.push(
|
|
111
|
+
` ${color.yellow('possible')} ${color.bold(String(guessCount))} ` +
|
|
112
|
+
color.dim('risky-looking code, but the input was not traced - check these by hand'),
|
|
113
|
+
);
|
|
114
|
+
out.push(
|
|
115
|
+
` ${'read'.padEnd(8)} ${result.stats.filesParsed} of ` +
|
|
116
|
+
`${result.stats.filesFound || result.stats.filesParsed} file(s)`,
|
|
117
|
+
);
|
|
118
|
+
for (const id of present) {
|
|
119
|
+
const cells = result.coverage.matrix.filter((c) => c.language === id);
|
|
120
|
+
const count = (s: string) => cells.filter((c) => c.status === s).length;
|
|
121
|
+
const deep = result.coverage.taint.find((t) => t.language === id)?.implemented ?? false;
|
|
122
|
+
out.push(
|
|
123
|
+
` ${'checked'.padEnd(8)} ${id}: ${count('implemented')} of ${cells.length} bug types fully, ` +
|
|
124
|
+
`${count('partial')} partly, ${count('not-implemented')} not at all` +
|
|
125
|
+
color.dim(deep ? ' - with data-flow tracing' : ' - pattern matching only, no data-flow tracing'),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (full && !score.examinedNothing) out.push(heading('Readings'));
|
|
130
|
+
if (score.examinedNothing) {
|
|
131
|
+
// Printed INSTEAD of the dials, not above them. Zeroes shown next to this
|
|
132
|
+
// warning would still be read as zeroes.
|
|
133
|
+
out.push(
|
|
134
|
+
` ${color.yellow(color.bold('NO READING.'))} ` +
|
|
135
|
+
color.dim(
|
|
136
|
+
wrapText(
|
|
137
|
+
'The files parsed, but not one statement, call or assignment reached a ' +
|
|
138
|
+
'rule - there was no code here to analyse. Every number below would be ' +
|
|
139
|
+
'the absence of a result, not a result, so they are withheld. Check you ' +
|
|
140
|
+
'pointed the scan at the right path.',
|
|
141
|
+
WIDTH - 4,
|
|
142
|
+
' ',
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
} else if (full) {
|
|
147
|
+
for (const reading of score.gauges) {
|
|
148
|
+
const filled = Math.round((reading.value / reading.max) * 28);
|
|
149
|
+
const bar = `${g('dotFull').repeat(filled)}${g('dotEmpty').repeat(28 - filled)}`;
|
|
150
|
+
const tone =
|
|
151
|
+
reading.id === 'verified-exposure'
|
|
152
|
+
? color.red
|
|
153
|
+
: reading.id === 'unverified-surface'
|
|
154
|
+
? color.yellow
|
|
155
|
+
: color.green;
|
|
156
|
+
out.push(
|
|
157
|
+
` ${reading.label.padEnd(20)} ${tone(bar)} ` +
|
|
158
|
+
`${color.bold(String(reading.value) + reading.unit)}${color.dim(`/${reading.max}${reading.unit}`)}` +
|
|
159
|
+
(reading.capped ? color.yellow(` PINNED - real total ${reading.rawTotal}`) : ''),
|
|
160
|
+
);
|
|
161
|
+
out.push(color.dim(` ${' '.repeat(20)} ${reading.working.join(' ' + g('middot') + ' ')}`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (full && !score.examinedNothing) {
|
|
165
|
+
out.push('');
|
|
166
|
+
out.push(
|
|
167
|
+
color.dim(
|
|
168
|
+
` ${wrapText(
|
|
169
|
+
'These three are never added together. Severity and confidence are ' +
|
|
170
|
+
'different questions, and a single blended risk number cannot answer ' +
|
|
171
|
+
'either one. Coverage tells you how much weight the other two carry.',
|
|
172
|
+
WIDTH - 4,
|
|
173
|
+
' ',
|
|
174
|
+
)}`,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
/*
|
|
179
|
+
* SOURCES RECOGNISED - printed whatever the number, and loudest at zero.
|
|
180
|
+
*
|
|
181
|
+
* A scan that found no sources cannot produce a traced finding no matter how
|
|
182
|
+
* good the tracer is, and "0 flow-verified" reads like a clean bill until you
|
|
183
|
+
* know that. Elasticsearch's 3,999 Java files produced exactly this shape.
|
|
184
|
+
*/
|
|
185
|
+
out.push('');
|
|
186
|
+
if (score.sourcesFound === 0 && result.stats.filesParsed >= 25) {
|
|
187
|
+
out.push(
|
|
188
|
+
color.yellow(
|
|
189
|
+
` No attacker-controlled sources were recognised in ${result.stats.filesParsed} files.`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
out.push(
|
|
193
|
+
color.dim(
|
|
194
|
+
` Every flow-verified finding starts at a source, so none could be produced here.`,
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
out.push(
|
|
198
|
+
color.dim(
|
|
199
|
+
` Either this code takes no external input, or it uses a framework we do not`,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
out.push(
|
|
203
|
+
color.dim(
|
|
204
|
+
` model - sources are per-framework, and only the ones listed in the coverage`,
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
out.push(color.dim(` section are recognised. Read "0 proven" as "could not begin".`));
|
|
208
|
+
} else {
|
|
209
|
+
out.push(
|
|
210
|
+
color.dim(
|
|
211
|
+
` ${score.sourcesFound} attacker-controlled source(s) recognised - every traced finding starts at one of these.`,
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (score.blindSpots.length > 0) {
|
|
216
|
+
out.push('');
|
|
217
|
+
out.push(color.dim(` Where this scan stopped early:`));
|
|
218
|
+
for (const spot of score.blindSpots) {
|
|
219
|
+
out.push(color.dim(` ${g('middot')} ${spot.count} ${spot.label}`));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ---------------- findings ---------------- */
|
|
224
|
+
if (shown.length === 0) {
|
|
225
|
+
out.push(heading('Findings'));
|
|
226
|
+
out.push(` ${color.green('No findings from the rules that ran.')}`);
|
|
227
|
+
out.push(
|
|
228
|
+
color.dim(
|
|
229
|
+
` ${wrapText(
|
|
230
|
+
'This is NOT a clean bill of health. It means the checks listed under ' +
|
|
231
|
+
'"What this scan did not check" were not triggered. Read that section.',
|
|
232
|
+
WIDTH - 4,
|
|
233
|
+
' ',
|
|
234
|
+
)}`,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
out.push(heading(`Findings (${shown.length})`));
|
|
239
|
+
let lastFile = '';
|
|
240
|
+
for (const finding of shown) {
|
|
241
|
+
if (finding.location.file !== lastFile) {
|
|
242
|
+
lastFile = finding.location.file;
|
|
243
|
+
out.push(`\n ${color.underline(color.cyan(lastFile))}`);
|
|
244
|
+
}
|
|
245
|
+
out.push(renderFinding(finding, full));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ---------------- the honesty block ----------------
|
|
250
|
+
* The short report states both counts, with their meanings, in the summary
|
|
251
|
+
* at the top; this block is the long form. */
|
|
252
|
+
if (full) {
|
|
253
|
+
out.push(heading('Confidence'));
|
|
254
|
+
const signature = result.findings.filter((f) => f.confidence === 'signature-based').length;
|
|
255
|
+
const flow = result.findings.filter((f) => f.confidence === 'flow-verified').length;
|
|
256
|
+
out.push(
|
|
257
|
+
` ${color.yellow('signature-based')} ${String(signature).padStart(4)} ` +
|
|
258
|
+
color.dim('pattern matched in the syntax tree; data flow NOT traced'),
|
|
259
|
+
);
|
|
260
|
+
out.push(
|
|
261
|
+
` ${color.green('flow-verified')} ${String(flow).padStart(4)} ` +
|
|
262
|
+
color.dim('attacker data traced into the sink; every hop shown above'),
|
|
263
|
+
);
|
|
264
|
+
out.push('');
|
|
265
|
+
out.push(
|
|
266
|
+
` ${wrapText(
|
|
267
|
+
result.findings.length === 0
|
|
268
|
+
? score.examinedNothing
|
|
269
|
+
? 'Both counts are zero because nothing was analysed, not because nothing ' +
|
|
270
|
+
'was found. A file that parses but contains no code produces exactly this ' +
|
|
271
|
+
'output, and it means the scan had no subject - not that the subject is clean.'
|
|
272
|
+
: `Nothing matched, out of ${result.stats.shapesExamined} code shape(s) actually ` +
|
|
273
|
+
'examined. That is a real result rather than an empty one - but it covers only ' +
|
|
274
|
+
'the rules that ran, listed under "What this scan did not check".'
|
|
275
|
+
: flow > 0
|
|
276
|
+
? `${flow} finding(s) were confirmed by following the data itself - each one prints the ` +
|
|
277
|
+
'full path so you can check it. The signature-based ones are pattern matches the ' +
|
|
278
|
+
'tracer could NOT confirm: it does not resolve imports, so a value arriving from ' +
|
|
279
|
+
'another file ends the trace, and a real bug can easily sit in that group. ' +
|
|
280
|
+
'Unconfirmed is not the same as harmless.'
|
|
281
|
+
: 'Every finding above is an unverified pattern match. The data-flow engine ran but ' +
|
|
282
|
+
'could not confirm any of them - most often because the value arrives from another ' +
|
|
283
|
+
'file, or from a source the dictionaries do not recognise. Treat them as leads.',
|
|
284
|
+
WIDTH - 4,
|
|
285
|
+
' ',
|
|
286
|
+
)}`,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
/* ---------------- analysis depth, by language ----------------
|
|
290
|
+
* Stated POSITIVELY, and only for languages actually in this scan.
|
|
291
|
+
*
|
|
292
|
+
* Two testers in a row wrote fixtures asserting "Go is signature-only, so a
|
|
293
|
+
* flow-verified Go finding would be an overclaim" - and both were reading a
|
|
294
|
+
* scope note that had been true months earlier. Nothing in the output
|
|
295
|
+
* contradicted them, because the report only ever listed languages where
|
|
296
|
+
* taint is MISSING. With every language implemented that list is empty, so the
|
|
297
|
+
* report said nothing at all and left the reader's stale belief standing.
|
|
298
|
+
*
|
|
299
|
+
* A capability you only mention in the negative is one nobody can confirm.
|
|
300
|
+
* The short report says it per language on the summary's "checked" lines. */
|
|
301
|
+
if (present.length > 0) {
|
|
302
|
+
out.push(heading('Analysis depth in this scan'));
|
|
303
|
+
for (const id of present) {
|
|
304
|
+
const cell = result.coverage.taint.find((t) => t.language === id);
|
|
305
|
+
const deep = cell?.implemented ?? false;
|
|
306
|
+
out.push(
|
|
307
|
+
` ${id.padEnd(12)} ${
|
|
308
|
+
deep
|
|
309
|
+
? color.green('signature + data flow')
|
|
310
|
+
: color.yellow('signature only - no flow verification in this language')
|
|
311
|
+
} ${color.dim(`${result.byLanguage[id]} file(s)`)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
out.push('');
|
|
315
|
+
out.push(
|
|
316
|
+
color.dim(
|
|
317
|
+
` ${wrapText(
|
|
318
|
+
'A flow-verified finding can only come from a language on the "signature + data ' +
|
|
319
|
+
'flow" line. Where it says signature only, zero verified findings means the ' +
|
|
320
|
+
'tracer never ran there - not that the code is clean.',
|
|
321
|
+
WIDTH - 4,
|
|
322
|
+
' ',
|
|
323
|
+
)}`,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ---------------- OWASP coverage, all ten ----------------
|
|
329
|
+
* Printed in full, both states, because the previous version printed only
|
|
330
|
+
* the missing ones - and a list of seven gaps invites the reader to assume
|
|
331
|
+
* the other three are handled. None of them is. See the note in coverage.ts. */
|
|
332
|
+
const owasp = result.coverage.owaspCoverage;
|
|
333
|
+
const none = owasp.filter((c) => c.state === 'none');
|
|
334
|
+
out.push(heading(`OWASP Top 10 (2025) - ${none.length} of 10 have no rule at all`));
|
|
335
|
+
out.push(
|
|
336
|
+
color.dim(
|
|
337
|
+
` ${wrapText(
|
|
338
|
+
'No category here is comprehensively covered - the three with rules are checked ' +
|
|
339
|
+
'in part only, and what they leave out is named. Silence in a category with no ' +
|
|
340
|
+
'rule is not a result: code full of those bugs looks exactly like code without.',
|
|
341
|
+
WIDTH - 4,
|
|
342
|
+
' ',
|
|
343
|
+
)}`,
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
out.push('');
|
|
347
|
+
for (const category of owasp) {
|
|
348
|
+
const mark =
|
|
349
|
+
category.state === 'partial' ? color.yellow('PARTIAL') : color.red('NO RULE');
|
|
350
|
+
out.push(` ${mark} ${color.bold(category.id)} ${category.title}`);
|
|
351
|
+
if (category.weCheck) {
|
|
352
|
+
out.push(color.dim(` we check ${category.weCheck} (${category.rules.join(', ')})`));
|
|
353
|
+
}
|
|
354
|
+
out.push(color.dim(` ${category.state === 'partial' ? 'in scope ' : 'e.g. '}${wrapText(category.examples, WIDTH - 22, ' ')}`));
|
|
355
|
+
if (category.note) {
|
|
356
|
+
out.push(color.dim(` why ${wrapText(category.note, WIDTH - 22, ' ')}`));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ---------------- coverage / gaps ----------------
|
|
362
|
+
* The short form still names every gap - by count, in plain words - so a
|
|
363
|
+
* quiet scan can never read as a clean one. It only leaves out the list. */
|
|
364
|
+
if (full || options.showCoverageMatrix) {
|
|
365
|
+
out.push(renderCoverage(result.coverage, options.showCoverageMatrix ?? false));
|
|
366
|
+
} else {
|
|
367
|
+
const owaspNone = result.coverage.owaspCoverage.filter((c) => c.state === 'none').length;
|
|
368
|
+
const here = result.coverage.gaps.filter((c) => present.includes(c.language));
|
|
369
|
+
const partly = here.filter((c) => c.status === 'partial').length;
|
|
370
|
+
const notAtAll = here.length - partly;
|
|
371
|
+
out.push(heading('What this scan did NOT check'));
|
|
372
|
+
out.push(
|
|
373
|
+
` ${wrapText(
|
|
374
|
+
`OWASP Top 10: ${owaspNone} of 10 categories have no rule at all, and the other ` +
|
|
375
|
+
`${10 - owaspNone} are checked only in part.` +
|
|
376
|
+
(present.length > 0
|
|
377
|
+
? ` For the language(s) in this scan, ${notAtAll} rule/language pair(s) are not ` +
|
|
378
|
+
`checked and ${partly} only partly.`
|
|
379
|
+
: '') +
|
|
380
|
+
` The engine has ${result.coverage.engine.notImplemented.length} known limits. ` +
|
|
381
|
+
'No findings means these rules found nothing - not that the code is safe.',
|
|
382
|
+
WIDTH - 4,
|
|
383
|
+
' ',
|
|
384
|
+
)}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ---------------- scan health ---------------- */
|
|
389
|
+
out.push(heading('Scan'));
|
|
390
|
+
const bySeverity = countBySeverity(result.findings);
|
|
391
|
+
if (options.target) {
|
|
392
|
+
out.push(` target ${color.bold(options.target)}`);
|
|
393
|
+
if (FIXTURE_DIRECTORY.test(options.target)) {
|
|
394
|
+
out.push(
|
|
395
|
+
color.dim(
|
|
396
|
+
' NOTE: that path looks like a test, fixture or example folder. Files there are' +
|
|
397
|
+
'\n often deliberately vulnerable, so findings in them are expected rather' +
|
|
398
|
+
"\n than actionable. If you meant to skip them, the flag is attached with an" +
|
|
399
|
+
'\n equals sign and needs a path of its own: scan . --exclude=tests' +
|
|
400
|
+
'\n (via npm, put it after a bare --: npm run scan -- . --exclude=tests)',
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
out.push(
|
|
406
|
+
` files parsed ${color.bold(String(result.stats.filesParsed))}` +
|
|
407
|
+
` code shapes examined ${color.bold(String(result.stats.shapesExamined))}` +
|
|
408
|
+
` time ${color.bold(`${result.stats.durationMs}ms`)}`,
|
|
409
|
+
);
|
|
410
|
+
if (result.crossFile.enabled) {
|
|
411
|
+
out.push(
|
|
412
|
+
color.dim(
|
|
413
|
+
` cross-file: ${result.crossFile.importEdges} import edge(s) resolved ${g('middot')} ` +
|
|
414
|
+
`${result.crossFile.resolved} call(s) followed into another file ${g('middot')} ` +
|
|
415
|
+
`${result.crossFile.ambiguous} declined as ambiguous` +
|
|
416
|
+
(result.crossFile.importsUnresolved > 0
|
|
417
|
+
? ` ${g('middot')} ${result.crossFile.importsUnresolved} import(s) pointed outside the scan`
|
|
418
|
+
: ''),
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
/*
|
|
422
|
+
* "NOTHING FOUND" AND "NOWHERE TO LOOK" ARE DIFFERENT ANSWERS.
|
|
423
|
+
*
|
|
424
|
+
* Someone scanned juice-shop's server.ts on its own - one file, 117 import
|
|
425
|
+
* statements, every one of them pointing at a route file that was not in
|
|
426
|
+
* the scan. The tool parsed it, examined 834 shapes, found 8 sources, and
|
|
427
|
+
* reported nothing, which is CORRECT: server.ts is wiring, and the bugs are
|
|
428
|
+
* in the files it imports. Scanning that repository's routes/ directory
|
|
429
|
+
* finds seven, two flow-verified.
|
|
430
|
+
*
|
|
431
|
+
* The evidence was already in the report - `importEdges 0` - and it said
|
|
432
|
+
* nothing about what that meant. A beginner read an empty findings list as
|
|
433
|
+
* a clean bill of health, then as a broken tool. Both readings were the
|
|
434
|
+
* report's fault, not theirs.
|
|
435
|
+
*
|
|
436
|
+
* This project prints what `--exclude` skipped for exactly this reason. An
|
|
437
|
+
* import that led nowhere is the same kind of hole: a place the analysis
|
|
438
|
+
* did not go, which the reader cannot see from the findings list.
|
|
439
|
+
*/
|
|
440
|
+
if (result.crossFile.importEdges === 0 && result.crossFile.importsUnresolved >= 3) {
|
|
441
|
+
out.push('');
|
|
442
|
+
out.push(
|
|
443
|
+
color.yellow(
|
|
444
|
+
` ${g('warn')} SCOPE WARNING: every one of the ${result.crossFile.importsUnresolved} ` +
|
|
445
|
+
`local import(s) in this scan pointed at a file that was not included.`,
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
out.push(
|
|
449
|
+
color.yellow(
|
|
450
|
+
' Nothing could be traced across a module boundary, so a quiet result here means',
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
out.push(
|
|
454
|
+
color.yellow(
|
|
455
|
+
' "not looked at", not "not vulnerable". Point the scan at the project directory',
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
out.push(color.yellow(' rather than a single file if you meant to analyse the whole thing.'));
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
out.push(color.yellow(` cross-file analysis OFF (${result.crossFile.reason})`));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Say when the scan traded time for memory.
|
|
466
|
+
*
|
|
467
|
+
* A scan that spills gives exactly the same answers, just more slowly - the
|
|
468
|
+
* findings are checked to be byte-identical under a budget small enough to
|
|
469
|
+
* evict almost everything. But "slow" with no explanation is the kind of
|
|
470
|
+
* thing a user blames the tool for and never mentions, so the report says
|
|
471
|
+
* which of the two things happened and what to change.
|
|
472
|
+
*/
|
|
473
|
+
if (result.treeMemory.reparses > 0) {
|
|
474
|
+
out.push(
|
|
475
|
+
color.dim(
|
|
476
|
+
` memory: project larger than the ${(result.treeMemory.budgetBytes / 1048576).toFixed(0)}MB ` +
|
|
477
|
+
`tree budget, so ${result.treeMemory.reparses} file(s) were parsed more than once. ` +
|
|
478
|
+
`Same answers, slower scan - raise DEFUSE_TREE_BUDGET_MB to trade memory back for speed.`,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
// Cleared lines whose receipt rests on a function Defuse does not model.
|
|
483
|
+
const onTrust = result.verifiedClean.filter((c) => (c.assumes?.length ?? 0) > 0).length;
|
|
484
|
+
if (
|
|
485
|
+
result.stats.flowsVerified > 0 ||
|
|
486
|
+
result.stats.signaturesRetracted > 0 ||
|
|
487
|
+
result.stats.signaturesProvedConstant > 0 ||
|
|
488
|
+
result.stats.regionsProvedUnreachable > 0 ||
|
|
489
|
+
result.stats.collectionReadsResolved > 0 ||
|
|
490
|
+
result.stats.tracesStoppedAtNewData > 0
|
|
491
|
+
) {
|
|
492
|
+
out.push(
|
|
493
|
+
color.dim(
|
|
494
|
+
` data flow: ${result.stats.flowsVerified} verified ${g('middot')} ` +
|
|
495
|
+
`${result.stats.signaturesUpgraded} guess(es) superseded by a proof ${g('middot')} ` +
|
|
496
|
+
`${result.stats.signaturesRetracted} withdrawn as provably sanitised` +
|
|
497
|
+
(onTrust > 0
|
|
498
|
+
? ` ${g('middot')} ${onTrust} cleared line(s) rest on a function Defuse does not model, taken to hand back ` +
|
|
499
|
+
`what it was given (listed in --json under verifiedClean[].assumes)`
|
|
500
|
+
: '') +
|
|
501
|
+
(result.stats.signaturesProvedConstant > 0
|
|
502
|
+
? ` ${g('middot')} ${result.stats.signaturesProvedConstant} withdrawn as provably ` +
|
|
503
|
+
`constant (listed in --json under verifiedClean, with the reason)`
|
|
504
|
+
: '') +
|
|
505
|
+
(result.stats.regionsProvedUnreachable > 0
|
|
506
|
+
? ` ${g('middot')} ${result.stats.regionsProvedUnreachable} branch(es) or case(s) not ` +
|
|
507
|
+
`followed because a fixed condition means they cannot run (listed in --json under ` +
|
|
508
|
+
`unreachableCodeSkipped)`
|
|
509
|
+
: '') +
|
|
510
|
+
(result.stats.collectionReadsResolved > 0
|
|
511
|
+
? ` ${g('middot')} ${result.stats.collectionReadsResolved} collection read(s) answered by key or position ` +
|
|
512
|
+
`(listed in --json under collectionReadsResolved)`
|
|
513
|
+
: '') +
|
|
514
|
+
(result.stats.tracesStoppedAtNewData > 0
|
|
515
|
+
? ` ${g('middot')} ${result.stats.tracesStoppedAtNewData} trace(s) ended at a database or file read, ` +
|
|
516
|
+
`which returns stored data rather than its input - not cleared, just not followed ` +
|
|
517
|
+
`(listed in --json under tracesStoppedAtNewData)`
|
|
518
|
+
: ''),
|
|
519
|
+
),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const languageSummary = Object.entries(result.byLanguage)
|
|
523
|
+
.map(([lang, count]) => `${lang} ${count}`)
|
|
524
|
+
.join(' ');
|
|
525
|
+
if (languageSummary) out.push(color.dim(` languages: ${languageSummary}`));
|
|
526
|
+
/*
|
|
527
|
+
* Which PHP the project declares. Printed even when every declaration is
|
|
528
|
+
* fine, because "no runtime finding" means something only if the reader can
|
|
529
|
+
* see what was read - and printed as a note when nothing could be read.
|
|
530
|
+
*/
|
|
531
|
+
const runtime = result.runtimeVersions;
|
|
532
|
+
if (runtime && (runtime.declared.length > 0 || runtime.notes.length > 0)) {
|
|
533
|
+
const shown = runtime.declared.slice(0, 5).map((d) => `${d.file}:${d.line} ${d.status}`);
|
|
534
|
+
const more = runtime.declared.length > shown.length ? ` ${g('middot')} ${runtime.declared.length - shown.length} more in --json` : '';
|
|
535
|
+
out.push(
|
|
536
|
+
color.dim(
|
|
537
|
+
` php runtime (facts from php.net, checked ${runtime.factsChecked}): ` +
|
|
538
|
+
(shown.length > 0 ? shown.join(` ${g('middot')} `) + more : 'no version declared'),
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
for (const note of runtime.notes) out.push(color.dim(` note: ${note}`));
|
|
542
|
+
}
|
|
543
|
+
const deps = result.dependencies;
|
|
544
|
+
out.push(
|
|
545
|
+
color.dim(
|
|
546
|
+
deps
|
|
547
|
+
? ` dependencies (osv.dev, ${deps.checkedAt.slice(0, 10)}): ${deps.checked} package versions from ` +
|
|
548
|
+
`${deps.read.length} lockfile(s)` +
|
|
549
|
+
(deps.notRead.length ? ` ${g('middot')} not read: ${deps.notRead.join(', ')}` : '') +
|
|
550
|
+
(deps.read.some((r) => r.skipped.length) ? ` ${g('middot')} skipped entries in --json` : '')
|
|
551
|
+
: ' dependencies: library versions not checked - add --deps (sends names and versions to osv.dev)',
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
// Build the list FIRST, then decide what to print. The earlier version tried
|
|
555
|
+
// to do both in one expression:
|
|
556
|
+
//
|
|
557
|
+
// ' findings: ' + list.join(' ') || ' findings: none'
|
|
558
|
+
//
|
|
559
|
+
// `+` binds tighter than `||`, so JavaScript read that as
|
|
560
|
+
// (' findings: ' + '') || ' findings: none'. The left side is the non-empty
|
|
561
|
+
// string ' findings: ', which is truthy, so the fallback never ran and a
|
|
562
|
+
// clean scan printed a bare "findings:" with nothing after it.
|
|
563
|
+
const severityLine = (['critical', 'high', 'medium', 'low', 'info'] as const)
|
|
564
|
+
.filter((s) => bySeverity[s] > 0)
|
|
565
|
+
.map((s) => SEVERITY_STYLE[s](`${bySeverity[s]} ${s}`))
|
|
566
|
+
.join(' ');
|
|
567
|
+
out.push(` findings: ${severityLine || color.green('none')}`);
|
|
568
|
+
if (hiddenBySeverity > 0) {
|
|
569
|
+
out.push(
|
|
570
|
+
color.dim(
|
|
571
|
+
` ${hiddenBySeverity} finding(s) below the --min-severity threshold were hidden ` +
|
|
572
|
+
`(they are still present in --json output)`,
|
|
573
|
+
),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (result.parseProblems.length > 0) {
|
|
578
|
+
out.push(
|
|
579
|
+
color.yellow(
|
|
580
|
+
` ${result.parseProblems.length} file(s) had parse errors — findings in them may be incomplete:`,
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
for (const problem of result.parseProblems.slice(0, 5)) {
|
|
584
|
+
const first = problem.issues[0];
|
|
585
|
+
out.push(color.dim(` ${problem.file}${first ? `:${first.line}` : ''} ${first?.text ?? ''}`));
|
|
586
|
+
}
|
|
587
|
+
if (result.parseProblems.length > 5) {
|
|
588
|
+
out.push(color.dim(` ${g('ellipsis')} and ${result.parseProblems.length - 5} more`));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (result.suppressions.length > 0) {
|
|
593
|
+
out.push(
|
|
594
|
+
color.dim(
|
|
595
|
+
` ${result.suppressions.length} finding(s) suppressed by defuse:ignore comments` +
|
|
596
|
+
(options.showSuppressed ? ':' : ' (use --show-suppressed to list them)'),
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
if (options.showSuppressed) {
|
|
600
|
+
for (const s of result.suppressions) {
|
|
601
|
+
out.push(color.dim(` ${s.file}:${s.line} ${s.ruleId} — ${s.comment}`));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (result.excluded.fileCount > 0 || result.excluded.patterns.length > 0) {
|
|
607
|
+
// An exclusion the user asked for is still a blind spot. Print it next to
|
|
608
|
+
// the results rather than letting a smaller number look like better news.
|
|
609
|
+
const directories = result.excluded.directories;
|
|
610
|
+
out.push(
|
|
611
|
+
color.dim(
|
|
612
|
+
` --exclude (${result.excluded.patterns.join(', ')}) skipped ` +
|
|
613
|
+
`${result.excluded.fileCount} file(s)` +
|
|
614
|
+
(directories.length > 0
|
|
615
|
+
? ` and ${directories.length} whole director(ies): ${directories.slice(0, 6).join(', ')}` +
|
|
616
|
+
(directories.length > 6 ? ` ${g('ellipsis')} +${directories.length - 6} more` : '')
|
|
617
|
+
: '') +
|
|
618
|
+
' - not scanned, not counted',
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (result.oversizedFiles.length > 0) {
|
|
624
|
+
out.push(color.dim(` ${result.oversizedFiles.length} file(s) skipped for size (>2MB)`));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!full) {
|
|
628
|
+
out.push('');
|
|
629
|
+
out.push(
|
|
630
|
+
color.dim(
|
|
631
|
+
' Short report. --full adds the reasoning and limits on each finding, the OWASP table\n' +
|
|
632
|
+
' and every gap by name; --report=report.html writes all of it to one file.',
|
|
633
|
+
),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
out.push('');
|
|
637
|
+
return out.join('\n');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/* Small helpers kept below the main function so the flow above reads in order. */
|
|
641
|
+
|
|
642
|
+
function countBySeverity(findings: readonly Finding[]): Record<Severity, number> {
|
|
643
|
+
const counts: Record<Severity, number> = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
644
|
+
for (const finding of findings) counts[finding.severity]++;
|
|
645
|
+
return counts;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function renderFinding(finding: Finding, full: boolean): string {
|
|
649
|
+
const rule = getRule(finding.ruleId) ?? [RUNTIME_VERSION_RULE, DEPENDENCY_RULE].find((r) => r.id === finding.ruleId);
|
|
650
|
+
const indent = ' ';
|
|
651
|
+
const lines: string[] = [];
|
|
652
|
+
// Pad BEFORE colouring: ANSI escape codes are invisible but count towards
|
|
653
|
+
// String.length, so padding a coloured string misaligns every column.
|
|
654
|
+
const position = color.dim(
|
|
655
|
+
`${finding.location.startLine}:${finding.location.startColumn}`.padEnd(12),
|
|
656
|
+
);
|
|
657
|
+
const confidenceTag =
|
|
658
|
+
finding.confidence === 'signature-based'
|
|
659
|
+
? color.yellow('[signature-based]')
|
|
660
|
+
: color.green('[flow-verified]');
|
|
661
|
+
|
|
662
|
+
lines.push(
|
|
663
|
+
` ${severityLabel(finding.severity)} ${position} ${color.bold(finding.message)}`,
|
|
664
|
+
);
|
|
665
|
+
lines.push(
|
|
666
|
+
` ${color.dim(finding.ruleId)} ${color.dim(g('middot'))} ${color.dim(finding.cwe)} ` +
|
|
667
|
+
`${color.dim(g('middot'))} ${color.dim(finding.owasp)} ${confidenceTag}`,
|
|
668
|
+
);
|
|
669
|
+
lines.push(
|
|
670
|
+
` ${color.dim('code')} ${color.gray(finding.location.snippet)}`,
|
|
671
|
+
);
|
|
672
|
+
if (full) {
|
|
673
|
+
lines.push(` ${color.dim('node')} ${color.gray(finding.location.astNodePath)}`);
|
|
674
|
+
}
|
|
675
|
+
if (rule) lines.push(` ${color.cyan('what')} ${wrapText(rule.summary, WIDTH - 18, indent)}`);
|
|
676
|
+
// A pattern match has no path to show, so its reasoning IS the evidence and
|
|
677
|
+
// stays in the short report; a traced finding's evidence is the path itself.
|
|
678
|
+
if (full || !finding.flowPath) {
|
|
679
|
+
lines.push(` ${color.cyan('why')} ${wrapText(finding.reasoning, WIDTH - 18, indent)}`);
|
|
680
|
+
}
|
|
681
|
+
if (finding.flowPath) lines.push(renderFlowPath(finding.flowPath));
|
|
682
|
+
if (rule) lines.push(` ${color.green('fix')} ${wrapText(rule.fix, WIDTH - 18, indent)}`);
|
|
683
|
+
if (full) {
|
|
684
|
+
lines.push(` ${color.yellow('gap')} ${color.dim(wrapText(finding.limitations, WIDTH - 18, indent))}`);
|
|
685
|
+
} else if (!finding.flowPath) {
|
|
686
|
+
lines.push(
|
|
687
|
+
` ${color.yellow('note')} ${color.dim('not traced - this may be a false positive; --full says what was not checked')}`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
return lines.join('\n');
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* The receipt. A flow-verified finding is only worth more than a guess if you
|
|
695
|
+
* can CHECK it, so every hop is printed with its own line number: where the
|
|
696
|
+
* data came from, everything that happened to it, and where it ended up.
|
|
697
|
+
* If any hop looks wrong to you, the finding is wrong - and that is the point.
|
|
698
|
+
*/
|
|
699
|
+
function renderFlowPath(path: readonly FlowStep[]): string {
|
|
700
|
+
const style: Record<FlowStep['kind'], (t: string) => string> = {
|
|
701
|
+
source: (t) => color.magenta(t),
|
|
702
|
+
propagation: (t) => color.dim(t),
|
|
703
|
+
sanitizer: (t) => color.green(t),
|
|
704
|
+
sink: (t) => color.red(t),
|
|
705
|
+
};
|
|
706
|
+
const lines: string[] = [` ${color.magenta('flow')} ${color.dim(`traced source ${g('arrow')} sink:`)}`];
|
|
707
|
+
// `a + b + c` is two concatenation hops on one line, and printing the same
|
|
708
|
+
// sentence twice read as a glitch. Same line, kind and words -> one row, xN.
|
|
709
|
+
// Display only: --json, SARIF and the HTML report keep every hop.
|
|
710
|
+
const rows: { step: FlowStep; times: number }[] = [];
|
|
711
|
+
for (const step of path) {
|
|
712
|
+
const last = rows[rows.length - 1];
|
|
713
|
+
if (
|
|
714
|
+
last &&
|
|
715
|
+
last.step.kind === step.kind &&
|
|
716
|
+
last.step.location.startLine === step.location.startLine &&
|
|
717
|
+
last.step.description === step.description
|
|
718
|
+
) {
|
|
719
|
+
last.times++;
|
|
720
|
+
} else {
|
|
721
|
+
rows.push({ step, times: 1 });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
rows.forEach(({ step, times }, index) => {
|
|
725
|
+
const marker =
|
|
726
|
+
index === 0 ? g('cornerTop') : index === rows.length - 1 ? g('cornerBottom') : g('vertical');
|
|
727
|
+
const where = `${step.location.startLine}`.padStart(4);
|
|
728
|
+
const kind = style[step.kind](step.kind.padEnd(11));
|
|
729
|
+
lines.push(
|
|
730
|
+
` ${color.dim(marker)} ${color.dim(`L${where}`)} ${kind} ` +
|
|
731
|
+
wrapText(step.description + (times > 1 ? ` (x${times})` : ''), WIDTH - 36, ' '),
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
return lines.join('\n');
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function renderCoverage(coverage: CoverageReport, showMatrix: boolean): string {
|
|
738
|
+
const out: string[] = [];
|
|
739
|
+
out.push(heading('What this scan did NOT check'));
|
|
740
|
+
|
|
741
|
+
out.push(color.dim(' Engine limits:'));
|
|
742
|
+
for (const item of coverage.engine.notImplemented) {
|
|
743
|
+
out.push(` ${color.red(g('cross'))} ${item}`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const missingTaint = coverage.taint.filter((t) => !t.implemented);
|
|
747
|
+
if (missingTaint.length > 0) {
|
|
748
|
+
out.push('');
|
|
749
|
+
out.push(color.dim(' Languages with NO data-flow verification (signature matching only):'));
|
|
750
|
+
for (const cell of missingTaint) {
|
|
751
|
+
out.push(` ${color.red(g('cross'))} ${cell.language.padEnd(12)} ${wrapText(cell.note, WIDTH - 20, ' ')}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const partial = coverage.gaps.filter((c) => c.status === 'partial');
|
|
756
|
+
const missing = coverage.gaps.filter((c) => c.status === 'not-implemented');
|
|
757
|
+
|
|
758
|
+
if (partial.length > 0) {
|
|
759
|
+
out.push('');
|
|
760
|
+
out.push(color.dim(` Rules with partial language coverage (${partial.length}):`));
|
|
761
|
+
for (const cell of partial) {
|
|
762
|
+
out.push(
|
|
763
|
+
` ${color.yellow(g('dotHalf'))} ${color.bold(cell.ruleId)}/${cell.language}: ` +
|
|
764
|
+
wrapText(cell.note, WIDTH - 12, ' '),
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (missing.length > 0) {
|
|
770
|
+
out.push('');
|
|
771
|
+
out.push(
|
|
772
|
+
color.dim(
|
|
773
|
+
` Rule/language combinations not implemented at all (${missing.length}): ` +
|
|
774
|
+
missing.map((c) => `${c.ruleId}/${c.language}`).join(', '),
|
|
775
|
+
),
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (coverage.unscannedExtensions.length > 0) {
|
|
780
|
+
out.push('');
|
|
781
|
+
out.push(
|
|
782
|
+
color.dim(
|
|
783
|
+
` File types present but not scanned: ${coverage.unscannedExtensions.join(', ')}`,
|
|
784
|
+
),
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
out.push('');
|
|
789
|
+
out.push(
|
|
790
|
+
color.dim(
|
|
791
|
+
` Coverage: ${coverage.counts.implemented} implemented ${g('middot')} ` +
|
|
792
|
+
`${coverage.counts.partial} partial ${g('middot')} ${coverage.counts.notImplemented} ` +
|
|
793
|
+
`not implemented (of ${coverage.counts.rules} rules x ${coverage.counts.languages} languages)`,
|
|
794
|
+
),
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
if (showMatrix) {
|
|
798
|
+
out.push('');
|
|
799
|
+
for (const cell of coverage.matrix) {
|
|
800
|
+
const mark =
|
|
801
|
+
cell.status === 'implemented'
|
|
802
|
+
? color.green(g('dotFull'))
|
|
803
|
+
: cell.status === 'partial'
|
|
804
|
+
? color.yellow(g('dotHalf'))
|
|
805
|
+
: color.gray(g('dotEmpty'));
|
|
806
|
+
out.push(` ${mark} ${cell.ruleId.padEnd(20)} ${cell.language.padEnd(12)} ${color.dim(cell.status)}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return out.join('\n');
|
|
811
|
+
}
|