From e314c0b3e9b2be466a76fc65477de7bfa55dbbdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:25:41 +0000 Subject: [PATCH 1/2] fix(actions_lock): accept gh-actions-lock v0.0.3 lockfiles gh-actions-lock 0.2.0 writes version 'v0.0.3', whose only change is an optional per-dependency hostname (github.com or a lowercase *.ghe.com). ActionsLock accepted only v0.0.2, so every relocked repo, hypatia's own included, failed with {:unsupported_version, "v0.0.3"} and broke 'Validate Hypatia Baseline' and the scan across the estate. Accept both versions. Under v0.0.3, validate the hostname and reject a duplicate one; a hostname line in a v0.0.2 lock is still malformed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- docs/rules/catalogue.adoc | 2 +- lib/rules/actions_lock.ex | 38 ++++++++++++++++++-------- test/rules/actions_lock_test.exs | 46 +++++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/rules/catalogue.adoc b/docs/rules/catalogue.adoc index de57dbaa..bdef9962 100644 --- a/docs/rules/catalogue.adoc +++ b/docs/rules/catalogue.adoc @@ -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, diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index fce24544..96b0f28b 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -3,7 +3,7 @@ defmodule Hypatia.Rules.ActionsLock do @moduledoc """ - Strict local validation for machine-generated `gh actions-lock` v0.0.2 files. + Strict local validation for machine-generated `gh actions-lock` v0.0.2 and v0.0.3 files. This module validates lockfile *shape and association*. It does not replace `gh actions-lock --verify`, which remains authoritative for resolving refs @@ -13,11 +13,15 @@ defmodule Hypatia.Rules.ActionsLock do The parser is deliberately line-oriented. `actions.lock` is generated by a single tool with a versioned stable format, and Hypatia must not add a general - YAML dependency merely to consume that format. Unknown or malformed v0.0.2 + YAML dependency merely to consume that format. Unknown or malformed lines fail closed rather than being guessed at. """ - @supported_version "v0.0.2" + # v0.0.3 (gh-actions-lock 0.2.0) adds only an optional per-dependency + # `hostname`; v0.0.2 locks stay valid. + @supported_versions ["v0.0.2", "v0.0.3"] + @hostname ~r/^(?:github\.com|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.ghe\.com)$/ + @hostname_line ~r/^ hostname: '([^']+)'$/ @action_ref ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[^\s':]+$/ @dependency_key ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[^\s':]+$/ @sha_ref ~r/^[0-9A-Fa-f]{40}$/ @@ -38,7 +42,7 @@ defmodule Hypatia.Rules.ActionsLock do } @doc """ - Parse and validate a complete v0.0.2 lockfile. + Parse and validate a complete v0.0.2 or v0.0.3 lockfile. Returns `{:error, reason}` for missing, unsupported, malformed, incomplete, duplicate, or contradictory input. One bad dependency invalidates the whole @@ -136,17 +140,17 @@ defmodule Hypatia.Rules.ActionsLock do cond do match = Regex.run(~r/^version: '([^']+)'$/, line) -> case match do - [_, @supported_version] when is_nil(state.version) -> - {:ok, %{state | version: @supported_version}} + [_, version] when version not in @supported_versions -> + line_error(line_number, {:unsupported_version, version}) - [_, @supported_version] -> - line_error(line_number, :duplicate_version) + [_, version] when is_nil(state.version) -> + {:ok, %{state | version: version}} - [_, version] -> - line_error(line_number, {:unsupported_version, version}) + [_, _version] -> + line_error(line_number, :duplicate_version) end - line == "workflows:" and state.version == @supported_version -> + line == "workflows:" and state.version in @supported_versions -> {:ok, %{state | section: :workflows}} true -> @@ -213,6 +217,18 @@ defmodule Hypatia.Rules.ActionsLock do Regex.match?(~r/^ (?:owner_id|repo_id):/, line) -> line_error(line_number, :malformed_dependency_identity) + match = state.version == "v0.0.3" && Regex.run(@hostname_line, line) -> + [_, host] = match + + if Regex.match?(@hostname, host) do + put_dependency_field(state, :hostname, host, line_number) + else + line_error(line_number, {:malformed_dependency_hostname, host}) + end + + Regex.match?(~r/^ hostname:/, line) -> + line_error(line_number, :malformed_dependency_hostname) + line == " uses:" -> put_dependency_field(state, :uses, MapSet.new(), line_number) diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index e7e9c9eb..2ed953bc 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -104,8 +104,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' @@ -268,12 +267,51 @@ 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")) + + for bad <- ["GitHub.com", "example.com", "acme.ghe.com.evil", "-x.ghe.com"] 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 "parses the repository's authoritative generated lockfile" do content = File.read!(".github/workflows/actions.lock") assert {:ok, parsed} = ActionsLock.parse(content) From 8586d7dd41f5be84be3d3db4d54df6d80860ce9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:36:02 +0000 Subject: [PATCH 2/2] fix(actions_lock): match the v0.0.3 hostname grammar and scope IDs per host The schema allows github.com or one 1-63 character tenant label before .ghe.com, so multi-label and overlong hosts are now rejected. owner_id and repo_id are defined on the recorded hostname, so identity checks now compare only dependencies on the same host (nil = home host): a mixed data-residency lock with acme/foo at home and acme/bar on github.com is valid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019a95kk88TZ1iegsBK3kox3 --- lib/rules/actions_lock.ex | 9 +++++-- test/rules/actions_lock_test.exs | 43 +++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 96b0f28b..7b6d17f6 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -20,7 +20,8 @@ defmodule Hypatia.Rules.ActionsLock do # v0.0.3 (gh-actions-lock 0.2.0) adds only an optional per-dependency # `hostname`; v0.0.2 locks stay valid. @supported_versions ["v0.0.2", "v0.0.3"] - @hostname ~r/^(?:github\.com|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.ghe\.com)$/ + # The v0.0.3 schema pattern: github.com or one 1-63 character tenant label. + @hostname ~r/^(?:github\.com|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.ghe\.com)$/ @hostname_line ~r/^ hostname: '([^']+)'$/ @action_ref ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[^\s':]+$/ @dependency_key ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[^\s':]+$/ @@ -33,6 +34,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__{ @@ -326,6 +328,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), @@ -334,7 +338,7 @@ defmodule Hypatia.Rules.ActionsLock do :ok <- validate_dependency_consistency(key, ref, commit, header_line), :ok <- validate_repository_identity( - state.dependencies, + Map.filter(state.dependencies, fn {_ref, d} -> d.hostname == hostname end), normalized, owner_id, repo_id, @@ -346,6 +350,7 @@ defmodule Hypatia.Rules.ActionsLock do commit: commit, owner_id: owner_id, repo_id: repo_id, + hostname: hostname, uses: Map.get(dependency, :uses, MapSet.new()) } diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 2ed953bc..aacc61cc 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -287,7 +287,17 @@ defmodule Hypatia.Rules.ActionsLockTest do assert {:ok, _} = ActionsLock.parse(with_host.("github.com")) assert {:ok, _} = ActionsLock.parse(with_host.("acme.ghe.com")) - for bad <- ["GitHub.com", "example.com", "acme.ghe.com.evil", "-x.ghe.com"] do + 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 @@ -312,6 +322,37 @@ defmodule Hypatia.Rules.ActionsLockTest do 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)