diff --git a/README.md b/README.md index 0774c9e..50ba88a 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] [--ignore-rule RULE_ID] +runbookproof scan PATH [--format {text,json,sarif}] [-o OUTPUT] [--ignore-rule RULE_ID] [--config PATH] ``` ### Human-readable output @@ -141,6 +141,35 @@ uv run runbookproof scan docs/ \ Ignored findings are removed from text, JSON, and SARIF output. They are also excluded from finding counts and exit-code calculation. +### Configuration file + +RunbookProof automatically loads `.runbookproof.toml` from the current working directory when the file exists: + +```toml +[scan] +ignore_rules = [ + "RBP-AZURE-001", + "RBP-AWS-002", +] +``` + +Run the scan normally: + +```bash +uv run runbookproof scan docs/ +``` + +Rules from the configuration file are combined with any `--ignore-rule` options supplied on the command line. + +Use a different configuration file with `--config`: + +```bash +uv run runbookproof scan docs/ \ + --config config/runbookproof.toml +``` + +An explicitly selected configuration file must exist and contain valid TOML. + ## Built-in verification packs | Pack | Command family | @@ -284,7 +313,6 @@ git diff --check ## Roadmap -- Configuration file and rule-level controls - Finding suppression with documented justifications - Custom and third-party verification packs - Expanded rule documentation diff --git a/src/runbookproof/cli.py b/src/runbookproof/cli.py index 0240951..59f744d 100644 --- a/src/runbookproof/cli.py +++ b/src/runbookproof/cli.py @@ -6,6 +6,7 @@ import json import re import sys +import tomllib from collections.abc import Sequence from contextlib import redirect_stdout from io import StringIO @@ -37,6 +38,7 @@ _SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" _PROJECT_URL = "https://github.com/antonisloukis/runbookproof" +_DEFAULT_CONFIG_PATH = Path(".runbookproof.toml") _RULE_ID_PATTERN = re.compile(r"^RBP-[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}$") @@ -50,6 +52,96 @@ def _rule_id(value: str) -> str: return normalized +def _print_config_error( + path: Path, + message: str, +) -> None: + """Print a configuration-file error.""" + print( + f"runbookproof: error: {path}: {message}", + file=sys.stderr, + ) + + +def _load_config_rule_ids( + path: Path, + *, + required: bool, +) -> frozenset[str] | None: + """Load ignored rule IDs from a TOML configuration file.""" + try: + with path.open("rb") as config_file: + config = tomllib.load(config_file) + except FileNotFoundError: + if required: + _print_config_error( + path, + "file not found", + ) + return None + + return frozenset() + except tomllib.TOMLDecodeError as error: + _print_config_error( + path, + f"invalid TOML: {error}", + ) + return None + except OSError as error: + detail = error.strerror or str(error) + + _print_config_error( + path, + f"cannot read file: {detail}", + ) + return None + + scan_value: object = config.get( + "scan", + {}, + ) + + if not isinstance(scan_value, dict): + _print_config_error( + path, + "scan must be a TOML table", + ) + return None + + ignore_value: object = scan_value.get( + "ignore_rules", + [], + ) + + if not isinstance(ignore_value, list): + _print_config_error( + path, + "scan.ignore_rules must be an array of rule IDs", + ) + return None + + ignored_rule_ids: set[str] = set() + + for value in ignore_value: + if not isinstance(value, str): + _print_config_error( + path, + "scan.ignore_rules must contain only strings", + ) + return None + + try: + ignored_rule_ids.add(_rule_id(value)) + except argparse.ArgumentTypeError as error: + _print_config_error( + path, + f"invalid rule ID {value!r}: {error}", + ) + return None + + return frozenset(ignored_rule_ids) + + def build_parser() -> argparse.ArgumentParser: """Create and configure the command-line parser.""" parser = argparse.ArgumentParser( @@ -105,6 +197,15 @@ def build_parser() -> argparse.ArgumentParser: metavar="RULE_ID", help=("Ignore findings with this rule ID. May be supplied more than once."), ) + scan_parser.add_argument( + "--config", + type=Path, + dest="config_path", + metavar="PATH", + help=( + "Load configuration from PATH. Defaults to .runbookproof.toml when present." + ), + ) return parser @@ -631,12 +732,28 @@ def main( Path | None, arguments.output_path, ) - ignored_rule_ids = frozenset( + cli_ignored_rule_ids = frozenset( cast( list[str], arguments.ignored_rule_ids, ) ) + config_argument = cast( + Path | None, + arguments.config_path, + ) + config_path = ( + config_argument if config_argument is not None else _DEFAULT_CONFIG_PATH + ) + config_ignored_rule_ids = _load_config_rule_ids( + config_path, + required=config_argument is not None, + ) + + if config_ignored_rule_ids is None: + return 2 + + ignored_rule_ids = config_ignored_rule_ids | cli_ignored_rule_ids return _run_scan_with_output( path, diff --git a/tests/test_cli.py b/tests/test_cli.py index 00df638..2a19b2f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -800,3 +800,219 @@ def test_scan_rejects_invalid_ignored_rule_id( assert captured.out == "" assert "rule ID must follow the format RBP-PACK-001" in captured.err + + +def test_scan_loads_default_config_ignore_rules( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The default configuration should provide ignored rules.""" + monkeypatch.chdir(tmp_path) + + Path(".runbookproof.toml").write_text( + '[scan]\nignore_rules = ["rbp-azure-001"]\n', + encoding="utf-8", + ) + + 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", + ] + ) + == 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_combines_config_and_cli_ignored_rules( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI and configuration ignored rules should be combined.""" + monkeypatch.chdir(tmp_path) + + Path(".runbookproof.toml").write_text( + '[scan]\nignore_rules = ["RBP-FAKE-999"]\n', + encoding="utf-8", + ) + + 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-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_loads_explicit_config_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """An explicit configuration path should be supported.""" + monkeypatch.chdir(tmp_path) + + config_path = Path("config/settings.toml") + config_path.parent.mkdir() + config_path.write_text( + '[scan]\nignore_rules = ["RBP-AZURE-001"]\n', + encoding="utf-8", + ) + + path = Path("risky.md") + path.write_text( + "```bash\naz group delete --name production --yes\n```\n", + encoding="utf-8", + ) + + assert ( + main( + [ + "scan", + str(path), + "--config", + str(config_path), + ] + ) + == 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_reports_missing_explicit_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A missing explicit configuration should return two.""" + monkeypatch.chdir(tmp_path) + + assert ( + main( + [ + "scan", + "README.md", + "--config", + "missing.toml", + ] + ) + == 2 + ) + + captured = capsys.readouterr() + + assert captured.out == "" + assert captured.err == ("runbookproof: error: missing.toml: file not found\n") + + +def test_scan_reports_invalid_toml_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Malformed TOML should return two.""" + monkeypatch.chdir(tmp_path) + + Path(".runbookproof.toml").write_text( + "[scan\n", + encoding="utf-8", + ) + + assert main(["scan", "README.md"]) == 2 + + captured = capsys.readouterr() + + assert captured.out == "" + assert ("runbookproof: error: .runbookproof.toml: invalid TOML:") in captured.err + + +def test_scan_rejects_non_array_config_ignore_rules( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Configuration ignore_rules must be a TOML array.""" + monkeypatch.chdir(tmp_path) + + Path(".runbookproof.toml").write_text( + '[scan]\nignore_rules = "RBP-AZURE-001"\n', + encoding="utf-8", + ) + + assert main(["scan", "README.md"]) == 2 + + captured = capsys.readouterr() + + assert captured.out == "" + assert captured.err == ( + "runbookproof: error: " + ".runbookproof.toml: " + "scan.ignore_rules must be an array of rule IDs\n" + ) + + +def test_scan_rejects_invalid_config_rule_id( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Malformed configuration rule IDs should be rejected.""" + monkeypatch.chdir(tmp_path) + + Path(".runbookproof.toml").write_text( + '[scan]\nignore_rules = ["not-a-rule"]\n', + encoding="utf-8", + ) + + assert main(["scan", "README.md"]) == 2 + + captured = capsys.readouterr() + + assert captured.out == "" + assert ( + "invalid rule ID 'not-a-rule': rule ID must follow the format RBP-PACK-001" + ) in captured.err