Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
6 changes: 3 additions & 3 deletions github_actions/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
FROM ghcr.io/dependabot/dependabot-updater-core

ARG TARGETARCH
ARG GH_ACTIONS_LOCK_VERSION=v0.1.6
ARG GH_ACTIONS_LOCK_AMD64_CHECKSUM=4181ec1da5408b34b9a542a7ee5c6ce3a4d6ac815c7d0206a00ceca8a817f4e3
ARG GH_ACTIONS_LOCK_ARM64_CHECKSUM=31c99e586f8f5fa2607ea79af74e3b50bc7ed4be1580a0d8449cdd73d1aba5e4
ARG GH_ACTIONS_LOCK_VERSION=v0.2.0-rc.1
ARG GH_ACTIONS_LOCK_AMD64_CHECKSUM=51c52217c40e15b38454b7ad3ca9ac82368dc4bd4c82af5d1d1f1fd03619c2dd
ARG GH_ACTIONS_LOCK_ARM64_CHECKSUM=9aba8840bf00d4d64e9ded2ec96882a7284bd3cd92058969e03468f822e8c056

RUN mkdir -p /opt/github_actions/bin \
&& curl -fsSL -o /opt/github_actions/bin/gh-actions-lock \
Expand Down
8 changes: 6 additions & 2 deletions github_actions/lib/dependabot/github_actions/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ module GithubActions
MANIFEST_FILE_YML = "action.yml"
# The name of the manifest file
MANIFEST_FILE_YAML = "action.yaml"
ACTION_FILE_NAMES = T.let([MANIFEST_FILE_YML, MANIFEST_FILE_YAML].freeze, T::Array[String])
# A step-level self repository reference, e.g. `uses: $/.github/actions/setup`
SELF_REPOSITORY_USES = %r{^\s*-?\s*uses:\s*["']?\$/}
# The pattern to match any .yml or .yaml file
ANYTHING_YML = "<anything>.yml"
# The path to the workflow directory
Expand All @@ -34,8 +37,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
33 changes: 31 additions & 2 deletions github_actions/lib/dependabot/github_actions/file_fetcher.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# typed: strong
# frozen_string_literal: true

require "pathname"
require "sorbet-runtime"

require "dependabot/file_fetchers"
Expand Down Expand Up @@ -50,7 +51,9 @@
# and never activates the ecosystem on its own. Relocking also requires
# every workflow to be materialized, so retain the workflow-only path if
# an invalidly encoded workflow had to be omitted.
fetched_files += [actions_lockfile].compact if incorrectly_encoded_workflow_files.empty?
if incorrectly_encoded_workflow_files.empty? && (lockfile = actions_lockfile)
fetched_files += [lockfile] + self_repository_action_files
end
return fetched_files
end

Expand Down Expand Up @@ -81,12 +84,38 @@
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
end

# `$/` composites can live anywhere in the repo and can call each other, so
# relocking needs every action definition, at its repo-relative path.
sig { returns(T::Array[DependencyFile]) }
def self_repository_action_files
return [] unless correctly_encoded_workflow_files.any? { |f| f.content&.match?(SELF_REPOSITORY_USES) }
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

# ponytail: one recursive tree call; a truncated tree (>100k entries) may miss composites,
# which the engine then reports as an EngineError.
Comment thread
nodeselector marked this conversation as resolved.
Outdated
repo_action_paths
.reject { |path| correctly_encoded_workflow_files.any? { |f| f.path == path } }
.map do |path|
file = fetch_file_from_host(Pathname.new(path).relative_path_from(File.join("/", directory)).to_s)
Comment on lines +102 to +105
file.support_file = true
file
end
end

sig { returns(T::Array[String]) }
def repo_action_paths
tree = github_client.tree(repo, T.must(commit), recursive: true)
T.let(tree[:tree], T::Array[Sawyer::Resource])
.select { |entry| T.let(entry[:type], String) == "blob" }
.map { |entry| T.let("/#{entry[:path]}", String) }
.select { |path| ACTION_FILE_NAMES.include?(File.basename(path)) }
end

sig { returns(T::Array[DependencyFile]) }
def workflow_files
return @workflow_files unless @workflow_files.empty?
Expand Down
33 changes: 32 additions & 1 deletion 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,9 +197,36 @@ 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 }
dependency_files.reject { |file| file.support_file? || file.path.delete_prefix("/") == LOCKFILE_PATH }
end

sig { override.void }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ 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(
Comment thread
nodeselector marked this conversation as resolved.
workflow_files: rewritten_onboarded_workflow_files(reader, updated_workflow_files),
lockfile: lock,
action_files: dependency_files.select { |f| ACTION_FILE_NAMES.include?(File.basename(f.name)) },
workflow_paths: changed_onboarded.map { |file| repo_relative_path(file) }
)

Expand Down Expand Up @@ -114,6 +115,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 @@ -46,12 +54,19 @@ def self.binary_path
params(
workflow_files: T::Array[Dependabot::DependencyFile],
lockfile: Dependabot::DependencyFile,
workflow_paths: T::Array[String]
workflow_paths: T::Array[String],
action_files: T::Array[Dependabot::DependencyFile]
).returns(String)
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
def relock(
workflow_files:,
lockfile:,
workflow_paths: workflow_files.map { |file| repo_path(file) },
action_files: []
)
in_repo(workflow_files + action_files, lockfile) do |dir|
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 +85,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 +122,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 +139,9 @@ 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 }]
# The temp dir has no .git, so name the root that `$/` paths resolve against.
env = Env.build(credentials, hostname).merge("GH_ACTIONS_LOCK_REPO_ROOT" => dir)
env_cmd = [env, 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
Comment thread
Copilot marked this conversation as resolved.
Outdated
return unless entry.is_a?(Hash)

hostname = entry["hostname"].to_s
hostname.empty? ? home : hostname
Comment thread
Copilot marked this conversation as resolved.
Outdated
Comment thread
Copilot marked this conversation as resolved.
Outdated
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
Loading
Loading