From 1439d8dbfaf87c216330d50484dc21d60d8909a8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:31:26 +0100 Subject: [PATCH] fix(scorecard): share validated action-lock audit semantics --- lib/scorecard_ingestor.ex | 36 +++++---- test/scorecard_ingestor_actions_lock_test.exs | 79 +++++++++++++++++++ 2 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 test/scorecard_ingestor_actions_lock_test.exs diff --git a/lib/scorecard_ingestor.ex b/lib/scorecard_ingestor.ex index 7bcdc5a8..611bc3e8 100644 --- a/lib/scorecard_ingestor.ex +++ b/lib/scorecard_ingestor.ex @@ -561,28 +561,34 @@ defmodule Hypatia.ScorecardIngestor do String.ends_with?(f, ".yml") or String.ends_with?(f, ".yaml") end) - unpinned = - yml_files - |> Enum.filter(fn f -> - path = Path.join(workflows_dir, f) + # Share the workflow auditor's validated lock semantics. A raw tag + # regex contradicts the estate's action-lock policy, while accepting + # the mere presence of a lock would silently hide integrity failures. + contents = Map.new(yml_files, &{&1, File.read!(Path.join(workflows_dir, &1))}) - case File.read(path) do - {:ok, content} -> - # Check for actions using tags instead of SHA hashes - # Match pattern: uses: owner/repo@v1 (tag) vs uses: owner/repo@abc123 (SHA) - Regex.match?(~r/uses:\s+[\w-]+\/[\w-]+@v\d/, content) + lock_content = + case File.read(Path.join(workflows_dir, "actions.lock")) do + {:ok, content} -> + content - _ -> - false - end - end) + {:error, :enoent} -> + nil + + {:error, reason} -> + raise File.Error, reason: reason, action: "read", path: workflows_dir + end + + findings = + Hypatia.Rules.WorkflowAudit.check_unpinned_actions(contents, actions_lock: lock_content) + |> Enum.reject(&(&1.type == :pin_exempt_accepted)) - if length(unpinned) > 0 do + if findings != [] do make_pattern( "SC-013", "Pinned-Dependencies", repo_name, - "#{length(unpinned)} workflow(s) with tag-pinned (not SHA-pinned) actions in #{repo_name}" + "#{length(findings)} action-pinning integrity finding(s) in #{repo_name}; " <> + "verify .github/workflows/actions.lock with gh actions-lock" ) end diff --git a/test/scorecard_ingestor_actions_lock_test.exs b/test/scorecard_ingestor_actions_lock_test.exs new file mode 100644 index 00000000..09a4c5f6 --- /dev/null +++ b/test/scorecard_ingestor_actions_lock_test.exs @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MPL-2.0 +defmodule Hypatia.ScorecardIngestorActionsLockTest do + use ExUnit.Case, async: true + + @workflow """ + permissions: + contents: read + jobs: + test: + steps: + - uses: actions/checkout@v7.0.1 + """ + @lock """ + version: 'v0.0.2' + workflows: + '.github/workflows/ci.yml': + - 'actions/checkout@v7.0.1' + dependencies: + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + """ + + setup do + dir = Path.join(System.tmp_dir!(), "scorecard-lock-#{System.unique_integer([:positive])}") + File.mkdir_p!(Path.join(dir, ".github/workflows")) + File.write!(Path.join(dir, ".github/workflows/ci.yml"), @workflow) + on_exit(fn -> File.rm_rf!(dir) end) + %{dir: dir} + end + + defp pinning_findings(dir) do + {:ok, findings} = Hypatia.ScorecardIngestor.local_scan(dir, "fixture") + Enum.filter(findings, &(&1["category"] == "DependencyPinning")) + end + + test "symbolic action with a valid associated lock is pinned", %{dir: dir} do + File.write!(Path.join(dir, ".github/workflows/actions.lock"), @lock) + assert pinning_findings(dir) == [] + end + + test "missing lock still reports the unpinned action", %{dir: dir} do + assert [_] = pinning_findings(dir) + end + + test "malformed lock fails closed rather than accepting symbolic refs", %{dir: dir} do + File.write!(Path.join(dir, ".github/workflows/actions.lock"), "version: 'invalid'\n") + assert [_] = pinning_findings(dir) + end + + test "a valid lock for a different workflow does not cover this workflow", %{dir: dir} do + File.write!( + Path.join(dir, ".github/workflows/actions.lock"), + String.replace(@lock, "ci.yml", "other.yml") + ) + + assert [_] = pinning_findings(dir) + end + + test "a valid lock for a different ref does not cover this ref", %{dir: dir} do + File.write!( + Path.join(dir, ".github/workflows/actions.lock"), + String.replace(@lock, "v7.0.1", "v7.0.0") + ) + + assert [_] = pinning_findings(dir) + end + + test "unpinned sub-actions and branch refs are detected", %{dir: dir} do + File.write!( + Path.join(dir, ".github/workflows/ci.yml"), + String.replace(@workflow, "actions/checkout@v7.0.1", "github/codeql-action/analyze@main") + ) + + assert [_] = pinning_findings(dir) + end +end