feat(platform): add tirith platform check - #272
Open
refeed wants to merge 4 commits into
Open
Conversation
Fixed:
- Variable substitution mutated the caller's policy dict. Evaluating the same
parsed policy twice (a policy set, or a retry) leaked substituted values from
one evaluation into the next.
- An unsupported condition.type returned without setting result["result"],
raising KeyError in the pretty printer far from the real cause. The consumer
is hardened with .get("result", []) as well.
- Provider errors reported without a ProviderError severity were discarded and
None was evaluated against the condition, so a typo'd operation_type read as
a genuine policy violation. Five sites across four providers were affected.
These are malformed provider calls, so they deliberately bypass
error_tolerance -- that setting exists to tolerate missing data, not to mask
a broken policy.
Added:
- meta.id/name/description/severity/enforcement/tags/remediation now reach the
result document when declared. Absent keys are omitted, so output for a
policy declaring none of them is unchanged.
Backward compatibility is pinned by tests/golden/json_policy_output.json,
captured before these changes and asserted byte-identical after them.
Runs 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.
This moves the StackGuardian protocol out of the GitHub Action, where it was
GitHub-only, untestable off a runner, and unavailable to anyone driving the
platform from GitLab or a Makefile. No new runtime dependencies -- the whole
thing is stdlib urllib, so a runner needs nothing beyond tirith itself.
Subcommands are dispatched before the flat parser sees anything. argparse cannot
express an optional subcommand alongside options like `-policy-path`, and the
local-evaluation surface is a contract that test_output_compatibility.py asserts
byte-for-byte. Also fixes cli.main(args=...), which was ignored because
parse_args() was called with no argument.
Two bugs found while writing this:
* APPROVAL_REQUIRED was missing from the poller's terminal statuses. It is a
resting state, so a run that reached it spun until the timeout and was then
reported as a tool failure -- an outage, rather than a finished evaluation
waiting on a human. It now yields an `approval-required` verdict.
* A file named state.json in the working directory was packed raw.
`terraform state pull > state.json` is the documented way to produce one, so
it routinely sits there unmasked, and it shipped in full beside the masked
copy. plan.json / state.json / infracost.json are now always written by
pack() from an already-masked object and never copied from the source tree.
Exit codes: 0 clean, 3 for a policy failure under --fail-on-error, 1 for an
unreachable platform or a run that produced no verdict -- the last regardless of
the flag, because a run with no verdict must never look like a pass.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new tirith platform check subcommand that runs StackGuardian policy evaluations against a plan/state/JSON document by packaging masked inputs + Terraform source into an archive, creating/polling a StackGuardian run, and emitting JSON/markdown verdict output. It also tightens several core/CLI behaviors to preserve existing output contracts and avoid previously observed failure/leak modes.
Changes:
- Add a stdlib-only StackGuardian “platform” integration (
client,check,archive,redact,report) plus extensive tests for polling, masking, archiving, and rendering. - Add CLI subcommand pre-dispatch (
tirith platform ...) while preserving the legacy flat CLI surface and byte-identical--jsonoutput compatibility. - Fix core behaviors (policy var substitution mutability, unsupported evaluator result shape, provider bare error surfacing) and bump version/changelog.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/platform/test_report.py | Tests for verdict computation and markdown rendering/truncation behavior. |
| tests/platform/test_redact.py | Security-focused tests asserting redaction on serialized bytes for plan/state. |
| tests/platform/test_client.py | Tests for StackGuardian client polling/terminal states and upload behavior. |
| tests/platform/test_archive.py | Tests archive contents/exclusions and ensures masked docs win over disk files. |
| tests/golden/json_policy_output.json | Golden output fixture used to pin legacy JSON byte compatibility. |
| tests/core/test_policy_parameterization.py | Adds regression tests ensuring var substitution doesn’t mutate caller policy dict. |
| tests/core/test_output_compatibility.py | New contract tests ensuring stable output shape/bytes for consumers. |
| tests/core/test_core.py | Adds tests for unsupported evaluator result shape and provider bare error surfacing. |
| tests/cli/test_dispatch.py | Tests for subcommand dispatch without breaking legacy flat CLI contract. |
| src/tirith/status.py | Adds distinct exit code for policy-failed outcomes under --fail-on-error. |
| src/tirith/prettyprinter.py | Avoids KeyError by tolerating missing result key in evaluator output. |
| src/tirith/platform/report.py | Implements result summarization, verdict mapping, and markdown rendering. |
| src/tirith/platform/redact.py | Implements plan slimming + marker-driven redaction and state masking. |
| src/tirith/platform/client.py | Implements stdlib-only StackGuardian API client including polling and artifact fetch. |
| src/tirith/platform/cli.py | Implements tirith platform argparse surface and exit-code semantics. |
| src/tirith/platform/check.py | Orchestrates read→mask→pack→upload→run→poll→fetch→report flow. |
| src/tirith/platform/archive.py | Builds tar.gz archive with exclusions and reserved-name handling. |
| src/tirith/platform/init.py | Introduces platform package with stdlib-only intent documented. |
| src/tirith/core/policy_parameterization.py | Switches var substitution to operate on a deep copy to avoid mutation leaks. |
| src/tirith/core/core.py | Ensures unsupported evaluator still populates result; passes through policy meta keys. |
| src/tirith/cli.py | Adds pre-dispatch for subcommands and fixes main(args=...) honoring provided argv. |
| src/tirith/init.py | Version bump to 1.2.0. |
| setup.py | Updates package version to 1.2.0. |
| CHANGELOG.md | Documents 1.2.0 release changes and notes/contracts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+107
to
+110
| if counts.get(WARN) or counts.get(APPROVAL_REQUIRED): | ||
| return "warned" | ||
| if counts.get(PASS) or counts.get("SKIPPED"): | ||
| return "passed" |
Comment on lines
+176
to
+179
| # The masked documents are written separately and must win. | ||
| if relative in reserved_names: | ||
| skipped += 1 | ||
| continue |
| f"The upload response for {filename} carried no storage key. The platform may " | ||
| f"predate the configuration_upload_url endpoint. Response: {payload}" | ||
| ) | ||
| signed_url = _extract_signed_url({"msg": msg.get("signedUrl")}) |
A third instance of the `planned_values` pattern, caught by a live GitHub Action run: a hardcoded value is masked in `resource_changes` and sits in plaintext in the same document under `configuration.root_module.resources[].expressions[].constant_value`, which carries no sensitivity markers at all. `configuration` cannot be dropped -- three operations read it -- so the literals are scrubbed while the reference graph is kept. Lossless: direct_references_operator reads only `references` and direct_dependencies_operator only `depends_on` (providers/terraform_plan/handler.py:329, :385-388). Covers nested block arguments, repeated blocks (a list of expressions), child modules via module_calls[].module, and variable `default` / output `expression` literals. Note this does not make a plan safe to hand out: the project archive carries the terraform source as written, so a secret hardcoded in HCL still reaches the platform in main.tf. Documented in the action's README rather than papered over.
…c tfstate.json
sensitive_attributes is a list of PATHS -- each entry is itself a list of steps:
[[{"type": "get_attr", "value": "content_base64"}],
[{"type": "get_attr", "value": "content"}]]
The code read only the flat forms, so on real state every entry was skipped: a
list is neither a dict nor a string. Nothing in a resource's attributes was
masked at all. The unit test passed because its fixture invented the flat shape;
verified now against `terraform state pull` output for a local_sensitive_file,
which is where the real shape came from.
Paths can also descend through nested objects and list indices, so the masker
walks them rather than assuming a single key, and deep-copies so the caller's
document is not mutated underneath it.
Renames the archive's state document from state.json to tfstate.json, matching
the TfStateCleaned fact it feeds and the name the terraform step already uses
for state. No collision: the archive unpacks into the user directory, while
managed state lives at the artifacts root, and policy-only forces
managedTerraformState off.
|
❌ The last analysis has failed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
platformsubcommand that runs an organization's StackGuardian policies against a plan,state or arbitrary JSON document — from CI or a laptop:
It 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.
This moves the StackGuardian protocol out of the GitHub Action, where it was GitHub-only,
untestable off a runner, and unavailable to anyone driving the platform from GitLab or a Makefile.
No new runtime dependencies — the whole thing is stdlib
urllib, so a runner needs nothingbeyond tirith itself.
The local surface is untouched
Subcommands are dispatched before the flat parser sees anything. argparse cannot express an optional
subcommand alongside options like
-policy-path, andtests/core/test_output_compatibility.pyasserts the
--jsonoutput byte-for-byte because the platform and the step templates parse it.Also fixes
cli.main(args=...), which was silently ignored:parse_args()was called with noargument, so the CLI could only ever read
sys.argv. That is why there were no CLI tests.Two bugs found while writing this
APPROVAL_REQUIREDwas missing from the poller's terminal statuses. It is a resting state, soa run that reached it spun until the timeout and was then reported as a tool failure — an outage,
rather than a finished evaluation waiting on a human. It now yields an
approval-requiredverdict.state.jsonin the working directory was packed raw.terraform state pull > state.jsonis the documented way to produce one, so it routinely sitsthere unmasked — and it shipped in full, beside the masked copy.
plan.json/state.json/infracost.jsonare now always written bypack()from an already-masked object and never copiedfrom the source tree.
The archive tests assert on the bytes inside the built tarball, not on the objects handed to
pack(). That distinction is the point: testing the masking function in isolation is what let aleak through once already.
Exit codes
0clean ·3a policy failed under--fail-on-error·1unreachable platform or no verdict —the last regardless of the flag, because a run that produced no verdict must never look like a pass.
335 tests pass (245 baseline + 90 new).
Companion PRs: StackGuardian/core#1235, StackGuardian/api#1708, StackGuardian/auth#201,
StackGuardian/workflow-step-templates#310, StackGuardian/sg-cli-gh-action.