Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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"
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ bundles/
.git/
vendor/pkg/
pyenv
Vagrantfile
Vagrantfile
__pycache__/
*.pyc
.pytest_cache/
17 changes: 0 additions & 17 deletions Dockerfile

This file was deleted.

161 changes: 141 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -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-<org>-<repo>-<workflow>` |
| `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/<YOUR_SG_ORG>/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.
Loading
Loading