Skip to main content

GitHub Actions

The TRUSCA composite action triggers a TRUSCA scan, waits for it to finish, evaluates the build gate, and (on pull requests) posts the SCA report back to the PR. It exits non-zero when the gate fails so the PR check turns red and your branch-protection rule blocks the merge.

Audience

Engineers maintaining a GitHub repository that uses GitHub Actions. You need an API key for the portal — see API keys.

Action source

Use the in-repo composite action at actions/scan/action.yml directly via uses: trustedoss/trusca/actions/scan@v0.22.6 (referenced from this monorepo). A standalone Marketplace publication is on the roadmap.

Before you begin

Three things must exist before the workflow below can run:

  • A portal the runner can reach. GitHub-hosted runners cannot reach http://localhost:5173 — the Quickstart demo stack on your laptop is not enough. You need a TRUSCA deployment with a network-reachable URL (see Install with Docker Compose); that URL becomes api-url. Self-hosted runners inside the same network can of course use an internal URL.
  • An API key, issued in the portal under /integrations → API keys — Setup step 1 below walks through it.
  • The project id of the portal project this repository maps to — the last path segment of its portal URL. Setup step 3 below shows where.

Quick start

# .github/workflows/sca.yml
name: TRUSCA SCA
on:
pull_request:
push:
branches: [main]

jobs:
sca:
runs-on: ubuntu-latest
permissions:
contents: read # the action needs nothing beyond checkout
steps:
- uses: actions/checkout@v4
- name: TRUSCA SCA scan
uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}

That's the minimum. The action:

  1. Calls POST /v1/projects/{project-id}/scans with kind=source to enqueue cdxgen + scancode + Trivy.
  2. Polls GET /v1/scans/{scan-id} every 30 seconds until terminal (succeeded / failed / cancelled), with a 30-minute timeout.
  3. Calls GET /v1/projects/{project-id}/gate-result?ref=<github.ref> and writes the verdict into the workflow's job summary.
  4. On pull_request events, calls POST /v1/scans/{scan-id}/post-pr-comment so the SCA Markdown report shows up as a PR comment.
  5. Exits 1 if the gate verdict is fail.

The ref in step 3 is what keeps a pull request's verdict its own. Ask for a project's gate result without one and the portal answers with the newest succeeded scan of its main line, so a PR build that just waited out its own scan would be judged by main — blocked by a critical CVE it never introduced, or passed while carrying one of its own. The action sends the same github.ref it sent when triggering, and the portal normalizes both ends the same way (refs/pull/12/merge and pr-12 are one key), so the verdict always describes the code that was scanned.

A ref with no succeeded scan yields the no-signal pass rather than another branch's findings. That is deliberate: naming a branch means that branch or nothing.

Setup

1. Generate an API key

In the portal: /integrations → API keys → Create API key. Pick scope project and bind it to the project CI will scan (or team if you intend one key to cover every project owned by a team). Set "What this key may do" to Read and write; this action triggers a scan, which a read-only key (the default) is refused. API keys otherwise inherit the issuing user's role. See API keys for the scope and permission-breadth model.

2. Store the key in GitHub

In your repo: Settings → Secrets and variables → Actions → New repository secret.

  • Name: TRUSTEDOSS_API_KEY
  • Value: the full key (tos_<prefix>_<secret>)

3. Store the project ID as a variable

In the same screen, switch to Variables and add:

  • Name: TRUSTEDOSS_PROJECT_ID
  • Value: the project's UUID — the last path segment of its portal URL (/projects/<uuid>).

Variables (not secrets) keep the project ID readable in workflow logs — it is not sensitive.

4. Add the workflow

Drop .github/workflows/sca.yml (above) into the repo. On the next PR, the SCA check appears as a PR status.

Inputs

NameRequiredDefaultDescription
api-urlyesPortal base URL, e.g. https://trustedoss.example.com. Trailing slash OK.
api-keyyesAPI key. Always supply via ${{ secrets.* }}.
project-idyesProject UUID.
scan-kindnosourcesource (cdxgen + scancode + Trivy) or container (Trivy image scan).
image-refwhen scan-kind: containerImage the portal pulls, e.g. ghcr.io/acme/api:1.4.0. Push it before this step runs.
fail-on-gatenotrueIf true, the job exits 1 when the gate verdict is fail.
post-pr-commentnotrueIf true (and the workflow was triggered by pull_request), posts the SCA report as a PR comment.
sarif-fileno-Path to write the scan's open findings to as a SARIF 2.1.0 document, for upload to the repository's Code scanning tab. Empty writes nothing. See Publish findings to code scanning.
poll-timeout-secondsno1800Max seconds to wait for the scan to reach a terminal state.
poll-interval-secondsno30Seconds between scan-status polls.

Outputs

NameDescription
scan-idUUID of the scan that was enqueued and evaluated.
gatepass or fail.
reasonHuman-readable reason when gate == 'fail'; empty otherwise.
critical-cve-countOpen critical-severity findings on the evaluated scan.
forbidden-license-countDistinct components carrying a forbidden-classification license.
epss-gate-countOpen findings whose EPSS score met or exceeded the configured EPSS threshold. 0 when the EPSS gate is disabled (the default). See Gate the build on EPSS.
malicious-component-countDistinct components the malicious-package snapshot flags. These block regardless of severity — remove the package and rotate any credentials the build could reach.
epss-outcomeWhat the EPSS axis of the gate was able to judge. not_configured: no threshold set, so the axis was off by choice. evaluated: every open finding carried an EPSS score, so epss-gate-count is a complete answer. partial: some open findings carried no score, so a 0 there is not proof nothing would have tripped the threshold. no_data: not one open finding carried a score, so the axis decided nothing at all and the pass is the absence of a verdict. Empty on a portal too old to report it.
sarif-filePath of the SARIF document written, or empty when the sarif-file input was not set.
kev-gate-countOpen findings whose CVE is listed in the CISA Known Exploited Vulnerabilities catalog. 0 when the KEV axis is off (the default). Read kev-outcome before treating a 0 as an all-clear.
kev-outcomeWhat the KEV axis was able to judge. not_configured: off by choice. evaluated: every open finding has been through a catalog sync. partial: some findings were discovered since the last sync, so their flag is a default rather than an answer. no_data: the catalog has never synced here, so the axis judged nothing.
eol-gate-countComponents on the evaluated scan whose release line is past end of life. 0 when the end-of-life axis is off (the default).
eol-outcomeWhat the end-of-life axis was able to judge, with the same four values. partial is the ordinary state: the lifecycle catalog covers a curated set of runtimes and frameworks, so most application dependencies have no entry.
component-outcomeWhat the scan's SBOM ended up containing. components_found is the ordinary case. empty_no_manifests and empty_with_manifests both mean the scan produced no components, so a passing gate reflects the absence of anything to judge rather than a clean result: the first is expected for a build system TRUSCA does not read, the second points at a scan failure. Empty on a portal too old to report it.

Use them in subsequent steps:

- name: TRUSCA SCA scan
id: sca
uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}
fail-on-gate: 'false' # collect, don't fail
- name: Branch on the gate verdict
if: steps.sca.outputs.gate == 'fail'
run: |
echo "Critical CVEs: ${{ steps.sca.outputs.critical-cve-count }}"
echo "Forbidden licenses: ${{ steps.sca.outputs.forbidden-license-count }}"
exit 1

Recipes

Advisory mode (don't fail, just report)

Useful while you are seeding policies and don't want to block PRs yet:

- uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}
fail-on-gate: 'false'

The PR comment still posts; the check stays green.

Container scan

- uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}
scan-kind: container
image-ref: ghcr.io/acme/api:${{ github.sha }}

Container scans run Trivy on the image's OS packages. image-ref is required — the portal has no default image for a project, and a container scan without one is rejected at trigger time rather than failing later in a worker.

The portal pulls the image itself, so push it before this step runs. A tag that exists only in the runner's local Docker daemon is not reachable, and a private registry needs credentials configured on the portal rather than on the runner.

Both source and container

Run two steps with different ids:

- name: SCA — source
uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}
scan-kind: source

- name: SCA — container
uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}
scan-kind: container
image-ref: ghcr.io/acme/api:${{ github.sha }}

Either step failing fails the job by default.

Both steps scan the same portal project, and the portal allows one active scan per (project, ref) — so run them in sequence, as above, rather than as parallel jobs. Run in parallel, the second would attach to the first instead of starting its own.

Gate by branch

Apply the gate only on main, advisory on PRs:

- uses: trustedoss/trusca/actions/scan@v0.22.6
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: ${{ vars.TRUSTEDOSS_PROJECT_ID }}
fail-on-gate: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }}

Gate on known-exploited CVEs and end-of-life components (optional)

Severity says how bad a flaw could be. Two other signals say something severity cannot, and each is its own opt-in axis on the portal (.env, then restart the backend):

VariableWhat it blocks on
GATE_KEV_ENABLED=trueAny open finding whose CVE is in the CISA KEV catalog: somebody is exploiting it right now, whatever it scores.
GATE_EOL_ENABLED=trueAny component past end of life. Its CVE count understates the risk, because the flaws that will never be patched are the ones not yet found.

Both are off by default, and switching one on changes which builds fail.

When the axis cannot judge. Each reports a *-outcome alongside its count, because a count of 0 has two very different meanings: nothing was found, or nothing was checked. A KEV count of 0 on a portal whose catalog never synced is not an all-clear, and the same is true of an end-of-life count on a scan whose components were never matched against the lifecycle catalog. The action prints a warning annotation and a job-summary row for both undecided states, so a passing build says so rather than looking clean.

If you would rather an undecided axis fail the build than pass quietly, set GATE_KEV_ON_MISSING_DATA=block or GATE_EOL_ON_MISSING_DATA=block on the portal. Both apply to no_data only, never to partial: partial coverage is normal for all three data-driven axes, and an option that fires on a normal state is one that gets switched off within a week, which protects nothing.

- uses: trustedoss/scan-action@v1
id: sca
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: 8f1c...

- name: Escalate a known-exploited finding
if: steps.sca.outputs.kev-gate-count != '0'
run: echo "::error::${{ steps.sca.outputs.kev-gate-count }} known-exploited CVE(s) in this build"

Publish findings to code scanning

The gate answers one question: may this build proceed. It does not put the findings anywhere a reviewer reads them. Setting sarif-file writes the scan's open findings as a SARIF 2.1.0 document, which GitHub renders in the repository's Code scanning tab beside every other scanner's results.

permissions:
contents: read
security-events: write # required by upload-sarif

steps:
- uses: trustedoss/scan-action@v1
id: sca
with:
api-url: https://trustedoss.example.com
api-key: ${{ secrets.TRUSTEDOSS_API_KEY }}
project-id: 8f1c...
sarif-file: trusca.sarif

- uses: github/codeql-action/upload-sarif@v3
# `always()` matters: on a failing gate the action exits non-zero, and
# without this the upload is skipped exactly when there is most to report.
if: always()
with:
sarif_file: ${{ steps.sca.outputs.sarif-file }}

Three behaviours worth knowing:

  • The file is written even when the gate passes. An empty run is how code scanning learns that an alert it raised earlier is fixed. Skipping the upload on green would leave resolved alerts standing on the branch forever.
  • Suppressed findings are omitted, though the gate still counts them. A suppression is a decision your team already made; re-raising it as an alert is how a scanner teaches reviewers to ignore it. The gate takes the opposite view on purpose, because "we agreed to look away" does not answer whether a release should ship.
  • Severity maps onto SARIF's three levels. SARIF has no critical, so critical and high both arrive as error; the exact severity stays in the alert body, and the numeric security-severity is what GitHub sorts on.

The document describes the branch the workflow is running on, resolved the same way as the gate verdict, so a pull request's alerts are its own rather than the main line's.

Gate the build on EPSS (optional)

The build gate evaluates Critical CVEs and forbidden licenses by default. You can add an EPSS dimension so a CVE with a high predicted exploitation probability fails the build even when it is not Critical — useful for catching the small set of findings most likely to be attacked.

This is an operator-side, org-wide switch, not a workflow input: set the GATE_EPSS_THRESHOLD environment variable on the portal (.env), then restart the backend. It is disabled by default; leaving it unset preserves the existing Critical-CVE / forbidden-license gate exactly as before.

# In the portal's .env (not your CI workflow), a value from 0 to 1:
GATE_EPSS_THRESHOLD=0.5

With the threshold set, the gate also fails when any open finding has epss_score >= GATE_EPSS_THRESHOLD. The gate result then carries two extra fields, epss_gate_count (offending findings) and epss_threshold (the configured value), and the action exposes epss-gate-count as an output. Findings without an EPSS value never trip the gate (a missing score cannot satisfy >=). See GATE_EPSS_THRESHOLD for the full reference and EPSS — exploitation probability for the concept.

When the threshold cannot be evaluated

epss_gate_count is 0 both when nothing scored above the threshold and when nothing was scored at all, and those are not the same result. EPSS values are filled by a daily sync that is off by default (EPSS_REFRESH_ENABLED), so on a deployment that has never turned it on, or one whose mirror is unreachable, every open finding has a NULL score and the threshold you configured decides nothing. Before this was reported, that read as a passing build.

The epss-outcome output says which case you are in, and the action writes a job-summary row and a warning annotation for no_data and partial. GATE_EPSS_ON_MISSING_DATA on the portal decides what the verdict should be:

# Default. An undecided EPSS axis lets the build through, which is what
# every deployment did before this option existed.
GATE_EPSS_ON_MISSING_DATA=allow

# Fail the build instead, so a configured threshold cannot be ignored.
GATE_EPSS_ON_MISSING_DATA=block

block applies to no_data only. It deliberately does not fire on partial, because EPSS does not score every CVE and gaps are normal even with a healthy sync: an option that fires on a normal state is one nobody can leave switched on, and a control that has been switched off protects nothing.

Pin to a tag

A release tag names one commit today, but a tag can be moved or deleted. Pin to the commit itself for reproducibility:

- uses: trustedoss/trusca/actions/scan@176bc3f0632bf0cf209c443da308e3d863dfde44 # v0.22.6

What the ref does

The action automatically forwards the workflow's ref as scan metadata: github.ref (refs/heads/<branch>) on a push, or the PR number (refs/pull/<n>/merge) on a pull_request event. That value does two jobs.

The portal checks it out. The worker fetches the ref you sent and scans that tree, so a pull request's scan sees the pull request's dependencies. If the ref has disappeared by the time a worker picks the scan up — merged or force-pushed while it queued — the scan falls back to the default branch and records metadata.ref_fallback saying so.

It is also the retention key. The portal normalizes the ref — refs/heads/mainmain, refs/pull/12/mergepr-12 — and uses (project, normalized ref) to group scans: the latest successful scan for a key stays live and supersedes the previous one.

You do not configure anything for this — running the action on push and pull_request gives correct per-branch and per-PR grouping out of the box. To keep a scan permanently (for a tagged release), trigger it with a metadata.release label; the Scan retention page covers the full model and the release exemption.

How the PR comment is posted

The PR comment is posted server-side by the portal, not by your workflow. After the action uploads the SCA results, the portal evaluates the build gate and — if comment posting is enabled — calls https://api.github.com directly using a GitHub PAT stored in the portal's environment (GITHUB_TOKEN or TRUSTEDOSS_GITHUB_TOKEN). Your workflow never forwards secrets.GITHUB_TOKEN to the portal. A first-class GitHub App with portal-stored installation tokens is on the roadmap.

The comment is idempotent: re-running the workflow on the same PR updates the existing comment in place. The portal finds it by the marker <!-- trustedoss-sca-bot --> in the comment body. It scans the PR's most recent 500 comments looking for that marker, so on a thread longer than that it posts a new comment instead of updating.

Branch protection

To enforce SCA on every PR:

  1. Settings → Branches → Branch protection rules → Add rule.
  2. Branch name pattern: main.
  3. Check Require status checks to pass before merging.
  4. Search and check sca (the job name from the workflow above).
  5. Save.

Now PRs cannot merge while the SCA check is pending or failing.

Troubleshooting

Job times out at "Polling scan status"

Either the worker is overwhelmed (raise poll-timeout-seconds) or the scan genuinely hangs. Open the portal's scan in the UI for the live log.

Timing out stops the waiting, not the scan — the portal keeps running it. Re-running the workflow right away therefore attaches to that same scan rather than starting a second one, and finishes as soon as it does.

"Attaching to the scan already running for this ref"

Not an error. The portal permits one active scan per (project, ref), so when a run finds its ref already busy it waits on the existing scan instead of failing.

The usual cause is the workflow's own previous run. cancel-in-progress: true cancels the runner, but the scan it triggered keeps going server-side, so the replacement run collides with a scan nobody is watching. Attaching turns that into a normal wait.

If the scan it attached to was triggered from a different commit on the same branch, its verdict describes that commit. Re-run once it finishes to grade the newer one.

Trigger is rate-limited (429)

The action honours Retry-After and retries up to four times before failing. One API key shared across many repositories is the usual cause — the limit is per key, so give each repository its own.

403 Forbidden from the action

The API key's scope does not cover the project it is calling. Re-issue the key with scope project (preferred) bound to that project, or scope team if it must reach every project owned by a team. Verify the project belongs to the scope-bound team. See API keys.

PR comment did not appear

Three possibilities:

  • The workflow was triggered by push, not pull_request — only PR events get a comment.
  • The portal's GITHUB_TOKEN / TRUSTEDOSS_GITHUB_TOKEN env is unset, expired, or lacks write access to the target repo's pull requests. The comment is posted by the portal with its own credentials, not by the workflow's GITHUB_TOKEN, so granting the job more permission changes nothing. Operators rotate or extend the token in the portal .env and restart the backend.
  • The PR number reaching the portal was wrong or absent. The action reads it from github.event.pull_request.number, which is empty outside pull_request events.

Need to skip on a chore PR

Use a path filter so the workflow does not run when only docs change:

on:
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'

See also