Run checks in GitHub Actions
This page puts ctfkit by compliance.tf findings on every pull request, in a sticky comment, the job summary and the Security tab, from one public action. ctfkit itself opens no network connection and reads no cloud credentials. The action needs the release token to download ctfkit, and the steps your workflow runs before it need their usual credentials.
The public composite action compliancetf/ctfkit-action installs one pinned ctfkit release, checks it against the release checksums, and runs one ctfkit command over what your workflow already produced. It does not run init, plan or test; your workflow owns those steps. Status: preview.
Get access
ctfkit is available to compliance.tf customers on request: see Get access.
Two defaults need attention on first use:
commanddefaults toreport, which reads the module cache and so needsinitto have run. For a first run withoutinit, setcommand: scanandstage: pre_plan.sarifdefaults to'true'. On a repository without code scanning the upload fails and the job fails with it, even when ctfkit found nothing. Setsarif: 'false'there.
1. Create the release token
The action downloads ctfkit from the releases of the private compliancetf/ctfkit repository. The job token cannot read another private repository, so every workflow needs this token. Create a fine-grained personal access token with an account that has been granted access:
- Resource owner:
compliancetf - Repository access: only
compliancetf/ctfkit - Repository permissions: Contents: read
Store it as an Actions secret, for example CTFKIT_RELEASE_TOKEN, at the repository or organization level, and pass it as github-token. The action uses it for the release download only.
A fine-grained token expires on the date you set. When it expires, the download fails and every ctfkit step fails with it, so note the date and rotate the secret before then. To keep the token independent of one person's account, create it with a dedicated machine user that has been granted access.
2. Pick a command
command | Reads | Your workflow runs first | Credentials needed |
|---|---|---|---|
scan with stage: pre_plan | configuration in chdir | checkout | none beyond the release token |
report (default) | the module cache, via path | init | your compliance.tf registry token, for init |
scan | plan JSON, via plan | init, plan, show -json | registry token and cloud credentials, for plan |
Start with command: scan and stage: pre_plan when your workflow does not run init yet: it needs only the checkout and the release token. Use report, the default, where init already runs in CI, and a plan scan where plan does.
- uses: compliancetf/ctfkit-action@v0.2.1
with:
command: scan
stage: pre_plan
chdir: infra
version: v0.2.1
github-token: ${{ secrets.CTFKIT_RELEASE_TOKEN }}3. Choose where results go
| Destination | Input | Needs |
|---|---|---|
| Security tab | sarif: 'true' (default) | code scanning on the repository and security-events: write |
| Pull request comment | pr-comment: 'true' | pull-requests: write. One comment per command and directory, updated in place on every run |
| Job summary | always | nothing |
The job summary and the comment carry the same text. For report it is the report. For scan, which has no format, it is ctfkit's text output, HTML-escaped inside a <pre> block so that nothing from the checkout can inject markdown. The markdown output is the path to that file.
The action keys each comment by the command and the directory: chdir for scan, path for report, and . when neither is set. Two steps with the same command and directory update the same comment.
The comment uses comment-token, which defaults to the job token and is separate from github-token. A comment that cannot be written is a warning and does not fail the step.
Blocking a merge
The action fails its step when ctfkit exits 1 or 2, so the job shows a failing check. That check blocks a merge only when branch protection or a ruleset on the target branch requires it, so what blocks depends on the checks you require and the fail-on you set.
4. Set permissions
| Permission | Needed for |
|---|---|
contents: read | checkout |
pull-requests: write | pr-comment: 'true' |
security-events: write | sarif: 'true' |
Complete workflow
This is the workflow in the diagram. Job ctfkit-scan runs a pre_plan scan over each root with no cloud or registry credentials. Job test runs init and tofu test over each root, then reports module coverage with the test results. Both comment on the pull request. It is written for a private repository without code scanning; the notes after it show the change for code scanning.
name: ctfkit
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
ctfkit-scan:
# Pull requests from forks get no secrets, so the release download would fail.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
root: [infra/network, infra/storage]
steps:
- uses: actions/checkout@v4
- uses: compliancetf/ctfkit-action@v0.2.1
with:
command: scan
stage: pre_plan
chdir: ${{ matrix.root }}
version: v0.2.1
github-token: ${{ secrets.CTFKIT_RELEASE_TOKEN }}
sarif: 'false'
pr-comment: 'true'
test:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
root: [infra/network, infra/storage]
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v2
- name: Initialize
working-directory: ${{ matrix.root }}
env:
# Your compliance.tf registry token, for init; ctfkit never reads it.
# One variable per compliance.tf host in your module sources.
TF_TOKEN_bobthecorp_compliance_tf: ${{ secrets.CTF_TOKEN }}
run: tofu init -input=false
- name: Run the tests
working-directory: ${{ matrix.root }}
run: tofu test -json > tests.json
continue-on-error: true
- uses: compliancetf/ctfkit-action@v0.2.1
with:
command: report
path: ${{ matrix.root }}
tests-json: ${{ matrix.root }}/tests.json
version: v0.2.1
github-token: ${{ secrets.CTFKIT_RELEASE_TOKEN }}
sarif: 'false'
pr-comment: 'true'Terraform reads a registry token from TF_TOKEN_<host>, with the dots in the host name replaced by underscores. For the host bobthecorp.compliance.tf that is TF_TOKEN_bobthecorp_compliance_tf.
With code scanning on the repository, change two things in each job: add security-events: write to permissions, and drop sarif: 'false' so the default upload runs. A root without tests drops the test step and the tests-json input. Terraform works the same way: use hashicorp/setup-terraform, terraform init and terraform test -json.
To scan a plan as well, add tofu plan -out=tfplan and tofu show -json tfplan > plan.json in the root after init, then a step with command: scan, plan: ${{ matrix.root }}/plan.json and chdir: ${{ matrix.root }}. That job needs the same cloud credentials your plan job already has.
Scanning one root of several
Without chdir, a scan reads configuration from the whole checkout. In a repository with several roots, set chdir to one root per step or matrix entry, as in the workflow above. ctfkit writes each finding's file relative to chdir, and the copy uploaded to the Security tab gets the chdir prefix back, so code scanning resolves infra/network/main.tf against the repository root. plan stays relative to the checkout.
Settings the repository cannot change
The action passes fail-on, config, exceptions and catalog to ctfkit on every run, so ctfkit never reads a .ctfkit.yaml or .ctf-exceptions.yaml committed to the repository it analyzes. A pull request cannot lower the threshold, widen the endpoint allowlist or grant itself an exception by committing a file.
Unless you set the config and exceptions inputs, the action uses the files bundled with it. The bundled settings leave every key at the ctfkit default, so no tag policy runs. The bundled exception registry is empty.
| Input | Default | Meaning |
|---|---|---|
fail-on | error | lowest level that fails the step: error, warning, note, never |
config | bundled | settings file; the bundled one leaves every key at the ctfkit default |
exceptions | bundled | exception registry; the bundled one is empty, so nothing is suppressed |
catalog | embedded | pins the catalog compiled into the release you pinned with version |
To use your own settings or exceptions, keep ctfkit.yaml and exceptions.yaml on the default branch, or in a separate repository, and read them from a second checkout of the pull request's base commit. A pull request can change its own head, but not the base commit's copy:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: .ctfkit-base
sparse-checkout: .ctfkit
- uses: compliancetf/ctfkit-action@v0.2.1
with:
command: scan
stage: pre_plan
chdir: infra
version: v0.2.1
github-token: ${{ secrets.CTFKIT_RELEASE_TOKEN }}
config: ${{ github.workspace }}/.ctfkit-base/.ctfkit/ctfkit.yaml
exceptions: ${{ github.workspace }}/.ctfkit-base/.ctfkit/exceptions.yamlFor a separate repository, set repository on the second checkout instead of ref, with a token that can read it.
Pull requests from forks
- A
pull_requestfrom a fork runs without repository secrets, soCTFKIT_RELEASE_TOKENis empty and the release download fails. Theif:condition in the workflow above skips those runs. - The job token on a fork pull request is read-only, so a
pr-commentthere ends with a warning and no comment. pull_request_targethas a write token and secrets and runs the base branch workflow. Under it, never check out the pull request head, and never runterraformortofuwith secrets available: configuration from a fork can run code duringinitandplan.
Inputs
| Input | Default | Meaning |
|---|---|---|
command | report | report or scan |
version | required | release to install, such as v0.2.1 |
github-token | job token | token for the release download only. The job token cannot read the private compliancetf/ctfkit repository, so set this |
path | . | module root for report |
tests-json | none | tofu test -json or terraform test -json output for the same root, for report |
plan | none | plan JSON for scan; refused with stage: pre_plan |
chdir | none | configuration directory for scan |
stage | empty | scan: auto, pre_plan or post_plan; empty means auto. report: empty means post_init |
format | receipt,sarif,markdown | report formats; spacelift is refused. scan always renders sarif and text |
sarif | true | upload to the Security tab; set 'false' on a repository without code scanning |
pr-comment | false | keep one comment on the pull request |
comment-token | job token | token for the pull request comment only |
fail-on, config, exceptions, catalog | see above | pinned policy inputs |
repository | compliancetf/ctfkit | repository whose release holds the archive |
Outputs
| Output | Meaning |
|---|---|
exit-code | 0 ok, 1 findings at or above fail-on, 2 environment error |
sarif | path to the SARIF findings file |
markdown | path to the text written to the job summary: the report section for report, the escaped text findings for scan |
receipt | path to the evidence receipt; empty for scan |
pr-comment | created, updated, skipped or failed; empty when pr-comment is off |
The action records the ctfkit exit code, runs the SARIF upload, the job summary and the comment, and applies the exit code last. A run with findings still fills the Security tab and the comment before the job goes red. To read exit-code in a later step, set continue-on-error: true on the action step.
The receipt records which controls the compliance.tf modules in the configuration enforce at module build time, as read from the manifests written at init. It describes the configuration and says nothing about live resources. It is unsigned; signing is planned.