Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ on:
description: sha256 of that release's linux.x86_64 tarball; travels with shellcheck-version, so overriding one without the other fails the install
type: string
default: "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198"
tooling-isolation:
description: Fail when a job that holds a secret also installs third-party tooling
type: boolean
default: true

permissions:
contents: read
Expand Down Expand Up @@ -83,3 +87,127 @@ jobs:
env:
SHELLCHECK_BIN: ${{ env.SHELLCHECK_BIN }}
run: actionlint -color "-shellcheck=${SHELLCHECK_BIN}"

# A job that installs third-party tooling is running code we did not
# write: `cargo install` (and the other installers) executes build
# scripts over the whole dependency tree, so the secrets a job holds
# are reachable from code outside this organisation. Keeping the
# install out of any job that holds a secret bounds that reach to
# nothing. `pip install --require-hashes` is the exception because a
# hash-pinned install resolves nothing — the artifacts are fixed by
# the lockfile, so no surprise tree appears.
- name: A job holding a secret must not install third-party tooling
if: ${{ inputs.tooling-isolation }}
run: |
python3 - <<'PY'
import glob
import re
import sys

import yaml

# `secrets.<id>` is how every GitHub Actions expression reaches a
# secret. `secrets: inherit` and `secrets:` declarations on
# reusable calls don't match — those are declaration blocks, not
# references, and `secrets:` does not contain a `.`.
SECRET_RE = re.compile(r"(?<!\w)secrets\.[A-Za-z_]\w*")

# (`pattern`, `opt-out marker on the same line`). The marker, when
# present on the line, exempts that install: the only case where
# the resolved tree is fixed is pip with --require-hashes, which
# reads the lockfile verbatim and installs exactly what it lists.
INSTALL_PATTERNS = (
("cargo install", None),
("go install", None),
("gem install", None),
("npm install -g", None),
("npm i -g", None),
("pip install", "--require-hashes"),
)


def normalize_run(value):
# `run:` is either a single string or a list of strings. Split
# each into lines so the install scan is per-line: a
# `--require-hashes` marker on one line only exempts that line.
if isinstance(value, list):
out = []
for v in value:
if isinstance(v, str):
out.extend(v.splitlines())
return out
if isinstance(value, str):
return value.splitlines()
return []


def find_violations(spec_path):
violations = []
try:
with open(spec_path) as fh:
spec = yaml.safe_load(fh)
except yaml.YAMLError as e:
# Silent skip would hide a misconfigured workflow that the
# caller is going to learn about elsewhere in CI for the
# wrong reason. Warning annotation instead.
print(f"::warning file={spec_path}::skipped: YAML parse error: {e}")
return violations
if not isinstance(spec, dict):
return violations
jobs = spec.get("jobs") or {}
for job_id, job in jobs.items():
if not isinstance(job, dict):
continue
# Re-serialize the job so `with:`, `env:`, `if:` and
# `run:` strings are all scanned for a secret reference.
# Comments are stripped by safe_load, so they cannot
# produce a false positive.
job_text = yaml.safe_dump(job)
if not SECRET_RE.search(job_text):
continue
for step in (job.get("steps") or []):
if not isinstance(step, dict):
continue
run = step.get("run")
if run is None:
continue
for line in normalize_run(run):
stripped = line.lstrip()
if not stripped or stripped.startswith("#"):
continue
for pattern, except_marker in INSTALL_PATTERNS:
if pattern in line and not (
except_marker and except_marker in line
):
violations.append((spec_path, job_id, line))
break
return violations


def main():
files = sorted(set(
glob.glob(".github/workflows/*.yml")
+ glob.glob(".github/workflows/*.yaml")
))
violations = []
for path in files:
violations.extend(find_violations(path))
if violations:
for path, job_id, line in violations:
msg = (
f"job `{job_id}` installs third-party tooling while holding a secret: "
f"`{line.strip()}` — see Glyndor/apt#121. Fix by moving the install "
f"into a job that holds no secrets, or pin it by hash "
f"(e.g. `pip install --require-hashes`)."
)
print(f"::error file={path}::{msg}")
sys.exit(1)
print(
"tooling-isolation: no job holding a secret installs third-party tooling."
)
sys.exit(0)


if __name__ == "__main__":
sys.exit(main())
PY
2 changes: 1 addition & 1 deletion docs/reusables/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ which blocks every pull request until someone works out why.
| [`rust-toolchain-watch`](rust-toolchain-watch.md) | 1 | 0 | 1 |
| [`schedule-freshness`](schedule-freshness.md) | 1 | 0 | 2 |
| [`shell-ci`](shell-ci.md) | 2 | 1 | 7 |
| [`workflow-lint`](workflow-lint.md) | 1 | 0 | 2 |
| [`workflow-lint`](workflow-lint.md) | 1 | 0 | 3 |

*Conditional* checks only run when an input turns them on, so they emit no
check name at all when it is left at its default.
Expand Down
1 change: 1 addition & 0 deletions docs/reusables/workflow-lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ a required check whose name nothing emits blocks every pull request.
|---|---|---|---|---|
| `shellcheck-version` | string | `v0.11.0` | no | Exact shellcheck release tag to install from koalaman/shellcheck for actionlint's embedded shellcheck pass; bumped via .github releases |
| `shellcheck-sha256` | string | `8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198` | no | sha256 of that release's linux.x86_64 tarball; travels with shellcheck-version, so overriding one without the other fails the install |
| `tooling-isolation` | boolean | `true` | no | Fail when a job that holds a secret also installs third-party tooling |

---

Expand Down
Loading