diff --git a/README.md b/README.md index a32baf5..0774c9e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ uv run runbookproof scan docs/ ## Usage ```text -runbookproof scan PATH [--format {text,json,sarif}] [-o OUTPUT] +runbookproof scan PATH [--format {text,json,sarif}] [-o OUTPUT] [--ignore-rule RULE_ID] ``` ### Human-readable output @@ -122,6 +122,25 @@ uv run runbookproof scan README.md \ -o runbookproof.sarif ``` +### Ignore selected rules + +Ignore a finding by its rule ID: + +```bash +uv run runbookproof scan docs/ \ + --ignore-rule RBP-AZURE-001 +``` + +The option can be repeated and rule IDs are case-insensitive: + +```bash +uv run runbookproof scan docs/ \ + --ignore-rule RBP-AZURE-001 \ + --ignore-rule RBP-AWS-002 +``` + +Ignored findings are removed from text, JSON, and SARIF output. They are also excluded from finding counts and exit-code calculation. + ## Built-in verification packs | Pack | Command family | diff --git a/src/runbookproof/cli.py b/src/runbookproof/cli.py index a211bab..0240951 100644 --- a/src/runbookproof/cli.py +++ b/src/runbookproof/cli.py @@ -4,6 +4,7 @@ import argparse import json +import re import sys from collections.abc import Sequence from contextlib import redirect_stdout @@ -36,6 +37,17 @@ _SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" _PROJECT_URL = "https://github.com/antonisloukis/runbookproof" +_RULE_ID_PATTERN = re.compile(r"^RBP-[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}$") + + +def _rule_id(value: str) -> str: + """Validate and normalize one ignored rule ID.""" + normalized = value.strip().upper() + + if not _RULE_ID_PATTERN.fullmatch(normalized): + raise argparse.ArgumentTypeError("rule ID must follow the format RBP-PACK-001") + + return normalized def build_parser() -> argparse.ArgumentParser: @@ -84,6 +96,15 @@ def build_parser() -> argparse.ArgumentParser: dest="output_path", help="Write scan output to a UTF-8 file instead of standard output.", ) + scan_parser.add_argument( + "--ignore-rule", + action="append", + default=[], + type=_rule_id, + dest="ignored_rule_ids", + metavar="RULE_ID", + help=("Ignore findings with this rule ID. May be supplied more than once."), + ) return parser @@ -379,10 +400,31 @@ def _markdown_files( ) +def _filter_report( + report: AnalysisReport, + ignored_rule_ids: frozenset[str], +) -> AnalysisReport: + """Return a report without findings from ignored rules.""" + if not ignored_rule_ids: + return report + + return AnalysisReport( + path=report.path, + parse_results=report.parse_results, + findings=tuple( + finding + for finding in report.findings + if finding.rule_id not in ignored_rule_ids + ), + pack_names=report.pack_names, + ) + + def _run_file_scan( path: Path, engine: VerificationEngine, output_format: OutputFormat, + ignored_rule_ids: frozenset[str], ) -> int: """Scan one Markdown file.""" report = _analyze_file(path, engine) @@ -390,6 +432,11 @@ def _run_file_scan( if report is None: return 2 + report = _filter_report( + report, + ignored_rule_ids, + ) + if output_format == "json": payload = _report_to_dict(report) payload["kind"] = "file" @@ -406,6 +453,7 @@ def _run_directory_scan( path: Path, engine: VerificationEngine, output_format: OutputFormat, + ignored_rule_ids: frozenset[str], ) -> int: """Recursively scan Markdown files.""" files = _markdown_files(path) @@ -420,7 +468,12 @@ def _run_directory_scan( if report is None: return 2 - reports.append(report) + reports.append( + _filter_report( + report, + ignored_rule_ids, + ) + ) command_count = sum(report.command_count for report in reports) finding_count = sum(report.finding_count for report in reports) @@ -480,6 +533,7 @@ def _run_directory_scan( def _run_scan( path: Path, output_format: OutputFormat, + ignored_rule_ids: frozenset[str], ) -> int: """Scan one Markdown file or directory.""" engine = VerificationEngine( @@ -491,12 +545,14 @@ def _run_scan( path, engine, output_format, + ignored_rule_ids, ) return _run_file_scan( path, engine, output_format, + ignored_rule_ids, ) @@ -504,12 +560,14 @@ def _run_scan_with_output( path: Path, output_format: OutputFormat, output_path: Path | None, + ignored_rule_ids: frozenset[str], ) -> int: """Run a scan and optionally write its output to a file.""" if output_path is None: return _run_scan( path, output_format, + ignored_rule_ids, ) if path.is_file() and path.resolve() == output_path.resolve(): @@ -525,6 +583,7 @@ def _run_scan_with_output( exit_code = _run_scan( path, output_format, + ignored_rule_ids, ) if exit_code == 2: @@ -572,11 +631,18 @@ def main( Path | None, arguments.output_path, ) + ignored_rule_ids = frozenset( + cast( + list[str], + arguments.ignored_rule_ids, + ) + ) return _run_scan_with_output( path, output_format, output_path, + ignored_rule_ids, ) parser.error(f"unknown command: {command}") diff --git a/tests/test_cli.py b/tests/test_cli.py index e6a588c..00df638 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -667,3 +667,136 @@ def test_scan_reports_output_write_error( assert captured.out == "" assert ("runbookproof: error: cannot write missing/report.json") in captured.err + + +def test_scan_can_ignore_rule_in_text_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """An ignored rule should be removed from text output.""" + monkeypatch.chdir(tmp_path) + + path = Path("risky.md") + path.write_text( + "```bash\naz group delete --name production --yes\n```\n", + encoding="utf-8", + ) + + assert ( + main( + [ + "scan", + str(path), + "--ignore-rule", + "RBP-FAKE-999", + "--ignore-rule", + "rbp-azure-001", + ] + ) + == 0 + ) + + captured = capsys.readouterr() + + assert captured.out == ( + "Scanned risky.md: 1 command, 0 errors, 0 warnings, 0 info\n" + ) + assert captured.err == "" + + +def test_scan_can_ignore_rule_in_json_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ignored rules should be absent from JSON reports.""" + monkeypatch.chdir(tmp_path) + + path = Path("risky.md") + path.write_text( + "```bash\naz group delete --name production --yes\n```\n", + encoding="utf-8", + ) + + assert ( + main( + [ + "scan", + str(path), + "--format", + "json", + "--ignore-rule", + "RBP-AZURE-001", + ] + ) + == 0 + ) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + + assert captured.err == "" + assert payload["finding_count"] == 0 + assert payload["error_count"] == 0 + assert payload["exit_code"] == 0 + assert payload["findings"] == [] + + +def test_scan_can_ignore_rule_in_sarif_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ignored rules should be absent from SARIF output.""" + monkeypatch.chdir(tmp_path) + + path = Path("risky.md") + path.write_text( + "```bash\naz group delete --name production --yes\n```\n", + encoding="utf-8", + ) + + assert ( + main( + [ + "scan", + str(path), + "--format", + "sarif", + "--ignore-rule", + "RBP-AZURE-001", + ] + ) + == 0 + ) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + run = payload["runs"][0] + + assert captured.err == "" + assert run["tool"]["driver"]["rules"] == [] + assert run["results"] == [] + + +def test_scan_rejects_invalid_ignored_rule_id( + capsys: pytest.CaptureFixture[str], +) -> None: + """Malformed ignored rule IDs should be rejected.""" + with pytest.raises(SystemExit) as error: + main( + [ + "scan", + "README.md", + "--ignore-rule", + "not-a-rule", + ] + ) + + assert error.value.code == 2 + + captured = capsys.readouterr() + + assert captured.out == "" + assert "rule ID must follow the format RBP-PACK-001" in captured.err