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