Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<report-artifact-name>-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.
21 changes: 20 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 != ''
Expand Down Expand Up @@ -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"
Expand Down
106 changes: 106 additions & 0 deletions scripts/encrypt-validation-report.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 1 addition & 1 deletion scripts/test-action-dependencies.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
13 changes: 13 additions & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading