diff --git a/.forgejo/workflows/cluster-ci.yaml b/.forgejo/workflows/cluster-ci.yaml index 53a02e0..916619e 100644 --- a/.forgejo/workflows/cluster-ci.yaml +++ b/.forgejo/workflows/cluster-ci.yaml @@ -156,22 +156,44 @@ jobs: - name: Check for Secrets in Code run: | echo "=== Scanning for hardcoded secrets ===" - SECRETS_FOUND=0 + # BLOCKING. This step used to only count findings and then exit 0, so a + # plaintext deploy key rode through it into a public remote. Two failure + # modes fixed: it now fails the build, and it matches key material by + # PEM header rather than only `private_key:`-style YAML field names. + # Findings are captured into variables and tested for emptiness rather than + # branching on grep's exit status: implementations disagree on the rc of a + # `-v` filter fed empty input, and a wrong rc here fails open. + # NOTE: --include must precede `--`; after `--` grep treats it as a filename + # and silently scans nothing. + FAILED=0 - for pattern in "password:" "secret:" "token:" "api_key:" "apikey:" "private_key:" "privatekey:"; do - if grep -r "$pattern" k8s/ --include="*.yaml" --include="*.yml" | grep -v "^Binary"; then - echo "⚠️ Found potential secret pattern: $pattern" - SECRETS_FOUND=$((SECRETS_FOUND + 1)) - fi - done - - if [ $SECRETS_FOUND -gt 0 ]; then - echo "⚠️ Warning: Found $SECRETS_FOUND potential secrets" - echo "Secrets should be encrypted with SOPS or stored in ArgoCD Sealed Secrets" - else - echo "✓ No hardcoded secrets found" + # Any private key block is fatal, regardless of the field name carrying it. + KEYS=$(grep -rIE --include="*.yaml" --include="*.yml" \ + -- "-----BEGIN ([A-Z]+ )?PRIVATE KEY-----" k8s/ \ + | grep -v "\.enc\.yaml" || true) + if [ -n "$KEYS" ]; then + echo "❌ Unencrypted private key material found:" + echo "$KEYS" + FAILED=1 fi + # Plaintext values in secret-ish YAML fields. SOPS output is ENC[...], + # so encrypted files never trip this. + VALS=$(grep -rInE --include="*.yaml" --include="*.yml" \ + -- "^[[:space:]]*(password|token|apiKey|api_key|sshPrivateKey|client_secret):[[:space:]]*[\"']?[^\"'[:space:]{\$]{8,}" k8s/ \ + | grep -v "ENC\[" | grep -v "\.enc\.yaml" || true) + if [ -n "$VALS" ]; then + echo "❌ Plaintext secret value found:" + echo "$VALS" + FAILED=1 + fi + + if [ "$FAILED" -ne 0 ]; then + echo "Encrypt with SOPS (see .sops.yaml) — *.enc.yaml files are exempt." + exit 1 + fi + echo "✓ No hardcoded secrets found" + # === Check K8s Security Best Practices === - name: Check K8s Security Best Practices run: | diff --git a/.gitignore b/.gitignore index 6dc7f3d..41df5d3 100644 --- a/.gitignore +++ b/.gitignore @@ -50,10 +50,19 @@ terraform/*.tfstate.* terraform.tfvars.local skills-lock.json secrets-plaintext.yaml -skills-lock.json + +# Saved plan files — binary, environment-specific, may embed resource attributes +terraform/tfplan +terraform/tfplan-* .DS_Store CLAUDE.md docs/ -k8s/argocd/seed-repo-secret.yamlbootstrap-argocd.log +bootstrap-argocd.log + +# Any plaintext (non-SOPS) secret manifest. Encrypted ones are *.enc.yaml and +# ARE committed — see .sops.yaml. A missing newline once merged two patterns on +# one line here, which is how a plaintext deploy key reached a public remote. +k8s/**/*-secret.yaml +!k8s/**/*.enc.yaml diff --git a/.sops.yaml b/.sops.yaml index b377c7f..faa070f 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -1,3 +1,5 @@ creation_rules: - - path_regex: k8s/.*secrets.*\.ya?ml + # `secrets?` — singular too. A `seed-repo-secret.yaml` once slipped this regex + # and was committed in plaintext to a public remote. + - path_regex: k8s/.*secrets?.*\.ya?ml age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla