Skip to content

feat(rules): BranchProtection (BP001-BP007) — 7 rules from CIS GH / Scorecard / NIST SSDF#323

Merged
hyperpolymath merged 2 commits into
mainfrom
claude/branch-protection-rules
May 26, 2026
Merged

feat(rules): BranchProtection (BP001-BP007) — 7 rules from CIS GH / Scorecard / NIST SSDF#323
hyperpolymath merged 2 commits into
mainfrom
claude/branch-protection-rules

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Summary

New module Hypatia.Rules.BranchProtection adds 7 rules covering review-and-integrity controls on the default branch. Complements BaselineHealth (drift, #320), WorkflowHardening (content, #321), and SupplyChain (provenance, #322) — together the four modules cover the four facets of estate-wide CI/CD hygiene.

All seven rules read from a single GitHub API endpoint:

gh api repos/{owner}/{repo}/branches/main/protection

Rules

ID Detects Severity Provenance
BP001 required_signatures != true (unsigned commits can land) high CIS GH 1.1.x
BP002 required_linear_history != true (merge commits obscure audit trail) warn CIS GH 1.3.x
BP003 required_approving_review_count < 1 (PRs self-mergeable) critical CIS GH 1.4.x
BP004 dismiss_stale_reviews != true (approvals carry across new commits) warn CIS GH 1.5.x
BP005 require_code_owner_reviews: true but CODEOWNERS missing/empty high CIS GH 1.6.x + NIST SP 800-218 PO.3.2
BP006 enforce_admins != true (admins bypass every other protection) high CIS GH
BP007 allow_force_pushes or allow_deletions (history-integrity break) critical scorecard Branch-Protection tier 1

Tests

mix test test/branch_protection_test.exs15 tests / 0 failures:

  • One no-token regression per rule (7 tests) — each bpNNN_* returns [] cleanly when neither GITHUB_TOKEN nor HYPATIA_DISPATCH_PAT is set. Mirrors the convention in BaselineHealth and SupplyChain.
  • scan/2 facade shape (3 tests) — standard {findings, total, by_severity, dispatch} map; empty findings when no token / no origin remote.
  • dispatch_recommendations/1 (3 tests) — every BP rule routes to :sustainabot; severity→confidence calibration matches the documented table (critical 0.92, high 0.85, warn 0.75, info 0.60).
  • Module surface (2 tests) — all seven bpNNN_* functions plus scan/2 and dispatch_recommendations/1 are exported at the expected arities.

Dispatch routing

Every BP rule routes to :sustainabot. Branch-protection edits require admin scope on the repository, which the bot fleet does not hold — the finding is necessarily advisory only. Confidence is derived from severity (0.92 / 0.85 / 0.75 / 0.60 by tier).

This is a deliberate divergence from SupplyChain's mixed routing (where SC001/SC002/SC009 mechanically route to rhodibot at higher confidence because they're file-add fixes). No BP rule has a mechanical fix that a bot can ship — every one is an owner decision against the live repo settings.

API style

curl_github/1 and fetch_branch_protection/3 are direct ports of the BaselineHealth helpers (same auth header set, same JSON decode path, same {:error, :no_token} short-circuit). Owner/repo extraction from the origin remote also mirrors BaselineHealth for the no-owner_repo-passed case. System.cmd("curl", [...]) form is used throughout per the hypatia CLAUDE.md scanner-hygiene rules (no System.shell, no :os.cmd).

fetch_branch_protection/3 distinguishes {:error, :not_protected} (the API's "Branch not protected" message) from {:error, :no_token} and {:error, "GitHub API: ..."} — every BP rule treats all error cases as [] (no false positives without ground truth).

Module summary

lib/rules/branch_protection.ex — 644 lines.
Single endpoint, seven rules, pattern-match-on-JSON-shape predicate per protection flag.

🤖 Generated with Claude Code

…corecard / NIST SSDF

New module `Hypatia.Rules.BranchProtection` covers review-and-integrity
controls on the default branch. Complements `BaselineHealth` (drift),
`WorkflowHardening` (content), and `SupplyChain` (provenance).

All seven rules read from a single GitHub API endpoint:
`gh api repos/{owner}/{repo}/branches/main/protection`.

## Rules

| ID | Detects | Severity | Source |
|---|---|---|---|
| BP001 | required_signatures != true | high | CIS GH 1.1.x |
| BP002 | required_linear_history != true | warn | CIS GH 1.3.x |
| BP003 | required_approving_review_count < 1 | critical | CIS GH 1.4.x |
| BP004 | dismiss_stale_reviews != true | warn | CIS GH 1.5.x |
| BP005 | require_code_owner_reviews but CODEOWNERS missing | high | CIS GH 1.6.x + NIST PO.3.2 |
| BP006 | enforce_admins != true | high | CIS GH |
| BP007 | allow_force_pushes or allow_deletions | critical | scorecard Branch-Protection tier 1 |

## Tests

15 tests covering:
- One no-token regression per rule (7 tests) — each `bpNNN_*` returns `[]`
  cleanly when neither `GITHUB_TOKEN` nor `HYPATIA_DISPATCH_PAT` is set.
- `scan/2` facade shape (3 tests) — standard `{findings, total,
  by_severity, dispatch}` map; empty result when no token / no origin.
- `dispatch_recommendations/1` (3 tests) — every BP rule routes to
  `:sustainabot`; severity-to-confidence calibration matches the
  documented table (critical 0.92, high 0.85, warn 0.75, info 0.60).
- Module surface (2 tests) — verifies all seven `bpNNN_*` functions plus
  `scan/2` and `dispatch_recommendations/1` are exported.

## Dispatch routing

Every BP rule is owner-side (branch-protection edits require admin
scope on the repository, which the bot fleet does not have). All seven
findings route to `:sustainabot` with severity-derived confidence.

## API style

`curl_github/1` and `fetch_branch_protection/3` are direct ports of the
`BaselineHealth` helpers (same auth header set, same JSON decode, same
`{:error, :no_token}` short-circuit). Owner/repo extraction from
`origin` remote also mirrors `BaselineHealth` for the no-`owner_repo`
case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 110 issues detected

Severity Count
🔴 Critical 0
🟠 High 5
🟡 Medium 105
View findings
[
  {
    "reason": "Docker reference in Nickel config -- Podman/Containerfile highly preferred (Docker permitted) (1 occurrences, CWE-1104)",
    "type": "ncl_docker_not_podman",
    "file": "/home/runner/work/hypatia/hypatia/.machine_readable/svc/k9/hypatia-metadata.k9.ncl",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (1 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/fleet.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/output.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/build.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (3 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/main.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "expect() in hot path (5 occurrences, CWE-754)",
    "type": "expect_in_hot_path",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/scanner.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

@github-actions
Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 110 issues detected

Severity Count
🔴 Critical 0
🟠 High 5
🟡 Medium 105
View findings
[
  {
    "reason": "Docker reference in Nickel config -- Podman/Containerfile highly preferred (Docker permitted) (1 occurrences, CWE-1104)",
    "type": "ncl_docker_not_podman",
    "file": "/home/runner/work/hypatia/hypatia/.machine_readable/svc/k9/hypatia-metadata.k9.ncl",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (1 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/fleet.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/output.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/build.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (3 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/main.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "expect() in hot path (5 occurrences, CWE-754)",
    "type": "expect_in_hot_path",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/scanner.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

@hyperpolymath hyperpolymath merged commit 8c45c8b into main May 26, 2026
35 checks passed
@hyperpolymath hyperpolymath deleted the claude/branch-protection-rules branch May 26, 2026 01:44
hyperpolymath added a commit that referenced this pull request May 26, 2026
## Summary

Adds delegates on \`Hypatia.Rules\` for the rule families landed in PRs
#320, #321, #322, #323. The new modules were independently usable but
invisible from the top-level facade; this PR closes that gap.

## New facade surface

| Function | Delegates to | Rules |
|---|---|---|
| \`scan_baseline_health/2\` | \`BaselineHealth.scan\` (existing;
docstring updated for BH007) | BH001-BH007 |
| \`scan_workflow_hardening/2\` | \`WorkflowHardening.scan\` |
WH001-WH012 |
| \`scan_supply_chain/2\` | \`SupplyChain.scan\` | SC001-SC011 |
| \`scan_branch_protection/2\` | \`BranchProtection.scan\` (API-backed;
takes \`owner, repo\`) | BP001-BP007 |
| \`scan_all_estate_policies/2\` | — runs all four families and merges
results | 34 rules total |

## Why combined-scan helper

Callers (\`pattern_analyzer\`, \`triangle_router\`,
\`fleet_dispatcher\`) already treat \`Hypatia.Rules\` as the entrypoint.
Without these delegates the new modules are reachable only via
fully-qualified names, which works but inverts the facade pattern
adopted in #316.

## RE001-RE010 (PR #325) not included

PR #325 (ResearchExtensions) is still OPEN at the time of this PR.
Adding its alias here would break compilation on \`main\` for the window
between this PR landing and #325 landing. A follow-up commit will wire
\`ResearchExtensions\` once #325 merges — tracked inline at the alias
block as a \`# wired in follow-up\` comment.

## Test plan

- Existing 18-test \`BaselineHealth\` suite still passes (untouched).
- Module compile-checks pass for the four families + facade.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant