Skip to content
36 changes: 34 additions & 2 deletions lib/rules/actions_lock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ defmodule Hypatia.Rules.ActionsLock do
commit: String.t(),
owner_id: pos_integer(),
repo_id: pos_integer(),
hostname: String.t() | nil,
uses: MapSet.t(String.t())
}
@type t :: %__MODULE__{
Expand Down Expand Up @@ -166,7 +167,10 @@ defmodule Hypatia.Rules.ActionsLock do
[_, version] when version in @supported_versions ->
line_error(line_number, :duplicate_version)

[_, version] ->
version in @supported_versions ->

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore the list pattern in the fallback case clause

This clause is parsed as an invocation of Kernel.in/2 in match position rather than as a pattern, so compiling the module fails with cannot invoke remote function :erlang.=:=/2 inside a match. Consequently, every build or test command that compiles Hypatia.Rules.ActionsLock stops before running; the unsupported-version fallback needs to retain a pattern that binds the version, such as [_, version].

Useful? React with 👍 / 👎.

{:ok, %{state | version: version}}

true ->
line_error(line_number, {:unsupported_version, version})
end

Expand Down Expand Up @@ -244,6 +248,23 @@ defmodule Hypatia.Rules.ActionsLock do
Regex.match?(~r/^ (?:owner_id|repo_id):/, line) ->
line_error(line_number, :malformed_dependency_identity)

match = Regex.run(~r/^ hostname: '([^']*)'$/, line) ->
[_, hostname] = match

cond do
state.version != "v0.0.3" ->
line_error(line_number, {:hostname_requires_v0_0_3, state.version})

Regex.match?(@hostname, hostname) ->

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove the duplicate hostname parser that uses an undefined attribute

After the version-pattern compilation error is fixed, this reference still emits an undefined module attribute @hostname warning and is inferred as Regex.match?(nil, hostname). The repository's .github/workflows/tests.yml runs mix compile --warnings-as-errors, so compilation remains red; moreover, this entire branch is unreachable because the earlier hostname clauses already match the same input. Remove the stale duplicate branch rather than referencing an undeclared regex.

Useful? React with 👍 / 👎.

put_dependency_field(state, :hostname, hostname, line_number)

true ->
line_error(line_number, {:malformed_dependency_hostname, hostname})
end

Regex.match?(~r/^ hostname:/, line) ->
line_error(line_number, :malformed_dependency_hostname)

line == " uses:" ->
put_dependency_field(state, :uses, MapSet.new(), line_number)

Expand Down Expand Up @@ -365,7 +386,7 @@ defmodule Hypatia.Rules.ActionsLock do
:ok <- validate_dependency_consistency(key, ref, commit, header_line),
:ok <-
validate_repository_identity(
state.dependencies,
scope_to_host(state.dependencies, Map.get(dependency, :hostname)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize omitted hostnames before identity scoping

When one v0.0.3 dependency omits hostname and another explicitly uses github.com, both belong to the home host, but this passes raw nil versus "github.com" into scope_to_host/2. The two entries are therefore placed in separate identity scopes, allowing conflicting owner or repository IDs for the same GitHub host to pass validation. Fresh evidence in this revision is the use of raw Map.get/2 here even though the parser documents and stores an omitted hostname as github.com; normalize to the home hostname before filtering.

Useful? React with 👍 / 👎.

normalized,
owner_id,
repo_id,
Expand All @@ -378,6 +399,7 @@ defmodule Hypatia.Rules.ActionsLock do
commit: commit,
owner_id: owner_id,
repo_id: repo_id,
hostname: Map.get(dependency, :hostname),
Comment thread
hyperpolymath marked this conversation as resolved.
uses: Map.get(dependency, :uses, MapSet.new())
}

Expand All @@ -393,6 +415,16 @@ defmodule Hypatia.Rules.ActionsLock do
end
end

# Owner and repository IDs are numbered per GitHub instance (the v0.0.3
# schema defines them "on the recorded hostname"), so identity is compared
# only among pins recorded on the same host. An omitted hostname is its own
# scope (the lock's home host).
defp scope_to_host(dependencies, hostname) do
dependencies
|> Enum.filter(fn {_ref, dependency} -> dependency.hostname == hostname end)
|> Map.new()
end

defp validate_repository_identity(dependencies, normalized, owner_id, repo_id, line_number) do
[repository, _ref] = String.split(normalized, "@", parts: 2)
[owner, _repo] = String.split(repository, "/", parts: 2)
Expand Down
127 changes: 127 additions & 0 deletions test/rules/actions_lock_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,133 @@ defmodule Hypatia.Rules.ActionsLockTest do
ActionsLock.parse(missing_dependency)
end

# #926: `gh actions-lock` began writing v0.0.3: the v0.0.2 line grammar plus
# an optional dependency `hostname` (github/actions-lockfile lockfile-v0.0.3.json).
describe "format v0.0.3" do
@v3_lock String.replace(@valid_lock, "version: 'v0.0.2'", "version: 'v0.0.3'")

test "is accepted, and records the version it was read as" do
assert {:ok, lock} = ActionsLock.parse(@v3_lock)
assert lock.version == "v0.0.3"
assert ActionsLock.pinned?(lock, "ci.yml", "actions/checkout@v7.0.1")
refute ActionsLock.pinned?(lock, "other.yml", "actions/checkout@v7.0.1")

assert {:ok, %{version: "v0.0.2"}} = ActionsLock.parse(@valid_lock)
end

test "is held to the same grammar: a malformed line still fails closed" do
short = String.replace(@v3_lock, ~r/sha1-[0-9a-f]{40}/, "sha1-1234")
assert {:error, {:line, _, :malformed_dependency_commit}} = ActionsLock.parse(short)

unknown =
String.replace(@v3_lock, " repo_id:", " integrity: 'x'\n repo_id:")

assert {:error, {:line, _, :malformed_dependency_entry}} = ActionsLock.parse(unknown)
end

test "accepts and records the optional dependency hostname (GHE.com pins)" do
lock =
String.replace(
@v3_lock,
" owner_id:",
" hostname: 'github.com'\n owner_id:"
)

assert {:ok, parsed} = ActionsLock.parse(lock)
assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com"
assert ActionsLock.pinned?(parsed, "ci.yml", "actions/checkout@v7.0.1")

tenant = String.replace(lock, "github.com", "octocorp.ghe.com")
assert {:ok, parsed} = ActionsLock.parse(tenant)
assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "octocorp.ghe.com"

assert {:ok, parsed} = ActionsLock.parse(@v3_lock)
assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == nil
end

test "scopes owner/repo identity checks to the dependency's host" do
# Same owner name and repo, different numeric IDs: a conflict within one
# host, but legitimate across hosts (IDs are numbered per instance).
two_pins = """
version: 'v0.0.3'
workflows:
'.github/workflows/ci.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.0.0'
dependencies:
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/setup-node@v7.0.0':
HOST
ref: 'v7.0.0'
commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
owner_id: 99
repo_id: 12345
"""

same_host = Regex.replace(~r/^ *HOST\n/m, two_pins, "")

assert {:error, {:line, _, {:owner_identity_mismatch, "actions", 44_036_562, 99}}} =
ActionsLock.parse(same_host)

cross_host = String.replace(two_pins, "HOST", "hostname: 'octocorp.ghe.com'")
assert {:ok, parsed} = ActionsLock.parse(cross_host)
assert parsed.dependencies["actions/setup-node@v7.0.0"].hostname == "octocorp.ghe.com"
end

test "refuses a malformed, repeated, or v0.0.2 hostname" do
with_host = fn host ->
String.replace(
@v3_lock,
" owner_id:",
" hostname: '#{host}'\n owner_id:"
)
end

for bad <- [
"Octocorp.ghe.com",
"https://github.com",
"github.com:443",
"evil.example.com",
"",
"a.ghe.com/x"
] do
assert {:error, {:line, _, reason}} = ActionsLock.parse(with_host.(bad))
assert reason in [:malformed_dependency_hostname, {:malformed_dependency_hostname, bad}]
end

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

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

v2 = String.replace(with_host.("github.com"), "v0.0.3", "v0.0.2")
assert {:error, {:line, _, {:hostname_requires_v0_0_3, "v0.0.2"}}} = ActionsLock.parse(v2)
end

test "negative control: a version past v0.0.3 is still refused" do
assert {:error, {:line, 2, {:unsupported_version, "v0.0.4"}}} =
@valid_lock
|> String.replace("v0.0.2", "v0.0.4")
|> ActionsLock.parse()
end

test "a second version line is refused, even when both are supported" do
twice =
String.replace(@v3_lock, "version: 'v0.0.3'\n", "version: 'v0.0.3'\nversion: 'v0.0.2'\n")

assert {:error, {:line, 3, :duplicate_version}} = ActionsLock.parse(twice)
end
end

test "rejects symbolic-ref and SHA-key contradictions" do
symbolic = String.replace(@valid_lock, "ref: 'v7.0.1'", "ref: 'v6.0.0'")

Expand Down
Loading