Skip to content

feat(cli): predefined policy packs — run many policies in one invocation - #364

Open
refeed wants to merge 2 commits into
mainfrom
sg-predefined-packs
Open

feat(cli): predefined policy packs — run many policies in one invocation#364
refeed wants to merge 2 commits into
mainfrom
sg-predefined-packs

Conversation

@refeed

@refeed refeed commented Sep 4, 2026

Copy link
Copy Markdown
Member

tirith evaluated exactly one policy file per run — start_policy_evaluation did an open()
and a json.load(), and a directory raised IsADirectoryError into the broad handler and
exited 1. Running a set of rules meant one process per rule and no aggregate verdict, which is
why there was nowhere for a predefined pack to live.

This adds the runner, the pack format, and the first pack.

$ tirith --list-packs
terraform-baseline    104 policies  Baseline security and configuration checks for Terraform plans

$ tirith --pack terraform-baseline -input-path plan.json --fail-on-error
...
104 policies · 5 passed · 2 failed · 97 skipped
Skipped policies found no resource of the type they check.

✘ 2 policy/policies failed
$ echo $?
3

Two commits, on purpose

4ee02ccfix(core): a tolerated skip no longer erases an earlier real failure.
Verdict-changing, and reviewable on its own. A provider error within error_tolerance marked
the check skipped unconditionally, so a tolerated miss arriving after a resource that
genuinely violated the policy overwrote the failure. The verdict depended on the order the
provider emitted resources, which is not part of the policy; under --fail-on-error that is
exit 1 where exit 3 was correct — a real violation reported as "nothing was checked".

inputs before after
[FAIL, skip] None False
[skip, FAIL] False False
[PASS, skip] None None
[skip, PASS] None None

[PASS, skip, PASS] is answered by keeping current behaviour rather than by a second change in
the same commit. Regression test covers all four orderings and fails without the fix. It has
its own CHANGELOG section rather than being filed under "backward compatible".

Closes #293

660d25dfeat(cli): the runner and the pack. Closes #319

The runner

start_policy_set_evaluation reads the input document and the variables once and shares them,
so a pack parses the plan once rather than N times. Each policy keeps its own result document
unchanged — a set run is the single-policy result repeated, plus a summary — so anything that
already reads a Tirith result can read one element of policies. A policy that fails to load is
recorded as errored and does not take the run down with it.

-policy-path now accepts a directory (recursive *.json); --pack NAME is repeatable and
combines with it, so local and bundled rules produce one verdict; --list-packs lists what is
installed.

A single policy file is untouched — same result document, same exit codes, same printer. The
rule is that the shape follows how the run was asked for, not how many policies matched, so a
directory holding one policy still reports as a set. That keeps it stateable in one sentence and
keeps tests/core/test_output_compatibility.py's golden bytes intact.

skipped is not a failure

This is the part that decides whether a pack is usable at all. A check applies only to plans
that touch the resource it names, so on any real plan most of a large pack has nothing to look
at — 97 of 104 above. Those are counted separately and do not affect the exit code. Counting
them as errors would make every pack run red regardless of the infrastructure, and the exit code
would carry no information. With --fail-on-error: 3 if any policy failed, 0 if none failed
and at least one reached a verdict, 1 if nothing did.

The pack

terraform-baseline — 104 baseline security and configuration checks for Terraform plans across
AWS, Azure, GCP, Kubernetes and seven smaller providers. Generated by tools/sync_pack.py from
tirith-policy-corpus, whose confirmed tier is its strongest evidence class: the verdict flips
against a synthesized compliant and violating document, and the tool the rule was translated
from independently agreed on both.

Checks carry StackGuardian identifiers and nothing else. meta.id is SG_TF_<NNNN>, allocated
once and frozen in the corpus so a report can name a check and mean the same check next release;
filenames are SG_TF_0042_<resource>_<attribute>.json; tags are lowercased with a cloud:<name>
added and the redundant terraform dropped.

Two independent gates on the renaming, so one edit cannot disable both: the generator refuses to
write a file in which an upstream name, check id or API vocabulary survived, and tests/packs
refuses to ship one. The sweep found branded text in two obvious places (104 meta.id, 13
descriptions) and then 53 more descriptions quoting upstream's APImissing_block_result,
ANY_VALUE, BaseResourceValueCheck — which is not a brand name but points at one particular
scanner as plainly as one. All 51 affected policies were rewritten; see
StackGuardian/tirith-policy-corpus#2, which holds the id registry and the rewrites and is the
only place the upstream identifier survives.

Attribution for the derived rules is in the new NOTICE, as Apache-2.0 requires.

Verification

  • pytest tests — 902 pass. The 11 failures are pre-existing and environmental
    (FileNotFoundError: .test_tmp/plan.json; they need terraform locally), identical on
    unmodified main.
  • 316 pack tests: structurally valid via Tirith's own check_policy, unique SG_TF_ ids, the
    no-leak sweep, and all 104 still flipping against their fixtures. That last one is what
    catches an engine change silently turning a check into a no-op — a pack is generated once and
    then sits still while the engine moves underneath it. Fixtures live in tests/packs/fixtures
    and are deliberately not in the wheel.
  • Wheel built, installed into a clean venv, run from an unrelated directory: pack found, exit 3.
    package_data is declared as well as MANIFEST.in for the reason already recorded there — a
    wheel built from the tree ignores MANIFEST, and the TUI shipped empty that way once.
  • black --check and pydocstyle clean.
  • sync_pack.py re-run is idempotent: ids byte-identical.

Two things worth knowing before this becomes a CIS story

confirmed is 104 policies because the differential was sampled at 120, not because the rest
are worse — absence is unproven, not disproven. Widening is sync_pack.py --tier exact (962) or
--tier verified (2,696): content, not code.

And CIS coverage is thinner than the roadmap's R2-16 assumes. Joining every translated corpus
record to its inventory row: 795 / 2,697 carry any compliance-framework tag, 108 carry an
unversioned cis boolean, and zero carry a versioned CIS control id — all 2,130 cis_v###_#_#
ids live on the out-of-scope cloud-API mods. Thousands of CIS checks runs through #349, not
through regrouping the IaC translations. Relevant to #331 and #327.

Not in scope

resource_filter (#316), no_match_result at the provider level (#297), tirith test
(#318/#301), policies-from-git (#325), --fail-on-severity (#298). On that last one: meta.severity
is present on 1 of the 1,224 policies from this source, so severity gating is real work but it is
not gating for this pack.

`generate_evaluator_result` walks the list a provider returns and folds each
result into one verdict. A provider error within `error_tolerance` marked the
check skipped by assigning `has_evaluation_passed = None` unconditionally — so a
tolerated miss arriving *after* a resource that genuinely violated the policy
overwrote the failure with the skip marker.

The verdict therefore depended on the order the provider happened to emit
resources, which is not part of the policy. Both orderings are reachable on an
ordinary plan: one resource of the target type violating the rule, another the
provider cannot read — a destroy, whose `after` is null.

Under `--fail-on-error` this is the difference between exit 3 and exit 1: a real
violation was reported as "nothing was checked". Failing open is the wrong
direction for a gate.

The fix is to not overwrite a verdict that already exists. A `False` stands; a
`True` still yields to the skip, because nothing was actually verified about the
tolerated resource:

    [FAIL, skip] -> False   (was None)
    [skip, FAIL] -> False
    [PASS, skip] -> None
    [skip, PASS] -> None

That answers the open `[PASS, skip, PASS]` question by keeping current behaviour
rather than by making a second change in the same commit. Regression test covers
all four orderings and fails without this fix.

Verdict-changing, so it gets its own CHANGELOG section rather than being filed
under "backward compatible".

Closes #293
…policy pack

`tirith` evaluated exactly one policy file per run: `start_policy_evaluation` did
an `open()` and a `json.load()`, and a directory raised `IsADirectoryError` into
the broad handler and exited 1. Running a set of rules meant one process per rule
and no aggregate verdict, which is why there was nowhere for a predefined pack to
live.

Three parts.

**The engine.** `start_policy_set_evaluation` reads the input document and the
variables once and shares them across every policy, so a pack parses the plan once
rather than N times. Each policy keeps its own result document unchanged — a set
run is the single-policy result repeated, plus a summary — so anything that
already reads a Tirith result can read one element of `policies` without knowing
where it came from. A policy that fails to load is recorded as `errored` and does
not take the run down with it: a pack is shipped content, and the useful answer is
"these 103 ran, this one is broken".

**The CLI.** `-policy-path` now accepts a directory, walked recursively for
`*.json`. `--pack NAME` runs a bundled pack, is repeatable, and combines with
`-policy-path` so local and bundled rules produce one verdict. `--list-packs`
lists what is installed.

A single policy *file* is untouched — same result document, same exit codes, same
printer. The rule is that the shape follows how the run was asked for, not how
many policies matched, so a directory holding one policy still reports as a set.
That keeps it stateable in one sentence and keeps the golden-file contract in
`tests/core/test_output_compatibility.py` intact.

**`skipped` is not a failure**, and this is the part that decides whether a pack
is usable at all. A check applies only to plans that touch the resource it names,
so on any real plan most of a large pack has nothing to look at: 97 of 104 on the
`aws_instance` fixture. Those are counted separately and do not affect the exit
code. Counting them as errors would make every pack run red regardless of the
infrastructure, and the exit code would carry no information. With
`--fail-on-error`: 3 if any policy failed, 0 if none failed and at least one
reached a verdict, 1 if nothing did.

**The pack.** `terraform-baseline`, 104 baseline security and configuration checks
for Terraform plans across AWS, Azure, GCP, Kubernetes and seven smaller
providers. Generated by `tools/sync_pack.py` from tirith-policy-corpus, whose
`confirmed` tier is its strongest evidence class: the verdict flips against a
synthesized compliant and violating document, *and* the tool the rule was
translated from independently agreed on both.

Checks carry StackGuardian identifiers and nothing else. `meta.id` is
`SG_TF_<NNNN>`, allocated once and frozen in the corpus so a report can name a
check and mean the same check next release; filenames are
`SG_TF_0042_<resource>_<attribute>.json`; tags are lowercased with a `cloud:<name>`
added. The generator refuses to write a file in which an upstream name, check id
or API vocabulary survived, and `tests/packs` refuses to ship one — two
independent gates, so a single edit cannot disable both. Attribution for the
derived rules is in NOTICE, as Apache-2.0 requires.

Every packed policy is tested in CI: structurally valid via Tirith's own
`check_policy`, unique id, no leaked name, and *still flipping* against its two
fixtures. That last one is what catches an engine change silently turning a check
into a no-op — a pack is generated once and then sits still while the engine moves
underneath it.

Wheel verified: built, installed into a clean venv, and run from an unrelated
directory. `package_data` is declared as well as `MANIFEST.in` for the reason
already recorded there — a wheel built from the tree ignores MANIFEST, and the TUI
shipped empty that way once.

Widening the pack is `sync_pack.py --tier exact` (962 policies) or
`--tier verified` (2,696): content, not code.

Closes #319
@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.55556% with 35 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/tirith/cli.py 74.00% 6 Missing and 7 partials ⚠️
src/tirith/packs/__init__.py 80.35% 6 Missing and 5 partials ⚠️
src/tirith/prettyprinter.py 73.52% 7 Missing and 2 partials ⚠️
src/tirith/core/core.py 95.00% 1 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
src/tirith/core/core.py 87.60% <95.00%> (+2.22%) ⬆️
src/tirith/prettyprinter.py 75.55% <73.52%> (+24.07%) ⬆️
src/tirith/packs/__init__.py 80.35% <80.35%> (ø)
src/tirith/cli.py 76.74% <74.00%> (+2.35%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant