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
20 changes: 6 additions & 14 deletions github_actions/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
# syntax=docker.io/docker/dockerfile:1.20
FROM ghcr.io/dependabot/dependabot-updater-core
FROM docker.io/library/golang:1.27.0-bookworm AS gh-actions-lock
RUN CGO_ENABLED=0 GOBIN=/out go install \
github.com/github/gh-actions-lock/cmd/gh-actions-lock@c5ee15d8f20e3e52d6671851192770b9a2e02152

ARG TARGETARCH
ARG GH_ACTIONS_LOCK_VERSION=v0.1.6
ARG GH_ACTIONS_LOCK_AMD64_CHECKSUM=4181ec1da5408b34b9a542a7ee5c6ce3a4d6ac815c7d0206a00ceca8a817f4e3
ARG GH_ACTIONS_LOCK_ARM64_CHECKSUM=31c99e586f8f5fa2607ea79af74e3b50bc7ed4be1580a0d8449cdd73d1aba5e4
FROM ghcr.io/dependabot/dependabot-updater-core

RUN mkdir -p /opt/github_actions/bin \
&& curl -fsSL -o /opt/github_actions/bin/gh-actions-lock \
"https://github.com/github/gh-actions-lock/releases/download/${GH_ACTIONS_LOCK_VERSION}/linux-${TARGETARCH}" \
&& case "${TARGETARCH}" in \
amd64) echo "${GH_ACTIONS_LOCK_AMD64_CHECKSUM} /opt/github_actions/bin/gh-actions-lock" | sha256sum -c - ;; \
arm64) echo "${GH_ACTIONS_LOCK_ARM64_CHECKSUM} /opt/github_actions/bin/gh-actions-lock" | sha256sum -c - ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
esac \
&& chmod +x /opt/github_actions/bin/gh-actions-lock
# TEMP(staging): gh-actions-lock built from github/gh-actions-lock#137; swap back to a release pin before review.
COPY --from=gh-actions-lock /out/gh-actions-lock /opt/github_actions/bin/gh-actions-lock

USER dependabot

Expand Down
5 changes: 3 additions & 2 deletions github_actions/lib/dependabot/github_actions/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ module GithubActions
LOCKFILE_NAME = "actions.lock"
# The repo-relative path to the Actions lockfile
LOCKFILE_PATH = T.let("#{WORKFLOW_DIRECTORY}/#{LOCKFILE_NAME}".freeze, String)
# The only lockfile schema version this ecosystem understands.
SUPPORTED_LOCKFILE_VERSION = "v0.0.2"
# Lockfile schema versions this ecosystem understands. gh-actions-lock migrates
# v0.0.2 input and always writes v0.0.3.
SUPPORTED_LOCKFILE_VERSIONS = %w(v0.0.2 v0.0.3).freeze

OWNER_KEY = "owner"
REPO_KEY = "repo"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def fetch_files
sig { returns(T.nilable(DependencyFile)) }
def actions_lockfile
return unless Dependabot::Experiments.enabled?(:github_actions_lockfile)
return unless source.hostname == GITHUB_COM
return unless source.hostname == GITHUB_COM || source.hostname.end_with?(".ghe.com")
return fetch_file_if_present(LOCKFILE_PATH) if directory == "/"

fetch_file_if_present(LOCKFILE_NAME) if directory.delete_prefix("/") == WORKFLOW_DIRECTORY
Expand Down
31 changes: 31 additions & 0 deletions github_actions/lib/dependabot/github_actions/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require "dependabot/file_parsers"
require "dependabot/file_parsers/base"
require "dependabot/github_actions/constants"
require "dependabot/github_actions/lockfile/reader"
require "dependabot/github_actions/version"
require "dependabot/github_actions/package_manager"
require "dependabot/github_actions/workflow_file"
Expand Down Expand Up @@ -137,6 +138,9 @@ def dependency_with_resolved_version(dep, git_checker)
).returns(Dependabot::Dependency)
end
def build_github_dependency(file, string, metadata)
locked = locked_hostname(string)
return github_dependency(file, string, locked, metadata) if locked

unless source&.hostname == GITHUB_COM
dep = github_dependency(file, string, T.must(source).hostname, metadata)
git_checker = Dependabot::GitCommitChecker.new(dependency: dep, credentials: credentials)
Expand Down Expand Up @@ -193,6 +197,33 @@ def github_dependency(file, string, hostname, metadata)
)
end

# A lockfile pin already names its host, so trust it over probing the tenant:
# a tenant blip must not silently rebind an in-tenant action to github.com.
sig { params(string: String).returns(T.nilable(String)) }
def locked_hostname(string)
reader = lockfile_reader
return unless reader

details = T.must(string.match(GITHUB_REPO_REFERENCE)).named_captures
action_ref = "#{details.fetch(OWNER_KEY)}/#{details.fetch(REPO_KEY)}@#{details.fetch(REF_KEY)}"
reader.pinned_hostname(action_ref, home: source&.hostname || GITHUB_COM)
end

# A malformed lock must not block parsing; the updater gates it at relock.
sig { returns(T.nilable(Lockfile::Reader)) }
def lockfile_reader
return @lockfile_reader if defined?(@lockfile_reader)

@lockfile_reader = T.let(
begin
Lockfile::Reader.from_files(dependency_files)
rescue Dependabot::DependencyFileNotParseable
nil
end,
T.nilable(Lockfile::Reader)
)
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def workflow_files
dependency_files.reject { |file| file.path.delete_prefix("/") == LOCKFILE_PATH }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def relocked_files(updated_workflow_files)

# Materialize the full onboarded closure so the lock remains intact, but fix
# only changed workflows so unrelated refs are not touched.
content = Lockfile::CliEngine.new(credentials).relock(
content = Lockfile::CliEngine.new(credentials, hostname: source_hostname).relock(
workflow_files: rewritten_onboarded_workflow_files(reader, updated_workflow_files),
lockfile: lock,
workflow_paths: changed_onboarded.map { |file| repo_relative_path(file) }
Expand Down Expand Up @@ -114,6 +114,12 @@ def onboarded_workflow_files(reader)
.select { |f| reader.onboarded?(repo_relative_path(f)) }
end

# The repository's home host, plumbed from the job source by the updater.
sig { returns(String) }
def source_hostname
T.cast(options.fetch(:source_hostname, GITHUB_COM), String)
Comment thread
nodeselector marked this conversation as resolved.
end

sig { returns(T.nilable(Dependabot::DependencyFile)) }
def lockfile
dependency_files.find { |f| lockfile?(f) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ class CliEngine
FIXED_FINDING_CATEGORIES = %w(onboarding-required ref-changed stale).freeze
UNRESOLVABLE_CATEGORIES = %w(impostor-commit lockfile-forgery).freeze

sig { params(credentials: T::Array[Dependabot::Credential]).void }
def initialize(credentials)
# Host-identity failures from the engine: the lock is bound to a host it can't
# be verified on. User-actionable (restore/regenerate), never retryable. The
# engine wraps every lookup error, so only a 404 counts; 5xx/429/403/timeouts stay EngineError.
HOST_IDENTITY_ERROR = /verifying repository identity .*: HTTP 404\b|repository IDs|not the selected host/

# `hostname` is the repository's home host. The engine binds every pin with an
# omitted lockfile `hostname` to it, so it must never be guessed.
sig { params(credentials: T::Array[Dependabot::Credential], hostname: String).void }
def initialize(credentials, hostname: GITHUB_COM)
@credentials = credentials
@hostname = hostname
end

# Binary baked into the ecosystem image; falls back to PATH for local dev.
Expand All @@ -51,7 +59,8 @@ def self.binary_path
end
def relock(workflow_files:, lockfile:, workflow_paths: workflow_files.map { |file| repo_path(file) })
in_repo(workflow_files, lockfile) do |dir|
args = %w(--no-onboard --no-narrow --no-interactive --json=findings) + workflow_paths
args = %w(--no-onboard --no-narrow --no-interactive --json=findings) +
["--hostname", hostname] + workflow_paths
json, exit_status = run(dir, args)

skipped = onboarding_skips(json, lockfile)
Expand All @@ -70,6 +79,9 @@ def relock(workflow_files:, lockfile:, workflow_paths: workflow_files.map { |fil
sig { returns(T::Array[Dependabot::Credential]) }
attr_reader :credentials

sig { returns(String) }
attr_reader :hostname

sig do
type_parameters(:T)
.params(
Expand Down Expand Up @@ -104,6 +116,7 @@ def repo_path(file)
sig { params(dir: String, args: T::Array[String]).returns([JsonObject, Integer]) }
def run(dir, args)
stdout, stderr, exit_status = invoke(dir, args)
raise DependencyFileNotResolvable, stderr.strip if exit_status > 1 && stderr.match?(HOST_IDENTITY_ERROR)
raise EngineError, "gh-actions-lock failed (exit #{exit_status}): #{stderr.strip}" if exit_status > 1

json = case (parsed = JSON.parse(stdout))
Expand All @@ -120,7 +133,7 @@ def run(dir, args)
# [stdout, stderr, exit_status].
sig { params(dir: String, args: T::Array[String]).returns([String, String, Integer]) }
def invoke(dir, args)
env_cmd = [Env.build(credentials), self.class.binary_path, *args, { chdir: dir }]
env_cmd = [Env.build(credentials, hostname), self.class.binary_path, *args, { chdir: dir }]
stdout, stderr, process = CommandHelpers.capture3_with_timeout(env_cmd)

# A failed spawn comes back as a nil status, not an exception.
Expand Down
16 changes: 9 additions & 7 deletions github_actions/lib/dependabot/github_actions/lockfile/env.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ module GithubActions
module Lockfile
# Builds the subprocess environment for the gh-actions-lock engine. Hosted
# Dependabot is tokenless behind a MITM proxy that overwrites the auth header,
# while proxyless local runs hold the real github.com token in `credentials`.
# while proxyless local runs hold the real home-host token in `credentials`.
# Only a home-host `git_source` token is ever used; the engine reads other
# hosts (dotcom fallback) anonymously.
module Env
extend T::Sig

Expand All @@ -20,13 +22,13 @@ module Env
DUMMY_TOKEN = "x-access-token"

sig do
params(credentials: T::Array[Dependabot::Credential])
params(credentials: T::Array[Dependabot::Credential], hostname: String)
.returns(T::Hash[String, String])
end
def self.build(credentials)
def self.build(credentials, hostname = GITHUB_COM)
env = {}

github_credential = github_dot_com_credential(credentials)
github_credential = home_credential(credentials, hostname)
env["GH_TOKEN"] = github_credential&.fetch("password", nil) || DUMMY_TOKEN
env["GH_ACTIONS_LOCK_DEPENDABOT_PROXY"] = "1" unless github_credential

Expand All @@ -36,12 +38,12 @@ def self.build(credentials)
# Mirrors SharedHelpers.configure_git_to_use_https_with_credentials: prefer a
# deliberately-added token over an app installation token ("v1." prefix).
sig do
params(credentials: T::Array[Dependabot::Credential])
params(credentials: T::Array[Dependabot::Credential], hostname: String)
.returns(T.nilable(Dependabot::Credential))
end
def self.github_dot_com_credential(credentials)
def self.home_credential(credentials, hostname)
candidates = credentials.select do |c|
c["type"] == "git_source" && c["host"] == GITHUB_COM && c["password"]
c["type"] == "git_source" && c["host"] == hostname && c["password"]
end

candidates.find { |c| !c["password"]&.start_with?("v1.") } || candidates.first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def initialize(found, supported)
super(
LOCKFILE_PATH,
"Unsupported actions.lock version #{found.inspect}; " \
"this version of Dependabot supports #{supported.inspect}. " \
"this version of Dependabot supports #{supported}. " \
"Upgrade Dependabot or regenerate the lockfile with a compatible gh-actions-lock version."
)
end
Expand Down
11 changes: 11 additions & 0 deletions github_actions/lib/dependabot/github_actions/lockfile/reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ def pins_action?(path, action_ref)
Array(workflows[path]).include?(action_ref)
end

# The host a pinned `owner/repo@ref` is bound to: its `hostname`, or `home`
# when omitted. Nil when the lockfile doesn't pin it.
sig { params(action_ref: String, home: String).returns(T.nilable(String)) }
def pinned_hostname(action_ref, home:)
entry = dependencies.find { |key, _| key.casecmp?(action_ref) }&.last
return unless entry.is_a?(Hash)

hostname = entry["hostname"].to_s
hostname.empty? ? home : hostname
Comment on lines +77 to +78
end

# Asserts every `dependencies` entry carries {REQUIRED_DEPENDENCY_KEYS}. A
# missing key makes the engine silently treat the whole lockfile as empty.
# Deferred to the relock gate (not the constructor) so a malformed lock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,20 @@ module Dependabot
module GithubActions
module Lockfile
# Pre-1.0 lockfile schema revisions may be breaking, so only the explicitly
# supported version is safe to read and rewrite.
# supported versions are safe to read and rewrite.
module VersionGate
extend T::Sig

sig { params(found: String).void }
def self.assert_supported!(found)
return if compatible?(found)

raise UnsupportedLockfileVersion.new(found, SUPPORTED_LOCKFILE_VERSION)
raise UnsupportedLockfileVersion.new(found, SUPPORTED_LOCKFILE_VERSIONS.join(", "))
end

sig { params(found: String).returns(T::Boolean) }
def self.compatible?(found)
found == SUPPORTED_LOCKFILE_VERSION
SUPPORTED_LOCKFILE_VERSIONS.include?(found)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,30 @@
expect(a_request(:get, url + ".github/workflows/actions.lock?ref=sha")).not_to have_been_made
end
end

context "with a ghe.com source" do
let(:credentials) do
[{
"type" => "git_source",
"host" => "tenant.ghe.com",
"username" => "x-access-token",
"password" => "token"
}]
end
let(:source) do
Dependabot::Source.new(
provider: "github",
repo: "gocardless/bump",
directory: directory,
hostname: "tenant.ghe.com",
api_endpoint: github_url
)
end

it "fetches the lockfile" do
expect(file_fetcher_instance.files.map(&:name)).to include(".github/workflows/actions.lock")
end
end
end

context "when it has an invalid encoding" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,42 @@ def mock_service_pack_request(nwo)
expect(dependency.requirements).to eq(expected_requirements)
end
end

context "when the actions lockfile pins the action" do
let(:pin_hostname) { "" }
let(:lockfile) do
Dependabot::DependencyFile.new(
name: Dependabot::GithubActions::LOCKFILE_NAME,
directory: Dependabot::GithubActions::WORKFLOW_DIRECTORY,
content: <<~YAML
version: 'v0.0.3'
dependencies:
'inactions/checkout@01aecccf739ca6ff86c0539fbc67a7a5007bbc81':
#{pin_hostname}
ref: '01aecccf739ca6ff86c0539fbc67a7a5007bbc81'
YAML
)
end
let(:files) { [workflow_files, lockfile] }
let(:url) { dependencies.first.requirements.first.dig(:source, :url) }

before { stub_request(:get, service_pack_url).to_return(status: 503) }

it "keeps an omitted-hostname pin on the home host when the tenant blips" do
expect(url).to eq("https://ghes.other.com/inactions/checkout")
end

context "with an explicit github.com hostname" do
let(:pin_hostname) { "hostname: 'github.com'" }

before { mock_service_pack_request("inactions/checkout") }

it "binds to github.com without probing the tenant" do
expect(url).to eq("https://github.com/inactions/checkout")
expect(a_request(:get, service_pack_url)).not_to have_been_made
end
end
end
end

context "with an inaccessible source" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2145,6 +2145,24 @@ def flow_requirement(name, ref, path)
end
end

context "with a ghe.com home host" do
let(:updater) do
described_class.new(
dependency_files: files,
dependencies: [dependency],
credentials: credentials,
options: { source_hostname: "tenant.ghe.com" }
)
end

it "relocks against the home host" do
updated_files

expect(Dependabot::GithubActions::Lockfile::CliEngine)
.to have_received(:new).with(credentials, hostname: "tenant.ghe.com")
end
end

context "when the workflow is converted from a tag to a SHA" do
let(:sha) { "5273d0df9c603edc4284ac8402cf650b4f1f6686" }
let(:workflow_file_body) do
Expand Down
Loading
Loading