Skip to content

Commit 5d30eb4

Browse files
fix(scanners): correct scanner exit-code handling and stop duplicate skip logs (#156)
## Summary Fixes a cluster of scanner/indexer robustness bugs surfaced by running `codehub analyze` on an external Python/uv project. Each was misreporting a ran-but-nonzero scanner as a hard skip, or emitting misleading/duplicate diagnostics. Grounded against osv-scanner v2 and bandit exit-code semantics. ## Issues fixed **1. osv-scanner "exit code 127" despite running fine** — osv v2 reserves exit `1–126` = vulns found, `127` = general error, `128` = no packages (per osv docs). The shared invoker treated only 0/1 as clean, so 127 surfaced as a bare error. Root trigger: the wrapper passed `--offline-vulnerabilities` by default, which on a repo with no synced DB makes osv walk the tree, then fail to load the offline DB → exit 127. → Added an osv-specific exit-code interpreter (127 → "general error, try `codehub db-sync`"; 128 → "no packages discovered"). Dropped offline-by-default; use the canonical `scan source --recursive .` form (matches `ci.yml`). **The `root: /` line is osv's own internal log — the adapter correctly roots at the repo dir (`cwd=projectPath`, arg `.`).** **2. bandit "exit code 2 + usage:"** — bandit exits 2 on argparse errors; `-f sarif` is invalid without the `bandit[sarif]` extra installed → usage banner. (The old "falls back to text" assumption was false.) → Detect exit-2 + `usage: bandit` and emit an actionable "install `bandit[sarif]`" advisory; suppress the misleading "stdout was not valid JSON" note. **3. Duplicate skip messages** — the runner routed `onWarn` to status `"skipped"` AND re-emitted the terminal note, so lines printed twice and ran-but-nonzero advisories were mislabeled "skipped". → Added a distinct `"warn"` status (scan ran, here's a note) and coalesced the terminal event so each note prints once. **4. scip-python "mise ERROR No version is set for shim"** — `runIndexer` threw on any non-zero exit; a mise/asdf shim with no pinned version resolves on PATH but exits non-zero before the real indexer runs, producing an alarming "indexer failed". → Detect the version-manager-shim failure pattern and return a graceful `skipped` (logged as the calmer "python skipped — …") with an actionable hint. A genuine traceback still throws. ## Out of scope (user-env, not codehub bugs) pip-audit "binary not found" is a graceful skip already (only bug was the duplicate print, fixed in #3). The dead-code ghost-community warning is correctly guarded and informational. ## Verification - `@opencodehub/scanners` — **88 tests pass** (+7: osv exit 1/127/128 + argv, bandit exit-2, runner de-dup + warn) - `@opencodehub/scip-ingest` — **66 tests pass** (+4: mise/asdf shim detection + genuine-crash still throws) ## Note for reviewer Dropping `--offline-vulnerabilities` is a deliberate posture change (osv does online lookups by default now) — flagging for sign-off.
1 parent 790ca4e commit 5d30eb4

8 files changed

Lines changed: 409 additions & 32 deletions

File tree

packages/cli/src/commands/scan.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export async function runScan(path: string, opts: ScanOptions = {}): Promise<Sca
126126
onProgress: (spec: ScannerSpec, status: ScannerStatus, note?: string) => {
127127
if (status === "error") {
128128
console.warn(`codehub scan: ${spec.id} errored: ${note ?? ""}`);
129+
} else if (status === "warn" && note) {
130+
// Advisory: the scan ran but emitted a note (non-clean exit code,
131+
// SARIF parse fallback). Not a skip — the scanner still produced
132+
// output. Label it distinctly so a non-zero-but-ran exit (e.g.
133+
// osv-scanner exit 127 = general error) is not mistaken for "did
134+
// not run".
135+
console.warn(`codehub scan: ${spec.id}: ${note}`);
129136
} else if (status === "skipped" && note) {
130137
console.warn(`codehub scan: ${spec.id} skipped: ${note}`);
131138
} else {

packages/scanners/src/runner.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,54 @@ test("runScanners invokes onProgress lifecycle for each scanner", async () => {
9797
assert.ok(bEvents.some((e) => e.status === "done"));
9898
});
9999

100+
test("runScanners routes onWarn to `warn` status and does not double-emit the skip note", async () => {
101+
// A wrapper that warns via onWarn AND returns `skipped` (the missing-binary
102+
// shape). Previously the runner re-emitted the same note on the terminal
103+
// `skipped` event, producing two identical lines.
104+
const spec = makeSpec("pip-audit");
105+
const skipMsg = "pip-audit: binary 'pip-audit' not found on PATH";
106+
const wrapper: ScannerWrapper = {
107+
spec,
108+
run: async (c: ScannerRunContext): Promise<ScannerRunResult> => {
109+
c.onWarn?.(skipMsg);
110+
return { spec, sarif: emptySarifFor(spec), skipped: skipMsg, durationMs: 1 };
111+
},
112+
};
113+
const events: Array<{ status: string; note: string | undefined }> = [];
114+
await runScanners("/tmp/repo", [wrapper], {
115+
onProgress: (_spec, status, note) => events.push({ status, note }),
116+
});
117+
// The note must appear exactly once (via the `warn` event), and the
118+
// terminal `skipped` event must carry NO note (no duplicate).
119+
const noteOccurrences = events.filter((e) => e.note === skipMsg);
120+
assert.equal(noteOccurrences.length, 1, `note should print once, got ${noteOccurrences.length}`);
121+
assert.equal(noteOccurrences[0]?.status, "warn");
122+
const terminal = events.filter((e) => e.status === "skipped");
123+
assert.equal(terminal.length, 1);
124+
assert.equal(terminal[0]?.note, undefined, "terminal skipped event must not repeat the note");
125+
});
126+
127+
test("runScanners emits a single `done` (no note) when a wrapper warns but does not skip", async () => {
128+
// The osv-scanner exit-127 shape: warns via onWarn but returns SARIF
129+
// (not skipped). Should produce exactly one terminal `done`, not a
130+
// contradictory `skipped` + `done` pair.
131+
const spec = makeSpec("osv-scanner");
132+
const wrapper: ScannerWrapper = {
133+
spec,
134+
run: async (c: ScannerRunContext): Promise<ScannerRunResult> => {
135+
c.onWarn?.("osv-scanner: general error (exit 127)");
136+
return { spec, sarif: emptySarifFor(spec), durationMs: 1 };
137+
},
138+
};
139+
const events: Array<{ status: string; note: string | undefined }> = [];
140+
await runScanners("/tmp/repo", [wrapper], {
141+
onProgress: (_spec, status, note) => events.push({ status, note }),
142+
});
143+
assert.equal(events.filter((e) => e.status === "warn").length, 1);
144+
assert.equal(events.filter((e) => e.status === "done").length, 1);
145+
assert.equal(events.filter((e) => e.status === "skipped").length, 0);
146+
});
147+
100148
test("runScanners respects concurrency cap", async () => {
101149
let inFlight = 0;
102150
let peak = 0;

packages/scanners/src/runner.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,21 @@ import {
2323
type ScannerWrapper,
2424
} from "./spec.js";
2525

26-
export type ScannerStatus = "start" | "done" | "error" | "skipped";
26+
/**
27+
* Per-scanner lifecycle status.
28+
*
29+
* - `start` — the wrapper began executing.
30+
* - `warn` — the wrapper surfaced an advisory via `onWarn` (e.g. a
31+
* non-clean exit code, or a SARIF-parse fallback). The scan
32+
* still ran and produced (possibly empty) output. Distinct
33+
* from `skipped` so callers don't mislabel "ran with a
34+
* non-zero exit" as "did not run".
35+
* - `done` — the wrapper finished and produced output.
36+
* - `skipped` — the scan did not run (binary missing, etc.); `note`
37+
* carries the reason.
38+
* - `error` — the wrapper threw; `note` carries the error message.
39+
*/
40+
export type ScannerStatus = "start" | "warn" | "done" | "error" | "skipped";
2741

2842
export interface RunScannersOptions {
2943
/** Cap on parallel scanners. Default min(availableParallelism(), 4). */
@@ -75,13 +89,32 @@ export async function runScanners(
7589
const started = performance.now();
7690
opts.onProgress?.(spec, "start");
7791
try {
92+
// Wrappers call `onWarn` for advisories (non-clean exit code, SARIF
93+
// parse fallback) and ALSO return a `skipped` string when the scan
94+
// did not run. Route `onWarn` to the `warn` status (scan ran, here's
95+
// a note) — NOT `skipped` — and track that it fired. Without this:
96+
// (1) a missing-binary wrapper that calls onWarn AND returns
97+
// `skipped` re-printed the same line twice (the duplicate
98+
// `pip-audit skipped: ...` lines), and
99+
// (2) an advisory-only wrapper (osv-scanner exit 127) was mislabeled
100+
// "skipped: ..." and then immediately followed by a "done" line.
101+
// We coalesce the terminal event: when the wrapper already surfaced
102+
// its `skipped` reason via `onWarn`, emit the terminal lifecycle
103+
// status with no duplicate note.
104+
let warned = false;
78105
const result = await wrapper.run({
79106
projectPath,
80107
timeoutMs,
81-
onWarn: (m: string) => opts.onProgress?.(spec, "skipped", m),
108+
onWarn: (m: string) => {
109+
warned = true;
110+
opts.onProgress?.(spec, "warn", m);
111+
},
82112
});
83113
runs[myIdx] = result;
84-
opts.onProgress?.(spec, result.skipped ? "skipped" : "done", result.skipped);
114+
const terminal: ScannerStatus = result.skipped ? "skipped" : "done";
115+
// If the wrapper already explained itself via onWarn, don't repeat
116+
// the note on the terminal line.
117+
opts.onProgress?.(spec, terminal, warned ? undefined : result.skipped);
85118
} catch (err) {
86119
const message = err instanceof Error ? err.message : String(err);
87120
errored.push({ spec, error: message });
Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,93 @@
11
/**
22
* Bandit wrapper — Python SAST.
33
*
4-
* Invocation: `bandit -r <projectPath> -f sarif`
4+
* Invocation: `bandit -r <projectPath> -f sarif --quiet`
55
*
66
* Requires the `bandit[sarif]` install (which pulls in
7-
* `bandit-sarif-formatter` ≥1.1.1). If the formatter is missing, bandit
8-
* falls back to text output and our SARIF parser emits an empty log +
9-
* warning — graceful degradation.
7+
* `bandit-sarif-formatter` ≥1.1.1). IMPORTANT: when that extra is NOT
8+
* installed, `sarif` is absent from bandit's dynamic `-f/--format` choice
9+
* list, so argparse REJECTS `-f sarif` with exit code 2 and a `usage: …`
10+
* message on stderr — bandit does NOT fall back to text output. We detect
11+
* that specific failure and emit an actionable advisory pointing at the
12+
* `bandit[sarif]` install, instead of the misleading generic "stdout was
13+
* not valid JSON" note.
14+
*
15+
* Bandit exit codes: 0 = no issues, 1 = issues found, 2 = usage/argparse
16+
* or config error (NOT a finding). We treat 0 and 1 as "ran"; 2+ is a
17+
* genuine invocation failure.
1018
*
1119
* Note: bandit writes SARIF to stdout when `-f sarif` is given WITHOUT
12-
* `-o <file>`. Passing `.` as the target would confuse bandit (it treats
13-
* `.` as "current directory" but recurses via `-r` explicitly); we pass
14-
* `projectPath` so the wrapper works from any CWD.
20+
* `-o <file>`. We pass `-r <projectPath>` so the wrapper works from any CWD.
1521
*/
1622

1723
import { BANDIT_SPEC } from "../catalog.js";
1824
import type { ScannerRunContext, ScannerRunResult, ScannerWrapper } from "../spec.js";
19-
import { DEFAULT_DEPS, invokeScanner, type WrapperDeps } from "./shared.js";
25+
import { emptySarifFor } from "../spec.js";
26+
import { DEFAULT_DEPS, parseSarifOrEmpty, type WrapperDeps } from "./shared.js";
27+
28+
/**
29+
* Detect bandit's argparse rejection of `-f sarif` (exit 2 + a `usage:`
30+
* banner mentioning the format flag) and return an actionable advisory.
31+
* Returns `undefined` when the exit looks like a normal run (0/1) or an
32+
* unrelated failure (handled by the generic exit-code note).
33+
*/
34+
export function banditExitAdvisory(exitCode: number, stderr: string): string | undefined {
35+
if (exitCode === 0 || exitCode === 1) return undefined;
36+
const looksLikeUsage = /\busage:\s*bandit\b/i.test(stderr);
37+
if (exitCode === 2 && looksLikeUsage) {
38+
return (
39+
`${BANDIT_SPEC.id}: rejected '-f sarif' (exit 2). The SARIF formatter is not ` +
40+
`installed — install the extra: ${BANDIT_SPEC.installCmd}. ` +
41+
`(Bandit does not fall back to text output here; it exits with a usage error.)`
42+
);
43+
}
44+
return `${BANDIT_SPEC.id}: exit code ${exitCode}; stderr: ${truncate(stderr, 160)}`;
45+
}
2046

2147
export function createBanditWrapper(deps: WrapperDeps = DEFAULT_DEPS): ScannerWrapper {
2248
return {
2349
spec: BANDIT_SPEC,
2450
run: async (ctx: ScannerRunContext): Promise<ScannerRunResult> => {
51+
const started = performance.now();
52+
const probe = await deps.which("bandit");
53+
if (!probe.found) {
54+
const msg = `${BANDIT_SPEC.id}: binary 'bandit' not found on PATH (install: ${BANDIT_SPEC.installCmd}).`;
55+
ctx.onWarn?.(msg);
56+
return {
57+
spec: BANDIT_SPEC,
58+
sarif: emptySarifFor(BANDIT_SPEC),
59+
skipped: msg,
60+
durationMs: performance.now() - started,
61+
};
62+
}
2563
const args: readonly string[] = ["-r", ctx.projectPath, "-f", "sarif", "--quiet"];
26-
return invokeScanner(BANDIT_SPEC, ctx, "bandit", args, deps);
64+
const result = await deps.runBinary("bandit", args, {
65+
timeoutMs: ctx.timeoutMs,
66+
cwd: ctx.projectPath,
67+
});
68+
const advisory = banditExitAdvisory(result.exitCode, result.stderr);
69+
if (advisory !== undefined) {
70+
// Argparse / invocation failure: stdout is empty (the usage banner
71+
// went to stderr). Surface the specific advisory and skip the generic
72+
// "stdout was not valid JSON" note, which would be misleading here.
73+
ctx.onWarn?.(advisory);
74+
return {
75+
spec: BANDIT_SPEC,
76+
sarif: emptySarifFor(BANDIT_SPEC),
77+
durationMs: performance.now() - started,
78+
};
79+
}
80+
const sarif = parseSarifOrEmpty(result.stdout, BANDIT_SPEC, ctx.onWarn);
81+
return {
82+
spec: BANDIT_SPEC,
83+
sarif,
84+
durationMs: performance.now() - started,
85+
};
2786
},
2887
};
2988
}
89+
90+
function truncate(s: string, max: number): string {
91+
if (s.length <= max) return s.trim();
92+
return `${s.slice(0, max).trim()}…`;
93+
}
Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,98 @@
11
/**
22
* OSV-Scanner wrapper — dependency vulnerability scanner.
33
*
4-
* Invocation: `osv-scanner scan --format=sarif --offline-vulnerabilities
5-
* --recursive .`
4+
* Invocation: `osv-scanner scan source --format=sarif --recursive .`
65
*
7-
* We enable offline mode by default — OpenCodeHub's v1.0 posture is that
8-
* the vulnerability database is pre-synced by `codehub db-sync`.
9-
* `--offline-vulnerabilities` keeps the tool from reaching osv.dev while
10-
* still allowing local lockfile resolution to touch manifests on disk.
6+
* Exit-code semantics (osv-scanner v2, per
7+
* https://google.github.io/osv-scanner/output/ "Return Codes"):
8+
*
9+
* 0 — packages found, no vulnerabilities/findings.
10+
* 1 — packages found, vulnerabilities/findings present (the COMMON
11+
* non-zero case; NOT an error).
12+
* 2-126 — reserved for vulnerability/finding-related results.
13+
* 127 — GENERAL ERROR (e.g. the offline vulnerability DB could not be
14+
* loaded). Note: the scan may still have walked the filesystem and
15+
* parsed lockfiles before failing, so partial stderr ("Scanned …
16+
* uv.lock … found N packages") does NOT mean the scan succeeded.
17+
* 128 — no packages discovered (scanning format picked up no files).
18+
* 129-255 — non-result-related errors.
19+
*
20+
* The shared `invokeScanner` treats only 0 and 1 as "ran cleanly". For
21+
* osv-scanner that is too coarse: it would flag exit 1 (vulns found — the
22+
* whole point of the scan) as a warning, and it would surface exit 127 as
23+
* a generic "exit code 127" without explaining that the most likely cause
24+
* is a missing offline database. We interpret osv's codes explicitly here.
25+
*
26+
* Offline posture: earlier revisions passed `--offline-vulnerabilities` by
27+
* default on the assumption the DB was pre-synced by `codehub db-sync`.
28+
* On a fresh checkout with no synced DB, osv-scanner walks the tree, then
29+
* fails to load the offline DB and exits 127 — a confusing "it ran but
30+
* errored" signal. We DROP the default offline flag so the common case
31+
* (online lookup) works out of the box; operators who need air-gapped
32+
* scans pass it back via osv-scanner's own env/flags after `db-sync`.
1133
*/
1234

1335
import { OSV_SCANNER_SPEC } from "../catalog.js";
1436
import type { ScannerRunContext, ScannerRunResult, ScannerWrapper } from "../spec.js";
15-
import { DEFAULT_DEPS, invokeScanner, type WrapperDeps } from "./shared.js";
37+
import { emptySarifFor } from "../spec.js";
38+
import { DEFAULT_DEPS, parseSarifOrEmpty, type WrapperDeps } from "./shared.js";
1639

17-
const OSV_ARGS: readonly string[] = [
18-
"scan",
19-
"--format=sarif",
20-
"--offline-vulnerabilities",
21-
"--recursive",
22-
".",
23-
];
40+
const OSV_ARGS: readonly string[] = ["scan", "source", "--format=sarif", "--recursive", "."];
41+
42+
/**
43+
* Map an osv-scanner v2 exit code to an advisory note, or `undefined` when
44+
* the exit represents a clean / findings-present run that needs no warning.
45+
*/
46+
export function osvExitAdvisory(exitCode: number, stderr: string): string | undefined {
47+
// 0 = no findings; 1-126 = findings present (the normal non-zero outcome).
48+
if (exitCode >= 0 && exitCode <= 126) return undefined;
49+
if (exitCode === 127) {
50+
return (
51+
`${OSV_SCANNER_SPEC.id}: general error (exit 127). Common cause: the offline ` +
52+
`vulnerability DB is missing — run \`codehub db-sync\` then retry, or omit ` +
53+
`offline mode. stderr: ${truncate(stderr, 160)}`
54+
);
55+
}
56+
if (exitCode === 128) {
57+
// No packages discovered. Benign for repos with no lockfiles/manifests.
58+
return `${OSV_SCANNER_SPEC.id}: no packages discovered (exit 128); nothing to scan.`;
59+
}
60+
return `${OSV_SCANNER_SPEC.id}: exit code ${exitCode}; stderr: ${truncate(stderr, 160)}`;
61+
}
2462

2563
export function createOsvScannerWrapper(deps: WrapperDeps = DEFAULT_DEPS): ScannerWrapper {
2664
return {
2765
spec: OSV_SCANNER_SPEC,
28-
run: (ctx: ScannerRunContext): Promise<ScannerRunResult> =>
29-
invokeScanner(OSV_SCANNER_SPEC, ctx, "osv-scanner", OSV_ARGS, deps),
66+
run: async (ctx: ScannerRunContext): Promise<ScannerRunResult> => {
67+
const started = performance.now();
68+
const probe = await deps.which("osv-scanner");
69+
if (!probe.found) {
70+
const msg = `${OSV_SCANNER_SPEC.id}: binary 'osv-scanner' not found on PATH (install: ${OSV_SCANNER_SPEC.installCmd}).`;
71+
ctx.onWarn?.(msg);
72+
return {
73+
spec: OSV_SCANNER_SPEC,
74+
sarif: emptySarifFor(OSV_SCANNER_SPEC),
75+
skipped: msg,
76+
durationMs: performance.now() - started,
77+
};
78+
}
79+
const result = await deps.runBinary("osv-scanner", OSV_ARGS, {
80+
timeoutMs: ctx.timeoutMs,
81+
cwd: ctx.projectPath,
82+
});
83+
const sarif = parseSarifOrEmpty(result.stdout, OSV_SCANNER_SPEC, ctx.onWarn);
84+
const advisory = osvExitAdvisory(result.exitCode, result.stderr);
85+
if (advisory !== undefined) ctx.onWarn?.(advisory);
86+
return {
87+
spec: OSV_SCANNER_SPEC,
88+
sarif,
89+
durationMs: performance.now() - started,
90+
};
91+
},
3092
};
3193
}
94+
95+
function truncate(s: string, max: number): string {
96+
if (s.length <= max) return s.trim();
97+
return `${s.slice(0, max).trim()}…`;
98+
}

0 commit comments

Comments
 (0)