Add support for pinning tools to a checksum (#1098)

Tools in the tools input can now be pinned to a checksum using the
tool:version@sha256:<hash> or tool:version@sha512:<hash> syntax.
The downloaded tool is verified against the checksum on all platforms,
including when it is served from the tools cache, and it is removed
along with its cache entry if the verification fails.

Checksum verification is supported for tools downloaded as phar
archives. Specifying a checksum for tools set up using composer
packages or custom package scripts results in an error.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Keisuke Maeda
2026-07-23 21:45:09 +09:00
committed by Shivam Mathur
parent b9ef68088b
commit 8ca9579834
7 changed files with 225 additions and 16 deletions
+12
View File
@@ -264,6 +264,18 @@ These tools can be set up globally using the `tools` input. It accepts a string
When you specify just the major version or the version in `major.minor` format, the latest patch version matching the input will be set up. When you specify just the major version or the version in `major.minor` format, the latest patch version matching the input will be set up.
- To harden your workflow against supply chain attacks, you can pin a tool to a checksum in the form `tool:version@sha256:<hash>` or `tool:version@sha512:<hash>`. The downloaded tool is verified against the checksum and it is not set up if the verification fails, even if the tool is served from the tools cache.
```yaml
- name: Setup PHP with tools pinned to a checksum
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
tools: composer:2.9.8@sha256:59b2c50e10cafa0d8efc19ede9a326d782f096c674a26baf98cf042ce23de890
```
Checksum verification is supported for tools which are downloaded as phar archives. It is not supported for tools set up using `composer` packages or custom package scripts, specifying a checksum for these tools will result in an error. For checksum pinning to be effective, pin the tool to an exact version, as mutable versions like `latest` or `major.minor` can resolve to a different release with a different checksum.
- The latest stable version of `composer` is set up by default. You can set up the required `composer` version by specifying the major version `v1` or `v2`, or the version in `major.minor` or `semver` format. Additionally, for composer `snapshot` and `preview` can also be specified to set up the respective releases. - The latest stable version of `composer` is set up by default. You can set up the required `composer` version by specifying the major version `v1` or `v2`, or the version in `major.minor` or `semver` format. Additionally, for composer `snapshot` and `preview` can also be specified to set up the respective releases.
```yaml ```yaml
+87 -11
View File
@@ -222,17 +222,41 @@ describe('Tools tests', () => {
}); });
it.each` it.each`
input_list | filtered_list release | expected_release | checksum | error
${'a, b'} | ${'composer, a, b'} ${'tool:1.2.3'} | ${'tool:1.2.3'} | ${undefined} | ${undefined}
${'a, b, composer'} | ${'composer, a, b'} ${'tool:1.2.3@sha256:' + 'a'.repeat(64)} | ${'tool:1.2.3'} | ${'sha256:' + 'a'.repeat(64)} | ${undefined}
${'a, b, composer:1.2.3'} | ${'composer:1.2.3, a, b'} ${'tool:1.2.3@sha256:' + 'A'.repeat(64)} | ${'tool:1.2.3'} | ${'sha256:' + 'a'.repeat(64)} | ${undefined}
${'a, b, composer:v1.2.3'} | ${'composer:1.2.3, a, b'} ${'tool:1.2.3@sha512:' + 'b'.repeat(128)} | ${'tool:1.2.3'} | ${'sha512:' + 'b'.repeat(128)} | ${undefined}
${'a, b, composer:snapshot'} | ${'composer:snapshot, a, b'} ${'composer:2.9.8@sha256:' + 'c'.repeat(64)} | ${'composer:2.9.8'} | ${'sha256:' + 'c'.repeat(64)} | ${undefined}
${'a, b, composer:preview'} | ${'composer:preview, a, b'} ${'tool:1.2.3@sha256:xyz'} | ${'tool:1.2.3'} | ${undefined} | ${'Invalid sha256 checksum, expected 64 hexadecimal characters'}
${'a, b, composer:1'} | ${'composer:1, a, b'} ${'tool:1.2.3@sha256:' + 'a'.repeat(63)} | ${'tool:1.2.3'} | ${undefined} | ${'Invalid sha256 checksum, expected 64 hexadecimal characters'}
${'a, b, composer:2'} | ${'composer:2, a, b'} ${'tool:1.2.3@sha512:' + 'b'.repeat(64)} | ${'tool:1.2.3'} | ${undefined} | ${'Invalid sha512 checksum, expected 128 hexadecimal characters'}
${'a, b, composer:v1'} | ${'composer:1, a, b'} ${'tool:1.0@dev'} | ${'tool:1.0@dev'} | ${undefined} | ${undefined}
${'a, b, composer:v2'} | ${'composer:2, a, b'} `(
'checking extractChecksum: $release',
({release, expected_release, checksum, error}) => {
expect(tools.extractChecksum(release)).toStrictEqual({
release: expected_release,
...(checksum !== undefined && {checksum}),
...(error !== undefined && {error})
});
}
);
it.each`
input_list | filtered_list
${'a, b'} | ${'composer, a, b'}
${'a, b, composer'} | ${'composer, a, b'}
${'a, b, composer:1.2.3'} | ${'composer:1.2.3, a, b'}
${'a, b, composer:v1.2.3'} | ${'composer:1.2.3, a, b'}
${'a, b, composer:snapshot'} | ${'composer:snapshot, a, b'}
${'a, b, composer:preview'} | ${'composer:preview, a, b'}
${'a, b, composer:1'} | ${'composer:1, a, b'}
${'a, b, composer:2'} | ${'composer:2, a, b'}
${'a, b, composer:v1'} | ${'composer:1, a, b'}
${'a, b, composer:v2'} | ${'composer:2, a, b'}
${'a, b, composer:2.7.1@sha256:' + 'a'.repeat(64)} | ${'composer:2.7.1@sha256:' + 'a'.repeat(64) + ', a, b'}
${'a, b, composer:v2.7.1@sha256:' + 'a'.repeat(64)} | ${'composer:2.7.1@sha256:' + 'a'.repeat(64) + ', a, b'}
`('checking filterList $input_list', async ({input_list, filtered_list}) => { `('checking filterList $input_list', async ({input_list, filtered_list}) => {
expect(await tools.filterList(input_list.split(', '))).toStrictEqual( expect(await tools.filterList(input_list.split(', '))).toStrictEqual(
filtered_list.split(', ') filtered_list.split(', ')
@@ -304,6 +328,23 @@ describe('Tools tests', () => {
expect(await tools.addArchive(data)).toContain(script); expect(await tools.addArchive(data)).toContain(script);
}); });
it.each`
os | script
${'linux'} | ${'add_tool https://example.com/tool.phar tool "-v" sha256:' + 'a'.repeat(64)}
${'darwin'} | ${'add_tool https://example.com/tool.phar tool "-v" sha256:' + 'a'.repeat(64)}
${'win32'} | ${'Add-Tool https://example.com/tool.phar tool "-v" sha256:' + 'a'.repeat(64)}
`('checking addArchive with checksum: $os', async ({os, script}) => {
const data = getData({
tool: 'tool',
version: '1.2.3',
version_parameter: JSON.stringify('-v'),
os: os,
url: 'https://example.com/tool.phar'
});
data.checksum = 'sha256:' + 'a'.repeat(64);
expect(await tools.addArchive(data)).toContain(script);
});
it.each` it.each`
os | release | scope | script os | release | scope | script
${'linux'} | ${'tool:1.2.3'} | ${'global'} | ${'add_composer_tool tool tool:1.2.3 user/ global'} ${'linux'} | ${'tool:1.2.3'} | ${'global'} | ${'add_composer_tool tool tool:1.2.3 user/ global'}
@@ -774,6 +815,41 @@ describe('Tools tests', () => {
expect(await tools.addTools(tools_csv, '7.4', 'linux')).toContain(script); expect(await tools.addTools(tools_csv, '7.4', 'linux')).toContain(script);
}); });
it.each`
tools_csv | os | script
${'phpunit:9.5.0@sha256:' + 'a'.repeat(64)} | ${'linux'} | ${'add_tool https://phar.phpunit.de/phpunit-9.5.0.phar,https://phar.phpunit.de/phpunit-9.phar phpunit "--version" sha256:' + 'a'.repeat(64)}
${'phpunit:9.5.0@sha256:' + 'a'.repeat(64)} | ${'win32'} | ${'Add-Tool https://phar.phpunit.de/phpunit-9.5.0.phar,https://phar.phpunit.de/phpunit-9.phar phpunit "--version" sha256:' + 'a'.repeat(64)}
${'composer:2.9.8@sha256:' + 'b'.repeat(64)} | ${'linux'} | ${'composer 2.9.8 sha256:' + 'b'.repeat(64)}
${'cs2pr:1.2.3@sha256:' + 'd'.repeat(64)} | ${'linux'} | ${'add_tool https://github.com/staabm/annotate-pull-request-from-checkstyle/releases/download/1.2.3/cs2pr cs2pr "-V" sha256:' + 'd'.repeat(64)}
${'phive:0.15.3@sha512:' + 'c'.repeat(128)} | ${'darwin'} | ${'add_tool https://github.com/phar-io/phive/releases/download/0.15.3/phive-0.15.3.phar phive "status" sha512:' + 'c'.repeat(128)}
${'phinx:1.2.3@sha256:' + 'a'.repeat(64)} | ${'linux'} | ${'add_log "$cross" "phinx" "Checksum verification is not supported for phinx"'}
${'pecl@sha256:' + 'a'.repeat(64)} | ${'linux'} | ${'add_log "$cross" "pecl" "Checksum verification is not supported for pecl"'}
${'phpunit:9.5.0@sha256:invalid'} | ${'linux'} | ${'add_log "$cross" "phpunit" "Invalid sha256 checksum, expected 64 hexadecimal characters"'}
`(
'checking addTools with checksum: $tools_csv, $os',
async ({tools_csv, os, script}) => {
expect(await tools.addTools(tools_csv, '7.4', os)).toContain(script);
}
);
it.each`
type | tool_function | supported
${'phar'} | ${undefined} | ${true}
${'custom-function'} | ${undefined} | ${true}
${'custom-function'} | ${'composer'} | ${true}
${'custom-function'} | ${'pecl'} | ${false}
${'custom-function'} | ${'dev_tools'} | ${false}
${'composer'} | ${undefined} | ${false}
${'custom-package'} | ${undefined} | ${false}
`(
'checking supportsChecksum: $type, $tool_function',
async ({type, tool_function, supported}) => {
const data = getData({tool: 'tool', type: type});
data.function = tool_function;
expect(await tools.supportsChecksum(data)).toBe(supported);
}
);
it.each` it.each`
tools_csv | token | script tools_csv | token | script
${'cs2pr:1.2'} | ${'invalid_token'} | ${'add_log "$cross" "cs2pr" "Invalid token"'} ${'cs2pr:1.2'} | ${'invalid_token'} | ${'add_log "$cross" "cs2pr" "Invalid token"'}
+1 -1
View File
File diff suppressed because one or more lines are too long
+24 -1
View File
@@ -229,8 +229,18 @@ Function Add-Tool() {
[Parameter(Position = 2, Mandatory = $false)] [Parameter(Position = 2, Mandatory = $false)]
$ver_param, $ver_param,
[Parameter(Position = 3, Mandatory = $false)] [Parameter(Position = 3, Mandatory = $false)]
$skip_composer_github_auth $skip_composer_github_auth,
[Parameter(Position = 4, Mandatory = $false)]
$checksum
) )
$checksum_regex = '^sha(256|512):[0-9a-fA-F]+$'
if("$ver_param" -match $checksum_regex) {
$checksum = $ver_param
$ver_param = $null
} elseif("$skip_composer_github_auth" -match $checksum_regex) {
$checksum = $skip_composer_github_auth
$skip_composer_github_auth = $null
}
if($tool -eq "composer") { if($tool -eq "composer") {
$script:skip_composer_github_auth = $skip_composer_github_auth -eq 'true' $script:skip_composer_github_auth = $skip_composer_github_auth -eq 'true'
} }
@@ -277,6 +287,19 @@ Function Add-Tool() {
Remove-Item $backup_path -Force -ErrorAction SilentlyContinue Remove-Item $backup_path -Force -ErrorAction SilentlyContinue
} }
if($checksum -and ($status_code -eq 200) -and (Test-Path $tool_path)) {
$checksum_parts = $checksum -split ':'
$actual_checksum = (Get-FileHash -Path $tool_path -Algorithm $checksum_parts[0]).Hash
if($actual_checksum -ne $checksum_parts[1]) {
Remove-Item @($tool_path, $cache_path) -Force -ErrorAction SilentlyContinue
if($tool -eq "composer") {
$env:fail_fast = 'true'
}
Add-Log $cross $tool "Checksum verification failed for $tool"
return
}
}
$escaped_tool = [regex]::Escape($tool) $escaped_tool = [regex]::Escape($tool)
if (((Get-ChildItem -Path $bin_dir/* | Where-Object Name -Match "^$escaped_tool(\.exe|\.phar)?$").Count -gt 0)) { if (((Get-ChildItem -Path $bin_dir/* | Where-Object Name -Match "^$escaped_tool(\.exe|\.phar)?$").Count -gt 0)) {
$bat_content = @() $bat_content = @()
+14 -1
View File
@@ -201,8 +201,15 @@ add_tool() {
url=$1 url=$1
tool=$2 tool=$2
ver_param=$3 ver_param=$3
checksum=
local arg
for arg in "$@"; do
[[ "$arg" =~ ^sha(256|512):[0-9a-fA-F]+$ ]] && checksum="$arg"
done
[[ "$ver_param" =~ ^sha(256|512): ]] && ver_param=
if [ "$tool" = "composer" ]; then if [ "$tool" = "composer" ]; then
skip_composer_github_auth="${4:-false}" skip_composer_github_auth="${4:-false}"
[[ "$skip_composer_github_auth" =~ ^sha(256|512): ]] && skip_composer_github_auth=false
fi fi
tool_path="$tool_path_dir/$tool" tool_path="$tool_path_dir/$tool"
if ! [ -d "$tool_path_dir" ]; then if ! [ -d "$tool_path_dir" ]; then
@@ -235,6 +242,10 @@ add_tool() {
fi fi
sudo rm -f "$tool_path.bak" sudo rm -f "$tool_path.bak"
fi fi
if [ "$status_code" = "200" ] && [ -n "$checksum" ] && ! verify_checksum "$tool_path" "$checksum"; then
sudo rm -f "$tool_path" "$cache_path"
status_code="checksum_mismatch"
fi
if [ "$status_code" = "200" ]; then if [ "$status_code" = "200" ]; then
add_tools_helper "$tool" add_tools_helper "$tool"
tool_version=$(get_tool_version "$tool" "$ver_param") tool_version=$(get_tool_version "$tool" "$ver_param")
@@ -244,7 +255,9 @@ add_tool() {
if [ "$tool" = "composer" ]; then if [ "$tool" = "composer" ]; then
export fail_fast=true export fail_fast=true
fi fi
if [ "$status_code" = "404" ]; then if [ "$status_code" = "checksum_mismatch" ]; then
add_log "$cross" "$tool" "Checksum verification failed for $tool"
elif [ "$status_code" = "404" ]; then
add_log "$cross" "$tool" "Failed to download $tool from ${url[*]}" add_log "$cross" "$tool" "Failed to download $tool from ${url[*]}"
else else
add_log "$cross" "$tool" "Could not setup $tool" add_log "$cross" "$tool" "Could not setup $tool"
+18
View File
@@ -122,6 +122,24 @@ get_sha256() {
fi fi
} }
# Function to verify the checksum of a file.
# checksum format: sha256:<hash> or sha512:<hash>
verify_checksum() {
local file_path=$1
local checksum=$2
local algo="${checksum%%:*}"
local expected="${checksum#*:}"
local actual=
if command -v "${algo}sum" >/dev/null; then
actual="$(sudo "${algo}sum" "$file_path" | cut -d' ' -f1)"
elif command -v shasum >/dev/null; then
actual="$(sudo shasum -a "${algo#sha}" "$file_path" | cut -d' ' -f1)"
elif command -v openssl >/dev/null; then
actual="$(sudo openssl dgst -"$algo" "$file_path" | awk '{print $NF}')"
fi
[ -n "$actual" ] && [ "$(echo "$actual" | tr '[:upper:]' '[:lower:]')" = "$(echo "$expected" | tr '[:upper:]' '[:lower:]')" ]
}
# Function to download a file using cURL. # Function to download a file using cURL.
# mode: -s pipe to stdout, -v save file and return status code # mode: -s pipe to stdout, -v save file and return status code
# execute: -e save file as executable # execute: -e save file as executable
+69 -2
View File
@@ -27,6 +27,7 @@ type ToolFunction =
export interface ToolData { export interface ToolData {
tool: string; tool: string;
version: string; version: string;
checksum?: string;
os: string; os: string;
php_version: string; php_version: string;
github: string; github: string;
@@ -73,6 +74,38 @@ interface ToolConfig {
packagist?: string; packagist?: string;
} }
/**
* Regex to match a checksum suffix in a tool release - tool:version@sha256:<hash>
*/
const checksum_suffix_regex = /@(sha256|sha512):([^@]*)$/;
/**
* Function to parse the checksum suffix in the tool release
*
* @param release
*/
export function extractChecksum(release: string): {
release: string;
checksum?: string;
error?: string;
} {
const matches = release.match(checksum_suffix_regex);
if (!matches) {
return {release};
}
release = release.slice(0, -matches[0].length);
const algo = matches[1];
const hash = matches[2].toLowerCase();
const hash_length = algo === 'sha256' ? 64 : 128;
if (!new RegExp(`^[a-f0-9]{${hash_length}}$`).test(hash)) {
return {
release,
error: `Invalid ${algo} checksum, expected ${hash_length} hexadecimal characters`
};
}
return {release, checksum: `${algo}:${hash}`};
}
/** /**
* GitHub reference object from API response * GitHub reference object from API response
*/ */
@@ -260,7 +293,9 @@ export async function filterList(tools_list: string[]): Promise<string[]> {
const regex_any = /^composer($|:.*)/; const regex_any = /^composer($|:.*)/;
const regex_valid = const regex_valid =
/^composer:?($|preview$|snapshot$|v?\d+(\.\d+)?$|v?\d+\.\d+\.\d+[\w-]*$)/; /^composer:?($|preview$|snapshot$|v?\d+(\.\d+)?$|v?\d+\.\d+\.\d+[\w-]*$)/;
const matches: string[] = tools_list.filter(tool => regex_valid.test(tool)); const matches: string[] = tools_list.filter(tool =>
regex_valid.test(tool.replace(checksum_suffix_regex, ''))
);
let composer = 'composer'; let composer = 'composer';
tools_list = tools_list.filter(tool => !regex_any.test(tool)); tools_list = tools_list.filter(tool => !regex_any.test(tool));
switch (true) { switch (true) {
@@ -335,7 +370,8 @@ export async function getPharUrl(data: ToolData): Promise<string> {
export async function addArchive(data: ToolData): Promise<string> { export async function addArchive(data: ToolData): Promise<string> {
return ( return (
(await utils.getCommand(data.os, 'tool')) + (await utils.getCommand(data.os, 'tool')) +
(await utils.joins(data.url, data.tool, data.version_parameter)) (await utils.joins(data.url, data.tool, data.version_parameter)) +
(data.checksum ? ' ' + data.checksum : '')
); );
} }
@@ -612,6 +648,8 @@ export async function getData(
const json_file: string = fs.readFileSync(json_file_path, 'utf8'); const json_file: string = fs.readFileSync(json_file_path, 'utf8');
const json_objects: Record<string, ToolConfig> = JSON.parse(json_file); const json_objects: Record<string, ToolConfig> = JSON.parse(json_file);
release = release.replace(/\s+/g, ''); release = release.replace(/\s+/g, '');
const checksum_data = extractChecksum(release);
release = checksum_data.release;
const parts: string[] = release.split(':'); const parts: string[] = release.split(':');
const tool = parts[0]; const tool = parts[0];
const version = parts[1]; const version = parts[1];
@@ -663,6 +701,8 @@ export async function getData(
function: config.function, function: config.function,
alias: config.alias alias: config.alias
}; };
data.checksum = checksum_data.checksum;
data.error = checksum_data.error;
data.release = await getRelease(release, data); data.release = await getRelease(release, data);
data.version = version data.version = version
? await getVersion(version, data) ? await getVersion(version, data)
@@ -688,6 +728,25 @@ export const functionRecord: Record<
wp_cli: addWPCLI wp_cli: addWPCLI
}; };
/**
* Function to check if a tool supports checksum verification
*
* Only tools downloaded as archives via add_tool support it, so composer
* packages and tools set up using custom package scripts do not.
*
* @param data
*/
export async function supportsChecksum(data: ToolData): Promise<boolean> {
switch (data.type) {
case 'phar':
return true;
case 'custom-function':
return !['pecl', 'dev_tools'].includes(data.function ?? '');
default:
return false;
}
}
/** /**
* Setup tools * Setup tools
* *
@@ -714,6 +773,14 @@ export async function addTools(
case data.error !== undefined: case data.error !== undefined:
script += await utils.addLog('$cross', data.tool, data.error, data.os); script += await utils.addLog('$cross', data.tool, data.error, data.os);
break; break;
case data.checksum !== undefined && !(await supportsChecksum(data)):
script += await utils.addLog(
'$cross',
data.tool,
'Checksum verification is not supported for ' + data.tool,
data.os
);
break;
case 'phar' === data.type: case 'phar' === data.type:
script += await addArchive(data); script += await addArchive(data);
break; break;