@comity-dev/validate 0.2.1 → 0.2.3
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/README.md +21 -4
- package/dist/engines/semgrep.d.ts +33 -1
- package/dist/engines/semgrep.js +518 -29
- package/dist/run.js +23 -8
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -50,6 +50,11 @@ comity-validate --only=schema # one engine only
|
|
|
50
50
|
comity-validate --only=eslint --verbose
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
`--only <category>` runs a single engine. Policies owned by another engine are
|
|
54
|
+
not evaluated: `--only semgrep` does not check ESLint-owned policy, including
|
|
55
|
+
the required `@internal` marker rule (`@comity-dev/require-internal-marker`).
|
|
56
|
+
Use the default run (no `--only`) for full policy coverage.
|
|
57
|
+
|
|
53
58
|
Exit codes:
|
|
54
59
|
|
|
55
60
|
- `0` — validation passed
|
|
@@ -61,10 +66,11 @@ Exit codes:
|
|
|
61
66
|
|
|
62
67
|
The Semgrep engine shells out to a local Semgrep installation:
|
|
63
68
|
|
|
64
|
-
| Variable
|
|
65
|
-
|
|
|
66
|
-
| `SEMGREP_BIN`
|
|
67
|
-
| `SEMGREP_PYTHON`
|
|
69
|
+
| Variable | Purpose |
|
|
70
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
71
|
+
| `SEMGREP_BIN` | Path to the `semgrep` executable. Used as-is when it exists; otherwise resolved from PATH. |
|
|
72
|
+
| `SEMGREP_PYTHON` | Path to the Python interpreter that ships the `semgrep` module. The scan is executed with it. |
|
|
73
|
+
| `COMITY_SEMGREP_TIMEOUT_MS` | Overall scan timeout in milliseconds. Defaults to `300000` (five minutes). Must be an integer between `1` and `2147483647`; invalid or oversized values are reported as `CONFIGURATION-ERROR`. |
|
|
68
74
|
|
|
69
75
|
Rules:
|
|
70
76
|
|
|
@@ -77,6 +83,17 @@ Rules:
|
|
|
77
83
|
then `python3` on PATH).
|
|
78
84
|
- The scan `PATH` is prefixed with the directories of the resolved
|
|
79
85
|
`SEMGREP_BIN` and interpreter so Semgrep finds its sibling `pysemgrep`.
|
|
86
|
+
- Every scan has an overall liveness bound (`COMITY_SEMGREP_TIMEOUT_MS`). A
|
|
87
|
+
timeout is reported as `EXECUTION-ERROR`, never as PASS. On POSIX the
|
|
88
|
+
scanner's process group is terminated so Semgrep's Python and `semgrep-core`
|
|
89
|
+
descendants do not remain running; on Windows only the immediate child can
|
|
90
|
+
be signalled, so descendant termination is best-effort.
|
|
91
|
+
- `--verbose` emits a stderr heartbeat every ten seconds while a scan is
|
|
92
|
+
running; the final summary remains on stdout.
|
|
93
|
+
- A file Semgrep could not fully parse (`PartialParsing`) is reported as
|
|
94
|
+
`EXECUTION-ERROR` with a `SEM-PARTIAL-PARSE` finding naming the file, never
|
|
95
|
+
as PASS: a partially parsed file may have been skipped by AST-based rules,
|
|
96
|
+
so the scan is incomplete rather than clean.
|
|
80
97
|
|
|
81
98
|
```bash
|
|
82
99
|
SEMGREP_BIN=~/.local/bin/semgrep SEMGREP_PYTHON=/opt/homebrew/bin/python3 \
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import type { Repository } from "../discover.js";
|
|
2
2
|
import type { EngineResult } from "./types.js";
|
|
3
|
+
/** Default overall Semgrep scan timeout: five minutes. */
|
|
4
|
+
export declare const DEFAULT_SEMGREP_TIMEOUT_MS = 300000;
|
|
5
|
+
/** Maximum single Node.js timer value, in milliseconds (2^31 - 1). */
|
|
6
|
+
export declare const MAX_SEMGREP_TIMEOUT_MS = 2147483647;
|
|
7
|
+
export interface SemgrepOptions {
|
|
8
|
+
/** Overall scan timeout in milliseconds; overrides COMITY_SEMGREP_TIMEOUT_MS. */
|
|
9
|
+
readonly timeoutMs?: number;
|
|
10
|
+
/** Emit a heartbeat on stderr while the scan is running. */
|
|
11
|
+
readonly verbose?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface SemgrepTimeoutResolution {
|
|
14
|
+
/** Validated timeout in milliseconds. */
|
|
15
|
+
readonly timeoutMs: number;
|
|
16
|
+
/** Configuration error, if the configured timeout is invalid. */
|
|
17
|
+
readonly error?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the overall Semgrep timeout from an explicit override or the
|
|
21
|
+
* `COMITY_SEMGREP_TIMEOUT_MS` environment variable.
|
|
22
|
+
*
|
|
23
|
+
* @param env - Environment to read
|
|
24
|
+
* @param override - Test/embedding override; when supplied, it wins
|
|
25
|
+
*
|
|
26
|
+
* @returns A supported timeout in milliseconds, or a configuration error
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* The timeout is deliberately bounded. A value of zero would disable the
|
|
30
|
+
* liveness guarantee, and values above Node's 32-bit timer limit would be
|
|
31
|
+
* clamped to 1 ms, so both are rejected. Invalid values are reported as
|
|
32
|
+
* configuration errors and never fall back silently.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveSemgrepTimeout(env?: NodeJS.ProcessEnv, override?: number): SemgrepTimeoutResolution;
|
|
3
35
|
/**
|
|
4
36
|
* Derive the Python interpreter from a semgrep executable's shebang
|
|
5
37
|
* line. Shim installs (Homebrew, pipx, venv) point `#!` at the
|
|
@@ -37,4 +69,4 @@ export declare function resolveInterpreterFromBin(binPath: string, env?: NodeJS.
|
|
|
37
69
|
* report it as invalid configuration rather than falling back silently.
|
|
38
70
|
*/
|
|
39
71
|
export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv, binPath?: string): string | null;
|
|
40
|
-
export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
|
|
72
|
+
export declare function runSemgrep(repo: Repository, options?: SemgrepOptions): Promise<EngineResult>;
|
package/dist/engines/semgrep.js
CHANGED
|
@@ -1,7 +1,222 @@
|
|
|
1
1
|
import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { relative, resolve } from "node:path";
|
|
5
|
+
/** Default overall Semgrep scan timeout: five minutes. */
|
|
6
|
+
export const DEFAULT_SEMGREP_TIMEOUT_MS = 300_000;
|
|
7
|
+
/** Maximum single Node.js timer value, in milliseconds (2^31 - 1). */
|
|
8
|
+
export const MAX_SEMGREP_TIMEOUT_MS = 2_147_483_647;
|
|
9
|
+
/** Time allowed for a timed-out process group to exit before SIGKILL. */
|
|
10
|
+
const SEMGREP_TERMINATION_GRACE_MS = 2_000;
|
|
11
|
+
/** Frequency of optional verbose heartbeats. */
|
|
12
|
+
const SEMGREP_HEARTBEAT_MS = 10_000;
|
|
13
|
+
/** Preserve the previous spawnSync maxBuffer ceiling for the async scanner. */
|
|
14
|
+
const MAX_SEMGREP_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the overall Semgrep timeout from an explicit override or the
|
|
17
|
+
* `COMITY_SEMGREP_TIMEOUT_MS` environment variable.
|
|
18
|
+
*
|
|
19
|
+
* @param env - Environment to read
|
|
20
|
+
* @param override - Test/embedding override; when supplied, it wins
|
|
21
|
+
*
|
|
22
|
+
* @returns A supported timeout in milliseconds, or a configuration error
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* The timeout is deliberately bounded. A value of zero would disable the
|
|
26
|
+
* liveness guarantee, and values above Node's 32-bit timer limit would be
|
|
27
|
+
* clamped to 1 ms, so both are rejected. Invalid values are reported as
|
|
28
|
+
* configuration errors and never fall back silently.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveSemgrepTimeout(env = process.env, override) {
|
|
31
|
+
if (override !== undefined) {
|
|
32
|
+
if (!Number.isSafeInteger(override) ||
|
|
33
|
+
override <= 0 ||
|
|
34
|
+
override > MAX_SEMGREP_TIMEOUT_MS) {
|
|
35
|
+
return {
|
|
36
|
+
timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS,
|
|
37
|
+
error: `timeoutMs must be an integer between 1 and ${MAX_SEMGREP_TIMEOUT_MS}; received ${String(override)}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { timeoutMs: override };
|
|
41
|
+
}
|
|
42
|
+
const raw = env["COMITY_SEMGREP_TIMEOUT_MS"];
|
|
43
|
+
if (raw === undefined) {
|
|
44
|
+
return { timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS };
|
|
45
|
+
}
|
|
46
|
+
const trimmed = raw.trim();
|
|
47
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
48
|
+
return {
|
|
49
|
+
timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS,
|
|
50
|
+
error: `COMITY_SEMGREP_TIMEOUT_MS must be an integer between 1 and ${MAX_SEMGREP_TIMEOUT_MS}; received "${raw}"`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const parsed = Number(trimmed);
|
|
54
|
+
if (!Number.isSafeInteger(parsed) ||
|
|
55
|
+
parsed <= 0 ||
|
|
56
|
+
parsed > MAX_SEMGREP_TIMEOUT_MS) {
|
|
57
|
+
return {
|
|
58
|
+
timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS,
|
|
59
|
+
error: `COMITY_SEMGREP_TIMEOUT_MS must be an integer between 1 and ${MAX_SEMGREP_TIMEOUT_MS}; received "${raw}"`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { timeoutMs: parsed };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Terminate a Semgrep child process and, on POSIX, its process group.
|
|
66
|
+
*
|
|
67
|
+
* @param child - Child process started by the scanner
|
|
68
|
+
* @param signal - Signal to send
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* The scanner is started with `detached: true` on POSIX so its Python and
|
|
72
|
+
* semgrep-core descendants share a process group. Killing the group prevents
|
|
73
|
+
* orphaned semgrep-core processes after a timeout. Windows has no equivalent
|
|
74
|
+
* process-group primitive in Node; there only the immediate child is signalled.
|
|
75
|
+
*/
|
|
76
|
+
function terminateSemgrepProcess(child, signal) {
|
|
77
|
+
if (child.pid === undefined)
|
|
78
|
+
return;
|
|
79
|
+
if (process.platform === "win32") {
|
|
80
|
+
try {
|
|
81
|
+
child.kill(signal);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// The child may already have exited.
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
process.kill(-child.pid, signal);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
try {
|
|
93
|
+
child.kill(signal);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// The process group may already have exited.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Run the Semgrep subprocess with an overall timeout and bounded output.
|
|
102
|
+
*
|
|
103
|
+
* @param command - Interpreter to execute
|
|
104
|
+
* @param args - Interpreter arguments
|
|
105
|
+
* @param options - Working directory, environment, timeout, and verbosity
|
|
106
|
+
*
|
|
107
|
+
* @returns Captured output and termination details
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* A timeout sends SIGTERM to the process group, then SIGKILL after a short
|
|
111
|
+
* grace period. The promise always settles; a close event normally does so
|
|
112
|
+
* sooner, while the grace timer is a hard ceiling. On Windows only the direct
|
|
113
|
+
* child can be signalled, so descendant termination is best-effort there.
|
|
114
|
+
*/
|
|
115
|
+
function runSemgrepProcess(command, args, options) {
|
|
116
|
+
return new Promise((resolvePromise) => {
|
|
117
|
+
let child;
|
|
118
|
+
try {
|
|
119
|
+
child = spawn(command, args, {
|
|
120
|
+
cwd: options.cwd,
|
|
121
|
+
env: options.env,
|
|
122
|
+
detached: process.platform !== "win32",
|
|
123
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
resolvePromise({
|
|
128
|
+
stdout: "",
|
|
129
|
+
stderr: "",
|
|
130
|
+
status: null,
|
|
131
|
+
signal: null,
|
|
132
|
+
timedOut: false,
|
|
133
|
+
spawnError: error instanceof Error ? error : new Error(String(error)),
|
|
134
|
+
outputLimitExceeded: false,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let stdout = "";
|
|
139
|
+
let stderr = "";
|
|
140
|
+
let timedOut = false;
|
|
141
|
+
let spawnError = null;
|
|
142
|
+
let outputLimitExceeded = false;
|
|
143
|
+
let settled = false;
|
|
144
|
+
let timeoutTimer;
|
|
145
|
+
let forceTimer;
|
|
146
|
+
let heartbeatTimer;
|
|
147
|
+
const started = Date.now();
|
|
148
|
+
const clearTimers = () => {
|
|
149
|
+
if (timeoutTimer !== undefined)
|
|
150
|
+
clearTimeout(timeoutTimer);
|
|
151
|
+
if (forceTimer !== undefined)
|
|
152
|
+
clearTimeout(forceTimer);
|
|
153
|
+
if (heartbeatTimer !== undefined)
|
|
154
|
+
clearInterval(heartbeatTimer);
|
|
155
|
+
};
|
|
156
|
+
const finish = (status, signal) => {
|
|
157
|
+
if (settled)
|
|
158
|
+
return;
|
|
159
|
+
settled = true;
|
|
160
|
+
clearTimers();
|
|
161
|
+
resolvePromise({
|
|
162
|
+
stdout,
|
|
163
|
+
stderr,
|
|
164
|
+
status,
|
|
165
|
+
signal,
|
|
166
|
+
timedOut,
|
|
167
|
+
spawnError,
|
|
168
|
+
outputLimitExceeded,
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
const terminate = () => {
|
|
172
|
+
terminateSemgrepProcess(child, "SIGTERM");
|
|
173
|
+
// A process that ignores SIGTERM must not hold validation open. The
|
|
174
|
+
// hard timer also guarantees the promise settles if no close event
|
|
175
|
+
// arrives; killing the process group is the primary termination path.
|
|
176
|
+
forceTimer = setTimeout(() => {
|
|
177
|
+
terminateSemgrepProcess(child, "SIGKILL");
|
|
178
|
+
finish(null, "SIGKILL");
|
|
179
|
+
}, SEMGREP_TERMINATION_GRACE_MS);
|
|
180
|
+
};
|
|
181
|
+
const appendOutput = (target, chunk) => {
|
|
182
|
+
const text = chunk.toString();
|
|
183
|
+
if (target === "stdout")
|
|
184
|
+
stdout += text;
|
|
185
|
+
else
|
|
186
|
+
stderr += text;
|
|
187
|
+
if (!outputLimitExceeded &&
|
|
188
|
+
Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8") >
|
|
189
|
+
MAX_SEMGREP_OUTPUT_BYTES) {
|
|
190
|
+
outputLimitExceeded = true;
|
|
191
|
+
terminate();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
child.stdout?.on("data", (chunk) => appendOutput("stdout", chunk));
|
|
195
|
+
child.stderr?.on("data", (chunk) => appendOutput("stderr", chunk));
|
|
196
|
+
child.on("error", (error) => {
|
|
197
|
+
spawnError = error;
|
|
198
|
+
finish(null, null);
|
|
199
|
+
});
|
|
200
|
+
child.on("close", (status, signal) => {
|
|
201
|
+
finish(status, signal);
|
|
202
|
+
});
|
|
203
|
+
timeoutTimer = setTimeout(() => {
|
|
204
|
+
timedOut = true;
|
|
205
|
+
terminate();
|
|
206
|
+
}, options.timeoutMs);
|
|
207
|
+
if (options.verbose) {
|
|
208
|
+
heartbeatTimer = setInterval(() => {
|
|
209
|
+
const elapsedSeconds = Math.round((Date.now() - started) / 1000);
|
|
210
|
+
try {
|
|
211
|
+
process.stderr.write(`comity-validate: semgrep scan still running after ${elapsedSeconds}s (timeout ${Math.round(options.timeoutMs / 1000)}s)\n`);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Diagnostics must never change the validation result.
|
|
215
|
+
}
|
|
216
|
+
}, SEMGREP_HEARTBEAT_MS);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
5
220
|
/**
|
|
6
221
|
* Derive the Python interpreter from a semgrep executable's shebang
|
|
7
222
|
* line. Shim installs (Homebrew, pipx, venv) point `#!` at the
|
|
@@ -186,8 +401,21 @@ function buildSemgrepArgs(configPath, target, excludedDirs) {
|
|
|
186
401
|
}
|
|
187
402
|
return args;
|
|
188
403
|
}
|
|
189
|
-
export async function runSemgrep(repo) {
|
|
404
|
+
export async function runSemgrep(repo, options = {}) {
|
|
190
405
|
const start = Date.now();
|
|
406
|
+
const timeout = resolveSemgrepTimeout(process.env, options.timeoutMs);
|
|
407
|
+
if (timeout.error !== undefined) {
|
|
408
|
+
return {
|
|
409
|
+
executed: false,
|
|
410
|
+
exitCode: null,
|
|
411
|
+
passed: false,
|
|
412
|
+
findings: [],
|
|
413
|
+
duration: Date.now() - start,
|
|
414
|
+
status: "CONFIGURATION-ERROR",
|
|
415
|
+
tool: "semgrep",
|
|
416
|
+
reason: `${timeout.error}; the Semgrep scan was not started`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
191
419
|
// Build the combined Semgrep config. Semgrep's CLI accepts a single
|
|
192
420
|
// --config path, so we concatenate all canonical rule files into one
|
|
193
421
|
// synthesized config.
|
|
@@ -281,73 +509,334 @@ export async function runSemgrep(repo) {
|
|
|
281
509
|
};
|
|
282
510
|
}
|
|
283
511
|
const args = buildSemgrepArgs(configPath, target, excludedDirs);
|
|
284
|
-
const
|
|
512
|
+
const outcome = await runSemgrepProcess(pythonBin, args, {
|
|
285
513
|
cwd: repo.root,
|
|
286
514
|
env: { ...process.env, PATH: envPath },
|
|
287
|
-
|
|
288
|
-
|
|
515
|
+
timeoutMs: timeout.timeoutMs,
|
|
516
|
+
verbose: options.verbose === true,
|
|
289
517
|
});
|
|
290
518
|
cleanup();
|
|
291
|
-
const stdout
|
|
292
|
-
const
|
|
293
|
-
const findings =
|
|
519
|
+
const { stdout, stderr } = outcome;
|
|
520
|
+
const parsed = parseSemgrepOutput(stdout, stderr);
|
|
521
|
+
const findings = [...parsed.findings];
|
|
522
|
+
const duration = Date.now() - start;
|
|
523
|
+
if (outcome.spawnError !== null) {
|
|
524
|
+
const message = `semgrep could not be started: ${outcome.spawnError.message}; this is a tool execution failure, not a rule match`;
|
|
525
|
+
findings.push({
|
|
526
|
+
tool: "semgrep",
|
|
527
|
+
rule: "SEM-SPAWN",
|
|
528
|
+
message,
|
|
529
|
+
severity: "error",
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
executed: true,
|
|
533
|
+
exitCode: null,
|
|
534
|
+
passed: false,
|
|
535
|
+
findings,
|
|
536
|
+
duration,
|
|
537
|
+
status: "EXECUTION-ERROR",
|
|
538
|
+
tool: "semgrep",
|
|
539
|
+
reason: message,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (outcome.timedOut) {
|
|
543
|
+
const message = `semgrep exceeded the ${timeout.timeoutMs} ms timeout after ${duration} ms during structural-pattern validation; the process group was terminated. This is a tool execution failure, not a rule match.`;
|
|
544
|
+
findings.push({
|
|
545
|
+
tool: "semgrep",
|
|
546
|
+
rule: "SEM-TIMEOUT",
|
|
547
|
+
message,
|
|
548
|
+
severity: "error",
|
|
549
|
+
});
|
|
550
|
+
return {
|
|
551
|
+
executed: true,
|
|
552
|
+
exitCode: null,
|
|
553
|
+
passed: false,
|
|
554
|
+
findings,
|
|
555
|
+
duration,
|
|
556
|
+
status: "EXECUTION-ERROR",
|
|
557
|
+
tool: "semgrep",
|
|
558
|
+
reason: message,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (outcome.outputLimitExceeded) {
|
|
562
|
+
const message = `semgrep output exceeded ${MAX_SEMGREP_OUTPUT_BYTES} bytes; the process group was terminated. This is a tool execution failure, not a rule match.`;
|
|
563
|
+
findings.push({
|
|
564
|
+
tool: "semgrep",
|
|
565
|
+
rule: "SEM-OUTPUT",
|
|
566
|
+
message,
|
|
567
|
+
severity: "error",
|
|
568
|
+
});
|
|
569
|
+
return {
|
|
570
|
+
executed: true,
|
|
571
|
+
exitCode: null,
|
|
572
|
+
passed: false,
|
|
573
|
+
findings,
|
|
574
|
+
duration,
|
|
575
|
+
status: "EXECUTION-ERROR",
|
|
576
|
+
tool: "semgrep",
|
|
577
|
+
reason: message,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (outcome.signal !== null) {
|
|
581
|
+
const message = `semgrep was terminated by ${outcome.signal}; this is a tool execution failure, not a rule match.`;
|
|
582
|
+
findings.push({
|
|
583
|
+
tool: "semgrep",
|
|
584
|
+
rule: "SEM-SIGNAL",
|
|
585
|
+
message,
|
|
586
|
+
severity: "error",
|
|
587
|
+
});
|
|
588
|
+
return {
|
|
589
|
+
executed: true,
|
|
590
|
+
exitCode: null,
|
|
591
|
+
passed: false,
|
|
592
|
+
findings,
|
|
593
|
+
duration,
|
|
594
|
+
status: "EXECUTION-ERROR",
|
|
595
|
+
tool: "semgrep",
|
|
596
|
+
reason: message,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
// Incomplete analysis takes precedence over the policy verdict. Semgrep
|
|
600
|
+
// reports a partially parsed file as a `level: "warn"` error and still
|
|
601
|
+
// exits 0, so the exit code alone would report a clean pass even though
|
|
602
|
+
// AST-based rules may never have analysed that file. This check runs before
|
|
603
|
+
// the crash/exit-code checks so the affected file is always surfaced.
|
|
604
|
+
if (parsed.partialParsing.length > 0) {
|
|
605
|
+
const first = parsed.partialParsing[0];
|
|
606
|
+
for (const diagnostic of parsed.partialParsing) {
|
|
607
|
+
findings.push({
|
|
608
|
+
tool: "semgrep",
|
|
609
|
+
rule: "SEM-PARTIAL-PARSE",
|
|
610
|
+
message: `semgrep could not fully parse ${diagnostic.path}: ${diagnostic.message}. AST-based rules may not have analysed this file, so the result is incomplete.`,
|
|
611
|
+
file: diagnostic.path,
|
|
612
|
+
severity: "error",
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
const exitNote = outcome.status !== null && outcome.status !== 0
|
|
616
|
+
? `; semgrep exited with code ${outcome.status}`
|
|
617
|
+
: "";
|
|
618
|
+
const message = `semgrep could not fully parse ${parsed.partialParsing.length} file(s) (first: ${first?.path ?? "<unknown file>"}); the scan is incomplete, so this category cannot report PASS${exitNote}. This is an incomplete-analysis failure, not a rule match.`;
|
|
619
|
+
return {
|
|
620
|
+
executed: true,
|
|
621
|
+
exitCode: outcome.status,
|
|
622
|
+
passed: false,
|
|
623
|
+
findings,
|
|
624
|
+
duration,
|
|
625
|
+
status: "EXECUTION-ERROR",
|
|
626
|
+
tool: "semgrep",
|
|
627
|
+
reason: message,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
294
630
|
// If semgrep exited non-zero but produced no parseable findings, this
|
|
295
631
|
// is an EXECUTION-ERROR (configuration, rule-parse, internal crash),
|
|
296
632
|
// not a policy violation.
|
|
297
|
-
if (
|
|
633
|
+
if (outcome.status !== null && outcome.status !== 0 && findings.length === 0) {
|
|
298
634
|
const stderrHead = stderr.split("\n")[0] ?? "";
|
|
299
635
|
const stdoutHead = stdout.slice(0, 500);
|
|
300
636
|
findings.push({
|
|
301
637
|
tool: "semgrep",
|
|
302
638
|
rule: "SEM-CRASH",
|
|
303
|
-
message: `semgrep exited with code ${
|
|
639
|
+
message: `semgrep exited with code ${outcome.status}; stderr: ${stderrHead}; stdout (first 500): ${stdoutHead}`,
|
|
304
640
|
severity: "error",
|
|
305
641
|
});
|
|
306
642
|
return {
|
|
307
643
|
executed: true,
|
|
308
|
-
exitCode:
|
|
644
|
+
exitCode: outcome.status,
|
|
309
645
|
passed: false,
|
|
310
646
|
findings,
|
|
311
|
-
duration
|
|
647
|
+
duration,
|
|
648
|
+
status: "EXECUTION-ERROR",
|
|
649
|
+
tool: "semgrep",
|
|
650
|
+
reason: `semgrep exited with code ${outcome.status} before producing findings (stderr: ${stderrHead}); this is a tool execution failure, not a rule match — see the SEM-CRASH finding`,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (outcome.status === null) {
|
|
654
|
+
const message = "semgrep terminated without an exit code; this is a tool execution failure, not a rule match.";
|
|
655
|
+
findings.push({
|
|
656
|
+
tool: "semgrep",
|
|
657
|
+
rule: "SEM-CRASH",
|
|
658
|
+
message,
|
|
659
|
+
severity: "error",
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
executed: true,
|
|
663
|
+
exitCode: null,
|
|
664
|
+
passed: false,
|
|
665
|
+
findings,
|
|
666
|
+
duration,
|
|
312
667
|
status: "EXECUTION-ERROR",
|
|
313
668
|
tool: "semgrep",
|
|
314
|
-
reason:
|
|
669
|
+
reason: message,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (parsed.malformed) {
|
|
673
|
+
const stdoutHead = stdout.slice(0, 500);
|
|
674
|
+
const message = `semgrep produced malformed JSON output (first 500 chars: ${stdoutHead}); this is a tool execution failure, not a rule match.`;
|
|
675
|
+
findings.push({
|
|
676
|
+
tool: "semgrep",
|
|
677
|
+
rule: "SEM-MALFORMED",
|
|
678
|
+
message,
|
|
679
|
+
severity: "error",
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
executed: true,
|
|
683
|
+
exitCode: outcome.status,
|
|
684
|
+
passed: false,
|
|
685
|
+
findings,
|
|
686
|
+
duration,
|
|
687
|
+
status: "EXECUTION-ERROR",
|
|
688
|
+
tool: "semgrep",
|
|
689
|
+
reason: message,
|
|
315
690
|
};
|
|
316
691
|
}
|
|
317
692
|
return {
|
|
318
693
|
executed: true,
|
|
319
|
-
exitCode:
|
|
320
|
-
passed:
|
|
694
|
+
exitCode: outcome.status,
|
|
695
|
+
passed: outcome.status === 0 && findings.length === 0,
|
|
321
696
|
findings,
|
|
322
|
-
duration
|
|
323
|
-
status:
|
|
697
|
+
duration,
|
|
698
|
+
status: outcome.status === 0 && findings.length === 0 ? "PASS" : "FAIL",
|
|
324
699
|
tool: "semgrep",
|
|
325
700
|
};
|
|
326
701
|
}
|
|
702
|
+
function isRecord(value) {
|
|
703
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
704
|
+
}
|
|
705
|
+
/** Validate the result fields consumed by this validator. */
|
|
706
|
+
function isSemgrepResult(value) {
|
|
707
|
+
if (!isRecord(value))
|
|
708
|
+
return false;
|
|
709
|
+
const start = value["start"];
|
|
710
|
+
return (typeof value["check_id"] === "string" &&
|
|
711
|
+
typeof value["path"] === "string" &&
|
|
712
|
+
isRecord(start) &&
|
|
713
|
+
typeof start["line"] === "number" &&
|
|
714
|
+
typeof start["col"] === "number");
|
|
715
|
+
}
|
|
716
|
+
/** Validate the error fields consumed by this validator. */
|
|
717
|
+
function isSemgrepError(value) {
|
|
718
|
+
if (!isRecord(value))
|
|
719
|
+
return false;
|
|
720
|
+
if (typeof value["level"] !== "string")
|
|
721
|
+
return false;
|
|
722
|
+
if ("rule_id" in value && typeof value["rule_id"] !== "string")
|
|
723
|
+
return false;
|
|
724
|
+
if ("message" in value && typeof value["message"] !== "string")
|
|
725
|
+
return false;
|
|
726
|
+
// Semgrep encodes `type` as a tag name or a `[tagName, ...]` tuple; this
|
|
727
|
+
// validator now reads it to detect partial parsing, so anything else is
|
|
728
|
+
// treated as malformed rather than silently ignored.
|
|
729
|
+
if ("type" in value && !isSemgrepErrorType(value["type"]))
|
|
730
|
+
return false;
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
/** Whether a Semgrep error `type` field has a supported shape. */
|
|
734
|
+
function isSemgrepErrorType(value) {
|
|
735
|
+
if (typeof value === "string")
|
|
736
|
+
return true;
|
|
737
|
+
return Array.isArray(value) && typeof value[0] === "string";
|
|
738
|
+
}
|
|
739
|
+
/** The tag name of a Semgrep error `type` field, when it is well formed. */
|
|
740
|
+
function semgrepErrorTypeName(value) {
|
|
741
|
+
if (typeof value === "string")
|
|
742
|
+
return value;
|
|
743
|
+
if (Array.isArray(value) && typeof value[0] === "string")
|
|
744
|
+
return value[0];
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Whether an error entry reports a file Semgrep could not fully parse.
|
|
749
|
+
*
|
|
750
|
+
* @remarks
|
|
751
|
+
* Semgrep 1.180.0 reports partial parsing at `level: "warn"` with the
|
|
752
|
+
* `PartialParsing` tag name while still exiting `0`, so the exit code alone
|
|
753
|
+
* cannot detect it. The tag name is the primary signal; a warning that names
|
|
754
|
+
* a syntax error is treated the same way so Semgrep versions that omit the
|
|
755
|
+
* tag name cannot silently skip a file. Warnings that do not indicate
|
|
756
|
+
* incomplete analysis keep their existing (non-fatal) treatment.
|
|
757
|
+
*/
|
|
758
|
+
function isIncompleteAnalysisError(entry) {
|
|
759
|
+
const type = semgrepErrorTypeName(entry["type"]);
|
|
760
|
+
if (type !== null && /partialparsing/i.test(type))
|
|
761
|
+
return true;
|
|
762
|
+
const message = entry["message"];
|
|
763
|
+
return typeof message === "string" && /\bsyntax error\b/i.test(message);
|
|
764
|
+
}
|
|
765
|
+
/** The file an error entry refers to, when Semgrep reports one. */
|
|
766
|
+
function errorEntryPath(entry) {
|
|
767
|
+
const direct = entry["path"];
|
|
768
|
+
if (typeof direct === "string" && direct.length > 0)
|
|
769
|
+
return direct;
|
|
770
|
+
const spans = entry["spans"];
|
|
771
|
+
if (Array.isArray(spans)) {
|
|
772
|
+
for (const span of spans) {
|
|
773
|
+
if (isRecord(span) && typeof span["file"] === "string") {
|
|
774
|
+
return span["file"];
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const locations = Array.isArray(entry["type"]) ? entry["type"][1] : undefined;
|
|
779
|
+
if (Array.isArray(locations)) {
|
|
780
|
+
for (const location of locations) {
|
|
781
|
+
if (isRecord(location) && typeof location["path"] === "string") {
|
|
782
|
+
return location["path"];
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return "<unknown file>";
|
|
787
|
+
}
|
|
327
788
|
function parseSemgrepOutput(stdout, stderr) {
|
|
328
|
-
const out = stdout
|
|
789
|
+
const out = stdout?.trim() ? stdout : (stderr ?? "");
|
|
329
790
|
if (!out.trim())
|
|
330
|
-
return [];
|
|
331
|
-
let
|
|
791
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
792
|
+
let parsedValue;
|
|
332
793
|
try {
|
|
333
|
-
|
|
794
|
+
parsedValue = JSON.parse(out);
|
|
334
795
|
}
|
|
335
796
|
catch {
|
|
336
|
-
return [];
|
|
797
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
798
|
+
}
|
|
799
|
+
if (!isRecord(parsedValue)) {
|
|
800
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
801
|
+
}
|
|
802
|
+
const rawResults = parsedValue["results"];
|
|
803
|
+
const rawErrors = parsedValue["errors"];
|
|
804
|
+
if (!Array.isArray(rawResults)) {
|
|
805
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
806
|
+
}
|
|
807
|
+
if (rawErrors !== undefined && !Array.isArray(rawErrors)) {
|
|
808
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
809
|
+
}
|
|
810
|
+
if (!rawResults.every(isSemgrepResult)) {
|
|
811
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
812
|
+
}
|
|
813
|
+
if (rawErrors !== undefined && !rawErrors.every(isSemgrepError)) {
|
|
814
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
337
815
|
}
|
|
338
816
|
const findings = [];
|
|
339
|
-
|
|
340
|
-
|
|
817
|
+
const partialParsing = [];
|
|
818
|
+
for (const e of (rawErrors ?? [])) {
|
|
819
|
+
if (e["level"] === "error") {
|
|
341
820
|
findings.push({
|
|
342
821
|
tool: "semgrep",
|
|
343
|
-
rule: `SEM-PARSE-${e
|
|
344
|
-
message: e
|
|
822
|
+
rule: `SEM-PARSE-${typeof e["rule_id"] === "string" ? e["rule_id"] : "UNKNOWN"}`,
|
|
823
|
+
message: typeof e["message"] === "string"
|
|
824
|
+
? e["message"]
|
|
825
|
+
: "semgrep rule parse error",
|
|
345
826
|
severity: "error",
|
|
346
827
|
});
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (isIncompleteAnalysisError(e)) {
|
|
831
|
+
partialParsing.push({
|
|
832
|
+
path: errorEntryPath(e),
|
|
833
|
+
message: typeof e["message"] === "string"
|
|
834
|
+
? e["message"]
|
|
835
|
+
: "semgrep reported a file it could not fully parse",
|
|
836
|
+
});
|
|
347
837
|
}
|
|
348
838
|
}
|
|
349
|
-
const
|
|
350
|
-
for (const r of results) {
|
|
839
|
+
for (const r of rawResults) {
|
|
351
840
|
const start = r["start"];
|
|
352
841
|
const extra = r["extra"];
|
|
353
842
|
findings.push({
|
|
@@ -360,5 +849,5 @@ function parseSemgrepOutput(stdout, stderr) {
|
|
|
360
849
|
severity: extra?.severity === "ERROR" ? "error" : "warning",
|
|
361
850
|
});
|
|
362
851
|
}
|
|
363
|
-
return findings;
|
|
852
|
+
return { findings, malformed: false, partialParsing };
|
|
364
853
|
}
|
package/dist/run.js
CHANGED
|
@@ -18,6 +18,21 @@ function categoryOf(result) {
|
|
|
18
18
|
reason: result.reason,
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a category is acceptable for the overall validation result.
|
|
23
|
+
*
|
|
24
|
+
* A skipped category is acceptable only when it is explicitly NOT-RUN or
|
|
25
|
+
* NOT-APPLICABLE, or when a required tool is unavailable (which the exit-code
|
|
26
|
+
* mapping reports separately). Configuration and execution errors are never
|
|
27
|
+
* acceptable merely because they did not execute.
|
|
28
|
+
*/
|
|
29
|
+
function categoryIsAcceptable(result) {
|
|
30
|
+
if (result.executed)
|
|
31
|
+
return result.passed;
|
|
32
|
+
return (result.status === "NOT-RUN" ||
|
|
33
|
+
result.status === "NOT-APPLICABLE" ||
|
|
34
|
+
result.status === "TOOL-UNAVAILABLE");
|
|
35
|
+
}
|
|
21
36
|
function emptyCategory(reason) {
|
|
22
37
|
return {
|
|
23
38
|
status: "NOT-RUN",
|
|
@@ -88,7 +103,7 @@ export async function runValidation(options) {
|
|
|
88
103
|
}
|
|
89
104
|
let semgrepResult;
|
|
90
105
|
if (!only || only === "semgrep") {
|
|
91
|
-
semgrepResult = await runSemgrep(repo);
|
|
106
|
+
semgrepResult = await runSemgrep(repo, { verbose: options.verbose === true });
|
|
92
107
|
}
|
|
93
108
|
else {
|
|
94
109
|
semgrepResult = emptyEngineResult("semgrep engine skipped");
|
|
@@ -113,11 +128,9 @@ export async function runValidation(options) {
|
|
|
113
128
|
}
|
|
114
129
|
}
|
|
115
130
|
const allExecuted = [depResult, eslintResult, semgrepResult, adapterResult];
|
|
116
|
-
const allPassed = allExecuted.every(
|
|
117
|
-
(metaResult
|
|
118
|
-
(configResult
|
|
119
|
-
? configResult.passed
|
|
120
|
-
: configResult.status !== "FAIL");
|
|
131
|
+
const allPassed = allExecuted.every(categoryIsAcceptable) &&
|
|
132
|
+
categoryIsAcceptable(metaResult) &&
|
|
133
|
+
categoryIsAcceptable(configResult);
|
|
121
134
|
return {
|
|
122
135
|
passed: allPassed,
|
|
123
136
|
violations,
|
|
@@ -152,8 +165,10 @@ function emptyEngineResult(reason) {
|
|
|
152
165
|
* fabricating a passing result.
|
|
153
166
|
*/
|
|
154
167
|
export function exitCodeForResult(result) {
|
|
155
|
-
if (!result.passed)
|
|
156
|
-
|
|
168
|
+
if (!result.passed) {
|
|
169
|
+
const configurationError = Object.values(result.categories).some((category) => category.status === "CONFIGURATION-ERROR");
|
|
170
|
+
return configurationError ? ExitCode.CONFIG_ERROR : ExitCode.FAIL;
|
|
171
|
+
}
|
|
157
172
|
const toolUnavailable = Object.values(result.categories).some((c) => c.status === "TOOL-UNAVAILABLE");
|
|
158
173
|
return toolUnavailable ? ExitCode.TOOL_MISSING : ExitCode.PASS;
|
|
159
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comity-dev/validate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -31,20 +31,20 @@
|
|
|
31
31
|
"./dist"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@typescript-eslint/parser": "^8.69.0",
|
|
35
|
-
"dependency-cruiser": "^16.10.4",
|
|
36
|
-
"eslint": "^9.39.5",
|
|
37
34
|
"@comity-dev/schemas": "0.2.0",
|
|
38
35
|
"@comity-dev/dependency-rules": "0.2.0",
|
|
39
|
-
"@comity-dev/eslint-plugin": "0.
|
|
40
|
-
"@comity-dev/semgrep-rules": "0.2.
|
|
36
|
+
"@comity-dev/eslint-plugin": "0.3.0",
|
|
37
|
+
"@comity-dev/semgrep-rules": "0.2.3",
|
|
38
|
+
"@typescript-eslint/parser": "^8.69.0",
|
|
39
|
+
"dependency-cruiser": "^16.10.4",
|
|
40
|
+
"eslint": "^9.39.5"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^24.13.3",
|
|
44
44
|
"typescript": "^5.9.3"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
|
-
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
48
48
|
"test": "vitest run"
|
|
49
49
|
}
|
|
50
50
|
}
|