Reusable CI configuration for the Tooark
security-scanner
image — Trivy (vulnerabilities), Hadolint (Dockerfile lint) and
Betterleaks (secret detection) behind one ark-tools CLI, producing a
single ark-report-tools v1.2 report.
One repository, two front ends:
- GitLab — CI/CD component templates in
templates/, usable throughinclude: remote:from anywhere, or published to a CI/CD Catalog. - GitHub — a composite Action defined by
action.yml.
Input names, defaults and precedence are the same on both sides; only the syntax differs.
New to CI pipelines? The
onboarding guide walks through
every file in this repository and the reasoning behind each decision, written
for readers who know software development but not CI. Source in
docs/.
🌍 Languages: 

- Quick start
- Templates and commands
- Trivy database cache
- Inputs
- How precedence works
- Secrets
- Security notes
- Overriding what inputs do not expose
- Versioning
- Publishing
- Gotchas
- Repository layout
- Development
- License
Works on gitlab.com and on any instance that can reach
raw.githubusercontent.com. No catalog required.
include:
- remote: "https://raw.githubusercontent.com/Tooark/ci-security-scanner/v1.1.0/templates/full-scan.yml"
inputs:
stage: test
image: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
trivy_severity: "CRITICAL,HIGH"
hadolint_failure_level: "warning"Once the mirror project has published a version to your instance:
include:
- component: $CI_SERVER_FQDN/tooark/ci-security-scanner/full-scan@1.1.0
inputs:
image: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
trivy_severity: "CRITICAL,HIGH"- uses: actions/checkout@v4
with:
fetch-depth: 0 # Betterleaks needs the full git history
- uses: Tooark/ci-security-scanner@v1.1.0
with:
command: full-scan
image: "myapp:${{ github.sha }}"
docker-socket: "true" # only when scanning an image built on this runner
trivy-severity: CRITICAL,HIGHLonger examples live in examples/.
Each GitLab template generates exactly one job. On GitHub the same scans are
selected through the command input of the single Action.
| GitLab template | Action command |
What it does |
|---|---|---|
full-scan.yml |
full-scan |
Image + source + secrets + Dockerfile lint, one merged report |
image-scan.yml |
image-scan |
Trivy vulnerability scan of a container image |
filesystem-scan.yml |
filesystem-scan |
Trivy scan of the source tree (lockfiles, OS and language deps) |
config-scan.yml |
config-scan |
Trivy IaC / misconfiguration scan |
repo-scan.yml |
repo-scan |
Trivy repository scan; accepts a remote URL |
dockerfile-lint.yml |
dockerfile-lint |
Hadolint on one Dockerfile |
secret-scan.yml |
secret-scan |
Betterleaks over the working tree and git history |
Reports land in scan-reports/ and are uploaded as artifacts. full-scan also
writes the consolidated full-scan-report.json.
| Tool | Fails when | Turn it off with |
|---|---|---|
| Trivy | A severity in trivy_severity_fail has a fix |
trivy_exit_code: "0" |
| Hadolint | A finding at hadolint_failure_level or above |
hadolint_failure_level: "none" |
| Betterleaks | Any secret is detected | betterleaks_fail_on_findings: "false" |
On GitHub, soft-fail: "true" turns any gate into the exit-code output
instead of failing the step.
Downloading the vulnerability database on every build is the slowest part of a
scan and the easiest way to hit registry rate limits, so both platforms cache
it. dockerfile-lint and secret-scan skip the cache entirely — Hadolint and
Betterleaks never read the database.
GitLab points TRIVY_CACHE_DIR at $CI_PROJECT_DIR/.cache/trivy and caches
that path under the fixed key ark-trivy-db, so every branch shares one
database.
On a self-hosted instance, runner caches are stored on the runner's own disk by default. With several runners, a job only hits the cache when it lands on the runner that wrote it. Configuring distributed caching (S3 or equivalent) in
config.tomlis what makes the hit rate consistent.
GitHub keeps the database in RUNNER_TEMP, which the job wipes on exit, so
the mount alone would only help across steps. actions/cache carries it
between runs with one entry per day per scanner version, falling back to the
previous day so Trivy refreshes an existing database rather than fetching a
whole one.
The save is a separate actions/cache/save step rather than the automatic post
step, because the post step is skipped when an earlier step failed — and this
action fails by design when a gate trips. Without the split, only repositories
that found nothing would ever populate the cache.
| Goal | GitLab | GitHub |
|---|---|---|
| Disable the cache | Override the job with cache: [] |
trivy-cache: "false" |
| Reuse without refreshing | TRIVY_SKIP_DB_UPDATE: "true" |
same, as job env |
A cached database is still refreshed when Trivy considers it stale; the cache
saves the download, it does not freeze the data. TRIVY_SKIP_DB_UPDATE does
freeze it, which trades result accuracy for speed — the image forwards it to
Trivy, which reads the variable natively.
GitLab uses snake_case, GitHub Actions uses kebab-case. Otherwise the names
and meanings match one to one.
| GitLab | Action | Default | Notes |
|---|---|---|---|
job_name |
— | security:<command> |
Change it to generate the job more than once |
stage |
— | test |
The stage must exist in the pipeline |
scanner_image |
scanner-image |
ghcr.io/tooark/security-scanner |
|
scanner_version |
scanner-version |
1.9 |
Pin it |
allow_failure |
soft-fail |
false |
GitLab marks the job non-blocking; the Action reports the exit code |
rules |
— | [{when: on_success}] |
GitHub uses the workflow's own if: |
tags |
— | [] |
Runner selection |
timeout |
— | 1h |
|
reports_dir |
reports-dir |
scan-reports |
|
artifacts_expire_in |
artifact-retention-days |
7 days / 7 |
Artifact retention |
report_url |
report-url |
— | Webhook targets, comma-separated |
report_fail_on_error |
report-fail-on-error |
— | Fail when the upload fails |
extra_args |
extra-args |
— | Flags forwarded after the -- separator |
| GitLab | Action | Used by |
|---|---|---|
image |
image |
full-scan (empty skips the image step), image-scan (required) |
scan_path |
path |
full-scan |
path |
path |
filesystem-scan, config-scan, secret-scan |
target |
target |
repo-scan |
dockerfile |
dockerfile |
dockerfile-lint |
dockerfiles |
dockerfiles |
full-scan, comma-separated, relative to the scan path |
On GitHub, paths are relative to the workspace; the Action rewrites them against the container mount point.
trivy_severity, trivy_severity_fail, trivy_ignore_unfixed,
trivy_ignore_unfixed_fail, trivy_exit_code, trivy_format,
trivy_scanners, trivy_timeout, trivy_server, trivy_ignorefile,
hadolint_failure_level, hadolint_config, hadolint_format,
betterleaks_fail_on_findings, betterleaks_redact, betterleaks_config,
betterleaks_baseline, sbom, sbom_format, scan_mode, skip_image,
skip_lint, skip_secrets, no_git.
Each maps to the matching environment variable documented in the
image README.
Every template declares the ones it supports, with descriptions and allowed
values, in its spec:inputs block — that is the authoritative reference.
| Input | Default | Notes |
|---|---|---|
command |
full-scan |
Selects the scan |
docker-socket |
false |
Mounts /var/run/docker.sock so Trivy can read locally built images |
trivy-cache |
true |
Caches the vulnerability database under RUNNER_TEMP |
soft-fail |
false |
Report the exit code instead of failing the step |
upload-artifact |
true |
Uploads reports-dir with actions/upload-artifact |
artifact-name |
security-reports |
Outputs: exit-code, reports-dir, report.
Every tunable resolves in the same order on both platforms:
input > CI/CD variable (GitLab) or job env (GitHub) > image default
An empty input is never forwarded. That is deliberate: it lets a project
set TRIVY_SEVERITY once as a global variable and leave the matching input
blank in every job, rather than repeating it. Setting both means the input
wins.
Input values are visible in the rendered pipeline configuration, so secrets
never travel as inputs. Pass them as masked CI/CD variables (GitLab) or job
env (GitHub), and they are forwarded to the container automatically:
TRIVY_TOKEN, TRIVY_USERNAME, TRIVY_PASSWORD, REPORT_TOKEN,
REPORT_HEADERS, REPORT_SBOM_URL, REPORT_SBOM_TOKEN.
# GitHub
- uses: Tooark/ci-security-scanner@v1.1.0
env:
REPORT_TOKEN: ${{ secrets.REPORT_TOKEN }}
with:
report-url: https://security-hub.example.com/api/reportsBetterleaks redacts every secret in the report by default
(betterleaks_redact: "100"), and the job log prints only rule, file, line and
short commit — never the secret itself.
Four things are worth knowing before you wire this into a pipeline that holds credentials.
docker-socket: "true" hands the container root on the runner. The Docker
socket is an unrestricted control plane for the daemon, so anything inside the
container can start a privileged container and read the host. It is off by
default and only needed to scan an image built earlier in the same job — an
image already pushed to a registry does not need it. On a shared self-hosted
runner, prefer pushing to a registry and scanning from there.
Reports can contain the secrets they found. Two settings turn an artifact
into a disclosure: betterleaks_redact: "0" writes detected secrets in
cleartext, and adding secret to trivy_scanners puts Trivy's findings in the
report. Artifacts are downloadable by everyone with read access to the
repository or project, so leave redaction at its default unless the artifact
destination is as restricted as the secrets themselves.
Floating tags are mutable by design. Each release force-moves v1 and
v1.0, so pinning either means code you have not reviewed runs in your
pipeline after the next release. v1.0.0 is never moved, but a GitHub tag can
in principle be rewritten by anyone with push access; a remote include pinned
to a commit SHA is the only fully immutable reference:
include:
- remote: "https://raw.githubusercontent.com/Tooark/ci-security-scanner/<commit-sha>/templates/full-scan.yml"The reports directory is briefly world-writable on GitHub runners. The image drops to uid 1000, which is not the runner user, so the directory is opened up for the duration of the scan and tightened again afterwards. On ephemeral runners this is immaterial; on a self-hosted runner with concurrent jobs, another job could write into it during the scan.
eval appears in the templates and in src/run-scanner.sh, but only ever
iterates a hardcoded list of variable names — no input reaches it. Word
splitting of extra_args is deliberate and runs under set -f, so a value
like * cannot expand against repository files. Workflow expressions reach
run: blocks through env: rather than string interpolation.
scripts/validate-templates.py uses yaml.safe_load. Workflow tokens are
scoped: contents: read for CI, contents: write only for the release job.
The third-party actionlint image is pinned by digest, and Dependabot tracks
the rest.
A generated GitLab job is an ordinary job. Redeclare it by name to change anything the inputs do not cover:
"security:full-scan":
needs: ["build"]
services:
- docker:27-dind
cache: [] # disable the Trivy database cache
variables:
TRIVY_SCANNERS: "vuln,secret,misconfig,license"To run the same scan twice with different settings, include the template twice
with a different job_name — see
examples/gitlab/remote-include.gitlab-ci.yml.
Releases are tagged vMAJOR.MINOR.PATCH. Each release also moves two floating
tags so consumers can track a line without editing pipelines on every patch:
| Reference | Resolves to | Use when |
|---|---|---|
v1.0.0 |
Exactly that release | Reproducible pipelines |
v1.0 |
Newest patch of 1.0 | Automatic patch updates |
v1 |
Newest release of the 1.x line | Automatic minor and patch updates |
main |
Unreleased work | Never in a pipeline you care about |
VERSION is the single source of truth for both the component
version and the scanner image tag that every template and the Action pin.
scripts/check-sync.sh fails CI if any of them drift, and the release workflow
refuses a tag that disagrees with COMPONENT_VERSION.
Bumping the scanner image is therefore a three-line change: edit VERSION,
run ./scripts/check-sync.sh, update the pins it flags.
Push a v*.*.* tag. .github/workflows/release.yml
validates the templates, checks the tag against VERSION, creates the release
with generated notes and moves the floating tags.
Marketplace listing is a manual opt-in that the API cannot set: open the release on GitHub and tick Publish this Action to the GitHub Marketplace. Only needed once; later releases offer the same checkbox.
The catalog only lists components hosted on the GitLab instance itself, so a
GitHub repository cannot be published to it directly. Set up the mirror project
described in examples/gitlab-catalog-mirror/:
it polls GitHub releases on a schedule, copies templates/ across when the
version moves, and publishes to the internal catalog.
The GitLab entrypoint override is required. The image entrypoint execs
ark-tools directly, and GitLab Runner keeps the image entrypoint and attaches
a shell to it. Every template sets entrypoint: [""]; removing it breaks the
job before the script runs.
GIT_DEPTH: "0" matters for secret scanning. Betterleaks walks the git
history. With GitLab's default shallow clone, or GitHub's default
fetch-depth: 1, it silently sees almost nothing. The templates set
git_depth: "0"; on GitHub set fetch-depth: 0 on actions/checkout.
Scanning an image built in the same GitHub job needs the socket. Trivy
looks the image up in the local daemon, which the container cannot reach
without docker-socket: "true". Images already pushed to a registry do not
need it.
File ownership on GitHub runners. The image drops to the non-root app
user (uid 1000), which is not the runner user. The Action creates the reports
directory world-writable and hands ownership back afterwards, and declares the
workspace as a git safe directory. Custom reports-dir values inherit this.
Dockerfile lint handles one file per job. Use full-scan with
dockerfiles: "a,b,c", or include dockerfile-lint once per file with
different job_name values.
The empty tags default renders as tags: []. GitLab has no way for an
input to omit a keyword, and an empty tag list is how you say "any runner".
Pipelines accept it; only GitLab's editor JSON schema rejects empty arrays
(gitlab#551088), and
that schema applies to .gitlab-ci.yml files, not to these templates.
templates/ GitLab CI/CD component templates, one job each
action.yml GitHub composite Action
src/run-scanner.sh Shared runner behind the Action
scripts/ Validation run in CI and locally
examples/ Ready-to-copy pipelines for both platforms
docs/ Onboarding guide, published to GitHub Pages
SUPPORTED-INTEGRATIONS.md Platforms, runners and versions supported
VERSION Single source of truth for versions
python3 -m pip install pyyaml
python3 scripts/validate-templates.py # structure, input wiring, dead inputs
./scripts/check-sync.sh # version pinning and cross-platform parity
shellcheck -s bash src/run-scanner.sh scripts/check-sync.shCI runs all three, plus actionlint, plus a self-scan in which the Action in
this repository scans this repository.
When adding an input, touch all four places or check-sync.sh will say so:
the template's spec:inputs, the template's variables: block as ARK_IN_*,
action.yml, and src/run-scanner.sh.
MIT — see LICENSE.
