Skip to content

Commit 1c2cd57

Browse files
authored
Merge pull request #218 from Raftersecurity/fix/217-betterleaks-null-report
fix(scanners): treat betterleaks null report as empty result set (#217)
2 parents e5d4867 + 41d5b13 commit 1c2cd57

11 files changed

Lines changed: 229 additions & 33 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ Rafter is a security CLI for AI coding agents. It ships as two feature-identical
3232
- Scanners use dual-engine: Betterleaks binary first, regex fallback. Patterns defined in `secret-patterns.ts` / `secret_patterns.py`
3333
- Risk classification: critical > high > medium > low
3434
- Audit log: JSONL format, append-only, documented schema in CLI_SPEC.md
35-
- MCP server: 4 tools + 2 resources over stdio transport
35+
- MCP server: 4 tools + 3 resources over stdio transport

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ cd python && poetry install && pytest
6060

6161
**Secret scanning**: Dual-engine — tries Betterleaks binary first (higher accuracy), falls back to built-in regex patterns (21+ patterns, zero dependencies). Deterministic for a given version. Betterleaks is the gitleaks successor maintained by the original gitleaks authors. Existing installs with a leftover `~/.rafter/bin/gitleaks` are detected by `agent verify`/`status` so users get an upgrade hint, but the legacy CLI flags (`--with-gitleaks`, `--engine gitleaks`, `update-gitleaks`) have been removed.
6262

63-
**MCP server**: `rafter mcp serve` exposes 4 tools (`scan_secrets`, `evaluate_command`, `read_audit_log`, `get_config`) and 2 resources (`rafter://config`, `rafter://policy`) over stdio.
63+
**MCP server**: `rafter mcp serve` exposes 4 tools (`scan_secrets`, `evaluate_command`, `read_audit_log`, `get_config`) and 3 resources (`rafter://config`, `rafter://policy`, `rafter://docs`) over stdio.
6464

6565
## Development
6666

demo/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The `/rafter-showcase` skill walks through all 9 core features with live command
1818
4. Audit logging (JSONL trail)
1919
5. Pre-commit hooks
2020
6. CI/CD integration (GitHub Actions)
21-
7. MCP server (4 tools, 2 resources)
21+
7. MCP server (4 tools, 3 resources)
2222
8. Skill auditing
2323
9. Remote SAST/SCA (requires API key)
2424

node/src/commands/agent/scan.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
policyIgnoreToSuppressions,
1717
} from "../../core/custom-patterns.js";
1818
import type { ScanIgnoreRule } from "../../core/config-schema.js";
19-
import { execSync, execFileSync } from "child_process";
19+
import { execFileSync } from "child_process";
2020
import fs from "fs";
2121
import os from "os";
2222
import path from "path";
@@ -492,7 +492,9 @@ async function runGitAddedLineScan(
492492

493493
if (!patch.trim()) {
494494
if (!opts.quiet) {
495-
console.log(`\n${fmt.success(emptyMessage)}\n`);
495+
// Status line, so stderr — stdout must stay parseable as JSON under
496+
// --json, and outputScanResults owns the single stdout success line.
497+
console.error(fmt.success(emptyMessage));
496498
}
497499
outputScanResults([], opts, contextLabel, true, suppressions);
498500
return;
@@ -501,7 +503,9 @@ async function runGitAddedLineScan(
501503
const addedLines = parseUnifiedDiffAddedLines(patch);
502504
if (addedLines.length === 0) {
503505
if (!opts.quiet) {
504-
console.log(`\n${fmt.success(emptyMessage)}\n`);
506+
// Status line, so stderr — stdout must stay parseable as JSON under
507+
// --json, and outputScanResults owns the single stdout success line.
508+
console.error(fmt.success(emptyMessage));
505509
}
506510
outputScanResults([], opts, contextLabel, true, suppressions);
507511
return;

node/src/scanners/betterleaks.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ export class BetterleaksScanner {
206206
return [];
207207
}
208208
const parsed = JSON.parse(content);
209+
// #217 — betterleaks >=1.1.2 writes the literal `null` (not `[]`) for a
210+
// clean scan via the `dir`/`git` subcommands we invoke. That is a valid
211+
// empty result, not a version mismatch, so it must not reach the warning
212+
// branch below — otherwise every clean file scanned emits warning noise.
213+
if (parsed === null) {
214+
return [];
215+
}
209216
if (!Array.isArray(parsed)) {
210217
// sable-o4k — a stale/incompatible binary emits a non-array shape and
211218
// would otherwise silently yield zero findings. The managed binary is

node/tests/agent-commands.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,12 @@ describe("agent status", () => {
707707
const binDir = path.join(home, ".rafter", "bin");
708708
fs.mkdirSync(binDir, { recursive: true });
709709
fs.writeFileSync(path.join(binDir, "gitleaks"), "#!/bin/sh\necho fake\n", { mode: 0o755 });
710-
const r = runCli("agent status", home);
710+
// `agent status` probes `betterleaks` on PATH first and only falls through
711+
// to the legacy-gitleaks hint when that fails — so the assertion below is
712+
// only meaningful with an empty PATH. Otherwise this passes on CI and fails
713+
// on any dev box that has betterleaks installed.
714+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-empty-path-"));
715+
const r = runCli("agent status", home, { PATH: emptyDir });
711716
expect(r.stdout).toMatch(/legacy gitleaks/i);
712717
expect(r.stdout).toMatch(/update-betterleaks/i);
713718
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import fs from "fs";
3+
import os from "os";
4+
import path from "path";
5+
import { BetterleaksScanner } from "../src/scanners/betterleaks.js";
6+
7+
// parseResults is private; exercise it directly rather than shelling out to the
8+
// real binary, so these stay hermetic and run without betterleaks installed.
9+
const scanner = new BetterleaksScanner();
10+
const parseResults = (scanner as any).parseResults.bind(scanner);
11+
12+
let tmpDir: string;
13+
let stderrSpy: ReturnType<typeof vi.spyOn>;
14+
15+
function writeReport(content: string): string {
16+
const p = path.join(tmpDir, "report.json");
17+
fs.writeFileSync(p, content, "utf-8");
18+
return p;
19+
}
20+
21+
beforeEach(() => {
22+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bl-parse-test-"));
23+
stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
24+
});
25+
26+
afterEach(() => {
27+
stderrSpy.mockRestore();
28+
fs.rmSync(tmpDir, { recursive: true, force: true });
29+
});
30+
31+
describe("BetterleaksScanner.parseResults", () => {
32+
// #217 — betterleaks >=1.1.2 writes the literal `null` for a clean scan via
33+
// the `dir`/`git` subcommands. Regression guard: this is an empty result, not
34+
// a version mismatch, and must not emit warning noise on every clean file.
35+
it("treats a literal `null` report as an empty result set", () => {
36+
expect(parseResults(writeReport("null"))).toEqual([]);
37+
});
38+
39+
it("does not warn on a `null` report", () => {
40+
parseResults(writeReport("null"));
41+
expect(stderrSpy).not.toHaveBeenCalled();
42+
});
43+
44+
it("tolerates trailing whitespace around `null`", () => {
45+
expect(parseResults(writeReport("null\n"))).toEqual([]);
46+
expect(stderrSpy).not.toHaveBeenCalled();
47+
});
48+
49+
it("returns an empty result set for an empty report", () => {
50+
expect(parseResults(writeReport(""))).toEqual([]);
51+
expect(stderrSpy).not.toHaveBeenCalled();
52+
});
53+
54+
it("returns findings from a normal array report", () => {
55+
const findings = [{ RuleID: "aws-secret-key", Description: "AWS key", StartLine: 3 }];
56+
expect(parseResults(writeReport(JSON.stringify(findings)))).toEqual(findings);
57+
expect(stderrSpy).not.toHaveBeenCalled();
58+
});
59+
60+
it("returns an empty result set for an empty array report", () => {
61+
expect(parseResults(writeReport("[]"))).toEqual([]);
62+
expect(stderrSpy).not.toHaveBeenCalled();
63+
});
64+
65+
// sable-o4k — the stale-binary guard must survive the #217 fix.
66+
it("still warns about a non-array object report", () => {
67+
expect(parseResults(writeReport('{"findings": []}'))).toEqual([]);
68+
expect(stderrSpy).toHaveBeenCalledOnce();
69+
expect(String(stderrSpy.mock.calls[0][0])).toContain("possible version mismatch");
70+
});
71+
72+
it("still warns about malformed JSON", () => {
73+
expect(parseResults(writeReport("{not json"))).toEqual([]);
74+
expect(stderrSpy).toHaveBeenCalledOnce();
75+
expect(String(stderrSpy.mock.calls[0][0])).toContain("Failed to parse");
76+
});
77+
});

python/rafter_cli/commands/agent.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1711,6 +1711,31 @@ def run_patterns() -> list[ScanResult]:
17111711
return run_patterns()
17121712

17131713

1714+
def _output_empty_diff_scan(
1715+
empty_message: str,
1716+
json_output: bool,
1717+
quiet: bool,
1718+
context_label: str,
1719+
format: str,
1720+
suppressions,
1721+
) -> None:
1722+
"""Emit the result of a diff scan that had no files to scan.
1723+
1724+
``empty_message`` ("No files changed since <ref>") is a *status* line, so it
1725+
goes to stderr — stdout has to stay parseable as JSON under ``--json``, and
1726+
``_output_scan_results`` owns the single stdout success line. Mirrors the
1727+
empty branches of ``runGitAddedLineScan`` in
1728+
node/src/commands/agent/scan.ts — keep the two in sync.
1729+
"""
1730+
if not quiet:
1731+
# rprint, not print — fmt.success returns rich markup that plain print
1732+
# would emit literally as "[green]...[/green]".
1733+
rprint(fmt.success(empty_message), file=sys.stderr)
1734+
_output_scan_results(
1735+
[], json_output, quiet, context_label, format=format, suppressions=suppressions
1736+
)
1737+
1738+
17141739
def _run_git_added_line_scan(
17151740
git_args: list[str],
17161741
git_cwd: str | None,
@@ -1743,14 +1768,16 @@ def _run_git_added_line_scan(
17431768
raise typer.Exit(code=2)
17441769

17451770
if not patch.strip():
1746-
if not quiet:
1747-
rprint(fmt.success(empty_message))
1771+
_output_empty_diff_scan(
1772+
empty_message, json_output, quiet, context_label, format, suppressions
1773+
)
17481774
raise typer.Exit(code=0)
17491775

17501776
added = parse_unified_diff_added_lines(patch)
17511777
if not added:
1752-
if not quiet:
1753-
rprint(fmt.success(empty_message))
1778+
_output_empty_diff_scan(
1779+
empty_message, json_output, quiet, context_label, format, suppressions
1780+
)
17541781
raise typer.Exit(code=0)
17551782

17561783
try:

python/rafter_cli/scanners/betterleaks.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -164,29 +164,46 @@ def _run_scan(self, target: str, *, use_git: bool = False) -> list[dict]:
164164
)
165165
return []
166166

167-
try:
168-
with open(report_path) as f:
169-
content = f.read().strip()
170-
if not content:
171-
return []
172-
parsed = json.loads(content)
173-
except json.JSONDecodeError as exc:
174-
print(f"[rafter] Warning: Failed to parse Betterleaks report: {exc}", file=sys.stderr)
175-
return []
167+
return self._parse_report(report_path)
176168

177-
if not isinstance(parsed, list):
178-
# sable-o4k — a stale/incompatible binary emits a non-array
179-
# shape and would otherwise silently yield zero findings. The
180-
# managed binary is auto-updated upstream; this covers a stale
181-
# binary on PATH, which we can't safely overwrite — so point the
182-
# user at the fix.
183-
print(
184-
"[rafter] Warning: Betterleaks output is not an array — possible version mismatch. "
185-
"Run: rafter agent update-betterleaks",
186-
file=sys.stderr,
187-
)
169+
@staticmethod
170+
def _parse_report(report_path: str) -> list[dict]:
171+
"""Read a betterleaks JSON report into a findings list.
172+
173+
Mirrors `parseResults` in node/src/scanners/betterleaks.ts — keep the
174+
two in sync.
175+
"""
176+
try:
177+
with open(report_path) as f:
178+
content = f.read().strip()
179+
if not content:
188180
return []
189-
return parsed
181+
parsed = json.loads(content)
182+
except json.JSONDecodeError as exc:
183+
print(f"[rafter] Warning: Failed to parse Betterleaks report: {exc}", file=sys.stderr)
184+
return []
185+
186+
# #217 — betterleaks >=1.1.2 writes the literal `null` (not `[]`)
187+
# for a clean scan via the `dir`/`git` subcommands we invoke. That
188+
# is a valid empty result, not a version mismatch, so it must not
189+
# reach the warning branch below — otherwise every clean file
190+
# scanned emits warning noise.
191+
if parsed is None:
192+
return []
193+
194+
if not isinstance(parsed, list):
195+
# sable-o4k — a stale/incompatible binary emits a non-array
196+
# shape and would otherwise silently yield zero findings. The
197+
# managed binary is auto-updated upstream; this covers a stale
198+
# binary on PATH, which we can't safely overwrite — so point the
199+
# user at the fix.
200+
print(
201+
"[rafter] Warning: Betterleaks output is not an array — possible version mismatch. "
202+
"Run: rafter agent update-betterleaks",
203+
file=sys.stderr,
204+
)
205+
return []
206+
return parsed
190207

191208
@staticmethod
192209
def _convert(result: dict) -> PatternMatch:
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for BetterleaksScanner._parse_report.
2+
3+
Mirrors node/tests/betterleaks-parse-report.test.ts — keep the two in sync.
4+
Exercises the parser directly so the tests stay hermetic and run without the
5+
betterleaks binary installed.
6+
"""
7+
from __future__ import annotations
8+
9+
import json
10+
11+
import pytest
12+
13+
from rafter_cli.scanners.betterleaks import BetterleaksScanner
14+
15+
16+
@pytest.fixture
17+
def write_report(tmp_path):
18+
def _write(content: str) -> str:
19+
p = tmp_path / "report.json"
20+
p.write_text(content)
21+
return str(p)
22+
23+
return _write
24+
25+
26+
class TestParseReport:
27+
# #217 — betterleaks >=1.1.2 writes the literal `null` for a clean scan via
28+
# the `dir`/`git` subcommands. Regression guard: this is an empty result,
29+
# not a version mismatch, and must not emit warning noise on every clean
30+
# file scanned.
31+
def test_null_report_is_empty_result(self, write_report, capsys):
32+
assert BetterleaksScanner._parse_report(write_report("null")) == []
33+
assert capsys.readouterr().err == ""
34+
35+
def test_null_report_with_trailing_whitespace(self, write_report, capsys):
36+
assert BetterleaksScanner._parse_report(write_report("null\n")) == []
37+
assert capsys.readouterr().err == ""
38+
39+
def test_empty_report(self, write_report, capsys):
40+
assert BetterleaksScanner._parse_report(write_report("")) == []
41+
assert capsys.readouterr().err == ""
42+
43+
def test_array_report_returns_findings(self, write_report, capsys):
44+
findings = [{"RuleID": "aws-secret-key", "Description": "AWS key", "StartLine": 3}]
45+
assert BetterleaksScanner._parse_report(write_report(json.dumps(findings))) == findings
46+
assert capsys.readouterr().err == ""
47+
48+
def test_empty_array_report(self, write_report, capsys):
49+
assert BetterleaksScanner._parse_report(write_report("[]")) == []
50+
assert capsys.readouterr().err == ""
51+
52+
# sable-o4k — the stale-binary guard must survive the #217 fix.
53+
def test_non_array_object_still_warns(self, write_report, capsys):
54+
assert BetterleaksScanner._parse_report(write_report('{"findings": []}')) == []
55+
assert "possible version mismatch" in capsys.readouterr().err
56+
57+
def test_malformed_json_still_warns(self, write_report, capsys):
58+
assert BetterleaksScanner._parse_report(write_report("{not json")) == []
59+
assert "Failed to parse" in capsys.readouterr().err

0 commit comments

Comments
 (0)