Skip to content
Open
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
36 changes: 36 additions & 0 deletions lock_file_exemptions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# lock_file_exemptions.yml
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RAT Check complains about the missing license header here.

#
# Per-(org/repo) per-ecosystem exemptions from the "every dependency manifest
# must have a matching lock file" check enforced by verify-action-build.
#
# When is an exemption appropriate? Only when the upstream repo is primarily
# a library or CLI tool that also ships a GitHub Action wrapper, and that
# project's ecosystem convention is to *not* commit a lock file. Typical
# examples:
# * Python libraries published to PyPI — lock files would pin transitive
# versions that downstream library consumers shouldn't be forced into.
# * Dart packages published to pub.dev — pubspec.lock is deliberately
# .gitignore'd for libraries.
#
# Exemptions are scoped per-ecosystem, so a project exempted for Python is
# still checked for its Node side (if it has one).
#
# Ecosystem keys (must match one of the names in analyze_lock_files):
# node python deno dart ruby go rust
#
# Format:
# org/repo:
# - ecosystem1
# - ecosystem2

pypa/cibuildwheel:
# cibuildwheel is a Python library published to PyPI. Its pyproject.toml
# declares runtime deps for users who `pip install cibuildwheel`; committing
# a lock file would over-constrain library consumers.
- python

dart-lang/setup-dart:
# The repo is a Dart package (pubspec.yaml) shipped as an action. Dart
# convention for library packages is to not commit pubspec.lock. The node
# side of this action is still checked (package.json → package-lock.json).
- dart
312 changes: 312 additions & 0 deletions utils/tests/verify_action_build/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
analyze_binary_downloads,
analyze_binary_downloads_recursive,
analyze_dockerfile,
analyze_lock_files,
analyze_scripts,
analyze_action_metadata,
analyze_repo_metadata,
Expand Down Expand Up @@ -441,3 +442,314 @@ def fetch(org, repo, commit, path):
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=fetch):
warnings = analyze_repo_metadata("actions", "checkout", "a" * 40)
assert len(warnings) == 0


class TestAnalyzeLockFiles:
def _run(self, files: dict, sub_path: str = "") -> list[str]:
def fetch(org, repo, commit, path):
return files.get(path)

with mock.patch(
"verify_action_build.security.fetch_file_from_github",
side_effect=fetch,
):
return analyze_lock_files("org", "repo", "a" * 40, sub_path=sub_path)

# --- Node --------------------------------------------------------------

def test_node_package_json_with_package_lock_passes(self):
files = {
"package.json": '{"name":"x","dependencies":{"a":"1.0.0"}}',
"package-lock.json": "{}",
}
assert self._run(files) == []

def test_node_package_json_with_yarn_lock_passes(self):
files = {"package.json": "{}", "yarn.lock": ""}
assert self._run(files) == []

def test_node_package_json_with_pnpm_lock_passes(self):
files = {"package.json": "{}", "pnpm-lock.yaml": ""}
assert self._run(files) == []

def test_node_package_json_with_bun_lock_passes(self):
files = {"package.json": "{}", "bun.lock": ""}
assert self._run(files) == []

def test_node_package_json_without_lock_fails(self):
errors = self._run({"package.json": '{"name":"x"}'})
assert len(errors) == 1
assert "package.json" in errors[0]
assert "package-lock.json" in errors[0]

# --- Python ------------------------------------------------------------

def test_python_pyproject_with_uv_lock_passes(self):
files = {
"pyproject.toml": '[project]\nname="x"\ndependencies = ["requests"]\n',
"uv.lock": "",
}
assert self._run(files) == []

def test_python_pyproject_with_poetry_lock_passes(self):
files = {
"pyproject.toml": "[tool.poetry.dependencies]\npython = '^3.11'\n",
"poetry.lock": "",
}
assert self._run(files) == []

def test_python_pyproject_with_requirements_txt_passes(self):
files = {
"pyproject.toml": '[project]\ndependencies = ["requests"]\n',
"requirements.txt": "requests==2.31.0\n",
}
assert self._run(files) == []

def test_python_pyproject_without_lock_fails(self):
files = {
"pyproject.toml": '[project]\nname="x"\ndependencies = ["requests"]\n',
}
errors = self._run(files)
assert len(errors) == 1
assert "pyproject.toml" in errors[0]

def test_python_pyproject_without_deps_skipped(self):
# Bare config (ruff, black, mypy settings) doesn't need a lock.
files = {
"pyproject.toml": "[tool.ruff]\nline-length = 100\n",
}
assert self._run(files) == []

def test_python_pipfile_with_lock_passes(self):
files = {"Pipfile": "[packages]\nrequests = '*'\n", "Pipfile.lock": "{}"}
assert self._run(files) == []

def test_python_pipfile_without_lock_fails(self):
errors = self._run({"Pipfile": "[packages]\nrequests = '*'\n"})
assert len(errors) == 1
assert "Pipfile" in errors[0]

# --- Deno --------------------------------------------------------------

def test_deno_with_lock_passes(self):
files = {"deno.json": '{"imports":{}}', "deno.lock": "{}"}
assert self._run(files) == []

def test_deno_jsonc_without_lock_fails(self):
errors = self._run({"deno.jsonc": "{}"})
assert len(errors) == 1
assert "deno.jsonc" in errors[0]

# --- Dart --------------------------------------------------------------

def test_dart_with_pubspec_lock_passes(self):
files = {"pubspec.yaml": "name: x\n", "pubspec.lock": ""}
assert self._run(files) == []

def test_dart_without_lock_fails(self):
errors = self._run({"pubspec.yaml": "name: x\n"})
assert len(errors) == 1
assert "pubspec.yaml" in errors[0]

# --- Ruby --------------------------------------------------------------

def test_ruby_with_gemfile_lock_passes(self):
files = {"Gemfile": "gem 'rails'\n", "Gemfile.lock": ""}
assert self._run(files) == []

def test_ruby_without_lock_fails(self):
errors = self._run({"Gemfile": "gem 'rails'\n"})
assert len(errors) == 1

# --- Go ----------------------------------------------------------------

def test_go_with_sum_passes(self):
files = {
"go.mod": "module x\n\nrequire github.com/a/b v1.2.3\n",
"go.sum": "github.com/a/b v1.2.3 h1:...\n",
}
assert self._run(files) == []

def test_go_without_sum_fails(self):
files = {"go.mod": "module x\n\nrequire github.com/a/b v1.2.3\n"}
errors = self._run(files)
assert len(errors) == 1
assert "go.mod" in errors[0]

def test_go_without_requires_skipped(self):
# go.mod with no external deps doesn't need go.sum.
assert self._run({"go.mod": "module x\n\ngo 1.21\n"}) == []

# --- Rust --------------------------------------------------------------

def test_rust_binary_without_lock_fails(self):
# Default binary crate — Cargo.lock expected.
files = {"Cargo.toml": '[package]\nname = "x"\n'}
errors = self._run(files)
assert len(errors) == 1

def test_rust_binary_with_lock_passes(self):
files = {
"Cargo.toml": '[package]\nname = "x"\n',
"Cargo.lock": "",
}
assert self._run(files) == []

def test_rust_library_without_lock_skipped(self):
# [lib] without [[bin]] — library crate, Cargo.lock not conventionally committed.
files = {
"Cargo.toml": '[package]\nname = "x"\n\n[lib]\nname = "x"\n',
}
assert self._run(files) == []

def test_rust_library_with_bin_still_requires_lock(self):
files = {
"Cargo.toml": (
'[package]\nname = "x"\n'
'[lib]\nname = "x"\n'
'[[bin]]\nname = "x-cli"\n'
),
}
errors = self._run(files)
assert len(errors) == 1

def test_rust_workspace_requires_lock(self):
files = {"Cargo.toml": '[workspace]\nmembers = ["a"]\n'}
errors = self._run(files)
assert len(errors) == 1

# --- Sub-path handling ------------------------------------------------

def test_sub_path_manifest_detected(self):
# Manifest in sub-path with lock in sub-path — passes.
files = {
"sub/package.json": "{}",
"sub/package-lock.json": "{}",
}
assert self._run(files, sub_path="sub") == []

def test_sub_path_falls_back_to_root(self):
# Sub-action may reuse repo-root manifests.
files = {
"package.json": "{}",
"package-lock.json": "{}",
}
assert self._run(files, sub_path="sub") == []

def test_sub_path_without_lock_fails(self):
files = {"sub/package.json": "{}"}
errors = self._run(files, sub_path="sub")
assert len(errors) == 1
assert "sub/package.json" in errors[0]

# --- No manifests -----------------------------------------------------

def test_no_manifests_found_passes(self):
# Pure composite action — no manifests anywhere.
assert self._run({}) == []

# --- Multiple ecosystems -----------------------------------------------

def test_multiple_ecosystems_all_missing_aggregates_errors(self):
files = {
"package.json": "{}",
"go.mod": "module x\n\nrequire a v1\n",
"pubspec.yaml": "name: x\n",
}
errors = self._run(files)
assert len(errors) == 3

# --- Exemptions -------------------------------------------------------

def _run_with_exemptions(
self,
files: dict,
exemptions: dict,
org: str = "org",
repo: str = "repo",
) -> list[str]:
def fetch(o, r, commit, path):
return files.get(path)

with mock.patch(
"verify_action_build.security.fetch_file_from_github",
side_effect=fetch,
):
return analyze_lock_files(org, repo, "a" * 40, exemptions=exemptions)

def test_exemption_skips_matching_ecosystem(self):
# pyproject.toml with deps but no lock — normally fails; exempted here.
files = {
"pyproject.toml": '[project]\ndependencies = ["requests"]\n',
}
errors = self._run_with_exemptions(
files, {("org", "repo"): {"python"}},
)
assert errors == []

def test_exemption_does_not_skip_other_ecosystems(self):
# Exempt only python; node still fails.
files = {
"pyproject.toml": '[project]\ndependencies = ["requests"]\n',
"package.json": "{}",
}
errors = self._run_with_exemptions(
files, {("org", "repo"): {"python"}},
)
assert len(errors) == 1
assert "package.json" in errors[0]

def test_exemption_case_insensitive(self):
# Look-up key lowercases org/repo, so an exemption entry written as
# "Pypa/cibuildwheel" matches a run on "pypa/cibuildwheel".
files = {"pyproject.toml": '[project]\ndependencies = ["a"]\n'}
errors = self._run_with_exemptions(
files, {("pypa", "cibuildwheel"): {"python"}},
org="Pypa", repo="CIBuildWheel",
)
assert errors == []

def test_exemption_for_different_repo_does_not_apply(self):
files = {"pyproject.toml": '[project]\ndependencies = ["a"]\n'}
errors = self._run_with_exemptions(
files, {("other", "project"): {"python"}},
)
assert len(errors) == 1

# --- Exemption file parser -------------------------------------------

def test_exemption_file_parses(self, tmp_path):
from verify_action_build.security import _load_lock_file_exemptions

yml = tmp_path / "lock_file_exemptions.yml"
yml.write_text(
"# comment\n"
"pypa/cibuildwheel:\n"
" - python\n"
"\n"
"dart-lang/setup-dart:\n"
" - dart # trailing comment\n"
)
result = _load_lock_file_exemptions(yml)
assert result == {
("pypa", "cibuildwheel"): {"python"},
("dart-lang", "setup-dart"): {"dart"},
}

def test_exemption_file_missing_returns_empty(self, tmp_path):
from verify_action_build.security import _load_lock_file_exemptions

result = _load_lock_file_exemptions(tmp_path / "does-not-exist.yml")
assert result == {}

def test_exemption_file_multiple_ecosystems_per_repo(self, tmp_path):
from verify_action_build.security import _load_lock_file_exemptions

yml = tmp_path / "lock_file_exemptions.yml"
yml.write_text(
"some/multiecosystem-repo:\n"
" - python\n"
" - dart\n"
)
result = _load_lock_file_exemptions(yml)
assert result[("some", "multiecosystem-repo")] == {"python", "dart"}
Loading
Loading