diff --git a/.github/actions/pulumi-guarded/action.yml b/.github/actions/pulumi-guarded/action.yml index d4026b3..6c6662e 100644 --- a/.github/actions/pulumi-guarded/action.yml +++ b/.github/actions/pulumi-guarded/action.yml @@ -28,6 +28,19 @@ inputs: required: false default: "" + output-file: + description: >- + Optional workspace-relative path for scanned stdout instead of logging it, + independent of work-dir. Absolute paths, .. components, directories, symlink + targets and parents resolving outside GITHUB_WORKSPACE are rejected. The + parent must exist. An existing file is removed before running. Only + successful output with clean stdout and stderr scans is published atomically + with mode 0600. Clean stderr is logged separately. Use command stack with + args 'output ' or preview with '--json'; the same invocation refusals + apply in every mode, including config get and --show-secrets. + required: false + default: "" + runs: using: composite steps: @@ -38,6 +51,7 @@ runs: PULUMI_GUARD_COMMAND: ${{ inputs.command }} PULUMI_GUARD_STACK: ${{ inputs.stack-name }} PULUMI_GUARD_ARGS: ${{ inputs.args }} + PULUMI_GUARD_OUTPUT_FILE: ${{ inputs.output-file }} PULUMI_GUARD_SCRIPT: ${{ github.action_path }}/../../scripts/pulumi-guarded.sh run: | set -euo pipefail diff --git a/.github/scripts/pulumi-guarded.sh b/.github/scripts/pulumi-guarded.sh index ae1858a..b40c5a6 100755 --- a/.github/scripts/pulumi-guarded.sh +++ b/.github/scripts/pulumi-guarded.sh @@ -10,6 +10,8 @@ # Config reads, ESC plaintext reads and explicit secret output are refused before invocation. # Exit status: 1 when admission or scanning fails (wins over Pulumi's status); otherwise # Pulumi's own status. Pattern scanning cannot identify all unarmoured secrets. +# PULUMI_GUARD_OUTPUT_FILE optionally publishes clean stdout as a private file instead +# of logging it; stderr is scanned separately. set -euo pipefail umask 077 @@ -31,13 +33,9 @@ admit_command() { return 0 } -if ! admit_command "$@"; then - echo "::error title=Pulumi output withheld::Pulumi invocation refused." - exit 1 -fi - output="$(mktemp)" -trap 'rm -f "$output"' EXIT +temporary_files=("$output") +trap 'rm -f -- "${temporary_files[@]}"' EXIT trap 'exit 143' TERM trap 'exit 130' INT @@ -55,26 +53,103 @@ CLASSES=( # grep exit: 0 match, 1 none, anything else = not checked. Prints the status. match_status() { local rc=0 - grep -Eq -e "$1" "$output" || rc=$? + grep -Eq -e "$1" "$2" || rc=$? echo "$rc" } +invalid_path() { + echo "::error title=Invalid Pulumi output path::Use a workspace-relative file with an existing parent inside GITHUB_WORKSPACE, without .. components, directories or symlink targets." >&2 + exit 1 +} + +prepare_destination() { + local requested="$1" parent root + [[ "$requested" =~ [^[:space:]] ]] || invalid_path + case "/$requested/" in + //*|*/../*) invalid_path ;; + esac + [ -n "${GITHUB_WORKSPACE:-}" ] || invalid_path + requested="$GITHUB_WORKSPACE/$requested" + [ ! -d "$requested" ] && [ ! -L "$requested" ] || invalid_path + root="$(cd -- "$GITHUB_WORKSPACE" 2>/dev/null && pwd -P)" || invalid_path + parent="$(cd -- "$(dirname -- "$requested")" 2>/dev/null && pwd -P)" || invalid_path + case "$parent/" in + "$root/"*) ;; + *) invalid_path ;; + esac + workspace_root="$root" + destination_parent="$parent" + destination="$parent/$(basename -- "$requested")" + rm -f -- "$destination" +} + +publication_path_is_safe() { + local parent + parent="$(cd -- "$destination_parent" 2>/dev/null && pwd -P)" || return 1 + [ "$parent" = "$destination_parent" ] || return 1 + case "$parent/" in + "$workspace_root/"*) ;; + *) return 1 ;; + esac + [ ! -L "$destination" ] && [ ! -d "$destination" ] +} + +publication_failed() { + echo "::error title=Pulumi output withheld::Pulumi output file could not be published." >&2 + exit 1 +} + +publish_destination() { + local staged + publication_path_is_safe || publication_failed + staged="$(mktemp "$destination_parent/.pulumi-guarded.XXXXXXXX" 2>/dev/null)" || publication_failed + temporary_files+=("$staged") + cat "$output" > "$staged" || publication_failed + chmod 0600 "$staged" 2>/dev/null || publication_failed + publication_path_is_safe || publication_failed + mv -f -T -- "$staged" "$destination" 2>/dev/null || publication_failed +} + +destination="" +if [ -n "${PULUMI_GUARD_OUTPUT_FILE:-}" ]; then + prepare_destination "$PULUMI_GUARD_OUTPUT_FILE" +fi + +# Prepare the destination and remove stale output before admission, so refused +# commands cannot leave a previous result behind. Admission itself is unchanged. +if ! admit_command "$@"; then + echo "::error title=Pulumi output withheld::Pulumi invocation refused." + exit 1 +fi + command_label="${1:-}" if ! [[ "$command_label" =~ ^[a-zA-Z][a-zA-Z0-9-]*$ ]]; then command_label="command" fi status=0 -pulumi "$@" > "$output" 2>&1 || status=$? +scan_files=("$output") +if [ -n "$destination" ]; then + stderr="$(mktemp)" + temporary_files+=("$stderr") + scan_files+=("$stderr") + pulumi "$@" > "$output" 2> "$stderr" || status=$? +else + pulumi "$@" > "$output" 2>&1 || status=$? +fi matched=() unchecked=0 for entry in "${CLASSES[@]}"; do name="${entry%%|*}" - case "$(match_status "${entry#*|}")" in - 0) matched+=("$name") ;; - 1) ;; - *) unchecked=1 ;; - esac + found=0 + for file in "${scan_files[@]}"; do + case "$(match_status "${entry#*|}" "$file")" in + 0) found=1 ;; + 1) ;; + *) unchecked=1 ;; + esac + done + if [ "$found" -eq 1 ]; then matched+=("$name"); fi done if [ "${#matched[@]}" -gt 0 ] || [ "$unchecked" -ne 0 ]; then @@ -86,5 +161,12 @@ if [ "${#matched[@]}" -gt 0 ] || [ "$unchecked" -ne 0 ]; then exit 1 fi -cat "$output" +if [ -n "$destination" ]; then + cat "$stderr" >&2 + if [ "$status" -eq 0 ]; then + publish_destination + fi +else + cat "$output" +fi exit "$status" diff --git a/.github/scripts/tests/fixtures/pulumi-guarded/pulumi b/.github/scripts/tests/fixtures/pulumi-guarded/pulumi index 7839074..33610b8 100755 --- a/.github/scripts/tests/fixtures/pulumi-guarded/pulumi +++ b/.github/scripts/tests/fixtures/pulumi-guarded/pulumi @@ -1,5 +1,19 @@ #!/usr/bin/env bash # Fake pulumi for specs: prints PULUMI_FAKE_OUTPUT, records its arguments, exits PULUMI_FAKE_STATUS. +set -euo pipefail printf '%s\n' "$*" > "${PULUMI_FAKE_ARGS:-/dev/null}" +case "${PULUMI_FAKE_MUTATION:-}" in + parent-symlink) + mv -f -T -- "$PULUMI_FAKE_PARENT" "$PULUMI_FAKE_PARENT.saved" + ln -s -- "$PULUMI_FAKE_TARGET" "$PULUMI_FAKE_PARENT" ;; + destination-symlink) + ln -s -- "$PULUMI_FAKE_TARGET" "$PULUMI_FAKE_DESTINATION" ;; + destination-directory) + mkdir -- "$PULUMI_FAKE_DESTINATION" ;; +esac +if [ -n "${PULUMI_FAKE_SIGNAL:-}" ]; then + kill -s "$PULUMI_FAKE_SIGNAL" "$PPID" +fi cat "${PULUMI_FAKE_OUTPUT:-/dev/null}" +cat "${PULUMI_FAKE_STDERR:-/dev/null}" >&2 exit "${PULUMI_FAKE_STATUS:-0}" diff --git a/.github/scripts/tests/pulumi-guarded.test.py b/.github/scripts/tests/pulumi-guarded.test.py index edb04dc..6d23c3a 100644 --- a/.github/scripts/tests/pulumi-guarded.test.py +++ b/.github/scripts/tests/pulumi-guarded.test.py @@ -5,6 +5,7 @@ import hashlib import os import subprocess +import sys import tempfile import unittest @@ -54,6 +55,20 @@ def setUp(self): self.work = Path(self._dir.name) self.tmp = self.work / "tmp" self.tmp.mkdir() + self.platform_dirs = [] + if sys.platform == "darwin": + # BSD mv lacks -T. Linux CI exercises the real GNU command; locally + # os.replace supplies the same no-target-directory rename semantics. + platform_bin = self.work / "platform-bin" + platform_bin.mkdir() + mv = platform_bin / "mv" + mv.write_text( + f'#!{sys.executable}\nimport os, sys\n' + 'if len(sys.argv) != 6 or sys.argv[1:4] != ["-f", "-T", "--"]: sys.exit(2)\n' + 'os.replace(sys.argv[4], sys.argv[5])\n' + ) + mv.chmod(0o755) + self.platform_dirs.append(platform_bin) def fixture(self, name): return FIXTURES / name @@ -66,7 +81,7 @@ def generated(self, name): def run_guard(self, output, status=0, args=("preview", "--stack", "production", "--yes"), extra=None, path_dirs=(), action=None): recorded = self.work / "args.txt" env = { - "PATH": os.pathsep.join([*map(str, path_dirs), str(FIXTURES), os.environ["PATH"]]), + "PATH": os.pathsep.join([*map(str, (*path_dirs, *self.platform_dirs)), str(FIXTURES), os.environ["PATH"]]), "TMPDIR": str(self.tmp), "PULUMI_FAKE_OUTPUT": str(output), "PULUMI_FAKE_STATUS": str(status), @@ -442,6 +457,7 @@ def test_no_input_expression_in_run_body(self): def test_inputs_reach_the_script_through_env(self): self.assertEqual(self.step["env"]["PULUMI_GUARD_COMMAND"], "${{ inputs.command }}") + self.assertEqual(self.step["env"]["PULUMI_GUARD_OUTPUT_FILE"], "${{ inputs.output-file }}") self.assertEqual(self.step["working-directory"], "${{ inputs.work-dir }}") def test_runs_the_shared_script(self): @@ -478,9 +494,212 @@ def test_run_body_without_stack_or_args(self): def test_declares_the_interface(self): self.assertEqual( set(self.action["inputs"]), - {"command", "stack-name", "work-dir", "args"}, + {"command", "stack-name", "work-dir", "args", "output-file"}, ) +class OutputFileAdmissionSpecs(AdmissionSpecs): + def run_guard(self, *args, extra=None, **kwargs): + (self.work / "result.txt").write_text("stale") + return super().run_guard(*args, extra={ + "GITHUB_WORKSPACE": str(self.work), + "PULUMI_GUARD_OUTPUT_FILE": "result.txt", + **(extra or {}), + }, **kwargs) + + def assert_refused(self, run): + super().assert_refused(run) + self.assertFalse((self.work / "result.txt").exists()) + + +class OutputFileSpecs(GuardCase): + def run_action(self, stdout="clean.txt", stderr=None, status=0, destination="result.txt", + command="stack", args="output clusterId", output_mode=True, path_dirs=(), extra=None): + step = yaml.safe_load(ACTION.read_text())["runs"]["steps"][0] + self.destination = self.work / destination + recorded = self.work / "args.txt" + env = { + "PATH": os.pathsep.join([*map(str, (*path_dirs, *self.platform_dirs)), str(FIXTURES), os.environ["PATH"]]), + "TMPDIR": str(self.tmp), + "RUNNER_TEMP": str(self.tmp), + "GITHUB_WORKSPACE": str(self.work), + "PULUMI_GUARD_COMMAND": command, + "PULUMI_GUARD_STACK": "", + "PULUMI_GUARD_ARGS": args, + "PULUMI_GUARD_SCRIPT": str(SCRIPT), + "PULUMI_FAKE_OUTPUT": str(self.fixture(stdout)), + "PULUMI_FAKE_STDERR": str(self.fixture(stderr)) if stderr else "/dev/null", + "PULUMI_FAKE_STATUS": str(status), + "PULUMI_FAKE_ARGS": str(recorded), + **(extra or {}), + } + if output_mode: + env["PULUMI_GUARD_OUTPUT_FILE"] = str(destination) + process = subprocess.run(["bash", "-c", step["run"]], env=env, + cwd=self.tmp, capture_output=True, text=True, check=False) + return Run(process, recorded, self.tmp) + + def test_absent_input_keeps_stdout_and_stderr_in_log(self): + run = self.run_action(stderr="clean.txt", output_mode=False) + self.assertEqual(run.code, 0, run.text) + self.assertEqual(run.text, self.fixture("clean.txt").read_text() * 2) + self.assertFalse(self.destination.exists()) + self.assert_temp_clean(run) + + def test_clean_stdout_is_exact_private_file_not_log(self): + run = self.run_action() + self.assertEqual(run.code, 0, run.text) + self.assertEqual(run.text, "") + self.assertEqual(self.destination.read_bytes(), self.fixture("clean.txt").read_bytes()) + self.assertEqual(self.destination.stat().st_mode & 0o777, 0o600) + self.assert_temp_clean(run) + self.assertEqual(list(self.work.glob(".pulumi-guarded.*")), []) + + def test_stdout_bytes_and_empty_output_replace_stale_file(self): + source = self.work / "stdout.txt" + for content in (b"TESTONLY\x00exact\r\nno trailing newline", b""): + with self.subTest(content=content): + source.write_bytes(content) + (self.work / "result.txt").write_text("stale") + run = self.run_action(stdout=source) + self.assertEqual(run.code, 0, run.text) + self.assertEqual(run.text, "") + self.assertEqual(self.destination.read_bytes(), content) + self.assertEqual(self.destination.stat().st_mode & 0o777, 0o600) + self.assert_temp_clean(run) + + def test_clean_stderr_is_logged_separately(self): + run = self.run_action(stderr="clean.txt") + self.assertEqual(run.code, 0, run.text) + self.assertEqual(run.process.stdout, "") + self.assertEqual(run.process.stderr, self.fixture("clean.txt").read_text()) + self.assertEqual(self.destination.read_bytes(), self.fixture("clean.txt").read_bytes()) + self.assert_temp_clean(run) + + def test_every_secret_class_in_either_stream_withholds_stale_file(self): + samples = {label: self.fixture(name) for label, name in FILES.items()} + samples.update({label: self.generated(label) for label in GENERATED}) + for label, sample in samples.items(): + for stream in ("stdout", "stderr"): + with self.subTest(label=label, stream=stream): + (self.work / "result.txt").write_text("stale") + run = self.run_action(**{stream: sample}) + self.assert_withheld(run, label) + self.assertFalse(self.destination.exists()) + self.assert_temp_clean(run) + + def test_failure_removes_stale_file_and_preserves_exit_code(self): + (self.work / "result.txt").write_text("stale") + run = self.run_action(status=23, stderr="clean.txt") + self.assertEqual(run.code, 23, run.text) + self.assertEqual(run.process.stdout, "") + self.assertEqual(run.process.stderr, self.fixture("clean.txt").read_text()) + self.assertFalse(self.destination.exists()) + self.assert_temp_clean(run) + + def test_scan_error_in_either_stream_withholds_stale_file(self): + fake = self.work / "bin" + fake.mkdir() + grep = fake / "grep" + stdout, stderr = self.work / "stdout.txt", self.work / "stderr.txt" + stdout.write_text("TESTONLYstdout") + stderr.write_text("TESTONLYstderr") + for stream in ("stdout", "stderr"): + with self.subTest(stream=stream): + grep.write_text(f'#!/usr/bin/env bash\n[ "$(cat "$4")" = TESTONLY{stream} ] && exit 2\nexit 1\n') + grep.chmod(0o755) + (self.work / "result.txt").write_text("stale") + run = self.run_action(stdout=stdout, stderr=stderr, path_dirs=[fake]) + self.assert_withheld(run) + self.assertIn("could not be checked", run.text) + self.assertFalse(self.destination.exists()) + self.assert_temp_clean(run) + + def test_read_commands_reach_pulumi(self): + for command, args in (("stack", "output clusterId"), ("preview", "--json")): + with self.subTest(command=command): + run = self.run_action(command=command, args=args) + self.assertEqual(run.code, 0, run.text) + self.assertEqual(run.args.read_text().strip(), f"{command} {args}") + + def test_relative_path_is_workspace_relative_not_work_dir(self): + run = self.run_action(destination="relative.txt") + self.assertEqual(run.code, 0, run.text) + self.assertTrue((self.work / "relative.txt").is_file()) + self.assertFalse((self.tmp / "relative.txt").exists()) + self.assert_temp_clean(run) + + def assert_publication_race_fails(self, mutation): + outside_dir = tempfile.TemporaryDirectory() + self.addCleanup(outside_dir.cleanup) + outside = Path(outside_dir.name) + protected = outside / "protected.txt" + protected.write_text("untouched") + parent = self.work / "publish" + parent.mkdir() + destination = parent / "result.txt" + destination.write_text("stale") + run = self.run_action(destination="publish/result.txt", extra={ + "PULUMI_FAKE_MUTATION": mutation, + "PULUMI_FAKE_PARENT": str(parent), + "PULUMI_FAKE_DESTINATION": str(destination), + "PULUMI_FAKE_TARGET": str(outside if mutation == "parent-symlink" else protected), + }) + self.assertEqual(run.code, 1, run.text) + self.assertTrue(run.args.exists(), "the mutation must happen during Pulumi") + self.assertEqual(run.process.stdout, "") + self.assertEqual(run.process.stderr, ANNOTATION + "Pulumi output file could not be published.\n") + self.assertEqual(protected.read_text(), "untouched") + self.assertEqual(list(outside.iterdir()), [protected]) + self.assertEqual(list(self.work.rglob(".pulumi-guarded.*")), []) + self.assert_temp_clean(run) + return parent, destination + + def test_parent_swapped_for_outside_symlink_before_publish_is_refused(self): + parent, destination = self.assert_publication_race_fails("parent-symlink") + self.assertTrue(parent.is_symlink()) + self.assertFalse(destination.exists()) + self.assertFalse((self.work / "publish.saved" / "result.txt").exists()) + + def test_destination_swapped_for_symlink_before_publish_is_refused(self): + _, destination = self.assert_publication_race_fails("destination-symlink") + self.assertTrue(destination.is_symlink()) + + def test_destination_swapped_for_directory_before_publish_is_refused(self): + _, destination = self.assert_publication_race_fails("destination-directory") + self.assertTrue(destination.is_dir()) + self.assertEqual(list(destination.iterdir()), []) + + def test_signals_remove_stale_output_and_all_capture_files(self): + for signal, status in (("TERM", 143), ("INT", 130)): + with self.subTest(signal=signal): + (self.work / "result.txt").write_text("stale") + run = self.run_action(extra={"PULUMI_FAKE_SIGNAL": signal}) + self.assertEqual(run.code, status, run.text) + self.assertEqual(run.text, "") + self.assertFalse(self.destination.exists()) + self.assert_temp_clean(run) + self.assertEqual(list(self.work.rglob(".pulumi-guarded.*")), []) + + def test_invalid_paths_are_rejected_before_running(self): + alias = self.work / "alias" + alias.symlink_to(self.work.parent, target_is_directory=True) + target = self.work / "target.txt" + target.write_text("untouched") + (self.work / "link.txt").symlink_to(target) + (self.work / "dangling.txt").symlink_to(self.work / "missing.txt") + for destination in (self.work, self.work / "result.txt", self.tmp / "result.txt", + "../outside.txt", "tmp/../result.txt", "alias/outside.txt", + "link.txt", "dangling.txt", "tmp", "missing/result", ".", " "): + with self.subTest(destination=destination): + run = self.run_action(destination=destination) + self.assertEqual(run.code, 1, run.text) + self.assertIn("Invalid Pulumi output path", run.text) + self.assertFalse(run.args.exists()) + self.assert_temp_clean(run) + self.assertEqual(target.read_text(), "untouched") + self.assertFalse((self.work / "missing.txt").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/verify-pulumi-guarded.yml b/.github/workflows/verify-pulumi-guarded.yml index fa88825..9087e16 100644 --- a/.github/workflows/verify-pulumi-guarded.yml +++ b/.github/workflows/verify-pulumi-guarded.yml @@ -62,6 +62,39 @@ jobs: expected='up --stack selftest --yes --skip-preview --non-interactive --refresh --diff' actual="$(cat "$ARGS_FILE")" [ "$actual" = "$expected" ] || { echo "::error title=verify-pulumi-guarded::unexpected arguments"; exit 1; } + - name: Clean stdout is published privately + uses: ./.github/actions/pulumi-guarded + env: + PULUMI_FAKE_OUTPUT: ${{ github.workspace }}/.github/scripts/tests/fixtures/pulumi-guarded/clean.txt + with: + command: stack + args: output clusterId + output-file: pulumi-result.txt + - name: Check exact file content and permissions + env: + RESULT: ${{ github.workspace }}/pulumi-result.txt + EXPECTED: ${{ github.workspace }}/.github/scripts/tests/fixtures/pulumi-guarded/clean.txt + run: | + cmp "$RESULT" "$EXPECTED" + [ "$(stat -c '%a' "$RESULT")" = 600 ] + - name: Secret stderr withholds the file + id: file-leak + continue-on-error: true + uses: ./.github/actions/pulumi-guarded + env: + PULUMI_FAKE_OUTPUT: ${{ github.workspace }}/.github/scripts/tests/fixtures/pulumi-guarded/clean.txt + PULUMI_FAKE_STDERR: ${{ github.workspace }}/.github/scripts/tests/fixtures/pulumi-guarded/private-key.txt + with: + command: preview + args: --json + output-file: pulumi-result.txt + - name: Withheld run removed the stale file + env: + OUTCOME: ${{ steps.file-leak.outcome }} + RESULT: ${{ github.workspace }}/pulumi-result.txt + run: | + [ "$OUTCOME" = failure ] + [ ! -e "$RESULT" ] - name: Key material must fail the step id: leak continue-on-error: true