Your first rule in 10 minutes
This is the fast path: scaffold a rule, fill in the detection, run one
gate, open the PR. For the full reference (every Rule field, the
per-provider check signatures, cross-provider primitives, autofix,
standards mappings) see Adding a rule.
We will add a GitHub Actions rule as the worked example. GitHub has the
most rules and the simplest check signature, so it is the easiest
place to start.
0. Set up (once)
pip install -e ".[dev]" # or: make install (hash-locked deps)
pre-commit install # ruff on commit
pre-commit install --hook-type pre-push # drift tests on push
1. Scaffold the rule
This picks the next free ID, writes the rule module and a matching test stub, and prints the remaining checklist:
created pipeline_check/core/checks/github/rules/gha107_schedule_without_permissions.py
created tests/github/test_schedule_without_permissions.py
(Drop --apply to preview the files without writing them.) The module
is born with a passing stub check, so it is already discoverable and
the suite still imports. Your job is to replace the stub with real
logic.
2. Write the detection
Open the new module. The scaffold gives you the imports, a RULE block
with TODO prose, and a stub check. Replace the TODO prose, then
write the body. For this example we flag a workflow that runs on a
schedule: trigger but sets no top-level permissions: (so it inherits
the broad default token).
"""GHA-107. Scheduled workflow without an explicit permissions block."""
from __future__ import annotations
from typing import Any
from ...base import Finding, Severity
from ...rule import Rule
from ..base import workflow_triggers # add the helper you need
RULE = Rule(
id="GHA-107",
title="Scheduled workflow runs with the default token scope",
severity=Severity.MEDIUM,
recommendation=(
"Set a top-level `permissions:` block (start from "
"`permissions: { contents: read }`) so the scheduled run does "
"not inherit the broad default GITHUB_TOKEN scope."
),
docs_note=(
"Fires when a workflow's `on:` includes `schedule` and the "
"workflow has no top-level `permissions:` key."
),
)
def check(path: str, doc: dict[str, Any]) -> Finding:
scheduled = "schedule" in workflow_triggers(doc)
has_permissions = "permissions" in doc
passed = not (scheduled and not has_permissions)
# ``RULE.finding`` fills check_id / title / severity / recommendation
# from RULE; pass any other Finding field as a keyword (locations=,
# job_anchors=, ...). RULE.fail_finding / pass_finding are the
# fixed-``passed`` shorthands.
return RULE.finding(
path,
"No issue detected."
if passed
else "Scheduled workflow has no top-level permissions block.",
passed=passed,
)
Read a neighboring rule (gha105_self_hosted_untrusted_trigger.py is a
good one) for the helpers available on ..base and the house style for
the prose fields.
3. Write the tests
Open tests/github/test_schedule_without_permissions.py. The scaffold
left a passing test_metadata and two skipped behavioral stubs. Replace
the skips with real cases using the provider's run_check helper:
from .conftest import run_check
CHECK_ID = "GHA-107"
class TestGHA107:
def test_fails_on_schedule_without_permissions(self):
wf = """
on: { schedule: [{ cron: "0 0 * * *" }] }
jobs: { b: { runs-on: ubuntu-latest, steps: [{ run: echo hi }] } }
"""
assert not run_check(wf, CHECK_ID).passed
def test_passes_with_permissions(self):
wf = """
on: { schedule: [{ cron: "0 0 * * *" }] }
permissions: { contents: read }
jobs: { b: { runs-on: ubuntu-latest, steps: [{ run: echo hi }] } }
"""
assert run_check(wf, CHECK_ID).passed
tests/test_rule_test_coverage.py requires a class Test<RULE_ID> for
every CI-provider rule, so keep the class name in sync with the ID.
4. Bump the drift gates
The scaffold printed these. The framework makes the count bump a deliberate step, so it is not automated:
- Set
EXPECTED_RULE_COUNTS["github"] = 115intests/test_rule_framework.py(the current count plus your new rule). - Add a positive trigger to
tests/fixtures/workflows/github/insecure-*and bumpEXPECTED_IDSintests/test_workflow_fixtures.py. - Regenerate the provider doc:
python scripts/gen_provider_docs.py github. - Update the check count in
README.mdanddocs/index.md(tests/test_doc_claims.pypins them).
5. Run the gate
python scripts/preflight.py --quick # lint, doc-freshness, mypy, drift tests
python scripts/preflight.py # the full suite before you push
preflight.py runs the same checks CI does, so a green run locally means
a green run on the PR.
6. Open the PR
- Branch from
dev, notmaster. - Add a
## [Unreleased]entry toCHANGELOG.mdunder### Added. - Fill in the PR template checklist.
That is the whole loop. Map the rule to a compliance control or add an autofix when it fits; both are optional and covered in Adding a rule.
Going deeper
- Adding a rule: the complete reference.
- Adding an attack chain: correlate findings into a kill chain.
- Adding a provider: wire up a brand-new ecosystem.