@comity-dev/validate 0.2.0 → 0.2.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/README.md +21 -4
- package/dist/bin/comity-validate.js +5 -2
- package/dist/engines/semgrep.d.ts +57 -3
- package/dist/engines/semgrep.js +616 -44
- package/dist/format.js +14 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/run.d.ts +10 -0
- package/dist/run.js +36 -6
- package/package.json +3 -3
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 \
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { resolve } from "node:path";
|
|
27
27
|
import { ExitCode } from "../exit-codes.js";
|
|
28
28
|
import { formatSummary, formatViolations } from "../format.js";
|
|
29
|
-
import { runValidation } from "../run.js";
|
|
29
|
+
import { exitCodeForResult, runValidation } from "../run.js";
|
|
30
30
|
function parseArgs(argv) {
|
|
31
31
|
const args = {
|
|
32
32
|
root: process.cwd(),
|
|
@@ -88,7 +88,10 @@ async function main() {
|
|
|
88
88
|
console.log("");
|
|
89
89
|
console.log(formatViolations(result.violations));
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
// A required tool that could not run must never surface as PASS:
|
|
92
|
+
// report it with the documented TOOL_MISSING exit code instead of
|
|
93
|
+
// fabricating a passing result.
|
|
94
|
+
process.exit(exitCodeForResult(result));
|
|
92
95
|
}
|
|
93
96
|
catch (err) {
|
|
94
97
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1,18 +1,72 @@
|
|
|
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;
|
|
35
|
+
/**
|
|
36
|
+
* Derive the Python interpreter from a semgrep executable's shebang
|
|
37
|
+
* line. Shim installs (Homebrew, pipx, venv) point `#!` at the
|
|
38
|
+
* interpreter that ships the `semgrep` module; resolving it keeps the
|
|
39
|
+
* scan on a working interpreter instead of an unrelated system python
|
|
40
|
+
* that merely exists on PATH.
|
|
41
|
+
*
|
|
42
|
+
* @param binPath - Path to the semgrep executable.
|
|
43
|
+
* @param env - Environment to read for `/usr/bin/env` resolution.
|
|
44
|
+
*
|
|
45
|
+
* @returns The interpreter path when the file starts with a usable
|
|
46
|
+
* `#!` line naming an existing executable, otherwise `null`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveInterpreterFromBin(binPath: string, env?: NodeJS.ProcessEnv): string | null;
|
|
3
49
|
/**
|
|
4
50
|
* Discover the Python interpreter that ships the `semgrep` module.
|
|
5
51
|
*
|
|
6
52
|
* Selection order:
|
|
7
53
|
* 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
|
|
8
54
|
* configuration always wins and is never silently overridden.
|
|
9
|
-
* 2.
|
|
55
|
+
* 2. The interpreter named by the semgrep executable's shebang line
|
|
56
|
+
* (shim installs point at the interpreter that ships the module).
|
|
57
|
+
* 3. Well-known absolute paths, then `python3` on PATH (historic default).
|
|
58
|
+
*
|
|
59
|
+
* Every auto-discovered candidate (steps 2-3) must both exist and
|
|
60
|
+
* successfully `import semgrep`; the first candidate that does wins.
|
|
61
|
+
* An explicit `SEMGREP_PYTHON` is honored as-is for backwards
|
|
62
|
+
* compatibility.
|
|
10
63
|
*
|
|
11
64
|
* @param env - Environment to read. Defaults to the process environment.
|
|
65
|
+
* @param binPath - Resolved semgrep executable used for step 2.
|
|
12
66
|
*
|
|
13
67
|
* @returns The selected interpreter path, or `null` when none is available.
|
|
14
68
|
* A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
|
|
15
69
|
* report it as invalid configuration rather than falling back silently.
|
|
16
70
|
*/
|
|
17
|
-
export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv): string | null;
|
|
18
|
-
export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
|
|
71
|
+
export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv, binPath?: string): string | null;
|
|
72
|
+
export declare function runSemgrep(repo: Repository, options?: SemgrepOptions): Promise<EngineResult>;
|
package/dist/engines/semgrep.js
CHANGED
|
@@ -1,41 +1,336 @@
|
|
|
1
1
|
import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
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
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Derive the Python interpreter from a semgrep executable's shebang
|
|
222
|
+
* line. Shim installs (Homebrew, pipx, venv) point `#!` at the
|
|
223
|
+
* interpreter that ships the `semgrep` module; resolving it keeps the
|
|
224
|
+
* scan on a working interpreter instead of an unrelated system python
|
|
225
|
+
* that merely exists on PATH.
|
|
226
|
+
*
|
|
227
|
+
* @param binPath - Path to the semgrep executable.
|
|
228
|
+
* @param env - Environment to read for `/usr/bin/env` resolution.
|
|
229
|
+
*
|
|
230
|
+
* @returns The interpreter path when the file starts with a usable
|
|
231
|
+
* `#!` line naming an existing executable, otherwise `null`.
|
|
232
|
+
*/
|
|
233
|
+
export function resolveInterpreterFromBin(binPath, env = process.env) {
|
|
234
|
+
let head;
|
|
235
|
+
try {
|
|
236
|
+
const fd = openSync(binPath, "r");
|
|
237
|
+
try {
|
|
238
|
+
const buf = Buffer.alloc(2048);
|
|
239
|
+
const read = readSync(fd, buf, 0, buf.length, 0);
|
|
240
|
+
head = buf.subarray(0, read).toString("utf8").split("\n")[0] ?? "";
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
closeSync(fd);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (!head.startsWith("#!"))
|
|
250
|
+
return null;
|
|
251
|
+
const parts = head.slice(2).trim().split(/\s+/).filter(Boolean);
|
|
252
|
+
const [first, second] = parts;
|
|
253
|
+
if (!first)
|
|
254
|
+
return null;
|
|
255
|
+
// `#!/usr/bin/env python3 ...` form: resolve the named interpreter
|
|
256
|
+
// against PATH the same way a shell would.
|
|
257
|
+
if (first === "env" || first.endsWith("/env")) {
|
|
258
|
+
if (!second)
|
|
259
|
+
return null;
|
|
260
|
+
const probe = spawnSync("which", [second], {
|
|
261
|
+
encoding: "utf8",
|
|
262
|
+
env: { ...env, PATH: env["PATH"] ?? "" },
|
|
263
|
+
});
|
|
264
|
+
if (probe.status === 0 && probe.stdout?.trim()) {
|
|
265
|
+
return probe.stdout.trim();
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return existsSync(first) ? first : null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Probe whether an interpreter ships the `semgrep` module, so a
|
|
273
|
+
* resolved python is never assumed to work by mere existence.
|
|
274
|
+
*/
|
|
275
|
+
function interpreterShipsSemgrep(interpreter) {
|
|
276
|
+
try {
|
|
277
|
+
const probe = spawnSync(interpreter, ["-c", "import semgrep"], {
|
|
278
|
+
encoding: "utf8",
|
|
279
|
+
timeout: 30000,
|
|
280
|
+
});
|
|
281
|
+
return probe.status === 0;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
5
287
|
/**
|
|
6
288
|
* Discover the Python interpreter that ships the `semgrep` module.
|
|
7
289
|
*
|
|
8
290
|
* Selection order:
|
|
9
291
|
* 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
|
|
10
292
|
* configuration always wins and is never silently overridden.
|
|
11
|
-
* 2.
|
|
293
|
+
* 2. The interpreter named by the semgrep executable's shebang line
|
|
294
|
+
* (shim installs point at the interpreter that ships the module).
|
|
295
|
+
* 3. Well-known absolute paths, then `python3` on PATH (historic default).
|
|
296
|
+
*
|
|
297
|
+
* Every auto-discovered candidate (steps 2-3) must both exist and
|
|
298
|
+
* successfully `import semgrep`; the first candidate that does wins.
|
|
299
|
+
* An explicit `SEMGREP_PYTHON` is honored as-is for backwards
|
|
300
|
+
* compatibility.
|
|
12
301
|
*
|
|
13
302
|
* @param env - Environment to read. Defaults to the process environment.
|
|
303
|
+
* @param binPath - Resolved semgrep executable used for step 2.
|
|
14
304
|
*
|
|
15
305
|
* @returns The selected interpreter path, or `null` when none is available.
|
|
16
306
|
* A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
|
|
17
307
|
* report it as invalid configuration rather than falling back silently.
|
|
18
308
|
*/
|
|
19
|
-
export function resolvePythonForSemgrep(env = process.env) {
|
|
309
|
+
export function resolvePythonForSemgrep(env = process.env, binPath) {
|
|
20
310
|
const explicit = env["SEMGREP_PYTHON"];
|
|
21
311
|
if (explicit) {
|
|
22
312
|
return existsSync(explicit) ? explicit : null;
|
|
23
313
|
}
|
|
24
|
-
const candidates = [
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
314
|
+
const candidates = [];
|
|
315
|
+
if (binPath) {
|
|
316
|
+
const fromBin = resolveInterpreterFromBin(binPath, env);
|
|
317
|
+
if (fromBin)
|
|
318
|
+
candidates.push(fromBin);
|
|
319
|
+
}
|
|
320
|
+
candidates.push("/usr/bin/python3", "/usr/local/bin/python3", "/opt/homebrew/bin/python3");
|
|
29
321
|
for (const c of candidates) {
|
|
30
|
-
if (existsSync(c))
|
|
322
|
+
if (existsSync(c) && interpreterShipsSemgrep(c))
|
|
31
323
|
return c;
|
|
32
324
|
}
|
|
33
325
|
const probe = spawnSync("which", ["python3"], {
|
|
34
326
|
encoding: "utf8",
|
|
35
327
|
env: { ...env, PATH: env["PATH"] ?? "" },
|
|
36
328
|
});
|
|
37
|
-
if (probe.status === 0 && probe.stdout?.trim())
|
|
38
|
-
|
|
329
|
+
if (probe.status === 0 && probe.stdout?.trim()) {
|
|
330
|
+
const viaPath = probe.stdout.trim();
|
|
331
|
+
if (interpreterShipsSemgrep(viaPath))
|
|
332
|
+
return viaPath;
|
|
333
|
+
}
|
|
39
334
|
return null;
|
|
40
335
|
}
|
|
41
336
|
function findSemgrepBin(fallbackDirs) {
|
|
@@ -106,8 +401,21 @@ function buildSemgrepArgs(configPath, target, excludedDirs) {
|
|
|
106
401
|
}
|
|
107
402
|
return args;
|
|
108
403
|
}
|
|
109
|
-
export async function runSemgrep(repo) {
|
|
404
|
+
export async function runSemgrep(repo, options = {}) {
|
|
110
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
|
+
}
|
|
111
419
|
// Build the combined Semgrep config. Semgrep's CLI accepts a single
|
|
112
420
|
// --config path, so we concatenate all canonical rule files into one
|
|
113
421
|
// synthesized config.
|
|
@@ -140,10 +448,12 @@ export async function runSemgrep(repo) {
|
|
|
140
448
|
reason: "semgrep binary not found on PATH; install semgrep or set SEMGREP_BIN",
|
|
141
449
|
};
|
|
142
450
|
}
|
|
143
|
-
// Locate the Python interpreter that ships the semgrep module
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
451
|
+
// Locate the Python interpreter that ships the semgrep module,
|
|
452
|
+
// preferring the interpreter named by the semgrep executable's own
|
|
453
|
+
// shebang line. An explicit SEMGREP_PYTHON is honored as-is; a
|
|
454
|
+
// set-but-missing value is invalid configuration, never a reason to
|
|
455
|
+
// silently fall back.
|
|
456
|
+
const pythonBin = resolvePythonForSemgrep(process.env, semgrepBin ?? undefined);
|
|
147
457
|
if (!pythonBin) {
|
|
148
458
|
cleanup();
|
|
149
459
|
const explicitPython = process.env["SEMGREP_PYTHON"];
|
|
@@ -157,7 +467,7 @@ export async function runSemgrep(repo) {
|
|
|
157
467
|
tool: "semgrep",
|
|
158
468
|
reason: explicitPython
|
|
159
469
|
? `SEMGREP_PYTHON is set to "${explicitPython}" but that path does not exist; set SEMGREP_PYTHON to a Python interpreter that ships the semgrep module`
|
|
160
|
-
:
|
|
470
|
+
: `No Python interpreter shipping the semgrep module was found (semgrep binary: "${semgrepBin}"); install semgrep so its executable resolves to a working interpreter, or set SEMGREP_PYTHON to a Python interpreter that ships the semgrep module`,
|
|
161
471
|
};
|
|
162
472
|
}
|
|
163
473
|
// Augment PATH so the inner Python process can find semgrep's
|
|
@@ -199,72 +509,334 @@ export async function runSemgrep(repo) {
|
|
|
199
509
|
};
|
|
200
510
|
}
|
|
201
511
|
const args = buildSemgrepArgs(configPath, target, excludedDirs);
|
|
202
|
-
const
|
|
512
|
+
const outcome = await runSemgrepProcess(pythonBin, args, {
|
|
203
513
|
cwd: repo.root,
|
|
204
514
|
env: { ...process.env, PATH: envPath },
|
|
205
|
-
|
|
206
|
-
|
|
515
|
+
timeoutMs: timeout.timeoutMs,
|
|
516
|
+
verbose: options.verbose === true,
|
|
207
517
|
});
|
|
208
518
|
cleanup();
|
|
209
|
-
const stdout
|
|
210
|
-
const
|
|
211
|
-
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
|
+
}
|
|
212
630
|
// If semgrep exited non-zero but produced no parseable findings, this
|
|
213
631
|
// is an EXECUTION-ERROR (configuration, rule-parse, internal crash),
|
|
214
632
|
// not a policy violation.
|
|
215
|
-
if (
|
|
633
|
+
if (outcome.status !== null && outcome.status !== 0 && findings.length === 0) {
|
|
216
634
|
const stderrHead = stderr.split("\n")[0] ?? "";
|
|
217
635
|
const stdoutHead = stdout.slice(0, 500);
|
|
218
636
|
findings.push({
|
|
219
637
|
tool: "semgrep",
|
|
220
638
|
rule: "SEM-CRASH",
|
|
221
|
-
message: `semgrep exited with code ${
|
|
639
|
+
message: `semgrep exited with code ${outcome.status}; stderr: ${stderrHead}; stdout (first 500): ${stdoutHead}`,
|
|
222
640
|
severity: "error",
|
|
223
641
|
});
|
|
224
642
|
return {
|
|
225
643
|
executed: true,
|
|
226
|
-
exitCode:
|
|
644
|
+
exitCode: outcome.status,
|
|
227
645
|
passed: false,
|
|
228
646
|
findings,
|
|
229
|
-
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,
|
|
667
|
+
status: "EXECUTION-ERROR",
|
|
668
|
+
tool: "semgrep",
|
|
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,
|
|
230
687
|
status: "EXECUTION-ERROR",
|
|
231
688
|
tool: "semgrep",
|
|
689
|
+
reason: message,
|
|
232
690
|
};
|
|
233
691
|
}
|
|
234
692
|
return {
|
|
235
693
|
executed: true,
|
|
236
|
-
exitCode:
|
|
237
|
-
passed:
|
|
694
|
+
exitCode: outcome.status,
|
|
695
|
+
passed: outcome.status === 0 && findings.length === 0,
|
|
238
696
|
findings,
|
|
239
|
-
duration
|
|
240
|
-
status:
|
|
697
|
+
duration,
|
|
698
|
+
status: outcome.status === 0 && findings.length === 0 ? "PASS" : "FAIL",
|
|
241
699
|
tool: "semgrep",
|
|
242
700
|
};
|
|
243
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
|
+
}
|
|
244
788
|
function parseSemgrepOutput(stdout, stderr) {
|
|
245
|
-
const out = stdout
|
|
789
|
+
const out = stdout?.trim() ? stdout : (stderr ?? "");
|
|
246
790
|
if (!out.trim())
|
|
247
|
-
return [];
|
|
248
|
-
let
|
|
791
|
+
return { findings: [], malformed: true, partialParsing: [] };
|
|
792
|
+
let parsedValue;
|
|
249
793
|
try {
|
|
250
|
-
|
|
794
|
+
parsedValue = JSON.parse(out);
|
|
251
795
|
}
|
|
252
796
|
catch {
|
|
253
|
-
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: [] };
|
|
254
815
|
}
|
|
255
816
|
const findings = [];
|
|
256
|
-
|
|
257
|
-
|
|
817
|
+
const partialParsing = [];
|
|
818
|
+
for (const e of (rawErrors ?? [])) {
|
|
819
|
+
if (e["level"] === "error") {
|
|
258
820
|
findings.push({
|
|
259
821
|
tool: "semgrep",
|
|
260
|
-
rule: `SEM-PARSE-${e
|
|
261
|
-
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",
|
|
262
826
|
severity: "error",
|
|
263
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
|
+
});
|
|
264
837
|
}
|
|
265
838
|
}
|
|
266
|
-
const
|
|
267
|
-
for (const r of results) {
|
|
839
|
+
for (const r of rawResults) {
|
|
268
840
|
const start = r["start"];
|
|
269
841
|
const extra = r["extra"];
|
|
270
842
|
findings.push({
|
|
@@ -277,5 +849,5 @@ function parseSemgrepOutput(stdout, stderr) {
|
|
|
277
849
|
severity: extra?.severity === "ERROR" ? "error" : "warning",
|
|
278
850
|
});
|
|
279
851
|
}
|
|
280
|
-
return findings;
|
|
852
|
+
return { findings, malformed: false, partialParsing };
|
|
281
853
|
}
|
package/dist/format.js
CHANGED
|
@@ -27,6 +27,19 @@ function statusBadge(cat) {
|
|
|
27
27
|
return cat.executed ? (cat.passed ? "PASS" : "FAIL") : "NOT-RUN";
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The overall result line. A run that found no violations but could not
|
|
32
|
+
* execute a required stage is neither a pass nor a failure: it is
|
|
33
|
+
* incomplete. In particular the summary must never claim PASS while a
|
|
34
|
+
* category reports TOOL-UNAVAILABLE (the process exit code already
|
|
35
|
+
* distinguishes that case as TOOL_MISSING).
|
|
36
|
+
*/
|
|
37
|
+
function overallResult(result) {
|
|
38
|
+
if (!result.passed)
|
|
39
|
+
return "FAIL";
|
|
40
|
+
const toolUnavailable = Object.values(result.categories).some((cat) => cat.status === "TOOL-UNAVAILABLE");
|
|
41
|
+
return toolUnavailable ? "INCOMPLETE" : "PASS";
|
|
42
|
+
}
|
|
30
43
|
/**
|
|
31
44
|
* Format a unified summary for the terminal.
|
|
32
45
|
*/
|
|
@@ -56,7 +69,7 @@ export function formatSummary(result) {
|
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
lines.push("");
|
|
59
|
-
lines.push(`Result: ${result
|
|
72
|
+
lines.push(`Result: ${overallResult(result)}`);
|
|
60
73
|
return lines.join("\n");
|
|
61
74
|
}
|
|
62
75
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,4 @@ export type { CategoryResult, RunOptions, RunResult } from "./run.js";
|
|
|
6
6
|
export { discoverRepository } from "./discover.js";
|
|
7
7
|
export { ExitCode } from "./exit-codes.js";
|
|
8
8
|
export { formatSummary, formatViolations } from "./format.js";
|
|
9
|
-
export { runValidation } from "./run.js";
|
|
9
|
+
export { exitCodeForResult, runValidation } from "./run.js";
|
package/dist/index.js
CHANGED
package/dist/run.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EngineResult, Finding } from "./engines/types.js";
|
|
2
|
+
import type { ExitCodeValue } from "./exit-codes.js";
|
|
2
3
|
export interface RunOptions {
|
|
3
4
|
/** The root directory of the repository to validate */
|
|
4
5
|
root: string;
|
|
@@ -54,3 +55,12 @@ export interface RunResult {
|
|
|
54
55
|
* state. PASS without execution is impossible.
|
|
55
56
|
*/
|
|
56
57
|
export declare function runValidation(options: RunOptions): Promise<RunResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Map a validation result to the documented process exit code.
|
|
60
|
+
*
|
|
61
|
+
* A required tool that could not run must never surface as PASS: when
|
|
62
|
+
* every executed engine passed but at least one category reports
|
|
63
|
+
* `TOOL-UNAVAILABLE`, the result exits `TOOL_MISSING` (3) instead of
|
|
64
|
+
* fabricating a passing result.
|
|
65
|
+
*/
|
|
66
|
+
export declare function exitCodeForResult(result: RunResult): ExitCodeValue;
|
package/dist/run.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runDepcruise } from "./engines/depcruise.js";
|
|
|
6
6
|
import { runEslint } from "./engines/eslint.js";
|
|
7
7
|
import { runSchema } from "./engines/schema.js";
|
|
8
8
|
import { runSemgrep } from "./engines/semgrep.js";
|
|
9
|
+
import { ExitCode } from "./exit-codes.js";
|
|
9
10
|
function categoryOf(result) {
|
|
10
11
|
return {
|
|
11
12
|
status: result.status,
|
|
@@ -17,6 +18,21 @@ function categoryOf(result) {
|
|
|
17
18
|
reason: result.reason,
|
|
18
19
|
};
|
|
19
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
|
+
}
|
|
20
36
|
function emptyCategory(reason) {
|
|
21
37
|
return {
|
|
22
38
|
status: "NOT-RUN",
|
|
@@ -87,7 +103,7 @@ export async function runValidation(options) {
|
|
|
87
103
|
}
|
|
88
104
|
let semgrepResult;
|
|
89
105
|
if (!only || only === "semgrep") {
|
|
90
|
-
semgrepResult = await runSemgrep(repo);
|
|
106
|
+
semgrepResult = await runSemgrep(repo, { verbose: options.verbose === true });
|
|
91
107
|
}
|
|
92
108
|
else {
|
|
93
109
|
semgrepResult = emptyEngineResult("semgrep engine skipped");
|
|
@@ -112,11 +128,9 @@ export async function runValidation(options) {
|
|
|
112
128
|
}
|
|
113
129
|
}
|
|
114
130
|
const allExecuted = [depResult, eslintResult, semgrepResult, adapterResult];
|
|
115
|
-
const allPassed = allExecuted.every(
|
|
116
|
-
(metaResult
|
|
117
|
-
(configResult
|
|
118
|
-
? configResult.passed
|
|
119
|
-
: configResult.status !== "FAIL");
|
|
131
|
+
const allPassed = allExecuted.every(categoryIsAcceptable) &&
|
|
132
|
+
categoryIsAcceptable(metaResult) &&
|
|
133
|
+
categoryIsAcceptable(configResult);
|
|
120
134
|
return {
|
|
121
135
|
passed: allPassed,
|
|
122
136
|
violations,
|
|
@@ -142,3 +156,19 @@ function emptyEngineResult(reason) {
|
|
|
142
156
|
reason,
|
|
143
157
|
};
|
|
144
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Map a validation result to the documented process exit code.
|
|
161
|
+
*
|
|
162
|
+
* A required tool that could not run must never surface as PASS: when
|
|
163
|
+
* every executed engine passed but at least one category reports
|
|
164
|
+
* `TOOL-UNAVAILABLE`, the result exits `TOOL_MISSING` (3) instead of
|
|
165
|
+
* fabricating a passing result.
|
|
166
|
+
*/
|
|
167
|
+
export function exitCodeForResult(result) {
|
|
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
|
+
}
|
|
172
|
+
const toolUnavailable = Object.values(result.categories).some((c) => c.status === "TOOL-UNAVAILABLE");
|
|
173
|
+
return toolUnavailable ? ExitCode.TOOL_MISSING : ExitCode.PASS;
|
|
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.2",
|
|
4
4
|
"description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"eslint": "^9.39.5",
|
|
37
37
|
"@comity-dev/schemas": "0.2.0",
|
|
38
38
|
"@comity-dev/dependency-rules": "0.2.0",
|
|
39
|
-
"@comity-dev/eslint-plugin": "0.
|
|
40
|
-
"@comity-dev/semgrep-rules": "0.2.
|
|
39
|
+
"@comity-dev/eslint-plugin": "0.3.0",
|
|
40
|
+
"@comity-dev/semgrep-rules": "0.2.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^24.13.3",
|