Skip to content

coroboros/shellscan

Repository files navigation

shellscan

shellscan

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.

latest pipeline ghcr.io Docker Hub license stars coverage skills coroboros.com

Contents

Requirements

Docker, BuildKit, or any OCI runtime able to pull from the GitHub Container Registry (or the Docker Hub mirror).

Image

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)

Tags

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.

Commands

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-ci
SHELLCHECK_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.json
SHELLSCAN_SECURITY=1 SHELLSCAN_FORMAT=sarif \
shellscan github-actions > shellscan.sarif

Run

GitLab 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 .sh
check-ci-yaml-files:
  image:
    name: registry.gitlab.com/coroboros/security/infrastructure/shellscan:<tag>
    entrypoint: [""]
  stage: check
  script:
    - shellscan gitlab-ci
check-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 all
CI/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: true

The 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.sarif
pre-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: shellscan
Docker

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"'

Reports

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.

Security rules

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.

Agents

shellscan ships an agent skill — its own scan-and-triage guide — for coding agents. Install it into an agent:

npx skills add coroboros/shellscan

Or read it without installing: skills/shellscan/SKILL.md.

Packages

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.

Provenance

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>

Compared to alternatives

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.

Security

Report a vulnerability privately via the security policyob@coroboros.com, never a public issue.

Contributing

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 main branch.

Run the unit tests locally:

bash test/unit.sh

Coverage report (requires Ruby + Bundler):

bundle install
bundle exec bashcov --command-name shellscan --mute test/coverage.sh

License

Apache 2.0

Packages

 
 
 

Contributors