diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md new file mode 100644 index 00000000..b427587f --- /dev/null +++ b/.claude/skills/tirith-policies/SKILL.md @@ -0,0 +1,133 @@ +--- +name: tirith-policies +description: Author, debug and review Tirith IaC governance policies. Use when writing or editing files under .tirith/policies, when a Tirith check fails in CI, when asked to add a guardrail to a Terraform or OpenTofu pipeline, or when reading a Tirith result document or exit code. +--- + +# Writing Tirith policies + +A Tirith policy is **JSON data, not a program**. It names a provider, the value to inspect and +the condition that value must satisfy. Tirith does the traversal and returns resource-level +evidence. + +Policies live under `.tirith/policies` and are evaluated against the plan a pipeline already +produces (`terraform show -json tfplan > plan.json`). + +## Do not guess the vocabulary + +The two mistakes that cost the most time are inventing a `condition.type` and inventing an +`operation_type`. Both are enumerated below. An unknown condition type is especially expensive: +**the engine reports it as an ordinary failed check with no error attached**, so it is +indistinguishable from a real violation and will send someone to debug infrastructure that is +fine. + +If the `mcp` extra is installed, prefer the tools over this file — they read the live registries: + +``` +tirith mcp # describe_provider, lint_policy, evaluate, explain_result +``` + +## Shape + +```json +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every resource carries a costcenter tag" + }, + "evaluators": [{ + "id": "costcenter_tag_present", + "description": "Every taggable resource declares a costcenter tag", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.costcenter" + }, + "condition": {"type": "IsNotEmpty"} + }], + "eval_expression": "costcenter_tag_present" +} +``` + +- `eval_expression` references evaluators **by id** and combines them with `and` / `or`. An + evaluator the expression never names cannot affect the verdict. +- `terraform_resource_type: "*"` means every resource type is in scope. + +## Condition types + +`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`, +`IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`, +`RegexMatch` + +There is no `Exists`, no `Matches`, no `In`. Use `IsNotEmpty` for presence. + +## Providers and their `operation_type` values + +| `required_provider` | Reads | `operation_type` | +|---|---|---| +| `stackguardian/terraform_plan` | a `terraform show -json` plan | `action`, `attribute`, `count`, `direct_dependencies`, `direct_references`, `provider_config`, `terraform_version` | +| `stackguardian/infracost` | an Infracost breakdown | `total_monthly_cost`, `total_hourly_cost` | +| `stackguardian/kubernetes` | Kubernetes manifests | `attribute` | +| `stackguardian/json` | any JSON document | `get_value` | +| `stackguardian/terraform_state` | a state document | as for plans | + +## Gotchas the schema does not tell you + +Each of these produces a policy that looks correct and behaves wrongly. They cost real time to +discover. + +- **The key naming the value to read differs per provider.** `terraform_plan` and + `terraform_state` use `terraform_resource_attribute`; `kubernetes` uses `attribute_path` (and + requires `kubernetes_kind`); `json` uses `key_path`. Using another provider's key is not an + error — it is *ignored*, so the evaluator reads `None` and tests the condition against nothing. +- **`error_tolerance` lives inside `condition`**, and its severities are specific: + `0` = the resource is being deleted (`change.after` is null), `1` = the type is absent from the + plan, `2` = the attribute is absent. Choose the tolerance from which of those you mean to + forgive, not by feel. +- **There is no `NotRegexMatch`**, and no inverse conditions generally. Write the positive + detector and invert it in `eval_expression` with `!`. That is the only negation mechanism. +- **`operation_type: attribute` reads `change.after` only.** Nothing about a resource being + *destroyed* is visible through it — use `action` for that. +- **`count` has no action filter**, and Terraform reports unchanged resources as `no-op`, so + `count(*)` measures root-module size, not the size of the change. Blast radius is not + expressible today. +- **`jmespath` and `jq_query` do not ship.** Some test fixtures in the repository reference them, + which makes them look supported. The `json` provider supports `get_value`. + +## Reading the verdict + +Exit codes are a contract. Never collapse them: + +| Exit | Meaning | +|---|---| +| `0` | Policies passed, or nothing was in scope | +| `3` | A policy ran and said no — the change violates a rule | +| `1` | Tirith could not tell you either way: bad input, an unevaluable policy, or **every check skipped** | + +`final_result: null` means every check was skipped. **That is not a pass.** It almost always +means `provider_args` matched nothing — check `terraform_resource_type` and the attribute path +before touching the condition. + +One asymmetry worth knowing: when a check fails because an attribute is *absent*, there is no +value to attach a resource to, so that failure arrives **without a resource address**. Find the +culprit by looking in the plan for the resource lacking the attribute. + +## Before you hand a policy back + +1. Does `eval_expression` reference every evaluator you wrote? +2. Is every `condition.type` in the list above? +3. Did you **run it**? A policy that matches nothing looks identical to one that works. + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Never claim a policy works without evaluating it against a document that should fail it. A +guardrail only ever seen passing is a guardrail nobody has tested. + +## Reference + +- Policy reference — https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/ +- Conditions — https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/ +- Exit codes — https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/ +- Worked examples — `src/tirith/tui/examples/` diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc new file mode 100644 index 00000000..b9563d0f --- /dev/null +++ b/.cursor/rules/tirith-policies.mdc @@ -0,0 +1,47 @@ +--- +description: Authoring and debugging Tirith IaC governance policies +globs: ["**/.tirith/policies/**", "**/*.tirith.json"] +alwaysApply: false +--- + +Tirith policies are JSON data, not programs. Do not invent vocabulary — both registries are +closed, and an unknown `condition.type` reaches the engine as an ordinary failed check with no +error attached, so it reads as a real infrastructure violation and sends someone to debug +working infrastructure. + +**Condition types** — the complete list: +`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`, +`IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`, +`RegexMatch`. There is no `Exists` — use `IsNotEmpty` for presence. + +**`operation_type` by provider:** +- `stackguardian/terraform_plan` — `action`, `attribute`, `count`, `direct_dependencies`, + `direct_references`, `provider_config`, `terraform_version` +- `stackguardian/infracost` — `total_monthly_cost`, `total_hourly_cost` +- `stackguardian/kubernetes` — `attribute` +- `stackguardian/json` — `get_value` + +**Structure:** `meta` (`version`, `required_provider`, `name`) · `evaluators[]` (each with `id`, +`provider_args`, `condition`) · `eval_expression` combining evaluator ids with `and` / `or`. An +evaluator the expression never references cannot affect the verdict. + +**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. `final_result: +null` means every check was skipped — that is not a pass, and it usually means `provider_args` +matched nothing. + +**Gotchas:** the key naming the value differs per provider — `terraform_resource_attribute` +for terraform_plan/state, `attribute_path` plus `kubernetes_kind` for kubernetes, `key_path` for +json. Another provider's key is ignored rather than rejected, so the evaluator reads nothing. `error_tolerance` goes inside +`condition` — severity `0` = resource being deleted, `1` = type absent, `2` = attribute absent. +`attribute` reads `change.after` only, so use `action` for destroys. `count(*)` is root-module +size, not change size. `jmespath` and `jq_query` do not ship despite appearing in test fixtures. + +**Always evaluate before claiming a policy works.** A policy that matches nothing looks identical +to one that works: + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Worked examples: `src/tirith/tui/examples/`. Reference: +https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/ diff --git a/.github/ISSUE_TEMPLATE/first-pipeline-help.md b/.github/ISSUE_TEMPLATE/first-pipeline-help.md new file mode 100644 index 00000000..3f0b9360 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/first-pipeline-help.md @@ -0,0 +1,34 @@ +--- +name: Help me govern my first IaC pipeline +about: Get help adding a local, credential-free first check to your pipeline +title: 'Help: first pipeline — ' +labels: ['help wanted', 'first-pipeline'] +assignees: '' +--- + +A Tirith maintainer or community member can help you add a local, +credential-free first check. + +**Do not include secrets, plan files or private source code in this issue.** +Redact anything you paste, or reduce it to a minimal public example. + +### CI system + + + +### IaC tool and version + + + +### How the plan is produced, and where the plan JSON ends up + + + +### The first guardrail you want to enforce + + + +### A public example repository, or a redacted workflow snippet + + diff --git a/.github/ISSUE_TEMPLATE/policy-request.md b/.github/ISSUE_TEMPLATE/policy-request.md new file mode 100644 index 00000000..36e93b27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/policy-request.md @@ -0,0 +1,26 @@ +--- +name: Policy request +about: A rule you cannot express in a Tirith policy today +title: 'Policy: ' +labels: ['policy', 'enhancement'] +assignees: '' +--- + +### The rule, in plain words + + + +### What you tried + + + +### What happened instead + + + +### The input document + + diff --git a/.github/ISSUE_TEMPLATE/proposal.md b/.github/ISSUE_TEMPLATE/proposal.md new file mode 100644 index 00000000..1c086163 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/proposal.md @@ -0,0 +1,36 @@ +--- +name: Proposal / RFC +about: Propose a design change before writing it +title: 'RFC: ' +labels: ['rfc'] +assignees: '' +--- + +Substantial changes should start here rather than as a pull request — it is +cheaper to disagree about an approach in a paragraph than in a diff. See +[GOVERNANCE.md](../../GOVERNANCE.md) for how these get decided. + +### The problem + + + +### Proposed change + + + +### Contracts this touches + + + +### Alternatives considered + + + +### Who this affects, and how they migrate + + diff --git a/.github/repository-metadata.md b/.github/repository-metadata.md new file mode 100644 index 00000000..363c99b7 --- /dev/null +++ b/.github/repository-metadata.md @@ -0,0 +1,77 @@ +# Repository metadata + +GitHub's About panel, topics and social preview cannot be set from a file in +the repository — a maintainer has to apply them in **Settings** and in the +sidebar's **About** gear. This file is the source of truth for what they should +say, so the values are reviewable in a pull request rather than living only in +one person's browser. + +Keep it in step with the landing page (`documentation/src/pages/index.js`) and +the README opening. + +## About + +**Description** + +> Open-source IaC governance for any Terraform or OpenTofu pipeline. Evaluate +> plans locally, explain failures and stop unsafe changes before apply. + +**Website** + +> https://stackguardian.github.io/tirith/ + +**Topics** + +``` +terraform +opentofu +infrastructure-as-code +policy-as-code +devops +platform-engineering +ci-cd +compliance +cloud-security +github-actions +gitlab-ci +``` + +## Social preview + +Settings → General → Social preview. 1280×640. + +**Title** + +> Tirith — open-source IaC governance + +**Subtitle** + +> Put governance in front of every Terraform or OpenTofu plan. + +> [!NOTE] +> **Open item:** the image itself does not exist yet. Until it is uploaded, +> GitHub falls back to the repository owner's avatar, which reads as a +> StackGuardian link rather than a project link. + +## Settings to confirm before launch + +- **Private vulnerability reporting** enabled (Settings → Security). Without + it, the reporting link in [SECURITY.md](../SECURITY.md) does not work. +- **Discussions** enabled, if the "tell us how the first plan went" link on the + landing page is to resolve; otherwise repoint it at Issues. +- A **`good first issue`** label with 8–12 genuinely scoped issues behind it. + Both the README and the landing page link to that label. +- **`POSTHOG_KEY`** repository secret and optional **`POSTHOG_HOST`** variable, + read by `.github/workflows/deploy_docs.yml`. Unset means the published site + ships no analytics, which is a safe default rather than a failure. +- **`HUBSPOT_PORTAL_ID`** and **`HUBSPOT_FORM_GUID`** repository variables, for + the Fleet enquiry form. The HubSpot form needs properties named `email`, + `company`, `repository_band`, `ci_systems`, `primary_problem` and `context`. + Unset, the form disables itself and points at GitHub Issues instead. + +## Naming + +Search results and social cards use **Tirith IaC Governance** (the Docusaurus +site title) rather than bare "Tirith", to distinguish the project from the +unrelated Tirith terminal-security tool and the unrelated `tirith` package on +PyPI. Keep that qualifier in any new SEO-facing copy. diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index c18f02dc..c25ddfa0 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -29,7 +29,20 @@ jobs: cd documentation npm ci + # POSTHOG_KEY is optional. Unset -- on a fork, or in a pull-request build -- + # the site ships no analytics script at all, which is the behaviour we want + # everywhere except the published site. - name: Build documentation site + env: + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} + # The Fleet enquiry form. Neither is a secret -- a HubSpot portal id + # and form guid are public by design, since the browser posts to them + # directly -- but they are supplied here rather than committed so a + # fork's build does not point at StackGuardian's CRM. Unset, the form + # disables itself and explains why. + HUBSPOT_PORTAL_ID: ${{ vars.HUBSPOT_PORTAL_ID }} + HUBSPOT_FORM_GUID: ${{ vars.HUBSPOT_FORM_GUID }} run: | cd documentation npm run build diff --git a/ADOPTERS.md b/ADOPTERS.md new file mode 100644 index 00000000..59af588a --- /dev/null +++ b/ADOPTERS.md @@ -0,0 +1,29 @@ +# Adopters + +Organisations and teams using Tirith, listed voluntarily. + +This file is empty on purpose. Nobody has been added without asking, and nobody +will be. A logo on a page that its owner did not agree to is worth less than an +empty list. + +## Adding yourself + +Open a pull request adding a row. Anything you would rather not state, leave +out — the name alone is a perfectly good entry. + +| Organisation | Since | Scale | How it is used | +|---|---|---|---| +| _(your team here)_ | | | | + +- **Scale** is deliberately vague: "a handful of repositories", "~200 + pipelines". No precise counts are wanted. +- **How it is used** is a sentence, not a case study. "Tag and cost policies on + every AWS plan in CI" is ideal. +- Adding yourself here is not an endorsement of StackGuardian, and does not + create any commercial relationship. + +## Quotes + +If you are happy to be quoted on the project's landing page or README, say so +in the same pull request and include the exact wording and the attribution you +want. Nothing is used beyond what you write, and it is removed on request. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..46901dad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# Working on Tirith with a coding agent + +Tirith is an open-source IaC governance tool: it evaluates the Terraform or OpenTofu plan a +pipeline already produces against declarative JSON policies and returns a pass, warning, failure +or unevaluated verdict. + +This file is for an agent working **on this repository**. If you are writing policies in someone +else's repository, the skill at `.claude/skills/tirith-policies/SKILL.md` is the one you want — +it is self-contained and can be copied into any project. + +## Layout + +| Path | What lives there | +|---|---| +| `src/tirith/core/` | The evaluation engine and the condition registry (`EVALUATORS_DICT`) | +| `src/tirith/providers/` | One directory per provider; each turns an input document into values | +| `src/tirith/platform/` | `tirith platform check` — the only surface that talks to a network | +| `src/tirith/tui/` | `tirith ui`, optional `[tui]` extra | +| `src/tirith/mcp/` | `tirith mcp`, optional `[mcp]` extra | +| `src/tirith/tui/examples/` | Worked policy/input pairs, used by the UI, the docs site and the tests | +| `documentation/` | The Docusaurus site | +| `tests/` | pytest | + +## Running things + +```bash +pip install -e . # the CLI +pip install -e '.[tui]' # plus the interactive interface (needs Python 3.9+) +pip install -e '.[mcp]' # plus the MCP server (needs Python 3.10+) + +pytest # the suite +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +The documentation site needs Node 18 or newer: + +```bash +cd documentation && npm ci && npm run build +``` + +## Things that will bite you + +**Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1` Tirith +could not reach a verdict, `2` platform timeout. `3` is deliberately not `1`: a caller has to be +able to page the platform team on one and the change author on the other. Both surfaces fail +closed. `tests/core/test_output_compatibility.py` asserts the `--json` output is byte-identical +to a golden file — if you change the result shape, that test is the conversation. + +**`final_result: null` is not a pass.** It means every check was skipped, so the policy evaluated +nothing. It exits `1`, not `0` and not `3`. + +**Optional extras must degrade, not crash.** `tui` needs Python 3.9 and `mcp` needs 3.10, while +Tirith itself supports 3.8. Both are dispatched before the flat argument parser and both report a +missing extra as an actionable message rather than an ImportError traceback. Keep the SDK imports +inside the subcommand's `cli.py`, never at package import time — the tests rely on importing the +package without the extra installed. + +**Local mode makes no network call.** That is a published governance commitment, not just current +behaviour. Nothing outside `src/tirith/platform/` may open a connection. + +**Adding a condition type** means adding it to `EVALUATORS_DICT`; the MCP server, the docs page +and the skill's list all read from or mirror that registry. Adding a provider `operation_type` to +`terraform_plan` means updating `_TERRAFORM_PLAN_OPS` in `src/tirith/mcp/tools.py` — there is a +drift test that will tell you. + +## Style + +- Comments explain **why**, not what. The existing code is comment-rich in that specific way; + match it rather than stripping it. +- British spelling in prose; American in code identifiers where the ecosystem uses it. +- Commit messages: imperative present tense (`Add …`, not `Added …`), with a body saying why. +- Every behavioural change needs a test that fails without it. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the pull-request process and +[GOVERNANCE.md](GOVERNANCE.md) for which changes need two approvals. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23c3b402..2161ba73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,91 +1,115 @@ -# Contributing to Tirith (StackGuardian Policy Framework) +# Contributing to Tirith -Thank you for taking the time to contribute! 🎉 -Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. +Thank you for taking the time to contribute. Every bit helps, and credit is always given. -The following is a set of guidelines for contributing to Tirith on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. +These are guidelines rather than rules. Use your judgement, and propose changes to this document in +a pull request if something here is wrong or unhelpful. -## Join the StackGuardian Community -We'd love for you to join our community! Join our [Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ) to ask questions, share ideas, and connect with other contributors. Follow us on [LinkedIn](https://www.linkedin.com/company/stackguardian/posts/?feedView=all) to stay updated on the latest news and announcements. +## Where the project lives -## Contribution types +**Everything happens on GitHub, in public.** Bugs, feature requests, policy questions, design +disagreements and help getting a first pipeline gated all go through +[Issues](https://github.com/StackGuardian/tirith/issues/new/choose). There is no private channel +you need to be in to participate, and no account anywhere but GitHub. -### Report Bugs +Public by default is deliberate: a question answered in an issue is findable by the next person +with the same problem. -We use GitHub issues to track bugs at [https://github.com/stackguardian/tirith/issues](https://github.com/stackguardian/tirith/issues). Please use Bug report issue template. +- **How decisions are made** — [GOVERNANCE.md](GOVERNANCE.md) +- **Who maintains what** — [MAINTAINERS.md](MAINTAINERS.md) +- **What is planned** — [ROADMAP.md](ROADMAP.md) +- **Where to ask for help** — [SUPPORT.md](SUPPORT.md) +- **Reporting a vulnerability** — [SECURITY.md](SECURITY.md), which is a private route. Not an + issue. -### Fix Bugs and implement features +The [Code of Conduct](CODE_OF_CONDUCT.md) applies everywhere in the project. -All contributions to solve GitHub issues tagged with "bug", "enhancement" and "help wanted" are most welcome and greatly appreciated. +## Ways to contribute -### Documentation +### Report a bug -Trith could always use more documentation, whether as part of the -official Tirith docs, in docstrings, or even on the web in blog posts, -articles, and such. +[Open a bug report](https://github.com/StackGuardian/tirith/issues/new/choose). A reproduction — +even a rough one — is the difference between a fix this week and a fix eventually. -### Submit Feedback +**Do not include secrets, plan files or private source code.** Redact them, or reduce the case to +a minimal public example. This applies to every issue and every pull request. -Please use GitHub Discussions to submit feedback and engage with community [https://github.com/StackGuardian/feedback/discussions/8](https://github.com/StackGuardian/feedback/discussions/8). +### Request a policy you cannot express -## Basic guidelines +Use the policy request template. Say the rule in plain words, show what you tried, and show what +happened instead. A rule that is awkward to write is usually a missing provider operation rather +than a missing language feature, and that is worth knowing. -### Commits +### Fix bugs and implement features -- Use the imperative, present tense («change», not «changed» or «changes») to be consistent with generated messages from commands like git merge. -- Describe the changes you have made +Issues labelled `bug`, `enhancement` and `help wanted` are all fair game. Issues labelled +[good first issue](https://github.com/StackGuardian/tirith/labels/good%20first%20issue) carry +enough maintainer context and acceptance criteria to start on without asking. -#### Examples -**Good example**: - - Commit Message:\ - `Add feature to calculate total monthly cost for AWS resources` +### Improve the documentation -- Description:\ -`Implement a function that calculates the total monthly cost for AWS resources. Update the documentation to reflect this new feature.` +Docs count as contributions, and are frequently the highest-leverage ones. That includes +docstrings, the documentation site under `documentation/`, and worked policy examples. - Why it’s Good: - > **Clarity**: Clearly states the action and the feature.\ - >**Specificity**: Specifies what the feature is and what it affects (AWS resources).\ - >**Consistency**: Uses imperative, present tense, aligning with best practices. +### Propose a design change +Substantial changes should start as an RFC issue describing the problem, before a pull request +describing the solution — it is cheaper to disagree about an approach in a paragraph than in a +diff. Changes to the policy schema, the CLI contract, the action's behaviour, or what leaves the +machine need two maintainer approvals; see [GOVERNANCE.md](GOVERNANCE.md). -**Bad Example**: -- Commit Message:\ - `Fixed some stuff` -- Description:\ -`Made changes to the code to fix issues. Updated a few things here and there.` - Why it’s Bad: - > **Vague**: Does not explain what was fixed.\ - > **Lacks Detail**: Provides no insight into what "stuff" refers to or how it was changed.\ - > Does not use imperitve present tense. +## Getting an issue assigned -### Pull Requests +Ask on the issue before you start, and a maintainer will assign it. Then: -- **Stay Updated**: Make sure your PR is based on the latest code from the `main` branch. -- **Clear Title and Description**: Try to include a clear, descriptive title and a detailed explanation of your changes. -- **Reference Issues**: If applicable, link to related issues and PRs. -- **Pass Tests**: Ensure all tests pass before submitting your PR. -- **Be Open to Feedback**: We're all here to help each other improve, so please be open to feedback and ready to make adjustments. +- Work on one issue at a time. +- Limit yourself to four `good first issue` items in total; after that, please move on to other + kinds of issue so the easy ones stay available to newcomers. +- If an issue assigned to you goes quiet for about a week, the assignee is removed so somebody else + can pick it up. Say so on the issue if you are still on it — that is enough. -### Creating Issues +## Opening a pull request -- **Search First**: It helps to check if your problem or feature request has already been discussed before opening a new issue. -- **Be Detailed**: When you open a new issue, providing as much detail as possible really helps. Feel free to use our templates for bugs and feature requests. -- **Be Respectful**: Let's all be kind and considerate in our communication. +1. Fork the repository and clone your fork. +2. Create a branch named for the change: `git switch -c fix-equals-evaluator`. +3. Make the change, and **add a test that fails without it**. +4. Run the test suite and the linters. +5. Push, and open a pull request against `main`. -### Solving Issues +What makes a pull request easy to merge: -- Limit yourself to solving a maximum of four `good first issues`. Once you've reached this limit, consider tackling other types of issues. -- Please work on only one issue at a time. -- Please ask for assignee before working, and if there's no update for about a week on a particular issue, we'll remove the assignee. +- **Based on current `main`.** Rebase rather than merging `main` into your branch. +- **A clear title and description.** Say what changed and why; link the issue it closes. +- **Green CI.** +- **Openness to review.** Review comments are about the change, not about you. -Thank you for taking the time to help improve our project! +### Commit messages +Use the imperative, present tense — «change», not «changed» or «changes» — so your messages read +the same way as the ones git generates itself. Describe what the change does, and why if it is not +obvious. +**Good:** -### If you have commit access: +> `Add support for calculating total monthly cost of AWS resources` +> +> Implement a function that sums the monthly cost of the resources an Infracost breakdown would +> create, so a policy can gate on the total. Update the provider documentation. -- Do NOT use git push --force. -- Do NOT commit to other contributor's branches without their consent. -- Use Pull Requests if you are unsure and to suggest changes to other maintainers. +Clear about the action, specific about what it affects, and consistent with the rest of the log. + +**Bad:** + +> `Fixed some stuff` +> +> Made changes to the code to fix issues. Updated a few things here and there. + +Vague, no detail about what "stuff" was, and the wrong tense. + +## If you have commit access + +- Do **not** use `git push --force` on shared branches. +- Do **not** commit to another contributor's branch without their consent. +- Use a pull request when you are unsure, or when suggesting changes to another maintainer's work. + +Thank you for taking the time to help improve Tirith. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..5705de8b --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,111 @@ +# Tirith governance + +Tirith is an Apache-2.0 open-source project governed by its maintainers. +StackGuardian contributes engineering time, infrastructure and production +experience, but using, forking or contributing to Tirith does not require a +StackGuardian account or a commercial relationship. Project decisions are made +in public, through GitHub Issues and pull requests, under the process below. + +## What this project promises + +These commitments hold for every release. If one of them has to change, it +changes in public, in a pull request against this file, with the reasoning +stated. + +- **Local policy evaluation stays usable without a StackGuardian account.** + Running `tirith` against a plan on your own machine or runner will not start + requiring credentials. +- **The open parts stay open.** The policy schema, the providers, the CLI + contract, the action's local mode and the example policy library are + Apache-2.0 and remain so. +- **Local mode sends nothing.** No telemetry, no plans, no source, no results — + unless a future opt-in is added, and then only as an opt-in that is off by + default and documented before it ships. +- **Commercial capabilities are labelled.** Anything that needs a StackGuardian + organisation is called out as such in the docs, on the landing page, in demos + and in release notes. `tirith platform check` is the only such surface today. +- **Breaking changes are versioned and documented**, and CI examples pin a + released tag rather than a moving branch. +- **Community policies get a real home.** Accepted policy contributions live in + a public, tested policy library with clear ownership and licensing. + +## Roles + +**Contributor** — anyone who opens an issue or a pull request. No prior +involvement is expected, and no account anywhere but GitHub is needed. + +**Maintainer** — has commit access and review authority over some area of the +project. Maintainers are listed in [MAINTAINERS.md](MAINTAINERS.md), which also +records what each one looks after. + +There is no third tier. If the project grows enough to need one, it will be +added here first. + +## How decisions get made + +Most changes need one maintainer approval and green CI. That covers bug fixes, +documentation, new policy examples, new conditions and new provider operations. + +Changes that alter a contract need **two** maintainer approvals, from +maintainers who did not author the change: + +- the policy schema, or the meaning of an existing field; +- the CLI's flags, output shape or exit codes; +- the action's inputs, outputs or default behaviour; +- what leaves the machine in either mode; +- anything in this file. + +Where a change is contested, the maintainers seek consensus in the pull request +or issue thread. Consensus means no maintainer sustains an objection — not that +everybody is enthusiastic. If consensus is not reached within two weeks, a +simple majority of maintainers decides, and the reasoning is recorded in the +thread. A maintainer may block a change on the grounds that it breaks one of +the promises above; that objection is resolved by changing the promise first, +in its own pull request, or not at all. + +Substantial changes should start as an issue describing the problem before a +pull request describing the solution. This is a courtesy to the contributor as +much as to the project: it is cheaper to disagree about an approach in a +paragraph than in a diff. + +## Releases + +Any maintainer may cut a release. Releases are tagged, and the tag is what CI +examples and installation instructions point at. A release that changes a +documented contract says so in [CHANGELOG.md](CHANGELOG.md) and in the release +notes, in the plain terms a reader upgrading their pipeline needs. + +## Adding and removing maintainers + +A contributor with a sustained record of good judgement in the project — review +comments as much as commits — may be nominated by any maintainer. The +nomination is an issue; it carries if a majority of maintainers agree and none +sustain an objection. + +Maintainers who have been inactive for six months move to emeritus in +[MAINTAINERS.md](MAINTAINERS.md) and lose commit access, with no implication of +fault; the door back is another nomination. A maintainer may step down at any +time by opening a pull request against that file. Removal for cause — repeated +violation of the [Code of Conduct](CODE_OF_CONDUCT.md), or acting against the +promises above — requires a majority of the remaining maintainers. + +## Relationship to StackGuardian + +StackGuardian employs several of the maintainers, funds the project's +development and operates the optional platform mode that `tirith platform +check` talks to. That relationship is why the project exists and is worth being +plain about. + +What it does not confer: StackGuardian has no reserved seats, no casting vote +and no veto. A StackGuardian-employed maintainer's approval counts the same as +anyone else's, and the two-approval rule above deliberately makes it awkward +for a single employer's engineers to change a contract quietly. Where a change +would benefit the platform at the expense of the local, accountless path, the +promises at the top of this file are the tiebreaker. + +## Conduct + +The [Code of Conduct](CODE_OF_CONDUCT.md) applies to every project space. +Reports go to the maintainers listed in [MAINTAINERS.md](MAINTAINERS.md); +security reports follow [SECURITY.md](SECURITY.md) instead, which is a private +route. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 00000000..c8971bcd --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,46 @@ +# Maintainers + +Who looks after what, and how that list changes. The process is in +[GOVERNANCE.md](GOVERNANCE.md). + +> [!IMPORTANT] +> **This roster was derived from `.github/CODEOWNERS` and the commit history, +> and has not yet been confirmed by the people named.** Every maintainer should +> check their own row — handle, email, and especially the areas of +> responsibility — and correct it in a pull request. Remove this notice once +> the whole table has been confirmed. + +## Current maintainers + +| Name | GitHub | Looks after | +|---|---|---| +| Akshat Tandon | [@Akshat0694](https://github.com/Akshat0694) | Project direction, releases, code owner for the whole tree | +| Arunim Chaudhary | [@arunim2405](https://github.com/arunim2405) | CI, supply-chain and security hardening | +| Rafid Aslam | [@refeed](https://github.com/refeed) | Policy engine, providers, `tirith platform check`, docs | + +## Proposed + +Derived from sustained recent contribution, and listed here rather than above +because the nomination in [GOVERNANCE.md](GOVERNANCE.md) has not been run. + +| Name | GitHub | Contribution area | +|---|---|---| +| Akash S | [@AkashS0510](https://github.com/AkashS0510) | `terraform_plan` provider, `tirith ui`, documentation CI | + +## Emeritus + +Nobody yet. Maintainers who step down or go inactive move here, with no +implication of fault, and are welcome back through the same nomination process. + +## Getting hold of a maintainer + +For anything about the project — a bug, a policy that will not match, a +question about whether Tirith fits your pipeline — open an +[issue](https://github.com/StackGuardian/tirith/issues/new/choose). Public by +default is the point: the answer helps the next person too. + +For a suspected vulnerability, do **not** open an issue. Follow +[SECURITY.md](SECURITY.md), which is a private route. + +Code of Conduct reports go to the maintainers above; if the report concerns a +maintainer, send it to any of the others. diff --git a/README.md b/README.md index 49ad8c96..93c89ef0 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,25 @@ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=StackGuardian_policy-framework&metric=alert_status&token=4a4d06e73940505edb7fc9d27a7f03b35fbbf23d)](https://sonarcloud.io/summary/new_code?id=StackGuardian_policy-framework) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=StackGuardian_policy-framework&metric=sqale_rating&token=4a4d06e73940505edb7fc9d27a7f03b35fbbf23d)](https://sonarcloud.io/summary/new_code?id=StackGuardian_policy-framework) -[![Slack](https://img.shields.io/badge/Slack-4A154B?style=for-the-badge&logo=slack&logoColor=white)](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ) [![codecov](https://codecov.io/gh/StackGuardian/tirith/branch/main/graph/badge.svg)](https://codecov.io/gh/StackGuardian/tirith) -# Tirith — IaC Governance plugin +# Tirith — open-source IaC governance + +Put governance in front of the Terraform or OpenTofu plan your pipeline already produces. Tirith +evaluates readable JSON policies on your own runner, reports the rule, resource and value behind +every verdict, and can stop a non-compliant change before apply. + +**Apache-2.0 · no account · no network in local mode · works with any CI** + +```yaml +- run: terraform show -json tfplan > plan.json +- uses: StackGuardian/tirith-iac-governance-action@v2 + with: {fail-on-error: true} +``` + +[Quick start](#credential-free-quick-start) · [Example policies](#example-tirith-policies) · +[Run it in CI](#run-it-in-ci) · [The interactive interface](#the-interactive-interface) · +[Star the project](https://github.com/StackGuardian/tirith) > [!NOTE] > **New — `tirith ui`, an interactive interface. In beta, and we want your input.** @@ -14,34 +29,55 @@ > Explore a failing evaluation down to the resource that caused it, assemble policies from a > form, and experiment in a playground with worked examples. Try it with > `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then -> `tirith ui` — see -> [The interactive interface](#the-interactive-interface). +> `tirith ui` — see [The interactive interface](#the-interactive-interface). > > It is new, so the rough edges are still being found. Tell us what is confusing, what is > missing, or what you would rather it did: -> [open an issue](https://github.com/StackGuardian/tirith/issues) or say so in -> [Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ). +> [open an issue](https://github.com/StackGuardian/tirith/issues/new/choose). > Nothing about the existing CLI changes: same flags, same `--json` output, same exit codes. -**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. +## What you get from the first run + +- **A verdict on the plan you already generate.** No new job, no change to Terraform, no policy + language to program. Tirith reads the output of `terraform show -json tfplan`. +- **The rule, the resource, the action and the value** behind every pass and every failure — not a + job log that says a job failed. +- **An exit code your pipeline can act on.** `3` means a policy said no; `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. +- **Nothing leaving your machine.** Policies are JSON files in your repository and evaluation + happens on your runner. There is no account, and local mode makes no network call. + +Policies also cover Terraform state, Kubernetes manifests, Infracost breakdowns and arbitrary +JSON — the same schema and the same verdict for each. -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. +## Credential-free quick start -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. +Two lines on GitHub Actions, with policies committed under `.tirith/policies`: + +```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: {fail-on-error: true} +``` + +No credentials anywhere: without them the action evaluates your repository's policy files on the +runner and uploads nothing. GitLab CI and any other container-based CI invoke the CLI directly — +see [Run it in CI](#run-it-in-ci). ## Content +- [What you get from the first run](#what-you-get-from-the-first-run) +- [Credential-free quick start](#credential-free-quick-start) - [What is Tirith?](#what-is-tirith) - [Features](#features) - [Installation](#installation) @@ -51,8 +87,10 @@ verdict, same exit codes. That mode is optional and is the only part that talks - [Builder](#builder) - [Playground](#playground) - [Serving it on a port](#serving-it-on-a-port) +- [Use it with a coding agent](#use-it-with-a-coding-agent) - [Run it in CI](#run-it-in-ci) - [Exit codes](#exit-codes) +- [How Tirith differs from a scanner](#how-tirith-differs-from-a-scanner) - [Evaluating against your StackGuardian organization](#evaluating-against-your-stackguardian-organization) - [Example Tirith policies](#example-tirith-policies) - [error_tolerance](#error_tolerance-and-the-third-outcome) @@ -63,11 +101,8 @@ verdict, same exit codes. That mode is optional and is the only part that talks - [Kubernetes](#kubernetes) - [Getting Started](#getting-started) - [Want to contribute?](#want-to-contribute) - - [Getting an issue assigned](#getting-an-issue-assigned) - - [A bug report](#a-bug-report) - - [Opening a Pull Request and getting it merged](#opening-a-pull-request-and-getting-it-merged) -- [Submitting a feedback](#submitting-a-feedback) - [Support](#support) +- [Project and governance](#project-and-governance) - [License](#license) ## What is Tirith? @@ -110,10 +145,10 @@ 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" +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" ``` -`1.0.5` is the newest tag; `git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists +`1.2.0` 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. @@ -225,8 +260,7 @@ About Tirith: > [!NOTE] > **Beta.** Everything below works and is covered by tests, but the interface is new and the > shape of it is still open. Feedback is genuinely wanted — especially on what is missing. -> [Open an issue](https://github.com/StackGuardian/tirith/issues) or find us in -> [Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ). +> [Open an issue](https://github.com/StackGuardian/tirith/issues/new/choose). `tirith ui` opens a terminal interface with three tabs: an **Explorer** for reading results, a **Builder** for assembling policies, and a **Playground** for experimenting. @@ -347,6 +381,43 @@ behaves identically and there is nothing extra to keep in sync. Bind address and port are yours to choose, but note the served interface can read any file path the serving process can. Keep it on `localhost` unless you have a reason not to. +## Use it with a coding agent + +Ask any agent for "a policy requiring an Owner tag" and it will write plausible JSON against a +schema it is guessing at — usually inventing a `condition.type` that does not exist. That mistake +is expensive because the engine reports an unknown condition type as an ordinary **failed check** +with no error attached: it reads as a real violation, and somebody debugs infrastructure that was +fine. + +Tirith ships an MCP server so the agent reads the real registries and gets a real verdict: + +```bash +pip install 'py-tirith[mcp] @ git+https://github.com/StackGuardian/tirith.git' + +claude mcp add tirith -- tirith mcp +``` + +Four tools, all local — no network call, nothing written to disk: + +| Tool | What it does | +|---|---| +| `evaluate` | Runs a policy against a document and returns the real verdict and exit code | +| `lint_policy` | Catches unknown condition types, a missing `eval_expression`, unreferenced evaluators | +| `describe_provider` | The providers, their `operation_type` values and every condition type, read from the engine's registries | +| `explain_result` | Turns a result document into which rule failed, on which resource, and why | + +Needs Python 3.10 or newer; it is an optional extra so a CI gate stays dependency-light. + +Prefer not to run a server? The vocabulary is just a file: +[`.claude/skills/tirith-policies/SKILL.md`](.claude/skills/tirith-policies/SKILL.md) is +self-contained and can be copied into any repository, +[`AGENTS.md`](AGENTS.md) covers working on Tirith itself, and +[`.cursor/rules/tirith-policies.mdc`](.cursor/rules/tirith-policies.mdc) attaches automatically +in Cursor when a policy file is open. + +One-click install for Cursor and VS Code, and configuration for Claude Desktop and Codex, are on +the [AI page](https://stackguardian.github.io/tirith/ai). + ## Run it in CI ### GitHub Actions @@ -374,7 +445,7 @@ policy: image: python:3.12 needs: [plan] script: - - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5" + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` @@ -412,6 +483,30 @@ One limit worth stating plainly: a *misconfigured* policy — an unsupported `co 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. +## How Tirith differs from a scanner + +Tirith is a policy engine, but its job is not to replace every scanner or policy language. It turns +the plan your pipeline already produces and the policies you choose into one enforceable decision +before apply. The comparison below is meant to be fair rather than flattering — pick whichever of +these fits the job. + +| | Primary job | Authoring | Runtime | Where it is strong | +|---|---|---|---|---| +| **Tirith** | The governance gate between plan and apply | JSON declarative policy plus providers | Local runner; optional central platform | Adoption from one repository outward, verdict semantics, an optional path to centrally governed execution | +| **Checkov** | Broad IaC scanning | Large built-in library; Python/YAML custom policies | CLI/CI; optional platform | Breadth, graph checks, many IaC formats, established checks | +| **OPA / Rego** | General-purpose policy decision engine | Rego | Embedded, CLI, service or platform integration | Expressiveness, portability, a mature policy ecosystem | +| **Sentinel** | Policy as code for HashiCorp integrations | Sentinel language | Sentinel-enabled products and CLI | Terraform/HCP integration, enforcement levels, testing | + +Two things worth saying plainly. Checkov already scans Terraform plan JSON and has far broader +built-in coverage than Tirith; OPA and Sentinel are mature and more expressive than a JSON schema +can be. Tirith did not invent plan-time policy. + +Where it earns its place is the shape of the result and the cost of adopting it: policies are data +rather than programs, the same policy and exit-code contract works on a laptop and in every CI +system, and a check that could not run is reported as `1` rather than quietly passing. In platform +mode, Tirith rules and Checkov findings come back as one verdict instead of two tools to reconcile +by hand. + ## Evaluating against your StackGuardian organization `tirith platform check` evaluates against the policies your StackGuardian organization enforces, @@ -1480,50 +1575,61 @@ Final expression used: ## Want to contribute? -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 - -Go to the Tirith Repository and in the issues tab describe any bug or feature you want to add. If found relevant, the maintainers will assign the issue to you and you may start working on it as mentioned in the next section. - -

The kinds of issues a contributor can open:

- +Contributions are welcome, and the project is run in public: bugs, feature proposals and +disagreements about design all go through GitHub. Start with +[CONTRIBUTING.md](./CONTRIBUTING.md). -### A bug report +- **Report a bug or request a policy** — + [open an issue](https://github.com/StackGuardian/tirith/issues/new/choose) and pick the template + that fits. No secrets, plan files or private source in the issue, please. +- **Pick something up** — issues labelled + [good first issue](https://github.com/StackGuardian/tirith/labels/good%20first%20issue) carry + enough context to start on. Ask to be assigned before you begin, and take one at a time. +- **Propose a design change** — open an RFC issue before writing the pull request. It is cheaper to + disagree about an approach in a paragraph than in a diff, and changes to the policy schema, the + CLI contract or what leaves the machine need two maintainer approvals either way. -Head over to the Tirith repository and in the issues tab describe the bug you encountered and we will be happy to take a look into it. +### Opening a pull request -### Opening a Pull Request and getting it merged? +1. Fork the repository and create a branch named for the change + (`git switch -c fix-equals-evaluator`). +2. Make the change, and add a test that fails without it. +3. Run the test suite and the linters. +4. Push and open a pull request against `main`, linking the issue it closes. -1. Go to the repository and fork it. -2. Clone the repository in your local machine. -3. Open your terminal and `cd tirith` -4. Create your own branch to work on the changes you intend to perform. For e.g. if you want some changes or bug fix to any function in the evaluators, name your branch with something relevant like, `git branch bug-fix-equals-evaluator` -5. After necessary changes, `git push --set-upstream origin bug-fix-equals-evaluator`, `git checkout main` and `git merge bug-fix-equals-evaluator` or use the GUI to create a "Pull Request" after pushing it in the respective branch. -6. A review request will be sent to the repository maintainers and your changes will be merged if found relevant. +A maintainer will review it. Approval rules and how contested changes are decided are in +[GOVERNANCE.md](./GOVERNANCE.md). -## Submitting a Feedback +## Support -Wanna submit a feedback? It's as simple as writing and posting it in the feedback section. +**[GitHub Issues](https://github.com/StackGuardian/tirith/issues/new/choose) is the support +channel** — for bugs, policy authoring questions, and help getting a first pipeline gated. Public +by default is deliberate: a question answered in an issue is findable by the next person with the +same problem. [SUPPORT.md](./SUPPORT.md) says which template to use. -

Your feedback will help us improve

+Suspected vulnerabilities go through the private route in [SECURITY.md](./SECURITY.md), not through +issues. -## Maintainers +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. You never need it for local mode. -This project is maintained by [StackGuardian](https://www.linkedin.com/company/stackguardian/). +## Project and governance -## Support +Tirith is an Apache-2.0 project governed by its maintainers. StackGuardian contributes engineering +time, infrastructure and production experience; using, forking or contributing to Tirith requires +no StackGuardian account and no commercial relationship. -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. +| | | +|---|---| +| [GOVERNANCE.md](./GOVERNANCE.md) | How decisions are made, what the project commits to, and the relationship to StackGuardian | +| [MAINTAINERS.md](./MAINTAINERS.md) | Who maintains what, and how that changes | +| [ROADMAP.md](./ROADMAP.md) | Now / next / later, and what is deliberately not planned | +| [SECURITY.md](./SECURITY.md) | Supported versions and the private vulnerability route | +| [SUPPORT.md](./SUPPORT.md) | Where to ask, and the community/commercial boundary | +| [CONTRIBUTING.md](./CONTRIBUTING.md) | How to contribute | +| [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) | Expected conduct, and how to report | +| [ADOPTERS.md](./ADOPTERS.md) | Add your team, if you would like to | ## License diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..694aefc8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,76 @@ +# Roadmap + +Themes, not dates. This file says what the maintainers intend to work on and in +roughly what order; it is not a commitment, and anything here can be argued +with in an [issue](https://github.com/StackGuardian/tirith/issues). + +Everything under **Now** and **Next** is open source and works without an +account. Where an item involves the optional StackGuardian platform, it is +labelled — and labelled *planned*, not shipped. + +> [!NOTE] +> **Draft.** These themes were assembled from the launch messaging brief and +> the current state of the repository. Maintainers should confirm, reorder and +> cut before this is treated as the project's position. + +## Now + +Work in progress or next up. + +- **Remove first-run friction.** An unambiguous installation story is the + biggest one: Tirith is not on PyPI, `pip install tirith` installs an + unrelated project, and the git-URL install is a stumbling block for anyone + evaluating quickly. Publish and own a package name, or make the git route + impossible to get wrong. +- **A credential-free demo path.** The existing four-PR demo requires a + StackGuardian organisation and token, so it demonstrates platform mode. Add a + local-mode quick start that reaches the same first verdict with nothing but a + repository. +- **`tirith ui` out of beta.** The explorer, builder and playground shipped + recently and the rough edges are still being found. Feedback is wanted. +- **Community foundations.** Governance, maintainers, support and security + documented; issue templates for the paths people actually arrive on; a set of + genuinely scoped good-first issues. + +## Next + +Intended, not started. + +- **A policy test harness.** Today a policy that silently matches nothing is + hard to distinguish from a policy that passed. Authors need a way to assert + that a rule matched what they meant it to match — this is the gap most likely + to cost someone real trust in a green check. +- **GitLab CI support that is as good as the GitHub Action.** Right now GitLab + users invoke the CLI directly and get no native reporting. Either a catalog + component or an honest statement that the CLI is the supported route. +- **A published, tested policy library.** Community-contributed policies with + clear ownership, licensing and tests, rather than examples pasted from docs. +- **Better provider coverage and clearer provider errors**, driven by what + people actually report. +- **A fair, reproducible comparison** with Checkov, OPA/Rego and Sentinel — + identical policies in a benchmark repository, published including the cases + where another tool is the better fit. + +## Later + +Directional. No design work has been done, and any of it may be dropped. + +- **Richer remediation output** — showing the smallest compliant change, not + just the failing value. +- **Policy versioning and deprecation** as policy sets grow past what one + person holds in their head. +- **Governed execution via the StackGuardian workflow API** *(planned; platform + mode)* — moving from a policy decision into controlled execution. This does + not exist today and should not be described as if it does. + +## Not planned + +Stated so nobody spends effort proposing them: + +- **Replacing scanners.** Tirith is the governance step between plan and apply, + not a catalogue of built-in security checks. Checkov's breadth is real and we + are not trying to reproduce it. +- **A second policy language.** Policies are JSON data. If a rule needs a + program, that is a signal the provider is missing an operation, not that + Tirith needs an expression language. +- **Network calls in local mode.** See [GOVERNANCE.md](GOVERNANCE.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8cdf883f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,91 @@ +# Security policy + +## Supported versions + +Tirith is released as git tags; there is no long-term support branch. Security +fixes land on `main` and are released as a new tag. + +| Version | Supported | +|---|---| +| Latest tag | Yes | +| Anything older | No — upgrade to the latest tag | + +CI examples in this repository pin a tag rather than tracking `main`, so +upgrading is a one-line change and a deliberate one. Check what you are pinned +to with `git ls-remote --tags https://github.com/StackGuardian/tirith.git`. + +## Reporting a vulnerability + +**Do not open a public issue for a suspected vulnerability.** + +Use GitHub's private reporting: +[Report a vulnerability](https://github.com/StackGuardian/tirith/security/advisories/new). +It creates a private advisory visible only to you and the maintainers, and it +is the fastest route. + +> [!NOTE] +> **Confirm before launch:** private vulnerability reporting must be enabled in +> the repository's Settings → Security before the link above works, and a +> monitored security contact address should be added here as a fallback for +> reporters who cannot use GitHub. Both are open items. + +A useful report includes what you did, what happened, what you expected, the +Tirith version or commit, and — if you have one — a minimal reproduction. +Please do not include real secrets, plan files or private source in a report; +redact them, the same way the issue templates ask. + +### What to expect + +- **Acknowledgement within three working days.** If you have not heard back, + assume the message was lost rather than ignored, and ping any maintainer in + [MAINTAINERS.md](MAINTAINERS.md) — without disclosing details publicly. +- **An assessment within ten working days**, saying whether we consider it a + vulnerability and, if so, roughly when a fix will land. +- **Credit in the advisory and release notes**, unless you would rather not be + named. + +We will not take legal action against anyone reporting in good faith, and we +ask the same courtesy in return: give us a reasonable window to ship a fix +before disclosing publicly. + +## What counts + +Things we want to hear about: + +- Anything that causes Tirith to report a **pass for a policy that should have + failed**, or to exit `0` when the verdict was never established. A gate that + silently stops gating is the worst failure this project has. +- **A sensitive value surviving masking** in `tirith platform check` — a + terraform-sensitive value, or a credential-shaped literal, reaching the + network unredacted. +- Code execution, path traversal or file disclosure triggered by a crafted + policy file, plan document or provider input. +- Credential leakage into logs, PR comments, check-run output or the result + document. +- Dependency vulnerabilities that Tirith actually reaches. + +## Known limits, already documented + +These are stated behaviour rather than vulnerabilities. Reporting them is +welcome as a documentation or design issue, but they are not secret: + +- **`json` and `kubernetes` documents are not masked.** There is no schema that + says which fields are secret. +- **Committed source ships as written** in platform mode. 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 sensitivity markers are 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. +- **A misconfigured policy fails closed but reads as a violation** — an + unsupported `condition.type` or unknown `required_provider` comes back as an + ordinary failed check and exits `3`, pointing at your infrastructure when the + fault is in the policy. + +The full detail is in the +[platform-check documentation](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/) +and the [exit-code contract](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/). + +Local mode makes no network call at all, which bounds a great deal of this: if +you never pass credentials, nothing leaves your runner. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..9c4682fc --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,58 @@ +# Getting help + +**GitHub Issues is the support channel for Tirith.** +[Open one](https://github.com/StackGuardian/tirith/issues/new/choose). + +Public by default is deliberate. A question answered in an issue is findable by +the next person with the same problem; the same question answered in a private +channel is answered once. + +## Before you open one + +- Check the [documentation](https://stackguardian.github.io/tirith/) — the + [exit-code contract](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/) + in particular, if a job is red and you are not sure why. +- Search [existing issues](https://github.com/StackGuardian/tirith/issues?q=is%3Aissue). +- Try `tirith ui` if you are debugging a policy: the explorer walks a failing + evaluation down to the resource that caused it, which is usually faster than + reading raw JSON. + +## Which template + +| You have | Use | +|---|---| +| Something is broken | Bug report | +| A rule you cannot express | Policy request | +| Tirith will not slot into your CI | Integration help | +| A first pipeline to set up | Help me govern my first IaC pipeline | +| A design change to propose | Proposal / RFC | + +Whatever you open: **no secrets, no plan files, no private source code.** +Redact, or reduce it to a minimal public example. + +## What you can expect + +Maintainers are volunteers and StackGuardian engineers, not a support rota. +Issues are usually acknowledged within a few working days. An issue with a +reproduction gets looked at sooner than one without, which is not a rule so +much as an inevitability. + +## Community versus commercial support + +Everything in this repository — the CLI, the providers, the policy schema, the +GitHub Action's local mode, the example policies — is Apache-2.0 and supported +here, by the maintainers, for free. + +[StackGuardian](https://www.stackguardian.io/) sells a platform that Tirith can +optionally talk to (`tirith platform check`), and sells commercial support for +it. That is a separate relationship with a separate contract. You never need it +to use Tirith: local mode needs no account, and questions about local mode +belong here, in issues, regardless of whether you are a StackGuardian customer. + +If your question is specifically about a StackGuardian organisation, its +policies, or a platform-mode run, StackGuardian's own support is the faster +route. + +## Security + +Not here — see [SECURITY.md](SECURITY.md), which is a private route. diff --git a/documentation/docs/getting-started-with-tirith.md b/documentation/docs/getting-started-with-tirith.md index f57a5de9..ea5c59c7 100644 --- a/documentation/docs/getting-started-with-tirith.md +++ b/documentation/docs/getting-started-with-tirith.md @@ -21,9 +21,8 @@ experiment in a playground with worked examples. Install it with [the interactive interface](tirith-usage/interactive-interface.md). It is new, so the rough edges are still being found. Tell us what is confusing, what is missing, or -what you would rather it did: [open an issue](https://github.com/StackGuardian/tirith/issues) or say -so in -[Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ). +what you would rather it did: +[open an issue](https://github.com/StackGuardian/tirith/issues/new/choose). Nothing about the existing CLI changes. ::: diff --git a/documentation/docs/tirith-usage/ci-integration.md b/documentation/docs/tirith-usage/ci-integration.md index 68cab83c..b57a36dd 100644 --- a/documentation/docs/tirith-usage/ci-integration.md +++ b/documentation/docs/tirith-usage/ci-integration.md @@ -107,13 +107,13 @@ policy: image: python:3.12 needs: [plan] script: - - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5" + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" - 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; +behaviour underneath you. `1.2.0` 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 @@ -126,7 +126,7 @@ policy: 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" + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" - tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error ``` @@ -138,7 +138,7 @@ Nothing above is GitLab-specific: any runner that can execute a container and pr 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"`; +2. `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"`; 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. diff --git a/documentation/docs/tirith-usage/interactive-interface.md b/documentation/docs/tirith-usage/interactive-interface.md index df9425b7..af0cd04b 100644 --- a/documentation/docs/tirith-usage/interactive-interface.md +++ b/documentation/docs/tirith-usage/interactive-interface.md @@ -17,8 +17,7 @@ slug: interactive-interface/ interface is still open, and the rough edges are still being found. Tell us what is confusing, what is missing, or what you would rather it did: -[open an issue](https://github.com/StackGuardian/tirith/issues) or say so in -[Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ). +[open an issue](https://github.com/StackGuardian/tirith/issues/new/choose). Nothing about the existing CLI changes: same flags, same `--json` output, same exit codes. ::: diff --git a/documentation/docusaurus.config.js b/documentation/docusaurus.config.js index 161a0d67..20a1556e 100644 --- a/documentation/docusaurus.config.js +++ b/documentation/docusaurus.config.js @@ -1,8 +1,33 @@ import {themes as prismThemes} from 'prism-react-renderer'; +/* + * PostHog is configured entirely from the environment, and nothing is + * committed. An unset key means the client module never loads the script and + * src/lib/analytics.js no-ops -- which is the correct behaviour for a local + * `docusaurus start`, a fork's build and a contributor's checkout. + */ +const posthogKey = process.env.POSTHOG_KEY || ''; +const posthogHost = process.env.POSTHOG_HOST || 'https://eu.i.posthog.com'; + +/* + * The Fleet enquiry form posts straight to HubSpot from the browser, which is + * what that endpoint is for -- a static site needs no backend. Both values are + * environment-supplied: unset, the form disables itself and says why, rather + * than posting into the void. + */ +const hubspotPortalId = process.env.HUBSPOT_PORTAL_ID || ''; +const hubspotFormGuid = process.env.HUBSPOT_FORM_GUID || ''; + +const repo = 'https://github.com/StackGuardian/tirith'; + /** @type {import('@docusaurus/types').Config} */ const config = { - title: 'Tirith', + // Named `Tirith IaC Governance` rather than `Tirith` so that search results + // and social cards distinguish this project from the unrelated Tirith + // terminal-security tool and the unrelated `tirith` package on PyPI. The + // navbar still reads `Tirith`, which is what the project is called. + title: 'Tirith IaC Governance', + tagline: 'Put governance in front of every Terraform or OpenTofu plan.', favicon: 'img/tirith.png', // Set the production url of your site here url: 'https://stackguardian.github.io', @@ -22,6 +47,15 @@ const config = { locales: ['en'], }, + customFields: { + posthogKey, + posthogHost, + hubspotPortalId, + hubspotFormGuid, + }, + + clientModules: ['./src/clientModules/posthog.js'], + presets: [ [ 'classic', @@ -40,17 +74,21 @@ const config = { themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({ - // Replace with your project's social card - image: 'img/docusaurus-social-card.jpg', + image: 'img/tirith.png', navbar: { title: 'Tirith', - hideOnScroll: true, + 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. + // reader clicking a site's own logo expects. + // + // The mark is Tirith's own, not StackGuardian's. This is an Apache-2.0 + // project that works with no account and no vendor relationship, and + // flying the sponsor's logo as the page logo argues the opposite + // before a word is read. StackGuardian is credited in the footer, and + // its blue survives as the single accent colour. logo: { - alt: 'StackGuardian logo', - src: 'img/sg-icon.png', + alt: 'Tirith', + src: 'img/tirith.png', }, items: [ { @@ -59,17 +97,75 @@ const config = { position: 'left', label: 'Docs', }, + {to: '/learn', label: 'Learn', position: 'left'}, + {to: '/playground', label: 'Playground', position: 'left'}, + {to: '/policies', label: 'Policies', position: 'left'}, + {to: '/ai', label: 'AI', position: 'left'}, + {to: '/traction', label: 'Traction', position: 'left'}, + // Last in the left group, after Docs: the commercial route is + // reachable but sits at the end of the OSS surfaces rather than + // beside the primary action. `Star on GitHub` stays alone on the + // right, so nothing competes with it. + {to: '/fleet', label: 'Fleet governance', position: 'left'}, { - href: 'https://tirith-policy-builder.vercel.app/', - label: 'Policy Builder', + href: repo, + label: 'Star on GitHub', position: 'right', }, + ], + }, + footer: { + style: 'light', + links: [ { - href: 'https://github.com/StackGuardian/tirith', - label: 'GitHub', - position: 'right', + title: 'Use it', + items: [ + {label: 'Documentation', to: '/docs/getting-started-with-tirith/'}, + {label: 'CI integration', to: '/docs/tirith-usage/ci-integration/'}, + {label: 'Exit codes', to: '/docs/tirith-usage/exit-codes/'}, + {label: 'Platform mode (optional)', to: '/docs/tirith-usage/platform-check/'}, + ], + }, + { + title: 'Explore', + items: [ + {label: 'Learn', to: '/learn'}, + {label: 'Playground', to: '/playground'}, + {label: 'Policies', to: '/policies'}, + {label: 'AI and MCP', to: '/ai'}, + {label: 'Traction', to: '/traction'}, + {label: 'Fleet governance', to: '/fleet'}, + ], + }, + { + title: 'Policies', + items: [ + {label: 'Policy reference', to: '/docs/tirith-policies/tirith-policy-reference/'}, + {label: 'Worked examples', to: '/docs/tirith-policies/tirith-policy-examples/'}, + {label: 'Providers', to: '/docs/tirith-providers/providers-overview/'}, + // The builder is now a tab inside the Playground rather than a + // separate destination, so this points there instead of leaving + // the site. People search the footer for the word "builder". + {label: 'Policy builder', to: '/playground'}, + ], + }, + { + title: 'Project', + items: [ + {label: 'GitHub', href: repo}, + {label: 'Issues', href: `${repo}/issues`}, + {label: 'Contributing', href: `${repo}/blob/main/CONTRIBUTING.md`}, + {label: 'Governance', href: `${repo}/blob/main/GOVERNANCE.md`}, + {label: 'Maintainers', href: `${repo}/blob/main/MAINTAINERS.md`}, + {label: 'Security', href: `${repo}/blob/main/SECURITY.md`}, + {label: 'Roadmap', href: `${repo}/blob/main/ROADMAP.md`}, + {label: 'License', href: `${repo}/blob/main/LICENSE`}, + ], }, ], + copyright: + 'Tirith is Apache-2.0, maintained by community contributors with engineering support ' + + 'from StackGuardian.', }, prism: { theme: prismThemes.github, @@ -79,7 +175,3 @@ const config = { }; export default config; - - - - diff --git a/documentation/scripts/find-dashes.py b/documentation/scripts/find-dashes.py new file mode 100644 index 00000000..223bf1d7 --- /dev/null +++ b/documentation/scripts/find-dashes.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Find em dashes in anything that reaches a reader. + +Written because two hand audits both missed real occurrences. The first looked only at +src/pages/*.js and missed the shared components; the second missed that a single dash inside a +component is multiplied by every item it renders -- two in the verdict renderer became 56 on the +policies page. Counting sources is not the same as counting what ships. + +So this checks both: the authored files, and the built HTML. Run it after a build. + + python3 documentation/scripts/find-dashes.py # source + build + python3 documentation/scripts/find-dashes.py --built # only what ships + +Exits non-zero when the built site contains any, which makes it usable as a CI gate. +""" + +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DOCS = os.path.dirname(HERE) + +# En dashes are deliberately not flagged. In `1-10` or `lessons 1-3` an en dash is correct +# typography for a numeric range, not a stylistic tic, and replacing it with a hyphen would be +# a downgrade. +EM = "—" + +SOURCE_DIRS = [("src", (".js", ".json", ".css")), ("docs", (".md",))] +SOURCE_FILES = ["docusaurus.config.js"] + + +def scan_source(): + rows = [] + for rel, exts in SOURCE_DIRS: + root = os.path.join(DOCS, rel) + for dirpath, _, names in os.walk(root): + for name in sorted(names): + if not name.endswith(exts): + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8") as handle: + for number, line in enumerate(handle, 1): + if EM in line: + rows.append((os.path.relpath(path, DOCS), number, line.strip())) + for name in SOURCE_FILES: + path = os.path.join(DOCS, name) + if not os.path.exists(path): + continue + with open(path, encoding="utf-8") as handle: + for number, line in enumerate(handle, 1): + if EM in line: + rows.append((name, number, line.strip())) + return rows + + +def scan_built(): + """ + What a visitor receives. Counts per page, because one dash in a shared component shows up + once per rendered item and that multiplication is the thing worth seeing. + """ + build = os.path.join(DOCS, "build") + if not os.path.isdir(build): + return None + counts = {} + for dirpath, _, names in os.walk(build): + for name in names: + if name != "index.html": + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8") as handle: + found = handle.read().count(EM) + if found: + counts[os.path.relpath(path, build)] = found + return counts + + +def main(): + only_built = "--built" in sys.argv + + if not only_built: + rows = scan_source() + by_file = {} + for path, number, line in rows: + by_file.setdefault(path, []).append((number, line)) + print(f"SOURCE: {len(rows)} em dash(es) in {len(by_file)} file(s)") + for path in sorted(by_file): + print(f"\n {path} ({len(by_file[path])})") + for number, line in by_file[path][:40]: + print(f" {number:>5} {line[:110]}") + + counts = scan_built() + if counts is None: + print("\nBUILT: no build/ directory; run `npm run build` first.") + return 0 + total = sum(counts.values()) + print(f"\nBUILT: {total} em dash(es) reaching readers, across {len(counts)} page(s)") + for path in sorted(counts, key=lambda p: -counts[p]): + print(f" {counts[path]:>5} /{os.path.dirname(path) or ''}") + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/documentation/scripts/generate-fixtures.py b/documentation/scripts/generate-fixtures.py new file mode 100644 index 00000000..a5622890 --- /dev/null +++ b/documentation/scripts/generate-fixtures.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Generate the fixture data the Learn and Playground pages render. + +Both pages show real Tirith verdicts rather than hand-written ones. This script +runs the actual engine over the worked examples that ship with `tirith ui` +(`src/tirith/tui/examples/`) and writes the results to +`documentation/src/data/fixtures.json`, which the site imports at build time. + +Doing it here rather than in the browser is a deliberate trade. Tirith is +Python; evaluating in the browser means shipping a Python runtime to every +visitor. Precomputing keeps the pages fast and keeps the promise that nothing +a visitor brings is uploaded -- there is no evaluation endpoint to upload to. +The cost is that an edited policy cannot be re-evaluated on the page, and the +Playground says so plainly rather than pretending otherwise. + +Regenerate after changing any example, or after a change to the engine that +alters result shape: + + python3 documentation/scripts/generate-fixtures.py + +The output is committed, so a docs build never needs Python. +""" + +import json +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.join(REPO_ROOT, "src")) + +from tirith.core.core import start_policy_evaluation_from_dict # noqa: E402 +from tirith.tui.examples import load_examples # noqa: E402 + +OUT_PATH = os.path.join(REPO_ROOT, "documentation", "src", "data", "fixtures.json") + + +def exit_code_for(result): + """ + Mirror the CLI's --fail-on-error contract, from docs/tirith-usage/exit-codes.md. + + Kept as a small local function rather than imported because the CLI decides + this while also handling flags and IO; what the page needs is only the + mapping from a tri-state final_result to the code a pipeline would see. + """ + if "final_result" not in result: + return 1, "The policy could not be evaluated at all." + final = result.get("final_result") + if final is True: + return 0, "Every check that ran passed." + if final is False: + return 3, "A check ran and failed. With --fail-on-error this stops the job." + return 1, "Every check was skipped, so the policy evaluated nothing. This is not a pass." + + +def main(): + payload = { + "_comment": ( + "Generated by documentation/scripts/generate-fixtures.py. Do not edit by hand. " + "Every result is the real output of the Tirith engine." + ), + "examples": _tui_examples(), + "pack": _starter_pack(), + } + + with open(OUT_PATH, "w") as handle: + json.dump(payload, handle, indent=2, sort_keys=False) + handle.write("\n") + + print(f"wrote {OUT_PATH}") + print(f" examples (bundled with `tirith ui`): {len(payload['examples'])}") + for entry in payload["examples"]: + print(f" {entry['key']:<26} exit={entry['exitCode']}") + print(f" pack (examples/ starter policies): {len(payload['pack'])}") + for entry in payload["pack"]: + clean = entry["clean"]["exitCode"] if entry.get("clean") else "-" + print(f" {entry['key']:<34} fails={entry['exitCode']} clean={clean}") + + +def _tui_examples(): + """ + The worked examples bundled with `tirith ui`. Editable in the Playground, and the source the + Learn lesson is written against. + """ + examples = load_examples() + if not examples: + raise SystemExit("no examples found -- has src/tirith/tui/examples/ moved?") + + out = [] + for example in examples: + result = start_policy_evaluation_from_dict(example.policy, example.input_document) + code, meaning = exit_code_for(result) + out.append( + { + "key": example.key, + "title": example.title, + "summary": example.summary, + "about": example.about, + "policy": example.policy, + "input": example.input_document, + "result": result, + "exitCode": code, + "exitMeaning": meaning, + } + ) + return out + + +def _starter_pack(): + """ + The starter policy pack in examples/ -- policies meant to be copied into a real repository. + + Each one is evaluated twice, which is the whole point of the pack: against the fixture built + to trip it, and against a clean plan. A policy only ever seen failing might be firing on + everything; one only ever seen passing might be matching nothing. Both runs are recorded so + the site can show either, and so a false positive on the clean plan cannot pass unnoticed. + """ + policies_dir = os.path.join(REPO_ROOT, "examples", "policies") + inputs_dir = os.path.join(REPO_ROOT, "examples", "inputs") + if not os.path.isdir(policies_dir): + print(f"note: {policies_dir} is absent; the starter pack will be empty") + return [] + + clean_path = os.path.join(inputs_dir, "00-clean-plan.passes.json") + clean_plan = None + if os.path.exists(clean_path): + with open(clean_path) as handle: + clean_plan = json.load(handle) + + out = [] + for name in sorted(os.listdir(policies_dir)): + if not name.endswith(".json"): + continue + key = name[: -len(".json")] + with open(os.path.join(policies_dir, name)) as handle: + policy = json.load(handle) + + fails_path = os.path.join(inputs_dir, f"{key}.fails.json") + if not os.path.exists(fails_path): + print(f"note: {key} has no .fails.json fixture; skipping") + continue + with open(fails_path) as handle: + document = json.load(handle) + + meta = policy.get("meta", {}) + provider = meta.get("required_provider", "") + evaluators = policy.get("evaluators", []) or [] + operations = sorted( + { + (e.get("provider_args") or {}).get("operation_type") + for e in evaluators + if (e.get("provider_args") or {}).get("operation_type") + } + ) + + result = start_policy_evaluation_from_dict(policy, document) + code, meaning = exit_code_for(result) + + entry = { + "key": key, + "title": meta.get("name") or key, + "severity": meta.get("severity"), + "provider": provider, + "operations": operations, + "evaluatorCount": len(evaluators), + "policy": policy, + "input": document, + "result": result, + "exitCode": code, + "exitMeaning": meaning, + } + + # Only plan-reading policies can be run against the shared clean plan; a cost or JSON + # policy would be evaluated against the wrong document shape and report a meaningless + # verdict, which is worse than reporting nothing. + if clean_plan is not None and provider.endswith("terraform_plan"): + clean_result = start_policy_evaluation_from_dict(policy, clean_plan) + clean_code, clean_meaning = exit_code_for(clean_result) + entry["clean"] = { + "exitCode": clean_code, + "outcome": "passed" if clean_code == 0 else "not clean", + "meaning": clean_meaning, + } + + out.append(entry) + return out + + +if __name__ == "__main__": + main() diff --git a/documentation/src/analytics.js b/documentation/src/analytics.js new file mode 100644 index 00000000..b32865c3 --- /dev/null +++ b/documentation/src/analytics.js @@ -0,0 +1,108 @@ +import {useEffect} from 'react'; + +/** + * The one place the landing page talks to analytics. + * + * Every call goes through capture() rather than touching window.posthog + * directly, so that: + * + * - a page with no key configured is silent rather than broken. PostHog is + * only loaded when POSTHOG_KEY is set at build time (see + * src/clientModules/posthog.js), which it is not for a local `docusaurus + * start`, a fork's build, or anyone running the docs offline; + * - the event vocabulary is auditable from a single file. The properties + * below are deliberately coarse -- a CI system name, a mode, a stage. + * Nothing here may carry a repository name, a plan, policy text, source + * code or a token, because this is a public page and none of that is ours + * to collect. + * + * Tirith's own local mode is unrelated to any of this and stays networkless. + */ + +export const EVENTS = { + // Landing + heroStar: 'hero_star_click', + quickstart: 'quickstart_click', + installCopy: 'install_copy', + demoPr: 'demo_pr_open', + policyExample: 'policy_example_open', + firstPlan: 'first_plan_self_report', + helpIssue: 'help_issue_open', + platformInterest: 'platform_interest', + + // Learn + learnStart: 'learn_start', + lessonStart: 'lesson_start', + lessonComplete: 'lesson_complete', + courseComplete: 'course_complete', + learnToPlayground: 'learn_to_playground', + learnToQuickstart: 'learn_to_quickstart', + + // Playground + playgroundOpen: 'playground_open', + fixtureSelect: 'fixture_select', + templateSelect: 'template_select', + evaluationRun: 'evaluation_run', + evaluationOutcome: 'evaluation_outcome', + policyExport: 'policy_export', + snippetCopy: 'snippet_copy', + playgroundToRepo: 'playground_to_repo', + playgroundToFleet: 'playground_to_fleet', + // Not in the brief's list: the builder became a tab on this page, and a tab + // nobody opens is worth knowing about. + builderOpen: 'builder_open', + + // Fleet + fleetView: 'fleet_view', + offerCta: 'offer_cta_click', + fleetCapabilityExpand: 'fleet_capability_expand', + fleetFormStart: 'fleet_form_start', + fleetFormSubmit: 'fleet_form_submit', + fleetToOss: 'fleet_to_oss', + + // AI + aiView: 'ai_view', + mcpInstall: 'mcp_install_click', + skillCopy: 'skill_file_copy', + sgMcpInterest: 'sg_mcp_interest', + + // Traction + tractionView: 'traction_view', + metricSourceOpen: 'metric_source_open', + starFromTraction: 'star_from_traction', + adopterIssueOpen: 'adopter_issue_open', + contributionCta: 'contribution_cta_click', +}; + +export function capture(event, properties = {}) { + if (typeof window === 'undefined') return; + const posthog = window.posthog; + if (!posthog || typeof posthog.capture !== 'function') return; + try { + posthog.capture(event, properties); + } catch { + // Analytics must never take the page down with it. + } +} + +/** + * Returns an onClick handler. Written as a factory because nearly every call + * site is a link whose only job is to fire one event and then behave like a + * link -- no preventDefault, no navigation of its own. + */ +export function track(event, properties) { + return () => capture(event, properties); +} + +/** + * Fire once when a page mounts. Written as a hook so a page can say what it is + * in one line, and so the effect's empty dependency list lives in exactly one + * place rather than being re-derived (and occasionally got wrong) per page. + */ +export function usePageView(event, properties) { + useEffect(() => { + capture(event, properties); + // Mount only: a page view is not re-fired when props change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/documentation/src/clientModules/posthog.js b/documentation/src/clientModules/posthog.js new file mode 100644 index 00000000..a8d66104 --- /dev/null +++ b/documentation/src/clientModules/posthog.js @@ -0,0 +1,34 @@ +/** + * Loads PostHog, and only if a key was supplied at build time. + * + * The key is read from siteConfig.customFields, which docusaurus.config.js + * fills from the POSTHOG_KEY environment variable. Nothing is committed: an + * unconfigured build -- a local `docusaurus start`, a fork, a contributor's + * checkout -- ships no tracking script at all, and src/lib/analytics.js + * degrades to a no-op on its own. + */ + +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import siteConfig from '@generated/docusaurus.config'; + +if (ExecutionEnvironment.canUseDOM) { + const {posthogKey, posthogHost} = siteConfig.customFields || {}; + + if (posthogKey) { + // The official snippet, transcribed rather than depended on, so the docs + // build does not grow a package for eight events. + /* eslint-disable */ + !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n + TODO + {children} + + ); +} + +/** + * A reserved space for an asset that does not exist yet. + * + * Renders the well at the aspect ratio the real thing will occupy, so the page + * does not reflow when a GIF or screenshot lands, and so the amount of missing + * material is obvious to anyone reviewing the page. + * + * `compact` is the in-card variant used by the demo pull-request cards, where + * a full-width 16:9 frame would dwarf the copy beside it. + */ +export function VisualSlot({children, compact, label = 'ASSET'}) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +/** Several asset wells shown as a set. */ +export function AssetGrid({children}) { + return
{children}
; +} + +// Uses Docusaurus's own button classes rather than hand-rolled ones: they carry +// a readable foreground in both light and dark mode. The primary button +// additionally takes a CSS-module class that recolours it to the accent blue by +// overriding the --ifm-button-* custom properties (see site.module.css). +export function Action({label, to, href, primary, onClick}) { + const className = primary + ? `button button--lg button--primary ${styles.heroPrimary}` + : 'button button--lg button--secondary'; + return to ? ( + + {label} + + ) : ( + + {label} + + ); +} + +/* + * The id lands on the heading rather than on the
. Docusaurus's + * broken-anchor check only registers heading anchors, and the navbar's + * cross-page links have to resolve against something it knows about -- an id + * on the wrapper builds clean but is reported broken on every page. + */ +export function Section({id, heading, kicker, children, tone}) { + return ( +
+ {kicker ?

{kicker}

: null} + {heading ? ( + + {heading} + + ) : null} + {children} +
+ ); +} + +export function PageShell({title, description, children}) { + return ( + +
{children}
+
+ ); +} + +export function Hero({eyebrow, title, body, trust, actions, children}) { + return ( +
+ {eyebrow ?

{eyebrow}

: null} + + {title} + + {body ?

{body}

: null} + {actions?.length ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : null} + {trust?.length ? ( +
    + {trust.map((item) => ( +
  • {item}
  • + ))} +
+ ) : null} + {children} +
+ ); +} + +export function Cards({items}) { + return ( +
+ {items.map((item) => ( +
+

{item.title}

+

{item.body}

+
+ ))} +
+ ); +} + +/** + * A full-width invitation to a companion page. + * + * `subdued` is for the commercial route. The brief requires the commercial CTA + * never to outrank `Use Tirith OSS`, so that variant loses its fill and its + * button and becomes a plain link. + */ +export function Doorway({title, body, cta, subdued, onClick}) { + return ( +
+
+

{title}

+

{body}

+
+ {subdued ? ( + + {cta.label} → + + ) : ( + + {cta.label} + + )} +
+ ); +} + +export function DataTable({columns, rows, rowHeader = true}) { + return ( + + + + {columns.map((column, index) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell, index) => + index === 0 && rowHeader ? ( + + ) : ( + + ), + )} + + ))} + +
{column}
+ {cell} + {cell}
+ ); +} + +/* + * CodeBlock owns its copy button and exposes no callback, so the click is + * caught on the way up instead. Coarse by design: any button inside a code + * block is the copy button. + */ +export function TrackedCode({ciSystem, mode = 'local', ...props}) { + const onClick = (event) => { + if (event.target.closest('button')) { + capture(EVENTS.installCopy, {ci_system: ciSystem, mode}); + } + }; + return ( +
+ +
+ ); +} + +/* ------------------------------------------------------------ verdict --- */ + +/** + * The four outcomes Tirith can return, and what each one means. + * + * `unevaluated` is the one that matters most and the one a scanner UI usually + * gets wrong. A policy whose every check was skipped evaluated nothing, and + * showing that as green is exactly the failure the exit-code contract exists + * to prevent -- so it gets its own badge, its own glyph and its own wording, + * never a quiet pass. + */ +export function outcomeOf(result) { + if (!result || !('final_result' in result)) { + return { + key: 'error', + label: 'Tool error', + glyph: '!', + className: styles.badgeUnknown, + sr: 'Tool error: the evaluation did not complete', + note: 'The evaluation did not complete. Your policy did not fail; fix the execution error and run it again.', + }; + } + if (result.final_result === true) { + return { + key: 'passed', + label: 'Passed', + glyph: '✓', + className: styles.badgePass, + sr: 'Passed: every check that ran passed', + note: 'This plan satisfies the policy. Inspect the resources evaluated before exporting the rule.', + }; + } + if (result.final_result === false) { + return { + key: 'failed', + label: 'Failed', + glyph: '✕', + className: styles.badgeFail, + sr: 'Failed: a check ran and failed', + note: 'This change would be blocked.', + }; + } + return { + key: 'unevaluated', + label: 'Unevaluated', + glyph: '?', + className: styles.badgeUnknown, + sr: 'Unevaluated: no policy answer was reached, which is not a pass', + note: 'Tirith could not reach a policy answer. This is not a pass. Review the provider input, match count and policy diagnostics.', + }; +} + +function Evidence({item}) { + const passed = item.passed === true; + const address = item.meta?.address; + const actions = item.meta?.change?.actions; + return ( +
  • + + + {passed ? 'Passed: ' : 'Failed: '} + {address ? ( + <> + {address} + {actions?.length ? ( + {actions.join(', ')} + ) : null} +
    + + ) : null} + {item.message} +
    +
  • + ); +} + +/** + * Renders one real engine result. + * + * Every field read here comes out of + * documentation/scripts/generate-fixtures.py, which runs the actual engine -- + * so this follows the engine's shape rather than an idealised version of it, + * including the fact that a skipped check carries a message but no resource + * metadata. + */ +export function Verdict({example, showExit = true}) { + const {result, exitCode, exitMeaning} = example; + const outcome = outcomeOf(result); + const failing = (result.evaluators || []).reduce( + (total, evaluator) => total + (evaluator.result || []).filter((r) => r.passed === false).length, + 0, + ); + + return ( +
    +
    + + + {outcome.label} + + {outcome.sr} + {result.meta?.name} + {showExit ? ( + + exit {exitCode} — {exitMeaning} + + ) : null} +
    +
    + {outcome.key === 'failed' && failing ? ( +

    + Tirith found {failing} failing resource{failing === 1 ? '' : 's'}; each one below shows + the planned action and the value behind the result. +

    + ) : ( +

    {outcome.note}

    + )} + + {(result.evaluators || []).map((evaluator) => ( +
    +
    + {evaluator.description || evaluator.id} +
    +
    + {evaluator.id} — {evaluator.passed ? 'passed' : 'failed'} +
    +
      + {(evaluator.result || []).map((item, index) => ( + + ))} +
    +
    + ))} + + {result.errors?.length ? ( +
    + Errors +
      + {result.errors.map((error, index) => ( +
    • + + {typeof error === 'string' ? error : JSON.stringify(error)} +
    • + ))} +
    +
    + ) : null} +
    +
    + ); +} + +export {styles}; diff --git a/documentation/src/css/custom.css b/documentation/src/css/custom.css index 3839cb93..75f828a0 100644 --- a/documentation/src/css/custom.css +++ b/documentation/src/css/custom.css @@ -1,11 +1,40 @@ +/** + * Site-wide theme. + * + * The palette is deliberately neutral: greyscale surfaces and type, with a + * single accent used only for interaction (links, focus rings, the primary + * button). That accent is the StackGuardian icon blue, kept as an accent and + * nothing more -- Tirith is an Apache-2.0 project that people are meant to + * adopt without an account, and a page painted in a vendor's brand colour + * argues against that before a word is read. + * + * The blue is #007aff in the icon, which fails 4.5:1 against white (4.02:1). + * The light-mode scale is therefore built from #006ee6 (4.80:1). Dark mode + * inverts to a light tint, because the same blue on a near-black surface is + * too dim to read. + */ + :root { - --ifm-color-primary: #040084; + --ifm-color-primary: #006ee6; + --ifm-color-primary-dark: #0063cf; + --ifm-color-primary-darker: #005dc4; + --ifm-color-primary-darkest: #004da1; + --ifm-color-primary-light: #0d7bf5; + --ifm-color-primary-lighter: #1a83f7; + --ifm-color-primary-lightest: #3d97f9; + --ifm-code-font-size: 100%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } -/* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme='dark'] { - --ifm-color-primary: #847cc4; + --ifm-color-primary: #6cb6ff; + --ifm-color-primary-dark: #4da4ff; + --ifm-color-primary-darker: #3d9bff; + --ifm-color-primary-darkest: #0f7fff; + --ifm-color-primary-light: #8bc8ff; + --ifm-color-primary-lighter: #9bd0ff; + --ifm-color-primary-lightest: #cce6ff; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } diff --git a/documentation/src/css/site.module.css b/documentation/src/css/site.module.css new file mode 100644 index 00000000..6a5afb5d --- /dev/null +++ b/documentation/src/css/site.module.css @@ -0,0 +1,1141 @@ +/** + * The shared design system for every non-docs page: Home, Learn, Playground, + * Policies, Traction and Fleet. + * + * One stylesheet rather than one per page, because the brief asks the site to + * read as a system -- a visitor moving Home -> Learn -> Playground should not + * feel handed between three different websites. Page-specific rules live at + * the bottom under a clear heading; everything above is shared. + * + * Colours are Infima theme variables wherever one exists, so light and dark + * mode both work without maintaining a second palette. The exceptions are the + * accent blue and the TODO chip, which need a fixed contrast ratio. + */ + +.page { + --tirith-gutter: 1.5rem; + --tirith-radius: 0.65rem; + --tirith-surface: var(--ifm-color-emphasis-100); + --tirith-border: var(--ifm-color-emphasis-200); + --tirith-accent: #006ee6; + + /* + * One content width, and everything obeys it -- prose, headings, hero, card + * grids, tables and code all start and end on the same two vertical lines. + * 54rem is wide enough for a three-card grid and narrow enough that a + * paragraph at the enlarged base size below stays near 90 characters. + */ + max-width: 54rem; + margin: 0 auto; + padding: 3.5rem var(--tirith-gutter) 6rem; + font-size: 1.05rem; +} + +[data-theme='dark'] .page { + --tirith-accent: #6cb6ff; +} + +/* ---------------------------------------------------------------- hero --- */ + +.hero { + padding-bottom: 1rem; +} + +.eyebrow { + margin: 0 0 0.6rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.heroTitle { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.1; + letter-spacing: -0.025em; + margin-bottom: 1rem; +} + +.tagline { + font-size: 1.15rem; + line-height: 1.6; + color: var(--ifm-color-emphasis-800); + margin-bottom: 1.75rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1.5rem 0; +} + +/* + * The primary button uses the accent blue rather than the site primary. The + * StackGuardian icon's #007aff fails 4.5:1 against white (4.02:1), so the fill + * is the same hue darkened to #006ee6 (4.80:1), darker again on hover. The + * label is pinned to opaque white because --ifm-button-color resolves + * differently per theme. + */ +.heroPrimary { + --ifm-button-background-color: #006ee6; + --ifm-button-border-color: #006ee6; + --ifm-button-color: #fff; +} + +.heroPrimary:hover, +.heroPrimary:active { + --ifm-button-background-color: #0062cc; + --ifm-button-border-color: #0062cc; +} + +.trustLine { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1.25rem; + margin: 0; + padding: 0; + list-style: none; + color: var(--ifm-color-emphasis-700); + font-size: 0.9rem; +} + +.trustLine li + li::before { + content: '•'; + margin-right: 1.25rem; + color: var(--ifm-color-emphasis-500); +} + +/* --------------------------------------------------------- structure --- */ + +.section { + margin-top: 4rem; + padding-top: 2.5rem; + border-top: 1px solid var(--tirith-border); +} + +.sectionHeading { + font-size: clamp(1.4rem, 2.6vw, 1.85rem); + line-height: 1.25; + letter-spacing: -0.015em; + margin-bottom: 1rem; +} + +.kicker { + margin: 0 0 0.4rem; + color: var(--ifm-color-emphasis-600); + font-size: 0.8rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* + * `quiet` marks sections that are about someone else's product or someone + * else's proof -- the enterprise strip, platform mode, the Fleet offer. They + * sit on a recessed surface so a reader scanning the page can see at a glance + * which parts are not the open-source promise. + */ +.quiet { + background: var(--tirith-surface); + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + padding: 2rem 1.75rem; + margin-top: 4rem; +} + +.finale { + border-top-width: 2px; +} + +.muted { + color: var(--ifm-color-emphasis-700); + font-size: 0.95rem; +} + +.pullQuote { + border-left: 3px solid var(--tirith-accent); + padding-left: 1rem; + font-size: 1.05rem; + color: var(--ifm-color-emphasis-800); +} + +.details { + margin: 0 0 1rem; +} + +.details summary { + cursor: pointer; + color: var(--ifm-color-primary); + font-size: 0.95rem; + margin-bottom: 0.75rem; +} + +/* ------------------------------------------------------------- cards --- */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; + margin: 1.75rem 0; +} + +.card { + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + padding: 1.25rem; + background: var(--ifm-background-color); +} + +.card h3 { + font-size: 1rem; + margin-bottom: 0.5rem; +} + +.card p { + margin-bottom: 0; + font-size: 0.95rem; + color: var(--ifm-color-emphasis-800); +} + +.cardNumber { + display: inline-block; + margin-bottom: 0.5rem; + color: var(--ifm-color-emphasis-600); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.1em; +} + +.cardPlatform { + background: var(--tirith-surface); + border-style: dashed; +} + +/* + * A full-width call-out that routes to a companion page. Distinct from .card + * so the landing page's Learn/Playground/Fleet invitations read as doors + * rather than as more feature copy. + */ +.doorway { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 2rem 0 0; + padding: 1.25rem 1.4rem; + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + background: var(--tirith-surface); +} + +.doorway h3 { + font-size: 1rem; + margin: 0 0 0.3rem; +} + +.doorway p { + margin: 0; + font-size: 0.95rem; + color: var(--ifm-color-emphasis-800); +} + +.doorwayBody { + flex: 1 1 22rem; +} + +/* + * The commercial doorway is deliberately the quietest thing on the page. The + * brief requires the commercial CTA never to outrank `Use Tirith OSS`, so it + * gets no fill and a plain text link rather than a button. + */ +.doorwaySubdued { + background: none; + border-style: dashed; +} + +.optionalTag { + display: inline-block; + margin-left: 0.5rem; + padding: 0.05rem 0.45rem; + border: 1px solid var(--tirith-border); + border-radius: 1rem; + background: var(--ifm-background-color); + color: var(--ifm-color-emphasis-700); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + vertical-align: middle; +} + +/* ------------------------------------------------------------ tables --- */ + +.table { + display: table; + width: 100%; + margin: 1.5rem 0; + font-size: 0.95rem; +} + +.table th, +.table td { + vertical-align: top; +} + +.table thead th { + font-size: 0.8rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-700); +} + +.table tbody th { + text-align: left; + font-weight: 600; + white-space: nowrap; +} + +/* ------------------------------------------------------------ ladder --- */ + +.ladder { + list-style: none; + margin: 1.75rem 0; + padding: 0; +} + +.ladder li { + display: grid; + grid-template-columns: 10rem 1fr; + gap: 1rem; + align-items: baseline; + padding: 0.7rem 0; + border-bottom: 1px solid var(--tirith-border); +} + +.ladder li:last-child { + border-bottom: none; +} + +.ladderStage { + font-weight: 600; +} + +/* ------------------------------------------------------------- stats --- */ + +/* + * minmax(12rem) gives four columns at the page width. That is the right + * rhythm for the four counters on the home page, and lays the six on Traction + * out as four plus two rather than squeezing all six onto one cramped row -- + * `Merged community PRs` needs the width. + */ +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1.25rem; + margin: 1.5rem 0; +} + +.stats div { + border-left: 2px solid var(--tirith-border); + padding-left: 0.9rem; +} + +.stats dd { + margin: 0; + color: var(--ifm-color-emphasis-700); + font-size: 0.88rem; +} + +.statValue { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; +} + +/* -------------------------------------------------------------- misc --- */ + +.inlineLinks { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1.25rem; + list-style: none; + margin: 1.25rem 0 0; + padding: 0; + font-size: 0.95rem; +} + +.inlineLinks li + li::before { + content: '•'; + margin-right: 1.25rem; + color: var(--ifm-color-emphasis-500); +} + +/* + * minmax(12rem) rather than 16rem so the four integrations sit on one row at + * the page width instead of wrapping three-plus-one. + */ +.integrations { + list-style: none; + margin: 1.5rem 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.75rem; +} + +.integrations li { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + padding: 0.75rem 0.9rem; + font-size: 0.95rem; +} + +/* --------------------------------------------------------- unfinished --- */ + +/* + * Deliberately loud. These mark copy the page cannot ship with, and a + * placeholder that blends in is a placeholder that survives review. Amber + * rather than red: this is unfinished work, not an error. + */ +.todo { + display: block; + margin: 1rem 0; + padding: 0.7rem 0.9rem; + border: 1px dashed #b45309; + border-radius: var(--tirith-radius); + background: rgba(180, 83, 9, 0.07); + color: var(--ifm-font-color-base); + font-size: 0.9rem; + line-height: 1.55; +} + +[data-theme='dark'] .todo { + border-color: #fbbf24; + background: rgba(251, 191, 36, 0.1); +} + +.todoTag { + display: inline-block; + margin-right: 0.6rem; + padding: 0.05rem 0.45rem; + border-radius: 0.25rem; + background: #b45309; + color: #fff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + vertical-align: middle; +} + +[data-theme='dark'] .todoTag { + background: #fbbf24; + color: #1c1200; +} + +/* + * An empty media well, sized like the asset that belongs in it. + * + * The earlier version was a dashed box of caption text, which read as a note + * rather than as a hole in the page. Reserving the actual aspect ratio does + * two useful things: the layout does not jump when a real GIF replaces the + * placeholder, and a reviewer can see at a glance how much of the page is + * still missing. + */ +.visualSlot { + display: block; + margin: 2rem 0 0; +} + +.assetFrame { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 16 / 9; + border: 1px dashed var(--ifm-color-emphasis-400); + border-radius: var(--tirith-radius); + background-color: var(--tirith-surface); + /* A faint hatch, so an empty well is never mistaken for a blank card. */ + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 9px, + rgba(128, 128, 128, 0.07) 9px, + rgba(128, 128, 128, 0.07) 18px + ); +} + +.visualSlot figcaption { + max-width: 38rem; + margin: 0.75rem auto 0; + color: var(--ifm-color-emphasis-700); + font-size: 0.88rem; + line-height: 1.55; + text-align: center; +} + +/* The in-card variant, for the demo pull-request cards. */ +.visualSlotCompact { + margin: 0.9rem 0 0; +} + +.visualSlotCompact .assetFrame { + aspect-ratio: 16 / 10; + border-radius: 0.4rem; +} + +.visualSlotCompact figcaption { + margin-top: 0.5rem; + font-size: 0.8rem; + line-height: 1.45; + text-align: left; +} + +/* + * Two-up for the platform screenshots on Fleet: these are views of a separate + * product, and showing them as a set makes the boundary between it and the OSS + * tool easier to hold in mind than four scattered single frames would. + */ +.assetGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1.25rem; + margin: 1.5rem 0 0; +} + +.assetGrid .visualSlot { + margin: 0; +} + +.assetGrid figcaption { + text-align: left; + max-width: none; +} + +/* ------------------------------------------------- verdict rendering --- */ + +/* + * Shared by Learn and Playground. Every one of these verdicts is real output + * from the Tirith engine, generated by + * documentation/scripts/generate-fixtures.py -- so the shape rendered here has + * to follow the engine rather than an idealised version of it. + */ + +.verdict { + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + overflow: hidden; + margin: 1.5rem 0; +} + +.verdictHead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + background: var(--tirith-surface); + border-bottom: 1px solid var(--tirith-border); +} + +/* + * Outcome is never signalled by colour alone: each badge carries a distinct + * glyph and an explicit word, and the screen-reader label spells out the + * meaning. Required by the brief, and correct regardless. + */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0.6rem; + border-radius: 1rem; + border: 1px solid; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.badgePass { + border-color: #1a7f37; + color: #1a7f37; + background: rgba(26, 127, 55, 0.08); +} + +.badgeFail { + border-color: #b42318; + color: #b42318; + background: rgba(180, 35, 24, 0.08); +} + +.badgeUnknown { + border-color: #b45309; + color: #b45309; + background: rgba(180, 83, 9, 0.08); +} + +[data-theme='dark'] .badgePass { + border-color: #4ac26b; + color: #4ac26b; +} + +[data-theme='dark'] .badgeFail { + border-color: #ff8880; + color: #ff8880; +} + +[data-theme='dark'] .badgeUnknown { + border-color: #fbbf24; + color: #fbbf24; +} + +.verdictExit { + font-size: 0.85rem; + color: var(--ifm-color-emphasis-700); +} + +.verdictBody { + padding: 0.5rem 1rem 1rem; +} + +.evaluator { + border-top: 1px solid var(--tirith-border); + padding-top: 0.85rem; + margin-top: 0.85rem; +} + +.evaluator:first-child { + border-top: none; + margin-top: 0; +} + +.evaluatorId { + font-size: 0.85rem; + color: var(--ifm-color-emphasis-700); +} + +.evidence { + list-style: none; + margin: 0.6rem 0 0; + padding: 0; + display: grid; + gap: 0.4rem; +} + +.evidence li { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.6rem; + align-items: baseline; + padding: 0.45rem 0.6rem; + border-radius: 0.4rem; + background: var(--tirith-surface); + font-size: 0.9rem; +} + +.evidenceGlyph { + font-weight: 700; +} + +.evidencePass { + color: #1a7f37; +} + +.evidenceFail { + color: #b42318; +} + +[data-theme='dark'] .evidencePass { + color: #4ac26b; +} + +[data-theme='dark'] .evidenceFail { + color: #ff8880; +} + +.resourceAddress { + font-family: var(--ifm-font-family-monospace); + font-size: 0.85rem; +} + +.actionTag { + display: inline-block; + margin-left: 0.4rem; + padding: 0 0.4rem; + border-radius: 0.25rem; + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ----------------------------------------------- playground workbench --- */ + +.panes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.paneFull { + grid-column: 1 / -1; +} + +.pane { + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + overflow: hidden; + min-width: 0; +} + +.paneHead { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 0.9rem; + background: var(--tirith-surface); + border-bottom: 1px solid var(--tirith-border); + font-size: 0.85rem; + font-weight: 600; +} + +.paneHint { + font-weight: 400; + color: var(--ifm-color-emphasis-700); +} + +/* + * Code inside a pane scrolls within the pane rather than stretching it, so a + * long plan cannot force the page into horizontal scroll. + */ +.paneBody { + max-height: 22rem; + overflow: auto; +} + +.paneBody :global(.theme-code-block) { + margin: 0; + border-radius: 0; + box-shadow: none; +} + +/* + * The embedded builder. A fixed viewport height rather than a fixed pixel + * height, so the frame is usable on a laptop without swallowing a phone + * screen -- and a floor, so it never collapses to nothing if the embedded app + * reports no height. + */ +.embed { + width: 100%; + height: min(78vh, 46rem); + min-height: 26rem; + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + background: var(--tirith-surface); + display: block; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} + +.chip { + display: inline-block; + padding: 0.35rem 0.75rem; + border: 1px solid var(--tirith-border); + border-radius: 1.5rem; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font-size: 0.9rem; + cursor: pointer; + font-family: inherit; +} + +.chip:hover { + border-color: var(--ifm-color-primary); +} + +.chipActive { + border-color: var(--tirith-accent); + background: var(--tirith-surface); + font-weight: 600; +} + +/* ------------------------------------------------------ learn lessons --- */ + +.lessons { + list-style: none; + counter-reset: lesson; + margin: 1.75rem 0; + padding: 0; +} + +.lessons li { + display: grid; + grid-template-columns: 2rem 1fr auto; + gap: 0.5rem 1rem; + align-items: baseline; + padding: 0.9rem 0; + border-bottom: 1px solid var(--tirith-border); +} + +.lessons li:last-child { + border-bottom: none; +} + +.lessonNumber { + color: var(--ifm-color-emphasis-600); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.lessonTitle { + font-weight: 600; +} + +.lessonTask { + grid-column: 2 / -1; + font-size: 0.92rem; + color: var(--ifm-color-emphasis-700); +} + +/* ------------------------------------------------------------- forms --- */ + +.form { + display: grid; + gap: 1rem; + margin: 1.5rem 0; + max-width: 34rem; +} + +.field { + display: grid; + gap: 0.3rem; +} + +.field label, +.field legend { + font-size: 0.9rem; + font-weight: 600; + padding: 0; +} + +.field input, +.field select, +.field textarea { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid var(--tirith-border); + border-radius: 0.4rem; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font: inherit; + font-size: 0.95rem; +} + +.field small { + color: var(--ifm-color-emphasis-700); + font-size: 0.82rem; +} + +.checkboxRow { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; +} + +.checkboxRow label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-weight: 400; + font-size: 0.92rem; +} + +.formNote { + font-size: 0.85rem; + color: var(--ifm-color-emphasis-700); +} + +/* ------------------------------------------------------- narrow view --- */ + +@media screen and (max-width: 996px) { + .page { + padding: 2rem 1rem 3.5rem; + } + + .quiet { + padding: 1.5rem 1.1rem; + } + + .ladder li, + .lessons li { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .lessonTask { + grid-column: 1; + } + + .panes { + grid-template-columns: 1fr; + } +} + +/* ---------------------------------------------------- announcement --- */ + +/* + * The announcement strip above the hero title. A link rather than a static + * banner: the whole row is the target, since a one-line notice about a new + * feature is only useful if it is also the way to reach it. + */ +.announcement { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.75rem; + padding: 0.5rem 0.9rem; + border: 1px solid var(--tirith-border); + border-radius: 2rem; + background: var(--tirith-surface); + color: var(--ifm-font-color-base); + font-size: 0.95rem; + line-height: 1.5; + text-decoration: none; +} + +.announcement:hover { + border-color: var(--ifm-color-primary); + text-decoration: none; +} + +.announcementLabel { + padding: 0.05rem 0.5rem; + border-radius: 1rem; + background: #006ee6; + color: #fff; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.announcementLink { + color: var(--ifm-color-primary); + font-weight: 600; + white-space: nowrap; +} + +/* ---------------------------------------------------------- utilities --- */ + +/* + * Visually hidden but announced. Used to spell out an outcome for screen + * readers where sighted users get a glyph plus a colour, so that colour is + * never the only carrier of meaning. + */ +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + + +/* ------------------------------------------------ policy card gallery --- */ + +.filterBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 1.5rem 0 1rem; +} + +.filterBar .chips { + margin: 0; +} + +.chipCount { + display: inline-block; + margin-left: 0.35rem; + padding: 0 0.35rem; + border-radius: 0.75rem; + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +.searchInput { + flex: 1 1 12rem; + min-width: 10rem; + max-width: 18rem; + padding: 0.45rem 0.7rem; + border: 1px solid var(--tirith-border); + border-radius: 1.5rem; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font: inherit; + font-size: 0.92rem; +} + +.searchInput:focus { + outline: 2px solid var(--tirith-accent); + outline-offset: 1px; + border-color: transparent; +} + +.policyGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; + margin: 1.25rem 0 0; + /* + * align-items:start so a card that has been expanded to show its policy and + * verdict grows on its own instead of stretching every sibling in its row. + */ + align-items: start; +} + +.policyCard { + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: 1.1rem 1.15rem 1rem; + border: 1px solid var(--tirith-border); + border-radius: var(--tirith-radius); + background: var(--ifm-background-color); + transition: border-color 120ms ease, transform 120ms ease; +} + +.policyCard:hover { + border-color: var(--ifm-color-emphasis-400); + transform: translateY(-1px); +} + +@media (prefers-reduced-motion: reduce) { + .policyCard { + transition: none; + } + .policyCard:hover { + transform: none; + } +} + +.policyCard h3 { + font-size: 1.02rem; + line-height: 1.3; + margin: 0; +} + +.policyCard p { + margin: 0; + font-size: 0.92rem; + line-height: 1.5; + color: var(--ifm-color-emphasis-800); +} + +/* + * The platform cards sit on the same recessed surface used everywhere else on + * this site for "this part is the commercial product", so the two populations + * are distinguishable at a glance and not only by reading the badge. + */ +.policyCardPlatform { + background: var(--tirith-surface); + border-style: dashed; +} + +.policyBadges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.sourceTag { + padding: 0.1rem 0.5rem; + border-radius: 1rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid; +} + +.sourceOss { + border-color: #1a7f37; + color: #1a7f37; + background: rgba(26, 127, 55, 0.08); +} + +.sourcePlatform { + border-color: var(--tirith-accent); + color: var(--tirith-accent); + background: rgba(0, 110, 230, 0.08); +} + +[data-theme='dark'] .sourceOss { + border-color: #4ac26b; + color: #4ac26b; +} + +.metaTag { + padding: 0.1rem 0.45rem; + border-radius: 0.3rem; + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); + font-family: var(--ifm-font-family-monospace); + font-size: 0.72rem; +} + +.policyFacts { + font-size: 0.85rem !important; + color: var(--ifm-color-emphasis-700) !important; +} + +.countPill { + font-size: 1.15rem; + font-variant-numeric: tabular-nums; + color: var(--ifm-font-color-base); +} + +.policyActions { + margin-top: auto; + padding-top: 0.3rem; + font-size: 0.9rem; + font-weight: 600; +} + +/* + * An expanded card breaks out of the grid's column width for its code block, + * which would otherwise force the whole row wider. Contain it. + */ +.policyCard .details :global(.theme-code-block) { + max-height: 18rem; + overflow: auto; +} diff --git a/documentation/src/data/coverage.json b/documentation/src/data/coverage.json new file mode 100644 index 00000000..b2eb12cc --- /dev/null +++ b/documentation/src/data/coverage.json @@ -0,0 +1,45 @@ +{ + "_comment": [ + "Check-catalogue coverage, summarised for a public page.", + "", + "Counts and category names ONLY. The catalogue's individual check IDs,", + "titles, detection methods and rationale are deliberately not in this", + "repository and must not be added to it -- they are the substance of the", + "commercial offer, and a public JSON file is the fastest possible way to", + "give them away. The `focus` strings below are written from scratch to", + "describe a category's territory; none of them quote a check.", + "", + "`available` is the number the platform's repository scanner can evaluate", + "from its own inputs. `catalogue` is the number of checks written for that", + "category, including those whose evidence lives somewhere the scanner", + "cannot reach (a plan, a state file, a live cloud account, a customer's", + "own convention)." + ], + "totals": { + "available": 382, + "catalogue": 519, + "shippedToday": 15, + "categories": 19 + }, + "categories": [ + {"code": "GHA", "name": "GitHub Actions pipeline engineering", "focus": "Plan and apply gating, workflow permissions, action pinning, secret exposure, concurrency and timeouts.", "available": 43, "catalogue": 45}, + {"code": "MOD", "name": "Reusable module API and lifecycle", "focus": "Module interfaces, documentation accuracy, semantic versioning, deprecation and who still consumes it.", "available": 35, "catalogue": 39}, + {"code": "TEST", "name": "Tests, examples, documentation and releases", "focus": "Whether tests exist and what they cover, example hygiene, documentation drift, release discipline.", "available": 25, "catalogue": 30}, + {"code": "GL", "name": "GitLab CI pipeline engineering", "focus": "The same gating, artefact and credential questions, asked of GitLab pipelines.", "available": 24, "catalogue": 25}, + {"code": "SEC", "name": "Secrets, supply chain and code execution", "focus": "Hard-coded credentials, untrusted sources, pipeline injection and unverified downloads.", "available": 23, "catalogue": 25}, + {"code": "TG", "name": "Terragrunt structure and governance", "focus": "Dependency graphs, inheritance depth, generated blocks and what hooks are allowed to run.", "available": 23, "catalogue": 25}, + {"code": "ORG", "name": "Organisation-wide standardisation", "focus": "Divergence measured across the whole estate: versions, backends, tagging, duplicated modules.", "available": 23, "catalogue": 30}, + {"code": "API", "name": "Variables, locals, outputs and interfaces", "focus": "Input and output hygiene, tfvars correctness, and whether sensitive values are marked as such.", "available": 21, "catalogue": 25}, + {"code": "ARCH", "name": "Root architecture and scalability", "focus": "Root sizing, environment separation, cross-environment coupling and ownership.", "available": 20, "catalogue": 25}, + {"code": "LIFE", "name": "Resource lifecycle and safe refactoring", "focus": "Destroy protection, moved and import blocks, unstable addressing, provisioner safety.", "available": 20, "catalogue": 25}, + {"code": "PROVIDER", "name": "Kubernetes, Helm, VMware and generic providers", "focus": "Chart and image pinning, secret material, placement constraints, provider-specific sensitivity.", "available": 20, "catalogue": 25}, + {"code": "GOV", "name": "Governance, ownership and policy lifecycle", "focus": "Code ownership, review requirements, and how suppressions and exceptions are handled.", "available": 20, "catalogue": 30}, + {"code": "PROV", "name": "Provider configuration and dependency integrity", "focus": "Provider sourcing, version constraints, lock files and credentials in configuration.", "available": 19, "catalogue": 25}, + {"code": "REP", "name": "Repository discovery and lifecycle", "focus": "What each repository is for, whether it is dormant, and what should not be committed to it.", "available": 18, "catalogue": 20}, + {"code": "VER", "name": "Versions and portability", "focus": "Version pinning, Terraform and OpenTofu portability, deprecated language syntax.", "available": 17, "catalogue": 20}, + {"code": "STATE", "name": "Backend state and workspaces", "focus": "Backend strategy, encryption, locking, state key boundaries and workspace use.", "available": 16, "catalogue": 30}, + {"code": "STYLE", "name": "Formatting, naming and readability", "focus": "Canonical formatting, file organisation, expression complexity and commented-out code.", "available": 14, "catalogue": 20}, + {"code": "TFC", "name": "Terraform Cloud workspaces and runs", "focus": "Policy enforcement levels, where a customer keeps their policy sets in Git.", "available": 1, "catalogue": 25}, + {"code": "RUN", "name": "Plan, apply and cloud maturity", "focus": "Questions only a plan or a live account can answer: destructive changes, drift, unmanaged resources.", "available": 0, "catalogue": 30} + ] +} diff --git a/documentation/src/data/fixtures.json b/documentation/src/data/fixtures.json new file mode 100644 index 00000000..2c6722fd --- /dev/null +++ b/documentation/src/data/fixtures.json @@ -0,0 +1,7863 @@ +{ + "_comment": "Generated by documentation/scripts/generate-fixtures.py. Do not edit by hand. Every result is the real output of the Tirith engine.", + "examples": [ + { + "key": "01-required-tags", + "title": "Required tags", + "summary": "Require a costcenter tag on every resource \u2014 and watch one resource fail.", + "about": "Require a costcenter tag on every resource \u2014 and watch one resource fail.\n\nThis is the smallest useful policy: one check, one condition, no operators.\n\n`terraform_resource_type: \"*\"` matches every resource in the plan, and\n`terraform_resource_attribute: \"tags.costcenter\"` reads a nested attribute \u2014 the dot\nwalks into the tags map. `IsNotEmpty` needs no `value`, because there is nothing to\ncompare against.\n\nThe plan has two resources and only `aws_instance.web` is tagged, so the policy fails.\n\n**Things to try**\n\n- Add `\"costcenter\": \"product-456\"` to the bucket's tags and re-run. The verdict flips.\n- Add `\"error_tolerance\": 2` to the condition. The verdict becomes *skipped*, not passed \u2014\n and a policy that skips every check has checked nothing. That is why `--fail-on-error`\n treats skipped as a failure rather than a pass.\n- Change `IsNotEmpty` to `Equals` with `\"value\": \"product-123\"` to pin one exact value.\n\n**A rough edge worth knowing**\n\nThe failing row has no resource address. When the provider cannot find an attribute it\nreports the miss without the resource it was looking at, so the message names the\nattribute but not the bucket. Results that *do* find a value carry the full address \u2014\nselect the passing row to see it.", + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every resource carries a costcenter tag" + }, + "evaluators": [ + { + "id": "costcenter_tag_present", + "description": "Every taggable resource declares a costcenter tag", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.costcenter" + }, + "condition": { + "type": "IsNotEmpty" + } + } + ], + "eval_expression": "costcenter_tag_present" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web-server", + "costcenter": "product-123" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + }, + { + "address": "aws_s3_bucket.assets", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "example-assets", + "tags": { + "Name": "assets" + } + }, + "after_unknown": { + "arn": true + } + } + } + ] + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every resource carries a costcenter tag" + }, + "final_result": false, + "evaluators": [ + { + "id": "costcenter_tag_present", + "passed": false, + "result": [ + { + "passed": true, + "message": "`\"product-123\"` is not empty", + "meta": { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web-server", + "costcenter": "product-123" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + } + }, + { + "message": "attribute: 'tags.costcenter' is not found", + "passed": false + } + ], + "description": "Every taggable resource declares a costcenter tag" + } + ], + "errors": [], + "eval_expression": "costcenter_tag_present" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job." + }, + { + "key": "02-no-public-buckets", + "title": "No public buckets", + "summary": "Four checks combined with `&&`, and one resource that satisfies only half of them.", + "about": "Four checks combined with `&&`, and one resource that satisfies only half of them.\n\nThis is the shape most real policies take: several independent checks joined into one verdict by\n`eval_expression`. All four have to hold, because S3 public access has four separate switches and\nblocking two of them is not blocking public access.\n\n`aws_s3_bucket_public_access_block.web` sets `block_public_acls` and `ignore_public_acls` to true\nand leaves the other two false. So two evaluators pass, two fail, and `&&` fails \u2014 which is the\ncorrect answer. A policy that only checked one attribute would have called this bucket safe.\n\nThis example previously targeted `aws_s3_bucket.acl` and an inline\n`server_side_encryption_configuration` block. Both were removed in AWS provider v4, so it matched\nnothing on a modern plan while still appearing to work against its own fixture \u2014 a policy that\nmatches nothing is the failure mode this interface exists to make visible, so shipping one as a\nteaching example was the wrong lesson.\n\n**Things to try**\n\n- Set `block_public_policy` and `restrict_public_buckets` to `true` in the plan. All four pass and\n the verdict turns green.\n- Change `eval_expression` to `block_public_acls || block_public_policy`. It passes \u2014 `||` needs\n only one, which is exactly why this policy uses `&&`.\n- Add `!` to negate a check: `!block_public_policy` passes precisely when the setting is missing.\n That is how you write a detector rather than a prohibition \u2014 there is no `NotRegexMatch` or\n inverse condition, so `!` in the expression is the mechanism.", + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "S3 public access blocks deny all four vectors", + "severity": "high" + }, + "evaluators": [ + { + "id": "block_public_acls", + "description": "aws_s3_bucket_public_access_block.block_public_acls is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "block_public_acls" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "block_public_policy", + "description": "aws_s3_bucket_public_access_block.block_public_policy is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "block_public_policy" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "ignore_public_acls", + "description": "aws_s3_bucket_public_access_block.ignore_public_acls is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "ignore_public_acls" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "restrict_public_buckets", + "description": "aws_s3_bucket_public_access_block.restrict_public_buckets is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "restrict_public_buckets" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + } + ], + "eval_expression": "block_public_acls && block_public_policy && ignore_public_acls && restrict_public_buckets" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "S3 public access blocks deny all four vectors", + "severity": "high" + }, + "final_result": false, + "evaluators": [ + { + "id": "block_public_acls", + "passed": true, + "result": [ + { + "passed": true, + "message": "`true` is equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.block_public_acls is true" + }, + { + "id": "block_public_policy", + "passed": false, + "result": [ + { + "passed": false, + "message": "`false` is not equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.block_public_policy is true" + }, + { + "id": "ignore_public_acls", + "passed": true, + "result": [ + { + "passed": true, + "message": "`true` is equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.ignore_public_acls is true" + }, + { + "id": "restrict_public_buckets", + "passed": false, + "result": [ + { + "passed": false, + "message": "`false` is not equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.restrict_public_buckets is true" + } + ], + "errors": [], + "eval_expression": "block_public_acls && block_public_policy && ignore_public_acls && restrict_public_buckets" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job." + }, + { + "key": "03-cost-ceiling", + "title": "Cost ceiling", + "summary": "Budget gates over an Infracost report, with a total and a per-service ceiling.", + "about": "Budget gates over an Infracost report, with a total and a per-service ceiling.\n\nA different provider and a different input document: this reads\n`infracost breakdown --format json`, not a terraform plan.\n\n`resource_type` here is a **list**, unlike the terraform provider's plain string.\n`[\"*\"]` totals the whole plan; naming types instead sums only those. The two checks show\nboth: `$388.47` across everything, `$181.77` for the two `aws_instance` resources.\n\nBoth pass, so the policy passes.\n\n**Things to try**\n\n- Lower the total budget to `300`. The first check fails and the verdict flips.\n- Set `resource_type` to `[\"aws_rds_cluster\"]` to gate the database separately.\n- Note there are no resource addresses in the results. Infracost sums across resources, so\n a cost check reports one number with no single resource behind it \u2014 unlike the terraform\n examples, where every result names its resource.\n\n**A rough edge worth knowing**\n\nMatching is on the exact resource *type* \u2014 the part of the name before the first dot \u2014 so\n`[\"aws_instance\"]` matches `aws_instance.app_server`, but a partial type like `[\"aws_s3\"]`\nmatches nothing and silently sums to `0`. A cost check that suddenly reads `0` is usually a\nmisspelled type rather than a free plan, and because `0` passes every `LessThanEqualTo`\nceiling, it fails open. Name types exactly.", + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/infracost", + "name": "Monthly spend stays under budget" + }, + "evaluators": [ + { + "id": "total_under_budget", + "description": "The whole plan costs less than $500 a month", + "provider_args": { + "operation_type": "total_monthly_cost", + "resource_type": [ + "*" + ] + }, + "condition": { + "type": "LessThanEqualTo", + "value": 500 + } + }, + { + "id": "compute_under_budget", + "description": "Compute alone stays under $200 a month", + "provider_args": { + "operation_type": "total_monthly_cost", + "resource_type": [ + "aws_instance" + ] + }, + "condition": { + "type": "LessThanEqualTo", + "value": 200 + } + } + ], + "eval_expression": "total_under_budget && compute_under_budget" + }, + "input": { + "version": "0.2", + "currency": "USD", + "projects": [ + { + "name": "acme/infrastructure", + "breakdown": { + "resources": [ + { + "name": "aws_instance.app_server", + "monthlyCost": "121.18", + "hourlyCost": "0.166" + }, + { + "name": "aws_instance.worker", + "monthlyCost": "60.59", + "hourlyCost": "0.083" + }, + { + "name": "aws_rds_cluster.primary", + "monthlyCost": "204.40", + "hourlyCost": "0.280" + }, + { + "name": "aws_s3_bucket.assets", + "monthlyCost": "2.30", + "hourlyCost": "0.003" + } + ] + } + } + ], + "totalMonthlyCost": "388.47", + "totalHourlyCost": "0.532" + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/infracost", + "name": "Monthly spend stays under budget" + }, + "final_result": true, + "evaluators": [ + { + "id": "total_under_budget", + "passed": true, + "result": [ + { + "passed": true, + "message": "`388.47` is less than equal to `500`", + "meta": null + } + ], + "description": "The whole plan costs less than $500 a month" + }, + { + "id": "compute_under_budget", + "passed": true, + "result": [ + { + "passed": true, + "message": "`181.77` is less than equal to `200`", + "meta": null + } + ], + "description": "Compute alone stays under $200 a month" + } + ], + "errors": [], + "eval_expression": "total_under_budget && compute_under_budget" + }, + "exitCode": 0, + "exitMeaning": "Every check that ran passed." + }, + { + "key": "04-block-destroy", + "title": "Block destroy", + "summary": "Catch a database replacement hiding inside a routine plan.", + "about": "Catch a database replacement hiding inside a routine plan.\n\n`operation_type: \"action\"` reads what terraform intends to *do* to a resource, rather than\nan attribute of it. This is how you gate on destruction.\n\nThe catch this policy exists for: `aws_db_instance.primary` is not being destroyed on\npurpose. Its `instance_class` changed, which forces a replacement, and terraform expresses\nthat as `[\"delete\", \"create\"]` \u2014 a destroy and a recreate. In a plan of any size that is\neasy to miss, and it means losing the database.\n\nSelect the failing row. The detail pane names the action **replace (destroy first)** and\nshows the attribute that forced it: `instance_class`, `db.t3.medium \u2192 db.t3.large`. The\nordering matters \u2014 destroy-first has downtime, create-first does not \u2014 so the two are named\ndifferently rather than both reading \"replace\".\n\n`aws_db_instance.replica` is only growing its storage, so it updates in place and passes.\n\n**Things to try**\n\n- Change `primary`'s `instance_class` back to `db.t3.medium` and set `actions` to\n `[\"update\"]`. The policy passes.\n- Swap `NotContains` for `ContainedIn` with `[\"delete\"]` to write the inverse check.\n- Drop `error_tolerance: 1` and change the resource type to one the plan does not contain.\n Without the tolerance a missing resource is a failure; with it the check is skipped.\n\n**Why the resource appears twice**\n\nThe `action` operation emits one result per action, so a replacement produces two rows for\nthe same resource \u2014 one for `delete` (which fails) and one for `create` (which passes).\nThe check as a whole fails, because any failing result fails its check.", + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Stateful resources are never destroyed", + "severity": "critical" + }, + "evaluators": [ + { + "id": "database_not_destroyed", + "description": "No plan may destroy an RDS instance", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_db_instance" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + } + ], + "eval_expression": "database_not_destroyed" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_db_instance.primary", + "mode": "managed", + "type": "aws_db_instance", + "name": "primary", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": { + "identifier": "acme-primary", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-primary", + "instance_class": "db.t3.large", + "allocated_storage": 100 + }, + "after_unknown": { + "endpoint": true, + "id": true + } + } + }, + { + "address": "aws_db_instance.replica", + "mode": "managed", + "type": "aws_db_instance", + "name": "replica", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "update" + ], + "before": { + "identifier": "acme-replica", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-replica", + "instance_class": "db.t3.medium", + "allocated_storage": 200 + }, + "after_unknown": {} + } + } + ] + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Stateful resources are never destroyed", + "severity": "critical" + }, + "final_result": false, + "evaluators": [ + { + "id": "database_not_destroyed", + "passed": false, + "result": [ + { + "passed": false, + "message": "Found `\"delete\"` inside `\"delete\"`", + "meta": { + "address": "aws_db_instance.primary", + "mode": "managed", + "type": "aws_db_instance", + "name": "primary", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": { + "identifier": "acme-primary", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-primary", + "instance_class": "db.t3.large", + "allocated_storage": 100 + }, + "after_unknown": { + "endpoint": true, + "id": true + } + } + } + }, + { + "passed": true, + "message": "Did not find `\"delete\"` inside `\"create\"`", + "meta": { + "address": "aws_db_instance.primary", + "mode": "managed", + "type": "aws_db_instance", + "name": "primary", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": { + "identifier": "acme-primary", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-primary", + "instance_class": "db.t3.large", + "allocated_storage": 100 + }, + "after_unknown": { + "endpoint": true, + "id": true + } + } + } + }, + { + "passed": true, + "message": "Did not find `\"delete\"` inside `\"update\"`", + "meta": { + "address": "aws_db_instance.replica", + "mode": "managed", + "type": "aws_db_instance", + "name": "replica", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "update" + ], + "before": { + "identifier": "acme-replica", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-replica", + "instance_class": "db.t3.medium", + "allocated_storage": 200 + }, + "after_unknown": {} + } + } + } + ], + "description": "No plan may destroy an RDS instance" + } + ], + "errors": [], + "eval_expression": "database_not_destroyed" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job." + }, + { + "key": "05-kubernetes-probes", + "title": "Kubernetes probes", + "summary": "Kubernetes manifests, wildcard paths, and the `!` operator.", + "about": "Kubernetes manifests, wildcard paths, and the `!` operator.\n\nA third input shape: a **list** of manifests, not a single object. `kubernetes_kind` picks\nwhich ones to look at, so the `Service` here is ignored and only the `Pod` is checked.\n\nThe `*` in `spec.containers.*.image` walks every container. This is the part worth\nunderstanding, because it is where the engine surprises people: the wildcard collects the\ncontainers into **one list**, and the condition is applied to that list rather than once\nper container. So the question you ask has to be about the list.\n\nThat is why both checks are phrased as `Contains`:\n\n- `has_liveness_probe` asks whether the list of probes contains `null` \u2014 a container with\n no probe contributes a `null`. Written as `IsNotEmpty` it would pass, because a list\n containing `null` is not empty.\n- `uses_latest_tag` asks whether any image contains `:latest`. It is a *detector*, so the\n expression negates it with `!`.\n\nBoth fire on the same container: the `sidecar`, which has no probe and floats on `:latest`.\n\n**Things to try**\n\n- Give the sidecar a `livenessProbe` and pin its image to `acme/log-shipper:2.1.0`. The\n policy passes.\n- Change `has_liveness_probe` to `IsNotEmpty` and watch it pass while the probe is still\n missing. This is the trap the check above avoids.\n- Change `kubernetes_kind` to `Service` \u2014 no pod matches, so the checks report that the kind\n was found but the path was not, rather than passing silently.", + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/kubernetes", + "name": "Pods declare liveness probes and pinned images" + }, + "evaluators": [ + { + "id": "has_liveness_probe", + "description": "Every container declares a liveness probe", + "provider_args": { + "operation_type": "attribute", + "kubernetes_kind": "Pod", + "attribute_path": "spec.containers.*.livenessProbe" + }, + "condition": { + "type": "NotContains", + "value": null + } + }, + { + "id": "uses_latest_tag", + "description": "Detects any container running the floating :latest tag", + "provider_args": { + "operation_type": "attribute", + "kubernetes_kind": "Pod", + "attribute_path": "spec.containers.*.image" + }, + "condition": { + "type": "Contains", + "value": ":latest" + } + } + ], + "eval_expression": "has_liveness_probe && !uses_latest_tag" + }, + "input": [ + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "api", + "namespace": "production" + }, + "spec": { + "containers": [ + { + "name": "api", + "image": "acme/api:1.4.2", + "livenessProbe": { + "httpGet": { + "path": "/healthz", + "port": 8080 + }, + "initialDelaySeconds": 10 + } + }, + { + "name": "sidecar", + "image": "acme/log-shipper:latest" + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "api", + "namespace": "production" + }, + "spec": { + "selector": { + "app": "api" + }, + "ports": [ + { + "port": 80, + "targetPort": 8080 + } + ] + } + } + ], + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/kubernetes", + "name": "Pods declare liveness probes and pinned images" + }, + "final_result": false, + "evaluators": [ + { + "id": "has_liveness_probe", + "passed": false, + "result": [ + { + "passed": false, + "message": "Found `null` inside `[{\"httpGet\": {\"path\": \"/healthz\", \"port\": 8080}, \"initialDelaySeconds\": 10}, null]`", + "meta": null + } + ], + "description": "Every container declares a liveness probe" + }, + { + "id": "uses_latest_tag", + "passed": false, + "result": [ + { + "passed": false, + "message": "Failed to find `\":latest\"` inside `[\"acme/api:1.4.2\", \"acme/log-shipper:latest\"]`", + "meta": null + } + ], + "description": "Detects any container running the floating :latest tag" + } + ], + "errors": [], + "eval_expression": "has_liveness_probe && !uses_latest_tag" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job." + } + ], + "pack": [ + { + "key": "01-required-tags", + "title": "Every taggable resource declares an owner", + "severity": "low", + "provider": "stackguardian/terraform_plan", + "operations": [ + "attribute" + ], + "evaluatorCount": 1, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every taggable resource declares an owner", + "severity": "low" + }, + "evaluators": [ + { + "id": "owner_tag_present", + "description": "tags.Owner is present and not blank. Sub-resources that AWS does not accept tags on are excluded by name -- without that list every aws_s3_bucket_versioning in the plan fails a rule it cannot satisfy.", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.Owner", + "exclude_resource_types": [ + "aws_s3_bucket_acl", + "aws_s3_bucket_lifecycle_configuration", + "aws_s3_bucket_policy", + "aws_s3_bucket_public_access_block", + "aws_s3_bucket_server_side_encryption_configuration", + "aws_s3_bucket_versioning", + "aws_iam_policy_attachment", + "aws_iam_role_policy", + "aws_iam_role_policy_attachment", + "aws_route_table_association", + "aws_security_group_rule", + "aws_lambda_permission", + "null_resource", + "terraform_data", + "random_id", + "random_password", + "random_string" + ] + }, + "condition": { + "type": "IsNotEmpty" + } + } + ], + "eval_expression": "owner_tag_present" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_s3_bucket.analytics", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "analytics", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-analytics", + "tags": { + "Name": "analytics", + "Owner": "" + } + } + } + }, + { + "address": "aws_s3_bucket_versioning.analytics", + "mode": "managed", + "type": "aws_s3_bucket_versioning", + "name": "analytics", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-analytics" + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every taggable resource declares an owner", + "severity": "low" + }, + "final_result": false, + "evaluators": [ + { + "id": "owner_tag_present", + "passed": false, + "result": [ + { + "passed": false, + "message": "`\"\"` is empty", + "meta": { + "address": "aws_s3_bucket.analytics", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "analytics", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-analytics", + "tags": { + "Name": "analytics", + "Owner": "" + } + } + } + } + } + ], + "description": "tags.Owner is present and not blank. Sub-resources that AWS does not accept tags on are excluded by name -- without that list every aws_s3_bucket_versioning in the plan fails a rule it cannot satisfy." + } + ], + "errors": [], + "eval_expression": "owner_tag_present" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "02-public-access-blocked", + "title": "S3 public access blocks deny all four vectors", + "severity": "high", + "provider": "stackguardian/terraform_plan", + "operations": [ + "attribute" + ], + "evaluatorCount": 4, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "S3 public access blocks deny all four vectors", + "severity": "high" + }, + "evaluators": [ + { + "id": "block_public_acls", + "description": "aws_s3_bucket_public_access_block.block_public_acls is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "block_public_acls" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "block_public_policy", + "description": "aws_s3_bucket_public_access_block.block_public_policy is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "block_public_policy" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "ignore_public_acls", + "description": "aws_s3_bucket_public_access_block.ignore_public_acls is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "ignore_public_acls" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "restrict_public_buckets", + "description": "aws_s3_bucket_public_access_block.restrict_public_buckets is true", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket_public_access_block", + "terraform_resource_attribute": "restrict_public_buckets" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + } + ], + "eval_expression": "block_public_acls && block_public_policy && ignore_public_acls && restrict_public_buckets" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "S3 public access blocks deny all four vectors", + "severity": "high" + }, + "final_result": false, + "evaluators": [ + { + "id": "block_public_acls", + "passed": true, + "result": [ + { + "passed": true, + "message": "`true` is equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.block_public_acls is true" + }, + { + "id": "block_public_policy", + "passed": false, + "result": [ + { + "passed": false, + "message": "`false` is not equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.block_public_policy is true" + }, + { + "id": "ignore_public_acls", + "passed": true, + "result": [ + { + "passed": true, + "message": "`true` is equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.ignore_public_acls is true" + }, + { + "id": "restrict_public_buckets", + "passed": false, + "result": [ + { + "passed": false, + "message": "`false` is not equal to `true`", + "meta": { + "address": "aws_s3_bucket_public_access_block.web", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-web", + "block_public_acls": true, + "block_public_policy": false, + "ignore_public_acls": true, + "restrict_public_buckets": false + } + } + } + } + ], + "description": "aws_s3_bucket_public_access_block.restrict_public_buckets is true" + } + ], + "errors": [], + "eval_expression": "block_public_acls && block_public_policy && ignore_public_acls && restrict_public_buckets" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "03-block-destroy", + "title": "Stateful resources are never destroyed or replaced", + "severity": "critical", + "provider": "stackguardian/terraform_plan", + "operations": [ + "action" + ], + "evaluatorCount": 17, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Stateful resources are never destroyed or replaced", + "severity": "critical" + }, + "evaluators": [ + { + "id": "db_instance_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_db_instance" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + }, + "description": "No aws_db_instance is deleted. Terraform plans a replacement as [\"delete\",\"create\"], so checking for delete catches replacement too -- which is the case that surprises people, because the diff reads like an edit. error_tolerance 1 on every evaluator so a plan containing none of a given type skips rather than failing." + }, + { + "id": "rds_cluster_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_rds_cluster" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "dynamodb_table_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_dynamodb_table" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "s3_bucket_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_s3_bucket" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "efs_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_efs_file_system" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "elasticache_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_elasticache_cluster" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "elasticache_replication_group_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_elasticache_replication_group" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "opensearch_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_opensearch_domain" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "redshift_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_redshift_cluster" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "neptune_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_neptune_cluster" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "docdb_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_docdb_cluster" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "ebs_volume_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_ebs_volume" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "fsx_lustre_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_fsx_lustre_file_system" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "kms_key_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_kms_key" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "log_group_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_cloudwatch_log_group" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "secret_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_secretsmanager_secret" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + }, + { + "id": "efs_backup_kept", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_backup_vault" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + } + ], + "eval_expression": "db_instance_kept && rds_cluster_kept && dynamodb_table_kept && s3_bucket_kept && efs_kept && elasticache_kept && elasticache_replication_group_kept && opensearch_kept && redshift_kept && neptune_kept && docdb_kept && ebs_volume_kept && fsx_lustre_kept && kms_key_kept && log_group_kept && secret_kept && efs_backup_kept" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_db_instance.orders", + "mode": "managed", + "type": "aws_db_instance", + "name": "orders", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": null, + "after": { + "identifier": "orders", + "engine": "postgres", + "storage_encrypted": true, + "deletion_protection": true + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Stateful resources are never destroyed or replaced", + "severity": "critical" + }, + "final_result": false, + "evaluators": [ + { + "id": "db_instance_kept", + "passed": false, + "result": [ + { + "passed": false, + "message": "Found `\"delete\"` inside `\"delete\"`", + "meta": { + "address": "aws_db_instance.orders", + "mode": "managed", + "type": "aws_db_instance", + "name": "orders", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": null, + "after": { + "identifier": "orders", + "engine": "postgres", + "storage_encrypted": true, + "deletion_protection": true + } + } + } + }, + { + "passed": true, + "message": "Did not find `\"delete\"` inside `\"create\"`", + "meta": { + "address": "aws_db_instance.orders", + "mode": "managed", + "type": "aws_db_instance", + "name": "orders", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": null, + "after": { + "identifier": "orders", + "engine": "postgres", + "storage_encrypted": true, + "deletion_protection": true + } + } + } + } + ], + "description": "No aws_db_instance is deleted. Terraform plans a replacement as [\"delete\",\"create\"], so checking for delete catches replacement too -- which is the case that surprises people, because the diff reads like an edit. error_tolerance 1 on every evaluator so a plan containing none of a given type skips rather than failing." + }, + { + "id": "rds_cluster_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_rds_cluster' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "dynamodb_table_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_dynamodb_table' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "s3_bucket_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_s3_bucket' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "efs_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_efs_file_system' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "elasticache_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_elasticache_cluster' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "elasticache_replication_group_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_elasticache_replication_group' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "opensearch_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_opensearch_domain' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "redshift_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_redshift_cluster' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "neptune_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_neptune_cluster' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "docdb_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_docdb_cluster' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "ebs_volume_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_ebs_volume' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "fsx_lustre_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_fsx_lustre_file_system' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "kms_key_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_kms_key' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "log_group_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_cloudwatch_log_group' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "secret_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_secretsmanager_secret' is not found", + "passed": null + } + ], + "description": null + }, + { + "id": "efs_backup_kept", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_backup_vault' is not found", + "passed": null + } + ], + "description": null + } + ], + "errors": [], + "eval_expression": "db_instance_kept && rds_cluster_kept && dynamodb_table_kept && s3_bucket_kept && efs_kept && elasticache_kept && elasticache_replication_group_kept && opensearch_kept && redshift_kept && neptune_kept && docdb_kept && ebs_volume_kept && fsx_lustre_kept && kms_key_kept && log_group_kept && secret_kept && efs_backup_kept" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "04-allowed-regions", + "title": "The AWS provider deploys only to approved regions", + "severity": "medium", + "provider": "stackguardian/terraform_plan", + "operations": [ + "provider_config" + ], + "evaluatorCount": 1, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "The AWS provider deploys only to approved regions", + "severity": "medium" + }, + "evaluators": [ + { + "id": "aws_region_approved", + "description": "provider_config reads the region straight off the provider block. error_tolerance 2 is deliberate: Tirith reads expressions.region.constant_value, so a repository setting region = var.region reports severity 2 and this SKIPS rather than fails -- a rule that cannot see the value must not pretend to have judged it. Severity 1 covers the AWS provider being absent entirely.", + "provider_args": { + "operation_type": "provider_config", + "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws", + "attribute": "region" + }, + "condition": { + "type": "ContainedIn", + "value": [ + "us-east-1", + "eu-central-1" + ], + "error_tolerance": 2 + } + } + ], + "eval_expression": "aws_region_approved" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_s3_bucket.eu", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "eu", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-eu", + "tags": { + "Owner": "platform" + } + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "ap-southeast-2" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "The AWS provider deploys only to approved regions", + "severity": "medium" + }, + "final_result": false, + "evaluators": [ + { + "id": "aws_region_approved", + "passed": false, + "result": [ + { + "passed": false, + "message": "Failed to find `\"ap-southeast-2\"` inside `[\"eu-central-1\", \"us-east-1\"]`", + "meta": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "ap-southeast-2" + } + } + } + } + ], + "description": "provider_config reads the region straight off the provider block. error_tolerance 2 is deliberate: Tirith reads expressions.region.constant_value, so a repository setting region = var.region reports severity 2 and this SKIPS rather than fails -- a rule that cannot see the value must not pretend to have judged it. Severity 1 covers the AWS provider being absent entirely." + } + ], + "errors": [], + "eval_expression": "aws_region_approved" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "05-minimum-terraform-version", + "title": "The plan was produced by Terraform 1.10 or newer", + "severity": "low", + "provider": "stackguardian/terraform_plan", + "operations": [ + "terraform_version" + ], + "evaluatorCount": 1, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "The plan was produced by Terraform 1.10 or newer", + "severity": "low" + }, + "evaluators": [ + { + "id": "version_at_least_1_10", + "description": "RegexMatch, NOT GreaterThanEqualTo. Version comparison here is a plain Python >= on strings, which is lexicographic -- '1.9.0' >= '1.11.4' evaluates true and the rule would silently pass everything. The pattern matches 1.10 through 1.99 and any 2.x or later. Note this is the version that RAN the plan, a runtime fact, not the required_version constraint in the source.", + "provider_args": { + "operation_type": "terraform_version" + }, + "condition": { + "type": "RegexMatch", + "value": "^(1\\.[1-9][0-9]|[2-9])" + } + } + ], + "eval_expression": "version_at_least_1_10" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.9.2", + "resource_changes": [ + { + "address": "aws_s3_bucket.legacy", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "legacy", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-legacy", + "tags": { + "Owner": "platform" + } + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "The plan was produced by Terraform 1.10 or newer", + "severity": "low" + }, + "final_result": false, + "evaluators": [ + { + "id": "version_at_least_1_10", + "passed": false, + "result": [ + { + "passed": false, + "message": "`\"1.9.2\"` does not match regex pattern `\"^(1\\\\.[1-9][0-9]|[2-9])\"`", + "meta": { + "format_version": "1.2", + "terraform_version": "1.9.2", + "resource_changes": [ + { + "address": "aws_s3_bucket.legacy", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "legacy", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-legacy", + "tags": { + "Owner": "platform" + } + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + } + } + ], + "description": "RegexMatch, NOT GreaterThanEqualTo. Version comparison here is a plain Python >= on strings, which is lexicographic -- '1.9.0' >= '1.11.4' evaluates true and the rule would silently pass everything. The pattern matches 1.10 through 1.99 and any 2.x or later. Note this is the version that RAN the plan, a runtime fact, not the required_version constraint in the source." + } + ], + "errors": [], + "eval_expression": "version_at_least_1_10" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "06-database-safeguards", + "title": "Databases are encrypted and protected from deletion", + "severity": "high", + "provider": "stackguardian/terraform_plan", + "operations": [ + "attribute" + ], + "evaluatorCount": 2, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Databases are encrypted and protected from deletion", + "severity": "high" + }, + "evaluators": [ + { + "id": "deletion_protection_on", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_db_instance", + "terraform_resource_attribute": "deletion_protection" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + }, + { + "id": "storage_encrypted", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_db_instance", + "terraform_resource_attribute": "storage_encrypted" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 1 + } + } + ], + "eval_expression": "deletion_protection_on && storage_encrypted" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_db_instance.reporting", + "mode": "managed", + "type": "aws_db_instance", + "name": "reporting", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "identifier": "reporting", + "engine": "postgres", + "storage_encrypted": false, + "deletion_protection": false + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Databases are encrypted and protected from deletion", + "severity": "high" + }, + "final_result": false, + "evaluators": [ + { + "id": "deletion_protection_on", + "passed": false, + "result": [ + { + "passed": false, + "message": "`false` is not equal to `true`", + "meta": { + "address": "aws_db_instance.reporting", + "mode": "managed", + "type": "aws_db_instance", + "name": "reporting", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "identifier": "reporting", + "engine": "postgres", + "storage_encrypted": false, + "deletion_protection": false + } + } + } + } + ], + "description": null + }, + { + "id": "storage_encrypted", + "passed": false, + "result": [ + { + "passed": false, + "message": "`false` is not equal to `true`", + "meta": { + "address": "aws_db_instance.reporting", + "mode": "managed", + "type": "aws_db_instance", + "name": "reporting", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "identifier": "reporting", + "engine": "postgres", + "storage_encrypted": false, + "deletion_protection": false + } + } + } + } + ], + "description": null + } + ], + "errors": [], + "eval_expression": "deletion_protection_on && storage_encrypted" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "07-cost-ceiling", + "title": "Planned monthly cost stays under 500 USD -- REQUIRES an Infracost breakdown as input, not a plan", + "severity": "medium", + "provider": "stackguardian/infracost", + "operations": [ + "total_monthly_cost" + ], + "evaluatorCount": 2, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/infracost", + "name": "Planned monthly cost stays under 500 USD -- REQUIRES an Infracost breakdown as input, not a plan", + "severity": "medium" + }, + "evaluators": [ + { + "id": "total_under_budget", + "description": "Run against `infracost breakdown --format json`, not against plan.json. In the GitHub Action's local mode Infracost is ignored with a warning, so this rule only reports when you generate the breakdown yourself.", + "provider_args": { + "operation_type": "total_monthly_cost", + "resource_type": [ + "*" + ] + }, + "condition": { + "type": "LessThanEqualTo", + "value": 500 + } + }, + { + "id": "compute_under_budget", + "provider_args": { + "operation_type": "total_monthly_cost", + "resource_type": [ + "aws_instance" + ] + }, + "condition": { + "type": "LessThanEqualTo", + "value": 200 + } + } + ], + "eval_expression": "total_under_budget && compute_under_budget" + }, + "input": { + "version": "0.2", + "currency": "USD", + "projects": [ + { + "name": "acme", + "breakdown": { + "resources": [ + { + "name": "aws_instance.batch", + "resourceType": "aws_instance", + "monthlyCost": "612.40" + }, + { + "name": "aws_s3_bucket.artifacts", + "resourceType": "aws_s3_bucket", + "monthlyCost": "1.15" + } + ], + "totalMonthlyCost": "613.55" + } + } + ], + "totalMonthlyCost": "613.55" + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/infracost", + "name": "Planned monthly cost stays under 500 USD -- REQUIRES an Infracost breakdown as input, not a plan", + "severity": "medium" + }, + "final_result": false, + "evaluators": [ + { + "id": "total_under_budget", + "passed": false, + "result": [ + { + "passed": false, + "message": "`613.55` is not less than or equal to `500`", + "meta": null + } + ], + "description": "Run against `infracost breakdown --format json`, not against plan.json. In the GitHub Action's local mode Infracost is ignored with a warning, so this rule only reports when you generate the breakdown yourself." + }, + { + "id": "compute_under_budget", + "passed": false, + "result": [ + { + "passed": false, + "message": "`612.4` is not less than or equal to `200`", + "meta": null + } + ], + "description": null + } + ], + "errors": [], + "eval_expression": "total_under_budget && compute_under_budget" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job." + }, + { + "key": "08-provider-version-pinned", + "title": "The AWS provider is pinned to a bounded version range", + "severity": "medium", + "provider": "stackguardian/terraform_plan", + "operations": [ + "provider_config" + ], + "evaluatorCount": 1, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "The AWS provider is pinned to a bounded version range", + "severity": "medium" + }, + "evaluators": [ + { + "id": "aws_provider_bounded", + "description": "version_constraint must carry an upper bound -- '~> 5.0' or an explicit '< 6.0'. A bare '>= 5.0' lets a major release in on a Monday morning, and provider majors rewrite resource schemas: the plan that follows is full of replacements nobody asked for. This is a fact about the provider block, not about any one resource, which is why a per-resource policy engine has nowhere to put it.", + "provider_args": { + "operation_type": "provider_config", + "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws", + "attribute": "version_constraint" + }, + "condition": { + "type": "RegexMatch", + "value": "(~>|<=|<|==|^=?[0-9])", + "error_tolerance": 2 + } + } + ], + "eval_expression": "aws_provider_bounded" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_s3_bucket.a", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "a", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "a", + "tags": { + "Owner": "platform" + } + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": ">= 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "The AWS provider is pinned to a bounded version range", + "severity": "medium" + }, + "final_result": false, + "evaluators": [ + { + "id": "aws_provider_bounded", + "passed": false, + "result": [ + { + "passed": false, + "message": "`\">= 5.0\"` does not match regex pattern `\"(~>|<=|<|==|^=?[0-9])\"`", + "meta": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": ">= 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + ], + "description": "version_constraint must carry an upper bound -- '~> 5.0' or an explicit '< 6.0'. A bare '>= 5.0' lets a major release in on a Monday morning, and provider majors rewrite resource schemas: the plan that follows is full of replacements nobody asked for. This is a fact about the provider block, not about any one resource, which is why a per-resource policy engine has nowhere to put it." + } + ], + "errors": [], + "eval_expression": "aws_provider_bounded" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "09-no-credentials-in-resolved-values", + "title": "No credential material in resolved plan values", + "severity": "critical", + "provider": "stackguardian/terraform_plan", + "operations": [ + "attribute" + ], + "evaluatorCount": 4, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "No credential material in resolved plan values", + "severity": "critical" + }, + "evaluators": [ + { + "id": "key_in_user_data", + "description": "Detector, inverted in eval_expression. This is the case a scan of the source cannot reach: a key written literally into a .tf file is easy to catch, but one arriving through a variable default, a locals lookup or a tfvars file is invisible in the resource block and fully resolved in the plan. error_tolerance 2 because most plans have no user_data at all -- severity 2 is 'attribute not found', and a rule that found nothing to read must skip, not pass.", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "user_data" + }, + "condition": { + "type": "RegexMatch", + "value": "(AKIA|ASIA)[0-9A-Z]{16}", + "error_tolerance": 2 + } + }, + { + "id": "private_key_in_user_data", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "user_data" + }, + "condition": { + "type": "RegexMatch", + "value": "BEGIN [A-Z ]*PRIVATE KEY", + "error_tolerance": 2 + } + }, + { + "id": "key_in_container_definitions", + "description": "container_definitions is a JSON string in the plan. RegexMatch stringifies whatever it is handed, so the whole nested document is searchable without knowing its shape.", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_ecs_task_definition", + "terraform_resource_attribute": "container_definitions" + }, + "condition": { + "type": "RegexMatch", + "value": "(AKIA|ASIA)[0-9A-Z]{16}", + "error_tolerance": 2 + } + }, + { + "id": "key_in_lambda_environment", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_lambda_function", + "terraform_resource_attribute": "environment" + }, + "condition": { + "type": "RegexMatch", + "value": "(AKIA|ASIA)[0-9A-Z]{16}", + "error_tolerance": 2 + } + } + ], + "eval_expression": "!key_in_user_data && !private_key_in_user_data && !key_in_container_definitions && !key_in_lambda_environment" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.small", + "tags": { + "Owner": "platform" + }, + "user_data": "#!/bin/bash\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\naws s3 sync /data s3://backups\n" + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "No credential material in resolved plan values", + "severity": "critical" + }, + "final_result": false, + "evaluators": [ + { + "id": "key_in_user_data", + "passed": true, + "result": [ + { + "passed": true, + "message": "`\"#!/bin/bash\\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\\naws s3 sync /data s3://backups\\n\"` matches regex pattern `\"(AKIA|ASIA)[0-9A-Z]{16}\"`", + "meta": { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.small", + "tags": { + "Owner": "platform" + }, + "user_data": "#!/bin/bash\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\naws s3 sync /data s3://backups\n" + } + } + } + } + ], + "description": "Detector, inverted in eval_expression. This is the case a scan of the source cannot reach: a key written literally into a .tf file is easy to catch, but one arriving through a variable default, a locals lookup or a tfvars file is invisible in the resource block and fully resolved in the plan. error_tolerance 2 because most plans have no user_data at all -- severity 2 is 'attribute not found', and a rule that found nothing to read must skip, not pass." + }, + { + "id": "private_key_in_user_data", + "passed": false, + "result": [ + { + "passed": false, + "message": "`\"#!/bin/bash\\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\\naws s3 sync /data s3://backups\\n\"` does not match regex pattern `\"BEGIN [A-Z ]*PRIVATE KEY\"`", + "meta": { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.small", + "tags": { + "Owner": "platform" + }, + "user_data": "#!/bin/bash\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\naws s3 sync /data s3://backups\n" + } + } + } + } + ], + "description": null + }, + { + "id": "key_in_container_definitions", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_ecs_task_definition' is not found", + "passed": null + } + ], + "description": "container_definitions is a JSON string in the plan. RegexMatch stringifies whatever it is handed, so the whole nested document is searchable without knowing its shape." + }, + { + "id": "key_in_lambda_environment", + "passed": null, + "result": [ + { + "message": "resource_type: 'aws_lambda_function' is not found", + "passed": null + } + ], + "description": null + } + ], + "errors": [], + "eval_expression": "!key_in_user_data && !private_key_in_user_data && !key_in_container_definitions && !key_in_lambda_environment" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "10-root-module-size", + "title": "This root module holds no more than 200 resources", + "severity": "low", + "provider": "stackguardian/terraform_plan", + "operations": [ + "count" + ], + "evaluatorCount": 1, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "This root module holds no more than 200 resources", + "severity": "low" + }, + "evaluators": [ + { + "id": "under_two_hundred", + "description": "Named for what it measures. `count` counts every entry in resource_changes, and Terraform includes unchanged resources as no-op entries -- so this is the SIZE OF THE ROOT MODULE, not the size of the change. A ceiling on module size is still worth having: a 900-resource root module means every plan is slow, every apply is high-stakes, and every lock contends. For a ceiling on what a change TOUCHES, see policies-pending/blast-radius.json.", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "*" + }, + "condition": { + "type": "LessThanEqualTo", + "value": 200 + } + } + ], + "eval_expression": "under_two_hundred" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_s3_bucket.b0", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b0", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b0", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b1", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b1", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b1", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b2", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b2", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b2", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b3", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b3", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b3", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b4", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b4", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b4", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b5", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b5", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b5", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b6", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b6", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b6", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b7", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b7", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b7", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b8", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b8", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b8", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b9", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b9", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b9", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b10", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b10", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b10", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b11", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b11", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b11", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b12", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b12", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b12", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b13", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b13", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b13", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b14", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b14", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b14", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b15", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b15", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b15", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b16", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b16", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b16", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b17", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b17", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b17", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b18", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b18", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b18", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b19", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b19", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b19", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b20", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b20", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b20", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b21", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b21", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b21", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b22", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b22", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b22", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b23", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b23", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b23", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b24", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b24", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b24", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b25", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b25", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b25", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b26", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b26", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b26", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b27", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b27", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b27", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b28", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b28", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b28", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b29", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b29", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b29", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b30", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b30", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b30", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b31", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b31", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b31", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b32", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b32", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b32", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b33", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b33", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b33", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b34", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b34", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b34", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b35", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b35", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b35", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b36", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b36", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b36", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b37", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b37", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b37", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b38", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b38", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b38", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b39", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b39", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b39", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b40", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b40", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b40", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b41", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b41", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b41", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b42", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b42", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b42", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b43", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b43", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b43", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b44", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b44", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b44", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b45", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b45", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b45", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b46", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b46", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b46", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b47", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b47", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b47", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b48", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b48", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b48", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b49", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b49", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b49", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b50", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b50", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b50", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b51", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b51", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b51", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b52", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b52", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b52", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b53", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b53", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b53", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b54", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b54", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b54", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b55", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b55", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b55", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b56", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b56", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b56", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b57", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b57", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b57", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b58", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b58", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b58", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b59", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b59", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b59", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b60", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b60", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b60", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b61", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b61", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b61", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b62", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b62", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b62", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b63", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b63", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b63", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b64", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b64", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b64", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b65", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b65", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b65", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b66", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b66", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b66", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b67", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b67", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b67", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b68", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b68", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b68", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b69", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b69", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b69", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b70", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b70", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b70", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b71", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b71", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b71", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b72", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b72", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b72", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b73", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b73", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b73", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b74", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b74", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b74", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b75", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b75", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b75", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b76", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b76", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b76", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b77", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b77", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b77", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b78", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b78", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b78", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b79", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b79", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b79", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b80", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b80", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b80", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b81", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b81", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b81", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b82", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b82", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b82", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b83", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b83", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b83", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b84", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b84", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b84", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b85", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b85", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b85", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b86", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b86", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b86", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b87", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b87", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b87", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b88", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b88", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b88", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b89", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b89", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b89", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b90", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b90", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b90", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b91", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b91", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b91", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b92", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b92", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b92", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b93", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b93", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b93", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b94", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b94", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b94", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b95", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b95", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b95", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b96", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b96", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b96", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b97", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b97", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b97", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b98", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b98", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b98", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b99", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b99", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b99", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b100", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b100", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b100", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b101", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b101", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b101", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b102", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b102", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b102", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b103", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b103", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b103", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b104", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b104", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b104", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b105", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b105", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b105", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b106", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b106", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b106", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b107", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b107", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b107", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b108", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b108", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b108", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b109", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b109", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b109", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b110", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b110", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b110", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b111", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b111", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b111", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b112", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b112", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b112", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b113", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b113", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b113", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b114", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b114", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b114", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b115", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b115", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b115", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b116", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b116", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b116", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b117", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b117", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b117", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b118", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b118", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b118", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b119", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b119", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b119", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b120", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b120", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b120", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b121", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b121", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b121", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b122", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b122", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b122", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b123", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b123", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b123", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b124", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b124", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b124", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b125", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b125", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b125", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b126", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b126", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b126", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b127", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b127", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b127", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b128", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b128", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b128", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b129", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b129", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b129", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b130", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b130", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b130", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b131", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b131", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b131", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b132", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b132", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b132", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b133", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b133", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b133", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b134", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b134", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b134", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b135", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b135", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b135", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b136", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b136", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b136", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b137", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b137", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b137", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b138", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b138", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b138", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b139", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b139", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b139", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b140", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b140", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b140", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b141", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b141", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b141", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b142", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b142", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b142", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b143", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b143", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b143", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b144", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b144", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b144", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b145", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b145", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b145", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b146", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b146", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b146", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b147", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b147", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b147", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b148", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b148", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b148", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b149", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b149", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b149", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b150", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b150", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b150", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b151", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b151", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b151", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b152", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b152", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b152", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b153", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b153", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b153", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b154", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b154", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b154", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b155", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b155", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b155", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b156", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b156", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b156", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b157", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b157", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b157", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b158", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b158", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b158", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b159", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b159", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b159", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b160", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b160", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b160", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b161", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b161", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b161", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b162", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b162", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b162", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b163", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b163", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b163", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b164", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b164", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b164", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b165", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b165", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b165", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b166", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b166", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b166", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b167", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b167", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b167", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b168", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b168", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b168", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b169", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b169", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b169", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b170", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b170", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b170", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b171", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b171", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b171", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b172", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b172", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b172", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b173", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b173", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b173", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b174", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b174", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b174", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b175", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b175", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b175", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b176", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b176", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b176", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b177", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b177", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b177", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b178", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b178", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b178", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b179", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b179", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b179", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b180", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b180", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b180", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b181", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b181", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b181", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b182", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b182", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b182", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b183", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b183", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b183", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b184", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b184", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b184", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b185", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b185", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b185", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b186", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b186", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b186", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b187", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b187", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b187", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b188", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b188", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b188", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b189", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b189", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b189", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b190", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b190", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b190", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b191", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b191", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b191", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b192", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b192", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b192", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b193", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b193", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b193", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b194", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b194", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b194", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b195", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b195", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b195", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b196", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b196", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b196", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b197", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b197", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b197", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b198", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b198", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b198", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b199", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b199", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b199", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b200", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b200", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b200", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b201", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b201", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b201", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b202", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b202", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b202", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b203", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b203", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b203", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b204", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b204", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b204", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b205", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b205", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b205", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b206", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b206", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b206", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b207", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b207", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b207", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b208", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b208", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b208", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b209", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b209", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b209", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b210", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b210", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b210", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b211", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b211", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b211", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b212", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b212", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b212", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b213", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b213", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b213", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b214", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b214", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b214", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b215", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b215", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b215", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b216", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b216", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b216", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b217", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b217", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b217", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b218", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b218", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b218", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b219", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b219", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b219", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b220", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b220", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b220", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b221", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b221", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b221", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b222", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b222", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b222", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b223", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b223", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b223", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b224", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b224", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b224", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b225", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b225", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b225", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b226", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b226", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b226", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b227", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b227", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b227", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b228", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b228", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b228", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b229", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b229", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b229", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b230", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b230", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b230", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b231", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b231", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b231", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b232", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b232", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b232", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b233", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b233", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b233", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b234", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b234", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b234", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b235", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b235", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b235", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b236", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b236", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b236", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.b237", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "b237", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": null, + "after": { + "bucket": "b237", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_s3_bucket.new", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "new", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "new", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_kms_key.k", + "mode": "managed", + "type": "aws_kms_key", + "name": "k", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "update" + ], + "before": null, + "after": { + "description": "k", + "tags": { + "Owner": "platform" + } + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "This root module holds no more than 200 resources", + "severity": "low" + }, + "final_result": false, + "evaluators": [ + { + "id": "under_two_hundred", + "passed": false, + "result": [ + { + "passed": false, + "message": "`240` is not less than or equal to `200`", + "meta": { + "address": "aws_kms_key.k", + "mode": "managed", + "type": "aws_kms_key", + "name": "k", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "update" + ], + "before": null, + "after": { + "description": "k", + "tags": { + "Owner": "platform" + } + } + } + } + } + ], + "description": "Named for what it measures. `count` counts every entry in resource_changes, and Terraform includes unchanged resources as no-op entries -- so this is the SIZE OF THE ROOT MODULE, not the size of the change. A ceiling on module size is still worth having: a 900-resource root module means every plan is slow, every apply is high-stakes, and every lock contends. For a ceiling on what a change TOUCHES, see policies-pending/blast-radius.json." + } + ], + "errors": [], + "eval_expression": "under_two_hundred" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "11-prohibited-resource-types", + "title": "Resource types the organisation has retired do not appear", + "severity": "medium", + "provider": "stackguardian/terraform_plan", + "operations": [ + "count" + ], + "evaluatorCount": 4, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Resource types the organisation has retired do not appear", + "severity": "medium" + }, + "evaluators": [ + { + "id": "no_iam_users", + "description": "count == 0 is an assertion about the whole plan rather than about any resource in it -- a shape a per-resource policy engine has nowhere to put, because there is no resource to attach the finding to when the answer is 'none, correctly'. Long-lived IAM users are the standard example: roles and OIDC federation replaced them, and the rule that keeps them gone has to be able to say 'zero'.", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "aws_iam_user" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_iam_access_keys", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "aws_iam_access_key" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_inline_bucket_objects", + "description": "aws_s3_bucket_object was deprecated in favour of aws_s3_object.", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "aws_s3_bucket_object" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_default_vpc_adoption", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "aws_default_vpc" + }, + "condition": { + "type": "Equals", + "value": 0 + } + } + ], + "eval_expression": "no_iam_users && no_iam_access_keys && no_inline_bucket_objects && no_default_vpc_adoption" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_iam_user.ci", + "mode": "managed", + "type": "aws_iam_user", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "ci-deploy", + "tags": { + "Owner": "platform" + } + } + } + }, + { + "address": "aws_iam_access_key.ci", + "mode": "managed", + "type": "aws_iam_access_key", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "user": "ci-deploy" + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Resource types the organisation has retired do not appear", + "severity": "medium" + }, + "final_result": false, + "evaluators": [ + { + "id": "no_iam_users", + "passed": false, + "result": [ + { + "passed": false, + "message": "`1` is not equal to `0`", + "meta": { + "address": "aws_iam_user.ci", + "mode": "managed", + "type": "aws_iam_user", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "ci-deploy", + "tags": { + "Owner": "platform" + } + } + } + } + } + ], + "description": "count == 0 is an assertion about the whole plan rather than about any resource in it -- a shape a per-resource policy engine has nowhere to put, because there is no resource to attach the finding to when the answer is 'none, correctly'. Long-lived IAM users are the standard example: roles and OIDC federation replaced them, and the rule that keeps them gone has to be able to say 'zero'." + }, + { + "id": "no_iam_access_keys", + "passed": false, + "result": [ + { + "passed": false, + "message": "`1` is not equal to `0`", + "meta": { + "address": "aws_iam_access_key.ci", + "mode": "managed", + "type": "aws_iam_access_key", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "user": "ci-deploy" + } + } + } + } + ], + "description": null + }, + { + "id": "no_inline_bucket_objects", + "passed": true, + "result": [ + { + "passed": true, + "message": "`0` is equal to `0`", + "meta": {} + } + ], + "description": "aws_s3_bucket_object was deprecated in favour of aws_s3_object." + }, + { + "id": "no_default_vpc_adoption", + "passed": true, + "result": [ + { + "passed": true, + "message": "`0` is equal to `0`", + "meta": {} + } + ], + "description": null + } + ], + "errors": [], + "eval_expression": "no_iam_users && no_iam_access_keys && no_inline_bucket_objects && no_default_vpc_adoption" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "12-no-open-ingress", + "title": "Security group rules do not open to the whole internet", + "severity": "high", + "provider": "stackguardian/terraform_plan", + "operations": [ + "attribute" + ], + "evaluatorCount": 2, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Security group rules do not open to the whole internet", + "severity": "high" + }, + "evaluators": [ + { + "id": "legacy_rule_not_open", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_security_group_rule", + "terraform_resource_attribute": "cidr_blocks" + }, + "condition": { + "type": "NotContains", + "value": "0.0.0.0/0", + "error_tolerance": 1 + } + }, + { + "id": "vpc_ingress_rule_not_open", + "description": "aws_vpc_security_group_ingress_rule is the current resource and takes a single cidr_ipv4 string rather than a list.", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_vpc_security_group_ingress_rule", + "terraform_resource_attribute": "cidr_ipv4" + }, + "condition": { + "type": "NotEquals", + "value": "0.0.0.0/0", + "error_tolerance": 1 + } + } + ], + "eval_expression": "legacy_rule_not_open && vpc_ingress_rule_not_open" + }, + "input": { + "format_version": "1.2", + "terraform_version": "1.11.4", + "resource_changes": [ + { + "address": "aws_security_group_rule.ssh", + "mode": "managed", + "type": "aws_security_group_rule", + "name": "ssh", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "type": "ingress", + "from_port": 22, + "to_port": 22, + "protocol": "tcp", + "cidr_blocks": [ + "10.0.0.0/8", + "0.0.0.0/0" + ] + } + } + }, + { + "address": "aws_vpc_security_group_ingress_rule.api", + "mode": "managed", + "type": "aws_vpc_security_group_ingress_rule", + "name": "api", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "from_port": 443, + "to_port": 443, + "ip_protocol": "tcp", + "cidr_ipv4": "0.0.0.0/0" + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + } + } + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Security group rules do not open to the whole internet", + "severity": "high" + }, + "final_result": false, + "evaluators": [ + { + "id": "legacy_rule_not_open", + "passed": false, + "result": [ + { + "passed": false, + "message": "Found `\"0.0.0.0/0\"` inside `[\"0.0.0.0/0\", \"10.0.0.0/8\"]`", + "meta": { + "address": "aws_security_group_rule.ssh", + "mode": "managed", + "type": "aws_security_group_rule", + "name": "ssh", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "type": "ingress", + "from_port": 22, + "to_port": 22, + "protocol": "tcp", + "cidr_blocks": [ + "10.0.0.0/8", + "0.0.0.0/0" + ] + } + } + } + } + ], + "description": null + }, + { + "id": "vpc_ingress_rule_not_open", + "passed": false, + "result": [ + { + "passed": false, + "message": "`\"0.0.0.0/0\"` is equal to `\"0.0.0.0/0\"`", + "meta": { + "address": "aws_vpc_security_group_ingress_rule.api", + "mode": "managed", + "type": "aws_vpc_security_group_ingress_rule", + "name": "api", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "from_port": 443, + "to_port": 443, + "ip_protocol": "tcp", + "cidr_ipv4": "0.0.0.0/0" + } + } + } + } + ], + "description": "aws_vpc_security_group_ingress_rule is the current resource and takes a single cidr_ipv4 string rather than a list." + } + ], + "errors": [], + "eval_expression": "legacy_rule_not_open && vpc_ingress_rule_not_open" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job.", + "clean": { + "exitCode": 0, + "outcome": "passed", + "meaning": "Every check that ran passed." + } + }, + { + "key": "13-scanner-output-is-trustworthy", + "title": "The resource scanner ran completely and reported nothing failing", + "severity": "high", + "provider": "stackguardian/json", + "operations": [ + "get_value" + ], + "evaluatorCount": 3, + "policy": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/json", + "name": "The resource scanner ran completely and reported nothing failing", + "severity": "high" + }, + "evaluators": [ + { + "id": "scanner_parsed_everything", + "description": "Run against your resource scanner's own JSON output, not against a plan. This first check is the reason the policy exists: a scanner that silently failed to parse half the repository reports a clean run, and almost nobody asserts otherwise. Governing another tool's output with the same policy language, the same severity vocabulary and the same exit codes means four tools can behave like one gate.", + "provider_args": { + "operation_type": "get_value", + "key_path": "summary.parsing_errors" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_failing_checks", + "provider_args": { + "operation_type": "get_value", + "key_path": "summary.failed" + }, + "condition": { + "type": "LessThanEqualTo", + "value": 0 + } + }, + { + "id": "scanner_actually_checked_something", + "description": "A run that evaluated nothing at all passes every other assertion here.", + "provider_args": { + "operation_type": "get_value", + "key_path": "summary.passed" + }, + "condition": { + "type": "GreaterThan", + "value": 0 + } + } + ], + "eval_expression": "scanner_parsed_everything && no_failing_checks && scanner_actually_checked_something" + }, + "input": { + "summary": { + "passed": 0, + "failed": 3, + "skipped": 1, + "parsing_errors": 2, + "resource_count": 41 + }, + "results": { + "failed_checks": [ + { + "check_id": "CKV_AWS_18", + "check_name": "Ensure the S3 bucket has access logging enabled", + "resource": "aws_s3_bucket.analytics", + "file_path": "/main.tf", + "severity": "LOW" + }, + { + "check_id": "CKV_AWS_21", + "check_name": "Ensure all data stored in the S3 bucket have versioning enabled", + "resource": "aws_s3_bucket.analytics", + "file_path": "/main.tf", + "severity": "LOW" + }, + { + "check_id": "CKV_AWS_145", + "check_name": "Ensure that S3 buckets are encrypted with KMS by default", + "resource": "aws_s3_bucket.analytics", + "file_path": "/main.tf", + "severity": "HIGH" + } + ] + } + }, + "result": { + "meta": { + "version": "v1", + "required_provider": "stackguardian/json", + "name": "The resource scanner ran completely and reported nothing failing", + "severity": "high" + }, + "final_result": false, + "evaluators": [ + { + "id": "scanner_parsed_everything", + "passed": false, + "result": [ + { + "passed": false, + "message": "`2` is not equal to `0`", + "meta": null + } + ], + "description": "Run against your resource scanner's own JSON output, not against a plan. This first check is the reason the policy exists: a scanner that silently failed to parse half the repository reports a clean run, and almost nobody asserts otherwise. Governing another tool's output with the same policy language, the same severity vocabulary and the same exit codes means four tools can behave like one gate." + }, + { + "id": "no_failing_checks", + "passed": false, + "result": [ + { + "passed": false, + "message": "`3` is not less than or equal to `0`", + "meta": null + } + ], + "description": null + }, + { + "id": "scanner_actually_checked_something", + "passed": false, + "result": [ + { + "passed": false, + "message": "`0` is not greater than `0`", + "meta": null + } + ], + "description": "A run that evaluated nothing at all passes every other assertion here." + } + ], + "errors": [], + "eval_expression": "scanner_parsed_everything && no_failing_checks && scanner_actually_checked_something" + }, + "exitCode": 3, + "exitMeaning": "A check ran and failed. With --fail-on-error this stops the job." + } + ] +} diff --git a/documentation/src/pages/ai.js b/documentation/src/pages/ai.js new file mode 100644 index 00000000..d9bc6594 --- /dev/null +++ b/documentation/src/pages/ai.js @@ -0,0 +1,363 @@ +import Link from '@docusaurus/Link'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +import {EVENTS, track, usePageView} from '../analytics'; +import { + Action, + DataTable, + Hero, + PageShell, + REPO, + Section, + Todo, + TrackedCode, + issueUrl, + styles, +} from '../components/site'; + +/* + * --------------------------------------------------------------------------- + * AI + * + * Job: show that Tirith is usable from a coding agent without the page + * becoming the "vague AI-first copy" the messaging brief rules out. + * + * The discipline that keeps it concrete: every claim on this page names a + * specific tool, a specific file or a specific command, and the install links + * are real. The Tirith MCP server is a local stdio process (`tirith mcp`), so + * the Cursor and VS Code deeplinks below encode an actual working config + * rather than pointing at a hosted endpoint that would need an account. + * + * The one thing that is NOT real yet is the StackGuardian MCP package name -- + * it is a config constant with a visible TODO, the same pattern as the HubSpot + * form and the sign-up URL. + * --------------------------------------------------------------------------- + */ + +// A local stdio server: no endpoint, no account, nothing to host. That is what +// makes genuine one-click installation possible here. +const LOCAL_CONFIG = {command: 'tirith', args: ['mcp']}; + +// Generated from LOCAL_CONFIG -- Cursor takes base64 JSON, VS Code takes +// URL-encoded JSON. Precomputed rather than encoded at render time because +// btoa is browser-only and this page is server-rendered too. +const CURSOR_LINK = + 'cursor://anysphere.cursor-deeplink/mcp/install?name=tirith&config=eyJjb21tYW5kIjoidGlyaXRoIiwiYXJncyI6WyJtY3AiXX0='; +const VSCODE_LINK = + 'vscode:mcp/install?%7B%22name%22%3A%22tirith%22%2C%22command%22%3A%22tirith%22%2C%22args%22%3A%5B%22mcp%22%5D%7D'; + +/* + * The StackGuardian MCP server: a local package over stdio, reading the same + * SG_API_TOKEN and SG_ORG that platform mode already uses. The package name is + * not published here because it has not been supplied -- set it and the whole + * section below turns into working install instructions. + */ +const SG_MCP_PACKAGE = null; + +const TOOLS = [ + [ + 'evaluate', + 'Run a policy against a document and return the real verdict (passed, failed or unevaluated) with the exit code a pipeline would see.', + 'The one that stops the guessing. A policy that matches nothing looks identical to one that works until it is run.', + ], + [ + 'lint_policy', + 'Check a policy’s shape before it runs: unknown condition types, missing eval_expression, evaluators the expression never references.', + 'An unknown condition type reaches the engine as an ordinary failed check, so it reads as a real violation. Catching it here saves debugging infrastructure that is fine.', + ], + [ + 'describe_provider', + 'List the providers, the operation_type values each accepts, and every condition type, read from the engine’s own registries.', + 'Agents invent plausible vocabulary. There is no IsPresent, no Exists, no Matches. This is the closed list.', + ], + [ + 'explain_result', + 'Turn a result document into which rule failed, on which resource, and why.', + 'Point it at the JSON from a red CI job. It also says when a failure carries no resource address, rather than leaving you to wonder.', + ], +]; + +const SKILL_FILES = [ + [ + '.claude/skills/tirith-policies/SKILL.md', + 'Claude Code, Claude Desktop', + 'The policy-authoring skill: schema, the closed condition list, provider operations, how to read a verdict, and the instruction to evaluate before claiming a policy works. Self-contained, so it can be copied into any repository.', + ], + [ + 'AGENTS.md', + 'Codex, Cursor, Zed, and anything else that reads the convention', + 'For an agent working on Tirith itself: layout, how to run the suite, and the contracts that will bite: exit codes, the golden-file output test, optional extras that must degrade rather than crash.', + ], + [ + '.cursor/rules/tirith-policies.mdc', + 'Cursor', + 'The same vocabulary as the skill, scoped with globs so it attaches automatically when a file under .tirith/policies is open.', + ], +]; + +export default function Ai() { + usePageView(EVENTS.aiView); + + return ( + + + +
    +

    + Ask an agent for “a policy requiring an Owner tag on every resource” and you will + usually get something like {'"condition": {"type": "Exists"}'}. It reads + correctly. It is not a condition type Tirith has. +

    +

    + The expensive part is what happens next. An unknown condition type does not raise an + error. The engine returns it as an ordinary failed check with no error attached, so it + is indistinguishable from a genuine violation. The build goes red, and somebody spends + an afternoon looking at infrastructure that was fine all along. +

    +

    + The fix is not a better prompt. It is giving the agent the closed list, and making it + run the policy before it claims the policy works. +

    +
    + +
    +

    + tirith mcp speaks the Model Context Protocol over stdio. Your editor starts + it; it makes no network calls and writes nothing to disk, so pointing an agent at it + cannot change anyone’s infrastructure. +

    + +
    + {TOOLS.map(([name, what, why]) => ( +
    +

    + {name} +

    +

    {what}

    +

    + {why} +

    +
    + ))} +
    +
    + +
    +

    + The server ships as an optional extra, on the same terms as the interactive interface: + a CI gate should not pay install time for a server it never starts. +

    + + + {"pip install 'py-tirith[mcp] @ git+https://github.com/StackGuardian/tirith.git'"} + +

    + Needs Python 3.10 or newer; Tirith itself supports 3.8, which is why this is an extra + rather than a dependency. +

    + + + + + {'claude mcp add tirith -- tirith mcp'} + +

    + The skill file is picked up automatically from{' '} + .claude/skills/ when you work in a repository that has it. See{' '} + skill files below. +

    +
    + + +

    + +

    +

    + One click, because the link carries the whole configuration. Cursor also reads{' '} + .cursor/rules/tirith-policies.mdc automatically when a policy file is + open. +

    +
    + + +

    + +

    +

    + Opens VS Code’s MCP install prompt with the server pre-filled. On Insiders, swap the + scheme for vscode-insiders:. +

    +
    + + +

    + Add to claude_desktop_config.json: +

    + + {JSON.stringify({mcpServers: {tirith: LOCAL_CONFIG}}, null, 2)} + +
    + + +

    + Add to ~/.codex/config.toml: +

    + + {'[mcp_servers.tirith]\ncommand = "tirith"\nargs = ["mcp"]'} + +
    + + +

    + Any MCP client takes a command and arguments. There is no endpoint and no token: +

    + + {JSON.stringify(LOCAL_CONFIG, null, 2)} + +
    +
    +
    + +
    +

    + The MCP server is the better experience because the agent can evaluate a + policy. But most of the value is knowing the vocabulary, and that is just a file: no + install, no extra, no Python version floor. All three live in this repository and can be + copied into yours. +

    + + [ + + {path} + , + client, + what, + ])} + /> + + + {'# Copy the policy-authoring skill into your own repository\n' + + 'mkdir -p .claude/skills\n' + + 'curl -sL https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies/SKILL.md \\\n' + + ' -o .claude/skills/tirith-policies/SKILL.md'} + +
    + +
    +

    + An agent with the Tirith server can write and prove a policy in the repository in front + of it. What it cannot do is tell you which of your two hundred repositories have no gate + at all, which teams pinned four different provider versions, or which pipeline applies + without a reviewed plan, because none of that is in the repository it is looking at. +

    +

    + The StackGuardian MCP server puts that estate-wide view in the same conversation: ask + which repositories are ungoverned, ask what a finding means, and open the installation + pull requests from where you already are. +

    + + {SG_MCP_PACKAGE ? ( + + {`claude mcp add stackguardian -- npx -y ${SG_MCP_PACKAGE}`} + + ) : ( + + The StackGuardian MCP server is a local package over stdio, authenticated with the same{' '} + SG_API_TOKEN and SG_ORG that platform mode already uses, but + the package name has not been supplied, so no install command is shown and no + one-click link is generated. Set SG_MCP_PACKAGE in this file and the + command, the Cursor deeplink and the VS Code deeplink all follow from it. Until then + this section describes something a reader cannot install. + + )} + +
    + + +
    +
    + +
    +

    + Worth being explicit, because “AI” on a governance page usually means something vaguer + than this. +

    +
      +
    • + Nothing here remediates your infrastructure. The tools read documents + and return verdicts. An agent may propose a code change; a human reviews and merges it, + as before. +
    • +
    • + A drafted policy is a draft. Generated JSON is worth no more than the + evaluation that follows it, which is why evaluate exists and why the + skill file says never to hand back a policy you have not run against a document that + should fail it. +
    • +
    • + The engine is the arbiter, not the model. Every verdict on this site, + and every verdict these tools return, comes from the same evaluator your pipeline runs. +
    • +
    • + Nothing leaves your machine. The Tirith server makes no network call. + Your agent may be a hosted model, which is between you and your agent, but the + evaluation is local. +
    • +
    +
    + +
    +

    + The quickest way to see whether this is worth installing: open the Playground, take a + policy that catches a violation, and ask your agent to explain the verdict. If it can do + that from the result document alone, you do not need the server. If it cannot, that is + what explain_result is for. +

    +
    + + +
    +
      +
    • + + Report an issue with the server + +
    • +
    • + Working on Tirith itself +
    • +
    +
    +
    + ); +} diff --git a/documentation/src/pages/fleet.js b/documentation/src/pages/fleet.js new file mode 100644 index 00000000..d4f9f4cc --- /dev/null +++ b/documentation/src/pages/fleet.js @@ -0,0 +1,450 @@ +import {useState} from 'react'; +import Link from '@docusaurus/Link'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; + +import {EVENTS, capture, track, usePageView} from '../analytics'; +import { + Action, + AssetGrid, + DataTable, + Hero, + NEW_ISSUE, + PageShell, + issueUrl, + REPO, + Section, + Todo, + VisualSlot, + styles, +} from '../components/site'; + +/* + * --------------------------------------------------------------------------- + * FLEET + * + * Job: explain the commercial progression when a team needs to discover, + * standardise, approve and evidence Tirith governance across many IaC + * repositories -- without making StackGuardian a condition of OSS use. + * + * Two rules shape everything below, and both come from the brief: + * + * 1. The first viewport must state that Tirith OSS is free, independent and + * needs no StackGuardian account. It does, in the hero body and again in + * the trust line. + * + * 2. The commercial CTA never outranks `Use Tirith OSS`. On this page -- + * the one page where a commercial CTA is legitimately primary -- the OSS + * route still appears beside it every time, never buried. + * + * Capabilities are tagged available or planned. Direct execution initiated + * from Tirith by calling the StackGuardian workflow API is planned, not + * shipped, and is labelled as such rather than described in the present tense. + * --------------------------------------------------------------------------- + */ + +const STAGES = [ + ['Discover', 'Find Terraform and OpenTofu repositories and pipelines with read access; show coverage confidence and gaps.', 'available'], + ['Prioritise', 'Rank which repositories need governance first by verified severity and exposure signals.', 'available'], + ['Install', 'Open minimal Tirith pull requests for repository owners to review, and it does not write directly to protected branches.', 'available'], + ['Standardise', 'Manage central policy and evaluate it continuously across GitHub Actions, GitLab CI and other pipelines.', 'available'], + ['Approve', 'Add policy-aware approvals, credential brokering and private user-owned runners without rewriting the developer workflow.', 'available'], + ['Remediate', 'Stage an explainable code change for human approval, with file and line evidence and an audit trail.', 'available'], + ['Execute', 'Keep your current apply jobs, or move execution into StackGuardian for revisions, snapshots, recovery and notifications.', 'available'], + ['Execute from Tirith', 'Tirith calling the StackGuardian workflow API directly to move from policy decision into controlled execution.', 'planned'], +]; + +const COMPARISON = [ + ['Evaluate a Terraform or OpenTofu plan', 'Included', 'Included'], + ['Run with no account and no network', 'Included', 'Not applicable; connection is explicit'], + ['Policies and results in each repository', 'Included', 'Included'], + ['Discover IaC repositories and open rollout PRs', 'Manual', 'Included'], + ['Central policy, history and plan visualisation', 'None', 'Included'], + ['Approvals, credential brokering and audit', 'Use your existing CI tools', 'Included'], + ['Assisted prioritisation and remediation', 'None', 'Included; verify entitlement'], + ['Drift, snapshots, recovery and notifications', 'None', 'Where execution or state is connected'], + ['Private user-owned runtime', 'Your own CI runner', 'Included'], +]; + +const FAQ = [ + [ + 'Do I need StackGuardian to use Tirith?', + 'No. Local Tirith evaluation is Apache-2.0, runs wherever your pipeline runs, and requires no account and no network connection. That is a governance commitment rather than current behaviour. See GOVERNANCE.md.', + ], + [ + 'What changes when I connect?', + 'You explicitly supply a StackGuardian organisation and token; there is no other switch. Tirith masks Terraform-sensitive values locally, then can send the masked plan, results, metadata and, unless you disable it with --no-source, the related source.', + ], + [ + 'Can StackGuardian replace or control my repositories?', + 'Installation and remediation arrive as pull requests for repository owners to approve. It does not write directly to protected branches, and your existing CI and apply jobs can stay exactly as they are.', + ], + [ + 'Can the runtime remain ours?', + 'Yes. A private runtime fully owned and operated by you is supported; its network and control-plane requirements are covered in the technical follow-up.', + ], + [ + 'What stays open source?', + 'The policy schema, the providers, the CLI and local action contract, and the example policy library are all usable without any commercial relationship, subject to the published governance commitments.', + ], +]; + +const REPO_BANDS = ['1–10', '11–50', '51–250', '250+']; +const CI_SYSTEMS = ['GitHub Actions', 'GitLab CI', 'Azure DevOps', 'Jenkins', 'Other']; +const PROBLEMS = ['Visibility', 'Policy consistency', 'Approvals', 'Remediation', 'Audit', 'Governed execution']; + +/** + * The enquiry form. + * + * Submits to HubSpot's forms API directly from the browser, which is what that + * endpoint is designed for, so a static site needs no backend. The portal id + * and form guid come from build-time configuration rather than being committed + * here -- and when they are absent the form disables itself and says why, + * instead of silently posting into the void. + * + * Analytics never sees free text. Only the enumerated fields -- repository + * band and primary problem -- are captured; email, organisation and the + * context box are not. + */ +function FleetForm() { + const {siteConfig} = useDocusaurusContext(); + const {hubspotPortalId, hubspotFormGuid} = siteConfig.customFields || {}; + const configured = Boolean(hubspotPortalId && hubspotFormGuid); + + const [state, setState] = useState('idle'); + const [started, setStarted] = useState(false); + + const onFirstInput = () => { + if (!started) { + setStarted(true); + capture(EVENTS.fleetFormStart); + } + }; + + const onSubmit = async (event) => { + event.preventDefault(); + if (!configured) return; + + const data = new FormData(event.target); + const band = data.get('repository_band'); + const problem = data.get('primary_problem'); + const ci = data.getAll('ci_systems'); + + // Enumerated values only. Never the email, organisation or context box. + capture(EVENTS.fleetFormSubmit, { + repo_count_band: band, + primary_problem: problem, + ci_systems: ci.join(','), + }); + + setState('sending'); + try { + const response = await fetch( + `https://api.hsforms.com/submissions/v3/integration/submit/${hubspotPortalId}/${hubspotFormGuid}`, + { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + fields: [ + {objectTypeId: '0-1', name: 'email', value: data.get('email')}, + {objectTypeId: '0-1', name: 'company', value: data.get('company')}, + {objectTypeId: '0-1', name: 'repository_band', value: band}, + {objectTypeId: '0-1', name: 'ci_systems', value: ci.join('; ')}, + {objectTypeId: '0-1', name: 'primary_problem', value: problem}, + {objectTypeId: '0-1', name: 'context', value: data.get('context') || ''}, + ], + context: { + pageUri: typeof window !== 'undefined' ? window.location.href : '', + pageName: 'Tirith Fleet governance', + }, + }), + }, + ); + setState(response.ok ? 'sent' : 'failed'); + } catch { + setState('failed'); + } + }; + + if (state === 'sent') { + return ( +

    + Thanks. While we review your setup, govern one repository locally with + Tirith. The quick start needs no + account and takes about two lines. +

    + ); + } + + return ( + <> + {configured ? null : ( + + The HubSpot portal ID and form GUID are not configured, so this form is disabled. Set{' '} + HUBSPOT_PORTAL_ID and HUBSPOT_FORM_GUID in the docs deploy + workflow, and create the matching HubSpot properties:{' '} + email, company, repository_band,{' '} + ci_systems, primary_problem, context. Until then, + the GitHub issue route below is the working path. + + )} + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + {/* A real : `as` is not a prop React forwards, so the + previous markup rendered a
    + +
    + + +
    + +
    + +