diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml
new file mode 100644
index 00000000..6a43440c
--- /dev/null
+++ b/.github/workflows/build_docs.yml
@@ -0,0 +1,43 @@
+name: Build Documentation Site
+
+# The site is configured with `onBrokenLinks: 'throw'`, so a single bad
+# cross-link fails the build. deploy_docs.yml only runs on push to main, which
+# meant a broken link was discovered after merging rather than before. This job
+# builds the site on pull requests so the PR proves it still builds.
+
+on:
+ pull_request:
+ paths:
+ - 'documentation/**'
+ - '.github/workflows/build_docs.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'documentation/**'
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: npm
+ cache-dependency-path: documentation/package-lock.json
+
+ # npm, not yarn: package-lock.json is the lockfile that is committed, and
+ # there is no yarn.lock. `yarn install` would ignore it and resolve fresh.
+ - name: Install dependencies
+ working-directory: documentation
+ run: npm ci
+
+ - name: Build
+ working-directory: documentation
+ run: npm run build
diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml
index ee3f5c91..c18f02dc 100644
--- a/.github/workflows/deploy_docs.yml
+++ b/.github/workflows/deploy_docs.yml
@@ -21,18 +21,21 @@ jobs:
with:
node-version: '18'
+ # npm, not yarn: package-lock.json is the lockfile that is committed, and
+ # there is no yarn.lock. `yarn install` would ignore it and resolve fresh,
+ # so the deployed site was not built from the pinned dependency tree.
- name: Install dependencies
run: |
cd documentation
- yarn install
+ npm ci
- name: Build documentation site
run: |
cd documentation
- yarn build
+ npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./build
\ No newline at end of file
+ publish_dir: ./documentation/build
\ No newline at end of file
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 30a53ea5..8e28f628 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -15,3 +15,11 @@ jobs:
with:
options: "--check"
src: "."
+ # Pinned. `psf/black@stable` resolves to whatever Black is newest at the time the job runs,
+ # so a Black release reformats the world and this check goes red on every open branch with
+ # nothing in the repository having changed. That is what happened here: main last passed
+ # this job in November 2025 and fails it today, on 14 files nobody touched.
+ #
+ # 25.1.0 is the release the tree is actually formatted for -- verified by running it against
+ # origin/main, which comes back clean. Bump it deliberately, in a commit that reformats.
+ version: "25.1.0"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2ed0dfd..b1b1332d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+
+## [1.2.0] - 2026-08-03
+
+### Added
+- `tirith platform check`: run an organization's policies against a plan, state or arbitrary JSON
+ document from CI or a laptop. Masks the document locally, packs it with the terraform source into
+ an archive, uploads it, creates a StackGuardian run, polls it and reports the verdict as JSON
+ and/or markdown. The uploaded bundle carries the source under `code/` and a `metadata.json`
+ describing the repository, the commit and where in the repository `code/` belongs.
+- `--fail-on-error` on the local surface too, so evaluating policy files without an account can gate
+ a merge. Off by default: the local form has always exited 0 either way, and changing that silently
+ would turn existing green pipelines red.
+- `ExitStatus.ERROR_POLICY_FAILED` (3), so a caller can tell "a policy said no" from "tirith could
+ not tell you". Both surfaces use the same code for the same meaning. Note this applies **only**
+ with `--fail-on-error`; without it the local form still exits 0 for everything, including a policy
+ it could not evaluate.
+
+### Changed
+- `cli.main(args=...)` is now honoured. It previously called `parse_args()` with no argument, so
+ the parameter was ignored and the CLI could only ever read `sys.argv`.
+
+### Notes
+- The local evaluation surface is unchanged, including its single-dash long options. Subcommands
+ are dispatched before the flat parser sees anything, so `--json` output stays byte-identical.
+- No new runtime dependencies: the platform integration is stdlib-only.
+
+## [1.1.0] - 2026-08-01
+
+### Added
+- `core`: Policy metadata passthrough — `meta.id`, `meta.name`, `meta.description`,
+ `meta.severity`, `meta.enforcement`, `meta.tags` and `meta.remediation` now reach the result
+ document when a policy declares them. Keys that are absent are omitted, so the output of a
+ policy declaring none of them is unchanged. `{{ var.x }}` substitution works in all of them.
+
+### Fixed
+- `core`: Variable substitution no longer mutates the caller's policy dictionary. Evaluating the
+ same parsed policy more than once (a policy set, or a retry) previously leaked substituted
+ values from one evaluation into the next.
+- `core`: An unsupported `condition.type` now populates `result` instead of returning without it,
+ which raised `KeyError` in the pretty printer far from the real cause.
+- `core`: Provider errors reported without a `ProviderError` severity are now surfaced instead of
+ being discarded and `None` evaluated against the condition — a typo'd `operation_type` read as
+ a genuine policy violation. These are treated as malformed provider calls and are deliberately
+ not subject to `error_tolerance`.
+
## [1.0.5] - 2025-11-19
### Fixed
diff --git a/README.md b/README.md
index 786a16e5..62a3acc1 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,41 @@
-[](code_of_conduct.md)
+[](LICENSE)
+[](CODE_OF_CONDUCT.md)
[](https://github.com/psf/black)
[](https://sonarcloud.io/summary/new_code?id=StackGuardian_policy-framework)
[](https://sonarcloud.io/summary/new_code?id=StackGuardian_policy-framework)
[](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ)
[](https://codecov.io/gh/StackGuardian/tirith)
-# Tirith (StackGuardian Policy Framework)
+# Tirith — IaC Governance plugin
-## Maintainers
-
-This project is maintained by [StackGuardian](https://www.linkedin.com/company/stackguardian/).
+**Plugin IaC Governance for any pipeline, running anywhere.** Evaluate plans with Tirith, protect
+sensitive values, enforce centralised governance, and surface actionable results before
+infrastructure changes are applied.
+Tirith reads the plan your pipeline already produces — the output of `terraform show -json tfplan` —
+checks it against your policies, and exits non-zero so a violating change never reaches `apply`. The
+reason it is a plugin rather than an integration is that one policy set then covers every pipeline
+you run it from: the same policy files gate a GitHub Actions job, a GitLab job and a laptop, and in
+platform mode Tirith rules and Checkov findings come back in one verdict instead of two tools you
+have to reconcile by hand.
-## A call for contributors
-
-We are calling for contributors to help build out new features, review pull requests, fix bugs, and maintain overall code quality. If you're interested, please email us at team[at]stackguardian.io or get started by reading the [contributing.md](./CONTRIBUTING.md).
-
-Tirith scans declarative Infrastructure as Code (IaC) configurations like Terraform against policies defined using JSON.
+It is Apache-2.0 and needs no account. Policies are JSON files in your repository, evaluation happens
+on your own runner, and nothing is sent anywhere. If you would rather keep policy in one place across
+many repositories, `tirith platform check` evaluates against the policies a
+[StackGuardian](https://www.stackguardian.io/) organization enforces instead — same document, same
+verdict, same exit codes. That mode is optional and is the only part that talks to a network.
## Content
-
-
- [What is Tirith?](#what-is-tirith)
- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
+- [Run it in CI](#run-it-in-ci)
+- [Exit codes](#exit-codes)
+- [Evaluating against your StackGuardian organization](#evaluating-against-your-stackguardian-organization)
- [Example Tirith policies](#example-tirith-policies)
+ - [error_tolerance](#error_tolerance-and-the-third-outcome)
- [Terraform Plan](#terraform-plan-provider)
- [Infracost](#infracost-provider)
- [StackGuardian Workflow Policy](#stackguardian-workflow-policy-using-sg-workflow-provider)
@@ -43,7 +52,10 @@ Tirith scans declarative Infrastructure as Code (IaC) configurations like Terraf
## What is Tirith?
-Tirith is a policy framework developed by StackGuardian for enforcing policies on infrastructure configurations such as Terraform, CloudFormation, Kubernetes etc. It simplifies policy creation and enforcement ensuring compliance with infrastructure policies through a user-friendly approach.
+Tirith turns a declarative policy — a JSON file, not a program — into a pass or fail verdict on a
+concrete infrastructure change. Point it at a terraform plan, a terraform state file, a Kubernetes
+manifest, an Infracost breakdown or any JSON document, and it reports which rules passed, which
+failed, and on which resource and value.
## Who is the project for?
- DevSecOps engineers
@@ -66,13 +78,6 @@ Tirith is a policy framework developed by StackGuardian for enforcing policies o
- Easily evaluate inputs against policy using pre-defined evaluators like ContainedIn, Equals, RegexMatch etc.
- Write your own provider (plugin) by leveraging a highly extensible and pluggable architecture to support any input formats.
-
## Installation
@@ -82,6 +87,16 @@ This is only a list of approved features that will be included in Tirith over th
pip install git+https://github.com/StackGuardian/tirith.git
```
+Pin a tag rather than tracking the default branch, so a CI job cannot change behaviour underneath you:
+
+```
+pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+```
+
+`1.0.5` is the newest tag; `git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists
+them. Tirith is not on PyPI — `pip install tirith` installs an unrelated project of the same name, so
+install from git. Python 3.8 or newer.
+
### For developers
#### Running the Dev Container
@@ -89,8 +104,8 @@ pip install git+https://github.com/StackGuardian/tirith.git
- Clone the repository to your local machine:
```bash
- git clone
- cd
+ git clone https://github.com/StackGuardian/tirith.git
+ cd tirith
```
- Start the Docker Engine using docker desktop or CLI.
@@ -143,8 +158,7 @@ pip install -e .
```
tirith --version
-1.0.0-beta.12
-
+tirith 1.2.0
```
Congratulations! Tirith has been setup in your system
@@ -152,7 +166,8 @@ Congratulations! Tirith has been setup in your system
## Usage
```
-usage: tirith [-h] [-policy-path PATH] [-input-path PATH] [--json] [--verbose] [--version]
+usage: tirith [-h] [-policy-path PATH] [-input-path PATH] [-var-path PATH]
+ [-var PATH] [--json] [--verbose] [--fail-on-error] [--version]
Tirith (StackGuardian Policy Framework)
@@ -160,10 +175,18 @@ options:
-h, --help show this help message and exit
-policy-path PATH Path containing Tirith policy as code
-input-path PATH Input file path
+ -var-path PATH Variable file path(s)
+ -var PATH Inline variable(s)
--json Only print the result in JSON form (useful for passing output to other programs)
--verbose Show detailed logs of from the run
+ --fail-on-error Exit 3 when a policy fails, instead of 0. Off by default for compatibility.
--version show program's version number and exit
+Subcommands:
+
+ tirith platform check --help Evaluate against the policies your StackGuardian
+ organization enforces, rather than local files.
+
About Tirith:
* Abstract away the implementation complexity of policy engine underneath.
@@ -171,15 +194,137 @@ About Tirith:
* Provide a standard framework for scanning various configurations with granularity.
* Provide modularity to enable easy extensibility
* Github - https://github.com/StackGuardian/tirith
- * Docs - https://docs.stackguardian.io/docs/tirith/overview
+ * Docs - https://github.com/StackGuardian/tirith#readme
+```
+
+
+## Run it in CI
+
+### GitHub Actions
+
+Use [StackGuardian/tirith-iac-governance-action](https://github.com/StackGuardian/tirith-iac-governance-action).
+It finds the plan, posts a sticky pull-request comment, creates a check run and sets the job's exit
+code:
+
+```yaml
+- run: terraform show -json tfplan > plan.json
+- uses: StackGuardian/tirith-iac-governance-action@v2
+```
+
+With a `plan.json` in the working directory that is the whole integration — no `with:` block. Add
+`with: { fail-on-error: true }` to make a failing policy fail the job, and see the action's own README
+for the rest of its inputs.
+
+### GitLab, or any container-based CI
+
+There is no GitLab-native equivalent of the action, so you invoke the CLI directly — which is all the
+action does underneath. Given an earlier job that saved `plan.json` as an artifact:
+
+```yaml
+policy:
+ image: python:3.12
+ needs: [plan]
+ script:
+ - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+ - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+Swap the last line for `tirith platform check --workflow-id my-repo --input-path plan.json
+--fail-on-error` to use your organization's policies instead of the committed files. Nothing here is
+GitLab-specific: any runner that can execute a container and produce a plan works the same way.
+
+## Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | Policies passed, or nothing was in scope to gate on |
+| 1 | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API |
+| 2 | Timed out waiting for a StackGuardian run |
+| 3 | A policy failed. Only with `--fail-on-error`, on either surface |
+| 130 | Interrupted |
+
+**Gate a CI job with `--fail-on-error`:**
+
+```
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+echo $? # 3 a policy failed · 1 nothing could be evaluated · 0 everything passed
+```
+
+Without the flag the exit code is always `0` and the verdict is in the output — that is how the
+command has always behaved, and it is left alone so upgrading cannot turn a passing pipeline red.
+
+**`3` is deliberately not `1`.** `3` means a check ran and said no. `1` means Tirith could not tell you
+either way — an unparseable `eval_expression`, an unresolved variable, or a policy whose every check was
+skipped. A job that treats every non-zero code alike reports an outage as a policy violation, and
+cannot tell a working gate from a broken one.
+
+One limit worth stating plainly: a *misconfigured* policy — an unsupported `condition.type`, an unknown
+`required_provider` — comes back from the engine as an ordinary failed check with no error attached, so
+it is indistinguishable from a real violation and exits `3`. It fails closed, which is the safe
+direction, but it will point at your infrastructure when the fault is in the policy.
+
+## Evaluating against your StackGuardian organization
+
+`tirith platform check` evaluates against the policies your StackGuardian organization enforces,
+instead of policy files committed to your repository — so policy lives in one place rather than being
+copied into every repository that needs gating.
+
+```
+export SG_API_TOKEN=sgo_... # an organization token
+export SG_ORG=my-org
+
+tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error
```
+It masks the document on your machine before anything leaves it, packs it with your terraform source,
+uploads it, runs the policies on StackGuardian, and prints the verdict. `--input-path` is optional
+when a `plan.json` or `tfplan.json` is in the working directory.
+
+Common flags:
+
+| | |
+|---|---|
+| `--region {eu,us}` | Which StackGuardian region. Default `eu`, or `$SG_REGION` |
+| `--api-key -` | Read the key from stdin instead of the environment |
+| `--plan-file tfplan` | The binary plan from `terraform plan -out=`, rendered through `terraform show -json` in memory. Use `--input-path` if you already have the JSON |
+| `--state-path` / `--infracost-path` | Add a state document or a cost breakdown to the evaluation |
+| `--no-source` | Do not upload the terraform source. Discovery still looks in `--source-dir` for the plan |
+| `--fail-on-error` | Exit `3` when a policy fails, instead of `0` |
+| `--output-json` / `--output-markdown` | Write the verdict to files for a later CI step |
+
+`--api-url` overrides `--region` for a self-hosted or dedicated host. Every flag is in
+[docs/platform-check.md](docs/platform-check.md) or `tirith platform check --help`.
+
+Running this from GitHub Actions? Use [the action](#github-actions) instead — it wires up the plan
+discovery, the sticky pull-request comment, the check run and the exit codes for you.
+
## Example Tirith policies
[Examples using various providers](tests/providers)
+### `error_tolerance`, and the third outcome
+
+Every `condition` takes an `error_tolerance`, and it appears in most of the examples below without
+being explained. It is a severity threshold for *problems reading the input*, not for policy failures:
+
+- **`0`** — anything the provider could not read is an error, and the check **fails**.
+- **`1` or higher** — a problem whose severity is at or below the tolerance is *skipped* instead. A
+ missing attribute has severity 2, so `error_tolerance: 2` turns "this key is not in the plan" from a
+ failure into a non-answer.
+
+That third outcome is why some sample output below shows `"passed": null` rather than `true` or
+`false` — the check did not pass and did not fail, it never ran. A skipped check is then **removed from
+`eval_expression`** before it is evaluated, because `None` is falsy in Python and leaving it in would
+silently read as a failure.
+
+One consequence worth knowing before using it: a policy whose every check is skipped has evaluated
+nothing at all, and reports `"final_result": null` rather than `true` or `false`. With
+`--fail-on-error` that exits **1**, not 0 and not 3 — a check that looked at nothing is not a pass, and
+it is not a violation either. Keep the tolerance at `0` if you would rather such a policy fail outright.
+
### Terraform plan provider
+Terraform plan provider — example policies and output
#### Example 1:
VPC and EC2 instance policy
@@ -311,7 +456,7 @@ Policy:
}
}
],
- "eval_expression": "check1 && check11 && check111 & check2 & check22"
+ "eval_expression": "check1 && check22"
}
```
@@ -489,7 +634,7 @@ JSON Output:
}
],
"errors": [],
- "eval_expression": "check1 && check11 && check111 & check2 & check22"
+ "eval_expression": "check1 && check22"
}
```
@@ -497,6 +642,7 @@ JSON Output:
### Infracost Provider
+Infracost Provider — example policies and output
Cost control policy
@@ -648,6 +794,7 @@ JSON Output:
### StackGuardian Workflow Policy (using SG workflow provider)
+StackGuardian Workflow Policy (using SG workflow provider) — example policies and output
- Terraform Workflow should require an approval to create or destroy resources
```json
@@ -800,6 +947,7 @@ JSON Output:
### JSON
+JSON — example policies and output
Example Policy
```json
@@ -995,14 +1143,16 @@ JSON Output
],
"errors": [],
"eval_expression": "check1 && check2 && check3 && check4 && check5"
+}
```
### Kubernetes
+Kubernetes — example policies and output
Kubernetes (using Kubernetes provider)
-#### Example 1
+#### Example
- Make sure that all pods have a liveness probe defined
```json
@@ -1029,71 +1179,9 @@ Kubernetes (using Kubernetes provider)
"eval_expression": "!kinds_have_null_liveness_probe"
}
```
-#### Example 2
-Example Policy:
+Example output:
-```json
-{
- "meta": {
- "version": "v1",
- "required_provider": "stackguardian/kubernetes"
- },
- "evaluators": [
- {
- "id": "kinds_have_null_liveness_probe",
- "provider_args": {
- "operation_type": "attribute",
- "kubernetes_kind": "Pod",
- "attribute_path": "spec.containers.*.livenessProbe"
- },
- "condition": {
- "type": "Contains",
- "value": null,
- "error_tolerance": 2
- }
- }
- ],
- "eval_expression": "!kinds_have_null_liveness_probe"
-}
-```
-
-Example Input:
-
-```yml
-apiVersion: v1
-kind: ServiceAccount
-metadata:
- name: wfs-demp-wfs-demo
- labels:
- helm.sh/chart: wfs-demo-0.1.0
- app.kubernetes.io/name: wfs-demo
- app.kubernetes.io/instance: wfs-demp
- app.kubernetes.io/version: "1.16.0"
- app.kubernetes.io/managed-by: Helm
----
-# Source: wfs-demo/templates/user-acces.yaml
-apiVersion: rbac.authorization.k8s.io/v1
-...
- - name: wget
- image: busybox
- command: ['wget']
- args: ['wfs-demp-wfs-demo:80']
- livenessProbe:
- exec:
- command:
- - cat
- - /tmp/healthy
- initialDelaySeconds: 5
- periodSeconds: 5
- restartPolicy: Never
-
-```
-
-Output:
-
-
-JSON Output:
```json
{
"meta": {
@@ -1121,30 +1209,8 @@ JSON Output:
```
-
-
-
+
+
## Getting Started
This is a short getting started guide for Tirith. We will take a look on how we can use Tirith to guardrail a JSON input.
@@ -1265,7 +1331,9 @@ Final expression used:
## Want to contribute?
-If you're interested, please email us at team[at]stackguardian.io or get started by reading the [contributing.md](./CONTRIBUTING.md).
+We are calling for contributors to help build out new features, review pull requests, fix bugs, and
+maintain overall code quality. Email us at team[at]stackguardian.io, or get started by reading
+[contributing.md](./CONTRIBUTING.md).
### Getting an issue assigned
@@ -1297,8 +1365,17 @@ Wanna submit a feedback? It's as simple as writing and posting it in the Your feedback will help us improve
+## Maintainers
+
+This project is maintained by [StackGuardian](https://www.linkedin.com/company/stackguardian/).
+
## Support
+Open an [issue](https://github.com/StackGuardian/tirith/issues) for a bug or a question about policy
+authoring. For anything specific to a StackGuardian organization — enforcement scope, a run that
+errored, an API key — contact StackGuardian support instead, since that needs account context this
+repository has no access to.
+
## License
Apache License 2.0
diff --git a/docs/platform-check.md b/docs/platform-check.md
new file mode 100644
index 00000000..54eae4ec
--- /dev/null
+++ b/docs/platform-check.md
@@ -0,0 +1,255 @@
+# `tirith platform check`
+
+Evaluate a terraform plan, state document or cost breakdown against the policies your StackGuardian
+organization enforces, from any CI system or from a laptop.
+
+The [GitHub Action](https://github.com/StackGuardian/tirith-iac-governance-action) is a thin wrapper
+around this command. Use the action on GitHub; use this directly anywhere else — GitLab CI, a
+Makefile, a local shell.
+
+## What it does
+
+1. **Masks the document on your machine**, before anything is uploaded. Values terraform marked
+ sensitive are replaced with `__SG_REDACTED__`, root `variables` are dropped, and `prior_state` is
+ removed. `json` and `kubernetes` documents are *not* masked — there is no schema that says which
+ fields are secret.
+2. **Packs** the masked documents with your terraform source into a `tar.gz`, excluding `.git`,
+ `.terraform`, `*.tfstate*` and anything matched by `.gitignore`. `--source-dir ""` sends documents
+ only. An oversized tree degrades to documents-only rather than failing.
+3. **Uploads it** to the workflow's artifact directory and creates a StackGuardian workflow run.
+4. **Polls** the run and prints the verdict, optionally as JSON and markdown for a later CI step.
+
+Committed source ships as written: a secret hardcoded in HCL reaches the platform even though the
+plan was masked. `--source-dir ""` is the opt-out.
+
+## Credentials
+
+`--api-key` / `$SG_API_TOKEN` and `--org` / `$SG_ORG`. The key should be an **organization** (`sgo_`)
+token — `sgu_` keys are non-functional for SSO-group-only users, and are warned about rather than
+rejected, so the symptom is a later 403.
+
+`--api-key -` reads the key from stdin, which keeps it out of the process table and out of shell
+history:
+
+ echo "$SG_TOKEN" | tirith platform check --api-key - --workflow-id infra
+
+## Workflow identity
+
+`--workflow-id` names the StackGuardian workflow, and is created on first use. `--workflow-group`
+defaults to `default`.
+
+Two things worth knowing before choosing an id:
+
+* Runs on one workflow **serialize** while another is pending. A matrix that shares an id becomes a
+ queue, so give each leg its own.
+* `--artifact-tag` namespaces the uploaded bundle. Two runs of the same workflow with the same tag
+ and the same commit reuse one name, which is fine; different commits never collide.
+
+## What the bundle contains
+
+The archive uploaded to the workflow's artifact directory has a fixed layout. It is a contract: the
+step reads its inputs out of it, and other systems read it to see the code a verdict came from.
+
+```
+plan.json the masked terraform plan
+tfstate.json the masked state, if one was supplied
+infracost.json the cost breakdown, if one was supplied
+metadata.json what this bundle is
+code/ the terraform source, if any was packed
+```
+
+Documents sit at the **root**; the source sits under **`code/`**. `code/` is a path prefix rather than
+a directory entry, so it is absent entirely when no source was packed — `metadata.json` says which,
+and why.
+
+### metadata.json
+
+Field names are `snake_case`, matching the other JSON this tool authors and the `plan.json` beside it.
+Everything about the repository is nullable, because a local run has no repository to describe and a
+fabricated one would be worse than an honest `null`.
+
+```json
+{
+ "schema_version": 1,
+ "generator": {"name": "tirith", "version": "1.2.0"},
+ "created_at": "2026-08-12T09:14:03Z",
+ "input_kind": "terraform_plan",
+ "origin": {"kind": "ci", "trigger_type": "tirith", "ci_run_url": "https://github.com/acme/infra/actions/runs/1"},
+ "repository": {
+ "provider": "github",
+ "host": "github.com",
+ "url": "https://github.com/acme/infra",
+ "ref": "feat/rds",
+ "commit": "9f2c1ab5e0d34c7f8b1a2d3e4f506172",
+ "change_request": {"id": "412", "url": "https://github.com/acme/infra/pull/412", "target_ref": null}
+ },
+ "code": {
+ "present": true,
+ "prefix": "code/",
+ "repo_path": "infra/prod",
+ "repo_path_from": "git_root",
+ "files": 37,
+ "skipped": 5,
+ "absent_reason": null
+ },
+ "documents": {"plan": "plan.json", "state": null, "infracost": null},
+ "masking": {"redactions": 12, "marker": "__SG_REDACTED__", "documents_are_masked": true},
+ "workflow": {"org": "acme", "group": "default", "id": "infra-prod", "artifact_tag": "default"}
+}
+```
+
+The fields worth understanding before writing a consumer:
+
+**`code.repo_path`** is the one that cannot be recovered any other way. Members are named relative to
+`--source-dir`, so the path *within the repository* is destroyed at pack time. `--source-dir infra/prod`
+means `code/main.tf` belongs at `infra/prod/main.tf`. It is `""` for the repository root — not `"."`,
+and not `null`, so joining still works and it stays distinguishable from "we could not tell", which is
+`null`. `code.repo_path_from` is `"flag"` when `--repo-path` declared it and `"git_root"` when it was
+inferred from the enclosing checkout: for anything about to write into a repository, declared and
+inferred are not the same confidence.
+
+**`code.present`** means "there are members under `code/`", not "a source directory was requested". A
+tree whose every file was excluded produces `present: false` with `files: 0`, so the metadata and the
+tar can never disagree. `absent_reason` is `not_requested` (`--no-source`), `too_large` (the tree was
+dropped so the check could still run) or `empty_after_excludes`.
+
+**`masking`** exists so a consumer knows not to feed these documents to terraform. A `tfstate.json`
+full of `__SG_REDACTED__` looks exactly like state and would destroy infrastructure if applied.
+
+**`repository.provider`** is sniffed from the host, independently of `origin` — a GitHub Actions job
+can check out a GitLab repository. A host we do not recognise is `"unknown"` with `host` still set,
+rather than guessed. Any credential in the URL is stripped before it is written.
+
+**`schema_version`** is a single integer, bumped only by a breaking change; added fields do not bump it.
+On a higher version than you know, read what you recognise and do not act destructively — in particular
+do not write files back using a `code.repo_path` from a schema you do not understand. A bundle with no
+`metadata.json` at all predates this and should be treated as undescribed rather than invalid.
+
+## Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | Policies passed, or nothing was in scope |
+| 1 | Could not complete the check |
+| 2 | Timed out waiting for the run |
+| 3 | A policy failed — only with `--fail-on-error` |
+| 130 | Interrupted |
+
+`3` exists so a caller can distinguish "your infrastructure violates a policy" from "Tirith could not
+reach the platform". Without `--fail-on-error` a policy failure still exits `0`, and the verdict is
+in `--output-json`.
+
+## Full flag reference
+
+```
+usage: tirith platform check [-h] [--api-key API_KEY] [--org ORG]
+ [--region {eu,us}] [--api-url API_URL]
+ [--dashboard-url DASHBOARD_URL]
+ --workflow-id WORKFLOW_ID
+ [--workflow-group WORKFLOW_GROUP]
+ [--terraform-version TERRAFORM_VERSION]
+ [--repo-url REPO_URL] [--repo-ref REPO_REF]
+ [--repo-path REPO_PATH]
+ [--step-template-id STEP_TEMPLATE_ID]
+ [--input-path INPUT_PATH] [--plan-file PLAN_FILE]
+ [--terraform-bin TERRAFORM_BIN]
+ [--input-kind {terraform_plan,terraform_state,kubernetes,json}]
+ [--state-path STATE_PATH]
+ [--infracost-path INFRACOST_PATH]
+ [--source-dir SOURCE_DIR] [--no-source]
+ [--sha SHA] [--artifact-tag ARTIFACT_TAG]
+ [--trigger-details-json TRIGGER_DETAILS_JSON]
+ [--trigger-details-file TRIGGER_DETAILS_FILE]
+ [--timeout TIMEOUT] [--output-json OUTPUT_JSON]
+ [--output-markdown OUTPUT_MARKDOWN]
+ [--comment-marker COMMENT_MARKER]
+ [--markdown-limit MARKDOWN_LIMIT]
+ [--fail-on-error]
+
+Masks the document, packs it with the terraform source into an archive,
+uploads it, runs the policies on StackGuardian and reports the verdict.
+
+options:
+ -h, --help show this help message and exit
+
+identity:
+ --api-key API_KEY API key, or '-' to read it from stdin. Default:
+ $SG_API_TOKEN
+ --org ORG Organization name. Default: $SG_ORG
+ --region {eu,us} StackGuardian region, setting both URLs at once.
+ Default: $SG_REGION or eu.
+ --api-url API_URL API base URL, with or without /api/v1. Overrides
+ --region; needed only for a self-hosted install or a
+ dedicated host. Default: $SG_BASE_URL
+ --dashboard-url DASHBOARD_URL
+ Dashboard base URL, used to build run links. Inferred
+ from --api-url when it names a known region.
+
+workflow:
+ --workflow-id WORKFLOW_ID
+ Slug identifying the workflow. Created if absent.
+ Letters, digits, '-' and '_' only.
+ --workflow-group WORKFLOW_GROUP
+ Workflow group. Created if absent.
+ --terraform-version TERRAFORM_VERSION
+ Stored on the workflow at creation.
+ --repo-url REPO_URL Source repository URL, recorded on the workflow at
+ creation so it links back to the code.
+ --repo-ref REPO_REF Branch, tag or commit, recorded alongside --repo-url.
+ --repo-path REPO_PATH
+ Path of --source-dir within the repository, recorded
+ in the bundle's metadata.json so a consumer knows
+ where code/ belongs. Inferred from the enclosing git
+ checkout if omitted.
+ --step-template-id STEP_TEMPLATE_ID
+ Override the policy-evaluation step template. Omit to
+ use the platform's own default.
+
+inputs:
+ --input-path INPUT_PATH
+ Document to evaluate. Defaults to whichever of
+ plan.json or tfplan.json is in --source-dir.
+ --plan-file PLAN_FILE
+ Binary plan from `terraform plan -out=`. Rendered with
+ `show -json` in memory, so no unmasked plan JSON is
+ written to disk. Use --input-path if you already have
+ the JSON.
+ --terraform-bin TERRAFORM_BIN
+ terraform/tofu binary for --plan-file. Auto-detected,
+ preferring the real binary over a CI wrapper.
+ --input-kind {terraform_plan,terraform_state,kubernetes,json}
+ --state-path STATE_PATH
+ Optional terraform state, masked before upload.
+ --infracost-path INFRACOST_PATH
+ Optional `infracost breakdown --format json`.
+ --source-dir SOURCE_DIR
+ Terraform source to pack alongside the documents.
+ --no-source Send only the documents. Discovery still looks in
+ --source-dir (or .) for the plan.
+
+run:
+ --sha SHA Commit SHA, used to namespace the uploaded archive.
+ --artifact-tag ARTIFACT_TAG
+ Namespaces the archive within a commit. Needed only
+ when one workflow evaluates the same commit more than
+ once -- a plan phase and a state phase, or matrix legs
+ sharing a workflow.
+ --trigger-details-json TRIGGER_DETAILS_JSON
+ JSON object describing what triggered this run.
+ --trigger-details-file TRIGGER_DETAILS_FILE
+ File containing that JSON object.
+ --timeout TIMEOUT Seconds to wait for the run. Default: 1800
+
+output:
+ --output-json OUTPUT_JSON
+ Write the result document here.
+ --output-markdown OUTPUT_MARKDOWN
+ Write a markdown report here.
+ --comment-marker COMMENT_MARKER
+ Opaque first line of the markdown, for stickiness.
+ --markdown-limit MARKDOWN_LIMIT
+ Truncate the markdown to this length.
+ --fail-on-error Exit non-zero when a policy fails. An unreachable
+ platform or a run that produced no verdict always
+ exits non-zero regardless of this flag.
+```
diff --git a/documentation/docs/tirith-policies/tirith-policy-cookbook.md b/documentation/docs/tirith-policies/tirith-policy-cookbook.md
new file mode 100644
index 00000000..aac36e8f
--- /dev/null
+++ b/documentation/docs/tirith-policies/tirith-policy-cookbook.md
@@ -0,0 +1,529 @@
+---
+id: tirith-policy-cookbook
+title: Policy Cookbook
+sidebar_label: Policy Cookbook
+description: Complete, runnable Tirith policies for common real-world checks, each shown with its input and the verdict it produces.
+keywords:
+ - tirith
+site_name: Tirith
+slug: tirith-policy-cookbook/
+---
+
+Every recipe on this page is complete: copy the policy and the input into files, run the command shown, and you will get the output shown. All commands use `--fail-on-error` so the exit code carries the verdict — `0` pass, `3` fail, `1` when the run could not produce a verdict (see the [exit code table](./tirith-policy-reference.md#outcomes-and-exit-codes) and the [CLI reference](../tirith-usage/cli-reference.md)).
+
+Field-by-field schema details are in the [Policy Reference](./tirith-policy-reference.md); condition semantics in the [evaluator reference](../tirith-reference/evaluators.md); provider operations in the [provider documentation](../tirith-providers/overview.md).
+
+## Forbid unapproved instance types
+
+Every `aws_instance` in a Terraform plan must use an instance type from an approved list. `ContainedIn` checks each extracted value against the list, and the check passes only if **all** instances pass — so one oversized instance fails the whole policy.
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "allowed-instance-types",
+ "name": "Only approved EC2 instance types",
+ "description": "Every aws_instance in the plan must use an instance type from the approved list.",
+ "severity": "HIGH"
+ },
+ "evaluators": [
+ {
+ "id": "instance_type_is_approved",
+ "description": "aws_instance.instance_type must be one of the approved types",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_instance",
+ "terraform_resource_attribute": "instance_type"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": ["t3.micro", "t3.small", "t3.medium"]
+ }
+ }
+ ],
+ "eval_expression": "instance_type_is_approved"
+}
+```
+
+The input is a Terraform plan in JSON form (`terraform show -json plan.out > input.json`). This trimmed-down plan has one compliant and one non-compliant instance:
+
+```json title="input.json"
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "type": "aws_instance",
+ "name": "web",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "instance_type": "t3.small",
+ "tags": { "Environment": "prod" }
+ }
+ }
+ },
+ {
+ "address": "aws_instance.batch",
+ "type": "aws_instance",
+ "name": "batch",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "instance_type": "m5.24xlarge",
+ "tags": { "Environment": "prod" }
+ }
+ }
+ }
+ ]
+}
+```
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json
+```
+
+```text
+Check: instance_type_is_approved
+ FAILED
+ 1. PASSED: Found `"t3.small"` inside `["t3.medium", "t3.micro", "t3.small"]`
+ 2. FAILED: Failed to find `"m5.24xlarge"` inside `["t3.medium", "t3.micro", "t3.small"]`
+
+Passed: 0 Failed: 1 Skipped: 0
+
+Final expression used:
+-> instance_type_is_approved
+✘ Failed final evaluation
+```
+
+Exit code: `3`.
+
+## Require an Environment tag on every resource
+
+With `terraform_resource_type` set to `"*"`, the check runs against every resource in the plan. The dotted attribute path reaches into the `tags` map, and `RegexMatch` restricts the value to an allowed set.
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "require-environment-tag",
+ "name": "Every resource carries an Environment tag",
+ "description": "Every resource in the plan must be tagged with Environment set to dev, staging, or prod."
+ },
+ "evaluators": [
+ {
+ "id": "environment_tag_is_valid",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.Environment"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "^(dev|staging|prod)$"
+ }
+ }
+ ],
+ "eval_expression": "environment_tag_is_valid"
+}
+```
+
+```json title="input.json"
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.artifacts",
+ "type": "aws_s3_bucket",
+ "name": "artifacts",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "bucket": "team-artifacts",
+ "tags": { "Environment": "prod" }
+ }
+ }
+ },
+ {
+ "address": "aws_instance.web",
+ "type": "aws_instance",
+ "name": "web",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "instance_type": "t3.small",
+ "tags": { "Environment": "staging" }
+ }
+ }
+ }
+ ]
+}
+```
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json
+```
+
+```text
+Check: environment_tag_is_valid
+ PASSED
+ 1. PASSED: `"prod"` matches regex pattern `"^(dev|staging|prod)$"`
+ 2. PASSED: `"staging"` matches regex pattern `"^(dev|staging|prod)$"`
+
+Passed: 1 Failed: 0 Skipped: 0
+
+Final expression used:
+-> environment_tag_is_valid
+✔ Passed final evaluator
+```
+
+Exit code: `0`.
+
+A resource with no `tags.Environment` at all fails rather than slipping through. Against an input whose only resource has no tags:
+
+```text
+Check: environment_tag_is_valid
+ FAILED
+ 1. FAILED: attribute: 'tags.Environment' is not found
+
+Passed: 0 Failed: 1 Skipped: 0
+
+Final expression used:
+-> environment_tag_is_valid
+✘ Failed final evaluation
+```
+
+Exit code: `3`. (A missing attribute is a severity-2 provider error; the default `error_tolerance` of 0 turns it into a failure. The [last recipe](#tolerate-a-missing-key) shows how to skip instead.)
+
+## Block security group ingress from 0.0.0.0/0
+
+A public-ingress check. The attribute path `ingress.*.cidr_blocks` extracts the `cidr_blocks` list of **each** ingress rule, and `NotContains` requires that none of those lists contain `0.0.0.0/0`.
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "no-public-ingress",
+ "name": "No security group ingress from 0.0.0.0/0",
+ "description": "No ingress rule of any aws_security_group may allow traffic from 0.0.0.0/0.",
+ "severity": "HIGH",
+ "remediation": "Restrict the CIDR range or reference another security group instead."
+ },
+ "evaluators": [
+ {
+ "id": "no_public_cidr_in_ingress",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_security_group",
+ "terraform_resource_attribute": "ingress.*.cidr_blocks"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "0.0.0.0/0"
+ }
+ }
+ ],
+ "eval_expression": "no_public_cidr_in_ingress"
+}
+```
+
+```json title="input.json"
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_security_group.web",
+ "type": "aws_security_group",
+ "name": "web",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "name": "web-sg",
+ "ingress": [
+ {
+ "from_port": 443,
+ "to_port": 443,
+ "protocol": "tcp",
+ "cidr_blocks": ["10.0.0.0/8"]
+ },
+ {
+ "from_port": 22,
+ "to_port": 22,
+ "protocol": "tcp",
+ "cidr_blocks": ["0.0.0.0/0"]
+ }
+ ]
+ }
+ }
+ }
+ ]
+}
+```
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json
+```
+
+```text
+Check: no_public_cidr_in_ingress
+ FAILED
+ 1. PASSED: Did not find 0.0.0.0/0 inside ['10.0.0.0/8']
+ 2. FAILED: Found `"0.0.0.0/0"` inside `["0.0.0.0/0"]`
+
+Passed: 0 Failed: 1 Skipped: 0
+
+Final expression used:
+-> no_public_cidr_in_ingress
+✘ Failed final evaluation
+```
+
+Exit code: `3`. The port-443 rule scoped to `10.0.0.0/8` passes; the SSH rule open to the world fails the policy.
+
+## Cap the estimated monthly cost
+
+Uses the `stackguardian/infracost` provider against an [Infracost](https://www.infracost.io/) breakdown (`infracost breakdown --path . --format json > input.json`). The `total_monthly_cost` operation sums the monthly cost of the matched resources; `["*"]` matches all of them.
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/infracost",
+ "id": "monthly-cost-ceiling",
+ "name": "Monthly cost stays under the ceiling",
+ "description": "The estimated total monthly cost of all resources must not exceed 500 USD."
+ },
+ "evaluators": [
+ {
+ "id": "total_monthly_cost_under_ceiling",
+ "provider_args": {
+ "operation_type": "total_monthly_cost",
+ "resource_type": ["*"]
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 500
+ }
+ }
+ ],
+ "eval_expression": "total_monthly_cost_under_ceiling"
+}
+```
+
+```json title="input.json"
+{
+ "version": "0.2",
+ "currency": "USD",
+ "projects": [
+ {
+ "name": "main",
+ "breakdown": {
+ "resources": [
+ {
+ "name": "aws_instance.web",
+ "monthlyCost": "301.44"
+ },
+ {
+ "name": "aws_db_instance.app",
+ "monthlyCost": "109.86"
+ },
+ {
+ "name": "aws_s3_bucket.artifacts",
+ "monthlyCost": "2.30"
+ }
+ ]
+ }
+ }
+ ]
+}
+```
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json
+```
+
+```text
+Check: total_monthly_cost_under_ceiling
+ PASSED
+ 1. PASSED: `413.6` is less than equal to `500`
+
+Passed: 1 Failed: 0 Skipped: 0
+
+Final expression used:
+-> total_monthly_cost_under_ceiling
+✔ Passed final evaluator
+```
+
+Exit code: `0`. To limit the sum to particular resource types instead, list them: `"resource_type": ["aws_instance", "aws_db_instance"]`.
+
+## Tolerate a missing key
+
+By default a value the provider cannot find fails the check. `error_tolerance` turns "the data is absent" into a **skip** instead — here the logging level is validated only when a `logging` block exists at all. Encryption, by contrast, gets no tolerance: its absence must fail.
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json",
+ "id": "encryption-and-optional-logging",
+ "name": "Encryption required, logging checked when configured",
+ "description": "Encryption must be enabled. The logging level is validated only when the logging block exists."
+ },
+ "evaluators": [
+ {
+ "id": "encryption_enabled",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "spec.encryption.enabled"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ },
+ {
+ "id": "logging_level_is_valid",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "spec.logging.level"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": ["INFO", "WARN", "ERROR"],
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "encryption_enabled && logging_level_is_valid"
+}
+```
+
+```json title="input.json"
+{
+ "spec": {
+ "encryption": {
+ "enabled": true
+ }
+ }
+}
+```
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json
+```
+
+```text
+Check: encryption_enabled
+ PASSED
+ 1. PASSED: `true` is equal to `true`
+
+Check: logging_level_is_valid
+ SKIPPED
+ 1. SKIPPED: key_path: `spec.logging.level` is not found (severity: 2)
+
+Passed: 1 Failed: 0 Skipped: 1
+
+Final expression used:
+-> encryption_enabled && logging_level_is_valid
+✔ Passed final evaluator
+```
+
+Exit code: `0`. The skipped check is removed from `eval_expression` — the expression effectively becomes `encryption_enabled` — so the policy passes. The missing `key_path` is a severity-2 provider error; `"error_tolerance": 2` absorbs it. If the input *does* contain `spec.logging.level`, the value is validated normally and `DEBUG` would fail the policy.
+
+One consequence to be aware of: if **every** check in the expression is skipped, the final verdict is neither pass nor fail. Running only the tolerant check against the same input:
+
+```text
+Check: logging_level_is_valid
+ SKIPPED
+ 1. SKIPPED: key_path: `spec.logging.level` is not found (severity: 2)
+
+Passed: 0 Failed: 0 Skipped: 1
+
+Final expression used:
+-> logging_level_is_valid
+= Skipped final evaluator
+```
+
+Exit code: `1` — with `--fail-on-error`, an all-skipped run counts as an error, not a pass, because nothing was actually verified. Without the flag the exit code is `0`, like every other outcome.
+
+## Parameterize the policy with variables
+
+The same cost-ceiling policy, with the limit supplied at run time. A variable reference must be the entire string value; it is replaced with the variable's JSON value, so a number stays a number.
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/infracost",
+ "id": "parameterized-cost-ceiling",
+ "name": "Monthly cost stays under a configurable ceiling",
+ "description": "The estimated total monthly cost must not exceed the ceiling supplied as a variable."
+ },
+ "evaluators": [
+ {
+ "id": "cost_under_ceiling",
+ "provider_args": {
+ "operation_type": "total_monthly_cost",
+ "resource_type": ["*"]
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": "{{ var.max_monthly_cost }}"
+ }
+ }
+ ],
+ "eval_expression": "cost_under_ceiling"
+}
+```
+
+```json title="variables.json"
+{
+ "max_monthly_cost": 300
+}
+```
+
+Run against the same `input.json` as the previous cost recipe (total: 413.60):
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json -var-path variables.json
+```
+
+```text
+Check: cost_under_ceiling
+ FAILED
+ 1. FAILED: `413.6` is not less than or equal to `300`
+
+Passed: 0 Failed: 1 Skipped: 0
+
+Final expression used:
+-> cost_under_ceiling
+✘ Failed final evaluation
+```
+
+Exit code: `3`.
+
+An inline `-var` overrides the variable file:
+
+```bash
+tirith --fail-on-error -policy-path policy.json -input-path input.json \
+ -var-path variables.json -var 'max_monthly_cost=1000'
+```
+
+```text
+Check: cost_under_ceiling
+ PASSED
+ 1. PASSED: `413.6` is less than equal to `1000`
+
+Passed: 1 Failed: 0 Skipped: 0
+
+Final expression used:
+-> cost_under_ceiling
+✔ Passed final evaluator
+```
+
+Exit code: `0`. If a referenced variable is not supplied at all, the policy is not evaluated: the output reports `Variables not found: max_monthly_cost` and there is no verdict (exit `1` with `--fail-on-error`). The full substitution rules are in the [Policy Reference](./tirith-policy-reference.md#variables).
diff --git a/documentation/docs/tirith-policies/tirith-policy-reference.md b/documentation/docs/tirith-policies/tirith-policy-reference.md
new file mode 100644
index 00000000..6d271a3b
--- /dev/null
+++ b/documentation/docs/tirith-policies/tirith-policy-reference.md
@@ -0,0 +1,236 @@
+---
+id: tirith-policy-reference
+title: Policy Reference
+sidebar_label: Policy Reference
+description: Field-by-field reference for the Tirith policy file format, including every key, its type, its default, and its failure behavior.
+keywords:
+ - tirith
+site_name: Tirith
+slug: tirith-policy-reference/
+---
+
+A Tirith policy is a single JSON document with exactly three top-level keys. It is evaluated against an input document passed to the CLI with `-input-path` (see the [CLI reference](../tirith-usage/cli-reference.md)). The policy file itself is always JSON; the input file is parsed as JSON unless its name ends in `.yaml` or `.yml`, in which case it is parsed as YAML (a multi-document YAML file becomes a list of documents).
+
+```json title="policy.json"
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json"
+ },
+ "evaluators": [
+ {
+ "id": "can_post",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "verb"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "POST"
+ }
+ }
+ ],
+ "eval_expression": "can_post"
+}
+```
+
+Unknown keys, at any level, are ignored.
+
+## Top-level keys
+
+| Key | Required | Type | Description |
+|---|---|---|---|
+| `meta` | yes | object | Policy metadata. Selects the provider; everything else is informational. |
+| `evaluators` | yes | array of objects | The checks. Each one extracts values from the input and compares them against a condition. |
+| `eval_expression` | yes | string | Boolean expression over evaluator `id`s that produces the final verdict. |
+
+If `meta`, `evaluators`, or `eval_expression` is missing, the run aborts before producing a verdict: the CLI prints `ERROR` and exits with code `1` (with or without `--fail-on-error`).
+
+## `meta`
+
+| Key | Required | Type | Default | Behavior |
+|---|---|---|---|---|
+| `required_provider` | effectively yes | string | `"core"` | Selects the provider used by every evaluator in the policy. See [below](#metarequired_provider). |
+| `version` | no | string | none | Not interpreted. Always echoed into the result `meta` (as `null` when absent). |
+| `id` | no | any (conventionally string) | none | Not interpreted. Echoed verbatim into the result `meta` only when present. |
+| `name` | no | any (conventionally string) | none | Same as `id`. |
+| `description` | no | any (conventionally string) | none | Same as `id`. |
+| `severity` | no | any (conventionally string) | none | Same as `id`. |
+| `enforcement` | no | any (conventionally string) | none | Same as `id`. See [below](#metaenforcement). |
+| `tags` | no | any (conventionally array of strings) | none | Same as `id`. |
+| `remediation` | no | any (conventionally string) | none | Same as `id`. |
+
+An empty `meta` object (`"meta": {}`) is accepted; the policy then falls back to the default provider, which fails every check (see next section).
+
+### `meta.required_provider`
+
+The registered providers are:
+
+- `stackguardian/terraform_plan`
+- `stackguardian/infracost`
+- `stackguardian/sg_workflow`
+- `stackguardian/json`
+- `stackguardian/kubernetes`
+
+Each provider defines its own `provider_args`; see the [provider documentation](../tirith-providers/overview.md).
+
+When `required_provider` is absent it defaults to `"core"`, and no provider named `core` is registered. An unregistered provider name — the default included — is **not** a hard error: every evaluator in the policy simply receives no values and fails with the message `Could not find input value`. The final verdict is a failure (exit code `3` under `--fail-on-error`), which can be mistaken for a genuine policy violation. Always set `required_provider` explicitly.
+
+### `meta.enforcement`
+
+The open-source engine does **not** interpret this field. There is no list of accepted values, no validation, and no warning: any value — `hard_mandatory`, `soft_mandatory`, or any other string — is copied verbatim into the result `meta` and changes nothing about how the policy is evaluated. An unrecognised value has exactly the same effect as a recognised-looking one: none.
+
+In particular, `enforcement` never affects the exit code of the `tirith` command. The exit code is determined solely by `final_result` and the `--fail-on-error` flag (see [Outcomes and exit codes](#outcomes-and-exit-codes)). If you need a policy to block a pipeline *when invoking the CLI directly*, gate on the exit code with `--fail-on-error`, not on this field. The same applies to `severity`, `tags`, and `remediation`: they exist so that tools consuming Tirith's JSON output can act on them, and the engine passes them through untouched.
+
+:::note Consumers do interpret it
+
+The field is not decorative — it is read by the layer above the engine. The
+[GitHub Action](../tirith-usage/ci-integration.md) downgrades a failing policy to a warning when
+`meta.enforcement` is one of `soft_mandatory`, `advisory`, `warn`, `warning`, `low` or
+`approval_required`, and blocks on `hard_mandatory`, `mandatory`, `fail`, `error`, `high`,
+`critical` or `blocking`. Matching is case-insensitive and ignores surrounding whitespace.
+
+An **unrecognised** value blocks, and logs a warning that it did so. That is deliberate: a policy
+that is mislabelled or carries a typo must gate rather than slip through silently.
+
+So `enforcement` is meaningful when a consumer acts on it, and inert when you run `tirith` yourself.
+:::
+
+## `evaluators[]`
+
+Each entry in the `evaluators` array is an object with these keys:
+
+| Key | Required | Type | Default | Behavior |
+|---|---|---|---|---|
+| `id` | yes | string | — | The name this check is referenced by in `eval_expression`. Missing `id` aborts the run (`ERROR`, exit `1`). |
+| `provider_args` | yes | object | — | Arguments for the provider selected by `meta.required_provider`. Missing `provider_args` aborts the run (`ERROR`, exit `1`). |
+| `condition` | yes | object | — | The comparison applied to every value the provider extracts. Missing `condition` aborts the run (`ERROR`, exit `1`). |
+| `description` | no | string | none | Informational. Echoed into the result for this check (as `null` when absent). |
+
+`id` is substituted into `eval_expression` as a bare word, so it must look like an identifier: letters, digits, and underscores. Ids should be unique within a policy; if two evaluators share an id, both appear in the output but only the **last** one's outcome is substituted into `eval_expression`.
+
+### `evaluators[].provider_args`
+
+The contents are provider-specific; the one key every provider expects is `operation_type`, which selects the operation (for example `get_value` for `stackguardian/json`, or `attribute` for `stackguardian/terraform_plan`). See [providers](../tirith-providers/overview.md) for each provider's operations and arguments.
+
+A malformed `provider_args` — an unsupported `operation_type`, or a missing required argument — does not abort the run. The provider reports the mistake as an error on that check, the check fails regardless of `error_tolerance`, and the message tells you what was wrong (for example `operation_type: 'attrbute' is not supported (severity_value: 99)`).
+
+### `evaluators[].condition`
+
+| Key | Required | Type | Default | Behavior |
+|---|---|---|---|---|
+| `type` | yes | string | — | The condition (evaluator) name. An unknown or missing `type` does not abort the run: that check fails with `` `X` is not a supported evaluator ``. |
+| `value` | yes in practice | any | `null` | The operand the extracted value is compared against. The expected type depends on `type` (a list for `ContainedIn`, a pattern string for `RegexMatch`, a number for `LessThan`, and so on). Omitting it compares against `null`; the outcome then depends on the condition type, so always set it explicitly. |
+| `error_tolerance` | no | integer | `0` | The maximum provider-error severity this check tolerates. Errors at or below the tolerance mark the check as **skipped** instead of failed. See [Error tolerance](#error-tolerance-the-third-outcome). |
+
+The supported condition types are:
+
+`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`, `IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`, `RegexMatch`
+
+Their exact semantics are documented in the [evaluator reference](../tirith-reference/evaluators.md).
+
+A provider may extract several values for one check (for example, one attribute per matching resource). The condition is applied to each value, and the check passes only if **every** value passes.
+
+## `eval_expression`
+
+A boolean expression that combines the per-check outcomes into the final verdict. Operands are evaluator `id`s; the operators are:
+
+- `&&` — and
+- `||` — or
+- `!` — not
+- `(` `)` — grouping
+
+`&` and `|` are rejected with an explicit error (`Unsupported operator '&' in eval_expression. Use '&&' instead.`) and the run aborts with exit code `1`.
+
+Two behaviors worth knowing:
+
+- **An id that does not match any evaluator is silently dropped from the expression**, and the run continues. The result carries an informational note in its `errors` array (`The following evaluator ids are not defined and have been removed: ...`), but this is not a failure: a policy whose expression is `real_check && typo_id` passes if `real_check` passes. Check the `errors` array (or the `Errors:` block in the printed output) when authoring.
+- **Skipped checks are removed from the expression** before it is evaluated, rather than being treated as false. `a && b` with `b` skipped evaluates as just `a`. If every id in the expression is removed — all checks skipped — the final verdict is neither pass nor fail; see the next section.
+
+## Outcomes and exit codes
+
+Every check has one of three outcomes, reported in the `passed` field of its result:
+
+| `passed` | Meaning |
+|---|---|
+| `true` | Every value the provider extracted satisfied the condition. |
+| `false` | At least one value failed the condition, the provider found no values at all (`Could not find input value`), or a provider error exceeded `error_tolerance`. |
+| `null` | Skipped: the provider reported an error whose severity is within `error_tolerance`. |
+
+The final verdict, `final_result`, is also tri-state: `true` when the expression evaluates true, `false` when it evaluates false, and `null` when every check it references was skipped.
+
+The CLI exit code depends on `final_result` and the `--fail-on-error` flag:
+
+| Situation | `final_result` | Exit (default) | Exit (`--fail-on-error`) |
+|---|---|---|---|
+| Policy passed | `true` | 0 | 0 |
+| Policy failed | `false` | 0 | 3 |
+| All checks skipped | `null` | 0 | 1 |
+| Unresolved variable | absent | 0 | 1 |
+| Policy file malformed (missing `meta`, `evaluators`, `eval_expression`, `id`, `provider_args`, `condition`; `&` instead of `&&` in the expression, and likewise for the or operator) | — | 1 | 1 |
+
+Without `--fail-on-error` the exit code is `0` whether the policy passed or failed — the verdict is only in the output. A run where every check was skipped is deliberately treated as an error under `--fail-on-error`, not a pass: it verified nothing. See the [CLI reference](../tirith-usage/cli-reference.md) for the flag.
+
+## Error tolerance: the third outcome
+
+`error_tolerance` exists so a policy can tolerate *missing data* without tolerating *violations*. When a provider cannot extract a value, it reports an error with a numeric severity instead of a value. For each such error on a check:
+
+- severity **>** `error_tolerance` → the check **fails**, with the provider's message.
+- severity **≤** `error_tolerance` → that result is **skipped** (`passed: null`), with the provider's message.
+
+The default tolerance is `0`. The severities the bundled providers use:
+
+| Severity | Used by | Meaning |
+|---|---|---|
+| 0 | `terraform_plan` | No resource changes in the plan at all, or a matched resource has no planned attributes (for example, a resource being destroyed). Because the comparison is *strictly greater than*, severity-0 errors are skipped even at the default tolerance of 0. |
+| 1 | `terraform_plan` | The resource type was not found in the plan. |
+| 2 | `terraform_plan`, `json` | The attribute (`terraform_plan`) or `key_path` (`json`) was not found. |
+| 99 | `terraform_plan` | The policy itself is malformed (unsupported `operation_type`, missing required argument). Do not set a tolerance this high: it would mask broken policies. |
+
+So `"error_tolerance": 2` is the common setting for "skip this check when the key or attribute is absent", and `"error_tolerance": 1` for "skip when the resource type does not appear in the plan".
+
+Two situations are never tolerated, regardless of the setting:
+
+- The provider found **no values at all** for the check (`Could not find input value`) — this fails.
+- The provider reported an error **without a severity**, which the engine treats as a malformed provider call — this fails.
+
+A skipped check interacts with the final verdict as described above: it is removed from `eval_expression`, and if nothing is left, `final_result` is `null` — reported as `= Skipped final evaluator`, exit `0` by default and exit `1` under `--fail-on-error`.
+
+## Variables
+
+Any **string** value in the policy can be replaced by a variable reference:
+
+```json
+"condition": {
+ "type": "LessThanEqualTo",
+ "value": "{{ var.max_monthly_cost }}"
+}
+```
+
+The rules, exactly as implemented:
+
+- The syntax is `{{ var.NAME }}`. The `var.` prefix is mandatory; `{{ NAME }}` is not a variable reference and is left untouched.
+- The reference must start the string, and the whole string is replaced by the variable's value — which keeps the variable's JSON type. A number stays a number, a list stays a list. Variables cannot be interpolated into the middle of a longer string.
+- Substitution is applied to: string values directly under `meta`, each evaluator's `id`, string values directly under `provider_args` and `condition`, and `eval_expression`. It does **not** recurse into nested objects or arrays inside those keys.
+- `NAME` may be a dotted path (`{{ var.limits.cost }}`), looked up inside the variable document.
+
+Variables come from two CLI sources, applied in this order (later wins):
+
+1. `-var-path vars.json` — a JSON object per file; the flag may be repeated, and files are merged left to right, so a later file overrides an earlier one key by key.
+2. `-var NAME=VALUE` — `VALUE` is parsed as JSON (`-var 'max_monthly_cost=300'`, `-var 'env="prod"'`); the flag may be repeated. Inline variables override variable files. An inline variable that is not of the form `NAME=` is ignored with a logged error — it does not define the variable.
+
+If a referenced variable is not defined by any source, the policy is **not evaluated at all**: the result contains only `{"errors": ["Variables not found: NAME"]}`, there is no verdict, and the CLI exits `0` by default and `1` under `--fail-on-error`.
+
+## Result document
+
+With `--json`, the CLI prints a single JSON object:
+
+| Key | Type | Content |
+|---|---|---|
+| `meta` | object | `version` and `required_provider` (always present, `null`/`"core"` when defaulted), plus whichever of `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` the policy declared, copied verbatim. |
+| `final_result` | `true` / `false` / `null` | The final verdict. |
+| `evaluators` | array | One entry per check: `id`, `description`, tri-state `passed`, and `result` — the per-value messages, each with its own tri-state `passed`. |
+| `errors` | array of strings | Informational notes from evaluating `eval_expression` (undefined ids that were removed, disallowed symbols). Empty on a clean run — including a clean *failing* run. |
+| `eval_expression` | string | The expression that was evaluated, after variable substitution. |
+
+For complete, runnable policies with their inputs and verdicts, see the [Policy Cookbook](./tirith-policy-cookbook.md).
diff --git a/documentation/docs/tirith-policies/tirith-policy-variables.md b/documentation/docs/tirith-policies/tirith-policy-variables.md
index 5b4d2539..c37229f8 100644
--- a/documentation/docs/tirith-policies/tirith-policy-variables.md
+++ b/documentation/docs/tirith-policies/tirith-policy-variables.md
@@ -9,11 +9,12 @@ site_name: Tirith
slug: tirith-policy-variables/
---
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
Policy variables allow dynamic values in policy definitions. They can be referenced in conditions to make policies more flexible.
+A variable is referenced as `{{ var.NAME }}`. The `var.` prefix is required — a placeholder written
+without it, such as `{{ max_epoch }}`, is not recognised as a variable and is compared as the
+literal string, so the check quietly measures the wrong thing instead of failing.
+
```json title="variables.json"
{
"max_epoch": 1720415598
@@ -23,7 +24,7 @@ Policy variables allow dynamic values in policy definitions. They can be referen
```json title="policy.json"
{
"meta": {
- "version": "v1"
+ "version": "v1",
"required_provider": "stackguardian/json"
},
"evaluators": [
@@ -35,10 +36,11 @@ Policy variables allow dynamic values in policy definitions. They can be referen
},
"condition": {
"type": "LessThan",
- "value": "{{ max_epoch }}"
+ "value": "{{ var.max_epoch }}"
}
}
- ]
+ ],
+ "eval_expression": "epoch_check"
}
```
@@ -48,3 +50,18 @@ Example command:
tirith -input-path -policy-path policy.json -var-path variables.json
```
+Against an input of `{"meta": {"epoch": 1720000000}}` this passes:
+
+```
+Check: epoch_check
+ PASSED
+ 1. PASSED: `1720000000` is less than `1720415598`
+```
+
+Supply variables inline with `-var` instead of, or in addition to, `-var-path`; an inline `-var`
+wins when the same name is set in both. A variable that is referenced but never supplied is an
+error, not an empty value: the run reports `Variables not found` and exits `1`.
+
+See [Policy reference](tirith-policy-reference.md) for where `{{ var.NAME }}` may appear, and
+[CLI reference](../tirith-usage/cli-reference.md) for the flags.
+
diff --git a/documentation/docs/tirith-providers/infracost.md b/documentation/docs/tirith-providers/infracost.md
new file mode 100644
index 00000000..a3b694fc
--- /dev/null
+++ b/documentation/docs/tirith-providers/infracost.md
@@ -0,0 +1,92 @@
+---
+id: infracost-provider
+title: Infracost Provider
+sidebar_label: Infracost
+description: Reference for the stackguardian/infracost provider - operation types, parameters, return shapes, and error behavior.
+keywords:
+ - tirith
+site_name: Tirith
+slug: infracost-provider/
+---
+
+```
+required_provider: stackguardian/infracost
+```
+
+Sums estimated costs from an Infracost cost breakdown, either for all resources or for a chosen set of resource types.
+
+## Input document
+
+The JSON produced by Infracost:
+
+```bash
+infracost breakdown --path . --format json > infracost.json
+tirith -policy-path policy.json -input-path infracost.json
+```
+
+The provider reads `projects[].breakdown.resources[]` and understands both the older per-resource keys (`totalMonthlyCost` / `totalHourlyCost`) and the newer ones (`monthlyCost` / `hourlyCost`). Resources whose cost field is missing or `null` contribute nothing to the sum.
+
+Note: only the **first** project in the `projects` array is summed.
+
+## Operation types
+
+| `operation_type` | Purpose |
+|---|---|
+| `total_monthly_cost` | Sum of estimated monthly costs |
+| `total_hourly_cost` | Sum of estimated hourly costs |
+
+Both operations take the same parameters:
+
+| Parameter | Required | Description |
+|---|---|---|
+| `operation_type` | yes | `total_monthly_cost` or `total_hourly_cost`. |
+| `resource_type` | yes | Which resources to sum. `"*"`, `["*"]`, or an empty value sums **all** resources. Otherwise, a list of Terraform resource type names (e.g. `["aws_eks_cluster", "aws_s3_bucket"]`); a resource is included when the type part of its name (everything before the first `.`) is in the list. |
+
+**Returns:** a single number — the sum of the selected resources' costs. If nothing matches, the sum is `0`.
+
+**On a miss / error:** all errors from this provider carry **no severity value**, so they always fail the check and `error_tolerance` cannot skip them:
+
+- `operation_type` or `resource_type` key missing from `provider_args` — error `'resource_type/operation_type not found in provider_args'`.
+- An `operation_type` other than the two above — error naming the unknown value.
+- Input without a `projects` key — error `'projects not found in input_data'`.
+- A project without `breakdown.resources` — error `'breakdown/resources not found in one of the project'`.
+
+## Example
+
+Verified end-to-end against the test fixtures — the total monthly cost of the stack must stay at or below 30, and the selected resource types must be free:
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/infracost"
+ },
+ "evaluators": [
+ {
+ "id": "cost_check_1",
+ "provider_args": {
+ "operation_type": "total_monthly_cost",
+ "resource_type": ["*"]
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 30
+ }
+ },
+ {
+ "id": "cost_check_2",
+ "provider_args": {
+ "operation_type": "total_monthly_cost",
+ "resource_type": ["aws_eks_cluster", "aws_s3_bucket"]
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ }
+ ],
+ "eval_expression": "cost_check_1 && cost_check_2"
+}
+```
+
+Condition types are documented in the [evaluators reference](../tirith-reference/evaluators.md); CLI flags in the [CLI reference](../tirith-usage/cli-reference.md).
diff --git a/documentation/docs/tirith-providers/json.md b/documentation/docs/tirith-providers/json.md
new file mode 100644
index 00000000..033b7509
--- /dev/null
+++ b/documentation/docs/tirith-providers/json.md
@@ -0,0 +1,141 @@
+---
+id: json-provider
+title: JSON Provider
+sidebar_label: JSON
+description: Reference for the stackguardian/json provider - the get_value operation, key path syntax, return shapes, and error behavior.
+keywords:
+ - tirith
+site_name: Tirith
+slug: json-provider/
+---
+
+```
+required_provider: stackguardian/json
+```
+
+Extracts values from any JSON or YAML document by key path. Use this provider when no specialized provider exists for your input format.
+
+## Input document
+
+Any JSON file, or any YAML file (`.yaml` / `.yml` extension). A YAML file with multiple documents (separated by `---`) is parsed into a **list** of documents; start the key path with `*.` to iterate over them.
+
+## Operation types
+
+| `operation_type` | Purpose |
+|---|---|
+| `get_value` | Get the value(s) at a key path |
+
+Any other `operation_type` produces an error **without** a severity value, which always fails the check.
+
+---
+
+## `get_value`
+
+| Parameter | Required | Description |
+|---|---|---|
+| `key_path` | yes | Dot-separated path into the document. `*` as a path segment iterates over every element of a list or every value of a dict. |
+
+Path syntax, with examples of what each returns:
+
+| `key_path` | Input | Values produced |
+|---|---|---|
+| `a.b` | `{"a": {"b": 1}}` | `1` |
+| `c` | `{"c": ["aa", "bb"]}` | `["aa", "bb"]` (the whole list, one value) |
+| `nested_map` | `{"nested_map": {"e": {"f": "3"}}}` | `{"e": {"f": "3"}}` (the whole dict, one value) |
+| `list_of_dict.*.key1` | `{"list_of_dict": [{"key1": "value1"}, {"key1": "value1"}]}` | `"value1"`, `"value1"` (one value per element) |
+| `countries.*.capital` | `{"countries": {"US": {"capital": "Washington"}, "UK": {"capital": "London"}}}` | `"Washington"`, `"London"` (one per dict value) |
+| `*.name` | `[{"name": "Alice"}, {"name": "Bob"}]` | `"Alice"`, `"Bob"` (leading `*` over a top-level list) |
+
+**Returns:** one result per value found at the path. Without `*`, that is a single value of whatever shape lives there (scalar, list, or dict). With `*`, one result per matched element — and the condition must pass for **every** one of them.
+
+**On a miss:** if the path matches nothing, the provider reports an error with **severity 2** (`` key_path: `...` is not found ``). With the default `error_tolerance` of 0 the check fails; with `error_tolerance: 2` it is skipped instead. See [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md).
+
+## Examples
+
+Verified end-to-end against the test fixtures:
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json"
+ },
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "z.b"
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 1,
+ "error_tolerance": 2
+ }
+ },
+ {
+ "id": "check1",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "a.b"
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 1
+ }
+ },
+ {
+ "id": "check2",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "c"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "aa"
+ }
+ },
+ {
+ "id": "check4",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "list_of_dict.*.key1"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "value1"
+ }
+ }
+ ],
+ "eval_expression": "check1 && check2 && check4"
+}
+```
+
+(`check0` targets a path that does not exist; with `error_tolerance: 2` it is skipped and dropped from `eval_expression` instead of failing.)
+
+The provider also works on YAML — this policy checks an Ansible playbook (a YAML file whose top level is a list of plays, hence the leading `*.`):
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json"
+ },
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "*.vars.region"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "your_aws_region"
+ }
+ }
+ ],
+ "eval_expression": "check0"
+}
+```
+
+Condition types are documented in the [evaluators reference](../tirith-reference/evaluators.md).
diff --git a/documentation/docs/tirith-providers/kubernetes.md b/documentation/docs/tirith-providers/kubernetes.md
new file mode 100644
index 00000000..739d32c9
--- /dev/null
+++ b/documentation/docs/tirith-providers/kubernetes.md
@@ -0,0 +1,84 @@
+---
+id: kubernetes-provider
+title: Kubernetes Provider
+sidebar_label: Kubernetes
+description: Reference for the stackguardian/kubernetes provider - the attribute operation, parameters, return shapes, and error behavior.
+keywords:
+ - tirith
+site_name: Tirith
+slug: kubernetes-provider/
+---
+
+```
+required_provider: stackguardian/kubernetes
+```
+
+Extracts attribute values from Kubernetes manifests of a chosen `kind` (Pod, Deployment, Service, ...).
+
+## Input document
+
+A **list** of Kubernetes manifests. In practice this is a multi-document YAML file — for example the output of `helm template` or a concatenation of manifests separated by `---`:
+
+```bash
+helm template my-release ./chart > manifests.yml
+tirith -policy-path policy.json -input-path manifests.yml
+```
+
+Every document in the list must have a `kind` key. Note that a YAML file containing only a **single** document does not currently work with this provider — the input must parse to a list of manifests (two or more YAML documents, or a JSON array).
+
+## Operation types
+
+| `operation_type` | Purpose |
+|---|---|
+| `attribute` | Get the value at an attribute path from every manifest of a kind |
+
+Any other `operation_type` produces an error **without** a severity value, which always fails the check.
+
+---
+
+## `attribute`
+
+| Parameter | Required | Description |
+|---|---|---|
+| `kubernetes_kind` | yes | The `kind` to match, e.g. `Pod`, `Deployment`. Exact match. Omitting it produces a severity 99 error. |
+| `attribute_path` | yes | Dot-separated path into the manifest, e.g. `spec.containers.*.image`. `*` as a path segment iterates over every element of a list or every value of a dict. Omitting it (or passing an empty string) produces a severity 99 error. |
+
+**Returns:** one result per manifest whose `kind` matches:
+
+- If `attribute_path` contains **no** `*` — the single value at that path (scalar, list, or dict), or `null` when the path is absent from that manifest.
+- If `attribute_path` contains `*` — a **list** with one entry per matched element; elements where the remainder of the path is absent appear as `null` in the list. The condition is applied to the list as a whole, which makes `Contains` / `NotContains` (checking for `null` entries) the natural conditions to pair with wildcard paths.
+
+**On a miss:** no manifest of the requested kind — severity 1 (`kind: ... is not found`). A present kind with an absent path is **not** an error; it produces `null` values as described above.
+
+## Example
+
+Verified end-to-end against the test fixtures — every container of every `Pod` must define a `livenessProbe`:
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "kinds_have_null_liveness_probe",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Pod",
+ "attribute_path": "spec.containers.*.livenessProbe"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": null,
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "!kinds_have_null_liveness_probe"
+}
+```
+
+How this works: for each `Pod`, the provider returns the list of every container's `livenessProbe` value, with `null` for containers that lack one. The `Contains: null` condition is true when at least one container is missing the probe, and the `eval_expression` negates it, so the policy passes only when every container defines a probe.
+
+Condition types are documented in the [evaluators reference](../tirith-reference/evaluators.md).
diff --git a/documentation/docs/tirith-providers/overview.md b/documentation/docs/tirith-providers/overview.md
new file mode 100644
index 00000000..ede6d7ca
--- /dev/null
+++ b/documentation/docs/tirith-providers/overview.md
@@ -0,0 +1,90 @@
+---
+id: providers-overview
+title: Providers Overview
+sidebar_label: Overview
+description: What a Tirith provider is, how required_provider selects one, how provider_args are passed, and the list of available providers.
+keywords:
+ - tirith
+site_name: Tirith
+slug: providers-overview/
+---
+
+A **provider** is the part of Tirith that knows how to read one specific kind of input document and extract values from it. The policy declares which provider to use; each evaluator in the policy then asks the provider for values (via `provider_args`), and the evaluator's `condition` is applied to every value the provider returns.
+
+## Selecting a provider
+
+The provider is selected once for the whole policy with `meta.required_provider`:
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [ ... ],
+ "eval_expression": "..."
+}
+```
+
+The value must be one of the exact strings below. If the string does not match any known provider, every evaluator in the policy fails with an error.
+
+| `required_provider` | Summary | Expected input document |
+|---|---|---|
+| [`stackguardian/terraform_plan`](terraform-plan.md) | Inspects resource changes, actions, counts, dependencies, references, provider configuration, and the Terraform version in a Terraform plan. | Terraform plan in JSON form (`terraform show -json `) |
+| [`stackguardian/infracost`](infracost.md) | Sums estimated monthly or hourly costs from an Infracost breakdown. | Infracost output (`infracost breakdown --format json`) |
+| [`stackguardian/json`](json.md) | Extracts values from any JSON or YAML document by key path, with wildcard support. | Any JSON or YAML file |
+| [`stackguardian/kubernetes`](kubernetes.md) | Extracts attribute values from Kubernetes manifests of a given `kind`. | A list of Kubernetes manifests (multi-document YAML, e.g. `helm template` output) |
+| [`stackguardian/sg_workflow`](sg-workflow.md) | Reads attributes of a StackGuardian workflow definition. | StackGuardian workflow JSON |
+
+## How `provider_args` reaches the provider
+
+Each evaluator carries a `provider_args` object. Tirith hands that object to the selected provider **verbatim** — the provider decides which keys it understands. Every provider except `stackguardian/sg_workflow` dispatches on the `operation_type` key; the remaining keys are parameters of that operation.
+
+```json
+{
+ "id": "my_check",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket",
+ "terraform_resource_attribute": "force_destroy"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": false
+ }
+}
+```
+
+The provider returns a **list of results**. Each result is a value extracted from the input (a scalar, a list, or a dict, depending on the operation). The evaluator's `condition` is applied to each value independently, and the evaluator passes only if **every** value passes. If the provider returns nothing at all, the evaluator fails with the message `Could not find input value`.
+
+For the available condition types (`Equals`, `Contains`, `RegexMatch`, ...) see the [evaluators reference](../tirith-reference/evaluators.md).
+
+## How the input document is parsed
+
+The file given to [`-input-path`](../tirith-usage/cli-reference.md) is parsed by extension:
+
+- `.yaml` / `.yml` — parsed as YAML. A file with multiple documents (separated by `---`) becomes a **list** of documents; a file with a single document becomes that document directly.
+- anything else — parsed as JSON.
+
+The parsed value is what the provider sees.
+
+## Errors, misses, and `error_tolerance`
+
+When a provider cannot find what an operation asked for, it reports an error instead of a value. There are two kinds:
+
+1. **Errors with a severity value.** Most "not found" situations carry a numeric severity. Whether the check fails or is skipped depends on the evaluator's `condition.error_tolerance` (default `0`):
+ - severity **greater than** `error_tolerance` — the check **fails**.
+ - severity **less than or equal to** `error_tolerance` — the check is **skipped** (its `passed` is `null`, and its id is dropped from `eval_expression`).
+
+ The conventional severity values are:
+
+ | Severity | Meaning |
+ |---|---|
+ | 0 | Nothing to inspect (e.g. no resource changes in the plan). Skipped even at the default tolerance. |
+ | 1 | The requested resource / kind / provider was not found. |
+ | 2 | The resource was found but the requested attribute / key path was not. |
+ | 99 | The `provider_args` themselves are invalid (unsupported operation, missing required parameter). Practically never tolerated. |
+
+2. **Errors without a severity value.** Some errors (an unsupported `operation_type` in the `json` and `kubernetes` providers, and all errors from the `infracost` and `sg_workflow` providers) carry no severity. These always **fail** the check, regardless of `error_tolerance`.
+
+Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md).
diff --git a/documentation/docs/tirith-providers/sg-workflow.md b/documentation/docs/tirith-providers/sg-workflow.md
new file mode 100644
index 00000000..f6403d80
--- /dev/null
+++ b/documentation/docs/tirith-providers/sg-workflow.md
@@ -0,0 +1,117 @@
+---
+id: sg-workflow-provider
+title: SG Workflow Provider
+sidebar_label: SG Workflow
+description: Reference for the stackguardian/sg_workflow provider - supported workflow attributes, return shapes, and error behavior.
+keywords:
+ - tirith
+site_name: Tirith
+slug: sg-workflow-provider/
+---
+
+```
+required_provider: stackguardian/sg_workflow
+```
+
+Reads attributes of a StackGuardian workflow definition.
+
+## Input document
+
+A StackGuardian workflow definition in JSON form — the object that contains keys such as `WfType`, `TerraformConfig`, `VCSConfig`, and `DeploymentPlatformConfig`.
+
+## Parameters
+
+This provider does not dispatch on `operation_type`. It reads exactly one key from `provider_args`:
+
+| Parameter | Required | Description |
+|---|---|---|
+| `workflow_attribute` | yes | The name of the workflow attribute to read (see the table below). |
+
+By convention policies also set `"operation_type": "attribute"` (the test fixtures do), but the provider does not read or validate that key.
+
+## Supported values for `workflow_attribute`
+
+The attribute name determines where in the workflow document the value is read from:
+
+| `workflow_attribute` | Read from | Typical shape |
+|---|---|---|
+| `integrationId` | `DeploymentPlatformConfig[].config.integrationId`, with the `/integrations/` prefix stripped from each id | list of strings |
+| `Description` | top level | string |
+| `DocVersion` | top level | string |
+| `ResourceName` | top level | string |
+| `ResourceType` | top level | string (e.g. `WORKFLOW`) |
+| `Tags` | top level | list of strings |
+| `WfType` | top level | string (e.g. `TERRAFORM`) |
+| `approvalPreApply` | `TerraformConfig` | boolean |
+| `driftCheck` | `TerraformConfig` | boolean |
+| `managedTerraformState` | `TerraformConfig` | boolean |
+| `terraformVersion` | `TerraformConfig` | string |
+| `bucket_region` | `VCSConfig.iacInputData.data` | string |
+| `s3_bucket_acl` | `VCSConfig.iacInputData.data` | string |
+| `s3_bucket_block_public_acls` | `VCSConfig.iacInputData.data` | boolean |
+| `s3_bucket_block_public_policy` | `VCSConfig.iacInputData.data` | boolean |
+| `s3_bucket_force_destroy` | `VCSConfig.iacInputData.data` | boolean |
+| `s3_bucket_ignore_public_acls` | `VCSConfig.iacInputData.data` | boolean |
+| `s3_bucket_restrict_public_buckets` | `VCSConfig.iacInputData.data` | boolean |
+| `iacTemplateId` | `VCSConfig.iacVCSConfig` | string |
+| `useMarketplaceTemplate` | `VCSConfig.iacVCSConfig` | boolean |
+
+**Returns:** a single value with the shape shown above. `integrationId` returns a list — pair it with `Contains` (see the example) rather than `Equals`.
+
+**On a miss / error:** all errors from this provider carry **no severity value**, so they always fail the check and `error_tolerance` cannot skip them:
+
+- The attribute's containing key is absent from the workflow document (e.g. no `TerraformConfig` when asking for `driftCheck`) — error `' not found in input_data'`.
+- `workflow_attribute` missing from `provider_args` — error `workflow_attribute not found in provider_args`.
+- `workflow_attribute` present but empty — the provider returns nothing and the check fails with `Could not find input value`.
+- A `workflow_attribute` name that is not in the table above is **not** an error: the provider returns an empty string `""`, which is then evaluated against the condition. Double-check spelling — a typo silently evaluates `""` instead of the intended value.
+
+## Example
+
+Verified end-to-end against the test fixtures:
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/sg_workflow"
+ },
+ "evaluators": [
+ {
+ "id": "wf_check_1",
+ "provider_args": {
+ "operation_type": "attribute",
+ "workflow_attribute": "useMarketplaceTemplate"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ },
+ {
+ "id": "wf_check_2",
+ "provider_args": {
+ "operation_type": "attribute",
+ "workflow_attribute": "integrationId"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "aws-qa"
+ }
+ },
+ {
+ "id": "wf_check_3",
+ "provider_args": {
+ "operation_type": "attribute",
+ "workflow_attribute": "terraformVersion"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "^1\\."
+ }
+ }
+ ],
+ "eval_expression": "wf_check_1 && wf_check_2 && wf_check_3"
+}
+```
+
+Condition types are documented in the [evaluators reference](../tirith-reference/evaluators.md).
diff --git a/documentation/docs/tirith-providers/terraform-plan.md b/documentation/docs/tirith-providers/terraform-plan.md
new file mode 100644
index 00000000..d214b87b
--- /dev/null
+++ b/documentation/docs/tirith-providers/terraform-plan.md
@@ -0,0 +1,377 @@
+---
+id: terraform-plan-provider
+title: Terraform Plan Provider
+sidebar_label: Terraform Plan
+description: Reference for the stackguardian/terraform_plan provider - operation types, parameters, return shapes, and error behavior.
+keywords:
+ - tirith
+site_name: Tirith
+slug: terraform-plan-provider/
+---
+
+```
+required_provider: stackguardian/terraform_plan
+```
+
+Inspects a Terraform plan: attribute values of changed resources, the actions applied to them, resource counts, explicit dependencies, references between resources, provider configuration, and the Terraform version.
+
+## Input document
+
+The JSON representation of a Terraform plan:
+
+```bash
+terraform plan -out=plan.out
+terraform show -json plan.out > plan.json
+tirith -policy-path policy.json -input-path plan.json
+```
+
+Most operations read the `resource_changes` array of the plan. If the plan contains no `resource_changes` at all, every operation reports an error with severity 0 (`No Terraform resources changes are found`), which is skipped at the default `error_tolerance` of 0.
+
+## Operation types
+
+| `operation_type` | Purpose |
+|---|---|
+| `attribute` | Get an attribute's planned value for every instance of a resource type |
+| `action` | Get the plan actions (`create`, `update`, `delete`, ...) for a resource type |
+| `count` | Count the changed instances of a resource type |
+| `direct_dependencies` | Get the resource types listed in a resource's `depends_on` |
+| `direct_references` | Get or check references between resources |
+| `terraform_version` | Get the Terraform version that produced the plan |
+| `provider_config` | Get the configuration of a Terraform provider (version constraint or region) |
+
+Any other `operation_type` produces an error with severity 99, which fails the check.
+
+---
+
+## `attribute`
+
+Returns the planned (`change.after`) value of an attribute for every instance of a resource type.
+
+| Parameter | Required | Description |
+|---|---|---|
+| `terraform_resource_type` | yes | Resource type to match (e.g. `aws_s3_bucket`), or `*` to match every type. |
+| `terraform_resource_attribute` | yes | Attribute to read. A plain top-level key (`force_destroy`), a dotted path (`tags.costcenter`), or a path containing `.*.` to iterate over a list (`ebs_block_device.*.encrypted`). |
+| `exclude_resource_types` | no (default `[]`) | List of resource types to skip. Only applied when `terraform_resource_type` is `*`. |
+
+**Returns:** one value per matching resource instance. With a `.*.` wildcard, one value per list element; list elements that lack the attribute contribute `null`, so they are still evaluated. The value is whatever the attribute holds in the plan — scalar, list, or dict.
+
+**On a miss:**
+
+- No resource of the requested type in `resource_changes` — severity 1 (`resource_type: '...' is not found`).
+- Resource found, attribute absent — severity 2 (`attribute: '...' is not found`), reported per resource instance that lacks it.
+- Resource found but its `change.after` is empty (e.g. a destroy-only change) — severity 0 (`No Terraform changes found for resource type: '...'`).
+
+Example (adapted from a test fixture; requires every resource in the plan to carry a non-empty `costcenter` tag):
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "every_resource_has_costcenter_tag",
+ "description": "All resources must have a 'costcenter' tag with a non-empty value",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.costcenter",
+ "exclude_resource_types": ["aws_iam_role_policy_attachment"]
+ },
+ "condition": {
+ "type": "IsNotEmpty",
+ "value": "",
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "every_resource_has_costcenter_tag"
+}
+```
+
+---
+
+## `action`
+
+Returns the actions Terraform plans to take on every instance of a resource type. Actions come straight from `change.actions` in the plan: `create`, `update`, `delete`, `no-op`, `read` (a replacement appears as both `delete` and `create`).
+
+| Parameter | Required | Description |
+|---|---|---|
+| `terraform_resource_type` | yes | Resource type to match, or `*` for every type. |
+| `exclude_resource_types` | no (default `[]`) | List of resource types to skip. Only applied when `terraform_resource_type` is `*`. |
+
+**Returns:** one string per action per matching resource instance (a resource with actions `["delete", "create"]` yields two values).
+
+**On a miss:** no resource of the requested type — severity 1.
+
+Example (adapted from a test fixture; fails when a virtual network would be deleted):
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "vnet_is_deleted",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "azurerm_virtual_network"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": ["delete"],
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "!vnet_is_deleted"
+}
+```
+
+---
+
+## `count`
+
+Counts the instances of a resource type in `resource_changes`.
+
+| Parameter | Required | Description |
+|---|---|---|
+| `terraform_resource_type` | yes | Resource type to count, or `*` for every type. |
+| `exclude_resource_types` | no (default `[]`) | List of resource types to skip. Only applied when `terraform_resource_type` is `*`. |
+
+**Returns:** a single integer. A type with no instances returns `0` — this operation never produces a "not found" error.
+
+Example:
+
+```json
+{
+ "id": "at_most_ten_vpcs",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "aws_vpc"
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 10
+ }
+}
+```
+
+---
+
+## `direct_dependencies`
+
+Returns, for each resource of a type, the resource types named in its explicit `depends_on`. Only resources declared in the **root module** of the configuration are inspected.
+
+| Parameter | Required | Description |
+|---|---|---|
+| `terraform_resource_type` | yes | Resource type to inspect. Omitting it produces a severity 99 error. |
+
+**Returns:** one list of resource-type strings per matching resource (only the type part of each `depends_on` entry, i.e. `aws_s3_bucket.example` becomes `aws_s3_bucket`). A resource without `depends_on` yields an empty list.
+
+**On a miss:** no resource of the requested type in the configuration — severity 1.
+
+Example (verified against a test fixture; requires every EC2 instance to declare an explicit dependency on an S3 bucket):
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "ec2_depends_on_s3",
+ "description": "Make sure that EC2 instances have explicit dependency on S3 bucket",
+ "provider_args": {
+ "operation_type": "direct_dependencies",
+ "terraform_resource_type": "aws_instance"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "aws_s3_bucket",
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "ec2_depends_on_s3"
+}
+```
+
+---
+
+## `direct_references`
+
+Inspects references between resources (a reference is created when one resource's argument uses another resource's attribute, e.g. `security_groups = [aws_security_group.sg.id]`). It has three modes, chosen by which parameters are present:
+
+| Parameter | Required | Description |
+|---|---|---|
+| `terraform_resource_type` | yes | The resource type under inspection. |
+| `referenced_by` | no | A resource type that should point **at** `terraform_resource_type`. |
+| `references_to` | no | A resource type that `terraform_resource_type` should point **to**. |
+
+`referenced_by` and `references_to` are mutually exclusive — supplying both produces a severity 99 error.
+
+### Plain mode (neither `referenced_by` nor `references_to`)
+
+For each resource of `terraform_resource_type` declared in the **root module**, returns the list of resource types it references in its expressions.
+
+**Returns:** one list of resource-type strings per matching resource.
+
+**On a miss:** type not found in the configuration — severity 1. Omitting `terraform_resource_type` — severity 99.
+
+Example (verified against a test fixture):
+
+```json
+{
+ "id": "aws_elbs_have_direct_references_to_security_group",
+ "provider_args": {
+ "operation_type": "direct_references",
+ "terraform_resource_type": "aws_elb"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "aws_security_group",
+ "error_tolerance": 2
+ }
+}
+```
+
+### `referenced_by` mode
+
+Checks that instances of `terraform_resource_type` are referenced by resources of type `referenced_by`. Instances that are only being destroyed are ignored. Unlike the plain mode, references are searched through the whole configuration, including child modules.
+
+**Returns:** one boolean per instance of `terraform_resource_type` — `true` if some `referenced_by` resource references it, `false` otherwise. Use `"condition": {"type": "Equals", "value": true}` to require that all instances are referenced.
+
+**On a miss:** no (non-destroyed) instance of `terraform_resource_type` — severity 1.
+
+Example (from a test fixture; every S3 bucket must have an intelligent-tiering configuration attached):
+
+```json
+{
+ "meta": {
+ "required_provider": "stackguardian/terraform_plan",
+ "version": "v1"
+ },
+ "evaluators": [
+ {
+ "id": "s3HasLifeCycleIntelligentTiering",
+ "description": "Make sure all aws_s3_bucket are referenced by aws_s3_bucket_intelligent_tiering_configuration",
+ "provider_args": {
+ "operation_type": "direct_references",
+ "terraform_resource_type": "aws_s3_bucket",
+ "referenced_by": "aws_s3_bucket_intelligent_tiering_configuration"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true,
+ "error_tolerance": 0
+ }
+ }
+ ],
+ "eval_expression": "s3HasLifeCycleIntelligentTiering"
+}
+```
+
+### `references_to` mode
+
+Checks that every instance of `terraform_resource_type` references at least one resource of type `references_to`. Instances that are only being destroyed are ignored.
+
+**Returns:** a **single** boolean — `true` only if all instances reference the target type.
+
+**On a miss:** no (non-destroyed) instance of `terraform_resource_type` — severity 1.
+
+Example (verified against a test fixture):
+
+```json
+{
+ "id": "elbRefsToSecGroup",
+ "description": "Make sure ELBs references to security groups",
+ "provider_args": {
+ "operation_type": "direct_references",
+ "terraform_resource_type": "aws_elb",
+ "references_to": "aws_security_group"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true,
+ "error_tolerance": 0
+ }
+}
+```
+
+---
+
+## `terraform_version`
+
+Returns the Terraform version string recorded in the plan.
+
+No parameters besides `operation_type`.
+
+**Returns:** a single string (e.g. `"1.4.5"`), or `null` if the plan has no `terraform_version` key.
+
+Example (verified end-to-end):
+
+```json
+{
+ "id": "terraform_version_check",
+ "provider_args": {
+ "operation_type": "terraform_version"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "^1\\."
+ }
+}
+```
+
+---
+
+## `provider_config`
+
+Reads the configuration of a Terraform provider from `configuration.provider_config` in the plan.
+
+| Parameter | Required | Description |
+|---|---|---|
+| `terraform_provider_full_name` | yes | The provider's full registry name, e.g. `registry.terraform.io/hashicorp/aws`. Omitting it produces a severity 99 error. |
+| `attribute` | yes | What to read. Must be `version_constraint` or `region` — anything else produces a severity 99 error. |
+
+**Returns:** one string per provider entry whose `full_name` matches: the version constraint (e.g. `">= 3.11.0, < 4.0.0"`) or the region. The region is only found when it is written as a constant in the configuration; a region supplied through a variable is reported as not found (severity 2).
+
+**On a miss:**
+
+- Matching provider found but the attribute is absent — severity 2 (`` `region` is not found in the provider_config ``).
+- No provider with that `full_name` — severity 1.
+
+Example (verified end-to-end):
+
+```json
+{
+ "id": "aws_region_check",
+ "provider_args": {
+ "operation_type": "provider_config",
+ "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws",
+ "attribute": "region"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": ["eu-central-1", "eu-west-1"]
+ }
+}
+```
+
+---
+
+## Error severities used by this provider
+
+| Severity | Situation |
+|---|---|
+| 0 | No `resource_changes` in the plan, or the matched resource has no planned values (destroy-only change). |
+| 1 | Resource type / provider name not found. |
+| 2 | Attribute not found on a matched resource or provider config. |
+| 99 | Invalid `provider_args` (unsupported operation or attribute, missing required parameter, both `referenced_by` and `references_to` given). |
+
+Whether a severity fails or skips the check depends on `condition.error_tolerance` — see the [providers overview](overview.md) and [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md). Condition types are documented in the [evaluators reference](../tirith-reference/evaluators.md).
diff --git a/documentation/docs/tirith-reference/eval-expressions.md b/documentation/docs/tirith-reference/eval-expressions.md
new file mode 100644
index 00000000..970376a1
--- /dev/null
+++ b/documentation/docs/tirith-reference/eval-expressions.md
@@ -0,0 +1,118 @@
+---
+id: eval-expressions
+title: Evaluation Expressions
+sidebar_label: Eval Expressions
+description: Reference for eval_expression, the boolean expression that combines evaluator results into a policy's final verdict.
+keywords:
+ - tirith
+site_name: Tirith
+slug: eval-expressions/
+---
+
+A policy's top-level `eval_expression` is a boolean expression over the `id`s of its evaluators. After every evaluator has produced its verdict, Tirith substitutes those verdicts into the expression and evaluates it; the outcome becomes `final_result` in the output.
+
+```json
+{
+ "meta": { "version": "v1", "required_provider": "stackguardian/json" },
+ "evaluators": [
+ { "id": "check_region", "provider_args": { "...": "..." }, "condition": { "...": "..." } },
+ { "id": "check_tags", "provider_args": { "...": "..." }, "condition": { "...": "..." } },
+ { "id": "check_budget", "provider_args": { "...": "..." }, "condition": { "...": "..." } }
+ ],
+ "eval_expression": "(check_region || check_tags) && check_budget"
+}
+```
+
+## Referencing evaluators
+
+Evaluators are referenced by their `id`, written bare (no quotes, no prefix). Substitution matches ids as whole words, so one id being a prefix of another (`check` and `check_2`) is not a problem.
+
+Use only letters, digits, and underscores in ids that appear in the expression. An id with other characters (such as `-`) still works *if it is defined*, because it is replaced by its verdict before the expression is parsed — but if such an id is missing from the policy, the leftover text cannot be parsed as an expression and the whole run aborts (see [Unparseable expressions](#unparseable-expressions)).
+
+Each id stands for the tri-state verdict of its evaluator:
+
+- `true` — every value it checked passed,
+- `false` — at least one value failed,
+- *skipped* — the evaluator did not actually check anything (all of its provider errors were within `error_tolerance`).
+
+## Operators
+
+| Operator | Meaning | Example |
+| --- | --- | --- |
+| `&&` | logical AND | `check_a && check_b` |
+| `\|\|` | logical OR | `check_a \|\| check_b` |
+| `!` | logical NOT | `!check_a` |
+| `( )` | grouping | `(check_a \|\| check_b) && check_c` |
+
+Whitespace is ignored. There are no comparison operators, literals, or function calls — only ids, the three operators above, and parentheses.
+
+**Precedence**, from tightest to loosest: `!`, then `&&`, then `||`. Both of these hold (verified against the implementation):
+
+- `a || b && c` means `a || (b && c)` — with `a` true and `b`, `c` false, the expression is true.
+- `!a || b` means `(!a) || b` — with `a` and `b` both true, the expression is true.
+
+Use parentheses whenever the intent is not obvious.
+
+**Single `&` and `|` are rejected.** They are not silently treated as `&&`/`||`; the run aborts with an explicit error and exit code 1:
+
+```
+Unsupported operator '&' in eval_expression. Use '&&' instead.
+```
+
+## Skipped evaluators
+
+An evaluator whose verdict is *skipped* (`passed: null` in the output) is **removed from the expression** before evaluation, together with any `!` that applied to it, rather than being treated as false:
+
+- `skipped && other` reduces to `other`;
+- `!skipped && other` also reduces to `other`;
+- if *everything* in the expression is removed, `final_result` is `null` — see below.
+
+This is deliberate: treating a skipped check as `false` would fail policies through `!`-negations, and treating it as `true` would pass checks that never ran.
+
+## Missing evaluator ids
+
+An id used in the expression but not defined by any evaluator does **not** abort the run. It is removed from the expression the same way a skipped evaluator is, the rest of the expression is evaluated normally, and a note is appended to the top-level `errors` array of the output:
+
+```
+The following evaluator ids are not defined and have been removed: ghost_check
+```
+
+`errors` is informational: it does not affect `final_result` or the exit code. A policy whose expression is `real_check && ghost_check` passes with `final_result: true` and exit code 0 when `real_check` passes. Watch the `errors` array — a typo in an id silently weakens the policy.
+
+The reverse — an evaluator defined but never mentioned in the expression — still runs and appears in the output, but its verdict does not influence `final_result`.
+
+## Unparseable expressions
+
+If the expression cannot be parsed at all — a syntax error such as `check1 &&`, an empty string, a single `&`/`|`, or leftover text from a missing id that is not a valid identifier — the evaluation **aborts**: no result document is produced (with `--json`, the output is `{}`), and the process exits with code **1** regardless of `--fail-on-error`. This is the "tool error" exit code, distinct from a policy failure.
+
+As a safety measure the expression is evaluated with no access to builtins, and any symbol that survives id substitution is rejected (`The following symbols are not allowed: ...`) with `final_result: false`; the expression language cannot call functions or reach interpreter internals.
+
+## From expression result to exit code
+
+`final_result` is tri-state, and with `--fail-on-error` it maps to the exit code:
+
+| `final_result` | Meaning | Exit code with `--fail-on-error` |
+| --- | --- | --- |
+| `true` | every check that ran passed the expression | 0 |
+| `false` | the expression evaluated to false | 3 |
+| `null` | nothing was left to evaluate — every evaluator referenced in the expression was skipped or undefined | 1 |
+| *(absent)* | the run aborted before a verdict (unparseable expression, undefined policy variables) | 1 |
+
+Without `--fail-on-error`, the exit code is 0 in all of these cases except an aborted run, which still exits 1; the verdict is only in the output.
+
+`null` is not a pass: a policy whose every check was skipped checked precisely nothing.
+
+## Worked example
+
+```json
+"eval_expression": "!deprecated_api_used && (region_allowed || region_exempted)"
+```
+
+| `deprecated_api_used` | `region_allowed` | `region_exempted` | `final_result` |
+| --- | --- | --- | --- |
+| `false` | `true` | `false` | `true` |
+| `true` | `true` | `false` | `false` |
+| *skipped* | `true` | `false` | `true` — reduces to `(region_allowed \|\| region_exempted)` |
+| *skipped* | *skipped* | *skipped* | `null` — exit 1 under `--fail-on-error` |
+
+For what makes an individual evaluator pass, fail, or get skipped, see [Evaluators and Conditions](./evaluators.md).
diff --git a/documentation/docs/tirith-reference/evaluators.md b/documentation/docs/tirith-reference/evaluators.md
new file mode 100644
index 00000000..c71a9240
--- /dev/null
+++ b/documentation/docs/tirith-reference/evaluators.md
@@ -0,0 +1,362 @@
+---
+id: evaluators
+title: Evaluators and Conditions
+sidebar_label: Evaluators
+description: Complete reference for all Tirith condition types, their parameters, type handling, and pass/fail semantics.
+keywords:
+ - tirith
+site_name: Tirith
+slug: evaluators/
+---
+
+Every evaluator in a Tirith policy applies a **condition** to one or more values extracted by a provider. This page is the complete reference for all 13 condition types, including exactly how each one treats strings, numbers, lists, dictionaries, and `null`.
+
+## Anatomy of a condition
+
+An evaluator block looks like this:
+
+```json
+{
+ "id": "region_check",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "region"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "eu-central-1"
+ }
+}
+```
+
+The `condition` object accepts three keys:
+
+| Key | Required | Meaning |
+| --- | --- | --- |
+| `type` | yes | One of the 13 evaluator names listed below. The name is case-sensitive. |
+| `value` | yes, except for `IsEmpty` and `IsNotEmpty`, which ignore it | The value the extracted input is compared against. Any JSON type is accepted; each evaluator defines which types it supports. |
+| `error_tolerance` | no (default `0`) | The maximum provider error severity that is *skipped* instead of failing the evaluator. See [Error Tolerance](../tirith-policies/tirith-policy-error-tolerance.md). |
+
+Throughout this page:
+
+- **input value** means a value the provider extracted from the input document (`evaluator_input` in the code),
+- **condition value** means `condition.value` from the policy (`evaluator_data` in the code).
+
+A provider can return *several* input values for one evaluator (for example, a wildcard `key_path` such as `items.*`). The condition is applied to **each value independently, and the evaluator passes only if every value passes**. If the provider returns no values at all, the evaluator fails with the message `Could not find input value`.
+
+Each evaluator therefore ends in one of three states:
+
+- `passed: true` — every extracted value satisfied the condition,
+- `passed: false` — at least one value did not (or an unrecoverable provider error occurred),
+- `passed: null` — the evaluation was *skipped*: every provider error was within `error_tolerance` and no value was actually checked.
+
+## Failures versus errors
+
+This distinction matters for exit codes, so it is worth stating precisely:
+
+- **Evaluators never abort the run.** Every condition type catches internal exceptions. A type mismatch — comparing a string with a number, matching a regex against `null`, searching inside a boolean — produces `passed: false` with an explanatory message. It is reported and gated exactly like a genuine policy violation: with `--fail-on-error`, `final_result: false` exits with code **3**.
+- Exit code **1** (a tool error rather than a verdict) is reserved for problems outside the evaluators: an unreadable policy or input file, undefined policy variables, an `eval_expression` that cannot be parsed (see [Evaluation Expressions](./eval-expressions.md)), or a run in which every evaluator was skipped (`final_result: null`).
+- A misconfigured evaluator — an unsupported `condition.type` or an unsupported provider `operation_type` — is surfaced as an ordinary failed evaluator (`passed: false`) with an explanatory message, so under `--fail-on-error` it exits **3**, not 1.
+
+Without `--fail-on-error`, the process exits **0** regardless of the verdict; the verdict is only in the output.
+
+## Quick reference
+
+| `condition.type` | Passes when | `condition.value` | On a type mismatch |
+| --- | --- | --- | --- |
+| [`Equals`](#equals) | input value equals the condition value | any JSON | returns false (values of different types are simply not equal) |
+| [`NotEquals`](#notequals) | input value differs from the condition value | any JSON | returns true (different types are not equal) |
+| [`GreaterThan`](#comparisons-greaterthan-greaterthanequalto-lessthan-lessthanequalto) | `input > condition value` | number, string, or list (same type as input) | returns false, message carries the comparison error |
+| [`GreaterThanEqualTo`](#comparisons-greaterthan-greaterthanequalto-lessthan-lessthanequalto) | `input >= condition value` | same | same |
+| [`LessThan`](#comparisons-greaterthan-greaterthanequalto-lessthan-lessthanequalto) | `input < condition value` | same | same |
+| [`LessThanEqualTo`](#comparisons-greaterthan-greaterthanequalto-lessthan-lessthanequalto) | `input <= condition value` | same | same |
+| [`IsEmpty`](#isempty) | input is `null`, `""`, `[]`, or `{}` | ignored | returns false for numbers and booleans (never an error) |
+| [`IsNotEmpty`](#isnotempty) | input is a **non-empty string, list, or dictionary** | ignored | returns false for numbers, booleans, and `null` |
+| [`RegexMatch`](#regexmatch) | the pattern is found in the input | string (regular expression) | returns false for non-string/list/dict input; invalid pattern returns false with the regex error message |
+| [`ContainedIn`](#containedin) | the input value occurs inside the condition value | string, list, or dictionary | returns false with an "unsupported data type" message |
+| [`NotContainedIn`](#notcontainedin) | the input value does **not** occur inside the condition value | string, list, or dictionary | returns false (not true) with an "unsupported data type" message |
+| [`Contains`](#contains) | the condition value occurs inside the input value | any JSON (input must be string, list, or dictionary) | returns false with an "unsupported data type" message |
+| [`NotContains`](#notcontains) | the condition value does **not** occur inside the input value | any JSON (input must be string, list, or dictionary) | returns false (not true) with an "unsupported data type" message |
+
+Note the last two rows of each pair: **the `Not*` variants are not simple negations.** When the data has a type the evaluator does not support, *both* the positive and the negative form fail. If a value may be absent or of an unexpected type, test that explicitly (for example with `IsNotEmpty`) instead of relying on a `Not*` condition to pass.
+
+---
+
+## Equals
+
+Passes when the input value equals the condition value.
+
+- Comparison is by value, with one normalization: **lists of scalars are sorted before comparing**, recursively, including lists nested inside dictionaries. `[1, 2]` equals `[2, 1]`, and `{"a": [2, 1]}` equals `{"a": [1, 2]}`. A list that mixes types (for example `[1, "a"]`) cannot be sorted and is compared in its original order.
+- Numbers compare numerically: `1` equals `1.0`.
+- Booleans compare as the numbers 1 and 0: `true` equals `1` and `false` equals `0`.
+- Strings never equal numbers: `"1"` is **not** equal to `1`.
+- `null` equals `null`.
+- Dictionaries compare by keys and values; key order never matters.
+
+A type mismatch is not an error; the values are simply unequal and the check fails.
+
+```json
+"condition": { "type": "Equals", "value": ["b", "a"] }
+```
+
+| Input value | Result |
+| --- | --- |
+| `["a", "b"]` | passes (list order ignored) |
+| `["a", "b", "c"]` | fails |
+| `"a,b"` | fails |
+
+## NotEquals
+
+The exact negation of [`Equals`](#equals), using the same normalization. It passes whenever `Equals` would fail, including on type mismatches: `"1"` NotEquals `1` passes.
+
+```json
+"condition": { "type": "NotEquals", "value": "0.0.0.0/0" }
+```
+
+An input value of `"10.0.0.0/16"` passes; `"0.0.0.0/0"` fails.
+
+## Comparisons: GreaterThan, GreaterThanEqualTo, LessThan, LessThanEqualTo
+
+Each passes when `input value condition value` holds:
+
+| Type | Operator |
+| --- | --- |
+| `GreaterThan` | `>` |
+| `GreaterThanEqualTo` | `>=` |
+| `LessThan` | `<` |
+| `LessThanEqualTo` | `<=` |
+
+Supported operand combinations (both sides must be of a comparable type):
+
+- **numbers** with numbers — the usual numeric comparison; integers and floats mix freely (`1 <= 1.5`).
+- **booleans** with numbers — booleans act as 1 and 0 (`true >= 0` passes).
+- **strings** with strings — lexicographic, case-sensitive character-by-character comparison (`"b" > "a"` passes). Note this is *not* numeric: `"10" < "9"`.
+- **lists** with lists — element-by-element lexicographic comparison (`[1, 3] > [1, 2]` passes).
+
+Any other combination — a string against a number, `null` against anything — **returns false**, with the underlying comparison error as the message, for example:
+
+```
+'>' not supported between instances of 'str' and 'int'
+```
+
+This is a failed check (exit 3 under `--fail-on-error`), not a tool error. In particular, an input value of `null` can never pass a comparison.
+
+```json
+"condition": { "type": "LessThanEqualTo", "value": 100 }
+```
+
+| Input value | Result |
+| --- | --- |
+| `42` | passes |
+| `100` | passes |
+| `"42"` | fails — `'<=' not supported between instances of 'str' and 'int'` |
+| `null` | fails |
+
+## IsEmpty
+
+Passes when the input value is `null`, an empty string `""`, an empty list `[]`, or an empty dictionary `{}`. `condition.value` is ignored and may be omitted.
+
+Everything else is "not empty" — including `0` and `false`, which fail this check.
+
+```json
+"condition": { "type": "IsEmpty" }
+```
+
+| Input value | Result |
+| --- | --- |
+| `null` | passes |
+| `""`, `[]`, `{}` | passes |
+| `0` | fails |
+| `false` | fails |
+| `"x"` | fails |
+
+## IsNotEmpty
+
+Passes **only** when the input value is a non-empty string, a non-empty list, or a non-empty dictionary. `condition.value` is ignored and may be omitted.
+
+`IsNotEmpty` is **not** the negation of `IsEmpty`. Numbers and booleans are not strings, lists, or dictionaries, so they fail `IsNotEmpty` — even though they also fail `IsEmpty`. An input value of `5` fails both checks.
+
+```json
+"condition": { "type": "IsNotEmpty" }
+```
+
+| Input value | Result |
+| --- | --- |
+| `"x"`, `[1]`, `{"a": 1}` | passes |
+| `""`, `[]`, `{}`, `null` | fails |
+| `5` | fails (a number is neither empty nor "not empty") |
+| `true` | fails |
+
+## RegexMatch
+
+Passes when the regular expression in `condition.value` is found **anywhere** in the input value (search semantics, not full match). Anchor the pattern with `^` and `$` if you need it to match the whole string. Patterns use Python regular expression syntax and are case-sensitive.
+
+Input handling:
+
+- a **string** input is matched directly (multi-line strings included);
+- a **list** or **dictionary** input is first converted to its Python string form and the pattern is matched against that text. Note this form uses single quotes — `["a"]` becomes `['a']`, and `{"a": 2}` becomes `{'a': 2}` — not JSON.
+- **numbers, booleans, and `null` are never coerced**: the check returns false. An input value of `42` does not match the pattern `"4"`, and `true` does not match `"True"`.
+
+The pattern itself must be a string; a non-string `condition.value` returns false.
+
+An **invalid pattern** does not abort the run: the check returns false and the message carries the regex error, for example `unterminated character set at position 1`. Under `--fail-on-error` this exits 3, like any other failed check.
+
+```json
+"condition": { "type": "RegexMatch", "value": "^us-(east|west)-[12]$" }
+```
+
+| Input value | Result |
+| --- | --- |
+| `"us-east-1"` | passes |
+| `"eu-central-1"` | fails |
+| `42` (against pattern `"4"`) | fails — numbers are not coerced |
+
+## ContainedIn
+
+Asks: **is the input value inside `condition.value`?** The condition value is the container. Which check runs depends on the types of both sides:
+
+| Input value | Condition value | Check |
+| --- | --- | --- |
+| string | string | substring: passes if the input occurs anywhere in the condition value (`"amp"` is contained in `"example"`) |
+| scalar (string, number, boolean, `null`) | list | element membership: passes if the input equals one of the list's elements |
+| list | list | **element** membership, not subset: passes only if the whole input list is one *element* of the condition list. `["a", "b"]` is **not** contained in `["a", "b", "c"]`; `["a"]` *is* contained in `[["a"], ["b"]]`. Lists of scalars are sorted on both sides first, so element order does not matter (`[2, 1]` is found in `[[1, 2], [3]]`) |
+| dictionary | dictionary | subset: passes if **every** key of the input exists in the condition value with an equal value |
+| scalar | dictionary | key membership: passes if the input is one of the dictionary's keys |
+| anything else | number, boolean, or `null` — or a non-string input against a string | **unsupported**: returns false with the message `... is an unsupported data type for evaluating against value in 'condition.value'` |
+
+Two quirks to be aware of:
+
+- The common "is this value in the allowed list" use is the *scalar in list* row. If the provider hands you a **list** and you want to check that each element is allowed, extract the elements individually (for example with a `*` wildcard in `key_path`) rather than testing the list itself, which would be an element-membership test.
+- In the string-substring and key-in-dictionary forms, a *failing* check reports the message `Not evaluated` (with `passed: false`). The verdict is correct; only the message is unhelpful.
+
+```json
+"condition": { "type": "ContainedIn", "value": ["t3.micro", "t3.small"] }
+```
+
+| Input value | Result |
+| --- | --- |
+| `"t3.micro"` | passes |
+| `"m5.large"` | fails |
+| `["t3.micro"]` | fails — a list is checked as one element, and `["t3.micro"]` is not an element |
+
+## NotContainedIn
+
+Asks: **is the input value absent from `condition.value`?** Broadly the negation of [`ContainedIn`](#containedin), with the same type table — but with two deliberate differences:
+
+- **Dictionaries:** passes if **no** key of the input has an equal value in the condition value. Keys of the input that are absent from the condition value are ignored. This makes the pair asymmetric: with input `{"a": 1, "b": 2}` and condition value `{"a": 1}`, `ContainedIn` fails (key `b` is missing from the container) *and* `NotContainedIn` also fails (key `a` matches). Both directions can fail for the same pair.
+- **Unsupported types are still failures, not passes.** If the condition value is a number, boolean, or `null`, `NotContainedIn` returns false with the same "unsupported data type" message that `ContainedIn` produces. A check like `NotContainedIn: null` can never pass.
+
+```json
+"condition": { "type": "NotContainedIn", "value": ["0.0.0.0/0", "::/0"] }
+```
+
+| Input value | Result |
+| --- | --- |
+| `"10.0.0.0/16"` | passes |
+| `"0.0.0.0/0"` | fails |
+
+## Contains
+
+The mirror image of [`ContainedIn`](#containedin): asks **does the input value contain `condition.value`?** Here the *input* is the container:
+
+| Input value | Condition value | Check |
+| --- | --- | --- |
+| string | string | substring: passes if the condition value occurs anywhere in the input (`"hello world"` contains `"world"`) |
+| list | scalar | element membership |
+| list | list | **element** membership, not subset: `["a", "b", "c"]` does not contain `["a", "b"]`, but `[["a"], "b"]` contains `["a"]`. Lists of scalars are sorted on both sides first |
+| dictionary | dictionary | subset: passes if every key/value pair of the condition value exists in the input |
+| dictionary | scalar | key membership: passes if the condition value is one of the input's keys |
+| number, boolean, or `null` input | anything | **unsupported**: returns false with an "unsupported data type" message |
+
+An empty list or empty dictionary input contains nothing, so any search in it fails (except the degenerate `{}` contains `{}`, which passes). Unlike `ContainedIn`, failure messages here are always informative (`Failed to find ... inside ...`).
+
+The practical difference from `ContainedIn`: use `Contains` when the *extracted value* is the collection ("the tags attached to this resource must include X"); use `ContainedIn` when the *policy* holds the collection ("this value must be one of the allowed options").
+
+```json
+"condition": { "type": "Contains", "value": {"Environment": "production"} }
+```
+
+| Input value | Result |
+| --- | --- |
+| `{"Environment": "production", "Team": "core"}` | passes |
+| `{"Environment": "staging", "Team": "core"}` | fails |
+| `null` | fails — unsupported input type |
+
+## NotContains
+
+Asks: **does the input value *not* contain `condition.value`?** Broadly the negation of [`Contains`](#contains), with the same two departures the other `Not*` evaluator has:
+
+- **Dictionaries:** passes if **no** key/value pair of the condition value matches the input. Keys of the condition value that are absent from the input are ignored — `{"z": 1}` is "not contained" in `{"a": 1}` and the check passes.
+- **Unsupported input types are failures, not passes.** If the input value is a number, boolean, or `null`, `NotContains` returns false — it does not treat "cannot contain anything" as "does not contain it". An absent (`null`) value therefore fails *both* `Contains` and `NotContains`. (The failure message in this case quotes the condition value rather than the input value.)
+
+```json
+"condition": { "type": "NotContains", "value": "0.0.0.0/0" }
+```
+
+| Input value | Result |
+| --- | --- |
+| `["10.0.0.0/16", "192.168.0.0/24"]` | passes |
+| `["10.0.0.0/16", "0.0.0.0/0"]` | fails |
+| `null` | fails — unsupported input type |
+
+---
+
+## Worked example
+
+Policy (`policy.json`), using the `stackguardian/json` provider:
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json"
+ },
+ "evaluators": [
+ {
+ "id": "region_allowed",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "region"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": ["eu-central-1", "eu-west-1"]
+ }
+ },
+ {
+ "id": "instances_are_small",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "instances.*.count"
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 3
+ }
+ }
+ ],
+ "eval_expression": "region_allowed && instances_are_small"
+}
+```
+
+Input (`input.json`):
+
+```json
+{
+ "region": "eu-central-1",
+ "instances": [
+ { "name": "web", "count": 2 },
+ { "name": "worker", "count": 5 }
+ ]
+}
+```
+
+Run:
+
+```bash
+tirith -policy-path policy.json -input-path input.json
+```
+
+`region_allowed` passes. `instances_are_small` receives *two* input values from the wildcard (`2` and `5`); `2 <= 3` passes but `5 <= 3` fails, so the whole evaluator fails and `final_result` is `false`. With `--fail-on-error` the process exits with code 3.
+
+How the per-evaluator verdicts combine into `final_result` is defined by the policy's `eval_expression` — see [Evaluation Expressions](./eval-expressions.md).
diff --git a/documentation/docs/tirith-usage/ci-integration.md b/documentation/docs/tirith-usage/ci-integration.md
new file mode 100644
index 00000000..68cab83c
--- /dev/null
+++ b/documentation/docs/tirith-usage/ci-integration.md
@@ -0,0 +1,146 @@
+---
+id: ci-integration
+title: CI Integration
+sidebar_label: CI Integration
+description: Running Tirith in GitHub Actions via the action, and in GitLab CI or any container-based CI via the CLI directly.
+keywords:
+ - tirith
+ - ci
+ - github actions
+ - gitlab
+site_name: Tirith
+slug: ci-integration/
+---
+
+Tirith reads the plan your pipeline already produces — the output of
+`terraform show -json tfplan` — checks it against your policies, and exits non-zero so a violating
+change never reaches `apply`. The same policy files gate a GitHub Actions job, a GitLab job and a
+laptop.
+
+Two ways to run it in CI:
+
+- **GitHub Actions** — use the
+ [StackGuardian/tirith-iac-governance-action](https://github.com/StackGuardian/tirith-iac-governance-action),
+ which wraps the CLI and adds the GitHub-specific reporting.
+- **Everything else** — GitLab CI, or any CI that can run a container — invoke the CLI directly,
+ which is all the action does underneath.
+
+Either way, the job is gated by the [exit code](exit-codes.md): pass `--fail-on-error` (or the
+action's `fail-on-error` input) and a failing policy fails the job.
+
+## GitHub Actions
+
+The action finds the plan, evaluates the policies, posts a sticky pull-request comment, creates a
+check run and sets the job's exit code:
+
+```yaml
+permissions:
+ contents: read
+ pull-requests: write # sticky comment
+ checks: write # check run
+
+steps:
+ - run: |
+ terraform plan -out=tfplan -input=false
+ terraform show -json tfplan > plan.json
+
+ - uses: StackGuardian/tirith-iac-governance-action@v2
+```
+
+With a `plan.json` in the working directory that is the whole integration — no `with:` block. The
+action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy
+files committed under `.tirith/policies`, on the runner, talking to nothing. Add
+`with: { fail-on-error: true }` to make a failing policy fail the job.
+
+### Local mode and platform mode
+
+The action has two modes, chosen by whether credentials are present — there is no switch:
+
+- **Without credentials** (the default), policy files from your repository are evaluated on the
+ runner. Nothing is uploaded and no account is needed.
+- **With credentials**, the action evaluates the policies your StackGuardian organization enforces
+ instead, by way of `tirith platform check` — see [Platform Check](platform-check.md):
+
+```yaml
+env:
+ SG_API_TOKEN: ${{ secrets.SG_API_TOKEN }}
+ SG_ORG: ${{ vars.SG_ORG }}
+```
+
+Everything on the pull request is the same in both modes — the same comment, the same
+`Tirith IaC Governance` check run, the same outputs and exit codes.
+
+### Commonly used inputs
+
+Every input is optional. The full list, with the matrix/monorepo guidance and the outputs, is in
+the [action's own README](https://github.com/StackGuardian/tirith-iac-governance-action#readme).
+
+| Input | Default | |
+|---|---|---|
+| `policy-path` | `.tirith/policies` | Local mode only: a file, directory or glob of policy files |
+| `input-path` | `plan.json` / `tfplan.json` | Document to evaluate, found by convention |
+| `plan-file` | | Binary plan, rendered with `terraform show -json` in memory |
+| `input-kind` | `terraform_plan` | `terraform_plan`, `terraform_state`, `kubernetes`, `json` |
+| `fail-on-error` | `false` | Fail the job when a policy fails |
+| `sg-region` | `eu` | `eu` or `us`; platform mode only |
+| `source-dir` | `.` | Platform mode: the terraform source uploaded with the documents; `""` sends documents only |
+| `timeout` | `1800` | Platform mode: seconds to wait for the run |
+
+The action's exit behaviour follows the shared contract: `fail-on-error` governs policy verdicts,
+while a run that errored, an unreachable platform, or a job with no credentials *and* no policies
+is always red — a check that gated nothing must not report green.
+
+### Outputs
+
+The action exposes `verdict` (`passed` | `warned` | `failed` | `errored` | `no-policies`), `mode`
+(`platform` | `local`), the `passed` / `failed` / `warned` counts, the full result document as
+`results` and `results-file`, and — in platform mode — `wfrun-id` and `wfrun-url` linking to the
+run.
+
+## GitLab CI
+
+There is no GitLab-native equivalent of the action, so you invoke the CLI directly. Given an
+earlier job that saved `plan.json` as an artifact:
+
+```yaml
+policy:
+ image: python:3.12
+ needs: [plan]
+ script:
+ - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+ - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+Tirith is **not on PyPI** — `pip install tirith` installs an unrelated project of the same name.
+Install from git, and pin a tag rather than tracking the default branch so a CI job cannot change
+behaviour underneath you. `1.0.5` is the newest tag;
+`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them. Python 3.8 or newer.
+
+To evaluate your organization's policies instead of the committed files, swap the last line for
+`tirith platform check` and supply credentials as CI variables:
+
+```yaml
+policy:
+ image: python:3.12
+ needs: [plan]
+ variables:
+ SG_ORG: my-org # SG_API_TOKEN comes from a masked CI/CD variable
+ script:
+ - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+ - tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error
+```
+
+See [Platform Check](platform-check.md) for what that uploads and what it masks first.
+
+## Any container-based CI
+
+Nothing above is GitLab-specific: any runner that can execute a container and produce a plan works
+the same way. The recipe is always the same three steps —
+
+1. produce the input document (`terraform show -json tfplan > plan.json`);
+2. `pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"`;
+3. `tirith -policy-path -input-path plan.json --fail-on-error`
+
+— and gate the job on the exit code, which every CI system does by default for a non-zero exit.
+Use `--json` to capture the result document for a later step, and see [Exit codes](exit-codes.md)
+for telling a policy failure (`3`) apart from a tooling failure (`1`).
diff --git a/documentation/docs/tirith-usage/cli-reference.md b/documentation/docs/tirith-usage/cli-reference.md
new file mode 100644
index 00000000..b8a726c5
--- /dev/null
+++ b/documentation/docs/tirith-usage/cli-reference.md
@@ -0,0 +1,150 @@
+---
+id: cli-reference
+title: CLI Reference
+sidebar_label: CLI Reference
+description: Every flag of the tirith command, what it prints, and how --json and --verbose change the output.
+keywords:
+ - tirith
+ - cli
+site_name: Tirith
+slug: cli-reference/
+---
+
+The base `tirith` command evaluates a policy file against an input document, locally, on your own
+machine. Nothing is sent anywhere and no account is needed.
+
+```
+tirith -policy-path policy.json -input-path plan.json
+```
+
+Run with no arguments, `tirith` prints its help text and exits `0`.
+
+There is one subcommand, `tirith platform check`, which evaluates against the policies a
+StackGuardian organization enforces instead of local files. It has its own flags and its own page:
+[Platform Check](platform-check.md).
+
+## Flags
+
+Note the spelling: the path and variable options take a **single dash** (`-policy-path`, not
+`--policy-path`), while the output and behaviour switches take two.
+
+| Flag | Argument | What it does |
+|---|---|---|
+| `-policy-path` | `PATH` | Path to the Tirith policy file. Required. |
+| `-input-path` | `PATH` | Path to the document the policy is evaluated against. Required. |
+| `-var-path` | `PATH` | Path to a JSON file of policy variables. Repeatable. |
+| `-var` | `NAME=JSON` | One inline policy variable. Repeatable. |
+| `--json` | | Print only the result document as JSON on stdout. |
+| `--verbose` | | Show detailed (debug-level) logs from the run on stderr. |
+| `--fail-on-error` | | Exit `3` when a policy fails, instead of `0`. Off by default. |
+| `--version` | | Print the version and exit. |
+| `-h`, `--help` | | Print the help text and exit. |
+
+### `-policy-path`
+
+The policy file to evaluate — a JSON document with `meta`, `evaluators` and an `eval_expression`.
+See the [policy reference](../tirith-policies/tirith-policy-reference.md) for the schema, the
+[evaluators reference](../tirith-reference/evaluators.md) for the available condition types, and the
+[providers overview](../tirith-providers/overview.md) for what kinds of input each
+`required_provider` reads.
+
+If the flag is missing, `tirith` prints an error to stderr and exits `1`.
+
+### `-input-path`
+
+The document to evaluate: a terraform plan in JSON form (`terraform show -json tfplan`), a
+Kubernetes manifest, an Infracost breakdown, or any JSON document — whatever the policy's provider
+expects. Files ending in `.yaml` or `.yml` are parsed as YAML; a multi-document YAML file is read
+as a list of documents. Everything else is parsed as JSON.
+
+If the flag is missing, `tirith` prints an error to stderr and exits `1`.
+
+### `-var-path` and `-var`
+
+A policy can be parameterized with `{{ var.name }}` placeholders. These two flags supply the
+values:
+
+```
+tirith -policy-path policy.json -input-path plan.json \
+ -var-path common-vars.json -var 'max_cost=100'
+```
+
+- `-var-path` names a JSON file whose top-level keys are variable names. The flag may be repeated;
+ files are merged in order, and a later file overrides an earlier one for the same key.
+- `-var` supplies a single variable inline as `name=value`, where `value` is parsed as JSON — so
+ `-var 'max_cost=100'` is a number, `-var 'region="eu-central-1"'` is a string, and
+ `-var 'allowed=["a","b"]'` is a list. Inline variables are applied after all files, so they
+ override them. A value that is not valid JSON is reported as an error and the variable is not
+ set.
+
+If the policy references a variable that none of these supplied, evaluation does not run at all:
+the result carries only an `errors` entry (`Variables not found: ...`), and with
+`--fail-on-error` the exit code is `1` — the tool could not evaluate, which is different from a
+policy failing.
+
+### `--json`
+
+Prints the result document, and nothing else, to stdout — all logging is disabled, so the output
+can be piped straight into `jq` or another program:
+
+```
+tirith -policy-path policy.json -input-path plan.json --json | jq .final_result
+```
+
+The document has this shape:
+
+```json
+{
+ "meta": { "version": "v1", "required_provider": "stackguardian/json" },
+ "final_result": true,
+ "evaluators": [
+ {
+ "id": "check1",
+ "passed": true,
+ "result": [ { "passed": true, "message": "1 is equal to 1", "meta": null } ],
+ "description": null
+ }
+ ],
+ "errors": [],
+ "eval_expression": "check1"
+}
+```
+
+`final_result` is tri-state: `true` when every check that ran passed, `false` when a check ran and
+failed, and `null` when every check was skipped (see
+[error tolerance](../tirith-policies/tirith-policy-error-tolerance.md)) — the policy then evaluated
+nothing. Each evaluator's `passed` is tri-state in the same way. If evaluation throws an
+unexpected error under `--json`, the command prints an empty `{}` and exits `1`.
+
+The exit code does not change under `--json`; combine it with `--fail-on-error` to gate on the
+verdict while still capturing the document.
+
+### `--verbose`
+
+Without it, the run prints the pretty-printed per-check results on stdout and only messages at
+INFO level and above on stderr, formatted as `[LEVEL] message`. With `--verbose`, stderr carries
+debug-level logs in a long format that includes the timestamp, process id and source location —
+useful when a policy is not matching what you expect and you want to see each evaluator being
+processed.
+
+`--verbose` has no effect together with `--json`, which disables logging entirely.
+
+### `--fail-on-error`
+
+By default the command exits `0` whenever it completed the evaluation, whether the policy passed
+or failed — the verdict is in the output. `--fail-on-error` turns the exit code into a gate: `3`
+when a policy failed, `1` when nothing could be evaluated, `0` only when every check that ran
+passed. This is the flag that makes the command usable as a CI gate; the full contract is on the
+[exit codes](exit-codes.md) page.
+
+### `--version`
+
+Prints the version number (for example `1.2.0`) and exits `0`.
+
+## Output streams
+
+- **stdout** carries the result: the pretty-printed report by default, or the JSON document under
+ `--json`.
+- **stderr** carries logs and error messages.
+
+This split is deliberate so that redirecting stdout captures only the verdict.
diff --git a/documentation/docs/tirith-usage/exit-codes.md b/documentation/docs/tirith-usage/exit-codes.md
new file mode 100644
index 00000000..cac5ad99
--- /dev/null
+++ b/documentation/docs/tirith-usage/exit-codes.md
@@ -0,0 +1,93 @@
+---
+id: exit-codes
+title: Exit Codes
+sidebar_label: Exit Codes
+description: The complete Tirith exit-code contract, and how to gate a CI job on it.
+keywords:
+ - tirith
+ - exit codes
+ - ci
+site_name: Tirith
+slug: exit-codes/
+---
+
+Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and
+platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one
+vocabulary.
+
+| Code | Meaning |
+|---|---|
+| `0` | Policies passed, or nothing was in scope to gate on |
+| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API |
+| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) |
+| `3` | A policy failed. Only with `--fail-on-error`, on either surface |
+| `130` | Interrupted (Ctrl-C) |
+
+## `3` is deliberately not `1`
+
+`3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could
+not tell you either way — an unparseable policy file, an unresolved `{{ var.x }}` variable, an
+unreachable API, or a policy whose every check was skipped. A job that treats every non-zero code
+alike reports an outage as a policy violation, and cannot tell a working gate from a broken one.
+Keeping the two codes distinct lets a pipeline page the platform team on `1` and the change author
+on `3`.
+
+Both surfaces **fail closed**: anything that leaves the verdict unknown exits non-zero regardless
+of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no
+verdict must never look like a pass.
+
+## Without `--fail-on-error`
+
+The local command exits `0` whether the policy passed or failed, with the verdict in the output.
+That is how it has always behaved, and it is left alone so that upgrading Tirith cannot turn a
+passing pipeline red; the gate is opt-in. `tirith platform check` behaves the same way: without
+the flag a policy failure logs a message and still exits `0`, and the verdict is in
+`--output-json`.
+
+Errors are different: a missing input file, an unparseable policy or an unresolved variable exits
+`1` even without the flag.
+
+## What each local outcome produces
+
+Under `--fail-on-error`, the exit code is decided by the result's tri-state `final_result`:
+
+| `final_result` | Meaning | Exit |
+|---|---|---|
+| `true` | every check that ran passed | `0` |
+| `false` | a check ran and failed | `3` |
+| `null` | every check was skipped — the policy evaluated nothing | `1` |
+| absent | the policy could not be evaluated at all (for example an unresolved variable) | `1` |
+
+`null` is not a pass. A policy whose every check was skipped — an
+[`error_tolerance`](../tirith-policies/tirith-policy-error-tolerance.md) swallowing a provider that
+found nothing — checked precisely nothing, and reporting that as green is exactly what the flag
+exists to prevent. It is not a violation either, so it is `1` rather than `3`.
+
+**One limit worth stating plainly:** a *misconfigured* policy — an unsupported `condition.type`,
+an unknown `required_provider` — comes back from the engine as an ordinary failed check with no
+error attached, so it is indistinguishable from a real violation and exits `3`. It fails closed,
+which is the safe direction, but it will point at your infrastructure when the fault is in the
+policy.
+
+## Gating a CI job
+
+Most CI systems fail a job on any non-zero exit, so the minimal gate is one line:
+
+```sh
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+To act differently on "policy failed" versus "Tirith broke", branch on the code:
+
+```sh
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error --json > result.json
+code=$?
+case "$code" in
+ 0) echo "policies passed" ;;
+ 3) echo "a policy failed — see result.json" ; exit 1 ;;
+ *) echo "Tirith could not evaluate (exit $code) — this is a tooling problem, not a verdict" ; exit "$code" ;;
+esac
+```
+
+The same pattern works for `tirith platform check` unchanged — the codes mean the same things.
+Complete CI examples are on the [CI integration](ci-integration.md) page.
diff --git a/documentation/docs/tirith-usage/platform-check.md b/documentation/docs/tirith-usage/platform-check.md
new file mode 100644
index 00000000..34b9cf69
--- /dev/null
+++ b/documentation/docs/tirith-usage/platform-check.md
@@ -0,0 +1,204 @@
+---
+id: platform-check
+title: Platform Check
+sidebar_label: Platform Check
+description: The tirith platform check subcommand — every flag, what it uploads, what it masks on your machine first, and what it reports back.
+keywords:
+ - tirith
+ - platform check
+ - stackguardian
+site_name: Tirith
+slug: platform-check/
+---
+
+`tirith platform check` evaluates a terraform plan, state document or cost breakdown against the
+policies your **StackGuardian organization** enforces, from any CI system or from a laptop —
+instead of policy files committed to your repository. Policy then lives in one place rather than
+being copied into every repository that needs gating.
+
+This is the one part of Tirith that talks to a network and needs an account. Plain
+`tirith` — local evaluation — needs neither; see the [CLI reference](cli-reference.md).
+
+```sh
+export SG_API_TOKEN=sgo_... # an organization token
+export SG_ORG=my-org
+
+tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error
+```
+
+`--input-path` is optional when a `plan.json` or `tfplan.json` is in the working directory.
+
+On GitHub, prefer the
+[GitHub Action](https://github.com/StackGuardian/tirith-iac-governance-action), which is a thin
+wrapper around this command and adds the pull-request comment and check run — see
+[CI integration](ci-integration.md). Use this command directly anywhere else: GitLab CI, a
+Makefile, a local shell.
+
+## What it does
+
+1. **Masks the document on your machine**, before anything is uploaded (details below).
+2. **Packs** the masked documents together with your terraform source into a `tar.gz`, excluding
+ `.git`, `.terraform`, `*.tfstate*` and anything matched by `.gitignore`. `--no-source` sends
+ documents only. An oversized source tree degrades to documents-only with a warning rather than
+ failing the check.
+3. **Uploads** the archive to the workflow's artifact directory and creates a StackGuardian
+ workflow run. The workflow and its group are created on first use.
+4. **Polls** the run until it finishes and prints the verdict — optionally also as JSON and
+ markdown files for a later CI step.
+
+## What it masks
+
+Masking happens client-side, on your machine, before anything leaves it. Masked values are
+replaced with the sentinel `__SG_REDACTED__`.
+
+For a **terraform plan** (`--input-kind terraform_plan`, the default):
+
+- Every value terraform marked sensitive (`before_sensitive` / `after_sensitive`) is masked, in
+ `resource_changes`, `resource_drift` and `output_changes` alike.
+- Root `variables` are dropped wholesale — the plan does not reliably mark which were declared
+ `sensitive`, so the only safe assumption is that all of them might be.
+- `prior_state` is dropped, and terraform's own `planned_values` — which mirrors every value with
+ no sensitivity markers — is dropped and **rebuilt from the already-masked** `resource_changes`,
+ so tools that read that section still work without the leak.
+- Credential-bearing literals in `configuration` (provider blocks, resource expressions, module
+ arguments, variable defaults) are scrubbed while the reference graph policies read is kept.
+- Finally, any string terraform marked sensitive *somewhere* is masked *everywhere* in the
+ document — catching provider-computed mirrors such as `tags_all` that carry the same plaintext
+ without a marker of their own.
+
+For a **terraform state document** (`--input-kind terraform_state`, or `--state-path`): outputs
+marked `sensitive` and every attribute named in an instance's `sensitive_attributes` are masked,
+and the same everywhere-sweep is applied. Both shapes are handled — raw `terraform state pull`
+output and `terraform show -json` output.
+
+Two limits, stated plainly:
+
+- **`json` and `kubernetes` documents are not masked** — there is no schema that says which fields
+ are secret. A document that looks like terraform state but is sent with the wrong `--input-kind`
+ triggers a warning, because that is the mistake that would ship every attribute in plaintext.
+- **Committed source ships as written.** Masking applies to the documents, not to your repository:
+ a secret hardcoded in a `.tf` file reaches the platform even though the plan was masked.
+ `--no-source` is the opt-out. Terraform's `*_sensitive` markers are also not exhaustive — a
+ value that flows through `locals`, or comes from a provider that did not mark its schema, is not
+ caught by marker-driven masking.
+
+The number of masked values is printed before upload, and recorded in the bundle's metadata.
+
+## What it uploads
+
+One `tar.gz` archive per run, in the workflow's artifact directory, with a fixed layout:
+
+```
+plan.json the masked terraform plan
+tfstate.json the masked state, if one was supplied
+infracost.json the cost breakdown, if one was supplied
+metadata.json what this bundle is: origin, repository, commit, masking, workflow identity
+code/ the terraform source, if any was packed
+```
+
+The archive is retained after the run — it is the source that produced the findings, and other
+systems read it to see the code a verdict came from. When a state document is supplied, the masked
+copy is additionally published as the workflow's `tfstate.json` artifact so it appears in the
+platform's State view; that copy is masked and cannot be used to run terraform. The full
+`metadata.json` field reference is in
+[docs/platform-check.md](https://github.com/StackGuardian/tirith/blob/main/docs/platform-check.md)
+in the repository.
+
+## What it reports back
+
+- **Progress and the verdict headline go to stderr**, so stdout stays clean for machine-readable
+ output: the masking count, the upload, a link to the created run, each poll of the run's status,
+ and finally a one-line headline such as `Tirith — 3 failed, 1 warned`.
+- **`--output-json`** writes the result document: the run `status`, the `verdict`
+ (`passed` | `warned` | `failed` | `no-policies` | `errored`), per-outcome `counts` (passed,
+ failed, warned, approval_required, skipped, unknown), the `headline`, `wfrun_id` and `wfrun_url`
+ linking to the run, the full `policy_results`, the `monthly_cost` when a cost breakdown was
+ evaluated, and where the uploaded archive lives (`archive_key`, `source_packed`,
+ `source_skipped_reason`). It is written once with `status: RUNNING` as soon as the run is
+ created — so a timeout still leaves the run discoverable — and again with the final result.
+- **`--output-markdown`** writes a rendered report, suitable for posting as a pull-request or
+ merge-request comment by a later CI step. `--comment-marker` sets an opaque first line so your
+ script can find and update its own previous comment, and `--markdown-limit` truncates the body
+ (default 60000 characters).
+- **The exit code** follows the shared [contract](exit-codes.md): `0` passed, `1` the check could
+ not be completed, `3` a policy failed (only with `--fail-on-error`), `130` interrupted. A run
+ that produced no verdict — errored, unreachable, unreadable results — always exits non-zero
+ regardless of `--fail-on-error`: it fails closed. A policy that asks for approval is reported as
+ a warning and does not block, because the evaluation has already finished by the time the intent
+ is known.
+
+## Credentials
+
+`--api-key` / `$SG_API_TOKEN` and `--org` / `$SG_ORG` are required. The key should be an
+**organization** (`sgo_`) token — `sgu_` user tokens are non-functional for SSO-group-only users
+and are warned about rather than rejected, so the symptom is a later 403.
+
+`--api-key -` reads the key from stdin, which keeps it out of the process table and out of shell
+history:
+
+```sh
+echo "$SG_TOKEN" | tirith platform check --api-key - --workflow-id infra
+```
+
+## Flag reference
+
+### Identity
+
+| Flag | Default | What it does |
+|---|---|---|
+| `--api-key` | `$SG_API_TOKEN` | API key, or `-` to read it from stdin |
+| `--org` | `$SG_ORG` | Organization name |
+| `--region` | `$SG_REGION` or `eu` | StackGuardian region, `eu` or `us`. Sets both the API and dashboard URLs at once |
+| `--api-url` | `$SG_BASE_URL` | API base URL, with or without `/api/v1`. Overrides `--region`; needed only for a self-hosted install or a dedicated host |
+| `--dashboard-url` | `$SG_DASHBOARD_URL` | Dashboard base URL, used to build run links. Inferred from `--api-url` when it names a known region |
+
+`--region` and an explicit URL cannot be combined — they set the same thing, and silently picking
+one would hide the contradiction.
+
+### Workflow
+
+| Flag | Default | What it does |
+|---|---|---|
+| `--workflow-id` | *(required)* | Slug identifying the StackGuardian workflow. Created if absent. Letters, digits, `-` and `_` only; anything else is rejected with a suggested slug |
+| `--workflow-group` | `default` | Workflow group. Created if absent — note that policies are scoped per group, so a typo silently enforces nothing |
+| `--terraform-version` | | Stored on the workflow at creation |
+| `--repo-url` | | Source repository URL, recorded on the workflow at creation so it links back to the code. Any credential embedded in the URL is stripped before it is recorded |
+| `--repo-ref` | | Branch, tag or commit, recorded alongside `--repo-url` |
+| `--repo-path` | inferred | Path of `--source-dir` within the repository, recorded in the bundle's `metadata.json`. Inferred from the enclosing git checkout if omitted |
+| `--step-template-id` | platform default | Override the policy-evaluation step template |
+
+Runs on one workflow serialize while another is pending — a matrix that shares an id becomes a
+queue, so give each leg its own.
+
+### Inputs
+
+| Flag | Default | What it does |
+|---|---|---|
+| `--input-path` | `plan.json` / `tfplan.json` in `--source-dir` | Document to evaluate |
+| `--plan-file` | | Binary plan from `terraform plan -out=`. Rendered with `terraform show -json` in memory, so no unmasked plan JSON is ever written to disk. Cannot be combined with `--input-path` |
+| `--terraform-bin` | auto-detected | terraform/tofu binary for `--plan-file`, preferring the real binary over a CI wrapper |
+| `--input-kind` | `terraform_plan` | One of `terraform_plan`, `terraform_state`, `kubernetes`, `json`. Decides how the document is masked |
+| `--state-path` | | Optional terraform state, masked before upload |
+| `--infracost-path` | | Optional `infracost breakdown --format json` document |
+| `--source-dir` | `.` | Terraform source to pack alongside the documents |
+| `--no-source` | | Send only the documents. Discovery still looks in `--source-dir` (or `.`) for the plan |
+
+### Run
+
+| Flag | Default | What it does |
+|---|---|---|
+| `--sha` | | Commit SHA, used to namespace the uploaded archive |
+| `--artifact-tag` | `default` | Namespaces the archive within a commit. Needed only when one workflow evaluates the same commit more than once — a plan phase and a state phase, or matrix legs sharing a workflow |
+| `--trigger-details-json` | `{"type": "cli"}` | JSON object describing what triggered this run |
+| `--trigger-details-file` | | File containing that JSON object |
+| `--timeout` | `1800` | Seconds to wait for the run |
+
+### Output
+
+| Flag | Default | What it does |
+|---|---|---|
+| `--output-json` | | Write the result document here |
+| `--output-markdown` | | Write a markdown report here |
+| `--comment-marker` | | Opaque first line of the markdown, so a script can find its own comment |
+| `--markdown-limit` | `60000` | Truncate the markdown to this length |
+| `--fail-on-error` | off | Exit non-zero when a policy fails. An unreachable platform or a run that produced no verdict always exits non-zero regardless of this flag |
diff --git a/documentation/docusaurus.config.js b/documentation/docusaurus.config.js
index 980a086b..afa84dc2 100644
--- a/documentation/docusaurus.config.js
+++ b/documentation/docusaurus.config.js
@@ -5,10 +5,10 @@ const config = {
title: 'Tirith',
favicon: 'img/tirith.png',
// Set the production url of your site here
- url: 'https://your-docusaurus-site.example.com',
- // Set the // pathname under which your site is served
- // For GitHub pages deployment, it is often '//'
- baseUrl: '/',
+ url: 'https://stackguardian.github.io',
+ // Set the // pathname under which your site is served.
+ // This is a GitHub Pages project site, so it is served under //.
+ baseUrl: '/tirith/',
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
@@ -45,11 +45,12 @@ const config = {
navbar: {
title: 'Tirith',
hideOnScroll: true,
+ // No href: the logo and title link to the site home, which is what a
+ // reader clicking a site's own logo expects. It used to open the policy
+ // builder in a new tab, which left no way back to the docs home.
logo: {
alt: 'Tirith Logo',
src: 'img/tirith.png',
- href: 'https://tirith-policy-builder.vercel.app/',
- target:'_blank',
},
items: [
{
@@ -58,6 +59,11 @@ const config = {
position: 'left',
label: 'Docs',
},
+ {
+ href: 'https://tirith-policy-builder.vercel.app/',
+ label: 'Policy Builder',
+ position: 'right',
+ },
{
href: 'https://github.com/StackGuardian/tirith',
label: 'GitHub',
diff --git a/documentation/sidebars.js b/documentation/sidebars.js
index 752eb712..5638a35c 100644
--- a/documentation/sidebars.js
+++ b/documentation/sidebars.js
@@ -15,6 +15,17 @@ module.exports = {
'tirith-installation/manual-installation'
]
},
+ {
+ type: "category",
+ collapsed: true,
+ label: "Using Tirith",
+ items: [
+ "tirith-usage/cli-reference",
+ "tirith-usage/exit-codes",
+ "tirith-usage/ci-integration",
+ "tirith-usage/platform-check",
+ ]
+ },
{
type: "category",
collapsed: true,
@@ -22,11 +33,35 @@ module.exports = {
items: [
"tirith-policies/tirith-create-first-policy",
"tirith-policies/tirith-policy-structure",
+ "tirith-policies/tirith-policy-reference",
"tirith-policies/tirith-policy-error-tolerance",
"tirith-policies/tirith-policy-conditions",
"tirith-policies/tirith-policy-variables",
+ "tirith-policies/tirith-policy-cookbook",
// "tirith-policies/tirith-policy-examples"
]
},
+ {
+ type: "category",
+ collapsed: true,
+ label: "Providers",
+ items: [
+ "tirith-providers/providers-overview",
+ "tirith-providers/terraform-plan-provider",
+ "tirith-providers/infracost-provider",
+ "tirith-providers/json-provider",
+ "tirith-providers/kubernetes-provider",
+ "tirith-providers/sg-workflow-provider",
+ ]
+ },
+ {
+ type: "category",
+ collapsed: true,
+ label: "Reference",
+ items: [
+ "tirith-reference/evaluators",
+ "tirith-reference/eval-expressions",
+ ]
+ },
],
-};
\ No newline at end of file
+};
diff --git a/documentation/src/components/HomepageFeatures/index.js b/documentation/src/components/HomepageFeatures/index.js
deleted file mode 100644
index 5488d344..00000000
--- a/documentation/src/components/HomepageFeatures/index.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import clsx from 'clsx';
-import Heading from '@theme/Heading';
-import styles from './styles.module.css';
-
-const FeatureList = [
- // {
- // title: 'Easy to Use',
- // Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
- // description: (
- // <>
- // Docusaurus was designed from the ground up to be easily installed and
- // used to get your website up and running quickly.
- // >
- // ),
- // },
- // {
- // title: 'Focus on What Matters',
- // Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
- // description: (
- // <>
- // Docusaurus lets you focus on your docs, and we'll do the chores. Go
- // ahead and move your docs into the docs directory.
- // >
- // ),
- // },
- // {
- // title: 'Powered by React',
- // Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
- // description: (
- // <>
- // Extend or customize your website layout by reusing React. Docusaurus can
- // be extended while reusing the same header and footer.
- // >
- // ),
- // },
-];
-
-function Feature({title, description}) {
- return (
-
-
- );
-}
diff --git a/documentation/src/components/HomepageFeatures/styles.module.css b/documentation/src/components/HomepageFeatures/styles.module.css
deleted file mode 100644
index b248eb2e..00000000
--- a/documentation/src/components/HomepageFeatures/styles.module.css
+++ /dev/null
@@ -1,11 +0,0 @@
-.features {
- display: flex;
- align-items: center;
- padding: 2rem 0;
- width: 100%;
-}
-
-.featureSvg {
- height: 200px;
- width: 200px;
-}
diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js
index 378ab87d..4cdf434b 100644
--- a/documentation/src/pages/index.js
+++ b/documentation/src/pages/index.js
@@ -1,32 +1,250 @@
-import clsx from 'clsx';
import Link from '@docusaurus/Link';
-import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
-import HomepageFeatures from '@site/src/components/HomepageFeatures';
-
import Heading from '@theme/Heading';
+import CodeBlock from '@theme/CodeBlock';
+
import styles from './index.module.css';
-function HomepageHeader() {
- const {siteConfig} = useDocusaurusContext();
+/*
+ * ---------------------------------------------------------------------------
+ * COPY
+ *
+ * All prose for the landing page lives in this one object, deliberately kept
+ * apart from the markup below so it can be edited or lifted out without
+ * reading any JSX.
+ *
+ * It is derived from the repository README, which is the source of truth. If
+ * the two disagree, the README wins and this file is stale.
+ * ---------------------------------------------------------------------------
+ */
+const content = {
+ hero: {
+ title: 'Tirith — IaC Governance plugin',
+ tagline:
+ 'Plugin IaC Governance for any pipeline, running anywhere. Evaluate plans with Tirith, ' +
+ 'protect sensitive values, enforce centralised governance, and surface actionable results ' +
+ 'before infrastructure changes are applied.',
+ body:
+ 'Tirith reads the plan your pipeline already produces, checks it against your policies, and ' +
+ 'exits non-zero so a violating change never reaches apply. Apache-2.0, and no account needed.',
+ install: 'pip install git+https://github.com/StackGuardian/tirith.git',
+ actions: [
+ {label: 'Get started', to: '/docs/getting-started-with-tirith/', primary: true},
+ {label: 'GitHub', href: 'https://github.com/StackGuardian/tirith'},
+ ],
+ },
+
+ problem: {
+ heading: 'The problem',
+ body:
+ 'A pipeline that runs init, plan and apply deploys whatever the plan says. Nothing sits ' +
+ 'between the plan and the change.',
+ points: [
+ 'Every repository does it its own way, so there is no one place to see what was deployed, or what was refused.',
+ 'Rules that do exist live in whichever pipeline someone wrote them into, and get copied into the next repository by hand.',
+ 'When a check does fail, the log says a job failed. It does not say which rule, on which resource, or what value broke it.',
+ ],
+ },
+
+ add: {
+ heading: 'What you add',
+ body: 'Two lines, on GitHub Actions:',
+ code:
+ '- run: terraform show -json tfplan > plan.json\n' +
+ '- uses: StackGuardian/tirith-iac-governance-action@v2',
+ note:
+ 'With a plan.json in the working directory that is the whole integration — no with: block. ' +
+ 'Policies are JSON files committed under .tirith/policies.',
+ },
+
+ get: {
+ heading: 'What you get',
+ items: [
+ {
+ title: 'Policies as data, not code',
+ body:
+ 'A rule is a JSON file describing what to look for, rather than a program you have to ' +
+ 'maintain. Terraform plans, terraform state, Kubernetes manifests, Infracost breakdowns ' +
+ 'and arbitrary JSON are all evaluated the same way.',
+ },
+ {
+ title: 'Cost, before the change is applied',
+ body:
+ 'Point Tirith at an infracost breakdown and gate on the monthly or hourly total of the ' +
+ 'resources the plan would create.',
+ },
+ {
+ title: 'Sensitive values masked on your own runner',
+ body:
+ 'Masking happens before anything leaves the machine, so a value marked sensitive stays ' +
+ 'out of the report and out of any upload.',
+ },
+ {
+ title: 'An exit code your pipeline can act on',
+ body:
+ 'Exit 3 means a policy said no; exit 1 means Tirith could not tell you either way. A job ' +
+ 'that treats every non-zero code alike cannot tell a working gate from a broken one.',
+ },
+ {
+ title: 'The plan and the code, kept together',
+ body:
+ 'In platform mode each run uploads the masked documents alongside the terraform source ' +
+ 'they describe, so a finding can still be read against the code that caused it later on.',
+ },
+ {
+ title: 'One policy set, many pipelines',
+ body:
+ 'Because Tirith is a CLI rather than an integration built into one CI system, the same ' +
+ 'policies gate a GitHub Actions job, a GitLab job and a laptop. In platform mode, Tirith ' +
+ 'rules and Checkov findings come back in a single verdict.',
+ },
+ ],
+ },
+
+ worksWith: {
+ heading: 'Works with',
+ items: [
+ {
+ title: 'GitHub Actions',
+ body:
+ 'A native action that finds the plan, posts a sticky pull-request comment, creates a ' +
+ 'check run and sets the exit code.',
+ link: {
+ label: 'tirith-iac-governance-action',
+ href: 'https://github.com/StackGuardian/tirith-iac-governance-action',
+ },
+ },
+ {
+ title: 'GitLab CI, and any container-based CI',
+ body:
+ 'Install the CLI in the job and call it directly, which is all the action does ' +
+ 'underneath. There is no GitLab-native equivalent of the action.',
+ },
+ {
+ title: 'Your machine',
+ body: 'The same command, the same verdict, no account and no network.',
+ },
+ ],
+ },
+
+ platform: {
+ heading: 'Keeping policy in one place',
+ body:
+ 'Everything above works with policy files committed to your repository. If you would rather ' +
+ 'not copy those files into every repository that needs gating, tirith platform check ' +
+ 'evaluates against the policies a StackGuardian organization enforces instead — same ' +
+ 'document, same verdict, same exit codes, plus a central run history. That mode is optional, ' +
+ 'and is the only part that talks to a network.',
+ link: {label: 'Read about platform mode', to: '/docs/tirith-usage/platform-check/'},
+ },
+};
+
+/*
+ * ---------------------------------------------------------------------------
+ * MARKUP
+ * ---------------------------------------------------------------------------
+ */
+
+// Uses Docusaurus's own button classes rather than hand-rolled ones: they carry
+// a readable foreground in both light and dark mode. A custom rule here had set
+// the label to var(--ifm-background-color), which is #0000 in light mode -- so
+// the text was transparent on a purple fill.
+function Action({label, to, href, primary}) {
+ const className = `button button--lg ${primary ? 'button--primary' : 'button--secondary'}`;
+ return to ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ );
+}
+
+function Hero() {
+ const {title, tagline, body, install, actions} = content.hero;
return (
-
+
);
diff --git a/documentation/src/pages/index.module.css b/documentation/src/pages/index.module.css
index 9f71a5da..6154747a 100644
--- a/documentation/src/pages/index.module.css
+++ b/documentation/src/pages/index.module.css
@@ -1,23 +1,64 @@
/**
- * CSS files with the .module.css suffix will be treated as CSS modules
- * and scoped locally.
+ * Landing page styles. Kept deliberately small: this page is a placeholder, and
+ * it should not grow a design system that the real rebuild would have to undo.
+ *
+ * Colours come from Docusaurus theme variables so light and dark mode both work
+ * without a second palette being defined here.
*/
-.heroBanner {
- padding: 4rem 0;
- text-align: center;
- position: relative;
- overflow: hidden;
+.page {
+ max-width: 46rem;
+ margin: 0 auto;
+ padding: 3rem 1.25rem 5rem;
}
-@media screen and (max-width: 996px) {
- .heroBanner {
- padding: 2rem;
- }
+.hero {
+ margin-bottom: 1rem;
+}
+
+.heroTitle {
+ font-size: 2.25rem;
+ letter-spacing: -0.02em;
+ margin-bottom: 0.75rem;
+}
+
+.tagline {
+ font-size: 1.15rem;
+ margin-bottom: 1rem;
+}
+
+.muted {
+ color: var(--ifm-color-emphasis-700);
+}
+
+.section {
+ margin-top: 3rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--ifm-color-emphasis-300);
}
-.buttons {
+.sectionHeading {
+ font-size: 1.25rem;
+ margin-bottom: 0.75rem;
+}
+
+.list li {
+ margin-bottom: 0.75rem;
+}
+
+.actions {
display: flex;
- align-items: center;
- justify-content: center;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+}
+
+@media screen and (max-width: 996px) {
+ .page {
+ padding: 2rem 1rem 3rem;
+ }
+
+ .heroTitle {
+ font-size: 1.75rem;
+ }
}
diff --git a/setup.py b/setup.py
index 7d07cb9a..667e0b5a 100644
--- a/setup.py
+++ b/setup.py
@@ -22,7 +22,7 @@ def read(*names, **kwargs):
setup(
name="py-tirith",
- version="1.0.5",
+ version="1.2.0",
license="Apache",
description="Tirith simplifies defining Policy as Code.",
long_description_content_type="text/markdown",
diff --git a/src/tirith/__init__.py b/src/tirith/__init__.py
index 151dee52..4c2aac77 100644
--- a/src/tirith/__init__.py
+++ b/src/tirith/__init__.py
@@ -2,6 +2,6 @@
tirith: Execute policies defined using Tirith (StackGuardian Policy Framework)
"""
-__version__ = "1.0.5"
+__version__ = "1.2.0"
__author__ = "StackGuardian"
__license__ = "Apache"
diff --git a/src/tirith/cli.py b/src/tirith/cli.py
index 6642e312..f08c85e8 100755
--- a/src/tirith/cli.py
+++ b/src/tirith/cli.py
@@ -15,7 +15,6 @@
from .core import start_policy_evaluation
-
logger = logging.getLogger(__name__)
@@ -27,6 +26,23 @@ def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
+# Subcommands are dispatched before the flat parser sees anything. argparse cannot express an
+# optional subcommand alongside options like `-policy-path` (a single dash and a long name), and the
+# local-evaluation surface is a contract: tests/core/test_output_compatibility.py asserts its --json
+# output is byte-identical to a golden file. An explicit pre-dispatch leaves that untouched.
+#
+# Named `platform` because that is what it evaluates against: the policies your StackGuardian
+# organization enforces, run on the platform, rather than policy files in your repository.
+#
+# It was briefly `remote` on this branch, on the argument that "platform check" can read as *a check of
+# the platform*. Reverted -- the vagueness is minor next to having one name, and the concern that
+# prompted the rename was really that the open-source surface could not gate at all, which
+# `--fail-on-error` fixed. No alias in either direction: nothing is released, so there is no caller to
+# keep working.
+SUBCOMMAND = "platform"
+SUBCOMMANDS = {SUBCOMMAND}
+
+
def main(args=None) -> ExitStatus:
"""
The main function.
@@ -36,6 +52,13 @@ def main(args=None) -> ExitStatus:
Return exit status code.
"""
+ argv = list(sys.argv[1:] if args is None else args)
+
+ if argv and argv[0] in SUBCOMMANDS:
+ from tirith.platform import cli as platform_cli
+
+ return platform_cli.main(argv)
+
try:
class _WidthFormatter(argparse.RawTextHelpFormatter):
@@ -47,6 +70,11 @@ def __init__(self, prog="PROG") -> None:
formatter_class=_WidthFormatter,
epilog=textwrap.dedent(
"""\
+ Subcommands:
+
+ tirith platform check --help Evaluate against the policies your StackGuardian
+ organization enforces, rather than local files.
+
About Tirith:
* Abstract away the implementation complexity of policy engine underneath.
@@ -54,7 +82,7 @@ def __init__(self, prog="PROG") -> None:
* Provide a standard framework for scanning various configurations with granularity.
* Provide modularity to enable easy extensibility
* Github - https://github.com/StackGuardian/tirith
- * Docs - https://docs.stackguardian.io/docs/tirith/overview
+ * Docs - https://github.com/StackGuardian/tirith#readme
"""
),
)
@@ -102,11 +130,17 @@ def __init__(self, prog="PROG") -> None:
action="store_true",
help="Show detailed logs of from the run",
)
+ parser.add_argument(
+ "--fail-on-error",
+ dest="failOnError",
+ action="store_true",
+ help="Exit 3 when a policy fails, instead of 0. Off by default for compatibility.",
+ )
parser.add_argument("--version", action="version", version=__version__)
- args = parser.parse_args()
+ args = parser.parse_args(argv)
- if len(sys.argv) == 1:
+ if not argv:
parser.print_help()
sys.exit(0)
@@ -135,6 +169,49 @@ def __init__(self, prog="PROG") -> None:
print(formatted_result)
else:
pretty_print_result_dict(result)
+
+ # Without --fail-on-error this returns 0 whether the policy passed or failed, which is
+ # what it has always done: the verdict is in the output, and changing that silently would
+ # turn every existing green CI job red on upgrade.
+ #
+ # But a gate that cannot fail is not a gate, and this was the only way to run tirith
+ # without an account -- so the honest answer was an opt-in flag rather than pointing
+ # people at the hosted path when they need an exit code that means something.
+ #
+ # 3, not 1, and the distinction is the point: 3 says the infrastructure violates a policy,
+ # 1 says tirith could not tell you. The same split `platform check` uses, because a caller
+ # scripting both should not have to learn two vocabularies.
+ #
+ # `final_result` is tri-state, and that is what decides:
+ #
+ # True every check that ran passed -> 0
+ # False a check ran and said no -> 3
+ # None nothing ran; every check was skipped -> 1
+ # absent the policy could not be loaded at all -> 1
+ #
+ # None is not a pass. A policy whose every check was skipped -- `error_tolerance` swallowing
+ # a provider that found nothing -- checked precisely nothing, and reporting that as green is
+ # the failure this whole flag exists to prevent. `absent` is the missing-variables path,
+ # which returns `errors` and no result at all.
+ #
+ # `errors` is deliberately NOT consulted. It reads like a tool-failure signal and is not: it
+ # is populated only by the eval-expression pass, and the one thing that puts a message there
+ # beside a real verdict is the informational "these ids are not defined and have been
+ # removed" note. Gating on it inverted both halves of this contract -- a genuine violation
+ # whose expression mentioned a typo'd id exited 1, while a policy naming an unknown provider
+ # exited 3.
+ #
+ # Known limit, worth stating rather than pretending otherwise: a *misconfigured* policy -- an
+ # unsupported `condition.type`, an unknown `required_provider` -- surfaces from the engine as
+ # an ordinary failed evaluator with no error attached, so it is indistinguishable from a
+ # violation here and exits 3. Fixing that means the engine reporting it distinctly, not this
+ # branch guessing from free text.
+ if args.failOnError:
+ final_result = result.get("final_result")
+ if "final_result" not in result or final_result is None:
+ return ExitStatus.ERROR
+ if final_result is not True:
+ return ExitStatus.ERROR_POLICY_FAILED
return ExitStatus.SUCCESS
except Exception as e:
# TODO:write an exception class for all provider exceptions.
diff --git a/src/tirith/core/core.py b/src/tirith/core/core.py
index 27c60646..0dfedaa5 100644
--- a/src/tirith/core/core.py
+++ b/src/tirith/core/core.py
@@ -12,7 +12,6 @@
from .evaluators import EVALUATORS_DICT
from .policy_parameterization import get_policy_with_vars_replaced
-
logger = logging.getLogger(__name__)
@@ -50,6 +49,10 @@ def generate_evaluator_result(evaluator_obj, input_data, provider_module):
evaluator_class = EVALUATORS_DICT.get(evaluator_name)
if evaluator_class is None:
logger.error(f"{evaluator_name} is not a supported evaluator")
+ # Always populate "result" before returning. Consumers (the pretty printer, the
+ # workflow-step templates, the platform) index into it unconditionally, and an
+ # early return without it used to raise KeyError far away from the real cause.
+ result["result"] = [{"passed": False, "message": f"`{evaluator_name}` is not a supported evaluator"}]
return result
evaluator_instance = evaluator_class()
@@ -66,6 +69,17 @@ def generate_evaluator_result(evaluator_obj, input_data, provider_module):
has_valid_evaluation = False
for evaluator_input in evaluator_inputs:
+ # A provider reported an error without attaching a ProviderError severity. That means a
+ # malformed provider call -- an unsupported operation_type, a missing required argument --
+ # not a policy violation. Surface the message and fail hard: error_tolerance exists to
+ # tolerate missing data, never to mask a broken policy. Without this branch the error text
+ # is discarded and `None` is evaluated against the condition, so a typo'd operation_type
+ # reads as a genuine violation.
+ if evaluator_input.get("err") and not isinstance(evaluator_input["value"], ProviderError):
+ evaluation_results.append({"passed": False, "message": evaluator_input["err"]})
+ has_evaluation_passed = False
+ continue
+
if isinstance(evaluator_input["value"], ProviderError) and evaluator_input.get("err", None):
severity_value = evaluator_input["value"].severity_value
err_result = dict(message=evaluator_input["err"])
@@ -139,6 +153,18 @@ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
tree = ast.parse(eval_str, mode="eval")
+ # `&` and `|` parse as BinOp, which nothing below handles: the tree stays uncompilable, the retry
+ # loop exhausts, and the caller reports "Could not evaluate the eval expression. Please report this
+ # error" -- telling a user to file a bug against their own typo. The README documented `&` in two
+ # examples, so this was reachable by copying the docs. Name the operator instead.
+ for node in ast.walk(tree):
+ if isinstance(node, ast.BinOp):
+ operators = {ast.BitAnd: ("&", "&&"), ast.BitOr: ("|", "||")}
+ wrong, right = operators.get(type(node.op), (None, None))
+ if wrong:
+ raise ValueError(f"Unsupported operator '{wrong}' in eval_expression. Use '{right}' instead.")
+ raise ValueError("Unsupported operator in eval_expression. Only '&&', '||' and '!' are supported.")
+
compiled_code = None
tries_count = 0
is_tree_compilable = False
@@ -302,8 +328,16 @@ def start_policy_evaluation_from_dict(policy_dict: Dict, input_dict: Dict, var_d
eval_results.append(eval_result)
final_evaluation_result, errors = final_evaluator(final_evaluation_policy_string, eval_results_obj)
+ # Pass policy-declared metadata through to the result, but only the keys that are actually
+ # present. Absent keys are omitted rather than emitted as null, so the output of a policy
+ # that declares none of them is byte-identical to what it was before this was added.
+ final_output_meta = {"version": policy_meta.get("version"), "required_provider": provider_module}
+ for meta_key in ("id", "name", "description", "severity", "enforcement", "tags", "remediation"):
+ if meta_key in policy_meta:
+ final_output_meta[meta_key] = policy_meta[meta_key]
+
final_output = {
- "meta": {"version": policy_meta.get("version"), "required_provider": provider_module},
+ "meta": final_output_meta,
"final_result": final_evaluation_result,
"evaluators": eval_results,
"errors": errors,
diff --git a/src/tirith/core/policy_parameterization.py b/src/tirith/core/policy_parameterization.py
index ce81dafe..c34092af 100644
--- a/src/tirith/core/policy_parameterization.py
+++ b/src/tirith/core/policy_parameterization.py
@@ -1,3 +1,4 @@
+import copy
import re
import pydash
@@ -52,11 +53,17 @@ def get_policy_with_vars_replaced(policy_dict: dict, var_dict: dict) -> Tuple[di
"""
Replace the variables in the policy_dict with the values from the var_dict
+ The caller's `policy_dict` is never mutated: substitution happens on a deep copy. This
+ matters when the same parsed policy is evaluated more than once (for example a policy set
+ run against several inputs, or a retry), where substituted values would otherwise leak
+ from one evaluation into the next.
+
:param policy_dict: The policy dictionary
:param var_dict: The dictionary containing the variables
- :return: The policy dictionary with the variables replaced
+ :return: A copy of the policy dictionary with the variables replaced
and the list of variables that are not found
"""
+ policy_dict = copy.deepcopy(policy_dict)
not_found_vars = []
# Replace vars in the meta key
_replace_vars_in_dict(policy_dict["meta"], var_dict, not_found_vars)
diff --git a/src/tirith/platform/__init__.py b/src/tirith/platform/__init__.py
new file mode 100644
index 00000000..ae9467ba
--- /dev/null
+++ b/src/tirith/platform/__init__.py
@@ -0,0 +1,6 @@
+"""
+StackGuardian platform integration.
+
+Everything here is stdlib-only on purpose: tirith has three runtime dependencies and none of them
+are an HTTP library, so a CI runner needs nothing installed beyond tirith itself.
+"""
diff --git a/src/tirith/platform/archive.py b/src/tirith/platform/archive.py
new file mode 100644
index 00000000..e832328a
--- /dev/null
+++ b/src/tirith/platform/archive.py
@@ -0,0 +1,372 @@
+"""
+Build the gzipped tar that carries a run's inputs to StackGuardian.
+
+The step unpacks this and reads the documents out of it, so the layout is a contract:
+
+ plan.json terraform plan JSON -- the primary policy input
+ tfstate.json terraform state JSON
+ infracost.json cost breakdown
+ metadata.json what this bundle is: repository, commit, where the code belongs
+ code/ the terraform source, if any was packed
+
+**The documents stay at the root.** The step joins those three names onto the extraction directory and
+treats absence as normal -- so moving one under a prefix would not raise, it would make every policy
+report "unevaluated" and the run would look like it passed with warnings.
+
+**`code/` is a prefix, not a directory member.** Nothing writes an explicit directory entry, so the
+prefix exists in the tar only while at least one file carries it. `metadata.json` says so rather than
+leaving a consumer to infer it from an absence.
+
+Two things here are easy to get wrong and expensive to get wrong.
+
+**The masked documents go in, never the originals.** `pack()` takes already-redacted objects and
+serializes them itself; it never copies plan.json off disk. A caller that packed the source
+directory *first* and masked afterwards would ship the plaintext file alongside the masked one. The
+tests assert on the bytes inside the resulting tarball for this reason -- asserting on the dict
+that was passed in would pass while the archive leaked.
+
+**`.terraform/` must be excluded.** A provider cache is routinely hundreds of megabytes; including
+it would make every run upload the AWS provider. `*.tfstate*` is excluded for the same reason as
+the first point: an unmasked state file sitting in the working directory would otherwise travel
+next to the masked copy.
+"""
+
+import fnmatch
+import io
+import os
+import posixpath
+import tarfile
+
+# Fixed names the step looks for at the archive root.
+PLAN_DOCUMENT = "plan.json"
+STATE_DOCUMENT = "tfstate.json"
+INFRACOST_DOCUMENT = "infracost.json"
+
+# What this bundle is, for whatever reads it later. Written at the root beside the documents.
+METADATA_DOCUMENT = "metadata.json"
+
+# The source tree lives under here, so the root belongs to us alone.
+CODE_PREFIX = "code"
+
+# These names are ALWAYS written by pack(), never copied from the source tree -- whether or not a
+# masked document was supplied for them.
+#
+# This is a LEAK guard, not a collision guard, and the distinction matters now that the source sits
+# under `code/` where it cannot collide with anything. A file called tfstate.json in the working
+# directory is raw, unmasked state by definition -- `terraform state pull > state.json` is the
+# documented way to make one -- so packing it as `code/tfstate.json` would ship every attribute in
+# plaintext next to the masked copy. Nothing about the prefix makes that safe; see the note in pack().
+#
+# metadata.json is here for a plainer reason: it is a thoroughly ordinary filename for a repository to
+# contain, tar tolerates duplicate members, and extraction order would decide which one won.
+RESERVED_DOCUMENTS = frozenset((PLAN_DOCUMENT, STATE_DOCUMENT, INFRACOST_DOCUMENT, METADATA_DOCUMENT))
+
+# Always excluded, regardless of .gitignore.
+#
+# .terraform/ provider binaries and modules; hundreds of MB, and the runner does its own init
+# .git/ full history, so anything ever committed would ship
+# *.tfstate* raw state -- unmasked by definition, including .backup files
+# tfplan / *.tfplan the BINARY plan. It embeds the prior state, so it carries every attribute of
+# every existing resource in plaintext -- strictly worse than a raw state file,
+# and it matches none of the *.tfstate patterns. `--plan-file` reads it, converts
+# it and masks the result in memory, which the source walk then undid by packing
+# the original.
+# .terraform.lock.hcl is deliberately NOT excluded: it pins provider versions and is small.
+DEFAULT_EXCLUDES = (
+ ".git",
+ ".terraform",
+ "*.tfstate",
+ "*.tfstate.*",
+ "tfplan",
+ "*.tfplan",
+ "*.tfplan.*",
+ "__pycache__",
+ "*.pyc",
+ ".venv",
+ "node_modules",
+)
+
+# Refuse to build anything larger than this. A runaway archive is nearly always an exclusion that
+# did not fire, and failing loudly beats a five-minute upload that times out the run.
+#
+# Overridable, because the source tree is packed by default and the only other lever is dropping it
+# entirely: a large monorepo that genuinely needs to ship its code has nowhere else to go. Raising it
+# trades a clear error for a slow upload and more memory on the runner -- the whole archive is built
+# in memory before this is checked -- so it is deliberately not a documented headline.
+MAX_ARCHIVE_BYTES = 100 * 1024 * 1024
+
+_override = os.environ.get("TIRITH_MAX_ARCHIVE_BYTES", "").strip()
+if _override:
+ try:
+ MAX_ARCHIVE_BYTES = int(_override)
+ except ValueError:
+ # Not worth failing a run over; the default is a safe answer.
+ pass
+
+
+class ArchiveError(Exception):
+ """The archive could not be built."""
+
+
+def _human_bytes(count):
+ """
+ A size a person can read.
+
+ Integer MB division reported anything under a megabyte as "0 MB", which is what the size limit
+ message used to say -- and that message is now surfaced on a pull request, where "0 MB over the
+ 0 MB limit" tells the reader nothing.
+ """
+ for unit, size in (("MB", 1024 * 1024), ("KB", 1024)):
+ if count >= size:
+ return f"{count / size:.1f} {unit}"
+ return f"{count} bytes"
+
+
+def _load_gitignore_patterns(source_dir):
+ """
+ Read .gitignore into fnmatch patterns.
+
+ Deliberately simple: leading `/` and trailing `/` are stripped, negations (`!`) are ignored.
+ A full gitignore implementation is not worth it here -- DEFAULT_EXCLUDES covers the cases that
+ actually matter, and .gitignore is a convenience on top.
+ """
+ path = os.path.join(source_dir, ".gitignore")
+ patterns = []
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or line.startswith("!"):
+ continue
+ patterns.append(line.strip("/"))
+ except OSError:
+ return []
+ return patterns
+
+
+def _is_excluded(relative_path, name, patterns):
+ """Match a path against the exclusion patterns, by both basename and full relative path."""
+ for pattern in patterns:
+ if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(relative_path, pattern):
+ return True
+ # A directory pattern excludes everything beneath it.
+ if relative_path.startswith(pattern + os.sep):
+ return True
+ return False
+
+
+def pack(
+ source_dir,
+ plan=None,
+ state=None,
+ infracost=None,
+ extra_excludes=(),
+ respect_gitignore=True,
+ document_sources=(),
+ metadata=None,
+):
+ """
+ Build the archive in memory and return its bytes.
+
+ `plan`, `state` and `infracost` are already-redacted objects. They are serialized here and
+ written at the archive root, overriding any same-named file in `source_dir` -- so a stale
+ plan.json lying around cannot displace the masked one.
+
+ `metadata` is the caller's half of `metadata.json`: what it intended. This function fills in the
+ half only it can observe -- whether a tree was actually walked, under what prefix, and how many
+ files went in or were skipped -- and writes the member last. The split is deliberate: a bundle
+ that claims code but packed nothing is detectable only because the count is produced here rather
+ than asserted by the caller.
+
+ `document_sources` are the paths those objects were *read from*. They are excluded from the
+ source walk, because the file on disk is the unmasked original: masking `tfplan.json` and then
+ packing the source tree shipped the plaintext copy one filename away from the redacted one.
+ Reserving only the three names this function writes was not enough -- the input is routinely
+ called something else (`tfplan.json`, `state.json`, or the binary `tfplan`, which carries the
+ prior state inside it).
+
+ Returns (archive_bytes, manifest) where manifest lists what went in, for logging.
+ """
+ if source_dir and not os.path.isdir(source_dir):
+ raise ArchiveError(f"Source directory does not exist: {source_dir}")
+
+ patterns = list(DEFAULT_EXCLUDES) + list(extra_excludes)
+ if respect_gitignore and source_dir:
+ patterns += _load_gitignore_patterns(source_dir)
+
+ documents = {}
+ if plan is not None:
+ documents[PLAN_DOCUMENT] = plan
+ if state is not None:
+ documents[STATE_DOCUMENT] = state
+ if infracost is not None:
+ documents[INFRACOST_DOCUMENT] = infracost
+
+ buffer = io.BytesIO()
+ manifest = {"documents": sorted(documents), "files": 0, "skipped": 0}
+
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
+ if source_dir:
+ # RESERVED_DOCUMENTS, not just the ones being written. A file named tfstate.json in the
+ # working directory is unmasked by definition -- `terraform state pull > state.json` is
+ # the documented way to produce one -- so packing it would ship every attribute in
+ # plaintext beside the masked copy. If the caller wants it evaluated they pass
+ # --state-path, which masks it first.
+ #
+ # Plus whatever the documents were actually read from, which is usually named something
+ # else entirely.
+ reserved = set(RESERVED_DOCUMENTS) | _relative_sources(source_dir, document_sources)
+ manifest["files"], manifest["skipped"] = _add_tree(tar, source_dir, patterns, reserved)
+ for name, document in documents.items():
+ _add_document(tar, name, document)
+ if metadata is not None:
+ _add_document(tar, METADATA_DOCUMENT, _observed_metadata(metadata, source_dir, manifest))
+
+ archive = buffer.getvalue()
+ if len(archive) > MAX_ARCHIVE_BYTES:
+ raise ArchiveError(
+ f"Archive is {_human_bytes(len(archive))}, over the {_human_bytes(MAX_ARCHIVE_BYTES)} "
+ "limit. This usually means a large directory was not excluded -- check for provider "
+ "caches or build output, and pass extra excludes if needed."
+ )
+
+ manifest["bytes"] = len(archive)
+ return archive, manifest
+
+
+def _observed_metadata(metadata, source_dir, manifest):
+ """
+ Overlay what this module observed onto the caller's metadata, without mutating it.
+
+ `code.present` is `files > 0`, not "a source directory was requested". Nothing writes an explicit
+ directory member, so a tree where every file was excluded leaves no `code/` in the tar at all --
+ and a consumer comparing the two must not find them disagreeing. `present` therefore means
+ literally "there are members under the prefix".
+
+ The caller's `code.absent_reason` survives when it has one (it knows *why* it asked for no source);
+ a tree that was requested and vanished into the exclude list gets one from here, because the caller
+ cannot know that happened.
+ """
+ code = dict(metadata.get("code") or {})
+ files = manifest.get("files", 0)
+ present = bool(source_dir) and files > 0
+
+ code["present"] = present
+ code["prefix"] = f"{CODE_PREFIX}/" if present else None
+ code["files"] = files
+ code["skipped"] = manifest.get("skipped", 0)
+ if not present:
+ code["repo_path"] = None
+ code["repo_path_from"] = None
+ if not code.get("absent_reason"):
+ code["absent_reason"] = "empty_after_excludes" if source_dir else "not_requested"
+
+ merged = dict(metadata)
+ merged["code"] = code
+ merged["documents"] = {
+ "plan": PLAN_DOCUMENT if PLAN_DOCUMENT in manifest.get("documents", ()) else None,
+ "state": STATE_DOCUMENT if STATE_DOCUMENT in manifest.get("documents", ()) else None,
+ "infracost": INFRACOST_DOCUMENT if INFRACOST_DOCUMENT in manifest.get("documents", ()) else None,
+ }
+ return merged
+
+
+def _add_tree(tar, source_dir, patterns, reserved_names, prefix=CODE_PREFIX):
+ """
+ Walk `source_dir`, adding everything not excluded under `prefix`. Returns (added, skipped).
+
+ Member names are built with `posixpath`, not `os.path`: tar names are `/`-separated on every
+ platform, and joining with the OS separator would emit backslashes on Windows -- extracting to
+ literal one-segment filenames with backslashes in them.
+ """
+ added = 0
+ skipped = 0
+
+ for root, dirs, files in os.walk(source_dir):
+ relative_root = os.path.relpath(root, source_dir)
+ relative_root = "" if relative_root == "." else relative_root
+
+ # Prune in place so os.walk does not descend into excluded directories at all -- the point
+ # of excluding .terraform is not to read it.
+ kept_dirs = []
+ for d in dirs:
+ relative = os.path.join(relative_root, d) if relative_root else d
+ if _is_excluded(relative, d, patterns):
+ skipped += 1
+ elif os.path.islink(os.path.join(root, d)):
+ # os.walk does not follow symlinked directories, so this one contributes nothing --
+ # count it rather than letting a whole subtree disappear without appearing anywhere in
+ # the manifest. Same reasoning as the file-level islink guard below.
+ skipped += 1
+ else:
+ kept_dirs.append(d)
+ dirs[:] = kept_dirs
+
+ for name in files:
+ relative = os.path.join(relative_root, name) if relative_root else name
+ if _is_excluded(relative, name, patterns):
+ skipped += 1
+ continue
+ # Reserved names are skipped on the path they have in the SOURCE tree, before the prefix
+ # is applied. `code/tfstate.json` could not displace the masked root copy, but it would
+ # still be unmasked state inside the bundle -- which is the actual reason for this skip.
+ if relative in reserved_names:
+ skipped += 1
+ continue
+ full = os.path.join(root, name)
+ if os.path.islink(full):
+ # A symlink out of the tree would either break on extraction or smuggle a file in.
+ skipped += 1
+ continue
+ if not os.path.isfile(full):
+ # Sockets, fifos and device nodes. `tar.add` does not raise for a type it cannot
+ # classify -- it debug-logs "Unsupported type" and returns -- so counting the attempt
+ # made `added` disagree with what the tar actually holds, and `code.present` could
+ # then be true with nothing under the prefix at all.
+ skipped += 1
+ continue
+ try:
+ tar.add(full, arcname=posixpath.join(prefix, relative.replace(os.sep, "/")))
+ added += 1
+ except OSError:
+ skipped += 1
+
+ return added, skipped
+
+
+def _relative_sources(source_dir, document_sources):
+ """
+ The document source paths, expressed the way _add_tree names members, for exclusion.
+
+ Anything outside `source_dir` is dropped rather than kept as an unanchored basename: it cannot
+ collide with a member name, and excluding a bare basename would silently drop an unrelated
+ same-named file from the archive.
+ """
+ relative = set()
+ try:
+ root = os.path.realpath(source_dir)
+ except OSError:
+ return relative
+
+ for path in document_sources or ():
+ if not path:
+ continue
+ try:
+ full = os.path.realpath(path)
+ rel = os.path.relpath(full, root)
+ except (OSError, ValueError):
+ continue
+ if rel != os.pardir and not rel.startswith(os.pardir + os.sep) and not os.path.isabs(rel):
+ relative.add(rel)
+ return relative
+
+
+def _add_document(tar, name, document):
+ """Serialize one document straight into the tar, never via a file on disk."""
+ import json
+
+ payload = document if isinstance(document, bytes) else json.dumps(document).encode("utf-8")
+ info = tarfile.TarInfo(name=name)
+ info.size = len(payload)
+ info.mode = 0o644
+ tar.addfile(info, io.BytesIO(payload))
diff --git a/src/tirith/platform/check.py b/src/tirith/platform/check.py
new file mode 100644
index 00000000..643bf627
--- /dev/null
+++ b/src/tirith/platform/check.py
@@ -0,0 +1,718 @@
+"""
+Orchestration for `tirith platform check`.
+
+ read -> mask -> pack -> ensure workflow -> upload archive -> create run -> poll -> fetch -> report
+
+The masking is the part that matters most and it happens *here*, on the caller's machine, before
+anything leaves it. Masking server-side would be theatre: once the bytes arrive the exposure has
+already happened.
+"""
+
+import datetime
+import json
+import os
+import posixpath
+import sys
+import urllib.parse
+
+from .. import __version__
+from . import archive, redact, report
+from .client import ARCHIVE_DOCUMENT, ARCHIVE_NAME_TEMPLATE, SGClient, SGError
+
+# The version of the metadata.json contract. One integer, bumped only when a change breaks a reader;
+# added fields do not bump it. A consumer seeing a higher number should read what it recognises and
+# refuse to act destructively -- in particular, it must not write files back using `code.repo_path`
+# from a schema it does not understand.
+METADATA_SCHEMA_VERSION = 1
+
+# Hosts we can name with confidence. Anything else is reported as `unknown` with the raw host
+# alongside, because a self-hosted GitLab at git.example.internal is unrecognisable by design and
+# guessing "github" for it would be worse than admitting ignorance.
+_KNOWN_VCS_HOSTS = {
+ "github.com": "github",
+ "gitlab.com": "gitlab",
+ "bitbucket.org": "bitbucket",
+ "dev.azure.com": "azure_devops",
+ "ssh.dev.azure.com": "azure_devops",
+}
+
+DEFAULT_WORKFLOW_GROUP = "default"
+DEFAULT_TERRAFORM_VERSION = "1.5.7"
+
+# What the CLI understands as an input document. `terraform_state` exists as a distinct kind from
+# `json` purely so this side knows to mask it -- tirith itself has no state provider, and the step
+# routes it to the json provider.
+INPUT_KINDS = ("terraform_plan", "terraform_state", "kubernetes", "json")
+
+# The bundle's name lives in client.ARCHIVE_NAME_TEMPLATE, and the reasoning is worth keeping here
+# because it inverted twice while this was built.
+#
+# It began as `__sg.{sha}-{tag}.tar.gz`. The `__sg.` prefix deliberately kept it OUT of the artifact
+# sync, because that prefix is pulled into every run's working directory and pushed back with no
+# --delete. Once the sync became the *delivery* mechanism -- the step reads the bundle out of
+# $LOCAL_ARTIFACTS_DIR -- being excluded from it was exactly wrong, so the name must match none of the
+# sync's exclude patterns (`sg.*`, `*__sg.*`, `*pci_*`, the compliance globs) and must not be
+# `tfstate.json`.
+#
+# The sha stays, though, and it is load-bearing. A name shared by every run of the workflow is a name
+# two concurrent runs can overwrite -- and the action derives one workflow id per repository, so two
+# open pull requests is the ordinary case, not a corner. One run would then evaluate the other's code
+# and report the verdict as its own, silently, on a merge gate. Per commit, that cannot happen.
+#
+# It is affordable because the name is per *run*, not per workflow: core merges the run's
+# TerraformConfig over the workflow's, so each run names its own bundle in its own
+# `prePlanWfStepsConfig`. The workflow's stored copy is only a fallback.
+#
+# The cost is growth -- bundles accumulate in a prefix with no lifecycle rule, no --delete on either
+# sync, and no artifact DELETE in api, so every later run downloads all of them. Taken deliberately:
+# correctness over transfer cost. `client.delete_artifact` is kept for a retention sweep to use.
+
+# Deliberately NOT `__sg.`-prefixed, unlike the archive. This one is meant to be seen: it is the name
+# the platform already treats as a workflow's state document, so it lands in the State and artifacts
+# views rather than being hidden from them. The name is shared with the copy inside the archive
+# (`archive.STATE_DOCUMENT`).
+STATE_DOCUMENT_NAME = "tfstate.json"
+STATE_CONTENT_TYPE = "application/json"
+
+
+class CheckError(Exception):
+ """The check could not be completed. Always fails closed."""
+
+
+def log(message):
+ """Progress goes to stderr so stdout stays clean for machine-readable output."""
+ print(message, file=sys.stderr, flush=True)
+
+
+def read_json(path, label):
+ if not os.path.exists(path):
+ raise CheckError(f"{label} not found: {path}")
+ try:
+ with open(path, "r") as f:
+ return json.load(f)
+ except json.JSONDecodeError as e:
+ raise CheckError(f"{label} is not valid JSON ({path}): {e}")
+ except OSError as e:
+ raise CheckError(f"Could not read {label} ({path}): {e}")
+
+
+def prepare_documents(input_path, input_kind, state_path, infracost_path, input_document=None):
+ """
+ Read and mask everything that will go into the archive.
+
+ Returns (plan, state, infracost, redaction_count). The returned objects are the *masked* ones;
+ nothing downstream should ever touch the originals again.
+
+ `input_document` is an already-parsed document, used by --plan-file so `terraform show -json`
+ output goes straight from the pipe into the masker without an unmasked plan ever being written
+ to disk.
+ """
+ plan = None
+ state = None
+ redactions = 0
+
+ if input_document is not None or input_path:
+ document = input_document if input_document is not None else read_json(input_path, "input document")
+ if input_kind == "terraform_plan":
+ plan = redact.redact_plan(document)
+ redactions += redact.count_redactions(plan)
+ elif input_kind == "terraform_state":
+ state = redact.redact_state(document)
+ redactions += redact.count_redactions(state)
+ else:
+ # kubernetes / json: no marker structure to drive masking, so it goes as-is. Warn if it
+ # looks like state, because that is the mistake that would ship every attribute in
+ # plaintext.
+ if isinstance(document, dict) and {"version", "lineage", "resources"} <= set(document):
+ log(
+ "WARNING: this document looks like terraform state but --input-kind is "
+ f"'{input_kind}', so it will NOT be masked. Use --input-kind terraform_state."
+ )
+ plan = document
+
+ if state_path:
+ state_document = read_json(state_path, "state document")
+ masked_state = redact.redact_state(state_document)
+ redactions += redact.count_redactions(masked_state)
+ if state is None:
+ state = masked_state
+ else:
+ log("Both --input-path and --state-path are state documents; using --input-path")
+
+ infracost = read_json(infracost_path, "cost breakdown") if infracost_path else None
+
+ return plan, state, infracost, redactions
+
+
+# The step template that evaluates the policies, and the name its run stage takes.
+POLICY_STEP_TEMPLATE = "/stackguardian/tirith-iac-governance:1"
+# Names the run stage, so it surfaces as `on_0_tirith-iac-governance` in the dashboard and in
+# every status key. Matches the step template's own name rather than describing the action, so a
+# reader seeing the stage knows which template produced it.
+POLICY_STEP_NAME = "tirith-iac-governance"
+POLICY_STEP_TIMEOUT = 1800
+
+
+def policy_step(step_template_id, bundle_path):
+ """
+ The pre-plan step entry, naming the bundle this run should evaluate.
+
+ Sent in full on every run rather than relying on the copy stored on the workflow. core merges the
+ run's TerraformConfig over the workflow's (`workflowruns/__init__.py:1646`), and that merge is
+ shallow -- supplying `prePlanWfStepsConfig` replaces the whole list -- so the entry has to carry
+ its template id and timeout too, not just the path.
+ """
+ return {
+ "name": POLICY_STEP_NAME,
+ "wfStepTemplateId": step_template_id or POLICY_STEP_TEMPLATE,
+ "timeout": POLICY_STEP_TIMEOUT,
+ "approval": False,
+ # Everything the step needs travels here. It reads nothing from the workflow's terraform
+ # configuration.
+ "wfStepInputData": {
+ "schemaType": "FORM_JSONSCHEMA",
+ "data": {
+ "bundlePath": bundle_path,
+ # Passed through so the step knows whether it may write the masked state to
+ # `artifacts/tfstate.json`. For a managed-state workflow that object *is* the live
+ # state, and a masked copy over it would be data loss. Always false here, because
+ # terraform_config below sets it false -- sent explicitly rather than relying on the
+ # step's default, so the intent is visible on every run.
+ "managedTerraformState": False,
+ },
+ },
+ }
+
+
+def terraform_config(terraform_version, step_template_id):
+ """
+ The workflow's stored configuration, carrying the policy step as a PRE-PLAN step.
+
+ This is the whole mechanism, and it uses only primitives the platform already had. core splices
+ `prePlanWfStepsConfig` ahead of `generate-terraform-plan`, and a step exiting 12 tells the run
+ controller to complete the run successfully and skip everything after it. So the policy step runs,
+ exits 12, and the terraform plan never happens -- without core knowing anything about this feature.
+
+ That is why the run's TerraformAction is `plan`: a dummy value, never acted on, chosen because it
+ is the action whose synthesis splices pre-plan steps in.
+
+ `managedTerraformState` stays False -- a policy check writes no state, and it must not take the
+ managed-state backend override even on a workflow configured for one.
+
+ Deliberately carries no "input kind". The step routes on which document is present in the
+ archive, because a stored kind cannot be trusted: a two-phase pipeline gates the plan and then
+ checks the state against the SAME workflow, whose identity derives from the repository and
+ workflow name. The workflow is created once, by whichever phase ran first, so the stored kind was
+ that phase's and the other phase fed its document to a provider that cannot read it.
+
+ The `bundlePath` stored here is only a fallback. This configuration is written once, at workflow
+ creation -- `ensure_workflow` returns 409 for an existing workflow and updates nothing -- so it
+ cannot describe any particular run. Every run therefore sends its own `prePlanWfStepsConfig` in the
+ run body, which core merges over this one, naming that run's bundle.
+ """
+ config = {
+ "terraformVersion": terraform_version or DEFAULT_TERRAFORM_VERSION,
+ "managedTerraformState": False,
+ "prePlanWfStepsConfig": [policy_step(step_template_id, ARCHIVE_DOCUMENT)],
+ }
+ return config
+
+
+def write_output_json(path, payload):
+ if not path:
+ return
+ try:
+ with open(path, "w") as f:
+ json.dump(payload, f, indent=2)
+ except OSError as e:
+ log(f"WARNING: could not write {path}: {e}")
+
+
+def _split_repo_url(repo_url):
+ """
+ Return (sanitized_url, host) for a repo URL, or (None, None).
+
+ **Strips userinfo.** `https://x-access-token:ghs_abc@github.com/acme/infra` is an ordinary value
+ for a CI checkout to hold, and GitLab's own `CI_REPOSITORY_URL` embeds a job token the same way.
+ Writing that into a file that ships inside the bundle would persist a credential in an artifact
+ that outlives the run. Sanitizing here rather than at the call site because it is the kind of thing
+ a later caller would forget.
+
+ Handles scp syntax (`git@github.com:acme/infra.git`), which `urlsplit` reads as a path with no
+ host at all.
+ """
+ if not repo_url:
+ return None, None
+
+ text = repo_url.strip()
+ if "://" not in text and "@" in text:
+ # scp-style (`git@host:path`, and the `host/path` spelling a scheme-less CI variable produces).
+ # Rewritten to a URL shape so the host is recoverable, and so the userinfo is discarded rather
+ # than carried along.
+ _userinfo, _, remainder = text.rpartition("@")
+ host, separator, path = remainder.partition(":")
+ if not separator:
+ host, _, path = remainder.partition("/")
+ host = host.lower()
+ return (f"ssh://{host}/{path.lstrip('/')}", host) if host else (None, None)
+
+ parts = urllib.parse.urlsplit(text)
+ try:
+ host = (parts.hostname or "").lower() or None
+ port = parts.port
+ except ValueError:
+ # An unparseable port raises rather than returning None.
+ host, port = None, None
+
+ if not host:
+ # Fail closed. The input reached here *with* whatever userinfo it carried, and a URL we cannot
+ # parse is a URL we cannot sanitise -- returning it verbatim is how a token ends up in a file
+ # that ships inside the bundle and outlives the run. Both real-world shapes that land here
+ # carry credentials: `https://oauth2:${TOKEN}@${HOST}/x` with HOST unset renders an empty
+ # authority, and a scheme-less `user:token@host/path` parses its username as a scheme. Losing
+ # the URL from the metadata is a far cheaper failure than leaking the secret in it.
+ return None, None
+
+ # hostname strips IPv6 brackets, so they have to go back or the authority is malformed.
+ literal = f"[{host}]" if ":" in host else host
+ authority = literal if port is None else f"{literal}:{port}"
+ return urllib.parse.urlunsplit((parts.scheme, authority, parts.path, parts.query, "")), host
+
+
+def _repo_path(source_dir, declared=None):
+ """
+ Where `code/` belongs inside the repository. Returns (path, how) with POSIX separators.
+
+ This is the field an autofix consumer cannot do without: `--source-dir infra/prod` means `code/`
+ holds only that subtree, so `code/main.tf` has to be written back to `infra/prod/main.tf`. The
+ packing destroys that prefix -- members are named relative to the source directory -- so if it is
+ not recorded here it is unrecoverable.
+
+ `""` means the repository root, and is deliberately not `None`: joining still works and it stays
+ distinguishable from "we could not tell", which is `None`. `how` is `"flag"` or `"git_root"`, so a
+ consumer about to write into someone's repository can tell a declared answer from an inferred one.
+
+ Inference walks up for a `.git` entry rather than shelling out to git -- there is no git dependency
+ anywhere in this package, and a `.git` *file* (worktrees, submodules) counts.
+ """
+ if declared is not None:
+ candidate = posixpath.normpath(declared.replace(os.sep, "/").strip("/"))
+ if candidate in (".", "/"):
+ return "", "flag"
+ # `..` here would have a consumer write outside the repository it thinks it is patching, which
+ # is the entire use of this field. Refuse it rather than record a path that escapes, and fall
+ # through to inference so the answer is merely absent rather than wrong.
+ if candidate.startswith("..") or posixpath.isabs(candidate):
+ log(f"WARNING: ignoring --repo-path {declared!r}: it must be a path inside the repository")
+ else:
+ return candidate, "flag"
+ if not source_dir:
+ return None, None
+
+ try:
+ current = os.path.realpath(source_dir)
+ except OSError:
+ return None, None
+
+ root = current
+ while True:
+ if os.path.exists(os.path.join(root, ".git")):
+ relative = os.path.relpath(current, root)
+ return ("" if relative == "." else relative.replace(os.sep, "/")), "git_root"
+ parent = os.path.dirname(root)
+ if parent == root:
+ return None, None
+ root = parent
+
+
+def build_metadata(opts, redactions, absent_reason=None):
+ """
+ The caller's half of `metadata.json`: what this bundle is.
+
+ `archive.pack` fills in what it observes -- whether code was packed, under what prefix, and the
+ file counts -- so nothing here asserts a fact about the archive's contents.
+
+ Two shapes of run have to produce an honest document. From CI, `--trigger-details-file` carries the
+ repository and commit. From a laptop there is no trigger payload at all (`{"type": "cli"}`), often
+ no `--sha` and no `--repo-url`; those fields are then `null` rather than omitted or invented, and
+ `origin.kind` says `local` as a positive statement instead of leaving CI to be inferred from an
+ absence.
+
+ Field names are snake_case, matching every other JSON this tool *authors* -- the result document,
+ the manifest, and terraform's own plan.json sitting beside it. camelCase in this package appears
+ only where it mirrors the platform's wire API, which this file never touches.
+ """
+ trigger = opts.trigger_details if isinstance(getattr(opts, "trigger_details", None), dict) else {}
+ trigger_type = trigger.get("type") or "cli"
+ url, host = _split_repo_url(getattr(opts, "repo_url", None) or trigger.get("repoHttpUrl"))
+ path, path_from = _repo_path(opts.source_dir, getattr(opts, "repo_path", None))
+
+ change_request = None
+ if trigger.get("prId"):
+ change_request = {
+ "id": str(trigger["prId"]),
+ "url": trigger.get("eventSource"),
+ "target_ref": trigger.get("baseRef"),
+ }
+
+ return {
+ "schema_version": METADATA_SCHEMA_VERSION,
+ "generator": {"name": "tirith", "version": __version__},
+ "created_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "input_kind": opts.input_kind,
+ "origin": {
+ # `cli` is what the CLI defaults the trigger type to when nothing supplied one, so it is
+ # the signal that no CI system was involved.
+ "kind": "local" if trigger_type == "cli" else "ci",
+ "trigger_type": trigger_type,
+ "ci_run_url": trigger.get("runUrl"),
+ },
+ "repository": {
+ # Sniffed from the host, never from the CI provider: a GitHub Actions job can perfectly
+ # well check out a GitLab repository, so these are independent facts.
+ "provider": _KNOWN_VCS_HOSTS.get(host, "unknown"),
+ "host": host,
+ "url": url,
+ "ref": getattr(opts, "repo_ref", None) or trigger.get("ref"),
+ "commit": opts.sha or trigger.get("headSha"),
+ "change_request": change_request,
+ },
+ "code": {
+ "repo_path": path,
+ "repo_path_from": path_from,
+ "absent_reason": absent_reason,
+ },
+ "masking": {
+ # Named so a consumer can find masked values without hardcoding the sentinel, and knows
+ # not to feed this state to terraform.
+ "redactions": redactions,
+ "marker": redact.SENTINEL,
+ "documents_are_masked": True,
+ },
+ "workflow": {
+ "org": opts.org,
+ "group": opts.workflow_group,
+ "id": opts.workflow_id,
+ "artifact_tag": opts.artifact_tag,
+ },
+ }
+
+
+def pack_documents(source_dir, plan, state, infracost, document_sources=(), metadata=None):
+ """
+ Build the archive, dropping the source tree rather than failing if it is too large.
+
+ Returns (bytes, manifest, source_skipped_reason) where the reason is None on the normal path.
+
+ The source is packed by default, so an exclusion that does not fire -- a committed vendor
+ directory, a build output tree -- would otherwise turn a working policy check into a failed run.
+ That trade is the wrong way round: the verdict is what gates the merge, and the source is there
+ for the autofix system's benefit. So an oversized archive degrades to documents-only and says so,
+ loudly, rather than taking the gate down with it.
+
+ Only when a source tree was actually requested. If we are already documents-only and still over
+ the limit, the *documents* are too big and there is nothing left to drop, so that stays fatal.
+
+ A source directory that does not exist is a different thing entirely and must not degrade. It
+ raises `ArchiveError` too, so letting it reach the retry below reported a typo'd `--source-dir` as
+ "the tree was too large", dropped the code, and completed the run -- the check would pass having
+ silently evaluated no source at all. Caught here, where the distinction is still available.
+ """
+ if source_dir and not os.path.isdir(source_dir):
+ raise CheckError(f"--source-dir does not exist: {source_dir}")
+
+ try:
+ archive_bytes, manifest = archive.pack(
+ source_dir=source_dir,
+ plan=plan,
+ state=state,
+ infracost=infracost,
+ document_sources=document_sources,
+ metadata=metadata,
+ )
+ return archive_bytes, manifest, None
+ except archive.ArchiveError as e:
+ if not source_dir:
+ raise
+
+ reason = str(e)
+ log(
+ f"WARNING: {reason} Uploading the masked documents only, without the source. The policy "
+ f"check still runs, but the archive carries no code -- so anything reading it to generate "
+ f"fixes has nothing to work from. Point --source-dir at your terraform directory, or add "
+ f"the large paths to .gitignore."
+ )
+ # The retry has to say *why* the code is missing, or a consumer cannot tell a deliberate
+ # documents-only run from a tree that was dropped for size.
+ retry_metadata = metadata
+ if metadata is not None:
+ retry_metadata = dict(metadata)
+ code = dict(metadata.get("code") or {})
+ # Only overwrite a reason the caller did not already give. This path is reached solely
+ # when a source tree WAS requested and dropped -- `pack_documents` re-raises when there
+ # was none -- but stamping unconditionally would relabel a deliberate documents-only run
+ # as an oversize failure if the retry were ever reached another way.
+ if not code.get("absent_reason"):
+ code["absent_reason"] = "too_large"
+ retry_metadata["code"] = code
+ archive_bytes, manifest = archive.pack(
+ source_dir=None, plan=plan, state=state, infracost=infracost, metadata=retry_metadata
+ )
+ return archive_bytes, manifest, reason
+
+
+def upload_state_document(client, opts, state):
+ """
+ Also publish the masked state as the workflow's `artifacts/tfstate.json`.
+
+ That name is canonical rather than decorative: the managed-state backend writes it, state locking
+ keys on the literal basename, and the state-backends listing special-cases it. Putting the state
+ there is what makes it visible and downloadable in the platform's own State and artifacts views,
+ instead of being reachable only by unpacking the run's archive.
+
+ It goes *in addition to* the copy inside the archive -- the step reads that one to publish
+ `TfStateCleaned`, and the two must not diverge.
+
+ Best-effort: the check's verdict does not depend on it, so a failure warns rather than failing a
+ run whose policies evaluated perfectly well.
+ """
+ if client.manages_terraform_state(opts.workflow_group, opts.workflow_id):
+ log(
+ "WARNING: not writing tfstate.json -- this workflow manages its own terraform state, and "
+ "that object is the live state. Overwriting it with a masked document would be data loss. "
+ "The state is still evaluated, and still in the run's archive."
+ )
+ return
+
+ try:
+ key = client.upload_file(
+ opts.workflow_group,
+ opts.workflow_id,
+ STATE_DOCUMENT_NAME,
+ None,
+ json.dumps(state).encode("utf-8"),
+ content_type=STATE_CONTENT_TYPE,
+ )
+ except SGError as e:
+ log(f"WARNING: could not publish {STATE_DOCUMENT_NAME}: {e}")
+ return
+
+ log(
+ f"Published the state document: {key} -- masked, so it reflects what was evaluated and "
+ f"cannot be used to run terraform."
+ )
+
+
+def run_check(opts):
+ """
+ Execute the check. Returns the result document.
+
+ Raises CheckError for anything that leaves the verdict unknown -- the caller maps that to a
+ non-zero exit regardless of --fail-on-error, because a run that produced no verdict must never
+ look like a pass.
+ """
+ client = SGClient(opts.api_url, opts.org, opts.api_key, timeout=60)
+
+ plan, state, infracost, redactions = prepare_documents(
+ opts.input_path,
+ opts.input_kind,
+ opts.state_path,
+ opts.infracost_path,
+ input_document=getattr(opts, "input_document", None),
+ )
+ if redactions:
+ log(f"Masked {redactions} sensitive value(s) before upload")
+
+ # Every path a document was read from, so the source walk cannot ship the unmasked original
+ # beside the masked copy.
+ #
+ # `plan_file` belongs here most of all, and was the omission that made this half a fix:
+ # --plan-file converts the BINARY plan in memory precisely so nothing unmasked touches the
+ # disk, but the binary plan itself is already on disk, and it embeds the prior state -- every
+ # attribute of every existing resource. The `tfplan` name patterns in DEFAULT_EXCLUDES only
+ # cover the spellings the README happens to use; `terraform plan -out=plan.out` is at least as
+ # common, and that file is the one thing here worth protecting most.
+ archive_bytes, manifest, source_skipped = pack_documents(
+ opts.source_dir,
+ plan,
+ state,
+ infracost,
+ document_sources=(opts.input_path, opts.state_path, opts.infracost_path, getattr(opts, "plan_file", None)),
+ metadata=build_metadata(
+ opts,
+ redactions,
+ absent_reason=None if opts.source_dir else "not_requested",
+ ),
+ )
+ log(
+ f"Packed {manifest['files']} file(s) and {len(manifest['documents'])} document(s) "
+ f"into {manifest['bytes'] // 1024} KB"
+ )
+
+ try:
+ client.ensure_workflow_group(opts.workflow_group)
+ client.ensure_workflow(
+ opts.workflow_group,
+ opts.workflow_id,
+ f"Policy checks for {opts.workflow_id}",
+ terraform_config(opts.terraform_version, opts.step_template_id),
+ vcs_config=SGClient.vcs_config(getattr(opts, "repo_url", None), getattr(opts, "repo_ref", None)),
+ )
+
+ # A flat, fixed name at the artifact root, overwritten every run. The step finds it there
+ # because the run controller syncs that directory down before any step executes -- which is
+ # what removes the need for any run-creation field, and therefore for any api change at all.
+ bundle_name = ARCHIVE_NAME_TEMPLATE.format(sha=opts.sha[:7] if opts.sha else "latest", tag=opts.artifact_tag)
+ key = client.upload_file(
+ opts.workflow_group,
+ opts.workflow_id,
+ bundle_name,
+ None,
+ archive_bytes,
+ )
+ log(f"Uploaded the project archive: {key}")
+
+ if state is not None:
+ upload_state_document(client, opts, state)
+
+ # The run names its own bundle. core merges this over the workflow's stored TerraformConfig,
+ # which is what makes the name per-run even though the workflow's copy was written once and
+ # never updated -- and therefore what lets the name carry the commit instead of being shared
+ # by every run of the workflow.
+ run_id, _data = client.create_run(
+ opts.workflow_group,
+ opts.workflow_id,
+ opts.trigger_details,
+ pre_plan_steps=[policy_step(opts.step_template_id, bundle_name)],
+ )
+ except SGError as e:
+ raise CheckError(str(e))
+
+ # Quoted, the way client.py quotes the same three values on every API path it builds. This one is
+ # rendered into an `href` in the pull-request comment, so an unquoted value could put a space or a
+ # quote into a URL a reviewer clicks. The renderer escapes it as well; both, because neither alone
+ # is obviously sufficient at the point you are reading only one of them.
+ run_url = (
+ f"{opts.dashboard_url.rstrip('/')}/orchestrator/orgs/{urllib.parse.quote(opts.org)}"
+ f"/wfgrps/{urllib.parse.quote(opts.workflow_group)}/wfs/{urllib.parse.quote(opts.workflow_id)}"
+ f"/wfruns/{urllib.parse.quote(str(run_id))}"
+ )
+ log(f"Run created: {run_url}")
+
+ # Written before polling so a timeout still leaves the run discoverable.
+ write_output_json(opts.output_json, {"status": "RUNNING", "wfrun_id": run_id, "wfrun_url": run_url})
+
+ try:
+ status, _run = client.wait_for_run(
+ opts.workflow_group,
+ opts.workflow_id,
+ run_id,
+ timeout=opts.timeout,
+ on_poll=lambda s: log(f"Run status: {s}"),
+ )
+ except SGError as e:
+ raise CheckError(f"{e} (run: {run_url})")
+
+ # The run facts are the source of truth -- they are what the dashboard renders. Fetched once:
+ # the document carries the verdict and the cost estimate, and it embeds the whole plan, so it
+ # is large enough that fetching it twice is worth avoiding.
+ # A read failure is held rather than raised straight away: an older step image publishes its
+ # verdict as an artifact instead, and that fallback below is still worth trying. What must not
+ # happen is a failed read falling through to an empty result set, which renders as "no policies
+ # in scope" -- a clean-looking exit for a run whose policies may well have failed.
+ facts_error = None
+ try:
+ facts = client.get_run_facts(opts.workflow_group, opts.workflow_id, run_id)
+ except SGError as e:
+ facts = {}
+ facts_error = e
+
+ policy_results = facts.get("PolicyEvalResults") or {}
+ # PreApply is what the step writes for a check run; the bare key is the fallback for an older
+ # step image that only set that one.
+ cost_breakdown = facts.get("InfracostBreakdownPreApply") or facts.get("InfracostBreakdown")
+
+ # The results artifact is only consulted when the facts come back empty, which means an older
+ # step image that still writes it.
+ legacy = None
+ if not policy_results:
+ legacy = client.get_results_artifact(opts.workflow_group, opts.workflow_id, f"{run_id}/tirith-results.json")
+ if legacy is not None:
+ policy_results = legacy
+
+ # Only when NOTHING answered. `legacy is not None` means the artifact was read and was
+ # legitimately empty -- an older step image with no policies in scope -- which is a real
+ # no-policies result, not a failed read.
+ if facts_error is not None and legacy is None:
+ raise CheckError(f"The run completed but its results could not be read: {facts_error} (run: {run_url})")
+
+ # The archive is deliberately retained. It is the source that produced these findings, and the
+ # autofix system reads it to generate fixes -- so deleting it here would remove the only copy of
+ # what was actually evaluated.
+ #
+ # One object per workflow, replaced on every run, so retention costs a bounded amount rather than
+ # growing per commit. It does land in the artifact prefix that is synced into every later run of
+ # the workflow -- unavoidable, because that sync is how the step receives it -- but the step
+ # deletes it from the volume after unpacking, so it does not travel onward from there.
+ #
+ # `client.delete_artifact` is kept for a retention sweep to use later. Note it currently points at
+ # a view that serves only GET and POST.
+ log(f"Retained the project archive for autofix: {key}")
+
+ counts, _findings = report.summarize(policy_results)
+ verdict_value = report.verdict(counts, status)
+
+ result = {
+ "status": status,
+ "verdict": verdict_value,
+ "counts": {
+ "passed": counts.get(report.PASS, 0),
+ "failed": counts.get(report.FAIL, 0),
+ "warned": counts.get(report.WARN, 0),
+ "approval_required": counts.get(report.APPROVAL_REQUIRED, 0),
+ "skipped": counts.get("SKIPPED", 0),
+ # Published so a consumer can tell "nothing failed" from "we could not read part of
+ # it". Without it an errored run reported failed: 0, which the action copies straight
+ # to its `failed` output.
+ "unknown": counts.get(report.UNKNOWN, 0),
+ },
+ "headline": report.headline(counts, verdict_value),
+ "wfrun_id": run_id,
+ "wfrun_url": run_url,
+ "policy_results": policy_results or {},
+ # Surfaced for a caller aggregating several units into one comment of their own.
+ "monthly_cost": (cost_breakdown or {}).get("totalMonthlyCost"),
+ # Where the evaluated source lives. The autofix system reads this to fetch what produced
+ # the findings; it is also recorded on the run itself as SGCustomWorkflowRunFacts, so a
+ # consumer holding only a run id can find it without seeing this document.
+ "archive_key": key,
+ # Whether that archive actually contains the source. Normally true, and false when the tree
+ # was too large and got dropped so the check could still run. A consumer must not assume:
+ # "no code in the bundle" and "no code was wanted" need to be distinguishable.
+ # Derived from what the archive actually holds, not from what was asked for: a tree whose
+ # every file was excluded packs nothing, and this must not then claim otherwise while
+ # metadata.json says `present: false`.
+ "source_packed": bool(manifest.get("files")),
+ "source_skipped_reason": source_skipped,
+ }
+
+ write_output_json(opts.output_json, result)
+
+ if opts.output_markdown:
+ body = report.render_markdown(
+ policy_results,
+ status,
+ run_url,
+ marker=opts.comment_marker,
+ limit=opts.markdown_limit,
+ cost_breakdown=cost_breakdown,
+ commit=opts.sha,
+ )
+ try:
+ with open(opts.output_markdown, "w") as f:
+ f.write(body)
+ except OSError as e:
+ log(f"WARNING: could not write {opts.output_markdown}: {e}")
+
+ log(result["headline"])
+ return result
diff --git a/src/tirith/platform/cli.py b/src/tirith/platform/cli.py
new file mode 100644
index 00000000..c4070b3e
--- /dev/null
+++ b/src/tirith/platform/cli.py
@@ -0,0 +1,283 @@
+"""
+`tirith platform ...` -- run policy checks against a StackGuardian organization.
+
+Flag and environment names follow sg-cli (SG_API_TOKEN, SG_BASE_URL, SG_ORG, SG_DASHBOARD_URL) so
+someone who knows one tool knows the other. `--region` names both URLs at once; see regions.py for
+the precedence between it, the explicit flags and the environment.
+"""
+
+import argparse
+import json
+import os
+import re
+import sys
+
+from ..status import ExitStatus
+from . import discover, regions
+from .check import DEFAULT_WORKFLOW_GROUP, INPUT_KINDS, CheckError, log, run_check
+
+# `Id` is a DRF SlugField on the platform, and the value is interpolated into every API path.
+WORKFLOW_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
+
+
+def _resolve_api_key(value):
+ """
+ Resolve the API key, preferring the environment.
+
+ A key on argv is visible in `ps` for the lifetime of the process, so `-` reads it from stdin
+ and $SG_API_TOKEN is the documented default.
+ """
+ if value == "-":
+ return sys.stdin.readline().strip()
+ return value or os.environ.get("SG_API_TOKEN", "")
+
+
+def _load_trigger_details(opts):
+ if opts.trigger_details_json:
+ source, raw = "--trigger-details-json", opts.trigger_details_json
+ elif opts.trigger_details_file:
+ source = f"--trigger-details-file {opts.trigger_details_file}"
+ try:
+ with open(opts.trigger_details_file) as f:
+ raw = f.read()
+ except OSError as e:
+ raise CheckError(f"Could not read {opts.trigger_details_file}: {e}")
+ else:
+ return {"type": "cli"}
+
+ try:
+ details = json.loads(raw)
+ except json.JSONDecodeError as e:
+ raise CheckError(f"{source} is not valid JSON: {e}")
+ if not isinstance(details, dict):
+ raise CheckError(f"{source} must be a JSON object")
+ details.setdefault("type", "cli")
+ return details
+
+
+def build_parser():
+ parser = argparse.ArgumentParser(
+ prog="tirith platform",
+ description="Run StackGuardian policy checks from a CI pipeline or a laptop.",
+ )
+ sub = parser.add_subparsers(dest="subcommand")
+
+ check = sub.add_parser(
+ "check",
+ help="Evaluate the organization's policies against a document and report the verdict.",
+ description=(
+ "Masks the document, packs it with the terraform source into an archive, uploads it, "
+ "runs the policies on StackGuardian and reports the verdict."
+ ),
+ )
+
+ identity = check.add_argument_group("identity")
+ identity.add_argument(
+ "--api-key", default=None, help="API key, or '-' to read it from stdin. Default: $SG_API_TOKEN"
+ )
+ identity.add_argument("--org", default=None, help="Organization name. Default: $SG_ORG")
+ identity.add_argument(
+ "--region",
+ default=None,
+ choices=regions.REGION_IDS,
+ help=(
+ f"StackGuardian region, setting both URLs at once. " f"Default: $SG_REGION or {regions.DEFAULT_REGION_ID}."
+ ),
+ )
+ identity.add_argument(
+ "--api-url",
+ default=None,
+ help=(
+ "API base URL, with or without /api/v1. Overrides --region; needed only for a "
+ "self-hosted install or a dedicated host. Default: $SG_BASE_URL"
+ ),
+ )
+ identity.add_argument(
+ "--dashboard-url",
+ default=None,
+ help="Dashboard base URL, used to build run links. Inferred from --api-url when it names a known region.",
+ )
+
+ workflow = check.add_argument_group("workflow")
+ workflow.add_argument(
+ "--workflow-id",
+ required=True,
+ help="Slug identifying the workflow. Created if absent. Letters, digits, '-' and '_' only.",
+ )
+ workflow.add_argument("--workflow-group", default=DEFAULT_WORKFLOW_GROUP, help="Workflow group. Created if absent.")
+ workflow.add_argument("--terraform-version", default=None, help="Stored on the workflow at creation.")
+ workflow.add_argument(
+ "--repo-url",
+ default=None,
+ help="Source repository URL, recorded on the workflow at creation so it links back to the code.",
+ )
+ workflow.add_argument("--repo-ref", default=None, help="Branch, tag or commit, recorded alongside --repo-url.")
+ workflow.add_argument(
+ "--repo-path",
+ default=None,
+ help=(
+ "Path of --source-dir within the repository, recorded in the bundle's metadata.json so a "
+ "consumer knows where code/ belongs. Inferred from the enclosing git checkout if omitted."
+ ),
+ )
+ workflow.add_argument(
+ "--step-template-id",
+ default=None,
+ help="Override the policy-evaluation step template. Omit to use the platform's own default.",
+ )
+
+ inputs = check.add_argument_group("inputs")
+ inputs.add_argument(
+ "--input-path",
+ default=None,
+ help=(
+ "Document to evaluate. Defaults to whichever of "
+ f"{' or '.join(discover.PLAN_FILENAMES)} is in --source-dir."
+ ),
+ )
+ inputs.add_argument(
+ "--plan-file",
+ default=None,
+ help=(
+ "Binary plan from `terraform plan -out=`. Rendered with `show -json` in memory, so no "
+ "unmasked plan JSON is written to disk. Use --input-path if you already have the JSON."
+ ),
+ )
+ inputs.add_argument(
+ "--terraform-bin",
+ default=None,
+ help="terraform/tofu binary for --plan-file. Auto-detected, preferring the real binary over a CI wrapper.",
+ )
+ inputs.add_argument("--input-kind", default="terraform_plan", choices=INPUT_KINDS)
+ inputs.add_argument("--state-path", default=None, help="Optional terraform state, masked before upload.")
+ inputs.add_argument("--infracost-path", default=None, help="Optional `infracost breakdown --format json`.")
+ inputs.add_argument("--source-dir", default=".", help="Terraform source to pack alongside the documents.")
+ inputs.add_argument(
+ "--no-source",
+ action="store_true",
+ help="Send only the documents. Discovery still looks in --source-dir (or .) for the plan.",
+ )
+
+ run = check.add_argument_group("run")
+ run.add_argument("--sha", default=None, help="Commit SHA, used to namespace the uploaded archive.")
+ run.add_argument(
+ "--artifact-tag",
+ default="default",
+ help=(
+ "Namespaces the archive within a commit. Needed only when one workflow evaluates the same "
+ "commit more than once -- a plan phase and a state phase, or matrix legs sharing a workflow."
+ ),
+ )
+ run.add_argument("--trigger-details-json", default=None, help="JSON object describing what triggered this run.")
+ run.add_argument("--trigger-details-file", default=None, help="File containing that JSON object.")
+ run.add_argument("--timeout", type=int, default=1800, help="Seconds to wait for the run. Default: 1800")
+
+ output = check.add_argument_group("output")
+ output.add_argument("--output-json", default=None, help="Write the result document here.")
+ output.add_argument("--output-markdown", default=None, help="Write a markdown report here.")
+ output.add_argument("--comment-marker", default=None, help="Opaque first line of the markdown, for stickiness.")
+ output.add_argument("--markdown-limit", type=int, default=60000, help="Truncate the markdown to this length.")
+ output.add_argument(
+ "--fail-on-error",
+ action="store_true",
+ help=(
+ "Exit non-zero when a policy fails. An unreachable platform or a run that produced no "
+ "verdict always exits non-zero regardless of this flag."
+ ),
+ )
+
+ return parser
+
+
+def main(argv):
+ parser = build_parser()
+ opts = parser.parse_args(argv[1:])
+
+ if opts.subcommand != "check":
+ parser.print_help()
+ return ExitStatus.SUCCESS
+
+ opts.api_key = _resolve_api_key(opts.api_key)
+ opts.org = opts.org or os.environ.get("SG_ORG", "")
+ try:
+ opts.api_url, opts.dashboard_url, url_warnings = regions.resolve(
+ region_id=opts.region,
+ api_url=opts.api_url,
+ dashboard_url=opts.dashboard_url,
+ env=os.environ,
+ )
+ except ValueError as e:
+ log(f"ERROR: {e}")
+ return ExitStatus.ERROR
+ for warning in url_warnings:
+ log(f"WARNING: {warning}")
+ opts.source_dir = None if opts.no_source else opts.source_dir
+
+ missing = [name for name, value in (("--api-key", opts.api_key), ("--org", opts.org)) if not value]
+ if missing:
+ log(f"ERROR: missing required {' and '.join(missing)}")
+ return ExitStatus.ERROR
+
+ if not WORKFLOW_ID_PATTERN.match(opts.workflow_id):
+ # Checked before any HTTP call: the value goes straight into every API path, and the
+ # platform's own field is a slug, so a `/` yields a malformed URL rather than a clear error.
+ suggestion = re.sub(r"[^A-Za-z0-9_-]+", "-", opts.workflow_id).strip("-").lower()[:100]
+ log(f"ERROR: --workflow-id '{opts.workflow_id}' is not a valid slug. Try '{suggestion}'.")
+ return ExitStatus.ERROR
+
+ opts.input_document = None
+ if opts.plan_file:
+ if opts.input_path:
+ log("ERROR: --plan-file and --input-path cannot be combined; they name the same document")
+ return ExitStatus.ERROR
+ try:
+ opts.input_document = discover.terraform_show_json(
+ opts.plan_file, workdir=opts.source_dir, binary=opts.terraform_bin
+ )
+ except discover.DiscoveryError as e:
+ log(f"ERROR: {e}")
+ return ExitStatus.ERROR
+ log(f"Rendered {opts.plan_file} with `terraform show -json`")
+ elif not opts.input_path and not opts.state_path:
+ # Nothing was named, so look in the conventional place. This is what lets a caller run with
+ # no configuration at all.
+ try:
+ opts.input_path = discover.discover_input(opts.source_dir)
+ except discover.DiscoveryError as e:
+ log(f"ERROR: {e}")
+ return ExitStatus.ERROR
+ log(f"Using {opts.input_path}")
+
+ if opts.api_key.startswith("sgu_"):
+ log(
+ "WARNING: sgu_ tokens are non-functional for SSO-group-only users and inherit only "
+ "direct permissions for hybrid SSO users. Prefer an organization (sgo_) token."
+ )
+
+ try:
+ opts.trigger_details = _load_trigger_details(opts)
+ result = run_check(opts)
+ except CheckError as e:
+ # Fails closed: a run that produced no verdict must never look like a pass, whatever
+ # --fail-on-error says.
+ log(f"ERROR: {e}")
+ return ExitStatus.ERROR
+ except KeyboardInterrupt:
+ log("Interrupted")
+ return ExitStatus.ERROR_CTRL_C
+
+ verdict = result["verdict"]
+ if verdict == "errored":
+ # Fails closed regardless of --fail-on-error: the flag governs policy verdicts, not tool
+ # health, and a run that produced no verdict must never look like a pass.
+ log("The run did not produce a verdict")
+ return ExitStatus.ERROR
+ if verdict == "failed" and opts.fail_on_error:
+ return ExitStatus.ERROR_POLICY_FAILED
+ if verdict == "failed":
+ log("Policies failed, but --fail-on-error was not set")
+ # A policy asking for approval warns rather than gating -- see report.verdict for why.
+ if result.get("counts", {}).get("approval_required"):
+ log("Some policies ask for approval; reported as a warning, which does not block")
+
+ return ExitStatus.SUCCESS
diff --git a/src/tirith/platform/client.py b/src/tirith/platform/client.py
new file mode 100644
index 00000000..95574975
--- /dev/null
+++ b/src/tirith/platform/client.py
@@ -0,0 +1,502 @@
+"""
+StackGuardian API client.
+
+stdlib only -- urllib rather than requests -- so this adds no dependency to a package that has
+three, and a CI runner needs nothing installed beyond tirith itself.
+
+ POST /orgs//wfgrps/ create the workflow group
+ POST /orgs//wfgrps//wfs/ create the workflow
+ GET /orgs//wfgrps//wfs//file_upload_url/ presigned PUT (5 min) + key
+ POST /orgs//wfgrps//wfs//wfruns/ create the run
+ GET /orgs//wfgrps//wfs//wfruns// poll
+ GET /orgs//wfgrps//wfs//artifacts// fetch the results artifact
+ GET .../wfruns//wfrunfacts// fallback -> PolicyEvalResults
+"""
+
+import gzip
+import json
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+from . import regions
+
+# Signed into the upload URL by the platform, so the PUT must send the same value.
+# The bundle is PUT to a URL the platform signs for application/json regardless of filename, and S3
+# validates the signature against the header the client sends -- not against the body. So the header
+# has to be the signed one even though the body is gzip. Sending application/gzip earns a
+# SignatureDoesNotMatch; the stored object is merely labelled wrongly, which nothing reads.
+ARCHIVE_CONTENT_TYPE = "application/json"
+
+# The bundle's name in the workflow's artifact directory, per commit and tag.
+#
+# Namespaced rather than fixed because a fixed name is shared by every run of the workflow, and two
+# runs overlapping -- two pull requests, which is routine, since the action derives one workflow id per
+# repository -- would leave one run evaluating the other's code and reporting the verdict as its own.
+# Silent, and wrong in the direction that gates a merge. A per-commit name cannot collide, so the race
+# does not exist rather than being detected after the fact.
+#
+# The cost is growth: the artifact directory is synced *down* into every later run of the workflow, the
+# up-sync carries no --delete, and api exposes no artifact DELETE, so bundles accumulate and every run
+# pays to download all of them. Accepted deliberately -- correctness over transfer cost -- and the
+# reason `delete_artifact` below is kept for a retention sweep to use.
+#
+# Flat, because a nested key cannot be deleted correctly: the authorizer's greedy
+# converter swallows it, so `DELETE .../artifacts///` matches the workflow-group delete and
+# is checked against the wrong permission entirely.
+#
+# The name is constrained more than it looks. The down-sync excludes `sg.*`, `*__sg.*`, `*pci_*`,
+# `*_thrifty_*`, `*_gdpr_*`, `*compliance_raw*` and the other compliance globs, so a name matching any
+# of those would be dropped silently and never reach the container. It also must not be
+# `tfstate.json`, which at the artifact root is a managed-state workflow's live state.
+ARCHIVE_NAME_TEMPLATE = "tirith-bundle-{sha}-{tag}.tar.gz"
+
+# What the workflow stores as a fallback, and what the step falls back to if a run names nothing.
+ARCHIVE_DOCUMENT = "tirith-bundle.tar.gz"
+
+# Terminal run states. QUEUED/PENDING/RUNNING are transient; a run can sit in QUEUED for a long
+# while behind the per-workflow concurrency gate, which is why the caller logs each poll.
+#
+# APPROVAL_REQUIRED is terminal *for polling purposes*: it is a resting state, reached when a
+# policy's onFail is APPROVAL_REQUIRED, and nothing further happens without a human. Treating it as
+# transient would spin until the timeout and then report a tool failure for what is actually a
+# completed evaluation. sg-cli treats it the same way.
+TERMINAL_STATUSES = ("COMPLETED", "ERRORED", "CANCELLED", "APPROVAL_REQUIRED")
+
+RETRYABLE_STATUS = (408, 429, 500, 502, 503, 504)
+
+
+class SGError(Exception):
+ """An API call failed in a way the caller cannot recover from."""
+
+
+def _extract_signed_url(payload):
+ """
+ Pull the presigned URL out of an upload-url response.
+
+ The shape varies by endpoint and deployment: the tfstate/file upload endpoints return the URL
+ as a bare string in `msg`, while the newer template-artifact endpoints nest it under
+ `data.signedUrl`. Accept either rather than depending on one.
+ """
+ if not isinstance(payload, dict):
+ return None
+
+ for container_key in ("data", "msg"):
+ container = payload.get(container_key)
+ if isinstance(container, str) and container.startswith("http"):
+ return container
+ if isinstance(container, dict):
+ for url_key in ("signedUrl", "signed_url", "url"):
+ candidate = container.get(url_key)
+ if isinstance(candidate, str) and candidate.startswith("http"):
+ return candidate
+ return None
+
+
+class SGClient:
+ def __init__(self, api_url, org, api_key, user_agent="tirith-action", timeout=60):
+ # Accepts a base with or without /api/v1, so a SG_BASE_URL exported for sg-cli works here.
+ self.api_url = regions.normalize_api_url(api_url) or regions.normalize_api_url(
+ regions.by_id(regions.DEFAULT_REGION_ID).api_base
+ )
+ self.org = org
+ self.api_key = api_key
+ self.user_agent = user_agent
+ self.timeout = timeout
+
+ # -- plumbing ------------------------------------------------------------------------------
+
+ def _request(self, method, path, body=None, retries=4):
+ url = f"{self.api_url}/orgs/{urllib.parse.quote(self.org)}{path}"
+ data = json.dumps(body).encode() if body is not None else None
+
+ last_error = None
+ for attempt in range(retries + 1):
+ request = urllib.request.Request(url, data=data, method=method)
+ # SG's documented scheme. Must be an sgo_ (org) token: sgu_ tokens are non-functional
+ # for SSO-group-only users and inherit only direct permissions for hybrid SSO users,
+ # which surfaces as a confusing 403.
+ request.add_header("Authorization", f"apikey {self.api_key}")
+ request.add_header("Content-Type", "application/json")
+ request.add_header("X-SG-Client", self.user_agent)
+
+ try:
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
+ raw = response.read()
+ return response.status, (json.loads(raw) if raw else {})
+ except urllib.error.HTTPError as e:
+ raw = e.read()
+ try:
+ payload = json.loads(raw) if raw else {}
+ except json.JSONDecodeError:
+ payload = {"msg": raw.decode("utf-8", "replace")[:500]}
+
+ if e.code in RETRYABLE_STATUS and attempt < retries:
+ last_error = f"HTTP {e.code}: {payload.get('msg', '')}"
+ time.sleep(min(2**attempt, 8))
+ continue
+ return e.code, payload
+ except (urllib.error.URLError, TimeoutError) as e:
+ # Never treat a network failure as a pass -- the caller maps this to a red check.
+ last_error = str(e)
+ if attempt < retries:
+ time.sleep(min(2**attempt, 8))
+ continue
+ raise SGError(f"Could not reach StackGuardian at {self.api_url}: {last_error}")
+
+ raise SGError(f"StackGuardian request failed after {retries + 1} attempts: {last_error}")
+
+ # -- resources -----------------------------------------------------------------------------
+
+ def ensure_workflow_group(self, name):
+ """
+ Create the workflow group if absent.
+
+ Needed because `createIfNotExists` on run creation auto-creates the *workflow*, not the
+ group -- core's own error for a missing group reads "Workflow Group does not exist and
+ cannot be created". A 409 means someone else already made it, which is success here.
+ """
+ status, payload = self._request(
+ "POST",
+ "/wfgrps/",
+ {"ResourceName": name, "Description": "Created by tirith", "Tags": ["sg-created"]},
+ )
+ if status in (200, 201, 409):
+ return status
+ raise SGError(f"Could not create workflow group '{name}' (HTTP {status}): {payload.get('msg')}")
+
+ @staticmethod
+ def vcs_config(repo_url, repo_ref=None):
+ """
+ Build the workflow's VCSConfig from a repo URL, recording where the code came from.
+
+ `GIT_OTHER` -- singular, the wire value behind the UI's "Git Others" -- is the
+ connector-less provider. With `isPrivate: false` it needs no auth at all, and it skips the
+ GitHub repo-id extraction that rejects anything it cannot parse as an owner/name pair.
+
+ This is display metadata, set on the *workflow* so it shows a repo link instead of a
+ "configure" prompt. It is not a source of code: every run sends `VCSConfig: {}` to suppress
+ the checkout (see `create_run`). Keeping the two apart is deliberate -- the workflow records
+ where the code came from, the run declines to fetch it.
+
+ It cannot be made inert by shape alone. Dropping `useMarketplaceTemplate`, which is what
+ actually arms the runner's clone, is rejected by api: `IACVCSConfig` declares it
+ `BooleanField(required=True)` (`serializers/commons.py:141`). Send `iacVCSConfig` at all and
+ the key comes with it.
+ """
+ if not repo_url:
+ return None
+ config = {"isPrivate": False, "repo": repo_url}
+ if repo_ref:
+ config["ref"] = repo_ref
+ return {
+ "iacVCSConfig": {
+ "useMarketplaceTemplate": False,
+ "customSource": {"sourceConfigDestKind": "GIT_OTHER", "config": config},
+ }
+ }
+
+ def ensure_workflow(self, wfgrp, workflow_id, description, terraform_config, vcs_config=None):
+ """
+ Create the workflow if absent, keyed on `Id`.
+
+ `Id` is the stable slug identity and what goes in the URL; `ResourceName` is a display name
+ and is not unique. Both are set to the same string so there is one name to reason about.
+ Note `Id` is a DRF SlugField, so it cannot contain dots.
+
+ The workflow is `TERRAFORM`, not `CUSTOM`. For a terraform workflow core synthesises the
+ steps from the stored TerraformConfig plus the per-run TerraformAction and *ignores* any
+ WfStepsConfig in the request -- so the step configuration has to live here, once, rather
+ than being sent on every run. It also means the run renders as a real terraform run in the
+ dashboard rather than as opaque custom steps.
+
+ `vcs_config` is set on creation only -- a 409 means the workflow already exists and nothing
+ is updated, so a workflow created before this existed keeps its blank repo field.
+ """
+ body = {
+ "Id": workflow_id,
+ "ResourceName": workflow_id,
+ "Description": description,
+ "Tags": ["sg-created", "tirith"],
+ "WfType": "TERRAFORM",
+ "TerraformConfig": terraform_config,
+ }
+ if vcs_config:
+ body["VCSConfig"] = vcs_config
+
+ status, payload = self._request("POST", f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/", body)
+ if status in (200, 201, 409):
+ return status
+ raise SGError(f"Could not create workflow '{workflow_id}' (HTTP {status}): {payload.get('msg')}")
+
+ def manages_terraform_state(self, wfgrp, workflow_id):
+ """
+ Whether the workflow keeps its terraform state on the platform.
+
+ Consulted before writing `artifacts/tfstate.json`, because for a managed-state workflow that
+ object *is* the live state: the step's backend writes it, state locking keys on the literal
+ name, and the state-backends view lists it. Overwriting it with a masked document would be
+ data loss, so this is a hard gate rather than a warning.
+
+ Unreadable answers as True -- the safe direction. Not being able to tell whether an object is
+ live state is not a reason to overwrite it.
+ """
+ status, payload = self._request("GET", f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/")
+ if status != 200:
+ return True
+ body = payload.get("msg") or payload.get("data") or {}
+ if not isinstance(body, dict) or "TerraformConfig" not in body:
+ # A 200 that carries no TerraformConfig is still an answer we cannot read. Absent is not
+ # the same as false.
+ return True
+ return bool((body.get("TerraformConfig") or {}).get("managedTerraformState"))
+
+ # `content` rather than `payload`: the response variable below is already called payload, and
+ # shadowing it sent the JSON response body to S3 in place of the file.
+ def upload_file(self, wfgrp, workflow_id, filename, folder, content, content_type=ARCHIVE_CONTENT_TYPE):
+ r"""
+ Upload one object into the workflow's artifact prefix via a presigned PUT, returning its key.
+
+ The returned key is informational -- a log line, and something to quote in a bug report. It is
+ deliberately not load-bearing: nothing passes it back on the run, and the step finds the bundle
+ by *basename* inside the artifact directory the run controller syncs down for it. That is why
+ an api which returns no key at all is fine here. It comes from the response rather than being
+ rebuilt because the layout is runner-aware (a private runner's own S3 bucket or Azure container
+ rather than the shared bucket), so a client-side guess would be wrong for exactly the customers
+ who are hardest to debug.
+
+ `folder` is optional and must be a flat token -- the endpoint rejects `/`, `\\` and `..` to
+ prevent path traversal. Omitting it puts the object at the artifacts root, which is what both
+ callers want: the archive because a nested key cannot be deleted correctly, and the state
+ document because `artifacts/tfstate.json` is the canonical location the platform reads.
+ """
+ # No `contentType` parameter. The endpoint signs application/json regardless, and asking it to
+ # sign anything else needs an api change this feature deliberately does not make -- so the PUT
+ # below sends application/json to match the signature, and the bundle is merely labelled
+ # wrongly in storage. Nothing reads that label.
+ params = {"filename": filename}
+ if folder:
+ # Only when set. urlencode stringifies None to the literal "None", and the endpoint
+ # treats any non-empty value as a subfolder -- so passing it unconditionally produced a
+ # real `None/` directory in S3, and the archive then sat at a nested key that the
+ # post-run delete could not address.
+ params["folder"] = folder
+ query = urllib.parse.urlencode(params)
+ status, payload = self._request(
+ "GET", f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/file_upload_url/?{query}"
+ )
+ if status != 200:
+ raise SGError(f"Could not get an upload URL for {filename} (HTTP {status}): {payload.get('msg')}")
+
+ # Informational only, and optional. It used to be required, because the caller had to pass the
+ # key back as a run field -- and an api that did not return it produced a run pointing at
+ # nothing. Nothing passes the key anywhere now: the step finds the bundle by name in the
+ # artifacts directory. So an api that does not return a key is fine, and this stays a label for
+ # the log line rather than a hard requirement.
+ key = (payload.get("data") or {}).get("key") or f"{filename} (key not reported)"
+ signed_url = _extract_signed_url(payload)
+ if not signed_url:
+ raise SGError(f"No signed URL in the upload response for {filename}: {payload}")
+
+ # Must match the content type the URL was signed with, or S3 rejects it as a signature
+ # mismatch.
+ put = urllib.request.Request(signed_url, data=content, method="PUT")
+ put.add_header("Content-Type", content_type)
+ try:
+ with urllib.request.urlopen(put, timeout=self.timeout) as response:
+ if response.status not in (200, 204):
+ raise SGError(f"Upload of {filename} returned HTTP {response.status}")
+ except urllib.error.HTTPError as e:
+ # The signed URL is valid for 5 minutes; an expiry shows up here as a 403.
+ raise SGError(f"Upload of {filename} failed (HTTP {e.code}): {e.read()[:300]!r}")
+ except (urllib.error.URLError, TimeoutError) as e:
+ raise SGError(f"Upload of {filename} failed: {e}")
+
+ return key
+
+ def create_run(self, wfgrp, workflow_id, trigger_details, pre_plan_steps=None, action="plan"):
+ """
+ Create one workflow run. Every invocation makes a new run.
+
+ Deliberately carries no WfStepsConfig: core ignores that for TERRAFORM workflows, synthesising
+ the steps from TerraformConfig and TerraformAction instead. `TerraformConfig` is the field it
+ *does* honour per run -- core merges the run's over the workflow's
+ (`workflowruns/__init__.py:1646`) -- so that is how each run names its own bundle.
+
+ The merge is shallow, so `prePlanWfStepsConfig` replaces the workflow's list wholesale and the
+ caller must send the complete step entry. Keys it does not send, `terraformVersion` and
+ `managedTerraformState`, still come from the workflow.
+
+ Note what is *not* here: any archive field. The bundle reaches the step through the workflow's
+ artifact directory, which the run controller syncs down before any step runs, and the step is
+ told which one to read via that step's `wfStepInputData`. So api needs no new serializer field
+ and no new response key -- `TerraformConfig` is already declared on WorkflowRunSerializer.
+
+ `terraformProjectZip` was the previous carrier and is gone. It worked, but it cost a declared
+ field in api: DRF drops undeclared keys, so without that change a run came back 201 having
+ silently discarded the reference and would have evaluated a VCS checkout instead of the
+ uploaded code.
+
+ A context tag was the other obvious-looking option and is the wrong tool: run context tags are
+ indexed into global search, so an internal storage key would surface in customers' tag
+ typeaheads and could be enumerated by filtering on it.
+
+ `VCSConfig: {}` suppresses the checkout for this run. The workflow keeps its own VCSConfig so
+ the dashboard still shows which repository the runs came from, but core resolves the run's
+ copy as `data.get("VCSConfig", wfDetails.get("VCSConfig", {}))`
+ (`workflowruns/__init__.py:1770`) -- a *present* empty value beats the workflow's, while
+ omitting the key inherits it. The runner then clones only when
+ `vcsConfig.iacVCSConfig` carries a `useMarketplaceTemplate` key (`external.py:2484`), so an
+ empty config skips git entirely.
+
+ Sending it matters for two reasons. A private repository has no credentials here -- the
+ checkout died with "could not read Password for 'https://None@github.com'" before the step
+ ran -- and on a public one the clone quietly placed the *unmasked* source in the workspace,
+ which then reached S3 inside the run snapshot. The bundle is the only source this feature
+ wants on the platform.
+ """
+ body = {
+ "TerraformAction": {"action": action},
+ "TriggerDetails": trigger_details,
+ "VCSConfig": {},
+ }
+ if pre_plan_steps:
+ body["TerraformConfig"] = {"prePlanWfStepsConfig": pre_plan_steps}
+ status, payload = self._request("POST", f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/wfruns/", body)
+ if status not in (200, 201):
+ raise SGError(f"Could not create the workflow run (HTTP {status}): {payload.get('msg')}")
+
+ data = payload.get("data") or {}
+ run_name = data.get("ResourceName")
+ if not run_name:
+ raise SGError(f"No ResourceName in the run-creation response: {payload}")
+
+ return run_name, data
+
+ def get_run(self, wfgrp, workflow_id, run_id):
+ status, payload = self._request(
+ "GET", f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/wfruns/{run_id}/"
+ )
+ if status != 200:
+ raise SGError(f"Could not read run {run_id} (HTTP {status}): {payload.get('msg')}")
+ # This endpoint returns the run object under "msg" rather than "data".
+ return payload.get("msg") or payload.get("data") or {}
+
+ def wait_for_run(self, wfgrp, workflow_id, run_id, timeout=1800, interval=10, on_poll=None):
+ """
+ Poll until the run reaches a terminal state.
+
+ A timeout is a failure, never a pass: the caller maps it to a red check. `on_poll` exists
+ so the caller can log each status -- a run stuck in QUEUED behind another run on the same
+ workflow looks identical to a hung run otherwise.
+ """
+ deadline = time.time() + timeout
+ last_status = None
+
+ while time.time() < deadline:
+ run = self.get_run(wfgrp, workflow_id, run_id)
+ status = run.get("LatestStatus")
+ if status != last_status and on_poll:
+ on_poll(status)
+ last_status = status
+
+ if status in TERMINAL_STATUSES:
+ return status, run
+ time.sleep(interval)
+
+ raise SGError(
+ f"Run {run_id} did not finish within {timeout}s (last status: {last_status}). "
+ f"Runs on one workflow serialize, so it may be queued behind another run."
+ )
+
+ def get_results_artifact(self, wfgrp, workflow_id, artifact_path):
+ """
+ Read the results artifact the tirith step used to publish next to the inputs.
+
+ Kept only so a newer CLI still reads results from an older step image. Current step images
+ do not write this file: it carried exactly the PolicyEvalResults that the run facts already
+ hold, and it existed only because the facts endpoint used to answer "does not exist" for
+ every run. That was a key mismatch in the run controller, not a missing record.
+
+ Returns None -- not {} -- when absent, so the caller can tell "no such artifact, go ask the
+ facts endpoint" from "the artifact exists and no policies matched".
+ """
+ status, payload = self._request(
+ "GET",
+ f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/artifacts/{artifact_path}/",
+ )
+ if status != 200:
+ return None
+
+ # This endpoint returns the artifact body directly rather than an envelope.
+ if isinstance(payload, dict) and "PolicyEvalResults" in payload:
+ return payload.get("PolicyEvalResults") or {}
+ return None
+
+ def get_run_facts(self, wfgrp, workflow_id, run_id):
+ """
+ Fetch the whole run-facts document.
+
+ One call, because the document carries everything the caller reports on --
+ PolicyEvalResults, the cost breakdown, the plan -- and it embeds the full plan, so it is
+ large enough that fetching it twice is worth avoiding.
+
+ The endpoint hands back a presigned GET rather than the payload inline, for the same reason.
+
+ Raises SGError when the facts could not be *read*, and returns {} only when they were read
+ and were empty. Collapsing both into {} made an unreadable run -- a 403 on the endpoint, a
+ failed presigned GET -- indistinguishable from a run with no policies in scope, so a run
+ whose policies had actually failed reported "no policies in scope" and exited 0.
+ """
+ status, payload = self._request(
+ "GET",
+ f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/wfruns/{run_id}/wfrunfacts/default/",
+ )
+ if status == 404:
+ # Absent, not unreadable. A run that never produced a facts document answers this way,
+ # and that is a legitimate empty result -- treating it as a read failure would turn
+ # healthy runs red, which is the opposite of the mistake being fixed.
+ return {}
+ if status != 200:
+ raise SGError(f"Could not read the run facts for {run_id} (HTTP {status}): {payload.get('msg')}")
+
+ body = payload.get("msg") or payload.get("data") or {}
+ if isinstance(body, dict) and body.get("PolicyEvalResults"):
+ return body
+
+ # Via the shared helper: this endpoint returns `signed_url`, not `signedUrl`. Reading only
+ # the camelCase spelling meant this always fell through to {} -- which went unnoticed for as
+ # long as the results artifact was covering for it.
+ signed_url = _extract_signed_url(payload)
+ if not signed_url:
+ # A 200 carrying neither the facts inline nor a URL to them: the run genuinely has no
+ # facts document, which is what an empty result set looks like.
+ return {}
+
+ try:
+ with urllib.request.urlopen(signed_url, timeout=self.timeout) as response:
+ raw = response.read()
+ if response.info().get("Content-Encoding") == "gzip" or raw[:2] == b"\x1f\x8b":
+ raw = gzip.decompress(raw)
+ return json.loads(raw) or {}
+ except Exception as e:
+ raise SGError(f"Could not fetch the run facts document for {run_id}: {e}")
+
+ def get_policy_results(self, wfgrp, workflow_id, run_id):
+ """Read PolicyEvalResults from the run facts. This is the primary source of the verdict."""
+ return self.get_run_facts(wfgrp, workflow_id, run_id).get("PolicyEvalResults") or {}
+
+ def delete_artifact(self, wfgrp, workflow_id, artifact_name):
+ """
+ Delete one artifact. Best-effort: returns True on success, False otherwise.
+
+ `artifact_name` must be a single path segment. A nested name is swallowed by the greedy
+ converter in the authorizer and matches `DELETE .../wfgrps//` -- the
+ workflow-group delete -- so it would be checked against entirely the wrong permission.
+ """
+ status, _payload = self._request(
+ "DELETE",
+ f"/wfgrps/{urllib.parse.quote(wfgrp)}/wfs/{workflow_id}/artifacts/{artifact_name}/",
+ )
+ return status in (200, 204, 404)
diff --git a/src/tirith/platform/discover.py b/src/tirith/platform/discover.py
new file mode 100644
index 00000000..61f9384c
--- /dev/null
+++ b/src/tirith/platform/discover.py
@@ -0,0 +1,131 @@
+"""
+Find the document to evaluate without being told where it is.
+
+Exists so a caller with a plan in the conventional place needs no configuration at all. It lives
+here rather than in the GitHub Action so GitLab, Jenkins and a local shell get the same behaviour.
+
+`terraform show -json` is also run from here, so a caller never has to write an unmasked plan to
+disk at all -- see `terraform_show_json` for why resolving the right binary matters.
+"""
+
+import json
+import os
+import shutil
+import subprocess
+
+# Tried in order. Two names, not a glob: a glob over *.json would sweep up an infracost breakdown or
+# a package manifest and evaluate it as a plan.
+PLAN_FILENAMES = ("plan.json", "tfplan.json")
+
+
+class DiscoveryError(Exception):
+ """No document could be resolved. Always fails closed."""
+
+
+def discover_input(source_dir):
+ """
+ Find the plan document in `source_dir`, by convention.
+
+ Two matches is an error rather than "first one wins". Silently evaluating the wrong document
+ would report a verdict about infrastructure the caller did not ask about, and look like a pass.
+ """
+ directory = source_dir or "."
+ found = [name for name in PLAN_FILENAMES if os.path.isfile(os.path.join(directory, name))]
+
+ if not found:
+ raise DiscoveryError(
+ f"No plan document found in {os.path.abspath(directory)}. Expected one of "
+ f"{' or '.join(PLAN_FILENAMES)}. Either write one with "
+ f"`terraform show -json tfplan > plan.json`, point --plan-file at the binary plan, or "
+ f"pass --input-path explicitly."
+ )
+
+ if len(found) > 1:
+ raise DiscoveryError(
+ f"Found {' and '.join(found)} in {os.path.abspath(directory)} and cannot tell which to "
+ f"evaluate. Pass --input-path to choose."
+ )
+
+ return os.path.join(directory, found[0])
+
+
+def _resolve_binary(explicit=None):
+ """
+ Find a terraform/tofu binary, preferring the real one over a wrapper.
+
+ `hashicorp/setup-terraform` installs a JS wrapper as `terraform` and moves the real binary to
+ `terraform-bin`. That wrapper calls `core.setOutput('stdout', ...)`, so invoking it for
+ `show -json` appends the *entire plan* to $GITHUB_OUTPUT -- an unmasked plan written to a file
+ every later step in the job can read. `opentofu/setup-opentofu` does the same with `tofu-bin`.
+
+ So the `-bin` names come first, and the wrappers are only a last resort.
+ """
+ if explicit:
+ return explicit
+
+ candidates = []
+ for env_var, binary in (("TERRAFORM_CLI_PATH", "terraform-bin"), ("TOFU_CLI_PATH", "tofu-bin")):
+ directory = os.environ.get(env_var)
+ if directory:
+ candidates.append(os.path.join(directory, binary))
+ candidates += ["terraform-bin", "tofu-bin", "terraform", "tofu"]
+
+ for candidate in candidates:
+ if os.path.isabs(candidate):
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ return candidate
+ else:
+ resolved = shutil.which(candidate)
+ if resolved:
+ return resolved
+
+ raise DiscoveryError(
+ "No terraform or tofu binary found on PATH. Pass --terraform-bin, or write the plan JSON "
+ "yourself and pass --input-path."
+ )
+
+
+def terraform_show_json(plan_file, workdir=None, binary=None):
+ """
+ Render a binary plan to JSON in memory.
+
+ The point is that nothing unmasked touches the disk: the JSON is parsed straight off the pipe
+ and handed to the masker. stdout is never logged, for the same reason.
+ """
+ executable = _resolve_binary(binary)
+ if not binary:
+ # setup-terraform and setup-opentofu both install a wrapper that echoes stdout into
+ # $GITHUB_OUTPUT, and both advertise it the same way. Guarding only the terraform spelling
+ # left the opentofu one to copy the whole unmasked plan into the step output.
+ for env_var, wrapper in (("TERRAFORM_CLI_PATH", "terraform"), ("TOFU_CLI_PATH", "tofu")):
+ if os.environ.get(env_var) and os.path.basename(executable) == wrapper:
+ # Only reachable if the -bin names were all absent, which means the wrapper was
+ # installed without its usual layout. Say so rather than silently leaking the plan.
+ raise DiscoveryError(
+ f"{env_var} is set but no {wrapper}-bin was found beside it, so the only "
+ f"{wrapper} on PATH is the setup wrapper. Running it would copy the whole plan "
+ f"into $GITHUB_OUTPUT. Pass --terraform-bin with the real binary."
+ )
+
+ directory = workdir or os.path.dirname(os.path.abspath(plan_file)) or "."
+ plan_arg = os.path.abspath(plan_file)
+
+ try:
+ completed = subprocess.run(
+ [executable, "show", "-json", plan_arg],
+ cwd=directory,
+ capture_output=True,
+ timeout=300,
+ )
+ except (OSError, subprocess.TimeoutExpired) as e:
+ raise DiscoveryError(f"Could not run `{executable} show -json`: {e}")
+
+ if completed.returncode != 0:
+ stderr = completed.stderr.decode("utf-8", "replace").strip()[:2000]
+ raise DiscoveryError(f"`{executable} show -json` failed (exit {completed.returncode}): {stderr}")
+
+ try:
+ return json.loads(completed.stdout)
+ except json.JSONDecodeError as e:
+ # Deliberately does not echo stdout: on the wrapper path it would be the whole plan.
+ raise DiscoveryError(f"`{executable} show -json` did not produce JSON: {e}")
diff --git a/src/tirith/platform/redact.py b/src/tirith/platform/redact.py
new file mode 100644
index 00000000..321eb2b6
--- /dev/null
+++ b/src/tirith/platform/redact.py
@@ -0,0 +1,679 @@
+"""
+Slim and mask terraform documents before they leave the runner.
+
+This runs client-side on purpose. Once bytes reach StackGuardian the exposure has already
+happened, so masking on the server would be theatre. Everything here is a pure function over
+parsed JSON so it can be tested exhaustively.
+
+A caveat worth stating plainly, and repeated in the README: terraform's `*_sensitive` markers are
+NOT exhaustive. A value that flows through `locals`, or comes from a provider that did not mark
+its schema, arrives marked `false` and will not be masked by marker-driven redaction. Slimming and
+the `variables` drop below exist partly to limit that blast radius.
+"""
+
+import copy
+
+SENTINEL = "__SG_REDACTED__"
+
+# Top-level plan sections tirith's terraform_plan provider never reads, verified against
+# providers/terraform_plan/handler.py:
+#
+# resource_changes -> attribute / action / count operations
+# configuration -> direct_dependencies, direct_references, provider_config (KEPT)
+# terraform_version -> terraform_version operation
+#
+# `planned_values` is the dangerous one. It mirrors every resource's values in a second place and
+# carries NO sensitivity markers of its own, so marker-driven redaction of `resource_changes`
+# leaves the same secret in plaintext here. Dropping it is lossless for evaluation and closes that
+# hole; a real plan leaked a `local_sensitive_file` body through exactly this path.
+SLIM_DROP_KEYS = ("prior_state", "planned_values")
+
+# Provider blocks whose `expressions` can hold hardcoded credentials. `configuration` cannot be
+# dropped wholesale -- three tirith operations read it -- so the credential-bearing part is
+# scrubbed instead, keeping the two fields provider_config_operator actually consults.
+_PROVIDER_CONFIG_KEEP = ("name", "full_name", "version_constraint", "module_address", "alias")
+
+
+def slim_plan(plan):
+ """
+ Drop plan sections that are irrelevant to evaluation.
+
+ Typically removes 60-90% of the bytes. `configuration` is deliberately retained but scrubbed
+ (see `_scrub_configuration`), because dropping it would silently break the
+ `direct_dependencies`, `direct_references` and `provider_config` operations -- policies would
+ stop finding what they are looking for rather than failing loudly.
+ """
+ if not isinstance(plan, dict):
+ return plan
+
+ slimmed = {k: v for k, v in plan.items() if k not in SLIM_DROP_KEYS}
+ if isinstance(slimmed.get("configuration"), dict):
+ slimmed["configuration"] = _scrub_configuration(slimmed["configuration"])
+ return slimmed
+
+
+def _scrub_configuration(configuration):
+ """
+ Strip credential-bearing expressions from `configuration` while keeping what tirith reads.
+
+ Two places hold literals, and both have to be scrubbed:
+
+ `provider_config[].expressions` -- `provider_config_operator` reads only `version_constraint`
+ and `expressions.region.constant_value`, so access keys, tokens and assume-role blocks can go.
+
+ `root_module.resources[].expressions[].constant_value` -- every literal written in the HCL,
+ including a hardcoded password. This is a third instance of the `planned_values` pattern: a
+ place values live that carries no sensitivity markers, so marker-driven masking of
+ `resource_changes` never touches it. Caught in QA -- a `local_sensitive_file` body was masked
+ in `resource_changes` and sat in plaintext here in the same document.
+
+ Dropping `constant_value` is lossless: `direct_references_operator` reads only `references`
+ from these expressions, and `direct_dependencies_operator` reads only `depends_on`
+ (providers/terraform_plan/handler.py:329, :385-388).
+ """
+ scrubbed = dict(configuration)
+
+ provider_config = scrubbed.get("provider_config")
+ if isinstance(provider_config, dict):
+ cleaned = {}
+ for name, block in provider_config.items():
+ if not isinstance(block, dict):
+ cleaned[name] = block
+ continue
+ kept = {k: v for k, v in block.items() if k in _PROVIDER_CONFIG_KEEP}
+ region = (block.get("expressions") or {}).get("region")
+ if region is not None:
+ kept["expressions"] = {"region": region}
+ cleaned[name] = kept
+ scrubbed["provider_config"] = cleaned
+
+ root_module = scrubbed.get("root_module")
+ if isinstance(root_module, dict):
+ scrubbed["root_module"] = _scrub_config_module(root_module)
+
+ return scrubbed
+
+
+def _scrub_config_module(module):
+ """Recursively drop literal values from a configuration module, keeping the reference graph."""
+ scrubbed = dict(module)
+
+ resources = scrubbed.get("resources")
+ if isinstance(resources, list):
+ scrubbed["resources"] = [_scrub_config_resource(r) for r in resources]
+
+ # Child modules nest the same shape under module_calls[].module.
+ module_calls = scrubbed.get("module_calls")
+ if isinstance(module_calls, dict):
+ calls = {}
+ for name, call in module_calls.items():
+ if isinstance(call, dict):
+ if isinstance(call.get("module"), dict):
+ call = {**call, "module": _scrub_config_module(call["module"])}
+ else:
+ call = dict(call)
+ # A module's own arguments are literals too. Dropped whether or not the call
+ # carries an inlined `module` body -- it did not when the module came from a
+ # registry or a git source, which is the common case, and the arguments passed to
+ # it are literals either way.
+ call.pop("expressions", None)
+ calls[name] = call
+ scrubbed["module_calls"] = calls
+
+ # Variable defaults and output values are literals with no operation reading them.
+ for section in ("variables", "outputs"):
+ if isinstance(scrubbed.get(section), dict):
+ scrubbed[section] = _scrub_config_section(scrubbed[section])
+
+ return scrubbed
+
+
+def _scrub_config_resource(resource):
+ if not isinstance(resource, dict):
+ return resource
+
+ scrubbed = dict(resource)
+
+ expressions = scrubbed.get("expressions")
+ if isinstance(expressions, dict):
+ scrubbed["expressions"] = {k: _keep_references(v) for k, v in expressions.items()}
+
+ # A provisioner carries its own expressions one level down -- `connection.password`, and the
+ # `inline` script itself. Scrubbing only the resource's own expressions left those verbatim,
+ # and a provisioner block is exactly where a password tends to be written literally.
+ provisioners = scrubbed.get("provisioners")
+ if isinstance(provisioners, list):
+ scrubbed["provisioners"] = [_scrub_config_resource(p) for p in provisioners]
+
+ # count/for_each are expressions in their own right, and a `for_each` over a map of literals
+ # carries those literals.
+ for key in ("count_expression", "for_each_expression"):
+ if key in scrubbed:
+ scrubbed[key] = _keep_references(scrubbed[key])
+
+ return scrubbed
+
+
+def _keep_references(expression):
+ """
+ Reduce one expression to just its `references`, dropping every literal.
+
+ Terraform nests expressions arbitrarily: a block argument is a dict of expressions, and a
+ repeated block is a list of them, so this recurses rather than looking one level deep.
+ """
+ if isinstance(expression, list):
+ return [_keep_references(item) for item in expression]
+ if not isinstance(expression, dict):
+ return expression
+ if "references" in expression or "constant_value" in expression:
+ # A leaf: keep only the reference graph.
+ return {"references": expression["references"]} if "references" in expression else {}
+ return {k: _keep_references(v) for k, v in expression.items()}
+
+
+def _scrub_config_section(section):
+ """Drop `default` / `expression` literals from variables and outputs."""
+ cleaned = {}
+ for name, entry in section.items():
+ if isinstance(entry, dict):
+ entry = {k: v for k, v in entry.items() if k not in ("default", "expression", "value")}
+ cleaned[name] = entry
+ return cleaned
+
+
+def _mask_by_marker(value, marker):
+ """
+ Walk `value` alongside terraform's parallel sensitivity structure `marker`.
+
+ A marker node of `true` masks the whole subtree beneath it. Dicts and lists are walked in
+ lockstep; anything else is returned untouched.
+ """
+ if marker is True:
+ return SENTINEL
+
+ if isinstance(marker, dict) and isinstance(value, dict):
+ return {k: _mask_by_marker(v, marker.get(k)) for k, v in value.items()}
+
+ if isinstance(marker, list) and isinstance(value, list):
+ # Terraform emits a marker list positionally aligned with the value list. A shorter
+ # marker list means the tail is not sensitive.
+ return [_mask_by_marker(item, marker[i] if i < len(marker) else None) for i, item in enumerate(value)]
+
+ return value
+
+
+# A value shorter than this is not swept. `_sweep_known_secrets` replaces exact string matches
+# everywhere, and a two-character secret would also match ids, regions and resource names -- mangling
+# the document the policies then evaluate. A real credential is longer than this; a two-character one
+# that leaks is the lesser harm against breaking every policy on the plan.
+MIN_SWEPT_SECRET_LENGTH = 6
+
+
+def _collect_sensitive_values(value, marker, found):
+ """Gather the plaintext strings terraform marked sensitive, so they can be swept elsewhere."""
+ if marker is True:
+ if isinstance(value, str) and len(value) >= MIN_SWEPT_SECRET_LENGTH:
+ found.add(value)
+ elif isinstance(value, (dict, list)):
+ _collect_all_strings(value, found)
+ return
+
+ if isinstance(marker, dict) and isinstance(value, dict):
+ for key, item in value.items():
+ _collect_sensitive_values(item, marker.get(key), found)
+ elif isinstance(marker, list) and isinstance(value, list):
+ for index, item in enumerate(value):
+ _collect_sensitive_values(item, marker[index] if index < len(marker) else None, found)
+
+
+def _collect_all_strings(node, found):
+ """Every string under a subtree terraform marked sensitive wholesale."""
+ if isinstance(node, dict):
+ for item in node.values():
+ _collect_all_strings(item, found)
+ elif isinstance(node, list):
+ for item in node:
+ _collect_all_strings(item, found)
+ elif isinstance(node, str) and len(node) >= MIN_SWEPT_SECRET_LENGTH:
+ found.add(node)
+
+
+def _sweep_known_secrets(node, secrets):
+ """
+ Replace any value terraform told us was sensitive *somewhere* with the sentinel *everywhere*.
+
+ The markers alone are not enough. A provider that computes a mirror of an attribute does not
+ inherit its sensitivity: an `aws_instance` with a sensitive value in `tags` is marked
+ `after_sensitive.tags.Password = true`, while `after_sensitive.tags_all` comes back `{}` even
+ though `tags_all` holds the identical plaintext. Every AWS resource with tags has `tags_all`, so
+ that single gap leaks any secret ever used in a tag.
+
+ Caught by an end-to-end test that downloaded the uploaded bundle and grepped it, not by the unit
+ suite -- which asserted the markers were honoured, and they were.
+
+ Exact string matches only: it cannot know that a *substring* is the secret without guessing, and a
+ guess here corrupts the document the policies read.
+ """
+ if not secrets:
+ return node
+ if isinstance(node, dict):
+ return {k: _sweep_known_secrets(v, secrets) for k, v in node.items()}
+ if isinstance(node, list):
+ return [_sweep_known_secrets(item, secrets) for item in node]
+ if isinstance(node, str) and node in secrets:
+ return SENTINEL
+ return node
+
+
+def _mask_resource_change(resource_change):
+ """Mask one `resource_changes`/`resource_drift` entry by its own before/after markers."""
+ if not isinstance(resource_change, dict):
+ return resource_change
+
+ masked = dict(resource_change)
+ change = masked.get("change")
+ if isinstance(change, dict):
+ masked_change = dict(change)
+ for value_key, marker_key in (("before", "before_sensitive"), ("after", "after_sensitive")):
+ if value_key in masked_change:
+ masked_change[value_key] = _mask_by_marker(masked_change[value_key], masked_change.get(marker_key))
+ masked["change"] = masked_change
+ return masked
+
+
+def redact_plan(plan):
+ """
+ Slim, then mask every value terraform flagged sensitive, then drop root `variables`.
+
+ `variables` goes wholesale because the plan does not reliably mark which root variables were
+ declared `sensitive = true` -- so the only safe assumption is that all of them might be.
+ """
+ plan = slim_plan(plan)
+ if not isinstance(plan, dict):
+ return plan
+
+ redacted = dict(plan)
+
+ # Collect the sensitive plaintext BEFORE masking replaces it, and before `variables` is dropped --
+ # a sensitive root variable is often the origin of the value that reappears elsewhere unmarked.
+ secrets = set()
+ for section in ("resource_changes", "resource_drift"):
+ for entry in redacted.get(section) or []:
+ change = (entry or {}).get("change") if isinstance(entry, dict) else None
+ if isinstance(change, dict):
+ for value_key, marker_key in (("before", "before_sensitive"), ("after", "after_sensitive")):
+ _collect_sensitive_values(change.get(value_key), change.get(marker_key), secrets)
+ for name, variable in (redacted.get("variables") or {}).items():
+ if isinstance(variable, dict):
+ _collect_all_strings(variable.get("value"), secrets)
+
+ redacted.pop("variables", None)
+
+ # resource_drift has the same shape and the same sensitivity markers as resource_changes, and
+ # terraform emits it whenever a refresh finds drift -- so a masked resource_changes sitting
+ # beside an unmasked resource_drift shipped the same secret in plaintext one key away.
+ for section in ("resource_changes", "resource_drift"):
+ entries = redacted.get(section)
+ if isinstance(entries, list):
+ redacted[section] = [_mask_resource_change(entry) for entry in entries]
+
+ output_changes = redacted.get("output_changes")
+ if isinstance(output_changes, dict):
+ redacted["output_changes"] = {name: _redact_output_change(change) for name, change in output_changes.items()}
+
+ # Rebuild planned_values from what we just masked. slim_plan dropped terraform's own copy
+ # because it carries no sensitivity markers; this one is derived from the masked
+ # resource_changes, so it holds the same redacted values.
+ planned_values = rebuild_planned_values(redacted.get("resource_changes"))
+ if planned_values:
+ redacted["planned_values"] = planned_values
+
+ # Last, over the whole document: anything terraform called sensitive somewhere is masked
+ # everywhere, including the unmarked provider-computed mirrors the markers miss.
+ return _sweep_known_secrets(redacted, secrets)
+
+
+def rebuild_planned_values(masked_resource_changes):
+ """
+ Reconstruct `planned_values` from already-masked `resource_changes`.
+
+ Infracost and Checkov both read `planned_values` and nothing else -- give them a plan without
+ it and they return a clean, empty, entirely wrong answer. Measured against infracost 0.10.27
+ with a real API key: the same t3.medium prices at $39.80 with the key present and $0.00
+ without, differing only by this one section.
+
+ Terraform's own copy cannot be shipped: it mirrors every value with NO sensitivity markers, so
+ masking `resource_changes` leaves the same secret in plaintext there -- a real plan leaked a
+ `local_sensitive_file` body through exactly that path. This rebuild sidesteps that because it
+ reads the *masked* values, after `_mask_by_marker` has run over them.
+
+ Only `after` is used, and only for resources that will exist. A destroy has no planned value,
+ and `before` is the pre-change state that `prior_state` carries -- which is dropped for the
+ same marker-less reason.
+ """
+ if not isinstance(masked_resource_changes, list):
+ return None
+
+ root = {"resources": [], "child_modules": []}
+ modules = {}
+
+ for resource_change in masked_resource_changes:
+ if not isinstance(resource_change, dict):
+ continue
+ change = resource_change.get("change")
+ if not isinstance(change, dict):
+ continue
+ if "delete" in (change.get("actions") or []) and "create" not in (change.get("actions") or []):
+ # Nothing is planned to exist, so there is nothing to price or scan.
+ continue
+ after = change.get("after")
+ if after is None:
+ continue
+
+ resource = {
+ key: resource_change[key]
+ for key in ("address", "mode", "type", "name", "index", "provider_name")
+ if key in resource_change
+ }
+ resource["values"] = after
+
+ module_address = resource_change.get("module_address")
+ if module_address:
+ modules.setdefault(module_address, {"address": module_address, "resources": []})["resources"].append(
+ resource
+ )
+ else:
+ root["resources"].append(resource)
+
+ if modules:
+ # Flat rather than a true nesting tree. Verified equivalent for pricing, and both tools
+ # address resources by their full `address`, which already encodes the module path.
+ root["child_modules"] = sorted(modules.values(), key=lambda m: m["address"])
+ else:
+ root.pop("child_modules")
+
+ if not root["resources"] and not root.get("child_modules"):
+ return None
+
+ return {"root_module": root}
+
+
+def _redact_output_change(change):
+ """
+ Mask a sensitive output's before/after values.
+
+ Terraform spells the marker differently across versions: older plans carry a single
+ `sensitive`, newer ones carry `before_sensitive` / `after_sensitive` per side. Checking only
+ `sensitive` silently missed every modern plan, so all three are honoured -- and each side is
+ masked independently, since an output can become sensitive without having been so before.
+
+ Only keys that are actually present are replaced. Adding an `after` to a create whose value is
+ still unknown (`after_unknown: true`) would invent data the plan never contained.
+ """
+ if not isinstance(change, dict):
+ return change
+
+ masked = dict(change)
+ whole = bool(change.get("sensitive"))
+
+ for side in ("before", "after"):
+ if side not in masked:
+ continue
+ if whole or change.get(f"{side}_sensitive") is True:
+ masked[side] = SENTINEL
+
+ return masked
+
+
+def redact_state(state):
+ """
+ Mask a terraform state document.
+
+ State is more dangerous than a plan: it holds every resource attribute in plaintext, including
+ values no plan would surface. Two rules, matching what the platform's terraform step applies:
+
+ - `outputs[k].sensitive` is true -> replace that output's value
+ - each key named in an instance's `sensitive_attributes` -> replace that attribute
+
+ Handles BOTH shapes a caller can plausibly hand us:
+
+ - the raw state (`terraform state pull`): top-level `resources` / `outputs`, with each
+ instance naming its own `sensitive_attributes`;
+ - `terraform show -json `: resources nested under `values.root_module.resources`, with
+ sensitivity carried in a parallel `sensitive_values` tree.
+
+ Handling only the first was a silent leak. The function returned the document unchanged for the
+ second -- no error, no warning -- so a state produced with `show -json`, which is the natural
+ way to get a readable one, shipped every attribute in plaintext. Caught by an end-to-end run,
+ not by a unit test, because the unit tests all used the shape the code already understood.
+ """
+ if not isinstance(state, dict):
+ return state
+
+ redacted = dict(state)
+
+ # Every plaintext we are about to mask, collected as we go and swept from the whole document at the
+ # end -- the same two-pass shape as redact_plan, and for the same reason.
+ #
+ # Marker-driven masking alone is not enough, because a provider writes computed *mirrors* of an
+ # attribute with no sensitivity marker of their own. The confirmed case is `tags_all`: a secret in
+ # `tags.Password` is masked there and shipped in plaintext one key away. A pen test found this exact
+ # hole in state after the equivalent had been closed for plans -- and state is the worse place for
+ # it, since state carries every attribute of every resource and the bundle is retained.
+ secrets = set()
+
+ values = redacted.get("values")
+ if isinstance(values, dict):
+ redacted["values"] = _redact_show_json_values(values, secrets)
+
+ outputs = redacted.get("outputs")
+ if isinstance(outputs, dict):
+ masked_outputs = {}
+ for name, output in outputs.items():
+ if isinstance(output, dict) and output.get("sensitive"):
+ _collect_all_strings(output.get("value"), secrets)
+ masked_outputs[name] = {**output, "value": SENTINEL}
+ else:
+ masked_outputs[name] = output
+ redacted["outputs"] = masked_outputs
+
+ resources = redacted.get("resources")
+ if isinstance(resources, list):
+ redacted["resources"] = [_redact_state_resource(r, secrets) for r in resources]
+
+ return _sweep_known_secrets(redacted, secrets)
+
+
+def _redact_show_json_values(values, secrets=None):
+ """
+ Mask the `values` tree of `terraform show -json ` output.
+
+ Same marker convention as a plan: a parallel `sensitive_values` tree whose truthy leaves name
+ the attributes to replace, so _mask_by_marker does the work. Recurses through child_modules,
+ since a module's resources are nested rather than flattened.
+ """
+ if not isinstance(values, dict):
+ return values
+
+ if secrets is None:
+ secrets = set()
+
+ masked = dict(values)
+ root = masked.get("root_module")
+ if isinstance(root, dict):
+ masked["root_module"] = _redact_show_json_module(root, secrets)
+
+ outputs = masked.get("outputs")
+ if isinstance(outputs, dict):
+ masked_outputs = {}
+ for name, o in outputs.items():
+ if isinstance(o, dict) and o.get("sensitive"):
+ _collect_all_strings(o.get("value"), secrets)
+ masked_outputs[name] = {**o, "value": SENTINEL}
+ else:
+ masked_outputs[name] = o
+ masked["outputs"] = masked_outputs
+ return masked
+
+
+def _redact_show_json_module(module, secrets=None):
+ if not isinstance(module, dict):
+ return module
+
+ if secrets is None:
+ secrets = set()
+
+ masked = dict(module)
+
+ resources = masked.get("resources")
+ if isinstance(resources, list):
+ out = []
+ for resource in resources:
+ if not isinstance(resource, dict):
+ out.append(resource)
+ continue
+ entry = dict(resource)
+ if "values" in entry:
+ # Collect before masking. The marker convention here is identical to a plan's, so this
+ # is the same call redact_plan makes over `change.before` / `change.after`.
+ _collect_sensitive_values(entry["values"], entry.get("sensitive_values"), secrets)
+ entry["values"] = _mask_by_marker(entry["values"], entry.get("sensitive_values"))
+ out.append(entry)
+ masked["resources"] = out
+
+ children = masked.get("child_modules")
+ if isinstance(children, list):
+ masked["child_modules"] = [_redact_show_json_module(c, secrets) for c in children]
+
+ return masked
+
+
+def _redact_state_resource(resource, secrets=None):
+ if not isinstance(resource, dict):
+ return resource
+
+ if secrets is None:
+ secrets = set()
+
+ instances = resource.get("instances")
+ if not isinstance(instances, list):
+ return resource
+
+ masked_instances = []
+ for instance in instances:
+ if not isinstance(instance, dict):
+ masked_instances.append(instance)
+ continue
+
+ masked = dict(instance)
+ attributes = masked.get("attributes")
+ sensitive_attributes = masked.get("sensitive_attributes") or []
+
+ if isinstance(attributes, dict) and sensitive_attributes:
+ masked_attributes = copy.deepcopy(attributes)
+ for sensitive_attribute in sensitive_attributes:
+ steps = _attribute_steps(sensitive_attribute)
+ # Read from the untouched original, not from the copy being masked: once the first path
+ # is masked the copy holds the sentinel there, and sweeping for that would do nothing.
+ plaintext = _read_attribute_path(attributes, steps)
+ if plaintext is not _ABSENT:
+ _collect_all_strings(plaintext, secrets)
+ _mask_attribute_path(masked_attributes, steps)
+ masked["attributes"] = masked_attributes
+
+ masked_instances.append(masked)
+
+ return {**resource, "instances": masked_instances}
+
+
+def _attribute_steps(sensitive_attribute):
+ """
+ Normalise one `sensitive_attributes` entry into a list of path steps.
+
+ Terraform writes each entry as a PATH -- a list of steps -- not a single key:
+
+ [[{"type": "get_attr", "value": "content_base64"}],
+ [{"type": "get_attr", "value": "content"}]]
+
+ Reading only the flat forms silently masked nothing at all on real state, because a list is
+ neither a dict nor a string. Verified against `terraform state pull` output for a
+ `local_sensitive_file`; the earlier unit tests passed only because their fixture invented the
+ flat shape.
+
+ The two flat forms are still accepted: some providers and older state versions emit them.
+ """
+ if isinstance(sensitive_attribute, list):
+ entries = sensitive_attribute
+ else:
+ entries = [sensitive_attribute]
+
+ steps = []
+ for entry in entries:
+ if isinstance(entry, dict):
+ steps.append(entry.get("value"))
+ elif isinstance(entry, (str, int)):
+ steps.append(entry)
+ else:
+ # An unrecognised step means the path cannot be trusted; masking a guessed location
+ # would be worse than reporting nothing.
+ return []
+ return steps
+
+
+_ABSENT = object()
+
+
+def _read_attribute_path(container, steps):
+ """
+ Return the value at `steps` within `container`, or `_ABSENT`.
+
+ The mirror of `_mask_attribute_path` below, and deliberately the same walk: the two have to agree
+ on what a path means, or the sweep collects a different value from the one that was masked.
+ """
+ if not steps:
+ return _ABSENT
+
+ node = container
+ for step in steps:
+ if isinstance(node, dict) and step in node:
+ node = node[step]
+ elif isinstance(node, list) and isinstance(step, int) and 0 <= step < len(node):
+ node = node[step]
+ else:
+ return _ABSENT
+ return node
+
+
+def _mask_attribute_path(container, steps):
+ """
+ Replace the value at `steps` within `container` with the sentinel.
+
+ A path may descend through nested objects and list indices -- `[{"get_attr": "config"},
+ {"index": 0}, {"get_attr": "token"}]` -- so this walks rather than assuming one level.
+ """
+ if not steps:
+ return
+
+ *parents, leaf = steps
+ node = container
+ for step in parents:
+ if isinstance(node, dict) and step in node:
+ node = node[step]
+ elif isinstance(node, list) and isinstance(step, int) and 0 <= step < len(node):
+ node = node[step]
+ else:
+ return
+
+ if isinstance(node, dict) and leaf in node:
+ node[leaf] = SENTINEL
+ elif isinstance(node, list) and isinstance(leaf, int) and 0 <= leaf < len(node):
+ node[leaf] = SENTINEL
+
+
+def count_redactions(document):
+ """Count sentinel occurrences, for the attestation the action sends with the upload."""
+ if isinstance(document, dict):
+ return sum(count_redactions(v) for v in document.values())
+ if isinstance(document, list):
+ return sum(count_redactions(v) for v in document)
+ return 1 if document == SENTINEL else 0
diff --git a/src/tirith/platform/regions.py b/src/tirith/platform/regions.py
new file mode 100644
index 00000000..06df0a8b
--- /dev/null
+++ b/src/tirith/platform/regions.py
@@ -0,0 +1,145 @@
+"""
+StackGuardian regions, and the one place URLs are resolved.
+
+A region is a well-known (API, dashboard) pair, so asking a caller for both URLs is asking them to
+keep two constants in sync for no reason. Getting it half right is the common failure: overriding
+only the API leaves every run link in every PR comment pointing at the wrong environment, which
+looks like a broken integration rather than a misconfiguration.
+
+`region` is the same identifier the Raycast extension uses, so a user who has configured one
+recognises the other.
+
+Note the API base here excludes `/api/v1`, matching Raycast, sg-cli and the terraform provider.
+`--api-url` and `$SG_BASE_URL` have always included it, and `normalize_api_url` accepts both -- a
+value exported for sg-cli previously produced 404s from tirith.
+"""
+
+import collections
+
+Region = collections.namedtuple("Region", "id name api_base app_base")
+
+# Only production regions are listed. Internal environments are reachable through --api-url /
+# $SG_BASE_URL, which is also what a self-hosted or vanity host (api..stackguardian.io)
+# needs, so they are supported rather than merely tolerated.
+#
+# The dashboard uses a third spelling for the same regions ('eu1-europe' / 'us1-east'). These ids are
+# the CLI and action spelling; there are two regions, not four.
+REGIONS = (
+ Region("eu", "Europe", "https://api.app.stackguardian.io", "https://app.stackguardian.io"),
+ Region("us", "United States", "https://api.us.stackguardian.io", "https://us.stackguardian.io"),
+)
+
+DEFAULT_REGION_ID = "eu"
+
+REGION_IDS = tuple(region.id for region in REGIONS)
+
+API_PATH = "/api/v1"
+
+
+def by_id(region_id):
+ """
+ Look up a region, raising on an unknown id.
+
+ Deliberately not the "fall back to the first region" behaviour the Raycast extension uses:
+ here a typo would silently evaluate a US org's infrastructure against production EU, and the
+ only symptom would be an authentication error the user cannot explain.
+ """
+ for region in REGIONS:
+ if region.id == region_id:
+ return region
+ raise ValueError(f"Unknown region '{region_id}'. Valid regions: {', '.join(REGION_IDS)}")
+
+
+def normalize_api_url(api_url):
+ """
+ Accept an API base with or without the `/api/v1` suffix.
+
+ tirith's own flag has always included it; every other StackGuardian client omits it. Rejecting
+ one spelling would be a papercut for anyone who has already exported SG_BASE_URL for sg-cli.
+ """
+ trimmed = (api_url or "").rstrip("/")
+ if not trimmed:
+ return trimmed
+ if trimmed.endswith(API_PATH):
+ return trimmed
+ return f"{trimmed}{API_PATH}"
+
+
+def by_api_url(api_url):
+ """Find the region an API URL belongs to, tolerating the `/api/v1` suffix. None if unknown."""
+ normalized = normalize_api_url(api_url)
+ for region in REGIONS:
+ if normalized == normalize_api_url(region.api_base):
+ return region
+ return None
+
+
+def resolve(region_id=None, api_url=None, dashboard_url=None, env=None):
+ """
+ Resolve (api_url, dashboard_url, warnings) from a region, explicit URLs and the environment.
+
+ Precedence, highest first:
+
+ 1. explicit --api-url / --dashboard-url
+ 2. --region
+ 3. $SG_BASE_URL / $SG_DASHBOARD_URL, then $SG_REGION
+ 4. the default region
+
+ Explicit URLs beat a region because they are the only way to reach a self-hosted install, so
+ they have to keep working permanently rather than as a deprecation shim. Passing both a region
+ and an explicit URL is a caller error -- they contradict each other, and silently picking one
+ would hide it.
+
+ A URL environment variable beats $SG_REGION rather than erroring: environment is inherited
+ config the caller may not control, and failing a CI run over it would be unhelpful.
+ """
+ env = {} if env is None else env
+ warnings = []
+
+ env_api_url = env.get("SG_BASE_URL")
+ env_dashboard_url = env.get("SG_DASHBOARD_URL")
+ env_region_id = env.get("SG_REGION")
+
+ if region_id and (api_url or dashboard_url):
+ which = " and ".join(
+ name for name, value in (("--api-url", api_url), ("--dashboard-url", dashboard_url)) if value
+ )
+ raise ValueError(f"--region and {which} cannot be combined; they set the same thing")
+
+ effective_region_id = region_id or env_region_id
+ if effective_region_id and not region_id and (env_api_url or env_dashboard_url):
+ warnings.append(
+ f"both $SG_REGION and $SG_BASE_URL/$SG_DASHBOARD_URL are set; using the URLs and "
+ f"ignoring region '{effective_region_id}'"
+ )
+ effective_region_id = None
+
+ if effective_region_id:
+ region = by_id(effective_region_id)
+ return normalize_api_url(region.api_base), region.app_base, warnings
+
+ resolved_api = api_url or env_api_url
+ resolved_dashboard = dashboard_url or env_dashboard_url
+ default_region = by_id(DEFAULT_REGION_ID)
+
+ if not resolved_api and not resolved_dashboard:
+ return normalize_api_url(default_region.api_base), default_region.app_base, warnings
+
+ if not resolved_api:
+ resolved_api = default_region.api_base
+
+ if not resolved_dashboard:
+ # The footgun this function exists for: setting only the API leaves every run link pointing
+ # at the default environment. Infer the dashboard when the API is a region we know, and say
+ # so out loud when it is not.
+ matched = by_api_url(resolved_api)
+ if matched:
+ resolved_dashboard = matched.app_base
+ else:
+ resolved_dashboard = default_region.app_base
+ warnings.append(
+ f"no dashboard URL given and '{resolved_api}' is not a known region, so run links "
+ f"will point at {resolved_dashboard}; pass --dashboard-url to fix them"
+ )
+
+ return normalize_api_url(resolved_api), resolved_dashboard.rstrip("/"), warnings
diff --git a/src/tirith/platform/report.py b/src/tirith/platform/report.py
new file mode 100644
index 00000000..ffb15cd5
--- /dev/null
+++ b/src/tirith/platform/report.py
@@ -0,0 +1,442 @@
+"""
+Turn PolicyEvalResults into a PR comment body, a check-run summary, and a verdict.
+
+Pure functions over the results document so the layout and the truncation arithmetic can be tested
+without touching a network.
+"""
+
+import html
+
+FAIL = "FAIL"
+WARN = "WARN"
+PASS = "PASS"
+APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
+
+# Anything the step reports that is not one of the four above. It is counted separately and treated
+# as unresolved rather than folded into any of them: a result this module does not understand is not
+# evidence of a pass, and bucketing it under a key `verdict` never inspects made it one.
+UNKNOWN = "UNKNOWN"
+
+# GitHub rejects an issue-comment body over 65536 characters and a check-run output.summary over
+# 65535. Budget well under both: the count that matters is characters after rendering, and a
+# 422 at the end of a run is a bad way to find out.
+COMMENT_LIMIT = 60000
+
+_ICONS = {FAIL: "❌", WARN: "⚠️", APPROVAL_REQUIRED: "⏳", PASS: "✅", UNKNOWN: "❓"}
+
+
+def summarize(policy_results):
+ """
+ Collapse the results into counts plus a flat finding list.
+
+ A rule marked `skip` carries no verdict, so it is counted separately rather than being
+ folded into passes -- reporting a skipped control as passing is the kind of quiet
+ inaccuracy this whole design exists to avoid.
+ """
+ counts = {FAIL: 0, WARN: 0, APPROVAL_REQUIRED: 0, PASS: 0, "SKIPPED": 0, UNKNOWN: 0}
+ findings = []
+
+ for policy_id, rules in sorted((policy_results or {}).items()):
+ for rule in rules or []:
+ if rule.get("skip"):
+ counts["SKIPPED"] += 1
+ findings.append(
+ {
+ "policy_id": policy_id,
+ "rule_name": rule.get("rule_name", ""),
+ "result": "SKIPPED",
+ "messages": [],
+ "resources": [],
+ }
+ )
+ continue
+
+ # No default of PASS: a rule the step wrote without a `result`, or with one this module
+ # does not know, is unresolved. Defaulting to PASS turned "we cannot tell" into a clean
+ # bill of health, and an unrecognised value landed in a count key `verdict` never reads,
+ # so it disappeared entirely.
+ result = rule.get("result")
+ if result not in (FAIL, WARN, APPROVAL_REQUIRED, PASS):
+ result = UNKNOWN
+ counts[result] = counts.get(result, 0) + 1
+ messages, resources = _extract_detail(rule)
+ findings.append(
+ {
+ "policy_id": policy_id,
+ "rule_name": rule.get("rule_name", ""),
+ "result": result,
+ "messages": messages,
+ "resources": resources,
+ }
+ )
+
+ return counts, findings
+
+
+def _extract_detail(rule):
+ """Pull human-readable messages and resource addresses out of a rule's evaluations."""
+ messages = []
+ resources = []
+
+ for entry in (rule.get("evaluations") or {}).get("fails") or []:
+ if "exec_err" in entry:
+ # An engine/config problem rather than a policy violation -- surfaced verbatim so a
+ # malformed policy is not mistaken for a real finding.
+ messages.append(f"engine: {entry['exec_err']}")
+ continue
+
+ # Checkov findings are shaped differently from tirith's: {"description", "keys"} rather
+ # than a list under "result". Reading only the tirith shape rendered a Checkov policy as an
+ # empty block -- a dozen real findings, silently blank, in the one place a
+ # reviewer looks.
+ #
+ # Both shapes are read here rather than dispatched between, because an entry can carry both
+ # keys. A tirith rule sets `description` to "" when the policy declares none and puts the
+ # finding under `result`; branching on the *presence* of `description` therefore matched the
+ # Checkov shape, found nothing to say, and skipped the `result` loop -- reproducing exactly
+ # the blank block above for cost rules. This is additive: a Checkov entry has no `result`,
+ # so its loop is a no-op.
+ description = entry.get("description")
+ if description:
+ messages.append(description)
+ for key in entry.get("keys") or []:
+ # `aws_instance.app.root_block_device` -> `aws_instance.app`. The suffix is the
+ # attribute the check looked at; the address is what a reviewer navigates by.
+ address = _resource_address(key)
+ if address and address not in resources:
+ resources.append(address)
+
+ for evaluation in entry.get("result") or []:
+ message = evaluation.get("message")
+ if message:
+ messages.append(message)
+ # Only the terraform_plan provider populates meta; others set it to None.
+ meta = evaluation.get("meta") or {}
+ address = meta.get("address") if isinstance(meta, dict) else None
+ if address and address not in resources:
+ resources.append(address)
+
+ return messages, resources
+
+
+def _resource_address(key):
+ """
+ Reduce a Checkov evaluated key to the resource address it belongs to.
+
+ Checkov reports `..`, and the attribute path can be arbitrarily
+ deep (`aws_s3_bucket.data.rule.apply_server_side_encryption_by_default.sse_algorithm`). The
+ first two segments are the address; everything after is what the check inspected.
+ """
+ if not isinstance(key, str):
+ return None
+ parts = key.split(".")
+ if len(parts) < 2:
+ return None
+ return ".".join(parts[:2])
+
+
+def verdict(counts, run_status):
+ """
+ Reduce counts and run status to one word.
+
+ failed | warned | passed | no-policies | errored
+
+ `errored` covers a run that never produced a verdict -- an ERRORED/CANCELLED run, or results
+ that came back empty. It is deliberately distinct from `failed` so the caller can tell "a
+ policy said no" from "we do not know", and never conflate either with a pass.
+
+ A policy carrying `onFail: APPROVAL_REQUIRED` warns; it does not gate. That is a deliberate
+ interim position, because for these runs there is nothing to approve. The step exits 0 (it never
+ uses exit 11), so the run reaches COMPLETED, and the run controller engages an approval only on
+ exit 11 and skips it on the last step anyway -- and a policy-only run has exactly one step. So
+ the approval intent arrives as a count on an already-finished run, with no approval to act on.
+ Blocking on it produced a red check with nothing to click.
+
+ The count, the icon and the "N need approval" phrase in the headline all survive, so the policy
+ author's intent is still visible in the comment. Gating on it properly needs a run that stays
+ open, an approve action on it, and this client re-polling afterwards -- none of which exist yet.
+
+ Run status APPROVAL_REQUIRED means the platform itself paused the run. It is ranked by the same
+ ladder and then floored, rather than short-circuited: an early return there let a paused run
+ carrying a FAIL report `warned`, which is the one direction that must never happen. And because
+ a paused run did not finish, it can never rank better than `warned` either -- the policies that
+ would have run after the pause did not, so "everything passed" is not something we know.
+
+ A rule whose result this module does not recognise counts as UNKNOWN and lands in `errored`.
+ "We cannot tell" is not a pass.
+ """
+ if run_status not in ("COMPLETED", "APPROVAL_REQUIRED"):
+ return "errored"
+
+ paused = run_status == "APPROVAL_REQUIRED"
+
+ if counts.get(FAIL):
+ return "failed"
+ # An unreadable result outranks a warning: part of the evaluation is unaccounted for.
+ if counts.get(UNKNOWN):
+ return "errored"
+ if counts.get(APPROVAL_REQUIRED) or counts.get(WARN):
+ return "warned"
+ if counts.get(PASS) or counts.get("SKIPPED"):
+ return "warned" if paused else "passed"
+ # No policy results at all. On a COMPLETED run that means nothing was in scope -- worth saying,
+ # rather than implying a clean bill of health. On a paused run it means the evaluation never got
+ # far enough to produce any, which is not "nothing in scope" but "we do not know".
+ return "errored" if paused else "no-policies"
+
+
+def headline(counts, verdict_value):
+ if verdict_value == "errored":
+ return "Tirith could not evaluate policies"
+ if verdict_value == "no-policies":
+ return "Tirith — no policies in scope for this workflow"
+
+ parts = []
+ for key, label in ((FAIL, "failed"), (APPROVAL_REQUIRED, "need approval"), (WARN, "warned")):
+ if counts.get(key):
+ parts.append(f"{counts[key]} {label}")
+ if counts.get(PASS):
+ parts.append(f"{counts[PASS]} passed")
+ if counts.get("SKIPPED"):
+ parts.append(f"{counts['SKIPPED']} skipped")
+ return "Tirith — " + (", ".join(parts) if parts else "nothing evaluated")
+
+
+def _short_commit(commit):
+ """
+ Seven characters, the length git itself abbreviates to.
+
+ Anything that is not a hex sha is passed through untouched -- a tag or a branch name is more
+ useful whole, and truncating one would produce something that looks like a sha and is not.
+ """
+ text = str(commit).strip()
+ if len(text) > 7 and all(c in "0123456789abcdefABCDEF" for c in text):
+ return text[:7]
+ return text
+
+
+def render_cost(breakdown):
+ """
+ One line of cost, for the pull-request comment.
+
+ Rendered even when the estimate is zero or failed -- silence would be indistinguishable from
+ "this change costs nothing", and those are very different things to tell a reviewer.
+ Returns [] only when no estimate was attempted at all.
+ """
+ if not isinstance(breakdown, dict) or not breakdown:
+ return []
+
+ if breakdown.get("error"):
+ return ["", "💵 Cost estimate unavailable for this plan."]
+
+ currency = breakdown.get("currency") or "USD"
+ monthly = breakdown.get("totalMonthlyCost")
+ diff = breakdown.get("diffTotalMonthlyCost")
+
+ if monthly is None:
+ return []
+
+ try:
+ monthly_text = f"{float(monthly):,.2f}"
+ except (TypeError, ValueError):
+ monthly_text = str(monthly)
+
+ line = f"💵 Estimated monthly cost: **{_html(monthly_text)} {_html(currency)}**"
+
+ # Infracost fills the diff from the plan's prior state, so it is the number a reviewer of a
+ # change actually wants. Only shown when it is non-zero and distinguishable from the total.
+ try:
+ delta = float(diff)
+ except (TypeError, ValueError):
+ delta = None
+ if delta:
+ line += f" ({'+' if delta > 0 else '−'}{abs(delta):,.2f} from this change)"
+
+ return ["", f"{line}"]
+
+
+def render_markdown(
+ policy_results, run_status, run_url, marker=None, limit=COMMENT_LIMIT, cost_breakdown=None, commit=None
+):
+ """
+ Render the results as markdown, truncating detail before the summary table.
+
+ `marker` is an opaque first line the caller can use to find this document again -- GitHub's
+ sticky-comment marker, for instance. Kept as a parameter rather than built here so this module
+ stays VCS-agnostic.
+
+ `commit` is the revision these findings describe. It matters because the comment is *edited in
+ place* across runs: without it a reader has no way to tell whether the verdict they are looking
+ at is about the head of the branch or about a push from an hour ago. Rendered here rather than
+ appended by the caller so the check-run summary and the job summary carry it too.
+ """
+ counts, findings = summarize(policy_results)
+ verdict_value = verdict(counts, run_status)
+
+ header = ([marker, ""] if marker else []) + [
+ f"## 🛡️ {headline(counts, verdict_value)}",
+ "",
+ ]
+ if commit:
+ header += [f"Scanned commit {_html(_short_commit(commit))}", ""]
+
+ if verdict_value == "errored":
+ # Two different reasons land here, and saying the wrong one is worse than saying nothing:
+ # a run that produced NOTHING, and a run whose results included one this tool cannot read.
+ # The second renders a populated table, under which "without producing policy results"
+ # reads as a plain contradiction.
+ if counts.get(UNKNOWN):
+ header += [
+ f"{counts[UNKNOWN]} policy result(s) could not be read, so this run has no verdict.",
+ "This is reported as a failure rather than a pass: partial results are not a clean bill of health.",
+ "",
+ ]
+ else:
+ header += [
+ f"The workflow run finished as {_code(run_status)} without producing policy results.",
+ "This is reported as a failure rather than a pass: no verdict is not the same as a clean one.",
+ "",
+ ]
+
+ table = _render_table(findings)
+ # Ahead of the footer so the cost sits directly under the findings, and outside the truncation
+ # path below -- a long findings list must not push the cost line out of the comment.
+ cost = render_cost(cost_breakdown)
+ footer = cost + _render_footer(counts, run_url)
+
+ detail_sections = [_render_detail(f) for f in findings if f["result"] in (FAIL, APPROVAL_REQUIRED, WARN, UNKNOWN)]
+
+ body = "\n".join(header + table + detail_sections + footer)
+ if len(body) <= limit:
+ return body
+
+ # Drop detail sections from the end until it fits, keeping the summary table intact -- the
+ # table is the part a reviewer scans first.
+ kept = list(detail_sections)
+ while kept and len(body) > limit:
+ kept.pop()
+ omitted = len(detail_sections) - len(kept)
+ note = [f"", f"_… and {omitted} more finding(s). See the full run in StackGuardian._", ""]
+ body = "\n".join(header + table + kept + note + footer)
+
+ if len(body) > limit:
+ # Even the table is too large; truncate hard rather than risk a 422.
+ body = body[: limit - 200] + "\n\n_… truncated. See the full run in StackGuardian._\n"
+
+ return body
+
+
+def _code(value, in_table=False):
+ r"""
+ Render an untrusted value as an inline code span that cannot be closed from inside it.
+
+ A penetration test found the report spoofable: every plan- and policy-derived string was
+ interpolated raw or wrapped in a single backtick, and a backtick *in the value* closes that span so
+ the remainder renders as markdown and HTML. A pull-request author controls the terraform a plan is
+ made from, so they controlled the gate report a reviewer reads -- a fake "all policies passed"
+ banner, a stray `` collapsing the real findings, or a link whose text says one domain and
+ whose href says another. The verdict and exit code were never affected; the report was.
+
+ Escaping the dangerous characters was the other option and is worse here. The engine deliberately
+ puts backticks in its own messages (`json_format_value` wraps every compared value in one), so
+ escaping them would put visible backslashes through every finding a reviewer reads, and it only
+ holds while the list of dangerous characters stays complete.
+
+ A code span is inert by construction instead: per CommonMark a span opened by N backticks contains
+ any run of fewer than N, so a fence one longer than the longest run inside the value cannot be
+ closed by it. Nothing inside is interpreted -- no HTML, no links, no emphasis -- with no list to
+ enumerate.
+
+ Two things a fence does not fix, both handled here:
+ * a newline ends the span, and ends a table row with it, so newlines collapse to a space;
+ * a pipe splits a table cell even inside a code span, and GFM's documented remedy is `\|`, which
+ is the one escape that works in there. Only applied for table cells, since outside a table the
+ backslash would show.
+ """
+ text = "" if value is None else str(value)
+ text = " ".join(text.split())
+ if in_table:
+ text = text.replace("|", "\\|")
+
+ longest = 0
+ run = 0
+ for character in text:
+ run = run + 1 if character == "`" else 0
+ longest = max(longest, run)
+
+ fence = "`" * (longest + 1)
+ # A span whose content starts or ends with a backtick needs padding, or the delimiters merge.
+ pad = " " if text.startswith("`") or text.endswith("`") else ""
+ return f"{fence}{pad}{text}{pad}{fence}"
+
+
+def _html(value):
+ """
+ Escape an untrusted value for interpolation into inline HTML.
+
+ A code span is the wrong tool inside ``, ``, `` or an attribute: GFM does not
+ reliably render markdown inside inline HTML, so the span would show as literal backticks. What
+ matters in an HTML context is that the value cannot terminate the element or the attribute it sits
+ in, which is what escaping the four characters does. Backticks are harmless here -- there is no
+ span to close.
+
+ Backticks are escaped too, which `html.escape` does not do. They cannot close a span here because
+ there is none -- but an *odd* one can OPEN a span that runs on and swallows the markdown after it,
+ so a value like ``cost-control` `` inside `` still distorts the report even with the tags
+ neutralised. Turning it into an entity leaves it visible and inert.
+ """
+ escaped = html.escape("" if value is None else str(value), quote=True)
+ return escaped.replace("`", "`")
+
+
+def _render_table(findings):
+ if not findings:
+ return []
+ rows = [
+ "| | Policy | Rule | Resource |",
+ "|---|---|---|---|",
+ ]
+ for finding in findings:
+ icon = _ICONS.get(finding["result"], "⚪")
+ resources = ", ".join(_code(r, in_table=True) for r in finding["resources"][:3]) or "—"
+ if len(finding["resources"]) > 3:
+ resources += f" _+{len(finding['resources']) - 3}_"
+ rows.append(
+ f"| {icon} | {_code(finding['policy_id'], in_table=True)} "
+ f"| {_code(finding['rule_name'], in_table=True)} | {resources} |"
+ )
+ rows.append("")
+ return rows
+
+
+def _render_detail(finding):
+ icon = _ICONS.get(finding["result"], "⚪")
+ lines = [
+ "",
+ f"{icon} {_html(finding['policy_id'])} › {_html(finding['rule_name'])}" "",
+ "",
+ ]
+ for message in finding["messages"][:20]:
+ lines.append(f"- {_code(message)}")
+ if len(finding["messages"]) > 20:
+ lines.append(f"- _… and {len(finding['messages']) - 20} more_")
+ if finding["resources"]:
+ lines += ["", "Resources:"] + [f"- {_code(r)}" for r in finding["resources"][:20]]
+ lines += ["", "", ""]
+ return "\n".join(lines)
+
+
+def _render_footer(counts, run_url):
+ bits = []
+ if counts.get(PASS):
+ bits.append(f"✅ {counts[PASS]} passed")
+ if counts.get("SKIPPED"):
+ bits.append(f"⚪ {counts['SKIPPED']} skipped")
+ if run_url:
+ bits.append(f'View run in StackGuardian')
+ return ["", f"{' · '.join(bits)}"] if bits else []
+
+
+def strip_marker(body):
+ """Drop the marker line, for a rendering target that has no use for it."""
+ return "\n".join(line for line in body.split("\n") if not line.startswith("[//]: <>"))
diff --git a/src/tirith/prettyprinter.py b/src/tirith/prettyprinter.py
index 4134ba74..599f4100 100644
--- a/src/tirith/prettyprinter.py
+++ b/src/tirith/prettyprinter.py
@@ -97,7 +97,7 @@ def pretty_print_result_dict(final_result_dict: Dict) -> None:
print(f" {TermStyle.fail('FAILED')}")
num_failed_checks += 1
- for result_num, result_dict in enumerate(check_dict["result"]):
+ for result_num, result_dict in enumerate(check_dict.get("result", [])):
result_message = result_dict["message"]
if result_dict["passed"]:
print(TermStyle.green(f" {result_num+1}. PASSED: {result_message}"))
diff --git a/src/tirith/status.py b/src/tirith/status.py
index d7ee3217..b5605ae7 100644
--- a/src/tirith/status.py
+++ b/src/tirith/status.py
@@ -9,6 +9,11 @@ class ExitStatus(IntEnum):
ERROR = 1
ERROR_TIMEOUT = 2
+ # A policy said no, under `--fail-on-error`. Distinct from ERROR so a caller can
+ # tell "your infrastructure violates a policy" from "tirith could not reach the platform" --
+ # the same distinction --fail-on-error exists to draw, one level up.
+ ERROR_POLICY_FAILED = 3
+
#
# 128+2 SIGINT
ERROR_CTRL_C = 130
diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py
new file mode 100644
index 00000000..df0c012a
--- /dev/null
+++ b/tests/cli/test_dispatch.py
@@ -0,0 +1,108 @@
+"""
+Tests for subcommand dispatch.
+
+The local-evaluation surface is a contract: the platform and the workflow-step templates parse its
+--json output, and tests/core/test_output_compatibility.py asserts that output byte-for-byte.
+Adding `tirith platform` must leave it completely untouched, including its single-dash long
+options, which argparse cannot express alongside a subparser.
+"""
+
+import json
+import os
+
+import pytest
+
+from tirith import cli
+from tirith.status import ExitStatus
+
+FIXTURES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "providers", "json")
+POLICY = os.path.join(FIXTURES, "policy.json")
+INPUT = os.path.join(FIXTURES, "input.json")
+
+
+def test_legacy_invocation_still_works(capsys):
+ """The flat parser must keep working exactly as before, driven through main(args=...)."""
+ status = cli.main(["-policy-path", POLICY, "-input-path", INPUT, "--json"])
+
+ assert status == ExitStatus.SUCCESS
+ document = json.loads(capsys.readouterr().out)
+ assert "final_result" in document
+ assert "evaluators" in document
+
+
+def test_main_honours_its_args_parameter(capsys):
+ """
+ It did not before: parse_args() was called with no argument, so main(args=...) was ignored and
+ the CLI always read sys.argv. That made it untestable and undrivable from another program.
+ """
+ status = cli.main(["-policy-path", POLICY, "-input-path", INPUT, "--json"])
+
+ assert status == ExitStatus.SUCCESS
+ assert capsys.readouterr().out.strip().startswith("{")
+
+
+def test_no_arguments_prints_help(capsys):
+ """
+ Pre-existing behaviour, asserted so the dispatcher does not change it: the sys.exit(0) is
+ caught by main's own SystemExit handler, which returns None for a zero code. __main__ treats
+ that as success.
+ """
+ status = cli.main([])
+
+ assert not status
+ assert "usage" in capsys.readouterr().out.lower()
+
+
+def test_platform_is_dispatched_to_the_subcommand(capsys):
+ """`platform` with no subcommand prints the platform help, not the local-evaluation help."""
+ status = cli.main(["platform"])
+
+ assert status == ExitStatus.SUCCESS
+ assert "tirith platform" in capsys.readouterr().out
+
+
+def test_platform_check_requires_credentials(capsys, monkeypatch):
+ monkeypatch.delenv("SG_API_TOKEN", raising=False)
+ monkeypatch.delenv("SG_ORG", raising=False)
+
+ status = cli.main(["platform", "check", "--workflow-id", "wf", "--input-path", INPUT])
+
+ assert status == ExitStatus.ERROR
+ assert "--api-key" in capsys.readouterr().err
+
+
+def test_platform_check_requires_a_document(capsys, monkeypatch):
+ monkeypatch.setenv("SG_API_TOKEN", "sgo_x")
+ monkeypatch.setenv("SG_ORG", "acme")
+
+ status = cli.main(["platform", "check", "--workflow-id", "wf"])
+
+ assert status == ExitStatus.ERROR
+ assert "--input-path" in capsys.readouterr().err
+
+
+def test_a_bare_word_is_not_mistaken_for_a_subcommand(capsys):
+ """
+ Only names in SUBCOMMANDS dispatch; anything else goes to the flat parser.
+
+ `check` in particular must stay out: making it a top-level verb would mean the policy *source*
+ depended on whether SG_API_TOKEN happened to be exported, so an ambient environment variable could
+ silently swap local policy files for an organization's enforced set.
+ """
+ assert cli.SUBCOMMAND == "platform"
+ assert "platform" in cli.SUBCOMMANDS
+ assert "check" not in cli.SUBCOMMANDS
+
+
+def test_there_is_exactly_one_subcommand_name(capsys):
+ """
+ `platform` was briefly renamed to `remote` and then reverted. Neither direction kept an alias --
+ nothing is released, so there was never a caller to keep working -- and this pins the outcome: one
+ name, and `remote` is not quietly still accepted.
+ """
+ assert cli.SUBCOMMANDS == {"platform"}
+
+ status = cli.main(["remote"])
+
+ assert status != ExitStatus.SUCCESS
+ assert "tirith platform" not in capsys.readouterr().out
diff --git a/tests/cli/test_local_gating.py b/tests/cli/test_local_gating.py
new file mode 100644
index 00000000..157943c3
--- /dev/null
+++ b/tests/cli/test_local_gating.py
@@ -0,0 +1,173 @@
+"""
+The local surface can gate, opt-in, without breaking the callers that rely on it not gating.
+
+`tirith -policy-path … -input-path …` has always exited 0 whether the policy passed or failed. That
+made it useless as a CI gate on its own -- the only way to get an exit code that meant something was
+to talk to StackGuardian, which is a poor answer for the path most open-source users are on.
+
+`--fail-on-error` fixes it without changing anything by default. The default is asserted here as
+carefully as the new behaviour is: flipping it would turn every existing green pipeline red on upgrade,
+which is exactly the kind of change that gets a tool pinned forever.
+
+The interesting cases are the ones that are neither a pass nor a violation. `final_result` is
+tri-state: True passed, False said no, and **None means nothing ran** -- every check skipped. None is
+not a pass, and it is not a violation either, so it exits 1: 3 means the infrastructure violates a
+policy, 1 means tirith could not tell you.
+
+The first attempt at this gated on `errors` and inverted both halves. `errors` looks like a
+tool-failure signal and is not -- it also carries the informational "these ids are not defined and
+have been removed" note, so a genuine violation whose expression contained a typo exited 1 while a
+policy naming an unknown provider exited 3. Both directions are tested below.
+"""
+
+import json
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src"))
+
+from tirith.cli import main
+from tirith.status import ExitStatus
+
+POLICY = {
+ "meta": {
+ "id": "instance-type",
+ "name": "instance types are approved",
+ "required_provider": "stackguardian/terraform_plan",
+ "version": "v1",
+ },
+ "evaluators": [
+ {
+ "id": "ev",
+ "description": "instance_type must be t3.micro",
+ "condition": {"type": "Equals", "value": "t3.micro", "error_tolerance": 0},
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_attribute": "instance_type",
+ "terraform_resource_type": "aws_instance",
+ },
+ }
+ ],
+ "eval_expression": "ev",
+}
+
+
+def _plan(instance_type):
+ return {
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.app",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "app",
+ "change": {
+ "actions": ["create"],
+ "before": None,
+ "after": {"instance_type": instance_type},
+ "after_sensitive": {},
+ },
+ }
+ ],
+ }
+
+
+def _write(tmp_path, policy, instance_type="m5.24xlarge"):
+ policy_path = tmp_path / "policy.json"
+ plan_path = tmp_path / "plan.json"
+ policy_path.write_text(json.dumps(policy))
+ plan_path.write_text(json.dumps(_plan(instance_type)))
+ return ["-policy-path", str(policy_path), "-input-path", str(plan_path), "--json"]
+
+
+def test_a_failing_policy_still_exits_zero_by_default(tmp_path):
+ """
+ The compatibility guarantee. Anyone already running this in CI is relying on it, knowingly or not,
+ and a silent change would break their pipeline on an upgrade they did not ask for.
+ """
+ assert main(_write(tmp_path, POLICY)) == ExitStatus.SUCCESS
+
+
+def test_a_failing_policy_exits_three_with_fail_on_error(tmp_path):
+ assert main(_write(tmp_path, POLICY) + ["--fail-on-error"]) == ExitStatus.ERROR_POLICY_FAILED
+
+
+def test_a_passing_policy_exits_zero_with_fail_on_error(tmp_path):
+ args = _write(tmp_path, POLICY, instance_type="t3.micro")
+ assert main(args + ["--fail-on-error"]) == ExitStatus.SUCCESS
+
+
+def test_an_unsupported_operator_is_one_not_three(tmp_path):
+ """
+ `&` is not an operator the evaluator implements. It raises, so this exits 1 through the exception
+ handler rather than through the verdict branch -- worth having as an end-to-end assertion, but it
+ does not exercise the tri-state logic. The two tests below do.
+ """
+ broken = dict(POLICY, eval_expression="ev & nonexistent")
+
+ assert main(_write(tmp_path, broken) + ["--fail-on-error"]) == ExitStatus.ERROR
+
+
+def test_a_policy_that_checked_nothing_is_one_not_three(tmp_path):
+ """
+ `final_result: None` -- every check skipped, because `error_tolerance` swallowed a provider that
+ found nothing. Nothing ran, so there is no verdict: not a pass, and not a violation either.
+
+ This is the case the flag exists for. Reporting 0 would be a green gate over an empty check, and
+ reporting 3 would tell someone their infrastructure violates a policy that never looked at it.
+ """
+ skipped = dict(POLICY)
+ skipped["evaluators"] = [
+ dict(
+ POLICY["evaluators"][0],
+ condition={"type": "Equals", "value": "x", "error_tolerance": 2},
+ provider_args={
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_nonexistent",
+ "terraform_resource_attribute": "nope",
+ },
+ )
+ ]
+
+ assert main(_write(tmp_path, skipped) + ["--fail-on-error"]) == ExitStatus.ERROR
+
+
+def test_a_violation_is_three_even_when_the_expression_names_an_undefined_id(tmp_path):
+ """
+ A regression test for an inversion this had shipped.
+
+ An `eval_expression` mentioning an id that does not exist produces an *informational* note in
+ `errors` -- "the following evaluator ids are not defined and have been removed" -- alongside a
+ perfectly real verdict. Gating on `errors` therefore reported a genuine violation as a tool
+ failure, which is the more dangerous direction: a broken gate looks like an outage and gets
+ retried, or worse, ignored.
+ """
+ typo = dict(POLICY, eval_expression="ev && nonexistent")
+
+ assert main(_write(tmp_path, typo) + ["--fail-on-error"]) == ExitStatus.ERROR_POLICY_FAILED
+
+
+def test_a_missing_variable_is_one_not_three(tmp_path):
+ """
+ The other unevaluable shape, and it fails differently: this path returns errors and no
+ `final_result` key at all, so a check that only looked at `final_result` would read the absence as
+ falsy and report a violation.
+ """
+ parameterised = dict(POLICY, eval_expression="ev")
+ parameterised["evaluators"] = [
+ dict(POLICY["evaluators"][0], condition={"type": "Equals", "value": "{{ var.expected }}", "error_tolerance": 0})
+ ]
+
+ exit_status = main(_write(tmp_path, parameterised) + ["--fail-on-error"])
+
+ assert exit_status != ExitStatus.ERROR_POLICY_FAILED, "an unresolved variable is not a policy violation"
+
+
+@pytest.mark.parametrize("status", [ExitStatus.SUCCESS, ExitStatus.ERROR, ExitStatus.ERROR_POLICY_FAILED])
+def test_the_codes_this_relies_on_are_distinct(status):
+ """Guards the premise: 0, 1 and 3 have to be three different numbers for any of this to mean anything."""
+ others = {ExitStatus.SUCCESS, ExitStatus.ERROR, ExitStatus.ERROR_POLICY_FAILED} - {status}
+ assert status.value not in {other.value for other in others}
diff --git a/tests/core/test_core.py b/tests/core/test_core.py
index 3afdc41e..ec09ea3f 100644
--- a/tests/core/test_core.py
+++ b/tests/core/test_core.py
@@ -151,3 +151,67 @@ def test_generate_evaluator_result_multiple_resources_one_failing():
assert len(result["result"]) == 2
assert result["result"][0]["passed"] is True
assert result["result"][1]["passed"] is False
+
+
+@mark.passing
+def test_generate_evaluator_result_unsupported_evaluator_populates_result():
+ """
+ An unsupported condition.type must still produce a "result" list. Consumers index into
+ it unconditionally, so an early return without it used to raise KeyError far from the cause.
+ """
+ evaluator_obj = {
+ "id": "test_evaluator",
+ "provider_args": {"operation_type": "attribute", "key": "value"},
+ "condition": {"type": "NotAnEvaluator", "value": True},
+ }
+
+ with patch("tirith.core.core.get_evaluator_inputs_from_provider_inputs", return_value=[{"value": "x"}]):
+ result = generate_evaluator_result(evaluator_obj, {}, "test_provider")
+
+ assert result["passed"] is False
+ assert result["result"] == [{"passed": False, "message": "`NotAnEvaluator` is not a supported evaluator"}]
+
+
+@mark.passing
+def test_generate_evaluator_result_bare_provider_err_is_surfaced():
+ """
+ A provider that reports "err" without a ProviderError is a malformed provider call (bad
+ operation_type, missing arg), not a policy violation. The message must reach the output
+ instead of being dropped and None evaluated against the condition.
+ """
+ evaluator_obj = {
+ "id": "test_evaluator",
+ "provider_args": {"operation_type": "gt_value", "key": "value"},
+ "condition": {"type": "Equals", "value": "us-east-1"},
+ }
+
+ bare_err = {"value": None, "meta": None, "err": "operation_type: gt_value is not supported"}
+
+ with patch("tirith.core.core.get_evaluator_inputs_from_provider_inputs", return_value=[bare_err]):
+ with patch("tirith.core.core.EVALUATORS_DICT", {"Equals": MockEvaluator}):
+ result = generate_evaluator_result(evaluator_obj, {}, "test_provider")
+
+ assert result["passed"] is False
+ assert len(result["result"]) == 1
+ assert result["result"][0]["passed"] is False
+ assert result["result"][0]["message"] == "operation_type: gt_value is not supported"
+
+
+@mark.passing
+def test_generate_evaluator_result_bare_provider_err_ignores_error_tolerance():
+ """error_tolerance tolerates missing data; it must never mask a malformed provider call."""
+ evaluator_obj = {
+ "id": "test_evaluator",
+ "provider_args": {"operation_type": "gt_value", "key": "value"},
+ # A tolerance high enough to swallow every documented severity, including 99.
+ "condition": {"type": "Equals", "value": "us-east-1", "error_tolerance": 100},
+ }
+
+ bare_err = {"value": None, "meta": None, "err": "operation_type: gt_value is not supported"}
+
+ with patch("tirith.core.core.get_evaluator_inputs_from_provider_inputs", return_value=[bare_err]):
+ with patch("tirith.core.core.EVALUATORS_DICT", {"Equals": MockEvaluator}):
+ result = generate_evaluator_result(evaluator_obj, {}, "test_provider")
+
+ assert result["passed"] is False, "a malformed provider call must not be skipped"
+ assert result["result"][0]["passed"] is False
diff --git a/tests/core/test_output_compatibility.py b/tests/core/test_output_compatibility.py
new file mode 100644
index 00000000..4dc64546
--- /dev/null
+++ b/tests/core/test_output_compatibility.py
@@ -0,0 +1,121 @@
+"""
+Guardrails on the shape of the result document.
+
+The StackGuardian platform and the workflow-step templates parse this output, so its shape is a
+contract rather than an implementation detail. `test_legacy_json_output_is_byte_identical` holds
+the line: the golden file was captured before the engine changes landed, so any drift in the
+single-policy output is a regression until proven otherwise.
+"""
+
+import json
+import os
+
+from pytest import mark
+
+from tirith.core.core import start_policy_evaluation_from_dict
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+GOLDEN_PATH = os.path.join(REPO_ROOT, "tests", "golden", "json_policy_output.json")
+
+
+@mark.passing
+def test_legacy_json_output_is_byte_identical():
+ with open(os.path.join(REPO_ROOT, "tests", "providers", "json", "policy.json")) as f:
+ policy = json.load(f)
+ with open(os.path.join(REPO_ROOT, "tests", "providers", "json", "input.json")) as f:
+ input_data = json.load(f)
+
+ result = start_policy_evaluation_from_dict(policy, input_data)
+
+ with open(GOLDEN_PATH) as f:
+ # The golden file was captured from the CLI, whose print() adds a trailing newline
+ # that json.dumps does not produce.
+ expected = f.read().rstrip("\n")
+
+ # indent=3 matches what the CLI emits (cli.py), so the golden file doubles as a
+ # record of the exact bytes a --json consumer receives.
+ assert json.dumps(result, indent=3) == expected
+
+
+@mark.passing
+def test_meta_passthrough_omits_absent_keys():
+ """A policy declaring no optional metadata must produce exactly the two original keys."""
+ policy = {
+ "meta": {"version": "v1", "required_provider": "stackguardian/json"},
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {"operation_type": "get_value", "key_path": "a"},
+ "condition": {"type": "Equals", "value": 1},
+ }
+ ],
+ "eval_expression": "check0",
+ }
+
+ result = start_policy_evaluation_from_dict(policy, {"a": 1})
+
+ assert result["meta"] == {"version": "v1", "required_provider": "stackguardian/json"}
+
+
+@mark.passing
+def test_meta_passthrough_carries_declared_keys():
+ policy = {
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json",
+ "id": "no-public-ingress",
+ "name": "No 0.0.0.0/0 ingress",
+ "description": "Public ingress is not permitted",
+ "severity": "HIGH",
+ "enforcement": "hard_mandatory",
+ "tags": ["cis", "network"],
+ "remediation": "Restrict the CIDR or use a security-group reference",
+ },
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {"operation_type": "get_value", "key_path": "a"},
+ "condition": {"type": "Equals", "value": 1},
+ }
+ ],
+ "eval_expression": "check0",
+ }
+
+ result = start_policy_evaluation_from_dict(policy, {"a": 1})
+
+ assert result["meta"]["id"] == "no-public-ingress"
+ assert result["meta"]["name"] == "No 0.0.0.0/0 ingress"
+ assert result["meta"]["severity"] == "HIGH"
+ assert result["meta"]["enforcement"] == "hard_mandatory"
+ assert result["meta"]["tags"] == ["cis", "network"]
+ assert result["meta"]["remediation"] == "Restrict the CIDR or use a security-group reference"
+ # The originals survive alongside the additions.
+ assert result["meta"]["version"] == "v1"
+ assert result["meta"]["required_provider"] == "stackguardian/json"
+
+
+@mark.passing
+def test_meta_passthrough_supports_variables():
+ """
+ Variable substitution already covers the whole meta dict, so the new fields get
+ {{ var.x }} support without any extra plumbing. This pins that behaviour.
+ """
+ policy = {
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json",
+ "severity": "{{ var.sev }}",
+ },
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {"operation_type": "get_value", "key_path": "a"},
+ "condition": {"type": "Equals", "value": 1},
+ }
+ ],
+ "eval_expression": "check0",
+ }
+
+ result = start_policy_evaluation_from_dict(policy, {"a": 1}, {"sev": "CRITICAL"})
+
+ assert result["meta"]["severity"] == "CRITICAL"
diff --git a/tests/core/test_policy_parameterization.py b/tests/core/test_policy_parameterization.py
index db9fcc04..08a55682 100644
--- a/tests/core/test_policy_parameterization.py
+++ b/tests/core/test_policy_parameterization.py
@@ -48,6 +48,56 @@ def test_not_found_variable(processed_policy):
assert processed_policy[1] == ["key_path"]
+def test_caller_policy_is_not_mutated():
+ """Substitution must not write through to the caller's dict."""
+ policy = {
+ "meta": {"version": "", "required_provider": "{{var.provider}}"},
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {"operation_type": "get_value", "key_path": "a.b"},
+ "condition": {"type": "Equals", "value": "{{var.expected}}"},
+ }
+ ],
+ "eval_expression": "check0",
+ }
+
+ replaced, not_found = get_policy_with_vars_replaced(policy, {"provider": "stackguardian/json", "expected": "yes"})
+
+ assert not_found == []
+ # The copy carries the substituted values ...
+ assert replaced["meta"]["required_provider"] == "stackguardian/json"
+ assert replaced["evaluators"][0]["condition"]["value"] == "yes"
+ # ... while the original still carries the placeholders.
+ assert policy["meta"]["required_provider"] == "{{var.provider}}"
+ assert policy["evaluators"][0]["condition"]["value"] == "{{var.expected}}"
+
+
+def test_same_policy_reused_with_different_vars():
+ """
+ A policy dict evaluated twice with different vars must not leak values between runs.
+ This is the multi-policy / retry case: without a deep copy the second call sees the
+ first call's substitutions already baked in and reports nothing to substitute.
+ """
+ policy = {
+ "meta": {"version": "", "required_provider": "stackguardian/json"},
+ "evaluators": [
+ {
+ "id": "check0",
+ "provider_args": {"operation_type": "get_value", "key_path": "{{var.path}}"},
+ "condition": {"type": "Equals", "value": True},
+ }
+ ],
+ "eval_expression": "check0",
+ }
+
+ first, _ = get_policy_with_vars_replaced(policy, {"path": "first.path"})
+ second, _ = get_policy_with_vars_replaced(policy, {"path": "second.path"})
+
+ assert first["evaluators"][0]["provider_args"]["key_path"] == "first.path"
+ assert second["evaluators"][0]["provider_args"]["key_path"] == "second.path"
+
+
# TODO: Create testcases for:
# - test inline vars precendece over var files
# - test undefined vars
diff --git a/tests/golden/json_policy_output.json b/tests/golden/json_policy_output.json
new file mode 100644
index 00000000..d0afad49
--- /dev/null
+++ b/tests/golden/json_policy_output.json
@@ -0,0 +1,87 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json"
+ },
+ "final_result": true,
+ "evaluators": [
+ {
+ "id": "check0",
+ "passed": null,
+ "result": [
+ {
+ "message": "key_path: `z.b` is not found (severity: 2)",
+ "passed": null
+ }
+ ],
+ "description": null
+ },
+ {
+ "id": "check1",
+ "passed": true,
+ "result": [
+ {
+ "passed": true,
+ "message": "`1` is less than equal to `1`",
+ "meta": null
+ }
+ ],
+ "description": null
+ },
+ {
+ "id": "check2",
+ "passed": true,
+ "result": [
+ {
+ "passed": true,
+ "message": "Found `\"aa\"` inside `[\"aa\", \"bb\"]`",
+ "meta": null
+ }
+ ],
+ "description": null
+ },
+ {
+ "id": "check3",
+ "passed": true,
+ "result": [
+ {
+ "passed": true,
+ "message": "`\"3\"` is equal to `\"3\"`",
+ "meta": null
+ }
+ ],
+ "description": null
+ },
+ {
+ "id": "check4",
+ "passed": true,
+ "result": [
+ {
+ "passed": true,
+ "message": "`\"value1\"` is equal to `\"value1\"`",
+ "meta": null
+ },
+ {
+ "passed": true,
+ "message": "`\"value1\"` is equal to `\"value1\"`",
+ "meta": null
+ }
+ ],
+ "description": null
+ },
+ {
+ "id": "check5",
+ "passed": true,
+ "result": [
+ {
+ "passed": true,
+ "message": "`{\"e\": {\"f\": \"3\"}}` is equal to `{\"e\": {\"f\": \"3\"}}`",
+ "meta": null
+ }
+ ],
+ "description": null
+ }
+ ],
+ "errors": [],
+ "eval_expression": "check1 && check2 && check3 && check4 && check5"
+}
diff --git a/tests/platform/test_archive.py b/tests/platform/test_archive.py
new file mode 100644
index 00000000..b6681a23
--- /dev/null
+++ b/tests/platform/test_archive.py
@@ -0,0 +1,476 @@
+"""
+Tests for the project archive.
+
+The assertions that matter read the bytes *inside the built tarball*, not the objects handed to
+pack(). That distinction is the whole point: a previous iteration of this code masked a plan
+correctly in memory and still shipped the plaintext, because the secret lived in a second place
+nobody had looked at. Asserting on the input would have passed.
+"""
+
+import io
+import json
+import os
+import tarfile
+
+import pytest
+
+from tirith.platform import archive
+
+SECRET = "hunter2-this-must-never-leave-the-runner"
+
+
+def members(archive_bytes):
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
+ return sorted(tar.getnames())
+
+
+def read_member(archive_bytes, name):
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
+ return tar.extractfile(name).read()
+
+
+def raw_bytes(archive_bytes):
+ """Everything in the archive, decompressed, as one blob -- for leak assertions."""
+ blob = b""
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
+ for member in tar.getmembers():
+ blob += member.name.encode()
+ if member.isfile():
+ blob += tar.extractfile(member).read()
+ return blob
+
+
+# --- documents ---------------------------------------------------------------------------------
+
+
+def test_documents_land_at_the_fixed_names_the_step_looks_for(tmp_path):
+ body, _manifest = archive.pack(source_dir=None, plan={"a": 1}, state={"b": 2}, infracost={"c": 3})
+
+ assert members(body) == ["infracost.json", "plan.json", "tfstate.json"]
+ assert json.loads(read_member(body, "plan.json")) == {"a": 1}
+
+
+def test_absent_documents_are_simply_not_written():
+ body, _manifest = archive.pack(source_dir=None, state={"version": 4})
+
+ assert members(body) == ["tfstate.json"]
+
+
+def test_masked_document_wins_over_a_stale_file_on_disk(tmp_path):
+ """
+ The dangerous ordering: a plan.json left in the working directory from an earlier run would
+ otherwise be packed *and* the masked one written, shipping both.
+ """
+ (tmp_path / "plan.json").write_text(json.dumps({"leaked": SECRET}))
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), plan={"masked": "__SG_REDACTED__"})
+
+ assert json.loads(read_member(body, "plan.json")) == {"masked": "__SG_REDACTED__"}
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+@pytest.mark.parametrize("name", ["plan.json", "tfstate.json", "infracost.json"])
+def test_reserved_names_on_disk_are_never_packed(tmp_path, name):
+ """
+ The leak this closes: `terraform state pull > state.json` is the documented way to produce a
+ state file, so one routinely sits in the working directory -- raw and unmasked. Packing the
+ source tree naively shipped it in full, right next to the masked copy.
+
+ These names are only ever written by pack() from an already-masked object. A caller who wants
+ the file evaluated passes --state-path / --input-path, which masks it first.
+ """
+ (tmp_path / name).write_text(json.dumps({"outputs": {"db": {"value": SECRET}}}))
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), plan={"masked": True})
+
+ assert SECRET.encode() not in raw_bytes(body)
+ assert members(body) == ["code/main.tf", "plan.json"]
+
+
+@pytest.mark.parametrize("name", ["tfplan.json", "state.json", "terraform.plan.json"])
+def test_the_file_a_document_was_read_from_is_never_packed(tmp_path, name):
+ """
+ Reserving only the three names pack() writes was not enough. The input is routinely called
+ something else -- `tfplan.json` is the second name discovery accepts, and
+ `terraform state pull > state.json` is the documented way to produce state -- so the source walk
+ shipped the unmasked original one filename away from the masked copy.
+ """
+ (tmp_path / name).write_text(json.dumps({"outputs": {"db": {"value": SECRET}}}))
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(
+ source_dir=str(tmp_path),
+ plan={"masked": True},
+ document_sources=(str(tmp_path / name),),
+ )
+
+ assert SECRET.encode() not in raw_bytes(body)
+ assert members(body) == ["code/main.tf", "plan.json"]
+
+
+def test_the_binary_plan_is_never_packed(tmp_path):
+ """
+ A binary plan embeds the prior state, so it carries every attribute of every existing resource
+ in plaintext -- worse than a raw state file, and it matches none of the *.tfstate patterns.
+ --plan-file converts and masks it in memory, which the source walk then undid.
+ """
+ (tmp_path / "tfplan").write_bytes(b"\x1f\x8b binary plan " + SECRET.encode())
+ (tmp_path / "prod.tfplan").write_bytes(SECRET.encode())
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), plan={"masked": True})
+
+ assert SECRET.encode() not in raw_bytes(body)
+ assert members(body) == ["code/main.tf", "plan.json"]
+
+
+def test_a_document_source_outside_the_tree_excludes_nothing(tmp_path):
+ """
+ An out-of-tree path cannot collide with a member name, so it must not be reduced to a bare
+ basename -- doing so would silently drop an unrelated same-named file from the archive.
+ """
+ outside = tmp_path / "elsewhere"
+ outside.mkdir()
+ (outside / "main.tf").write_text("")
+ source = tmp_path / "src"
+ source.mkdir()
+ (source / "main.tf").write_text("resource {}")
+
+ body, _manifest = archive.pack(
+ source_dir=str(source),
+ plan={"masked": True},
+ document_sources=(str(outside / "main.tf"),),
+ )
+
+ assert members(body) == ["code/main.tf", "plan.json"]
+ assert read_member(body, "code/main.tf") == b"resource {}"
+
+
+def test_masked_document_is_what_gets_written(tmp_path):
+ """The counterpart: a supplied document really does reach the archive."""
+ (tmp_path / "tfstate.json").write_text(json.dumps({"secret": SECRET}))
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), state={"masked": True})
+
+ assert json.loads(read_member(body, "tfstate.json")) == {"masked": True}
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+# --- exclusions --------------------------------------------------------------------------------
+
+
+def test_terraform_provider_cache_is_excluded(tmp_path):
+ """A provider cache is routinely hundreds of MB; shipping it would make every run unusable."""
+ provider = tmp_path / ".terraform" / "providers" / "registry.terraform.io"
+ provider.mkdir(parents=True)
+ (provider / "terraform-provider-aws").write_bytes(b"x" * 1024)
+ (tmp_path / "main.tf").write_text('resource "null_resource" "a" {}')
+
+ body, manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert members(body) == ["code/main.tf"]
+ assert manifest["skipped"] >= 1
+
+
+def test_git_directory_is_excluded(tmp_path):
+ """.git carries full history, so anything ever committed would ship."""
+ (tmp_path / ".git").mkdir()
+ (tmp_path / ".git" / "config").write_text(f"token = {SECRET}")
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert members(body) == ["code/main.tf"]
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+@pytest.mark.parametrize("name", ["terraform.tfstate", "terraform.tfstate.backup", "prod.tfstate"])
+def test_raw_state_files_are_excluded(tmp_path, name):
+ """
+ Raw state is unmasked by definition. Left in, it would travel next to the masked copy and
+ undo the masking entirely.
+ """
+ (tmp_path / name).write_text(json.dumps({"outputs": {"db": {"value": SECRET}}}))
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert f"code/{name}" not in members(body)
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+def test_gitignore_is_honoured(tmp_path):
+ (tmp_path / ".gitignore").write_text("secrets.auto.tfvars\nbuild/\n")
+ (tmp_path / "secrets.auto.tfvars").write_text(f'password = "{SECRET}"')
+ (tmp_path / "build").mkdir()
+ (tmp_path / "build" / "out.bin").write_text("junk")
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert "code/secrets.auto.tfvars" not in members(body)
+ assert "code/build/out.bin" not in members(body)
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+def test_gitignore_can_be_turned_off(tmp_path):
+ (tmp_path / ".gitignore").write_text("keep-me.tf\n")
+ (tmp_path / "keep-me.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), respect_gitignore=False)
+
+ assert "code/keep-me.tf" in members(body)
+
+
+def test_extra_excludes_are_applied(tmp_path):
+ (tmp_path / "big.zip").write_text("junk")
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), extra_excludes=("*.zip",))
+
+ assert members(body) == ["code/main.tf"]
+
+
+def test_lock_file_is_kept(tmp_path):
+ """It pins provider versions, is small, and the run controller's init wants it."""
+ (tmp_path / ".terraform.lock.hcl").write_text("provider ...")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert "code/.terraform.lock.hcl" in members(body)
+
+
+def test_symlinks_are_skipped(tmp_path):
+ """A symlink out of the tree either breaks on extraction or smuggles a file in."""
+ outside = tmp_path.parent / "outside.txt"
+ outside.write_text(SECRET)
+ source = tmp_path / "src"
+ source.mkdir()
+ (source / "main.tf").write_text("")
+ os.symlink(str(outside), str(source / "link.txt"))
+
+ body, _manifest = archive.pack(source_dir=str(source))
+
+ assert members(body) == ["code/main.tf"]
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+# --- structure ---------------------------------------------------------------------------------
+
+
+def test_nested_directories_keep_their_relative_paths(tmp_path):
+ (tmp_path / "modules" / "vpc").mkdir(parents=True)
+ (tmp_path / "modules" / "vpc" / "main.tf").write_text("")
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert "code/modules/vpc/main.tf" in members(body)
+
+
+def test_no_source_dir_is_allowed():
+ """--no-source: send only the documents."""
+ body, manifest = archive.pack(source_dir=None, plan={"a": 1})
+
+ assert members(body) == ["plan.json"]
+ assert manifest["files"] == 0
+
+
+def test_missing_source_dir_is_an_error(tmp_path):
+ with pytest.raises(archive.ArchiveError):
+ archive.pack(source_dir=str(tmp_path / "does-not-exist"))
+
+
+def test_oversized_archive_is_refused(tmp_path, monkeypatch):
+ """
+ Failing loudly beats a five-minute upload that times out the run. A runaway archive is nearly
+ always an exclusion that did not fire.
+ """
+ monkeypatch.setattr(archive, "MAX_ARCHIVE_BYTES", 512)
+ (tmp_path / "big.tf").write_text("resource {}\n" * 20000)
+
+ with pytest.raises(archive.ArchiveError, match="limit"):
+ archive.pack(source_dir=str(tmp_path))
+
+
+def test_manifest_reports_what_went_in(tmp_path):
+ (tmp_path / "main.tf").write_text("")
+ (tmp_path / ".terraform").mkdir()
+ (tmp_path / ".terraform" / "x").write_text("")
+
+ _body, manifest = archive.pack(source_dir=str(tmp_path), plan={"a": 1})
+
+ assert manifest["files"] == 1
+ assert manifest["documents"] == ["plan.json"]
+ assert manifest["skipped"] >= 1
+ assert manifest["bytes"] > 0
+
+
+def test_the_binary_plan_that_plan_file_read_is_never_packed(tmp_path):
+ """
+ --plan-file converts the binary plan in memory precisely so nothing unmasked touches the disk --
+ but the binary plan is already on disk, and it embeds the prior state: every attribute of every
+ existing resource. The `tfplan` name patterns only cover the spellings the README uses, and
+ `terraform plan -out=plan.out` is at least as common.
+ """
+ (tmp_path / "plan.out").write_bytes(b"binary plan " + SECRET.encode())
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(
+ source_dir=str(tmp_path),
+ plan={"masked": True},
+ document_sources=(str(tmp_path / "plan.out"),),
+ )
+
+ assert SECRET.encode() not in raw_bytes(body)
+ assert members(body) == ["code/main.tf", "plan.json"]
+
+
+# --- layout: code/ is a prefix, the root belongs to the documents -------------------------------
+
+
+def test_the_source_lives_under_the_code_prefix(tmp_path):
+ """
+ The layout is a contract for whatever reads the bundle: source under `code/`, documents at the
+ root, and `code/x` maps back to `/x`.
+ """
+ (tmp_path / "main.tf").write_text("")
+ (tmp_path / "modules" / "vpc").mkdir(parents=True)
+ (tmp_path / "modules" / "vpc" / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), plan={"masked": True})
+
+ assert members(body) == ["code/main.tf", "code/modules/vpc/main.tf", "plan.json"]
+
+
+def test_the_documents_are_at_the_archive_root_and_never_under_a_prefix(tmp_path):
+ """
+ The one layout mistake that would not fail loudly.
+
+ The step finds its inputs with a flat join onto the extraction directory and treats absence as
+ normal (`_discover_document` returns None). So a document moved under `code/` -- or under any
+ prefix -- would not raise: every policy would come back unevaluated and the run would report as
+ passed-with-warnings. Nothing downstream distinguishes that from a genuinely clean plan, which is
+ why this is asserted here rather than trusted.
+ """
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(
+ source_dir=str(tmp_path),
+ plan={"masked": True},
+ state={"masked": True},
+ infracost={"masked": True},
+ )
+
+ for document in (archive.PLAN_DOCUMENT, archive.STATE_DOCUMENT, archive.INFRACOST_DOCUMENT):
+ assert document in members(body), f"{document} must be at the archive root"
+ assert not [name for name in members(body) if name.endswith(f"/{archive.PLAN_DOCUMENT}")]
+
+
+def test_a_committed_document_name_is_still_skipped_under_the_prefix(tmp_path):
+ """
+ The reservation is a leak guard, and it stopped being self-evident when the prefix arrived.
+
+ Under the old flat layout a committed `tfstate.json` would have collided with the masked one, so
+ skipping it looked obviously necessary. `code/tfstate.json` cannot collide with anything -- and is
+ still raw, unmasked state, which is the actual reason for the skip. Deleting it because "the
+ collision is impossible now" is the mistake this test exists to catch.
+ """
+ (tmp_path / "tfstate.json").write_text(json.dumps({"secret": SECRET}))
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path), state={"masked": True})
+
+ assert "code/tfstate.json" not in members(body)
+ assert SECRET.encode() not in raw_bytes(body)
+
+
+def test_metadata_is_absent_unless_asked_for(tmp_path):
+ """A caller that supplies no metadata gets no member, so old bundles stay describable as such."""
+ (tmp_path / "main.tf").write_text("")
+
+ body, _manifest = archive.pack(source_dir=str(tmp_path))
+
+ assert archive.METADATA_DOCUMENT not in members(body)
+
+
+# --- metadata.json: what pack() observes, as opposed to what it was told ------------------------
+
+
+def _metadata(archive_bytes):
+ return json.loads(read_member(archive_bytes, archive.METADATA_DOCUMENT))
+
+
+def test_metadata_records_what_was_actually_packed(tmp_path):
+ """
+ The counts come from the walk, not from the caller. "Claims code, packed nothing" is only
+ detectable because this half of the document is produced here.
+ """
+ (tmp_path / "main.tf").write_text("")
+ (tmp_path / "notes.txt").write_text("")
+
+ body, _manifest = archive.pack(
+ source_dir=str(tmp_path),
+ plan={"masked": True},
+ metadata={"schema_version": 1, "code": {"repo_path": "infra/prod", "repo_path_from": "flag"}},
+ )
+
+ code = _metadata(body)["code"]
+ assert code["present"] is True
+ assert code["prefix"] == "code/"
+ assert code["files"] == 2
+ assert code["repo_path"] == "infra/prod"
+ assert _metadata(body)["documents"] == {"plan": "plan.json", "state": None, "infracost": None}
+
+
+def test_metadata_cannot_claim_code_the_archive_does_not_carry(tmp_path):
+ """
+ `present` means "there are members under the prefix", not "a source directory was requested".
+
+ Nothing writes an explicit directory entry, so a tree whose every file was excluded leaves no
+ `code/` in the tar at all. A consumer comparing the tar against the metadata must never find them
+ disagreeing, so the flag is derived from the count rather than from the request.
+ """
+ (tmp_path / "everything.tfstate").write_text("raw state")
+
+ body, _manifest = archive.pack(
+ source_dir=str(tmp_path),
+ plan={"masked": True},
+ metadata={"schema_version": 1, "code": {"repo_path": "infra", "repo_path_from": "flag"}},
+ )
+
+ code = _metadata(body)["code"]
+ assert code["present"] is False
+ assert code["prefix"] is None
+ assert code["files"] == 0
+ # And the path is withdrawn: there is nothing for it to describe.
+ assert code["repo_path"] is None
+ assert code["absent_reason"] == "empty_after_excludes"
+ assert not [name for name in members(body) if name.startswith("code/")]
+
+
+def test_metadata_says_why_no_source_was_requested(tmp_path):
+ """
+ A documents-only bundle has to distinguish "none wanted" from "dropped for size", or a consumer
+ cannot tell a deliberate configuration from a truncated one.
+ """
+ body, _manifest = archive.pack(
+ source_dir=None,
+ plan={"masked": True},
+ metadata={"schema_version": 1, "code": {"absent_reason": "not_requested"}},
+ )
+
+ code = _metadata(body)["code"]
+ assert code["present"] is False
+ assert code["absent_reason"] == "not_requested"
+
+
+def test_metadata_does_not_mutate_the_caller_dict(tmp_path):
+ """The retry path re-packs with a modified copy; mutating the original would corrupt it."""
+ (tmp_path / "main.tf").write_text("")
+ supplied = {"schema_version": 1, "code": {"repo_path": "infra"}}
+
+ archive.pack(source_dir=str(tmp_path), metadata=supplied)
+
+ assert supplied == {"schema_version": 1, "code": {"repo_path": "infra"}}
diff --git a/tests/platform/test_check.py b/tests/platform/test_check.py
new file mode 100644
index 00000000..9abe62b8
--- /dev/null
+++ b/tests/platform/test_check.py
@@ -0,0 +1,503 @@
+"""
+Tests for the check orchestration.
+
+Focused on `upload_state_document`, because that is the one place in this codebase that can overwrite
+a customer's live terraform state. `artifacts/tfstate.json` is not just a name we picked: the
+managed-state backend writes it, state locking keys on the literal basename, and the state-backends
+view lists it. Writing a *masked* document there for a workflow that manages its own state would be
+data loss, so the guard is asserted rather than assumed.
+"""
+
+import json
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src"))
+
+from tirith.platform import check
+from tirith.platform.client import SGError
+
+
+class FakeClient:
+ def __init__(self, managed=False, fail=False):
+ self.managed = managed
+ self.fail = fail
+ self.uploads = []
+
+ def manages_terraform_state(self, wfgrp, workflow_id):
+ return self.managed
+
+ def upload_file(self, wfgrp, workflow_id, filename, folder, content, content_type=None):
+ if self.fail:
+ raise SGError("presigned URL expired")
+ self.uploads.append(
+ {
+ "filename": filename,
+ "folder": folder,
+ "content": content,
+ "content_type": content_type,
+ }
+ )
+ return f"orgs/acme/wfs/K/artifacts/{filename}"
+
+
+class Opts:
+ workflow_group = "default"
+ workflow_id = "wf"
+
+
+STATE = {
+ "version": 4,
+ "resources": [{"type": "aws_s3_bucket", "instances": [{"attributes": {"b": "__SG_REDACTED__"}}]}],
+}
+
+
+def test_the_state_is_published_as_tfstate_json():
+ client = FakeClient(managed=False)
+
+ check.upload_state_document(client, Opts(), STATE)
+
+ assert len(client.uploads) == 1
+ upload = client.uploads[0]
+ assert upload["filename"] == "tfstate.json"
+ # The artifacts root, not a subfolder: that is the key the platform reads.
+ assert upload["folder"] is None
+ assert upload["content_type"] == "application/json"
+ assert json.loads(upload["content"].decode()) == STATE
+
+
+def test_the_state_is_not_written_over_a_managed_state_workflow(capsys):
+ """The data-loss guard. That object is the live state for such a workflow."""
+ client = FakeClient(managed=True)
+
+ check.upload_state_document(client, Opts(), STATE)
+
+ assert client.uploads == []
+ warning = capsys.readouterr().err
+ assert "manages its own terraform state" in warning
+ # And it says the state is still evaluated, so the skip does not read as a lost check.
+ assert "still evaluated" in warning
+
+
+def test_a_failed_publish_is_a_warning_not_a_failure(capsys):
+ """
+ The verdict does not depend on this upload. A run whose policies evaluated perfectly well must not
+ go red because a best-effort convenience copy could not be written.
+ """
+ client = FakeClient(managed=False, fail=True)
+
+ check.upload_state_document(client, Opts(), STATE)
+
+ assert "could not publish tfstate.json" in capsys.readouterr().err
+
+
+def test_the_published_state_is_flagged_as_masked(capsys):
+ """
+ A file at the canonical state key that looks like state but is full of __SG_REDACTED__ is a
+ footgun for whoever downloads it next, so the log says so.
+ """
+ check.upload_state_document(FakeClient(managed=False), Opts(), STATE)
+
+ assert "cannot be used to run terraform" in capsys.readouterr().err
+
+
+def test_the_state_document_name_matches_the_one_inside_the_archive():
+ """
+ The step reads the archive copy to publish TfStateCleaned while the platform reads the uploaded
+ one. Two different names would be two sources of truth for the same thing.
+ """
+ from tirith.platform import archive
+
+ assert check.STATE_DOCUMENT_NAME == archive.STATE_DOCUMENT
+
+
+# --- packing: the source is uploaded, but never at the cost of the gate --------------------------
+#
+# The source tree is packed by default, so an exclusion that does not fire -- a committed vendor
+# directory, a build output tree -- would otherwise turn a working policy check into a failed run.
+# That trade is the wrong way round: the verdict gates the merge, the source is a convenience for
+# whatever reads the bundle afterwards.
+
+
+def _tree(tmp_path, extra_bytes=0):
+ source = tmp_path / "src"
+ source.mkdir()
+ (source / "main.tf").write_text('resource "null_resource" "a" {}\n')
+ if extra_bytes:
+ # Random, so gzip cannot make it disappear.
+ (source / "vendor.bin").write_bytes(os.urandom(extra_bytes))
+ return str(source)
+
+
+def test_the_source_is_packed_on_the_normal_path(tmp_path):
+ archive_bytes, manifest, skipped = check.pack_documents(_tree(tmp_path), {"masked": True}, None, None)
+
+ assert manifest["files"] == 1
+ assert manifest["documents"] == ["plan.json"]
+ assert skipped is None
+ assert archive_bytes
+
+
+def test_an_oversized_source_tree_degrades_to_documents_only(tmp_path, monkeypatch, capsys):
+ monkeypatch.setattr(check.archive, "MAX_ARCHIVE_BYTES", 50 * 1024)
+
+ archive_bytes, manifest, skipped = check.pack_documents(
+ _tree(tmp_path, extra_bytes=200_000), {"masked": True}, None, None
+ )
+
+ # The documents still go, so the policies still run.
+ assert manifest["documents"] == ["plan.json"]
+ assert manifest["files"] == 0
+ # And the caller can tell that the bundle has no code in it.
+ assert skipped and "over the" in skipped
+
+ warning = capsys.readouterr().err
+ assert "carries no code" in warning
+ assert "--source-dir" in warning
+
+
+def test_an_oversized_documents_only_archive_still_fails(tmp_path, monkeypatch):
+ """
+ Nothing left to drop. Degrading further would mean uploading an archive with no documents, which
+ is not a check at all -- so this stays fatal rather than becoming a silent pass.
+ """
+ monkeypatch.setattr(check.archive, "MAX_ARCHIVE_BYTES", 50 * 1024)
+
+ with pytest.raises(check.archive.ArchiveError):
+ check.pack_documents(None, {"blob": os.urandom(200_000).hex()}, None, None)
+
+
+def test_the_size_message_is_readable_below_a_megabyte(monkeypatch):
+ """
+ Integer MB division reported everything small as "0 MB over the 0 MB limit". That message is now
+ surfaced on a pull request, where it has to mean something.
+ """
+ from tirith.platform.archive import _human_bytes
+
+ assert _human_bytes(137 * 1024 * 1024) == "137.0 MB"
+ assert _human_bytes(300 * 1024) == "300.0 KB"
+ assert _human_bytes(512) == "512 bytes"
+
+
+def test_the_policy_step_is_spliced_in_as_a_pre_plan_step():
+ """
+ The whole mechanism, and it uses only primitives the platform already had: core splices
+ `prePlanWfStepsConfig` ahead of `generate-terraform-plan`, and the step exits 12, which tells the
+ run controller to complete the run and skip everything after it. So core needs to know nothing
+ about this feature -- which is why there is no terraform action for it.
+ """
+ config = check.terraform_config("1.5.7", None)
+
+ steps = config["prePlanWfStepsConfig"]
+ assert len(steps) == 1
+ assert steps[0]["name"] == check.POLICY_STEP_NAME
+ assert steps[0]["wfStepTemplateId"] == check.POLICY_STEP_TEMPLATE
+ # Every input the step needs travels in its own step input, not the terraform configuration.
+ assert steps[0]["wfStepInputData"]["schemaType"] == "FORM_JSONSCHEMA"
+ # A policy check writes no state, so it must not take a managed-state backend override.
+ assert config["managedTerraformState"] is False
+ # No stored input kind: routing is by which document is in the archive.
+ assert "policyInputKind" not in config
+
+
+def test_a_step_template_override_is_honoured():
+ config = check.terraform_config("1.5.7", "/demo-org/tirith-iac-governance:3")
+
+ assert config["prePlanWfStepsConfig"][0]["wfStepTemplateId"] == "/demo-org/tirith-iac-governance:3"
+
+
+# --- the bundle is named per commit, and per RUN ------------------------------------------------
+#
+# A name shared by every run of the workflow is one two concurrent runs can overwrite, and the action
+# derives a single workflow id per repository -- so two open pull requests, the ordinary case, would
+# have one run evaluating the other's code and reporting the verdict as its own. Silently, on a merge
+# gate. Naming it per commit removes the collision rather than detecting it afterwards.
+#
+# That is only possible because the name travels per RUN: core merges the run's TerraformConfig over
+# the workflow's, so `prePlanWfStepsConfig` can differ every time. The workflow's stored copy is
+# written once, at creation, and never updated.
+
+
+def test_the_bundle_name_carries_the_commit():
+ from tirith.platform.client import ARCHIVE_NAME_TEMPLATE
+
+ name = ARCHIVE_NAME_TEMPLATE.format(sha="a1b2c3d", tag="plan")
+
+ assert name == "tirith-bundle-a1b2c3d-plan.tar.gz"
+ # Two commits cannot collide, which is the entire point.
+ assert name != ARCHIVE_NAME_TEMPLATE.format(sha="9999999", tag="plan")
+
+
+def test_the_bundle_name_survives_the_artifact_syncs_exclude_list():
+ """
+ The sync is the delivery mechanism, so a name matching any of its excludes would be dropped
+ silently and never reach the container. `__sg.`, which this name used to carry, is excluded
+ precisely so the old carrier stayed OUT of the sync -- exactly wrong now.
+ """
+ import fnmatch
+
+ from tirith.platform.client import ARCHIVE_NAME_TEMPLATE
+
+ name = ARCHIVE_NAME_TEMPLATE.format(sha="a1b2c3d", tag="plan")
+ excluded = ("sg.*", "__sg.*", "*__sg.*", "*pci_*", "*_thrifty_*", "*_gdpr_*", "*compliance_raw*")
+
+ for pattern in excluded:
+ assert not fnmatch.fnmatch(name, pattern), f"the bundle name matches the sync exclude {pattern!r}"
+ assert name != "tfstate.json", "that name is a managed-state workflow's live state"
+
+
+def test_the_run_names_its_own_bundle():
+ """
+ The per-run half. `wfStepInputData` on the *workflow* is written once and never updated, so the
+ name has to be re-sent with each run for it to describe that run's commit.
+ """
+ step = check.policy_step(None, "tirith-bundle-a1b2c3d-plan.tar.gz")
+
+ assert step["wfStepInputData"]["data"]["bundlePath"] == "tirith-bundle-a1b2c3d-plan.tar.gz"
+ # Sent in full: core's merge is shallow, so supplying prePlanWfStepsConfig replaces the whole
+ # list and a partial entry would lose the template id the step runs from.
+ assert step["wfStepTemplateId"] == check.POLICY_STEP_TEMPLATE
+ assert step["name"] == check.POLICY_STEP_NAME
+ assert step["timeout"] == check.POLICY_STEP_TIMEOUT
+
+
+def test_the_step_template_override_reaches_the_per_run_step():
+ step = check.policy_step("/demo-org/tirith-iac-governance:3", "b.tar.gz")
+
+ assert step["wfStepTemplateId"] == "/demo-org/tirith-iac-governance:3"
+
+
+def test_the_run_tells_the_step_whether_state_is_managed():
+ """
+ The step writes the masked state to `artifacts/tfstate.json`, which for a managed-state workflow is
+ the LIVE state. It must be told, and told explicitly rather than left to a default: a missing key
+ that happens to mean "not managed" is one refactor away from meaning the opposite.
+ """
+ step = check.policy_step(None, "tirith-bundle-a1b2c3d-plan.tar.gz")
+
+ data = step["wfStepInputData"]["data"]
+ assert data["managedTerraformState"] is False
+
+
+def test_the_workflow_never_takes_a_managed_state_backend():
+ """And the claim the passthrough rests on: these workflows do not manage state in the first place."""
+ config = check.terraform_config("1.5.7", None)
+
+ assert config["managedTerraformState"] is False
+
+
+# --- metadata.json: the provenance half -----------------------------------------------------------
+#
+# Two shapes have to produce an honest document: a CI run, where the trigger payload carries the
+# repository and commit, and a bare local invocation, where none of it exists. The local case is the
+# one worth guarding -- the temptation is to fill the gaps from the environment, and a fabricated
+# repository in a file that outlives the run is worse than a null.
+
+
+class MetaOpts:
+ trigger_details = {"type": "cli"}
+ repo_url = None
+ repo_ref = None
+ repo_path = None
+ sha = None
+ source_dir = None
+ input_kind = "terraform_plan"
+ org = "acme"
+ workflow_group = "default"
+ workflow_id = "infra"
+ artifact_tag = "default"
+
+
+def _opts(**overrides):
+ opts = MetaOpts()
+ for key, value in overrides.items():
+ setattr(opts, key, value)
+ return opts
+
+
+def test_a_local_run_states_it_is_local_rather_than_leaving_ci_to_be_inferred():
+ metadata = check.build_metadata(_opts(), redactions=0)
+
+ assert metadata["origin"] == {"kind": "local", "trigger_type": "cli", "ci_run_url": None}
+ # Nulls, not omissions, and nothing invented.
+ assert metadata["repository"]["provider"] == "unknown"
+ assert metadata["repository"]["url"] is None
+ assert metadata["repository"]["commit"] is None
+ assert metadata["repository"]["change_request"] is None
+ assert metadata["schema_version"] == check.METADATA_SCHEMA_VERSION
+
+
+def test_a_ci_run_records_the_repository_and_the_change_request():
+ opts = _opts(
+ trigger_details={
+ "type": "tirith",
+ "repoHttpUrl": "https://github.com/acme/infra",
+ "headSha": "9f2c1ab5",
+ "ref": "feat/rds",
+ "prId": "412",
+ "eventSource": "https://github.com/acme/infra/pull/412",
+ "runUrl": "https://github.com/acme/infra/actions/runs/1",
+ }
+ )
+
+ metadata = check.build_metadata(opts, redactions=12)
+
+ assert metadata["origin"]["kind"] == "ci"
+ assert metadata["repository"]["provider"] == "github"
+ assert metadata["repository"]["commit"] == "9f2c1ab5"
+ assert metadata["repository"]["change_request"]["id"] == "412"
+ assert metadata["masking"]["redactions"] == 12
+
+
+def test_a_credential_in_the_repo_url_never_reaches_the_metadata():
+ """
+ `https://x-access-token:ghs_…@github.com/…` is an ordinary value for a CI checkout to hold, and
+ GitLab's own CI_REPOSITORY_URL embeds a job token the same way. This file ships inside the bundle
+ and outlives the run, so a token written here is a token persisted in an artifact.
+ """
+ import json as _json
+
+ opts = _opts(repo_url="https://x-access-token:ghs_verysecret@github.com/acme/infra.git")
+
+ metadata = check.build_metadata(opts, redactions=0)
+
+ assert "ghs_verysecret" not in _json.dumps(metadata)
+ assert "x-access-token" not in _json.dumps(metadata)
+ assert metadata["repository"]["url"] == "https://github.com/acme/infra.git"
+ assert metadata["repository"]["host"] == "github.com"
+
+
+def test_an_scp_style_remote_still_yields_a_host():
+ """`git@github.com:acme/infra.git` has no scheme, so urlsplit reads it as a path with no host."""
+ metadata = check.build_metadata(_opts(repo_url="git@github.com:acme/infra.git"), redactions=0)
+
+ assert metadata["repository"]["host"] == "github.com"
+ assert metadata["repository"]["provider"] == "github"
+
+
+def test_a_self_hosted_host_is_unknown_rather_than_guessed():
+ """Guessing `github` for git.example.internal would be worse than admitting we cannot tell."""
+ metadata = check.build_metadata(_opts(repo_url="https://git.example.internal/acme/infra"), redactions=0)
+
+ assert metadata["repository"]["provider"] == "unknown"
+ # The raw host is still recorded, which is what makes the honest answer useful.
+ assert metadata["repository"]["host"] == "git.example.internal"
+
+
+def test_the_declared_repo_path_wins_over_inference():
+ opts = _opts(source_dir=".", repo_path="infra/prod")
+
+ code = check.build_metadata(opts, redactions=0)["code"]
+
+ assert code["repo_path"] == "infra/prod"
+ assert code["repo_path_from"] == "flag"
+
+
+def test_the_repo_path_is_inferred_from_the_enclosing_checkout(tmp_path):
+ """
+ Inference walks up for a `.git` entry rather than shelling out -- this package has no git
+ dependency, and a `.git` *file* (worktrees, submodules) has to count too.
+ """
+ (tmp_path / ".git").write_text("gitdir: /elsewhere")
+ nested = tmp_path / "infra" / "prod"
+ nested.mkdir(parents=True)
+
+ code = check.build_metadata(_opts(source_dir=str(nested)), redactions=0)["code"]
+
+ assert code["repo_path"] == "infra/prod"
+ assert code["repo_path_from"] == "git_root"
+
+
+def test_the_repository_root_is_the_empty_string_not_a_dot(tmp_path):
+ """
+ `""` means the root and joins correctly; `None` means "we could not tell". Collapsing them would
+ make a consumer unable to distinguish a root-level project from an unknown one.
+ """
+ (tmp_path / ".git").mkdir()
+
+ code = check.build_metadata(_opts(source_dir=str(tmp_path)), redactions=0)["code"]
+
+ assert code["repo_path"] == ""
+ assert code["repo_path_from"] == "git_root"
+
+
+def test_an_unlocatable_repository_root_says_so(tmp_path):
+ code = check.build_metadata(_opts(source_dir=str(tmp_path)), redactions=0)["code"]
+
+ assert code["repo_path"] is None
+ assert code["repo_path_from"] is None
+
+
+def test_the_oversize_retry_records_that_the_code_was_dropped_for_size(tmp_path, monkeypatch):
+ """
+ The fallback re-packs without the source. A consumer holding only the bundle must be able to tell
+ that from a deliberate documents-only run, which is the difference between "nothing to fix here"
+ and "we could not show you the code".
+ """
+ import io
+ import json as _json
+ import tarfile
+
+ monkeypatch.setattr(check.archive, "MAX_ARCHIVE_BYTES", 50 * 1024)
+ source = tmp_path / "src"
+ source.mkdir()
+ (source / "main.tf").write_text("")
+ (source / "vendor.bin").write_bytes(os.urandom(200_000))
+
+ archive_bytes, _manifest, reason = check.pack_documents(
+ str(source),
+ {"masked": True},
+ None,
+ None,
+ metadata={"schema_version": 1, "code": {}},
+ )
+
+ assert reason
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
+ metadata = _json.loads(tar.extractfile(check.archive.METADATA_DOCUMENT).read())
+ assert metadata["code"]["absent_reason"] == "too_large"
+ assert metadata["code"]["present"] is False
+
+
+def test_a_declared_repo_path_cannot_escape_the_repository(tmp_path, capsys):
+ """
+ `--repo-path ../..` used to survive `strip("/")` and be recorded verbatim.
+
+ The single use of this field is a consumer joining it to write files back into the repository it
+ thinks it is patching, so a value that climbs out of the tree is the one shape that must not be
+ recorded. Refused and left absent rather than recorded wrong -- absent is a state consumers already
+ handle.
+ """
+ for escaping in ("../..", "/etc", "infra/../../elsewhere"):
+ code = check.build_metadata(_opts(source_dir=str(tmp_path), repo_path=escaping), redactions=0)["code"]
+
+ assert code["repo_path"] != escaping
+ assert code["repo_path"] is None or not code["repo_path"].startswith("..")
+ assert "must be a path inside the repository" in capsys.readouterr().err
+
+
+def test_a_declared_repo_path_is_normalised(tmp_path):
+ """Leading and trailing slashes, and a redundant `.`, all describe the same location."""
+ for declared, expected in (("/infra/prod/", "infra/prod"), ("./infra", "infra"), (".", ""), ("/", "")):
+ code = check.build_metadata(_opts(source_dir=str(tmp_path), repo_path=declared), redactions=0)["code"]
+ assert code["repo_path"] == expected, f"{declared!r} -> {code['repo_path']!r}"
+ assert code["repo_path_from"] == "flag"
+
+
+def test_a_nonexistent_source_dir_fails_rather_than_degrading(tmp_path):
+ """
+ `archive.pack` raises ArchiveError for a missing directory *and* for an oversized archive, and the
+ degrade path only knew about the second. A typo'd `--source-dir` therefore reported "the tree was
+ too large", dropped the code and completed the run -- a check that passed having evaluated no
+ source at all, with the bundle's own metadata stating the wrong reason.
+ """
+ with pytest.raises(check.CheckError, match="--source-dir does not exist"):
+ check.pack_documents(
+ str(tmp_path / "no-such-dir"),
+ {"masked": True},
+ None,
+ None,
+ metadata={"schema_version": 1, "code": {}},
+ )
diff --git a/tests/platform/test_cli_options.py b/tests/platform/test_cli_options.py
new file mode 100644
index 00000000..cba60e13
--- /dev/null
+++ b/tests/platform/test_cli_options.py
@@ -0,0 +1,215 @@
+"""
+Tests for `tirith platform check` option handling.
+
+Everything here is asserted *before* any HTTP call, which is the point: a bad workflow id or a
+contradictory pair of URL flags should fail immediately rather than after a run has been created.
+"""
+
+import json
+
+import pytest
+
+from tirith.platform import cli
+from tirith.status import ExitStatus
+
+PLAN = {"format_version": "1.2", "resource_changes": []}
+
+# The minimum run_check result cli.main will accept without reaching for a missing key.
+PASSED = {"verdict": "passed", "counts": {}, "policies": {}}
+
+
+@pytest.fixture
+def no_network(monkeypatch):
+ """Make any attempt to reach the platform an outright test failure."""
+
+ def explode(*a, **kw):
+ raise AssertionError("run_check was called; the option check should have failed first")
+
+ monkeypatch.setattr(cli, "run_check", explode)
+
+
+def base_args(tmp_path, *extra):
+ plan = tmp_path / "plan.json"
+ plan.write_text(json.dumps(PLAN))
+ return ["platform", "check", "--input-path", str(plan), *extra]
+
+
+def env(monkeypatch, **values):
+ for key in ("SG_API_TOKEN", "SG_ORG", "SG_BASE_URL", "SG_DASHBOARD_URL", "SG_REGION"):
+ monkeypatch.delenv(key, raising=False)
+ for key, value in values.items():
+ monkeypatch.setenv(key, value)
+
+
+class TestWorkflowIdValidation:
+ @pytest.mark.parametrize("workflow_id", ["live/prod/vpc", "has.dots", "a" * 101, "spaces here", ""])
+ def test_a_bad_slug_is_refused_before_any_request(self, workflow_id, tmp_path, monkeypatch, no_network, capsys):
+ """
+ The value is interpolated into every API path and the platform's own field is a slug, so a
+ '/' produces a malformed URL rather than a clear error.
+ """
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ status = cli.main(base_args(tmp_path, "--workflow-id", workflow_id))
+
+ assert status == ExitStatus.ERROR
+ assert "not a valid slug" in capsys.readouterr().err
+
+ def test_the_error_suggests_a_usable_slug(self, tmp_path, monkeypatch, no_network, capsys):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ cli.main(base_args(tmp_path, "--workflow-id", "live/prod/vpc"))
+
+ assert "live-prod-vpc" in capsys.readouterr().err
+
+ @pytest.mark.parametrize("workflow_id", ["github-com-acme-infra-plan", "a_b-C9", "x"])
+ def test_valid_slugs_pass(self, workflow_id, tmp_path, monkeypatch, capsys):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+ seen = {}
+
+ def capture(opts):
+ seen["workflow_id"] = opts.workflow_id
+ return PASSED
+
+ monkeypatch.setattr(cli, "run_check", capture)
+ cli.main(base_args(tmp_path, "--workflow-id", workflow_id))
+
+ assert seen["workflow_id"] == workflow_id
+
+
+class TestRegionResolution:
+ def resolved(self, tmp_path, monkeypatch, *extra):
+ seen = {}
+
+ def capture(opts):
+ seen["api_url"] = opts.api_url
+ seen["dashboard_url"] = opts.dashboard_url
+ return PASSED
+
+ monkeypatch.setattr(cli, "run_check", capture)
+ status = cli.main(base_args(tmp_path, "--workflow-id", "wf", *extra))
+ return status, seen
+
+ def test_region_us_sets_both_urls(self, tmp_path, monkeypatch):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ _status, seen = self.resolved(tmp_path, monkeypatch, "--region", "us")
+
+ assert seen["api_url"] == "https://api.us.stackguardian.io/api/v1"
+ assert seen["dashboard_url"] == "https://us.stackguardian.io"
+
+ def test_defaults_to_eu(self, tmp_path, monkeypatch):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ _status, seen = self.resolved(tmp_path, monkeypatch)
+
+ assert seen["api_url"] == "https://api.app.stackguardian.io/api/v1"
+ assert seen["dashboard_url"] == "https://app.stackguardian.io"
+
+ def test_region_with_an_explicit_url_fails_before_any_request(self, tmp_path, monkeypatch, no_network, capsys):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ status = cli.main(
+ base_args(tmp_path, "--workflow-id", "wf", "--region", "us", "--api-url", "https://x.example")
+ )
+
+ assert status == ExitStatus.ERROR
+ assert "cannot be combined" in capsys.readouterr().err
+
+ def test_an_unknown_region_is_rejected_by_the_parser(self, tmp_path, monkeypatch):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ with pytest.raises(SystemExit):
+ cli.main(base_args(tmp_path, "--workflow-id", "wf", "--region", "uss"))
+
+ def test_a_base_url_without_the_api_path_still_works(self, tmp_path, monkeypatch):
+ """A SG_BASE_URL exported for sg-cli omits /api/v1 and used to 404 here."""
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme", SG_BASE_URL="https://api.us.stackguardian.io")
+
+ _status, seen = self.resolved(tmp_path, monkeypatch)
+
+ assert seen["api_url"] == "https://api.us.stackguardian.io/api/v1"
+
+ def test_setting_only_the_api_url_still_gets_correct_run_links(self, tmp_path, monkeypatch):
+ """The original footgun: run links pointed at the EU dashboard for a US org."""
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ _status, seen = self.resolved(tmp_path, monkeypatch, "--api-url", "https://api.us.stackguardian.io")
+
+ assert seen["dashboard_url"] == "https://us.stackguardian.io"
+
+
+class TestDocumentSelection:
+ def test_a_plan_is_discovered_when_nothing_is_named(self, tmp_path, monkeypatch):
+ (tmp_path / "plan.json").write_text(json.dumps(PLAN))
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+ seen = {}
+ monkeypatch.setattr(cli, "run_check", lambda opts: seen.update(input_path=opts.input_path) or PASSED)
+
+ cli.main(["platform", "check", "--workflow-id", "wf", "--source-dir", str(tmp_path)])
+
+ assert seen["input_path"].endswith("plan.json")
+
+ def test_nothing_to_evaluate_is_an_error(self, tmp_path, monkeypatch, no_network, capsys):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ status = cli.main(["platform", "check", "--workflow-id", "wf", "--source-dir", str(tmp_path)])
+
+ assert status == ExitStatus.ERROR
+ assert "No plan document found" in capsys.readouterr().err
+
+ def test_plan_file_and_input_path_cannot_be_combined(self, tmp_path, monkeypatch, no_network, capsys):
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+
+ status = cli.main(base_args(tmp_path, "--workflow-id", "wf", "--plan-file", str(tmp_path / "tfplan")))
+
+ assert status == ExitStatus.ERROR
+ assert "cannot be combined" in capsys.readouterr().err
+
+ def test_an_explicit_input_path_skips_discovery(self, tmp_path, monkeypatch):
+ """Two candidates would be ambiguous for discovery, but naming one is unambiguous."""
+ (tmp_path / "plan.json").write_text(json.dumps(PLAN))
+ (tmp_path / "tfplan.json").write_text(json.dumps(PLAN))
+ env(monkeypatch, SG_API_TOKEN="sgo_x", SG_ORG="acme")
+ seen = {}
+ monkeypatch.setattr(cli, "run_check", lambda opts: seen.update(input_path=opts.input_path) or PASSED)
+
+ status = cli.main(
+ [
+ "platform",
+ "check",
+ "--workflow-id",
+ "wf",
+ "--source-dir",
+ str(tmp_path),
+ "--input-path",
+ str(tmp_path / "tfplan.json"),
+ ]
+ )
+
+ assert status != ExitStatus.ERROR
+ assert seen["input_path"].endswith("tfplan.json")
+
+
+class TestCredentials:
+ def test_credentials_come_from_the_environment(self, tmp_path, monkeypatch):
+ """
+ The one-liner needs this: GitHub exposes neither secrets nor vars as env automatically, so
+ an `env:` block is the only no-`with:` route.
+ """
+ env(monkeypatch, SG_API_TOKEN="sgo_fromenv", SG_ORG="acme-from-env")
+ seen = {}
+ monkeypatch.setattr(cli, "run_check", lambda opts: seen.update(api_key=opts.api_key, org=opts.org) or PASSED)
+
+ cli.main(base_args(tmp_path, "--workflow-id", "wf"))
+
+ assert seen == {"api_key": "sgo_fromenv", "org": "acme-from-env"}
+
+ def test_missing_credentials_name_both(self, tmp_path, monkeypatch, no_network, capsys):
+ env(monkeypatch)
+
+ status = cli.main(base_args(tmp_path, "--workflow-id", "wf"))
+
+ assert status == ExitStatus.ERROR
+ err = capsys.readouterr().err
+ assert "--api-key" in err and "--org" in err
diff --git a/tests/platform/test_client.py b/tests/platform/test_client.py
new file mode 100644
index 00000000..0b44ab2c
--- /dev/null
+++ b/tests/platform/test_client.py
@@ -0,0 +1,666 @@
+"""
+Tests for the StackGuardian client.
+
+The polling contract is the part worth pinning: a run that rests in a state the poller does not
+recognise as terminal spins until the timeout and is then reported as a tool failure -- turning a
+completed evaluation into what looks like an outage.
+"""
+
+import json
+
+import pytest
+
+from tirith.platform import client
+from tirith.platform.client import SGClient, SGError, _extract_signed_url
+
+# --- terminal statuses -------------------------------------------------------------------------
+
+
+def test_approval_required_is_terminal():
+ """
+ A regression test. APPROVAL_REQUIRED is a resting state -- reached when a policy's onFail is
+ APPROVAL_REQUIRED -- and nothing further happens without a human. Treating it as transient
+ made the poller spin to its timeout and report a tool failure for a finished evaluation.
+ """
+ assert "APPROVAL_REQUIRED" in client.TERMINAL_STATUSES
+
+
+@pytest.mark.parametrize("status", ["COMPLETED", "ERRORED", "CANCELLED", "APPROVAL_REQUIRED"])
+def test_terminal_statuses_stop_the_poll(status):
+ assert status in client.TERMINAL_STATUSES
+
+
+@pytest.mark.parametrize("status", ["QUEUED", "PENDING", "RUNNING"])
+def test_transient_statuses_keep_polling(status):
+ """A run can sit in QUEUED behind the per-workflow concurrency gate for a long while."""
+ assert status not in client.TERMINAL_STATUSES
+
+
+def test_wait_for_run_returns_on_a_terminal_status(monkeypatch):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ statuses = iter([{"LatestStatus": "QUEUED"}, {"LatestStatus": "RUNNING"}, {"LatestStatus": "COMPLETED"}])
+ monkeypatch.setattr(sg, "get_run", lambda *a, **k: next(statuses))
+ monkeypatch.setattr(client.time, "sleep", lambda _s: None)
+
+ status, _run = sg.wait_for_run("default", "wf", "run", timeout=30)
+
+ assert status == "COMPLETED"
+
+
+def test_wait_for_run_reports_each_status_change(monkeypatch):
+ """Without this a run queued behind another looks identical to a hung one."""
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ statuses = iter([{"LatestStatus": "QUEUED"}, {"LatestStatus": "QUEUED"}, {"LatestStatus": "COMPLETED"}])
+ monkeypatch.setattr(sg, "get_run", lambda *a, **k: next(statuses))
+ monkeypatch.setattr(client.time, "sleep", lambda _s: None)
+ seen = []
+
+ sg.wait_for_run("default", "wf", "run", timeout=30, on_poll=seen.append)
+
+ assert seen == ["QUEUED", "COMPLETED"], "only changes are reported, not every poll"
+
+
+def test_wait_for_run_timeout_is_an_error_never_a_pass(monkeypatch):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "get_run", lambda *a, **k: {"LatestStatus": "RUNNING"})
+ monkeypatch.setattr(client.time, "sleep", lambda _s: None)
+
+ with pytest.raises(SGError):
+ sg.wait_for_run("default", "wf", "run", timeout=-1)
+
+
+# --- signed URL extraction ---------------------------------------------------------------------
+
+
+def test_extract_signed_url_accepts_a_bare_string_in_msg():
+ """What tfstate_upload_url actually returns."""
+ assert _extract_signed_url({"msg": "https://s3.example/put"}) == "https://s3.example/put"
+
+
+def test_extract_signed_url_accepts_a_nested_object():
+ assert _extract_signed_url({"data": {"signedUrl": "https://s3.example/put"}}) == "https://s3.example/put"
+
+
+def test_extract_signed_url_returns_none_when_absent():
+ assert _extract_signed_url({"msg": "some error text"}) is None
+
+
+# --- archive upload ----------------------------------------------------------------------------
+
+
+def _fake_put(recorder):
+ """Stand in for the presigned PUT, recording what was sent."""
+
+ def fake_urlopen(request, timeout=None):
+ recorder["content_type"] = request.get_header("Content-type")
+ recorder["body"] = request.data
+
+ class _R:
+ status = 200
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ return _R()
+
+ return fake_urlopen
+
+
+def test_upload_archive_tolerates_a_response_with_no_storage_key(monkeypatch):
+ """
+ The key used to be mandatory, because the caller passed it back as a run field and an api that did
+ not return it produced a run pointing at nothing. Nothing passes it anywhere now -- the step finds
+ the bundle by name in the artifacts directory -- so an api that omits it must not fail the upload.
+
+ This is what lets the feature ship against an unmodified api.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (200, {"msg": "https://s3.example/put"}))
+ monkeypatch.setattr(client.urllib.request, "urlopen", _fake_put({}))
+
+ key = sg.upload_file("default", "wf", "a.tar.gz", None, b"x")
+
+ assert "a.tar.gz" in key
+
+
+def _upload_response():
+ """What file_upload_url returns: the URL as a bare string in msg, the key alongside in data."""
+ return (200, {"msg": "https://s3.example/put", "data": {"key": "orgs/acme/wfs/K/artifacts/abc1234/a.tar.gz"}})
+
+
+def test_upload_archive_returns_the_key_from_the_response(monkeypatch):
+ """
+ Never rebuilt client-side: the layout depends on ArtifactsUnderKSUID, ResourceKSUID and
+ OriginalArtifactPath, so a guess is wrong for exactly the customers hardest to debug.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: _upload_response())
+ uploaded = {}
+
+ def fake_urlopen(request, timeout=None):
+ uploaded["content_type"] = request.get_header("Content-type")
+ uploaded["body"] = request.data
+
+ class _R:
+ status = 200
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ return _R()
+
+ monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen)
+
+ key = sg.upload_file("default", "wf", "a.tar.gz", "abc1234", b"tarbytes")
+
+ assert key == "orgs/acme/wfs/K/artifacts/abc1234/a.tar.gz"
+ assert uploaded["body"] == b"tarbytes"
+ # application/json even though the body is gzip: the endpoint signs application/json whatever
+ # the filename, and S3 validates the signature against the header the client sends. Asking for
+ # application/gzip would need an api change, and sending it unasked earns SignatureDoesNotMatch.
+ assert uploaded["content_type"] == "application/json"
+
+
+def test_upload_archive_uses_the_shared_artifact_endpoint(monkeypatch):
+ """
+ Not a bespoke endpoint. The bundle has to land in the workflow's own artifact prefix, because
+ that prefix is what the runner syncs down into the step -- so it uploads through the same route
+ every other artifact uses.
+
+ And it must ask for nothing the endpoint does not already offer: no contentType parameter, since
+ signing anything other than application/json would need an api change this feature avoids.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ seen = {}
+
+ def fake_request(method, path, *a, **k):
+ seen["method"] = method
+ seen["path"] = path
+ return _upload_response()
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+ monkeypatch.setattr(client.urllib.request, "urlopen", _ok_urlopen())
+
+ sg.upload_file("default", "wf", "a.tar.gz", "abc1234", b"tarbytes")
+
+ assert seen["method"] == "GET"
+ assert "/file_upload_url/" in seen["path"]
+ assert "configuration_upload_url" not in seen["path"]
+ assert "contentType" not in seen["path"], "asking for a signed content type needs an api change"
+ assert "filename=a.tar.gz" in seen["path"]
+
+
+def _ok_urlopen():
+ def fake_urlopen(request, timeout=None):
+ class _R:
+ status = 200
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ return _R()
+
+ return fake_urlopen
+
+
+# --- run creation ------------------------------------------------------------------------------
+
+
+def test_create_run_sends_no_step_config(monkeypatch):
+ """
+ core ignores WfStepsConfig for TERRAFORM workflows and synthesises the steps from the stored
+ TerraformConfig plus this TerraformAction. Sending one would be dead weight that reads as if
+ it were doing something.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+
+ def fake_request(method, path, body=None, **kwargs):
+ captured["body"] = body
+ return 200, {"data": {"ResourceName": "wfrun-1"}}
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+
+ run_id, _data = sg.create_run("default", "wf", {"type": "tirith"})
+
+ assert run_id == "wfrun-1"
+ assert "WfStepsConfig" not in captured["body"]
+ # `plan` is a dummy: the policy step is spliced in ahead of the plan step and exits 12, so the
+ # plan never runs. `plan` is simply the action whose synthesis splices pre-plan steps in.
+ assert captured["body"]["TerraformAction"] == {"action": "plan"}
+ # No archive field of any kind. The bundle reaches the step through the workflow's artifact
+ # directory, which is what lets this run against an unmodified api -- so a field appearing here
+ # again would mean the api dependency had come back.
+ assert "terraformProjectZip" not in captured["body"]
+ assert "CodeZipWfArtifactPath" not in captured["body"]
+ assert "ContextTags" not in captured["body"]
+
+
+def test_create_run_does_not_depend_on_the_platform_echoing_an_archive_field(monkeypatch):
+ """
+ There used to be a guard here: the run body carried `terraformProjectZip`, an api that did not
+ declare it dropped it silently during validation, and the run then evaluated a VCS checkout instead
+ of the uploaded code. The guard asserted the field back out of RuntimeParameters.
+
+ It is gone because the cause is gone -- nothing is sent for the platform to drop. A run whose
+ RuntimeParameters mention no archive at all is now completely normal, and must not fail.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(
+ sg,
+ "_request",
+ lambda *a, **k: (200, {"data": {"ResourceName": "wfrun-1", "RuntimeParameters": {"vcsConfig": {}}}}),
+ )
+
+ run_id, _data = sg.create_run("default", "wf", {"type": "tirith"})
+
+ assert run_id == "wfrun-1"
+
+
+def test_create_run_accepts_a_response_that_carries_no_runtime_parameters(monkeypatch):
+ """
+ A response shape without RuntimeParameters is not evidence the field was dropped, and failing on
+ it would break the client against a platform that is behaving correctly.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (200, {"data": {"ResourceName": "wfrun-1"}}))
+
+ run_id, _data = sg.create_run("default", "wf", {"type": "tirith"})
+
+ assert run_id == "wfrun-1"
+
+
+def test_create_run_passes_when_the_platform_stored_the_archive_reference(monkeypatch):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(
+ sg,
+ "_request",
+ lambda *a, **k: (
+ 200,
+ {"data": {"ResourceName": "wfrun-1", "RuntimeParameters": {"terraformProjectZip": "orgs/acme/a.tar.gz"}}},
+ ),
+ )
+
+ run_id, _data = sg.create_run("default", "wf", {"type": "tirith"})
+
+ assert run_id == "wfrun-1"
+
+
+def test_ensure_workflow_creates_a_terraform_workflow(monkeypatch):
+ """
+ TERRAFORM rather than CUSTOM: it is what makes core synthesise the steps from TerraformConfig,
+ and what makes the run render as a real terraform run in the dashboard.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+
+ def fake_request(method, path, body=None, **kwargs):
+ captured["body"] = body
+ return 201, {}
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+
+ sg.ensure_workflow("default", "wf", "desc", {"terraformVersion": "1.5.7"})
+
+ assert captured["body"]["WfType"] == "TERRAFORM"
+ assert captured["body"]["TerraformConfig"] == {"terraformVersion": "1.5.7"}
+ assert captured["body"]["Id"] == captured["body"]["ResourceName"] == "wf"
+
+
+def test_conflict_on_create_is_success(monkeypatch):
+ """Re-running the action against an existing workflow must not be an error."""
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (409, {"msg": "already exists"}))
+
+ assert sg.ensure_workflow("default", "wf", "d", {}) == 409
+ assert sg.ensure_workflow_group("default") == 409
+
+
+# --- auth --------------------------------------------------------------------------------------
+
+
+def test_auth_header_uses_the_apikey_scheme(monkeypatch):
+ """Matches sg-cli: `Authorization: apikey `, not Bearer."""
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_secret")
+ captured = {}
+
+ def fake_urlopen(request, timeout=None):
+ captured["auth"] = request.get_header("Authorization")
+
+ class _R:
+ status = 200
+
+ def read(self):
+ return json.dumps({"msg": "ok"}).encode()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ return _R()
+
+ monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen)
+
+ sg._request("GET", "/wfgrps/")
+
+ assert captured["auth"] == "apikey sgo_secret"
+
+
+# --- run facts and cleanup ----------------------------------------------------------------------
+
+
+def test_policy_results_follow_the_snake_case_signed_url(monkeypatch):
+ """
+ The facts endpoint returns `signed_url`; this used to read only `signedUrl` and so always
+ returned {}. It went unnoticed for as long as the results artifact was covering for it.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (200, {"msg": {"signed_url": "https://s3.example/facts"}}))
+
+ class _R:
+ def read(self):
+ return json.dumps({"PolicyEvalResults": {"p": [{"result": "PASS"}]}}).encode()
+
+ def info(self):
+ return {}
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ monkeypatch.setattr(client.urllib.request, "urlopen", lambda *a, **k: _R())
+
+ assert sg.get_policy_results("default", "wf", "run-1") == {"p": [{"result": "PASS"}]}
+
+
+def test_policy_results_accept_an_inline_payload(monkeypatch):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(
+ sg, "_request", lambda *a, **k: (200, {"msg": {"PolicyEvalResults": {"p": [{"result": "FAIL"}]}}})
+ )
+
+ assert sg.get_policy_results("default", "wf", "run-1") == {"p": [{"result": "FAIL"}]}
+
+
+def test_missing_results_artifact_is_none_not_empty(monkeypatch):
+ """
+ The caller distinguishes "no such artifact, the facts are authoritative" from "the artifact
+ exists and no policies matched". Collapsing both to {} would hide a real no-policies verdict.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (404, {"msg": "not found"}))
+
+ assert sg.get_results_artifact("default", "wf", "run-1/tirith-results.json") is None
+
+
+@pytest.mark.parametrize("status", [200, 204, 404])
+def test_delete_artifact_treats_absence_as_success(monkeypatch, status):
+ """404 means someone already removed it, which is the state we wanted."""
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (status, {}))
+
+ assert sg.delete_artifact("default", "wf", "__sg.abc1234-default.tar.gz") is True
+
+
+def test_delete_artifact_reports_failure_rather_than_raising(monkeypatch):
+ """Cleanup runs after the verdict is known, so a failure must not change the outcome."""
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (403, {"msg": "denied"}))
+
+ assert sg.delete_artifact("default", "wf", "__sg.abc1234-default.tar.gz") is False
+
+
+def test_delete_artifact_targets_a_single_path_segment(monkeypatch):
+ """
+ A nested name is swallowed by the greedy converter in the authorizer and matches
+ `DELETE .../wfgrps//` -- the workflow-group delete -- so it would be checked against
+ entirely the wrong permission.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ seen = {}
+ monkeypatch.setattr(sg, "_request", lambda m, p, *a, **k: (seen.update(method=m, path=p), (200, {}))[1])
+
+ sg.delete_artifact("default", "wf", "__sg.abc1234-default.tar.gz")
+
+ assert seen["method"] == "DELETE"
+ tail = seen["path"].split("/artifacts/", 1)[1].rstrip("/")
+ assert "/" not in tail, f"artifact name must be one segment, got {tail!r}"
+
+
+@pytest.mark.parametrize("folder", [None, ""])
+def test_upload_archive_omits_an_unset_folder(monkeypatch, folder):
+ """
+ urlencode stringifies None to the literal "None", and the endpoint treats any non-empty value
+ as a subfolder -- so passing it unconditionally created a real `None/` directory in S3 and left
+ the archive at a nested key the post-run delete could not address. Caught in QA.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ seen = {}
+ monkeypatch.setattr(sg, "_request", lambda m, p, *a, **k: (seen.update(path=p), _upload_response())[1])
+ monkeypatch.setattr(client.urllib.request, "urlopen", _ok_urlopen())
+
+ sg.upload_file("default", "wf", "__sg.abc1234-default.tar.gz", folder, b"tarbytes")
+
+ assert "folder=" not in seen["path"], seen["path"]
+ assert "None" not in seen["path"], seen["path"]
+
+
+def test_upload_archive_sends_a_folder_when_one_is_given(monkeypatch):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ seen = {}
+ monkeypatch.setattr(sg, "_request", lambda m, p, *a, **k: (seen.update(path=p), _upload_response())[1])
+ monkeypatch.setattr(client.urllib.request, "urlopen", _ok_urlopen())
+
+ sg.upload_file("default", "wf", "a.tar.gz", "abc1234", b"tarbytes")
+
+ assert "folder=abc1234" in seen["path"]
+
+
+# --- publishing the state document ---------------------------------------------------------------
+
+
+def test_upload_file_honours_a_json_content_type(monkeypatch):
+ """
+ The state document is JSON, not a gzip. S3 signs the content type into the URL, so sending the
+ archive's type with a JSON body is a signature mismatch.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+
+ def fake_request(method, path, **kwargs):
+ captured["path"] = path
+ return _upload_response()
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+ uploaded = {}
+
+ def fake_urlopen(request, timeout=None):
+ uploaded["content_type"] = request.get_header("Content-type")
+ uploaded["body"] = request.data
+
+ class _R:
+ status = 200
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ return _R()
+
+ monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen)
+
+ sg.upload_file("default", "wf", "tfstate.json", None, b'{"version": 4}', content_type="application/json")
+
+ assert uploaded["content_type"] == "application/json"
+ assert uploaded["body"] == b'{"version": 4}'
+ # The endpoint already signs application/json, so nothing has to be asked for.
+ assert "contentType" not in captured["path"]
+
+
+def test_manages_terraform_state_reads_the_workflow_config(monkeypatch):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+
+ monkeypatch.setattr(
+ sg, "_request", lambda *a, **k: (200, {"msg": {"TerraformConfig": {"managedTerraformState": True}}})
+ )
+ assert sg.manages_terraform_state("default", "wf") is True
+
+ monkeypatch.setattr(
+ sg, "_request", lambda *a, **k: (200, {"msg": {"TerraformConfig": {"managedTerraformState": False}}})
+ )
+ assert sg.manages_terraform_state("default", "wf") is False
+
+
+@pytest.mark.parametrize(
+ "response",
+ [
+ (404, {"msg": "not found"}),
+ (500, {"msg": "boom"}),
+ (200, {"msg": "a string, not a dict"}),
+ (200, {}),
+ ],
+)
+def test_an_unreadable_workflow_is_treated_as_managing_its_own_state(monkeypatch, response):
+ """
+ Fails safe. Not being able to tell whether `artifacts/tfstate.json` is live terraform state is
+ not a reason to overwrite it with a masked document.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: response)
+
+ assert sg.manages_terraform_state("default", "wf") is True
+
+
+def test_an_absent_facts_document_is_not_a_read_failure(monkeypatch):
+ """
+ 404 means the run produced no facts document, which is a legitimate empty result. Treating it
+ as unreadable would turn healthy runs red -- the opposite of the mistake the raise exists to fix.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (404, {"msg": "not found"}))
+
+ assert sg.get_run_facts("default", "wf", "run-1") == {}
+
+
+def test_an_unreadable_facts_document_raises_rather_than_reading_as_empty(monkeypatch):
+ """
+ A 403 or a 500 means we could not read the verdict, not that there was none. Returning {} made
+ that indistinguishable from "no policies in scope", so a run whose policies had failed reported
+ a clean scope and exited 0.
+ """
+ for status in (403, 500, 502):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ monkeypatch.setattr(sg, "_request", lambda *a, **k: (status, {"msg": "nope"}))
+
+ with pytest.raises(SGError, match="Could not read the run facts"):
+ sg.get_run_facts("default", "wf", "run-1")
+
+
+def test_create_run_sends_the_bundle_name_in_terraform_config(monkeypatch):
+ """
+ The per-run channel, and the only one that works for a TERRAFORM workflow.
+
+ core ignores a run's `WfStepsConfig` for TERRAFORM and synthesises the steps from TerraformConfig
+ instead, so a step entry has to travel inside `TerraformConfig.prePlanWfStepsConfig` to reach the
+ run at all. Verified against QA: the same entry sent as top-level WfStepsConfig was silently
+ discarded and the step kept the workflow's stored path.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+
+ def fake_request(method, path, body=None, **kwargs):
+ captured["body"] = body
+ return 200, {"data": {"ResourceName": "wfrun-1"}}
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+ step = {
+ "name": "tirith-iac-governance",
+ "wfStepInputData": {"data": {"bundlePath": "tirith-bundle-a1b2c3d-plan.tar.gz"}},
+ }
+
+ sg.create_run("default", "wf", {"type": "tirith"}, pre_plan_steps=[step])
+
+ sent = captured["body"]["TerraformConfig"]["prePlanWfStepsConfig"]
+ assert sent[0]["wfStepInputData"]["data"]["bundlePath"] == "tirith-bundle-a1b2c3d-plan.tar.gz"
+ # Only prePlanWfStepsConfig: core's merge is shallow, so sending terraformVersion or
+ # managedTerraformState here would override what the workflow stores rather than inherit it.
+ assert set(captured["body"]["TerraformConfig"]) == {"prePlanWfStepsConfig"}
+
+
+def test_create_run_without_steps_sends_no_terraform_config(monkeypatch):
+ """A caller that names no bundle must not blank the workflow's stored configuration."""
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+ monkeypatch.setattr(
+ sg,
+ "_request",
+ lambda m, p, body=None, **k: (captured.setdefault("body", body), (200, {"data": {"ResourceName": "r"}}))[1],
+ )
+
+ sg.create_run("default", "wf", {"type": "tirith"})
+
+ assert "TerraformConfig" not in captured["body"]
+
+
+def test_every_run_suppresses_the_vcs_checkout(monkeypatch):
+ """
+ The run must send an *empty* VCSConfig, and must send it even when nothing else is set.
+
+ core resolves the run's config as `data.get("VCSConfig", wfDetails.get("VCSConfig", {}))`, so a
+ present empty value beats the workflow's and an omitted key inherits it. Inheriting is what broke
+ private repositories: the runner cloned with no credentials and the run ERRORED before the step
+ ran. Asserting `== {}` rather than truthiness is the point -- `None` would also read as "no
+ checkout" here but flows into core's config-policy payload as a null.
+ """
+ for kwargs in ({}, {"pre_plan_steps": [{"name": "tirith-iac-governance"}]}):
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+
+ def fake_request(method, path, body=None, **kw):
+ captured["body"] = body
+ return 200, {"data": {"ResourceName": "r"}}
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+ sg.create_run("default", "wf", {"type": "tirith"}, **kwargs)
+
+ assert "VCSConfig" in captured["body"], "an omitted key inherits the workflow's repo"
+ assert captured["body"]["VCSConfig"] == {}
+
+
+def test_the_workflow_still_records_its_repository(monkeypatch):
+ """
+ Suppressing the checkout per run must not cost the workflow its repo link -- that is the whole
+ reason the config is set on creation, and the two live at different levels for that reason.
+ """
+ sg = SGClient("https://api.example/api/v1", "acme", "sgo_x")
+ captured = {}
+
+ def fake_request(method, path, body=None, **kw):
+ captured["body"] = body
+ return 201, {}
+
+ monkeypatch.setattr(sg, "_request", fake_request)
+ vcs = SGClient.vcs_config("https://github.com/acme/repo", "main")
+ sg.ensure_workflow("default", "wf", "d", {"terraformVersion": "1.5.7"}, vcs_config=vcs)
+
+ source = captured["body"]["VCSConfig"]["iacVCSConfig"]["customSource"]
+ assert source["config"]["repo"] == "https://github.com/acme/repo"
+ assert source["sourceConfigDestKind"] == "GIT_OTHER"
+ # api rejects iacVCSConfig without it, so it is always present at this level -- which is exactly
+ # why the run has to send an empty config rather than a trimmed one.
+ assert captured["body"]["VCSConfig"]["iacVCSConfig"]["useMarketplaceTemplate"] is False
diff --git a/tests/platform/test_discover.py b/tests/platform/test_discover.py
new file mode 100644
index 00000000..f780210f
--- /dev/null
+++ b/tests/platform/test_discover.py
@@ -0,0 +1,229 @@
+"""
+Tests for convention-based document discovery and `terraform show -json`.
+
+The property worth protecting hardest is in `test_the_plan_never_reaches_github_output`: calling the
+CI wrapper instead of the real binary copies the entire unmasked plan into $GITHUB_OUTPUT, a file
+every later step in the job can read.
+"""
+
+import json
+import os
+import stat
+
+import pytest
+
+from tirith.platform import discover
+from tirith.platform.discover import DiscoveryError
+
+PLAN = {"format_version": "1.2", "resource_changes": []}
+
+
+def write(path, content):
+ path.write_text(content if isinstance(content, str) else json.dumps(content))
+ return path
+
+
+def fake_binary(directory, name, script):
+ """Drop an executable shell script on disk to stand in for terraform."""
+ directory.mkdir(parents=True, exist_ok=True)
+ path = directory / name
+ path.write_text(script)
+ path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+ return path
+
+
+class TestDiscoverInput:
+ def test_finds_plan_json(self, tmp_path):
+ write(tmp_path / "plan.json", PLAN)
+ assert discover.discover_input(str(tmp_path)) == os.path.join(str(tmp_path), "plan.json")
+
+ def test_finds_tfplan_json(self, tmp_path):
+ write(tmp_path / "tfplan.json", PLAN)
+ assert discover.discover_input(str(tmp_path)) == os.path.join(str(tmp_path), "tfplan.json")
+
+ def test_two_candidates_is_an_error(self, tmp_path):
+ """
+ Not "first one wins": silently evaluating the wrong document reports a verdict about
+ infrastructure the caller did not ask about, and it looks like a pass.
+ """
+ write(tmp_path / "plan.json", PLAN)
+ write(tmp_path / "tfplan.json", PLAN)
+
+ with pytest.raises(DiscoveryError) as excinfo:
+ discover.discover_input(str(tmp_path))
+
+ assert "plan.json" in str(excinfo.value)
+ assert "tfplan.json" in str(excinfo.value)
+ assert "--input-path" in str(excinfo.value)
+
+ def test_no_candidate_names_every_way_out(self, tmp_path):
+ with pytest.raises(DiscoveryError) as excinfo:
+ discover.discover_input(str(tmp_path))
+
+ message = str(excinfo.value)
+ assert "plan.json" in message and "tfplan.json" in message
+ assert "--plan-file" in message
+ assert "--input-path" in message
+
+ def test_is_not_recursive(self, tmp_path):
+ """A plan in a subdirectory belongs to a different unit; picking it up would be wrong."""
+ (tmp_path / "modules").mkdir()
+ write(tmp_path / "modules" / "plan.json", PLAN)
+
+ with pytest.raises(DiscoveryError):
+ discover.discover_input(str(tmp_path))
+
+ def test_ignores_other_json_in_the_directory(self, tmp_path):
+ """Two fixed names, not a glob -- a glob would sweep up infracost.json or package.json."""
+ write(tmp_path / "infracost.json", {"projects": []})
+ write(tmp_path / "package.json", {})
+
+ with pytest.raises(DiscoveryError):
+ discover.discover_input(str(tmp_path))
+
+ def test_a_directory_named_plan_json_is_not_a_document(self, tmp_path):
+ (tmp_path / "plan.json").mkdir()
+
+ with pytest.raises(DiscoveryError):
+ discover.discover_input(str(tmp_path))
+
+
+class TestResolveBinary:
+ def test_prefers_terraform_bin_over_terraform(self, tmp_path, monkeypatch):
+ """
+ setup-terraform installs a JS wrapper as `terraform` and moves the real binary to
+ `terraform-bin`. Calling the wrapper leaks the plan into $GITHUB_OUTPUT.
+ """
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "terraform", "#!/bin/sh\nexit 0\n")
+ fake_binary(bindir, "terraform-bin", "#!/bin/sh\nexit 0\n")
+ monkeypatch.setenv("PATH", str(bindir))
+
+ assert os.path.basename(discover._resolve_binary()) == "terraform-bin"
+
+ def test_uses_terraform_cli_path_when_set(self, tmp_path, monkeypatch):
+ bindir = tmp_path / "toolcache"
+ fake_binary(bindir, "terraform-bin", "#!/bin/sh\nexit 0\n")
+ otherdir = tmp_path / "bin"
+ fake_binary(otherdir, "terraform", "#!/bin/sh\nexit 0\n")
+ monkeypatch.setenv("PATH", str(otherdir))
+ monkeypatch.setenv("TERRAFORM_CLI_PATH", str(bindir))
+
+ assert discover._resolve_binary() == str(bindir / "terraform-bin")
+
+ def test_falls_back_to_tofu(self, tmp_path, monkeypatch):
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "tofu", "#!/bin/sh\nexit 0\n")
+ monkeypatch.setenv("PATH", str(bindir))
+ monkeypatch.delenv("TERRAFORM_CLI_PATH", raising=False)
+ monkeypatch.delenv("TOFU_CLI_PATH", raising=False)
+
+ assert os.path.basename(discover._resolve_binary()) == "tofu"
+
+ def test_an_explicit_binary_wins(self, tmp_path, monkeypatch):
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "terraform-bin", "#!/bin/sh\nexit 0\n")
+ monkeypatch.setenv("PATH", str(bindir))
+
+ assert discover._resolve_binary("/opt/custom/tofu") == "/opt/custom/tofu"
+
+ def test_nothing_found_says_what_to_do(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("PATH", str(tmp_path / "empty"))
+ monkeypatch.delenv("TERRAFORM_CLI_PATH", raising=False)
+ monkeypatch.delenv("TOFU_CLI_PATH", raising=False)
+
+ with pytest.raises(DiscoveryError, match="--terraform-bin"):
+ discover._resolve_binary()
+
+
+class TestTerraformShowJson:
+ def test_returns_the_parsed_plan(self, tmp_path, monkeypatch):
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "terraform-bin", f"#!/bin/sh\necho '{json.dumps(PLAN)}'\n")
+ monkeypatch.setenv("PATH", str(bindir))
+ plan_file = tmp_path / "tfplan"
+ plan_file.write_bytes(b"binary")
+
+ assert discover.terraform_show_json(str(plan_file)) == PLAN
+
+ def test_the_plan_never_reaches_github_output(self, tmp_path, monkeypatch):
+ """
+ The regression that motivates the whole resolution order. `terraform-bin` is the real
+ binary; the `terraform` beside it is the wrapper, which would append the plan to
+ $GITHUB_OUTPUT. That file must still be empty afterwards.
+ """
+ bindir = tmp_path / "bin"
+ github_output = tmp_path / "gh_output"
+ github_output.write_text("")
+ fake_binary(bindir, "terraform-bin", f"#!/bin/sh\necho '{json.dumps(PLAN)}'\n")
+ # Stands in for the setup-terraform wrapper: it echoes the plan AND appends it to
+ # $GITHUB_OUTPUT, exactly as core.setOutput('stdout', ...) does.
+ fake_binary(
+ bindir,
+ "terraform",
+ f"#!/bin/sh\necho 'stdout<> \"$GITHUB_OUTPUT\"\n"
+ f"echo '{json.dumps(PLAN)}' >> \"$GITHUB_OUTPUT\"\n"
+ f"echo '{json.dumps(PLAN)}'\n",
+ )
+ monkeypatch.setenv("PATH", str(bindir))
+ monkeypatch.setenv("GITHUB_OUTPUT", str(github_output))
+ plan_file = tmp_path / "tfplan"
+ plan_file.write_bytes(b"binary")
+
+ assert discover.terraform_show_json(str(plan_file)) == PLAN
+ assert github_output.read_text() == "", "the wrapper ran and leaked the plan into $GITHUB_OUTPUT"
+
+ def test_invokes_show_json(self, tmp_path, monkeypatch):
+ bindir = tmp_path / "bin"
+ argv_log = tmp_path / "argv"
+ fake_binary(
+ bindir,
+ "terraform-bin",
+ f"#!/bin/sh\necho \"$@\" > '{argv_log}'\necho '{json.dumps(PLAN)}'\n",
+ )
+ monkeypatch.setenv("PATH", str(bindir))
+ plan_file = tmp_path / "tfplan"
+ plan_file.write_bytes(b"binary")
+
+ discover.terraform_show_json(str(plan_file))
+
+ assert argv_log.read_text().startswith("show -json ")
+
+ def test_a_wrapper_without_its_real_binary_is_refused(self, tmp_path, monkeypatch):
+ """
+ TERRAFORM_CLI_PATH set but no terraform-bin anywhere means the only terraform on PATH is the
+ wrapper. Refuse rather than leak.
+ """
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "terraform", "#!/bin/sh\nexit 0\n")
+ monkeypatch.setenv("PATH", str(bindir))
+ monkeypatch.setenv("TERRAFORM_CLI_PATH", str(tmp_path / "toolcache"))
+ monkeypatch.delenv("TOFU_CLI_PATH", raising=False)
+ plan_file = tmp_path / "tfplan"
+ plan_file.write_bytes(b"binary")
+
+ with pytest.raises(DiscoveryError, match="GITHUB_OUTPUT"):
+ discover.terraform_show_json(str(plan_file))
+
+ def test_a_failure_surfaces_stderr(self, tmp_path, monkeypatch):
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "terraform-bin", '#!/bin/sh\necho "Saved plan is stale" >&2\nexit 1\n')
+ monkeypatch.setenv("PATH", str(bindir))
+ plan_file = tmp_path / "tfplan"
+ plan_file.write_bytes(b"binary")
+
+ with pytest.raises(DiscoveryError, match="Saved plan is stale"):
+ discover.terraform_show_json(str(plan_file))
+
+ def test_non_json_output_does_not_echo_stdout(self, tmp_path, monkeypatch):
+ """On the wrapper path stdout would be the whole plan, so it must never reach the log."""
+ bindir = tmp_path / "bin"
+ fake_binary(bindir, "terraform-bin", '#!/bin/sh\necho "AKIAIOSFODNN7EXAMPLE not json"\n')
+ monkeypatch.setenv("PATH", str(bindir))
+ plan_file = tmp_path / "tfplan"
+ plan_file.write_bytes(b"binary")
+
+ with pytest.raises(DiscoveryError) as excinfo:
+ discover.terraform_show_json(str(plan_file))
+
+ assert "AKIAIOSFODNN7EXAMPLE" not in str(excinfo.value)
diff --git a/tests/platform/test_redact.py b/tests/platform/test_redact.py
new file mode 100644
index 00000000..bc039b85
--- /dev/null
+++ b/tests/platform/test_redact.py
@@ -0,0 +1,1231 @@
+"""
+Tests for plan/state redaction.
+
+This is the security-critical module: it is the only thing standing between a customer's secrets
+and StackGuardian's storage. The tests assert on the *serialized bytes* wherever a leak would
+matter, because a value nested somewhere unexpected still leaks even if the top-level shape looks
+masked.
+"""
+
+import json
+import os
+import sys
+
+import pytest
+
+
+from tirith.platform import redact
+
+SECRET = "hunter2-this-must-never-leave-the-runner"
+
+
+def test_slim_drops_prior_state_and_planned_values():
+ """
+ `planned_values` is the important one. It mirrors every resource's values in a second place
+ and carries NO sensitivity markers, so masking `resource_changes` alone leaves the same secret
+ in plaintext there. A real plan leaked a local_sensitive_file body through exactly this path.
+ """
+ plan = {
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [],
+ "prior_state": {"values": {"secret": SECRET}},
+ "planned_values": {"root_module": {"resources": [{"values": {"content": SECRET}}]}},
+ }
+
+ slimmed = redact.slim_plan(plan)
+
+ assert "prior_state" not in slimmed
+ assert "planned_values" not in slimmed
+ assert slimmed["resource_changes"] == []
+ assert slimmed["terraform_version"] == "1.5.7"
+ assert SECRET not in json.dumps(slimmed)
+
+
+def test_planned_values_leak_is_closed_end_to_end():
+ """The exact shape that leaked in QA: masked in resource_changes, plaintext in planned_values."""
+ plan = {
+ "resource_changes": [
+ {
+ "type": "local_sensitive_file",
+ "change": {"after": {"content": SECRET}, "after_sensitive": {"content": True}},
+ }
+ ],
+ "planned_values": {
+ "root_module": {"resources": [{"type": "local_sensitive_file", "values": {"content": SECRET}}]}
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_configuration_is_kept_because_three_operations_read_it():
+ """
+ Dropping `configuration` would silently break direct_dependencies, direct_references and
+ provider_config: policies would stop finding what they look for rather than failing loudly.
+ """
+ plan = {
+ "resource_changes": [],
+ "configuration": {
+ "root_module": {"resources": [{"address": "aws_vpc.main", "depends_on": ["aws_x.y"]}]},
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {"constant_value": "eu-central-1"},
+ "secret_key": {"constant_value": SECRET},
+ "assume_role": {"role_arn": {"constant_value": SECRET}},
+ },
+ }
+ },
+ },
+ }
+
+ slimmed = redact.slim_plan(plan)
+ aws = slimmed["configuration"]["provider_config"]["aws"]
+
+ # What the provider_config operation reads survives ...
+ assert aws["full_name"] == "registry.terraform.io/hashicorp/aws"
+ assert aws["version_constraint"] == "~> 5.0"
+ assert aws["expressions"]["region"]["constant_value"] == "eu-central-1"
+ # ... and the reference graph the other two operations walk survives ...
+ assert slimmed["configuration"]["root_module"]["resources"][0]["depends_on"] == ["aws_x.y"]
+ # ... while hardcoded credentials do not.
+ assert "secret_key" not in aws["expressions"]
+ assert "assume_role" not in aws["expressions"]
+ assert SECRET not in json.dumps(slimmed)
+
+
+def test_hcl_literals_are_scrubbed_from_resource_expressions():
+ """
+ The third instance of the `planned_values` pattern, caught in QA: a hardcoded value is masked
+ in `resource_changes` and sits in plaintext under
+ `configuration.root_module.resources[].expressions[].constant_value`, which carries no
+ sensitivity markers at all.
+
+ Dropping it is lossless -- direct_references reads only `references`, direct_dependencies only
+ `depends_on`.
+ """
+ plan = {
+ "resource_changes": [
+ {
+ "type": "local_sensitive_file",
+ "change": {"after": {"content": SECRET}, "after_sensitive": {"content": True}},
+ }
+ ],
+ "configuration": {
+ "root_module": {
+ "resources": [
+ {
+ "address": "local_sensitive_file.creds",
+ "depends_on": ["null_resource.a"],
+ "expressions": {
+ "content": {"constant_value": SECRET},
+ "filename": {"references": ["path.module"]},
+ },
+ }
+ ]
+ }
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+ expressions = redacted["configuration"]["root_module"]["resources"][0]["expressions"]
+
+ assert SECRET not in json.dumps(redacted)
+ # The reference graph the operations walk survives ...
+ assert expressions["filename"]["references"] == ["path.module"]
+ assert redacted["configuration"]["root_module"]["resources"][0]["depends_on"] == ["null_resource.a"]
+ # ... the literal does not.
+ assert "constant_value" not in expressions["content"]
+
+
+def test_nested_and_repeated_block_literals_are_scrubbed():
+ """A block argument is a dict of expressions and a repeated block is a list of them."""
+ plan = {
+ "resource_changes": [],
+ "configuration": {
+ "root_module": {
+ "resources": [
+ {
+ "address": "aws_instance.web",
+ "expressions": {
+ "root_block_device": {"kms_key_id": {"constant_value": SECRET}},
+ "ebs_block_device": [
+ {"snapshot_id": {"constant_value": SECRET}},
+ {"volume_id": {"references": ["aws_ebs_volume.a.id"]}},
+ ],
+ },
+ }
+ ]
+ }
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert SECRET not in json.dumps(redacted)
+ ebs = redacted["configuration"]["root_module"]["resources"][0]["expressions"]["ebs_block_device"]
+ assert ebs[1]["volume_id"]["references"] == ["aws_ebs_volume.a.id"]
+
+
+def test_child_module_literals_are_scrubbed():
+ plan = {
+ "resource_changes": [],
+ "configuration": {
+ "root_module": {
+ "module_calls": {
+ "db": {
+ "source": "./modules/db",
+ "expressions": {"password": {"constant_value": SECRET}},
+ "module": {
+ "resources": [
+ {
+ "address": "aws_db_instance.main",
+ "expressions": {"password": {"constant_value": SECRET}},
+ }
+ ]
+ },
+ }
+ }
+ }
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_variable_defaults_and_outputs_are_scrubbed():
+ """A `default` on a sensitive variable is a literal in the configuration too."""
+ plan = {
+ "resource_changes": [],
+ "configuration": {
+ "root_module": {
+ "variables": {"db_password": {"default": SECRET, "sensitive": True}},
+ "outputs": {"conn": {"expression": {"constant_value": SECRET}}},
+ }
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert SECRET not in json.dumps(redacted)
+ # The declaration itself survives; only the value goes.
+ assert redacted["configuration"]["root_module"]["variables"]["db_password"]["sensitive"] is True
+
+
+def test_scrub_tolerates_a_provider_config_without_expressions():
+ plan = {"resource_changes": [], "configuration": {"provider_config": {"null": {"name": "null"}}}}
+
+ slimmed = redact.slim_plan(plan)
+
+ assert slimmed["configuration"]["provider_config"]["null"] == {"name": "null"}
+
+
+def test_redact_masks_marked_attributes():
+ plan = {
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.main",
+ "type": "aws_db_instance",
+ "change": {
+ "actions": ["create"],
+ "before": None,
+ "after": {"identifier": "main", "password": SECRET, "port": 5432},
+ "after_sensitive": {"password": True},
+ },
+ }
+ ]
+ }
+
+ redacted = redact.redact_plan(plan)
+ after = redacted["resource_changes"][0]["change"]["after"]
+
+ assert after["password"] == redact.SENTINEL
+ assert after["identifier"] == "main", "non-sensitive values must survive"
+ assert after["port"] == 5432
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_masks_a_whole_sensitive_subtree():
+ """A marker of `true` above an object masks everything beneath it."""
+ plan = {
+ "resource_changes": [
+ {
+ "address": "aws_secretsmanager_secret_version.v",
+ "change": {
+ "after": {"secret_string": {"user": "admin", "pass": SECRET}},
+ "after_sensitive": {"secret_string": True},
+ },
+ }
+ ]
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["resource_changes"][0]["change"]["after"]["secret_string"] == redact.SENTINEL
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_masks_inside_lists_positionally():
+ plan = {
+ "resource_changes": [
+ {
+ "change": {
+ "after": {"items": [{"k": "public"}, {"k": SECRET}]},
+ "after_sensitive": {"items": [{}, {"k": True}]},
+ }
+ }
+ ]
+ }
+
+ redacted = redact.redact_plan(plan)
+ items = redacted["resource_changes"][0]["change"]["after"]["items"]
+
+ assert items[0]["k"] == "public"
+ assert items[1]["k"] == redact.SENTINEL
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_masks_before_as_well_as_after():
+ """A destroy or update leaves the old secret in `before`; it leaks just as badly."""
+ plan = {
+ "resource_changes": [
+ {
+ "change": {
+ "actions": ["delete"],
+ "before": {"password": SECRET},
+ "before_sensitive": {"password": True},
+ "after": None,
+ }
+ }
+ ]
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["resource_changes"][0]["change"]["before"]["password"] == redact.SENTINEL
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_drops_root_variables_entirely():
+ """
+ The plan does not reliably mark which root variables were declared sensitive, so the only safe
+ assumption is that any of them might be.
+ """
+ plan = {"resource_changes": [], "variables": {"db_password": {"value": SECRET}}}
+
+ redacted = redact.redact_plan(plan)
+
+ assert "variables" not in redacted
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_masks_sensitive_output_changes():
+ plan = {
+ "resource_changes": [],
+ "output_changes": {
+ "db_url": {"actions": ["create"], "after": SECRET, "sensitive": True},
+ "region": {"actions": ["create"], "after": "eu-central-1", "sensitive": False},
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["output_changes"]["db_url"]["after"] == redact.SENTINEL
+ assert redacted["output_changes"]["region"]["after"] == "eu-central-1"
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_leaves_unmarked_values_alone():
+ """
+ Documents the known limitation honestly: terraform's markers are not exhaustive, so a secret
+ that arrives unmarked is NOT masked. Slimming and the variables drop limit the blast radius;
+ this test exists so the gap is visible rather than assumed away.
+ """
+ plan = {"resource_changes": [{"change": {"after": {"password_from_locals": SECRET}, "after_sensitive": {}}}]}
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["resource_changes"][0]["change"]["after"]["password_from_locals"] == SECRET
+
+
+def test_redact_plan_tolerates_junk():
+ assert redact.redact_plan({}) == {}
+ assert redact.redact_plan({"resource_changes": "not-a-list"})["resource_changes"] == "not-a-list"
+ assert redact.redact_plan([]) == []
+
+
+# --- state -------------------------------------------------------------------------------------
+
+
+def test_redact_state_masks_sensitive_outputs():
+ state = {
+ "version": 4,
+ "outputs": {
+ "db_password": {"value": SECRET, "type": "string", "sensitive": True},
+ "region": {"value": "eu-central-1", "type": "string"},
+ },
+ "resources": [],
+ }
+
+ redacted = redact.redact_state(state)
+
+ assert redacted["outputs"]["db_password"]["value"] == redact.SENTINEL
+ assert redacted["outputs"]["region"]["value"] == "eu-central-1"
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_state_masks_sensitive_attributes():
+ """
+ The shape `terraform state pull` actually writes: each entry is a PATH -- a list of steps --
+ not a single key.
+
+ Captured verbatim from a real `local_sensitive_file`. The previous fixture here invented the
+ flat form, so this passed while real state was not masked at all: a list is neither a dict nor
+ a string, so every entry was skipped.
+ """
+ state = {
+ "resources": [
+ {
+ "type": "local_sensitive_file",
+ "name": "s",
+ "instances": [
+ {
+ "attributes": {"id": "e590ef", "content": SECRET, "content_base64": SECRET},
+ "sensitive_attributes": [
+ [{"type": "get_attr", "value": "content_base64"}],
+ [{"type": "get_attr", "value": "content"}],
+ ],
+ }
+ ],
+ }
+ ]
+ }
+
+ redacted = redact.redact_state(state)
+ attributes = redacted["resources"][0]["instances"][0]["attributes"]
+
+ assert attributes["content"] == redact.SENTINEL
+ assert attributes["content_base64"] == redact.SENTINEL
+ assert attributes["id"] == "e590ef", "non-sensitive attributes must survive"
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_redact_state_masks_a_nested_attribute_path():
+ """A path can descend through objects and list indices, not just name a top-level key."""
+ state = {
+ "resources": [
+ {
+ "instances": [
+ {
+ "attributes": {"config": [{"token": SECRET, "url": "https://ok"}]},
+ "sensitive_attributes": [
+ [
+ {"type": "get_attr", "value": "config"},
+ {"type": "index", "value": 0},
+ {"type": "get_attr", "value": "token"},
+ ]
+ ],
+ }
+ ]
+ }
+ ]
+ }
+
+ redacted = redact.redact_state(state)
+ config = redacted["resources"][0]["instances"][0]["attributes"]["config"][0]
+
+ assert config["token"] == redact.SENTINEL
+ assert config["url"] == "https://ok"
+
+
+def test_redact_state_does_not_mutate_the_input():
+ """The caller still holds the original; masking must not reach back into it."""
+ state = {
+ "resources": [
+ {
+ "instances": [
+ {
+ "attributes": {"password": SECRET},
+ "sensitive_attributes": [[{"type": "get_attr", "value": "password"}]],
+ }
+ ]
+ }
+ ]
+ }
+
+ redact.redact_state(state)
+
+ assert state["resources"][0]["instances"][0]["attributes"]["password"] == SECRET
+
+
+def test_redact_state_accepts_the_flat_get_attr_form():
+ """Some providers and older state versions emit a single step rather than a path."""
+ state = {
+ "resources": [
+ {
+ "instances": [
+ {
+ "attributes": {"password": SECRET},
+ "sensitive_attributes": [{"type": "get_attr", "value": "password"}],
+ }
+ ]
+ }
+ ]
+ }
+
+ redacted = redact.redact_state(state)
+
+ assert redacted["resources"][0]["instances"][0]["attributes"]["password"] == redact.SENTINEL
+
+
+def test_redact_state_accepts_bare_string_sensitive_attributes():
+ """Older state versions write these as plain strings rather than objects."""
+ state = {"resources": [{"instances": [{"attributes": {"secret": SECRET}, "sensitive_attributes": ["secret"]}]}]}
+
+ redacted = redact.redact_state(state)
+
+ assert redacted["resources"][0]["instances"][0]["attributes"]["secret"] == redact.SENTINEL
+
+
+def test_redact_state_tolerates_junk():
+ assert redact.redact_state({}) == {}
+ assert redact.redact_state({"resources": "nope"})["resources"] == "nope"
+ assert redact.redact_state({"outputs": None})["outputs"] is None
+
+
+def test_count_redactions():
+ document = {"a": redact.SENTINEL, "b": [redact.SENTINEL, "fine"], "c": {"d": redact.SENTINEL}}
+
+ assert redact.count_redactions(document) == 3
+ assert redact.count_redactions({"a": "fine"}) == 0
+
+
+# --- output_changes marker spellings -------------------------------------------------------------
+#
+# These exist because a real plan slipped through: the code originally checked only a top-level
+# `sensitive` key, but modern terraform emits `before_sensitive` / `after_sensitive` per side, so
+# every sensitive output in a current plan went unmasked.
+
+
+def test_output_change_masked_via_after_sensitive():
+ """The spelling modern terraform actually uses."""
+ plan = {
+ "resource_changes": [],
+ "output_changes": {
+ "db_url": {"actions": ["update"], "before": "old", "after": SECRET, "after_sensitive": True}
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["output_changes"]["db_url"]["after"] == redact.SENTINEL
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_output_change_masks_each_side_independently():
+ """An output can become sensitive without having been so before, and vice versa."""
+ plan = {
+ "resource_changes": [],
+ "output_changes": {
+ "rotated": {
+ "actions": ["update"],
+ "before": SECRET,
+ "after": "now-public",
+ "before_sensitive": True,
+ "after_sensitive": False,
+ }
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+ change = redacted["output_changes"]["rotated"]
+
+ assert change["before"] == redact.SENTINEL
+ assert change["after"] == "now-public"
+ assert SECRET not in json.dumps(redacted)
+
+
+def test_output_change_legacy_sensitive_key_masks_both_sides():
+ plan = {
+ "resource_changes": [],
+ "output_changes": {"k": {"before": SECRET, "after": SECRET, "sensitive": True}},
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["output_changes"]["k"]["before"] == redact.SENTINEL
+ assert redacted["output_changes"]["k"]["after"] == redact.SENTINEL
+
+
+def test_output_change_does_not_invent_absent_keys():
+ """
+ A create whose value is not yet known has no `after` at all (`after_unknown: true`). Adding a
+ sentinel would fabricate data the plan never carried, and would misrepresent the plan to any
+ policy reading it.
+ """
+ plan = {
+ "resource_changes": [],
+ "output_changes": {
+ "pw": {"actions": ["create"], "before": None, "after_unknown": True, "after_sensitive": True}
+ },
+ }
+
+ redacted = redact.redact_plan(plan)
+ change = redacted["output_changes"]["pw"]
+
+ assert "after" not in change
+ assert change["before"] is None
+
+
+def test_unknown_create_values_are_simply_absent_from_the_plan():
+ """
+ Documents a property that made an earlier end-to-end test weaker than intended: for a create,
+ terraform does not know the value yet, so it is absent from `after` rather than present and
+ masked. Nothing leaks -- but a test that expects to see a sentinel here is testing nothing.
+ """
+ plan = {
+ "resource_changes": [
+ {
+ "type": "random_password",
+ "change": {
+ "actions": ["create"],
+ "after": {"length": 32},
+ "after_unknown": {"result": True},
+ "after_sensitive": {"result": True},
+ },
+ }
+ ]
+ }
+
+ redacted = redact.redact_plan(plan)
+ after = redacted["resource_changes"][0]["change"]["after"]
+
+ assert "result" not in after
+ assert redact.count_redactions(redacted) == 0
+
+
+def test_known_sensitive_value_at_plan_time_is_masked():
+ """
+ The case that DOES exercise marker-driven redaction: a hardcoded sensitive attribute is known
+ at plan time, so it really is in `after` and really must be replaced.
+ """
+ plan = {
+ "resource_changes": [
+ {
+ "type": "local_sensitive_file",
+ "change": {
+ "actions": ["create"],
+ "after": {"filename": "out.txt", "content": SECRET},
+ "after_sensitive": {"content": True},
+ },
+ }
+ ]
+ }
+
+ redacted = redact.redact_plan(plan)
+
+ assert redacted["resource_changes"][0]["change"]["after"]["content"] == redact.SENTINEL
+ assert redacted["resource_changes"][0]["change"]["after"]["filename"] == "out.txt"
+ assert SECRET not in json.dumps(redacted)
+
+
+# --- planned_values reconstruction ----------------------------------------------------------
+
+
+def _plan_with(resource_changes, **extra):
+ plan = {"format_version": "1.2", "terraform_version": "1.5.7", "resource_changes": resource_changes}
+ plan.update(extra)
+ return plan
+
+
+def test_planned_values_is_rebuilt_so_infracost_and_checkov_have_something_to_read():
+ """
+ Both tools read planned_values and nothing else. Measured against infracost 0.10.27 with a
+ real key: the same t3.medium prices at $39.80 with this section and $0.00 without.
+ """
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "aws_instance.app",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "app",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {"actions": ["create"], "after": {"instance_type": "t3.medium"}},
+ }
+ ]
+ )
+ )
+
+ resources = out["planned_values"]["root_module"]["resources"]
+ assert [r["address"] for r in resources] == ["aws_instance.app"]
+ assert resources[0]["values"]["instance_type"] == "t3.medium"
+ assert resources[0]["provider_name"] == "registry.terraform.io/hashicorp/aws"
+
+
+def test_the_rebuilt_planned_values_carries_masked_values_not_raw_ones():
+ """
+ The whole reason terraform's own copy is dropped: it mirrors every value with no sensitivity
+ markers, so masking resource_changes leaves the secret in plaintext there. A real plan leaked
+ a local_sensitive_file body through exactly that path. This copy is derived post-masking.
+ """
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "local_sensitive_file.creds",
+ "mode": "managed",
+ "type": "local_sensitive_file",
+ "name": "creds",
+ "change": {
+ "actions": ["create"],
+ "after": {"content": "hunter2", "filename": "/tmp/c"},
+ "after_sensitive": {"content": True},
+ },
+ }
+ ],
+ planned_values={
+ "root_module": {
+ "resources": [{"address": "local_sensitive_file.creds", "values": {"content": "hunter2"}}]
+ }
+ },
+ )
+ )
+
+ assert "hunter2" not in json.dumps(out)
+ values = out["planned_values"]["root_module"]["resources"][0]["values"]
+ assert values["content"] == redact.SENTINEL
+ assert values["filename"] == "/tmp/c", "non-sensitive attributes must survive"
+
+
+def test_terraform_own_planned_values_is_never_passed_through():
+ """It is replaced, not merged -- otherwise the unmarked original would leak straight through."""
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "aws_instance.app",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "app",
+ "change": {"actions": ["create"], "after": {"instance_type": "t3.medium"}},
+ }
+ ],
+ planned_values={
+ "root_module": {
+ "resources": [{"address": "ghost.resource", "values": {"secret": "leaked-from-original"}}]
+ }
+ },
+ )
+ )
+
+ assert "leaked-from-original" not in json.dumps(out)
+ assert [r["address"] for r in out["planned_values"]["root_module"]["resources"]] == ["aws_instance.app"]
+
+
+def test_a_destroyed_resource_has_no_planned_value():
+ """Nothing is planned to exist, so there is nothing to price or scan."""
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "aws_instance.gone",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "gone",
+ "change": {"actions": ["delete"], "before": {"instance_type": "m5.large"}, "after": None},
+ }
+ ]
+ )
+ )
+
+ assert "planned_values" not in out
+ assert out["resource_changes"], "the destroy is still a change policies evaluate"
+
+
+def test_a_replacement_is_planned_because_it_ends_up_existing():
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "aws_instance.app",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "app",
+ "change": {"actions": ["delete", "create"], "after": {"instance_type": "t3.large"}},
+ }
+ ]
+ )
+ )
+
+ assert out["planned_values"]["root_module"]["resources"][0]["values"]["instance_type"] == "t3.large"
+
+
+def test_module_resources_are_grouped_under_child_modules():
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "aws_instance.app",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "app",
+ "change": {"actions": ["create"], "after": {"instance_type": "t3.medium"}},
+ },
+ {
+ "address": "module.db.aws_instance.replica",
+ "module_address": "module.db",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "replica",
+ "change": {"actions": ["create"], "after": {"instance_type": "m5.large"}},
+ },
+ ]
+ )
+ )
+
+ root = out["planned_values"]["root_module"]
+ assert [r["address"] for r in root["resources"]] == ["aws_instance.app"]
+ assert [m["address"] for m in root["child_modules"]] == ["module.db"]
+ assert root["child_modules"][0]["resources"][0]["address"] == "module.db.aws_instance.replica"
+
+
+def test_child_modules_is_absent_when_there_are_none():
+ out = redact.redact_plan(
+ _plan_with(
+ [
+ {
+ "address": "aws_instance.app",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "app",
+ "change": {"actions": ["create"], "after": {"instance_type": "t3.medium"}},
+ }
+ ]
+ )
+ )
+
+ assert "child_modules" not in out["planned_values"]["root_module"]
+
+
+def test_an_empty_plan_gets_no_planned_values():
+ assert "planned_values" not in redact.redact_plan(_plan_with([]))
+
+
+# --- resource_drift and configuration literals ---------------------------------------------------
+
+
+def test_resource_drift_is_masked_like_resource_changes():
+ """
+ resource_drift has the identical shape and the identical sensitivity markers, and terraform
+ emits it whenever a refresh finds drift. Masking resource_changes and leaving this alone shipped
+ the same secret one key away -- the planned_values failure a third time.
+ """
+ plan = {
+ "format_version": "1.2",
+ "resource_drift": [
+ {
+ "address": "aws_secretsmanager_secret_version.db",
+ "type": "aws_secretsmanager_secret_version",
+ "change": {
+ "actions": ["update"],
+ "before": {"secret_string": "hunter2-before"},
+ "after": {"secret_string": "hunter2-after"},
+ "before_sensitive": {"secret_string": True},
+ "after_sensitive": {"secret_string": True},
+ },
+ }
+ ],
+ }
+
+ out = redact.redact_plan(plan)
+ drift = out["resource_drift"][0]["change"]
+
+ assert drift["before"]["secret_string"] == redact.SENTINEL
+ assert drift["after"]["secret_string"] == redact.SENTINEL
+ assert "hunter2-before" not in json.dumps(out)
+ assert "hunter2-after" not in json.dumps(out)
+
+
+def test_provisioner_literals_are_scrubbed_from_configuration():
+ """
+ A provisioner carries its own expressions one level below the resource's, and a connection block
+ is exactly where a password gets written literally. Scrubbing only the resource's own
+ expressions left these verbatim -- and configuration ships even with `source-dir: ""`.
+ """
+ plan = {
+ "format_version": "1.2",
+ "configuration": {
+ "root_module": {
+ "resources": [
+ {
+ "address": "aws_instance.app",
+ "expressions": {"ami": {"constant_value": "ami-123"}},
+ "provisioners": [
+ {
+ "type": "remote-exec",
+ "expressions": {
+ "inline": {"constant_value": ["echo s3cr3t-inline"]},
+ "connection": {"password": {"constant_value": "s3cr3t-conn"}},
+ },
+ }
+ ],
+ }
+ ]
+ }
+ },
+ }
+
+ out = json.dumps(redact.redact_plan(plan))
+
+ assert "s3cr3t-conn" not in out
+ assert "s3cr3t-inline" not in out
+
+
+def test_module_call_arguments_are_dropped_even_without_an_inlined_module():
+ """
+ A module sourced from a registry or a git ref carries no inlined `module` body, which is the
+ common case -- and its arguments are literals either way.
+ """
+ plan = {
+ "format_version": "1.2",
+ "configuration": {
+ "root_module": {
+ "module_calls": {
+ "db": {
+ "source": "terraform-aws-modules/rds/aws",
+ "expressions": {"password": {"constant_value": "s3cr3t-mod"}},
+ }
+ }
+ }
+ },
+ }
+
+ assert "s3cr3t-mod" not in json.dumps(redact.redact_plan(plan))
+
+
+def test_a_show_json_state_is_masked_not_passed_through():
+ """
+ The leak an end-to-end run found. `terraform show -json ` is the natural way to get a
+ readable state, and its shape nests resources under values.root_module with a parallel
+ sensitive_values tree -- nothing like the raw state this function was written for. It returned
+ the document unchanged: no error, no warning, every attribute in plaintext.
+ """
+ document = {
+ "format_version": "1.0",
+ "values": {
+ "root_module": {
+ "resources": [
+ {
+ "address": "aws_db_instance.main",
+ "type": "aws_db_instance",
+ "values": {"identifier": "prod-db", "password": "hunter2"},
+ "sensitive_values": {"password": True},
+ }
+ ],
+ "child_modules": [
+ {
+ "address": "module.net",
+ "resources": [
+ {
+ "address": "module.net.aws_secretsmanager_secret_version.k",
+ "values": {"secret_string": "hunter3"},
+ "sensitive_values": {"secret_string": True},
+ }
+ ],
+ }
+ ],
+ },
+ "outputs": {"db_url": {"value": "postgres://hunter4@host", "sensitive": True}},
+ },
+ }
+
+ out = redact.redact_state(document)
+ blob = json.dumps(out)
+
+ assert out["values"]["root_module"]["resources"][0]["values"]["password"] == redact.SENTINEL
+ # A module's resources are nested, not flattened -- masking only the root would miss them.
+ assert (
+ out["values"]["root_module"]["child_modules"][0]["resources"][0]["values"]["secret_string"] == redact.SENTINEL
+ )
+ assert out["values"]["outputs"]["db_url"]["value"] == redact.SENTINEL
+ for secret in ("hunter2", "hunter3", "hunter4"):
+ assert secret not in blob, secret
+
+
+def test_the_raw_state_shape_still_works():
+ """The shape this function was written for must keep working alongside the new one."""
+ document = {
+ "version": 4,
+ "resources": [
+ {
+ "type": "aws_db_instance",
+ "instances": [{"attributes": {"password": "hunter2"}, "sensitive_attributes": ["password"]}],
+ }
+ ],
+ "outputs": {"token": {"value": "hunter5", "sensitive": True}},
+ }
+
+ out = redact.redact_state(document)
+
+ assert out["resources"][0]["instances"][0]["attributes"]["password"] == redact.SENTINEL
+ assert out["outputs"]["token"]["value"] == redact.SENTINEL
+
+
+# --- provider-computed mirrors: the markers are not enough -----------------------------------------
+#
+# Terraform does not propagate sensitivity into attributes a provider computes from a sensitive one.
+# An aws_instance with a secret in `tags` is marked `after_sensitive.tags.Password = true`, while
+# `after_sensitive.tags_all` comes back `{}` even though `tags_all` holds the identical plaintext.
+# Every AWS resource with tags has `tags_all`, so that one gap leaks any secret used in a tag.
+#
+# Found by an E2E that downloaded the uploaded bundle and grepped it. The unit suite was green
+# throughout, because it asserted the markers were honoured -- and they were.
+
+
+def _plan_with_tags_all():
+ return {
+ "format_version": "1.2",
+ "resource_changes": [
+ {
+ "address": "aws_instance.app",
+ "type": "aws_instance",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {"Name": "keep-me", "Password": "hunter2-plan-secret"},
+ "tags_all": {"Name": "keep-me", "Password": "hunter2-plan-secret"},
+ },
+ "after_sensitive": {"tags": {"Password": True}, "tags_all": {}},
+ },
+ }
+ ],
+ }
+
+
+def test_a_secret_mirrored_into_an_unmarked_attribute_is_still_masked():
+ masked = redact.redact_plan(_plan_with_tags_all())
+
+ assert "hunter2-plan-secret" not in json.dumps(masked), "tags_all leaked the secret terraform marked in tags"
+
+
+def test_the_sweep_does_not_mangle_values_that_were_never_sensitive():
+ """Over-redaction would corrupt the document the policies read, which is its own kind of failure."""
+ masked = redact.redact_plan(_plan_with_tags_all())
+ after = masked["resource_changes"][0]["change"]["after"]
+
+ assert after["instance_type"] == "t3.micro"
+ assert after["tags"]["Name"] == "keep-me"
+ assert after["tags_all"]["Name"] == "keep-me"
+
+
+def test_a_sensitive_root_variable_is_swept_out_of_the_resources_too():
+ """
+ The variable block is dropped wholesale, but its value routinely reappears in an unmarked
+ attribute -- so the value has to be collected before it is dropped.
+ """
+ plan = {
+ "variables": {"db_password": {"value": "hunter2-plan-secret"}},
+ "resource_changes": [
+ {
+ "address": "aws_instance.app",
+ "type": "aws_instance",
+ "change": {
+ "actions": ["create"],
+ "after": {"tags_all": {"Password": "hunter2-plan-secret"}},
+ "after_sensitive": {},
+ },
+ }
+ ],
+ }
+
+ masked = redact.redact_plan(plan)
+
+ assert "variables" not in masked
+ assert "hunter2-plan-secret" not in json.dumps(masked)
+
+
+def test_a_very_short_sensitive_value_is_not_swept():
+ """
+ The sweep matches exact strings everywhere, so a two-character secret would also match ids and
+ regions and mangle the plan. Leaking a two-character value is the lesser harm against breaking
+ every policy on the document.
+ """
+ plan = {
+ "resource_changes": [
+ {
+ "address": "aws_instance.app",
+ "type": "aws_instance",
+ "change": {
+ "actions": ["create"],
+ "after": {"tags": {"P": "ab"}, "region": "ab", "instance_type": "t3.micro"},
+ "after_sensitive": {"tags": {"P": True}},
+ },
+ }
+ ],
+ }
+
+ masked = redact.redact_plan(plan)
+ after = masked["resource_changes"][0]["change"]["after"]
+
+ assert after["tags"]["P"] == redact.SENTINEL, "the marked value is still masked by the marker"
+ assert after["region"] == "ab", "but an unrelated two-character value must survive"
+
+
+# --- state: provider-computed mirrors -----------------------------------------------------------
+#
+# From a penetration test. `redact_plan` already swept the plaintext of every marked value across the
+# whole document to catch computed mirrors; `redact_state` did not, so a secret in a tag was masked at
+# `tags.Password` and shipped in cleartext at `tags_all.Password`.
+#
+# State is the worse place for this hole than a plan: it carries every attribute of every resource, and
+# the bundle it is uploaded in is retained indefinitely. Neither existing state test had an unmarked
+# mirror attribute, which is why both passed with the leak present.
+
+TAG_SECRET = "hunter2-tag-secret"
+
+
+def _raw_state_with_tags_all():
+ """Raw `terraform state pull`: sensitivity is a list of attribute paths."""
+ return {
+ "version": 4,
+ "resources": [
+ {
+ "type": "aws_instance",
+ "name": "app",
+ "instances": [
+ {
+ "attributes": {
+ "tags": {"Password": TAG_SECRET},
+ # The provider's computed mirror. Same plaintext, named by nothing.
+ "tags_all": {"Password": TAG_SECRET},
+ "region": "us-east-1",
+ },
+ "sensitive_attributes": [
+ [{"type": "get_attr", "value": "tags"}, {"type": "get_attr", "value": "Password"}]
+ ],
+ }
+ ],
+ }
+ ],
+ }
+
+
+def _show_json_state_with_tags_all():
+ """`terraform show -json `: sensitivity is a parallel marker tree, as in a plan."""
+ return {
+ "format_version": "1.0",
+ "values": {
+ "root_module": {
+ "resources": [
+ {
+ "address": "aws_instance.app",
+ "values": {
+ "tags": {"Password": TAG_SECRET},
+ "tags_all": {"Password": TAG_SECRET},
+ "region": "us-east-1",
+ },
+ # tags_all is present and empty -- terraform marks nothing in it.
+ "sensitive_values": {"tags": {"Password": True}, "tags_all": {}},
+ }
+ ]
+ }
+ },
+ }
+
+
+@pytest.mark.parametrize(
+ "build, read",
+ [
+ (_raw_state_with_tags_all, lambda o: o["resources"][0]["instances"][0]["attributes"]),
+ (_show_json_state_with_tags_all, lambda o: o["values"]["root_module"]["resources"][0]["values"]),
+ ],
+ ids=["raw", "show-json"],
+)
+def test_a_secret_mirrored_into_an_unmarked_state_attribute_is_still_masked(build, read):
+ out = redact.redact_state(build())
+ attributes = read(out)
+
+ assert attributes["tags"]["Password"] == redact.SENTINEL
+ assert attributes["tags_all"]["Password"] == redact.SENTINEL
+ # The whole-document assertion is the one that matters: the mirror is only the case we know about.
+ assert TAG_SECRET not in json.dumps(out)
+
+
+@pytest.mark.parametrize(
+ "build, read",
+ [
+ (_raw_state_with_tags_all, lambda o: o["resources"][0]["instances"][0]["attributes"]),
+ (_show_json_state_with_tags_all, lambda o: o["values"]["root_module"]["resources"][0]["values"]),
+ ],
+ ids=["raw", "show-json"],
+)
+def test_the_state_sweep_does_not_redact_unrelated_values(build, read):
+ """A sweep that masks by value will over-mask if it is not bounded. `region` is not a secret."""
+ out = redact.redact_state(build())
+
+ assert read(out)["region"] == "us-east-1"
+
+
+def test_a_sensitive_state_output_is_swept_out_of_a_resource_attribute():
+ """
+ An output's plaintext is discarded when the output is masked, so nothing else knew it was a secret --
+ and the same value sitting in an ordinary attribute stayed in cleartext.
+ """
+ state = {
+ "version": 4,
+ "outputs": {"db_password": {"value": TAG_SECRET, "sensitive": True}},
+ "resources": [
+ {
+ "type": "aws_db_instance",
+ "instances": [{"attributes": {"password_copy": TAG_SECRET, "engine": "postgres"}}],
+ }
+ ],
+ }
+
+ out = redact.redact_state(state)
+
+ assert out["outputs"]["db_password"]["value"] == redact.SENTINEL
+ assert out["resources"][0]["instances"][0]["attributes"]["password_copy"] == redact.SENTINEL
+ assert out["resources"][0]["instances"][0]["attributes"]["engine"] == "postgres"
+ assert TAG_SECRET not in json.dumps(out)
+
+
+def test_a_short_state_secret_is_not_swept():
+ """
+ The length floor exists so masking one short value does not redact every id, region and short
+ string that happens to equal it. Same bound as the plan sweep.
+ """
+ state = {
+ "version": 4,
+ "resources": [
+ {
+ "type": "aws_instance",
+ "instances": [
+ {
+ "attributes": {"tags": {"Env": "dev"}, "tags_all": {"Env": "dev"}, "stage": "dev"},
+ "sensitive_attributes": [
+ [{"type": "get_attr", "value": "tags"}, {"type": "get_attr", "value": "Env"}]
+ ],
+ }
+ ],
+ }
+ ],
+ }
+
+ out = redact.redact_state(state)
+ attributes = out["resources"][0]["instances"][0]["attributes"]
+
+ # Masked where it is marked, and left alone everywhere else.
+ assert attributes["tags"]["Env"] == redact.SENTINEL
+ assert attributes["stage"] == "dev"
diff --git a/tests/platform/test_regions.py b/tests/platform/test_regions.py
new file mode 100644
index 00000000..f5fdbebf
--- /dev/null
+++ b/tests/platform/test_regions.py
@@ -0,0 +1,169 @@
+"""
+Tests for the region table and URL resolution.
+
+The failure this replaces: `--api-url` and `--dashboard-url` were independent, so overriding only
+the API left every run link in every PR comment pointing at the wrong environment -- which reads as
+a broken integration rather than a misconfiguration.
+"""
+
+import pytest
+
+from tirith.platform import regions
+
+EU_API = "https://api.app.stackguardian.io/api/v1"
+EU_APP = "https://app.stackguardian.io"
+US_API = "https://api.us.stackguardian.io/api/v1"
+US_APP = "https://us.stackguardian.io"
+
+
+class TestTable:
+ def test_two_production_regions(self):
+ assert regions.REGION_IDS == ("eu", "us")
+
+ def test_eu_is_the_default(self):
+ assert regions.DEFAULT_REGION_ID == "eu"
+
+ @pytest.mark.parametrize(
+ "region_id, api_base, app_base",
+ [
+ ("eu", "https://api.app.stackguardian.io", EU_APP),
+ ("us", "https://api.us.stackguardian.io", US_APP),
+ ],
+ )
+ def test_region_pairs(self, region_id, api_base, app_base):
+ region = regions.by_id(region_id)
+ assert region.api_base == api_base
+ assert region.app_base == app_base
+
+ def test_api_bases_omit_the_api_path(self):
+ """Matches Raycast, sg-cli and the terraform provider; normalize_api_url adds it back."""
+ for region in regions.REGIONS:
+ assert not region.api_base.endswith("/api/v1")
+
+ def test_unknown_region_raises_and_names_the_valid_ones(self):
+ """
+ Deliberately not Raycast's "fall back to the first region": a typo would silently point a US
+ org at production EU, and the only symptom would be an unexplainable auth error.
+ """
+ with pytest.raises(ValueError) as excinfo:
+ regions.by_id("uss")
+ assert "eu" in str(excinfo.value)
+ assert "us" in str(excinfo.value)
+
+
+class TestNormalizeApiUrl:
+ @pytest.mark.parametrize(
+ "given",
+ [
+ "https://api.app.stackguardian.io",
+ "https://api.app.stackguardian.io/",
+ "https://api.app.stackguardian.io/api/v1",
+ "https://api.app.stackguardian.io/api/v1/",
+ ],
+ )
+ def test_both_spellings_converge(self, given):
+ """
+ sg-cli's SG_BASE_URL omits /api/v1 and tirith's has always included it, so a value exported
+ for one produced 404s from the other.
+ """
+ assert regions.normalize_api_url(given) == EU_API
+
+ def test_an_empty_value_stays_empty(self):
+ assert regions.normalize_api_url("") == ""
+ assert regions.normalize_api_url(None) == ""
+
+ def test_a_self_hosted_host_is_left_alone_apart_from_the_suffix(self):
+ assert regions.normalize_api_url("https://api.siemens-ag.stackguardian.io") == (
+ "https://api.siemens-ag.stackguardian.io/api/v1"
+ )
+
+
+class TestByApiUrl:
+ @pytest.mark.parametrize("given", ["https://api.us.stackguardian.io", US_API])
+ def test_matches_with_or_without_the_suffix(self, given):
+ assert regions.by_api_url(given).id == "us"
+
+ def test_returns_none_for_an_unknown_host(self):
+ assert regions.by_api_url("https://api.siemens-ag.stackguardian.io") is None
+
+
+class TestResolve:
+ def test_defaults_to_eu(self):
+ api, dashboard, warnings = regions.resolve()
+ assert (api, dashboard) == (EU_API, EU_APP)
+ assert warnings == []
+
+ def test_region_sets_both_urls(self):
+ api, dashboard, warnings = regions.resolve(region_id="us")
+ assert (api, dashboard) == (US_API, US_APP)
+ assert warnings == []
+
+ def test_explicit_urls_win_over_the_default(self):
+ api, dashboard, _w = regions.resolve(
+ api_url="https://api.self-hosted.example", dashboard_url="https://self-hosted.example"
+ )
+ assert api == "https://api.self-hosted.example/api/v1"
+ assert dashboard == "https://self-hosted.example"
+
+ @pytest.mark.parametrize(
+ "kwargs",
+ [
+ {"api_url": "https://api.self-hosted.example"},
+ {"dashboard_url": "https://self-hosted.example"},
+ {"api_url": "https://api.self-hosted.example", "dashboard_url": "https://self-hosted.example"},
+ ],
+ )
+ def test_region_with_an_explicit_url_is_an_error(self, kwargs):
+ """They set the same thing; silently picking one would hide the contradiction."""
+ with pytest.raises(ValueError, match="cannot be combined"):
+ regions.resolve(region_id="us", **kwargs)
+
+ def test_an_api_url_for_a_known_region_infers_its_dashboard(self):
+ """
+ The footgun the whole module exists for: this used to leave run links on the EU dashboard
+ for a US org.
+ """
+ api, dashboard, warnings = regions.resolve(api_url="https://api.us.stackguardian.io")
+ assert api == US_API
+ assert dashboard == US_APP
+ assert warnings == []
+
+ def test_an_unknown_api_url_without_a_dashboard_warns(self):
+ api, dashboard, warnings = regions.resolve(api_url="https://api.self-hosted.example")
+ assert api == "https://api.self-hosted.example/api/v1"
+ assert dashboard == EU_APP
+ assert len(warnings) == 1
+ assert "--dashboard-url" in warnings[0]
+
+
+class TestResolveFromEnvironment:
+ def test_sg_region_is_honoured(self):
+ api, dashboard, _w = regions.resolve(env={"SG_REGION": "us"})
+ assert (api, dashboard) == (US_API, US_APP)
+
+ def test_sg_base_url_without_the_suffix_is_normalized(self):
+ api, _d, _w = regions.resolve(env={"SG_BASE_URL": "https://api.us.stackguardian.io"})
+ assert api == US_API
+
+ def test_sg_base_url_infers_the_dashboard_too(self):
+ _api, dashboard, _w = regions.resolve(env={"SG_BASE_URL": "https://api.us.stackguardian.io"})
+ assert dashboard == US_APP
+
+ def test_an_explicit_flag_beats_the_environment(self):
+ api, _d, _w = regions.resolve(api_url="https://api.us.stackguardian.io", env={"SG_BASE_URL": "https://x"})
+ assert api == US_API
+
+ def test_a_region_flag_beats_a_url_environment(self):
+ api, dashboard, warnings = regions.resolve(region_id="us", env={"SG_BASE_URL": "https://x"})
+ assert (api, dashboard) == (US_API, US_APP)
+ assert warnings == []
+
+ def test_a_url_environment_beats_sg_region_with_a_warning(self):
+ """
+ Not an error: the environment is inherited config the caller may not control, and failing a
+ CI run over a contradiction they did not write would be unhelpful.
+ """
+ api, _d, warnings = regions.resolve(env={"SG_REGION": "eu", "SG_BASE_URL": "https://api.us.stackguardian.io"})
+ assert api == US_API
+ assert len(warnings) == 1
+ assert "SG_REGION" in warnings[0]
diff --git a/tests/platform/test_report.py b/tests/platform/test_report.py
new file mode 100644
index 00000000..eb03fbd9
--- /dev/null
+++ b/tests/platform/test_report.py
@@ -0,0 +1,708 @@
+"""
+Tests for verdict computation and comment rendering.
+
+The verdict mapping is the part worth pinning hardest: every path that does not produce a real
+"everything passed" must stay distinguishable from one that does, and must never map to a green
+required check.
+"""
+
+import os
+import re
+import sys
+
+import pytest
+
+
+from tirith.platform import report as render
+
+
+def _results(result="FAIL", **rule_overrides):
+ rule = {
+ "rule_name": "ingress-cidr",
+ "result": result,
+ "evaluations": {
+ "fails": [
+ {
+ "id": "check1",
+ "result": [
+ {
+ "passed": False,
+ "message": "`0.0.0.0/0` is contained in `cidr_blocks`",
+ "meta": {"address": "module.net.aws_security_group.web"},
+ }
+ ],
+ }
+ ]
+ },
+ }
+ rule.update(rule_overrides)
+ return {"no-public-ingress": [rule]}
+
+
+# --- summarize ---------------------------------------------------------------------------------
+
+
+def test_summarize_counts_and_extracts_detail():
+ counts, findings = render.summarize(_results())
+
+ assert counts["FAIL"] == 1
+ assert findings[0]["policy_id"] == "no-public-ingress"
+ assert findings[0]["messages"] == ["`0.0.0.0/0` is contained in `cidr_blocks`"]
+ assert findings[0]["resources"] == ["module.net.aws_security_group.web"]
+
+
+def test_summarize_counts_skipped_separately_from_passed():
+ """Reporting a skipped control as passing would be a quiet inaccuracy."""
+ counts, findings = render.summarize({"p": [{"rule_name": "r", "skip": True}]})
+
+ assert counts["SKIPPED"] == 1
+ assert counts["PASS"] == 0
+ assert findings[0]["result"] == "SKIPPED"
+
+
+def test_summarize_surfaces_engine_errors_distinctly():
+ """
+ A malformed policy must not read as a policy violation. Prefixing makes it obvious in the
+ comment that the engine, not the infrastructure, is the problem.
+ """
+ results = {"p": [{"rule_name": "r", "result": "FAIL", "evaluations": {"fails": [{"exec_err": "bad op"}]}}]}
+
+ _, findings = render.summarize(results)
+
+ assert findings[0]["messages"] == ["engine: bad op"]
+
+
+def test_summarize_handles_providers_without_resource_addresses():
+ """Only terraform_plan populates meta; json/kubernetes set it to None."""
+ results = {
+ "p": [
+ {
+ "rule_name": "r",
+ "result": "FAIL",
+ "evaluations": {"fails": [{"id": "c", "result": [{"message": "no", "meta": None}]}]},
+ }
+ ]
+ }
+
+ _, findings = render.summarize(results)
+
+ assert findings[0]["resources"] == []
+ assert findings[0]["messages"] == ["no"]
+
+
+def test_summarize_tolerates_empty_and_none():
+ assert render.summarize(None)[0]["FAIL"] == 0
+ assert render.summarize({})[1] == []
+
+
+# --- verdict -----------------------------------------------------------------------------------
+
+
+def test_verdict_failed_when_any_policy_fails():
+ counts, _ = render.summarize(_results("FAIL"))
+ assert render.verdict(counts, "COMPLETED") == "failed"
+
+
+def test_verdict_warned_for_a_warning():
+ counts, _ = render.summarize(_results("WARN"))
+ assert render.verdict(counts, "COMPLETED") == "warned"
+
+
+def test_verdict_approval_required_warns_rather_than_gating():
+ """
+ A rule result of APPROVAL_REQUIRED means its author wrote `onFail: APPROVAL_REQUIRED`. For these
+ runs there is nothing to approve: the step exits 0, the run reaches COMPLETED, and an approval
+ is only ever engaged on exit 11 and never on the last step -- of which a policy-only run has
+ exactly one. So it warns, deliberately, until a real gate exists.
+ """
+ counts, _ = render.summarize(_results("APPROVAL_REQUIRED"))
+
+ assert render.verdict(counts, "COMPLETED") == "warned"
+
+
+def test_verdict_failed_outranks_approval_required():
+ """A hard failure is the more urgent signal when a run has both."""
+ counts = {"FAIL": 1, "APPROVAL_REQUIRED": 1}
+
+ assert render.verdict(counts, "COMPLETED") == "failed"
+
+
+def test_verdict_passed_only_when_a_policy_actually_passed():
+ counts, _ = render.summarize(_results("PASS"))
+ assert render.verdict(counts, "COMPLETED") == "passed"
+
+
+def test_verdict_errored_for_a_non_completed_run():
+ """An ERRORED or CANCELLED run produced no verdict; that is not a pass."""
+ counts, _ = render.summarize(_results("PASS"))
+ for status in ("ERRORED", "CANCELLED", "RUNNING", None):
+ assert render.verdict(counts, status) == "errored", status
+
+
+def test_verdict_distinguishes_no_policies_from_passed():
+ """
+ A run with nothing in scope is reported as such rather than as a clean bill of health -- the
+ most likely cause is a policy scoped to the wrong workflow group.
+ """
+ assert render.verdict({}, "COMPLETED") == "no-policies"
+
+
+def test_a_run_paused_by_the_platform_warns_when_it_produced_results():
+ """
+ A run resting at APPROVAL_REQUIRED evaluated something before it paused. Reporting it as
+ `errored` would blame the tool for a working evaluation -- and the poller stops there rather
+ than spinning to its timeout.
+ """
+ counts, _ = render.summarize(_results("PASS"))
+
+ assert render.verdict(counts, "APPROVAL_REQUIRED") == "warned"
+
+
+def test_a_run_paused_before_it_evaluated_anything_is_an_error():
+ """
+ The one thing that must never happen: green, or even amber, for a run that produced no verdict.
+ A paused run with no results has not evaluated the code.
+ """
+ assert render.verdict({}, "APPROVAL_REQUIRED") == "errored"
+
+
+# --- rendering ---------------------------------------------------------------------------------
+
+
+def test_markdown_starts_with_the_marker_when_one_is_given():
+ """
+ The marker is opaque to this module -- GitHub's sticky-comment marker is one caller's choice --
+ but when supplied it must be line 1, so the caller can find the document again.
+ """
+ marker = "[//]: <> (tirith-comment, tag=envs-prod)"
+ body = render.render_markdown(_results(), "COMPLETED", "https://app.example/run", marker=marker)
+
+ assert body.split("\n")[0] == marker
+
+
+def test_markdown_has_no_marker_line_by_default():
+ """This module is VCS-agnostic: nothing is prepended unless the caller asks for it."""
+ body = render.render_markdown(_results(), "COMPLETED", "https://app.example/run")
+
+ assert not body.startswith("[//]")
+ assert body.lstrip().startswith("## ")
+
+
+def test_comment_includes_table_detail_and_run_link():
+ body = render.render_markdown(_results(), "COMPLETED", "https://app.example/run")
+
+ assert "| Policy | Rule | Resource |" in body
+ assert "`no-public-ingress`" in body
+ assert "`0.0.0.0/0` is contained in `cidr_blocks`" in body
+ assert "module.net.aws_security_group.web" in body
+ assert "https://app.example/run" in body
+
+
+def test_comment_explains_an_errored_run():
+ body = render.render_markdown({}, "ERRORED", "https://app.example/run")
+
+ assert "could not evaluate" in body.lower()
+ assert "ERRORED" in body
+
+
+def test_comment_truncates_below_the_github_limit_keeping_the_table():
+ """
+ GitHub rejects a body over 65536 characters with a 422. Detail sections go first; the summary
+ table is what a reviewer scans, so it must survive.
+ """
+ results = {
+ f"policy-{i}": [
+ {
+ "rule_name": f"rule-{i}",
+ "result": "FAIL",
+ "evaluations": {
+ "fails": [
+ {
+ "id": f"check-{j}",
+ "result": [
+ {
+ "message": "x" * 400,
+ "meta": {"address": f"aws_instance.i{j}"},
+ }
+ ],
+ }
+ for j in range(20)
+ ]
+ },
+ }
+ ]
+ for i in range(60)
+ }
+
+ body = render.render_markdown(results, "COMPLETED", "https://app.example/run", limit=20000)
+
+ assert len(body) <= 20000
+ assert "| Policy | Rule | Resource |" in body, "the summary table must survive truncation"
+ assert "more finding" in body or "truncated" in body
+
+
+def test_strip_marker_removes_it_for_targets_that_have_no_use_for_it():
+ """A check-run summary, for instance: the marker only means something on an issue comment."""
+ marker = "[//]: <> (tirith-comment, tag=default)"
+ body = render.render_markdown(_results(), "COMPLETED", "https://app.example/run", marker=marker)
+
+ summary = render.strip_marker(body)
+
+ assert "[//]: <>" not in summary
+ assert "no-public-ingress" in summary
+
+
+def test_headline_reports_each_nonzero_bucket():
+ counts = {"FAIL": 2, "WARN": 1, "APPROVAL_REQUIRED": 3, "PASS": 9, "SKIPPED": 1}
+
+ assert render.headline(counts, "failed") == "Tirith — 2 failed, 3 need approval, 1 warned, 9 passed, 1 skipped"
+
+
+# --- cost line ----------------------------------------------------------------------------------
+
+
+def test_cost_line_shows_the_monthly_total():
+ assert "39.80 USD" in "\n".join(render.render_cost({"totalMonthlyCost": "39.8", "currency": "USD"}))
+
+
+def test_cost_line_shows_the_delta_from_this_change():
+ """Infracost fills the diff from the plan's prior state -- the number a reviewer wants."""
+ line = "\n".join(render.render_cost({"totalMonthlyCost": "120.5", "diffTotalMonthlyCost": "39.8"}))
+
+ assert "120.50" in line
+ assert "+39.80 from this change" in line
+
+
+def test_a_cost_decrease_reads_as_a_decrease():
+ line = "\n".join(render.render_cost({"totalMonthlyCost": "10", "diffTotalMonthlyCost": "-5.25"}))
+
+ assert "−5.25 from this change" in line
+
+
+def test_a_zero_delta_is_omitted_rather_than_shown_as_plus_zero():
+ line = "\n".join(render.render_cost({"totalMonthlyCost": "10", "diffTotalMonthlyCost": "0"}))
+
+ assert "from this change" not in line
+
+
+def test_a_zero_cost_is_still_reported():
+ """Silence would be indistinguishable from 'this change costs nothing'."""
+ assert "0.00" in "\n".join(render.render_cost({"totalMonthlyCost": "0"}))
+
+
+def test_a_failed_estimate_says_so():
+ line = "\n".join(render.render_cost({"error": "failed to perform infrastructure cost estimation"}))
+
+ assert "unavailable" in line
+
+
+def test_no_estimate_renders_nothing():
+ assert render.render_cost(None) == []
+ assert render.render_cost({}) == []
+
+
+def test_the_cost_appears_in_the_comment_body():
+ body = render.render_markdown(
+ {"p": [{"rule_name": "r", "result": "PASS"}]},
+ "COMPLETED",
+ "https://dash.example/run",
+ cost_breakdown={"totalMonthlyCost": "39.8", "currency": "USD"},
+ )
+
+ assert "39.80 USD" in body
+
+
+def test_the_cost_survives_truncation_of_a_long_findings_list():
+ """A wall of findings must not push the cost line out of the comment."""
+ results = {
+ f"policy-{i}": [
+ {
+ "rule_name": f"rule-{i}",
+ "result": "FAIL",
+ "evaluations": {"fails": [{"result": [{"message": "x" * 400}]}]},
+ }
+ ]
+ for i in range(60)
+ }
+
+ body = render.render_markdown(
+ results,
+ "COMPLETED",
+ "https://dash.example/run",
+ limit=3000,
+ cost_breakdown={"totalMonthlyCost": "39.8"},
+ )
+
+ assert len(body) <= 3000
+ assert "39.80" in body
+
+
+# --- checkov findings ---------------------------------------------------------------------------
+
+
+def _checkov_rule(fails):
+ return {
+ "rule_name": "Policy-Rule-1",
+ "source_config_kind": "SG_INTERNAL_P2",
+ "result": "FAIL",
+ "evaluations": {"fails": fails},
+ }
+
+
+def test_checkov_findings_are_rendered():
+ """
+ Checkov entries are {"description", "keys"}, not tirith's list under "result". Reading only the
+ tirith shape rendered a dozen real findings as an empty block -- in the one place a
+ reviewer looks. Taken verbatim from QA run iqkxb26uzi1n.
+ """
+ body = render.render_markdown(
+ {
+ "best-practices": [
+ _checkov_rule(
+ [
+ {
+ "description": "Ensure that detailed monitoring is enabled for EC2 instances",
+ "keys": ["aws_instance.app.monitoring"],
+ },
+ ]
+ )
+ ]
+ },
+ "COMPLETED",
+ "https://dash.example/run",
+ )
+
+ assert "Ensure that detailed monitoring is enabled for EC2 instances" in body
+
+
+def test_a_checkov_key_is_reduced_to_its_resource_address():
+ """The attribute suffix is what the check inspected; the address is what a reviewer navigates by."""
+ _messages, resources = render._extract_detail(
+ _checkov_rule(
+ [
+ {
+ "description": "Ensure S3 buckets are encrypted",
+ "keys": ["aws_s3_bucket.data.rule.apply_server_side_encryption_by_default.sse_algorithm"],
+ },
+ ]
+ )
+ )
+
+ assert resources == ["aws_s3_bucket.data"]
+
+
+def test_repeated_keys_on_one_resource_are_listed_once():
+ _messages, resources = render._extract_detail(
+ _checkov_rule(
+ [
+ {
+ "description": "Ensure S3 buckets are encrypted",
+ "keys": ["aws_s3_bucket.data.rule.sse_algorithm", "aws_s3_bucket.data.resource_type"],
+ },
+ ]
+ )
+ )
+
+ assert resources == ["aws_s3_bucket.data"]
+
+
+def test_a_checkov_finding_with_no_keys_still_reports_its_description():
+ messages, resources = render._extract_detail(_checkov_rule([{"description": "Some check", "keys": []}]))
+
+ assert messages == ["Some check"]
+ assert resources == []
+
+
+@pytest.mark.parametrize("key", ["", "single", None, 42])
+def test_a_malformed_key_is_skipped_rather_than_crashing(key):
+ _messages, resources = render._extract_detail(_checkov_rule([{"description": "x", "keys": [key]}]))
+
+ assert resources == []
+
+
+def test_the_tirith_shape_still_renders():
+ """Teaching the renderer Checkov must not cost it the shape it already understood."""
+ messages, resources = render._extract_detail(
+ {
+ "evaluations": {
+ "fails": [
+ {"result": [{"message": "`3` is not equal to `0`", "meta": {"address": "null_resource.untagged"}}]}
+ ]
+ }
+ }
+ )
+
+ assert messages == ["`3` is not equal to `0`"]
+ assert resources == ["null_resource.untagged"]
+
+
+def test_an_empty_description_does_not_hide_the_finding():
+ """
+ The exact shape a tirith rule with no declared description produces, taken from a QA run of the
+ cost policy `DO_NOT_TOUCH / cost-control`:
+
+ {"id": ..., "description": "", "result": [{"message": "`23.832` is not less than `20`", ...}]}
+
+ Both keys are present. Dispatching on `"description" in entry` took the Checkov path, found an
+ empty string to report, and skipped `result` -- so the policy appeared in the summary table with
+ an empty block. A reviewer saw that a cost rule had tripped and no reason why.
+ """
+ messages, resources = render._extract_detail(
+ {
+ "evaluations": {
+ "fails": [
+ {
+ "id": "max-price-monthly-20",
+ "description": "",
+ "result": [{"passed": False, "message": "`23.832` is not less than `20`", "meta": None}],
+ "passed": False,
+ }
+ ]
+ }
+ }
+ )
+
+ assert messages == ["`23.832` is not less than `20`"]
+ # meta is None on the infracost provider -- only terraform_plan populates an address.
+ assert resources == []
+
+
+def test_an_entry_carrying_both_shapes_reports_both():
+ """Reading both is additive, so neither shape can mask the other."""
+ messages, resources = render._extract_detail(
+ {
+ "evaluations": {
+ "fails": [
+ {
+ "description": "Ensure RDS is encrypted at rest",
+ "keys": ["aws_db_instance.db.storage_encrypted"],
+ "result": [
+ {"message": "`false` is not equal to `true`", "meta": {"address": "aws_db_instance.db"}}
+ ],
+ }
+ ]
+ }
+ }
+ )
+
+ assert messages == ["Ensure RDS is encrypted at rest", "`false` is not equal to `true`"]
+ assert resources == ["aws_db_instance.db"]
+
+
+def test_an_engine_error_is_still_surfaced_verbatim():
+ messages, _resources = render._extract_detail(
+ {"evaluations": {"fails": [{"exec_err": "Checkov policy has no configPolicyIds"}]}}
+ )
+
+ assert messages == ["engine: Checkov policy has no configPolicyIds"]
+
+
+# --- the scanned commit --------------------------------------------------------------------------
+#
+# The comment is edited in place across runs, so without this a reader cannot tell whether the
+# verdict in front of them is about the head of the branch or about a push from an hour ago.
+
+
+def test_the_scanned_commit_is_rendered_under_the_headline():
+ body = render.render_markdown(_results(), "COMPLETED", "https://run", commit="9ea6388f1c2d3e4f5a6b")
+
+ lines = body.split("\n")
+ heading = next(i for i, line in enumerate(lines) if line.startswith("## "))
+ assert lines[heading + 2] == "Scanned commit 9ea6388", lines[: heading + 4]
+
+
+def test_no_commit_line_when_none_is_supplied():
+ body = render.render_markdown(_results(), "COMPLETED", "https://run")
+
+ assert "Scanned commit" not in body
+
+
+def test_the_commit_line_survives_alongside_the_marker():
+ """The marker has to stay line 1 -- it is what finds the comment again."""
+ marker = "[//]: <> (tirith-comment, tag=default)"
+ body = render.render_markdown(_results(), "COMPLETED", "https://run", marker=marker, commit="abc1234def")
+
+ assert body.startswith(marker)
+ assert "abc1234" in body
+
+
+def test_a_non_sha_revision_is_not_truncated():
+ """A tag or branch name is more useful whole; truncating one invents something sha-shaped."""
+ body = render.render_markdown(_results(), "COMPLETED", "https://run", commit="release-2026-08")
+
+ assert "release-2026-08" in body
+
+
+def test_a_short_sha_is_left_alone():
+ body = render.render_markdown(_results(), "COMPLETED", "https://run", commit="abc1234")
+
+ assert "abc1234" in body
+
+
+# --- a paused run, and results this module cannot read --------------------------------------------
+
+
+def test_a_fail_is_never_downgraded_by_a_paused_run():
+ """
+ The regression this pins: the APPROVAL_REQUIRED branch returned before the FAIL check, so a
+ paused run carrying a failing policy reported `warned` -- a neutral check, which SATISFIES a
+ required status check -- while the headline on the same counts said "1 failed".
+ """
+ counts = {"FAIL": 1, "PASS": 2}
+
+ assert render.verdict(counts, "APPROVAL_REQUIRED") == "failed"
+
+
+def test_a_rule_with_no_result_is_not_a_pass():
+ """`rule.get("result", PASS)` turned "the step wrote no verdict" into a clean bill of health."""
+ counts, findings = render.summarize({"p": [{"rule_name": "r"}]})
+
+ assert counts[render.UNKNOWN] == 1
+ assert counts[render.PASS] == 0
+ assert render.verdict(counts, "COMPLETED") == "errored"
+ assert findings[0]["result"] == render.UNKNOWN
+
+
+def test_a_result_this_module_does_not_recognise_is_not_silently_dropped():
+ """
+ An unrecognised value used to land in a count key `verdict` never inspects, so it vanished: the
+ run reported `no-policies` and exited 0.
+ """
+ counts, _ = render.summarize({"p": [{"rule_name": "r", "result": "ERROR"}]})
+
+ assert render.verdict(counts, "COMPLETED") == "errored"
+
+
+def test_a_fail_still_outranks_an_unreadable_result():
+ counts, _ = render.summarize({"p": [{"rule_name": "a", "result": "FAIL"}, {"rule_name": "b", "result": "?"}]})
+
+ assert render.verdict(counts, "COMPLETED") == "failed"
+
+
+# --- hostile input: the report must not be spoofable (pentest F1) --------------------------------
+#
+# A pull-request author controls the terraform a plan is built from, so evaluator messages, resource
+# addresses, rule names and policy ids are all attacker-influenced. Before this, every one of them was
+# interpolated raw or wrapped in a single backtick -- and a backtick in the value closes that span, so
+# the rest rendered as markdown and HTML. A pen test used it to put a fake "all policies passed" banner
+# and a link whose text said app.stackguardian.io and whose href said somewhere else into the comment a
+# reviewer reads. The gate itself was never affected; the report was.
+#
+# These assert against markdown RENDERED by a CommonMark parser, not against the source. The payload is
+# still present in the source by design -- inside a code span, where it is inert -- so a substring check
+# on the source proves nothing. Getting that wrong is easy: it is the mistake made while writing these.
+
+PAYLOAD = (
+ "`x` is not equal to `y``
All policies passed
"
+ "[app.stackguardian.io](https://evil.example) | broken | cell"
+)
+
+
+def _render(**overrides):
+ finding = {
+ "rule_name": "cost-control",
+ "result": "FAIL",
+ "evaluations": {
+ "fails": [{"result": [{"message": "ordinary message", "meta": {"address": "aws_s3_bucket.b"}}]}]
+ },
+ }
+ policy_id = overrides.pop("policy_id", "DO_NOT_TOUCH")
+ if "message" in overrides:
+ finding["evaluations"]["fails"][0]["result"][0]["message"] = overrides.pop("message")
+ if "address" in overrides:
+ finding["evaluations"]["fails"][0]["result"][0]["meta"]["address"] = overrides.pop("address")
+ finding.update(overrides)
+ return render.render_markdown({policy_id: [finding]}, "COMPLETED", "https://dash.example/run/1")
+
+
+def _html_of(body):
+ """Render as GitHub would, so the assertions are about what a reviewer's browser receives."""
+ pytest.importorskip("markdown_it", reason="needs markdown-it-py to render the assertion subject")
+ from markdown_it import MarkdownIt
+
+ return MarkdownIt("commonmark").enable("table").render(body)
+
+
+@pytest.mark.parametrize("field", ["message", "address", "rule_name", "policy_id"])
+def test_no_field_can_inject_markup_into_the_report(field):
+ """
+ Every attacker-influenced field, through the same payload. `rule_name` mattered most: it was the one
+ field interpolated with no wrapping at all, straight into the `` element.
+ """
+ rendered = _html_of(_render(**{field: PAYLOAD}))
+
+ assert "
" not in rendered, f"{field} injected a heading"
+ assert 'href="https://evil.example"' not in rendered, f"{field} injected a link"
+ assert rendered.count("") == rendered.count(""), f"{field} broke the collapsible"
+
+
+def test_the_payload_is_still_readable_after_being_neutralised():
+ """
+ Neutralising must not mean hiding. A reviewer has to be able to see what the policy actually
+ compared, or the fix trades a spoofing bug for a blind gate.
+ """
+ rendered = _html_of(_render(message=PAYLOAD))
+
+ assert "All policies passed" in rendered
+ assert "<h1>" in rendered, "the markup should be shown as text, not dropped"
+
+
+def test_a_pipe_or_newline_in_a_table_cell_keeps_the_row_intact():
+ """
+ A pipe splits a cell and a newline ends the row, so either one silently drops the real columns.
+ GFM's remedy is a backslash escape, which is the one escape that works inside a code span.
+ """
+ body = _render(rule_name="a | b\nsecond line", address="x | y")
+
+ rows = [line for line in body.splitlines() if line.startswith("|")]
+ assert len(rows) == 3, f"expected header, separator and one row; got {len(rows)}"
+ # Count only *unescaped* pipes -- an escaped `\|` is content, which is the whole point.
+ separators = len(re.findall(r"(?" in line)
+ assert "`" not in summary, f"an unescaped backtick survived into the summary: {summary}"
+ assert "`" in summary, "the backtick should be shown as an entity, not dropped"
+
+
+def test_the_run_url_cannot_break_out_of_the_href():
+ from html.parser import HTMLParser
+
+ class Anchors(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.attrs_seen = []
+
+ def handle_starttag(self, tag, attrs):
+ if tag == "a":
+ self.attrs_seen.append(dict(attrs))
+
+ body = render.render_markdown({}, "COMPLETED", 'https://dash.example/1" onmouseover="alert(1)')
+ parser = Anchors()
+ parser.feed(_html_of(body))
+
+ assert parser.attrs_seen, "expected the run link to be rendered"
+ for attributes in parser.attrs_seen:
+ assert list(attributes) == ["href"], f"the URL introduced an attribute: {list(attributes)}"
+
+
+def test_a_benign_value_renders_exactly_as_before():
+ """
+ The regression guard for the fence approach: with no backticks in the value the fence is a single
+ backtick, so ordinary reports are byte-identical to what they were. If this breaks, every report
+ changed appearance and the diff is bigger than intended.
+ """
+ body = _render()
+
+ assert "| ❌ | `DO_NOT_TOUCH` | `cost-control` | `aws_s3_bucket.b` |" in body
+ assert "- `ordinary message`" in body
diff --git a/tests/providers/json/policy_mixed_queries.json b/tests/providers/json/policy_mixed_queries.json
new file mode 100644
index 00000000..e28679a8
--- /dev/null
+++ b/tests/providers/json/policy_mixed_queries.json
@@ -0,0 +1,131 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json",
+ "policy_name": "Mixed Query Language Example",
+ "policy_description": "Demonstrates using both JMESPath and jq_query in the same policy"
+ },
+ "evaluators": [
+ {
+ "id": "jmespath_check_region",
+ "description": "Use JMESPath for simple field extraction",
+ "provider_args": {
+ "operation_type": "jmespath",
+ "query": "[0].vars.region"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "us-east-1"
+ }
+ },
+ {
+ "id": "jq_query_check_become",
+ "description": "Use jq_query for boolean checks",
+ "provider_args": {
+ "operation_type": "jq_query",
+ "query": ".[0].become"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ },
+ {
+ "id": "jmespath_task_count",
+ "description": "Use JMESPath length function",
+ "provider_args": {
+ "operation_type": "jmespath",
+ "query": "length([0].tasks)"
+ },
+ "condition": {
+ "type": "GreaterThan",
+ "value": 5
+ }
+ },
+ {
+ "id": "jq_query_filter_service_tasks",
+ "description": "Use jq_query for complex filtering",
+ "provider_args": {
+ "operation_type": "jq_query",
+ "query": "[.[0].tasks[] | select(has(\"service\"))] | length"
+ },
+ "condition": {
+ "type": "GreaterThan",
+ "value": 0
+ }
+ },
+ {
+ "id": "jmespath_contains_check",
+ "description": "Use JMESPath contains for array membership",
+ "provider_args": {
+ "operation_type": "jmespath",
+ "query": "[0].tasks[*].name"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "Start MySQL service"
+ }
+ },
+ {
+ "id": "jq_query_conditional_logic",
+ "description": "Use jq_query for conditional transformations",
+ "provider_args": {
+ "operation_type": "jq_query",
+ "query": "if .[0].become then \"privileged\" else \"unprivileged\" end"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "privileged"
+ }
+ },
+ {
+ "id": "jmespath_projection",
+ "description": "Use JMESPath for multi-select projection",
+ "provider_args": {
+ "operation_type": "jmespath",
+ "query": "[0].{playbook_name: name, host_group: hosts}"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": ".*Configure MySQL.*"
+ }
+ },
+ {
+ "id": "jq_query_type_validation",
+ "description": "Use jq_query for type checking",
+ "provider_args": {
+ "operation_type": "jq_query",
+ "query": ".[0].tasks | type"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "array"
+ }
+ },
+ {
+ "id": "get_value_simple",
+ "description": "Use classic get_value for straightforward paths",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "[0].hosts"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "mysql_servers"
+ }
+ },
+ {
+ "id": "jq_query_map_transform",
+ "description": "Use jq_query map for array transformations",
+ "provider_args": {
+ "operation_type": "jq_query",
+ "query": "[.[0].tasks[] | select(has(\"mysql\")) | .name]"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "Create application database"
+ }
+ }
+ ],
+ "eval_expression": "(jmespath_check_region && jq_query_check_become) && (jmespath_task_count || jq_query_filter_service_tasks) && jmespath_contains_check && get_value_simple"
+}
diff --git a/tests/test_readme_is_current.py b/tests/test_readme_is_current.py
new file mode 100644
index 00000000..7a02da86
--- /dev/null
+++ b/tests/test_readme_is_current.py
@@ -0,0 +1,132 @@
+"""
+The README's generated bits must match what the program actually prints.
+
+Three things in it were hand-copied and had gone stale: the `## Usage` block was a paste of an older
+`--help` missing `-var-path`, `-var` and the whole `platform` subcommand; the install-verification step
+showed `1.0.0-beta.12` against a shipped `1.2.0`; and the Getting Started sample output predated the
+current message format, so the first command a new user runs printed something different from the
+documentation.
+
+Correcting the text is a one-off; it had already been corrected before and rotted again. What stops
+that is checking it, so these run in CI. They compare against the real program output rather than
+against a golden file, so adding a flag updates the requirement automatically -- the README is what has
+to move.
+"""
+
+import os
+import re
+import subprocess
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SRC = os.path.join(ROOT, "src")
+README = os.path.join(ROOT, "README.md")
+
+sys.path.insert(0, SRC)
+
+from tirith import __version__
+
+
+def _readme():
+ with open(README) as f:
+ return f.read()
+
+
+def _help(*args):
+ """Run the CLI's --help the way a user would, in a subprocess, not by calling into argparse."""
+ argv = list(args) + ["--help"]
+ code = (
+ "import sys\n"
+ f"sys.argv = ['tirith'] + {argv!r}\n"
+ "from tirith.cli import main\n"
+ "try:\n"
+ " main()\n"
+ "except SystemExit:\n"
+ " pass\n"
+ )
+ env = dict(os.environ, PYTHONPATH=SRC)
+ return subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env=env).stdout
+
+
+def _fenced_block_after(heading):
+ text = _readme()
+ start = text.index(heading) + len(heading)
+ open_fence = text.index("```", start)
+ close_fence = text.index("```", open_fence + 3)
+ return text[open_fence + 3 : close_fence].strip("\n")
+
+
+def _options(text):
+ """Every option string in a help text, e.g. `{-policy-path, --json, --fail-on-error}`."""
+ return set(re.findall(r"(?