SecureChain Beta

User guide

CI/CD

Recipes for a pipeline that already sends a scan: pick the right token, choose a gate, check pull requests, record releases, scan a monorepo, and send the results to other tools. For the first job, see Get started. The examples use npm ci as the install step: use the install step of your project, before the scan (what the job needs).

Add the first job #

The steps for each CI system are on Get started: where to keep the token, and the job to add.

In your workspace, Repositories › Set up CI mints the token of one repository and shows its job for your CI system. Name the job securechain.

Pick a token for the pipeline #

Use the smallest token that does the job. If it leaks, less is exposed.

Your pipelineWho makes the tokenWhat to select on Access tokens
The pipeline of one repositoryAn admin, or a member of that repositoryThe CI token (one repository) preset and the repository. The form starts with this preset. The token can send and read that repository only, so the CLI shows no link to the scan of a git dependency in another repository (see links to another repository). It expires in 90 days. A member must set an expiry of 90 days or less. The token stops when the person who made it is disabled or loses the member role on the repository, and Access tokens marks it; it works again if that person gets the role back.
A CI server or a shared CI template that builds many repositories of one teamAn adminScope a department, the department, and the CI upload (all my repositories) rights. One token serves every repository of the department, so the team does not make and replace one token for each repository. A scan of a new repository puts the repository in that department. The token can read every repository of the department, so the CLI shows the link to the scan of a git dependency in another repository of the department (see links to another repository). The token cannot send scans to a repository of another department. This token does not depend on a person: it keeps working when the admin who made it leaves.
A pipeline that builds more than one repository, when you are not an adminA memberThe CI upload (all my repositories) rights. The token acts as you: it can read every repository that you can read, and the form warns about it. It expires in 90 days unless you clear the date. It stops when your user is disabled. A job that only sends scans (securechain sca, securechain check) can use Upload only: it reads only the findings of its own scans.

Access tokens explains each right and scope.

Sign in with no stored token #

GitHub Actions and GitLab CI give each job a signed OIDC token. With a trust rule, the job trades that token for a portal token of one hour, and the pipeline stores no portal token. A leaked token stops by itself. The audit log names the repository, the ref, and the run of each sign-in.

  1. An admin opens Settings › CI sign-in and adds a rule: the CI system, the repository or project path, and the rights and the scope of the tokens. Set the ref too: a branch name, for example main, or a full ref, for example refs/tags/v1.2.0. A branch name never takes a tag with the same name. With no ref, the rule takes each branch, tag, and pull request of the repository that can get an OIDC token.
  2. Change the job as below. PORTAL_URL is the address of your workspace, with no slash at the end, for example https://acme.example.com. The audience of the OIDC token must be this address.

GitHub Actions:

permissions:
  id-token: write
  contents: read
steps:
  - name: Sign in to the portal
    env:
      PORTAL_URL: https://acme.example.com
    run: |
      oidc=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
        "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$PORTAL_URL" | jq -r .value)
      token=$(jq -n --arg t "$oidc" '{token: $t}' | curl -sS --fail-with-body -X POST \
        --data-binary @- "$PORTAL_URL/securechain/v1/ci-token" | jq -r .access_token)
      echo "::add-mask::$token"
      echo "SECURECHAIN_PORTAL_TOKEN=$token" >> "$GITHUB_ENV"

GitLab CI:

scan:
  variables:
    PORTAL_URL: https://acme.example.com
  id_tokens:
    PORTAL_ID_TOKEN:
      aud: https://acme.example.com
  script:
    - export SECURECHAIN_PORTAL_TOKEN=$(jq -n --arg t "$PORTAL_ID_TOKEN" '{token: $t}' |
        curl -sS --fail-with-body -X POST --data-binary @- "$PORTAL_URL/securechain/v1/ci-token" |
        jq -r .access_token)

Choose the gate #

CommandThe job fails whenUse it on
securechain scaNever. It only reports.Any branch, to keep the portal current.
securechain sca --fail-on highAn open finding is high or critical. The other levels are critical, medium, and low.The default branch and the release branches.
securechain sca --fail-on-exploitedAn open finding is known exploited, at any severity. It combines with --fail-on: either gate fails the job. CLI 0.1.80 or later.The default branch and the release branches.
securechain compare --base main --fail-on-newThe branch has an open finding that the latest scan of main does not have.Pull requests and merge requests. See Pull and merge requests.
securechain checkA project that uses TuxCare builds is not in the state that harden made.Projects that use TuxCare builds. See Fix vulnerabilities.

Pull and merge requests #

A pull request often inherits old findings from the base branch. compare --fail-on-new fails it only on the findings that it adds. The base branch needs a scan of its own, so scan the base branch on each push too.

A Gradle or Maven project runs its build. Each command that reads a Gradle or Maven project runs the build of the project: its build scripts and ./gradlew, or the files and extensions of .mvn/ of Maven. This holds with and without --run-project-hooks, and no option turns it off. So a scan of a merge request from a fork, or of other code that you do not trust yet, runs that code. Run such a scan in a sandbox job that holds no secret that the code must not read.

GitHub Actions:

name: securechain
on:
  push:
    branches: [main]
  pull_request:
jobs:
  securechain:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci              # the install step of your project
      - name: Scan the branch
        if: github.event_name == 'push'
        uses: TuxCare/securechain-action@v0
        with:
          command: sca
          args: --fail-on high
          portal-token: ${{ secrets.SECURECHAIN_PORTAL_TOKEN }}
          registry-login: none
      - name: Fail on new findings
        if: github.event_name == 'pull_request'
        uses: TuxCare/securechain-action@v0
        with:
          command: compare
          args: --base ${{ github.base_ref }} --fail-on-new
          portal-token: ${{ secrets.SECURECHAIN_PORTAL_TOKEN }}
          registry-login: none

GitLab CI:

securechain:
  image: node:22
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - npm ci              # the install step of your project
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain sca --fail-on high

securechain-new-findings:
  image: node:22
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - npm ci
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain compare --base "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --fail-on-new

The merge request job runs on the source branch. If SECURECHAIN_PORTAL_TOKEN is Protected, GitLab gives it to protected branches only, and the job stops with exit code 3. Keep Protected on that variable when its token has wider rights than one repository, or when an admin saved a registry token in the workspace (see below): every branch could then read it. Choose one of these two ways.

  1. A second token for the merge request job (recommended when the workspace has no registry token). Mint the CI token (one repository) preset for this repository (pick a token). Save it in a variable SECURECHAIN_MR_TOKEN: Masked, not Protected. Only the merge request job reads it. The protected token stays for the default branch.
  2. Run the merge request job for protected source branches only. The protected token then works, but a merge request from an ordinary feature branch gets no check. Use this rule: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_COMMIT_REF_PROTECTED == "true". In a merge request pipeline, CI_COMMIT_REF_PROTECTED tests the source branch.

Here is the job for the first way:

securechain-new-findings:
  image: node:22
  variables:
    SECURECHAIN_PORTAL_TOKEN: $SECURECHAIN_MR_TOKEN
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - npm ci
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain compare --base "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --fail-on-new

The risk: every branch of the project can read an unprotected variable. Anyone who can push a branch can print the token. The token can upload scans and read the findings of this one repository only. It cannot read other repositories, and it expires in 90 days at most. Mint a new one before it expires.

The token can also get the registry token of the workspace. Every token with the submit permission can get it, also a token of one repository. So anyone who can push a branch to the project can get the registry token from this variable. When an admin saved a registry token in the workspace, use the second way, or ask an admin before you use the first way. The Audit log records the reads of the registry token through the CLI: one row a day for each token and network.

Jenkins (multibranch pipeline): CHANGE_TARGET is set for a pull request build.

sh '''
  npm ci              # the install step of your project
  curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
  export PATH="$HOME/.local/bin:$PATH"
  if [ -n "$CHANGE_TARGET" ]; then
    securechain compare --base "$CHANGE_TARGET" --fail-on-new
  else
    securechain sca --fail-on high
  fi
'''

The portal files the scan of a pull request under the branch that it comes from. The portal sends alerts for the default branch and the supported releases only, so a feature branch sends none.

Python projects #

A pip job must install the requirements, or the CLI finds no packages. Each job ends with securechain sca --fail-on high. Use the install line of your project in place of the one shown.

uv (GitLab CI):

securechain:
  image: ghcr.io/astral-sh/uv:python3.12-bookworm
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - uv sync --frozen
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain sca --fail-on high

The line uv sync --frozen is optional. The CLI reads the packages from uv.lock, so the findings are the same without it. The line installs the packages into .venv, and the CLI reads from .venv the version ranges that each package sets for its dependencies. Without .venv, the CLI assumes a range that admits every version. Then, in the CLI report, the WHERE cell of a fix that rests on such a range ends in (range assumed), and the fix that it names can fail when the real range is narrower. On a large project, the install can download several hundred MB on each run. Remove the line to save the download, or keep it to get exact WHERE cells.

WHERE names the file that you change to move the package to the fixed version, not the file that holds the version. For a package that comes in through another package, this is often the override section of your package manager, for example pyproject.toml (tool.uv.override-dependencies), while the version is in uv.lock. add, as in pyproject.toml (add tool.uv.override-dependencies), means that the section does not exist in the file yet: the fix adds it (from the CLI release after version 0.1.72).

Poetry (GitLab CI):

securechain:
  image: python:3.12
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - pip install poetry
    - poetry install --no-root
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain sca --fail-on high

pip (GitLab CI):

securechain:
  image: python:3.12
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - python -m venv .venv
    - .venv/bin/pip install -r requirements.txt
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain sca --fail-on high

The installed environment comes before a lockfile #

From CLI 0.1.75, the installed Python environment is the source of the scan. The scan then shows what the image or the job holds, not what a lockfile recorded at an earlier time. An earlier release reads the lockfile first. Your results can change after the upgrade when the project has both an environment and a lockfile: the report shows the versions of the environment, and no package that only the lockfile names.

A lockfile that disagrees with the installed environment #

The project with only a pyproject.toml and a uv.lock, poetry.lock, or pdm.lock is the one exception to the rule above: it keeps the tool of the lockfile, also with an installed environment, because no requirements file says that pip installs the environment. The findings of uv and Poetry then come from the lockfile, and a stale lockfile reports versions that the environment does not hold. From CLI 0.1.75, sca, check, and harden compare the lockfile with the installed packages and warn where the two disagree.

Go projects #

The CLI matches the Go standard library against the Go version of the build. In CI, state the toolchain with --go-toolchain 1.24.13 or with the variable SECURECHAIN_GO_TOOLCHAIN=1.24.13 (CLI 0.1.34 or later). The commands securechain sca, check, harden, update, status, and compare all read the variable. Without it, the CLI uses go env GOVERSION, then the toolchain line of go.mod, then the go line. An earlier release has no flag and no variable: it uses go env GOVERSION, then the go line of go.mod. With an earlier release, run the job in an image that holds the Go toolchain of your build, for example golang:1.24.13.

securechain:
  image: golang:1.24
  variables:
    SECURECHAIN_GO_TOOLCHAIN: "1.24.13"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain sca --fail-on high

Record a release #

A release is the list of packages of a build that you ship. The portal keeps it, checks it against each new advisory, and can export it as a VEX file. Record it from the job that builds the release:

securechain sca --release "$CI_COMMIT_TAG"           # GitLab
securechain sca --release "$GITHUB_REF_NAME"         # GitHub, on a tag push
securechain sca --release "$TAG_NAME"                # Jenkins, with tag discovery on

With the GitHub action, pass the name in args, for example args: --release ${{ github.ref_name }}. The action gives args to the CLI as a list of words, with no shell. Use the action of release 0.1.19 or later.

More than one project in a repository #

Send the results to other tools #

FormatCommandFor
SARIFsecurechain sca --output sarif > securechain.sarifGitHub code scanning and other code-scanning tools.
JSONsecurechain sca --output json > findings.jsonYour own scripts. The JSON format is stable and has a schema_version.
Markdownsecurechain sca --output markdown > findings.mdA comment on a pull request, or a job summary.
JUnitsecurechain check --output junit > report.xmlThe test report of GitLab and Jenkins.

To upload the SARIF file to GitHub code scanning, add this step after the scan. The job needs the permission security-events: write:

      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: securechain.sarif

To read the findings from a dashboard, use the REST API.

Pin the CLI version #

The install script with no SECURECHAIN_VERSION installs the promoted release: a new release reaches it 48 hours after its tag (since release 0.1.86; see When a new release reaches you). To run a new release at once, or the same release in each job, pin it:

Install methodHow to pin
Install scriptSet SECURECHAIN_VERSION=v0.1.22 before the script.
GitHub ActionPin the action tag, or set the input version.
GitLab CI/CD componentPin the component version, or set the input version.
DockerUse a version tag, for example tuxcare/securechain:0.1.22, not latest.

When the CLI is 75 or more releases behind the newest release, it prints a warning. The warning does not change the exit code. A CI job that updates the CLI each month sees no warning. When it is older than the portal accepts, it stops and tells you to upgrade. See Upgrade the CLI.

Exit codes in CI #

CodeMeaningWhat to do
0The run finished. No finding failed the gate.Nothing.
1A finding failed the gate.Read the findings in the job log or in the portal.
2A wrong option, a configuration file that the CLI cannot read, no package manager that it knows, or an --ecosystem or ecosystems name that it does not know (from version 0.1.71; the message lists the names).Read the message.
3No token, or the portal refused it.Check the secret variable. See Troubleshooting.
4The portal did not answer in time, or its answer failed a check.Run the job again, or use --on-portal-error warn.
5A tool on your side failed: the install command of your package manager, a project environment that the CLI cannot find, or a file that the CLI cannot write.Read the output of the tool in the job log.
6harden, update, unharden, or fix changed the project, but the installed packages are not the planned ones. The CLI put your files back.Read the message. Contact TuxCare if the error stays.
7An error in the CLI. The CLI prints the path of a report file.Send the report to TuxCare.

The install script never exits 1 (from version 0.1.71). It exits 5 when you must fix something on your side: the operating system or the CPU type, a directory that it cannot write, no answer from the server, or a download that failed. It exits 7 when the TuxCare release is wrong: no version, no checksum line, or a checksum that does not match. So exit_codes: [1] below lets only findings through, and a failed install fails the job. An older install script exits 1 when it fails: pin version 0.1.71 or later, or end the install line with || exit 5.

To let a job report findings and not stop the pipeline, allow exit code 1 only. In GitLab, write allow_failure: exit_codes: [1]. Do not write allow_failure: true: it also hides the codes 2 to 7, where the CLI could not do its job, and the pipeline then shows green for a scan that did not run.

The install command of your project also exits 1 when it fails, for example npm ci or pip install. With exit_codes: [1], the job then shows amber for a scan that did not run. End each install line with || exit 5, as in the example below. The CI/CD component does this for its install input from the CLI release after version 0.1.72: it prints the code of the install and exits 5.

GitLab Runner 19.0 and earlier can report exit code 1 in place of the real code, so exit_codes: [1] also lets a code from 2 to 7 pass. Set the variable FF_USE_NEW_BASH_EVAL_STRATEGY: "true" in the job: the runner then reports the real code. Runner 19.1 and later always reports the real code and ignores the variable.

securechain:
  image: node:22
  variables:
    FF_USE_NEW_BASH_EVAL_STRATEGY: "true"   # GitLab Runner 19.0 and earlier
  allow_failure:
    exit_codes: [1]
  script:
    - npm ci || exit 5    # the install step of your project
    - curl -fsSL https://securechain.tuxcare.com/get/securechain | sh
    - securechain sca --fail-on high

Exit codes lists every code.

Next: Portal