Skip to content

Commit 1ca88cc

Browse files
committed
fix: harden GitHub action entrypoints
1 parent 5914809 commit 1ca88cc

6 files changed

Lines changed: 194 additions & 61 deletions

File tree

.github/workflows/release.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ env:
1414
CARGO_TERM_COLOR: always
1515

1616
permissions:
17+
attestations: write
1718
contents: write
19+
id-token: write
1820

1921
jobs:
2022
create-release:
@@ -137,6 +139,11 @@ jobs:
137139
$hash = (Get-FileHash -Algorithm SHA256 $artifactPath).Hash.ToLower()
138140
"$hash ${{ matrix.asset_name }}" | Out-File -FilePath $checksumPath -Encoding ascii
139141
142+
- name: Attest release binary
143+
uses: actions/attest-build-provenance@v2
144+
with:
145+
subject-path: ./target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
146+
140147
- name: Upload Release Asset
141148
uses: actions/upload-release-asset@v1
142149
env:

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ You can also pass raw arguments via `args`, which overrides other inputs:
8181
```
8282

8383
Set `version` to download a specific release tag, or `install_only: true` to
84-
just add the binary to `PATH` without running it.
84+
just add the binary to `PATH` without running it. When you pin the action to a
85+
branch, commit SHA, or local checkout, set `version` explicitly; the action
86+
does not fall back to `latest` unless you opt into that mutable release.
8587

8688
To run a custom Atheris harness (see `python/examples/harness.py` for a starter
8789
template), use `mode: atheris` and provide a harness path:
@@ -94,6 +96,8 @@ template), use `mode: atheris` and provide a harness path:
9496
harness_args: "-max_total_time=60"
9597
```
9698

99+
`harness_args` is split on shell whitespace before being passed to the harness.
100+
97101
## Usage
98102

99103
### Generate a Single Pickle File

action.yml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ inputs:
1010
version:
1111
description: >
1212
Release tag to download (e.g., v1.2.3). Defaults to the action ref if it
13-
looks like a tag; otherwise uses the latest release.
13+
looks like a tag. For branch, SHA, or local checkouts, set this
14+
explicitly; use latest only if you want a mutable release.
1415
required: false
1516
default: ""
1617
args:
@@ -81,7 +82,7 @@ inputs:
8182
required: false
8283
default: ""
8384
harness_args:
84-
description: Arguments passed to the harness script (atheris mode).
85+
description: Whitespace-delimited arguments passed to the harness script (atheris mode).
8586
required: false
8687
default: ""
8788

@@ -97,11 +98,13 @@ runs:
9798
using: composite
9899
steps:
99100
- name: Validate mode
101+
env:
102+
INPUT_MODE: ${{ inputs.mode }}
100103
shell: bash
101104
run: |
102-
case "${{ inputs.mode }}" in
105+
case "$INPUT_MODE" in
103106
cli|atheris) ;;
104-
*) echo "Unsupported mode: ${{ inputs.mode }}" >&2; exit 1 ;;
107+
*) echo "Unsupported mode: $INPUT_MODE" >&2; exit 1 ;;
105108
esac
106109
107110
- id: install
@@ -150,13 +153,15 @@ runs:
150153
151154
- name: Setup Python (atheris)
152155
if: ${{ inputs.mode == 'atheris' }}
153-
uses: actions/setup-python@v5
156+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
154157
with:
155158
python-version: ${{ inputs.python_version }}
156159

157160
- name: Install Rust toolchain (atheris)
158161
if: ${{ inputs.mode == 'atheris' }}
159-
uses: dtolnay/rust-toolchain@stable
162+
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
163+
with:
164+
toolchain: stable
160165

161166
- name: Run Atheris harness
162167
if: ${{ inputs.mode == 'atheris' }}

scripts/action-atheris.sh

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,32 @@ if [[ -z "$harness_input" ]]; then
77
exit 1
88
fi
99

10-
workspace="${GITHUB_WORKSPACE:-$PWD}"
10+
workspace_input="${GITHUB_WORKSPACE:-$PWD}"
11+
if ! workspace="$(realpath "$workspace_input")"; then
12+
echo "Workspace not found: ${workspace_input}" >&2
13+
exit 1
14+
fi
15+
1116
if [[ "$harness_input" = /* ]]; then
12-
harness_path="$harness_input"
17+
harness_candidate="$harness_input"
18+
else
19+
harness_candidate="${workspace}/${harness_input}"
20+
fi
21+
22+
if ! harness_path="$(realpath "$harness_candidate")"; then
23+
echo "Harness not found: ${harness_candidate}" >&2
24+
exit 1
25+
fi
26+
27+
if [[ "$workspace" == "/" ]]; then
28+
workspace_prefix="/"
1329
else
14-
harness_path="${workspace}/${harness_input}"
30+
workspace_prefix="${workspace}/"
31+
fi
32+
33+
if [[ "${harness_path:0:${#workspace_prefix}}" != "$workspace_prefix" ]]; then
34+
echo "Harness must be within GITHUB_WORKSPACE: ${harness_path}" >&2
35+
exit 1
1536
fi
1637

1738
if [[ ! -f "$harness_path" ]]; then
@@ -23,18 +44,28 @@ python -m pip install --upgrade pip
2344
python -m pip install maturin atheris
2445

2546
wheel_dir="${RUNNER_TEMP:-/tmp}/pickle-fuzzer-wheels"
47+
rm -rf "$wheel_dir"
2648
mkdir -p "$wheel_dir"
2749

2850
pushd "${GITHUB_ACTION_PATH}" >/dev/null
2951
maturin build --release -o "$wheel_dir"
3052
popd >/dev/null
3153

32-
python -m pip install "${wheel_dir}"/*.whl
54+
shopt -s nullglob
55+
wheels=("${wheel_dir}"/*.whl)
56+
shopt -u nullglob
57+
58+
if [[ "${#wheels[@]}" -ne 1 ]]; then
59+
echo "Expected exactly one wheel in ${wheel_dir}, found ${#wheels[@]}" >&2
60+
exit 1
61+
fi
62+
63+
python -m pip install "${wheels[0]}"
3364

3465
if [[ -n "${INPUT_HARNESS_ARGS:-}" ]]; then
35-
echo "Running harness: ${harness_path} ${INPUT_HARNESS_ARGS}"
36-
# shellcheck disable=SC2086
37-
python "${harness_path}" ${INPUT_HARNESS_ARGS}
66+
IFS=$' \t\n' read -r -a harness_args <<< "${INPUT_HARNESS_ARGS}"
67+
echo "Running harness: ${harness_path} ${harness_args[*]}"
68+
python "${harness_path}" "${harness_args[@]}"
3869
else
3970
echo "Running harness: ${harness_path}"
4071
python "${harness_path}"

scripts/action-install.sh

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,53 @@
22
set -euo pipefail
33

44
repo="cisco-ai-defense/pickle-fuzzer"
5+
signer_workflow="${repo}/.github/workflows/release.yml"
56

67
version_input="${INPUT_VERSION:-}"
78
action_ref="${GITHUB_ACTION_REF:-}"
89

10+
is_safe_release_tag() {
11+
[[ "$1" =~ ^v[0-9A-Za-z][0-9A-Za-z._+-]*$ ]]
12+
}
13+
914
if [[ -n "$version_input" ]]; then
1015
version="$version_input"
11-
elif [[ -n "$action_ref" && "$action_ref" == v* ]]; then
12-
version="$action_ref"
16+
version_source="inputs.version"
17+
elif [[ -n "$action_ref" && ( "$action_ref" == v* || "$action_ref" == refs/tags/v* ) ]]; then
18+
version="${action_ref##refs/tags/}"
19+
version_source="GITHUB_ACTION_REF"
1320
else
14-
version="latest"
21+
echo "INPUT_VERSION is required when the action ref is not a release tag." >&2
22+
if [[ -n "$action_ref" ]]; then
23+
echo "Received GITHUB_ACTION_REF=${action_ref}." >&2
24+
else
25+
echo "Received an empty GITHUB_ACTION_REF (for example from a local checkout)." >&2
26+
fi
27+
echo "Set the action version input to an immutable release tag such as v1.2.3, or explicitly set it to latest if you accept a mutable release." >&2
28+
exit 1
29+
fi
30+
31+
if [[ "$version" != "latest" ]] && ! is_safe_release_tag "$version"; then
32+
echo "Unsupported release tag from ${version_source}: ${version}" >&2
33+
echo "Expected a release tag like v1, v1.2.3, or v1.2.3-rc1." >&2
34+
exit 1
35+
fi
36+
37+
if ! command -v gh >/dev/null 2>&1; then
38+
echo "GitHub CLI is required to resolve and verify release provenance." >&2
39+
exit 1
40+
fi
41+
42+
if [[ "$version" == "latest" ]]; then
43+
version="$(gh release view --repo "${repo}" --json tagName --jq '.tagName')"
44+
if [[ -z "$version" ]]; then
45+
echo "Failed to resolve the latest release tag for ${repo}." >&2
46+
exit 1
47+
fi
48+
if ! is_safe_release_tag "$version"; then
49+
echo "Resolved latest release tag is invalid: ${version}" >&2
50+
exit 1
51+
fi
1552
fi
1653

1754
os="${RUNNER_OS:-}"
@@ -61,52 +98,22 @@ else
6198
fi
6299
mkdir -p "$install_dir"
63100

64-
if [[ "$version" == "latest" ]]; then
65-
url="https://github.com/${repo}/releases/latest/download/${asset}"
66-
checksum_url="https://github.com/${repo}/releases/latest/download/${asset}.sha256"
67-
else
68-
url="https://github.com/${repo}/releases/download/${version}/${asset}"
69-
checksum_url="https://github.com/${repo}/releases/download/${version}/${asset}.sha256"
70-
fi
101+
url="https://github.com/${repo}/releases/download/${version}/${asset}"
71102

72103
echo "Downloading ${url}"
73104
curl -fsSL -o "${install_dir}/${bin_name}" "$url"
74105

75-
checksum_path="${install_dir}/${asset}.sha256"
76-
echo "Downloading ${checksum_url}"
77-
curl -fsSL -o "${checksum_path}" "$checksum_url"
78-
79-
expected_checksum="$(awk '{print $1}' "$checksum_path" | tr '[:upper:]' '[:lower:]')"
80-
if [[ -z "$expected_checksum" ]]; then
81-
echo "Checksum file is empty or invalid: ${checksum_path}" >&2
82-
exit 1
83-
fi
84-
85-
if command -v sha256sum >/dev/null 2>&1; then
86-
actual_checksum="$(sha256sum "${install_dir}/${bin_name}" | awk '{print $1}')"
87-
elif command -v shasum >/dev/null 2>&1; then
88-
actual_checksum="$(shasum -a 256 "${install_dir}/${bin_name}" | awk '{print $1}')"
89-
elif command -v certutil >/dev/null 2>&1; then
90-
file_native="${install_dir}/${bin_name}"
91-
if command -v cygpath >/dev/null 2>&1; then
92-
file_native="$(cygpath -w "${install_dir}/${bin_name}")"
93-
fi
94-
actual_checksum="$(certutil -hashfile "$file_native" SHA256 | awk 'NR==2 {print tolower($1)}')"
95-
else
96-
echo "No SHA-256 tool available to verify checksum." >&2
97-
exit 1
98-
fi
99-
100-
actual_checksum="$(echo "$actual_checksum" | tr '[:upper:]' '[:lower:]')"
101-
if [[ -z "$actual_checksum" ]]; then
102-
echo "Failed to compute SHA-256 checksum." >&2
103-
exit 1
104-
fi
105-
106-
if [[ "$expected_checksum" != "$actual_checksum" ]]; then
107-
echo "Checksum mismatch for ${bin_name}." >&2
108-
echo "Expected: ${expected_checksum}" >&2
109-
echo "Actual: ${actual_checksum}" >&2
106+
verify_args=(
107+
attestation verify
108+
"${install_dir}/${bin_name}"
109+
--repo "${repo}"
110+
--signer-workflow "${signer_workflow}"
111+
--source-ref "refs/tags/${version}"
112+
)
113+
114+
echo "Verifying release provenance for ${bin_name}"
115+
if ! gh "${verify_args[@]}" >/dev/null; then
116+
echo "Failed to verify GitHub release provenance for ${bin_name}." >&2
110117
exit 1
111118
fi
112119

scripts/action-run.sh

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,74 @@ is_true() {
88
esac
99
}
1010

11+
to_posix_path() {
12+
local path="${1:-}"
13+
if command -v cygpath >/dev/null 2>&1; then
14+
case "$path" in
15+
[A-Za-z]:[\\/]*|\\\\*)
16+
cygpath -u "$path"
17+
return
18+
;;
19+
*)
20+
printf '%s\n' "${path//\\//}"
21+
return
22+
;;
23+
esac
24+
fi
25+
26+
printf '%s\n' "$path"
27+
}
28+
29+
workspace_input="$(to_posix_path "${GITHUB_WORKSPACE:-$PWD}")"
30+
workspace="$(cd "$workspace_input" && pwd -P)"
31+
32+
resolve_workspace_path() {
33+
local input="$1"
34+
local label="$2"
35+
local normalized candidate parent leaf resolved_parent resolved
36+
37+
normalized="$(to_posix_path "$input")"
38+
if [[ "$normalized" = /* ]]; then
39+
candidate="$normalized"
40+
else
41+
candidate="${workspace}/${normalized}"
42+
fi
43+
44+
if [[ -e "$candidate" || -L "$candidate" ]]; then
45+
resolved="$(realpath "$candidate" 2>/dev/null)" || {
46+
echo "Failed to resolve ${label}: ${input}" >&2
47+
return 1
48+
}
49+
else
50+
parent="$(dirname "$candidate")"
51+
leaf="$(basename "$candidate")"
52+
resolved_parent="$(cd "$parent" && pwd -P)" || {
53+
echo "${label} parent directory does not exist: ${input}" >&2
54+
return 1
55+
}
56+
57+
if [[ "$leaf" == "." ]]; then
58+
resolved="$resolved_parent"
59+
else
60+
resolved="${resolved_parent}/${leaf}"
61+
fi
62+
fi
63+
64+
case "$resolved" in
65+
"$workspace"|"$workspace"/*)
66+
printf '%s\n' "$resolved"
67+
;;
68+
*)
69+
echo "${label} must stay within GITHUB_WORKSPACE: ${input}" >&2
70+
return 1
71+
;;
72+
esac
73+
}
74+
1175
if [[ -n "${INPUT_ARGS:-}" ]]; then
1276
echo "Running: pickle-fuzzer ${INPUT_ARGS}"
77+
# Preserve the existing argv splitting for args without allowing glob expansion.
78+
set -f
1379
# shellcheck disable=SC2086
1480
pickle-fuzzer ${INPUT_ARGS}
1581
exit 0
@@ -18,7 +84,8 @@ fi
1884
args=()
1985

2086
if [[ -n "${INPUT_OUTPUT_DIR:-}" ]]; then
21-
args+=(--dir "${INPUT_OUTPUT_DIR}")
87+
output_dir="$(resolve_workspace_path "${INPUT_OUTPUT_DIR}" "output_dir")"
88+
args+=(--dir "${output_dir}")
2289
fi
2390

2491
if [[ -n "${INPUT_SAMPLES:-}" ]]; then
@@ -33,6 +100,16 @@ if [[ -n "${INPUT_SEED:-}" ]]; then
33100
args+=(--seed "${INPUT_SEED}")
34101
fi
35102

103+
resolved_min_opcodes="${INPUT_MIN_OPCODES:-60}"
104+
resolved_max_opcodes="${INPUT_MAX_OPCODES:-300}"
105+
106+
if [[ "${resolved_min_opcodes}" =~ ^[0-9]+$ && "${resolved_max_opcodes}" =~ ^[0-9]+$ ]]; then
107+
if (( resolved_min_opcodes > resolved_max_opcodes )); then
108+
echo "min_opcodes (${resolved_min_opcodes}) cannot exceed max_opcodes (${resolved_max_opcodes})." >&2
109+
exit 1
110+
fi
111+
fi
112+
36113
if [[ -n "${INPUT_MIN_OPCODES:-}" ]]; then
37114
args+=(--min-opcodes "${INPUT_MIN_OPCODES}")
38115
fi
@@ -70,7 +147,9 @@ if [[ -n "${INPUT_OUTPUT_FILE:-}" ]]; then
70147
echo "Both output_dir and output_file were set; choose one." >&2
71148
exit 1
72149
fi
73-
args+=("${INPUT_OUTPUT_FILE}")
150+
output_file="$(resolve_workspace_path "${INPUT_OUTPUT_FILE}" "output_file")"
151+
# Keep the positional FILE separate from greedy variadic options.
152+
args+=(-- "${output_file}")
74153
fi
75154

76155
if [[ ${#args[@]} -eq 0 ]]; then

0 commit comments

Comments
 (0)