claude-crap 0.1.2

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 (202) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/LICENSE +21 -0
  3. package/README.md +550 -0
  4. package/bin/claude-crap.mjs +141 -0
  5. package/dist/adapters/bandit.d.ts +48 -0
  6. package/dist/adapters/bandit.d.ts.map +1 -0
  7. package/dist/adapters/bandit.js +145 -0
  8. package/dist/adapters/bandit.js.map +1 -0
  9. package/dist/adapters/common.d.ts +73 -0
  10. package/dist/adapters/common.d.ts.map +1 -0
  11. package/dist/adapters/common.js +78 -0
  12. package/dist/adapters/common.js.map +1 -0
  13. package/dist/adapters/eslint.d.ts +52 -0
  14. package/dist/adapters/eslint.d.ts.map +1 -0
  15. package/dist/adapters/eslint.js +142 -0
  16. package/dist/adapters/eslint.js.map +1 -0
  17. package/dist/adapters/index.d.ts +47 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +64 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/semgrep.d.ts +30 -0
  22. package/dist/adapters/semgrep.d.ts.map +1 -0
  23. package/dist/adapters/semgrep.js +130 -0
  24. package/dist/adapters/semgrep.js.map +1 -0
  25. package/dist/adapters/stryker.d.ts +55 -0
  26. package/dist/adapters/stryker.d.ts.map +1 -0
  27. package/dist/adapters/stryker.js +165 -0
  28. package/dist/adapters/stryker.js.map +1 -0
  29. package/dist/ast/cyclomatic.d.ts +48 -0
  30. package/dist/ast/cyclomatic.d.ts.map +1 -0
  31. package/dist/ast/cyclomatic.js +106 -0
  32. package/dist/ast/cyclomatic.js.map +1 -0
  33. package/dist/ast/index.d.ts +26 -0
  34. package/dist/ast/index.d.ts.map +1 -0
  35. package/dist/ast/index.js +23 -0
  36. package/dist/ast/index.js.map +1 -0
  37. package/dist/ast/language-config.d.ts +70 -0
  38. package/dist/ast/language-config.d.ts.map +1 -0
  39. package/dist/ast/language-config.js +192 -0
  40. package/dist/ast/language-config.js.map +1 -0
  41. package/dist/ast/tree-sitter-engine.d.ts +133 -0
  42. package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
  43. package/dist/ast/tree-sitter-engine.js +270 -0
  44. package/dist/ast/tree-sitter-engine.js.map +1 -0
  45. package/dist/config.d.ts +57 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +78 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/crap-config.d.ts +97 -0
  50. package/dist/crap-config.d.ts.map +1 -0
  51. package/dist/crap-config.js +144 -0
  52. package/dist/crap-config.js.map +1 -0
  53. package/dist/dashboard/server.d.ts +65 -0
  54. package/dist/dashboard/server.d.ts.map +1 -0
  55. package/dist/dashboard/server.js +147 -0
  56. package/dist/dashboard/server.js.map +1 -0
  57. package/dist/index.d.ts +32 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +574 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/metrics/crap.d.ts +71 -0
  62. package/dist/metrics/crap.d.ts.map +1 -0
  63. package/dist/metrics/crap.js +67 -0
  64. package/dist/metrics/crap.js.map +1 -0
  65. package/dist/metrics/index.d.ts +31 -0
  66. package/dist/metrics/index.d.ts.map +1 -0
  67. package/dist/metrics/index.js +27 -0
  68. package/dist/metrics/index.js.map +1 -0
  69. package/dist/metrics/score.d.ts +143 -0
  70. package/dist/metrics/score.d.ts.map +1 -0
  71. package/dist/metrics/score.js +224 -0
  72. package/dist/metrics/score.js.map +1 -0
  73. package/dist/metrics/tdr.d.ts +106 -0
  74. package/dist/metrics/tdr.d.ts.map +1 -0
  75. package/dist/metrics/tdr.js +117 -0
  76. package/dist/metrics/tdr.js.map +1 -0
  77. package/dist/metrics/workspace-walker.d.ts +43 -0
  78. package/dist/metrics/workspace-walker.d.ts.map +1 -0
  79. package/dist/metrics/workspace-walker.js +137 -0
  80. package/dist/metrics/workspace-walker.js.map +1 -0
  81. package/dist/sarif/index.d.ts +21 -0
  82. package/dist/sarif/index.d.ts.map +1 -0
  83. package/dist/sarif/index.js +19 -0
  84. package/dist/sarif/index.js.map +1 -0
  85. package/dist/sarif/sarif-builder.d.ts +128 -0
  86. package/dist/sarif/sarif-builder.d.ts.map +1 -0
  87. package/dist/sarif/sarif-builder.js +79 -0
  88. package/dist/sarif/sarif-builder.js.map +1 -0
  89. package/dist/sarif/sarif-store.d.ts +205 -0
  90. package/dist/sarif/sarif-store.d.ts.map +1 -0
  91. package/dist/sarif/sarif-store.js +246 -0
  92. package/dist/sarif/sarif-store.js.map +1 -0
  93. package/dist/sarif/sarif-validator.d.ts +45 -0
  94. package/dist/sarif/sarif-validator.d.ts.map +1 -0
  95. package/dist/sarif/sarif-validator.js +138 -0
  96. package/dist/sarif/sarif-validator.js.map +1 -0
  97. package/dist/schemas/tool-schemas.d.ts +216 -0
  98. package/dist/schemas/tool-schemas.d.ts.map +1 -0
  99. package/dist/schemas/tool-schemas.js +208 -0
  100. package/dist/schemas/tool-schemas.js.map +1 -0
  101. package/dist/sdk.d.ts +45 -0
  102. package/dist/sdk.d.ts.map +1 -0
  103. package/dist/sdk.js +44 -0
  104. package/dist/sdk.js.map +1 -0
  105. package/dist/tools/index.d.ts +24 -0
  106. package/dist/tools/index.d.ts.map +1 -0
  107. package/dist/tools/index.js +23 -0
  108. package/dist/tools/index.js.map +1 -0
  109. package/dist/tools/test-harness.d.ts +75 -0
  110. package/dist/tools/test-harness.d.ts.map +1 -0
  111. package/dist/tools/test-harness.js +137 -0
  112. package/dist/tools/test-harness.js.map +1 -0
  113. package/dist/workspace-guard.d.ts +53 -0
  114. package/dist/workspace-guard.d.ts.map +1 -0
  115. package/dist/workspace-guard.js +61 -0
  116. package/dist/workspace-guard.js.map +1 -0
  117. package/package.json +133 -0
  118. package/plugin/.claude-plugin/plugin.json +29 -0
  119. package/plugin/.mcp.json +18 -0
  120. package/plugin/CLAUDE.md +143 -0
  121. package/plugin/bundle/dashboard/public/index.html +368 -0
  122. package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
  123. package/plugin/bundle/mcp-server.mjs +8718 -0
  124. package/plugin/bundle/mcp-server.mjs.map +7 -0
  125. package/plugin/bundle/tdr-engine.mjs +50 -0
  126. package/plugin/bundle/tdr-engine.mjs.map +7 -0
  127. package/plugin/hooks/hooks.json +62 -0
  128. package/plugin/hooks/lib/crap-config.mjs +152 -0
  129. package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
  130. package/plugin/hooks/lib/hook-io.mjs +151 -0
  131. package/plugin/hooks/lib/quality-gate.mjs +329 -0
  132. package/plugin/hooks/lib/test-harness.mjs +152 -0
  133. package/plugin/hooks/post-tool-use.mjs +245 -0
  134. package/plugin/hooks/pre-tool-use.mjs +290 -0
  135. package/plugin/hooks/session-start.mjs +109 -0
  136. package/plugin/hooks/stop-quality-gate.mjs +226 -0
  137. package/plugin/package.json +18 -0
  138. package/plugin/skills/adopt/SKILL.md +74 -0
  139. package/plugin/skills/analyze/SKILL.md +77 -0
  140. package/plugin/skills/check-test/SKILL.md +50 -0
  141. package/plugin/skills/score/SKILL.md +31 -0
  142. package/scripts/bug-report.mjs +328 -0
  143. package/scripts/build-fast.mjs +130 -0
  144. package/scripts/bundle-plugin.mjs +74 -0
  145. package/scripts/doctor.mjs +320 -0
  146. package/scripts/install.mjs +192 -0
  147. package/scripts/lib/cli-ui.mjs +122 -0
  148. package/scripts/postinstall.mjs +127 -0
  149. package/scripts/run-tests.mjs +95 -0
  150. package/scripts/status.mjs +110 -0
  151. package/scripts/uninstall.mjs +72 -0
  152. package/src/adapters/bandit.ts +191 -0
  153. package/src/adapters/common.ts +133 -0
  154. package/src/adapters/eslint.ts +187 -0
  155. package/src/adapters/index.ts +78 -0
  156. package/src/adapters/semgrep.ts +150 -0
  157. package/src/adapters/stryker.ts +218 -0
  158. package/src/ast/cyclomatic.ts +131 -0
  159. package/src/ast/index.ts +33 -0
  160. package/src/ast/language-config.ts +231 -0
  161. package/src/ast/tree-sitter-engine.ts +385 -0
  162. package/src/config.ts +109 -0
  163. package/src/crap-config.ts +196 -0
  164. package/src/dashboard/public/index.html +368 -0
  165. package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
  166. package/src/dashboard/server.ts +205 -0
  167. package/src/index.ts +696 -0
  168. package/src/metrics/crap.ts +101 -0
  169. package/src/metrics/index.ts +51 -0
  170. package/src/metrics/score.ts +329 -0
  171. package/src/metrics/tdr.ts +155 -0
  172. package/src/metrics/workspace-walker.ts +146 -0
  173. package/src/sarif/index.ts +31 -0
  174. package/src/sarif/sarif-builder.ts +139 -0
  175. package/src/sarif/sarif-store.ts +347 -0
  176. package/src/sarif/sarif-validator.ts +145 -0
  177. package/src/schemas/tool-schemas.ts +225 -0
  178. package/src/sdk.ts +110 -0
  179. package/src/tests/adapters/bandit.test.ts +111 -0
  180. package/src/tests/adapters/dispatch.test.ts +100 -0
  181. package/src/tests/adapters/eslint.test.ts +138 -0
  182. package/src/tests/adapters/semgrep.test.ts +125 -0
  183. package/src/tests/adapters/stryker.test.ts +103 -0
  184. package/src/tests/crap-config.test.ts +228 -0
  185. package/src/tests/crap.test.ts +59 -0
  186. package/src/tests/cyclomatic.test.ts +87 -0
  187. package/src/tests/dashboard-http.test.ts +108 -0
  188. package/src/tests/dashboard-integrity.test.ts +128 -0
  189. package/src/tests/integration/mcp-server.integration.test.ts +352 -0
  190. package/src/tests/pre-tool-use-hook.test.ts +178 -0
  191. package/src/tests/sarif-store.test.ts +241 -0
  192. package/src/tests/sarif-validator.test.ts +164 -0
  193. package/src/tests/score.test.ts +260 -0
  194. package/src/tests/skills-frontmatter.test.ts +172 -0
  195. package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
  196. package/src/tests/tdr.test.ts +86 -0
  197. package/src/tests/test-harness.test.ts +153 -0
  198. package/src/tests/workspace-guard.test.ts +111 -0
  199. package/src/tools/index.ts +24 -0
  200. package/src/tools/test-harness.ts +158 -0
  201. package/src/workspace-guard.ts +64 -0
  202. package/tsconfig.json +27 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * End-to-end tests for the PreToolUse hook (`hooks/pre-tool-use.mjs`).
3
+ *
4
+ * These tests spawn the real hook as a subprocess with crafted stdin
5
+ * payloads and assert on the exit code. They are the contract test for
6
+ * F-A06-01 (fail-closed gate for high-risk tools): the PreToolUse hook
7
+ * is NOT allowed to fall back to permissive mode when the tool being
8
+ * invoked is `Write`, `Edit`, `MultiEdit`, `NotebookEdit` or `Bash`,
9
+ * because those tools are the ones the Golden Rule (CLAUDE.md) protects.
10
+ *
11
+ * Claude Code's hook exit code contract:
12
+ *
13
+ * 0 → allow (permissive / "pass")
14
+ * 2 → block (stderr is injected into the agent's context)
15
+ * * → treated as "allow" (fail-open)
16
+ *
17
+ * This suite encodes both the characterization invariants (benign calls
18
+ * still pass, the existing blocked-path rule still fires) and the attack
19
+ * invariants that were introduced for F-A06-01 (fail-closed on errors
20
+ * when the tool is high-risk).
21
+ *
22
+ * @module tests/pre-tool-use-hook.test
23
+ */
24
+
25
+ import { describe, it } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { spawn } from "node:child_process";
28
+ import { dirname, resolve } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+ const HOOK_PATH = resolve(HERE, "..", "..", "plugin", "hooks", "pre-tool-use.mjs");
33
+
34
+ interface HookResult {
35
+ readonly code: number;
36
+ readonly stdout: string;
37
+ readonly stderr: string;
38
+ }
39
+
40
+ /**
41
+ * Spawn the PreToolUse hook with the given stdin payload and return its
42
+ * exit code plus captured stdout/stderr. The child is given a fixed
43
+ * environment so no user-specific CLAUDE_PLUGIN_OPTION_* overrides leak
44
+ * in from the developer's shell.
45
+ */
46
+ function runHook(stdinPayload: string): Promise<HookResult> {
47
+ return new Promise((resolvePromise, reject) => {
48
+ const child = spawn(process.execPath, [HOOK_PATH], {
49
+ stdio: ["pipe", "pipe", "pipe"],
50
+ env: {
51
+ PATH: process.env.PATH ?? "",
52
+ NODE_ENV: "test",
53
+ },
54
+ });
55
+ let stdout = "";
56
+ let stderr = "";
57
+ child.stdout.on("data", (chunk) => {
58
+ stdout += chunk.toString();
59
+ });
60
+ child.stderr.on("data", (chunk) => {
61
+ stderr += chunk.toString();
62
+ });
63
+ child.on("error", reject);
64
+ child.on("exit", (code) => {
65
+ resolvePromise({ code: code ?? -1, stdout, stderr });
66
+ });
67
+ child.stdin.write(stdinPayload);
68
+ child.stdin.end();
69
+ });
70
+ }
71
+
72
+ describe("pre-tool-use hook — characterization (benign paths still work)", () => {
73
+ it("allows a well-formed Read call (exit 0)", async () => {
74
+ const result = await runHook(
75
+ JSON.stringify({
76
+ tool_name: "Read",
77
+ tool_input: { file_path: "/tmp/claude-crap-scan-fixture.txt" },
78
+ }),
79
+ );
80
+ assert.equal(result.code, 0, `stderr was: ${result.stderr}`);
81
+ });
82
+
83
+ it("allows a well-formed Write to a benign path (exit 0)", async () => {
84
+ const result = await runHook(
85
+ JSON.stringify({
86
+ tool_name: "Write",
87
+ tool_input: {
88
+ file_path: "/tmp/claude-crap-scan-fixture.txt",
89
+ content: "hello world",
90
+ },
91
+ }),
92
+ );
93
+ assert.equal(result.code, 0, `stderr was: ${result.stderr}`);
94
+ });
95
+
96
+ it("blocks a Write to a .env file (pre-existing SONAR-PATH-001 rule)", async () => {
97
+ // Characterization: the existing blocked-path rule must still fire.
98
+ const result = await runHook(
99
+ JSON.stringify({
100
+ tool_name: "Write",
101
+ tool_input: { file_path: "/tmp/.env", content: "SECRET=x" },
102
+ }),
103
+ );
104
+ assert.equal(result.code, 2);
105
+ assert.match(result.stderr, /SONAR-PATH-001/);
106
+ });
107
+
108
+ it("blocks a Bash command matching a destructive pattern", async () => {
109
+ // Characterization: the existing destructive-bash rule must still fire.
110
+ // We use `git push --force` because it has a simpler, well-defined regex;
111
+ // the BASH-RMROOT regex has its own edge cases that are out of scope here.
112
+ const result = await runHook(
113
+ JSON.stringify({
114
+ tool_name: "Bash",
115
+ tool_input: { command: "git push --force origin main" },
116
+ }),
117
+ );
118
+ assert.equal(result.code, 2);
119
+ assert.match(result.stderr, /BASH-GITFORCE/);
120
+ });
121
+ });
122
+
123
+ describe("pre-tool-use hook — F-A06-01 fail-closed gate for high-risk tools", () => {
124
+ it("fails CLOSED (exit 2) when stdin is unparseable but tool_name='Write' is extractable", async () => {
125
+ // Attack scenario: malformed JSON whose `tool_name` still leaks via regex.
126
+ // Before the fix this exited 1 (fail-open). After the fix it must exit 2.
127
+ const result = await runHook('{"tool_name":"Write","tool_input":NOT_JSON');
128
+ assert.equal(
129
+ result.code,
130
+ 2,
131
+ `expected fail-closed exit 2 for high-risk tool, got ${result.code}. stderr: ${result.stderr}`,
132
+ );
133
+ assert.match(result.stderr, /SONAR-GATEKEEPER-FAILCLOSED/);
134
+ });
135
+
136
+ it("fails CLOSED (exit 2) when stdin is unparseable but tool_name='Bash' is extractable", async () => {
137
+ const result = await runHook('{"tool_name":"Bash","tool_input":broken');
138
+ assert.equal(result.code, 2);
139
+ assert.match(result.stderr, /SONAR-GATEKEEPER-FAILCLOSED/);
140
+ });
141
+
142
+ it("fails CLOSED (exit 2) when stdin is unparseable but tool_name='Edit' is extractable", async () => {
143
+ const result = await runHook('{"tool_name":"Edit" bogus');
144
+ assert.equal(result.code, 2);
145
+ });
146
+
147
+ it("fails CLOSED (exit 2) when stdin is unparseable but tool_name='MultiEdit' is extractable", async () => {
148
+ const result = await runHook('{"tool_name":"MultiEdit" <garbage');
149
+ assert.equal(result.code, 2);
150
+ });
151
+
152
+ it("fails CLOSED (exit 2) when stdin is unparseable but tool_name='NotebookEdit' is extractable", async () => {
153
+ const result = await runHook('{"tool_name":"NotebookEdit"xxx');
154
+ assert.equal(result.code, 2);
155
+ });
156
+ });
157
+
158
+ describe("pre-tool-use hook — F-A06-01 fail-open stays for low-risk tools", () => {
159
+ it("fails OPEN (exit 1) when stdin is unparseable and tool_name='Read' is extractable", async () => {
160
+ // Read is not in the high-risk allowlist, so fail-open semantics are preserved.
161
+ const result = await runHook('{"tool_name":"Read","tool_input":broken');
162
+ assert.equal(
163
+ result.code,
164
+ 1,
165
+ `expected fail-open exit 1 for low-risk tool, got ${result.code}. stderr: ${result.stderr}`,
166
+ );
167
+ });
168
+
169
+ it("fails OPEN (exit 1) when stdin is unparseable and no tool_name is extractable", async () => {
170
+ const result = await runHook("totally garbage, not even json");
171
+ assert.equal(result.code, 1);
172
+ });
173
+
174
+ it("fails OPEN (exit 1) when stdin is empty", async () => {
175
+ const result = await runHook("");
176
+ assert.equal(result.code, 1);
177
+ });
178
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Unit tests for the on-disk SARIF store.
3
+ *
4
+ * Uses a fresh temp directory per test run to keep the suite hermetic.
5
+ * Only touches the filesystem through Node's `fs/promises` API — no
6
+ * external processes or fixtures.
7
+ *
8
+ * @module tests/sarif-store.test
9
+ */
10
+
11
+ import { describe, it, before, after } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { promises as fs } from "node:fs";
14
+ import { mkdtemp, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ import { SarifStore, type PersistedSarif } from "../sarif/sarif-store.js";
19
+
20
+ /**
21
+ * Build a minimal valid SARIF 2.1.0 document with a single finding. The
22
+ * `ruleId`, `uri`, `line` and `column` inputs drive the dedup key used
23
+ * by the store, so tests can craft duplicates or near-duplicates easily.
24
+ */
25
+ function makeSarif(opts: {
26
+ ruleId: string;
27
+ uri: string;
28
+ line: number;
29
+ column: number;
30
+ message?: string;
31
+ }): PersistedSarif {
32
+ return {
33
+ version: "2.1.0",
34
+ runs: [
35
+ {
36
+ tool: { driver: { name: "test-tool", version: "0.0.1" } },
37
+ results: [
38
+ {
39
+ ruleId: opts.ruleId,
40
+ level: "warning",
41
+ message: { text: opts.message ?? "finding" },
42
+ locations: [
43
+ {
44
+ physicalLocation: {
45
+ artifactLocation: { uri: opts.uri },
46
+ region: { startLine: opts.line, startColumn: opts.column },
47
+ },
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ };
55
+ }
56
+
57
+ describe("SarifStore", () => {
58
+ let workspace = "";
59
+
60
+ before(async () => {
61
+ workspace = await mkdtemp(join(tmpdir(), "claude-crap-test-"));
62
+ });
63
+
64
+ after(async () => {
65
+ if (workspace) await rm(workspace, { recursive: true, force: true });
66
+ });
67
+
68
+ it("starts empty when no report exists on disk", async () => {
69
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports" });
70
+ await store.loadLatest();
71
+ assert.equal(store.size(), 0);
72
+ assert.equal(store.invocationsCount, 0);
73
+ });
74
+
75
+ it("accepts a fresh finding and reports the stats", async () => {
76
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports" });
77
+ await store.loadLatest();
78
+ const stats = store.ingestRun(
79
+ makeSarif({ ruleId: "R1", uri: "src/a.ts", line: 10, column: 5 }),
80
+ "test-tool",
81
+ );
82
+ assert.deepEqual(stats, { accepted: 1, duplicates: 0, total: 1 });
83
+ assert.equal(store.size(), 1);
84
+ });
85
+
86
+ it("deduplicates identical findings across ingestions", async () => {
87
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports2" });
88
+ await store.loadLatest();
89
+ const doc = makeSarif({ ruleId: "R2", uri: "src/b.ts", line: 1, column: 1 });
90
+ store.ingestRun(doc, "test-tool");
91
+ const second = store.ingestRun(doc, "test-tool");
92
+ assert.deepEqual(second, { accepted: 0, duplicates: 1, total: 1 });
93
+ assert.equal(store.size(), 1);
94
+ });
95
+
96
+ it("treats different columns as distinct findings", async () => {
97
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports3" });
98
+ await store.loadLatest();
99
+ store.ingestRun(makeSarif({ ruleId: "R3", uri: "src/c.ts", line: 1, column: 1 }), "t");
100
+ store.ingestRun(makeSarif({ ruleId: "R3", uri: "src/c.ts", line: 1, column: 2 }), "t");
101
+ assert.equal(store.size(), 2);
102
+ });
103
+
104
+ it("persists to disk and reloads the same findings", async () => {
105
+ const dir = "reports4";
106
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: dir });
107
+ await store.loadLatest();
108
+ store.ingestRun(
109
+ makeSarif({ ruleId: "R4", uri: "src/d.ts", line: 7, column: 3, message: "boom" }),
110
+ "semgrep",
111
+ );
112
+ await store.persist();
113
+
114
+ // New store instance reading the same file should see the finding.
115
+ const store2 = new SarifStore({ workspaceRoot: workspace, outputDir: dir });
116
+ await store2.loadLatest();
117
+ assert.equal(store2.size(), 1);
118
+ const [finding] = store2.list();
119
+ assert.equal(finding?.ruleId, "R4");
120
+ assert.equal(finding?.sourceTool, "semgrep");
121
+ });
122
+
123
+ it("rejects non-SARIF-2.1.0 documents", async () => {
124
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports5" });
125
+ await store.loadLatest();
126
+ assert.throws(() =>
127
+ store.ingestRun(
128
+ { version: "2.0.0", runs: [] } as unknown as PersistedSarif,
129
+ "old-tool",
130
+ ),
131
+ );
132
+ });
133
+
134
+ it("ignores findings with missing coordinates", async () => {
135
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports6" });
136
+ await store.loadLatest();
137
+ const malformed: PersistedSarif = {
138
+ version: "2.1.0",
139
+ runs: [
140
+ {
141
+ tool: { driver: { name: "bad", version: "0" } },
142
+ results: [
143
+ {
144
+ ruleId: "R",
145
+ message: { text: "no location" },
146
+ // locations intentionally missing
147
+ },
148
+ ],
149
+ },
150
+ ],
151
+ };
152
+ const stats = store.ingestRun(malformed, "bad");
153
+ assert.equal(stats.accepted, 0);
154
+ assert.equal(stats.total, 1);
155
+ });
156
+
157
+ it("reports an absolute path for the consolidated report", async () => {
158
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: "reports7" });
159
+ await store.loadLatest();
160
+ assert.ok(store.consolidatedReportPath.startsWith(workspace));
161
+ assert.ok(store.consolidatedReportPath.endsWith("latest.sarif"));
162
+ // Sanity: directory should not yet exist until persist() runs.
163
+ assert.rejects(() => fs.access(store.consolidatedReportPath));
164
+ });
165
+
166
+ it("F-A08-01: loadLatest survives a run with non-iterable results", async () => {
167
+ // Persist a document whose first run has a non-array `results`
168
+ // field. A naive `for (const r of run.results)` throws TypeError
169
+ // "X is not iterable" on this input, which would crash the MCP
170
+ // server on boot (persistent DoS). After the fix the store must
171
+ // skip the malformed run, load the second run's well-formed
172
+ // finding, and return without throwing.
173
+ const dir = "reports-a08-a";
174
+ const reportDir = join(workspace, dir);
175
+ await fs.mkdir(reportDir, { recursive: true });
176
+ const latestPath = join(reportDir, "latest.sarif");
177
+ const corrupted = {
178
+ version: "2.1.0",
179
+ runs: [
180
+ // Malformed: `results` is null, not an array.
181
+ {
182
+ tool: { driver: { name: "broken", version: "0" } },
183
+ results: null,
184
+ },
185
+ // Well-formed: must survive.
186
+ {
187
+ tool: { driver: { name: "claude-crap", version: "0.1.0" } },
188
+ results: [
189
+ {
190
+ ruleId: "GOOD-001",
191
+ level: "warning",
192
+ message: { text: "a normal finding" },
193
+ locations: [
194
+ {
195
+ physicalLocation: {
196
+ artifactLocation: { uri: "src/ok.ts" },
197
+ region: { startLine: 1, startColumn: 1 },
198
+ },
199
+ },
200
+ ],
201
+ },
202
+ ],
203
+ },
204
+ ],
205
+ };
206
+ await fs.writeFile(latestPath, JSON.stringify(corrupted, null, 2), "utf8");
207
+
208
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: dir });
209
+ await assert.doesNotReject(
210
+ () => store.loadLatest(),
211
+ "loadLatest must not throw when a run has a non-iterable results field",
212
+ );
213
+ assert.equal(store.size(), 1, "only the well-formed finding should survive");
214
+ const [survivor] = store.list();
215
+ assert.equal(survivor?.ruleId, "GOOD-001");
216
+ });
217
+
218
+ it("F-A08-01: loadLatest survives a top-level runs field that is not an array", async () => {
219
+ // If `runs` is serialized as an object instead of an array (a
220
+ // tampered file, or a mis-generated report), the outer
221
+ // `for (const run of parsed.runs)` throws. The store must catch
222
+ // the failure, log to stderr, and return with zero findings — NOT
223
+ // crash the MCP server startup.
224
+ const dir = "reports-a08-b";
225
+ const reportDir = join(workspace, dir);
226
+ await fs.mkdir(reportDir, { recursive: true });
227
+ const latestPath = join(reportDir, "latest.sarif");
228
+ await fs.writeFile(
229
+ latestPath,
230
+ JSON.stringify({ version: "2.1.0", runs: { notAnArray: true } }),
231
+ "utf8",
232
+ );
233
+
234
+ const store = new SarifStore({ workspaceRoot: workspace, outputDir: dir });
235
+ await assert.doesNotReject(
236
+ () => store.loadLatest(),
237
+ "loadLatest must not throw when `runs` is not an array",
238
+ );
239
+ assert.equal(store.size(), 0, "no findings should survive a bad top-level shape");
240
+ });
241
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Unit tests for the AJV-backed SARIF validator.
3
+ *
4
+ * F-A05-01: the `ingest_sarif` MCP tool used to accept any object
5
+ * shaped like `{version: "2.1.0", runs: [...]}` without verifying
6
+ * the inner structure. These tests pin (a) the characterization
7
+ * invariants — valid minimal SARIF documents still pass — and (b)
8
+ * the attack invariants — every kind of malformed input throws a
9
+ * `SarifValidationError`.
10
+ *
11
+ * @module tests/sarif-validator.test
12
+ */
13
+
14
+ import { describe, it } from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ import {
18
+ SarifValidationError,
19
+ validateSarifDocument,
20
+ } from "../sarif/sarif-validator.js";
21
+
22
+ /**
23
+ * Produce a minimal, fully-valid SARIF 2.1.0 document with a single
24
+ * result. Tests use this as the "known good" baseline and then mutate
25
+ * one field at a time to exercise a specific validator branch.
26
+ */
27
+ function validMinimalSarif(): unknown {
28
+ return {
29
+ version: "2.1.0",
30
+ runs: [
31
+ {
32
+ tool: { driver: { name: "semgrep", version: "1.50.0" } },
33
+ results: [
34
+ {
35
+ ruleId: "R1",
36
+ level: "warning",
37
+ message: { text: "a finding" },
38
+ locations: [
39
+ {
40
+ physicalLocation: {
41
+ artifactLocation: { uri: "src/a.ts" },
42
+ region: { startLine: 1, startColumn: 1 },
43
+ },
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ describe("validateSarifDocument — characterization (valid docs pass)", () => {
54
+ it("accepts a minimal valid SARIF 2.1.0 document", () => {
55
+ assert.doesNotThrow(() => validateSarifDocument(validMinimalSarif()));
56
+ });
57
+
58
+ it("accepts a document with multiple runs", () => {
59
+ const doc = validMinimalSarif() as { runs: unknown[] };
60
+ doc.runs.push({
61
+ tool: { driver: { name: "eslint", version: "8" } },
62
+ results: [],
63
+ });
64
+ assert.doesNotThrow(() => validateSarifDocument(doc));
65
+ });
66
+
67
+ it("accepts a result with no locations array (passthrough)", () => {
68
+ const doc = validMinimalSarif() as any;
69
+ delete doc.runs[0].results[0].locations;
70
+ assert.doesNotThrow(() => validateSarifDocument(doc));
71
+ });
72
+
73
+ it("accepts a result with an extra passthrough property", () => {
74
+ const doc = validMinimalSarif() as any;
75
+ doc.runs[0].results[0].customExtensionField = { foo: "bar" };
76
+ assert.doesNotThrow(() => validateSarifDocument(doc));
77
+ });
78
+
79
+ it("accepts a document with an empty results array", () => {
80
+ const doc = validMinimalSarif() as any;
81
+ doc.runs[0].results = [];
82
+ assert.doesNotThrow(() => validateSarifDocument(doc));
83
+ });
84
+ });
85
+
86
+ describe("validateSarifDocument — attack invariants", () => {
87
+ it("rejects non-object input (null)", () => {
88
+ assert.throws(() => validateSarifDocument(null), SarifValidationError);
89
+ });
90
+
91
+ it("rejects non-object input (string)", () => {
92
+ assert.throws(() => validateSarifDocument("not a sarif doc"), SarifValidationError);
93
+ });
94
+
95
+ it("rejects input with wrong version", () => {
96
+ const doc = validMinimalSarif() as any;
97
+ doc.version = "2.0.0";
98
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
99
+ });
100
+
101
+ it("rejects input missing the runs field", () => {
102
+ const doc = validMinimalSarif() as any;
103
+ delete doc.runs;
104
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
105
+ });
106
+
107
+ it("rejects input where runs is not an array", () => {
108
+ const doc = validMinimalSarif() as any;
109
+ doc.runs = { notAnArray: true };
110
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
111
+ });
112
+
113
+ it("rejects a run with no tool.driver", () => {
114
+ const doc = validMinimalSarif() as any;
115
+ delete doc.runs[0].tool;
116
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
117
+ });
118
+
119
+ it("rejects a run with no results array", () => {
120
+ const doc = validMinimalSarif() as any;
121
+ delete doc.runs[0].results;
122
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
123
+ });
124
+
125
+ it("rejects a result missing ruleId", () => {
126
+ const doc = validMinimalSarif() as any;
127
+ delete doc.runs[0].results[0].ruleId;
128
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
129
+ });
130
+
131
+ it("rejects a result with an empty ruleId", () => {
132
+ const doc = validMinimalSarif() as any;
133
+ doc.runs[0].results[0].ruleId = "";
134
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
135
+ });
136
+
137
+ it("rejects a result missing message", () => {
138
+ const doc = validMinimalSarif() as any;
139
+ delete doc.runs[0].results[0].message;
140
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
141
+ });
142
+
143
+ it("rejects a result with an empty message.text", () => {
144
+ const doc = validMinimalSarif() as any;
145
+ doc.runs[0].results[0].message.text = "";
146
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
147
+ });
148
+
149
+ it("rejects a result with a non-enum level", () => {
150
+ const doc = validMinimalSarif() as any;
151
+ doc.runs[0].results[0].level = "critical"; // not in {none,note,warning,error}
152
+ assert.throws(() => validateSarifDocument(doc), SarifValidationError);
153
+ });
154
+
155
+ it("exposes the AJV error list on the thrown error", () => {
156
+ try {
157
+ validateSarifDocument({ version: "2.1.0", runs: "not-an-array" });
158
+ assert.fail("expected validateSarifDocument to throw");
159
+ } catch (err) {
160
+ assert.ok(err instanceof SarifValidationError);
161
+ assert.ok(Array.isArray(err.errors) || err.errors === null);
162
+ }
163
+ });
164
+ });