id: sops-gitops-secret-decryption-and-integrity-gate
namespace: company.gitops
inputs:
- id: git_repository_url
type: STRING
displayName: GitOps Repository URL
defaults: "https://github.com/getsops/sops"
description: HTTPS URL of the GitOps repository hosting Kubernetes, Helm, or
Terraform secret manifests. The default is the official SOPS project
repository, which contains genuinely SOPS-encrypted test fixtures.
- id: git_branch
type: STRING
displayName: Git Branch or Ref
defaults: "main"
description: Git branch, pull request ref, or commit SHA to clone and verify.
- id: manifests_directory
type: STRING
displayName: Manifests Directory Path
defaults: "functional-tests/res"
description: Subdirectory path relative to repository root containing secret manifests.
- id: secret_file_patterns
type: STRING
displayName: Secret File Glob Patterns
defaults: "*secret*.yaml,*secret*.yml,*.enc.yaml,*.enc.yml,*.enc.json"
description: Comma-separated glob patterns used to locate secret manifests
across the target directory.
- id: expected_age_recipient
type: STRING
displayName: Expected Age Recipient Public Key
defaults: ""
description: Required Age public key recipient that must be present in every
verified SOPS encryption envelope. Left empty by default since the demo
repository's fixtures use PGP, not Age; set this once you point the flow
at your own Age-encrypted repository.
- id: enforce_strict_encryption
type: BOOL
displayName: Enforce Strict Encryption Policy
defaults: true
description: When true, any unencrypted secret file or unencrypted leaf value
immediately fails the gate and halts deployment.
- id: slack_channel
type: STRING
displayName: Slack Alert Channel
defaults: "#gitops-security-alerts"
description: Destination Slack channel for security alerts and pre-deployment
gate failures.
triggers:
- id: gitops_pr_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: Triggered immediately when a Git pull request or commit touching
secret manifests is pushed.
key: "{{ secret('GITOPS_WEBHOOK_KEY') }}"
disabled: true
- id: nightly_secret_drift_schedule
type: io.kestra.plugin.core.trigger.Schedule
description: Scheduled nightly audit across GitOps repositories to detect key
rotation drift and expired recipients.
cron: "0 3 * * *"
disabled: true
tasks:
- id: prepare_working_directory
type: io.kestra.plugin.core.flow.WorkingDirectory
tasks:
- id: clone_gitops_repo
type: io.kestra.plugin.git.Clone
description: Clones the GitOps repository to inspect secret manifests on the
target commit.
url: "{{ inputs.git_repository_url }}"
branch: "{{ inputs.git_branch }}"
- id: audit_sops_manifests
type: io.kestra.plugin.scripts.shell.Commands
description: Scans manifests for unencrypted secrets, verifies SOPS envelopes,
checks Age recipient keys, and tests MAC integrity.
containerImage: alpine:3.20
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
env:
MANIFESTS_DIR: "{{ inputs.manifests_directory }}"
FILE_PATTERNS: "{{ inputs.secret_file_patterns }}"
EXPECTED_RECIPIENT: "{{ inputs.expected_age_recipient }}"
ENFORCE_STRICT: "{{ inputs.enforce_strict_encryption }}"
SOPS_AGE_KEY: "{{ secret('SOPS_AGE_KEY') }}"
beforeCommands:
- apk add --no-cache python3 py3-yaml curl bash
- curl -sLo /usr/local/bin/sops
https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
&& chmod +x /usr/local/bin/sops
commands:
- python3 audit_sops.py
inputFiles:
audit_sops.py: |
import os
import sys
import glob
import json
import re
import subprocess
import yaml
manifests_dir = os.environ.get("MANIFESTS_DIR", "manifests")
file_patterns = os.environ.get("FILE_PATTERNS", "*secret*.yaml,*secret*.yml,*.enc.yaml,*.enc.yml,*.enc.json").split(",")
expected_recipient = os.environ.get("EXPECTED_RECIPIENT", "").strip()
enforce_strict = os.environ.get("ENFORCE_STRICT", "true").lower() == "true"
sops_age_key = os.environ.get("SOPS_AGE_KEY", "").strip()
if sops_age_key:
os.environ["SOPS_AGE_KEY"] = sops_age_key
# Find matching files, scoped to manifests_dir only
matched_files = []
for pattern in file_patterns:
pattern = pattern.strip()
if not pattern:
continue
matched_files.extend(glob.glob(os.path.join(manifests_dir, "**", pattern), recursive=True))
matched_files = sorted(list(set(matched_files)))
findings = []
total_inspected = len(matched_files)
plaintext_leaks = 0
corrupted_macs = 0
recipient_mismatches = 0
enc_pattern = re.compile(r"^ENC\[[A-Za-z0-9_]+,data:.*\]$")
def check_plain_values(obj, path=""):
issues = []
if isinstance(obj, dict):
for k, v in obj.items():
if k == "sops":
continue
subpath = f"{path}.{k}" if path else k
issues.extend(check_plain_values(v, subpath))
elif isinstance(obj, list):
for idx, item in enumerate(obj):
subpath = f"{path}[{idx}]"
issues.extend(check_plain_values(item, subpath))
elif isinstance(obj, (str, int, float, bool)) and obj is not None:
str_val = str(obj).strip()
if not enc_pattern.match(str_val) and str_val != "":
issues.append((path, str_val))
return issues
for filepath in matched_files:
try:
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
try:
docs = list(yaml.safe_load_all(content))
except Exception as ye:
findings.append({
"file": filepath,
"type": "YAML_SYNTAX_ERROR",
"severity": "CRITICAL",
"details": f"Failed to parse YAML manifest: {str(ye)}"
})
corrupted_macs += 1
continue
for doc in docs:
if not isinstance(doc, dict):
continue
# Check 1: Missing SOPS envelope (unencrypted secret)
if "sops" not in doc:
findings.append({
"file": filepath,
"type": "UNENCRYPTED_SECRET_LEAK",
"severity": "CRITICAL",
"details": "Manifest lacks 'sops' metadata envelope and contains unencrypted plaintext values."
})
plaintext_leaks += 1
continue
# Check 2: Inspect values under stringData/data for unencrypted leaks
plain_values = []
if "stringData" in doc and isinstance(doc["stringData"], dict):
plain_values.extend(check_plain_values(doc["stringData"], "stringData"))
if "data" in doc and isinstance(doc["data"], dict):
plain_values.extend(check_plain_values(doc["data"], "data"))
if plain_values:
leaked_keys = [k for k, _ in plain_values]
findings.append({
"file": filepath,
"type": "UNENCRYPTED_FIELD_DETECTED",
"severity": "CRITICAL",
"details": f"Unencrypted plaintext keys detected: {', '.join(leaked_keys)}"
})
plaintext_leaks += 1
# Check 3: Age recipient verification
sops_meta = doc.get("sops", {})
age_recipients = [r.get("recipient") for r in sops_meta.get("age", []) if isinstance(r, dict)]
if expected_recipient and expected_recipient not in age_recipients:
findings.append({
"file": filepath,
"type": "MISSING_AGE_RECIPIENT",
"severity": "WARNING",
"details": f"Expected Age recipient '{expected_recipient}' missing from active recipients: {age_recipients}"
})
recipient_mismatches += 1
# Check 4: SOPS CLI MAC and Decryption verification
if sops_age_key:
sops_run = subprocess.run(["sops", "--extract", '["metadata"]["name"]', filepath], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if sops_run.returncode != 0:
err = sops_run.stderr.strip()
findings.append({
"file": filepath,
"type": "MAC_OR_DECRYPTION_FAILED",
"severity": "CRITICAL",
"details": f"SOPS decryption verification failed: {err}"
})
corrupted_macs += 1
except Exception as ex:
findings.append({
"file": filepath,
"type": "AUDIT_READ_ERROR",
"severity": "CRITICAL",
"details": f"Error inspecting file: {str(ex)}"
})
# Determine aggregate status
if not matched_files:
audit_status = "NO_SECRET_MANIFESTS_FOUND"
elif plaintext_leaks > 0 and enforce_strict:
audit_status = "CRITICAL_PLAINTEXT_LEAK"
elif corrupted_macs > 0:
audit_status = "CORRUPTED_MAC_OR_DECRYPT_FAILURE"
elif plaintext_leaks > 0:
audit_status = "PLAINTEXT_LEAK_ADVISORY"
elif recipient_mismatches > 0:
audit_status = "RECIPIENT_KEY_MISMATCH"
else:
audit_status = "VERIFIED_CLEAN"
report_lines = [
f"# SOPS GitOps Secret Audit Report",
f"- **Audit Status**: `{audit_status}`",
f"- **Manifests Inspected**: {total_inspected}",
f"- **Plaintext Leaks**: {plaintext_leaks}",
f"- **Corrupted MACs / Decrypt Errors**: {corrupted_macs}",
f"- **Recipient Key Mismatches**: {recipient_mismatches}",
""
]
if findings:
report_lines.append("## Identified Policy Violations")
for f in findings:
report_lines.append(f"- **[{f['severity']}] {f['type']}** in `{f['file']}`: {f['details']}")
else:
report_lines.append("All secret manifests verified clean with valid SOPS encryption envelopes and intact cryptographic signatures.")
report_md = "\n".join(report_lines)
print(report_md)
# Structured Kestra output
output_payload = {
"audit_status": audit_status,
"total_inspected": total_inspected,
"plaintext_leaks": plaintext_leaks,
"corrupted_macs": corrupted_macs,
"recipient_mismatches": recipient_mismatches,
"violations_count": len(findings)
}
print(f'::{{"outputs": {json.dumps(output_payload)}}}::')
- id: route_audit_outcome
type: io.kestra.plugin.core.flow.Switch
description: Routes execution based on secret integrity, escalating leaks to
Slack or certifying clean deployment.
value: "{{ outputs.audit_sops_manifests.vars.audit_status }}"
cases:
"CRITICAL_PLAINTEXT_LEAK":
- id: alert_plaintext_leak_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Dispatches an urgent security alert to Slack when unencrypted
plaintext secrets are committed.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"channel": "{{ inputs.slack_channel }}",
"text": ":rotating_light: *SOPS Security Gate Tripped: Plaintext Secret Leak Detected* :rotating_light:\n*Repository:* {{ inputs.git_repository_url }} (branch `{{ inputs.git_branch }}`)\n*Plaintext Violations:* {{ outputs.audit_sops_manifests.vars.plaintext_leaks }}\n*Total Files:* {{ outputs.audit_sops_manifests.vars.total_inspected }}\n*Action Required:* Remove plain secrets from git history or encrypt with `sops -e -i <file>`."
}
- id: halt_on_plaintext_leak
type: io.kestra.plugin.core.execution.Fail
description: Halts workflow and marks execution failed to block downstream
deployment pipelines.
errorMessage: "CRITICAL: Unencrypted plaintext secret committed to GitOps
repository. Deployment blocked."
"CORRUPTED_MAC_OR_DECRYPT_FAILURE":
- id: alert_corrupted_mac_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alerts platform engineering in Slack when SOPS MAC check fails due
to merge conflict corruption.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"channel": "{{ inputs.slack_channel }}",
"text": ":warning: *SOPS Decryption Gate Tripped: MAC Mismatch or Decryption Error* :warning:\n*Repository:* {{ inputs.git_repository_url }} (branch `{{ inputs.git_branch }}`)\n*Corrupted Files:* {{ outputs.audit_sops_manifests.vars.corrupted_macs }}\n*Remediation:* A git merge conflict likely corrupted the `sops.mac` signature. Re-encrypt with `sops -e -i <file>`."
}
- id: halt_on_corrupted_mac
type: io.kestra.plugin.core.execution.Fail
description: Halts workflow to prevent broken secrets from halting Flux or
ArgoCD continuous delivery controllers.
errorMessage: "FAILED: SOPS MAC verification or decryption failure detected.
Re-encrypt with valid keys."
"RECIPIENT_KEY_MISMATCH":
- id: alert_recipient_mismatch_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alerts team when secret manifests are missing the designated Age
recipient key after key rotation.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"channel": "{{ inputs.slack_channel }}",
"text": ":warning: *SOPS Key Policy Warning: Missing Age Recipient* :warning:\n*Repository:* {{ inputs.git_repository_url }} (branch `{{ inputs.git_branch }}`)\n*Required Recipient:* `{{ inputs.expected_age_recipient }}`\n*Mismatches:* {{ outputs.audit_sops_manifests.vars.recipient_mismatches }}\n*Remediation:* Run `sops updatekeys <file>` to rotate recipient keys."
}
- id: halt_on_key_mismatch
type: io.kestra.plugin.core.execution.Fail
description: Halts execution if strict encryption policy is enforced and
recipient keys do not match.
errorMessage: "POLICY VIOLATION: Secret manifests missing required Age recipient
key. Run 'sops updatekeys <file>'."
"PLAINTEXT_LEAK_ADVISORY":
- id: alert_plaintext_advisory_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Notifies the team of unencrypted plaintext secrets when
enforce_strict_encryption is false, without blocking deployment.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"channel": "{{ inputs.slack_channel }}",
"text": ":warning: *SOPS Advisory: Unencrypted Plaintext Secrets Found (Non-Blocking)* :warning:\n*Repository:* {{ inputs.git_repository_url }} (branch `{{ inputs.git_branch }}`)\n*Plaintext Violations:* {{ outputs.audit_sops_manifests.vars.plaintext_leaks }}\n*Action:* enforce_strict_encryption is false, so deployment is allowed to proceed. Remediate before enabling strict mode."
}
- id: log_plaintext_advisory
type: io.kestra.plugin.core.log.Log
description: Records the non-blocking plaintext advisory in execution history.
message: "SOPS GitOps advisory: {{
outputs.audit_sops_manifests.vars.plaintext_leaks }} unencrypted
plaintext finding(s) detected but not blocked because
enforce_strict_encryption is false."
"NO_SECRET_MANIFESTS_FOUND":
- id: log_no_manifests_found
type: io.kestra.plugin.core.log.Log
description: Records that no files matched the configured secret file patterns
under manifests_directory; nothing was audited.
message: "SOPS GitOps audit: no files matching {{ inputs.secret_file_patterns }}
were found under '{{ inputs.manifests_directory }}' in {{
inputs.git_repository_url }} (branch {{ inputs.git_branch }}).
Nothing was audited."
defaults:
- id: log_clean_audit
type: io.kestra.plugin.core.log.Log
description: Logs certification receipt confirming all secret manifests passed
SOPS encryption and MAC checks.
message: "SOPS GitOps verification passed: All {{
outputs.audit_sops_manifests.vars.total_inspected }} secret manifests
verified with valid SOPS encryption envelopes, untampered MAC
signatures, and approved Age recipient key."