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
2 changes: 1 addition & 1 deletion docs/rules/catalogue.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ a blank ID column means the module emits atom-keyed findings rather than prefixe
[cols="34,33,33",options="header"]
|===
|Module |Rule IDs |Purpose
|`+actions_lock+` |— |Strict local validation for machine-generated `gh actions-lock` v0.0.2 files.
|`+actions_lock+` |— |Strict local validation for machine-generated `gh actions-lock` v0.0.2 and v0.0.3 files.
|`+admin_merge_eligibility+` |DBA002 |Recognises PR shapes that are safe to admin-merge in bulk when the
|`+baseline_health+` |BH001 BH002 BH003 BH004 BH005 BH006 BH007 BH008 |Detects degraded `main`-branch baseline conditions that allow silent rot.
|`+branch_protection+` |BP001 BP002 BP003 BP004 BP005 BP006 BP007 BP008 BP009 |Branch-protection hygiene rules drawn from the CIS GitHub Benchmark,
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/actions_lock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ defmodule Hypatia.Rules.ActionsLock do
defp finalize_dependency(state) do
{key, dependency, header_line} = state.current_dependency
normalized = normalise_ref(key)
# nil is the home host; v0.0.3 records owner and repo IDs per host.
hostname = Map.get(dependency, :hostname)

with {:ok, ref} <- fetch_dependency_field(dependency, :ref, key, header_line),
{:ok, commit} <- fetch_dependency_field(dependency, :commit, key, header_line),
Expand Down
87 changes: 83 additions & 4 deletions test/rules/actions_lock_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,7 @@ defmodule Hypatia.Rules.ActionsLockTest do
test "rejects symbolic-ref and SHA-key contradictions" do
symbolic = String.replace(@valid_lock, "ref: 'v7.0.1'", "ref: 'v6.0.0'")

assert {:error, {:line, _, {:symbolic_key_ref_mismatch, _, _}}} =
ActionsLock.parse(symbolic)
assert {:error, {:line, _, {:symbolic_key_ref_mismatch, _, _}}} = ActionsLock.parse(symbolic)

sha_key = """
version: 'v0.0.2'
Expand Down Expand Up @@ -449,12 +448,92 @@ defmodule Hypatia.Rules.ActionsLockTest do
" 'actions/checkout@refs:heads/main':\n"
)

assert {:error, {:line, _, {:malformed_dependency_key, _}}} =
ActionsLock.parse(subpath_key)
assert {:error, {:line, _, {:malformed_dependency_key, _}}} = ActionsLock.parse(subpath_key)

assert {:error, {:line, _, {:malformed_dependency_key, _}}} = ActionsLock.parse(colon_key)
end

test "accepts v0.0.3 locks and validates the optional hostname" do
v3 = String.replace(@valid_lock, "v0.0.2", "v0.0.3")
assert {:ok, %ActionsLock{version: "v0.0.3"}} = ActionsLock.parse(v3)

with_host = fn host ->
String.replace(
v3,
" repo_id: 197814629\n",
" repo_id: 197814629\n hostname: '#{host}'\n"
)
end

assert {:ok, _} = ActionsLock.parse(with_host.("github.com"))
assert {:ok, _} = ActionsLock.parse(with_host.("acme.ghe.com"))

long_label = String.duplicate("a", 64) <> ".ghe.com"
assert {:ok, _} = ActionsLock.parse(with_host.(String.duplicate("a", 63) <> ".ghe.com"))

for bad <- [
"GitHub.com",
"example.com",
"acme.ghe.com.evil",
"-x.ghe.com",
"foo.bar.ghe.com",
long_label
] do
assert {:error, {:line, _, {:malformed_dependency_hostname, ^bad}}} =
ActionsLock.parse(with_host.(bad))
end

dup =
String.replace(
with_host.("github.com"),
"hostname: 'github.com'\n",
"hostname: 'github.com'\n hostname: 'github.com'\n"
)

assert {:error, {:line, _, {:duplicate_dependency_field, _, :hostname}}} =
ActionsLock.parse(dup)

v2_with_host =
String.replace(
@valid_lock,
" repo_id: 197814629\n",
" repo_id: 197814629\n hostname: 'github.com'\n"
)

assert {:error, {:line, _, :malformed_dependency_hostname}} = ActionsLock.parse(v2_with_host)
end

test "scopes owner and repository identities to their v0.0.3 host" do
mixed = """
version: 'v0.0.3'
workflows:
'.github/workflows/ci.yml':
- 'acme/foo@v1'
- 'acme/bar@v1'
dependencies:
'acme/bar@v1':
hostname: 'github.com'
ref: 'v1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 2
repo_id: 20
'acme/foo@v1':
ref: 'v1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 1
repo_id: 20
"""

assert {:ok, lock} = ActionsLock.parse(mixed)
assert lock.dependencies["acme/bar@v1"].hostname == "github.com"
assert lock.dependencies["acme/foo@v1"].hostname == nil

same_host = String.replace(mixed, " hostname: 'github.com'\n", "")

assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} =
ActionsLock.parse(same_host)
end

test "parses the repository's authoritative generated lockfile" do
content = File.read!(".github/workflows/actions.lock")
assert {:ok, parsed} = ActionsLock.parse(content)
Expand Down
Loading