diff --git a/README.md b/README.md index f19cf71..850e71f 100644 --- a/README.md +++ b/README.md @@ -1069,3 +1069,59 @@ MIT License - see [LICENSE](LICENSE) for details. - 💬 [Discussions](https://github.com/flowcore-io/usable-pr-validator/discussions) Made with ❤️ by [Flowcore](https://flowcore.io) + +### Opt-in encrypted final-report diagnostics + +Confidential consumers can keep `report-visibility: metadata-only` while exporting +an encrypted copy of the **validated final report** for an authorized recipient. +This is disabled by default. It does not export raw provider transcripts, change +verdicts, relax grounding, or retain plaintext after the action's cleanup. + +Authorize this encrypted export before enabling it. Configure these inputs from +trusted workflow/repository variables, never PR-controlled content: + +```yaml +with: + report-visibility: metadata-only + encrypted-report-recipient: ${{ vars.VALIDATION_RECIPIENT_CERTIFICATE }} + encrypted-report-recipient-sha256: ${{ vars.VALIDATION_RECIPIENT_SHA256 }} +``` + +The recipient is one PEM X.509 RSA certificate (3072–8192 bits). The second input +is its independently verified SHA-256 DER fingerprint, 64 hexadecimal characters +without colons. Keep the corresponding private key **off the runner and out of +GitHub/repository files**. Certificate validity, fingerprint and key size are +checked; self-signed certificates are allowed because trust comes from the pinned +fingerprint, not a CA chain. OpenSSL 3.x is required when this option is enabled. + +Before discarding its validated candidate, the action encrypts it as CMS DER +AuthEnvelopedData using AES-256-GCM and RSA-OAEP/SHA-256. It uploads only the +ciphertext as `-encrypted`, with retention set to **one day**. +The ordinary artifact/comment obeys `report-visibility` unchanged. Opt-in +configuration/encryption failures fail the action without a plaintext fallback. +No encrypted artifact is produced when validation never yields a valid final +report. Reports that pass are also encrypted when enabled. Disable both inputs +when diagnosis is finished; repository administrators may delete artifacts early. +Retention does not revoke downloaded copies or the recipient's access. + +Decrypt only on the authorized recipient's machine, with OpenSSL 3.x. A failed GCM +authentication can leave partial output; use a private temporary file and move it +only on successful exit: + +```sh +umask 077 +plain_tmp=$(mktemp) +if openssl cms -decrypt -binary -inform DER -in report.md.cms \ + -recip recipient.pem -inkey private.pem -out "$plain_tmp"; then + mv "$plain_tmp" decrypted-report.md +else + rm -f "$plain_tmp" + exit 1 +fi +``` + +Decrypted reports are untrusted model output, not commands or approval. Verify the +artifact's workflow/run/head provenance and investigate the cited code. Encryption +protects confidentiality and ciphertext integrity; it does not authenticate a +reviewer's identity or make a finding correct. Do not paste decrypted content into +public comments, logs, or a public validator PR. diff --git a/action.yml b/action.yml index 3308035..5b71f27 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,14 @@ inputs: description: 'full (default) publishes the validated assistant report in comments/artifacts. metadata-only publishes action-generated verdict/count/grounding only; use it for confidential repositories or Usable context.' required: false default: 'full' + encrypted-report-recipient: + description: 'Opt-in PEM RSA certificate for encrypting the validated final report. Keep the private key off the runner. Requires matching SHA256; authorize encrypted export before enabling.' + required: false + default: '' + encrypted-report-recipient-sha256: + description: 'Trusted SHA256 of the recipient certificate DER (64 hex characters). Required with encrypted-report-recipient; never derive from PR-controlled input.' + required: false + default: '' max-retries: description: 'Maximum retry attempts for retryable provider failures or incomplete final reports' required: false @@ -473,6 +481,8 @@ runs: env: VALIDATION_REPORT_PATH: ${{ steps.publication.outputs.report_path }} REPORT_VISIBILITY: ${{ steps.publication.outputs.visibility }} + ENCRYPTED_REPORT_RECIPIENT: ${{ inputs.encrypted-report-recipient }} + ENCRYPTED_REPORT_RECIPIENT_SHA256: ${{ inputs.encrypted-report-recipient-sha256 }} PROMPT_FILE: ${{ inputs.prompt-file }} USE_DYNAMIC_PROMPTS: ${{ inputs.use-dynamic-prompts }} PROVIDER: ${{ inputs.provider }} @@ -504,6 +514,15 @@ runs: run: | ${{ github.action_path }}/scripts/validate.sh + - name: Upload Encrypted Final Report + if: always() && steps.validate.outputs.encrypted_report_ready == 'true' && steps.publication.outputs.report_path != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.set-artifact-name.outputs.artifact_name }}-encrypted + path: ${{ steps.publication.outputs.report_path }}.cms + retention-days: 1 + if-no-files-found: error + - name: Upload Validation Report id: report-artifact if: always() && steps.validate.outputs.report_ready == 'true' && steps.publication.outputs.report_path != '' @@ -594,7 +613,7 @@ runs: rm -f /tmp/mcp-system-prompt.md /tmp/user-prompt.md /tmp/dynamic-prompt.md /tmp/validation-prompt.txt # Only this invocation's known file/directory, never a broad tmp cleanup. if [ -n "$REPORT_PATH" ]; then - rm -f -- "$REPORT_PATH" + rm -f -- "$REPORT_PATH" "${REPORT_PATH}.cms" rmdir -- "$(dirname "$REPORT_PATH")" 2>/dev/null || true fi echo "✅ Temporary files cleaned up" diff --git a/scripts/encrypt-validation-report.py b/scripts/encrypt-validation-report.py new file mode 100644 index 0000000..1190baf --- /dev/null +++ b/scripts/encrypt-validation-report.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Opt-in encrypted final report; no plaintext output or transcript fallback.""" + +import argparse +from datetime import datetime, timezone +import hashlib +import importlib.util +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +SPEC = importlib.util.spec_from_file_location( + "publication", Path(__file__).with_name("publish-validation-report.py") +) +PUBLICATION = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PUBLICATION) + + +def openssl(*args, data=None): + return subprocess.run( + ["openssl", *args], input=data, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, check=True, timeout=20, + ).stdout + + +def encrypt(report, output): + pem = os.environ.get("ENCRYPTED_REPORT_RECIPIENT", "") + fingerprint = os.environ.get("ENCRYPTED_REPORT_RECIPIENT_SHA256", "") + if not re.fullmatch(r"[a-fA-F0-9]{64}", fingerprint): + raise ValueError("invalid_fingerprint") + if len(pem) > 16384 or not re.fullmatch( + r"\s*-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\r\n]+" + r"-----END CERTIFICATE-----\s*", pem + ): + raise ValueError("invalid_certificate") + text = PUBLICATION.read_private(report) + PUBLICATION.PARSER.parse_report(text) + if not openssl("version").startswith(b"OpenSSL 3."): + raise ValueError("openssl_3_required") + with tempfile.TemporaryDirectory(prefix="validator-recipient-") as directory: + certificate = Path(directory) / "recipient.pem" + certificate.write_text(pem) + certificate.chmod(0o600) + der = openssl("x509", "-in", str(certificate), "-outform", "DER") + if hashlib.sha256(der).hexdigest() != fingerprint.lower(): + raise ValueError("recipient_mismatch") + openssl("x509", "-in", str(certificate), "-checkend", "0", "-noout") + start = openssl("x509", "-in", str(certificate), "-startdate", "-noout") + not_before = datetime.strptime( + start.decode("ascii").strip().removeprefix("notBefore="), + "%b %d %H:%M:%S %Y GMT", + ).replace(tzinfo=timezone.utc) + if not_before > datetime.now(timezone.utc): + raise ValueError("recipient_not_yet_valid") + public_key = openssl("x509", "-in", str(certificate), "-pubkey", "-noout") + description = openssl("pkey", "-pubin", "-text", "-noout", data=public_key) + bits = re.search(rb"Public-Key: \((\d+) bit\)", description) + if not bits or not 3072 <= int(bits[1]) <= 8192: + raise ValueError("invalid_recipient_key") + # AuthEnvelopedData: AES-GCM integrity plus RSA-OAEP/SHA256 key wrapping. + # Non-RSA recipients fail the RSA key options; no weaker fallback exists. + ciphertext = openssl( + "cms", "-encrypt", "-binary", "-aes-256-gcm", "-outform", "DER", + "-recip", str(certificate), "-keyopt", "rsa_padding_mode:oaep", + "-keyopt", "rsa_oaep_md:sha256", "-keyopt", "rsa_mgf1_md:sha256", + data=text.encode("utf-8"), + ) + if not ciphertext: + raise ValueError("empty_ciphertext") + target = Path(output) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as handle: + temporary = Path(handle.name) + os.fchmod(handle.fileno(), 0o600) + handle.write(ciphertext) + os.replace(temporary, target) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +class SafeParser(argparse.ArgumentParser): + def error(self, message): + self.exit(2, "Encrypted report rejected: invalid arguments.\n") + + +def main(): + parser = SafeParser() + parser.add_argument("--report", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + try: + encrypt(args.report, args.output) + except (OSError, ValueError, UnicodeError, subprocess.SubprocessError): + print("Encrypted report rejected: invalid report, recipient or encryption failure.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-action-dependencies.sh b/scripts/test-action-dependencies.sh index a0bff02..c10d9ba 100755 --- a/scripts/test-action-dependencies.sh +++ b/scripts/test-action-dependencies.sh @@ -30,7 +30,7 @@ assert_count 1 \ assert_count 1 \ '^[[:space:]]*uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9\.0\.0$' \ 'github-script v9.0.0 pin' -assert_count 1 \ +assert_count 2 \ '^[[:space:]]*uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7\.0\.1$' \ 'upload-artifact v7.0.1 pins' assert_count 1 \ diff --git a/scripts/validate.sh b/scripts/validate.sh index 7a7ecec..9b58f38 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -874,6 +874,19 @@ main() { return 1 fi REPORT_READY=true + # Encrypt only the strictly validated final candidate, never a provider transcript. + # Opt-in failure remains a failed action; no plaintext diagnostic fallback. + if [ -n "${ENCRYPTED_REPORT_RECIPIENT:-}" ] || [ -n "${ENCRYPTED_REPORT_RECIPIENT_SHA256:-}" ]; then + if ! python3 "$SCRIPT_DIR/scripts/encrypt-validation-report.py" \ + --report "$candidate_file" --output "${REPORT_PATH}.cms"; then + rm -f "$candidate_file" "${REPORT_PATH}.cms" + write_outputs "error" "false" "1" "$grounding_status" + return 1 + fi + if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "encrypted_report_ready=true" >> "$GITHUB_OUTPUT" + fi + fi rm -f "$candidate_file" # Parse results and set outputs diff --git a/tests/test_encrypted_report.py b/tests/test_encrypted_report.py new file mode 100644 index 0000000..f665e9c --- /dev/null +++ b/tests/test_encrypted_report.py @@ -0,0 +1,188 @@ +"""CLI-only tests with disposable keys and synthetic report canaries.""" +import hashlib +from datetime import datetime, timedelta, timezone +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).parents[1] +CANARY = "CONFIDENTIAL_SYNTHETIC_FINDING_34bf" +REPORT = ("# PR Validation Report\n## Summary\n" + CANARY + + "\n## Validation Outcome\n- **Status**: FAIL\n- **Critical Issues**: 1\n") + + +class EncryptedReportTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory() + cls.root = Path(cls.directory.name) + cls.key = cls.root / "private.pem" + cls.cert = cls.root / "recipient.pem" + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:3072", "-nodes", + "-keyout", str(cls.key), "-out", str(cls.cert), "-days", "1", + "-subj", "/CN=Synthetic test/"], check=True, capture_output=True) + der = subprocess.check_output(["openssl", "x509", "-in", str(cls.cert), "-outform", "DER"]) + cls.env = {**os.environ, "ENCRYPTED_REPORT_RECIPIENT": cls.cert.read_text(), + "ENCRYPTED_REPORT_RECIPIENT_SHA256": hashlib.sha256(der).hexdigest()} + + @classmethod + def tearDownClass(cls): + cls.directory.cleanup() + + def run_cli(self, source, target, env=None): + return subprocess.run(["python3", str(ROOT / "scripts/encrypt-validation-report.py"), + "--report", str(source), "--output", str(target)], + env=env or self.env, capture_output=True, text=True) + + def test_authenticated_round_trip_and_tampering(self): + with tempfile.TemporaryDirectory() as directory: + source, target = Path(directory) / "report", Path(directory) / "cipher.cms" + source.write_text(REPORT) + result = self.run_cli(source, target) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn(CANARY, result.stdout + result.stderr) + self.assertNotIn(CANARY.encode(), target.read_bytes()) + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + command = ["openssl", "cms", "-decrypt", "-binary", "-inform", "DER", + "-in", str(target), "-recip", str(self.cert), "-inkey", str(self.key)] + plain = subprocess.run(command, capture_output=True, check=True).stdout + self.assertEqual(plain, REPORT.encode()) + damaged = bytearray(target.read_bytes()) + damaged[-1] ^= 1 + target.write_bytes(damaged) + self.assertNotEqual(subprocess.run(command, capture_output=True).returncode, 0) + + def test_invalid_recipient_and_mismatch_are_generic_and_create_nothing(self): + for changes in [{"ENCRYPTED_REPORT_RECIPIENT": ""}, + {"ENCRYPTED_REPORT_RECIPIENT": CANARY}, + {"ENCRYPTED_REPORT_RECIPIENT_SHA256": "0" * 64}, + {"ENCRYPTED_REPORT_RECIPIENT_SHA256": ""}, + {"ENCRYPTED_REPORT_RECIPIENT": self.cert.read_text() + self.key.read_text()}]: + with self.subTest(changes=list(changes)), tempfile.TemporaryDirectory() as directory: + source, target = Path(directory) / "report", Path(directory) / "cipher" + source.write_text(REPORT) + result = self.run_cli(source, target, {**self.env, **changes}) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(CANARY, result.stdout + result.stderr) + self.assertNotIn("PRIVATE KEY", result.stdout + result.stderr) + self.assertFalse(target.exists()) + + def test_invalid_missing_oversize_and_symlink_reports_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + source, target = Path(directory) / "report", Path(directory) / "cipher" + for payload in [None, CANARY.encode(), b"\xff", b"x" * (1024 * 1024 + 1)]: + if payload is not None: + source.write_bytes(payload) + result = self.run_cli(source, target) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(CANARY, result.stdout + result.stderr) + self.assertFalse(target.exists()) + source.unlink() + source.symlink_to(self.cert) + self.assertNotEqual(self.run_cli(source, target).returncode, 0) + self.assertFalse(target.exists()) + + def test_encryption_failure_does_not_publish_partial_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source, target = root / "report", root / "cipher" + source.write_text(REPORT) + fake = root / "openssl" + fake.write_text("#!/bin/sh\nprintf 'PRIVATE_ENCRYPTION_FAILURE' >&2\nexit 1\n") + fake.chmod(0o700) + result = self.run_cli(source, target, {**self.env, "PATH": f"{root}:{os.environ['PATH']}"}) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("PRIVATE_ENCRYPTION_FAILURE", result.stdout + result.stderr) + self.assertFalse(target.exists()) + + def test_future_and_expired_certificates_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "index").write_text("") + (root / "serial").write_text("01\n") + config = root / "ca.conf" + config.write_text(f"""[ca] +default_ca = local +[local] +database = {root}/index +serial = {root}/serial +new_certs_dir = {root} +default_md = sha256 +policy = policy +unique_subject = no +[policy] +commonName = supplied +""") + csr = root / "request.pem" + subprocess.run(["openssl", "req", "-new", "-key", str(self.key), + "-subj", "/CN=Synthetic date test/", "-out", str(csr)], + check=True, capture_output=True) + now = datetime.now(timezone.utc) + for start_days, end_days in [(1, 2), (-2, -1)]: + with self.subTest(start_days=start_days): + cert = root / "dated.pem" + subprocess.run(["openssl", "ca", "-batch", "-selfsign", "-notext", + "-config", str(config), "-keyfile", str(self.key), + "-cert", str(self.cert), "-in", str(csr), "-out", str(cert), + "-startdate", (now + timedelta(days=start_days)).strftime("%Y%m%d%H%M%SZ"), + "-enddate", (now + timedelta(days=end_days)).strftime("%Y%m%d%H%M%SZ")], + check=True, capture_output=True) + der = subprocess.check_output(["openssl", "x509", "-in", str(cert), "-outform", "DER"]) + source, target = root / "report", root / "cipher" + source.write_text(REPORT) + result = self.run_cli(source, target, {**self.env, + "ENCRYPTED_REPORT_RECIPIENT": cert.read_text(), + "ENCRYPTED_REPORT_RECIPIENT_SHA256": hashlib.sha256(der).hexdigest()}) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(target.exists()) + self.assertNotIn(CANARY, result.stdout + result.stderr) + + def test_actual_publication_sequence_preserves_failure_and_keeps_prose_private(self): + validation = (ROOT / "scripts/validate.sh").read_text() + start = validation.index(' if ! "$SCRIPT_DIR/scripts/publish-validation-report.py" \\\n --report "$candidate_file"') + end = validation.index(' # Parse results and set outputs', start) + sequence = validation[start:end] + for enabled in [False, True, "invalid"]: + with self.subTest(enabled=enabled), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate, public, outputs = root / "candidate", root / "public", root / "outputs" + candidate.write_text(REPORT) + env = {**self.env, "ACTION_PATH": str(ROOT), "VALIDATE_SH_LIBRARY_ONLY": "true", + "VALIDATION_REPORT_PATH": str(public), "REPORT_VISIBILITY": "metadata-only", + "GITHUB_OUTPUT": str(outputs), "CANDIDATE": str(candidate)} + if enabled is False: + env.pop("ENCRYPTED_REPORT_RECIPIENT") + env.pop("ENCRYPTED_REPORT_RECIPIENT_SHA256") + elif enabled == "invalid": + env["ENCRYPTED_REPORT_RECIPIENT_SHA256"] = "0" * 64 + script = ('source "$ACTION_PATH/scripts/validate.sh"\n' + 'publish_fixture() {\nlocal candidate_file="$CANDIDATE"\n' + 'local grounding_status=complete\n' + sequence + + '\nwrite_outputs failed false 1 complete\n}\npublish_fixture\n') + result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + output = outputs.read_text() + self.assertNotIn(CANARY, public.read_text() + output + result.stdout + result.stderr) + self.assertFalse(candidate.exists()) + if enabled == "invalid": + self.assertNotEqual(result.returncode, 0) + self.assertIn("validation_status=error", output) + self.assertNotIn("encrypted_report_ready=true", output) + else: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("validation_status=failed", output) + self.assertIn("critical_issues=1", output) + self.assertEqual(Path(str(public) + ".cms").exists(), enabled is True) + + def test_upload_and_cleanup_boundary(self): + action = (ROOT / "action.yml").read_text() + upload = action.split(" - name: Upload Encrypted Final Report\n", 1)[1].split(" - name:", 1)[0] + self.assertIn("encrypted_report_ready == 'true'", upload) + self.assertIn("path: ${{ steps.publication.outputs.report_path }}.cms", upload) + self.assertIn("retention-days: 1", upload) + self.assertNotIn("validation-full-output", upload) + cleanup = action.split(" - name: Cleanup Secrets\n", 1)[1].split(" - name:", 1)[0] + self.assertIn("if: always()", cleanup) + self.assertIn('"${REPORT_PATH}.cms"', cleanup) + self.assertIn("rm -f /tmp/validation-full-output.md", cleanup)