Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
68 changes: 67 additions & 1 deletion src/runbookproof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import json
import re
import sys
from collections.abc import Sequence
from contextlib import redirect_stdout
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -379,17 +400,43 @@ 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)

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"
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -491,25 +545,29 @@ def _run_scan(
path,
engine,
output_format,
ignored_rule_ids,
)

return _run_file_scan(
path,
engine,
output_format,
ignored_rule_ids,
)


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():
Expand All @@ -525,6 +583,7 @@ def _run_scan_with_output(
exit_code = _run_scan(
path,
output_format,
ignored_rule_ids,
)

if exit_code == 2:
Expand Down Expand Up @@ -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}")
Expand Down
133 changes: 133 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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