From 114d015c512766f9c9e8e65a055b958329116cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:03:41 +0000 Subject: [PATCH 1/2] fix(actions-lock): accept lockfile v0.0.3, which shares the v0.0.2 grammar gh-actions-lock v0.2.0 writes lockfile v0.0.3, and the estate gate now pins v0.2.0 (standards#1250). ActionsLock accepted only v0.0.2, so any relocked repository raised a high-severity invalid_actions_lock from workflow_audit and workflow_hardening (palimpsest-license alerts 347 and 349). Hypatia's own lock has been v0.0.3 since #926, so its own "parses the repository's authoritative generated lockfile" test failed. v0.0.3 has the v0.0.2 line grammar: same sections, same record fields, same `'': []` form (measured on this repo's lock and on rsr-template-repo's). Both versions are now parsed by the one strict grammar; every other version string is still refused, and a second version line is still a duplicate. Tests: one case for v0.0.3 (accepted; malformed v0.0.3 still fails closed), one for unsupported versions and a duplicate line. Run standalone against the module: 14/14 pass; the unpatched module fails 3 of them, including the existing own-lock test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LET2eHeDbN3b4WuxYjMUX9 --- lib/rules/actions_lock.ex | 27 ++++++++++++++++++--------- test/rules/actions_lock_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index fce24544..79fe2998 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -3,7 +3,8 @@ 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 +14,19 @@ 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 - lines fail closed rather than being guessed at. + YAML dependency merely to consume that format. Unknown or malformed lines fail + closed rather than being guessed at. + + v0.0.3 is what gh-actions-lock v0.2.0 writes, and the version the estate gate + pins (standards#1250). Its line grammar is the v0.0.2 grammar: the same + `workflows:` and `dependencies:` sections, the same record fields (`ref`, + `commit`, `owner_id`, `repo_id`, `uses`) and the same `'': []` form for a + workflow with nothing to lock (measured on rsr-template-repo and this repo's + own lock, 2026-10-19). Both versions are therefore parsed by one grammar; any + other version string is still refused. """ - @supported_version "v0.0.2" + @supported_versions ["v0.0.2", "v0.0.3"] @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 +47,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 +145,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 in @supported_versions and is_nil(state.version) -> + {:ok, %{state | version: version}} - [_, @supported_version] -> + [_, version] when version in @supported_versions -> line_error(line_number, :duplicate_version) [_, version] -> line_error(line_number, {:unsupported_version, version}) end - line == "workflows:" and state.version == @supported_version -> + line == "workflows:" and state.version in @supported_versions -> {:ok, %{state | section: :workflows}} true -> diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index e7e9c9eb..7c1c5432 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -83,6 +83,33 @@ defmodule Hypatia.Rules.ActionsLockTest do assert {:error, {:line, _, :malformed_dependency_commit}} = ActionsLock.parse(non_hex) end + test "accepts lockfile v0.0.3, which gh-actions-lock v0.2.0 writes, with the same grammar" do + v003 = String.replace(@valid_lock, "version: 'v0.0.2'", "version: 'v0.0.3'") + + assert {:ok, lock} = ActionsLock.parse(v003) + 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") + + # The grammar is shared, not loosened: a malformed v0.0.3 lock still fails closed. + malformed = String.replace(v003, ~r/sha1-[0-9a-f]{40}/, "sha1-short") + assert {:error, {:line, _, :malformed_dependency_commit}} = ActionsLock.parse(malformed) + end + + test "rejects every version outside the supported set, and a second version line" do + for version <- ["v0.0.1", "v0.0.4", "v0.1.0", "0.0.3"] do + assert {:error, {:line, 2, {:unsupported_version, ^version}}} = + @valid_lock + |> String.replace("version: 'v0.0.2'", "version: '#{version}'") + |> ActionsLock.parse() + end + + twice = + String.replace(@valid_lock, "version: 'v0.0.2'", "version: 'v0.0.2'\nversion: 'v0.0.3'") + + assert {:error, {:line, 3, :duplicate_version}} = ActionsLock.parse(twice) + end + test "rejects unsupported versions and missing dependency entries" do assert {:error, {:line, 2, {:unsupported_version, "v9"}}} = @valid_lock From a50c0e5ea8ceee35b3dda3fa9978d944977615b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:33:46 +0000 Subject: [PATCH 2/2] fix(actions-lock): name v0.0.3's GHE.com hostname field instead of a generic error v0.0.3 is not quite the v0.0.2 grammar. gh-actions-lock v0.2.0 adds one optional per-dependency field, `hostname` (`json:"hostname,omitempty"`, confirmed in the v0.2.0 binary). It writes the field only for a GHE.com data-residency tenant, never for github.com, so neither real v0.0.3 lock in the estate (this repo's, rsr-template-repo's) carries it. Make the parser say so instead of reporting a generic :malformed_dependency_entry: - In a v0.0.3 lock, `hostname: 'github.com'` is accepted. It states the default. - In a v0.0.3 lock, any other host is refused as {:unsupported_dependency_hostname, host}. Repository identity (owner_id/repo_id uniqueness) is validated per host, and this parser models github.com only. - In a v0.0.2 lock, a hostname line is still malformed: the field did not exist then. The moduledoc now describes the restricted schema accurately. Tests: a new case covers all three behaviours and a duplicate hostname line. The real locks still parse: hypatia v0.0.3 (40 deps), rsr-template-repo v0.0.3 (37), palimpsest-license v0.0.2 (38), standards v0.0.2 (26). Addresses CodeRabbit's finding on #929. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LET2eHeDbN3b4WuxYjMUX9 --- lib/rules/actions_lock.ex | 30 ++++++++++++++++++++++++------ test/rules/actions_lock_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 79fe2998..9fcec245 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -18,12 +18,20 @@ defmodule Hypatia.Rules.ActionsLock do closed rather than being guessed at. v0.0.3 is what gh-actions-lock v0.2.0 writes, and the version the estate gate - pins (standards#1250). Its line grammar is the v0.0.2 grammar: the same - `workflows:` and `dependencies:` sections, the same record fields (`ref`, - `commit`, `owner_id`, `repo_id`, `uses`) and the same `'': []` form for a - workflow with nothing to lock (measured on rsr-template-repo and this repo's - own lock, 2026-10-19). Both versions are therefore parsed by one grammar; any - other version string is still refused. + pins (standards#1250). For github.com dependencies its line grammar is the + v0.0.2 grammar: the same `workflows:` and `dependencies:` sections, the same + record fields (`ref`, `commit`, `owner_id`, `repo_id`, `uses`) and the same + `'': []` form for a workflow with nothing to lock (measured on + rsr-template-repo and this repo's own lock, 2026-10-19). Any other version + string is still refused. + + The one v0.0.3 addition is an optional per-dependency `hostname`, which + gh-actions-lock writes only for a GHE.com data-residency tenant + (`hostname,omitempty`). An explicit `hostname: 'github.com'` is accepted as the + default it states. Any other host is refused as + `{:unsupported_dependency_hostname, host}`. Repository identity is validated + per host, this parser models github.com only, and the estate has no tenant + locks. """ @supported_versions ["v0.0.2", "v0.0.3"] @@ -222,6 +230,16 @@ defmodule Hypatia.Rules.ActionsLock do Regex.match?(~r/^ (?:owner_id|repo_id):/, line) -> line_error(line_number, :malformed_dependency_identity) + # v0.0.3 only: an explicit github.com host is the default spelled out. + line == " hostname: 'github.com'" and state.version == "v0.0.3" -> + put_dependency_field(state, :hostname, "github.com", line_number) + + # A GHE.com tenant host is refused by name: owner/repo identity is + # validated per host, and this parser models github.com only. + match = state.version == "v0.0.3" && Regex.run(~r/^ hostname: '([^']+)'$/, line) -> + [_, host] = match + line_error(line_number, {:unsupported_dependency_hostname, host}) + 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 7c1c5432..78af2905 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -96,6 +96,33 @@ defmodule Hypatia.Rules.ActionsLockTest do assert {:error, {:line, _, :malformed_dependency_commit}} = ActionsLock.parse(malformed) end + test "v0.0.3 hostname: github.com is the default, a GHE.com tenant host is refused" do + v003 = String.replace(@valid_lock, "version: 'v0.0.2'", "version: 'v0.0.3'") + + with_host = fn lock, host -> + String.replace( + lock, + " repo_id: 197814629", + " repo_id: 197814629\n hostname: '#{host}'" + ) + end + + assert {:ok, lock} = ActionsLock.parse(with_host.(v003, "github.com")) + assert ActionsLock.pinned?(lock, "ci.yml", "actions/checkout@v7.0.1") + + assert {:error, {:line, 13, {:unsupported_dependency_hostname, "acme.ghe.com"}}} = + ActionsLock.parse(with_host.(v003, "acme.ghe.com")) + + # The field is new in v0.0.3; a v0.0.2 lock carrying it is still malformed. + assert {:error, {:line, 13, :malformed_dependency_entry}} = + ActionsLock.parse(with_host.(@valid_lock, "github.com")) + + duplicate = with_host.(with_host.(v003, "github.com"), "github.com") + + assert {:error, {:line, _, {:duplicate_dependency_field, _, :hostname}}} = + ActionsLock.parse(duplicate) + end + test "rejects every version outside the supported set, and a second version line" do for version <- ["v0.0.1", "v0.0.4", "v0.1.0", "0.0.3"] do assert {:error, {:line, 2, {:unsupported_version, ^version}}} =