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