Files
craft-common-actions/tests/test_install_apt_packages.py
Dylan M. Taylor 80908a10c8 security(actions): harden apt package and resolve-version input handling (F-003, F-004)
- Disallow carriage returns and newlines upfront in resolve-version to prevent GitHub output-file protocol forgery
- Strictly validate generated build date against YYYY-MM-DD format
- Sanitize APP_TITLE against carriage returns and newlines in step summary
- Validate package tokens in install-apt-packages, rejecting options, path traversals, and injection payloads
- Terminate options with -- when invoking apt-get install
- Add unit tests for install-apt-packages validation and new test suite for resolve-version
2026-10-24 22:42:14 -04:00

99 lines
3.8 KiB
Python

"""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
ROOT = Path(__file__).resolve().parents[1]
ACTION = ROOT / 'actions/install-apt-packages/action.yml'
def steps():
return yaml.load(ACTION.read_text(), Loader=yaml.BaseLoader)['runs']['steps']
class InstallAptPackagesTests(unittest.TestCase):
def test_every_used_action_is_pinned_to_a_commit(self):
for step in steps():
if 'uses' in step:
with self.subTest(step=step.get('name')):
self.assertRegex(step['uses'], r'@[0-9a-f]{40}$')
def test_restore_and_save_use_the_same_key_and_path(self):
restore = next(s for s in steps() if s.get('id') == 'restore')
save = next(s for s in steps() if 'cache/save' in s.get('uses', ''))
self.assertEqual(restore['with']['key'], save['with']['key'])
self.assertEqual(restore['with']['path'], save['with']['path'])
def test_by_default_only_pushes_to_the_default_branch_save(self):
save = next(s for s in steps() if 'cache/save' in s.get('uses', ''))
condition = re.sub(r'\s+', ' ', save['if'])
self.assertIn("github.event_name == 'push'", condition)
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()