Skip to content
Open
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
36 changes: 21 additions & 15 deletions lib/scorecard_ingestor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 79 additions & 0 deletions test/scorecard_ingestor_actions_lock_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading