diff --git a/README.md b/README.md index 50ba88a..80377d5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ uv run runbookproof scan docs/ ```text runbookproof scan PATH [--format {text,json,sarif}] [-o OUTPUT] [--ignore-rule RULE_ID] [--config PATH] +runbookproof rules [--format {text,json}] ``` ### Human-readable output @@ -170,6 +171,23 @@ uv run runbookproof scan docs/ \ An explicitly selected configuration file must exist and contain valid TOML. +## Rule catalogue + +List every built-in rule: + +```bash +uv run runbookproof rules +``` + +Generate machine-readable output: + +```bash +uv run runbookproof rules --format json +``` + +The complete reference is available in +[`docs/rules.md`](docs/rules.md). + ## Built-in verification packs | Pack | Command family | @@ -315,7 +333,6 @@ git diff --check - Finding suppression with documented justifications - Custom and third-party verification packs -- Expanded rule documentation - Reusable GitHub Action distribution - First public package release diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..4887eb0 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,113 @@ +# RunbookProof rule catalogue + +This reference lists the built-in static-analysis rules. + +| Rule ID | Verification pack | Description | +|---|---|---| +| `RBP-AWS-001` | `aws-cli` | AWS CLI TLS certificate verification is disabled | +| `RBP-AWS-002` | `aws-cli` | AWS CLI command contains a literal credential | +| `RBP-AWS-003` | `aws-cli` | AWS CLI command deletes or destroys resources | +| `RBP-AWS-004` | `aws-cli` | AWS resource safety controls are bypassed | +| `RBP-AWS-005` | `aws-cli` | AWS EC2 mutation has no dry-run permission check | +| `RBP-AWS-006` | `aws-cli` | AWS CLI command enables public S3 access | +| `RBP-AWS-007` | `aws-cli` | EC2 security-group ingress is open to the internet | +| `RBP-AWS-008` | `aws-cli` | AWS IAM command grants broad administrative access | +| `RBP-AWS-009` | `aws-cli` | AWS CLI command returns sensitive credential material | +| `RBP-AWS-010` | `aws-cli` | AWS CLI command uses an unencrypted HTTP endpoint | +| `RBP-AWS-011` | `aws-cli` | AWS security monitoring is disabled or deleted | +| `RBP-AWS-012` | `aws-cli` | AWS database resource is configured as public | +| `RBP-AWS-013` | `aws-cli` | AWS CLI request is sent without request signing | +| `RBP-BASH-001` | `bash` | Remote script is piped directly to a shell | +| `RBP-BASH-002` | `bash` | Command performs recursive deletion
Recursive deletion targets a critical path | +| `RBP-BASH-003` | `bash` | Command grants world-writable permissions | +| `RBP-BASH-004` | `bash` | Command requires elevated privileges | +| `RBP-BASH-005` | `bash` | Destructive command contains an unquoted variable | +| `RBP-BASH-006` | `bash` | Command may not be portable across POSIX shells | +| `RBP-DOCKER-001` | `docker` | Remote Docker daemon connection is not verified | +| `RBP-DOCKER-002` | `docker` | Container is granted unrestricted host privileges | +| `RBP-DOCKER-003` | `docker` | Container shares host namespaces | +| `RBP-DOCKER-004` | `docker` | Container mounts the Docker daemon socket | +| `RBP-DOCKER-005` | `docker` | Container mounts a sensitive host path | +| `RBP-DOCKER-006` | `docker` | Container explicitly runs as root | +| `RBP-DOCKER-007` | `docker` | Container image is not pinned by digest | +| `RBP-DOCKER-008` | `docker` | Container security controls are disabled | +| `RBP-DOCKER-009` | `docker` | Container receives dangerous Linux capabilities | +| `RBP-DOCKER-010` | `docker` | Finding generated by the docker verification pack. | +| `RBP-DOCKER-011` | `docker` | Command contains a literal secret value | +| `RBP-GIT-001` | `git` | Hard reset can permanently discard local changes | +| `RBP-GIT-002` | `git` | Forced push can overwrite remote history | +| `RBP-GIT-003` | `git` | Force-with-lease can rewrite remote history | +| `RBP-GIT-004` | `git` | Git clean can remove untracked directories or files
Git clean permanently removes untracked files | +| `RBP-GIT-005` | `git` | Forced branch deletion can remove unmerged work | +| `RBP-GIT-006` | `git` | Command can discard working-tree changes | +| `RBP-GIT-007` | `git` | Command deletes a remote Git branch | +| `RBP-GIT-008` | `git` | Git pull does not specify an integration strategy | +| `RBP-GIT-009` | `git` | Branch creation uses legacy checkout syntax | +| `RBP-GIT-010` | `git` | Command permanently removes Git stash entries | +| `RBP-KUBECTL-001` | `kubectl` | Kubernetes API certificate verification is disabled | +| `RBP-KUBECTL-002` | `kubectl` | kubectl command contains a literal credential | +| `RBP-KUBECTL-003` | `kubectl` | Mutation targets every Kubernetes namespace | +| `RBP-KUBECTL-004` | `kubectl` | kubectl command deletes Kubernetes resources | +| `RBP-KUBECTL-005` | `kubectl` | kubectl command bypasses graceful safety controls | +| `RBP-KUBECTL-006` | `kubectl` | Mutation selects all matching Kubernetes resources | +| `RBP-KUBECTL-007` | `kubectl` | kubectl mutation has no dry-run preview | +| `RBP-KUBECTL-008` | `kubectl` | Server-side apply forcibly overrides field conflicts | +| `RBP-KUBECTL-009` | `kubectl` | kubectl command opens an interactive cluster session | +| `RBP-KUBECTL-010` | `kubectl` | kubectl command contains a literal secret value | +| `RBP-KUBECTL-011` | `kubectl` | Kubernetes workload image is not pinned by digest | +| `RBP-KUBECTL-012` | `kubectl` | kubectl command creates privileged workload access | +| `RBP-KUBECTL-013` | `kubectl` | Kubernetes object schema validation is disabled | +| `RBP-KUBECTL-014` | `kubectl` | kubectl command accesses a raw Kubernetes API path | +| `RBP-NODE-001` | `node-package` | Package manager disables transport security | +| `RBP-NODE-002` | `node-package` | Executed package does not specify a version | +| `RBP-NODE-003` | `node-package` | Package execution suppresses confirmation | +| `RBP-NODE-004` | `node-package` | Command installs a package globally | +| `RBP-NODE-005` | `node-package` | Dependency safety checks are bypassed | +| `RBP-NODE-006` | `node-package` | Dependency installation may not be reproducible | +| `RBP-NODE-007` | `node-package` | Package lifecycle scripts are explicitly enabled | +| `RBP-NODE-008` | `node-package` | Package security checks are disabled | +| `RBP-NODE-009` | `node-package` | npm --save is redundant in modern npm | +| `RBP-PYTHON-001` | `python-package` | Python package transport security is disabled | +| `RBP-PYTHON-002` | `python-package` | Python package installation is not exactly pinned | +| `RBP-PYTHON-003` | `python-package` | Remote Python package source is not immutable | +| `RBP-PYTHON-004` | `python-package` | Command can modify a system-managed Python installation | +| `RBP-PYTHON-005` | `python-package` | Requirements installation does not require hashes | +| `RBP-PYTHON-006` | `python-package` | Package hash verification is disabled | +| `RBP-PYTHON-007` | `python-package` | uv sync may update the project lockfile | +| `RBP-PYTHON-008` | `python-package` | Command uses a legacy Python installation interface | +| `RBP-TERRAFORM-001` | `terraform` | Terraform changes are automatically approved without a saved plan | +| `RBP-TERRAFORM-002` | `terraform` | Terraform command destroys managed infrastructure | +| `RBP-TERRAFORM-003` | `terraform` | Terraform apply generates a new plan at execution time | +| `RBP-TERRAFORM-004` | `terraform` | Terraform operation uses targeted or forced resource scope | +| `RBP-TERRAFORM-005` | `terraform` | Terraform state locking is disabled | +| `RBP-TERRAFORM-006` | `terraform` | Terraform state lock is forcibly removed | +| `RBP-TERRAFORM-007` | `terraform` | Finding generated by the terraform verification pack. | +| `RBP-TERRAFORM-008` | `terraform` | Terraform state push disables remote state safety checks | +| `RBP-TERRAFORM-009` | `terraform` | Terraform command contains a literal secret value | +| `RBP-TERRAFORM-010` | `terraform` | Terraform command exposes potentially sensitive state or output data | +| `RBP-TERRAFORM-011` | `terraform` | Terraform refresh command is deprecated | +| `RBP-TERRAFORM-012` | `terraform` | Terraform initialization upgrades dependency selections | +| `RBP-TERRAFORM-013` | `terraform` | Terraform workspace is deleted | +| `RBP-TERRAFORM-014` | `terraform` | Terraform import modifies resource state directly | +| `RBP-UNIVERSAL-001` | `universal` | Command contains invalid shell syntax | +| `RBP-UNIVERSAL-002` | `universal` | Command has no detectable executable | +| `RBP-UNIVERSAL-010` | `universal` | Command uses a shell pipeline | +| `RBP-UNIVERSAL-011` | `universal` | Command uses conditional command chaining | +| `RBP-UNIVERSAL-012` | `universal` | Command contains sequential operations | +| `RBP-UNIVERSAL-013` | `universal` | Command starts a background operation | +| `RBP-UNIVERSAL-014` | `universal` | Command redirects shell input or output | +| `RBP-UNIVERSAL-015` | `universal` | Command performs command substitution | +| `RBP-UNIVERSAL-016` | `universal` | Command performs process substitution | +| `RBP-UNIVERSAL-017` | `universal` | Command performs arithmetic expansion | + +Display the catalogue from the command line: + +```bash +runbookproof rules +``` + +Generate machine-readable output: + +```bash +runbookproof rules --format json +``` diff --git a/src/runbookproof/cli.py b/src/runbookproof/cli.py index 59f744d..712b1ac 100644 --- a/src/runbookproof/cli.py +++ b/src/runbookproof/cli.py @@ -32,8 +32,10 @@ TerraformPack, UniversalPack, ) +from runbookproof.rules import RULES, RuleInfo OutputFormat = Literal["text", "json", "sarif"] +RulesFormat = Literal["text", "json"] SarifLevel = Literal["error", "warning", "note"] _SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" @@ -207,6 +209,19 @@ def build_parser() -> argparse.ArgumentParser: ), ) + rules_parser = subparsers.add_parser( + "rules", + help="List built-in static-analysis rules.", + description=("Display the built-in RunbookProof rule catalogue."), + ) + rules_parser.add_argument( + "--format", + choices=("text", "json"), + default="text", + dest="output_format", + help="Output format. Defaults to text.", + ) + return parser @@ -226,6 +241,49 @@ def _built_in_packs() -> tuple[VerificationPack, ...]: ) +def _rule_to_dict( + rule: RuleInfo, +) -> dict[str, object]: + """Convert one catalogue rule into JSON data.""" + return { + "rule_id": rule.rule_id, + "pack": rule.pack_name, + "messages": list(rule.messages), + } + + +def _print_rule_catalog( + output_format: RulesFormat, +) -> None: + """Print the built-in rule catalogue.""" + if output_format == "json": + _print_json( + { + "rule_count": len(RULES), + "rules": [_rule_to_dict(rule) for rule in RULES], + } + ) + return + + rule_id_width = max( + len("RULE ID"), + *(len(rule.rule_id) for rule in RULES), + ) + pack_width = max( + len("PACK"), + *(len(rule.pack_name) for rule in RULES), + ) + + print(f"{'RULE ID':<{rule_id_width}} {'PACK':<{pack_width}} DESCRIPTION") + + for rule in RULES: + print( + f"{rule.rule_id:<{rule_id_width}} " + f"{rule.pack_name:<{pack_width}} " + f"{rule.description}" + ) + + def _pluralized(count: int, noun: str) -> str: """Return a count with a correctly pluralized noun.""" suffix = "" if count == 1 else "s" @@ -719,6 +777,14 @@ def main( parser.print_help() return 0 + if command == "rules": + rules_format = cast( + RulesFormat, + arguments.output_format, + ) + _print_rule_catalog(rules_format) + return 0 + if command == "scan": path = cast( Path, diff --git a/src/runbookproof/rules.py b/src/runbookproof/rules.py new file mode 100644 index 0000000..4d44e12 --- /dev/null +++ b/src/runbookproof/rules.py @@ -0,0 +1,506 @@ +"""Built-in RunbookProof rule catalogue.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RuleInfo: + """Describe one built-in RunbookProof rule.""" + + rule_id: str + pack_name: str + messages: tuple[str, ...] + + @property + def description(self) -> str: + """Return all known static message variants.""" + return " / ".join(self.messages) + + +RULES: tuple[RuleInfo, ...] = ( + RuleInfo( + rule_id="RBP-AWS-001", + pack_name="aws-cli", + messages=("AWS CLI TLS certificate verification is disabled",), + ), + RuleInfo( + rule_id="RBP-AWS-002", + pack_name="aws-cli", + messages=("AWS CLI command contains a literal credential",), + ), + RuleInfo( + rule_id="RBP-AWS-003", + pack_name="aws-cli", + messages=("AWS CLI command deletes or destroys resources",), + ), + RuleInfo( + rule_id="RBP-AWS-004", + pack_name="aws-cli", + messages=("AWS resource safety controls are bypassed",), + ), + RuleInfo( + rule_id="RBP-AWS-005", + pack_name="aws-cli", + messages=("AWS EC2 mutation has no dry-run permission check",), + ), + RuleInfo( + rule_id="RBP-AWS-006", + pack_name="aws-cli", + messages=("AWS CLI command enables public S3 access",), + ), + RuleInfo( + rule_id="RBP-AWS-007", + pack_name="aws-cli", + messages=("EC2 security-group ingress is open to the internet",), + ), + RuleInfo( + rule_id="RBP-AWS-008", + pack_name="aws-cli", + messages=("AWS IAM command grants broad administrative access",), + ), + RuleInfo( + rule_id="RBP-AWS-009", + pack_name="aws-cli", + messages=("AWS CLI command returns sensitive credential material",), + ), + RuleInfo( + rule_id="RBP-AWS-010", + pack_name="aws-cli", + messages=("AWS CLI command uses an unencrypted HTTP endpoint",), + ), + RuleInfo( + rule_id="RBP-AWS-011", + pack_name="aws-cli", + messages=("AWS security monitoring is disabled or deleted",), + ), + RuleInfo( + rule_id="RBP-AWS-012", + pack_name="aws-cli", + messages=("AWS database resource is configured as public",), + ), + RuleInfo( + rule_id="RBP-AWS-013", + pack_name="aws-cli", + messages=("AWS CLI request is sent without request signing",), + ), + RuleInfo( + rule_id="RBP-BASH-001", + pack_name="bash", + messages=("Remote script is piped directly to a shell",), + ), + RuleInfo( + rule_id="RBP-BASH-002", + pack_name="bash", + messages=( + "Command performs recursive deletion", + "Recursive deletion targets a critical path", + ), + ), + RuleInfo( + rule_id="RBP-BASH-003", + pack_name="bash", + messages=("Command grants world-writable permissions",), + ), + RuleInfo( + rule_id="RBP-BASH-004", + pack_name="bash", + messages=("Command requires elevated privileges",), + ), + RuleInfo( + rule_id="RBP-BASH-005", + pack_name="bash", + messages=("Destructive command contains an unquoted variable",), + ), + RuleInfo( + rule_id="RBP-BASH-006", + pack_name="bash", + messages=("Command may not be portable across POSIX shells",), + ), + RuleInfo( + rule_id="RBP-DOCKER-001", + pack_name="docker", + messages=("Remote Docker daemon connection is not verified",), + ), + RuleInfo( + rule_id="RBP-DOCKER-002", + pack_name="docker", + messages=("Container is granted unrestricted host privileges",), + ), + RuleInfo( + rule_id="RBP-DOCKER-003", + pack_name="docker", + messages=("Container shares host namespaces",), + ), + RuleInfo( + rule_id="RBP-DOCKER-004", + pack_name="docker", + messages=("Container mounts the Docker daemon socket",), + ), + RuleInfo( + rule_id="RBP-DOCKER-005", + pack_name="docker", + messages=("Container mounts a sensitive host path",), + ), + RuleInfo( + rule_id="RBP-DOCKER-006", + pack_name="docker", + messages=("Container explicitly runs as root",), + ), + RuleInfo( + rule_id="RBP-DOCKER-007", + pack_name="docker", + messages=("Container image is not pinned by digest",), + ), + RuleInfo( + rule_id="RBP-DOCKER-008", + pack_name="docker", + messages=("Container security controls are disabled",), + ), + RuleInfo( + rule_id="RBP-DOCKER-009", + pack_name="docker", + messages=("Container receives dangerous Linux capabilities",), + ), + RuleInfo( + rule_id="RBP-DOCKER-010", + pack_name="docker", + messages=("Finding generated by the docker verification pack.",), + ), + RuleInfo( + rule_id="RBP-DOCKER-011", + pack_name="docker", + messages=("Command contains a literal secret value",), + ), + RuleInfo( + rule_id="RBP-GIT-001", + pack_name="git", + messages=("Hard reset can permanently discard local changes",), + ), + RuleInfo( + rule_id="RBP-GIT-002", + pack_name="git", + messages=("Forced push can overwrite remote history",), + ), + RuleInfo( + rule_id="RBP-GIT-003", + pack_name="git", + messages=("Force-with-lease can rewrite remote history",), + ), + RuleInfo( + rule_id="RBP-GIT-004", + pack_name="git", + messages=( + "Git clean can remove untracked directories or files", + "Git clean permanently removes untracked files", + ), + ), + RuleInfo( + rule_id="RBP-GIT-005", + pack_name="git", + messages=("Forced branch deletion can remove unmerged work",), + ), + RuleInfo( + rule_id="RBP-GIT-006", + pack_name="git", + messages=("Command can discard working-tree changes",), + ), + RuleInfo( + rule_id="RBP-GIT-007", + pack_name="git", + messages=("Command deletes a remote Git branch",), + ), + RuleInfo( + rule_id="RBP-GIT-008", + pack_name="git", + messages=("Git pull does not specify an integration strategy",), + ), + RuleInfo( + rule_id="RBP-GIT-009", + pack_name="git", + messages=("Branch creation uses legacy checkout syntax",), + ), + RuleInfo( + rule_id="RBP-GIT-010", + pack_name="git", + messages=("Command permanently removes Git stash entries",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-001", + pack_name="kubectl", + messages=("Kubernetes API certificate verification is disabled",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-002", + pack_name="kubectl", + messages=("kubectl command contains a literal credential",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-003", + pack_name="kubectl", + messages=("Mutation targets every Kubernetes namespace",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-004", + pack_name="kubectl", + messages=("kubectl command deletes Kubernetes resources",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-005", + pack_name="kubectl", + messages=("kubectl command bypasses graceful safety controls",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-006", + pack_name="kubectl", + messages=("Mutation selects all matching Kubernetes resources",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-007", + pack_name="kubectl", + messages=("kubectl mutation has no dry-run preview",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-008", + pack_name="kubectl", + messages=("Server-side apply forcibly overrides field conflicts",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-009", + pack_name="kubectl", + messages=("kubectl command opens an interactive cluster session",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-010", + pack_name="kubectl", + messages=("kubectl command contains a literal secret value",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-011", + pack_name="kubectl", + messages=("Kubernetes workload image is not pinned by digest",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-012", + pack_name="kubectl", + messages=("kubectl command creates privileged workload access",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-013", + pack_name="kubectl", + messages=("Kubernetes object schema validation is disabled",), + ), + RuleInfo( + rule_id="RBP-KUBECTL-014", + pack_name="kubectl", + messages=("kubectl command accesses a raw Kubernetes API path",), + ), + RuleInfo( + rule_id="RBP-NODE-001", + pack_name="node-package", + messages=("Package manager disables transport security",), + ), + RuleInfo( + rule_id="RBP-NODE-002", + pack_name="node-package", + messages=("Executed package does not specify a version",), + ), + RuleInfo( + rule_id="RBP-NODE-003", + pack_name="node-package", + messages=("Package execution suppresses confirmation",), + ), + RuleInfo( + rule_id="RBP-NODE-004", + pack_name="node-package", + messages=("Command installs a package globally",), + ), + RuleInfo( + rule_id="RBP-NODE-005", + pack_name="node-package", + messages=("Dependency safety checks are bypassed",), + ), + RuleInfo( + rule_id="RBP-NODE-006", + pack_name="node-package", + messages=("Dependency installation may not be reproducible",), + ), + RuleInfo( + rule_id="RBP-NODE-007", + pack_name="node-package", + messages=("Package lifecycle scripts are explicitly enabled",), + ), + RuleInfo( + rule_id="RBP-NODE-008", + pack_name="node-package", + messages=("Package security checks are disabled",), + ), + RuleInfo( + rule_id="RBP-NODE-009", + pack_name="node-package", + messages=("npm --save is redundant in modern npm",), + ), + RuleInfo( + rule_id="RBP-PYTHON-001", + pack_name="python-package", + messages=("Python package transport security is disabled",), + ), + RuleInfo( + rule_id="RBP-PYTHON-002", + pack_name="python-package", + messages=("Python package installation is not exactly pinned",), + ), + RuleInfo( + rule_id="RBP-PYTHON-003", + pack_name="python-package", + messages=("Remote Python package source is not immutable",), + ), + RuleInfo( + rule_id="RBP-PYTHON-004", + pack_name="python-package", + messages=("Command can modify a system-managed Python installation",), + ), + RuleInfo( + rule_id="RBP-PYTHON-005", + pack_name="python-package", + messages=("Requirements installation does not require hashes",), + ), + RuleInfo( + rule_id="RBP-PYTHON-006", + pack_name="python-package", + messages=("Package hash verification is disabled",), + ), + RuleInfo( + rule_id="RBP-PYTHON-007", + pack_name="python-package", + messages=("uv sync may update the project lockfile",), + ), + RuleInfo( + rule_id="RBP-PYTHON-008", + pack_name="python-package", + messages=("Command uses a legacy Python installation interface",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-001", + pack_name="terraform", + messages=("Terraform changes are automatically approved without a saved plan",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-002", + pack_name="terraform", + messages=("Terraform command destroys managed infrastructure",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-003", + pack_name="terraform", + messages=("Terraform apply generates a new plan at execution time",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-004", + pack_name="terraform", + messages=("Terraform operation uses targeted or forced resource scope",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-005", + pack_name="terraform", + messages=("Terraform state locking is disabled",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-006", + pack_name="terraform", + messages=("Terraform state lock is forcibly removed",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-007", + pack_name="terraform", + messages=("Finding generated by the terraform verification pack.",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-008", + pack_name="terraform", + messages=("Terraform state push disables remote state safety checks",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-009", + pack_name="terraform", + messages=("Terraform command contains a literal secret value",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-010", + pack_name="terraform", + messages=( + "Terraform command exposes potentially sensitive state or output data", + ), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-011", + pack_name="terraform", + messages=("Terraform refresh command is deprecated",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-012", + pack_name="terraform", + messages=("Terraform initialization upgrades dependency selections",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-013", + pack_name="terraform", + messages=("Terraform workspace is deleted",), + ), + RuleInfo( + rule_id="RBP-TERRAFORM-014", + pack_name="terraform", + messages=("Terraform import modifies resource state directly",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-001", + pack_name="universal", + messages=("Command contains invalid shell syntax",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-002", + pack_name="universal", + messages=("Command has no detectable executable",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-010", + pack_name="universal", + messages=("Command uses a shell pipeline",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-011", + pack_name="universal", + messages=("Command uses conditional command chaining",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-012", + pack_name="universal", + messages=("Command contains sequential operations",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-013", + pack_name="universal", + messages=("Command starts a background operation",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-014", + pack_name="universal", + messages=("Command redirects shell input or output",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-015", + pack_name="universal", + messages=("Command performs command substitution",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-016", + pack_name="universal", + messages=("Command performs process substitution",), + ), + RuleInfo( + rule_id="RBP-UNIVERSAL-017", + pack_name="universal", + messages=("Command performs arithmetic expansion",), + ), +) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2a19b2f..6420628 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import json from pathlib import Path @@ -9,6 +10,7 @@ from runbookproof import __version__ from runbookproof.cli import main +from runbookproof.rules import RULES def test_main_without_arguments_displays_help( @@ -1016,3 +1018,93 @@ def test_scan_rejects_invalid_config_rule_id( assert ( "invalid rule ID 'not-a-rule': rule ID must follow the format RBP-PACK-001" ) in captured.err + + +def test_rules_command_displays_catalog( + capsys: pytest.CaptureFixture[str], +) -> None: + """The rules command should display a text catalogue.""" + assert main(["rules"]) == 0 + + captured = capsys.readouterr() + lines = captured.out.splitlines() + + assert captured.err == "" + assert lines[0].startswith("RULE ID") + assert len(lines) == len(RULES) + 1 + + displayed_rule_ids = [line.split(maxsplit=1)[0] for line in lines[1:]] + + assert displayed_rule_ids == [rule.rule_id for rule in RULES] + + +def test_rules_command_supports_json( + capsys: pytest.CaptureFixture[str], +) -> None: + """The rules command should support JSON output.""" + assert main(["rules", "--format", "json"]) == 0 + + captured = capsys.readouterr() + payload = json.loads(captured.out) + + assert captured.err == "" + assert payload["rule_count"] == len(RULES) + assert payload["rules"] == [ + { + "rule_id": rule.rule_id, + "pack": rule.pack_name, + "messages": list(rule.messages), + } + for rule in RULES + ] + + +def test_rule_catalog_is_unique_and_sorted() -> None: + """Rule IDs should be unique and deterministic.""" + rule_ids = [rule.rule_id for rule in RULES] + + assert rule_ids == sorted(rule_ids) + assert len(rule_ids) == len(set(rule_ids)) + + +def test_rule_catalog_supports_message_variants() -> None: + """One rule ID may have multiple valid messages.""" + bash_rule = next(rule for rule in RULES if rule.rule_id == "RBP-BASH-002") + + assert len(bash_rule.messages) >= 2 + + +def test_rule_catalog_matches_pack_source() -> None: + """Every literal pack rule should be catalogued.""" + discovered_rule_ids: set[str] = set() + + for path in Path("src/runbookproof/packs").glob("*.py"): + tree = ast.parse( + path.read_text(encoding="utf-8"), + filename=str(path), + ) + + for node in ast.walk(tree): + if not isinstance(node, ast.keyword): + continue + + if node.arg != "rule_id": + continue + + try: + value = ast.literal_eval(node.value) + except (ValueError, TypeError): + continue + + if isinstance(value, str) and value.startswith("RBP-"): + discovered_rule_ids.add(value) + + assert {rule.rule_id for rule in RULES} == discovered_rule_ids + + +def test_rule_documentation_contains_catalog() -> None: + """The generated reference should list every rule.""" + documentation = Path("docs/rules.md").read_text(encoding="utf-8") + + for rule in RULES: + assert f"| `{rule.rule_id}` |" in documentation