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

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.
- 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.
```yaml

View File

@@ -222,17 +222,41 @@ describe('Tools tests', () => {
});
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'}
release | expected_release | checksum | error
${'tool:1.2.3'} | ${'tool:1.2.3'} | ${undefined} | ${undefined}
${'tool:1.2.3@sha256:' + 'a'.repeat(64)} | ${'tool:1.2.3'} | ${'sha256:' + 'a'.repeat(64)} | ${undefined}
${'tool:1.2.3@sha256:' + 'A'.repeat(64)} | ${'tool:1.2.3'} | ${'sha256:' + 'a'.repeat(64)} | ${undefined}
${'tool:1.2.3@sha512:' + 'b'.repeat(128)} | ${'tool:1.2.3'} | ${'sha512:' + 'b'.repeat(128)} | ${undefined}
${'composer:2.9.8@sha256:' + 'c'.repeat(64)} | ${'composer:2.9.8'} | ${'sha256:' + 'c'.repeat(64)} | ${undefined}
${'tool:1.2.3@sha256:xyz'} | ${'tool:1.2.3'} | ${undefined} | ${'Invalid sha256 checksum, expected 64 hexadecimal characters'}
${'tool:1.2.3@sha256:' + 'a'.repeat(63)} | ${'tool:1.2.3'} | ${undefined} | ${'Invalid sha256 checksum, expected 64 hexadecimal characters'}
${'tool:1.2.3@sha512:' + 'b'.repeat(64)} | ${'tool:1.2.3'} | ${undefined} | ${'Invalid sha512 checksum, expected 128 hexadecimal characters'}
${'tool:1.0@dev'} | ${'tool:1.0@dev'} | ${undefined} | ${undefined}
`(
'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}) => {
expect(await tools.filterList(input_list.split(', '))).toStrictEqual(
filtered_list.split(', ')
@@ -304,6 +328,23 @@ describe('Tools tests', () => {
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`
os | release | scope | script
${'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);
});
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`
tools_csv | token | script
${'cs2pr:1.2'} | ${'invalid_token'} | ${'add_log "$cross" "cs2pr" "Invalid token"'}

2
dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -229,8 +229,18 @@ Function Add-Tool() {
[Parameter(Position = 2, Mandatory = $false)]
$ver_param,
[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") {
$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
}
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)
if (((Get-ChildItem -Path $bin_dir/* | Where-Object Name -Match "^$escaped_tool(\.exe|\.phar)?$").Count -gt 0)) {
$bat_content = @()

View File

@@ -201,8 +201,15 @@ add_tool() {
url=$1
tool=$2
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
skip_composer_github_auth="${4:-false}"
[[ "$skip_composer_github_auth" =~ ^sha(256|512): ]] && skip_composer_github_auth=false
fi
tool_path="$tool_path_dir/$tool"
if ! [ -d "$tool_path_dir" ]; then
@@ -235,6 +242,10 @@ add_tool() {
fi
sudo rm -f "$tool_path.bak"
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
add_tools_helper "$tool"
tool_version=$(get_tool_version "$tool" "$ver_param")
@@ -244,7 +255,9 @@ add_tool() {
if [ "$tool" = "composer" ]; then
export fail_fast=true
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[*]}"
else
add_log "$cross" "$tool" "Could not setup $tool"

View File

@@ -122,6 +122,24 @@ get_sha256() {
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.
# mode: -s pipe to stdout, -v save file and return status code
# execute: -e save file as executable

View File

@@ -27,6 +27,7 @@ type ToolFunction =
export interface ToolData {
tool: string;
version: string;
checksum?: string;
os: string;
php_version: string;
github: string;
@@ -73,6 +74,38 @@ interface ToolConfig {
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
*/
@@ -260,7 +293,9 @@ export async function filterList(tools_list: string[]): Promise<string[]> {
const regex_any = /^composer($|:.*)/;
const regex_valid =
/^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';
tools_list = tools_list.filter(tool => !regex_any.test(tool));
switch (true) {
@@ -335,7 +370,8 @@ export async function getPharUrl(data: ToolData): Promise<string> {
export async function addArchive(data: ToolData): Promise<string> {
return (
(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_objects: Record<string, ToolConfig> = JSON.parse(json_file);
release = release.replace(/\s+/g, '');
const checksum_data = extractChecksum(release);
release = checksum_data.release;
const parts: string[] = release.split(':');
const tool = parts[0];
const version = parts[1];
@@ -663,6 +701,8 @@ export async function getData(
function: config.function,
alias: config.alias
};
data.checksum = checksum_data.checksum;
data.error = checksum_data.error;
data.release = await getRelease(release, data);
data.version = version
? await getVersion(version, data)
@@ -688,6 +728,25 @@ export const functionRecord: Record<
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
*
@@ -714,6 +773,14 @@ export async function addTools(
case data.error !== undefined:
script += await utils.addLog('$cross', data.tool, data.error, data.os);
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:
script += await addArchive(data);
break;