diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b97a00a..70c3aaf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,46 @@ -on: [push] +name: test + +on: + push: + branches: [main] + pull_request: + jobs: - execute-sg-cli: + test: runs-on: ubuntu-latest - name: StackGuardian CLI Github Action - env: - SG_API_TOKEN: 'sgu_xyz' + strategy: + matrix: + python: ["3.9", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 - - name: StackGuardian/workflow-run-action - uses: ./ # Uses an action in the root directory - id: StackGuardian-CLI + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - operation: "--help" \ No newline at end of file + python-version: ${{ matrix.python }} + - name: Install tirith and pytest + run: | + pip install pytest + pip install "py-tirith @ git+https://github.com/StackGuardian/tirith@feat/gate-capable-engine" + - run: python -m pytest tests/ -q + + smoke: + # Proves the composite action wires up and fails cleanly without real credentials. The input + # file does not exist, so a green result here would mean the action swallowed a broken run. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - id: smoke + continue-on-error: true + uses: ./ + with: + sg-api-key: sgo_not_a_real_key + sg-org: does-not-exist + input-path: no-such-plan.json + comment: "false" + check: "false" + timeout: "30" + - name: The action must have failed + run: | + if [ "${{ steps.smoke.outcome }}" != "failure" ]; then + echo "::error::the action reported success with a missing input file" + exit 1 + fi diff --git a/.gitignore b/.gitignore index d68fd55..270caaa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ bundles/ .git/ vendor/pkg/ pyenv -Vagrantfile \ No newline at end of file +Vagrantfile +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f7446d2..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Container image that runs your code -FROM alpine:3.10 - -# Install necessary tools -RUN apk --no-cache add bash wget jq curl - -# Download and extract StackGuardian CLI release -RUN wget -q "$(wget -qO- "https://api.github.com/repos/stackguardian/sg-cli/releases/latest" | jq -r '.tarball_url')" -O sg-cli.tar.gz \ - && tar -xf sg-cli.tar.gz \ - && rm -f sg-cli.tar.gz \ - && mv StackGuardian-sg-cli*/sg-cli /usr/local/bin/ \ - && rm -rf StackGuardian-sg-cli* - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index cc1b595..27d1a57 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,154 @@ -# StackGuardian CLI docker action +# Tirith Policy Check -This action interacts with [sg-cli](https://github.com/StackGuardian/sg-cli/blob/main/README.md). +Evaluate your StackGuardian policies against a terraform plan in CI, and report the outcome as a +pull-request comment and a check run. + +```yaml +- run: | + terraform plan -out=tfplan -input=false + terraform show -json tfplan > plan.json + +- uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} + sg-org: ${{ vars.SG_ORG }} + input-path: plan.json + fail-on-error: true +``` + +```yaml +permissions: + contents: read + pull-requests: write # sticky comment + checks: write # check run +``` + +## What it does + +1. **Masks the plan on your runner**, before anything leaves it. Values terraform marked sensitive + are replaced, root `variables` are dropped wholesale, and `planned_values` and `prior_state` are + removed entirely. +2. **Packs** the masked documents together with your terraform source into a `tar.gz`, excluding + `.git`, `.terraform`, `*.tfstate*` and anything in `.gitignore`. +3. **Uploads** it and creates a StackGuardian workflow run, which evaluates the policies your + organization has scoped to that workflow. +4. **Reports** the verdict: a sticky pull-request comment, a `Tirith Policy` check run, the job + summary, and action outputs. + +Policies live in StackGuardian and are selected server-side by their `EnforcedOn` scope. There are +no policy files in your repository and nothing is evaluated on the runner. + +## What actually gets uploaded + +**Your terraform source is uploaded, as written.** The archive is the source tree plus the masked +documents, because that is what the platform unpacks in place of a VCS checkout — and it is what +policies over HCL will read. Masking applies to the *plan and state documents*, not to your `.tf` +files. + +So a secret hardcoded in HCL reaches StackGuardian in plaintext: + +```hcl +resource "local_sensitive_file" "creds" { + content = "hunter2" # masked in the plan, and still verbatim in main.tf +} +``` + +Excluded automatically: `.git`, `.terraform`, `*.tfstate*`, and anything in `.gitignore`. If you +have other files that must not travel, add them to `.gitignore`, or point `source-dir` at a +directory that does not contain them. + +## A note on masking + +Terraform's `*_sensitive` markers are **not exhaustive**. A value that flows through `locals`, or +comes from a provider that did not mark its schema, arrives marked `false` and marker-driven +masking will not catch it. Dropping `planned_values`, `variables` and the literal values in +`configuration` limits the blast radius, but if a value must never leave your infrastructure, do +not let it into a plan — and do not commit it to the repository either. + +Two related habits worth keeping: + +- Write `terraform state pull > state.json`, never `> terraform.tfstate`. With a local backend the + shell truncates the file terraform is about to read. +- Use `input-kind: terraform_state` for a state document. Plain `json` uploads it unmasked, and + state holds every attribute in plaintext. ## Inputs -## `operation` +| Input | Required | Default | | +|---|---|---|---| +| `sg-api-key` | yes | | Organization (`sgo_`) token | +| `sg-org` | yes | | Organization name | +| `input-path` | | | Document to evaluate. One of this or `state-path` | +| `input-kind` | | `terraform_plan` | `terraform_plan`, `terraform_state`, `kubernetes`, `json` | +| `state-path` | | | Terraform state, masked before upload | +| `infracost-path` | | | `infracost breakdown --format json` | +| `source-dir` | | `.` | Terraform source packed alongside the documents | +| `fail-on-error` | | `false` | Fail the job when a policy fails | +| `comment` / `check` | | `true` | Post the comment / check run | +| `comment-tag` | | `default` | Namespaces the comment and the archive | +| `timeout` | | `1800` | Seconds to wait for the run | +| `workflow-id` | | derived | Overrides `github-com---` | +| `terraform-version` | | | Recorded on the workflow at creation | +| `step-template-id` | | platform default | Override the terraform step template | +| `tirith-version` | | `1.2.0` | Pin the CLI version | +| `sg-api-url` / `sg-dashboard-url` | | prod | Set both together for other regions | -**Required** The sg-cli operation like `"workflow create ..."` or `"stack create ..."`. +## Outputs -## Environment variables +`verdict` (`passed` \| `warned` \| `failed` \| `errored` \| `no-policies` \| `approval-required`), +`passed`, `failed`, `warned`, `results`, `results-file`, `wfrun-id`, `wfrun-url`, `comment-id`. -## `SG_API_TOKEN` +## Exit codes -**Required** StackGuardian API Token. Retrieve on the platform at `https://app.stackguardian.io/orchestrator/orgs//settings?tab=api_key/`. +`fail-on-error` governs **policy verdicts**, not tool health. -## Example usage +| | `fail-on-error: false` | `fail-on-error: true` | +|---|---|---| +| Policies pass or warn | green | green | +| A policy fails | green | **red** | +| Run errored, platform unreachable, no verdict | **red** | **red** | + +The last row is deliberate: a run that never produced a verdict must never look like a pass. + +## Matrix and monorepo usage + +Give each leg its own `workflow-id` **and** `comment-tag`: ```yaml -jobs: - execute-sg-cli: - runs-on: ubuntu-latest - env: - SG_API_TOKEN: ${{ secrets.SG_API_TOKEN }} - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - uses: stackguardian/sg-cli-gh-action@main - with: - operation: 'workflow create --org demo-org --workflow-group gh-actions --run -- payload.json' +strategy: + fail-fast: false + matrix: + stack: [dev, prod] +steps: + - uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} + sg-org: ${{ vars.SG_ORG }} + input-path: ${{ matrix.stack }}/plan.json + source-dir: ${{ matrix.stack }} + workflow-id: infra-${{ matrix.stack }} + comment-tag: ${{ matrix.stack }} +``` + +Both are load-bearing. Runs on a single StackGuardian workflow serialize while one is pending, so +without a distinct `workflow-id` a 20-leg matrix becomes a 20-deep queue. And the sticky comment is +found by a marker containing the tag, so shared tags mean the legs overwrite each other's comment. + +## Migrating from the sg-cli action + +Version 1 of this action was a thin `sg-cli` passthrough with a single `operation` input. It is +unrelated to what this action does now. Pin `@v1.0.0-beta` to keep the old behaviour; there is no +automatic migration. + +## Where the code lives + +The action is a wrapper. Everything that talks to StackGuardian is `tirith platform check` in +[StackGuardian/tirith](https://github.com/StackGuardian/tirith) — so the same behaviour is +available from GitLab, a Makefile or a laptop: + +``` +tirith platform check --org acme --workflow-id infra --input-path plan.json --fail-on-error ``` -See [sg-cli docs](https://github.com/StackGuardian/sg-cli/blob/main/README.md) for the explanation on `payload.json`. +What is left in this repository is only what is genuinely GitHub-specific: reading the event +payload, posting the comment and check run, and setting action outputs. diff --git a/action.yml b/action.yml index 6d6a988..1143f12 100644 --- a/action.yml +++ b/action.yml @@ -1,15 +1,147 @@ -name: 'StackGuardian CLI' -description: 'Run sg-cli commands. Read more at https://github.com/StackGuardian/sg-cli/blob/main/README.md.' -author: 'StackGuardian' +name: "Tirith Policy Check" +description: "Evaluate StackGuardian policies against a terraform plan, and report the outcome on the pull request." +author: "StackGuardian" + branding: - icon: 'command' - color: 'blue' + icon: "shield" + color: "purple" + inputs: - operation: - description: 'sg-cli operation like "workflow create ..." or "stack create ..."' + sg-api-key: + description: "StackGuardian API key. Must be an organization (sgo_) token: sgu_ tokens are non-functional for SSO-group-only users." required: true + sg-org: + description: "StackGuardian organization name." + required: true + input-path: + description: "Path to the document to evaluate, e.g. the output of `terraform show -json tfplan`. One of input-path or state-path is required." + required: false + + sg-api-url: + description: "StackGuardian API base URL. Use https://api.us.stackguardian.io/api/v1 for the US region." + required: false + default: "https://api.app.stackguardian.io/api/v1" + sg-dashboard-url: + description: "StackGuardian dashboard base URL, used to build run links." + required: false + default: "https://app.stackguardian.io" + + input-kind: + description: "What the input document is: terraform_plan, terraform_state, kubernetes, or json. terraform_state is masked before upload and evaluated by the json provider." + required: false + default: "terraform_plan" + state-path: + description: "Optional path to a terraform state file. Masked in the runner before upload and recorded as the TfStateCleaned run fact." + required: false + infracost-path: + description: "Optional path to an `infracost breakdown --format json` document. Required by policies whose required_provider is stackguardian/infracost; if omitted, the step generates one when such a policy is enforced." + required: false + source-dir: + description: "Terraform source directory packed alongside the documents. Excludes .git, .terraform, *.tfstate* and anything in .gitignore." + required: false + default: "." + terraform-version: + description: "Recorded on the StackGuardian workflow when it is created." + required: false + + comment: + description: "Post a sticky comment on the pull request." + required: false + default: "true" + comment-tag: + description: "Namespaces the sticky comment and the uploaded archive. Set a distinct value per working directory so matrix jobs do not overwrite each other." + required: false + default: "default" + check: + description: "Create a 'Tirith Policy' check run on the head commit." + required: false + default: "true" + + fail-on-error: + description: "Exit non-zero (failing the job) when a policy fails. Note that an unreachable platform or a run that produced no verdict always exits non-zero, regardless of this setting." + required: false + default: "false" + + timeout: + description: "Seconds to wait for the StackGuardian run. Runs on one workflow serialize, so a run may be queued behind another." + required: false + default: "1800" + + workflow-id: + description: "Override the derived workflow identity. By default it is github-com---." + required: false + step-template-id: + description: "Override the StackGuardian terraform step template. Omit to use the platform's own default." + required: false + tirith-version: + description: "Git ref of py-tirith to install. TEMPORARY default: a branch, until StackGuardian/tirith is tagged 1.2.0. Set this back to a tag before release -- a moving ref means a green pipeline can turn red with nothing in the repository changing." + required: false + default: "feat/gate-capable-engine" + github-token: + description: "Token used to post the comment and the check run. Never sent to StackGuardian." + required: false + default: ${{ github.token }} + +outputs: + verdict: + description: "One of: passed, warned, failed, errored, no-policies, approval-required." + value: ${{ steps.tirith.outputs.verdict }} + passed: + description: "Number of policy rules that passed." + value: ${{ steps.tirith.outputs.passed }} + failed: + description: "Number of policy rules that failed." + value: ${{ steps.tirith.outputs.failed }} + warned: + description: "Number of policy rules that warned." + value: ${{ steps.tirith.outputs.warned }} + results: + description: "The full PolicyEvalResults document as JSON." + value: ${{ steps.tirith.outputs.results }} + results-file: + description: "Path to the full result document on disk. Prefer this over `results` when aggregating many units." + value: ${{ steps.tirith.outputs.results-file }} + wfrun-id: + description: "The StackGuardian workflow run id." + value: ${{ steps.tirith.outputs.wfrun-id }} + wfrun-url: + description: "Link to the run in StackGuardian." + value: ${{ steps.tirith.outputs.wfrun-url }} + comment-id: + description: "The id of the sticky pull-request comment, if one was posted." + value: ${{ steps.tirith.outputs.comment-id }} + runs: - using: 'docker' - image: 'Dockerfile' - args: - - ${{ inputs.operation }} + using: composite + steps: + - name: Mask the API key + shell: bash + run: echo "::add-mask::${{ inputs.sg-api-key }}" + + - name: Install tirith + shell: bash + run: pip install --quiet "py-tirith @ git+https://github.com/StackGuardian/tirith@${{ inputs.tirith-version }}" + + - name: Evaluate policies + id: tirith + shell: bash + run: python3 "${{ github.action_path }}/scripts/main.py" + env: + INPUT_SG_API_KEY: ${{ inputs.sg-api-key }} + INPUT_SG_ORG: ${{ inputs.sg-org }} + INPUT_SG_API_URL: ${{ inputs.sg-api-url }} + INPUT_SG_DASHBOARD_URL: ${{ inputs.sg-dashboard-url }} + INPUT_INPUT_PATH: ${{ inputs.input-path }} + INPUT_INPUT_KIND: ${{ inputs.input-kind }} + INPUT_STATE_PATH: ${{ inputs.state-path }} + INPUT_INFRACOST_PATH: ${{ inputs.infracost-path }} + INPUT_SOURCE_DIR: ${{ inputs.source-dir }} + INPUT_TERRAFORM_VERSION: ${{ inputs.terraform-version }} + INPUT_COMMENT: ${{ inputs.comment }} + INPUT_COMMENT_TAG: ${{ inputs.comment-tag }} + INPUT_CHECK: ${{ inputs.check }} + INPUT_FAIL_ON_ERROR: ${{ inputs.fail-on-error }} + INPUT_TIMEOUT: ${{ inputs.timeout }} + INPUT_WORKFLOW_ID: ${{ inputs.workflow-id }} + INPUT_STEP_TEMPLATE_ID: ${{ inputs.step-template-id }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} diff --git a/docs/terragrunt.md b/docs/terragrunt.md new file mode 100644 index 0000000..86d06bc --- /dev/null +++ b/docs/terragrunt.md @@ -0,0 +1,258 @@ +# Terragrunt pipelines + +> Design notes and a phased plan. Phase 1 works with the action as it ships today; phases 2–3 are +> proposals. + +## The short answer to "do we need multiple states and plans?" + +**Yes — one of each per unit, and that is not a workaround, it is terragrunt's model.** + +Every terragrunt unit (a directory with a `terragrunt.hcl`) is a separate terraform root module with +its **own backend and its own state**. `run-all plan` runs N independent plans in dependency order. +There is no combined plan document to evaluate, and producing one would be wrong: two units can +legitimately hold resources with identical addresses. + +So the shape is `N units → N plan JSONs → N policy evaluations`, and the real design questions are +about how those N map onto StackGuardian workflows, artifacts, and PR comments. + +## What exists today, and the gap worth knowing about + +The platform's terraform step supports terragrunt (`terragruntBinPath`, `run-all`), **but +`run-all` skips policy evaluation entirely**: + +```python +# workflow-step-templates/terraform/main.py:1331 +if terragruntRunAllEnabled: + terragrunt_plan_apply_destroy_all(...) + return # <-- returns before execute_policies() is ever reached +``` + +There is no `tf_plan.json` produced on that path and therefore nothing to evaluate. So a terragrunt +`run-all` workflow in StackGuardian today enforces **no IaC policies at all**. + +That reframes this work: the action is not catching up to the terraform step here, it is closing a +gap the platform has. + +## Getting one plan JSON per unit + +The mechanism depends on the terragrunt version, and the flag names changed in the CLI redesign: + +| Terragrunt | Command | +|---|---| +| ≥ v0.73 (`run --all`) | `terragrunt run --all plan --out-dir=plans` then `terragrunt run --all show -json --json-out-dir=json-plans` | +| ~v0.68–0.72 | `terragrunt run-all plan --terragrunt-out-dir=plans` / `--terragrunt-json-out-dir=json-plans` | +| older | loop over units yourself: `terragrunt-info` or `find . -name terragrunt.hcl` and run `plan`/`show -json` per directory | + +All three yield a directory tree mirroring the unit paths, one JSON per unit. **Pin the flag +spelling to the terragrunt version you install** — this is the most likely thing to break silently, +because an unrecognised `--out-dir` is accepted by some versions as a passthrough to terraform. + +The loop fallback is worth keeping in the docs regardless: it is version-independent and easy to +reason about. + +## Phase 1 — works today, no code changes + +Discover units, then matrix over them. This is exactly the pattern already verified in +`examples/monorepo-matrix.yml`, scaled up by generating the matrix instead of hard-coding it. + +```yaml +name: terragrunt-policy + +on: [pull_request] + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + units: ${{ steps.find.outputs.units }} + steps: + - uses: actions/checkout@v4 + - id: find + run: | + # Only units that actually changed in this PR. Planning every unit on a large repo is + # slow and mostly noise; scope to what the PR touches. + units=$(find live -name terragrunt.hcl -not -path '*/.terragrunt-cache/*' \ + | xargs -n1 dirname | sort -u | jq -R . | jq -sc .) + echo "units=$units" >> "$GITHUB_OUTPUT" + + policy: + needs: discover + runs-on: ubuntu-latest + strategy: + fail-fast: false # one unit failing must not hide the others + max-parallel: 5 # be kind to the API and to your own rate limits + matrix: + unit: ${{ fromJSON(needs.discover.outputs.units) }} + steps: + - uses: actions/checkout@v4 + - uses: gruntwork-io/terragrunt-action@v2 + with: { tg_version: '0.73.0', tf_version: '1.9.0', tg_dir: ${{ matrix.unit }}, tg_command: 'plan -out=tfplan' } + + - run: terragrunt show -json tfplan > plan.json + working-directory: ${{ matrix.unit }} + + - uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} + sg-org: ${{ vars.SG_ORG }} + input-path: ${{ matrix.unit }}/plan.json + # One StackGuardian workflow per unit: mirrors "one state per unit", gives per-unit run + # history, and — critically — stops units queueing behind each other, since runs on a + # single workflow serialize. + workflow-id: tg-${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ matrix.unit }} + # One sticky comment per unit; without this every leg overwrites the same comment. + comment-tag: ${{ matrix.unit }} + fail-on-error: true +``` + +**Two things that are not optional here.** + +`workflow-id` per unit: runs on one StackGuardian workflow serialize while one is pending, so +without this a 20-unit matrix becomes a 20-deep queue. + +`comment-tag` per unit: the sticky comment is found by a marker containing the tag, so shared tags +mean the legs overwrite each other and you see only whichever finished last. + +Note the action slugifies `workflow-id`, so `live/prod/vpc` becomes `live-prod-vpc` — but supply +something already slug-shaped if you want it predictable. + +### What Phase 1 costs you + +- **N comments on the PR.** Fine at 3 units, poor at 20. +- **N workflows in StackGuardian.** Correct, but the list gets long. +- **`EnforcedOn` per unit.** A policy scoped to one workflow does not cover the others. Scope + org-wide (`*`) or to the workflow group instead, or you will be editing policy scopes every time + someone adds a unit. + +## Phase 2 — one aggregated comment (small change) + +Turn commenting off per leg, collect the `results` outputs, and post once. + +```yaml + - uses: StackGuardian/sg-cli-gh-action@v2 + id: tirith + with: + # ... as above ... + comment: false # defer reporting + check: false + fail-on-error: false # aggregate the verdict instead + - uses: actions/upload-artifact@v4 + with: + name: tirith-${{ strategy.job-index }} + path: ${{ steps.tirith.outputs.results }} # (needs a results-file output — see below) + + report: + needs: policy + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: { pattern: 'tirith-*', merge-multiple: true, path: results } + - uses: StackGuardian/sg-cli-gh-action/comment@v2 + with: + results-glob: 'results/*.json' + group-by: unit +``` + +This needs two additions to the action: + +1. **A `results-file` output** (a path) alongside the existing `results` (the JSON itself). + `$GITHUB_OUTPUT` is fine for one plan's findings but is not where a 20-unit aggregate belongs. +2. **A standalone `comment/` sub-action** that renders many result documents into one comment, + grouped by unit. The renderer already summarises and truncates; it needs a grouping level above + "policy". + +Rough shape of the aggregated comment: + +```markdown +## 🛡️ Tirith — 2 units failed, 18 passed + +| Unit | Failed | Warned | Passed | +|---|---|---|---| +| `live/prod/vpc` | 1 | 0 | 4 | +| `live/prod/eks` | 1 | 2 | 3 | +| _18 others_ | 0 | 0 | 72 | + +
❌ live/prod/vpc +… findings … +
+``` + +**Effort: S–M.** The renderer already has the hard parts (summarising, truncation, sticky marker). + +## Phase 3 — native multi-unit support (proposal) + +Let one invocation accept many documents: + +```yaml +- uses: StackGuardian/sg-cli-gh-action@v2 + with: + input-glob: 'live/**/plan.json' + unit-from-path: 'live/(?.+)/plan.json' + workflow-id-template: 'tg-{repo}-{unit}' +``` + +The action would then fan out uploads and runs itself, poll them concurrently, and post one +comment. That removes the matrix boilerplate entirely and makes "20 units" a single job. + +**Effort: L**, and it duplicates scheduling that GitHub Actions already does well. Worth it only if +Phase 1/2 prove too clumsy in practice — I would not build it speculatively. + +## State: send it, per unit + +Same rule as the single-module case, once per unit, after apply: + +```yaml +- run: terragrunt state pull > state.json # NOT terraform.tfstate -- see below + working-directory: ${{ matrix.unit }} +- uses: StackGuardian/sg-cli-gh-action@v2 + with: + input-path: ${{ matrix.unit }}/state.json + input-kind: terraform_state # masks before upload + workflow-id: tg-...-${{ matrix.unit }} + comment-tag: ${{ matrix.unit }}-state # distinct tag => distinct artifact folder +``` + +Three traps, all of which bit during testing of the non-terragrunt pipeline: + +- **Never `state pull > terraform.tfstate`.** With a local backend that is the file terragrunt is + about to read, and the shell truncates it first. Use a different name. +- **`input-kind: terraform_state`, not `json`.** Plain `json` uploads the document unmasked; state + holds every attribute in plaintext. +- **A distinct `comment-tag` per phase.** It namespaces the artifact folder as well as the comment, + so the plan and state uploads for one commit do not overwrite each other. + +## Dependencies between units + +Terragrunt's `dependency` blocks mean a unit's plan can depend on another unit's *outputs*. On a PR +where the dependency has not been applied yet, terragrunt either fails or substitutes +`mock_outputs`. + +Policy evaluation sees only what the plan says, so **a plan built on mocked outputs can pass a +policy that the real apply would fail.** There is no clean fix at the action layer; the honest +handling is to make it visible: + +- Prefer `--terragrunt-fetch-dependency-output-from-state` where the dependency is already applied. +- Where `mock_outputs` are in play, treat the result as advisory for that unit. +- Consider re-checking post-apply (the state phase above), which sees real values. + +Worth stating plainly in whatever docs ship with this rather than discovering it in an incident. + +## Suggestions, in the order I would do them + +1. **Ship Phase 1 as a documented example** (`examples/terragrunt-matrix.yml`). Zero code, and it + works with the action as it stands. Validates the shape before investing in aggregation. +2. **Add `results-file` output + the `comment/` sub-action** (Phase 2). This is the change that + makes large repos usable, and it is small. +3. **Fix the platform gap separately.** Terragrunt `run-all` returning before `execute_policies` is + a bug in the terraform step regardless of this action. Either produce per-unit plan JSONs there + too, or document that `run-all` workflows are unpoliced. +4. **Decide the `EnforcedOn` story for many units** before anyone onboards a real monorepo. Per-unit + workflows plus per-workflow policy scoping does not scale; org-wide or workflow-group scoping + does. This is a platform question, not an action one. +5. **Leave Phase 3 alone** until 1 and 2 have real usage behind them. diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index 57558f5..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -l - -# Print a message indicating the execution of sg-cli command -echo "Executing: sg-cli $@" - -# Execute sg-cli command -sg-cli $@ diff --git a/examples/basic.yml b/examples/basic.yml new file mode 100644 index 0000000..343dfbe --- /dev/null +++ b/examples/basic.yml @@ -0,0 +1,54 @@ +# Pattern A -- two added steps, no restructuring. +# +# Works because a failed step fails the job and subsequent steps do not run, so apply is gated +# with no `needs:`, no job split, and no output plumbing. `tfplan` is still in the workspace, so +# apply applies exactly the bytes that were checked. +# +# Two caveats: +# - A later step carrying `if: always()` or `if: success() || failure()` WILL still run, so +# insert the tirith step above any such step. +# - This shape gates but cannot pause. For a human approval gate, use your own environment +# protection rules or a separate approval job -- this action does not implement approvals. + +name: terraform + +on: + pull_request: + push: + branches: [main] + +# NOTE: no `paths:` filter. A required check whose workflow never runs pins the PR at +# "Expected -- waiting for status to be reported" forever. Filter inside a job with `if:` instead. + +permissions: + contents: read + pull-requests: write # the sticky comment + checks: write # the "Tirith Policy" check run + id-token: write # your cloud auth, if you use OIDC + +jobs: + terraform: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + + - run: terraform init -input=false + + - run: terraform plan -out=tfplan -input=false + + # Added: render the plan as JSON for tirith. + - run: terraform show -json tfplan > plan.json + + # Added: evaluate policies. Policies live in StackGuardian and are selected by their + # EnforcedOn scope -- nothing about them is configured here. + - uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} # must be an sgo_ (organization) token + sg-org: ${{ vars.SG_ORG }} + input-path: plan.json + fail-on-error: true # make a failing policy fail the job + + - if: github.event_name == 'push' + run: terraform apply -auto-approve -input=false tfplan diff --git a/examples/monorepo-matrix.yml b/examples/monorepo-matrix.yml new file mode 100644 index 0000000..c7bd137 --- /dev/null +++ b/examples/monorepo-matrix.yml @@ -0,0 +1,45 @@ +# One repo, several terraform stacks. +# +# The important detail is `comment-tag`: the sticky comment is found by a hidden marker that +# includes that tag, so without a distinct value per stack every matrix leg would find and +# overwrite the same comment and you would see only whichever finished last. +# +# Each leg creates its own StackGuardian workflow (via `workflow-id`) so the legs do not queue +# behind each other -- runs on a single workflow serialize while one is PENDING/RUNNING. + +name: terraform-policy + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + policy: + runs-on: ubuntu-latest + strategy: + fail-fast: false # one stack failing should not hide the others' results + matrix: + stack: [envs/dev, envs/staging, envs/prod] + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + + - working-directory: ${{ matrix.stack }} + run: | + terraform init -input=false + terraform plan -out=tfplan -input=false + terraform show -json tfplan > plan.json + + - uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} + sg-org: ${{ vars.SG_ORG }} + input-path: ${{ matrix.stack }}/plan.json + # One comment per stack, and one workflow per stack. + comment-tag: ${{ matrix.stack }} + workflow-id: github-com-${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ strategy.job-index }} + fail-on-error: true diff --git a/examples/with-state.yml b/examples/with-state.yml new file mode 100644 index 0000000..7d106d3 --- /dev/null +++ b/examples/with-state.yml @@ -0,0 +1,56 @@ +# Two-phase pipeline: gate the plan before apply, then check the real state afterwards. +# +# The post-apply check is the one that sees reality. A plan can pass a policy that the applied +# infrastructure fails -- provider defaults, computed values, and anything terraform could not +# know until it ran. +# +# Note the state file is written as state.json, NOT terraform.tfstate. With a local backend the +# shell truncates the redirect target before terraform reads it, so `> terraform.tfstate` destroys +# the state you were trying to read. + +name: terraform + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + plan-and-apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + + - run: | + terraform init -input=false + terraform plan -out=tfplan -input=false + terraform show -json tfplan > plan.json + + - name: Gate the plan + uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} + sg-org: ${{ vars.SG_ORG }} + input-path: plan.json + comment-tag: pre-apply + fail-on-error: true + + - run: terraform apply -auto-approve tfplan + + - run: terraform state pull > state.json + + - name: Check the applied state + uses: StackGuardian/sg-cli-gh-action@v2 + with: + sg-api-key: ${{ secrets.SG_API_TOKEN }} + sg-org: ${{ vars.SG_ORG }} + input-path: state.json + # terraform_state, not json: `json` would upload every attribute unmasked. + input-kind: terraform_state + # A distinct tag, or this comment overwrites the pre-apply one. + comment-tag: post-apply + fail-on-error: true diff --git a/scripts/main.py b/scripts/main.py new file mode 100644 index 0000000..cf13293 --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +""" +Tirith Policy Check -- GitHub Action entry point. + +This is a wrapper. Everything that talks to StackGuardian -- masking, packing, uploading, running, +polling, rendering -- lives in the `tirith` CLI (`tirith platform check`). What is left here is +only the part that is genuinely GitHub-specific: + + * translating the workflow event into a workflow identity and trigger details + * invoking the CLI + * turning its JSON back into action outputs, a sticky pull-request comment and a check run + +Keeping the split at that line is the point: a GitLab or Jenkins integration reuses the CLI +unchanged, and this file never has to be the place where platform behaviour is decided. +""" + +import json +import os +import re +import subprocess +import tempfile +import sys +import uuid + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from tirith_action.gh_client import GitHubClient, GitHubError # noqa: E402 + +CHECK_NAME = "Tirith Policy" + +# Exit codes from `tirith platform check`. 3 means a policy said no; 1 means tirith could not +# reach the platform or the run produced no verdict. The distinction is the whole reason +# fail-on-error exists, so it must survive the round trip. +EXIT_OK = 0 +EXIT_TOOL_FAILURE = 1 +EXIT_POLICY_FAILED = 3 + + +def log(message): + print(message, flush=True) + + +def notice(message): + print(f"::notice::{message}", flush=True) + + +def warn(message): + print(f"::warning::{message}", flush=True) + + +def fail(message): + print(f"::error::{message}", flush=True) + + +def env(name, default=""): + return os.environ.get(name, default) or default + + +def env_bool(name, default=False): + raw = os.environ.get(name) + if raw is None or raw == "": + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def set_output(name, value): + """ + Append to $GITHUB_OUTPUT with a random heredoc delimiter. + + A fixed delimiter is an injection vector: policy results carry resource names and messages + that come from the evaluated infrastructure, and a crafted one containing the delimiter could + end the block early and inject arbitrary outputs. + """ + path = os.environ.get("GITHUB_OUTPUT") + if not path: + return + delimiter = f"ghadelim_{uuid.uuid4().hex}" + with open(path, "a") as f: + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + + +def _slug(value): + return re.sub(r"-+", "-", re.sub(r"[^a-zA-Z0-9_-]", "-", value or "")).strip("-") + + +def slugify_workflow_id(repository, action_name): + """ + Derive the StackGuardian workflow identity from the repository and workflow name. + + `Id` is a DRF SlugField, so dots are rejected -- hence `github-com-` rather than `github.com-`. + Verified: `github.com-...` is a 400, `github-com-...` is accepted. + """ + owner, _, repo = (repository or "").partition("/") + return _slug(f"github-com-{owner}-{repo}-{action_name}")[:100] + + +def _event_payload(): + path = os.environ.get("GITHUB_EVENT_PATH") + if not path or not os.path.exists(path): + return {} + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def pull_request_number(): + payload = _event_payload() + if payload.get("pull_request"): + return payload["pull_request"].get("number") + # `issue_comment` on a PR carries the number under `issue`. + issue = payload.get("issue") or {} + if issue.get("pull_request"): + return issue.get("number") + return None + + +def pull_request_title(): + return ((_event_payload().get("pull_request")) or {}).get("title") + + +def head_sha(): + """ + The SHA the check run must attach to. + + On `pull_request`, $GITHUB_SHA is the *merge* commit, which does not exist in the PR's branch + -- a check posted against it is invisible on the PR. `pull_request.head.sha` is the real one. + """ + payload = _event_payload() + if payload.get("pull_request"): + return payload["pull_request"]["head"]["sha"] + return env("GITHUB_SHA") + + +def build_trigger_details(sha): + """ + Describe what triggered this run, for the platform's UI and run history. + + Deliberately NOT `github_webhook`: the run controller gates its own PR comment and check run on + that value (common/vcs.py), so claiming it would produce two comments and two checks on every + pull request. `commentsUrl` and `checksApiUrl` are likewise omitted -- unused on this path, and + leaving them out means the platform is never handed a token-shaped URL it has no business with. + """ + repo = env("GITHUB_REPOSITORY") + server = env("GITHUB_SERVER_URL", "https://github.com") + pr = pull_request_number() + + details = { + "type": "github_action", + "ghEventType": env("GITHUB_EVENT_NAME"), + "repoHttpUrl": f"{server}/{repo}", + "headSha": sha, + "ref": env("GITHUB_HEAD_REF") or env("GITHUB_REF_NAME"), + "runUrl": f"{server}/{repo}/actions/runs/{env('GITHUB_RUN_ID')}", + "eventInitializer": env("GITHUB_ACTOR"), + } + if pr: + details["prId"] = str(pr) + # The html_url, not the API url: this is rendered as a link in the dashboard. + details["eventSource"] = f"{server}/{repo}/pull/{pr}" + title = pull_request_title() + if title: + details["pullRequestTitle"] = title + else: + branch = env("GITHUB_REF_NAME") + details["eventSource"] = f"{server}/{repo}/tree/{branch}" if branch else f"{server}/{repo}" + return details + + +def comment_marker(tag): + """ + A markdown link-reference definition, used to find this comment again on the next run. + + `[//]: <> (...)` renders as nothing and survives round-tripping through the API body, which + `` does not reliably do. + """ + return f"[//]: <> (tirith-comment, tag={tag})" + + +def check_conclusion(verdict): + """ + Map a verdict to a GitHub check-run conclusion. + + `neutral` SATISFIES a required status check, so it is correct for warnings and wrong for + anything unresolved. An errored or unreachable run must be `failure`, never `neutral` and + never `success`. + """ + return { + "passed": "success", + # Nothing in scope is not the same as a clean pass -- the likeliest cause is a policy + # scoped to the wrong workflow group -- but it must not block either, and `neutral` + # satisfies a required check just as `success` does. + "no-policies": "neutral", + "warned": "neutral", + # A human has to act; `action_required` says exactly that and does not satisfy the check. + "approval-required": "action_required", + "failed": "failure", + "errored": "failure", + }.get(verdict, "failure") + + +def build_command(result_path, markdown_path, trigger_path, tag): + """Assemble the `tirith platform check` invocation from the action inputs.""" + sha = head_sha() + workflow_id = env("INPUT_WORKFLOW_ID") or slugify_workflow_id( + env("GITHUB_REPOSITORY"), env("INPUT_ACTION_NAME") or env("GITHUB_WORKFLOW") + ) + + # Passed as a file rather than on argv: it carries a PR title, which is user-controlled text + # that would otherwise need shell-safe quoting for no benefit. + with open(trigger_path, "w") as f: + json.dump(build_trigger_details(sha), f) + + cmd = [ + "tirith", + "platform", + "check", + "--org", env("INPUT_SG_ORG"), + "--api-url", env("INPUT_SG_API_URL"), + "--dashboard-url", env("INPUT_SG_DASHBOARD_URL"), + "--workflow-id", workflow_id, + "--input-kind", env("INPUT_INPUT_KIND", "terraform_plan"), + "--artifact-tag", tag, + "--timeout", env("INPUT_TIMEOUT", "1800"), + "--output-json", result_path, + "--output-markdown", markdown_path, + "--comment-marker", comment_marker(tag), + "--trigger-details-file", trigger_path, + # Read the key from stdin rather than argv: an argument is visible in `ps` to anything + # else on the runner for the lifetime of the process. + "--api-key", "-", + ] + + if env("INPUT_INPUT_PATH"): + cmd += ["--input-path", env("INPUT_INPUT_PATH")] + if env("INPUT_STATE_PATH"): + cmd += ["--state-path", env("INPUT_STATE_PATH")] + if env("INPUT_INFRACOST_PATH"): + cmd += ["--infracost-path", env("INPUT_INFRACOST_PATH")] + if env("INPUT_SOURCE_DIR"): + cmd += ["--source-dir", env("INPUT_SOURCE_DIR")] + if env("INPUT_TERRAFORM_VERSION"): + cmd += ["--terraform-version", env("INPUT_TERRAFORM_VERSION")] + if env("INPUT_STEP_TEMPLATE_ID"): + cmd += ["--step-template-id", env("INPUT_STEP_TEMPLATE_ID")] + if sha: + cmd += ["--sha", sha] + if env_bool("INPUT_FAIL_ON_ERROR"): + cmd += ["--fail-on-error"] + + return cmd, workflow_id, sha + + +def report(result, markdown_path, tag, sha, want_comment, want_check): + """ + Post the sticky comment and the check run. + + Reporting failures are warnings, not errors. A pull request from a fork gets a read-only + GITHUB_TOKEN, so commenting fails there through no fault of the user -- and the policy verdict + is still carried by the job's exit code, which is what actually gates the merge. + """ + token = env("INPUT_GITHUB_TOKEN") + repository = env("GITHUB_REPOSITORY") + if not token or not repository: + return + + try: + with open(markdown_path) as f: + body = f.read() + except OSError: + body = result.get("headline", "Tirith policy check") + + gh = GitHubClient(token, repository, api_url=env("GITHUB_API_URL", "https://api.github.com")) + verdict = result.get("verdict", "errored") + + pr = pull_request_number() + if want_comment and pr: + try: + comment_id = gh.upsert_comment(pr, comment_marker(tag), body) + set_output("comment-id", str(comment_id)) + except GitHubError as e: + warn(f"Could not post the pull-request comment: {e}") + + if want_check and sha: + try: + gh.create_check_run( + head_sha=sha, + name=CHECK_NAME if tag == "default" else f"{CHECK_NAME} ({tag})", + conclusion=check_conclusion(verdict), + title=result.get("headline", "Tirith policy check"), + # The marker is meaningless outside an issue comment. + summary="\n".join(l for l in body.split("\n") if not l.startswith("[//]: <>")), + details_url=result.get("wfrun_url"), + ) + except GitHubError as e: + warn(f"Could not create the check run: {e}") + + +def main(): + api_key = env("INPUT_SG_API_KEY") + org = env("INPUT_SG_ORG") + if not api_key or not org: + fail("sg-api-key and sg-org are required") + return EXIT_TOOL_FAILURE + if not env("INPUT_INPUT_PATH") and not env("INPUT_STATE_PATH"): + fail("one of input-path or state-path is required") + return EXIT_TOOL_FAILURE + + tag = env("INPUT_COMMENT_TAG", "default") + + # Written to RUNNER_TEMP, never the working directory. source-dir defaults to "." and the + # archive packs it, so a scratch file next to the terraform lands in the upload -- verified in + # QA, where tirith-trigger.json shipped to the platform. RUNNER_TEMP is job-scoped, so + # results-file stays readable by later steps. + scratch = env("RUNNER_TEMP") or tempfile.gettempdir() + result_path = os.path.join(scratch, "tirith-result.json") + markdown_path = os.path.join(scratch, "tirith-comment.md") + trigger_path = os.path.join(scratch, "tirith-trigger.json") + + cmd, workflow_id, sha = build_command(result_path, markdown_path, trigger_path, tag) + log(f"Workflow: {workflow_id}") + + completed = subprocess.run(cmd, input=api_key + "\n", text=True) + + result = {} + if os.path.exists(result_path): + try: + with open(result_path) as f: + result = json.load(f) + except (json.JSONDecodeError, OSError) as e: + warn(f"Could not read the result document: {e}") + + verdict = result.get("verdict", "errored") + counts = result.get("counts") or {} + + set_output("verdict", verdict) + set_output("passed", str(counts.get("passed", 0))) + set_output("failed", str(counts.get("failed", 0))) + set_output("warned", str(counts.get("warned", 0))) + set_output("results", json.dumps(result.get("policy_results") or {})) + set_output("results-file", result_path) + if result.get("wfrun_id"): + set_output("wfrun-id", result["wfrun_id"]) + if result.get("wfrun_url"): + set_output("wfrun-url", result["wfrun_url"]) + notice(f"StackGuardian run: {result['wfrun_url']}") + + report(result, markdown_path, tag, sha, env_bool("INPUT_COMMENT", True), env_bool("INPUT_CHECK", True)) + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path and os.path.exists(markdown_path): + try: + with open(markdown_path) as src, open(summary_path, "a") as dst: + dst.write("\n".join(l for l in src.read().split("\n") if not l.startswith("[//]: <>"))) + dst.write("\n") + except OSError: + pass + + # The CLI already decided this; passing its code through keeps one source of truth for what + # counts as a failure. A tool failure is red regardless of fail-on-error. + if completed.returncode == EXIT_TOOL_FAILURE: + fail("Tirith could not complete the check; failing closed regardless of fail-on-error") + elif completed.returncode == EXIT_POLICY_FAILED: + fail(result.get("headline", "Policy check failed")) + return completed.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tirith_action/__init__.py b/scripts/tirith_action/__init__.py new file mode 100644 index 0000000..7a1c46b --- /dev/null +++ b/scripts/tirith_action/__init__.py @@ -0,0 +1 @@ +"""Helpers for the tirith GitHub Action. stdlib only -- nothing to install on the runner.""" diff --git a/scripts/tirith_action/gh_client.py b/scripts/tirith_action/gh_client.py new file mode 100644 index 0000000..a51a480 --- /dev/null +++ b/scripts/tirith_action/gh_client.py @@ -0,0 +1,106 @@ +""" +GitHub API client for the sticky PR comment and the check run. + +Both are posted from the runner with ${{ github.token }}, so no GitHub credential is ever sent to +StackGuardian. + +Note the platform can also post a comment and a check of its own, from sg-run-controller. That +path is gated on `TriggerDetails.type == "github_webhook"`; our runs set `github_action`, so it +does not fire. That gate is load-bearing -- see the README. The check created here is named +`Tirith Policy`, distinct from the platform's `StackGuardian Workflow Run`, so the two can coexist +if a repo ever uses both. +""" + +import json +import urllib.error +import urllib.parse +import urllib.request + + +class GitHubError(Exception): + pass + + +class GitHubClient: + def __init__(self, token, repository, api_url="https://api.github.com", timeout=30): + self.token = token + self.repository = repository + self.api_url = api_url.rstrip("/") + self.timeout = timeout + + def _request(self, method, path, body=None): + url = f"{self.api_url}{path}" + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(url, data=data, method=method) + request.add_header("Authorization", f"Bearer {self.token}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", "2022-11-28") + if data: + request.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read() + return response.status, (json.loads(raw) if raw else {}) + except urllib.error.HTTPError as e: + raw = e.read() + raise GitHubError(f"{method} {path} -> HTTP {e.code}: {raw[:400].decode('utf-8', 'replace')}") + except (urllib.error.URLError, TimeoutError) as e: + raise GitHubError(f"{method} {path} failed: {e}") + + def find_sticky_comment(self, pr_number, marker): + """ + Find this action's own previous comment by its hidden marker. + + Matches on the marker *and* on the comment being authored by a bot, so a human quoting the + marker in a reply cannot cause the action to overwrite their comment. + """ + page = 1 + while page <= 10: + status, comments = self._request( + "GET", f"/repos/{self.repository}/issues/{pr_number}/comments?per_page=100&page={page}" + ) + if status != 200 or not comments: + return None + for comment in comments: + if marker in (comment.get("body") or "") and (comment.get("user") or {}).get("type") == "Bot": + return comment["id"] + if len(comments) < 100: + return None + page += 1 + return None + + def upsert_comment(self, pr_number, marker, body): + """Update the existing sticky comment if there is one, otherwise create it.""" + comment_id = self.find_sticky_comment(pr_number, marker) + if comment_id: + # Editing rather than reposting keeps the comment in place in the timeline and does + # not re-notify everyone following the PR. + self._request("PATCH", f"/repos/{self.repository}/issues/comments/{comment_id}", {"body": body}) + return comment_id + + _, created = self._request( + "POST", f"/repos/{self.repository}/issues/{pr_number}/comments", {"body": body} + ) + return created.get("id") + + def create_check_run(self, head_sha, name, conclusion, title, summary, details_url=None): + """ + Create a completed check run. + + Annotations are deliberately omitted: they only render inline when anchored to a path and + line inside the PR diff, and terraform plan JSON carries no source positions at all. A + fabricated file:line would be worse than none. + """ + body = { + "name": name, + "head_sha": head_sha, + "status": "completed", + "conclusion": conclusion, + "output": {"title": title[:255], "summary": summary}, + } + if details_url: + body["details_url"] = details_url + + _, created = self._request("POST", f"/repos/{self.repository}/check-runs", body) + return created.get("id") diff --git a/tests/test_action_integration.py b/tests/test_action_integration.py new file mode 100644 index 0000000..57a965b --- /dev/null +++ b/tests/test_action_integration.py @@ -0,0 +1,368 @@ +""" +End-to-end tests for the action. + +The action is run as a real subprocess against a stub serving both the StackGuardian API and +GitHub on one port, with a real `tirith` installed. That is deliberate: the highest-value +assertion here is that a secret in the plan never appears in any recorded request body, and only +an end-to-end run can prove that. Testing the masking function in isolation is what let a leak +through once already -- the secret lived in a part of the plan the function never looked at. + +Requires `tirith` on PATH (`pip install -e ../tirith`). +""" + +import gzip +import io +import json +import os +import shutil +import subprocess +import sys +import tarfile +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +ACTION = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts", "main.py") +SECRET = "hunter2-must-not-reach-the-platform" + +pytestmark = pytest.mark.skipif(shutil.which("tirith") is None, reason="tirith is not installed") + + +class Stub(BaseHTTPRequestHandler): + """Serves the SG API, the presigned upload target and the GitHub API on one port.""" + + requests = [] + run_status = "COMPLETED" + policy_results = {} + + def log_message(self, *args): + pass + + def _read(self): + length = int(self.headers.get("Content-Length") or 0) + return self.rfile.read(length) if length else b"" + + def _respond(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _record(self, method): + body = self._read() + Stub.requests.append({"method": method, "path": self.path, "body": body}) + return body + + def do_GET(self): + self._record("GET") + base = f"http://127.0.0.1:{self.server.server_port}" + + if "configuration_upload_url" in self.path: + return self._respond(200, {"msg": {"signedUrl": f"{base}/put-archive", "key": "orgs/acme/wf/a.tar.gz"}}) + if "/wfruns/" in self.path and self.path.rstrip("/").endswith("wfrun-1"): + return self._respond(200, {"msg": {"LatestStatus": Stub.run_status}}) + if "/artifacts/" in self.path: + return self._respond(200, {"PolicyEvalResults": Stub.policy_results}) + if "/wfrunfacts/" in self.path: + return self._respond(404, {"msg": "not found"}) + if "/issues/" in self.path and "/comments" in self.path: + return self._respond(200, []) + return self._respond(200, {"msg": "ok"}) + + def do_PUT(self): + self._record("PUT") + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_PATCH(self): + self._record("PATCH") + return self._respond(200, {"id": 1}) + + def do_POST(self): + self._record("POST") + if self.path.endswith("/wfruns/"): + return self._respond(201, {"data": {"ResourceName": "wfrun-1"}}) + if "check-runs" in self.path: + return self._respond(201, {"id": 2}) + if "/comments" in self.path: + return self._respond(201, {"id": 1}) + return self._respond(201, {"msg": "created"}) + + +@pytest.fixture +def stub(): + Stub.requests = [] + Stub.run_status = "COMPLETED" + Stub.policy_results = {} + server = HTTPServer(("127.0.0.1", 0), Stub) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield server + server.shutdown() + + +def plan_with_a_secret(): + """A plan whose secret is masked in resource_changes and *also* present in planned_values.""" + return { + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "local_sensitive_file.secret", + "type": "local_sensitive_file", + "change": { + "actions": ["create"], + "after": {"content": SECRET, "filename": "out.txt"}, + "after_sensitive": {"content": True}, + }, + } + ], + "planned_values": { + "root_module": {"resources": [{"type": "local_sensitive_file", "values": {"content": SECRET}}]} + }, + } + + +def run_action(tmp_path, stub, **overrides): + base = f"http://127.0.0.1:{stub.server_port}" + + source = tmp_path / "src" + source.mkdir(exist_ok=True) + (source / "main.tf").write_text('resource "null_resource" "a" {}') + (source / "plan.json").write_text(json.dumps(plan_with_a_secret())) + + event = tmp_path / "event.json" + event.write_text( + json.dumps({"pull_request": {"number": 7, "title": "Add a VPC", "head": {"sha": "9f2c1ab" + "0" * 33}}}) + ) + + env = dict(os.environ) + env.update( + { + "INPUT_SG_API_KEY": "sgo_test", + "INPUT_SG_ORG": "acme", + "INPUT_SG_API_URL": f"{base}/api/v1", + "INPUT_SG_DASHBOARD_URL": base, + "INPUT_INPUT_PATH": str(source / "plan.json"), + "INPUT_INPUT_KIND": "terraform_plan", + "INPUT_SOURCE_DIR": str(source), + "INPUT_COMMENT_TAG": "default", + "INPUT_COMMENT": "true", + "INPUT_CHECK": "true", + "INPUT_FAIL_ON_ERROR": "false", + "INPUT_TIMEOUT": "60", + "INPUT_GITHUB_TOKEN": "ghs_test", + "GITHUB_REPOSITORY": "acme/infra", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_API_URL": base, + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": str(event), + "GITHUB_WORKFLOW": "policy", + "GITHUB_RUN_ID": "1", + "GITHUB_ACTOR": "someone", + "GITHUB_OUTPUT": str(tmp_path / "outputs.txt"), + "GITHUB_STEP_SUMMARY": str(tmp_path / "summary.md"), + } + ) + env.update(overrides) + + completed = subprocess.run( + [sys.executable, ACTION], env=env, cwd=str(tmp_path), capture_output=True, text=True + ) + outputs = {} + if (tmp_path / "outputs.txt").exists(): + raw = (tmp_path / "outputs.txt").read_text() + for block in raw.split("\n"): + if "<<" in block: + outputs[block.split("<<")[0]] = None + return completed, outputs + + +def uploaded_archive(): + for request in Stub.requests: + if request["method"] == "PUT": + return request["body"] + return None + + +def archive_contents(body): + """Every byte in the uploaded tarball, for leak assertions.""" + blob = b"" + with tarfile.open(fileobj=io.BytesIO(body), mode="r:gz") as tar: + for member in tar.getmembers(): + blob += member.name.encode() + if member.isfile(): + blob += tar.extractfile(member).read() + return blob + + +# --- the assertion that matters ---------------------------------------------------------------- + + +def test_the_secret_never_leaves_the_runner(tmp_path, stub): + """ + Asserted against every recorded request body, not against the masking function's return value. + + This is the shape that leaked in QA: masked correctly in resource_changes, plaintext in + planned_values, which mirrors every resource's values and carries no sensitivity markers. + """ + run_action(tmp_path, stub) + + for request in Stub.requests: + assert SECRET.encode() not in request["body"], f"secret leaked in {request['method']} {request['path']}" + + archive = uploaded_archive() + assert archive is not None, "no archive was uploaded" + assert SECRET.encode() not in archive_contents(archive) + + +def test_the_raw_plan_on_disk_is_not_packed(tmp_path, stub): + """ + The source directory contains plan.json -- unmasked, since that is what the user generated. + Only the masked copy may reach the archive. + """ + run_action(tmp_path, stub) + + with tarfile.open(fileobj=io.BytesIO(uploaded_archive()), mode="r:gz") as tar: + packed = json.loads(tar.extractfile("plan.json").read()) + + assert packed["resource_changes"][0]["change"]["after"]["content"] == "__SG_REDACTED__" + assert "planned_values" not in packed + + +def test_the_actions_own_scratch_files_are_not_uploaded(tmp_path, stub): + """ + source-dir defaults to "." and the archive packs it, so a scratch file written next to the + terraform lands in the upload. Verified in QA: tirith-trigger.json -- which carries the PR + title, repo URL and actor -- shipped to the platform. They go to RUNNER_TEMP instead. + """ + run_action(tmp_path, stub) + + with tarfile.open(fileobj=io.BytesIO(uploaded_archive()), mode="r:gz") as tar: + names = tar.getnames() + + assert not [n for n in names if n.startswith("tirith-")], names + + +def test_provider_cache_is_not_uploaded(tmp_path, stub): + source = tmp_path / "src" + source.mkdir(exist_ok=True) + (source / ".terraform").mkdir(exist_ok=True) + (source / ".terraform" / "provider").write_bytes(b"x" * 4096) + + run_action(tmp_path, stub) + + with tarfile.open(fileobj=io.BytesIO(uploaded_archive()), mode="r:gz") as tar: + assert not any(name.startswith(".terraform/") for name in tar.getnames()) + + +# --- run creation ------------------------------------------------------------------------------ + + +def test_run_is_created_with_the_archive_and_no_step_config(tmp_path, stub): + run_action(tmp_path, stub) + + created = [r for r in Stub.requests if r["method"] == "POST" and r["path"].endswith("/wfruns/")] + assert len(created) == 1 + + body = json.loads(created[0]["body"]) + assert body["TerraformAction"] == {"action": "policy-only"} + assert body["terraformProjectZip"] == "orgs/acme/wf/a.tar.gz" + assert "WfStepsConfig" not in body, "core ignores it for TERRAFORM workflows" + + +def test_trigger_details_do_not_claim_to_be_a_webhook(tmp_path, stub): + """ + The run controller posts its own comment and check when type is github_webhook. Claiming it + would double-post on every pull request. + """ + run_action(tmp_path, stub) + + body = json.loads([r for r in Stub.requests if r["path"].endswith("/wfruns/")][0]["body"]) + + assert body["TriggerDetails"]["type"] == "github_action" + assert "commentsUrl" not in body["TriggerDetails"] + assert "checksApiUrl" not in body["TriggerDetails"] + assert body["TriggerDetails"]["prId"] == "7" + + +def test_workflow_is_created_as_terraform(tmp_path, stub): + run_action(tmp_path, stub) + + created = [r for r in Stub.requests if r["method"] == "POST" and r["path"].endswith("/wfs/")] + body = json.loads(created[0]["body"]) + + assert body["WfType"] == "TERRAFORM" + assert body["TerraformConfig"]["managedTerraformState"] is False + # Id is a SlugField: dots are rejected, so github.com- would 400. + assert "." not in body["Id"] + + +# --- reporting --------------------------------------------------------------------------------- + + +def test_posts_one_comment_and_one_check(tmp_path, stub): + Stub.policy_results = {"p": [{"rule_name": "r", "result": "PASS", "evaluations": {"passes": []}}]} + + run_action(tmp_path, stub) + + comments = [r for r in Stub.requests if "/comments" in r["path"] and r["method"] in ("POST", "PATCH")] + checks = [r for r in Stub.requests if "check-runs" in r["path"]] + + assert len(comments) == 1 + assert len(checks) == 1 + assert json.loads(checks[0]["body"])["conclusion"] == "success" + + +def test_failing_policy_maps_to_a_failure_conclusion(tmp_path, stub): + Stub.policy_results = {"p": [{"rule_name": "r", "result": "FAIL", "evaluations": {"fails": []}}]} + + run_action(tmp_path, stub) + + checks = [r for r in Stub.requests if "check-runs" in r["path"]] + assert json.loads(checks[0]["body"])["conclusion"] == "failure" + + +# --- exit codes -------------------------------------------------------------------------------- + + +def test_failing_policy_is_green_without_fail_on_error(tmp_path, stub): + Stub.policy_results = {"p": [{"rule_name": "r", "result": "FAIL", "evaluations": {"fails": []}}]} + + completed, _ = run_action(tmp_path, stub) + + assert completed.returncode == 0 + + +def test_failing_policy_is_red_with_fail_on_error(tmp_path, stub): + Stub.policy_results = {"p": [{"rule_name": "r", "result": "FAIL", "evaluations": {"fails": []}}]} + + completed, _ = run_action(tmp_path, stub, INPUT_FAIL_ON_ERROR="true") + + assert completed.returncode == 3, "3 distinguishes a policy failure from a tool failure" + + +def test_errored_run_is_red_even_without_fail_on_error(tmp_path, stub): + """fail-on-error governs policy verdicts, not tool health.""" + Stub.run_status = "ERRORED" + + completed, _ = run_action(tmp_path, stub, INPUT_FAIL_ON_ERROR="false") + + assert completed.returncode == 1 + + +def test_unreachable_platform_is_red(tmp_path, stub): + completed, _ = run_action(tmp_path, stub, INPUT_SG_API_URL="http://127.0.0.1:1/api/v1") + + assert completed.returncode == 1 + + +def test_missing_inputs_fail_fast(tmp_path, stub): + completed, _ = run_action(tmp_path, stub, INPUT_SG_ORG="") + + assert completed.returncode == 1 + assert "sg-org" in completed.stdout