Find and lint every shell in a project — .sh files, shebangs, and scripts embedded in GitLab CI and GitHub Actions YAML.
An Alpine-based image wraps shellcheck with four discovery modes: .sh files, files with a shell shebang (/bin/bash and env bash forms), and shell scripts embedded in CI YAML — GitLab CI before_script / script / after_script keys and GitHub Actions run steps — extracted via yq, with YAML anchors expanded. File discovery uses fd. Findings render as terminal output, GitLab Code Quality JSON for the merge request widget, or SARIF 2.1.0 for GitHub code scanning; embedded-script findings map back to their YAML source lines. Opt-in security rules flag remote code piped into a shell, secrets echoed to job logs, unquoted attacker-controllable CI variables, and GitHub expressions injecting untrusted data into run scripts.
- Requirements
- Image
- Tags
- Commands
- Run
- Reports
- Security rules
- Agents
- Packages
- Provenance
- Compared to alternatives
- Security
- Contributing
- License
Docker, BuildKit, or any OCI runtime able to pull from the GitHub Container Registry (or the Docker Hub mirror).
ghcr.io/coroboros/shellscan:{tag} — mirrored to docker.io/coroboros/shellscan:{tag}, and in the GitLab Container Registry at registry.gitlab.com/coroboros/security/infrastructure/shellscan:{tag}.
- Architectures:
linux/amd64,linux/arm64 - Working directory:
/shellscan - Entrypoint:
shellscan - User: non-root
shellscan(uid 10000)
| Tag | Base | Architectures | Size | Source | Mutability |
|---|---|---|---|---|---|
<version> |
koalaman/shellcheck-alpine:v0.11.0 |
amd64, arm64 |
18.6 MB (amd64), 27.0 MB (arm64) | SemVer git tag | immutable |
main |
same as <version> |
same as <version> |
varies by build | latest green main build |
rolling |
<sha> |
same as <version> |
same as <version> |
varies by build | every build | immutable |
Sizes are compressed layer sums from GHCR. The base image source of truth is the FROM line in the Dockerfile. Pin a digest for reproducible builds; see Provenance.
shellscan [mode] [fd_options]
mode
Discovery mode passed as the first positional argument.
| Mode | Behavior |
|---|---|
all |
Default. Scan every situation below combined. |
gitlab-ci |
Scan scripts embedded in .yml / .yaml files under before_script / script / after_script keys. YAML anchors are expanded. |
github-actions |
Scan run scripts embedded in .yml / .yaml files under jobs.<id>.steps[].run (workflows) and runs.steps[].run (composite actions). ${{ }} expressions are neutralized before linting; shellcheck runs with the step's bash/sh dialect and skips steps whose effective shell: resolves to pwsh, python, cmd, or node — Windows runners and windows-only matrices default to pwsh. The security rules run on every run script regardless. YAML anchors are expanded. |
shebang |
Scan files with a shell shebang (sh, bash, dash, ksh). |
.sh |
Scan .sh files. |
-h |
Print help and exit. |
fd_options
Extra options for the fd invocation, passed as one quoted argument (reference). Options are whitespace-split and passed verbatim — fd matches its own glob patterns, the shell never expands them. Command-execution flags (-x, -X, --exec, --exec-batch) are refused.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
SHELLCHECK_OPTS |
--color=always |
Options passed to shellcheck. |
SHELLSCAN_JOBS |
1 |
Parallel scan workers. Above 1 speeds up large scans; shellcheck output interleaves across files, final counts and exit code stay correct. |
SHELLSCAN_FORMAT |
human |
Output format: human, codequality (GitLab Code Quality JSON), or sarif (SARIF 2.1.0). Machine formats own stdout; progress moves to stderr. |
SHELLSCAN_BASELINE |
.shellscanignore |
Baseline file of finding fingerprints suppressed in machine formats — one per line, # comments allowed. |
SHELLSCAN_SECURITY |
0 |
Set to 1 to enable the security rules on scripts embedded in CI YAML — GitLab CI and GitHub Actions. |
Exit codes
| Code | Meaning |
|---|---|
0 |
Scan succeeded, no findings. |
1 |
Findings reported. |
2 |
Usage error, refused option, or discovery failure. A failed discovery aborts — it never reads as a clean scan of zero files. |
Examples
shellscan gitlab-ciSHELLCHECK_OPTS="--severity=info --color=never" \
shellscan gitlab-ci '--exclude *.yaml --exclude .gitlab-ci.yml'SHELLSCAN_SECURITY=1 SHELLSCAN_FORMAT=codequality \
shellscan gitlab-ci > gl-code-quality-report.jsonSHELLSCAN_SECURITY=1 SHELLSCAN_FORMAT=sarif \
shellscan github-actions > shellscan.sarifGitLab CI
check-sh-files:
image:
name: registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>
entrypoint: [""]
stage: check
variables:
SHELLCHECK_OPTS: >-
--severity=warning
--color=never
script:
- shellscan .shcheck-ci-yaml-files:
image:
name: registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>
entrypoint: [""]
stage: check
script:
- shellscan gitlab-cicheck-files-with-shebang:
image:
name: registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>
entrypoint: [""]
stage: check
script:
- cd ..
- shellscan shebang '--exclude *.sh'parallel-scan:
image:
name: registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>
entrypoint: [""]
stage: check
variables:
SHELLSCAN_JOBS: "4"
script:
- shellscan allCI/CD component
One include runs the scan and publishes a Code Quality report for the merge request widget. Findings are non-blocking by default; set fail_on_findings: true to gate the pipeline.
include:
- component: gitlab.com/coroboros/security/infrastructure/shellscan/shellscan@<version>
inputs:
security: trueThe final /shellscan segment is the component name from templates/shellscan.yml; GitLab component refs are <fqdn>/<project-path>/<component-name>@<version>.
Inputs: job_name, stage, mode, fd_options, security, fail_on_findings, jobs, shellcheck_opts, baseline, image — see templates/shellscan.yml.
GitHub Actions
The same image scans a GitHub repository — github-actions mode lints the workflows themselves, and the SARIF report lands in code scanning:
jobs:
shellscan:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- name: scan
run: |
docker run --rm -v "$PWD:/shellscan" \
-e SHELLSCAN_SECURITY=1 -e SHELLSCAN_FORMAT=sarif \
ghcr.io/coroboros/shellscan:<tag> all > shellscan.sarif || [ $? -eq 1 ]
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: shellscan.sarifpre-commit
The hook builds from the pinned repo rev — Docker is the only local runtime dependency.
repos:
- repo: https://gitlab.com/coroboros/security/infrastructure/shellscan
rev: <version>
hooks:
- id: shellscanDocker
Mount the project as the /shellscan volume and run any mode:
docker run --rm \
-v "$PWD:/shellscan" \
registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>docker run --rm \
-v "$PWD:/shellscan" \
registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag> \
gitlab-ci '--exclude "*.yaml"'SHELLSCAN_FORMAT=codequality emits GitLab Code Quality JSON; SHELLSCAN_FORMAT=sarif emits SARIF 2.1.0 for GitHub code scanning. Both formats write the report to stdout and move progress to stderr. Findings from scripts embedded in CI YAML carry the YAML source line and their selector — before_script / script / after_script for GitLab CI, jobs.<id>.steps[].run for GitHub Actions — so annotations land on the YAML line a reviewer actually reads.
Wire the Code Quality report into a job and findings surface in the merge request widget:
shellscan:
image:
name: registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>
entrypoint: [""]
stage: check
variables:
SHELLSCAN_FORMAT: codequality
script:
- shellscan gitlab-ci > gl-code-quality-report.json || [ $? -eq 1 ]
artifacts:
when: always
reports:
codequality: gl-code-quality-report.json|| [ $? -eq 1 ] keeps findings non-blocking while discovery failures (exit 2) still fail the job. Drop it to gate the pipeline on findings.
Every finding carries a stable SHA-256 fingerprint of its file, rule, and message. List fingerprints in .shellscanignore (one per line, # comments allowed) to suppress known findings — adopt shellscan on a legacy tree without fixing decades of shell first, and let the gate catch only what is new.
SHELLSCAN_SECURITY=1 adds rules that target the scripts inside CI YAML — GitLab CI and GitHub Actions — the injection surface shellcheck has no opinion on.
| Rule | Severity | Flags |
|---|---|---|
SHELLSCAN-CURL-PIPE |
critical | curl or wget piped into a shell — remote code executed unverified. |
SHELLSCAN-EVAL |
major | eval on an expanded value — dynamic input runs as code. |
SHELLSCAN-SECRET-ECHO |
major | echo / printf of a secret-named variable — the secret lands in job logs. |
SHELLSCAN-CI-INJECTION |
major | Unquoted attacker-controllable CI variable (CI_COMMIT_MESSAGE, CI_MERGE_REQUEST_TITLE, branch and tag names) — a crafted commit injects shell syntax into the job. |
SHELLSCAN-GHA-INJECTION |
critical | ${{ }} expression of attacker-controllable data (github.event.pull_request.title, github.head_ref, commit messages) inside a run script — substituted before any shell parses, so quoting cannot help; pass it through an environment variable. |
Each finding reports the YAML source line. The rules run on extracted scripts only — .sh and shebang files already get the full shellcheck treatment. The injection rules are platform-scoped: SHELLSCAN-CI-INJECTION fires on GitLab CI scripts, SHELLSCAN-GHA-INJECTION on GitHub Actions run scripts — a literal ${{ }} in a GitLab script is inert text and never flagged.
shellscan ships an agent skill — its own scan-and-triage guide — for coding agents. Install it into an agent:
npx skills add coroboros/shellscanOr read it without installing: skills/shellscan/SKILL.md.
Same across all shellscan tags.
| Package | Purpose |
|---|---|
shellcheck |
The linter — provided by the koalaman/shellcheck-alpine base image. |
bash |
Shell — src/shellscan.sh runs on bash. |
fd |
Fast file discovery — used to enumerate files to scan. |
yq |
YAML processor — extracts scripts from before_script / script / after_script and run keys and expands anchors. |
jq |
JSON processor — renders Code Quality and SARIF reports from shellcheck json1 output. |
ca-certificates |
TLS certificate bundle. |
Every published image, via the shared coroboros/ci container-images template, is:
- multi-arch — BuildKit,
linux/amd64,linux/arm64; - gated — source secrets via
gitleaks, image CVEs via Trivy on the published:sha, and the image smoke before tag promotion; - signed — cosign keyless on the immutable digest, with a CycloneDX SBOM attestation.
On version tags, the promoted digest is published to the GitLab Registry, GitHub Container Registry, and Docker Hub. Each registry-local digest is signed and attested after copy. Pin the @sha256 digest downstream for byte-reproducible scans.
A cosign signature is bound to the digest, not the tag — verify the pinned digest:
cosign verify \
--certificate-identity-regexp 'https://gitlab.com/coroboros/security/infrastructure/shellscan//.*' \
--certificate-oidc-issuer https://gitlab.com \
ghcr.io/coroboros/shellscan@sha256:<manifest-list-digest>| Capability | shellcheck direct |
yamllint |
pre-commit + shellcheck |
GitLab CI Lint | super-linter |
actionlint |
shellscan |
|---|---|---|---|---|---|---|---|
Lint .sh files |
yes | no | yes | no | yes | no | yes |
| Lint files via shebang detection | no | no | no | no | yes | no | yes |
| Extract shells embedded in GitLab CI YAML | no | no | no | no | no | no | yes |
| Extract shells embedded in GitHub Actions YAML | no | no | no | no | via actionlint |
yes | yes |
| Expand YAML anchors before scanning | no | no | no | no | no | yes (workflows) | yes |
| Single binary / no orchestration setup | yes | yes | no | yes | no | yes | yes (image) |
Configurable file discovery (fd options) |
no | no | no | no | no | no | yes |
| Parallel scan (configurable workers) | no | no | no | no | no | auto | yes |
| GitLab Code Quality + SARIF output | no | no | no | no | no | SARIF via template | yes |
| Security rules on CI-embedded shell | no | no | no | no | no | GH untrusted inputs | yes |
| Baseline file for incremental adoption | no | no | no | no | no | no | yes |
The unique angle: shellscan finds shell wherever it lives in a project — including the often-overlooked scripts hidden inside CI YAML — runs it through the canonical shellcheck linter with YAML anchors expanded, and reports findings where reviewers look: GitLab Code Quality, GitHub code scanning, or the terminal. actionlint proved the demand for linting shell inside CI config on GitHub Actions; shellscan covers both platforms in one scanner, with platform-aware security rules on top.
Report a vulnerability privately via the security policy — ob@coroboros.com, never a public issue.
Bug reports and merge requests welcome.
- Open an issue before submitting non-trivial merge requests.
- Commits follow Conventional Commits.
- Sign off each commit (DCO):
git commit -s. - Target the
mainbranch.
Run the unit tests locally:
bash test/unit.shCoverage report (requires Ruby + Bundler):
bundle install
bundle exec bashcov --command-name shellscan --mute test/coverage.sh