actionlint¶
actionlint checks GitHub Actions workflow files: workflow syntax, ${{ }} expression types, script injection from untrusted event data, hard-coded container credentials, if: conditions that are always true, undefined needs: jobs, unknown runner labels, and more. It is a single Go binary, MIT licensed. zizmor, also a builtin scanner, flags template injection too; ASH does not deduplicate across scanners, so with both enabled such a step is reported by each, under its own rule id.
Enabling it¶
actionlint is a builtin scanner, enabled by default: a default scan runs it. scanners.actionlint.enabled: false in the ASH config turns it off, and --scanners and --exclude-scanners select it as they select any scanner. If the tool is not installed, the scanner is reported MISSING and the scan exits 1 (the incomplete-scan gate), as for every builtin scanner; ash dependencies install and the container image provide it.
Installing¶
| Where | How |
|---|---|
| Container image | Installed (pinned v1.7.12) |
| Local / pip install | ash dependencies install --tool actionlint downloads the pinned release asset and checks its SHA256 |
| Nix | pkgs.actionlint is in the flake's scanner set |
The pinned version and digests are in automated_security_helper/utils/tool_downloads.py, transcribed from the release's actionlint_<version>_checksums.txt.
What is scanned¶
ASH passes actionlint every *.yml / *.yaml file whose parent directory is .github/workflows, at any depth, taken from the scan set. That includes a source directory that is itself .github (its workflows/*.yml) or .github/workflows. Files excluded by .gitignore, .ignore (while honor_ignore_files is on, the default) or global_settings.ignore_paths, anything under ASH's output directory, and files under directories named node_modules, .venv or venv are not passed; zizmor skips the same workflows. A workflow that is a symlink pointing outside the scan root, or a broken symlink (its target is missing, or links loop), is skipped with a warning; actionlint would fail the whole run on a file it cannot open. Other YAML is never linted.
A uses: ./... in a workflow makes actionlint read the action metadata or reusable workflow it names, from the repository root, and its findings then quote what it read (the action's name and its input names). A workflow with such a reference that resolves outside the scanned repository, through .. or a symlink, is skipped with a warning, so actionlint never reads that file. References that stay inside the repository are checked as usual.
If the scan root has no workflow files, actionlint does not run and the scanner reports SKIPPED (it evaluated nothing). A scan that selects only actionlint (--scanners actionlint) on such a tree therefore exits 1 with "Scan ran no scanners".
actionlint is a native binary, so --python-only does not run it.
Configuration¶
scanners:
actionlint:
enabled: true # the default; false turns it off
options:
config_file: null # actionlint config; a relative path is taken from where it is set
shellcheck: null # "shellcheck" to enable it; null disables the integration
pyflakes: null # "pyflakes" to enable it; null disables the integration
severity_threshold: null
scan_timeout: 1800
config_file¶
ASH always passes actionlint -config-file with a config of its choosing, and it is never the scanned repository's own:
options.config_file, when the operator set it: from--config-overrides, or an ASH config file outside every git checkout and the scanned directory (one that is not repository-written). A relative path is taken from the directory of the ASH config file that sets it, or from the working directory for--config-overrides, not from the source directory (see Relative paths in the operator's settings), and an absolute one is used as given; the file the operator names may be inside the scanned tree. A path that does not exist fails the scan withERROR, and so does a path inside the tree that leads out of it through a symbolic link, without running actionlint: the scanned repository decides what is at that path, actionlint quotes the start of a value it cannot parse, and ASH logs a config'signorepatterns.- Otherwise an empty config ASH writes outside the scanned tree, in a directory of the scanner run's own that is removed when the run ends.
A .github/actionlint.yaml or .github/actionlint.yml in the scanned repository is not read (ASH notes it at INFO), and config_file set by an ASH config inside any git checkout or the scanned directory, or by an MCP client, is ignored with a warning. A config's paths.<glob>.ignore patterns make actionlint drop findings before ASH sees them, so they would be neither reported nor counted as suppressed. Tune findings with ASH suppressions, which are both. If the operator's config has ignore patterns, ASH logs a warning naming them. ASH reads the config with YAML aliases refused, as it reads every YAML config; actionlint accepts aliases, so for a config that uses one ASH cannot list the patterns, and its warning says that actionlint may still drop findings the config ignores.
shellcheck and pyflakes¶
actionlint runs shellcheck on run: scripts and pyflakes on shell: python steps when they are on PATH, and skips them silently when they are not. Results would then depend on what a host happens to have installed, so ASH disables both by default (actionlint -shellcheck= -pyflakes=).
To use them, set the option to shellcheck or pyflakes, which is looked up in the absolute directories on PATH and in ASH's bin directory, and handed to actionlint as an absolute path. If the configured tool cannot be found or is not executable, the scanner reports MISSING rather than running without it. shellcheck and pyflakes findings are LOW severity. Neither is installed in the ASH container image.
Each option names the program actionlint pipes every run: script to, started from the root of the scanned tree. Set to a shell or an interpreter, that program would run each run: script instead of checking it. ASH's config usually comes from the repository being scanned (.ash/.ash.yaml), so from a config file inside any git checkout or the scanned directory each option accepts only its own name. Another program, or any path, is honored only from --config-overrides or a config file outside every git checkout and the scanned directory:
Whoever sets it, a program that resolves inside the scanned tree is refused. An operator's relative path is resolved against the source directory and must land outside the tree, and a path inside the tree that a symbolic link there leads out of it is refused too, whether you wrote it or a name was found at it through an absolute PATH entry inside the tree, because the link, not you, chose the program. A refused value is logged as a warning and actionlint runs with that integration off.
A relative PATH entry, such as . or bin, is never searched, for actionlint itself or for these programs. actionlint runs from the scanned directory, where such an entry could name a program the repository committed, so ASH starts actionlint, and passes it shellcheck and pyflakes, by absolute path only. A tool reachable only through a relative entry is MISSING.
Severity mapping¶
actionlint has no severity of its own; its upstream SARIF template marks everything error. ASH maps each finding by its rule kind (the SARIF ruleId):
| Severity | SARIF level | Findings |
|---|---|---|
| HIGH | error |
expression findings whose message says the value is "potentially untrusted" (script injection), and credentials (a password written into container: or services:) |
| MEDIUM | warning |
if-cond (an if: that is always true), permissions, and deprecated-commands for set-env / add-path |
| LOW | note |
everything else: syntax-check, other expression errors, action, runner-label, job-needs, glob, matrix, events, id, env-var, shell-name, workflow-call, set-output / save-state deprecations, shellcheck, pyflakes |
A kind not in this table (a newer actionlint) is LOW and logged once. A binary whose version differs from the pin runs with a warning, since the mapping keys on kinds and message text of the pinned release. The snippet of a credentials finding (the password itself) is not copied into the report. With the default MEDIUM threshold, lint-only findings do not fail a scan and script injection does.
Suppressions¶
Rule, path and line suppressions work as for any scanner. The rule id is the actionlint kind:
global_settings:
suppressions:
- rule_id: runner-label
path: .github/workflows/self-hosted.yml
reason: Custom self-hosted runner labels
- rule_id: expression
path: .github/workflows/triage.yml
line_start: 20
line_end: 20
reason: Title is only echoed into the job log
Script injection is reported under expression, the same kind as expression type errors, so suppressing expression by rule alone also suppresses injection findings. Use a line-scoped suppression for those.
Package- and symbol-scoped suppressions do not apply: a workflow file has no package identity and no function or class symbols.
Offline¶
actionlint makes no network calls (its action metadata is compiled in), so it works unchanged with ASH_OFFLINE=true and on air-gapped hosts. A run that exceeds scan_timeout is killed and reported as ERROR with a timeout message.
Errors¶
actionlint exits 0 when clean, 1 when it found problems, 2 for a bad command line and 3 for a fatal error such as an unreadable file or an invalid config. ASH treats 0 and 1 as success and everything else as ERROR, as it does output that is not the expected JSON and an exit code that contradicts the findings. The source tree and the converted files are separate runs, each judged by its own exit code. The raw output is kept at scanners/actionlint/<target>/actionlint.json (with credentials snippets removed) and the SARIF ASH built from it at actionlint.sarif.
Relation to zizmor¶
zizmor also audits GitHub Actions workflows, including template injection. If ASH's zizmor scanner is enabled as well, the two run independently: they share no configuration or results, and an injection is reported once by each.