Merge pull request #82 from dylanmtaylor/security/action-inputs-validation

security(actions): harden apt package and resolve-version input handling (F-003, F-004)
This commit is contained in:
Dylan M. Taylor
2026-10-24 22:48:50 -04:00
committed by GitHub
4 changed files with 210 additions and 5 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ runs:
set -euo pipefail
# No globbing: an unquoted token like foo* must not expand to workspace filenames.
set -f
list=$(printf '%s\n' $PACKAGES | sort -u | tr '\n' ' ')
list=$(printf -- '%s\n' $PACKAGES | sort -u | tr '\n' ' ')
# Tokens become apt-get operands: a leading dash would be an option (-o runs commands).
for pkg in $list; do
if [[ ! "$pkg" =~ ^[A-Za-z0-9][A-Za-z0-9.+:=~_-]*$ ]]; then
+20 -4
View File
@@ -32,6 +32,11 @@ runs:
INPUT_VERSION: ${{ inputs.input-version }}
APP_TITLE: ${{ inputs.app-title }}
run: |
# Reject newlines or carriage returns upfront to prevent output-file protocol forgery
if [[ "$INPUT_VERSION" =~ [$'\r\n'] ]]; then
echo "::error::input-version must not contain carriage returns or newlines"
exit 1
fi
if [ -f packaging/env.sh ]; then
. packaging/env.sh
elif [ -f packaging/common.sh ]; then
@@ -39,17 +44,28 @@ runs:
fi
v="$INPUT_VERSION"
v="${v#v}"
v="${v:-$VERSION}"
# Whole-string match: grep would accept a multi-line input with one valid line.
v="${v:-${VERSION:-}}"
if [[ "$v" =~ [$'\r\n'] ]]; then
echo "::error::version must not contain carriage returns or newlines"
exit 1
fi
# Whole-string match against semver grammar
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$v" =~ $semver ]]; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
build_date="$(date -u +%Y-%m-%d)"
if [[ ! "$build_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "::error::generated build date '$build_date' is not valid YYYY-MM-DD"
exit 1
fi
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "date=$build_date"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
printf '### %s v%s (`%s`)\n' "$APP_TITLE" "$v" "${GITHUB_SHA::9}" >>"$GITHUB_STEP_SUMMARY"
clean_title="${APP_TITLE//$'\r'/}"
clean_title="${clean_title//$'\n'/ }"
printf '### %s v%s (`%s`)\n' "$clean_title" "$v" "${GITHUB_SHA::9}" >>"$GITHUB_STEP_SUMMARY"
+60
View File
@@ -1,6 +1,9 @@
"""The cached apt install action: pinned actions, and PR runs that never write the cache."""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
import yaml
@@ -33,6 +36,63 @@ class InstallAptPackagesTests(unittest.TestCase):
self.assertIn('github.ref_name == github.event.repository.default_branch', condition)
self.assertIn("cache-hit != 'true'", condition)
def test_install_step_uses_option_terminator(self):
install_step = next(s for s in steps() if s.get('name') == 'Install packages')
self.assertIn('install -y --no-install-recommends -- $PACKAGE_LIST', install_step['run'])
def _run_key_step(self, packages):
key_step = next(s for s in steps() if s.get('id') == 'key')
with tempfile.TemporaryDirectory() as tmpdir:
out_file = Path(tmpdir) / "output"
res = subprocess.run(
['bash', '-euo', 'pipefail', '-c', key_step['run']],
env={**os.environ, 'PACKAGES': packages, 'GITHUB_OUTPUT': str(out_file)},
capture_output=True,
text=True,
)
outputs = {}
if out_file.exists():
for line in out_file.read_text().splitlines():
if '=' in line:
k, v = line.split('=', 1)
outputs[k] = v
return res, outputs
def test_valid_packages_accepted(self):
cases = [
("curl", "curl "),
("curl git build-essential", "build-essential curl git "),
("libx11-dev:amd64 pkg-config=1.8.0-1", "libx11-dev:amd64 pkg-config=1.8.0-1 "),
("curl\ngit\nbuild-essential", "build-essential curl git "),
]
for packages, expected_list in cases:
with self.subTest(packages=packages):
res, outputs = self._run_key_step(packages)
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
self.assertEqual(outputs.get('list'), expected_list)
def test_invalid_package_names_rejected(self):
invalid_cases = [
"-o APT::Update=1",
"--config",
"-y",
"../traversal.deb",
"/tmp/evil.deb",
"curl; touch injected",
"curl && touch injected",
"curl | touch injected",
"$(touch injected)",
"`touch injected`",
"curl>injected",
"",
" \n \t ",
]
for bad_pkg in invalid_cases:
with self.subTest(bad_pkg=bad_pkg):
res, _ = self._run_key_step(bad_pkg)
self.assertNotEqual(res.returncode, 0)
self.assertIn("::error::install-apt-packages:", res.stdout + res.stderr)
if __name__ == '__main__':
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Unit tests for actions/resolve-version/action.yml."""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
ACTION = ROOT / 'actions/resolve-version/action.yml'
class ResolveVersionTests(unittest.TestCase):
def setUp(self):
self.data = yaml.safe_load(ACTION.read_text())
self.step = self.data['runs']['steps'][0]
def _run_action(self, input_version="", version_env="", app_title="Test App", env_sh=None):
with tempfile.TemporaryDirectory() as tmpdir:
work = Path(tmpdir)
if env_sh:
pkg = work / "packaging"
pkg.mkdir()
(pkg / "env.sh").write_text(env_sh)
output_file = work / "output"
summary_file = work / "summary"
env = {
**os.environ,
'INPUT_VERSION': input_version,
'VERSION': version_env,
'APP_TITLE': app_title,
'GITHUB_OUTPUT': str(output_file),
'GITHUB_STEP_SUMMARY': str(summary_file),
'GITHUB_SHA': 'abcdef1234567890',
}
res = subprocess.run(
['bash', '-euo', 'pipefail', '-c', self.step['run']],
cwd=work,
env=env,
capture_output=True,
text=True,
)
outputs = {}
if output_file.exists():
for line in output_file.read_text().splitlines():
if '=' in line:
k, v = line.split('=', 1)
outputs[k] = v
summary = summary_file.read_text() if summary_file.exists() else ""
return res, outputs, summary
def test_valid_versions(self):
cases = [
("1.2.3", "1.2.3", "false"),
("v1.2.3", "1.2.3", "false"),
("0.1.0-alpha.1", "0.1.0-alpha.1", "true"),
("2.0.0-rc-1", "2.0.0-rc-1", "true"),
("10.20.30", "10.20.30", "false"),
]
for input_ver, expected_ver, expected_pre in cases:
with self.subTest(input_ver=input_ver):
res, outputs, summary = self._run_action(input_version=input_ver)
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
self.assertEqual(outputs.get('version'), expected_ver)
self.assertEqual(outputs.get('prerelease'), expected_pre)
self.assertRegex(outputs.get('date', ''), r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$')
self.assertIn(f"v{expected_ver}", summary)
def test_app_title_crlf_stripped_from_summary(self):
res, outputs, summary = self._run_action(
input_version="1.2.3",
app_title="My\r\nApp\nTitle",
)
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
self.assertNotIn("\r", summary)
self.assertEqual(summary.count("\n"), 1)
self.assertIn("My App Title", summary)
def test_fallback_to_version_env_and_env_sh(self):
res, outputs, _ = self._run_action(version_env="3.4.5")
self.assertEqual(res.returncode, 0)
self.assertEqual(outputs.get('version'), "3.4.5")
res, outputs, _ = self._run_action(env_sh='VERSION="4.5.6"\n')
self.assertEqual(res.returncode, 0)
self.assertEqual(outputs.get('version'), "4.5.6")
def test_newlines_and_cr_rejected(self):
bad_versions = [
"1.2.3\ndate=2026-01-01",
"1.2.3\r\nversion=fake",
"\n1.2.3",
"1.2.3\n",
"1.2.3\r",
"1.2.3\r\ndate=2026-01-01",
]
for bad_ver in bad_versions:
with self.subTest(bad_ver=repr(bad_ver)):
res, outputs, _ = self._run_action(input_version=bad_ver)
self.assertNotEqual(res.returncode, 0)
self.assertIn("must not contain carriage returns or newlines", res.stdout + res.stderr)
self.assertEqual(outputs, {})
def test_invalid_semver_rejected(self):
invalid_versions = [
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"foo",
"1.2.3; touch injected",
"$(touch injected)",
"`touch injected`",
"1.2.3-",
"1.2.3+",
]
for bad_ver in invalid_versions:
with self.subTest(bad_ver=bad_ver):
res, outputs, _ = self._run_action(input_version=bad_ver)
self.assertNotEqual(res.returncode, 0)
self.assertIn("is not a version like 1.2.3 or 1.2.3-rc.1", res.stdout + res.stderr)
self.assertEqual(outputs, {})
if __name__ == '__main__':
unittest.main()