@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,107 @@
1
+ /**
2
+ * Terminal colour, in about forty lines and zero dependencies.
3
+ *
4
+ * ANSI escape codes are just special character sequences: printing "\x1b[31m"
5
+ * tells the terminal "red from here", and "\x1b[0m" means "back to normal".
6
+ * Every colour library is a wrapper around that idea.
7
+ *
8
+ * We turn colour OFF when output is being piped into a file or another program
9
+ * (`process.stdout.isTTY` is false), and we honour the NO_COLOR convention,
10
+ * because escape codes in a log file are unreadable garbage.
11
+ */
12
+
13
+ const enabled =
14
+ process.env['NO_COLOR'] === undefined &&
15
+ process.env['TERM'] !== 'dumb' &&
16
+ process.stdout.isTTY === true;
17
+
18
+ function wrap(open: number, close: number) {
19
+ return (text: string): string => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text);
20
+ }
21
+
22
+ export const color = {
23
+ enabled,
24
+ bold: wrap(1, 22),
25
+ dim: wrap(2, 22),
26
+ italic: wrap(3, 23),
27
+ underline: wrap(4, 24),
28
+ red: wrap(31, 39),
29
+ green: wrap(32, 39),
30
+ yellow: wrap(33, 39),
31
+ blue: wrap(34, 39),
32
+ magenta: wrap(35, 39),
33
+ cyan: wrap(36, 39),
34
+ white: wrap(37, 39),
35
+ gray: wrap(90, 39),
36
+ bgRed: wrap(41, 49),
37
+ bgYellow: wrap(43, 49),
38
+ };
39
+
40
+ /** Word-wrap a paragraph to `width`, indenting every line after the first. */
41
+ export function wrapText(text: string, width: number, indent: string): string {
42
+ const words = text.split(/\s+/).filter(Boolean);
43
+ const lines: string[] = [];
44
+ let current = '';
45
+
46
+ for (const word of words) {
47
+ if (current.length === 0) {
48
+ current = word;
49
+ } else if (current.length + 1 + word.length <= width) {
50
+ current += ` ${word}`;
51
+ } else {
52
+ lines.push(current);
53
+ current = word;
54
+ }
55
+ }
56
+ if (current) lines.push(current);
57
+ return lines.join(`\n${indent}`);
58
+ }
59
+
60
+
61
+ /**
62
+ * BOX-DRAWING GLYPHS, AND WHY THEY NEED A FALLBACK
63
+ *
64
+ * The report uses characters like ─ ✓ ● because they read well in a terminal.
65
+ * They are also a portability hazard: they are multi-byte UTF-8, and the moment
66
+ * output leaves a UTF-8 terminal the bytes can be re-interpreted by something
67
+ * that assumes a legacy code page. Redirecting a scan to a file on Windows and
68
+ * opening it in Notepad turns every `──` into `ÔöÇÔöÇ` - the report becomes
69
+ * unreadable while the analysis behind it was perfectly correct.
70
+ *
71
+ * So: one table, two spellings, and a switch. ASCII is chosen automatically
72
+ * when output is being redirected on Windows (the case that actually breaks),
73
+ * and can be forced anywhere with --ascii or NS1_ASCII=1.
74
+ */
75
+ let asciiMode =
76
+ process.env['NS1_ASCII'] === '1' ||
77
+ (process.platform === 'win32' && process.stdout.isTTY !== true);
78
+
79
+ export function setAsciiMode(enabled: boolean): void {
80
+ asciiMode = enabled;
81
+ }
82
+
83
+ const GLYPHS = {
84
+ rule: ['\u2500', '-'],
85
+ vertical: ['\u2502', '|'],
86
+ cornerTop: ['\u250c', '+'],
87
+ cornerBottom: ['\u2514', '+'],
88
+ tick: ['\u2713', 'ok'],
89
+ cross: ['\u2717', 'X'],
90
+ dotFull: ['\u25cf', '*'],
91
+ dotHalf: ['\u25d0', '~'],
92
+ dotEmpty: ['\u25cb', 'o'],
93
+ middot: ['\u00b7', '-'],
94
+ arrow: ['\u2192', '->'],
95
+ ellipsis: ['\u2026', '...'],
96
+ // Windows PowerShell renders U+26A0 inconsistently; the ASCII fallback keeps
97
+ // the warning legible everywhere, which for a warning is the whole point.
98
+ warn: ['\u26a0', '!'],
99
+ } as const;
100
+
101
+ export type GlyphName = keyof typeof GLYPHS;
102
+
103
+ /** The right spelling of a glyph for wherever this output is going. */
104
+ export function g(name: GlyphName): string {
105
+ const pair = GLYPHS[name];
106
+ return asciiMode ? pair[1] : pair[0];
107
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * THE README, DERIVED FROM THE REGISTRY.
3
+ * =====================================
4
+ *
5
+ * WHY THIS FILE EXISTS.
6
+ *
7
+ * Pointing a fresh reader at this repository produced a list of things the
8
+ * README asserts that the code contradicts:
9
+ *
10
+ * README says 5 rules registry ships 8
11
+ * README says 5 languages languages.ts 6 (PHP absent entirely)
12
+ * README says "Not SARIF" src/report/sarif.ts is a SARIF 2.1.0 exporter
13
+ * README says 13.8% recall the scorer measures 76.4%
14
+ *
15
+ * Every one of those was true when it was written. The project simply moved and
16
+ * the prose did not, because the prose was a hand-maintained restatement of
17
+ * facts the registry already knew.
18
+ *
19
+ * THIS IS THE FOURTH TIME THIS EXACT CLASS HAS BITTEN, and the previous three
20
+ * are recorded elsewhere in this codebase: sources built from the frameworks we
21
+ * had been shown (elasticsearch), escapers likewise (Jenkins), test-path
22
+ * conventions likewise (cal.com). The pattern is always a list maintained by
23
+ * hand that looks complete until reality moves.
24
+ *
25
+ * What makes this instance the sharpest is where the stale list was. There IS a
26
+ * documentation-drift check in the suite, and it passed the whole time - because
27
+ * it greps the README for exactly one regex, the `npm test - N checks` line.
28
+ * A narrow instrument was trusted as a broad one. The checker itself was the
29
+ * hand-maintained list.
30
+ *
31
+ * So: the counts and the matrix are now GENERATED from ALL_RULES and LANGUAGES,
32
+ * the benchmark table is generated from the scorer's own output file, and
33
+ * `npm test` fails when the README disagrees with either. Prose that restates a
34
+ * fact the program knows is a bug waiting for a quiet afternoon.
35
+ */
36
+ import { ALL_RULES } from '../rules/registry.js';
37
+ import { LANGUAGES, type LanguageId } from '../parse/languages.js';
38
+ import type { SupportStatus } from '../rules/contract.js';
39
+
40
+ /** Opening and closing markers for a generated block in a markdown file. */
41
+ export const BLOCK_START = (name: string): string => `<!-- derived:${name} -->`;
42
+ export const BLOCK_END = '<!-- /derived -->';
43
+
44
+ const MARK: Record<SupportStatus, string> = {
45
+ implemented: '●',
46
+ partial: '◐',
47
+ 'not-implemented': '○',
48
+ };
49
+
50
+ const SHORT: Partial<Record<LanguageId, string>> = {
51
+ javascript: 'JS',
52
+ typescript: 'TS',
53
+ python: 'PY',
54
+ java: 'JAVA',
55
+ php: 'PHP',
56
+ go: 'GO',
57
+ };
58
+
59
+ const columnLabel = (id: LanguageId): string => SHORT[id] ?? id.toUpperCase();
60
+
61
+ /**
62
+ * The rule × language coverage matrix.
63
+ *
64
+ * `Rule.support` is already a per-language honesty declaration with a REQUIRED
65
+ * note on every entry, printed at the end of every scan. The README's table was
66
+ * a second copy of it, kept by hand, and it is the copy that went stale.
67
+ */
68
+ export function renderRuleMatrix(): string {
69
+ const languages = LANGUAGES.map((l) => l.id);
70
+ const header = `| Rule | CWE | OWASP | ${languages.map(columnLabel).join(' | ')} |`;
71
+ const divider = `|---|---|---|${languages.map(() => ':--:').join('|')}|`;
72
+ const rows = ALL_RULES.map((rule) => {
73
+ const cells = languages.map((id) => MARK[rule.support[id]?.status ?? 'not-implemented']);
74
+ return `| \`${rule.id}\` | ${rule.cwe} | ${rule.owasp} | ${cells.join(' | ')} |`;
75
+ });
76
+ return [
77
+ header,
78
+ divider,
79
+ ...rows,
80
+ '',
81
+ '● implemented ◐ partial ○ not implemented',
82
+ '',
83
+ `${ALL_RULES.length} rules across ${languages.length} languages. This table is generated from`,
84
+ '`ALL_RULES` and `LANGUAGES` by `npm run sync:readme`, and `npm test` fails if it',
85
+ 'drifts - the counts in it are not maintained by hand.',
86
+ ].join('\n');
87
+ }
88
+
89
+ /** One sentence naming the current totals, for use in running prose. */
90
+ export function renderCounts(): string {
91
+ const names = LANGUAGES.map((l) => l.displayName);
92
+ return (
93
+ `**${ALL_RULES.length} rules** across **${names.length} languages** ` +
94
+ `(${names.join(', ')}).`
95
+ );
96
+ }
97
+
98
+ export interface BenchmarkResult {
99
+ readonly scored: number;
100
+ readonly tp: number;
101
+ readonly fp: number;
102
+ readonly fn: number;
103
+ readonly tn: number;
104
+ readonly precision: string;
105
+ readonly recall: string;
106
+ readonly missedByCategory: Record<string, number>;
107
+ readonly filesParsed: number;
108
+ readonly parseErrors: number;
109
+ readonly durationMs: number;
110
+ /** False positives carrying BenchmarkJava's constant-branch decoy. */
111
+ readonly decoyFalsePositives: number;
112
+ readonly decoyShare: string;
113
+ readonly engineVersion: string;
114
+ readonly measuredAt: string;
115
+ }
116
+
117
+ /**
118
+ * The OWASP BenchmarkJava table.
119
+ *
120
+ * This one cannot be derived from the registry, because it is a MEASUREMENT
121
+ * rather than a declaration - so it is derived from the measurement instead.
122
+ * `npm run benchmark` writes docs/benchmark-result.json, this renders it, and
123
+ * the test compares the README against it. The loop closes: you cannot change
124
+ * the engine's score without the README either following or failing the build.
125
+ *
126
+ * The stale figures this replaced were 64.5% precision / 13.8% recall. They
127
+ * were honestly measured and then left behind by the Spring and JAX-RS source
128
+ * bindings and by declaredTypes(), which took recall to 76.4% and cost
129
+ * precision on the way - a real trade the old table could not show.
130
+ */
131
+ export function renderBenchmark(result: BenchmarkResult): string {
132
+ /*
133
+ * THE HEADLINE IS DERIVED TOO, and that is the point of this function.
134
+ *
135
+ * The first version of this block generated the TABLE and left the sentence
136
+ * underneath it hand-written. That sentence read "Recall is low and that is
137
+ * the honest headline" - an interpretation of the OLD numbers, which went
138
+ * stale in the very commit that fixed the table it sat beneath. Deriving the
139
+ * facts and hand-keeping their meaning just moves the drift one line down.
140
+ *
141
+ * So the weak side is computed. More false positives than false negatives
142
+ * means precision is where the tool is worst; the reverse means recall is.
143
+ * Whichever it is, the README says it because the arithmetic said it.
144
+ */
145
+ const precisionIsWeaker = result.fp > result.fn;
146
+ const ratio = (Math.max(result.fp, result.fn) / Math.max(1, Math.min(result.fp, result.fn)))
147
+ .toFixed(1);
148
+ /*
149
+ * A ZERO IS NOT A RATIO. When the hunt for BenchmarkJava's 38 misses took
150
+ * false negatives to 0, this sentence printed "271 false positives against 0
151
+ * false negatives - 271.0x as many": arithmetic on a divide-by-one guard,
152
+ * dressed up as a measurement. A zero is said in words, and so is its limit:
153
+ * no misses HERE is a fact about these 1,210 test cases, not about your code.
154
+ */
155
+ const scope =
156
+ 'That is a statement about these scored test cases, not about code in general - ' +
157
+ 'the limitations list is where the misses this benchmark cannot show are written down.';
158
+ const headline =
159
+ result.fn === 0 && result.fp > 0
160
+ ? `**Precision is the weak side.** ${result.fp} false positives and no false ` +
161
+ `negatives, so on this benchmark the cost of this engine is triage time, not ` +
162
+ `missed bugs. ${scope}`
163
+ : result.fp === 0 && result.fn > 0
164
+ ? `**Recall is the weak side.** ${result.fn} false negatives and no false ` +
165
+ `positives, so on this benchmark the cost of this engine is missed bugs, not ` +
166
+ `triage time. ${scope}`
167
+ : precisionIsWeaker
168
+ ? `**Precision is the weaker side.** ${result.fp} false positives against ` +
169
+ `${result.fn} false negatives - ${ratio}x as many - so the cost of this engine ` +
170
+ `is triage time, not missed bugs.`
171
+ : `**Recall is the weaker side.** ${result.fn} false negatives against ` +
172
+ `${result.fp} false positives - ${ratio}x as many - so the cost of this engine ` +
173
+ `is missed bugs, not triage time.`;
174
+ const missed = Object.entries(result.missedByCategory);
175
+
176
+ return [
177
+ '| | cases | TP | FP | FN | TN | precision | recall |',
178
+ '|---|--:|--:|--:|--:|--:|--:|--:|',
179
+ `| **overall** | **${result.scored}** | **${result.tp}** | **${result.fp}** | ` +
180
+ `**${result.fn}** | **${result.tn}** | **${result.precision}** | **${result.recall}** |`,
181
+ '',
182
+ `${result.filesParsed} files, ${result.parseErrors} parse errors, ` +
183
+ `${(result.durationMs / 1000).toFixed(1)} seconds, on engine ${result.engineVersion} ` +
184
+ `(${result.measuredAt}).`,
185
+ '',
186
+ headline,
187
+ '',
188
+ (missed.length > 0
189
+ ? `Missed by category: ${missed.map(([k, v]) => `${k} ${v}`).join(', ')}. `
190
+ : 'Nothing was missed in the scored categories. ') +
191
+ `Of the ${result.fp} false positives, **${result.decoyFalsePositives} ` +
192
+ `(${result.decoyShare})** carry the shape of BenchmarkJava's constant-branch decoy - ` +
193
+ `a condition like \`if ((7 * 42) - num > 200)\`. The data-flow pass, the SQL rule ` +
194
+ `and the command-injection rule all decide such a condition, so the shape alone ` +
195
+ `explains none of them: every false positive here is ours.`,
196
+ '',
197
+ 'Every number above is generated by `npm run benchmark` into',
198
+ '`docs/benchmark-result.json` - including the sentence naming the weaker side,',
199
+ 'because an interpretation kept by hand goes stale exactly like a figure does.',
200
+ ].join('\n');
201
+ }
202
+
203
+ /** Replace the contents of one `<!-- derived:name -->` block. */
204
+ export interface LabelSplitTier {
205
+ readonly label: string;
206
+ readonly tp: number;
207
+ readonly fp: number;
208
+ readonly fn: number;
209
+ readonly precision: string;
210
+ readonly recall: string;
211
+ readonly decoyFalsePositives: number;
212
+ readonly decoyShare: string;
213
+ readonly nonDecoyFalsePositives: number;
214
+ }
215
+
216
+ export interface LabelSplitResult {
217
+ readonly scored: number;
218
+ readonly findingsByConfidence: Record<string, number>;
219
+ readonly tiers: readonly LabelSplitTier[];
220
+ readonly engineVersion: string;
221
+ readonly measuredAt: string;
222
+ }
223
+
224
+ /**
225
+ * THE BLOCK THAT SCORES THE LABELS SEPARATELY.
226
+ *
227
+ * `renderBenchmark` reports one precision figure covering both labels mixed
228
+ * together. That figure is an average across two populations this whole tool
229
+ * exists to keep apart - the same blending it refuses to do inside a single
230
+ * finding, done one level up in the report about itself.
231
+ *
232
+ * So this renders the split, and like the benchmark block it derives its own
233
+ * interpretation rather than leaving a hand-written sentence to go stale
234
+ * underneath it. Two sentences are computed:
235
+ *
236
+ * - whether the green label is actually worth more than the amber one, and
237
+ * by how much. If a change ever made them equal, this block would say so
238
+ * in the README rather than waiting for someone to notice.
239
+ *
240
+ * - how much of the green tier's error is BenchmarkJava's synthetic decoy
241
+ * rather than a real mistake. Quoting the decoy-excluded figure alone
242
+ * would be picking the flattering number; quoting only the blended one
243
+ * hides that the benchmark's traps dominate what is left.
244
+ */
245
+ export function renderLabelSplit(result: LabelSplitResult): string {
246
+ const pct = (s: string): number => Number.parseFloat(s);
247
+ const verified = result.tiers.find((t) => t.label.startsWith('flow-verified'));
248
+ const signature = result.tiers.find((t) => t.label.startsWith('signature-only'));
249
+
250
+ const rows = result.tiers.map(
251
+ (t) =>
252
+ `| \`${t.label}\` | ${t.tp} | ${t.fp} | ${t.fn} | **${t.precision}** | ${t.recall} | ` +
253
+ `${t.decoyFalsePositives} (${t.decoyShare}) |`,
254
+ );
255
+
256
+ const lines: string[] = [
257
+ '| tier | TP | FP | FN | precision | recall | FPs that are decoys |',
258
+ '|---|--:|--:|--:|--:|--:|--:|',
259
+ ...rows,
260
+ '',
261
+ `${Object.entries(result.findingsByConfidence)
262
+ .map(([k, v]) => `${v} \`${k}\``)
263
+ .join(', ')} findings, on engine ${result.engineVersion} (${result.measuredAt}).`,
264
+ '',
265
+ ];
266
+
267
+ if (verified && signature) {
268
+ const gap = pct(verified.precision) - pct(signature.precision);
269
+ lines.push(
270
+ gap > 0
271
+ ? `**The green label is worth ${gap.toFixed(1)} points.** \`flow-verified\` runs ` +
272
+ `${verified.precision} against \`signature-based\` at ${signature.precision}. The ` +
273
+ `gap is the whole claim this tool makes; it is measured here rather than asserted.`
274
+ : `**The labels are not separating.** \`flow-verified\` runs ${verified.precision} ` +
275
+ `against \`signature-based\` at ${signature.precision}, so the green label is ` +
276
+ `currently buying nothing. That is a defect, not a footnote.`,
277
+ );
278
+ lines.push('');
279
+
280
+ const real = verified.tp + verified.nonDecoyFalsePositives;
281
+ const excl = real === 0 ? 0 : (verified.tp / real) * 100;
282
+
283
+ /*
284
+ * TWO SENTENCES, AND WHICH ONE IS TRUE IS NOT OURS TO CHOOSE.
285
+ *
286
+ * For a long time the green label carried a large pile of the benchmark's
287
+ * constant-branch decoys, so the honest thing was to print the headline AND
288
+ * the decoy-excluded figure, and to say plainly that neither alone was the
289
+ * truth.
290
+ *
291
+ * Then constant-condition evaluation landed and the decoys went to zero.
292
+ * Keeping the old paragraph would have had the page arguing for a
293
+ * distinction between two numbers that are now identical - drift caused by
294
+ * an improvement, which is the sneakiest kind, because nothing failed and
295
+ * the prose simply stopped being about anything.
296
+ *
297
+ * So the branch is derived from the measurement, like every other sentence
298
+ * in this file. If decoys ever come back, so does the caveat.
299
+ */
300
+ if (verified.fp === 0) {
301
+ /*
302
+ * Zero is said in words, and so is its limit - the same rule the benchmark
303
+ * block follows for zero false negatives. "0 ordinary mistakes out of 456"
304
+ * would be arithmetic dressed as a boast.
305
+ */
306
+ lines.push(
307
+ `**No false positive carries the green label here.** All ${verified.tp} ` +
308
+ `\`flow-verified\` claims on this benchmark are real vulnerabilities. That is a ` +
309
+ `statement about these scored test cases, not a promise about your code: a proof ` +
310
+ `means the path exists in the source, and the ways that can still be wrong are ` +
311
+ `the ones in the limitations list, which a synthetic benchmark does not exercise.`,
312
+ );
313
+ } else if (verified.decoyFalsePositives === 0) {
314
+ lines.push(
315
+ `**There is no footnote any more.** Not one of the ${verified.fp} false positives ` +
316
+ `still carrying the green label is a constant-branch decoy. Until constant ` +
317
+ `conditions were evaluated, that pile was most of them, and this paragraph had to ` +
318
+ `print two precision figures and explain why neither alone was the truth. ` +
319
+ `\`flow-verified\` now reads **${excl.toFixed(1)}%** with nothing set aside and ` +
320
+ `nothing excluded - ${verified.nonDecoyFalsePositives} ordinary mistakes out of ` +
321
+ `${verified.tp + verified.fp} claims.`,
322
+ );
323
+ } else {
324
+ lines.push(
325
+ `Of the ${verified.fp} false positives still carrying the green label, ` +
326
+ `**${verified.decoyFalsePositives} (${verified.decoyShare})** are BenchmarkJava's ` +
327
+ `constant-branch decoy - a path that genuinely exists inside a branch that cannot ` +
328
+ `run - leaving **${verified.nonDecoyFalsePositives}** that are ordinary mistakes. ` +
329
+ `Set the decoys aside and \`flow-verified\` precision is **${excl.toFixed(1)}%**. ` +
330
+ `Both figures are printed because neither alone is the truth: the first is ` +
331
+ `contaminated by synthetic traps, the second requires excluding cases, and a ` +
332
+ `reader deserves to see the size of that choice rather than inherit it.`,
333
+ );
334
+ }
335
+ }
336
+
337
+ return lines.join('\n');
338
+ }
339
+
340
+ export function replaceBlock(markdown: string, name: string, body: string): string {
341
+ const start = BLOCK_START(name);
342
+ const from = markdown.indexOf(start);
343
+ if (from === -1) throw new Error(`README has no block named "${name}"`);
344
+ const to = markdown.indexOf(BLOCK_END, from);
345
+ if (to === -1) throw new Error(`block "${name}" is not closed with ${BLOCK_END}`);
346
+ return (
347
+ markdown.slice(0, from + start.length) + '\n' + body + '\n' + markdown.slice(to)
348
+ );
349
+ }
350
+
351
+ /** Read the current contents of one block, for comparison. */
352
+ export function readBlock(markdown: string, name: string): string | null {
353
+ const start = BLOCK_START(name);
354
+ const from = markdown.indexOf(start);
355
+ if (from === -1) return null;
356
+ const to = markdown.indexOf(BLOCK_END, from);
357
+ if (to === -1) return null;
358
+ return markdown.slice(from + start.length, to).trim();
359
+ }