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.
- package/CHANGELOG.md +308 -0
- package/LICENSE +21 -0
- package/README.md +550 -0
- package/bin/claude-crap.mjs +141 -0
- package/dist/adapters/bandit.d.ts +48 -0
- package/dist/adapters/bandit.d.ts.map +1 -0
- package/dist/adapters/bandit.js +145 -0
- package/dist/adapters/bandit.js.map +1 -0
- package/dist/adapters/common.d.ts +73 -0
- package/dist/adapters/common.d.ts.map +1 -0
- package/dist/adapters/common.js +78 -0
- package/dist/adapters/common.js.map +1 -0
- package/dist/adapters/eslint.d.ts +52 -0
- package/dist/adapters/eslint.d.ts.map +1 -0
- package/dist/adapters/eslint.js +142 -0
- package/dist/adapters/eslint.js.map +1 -0
- package/dist/adapters/index.d.ts +47 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +64 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/semgrep.d.ts +30 -0
- package/dist/adapters/semgrep.d.ts.map +1 -0
- package/dist/adapters/semgrep.js +130 -0
- package/dist/adapters/semgrep.js.map +1 -0
- package/dist/adapters/stryker.d.ts +55 -0
- package/dist/adapters/stryker.d.ts.map +1 -0
- package/dist/adapters/stryker.js +165 -0
- package/dist/adapters/stryker.js.map +1 -0
- package/dist/ast/cyclomatic.d.ts +48 -0
- package/dist/ast/cyclomatic.d.ts.map +1 -0
- package/dist/ast/cyclomatic.js +106 -0
- package/dist/ast/cyclomatic.js.map +1 -0
- package/dist/ast/index.d.ts +26 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +23 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/ast/language-config.d.ts +70 -0
- package/dist/ast/language-config.d.ts.map +1 -0
- package/dist/ast/language-config.js +192 -0
- package/dist/ast/language-config.js.map +1 -0
- package/dist/ast/tree-sitter-engine.d.ts +133 -0
- package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
- package/dist/ast/tree-sitter-engine.js +270 -0
- package/dist/ast/tree-sitter-engine.js.map +1 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +78 -0
- package/dist/config.js.map +1 -0
- package/dist/crap-config.d.ts +97 -0
- package/dist/crap-config.d.ts.map +1 -0
- package/dist/crap-config.js +144 -0
- package/dist/crap-config.js.map +1 -0
- package/dist/dashboard/server.d.ts +65 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +147 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/crap.d.ts +71 -0
- package/dist/metrics/crap.d.ts.map +1 -0
- package/dist/metrics/crap.js +67 -0
- package/dist/metrics/crap.js.map +1 -0
- package/dist/metrics/index.d.ts +31 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +27 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/score.d.ts +143 -0
- package/dist/metrics/score.d.ts.map +1 -0
- package/dist/metrics/score.js +224 -0
- package/dist/metrics/score.js.map +1 -0
- package/dist/metrics/tdr.d.ts +106 -0
- package/dist/metrics/tdr.d.ts.map +1 -0
- package/dist/metrics/tdr.js +117 -0
- package/dist/metrics/tdr.js.map +1 -0
- package/dist/metrics/workspace-walker.d.ts +43 -0
- package/dist/metrics/workspace-walker.d.ts.map +1 -0
- package/dist/metrics/workspace-walker.js +137 -0
- package/dist/metrics/workspace-walker.js.map +1 -0
- package/dist/sarif/index.d.ts +21 -0
- package/dist/sarif/index.d.ts.map +1 -0
- package/dist/sarif/index.js +19 -0
- package/dist/sarif/index.js.map +1 -0
- package/dist/sarif/sarif-builder.d.ts +128 -0
- package/dist/sarif/sarif-builder.d.ts.map +1 -0
- package/dist/sarif/sarif-builder.js +79 -0
- package/dist/sarif/sarif-builder.js.map +1 -0
- package/dist/sarif/sarif-store.d.ts +205 -0
- package/dist/sarif/sarif-store.d.ts.map +1 -0
- package/dist/sarif/sarif-store.js +246 -0
- package/dist/sarif/sarif-store.js.map +1 -0
- package/dist/sarif/sarif-validator.d.ts +45 -0
- package/dist/sarif/sarif-validator.d.ts.map +1 -0
- package/dist/sarif/sarif-validator.js +138 -0
- package/dist/sarif/sarif-validator.js.map +1 -0
- package/dist/schemas/tool-schemas.d.ts +216 -0
- package/dist/schemas/tool-schemas.d.ts.map +1 -0
- package/dist/schemas/tool-schemas.js +208 -0
- package/dist/schemas/tool-schemas.js.map +1 -0
- package/dist/sdk.d.ts +45 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +44 -0
- package/dist/sdk.js.map +1 -0
- package/dist/tools/index.d.ts +24 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/test-harness.d.ts +75 -0
- package/dist/tools/test-harness.d.ts.map +1 -0
- package/dist/tools/test-harness.js +137 -0
- package/dist/tools/test-harness.js.map +1 -0
- package/dist/workspace-guard.d.ts +53 -0
- package/dist/workspace-guard.d.ts.map +1 -0
- package/dist/workspace-guard.js +61 -0
- package/dist/workspace-guard.js.map +1 -0
- package/package.json +133 -0
- package/plugin/.claude-plugin/plugin.json +29 -0
- package/plugin/.mcp.json +18 -0
- package/plugin/CLAUDE.md +143 -0
- package/plugin/bundle/dashboard/public/index.html +368 -0
- package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/plugin/bundle/mcp-server.mjs +8718 -0
- package/plugin/bundle/mcp-server.mjs.map +7 -0
- package/plugin/bundle/tdr-engine.mjs +50 -0
- package/plugin/bundle/tdr-engine.mjs.map +7 -0
- package/plugin/hooks/hooks.json +62 -0
- package/plugin/hooks/lib/crap-config.mjs +152 -0
- package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
- package/plugin/hooks/lib/hook-io.mjs +151 -0
- package/plugin/hooks/lib/quality-gate.mjs +329 -0
- package/plugin/hooks/lib/test-harness.mjs +152 -0
- package/plugin/hooks/post-tool-use.mjs +245 -0
- package/plugin/hooks/pre-tool-use.mjs +290 -0
- package/plugin/hooks/session-start.mjs +109 -0
- package/plugin/hooks/stop-quality-gate.mjs +226 -0
- package/plugin/package.json +18 -0
- package/plugin/skills/adopt/SKILL.md +74 -0
- package/plugin/skills/analyze/SKILL.md +77 -0
- package/plugin/skills/check-test/SKILL.md +50 -0
- package/plugin/skills/score/SKILL.md +31 -0
- package/scripts/bug-report.mjs +328 -0
- package/scripts/build-fast.mjs +130 -0
- package/scripts/bundle-plugin.mjs +74 -0
- package/scripts/doctor.mjs +320 -0
- package/scripts/install.mjs +192 -0
- package/scripts/lib/cli-ui.mjs +122 -0
- package/scripts/postinstall.mjs +127 -0
- package/scripts/run-tests.mjs +95 -0
- package/scripts/status.mjs +110 -0
- package/scripts/uninstall.mjs +72 -0
- package/src/adapters/bandit.ts +191 -0
- package/src/adapters/common.ts +133 -0
- package/src/adapters/eslint.ts +187 -0
- package/src/adapters/index.ts +78 -0
- package/src/adapters/semgrep.ts +150 -0
- package/src/adapters/stryker.ts +218 -0
- package/src/ast/cyclomatic.ts +131 -0
- package/src/ast/index.ts +33 -0
- package/src/ast/language-config.ts +231 -0
- package/src/ast/tree-sitter-engine.ts +385 -0
- package/src/config.ts +109 -0
- package/src/crap-config.ts +196 -0
- package/src/dashboard/public/index.html +368 -0
- package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
- package/src/dashboard/server.ts +205 -0
- package/src/index.ts +696 -0
- package/src/metrics/crap.ts +101 -0
- package/src/metrics/index.ts +51 -0
- package/src/metrics/score.ts +329 -0
- package/src/metrics/tdr.ts +155 -0
- package/src/metrics/workspace-walker.ts +146 -0
- package/src/sarif/index.ts +31 -0
- package/src/sarif/sarif-builder.ts +139 -0
- package/src/sarif/sarif-store.ts +347 -0
- package/src/sarif/sarif-validator.ts +145 -0
- package/src/schemas/tool-schemas.ts +225 -0
- package/src/sdk.ts +110 -0
- package/src/tests/adapters/bandit.test.ts +111 -0
- package/src/tests/adapters/dispatch.test.ts +100 -0
- package/src/tests/adapters/eslint.test.ts +138 -0
- package/src/tests/adapters/semgrep.test.ts +125 -0
- package/src/tests/adapters/stryker.test.ts +103 -0
- package/src/tests/crap-config.test.ts +228 -0
- package/src/tests/crap.test.ts +59 -0
- package/src/tests/cyclomatic.test.ts +87 -0
- package/src/tests/dashboard-http.test.ts +108 -0
- package/src/tests/dashboard-integrity.test.ts +128 -0
- package/src/tests/integration/mcp-server.integration.test.ts +352 -0
- package/src/tests/pre-tool-use-hook.test.ts +178 -0
- package/src/tests/sarif-store.test.ts +241 -0
- package/src/tests/sarif-validator.test.ts +164 -0
- package/src/tests/score.test.ts +260 -0
- package/src/tests/skills-frontmatter.test.ts +172 -0
- package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
- package/src/tests/tdr.test.ts +86 -0
- package/src/tests/test-harness.test.ts +153 -0
- package/src/tests/workspace-guard.test.ts +111 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/test-harness.ts +158 -0
- package/src/workspace-guard.ts +64 -0
- 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
|
+
});
|