Browse Source

feat(templating): Add certSANs function to extract SANs from certificates (#6058)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
mzdeb 4 weeks ago
parent
commit
7d3d062421

+ 1 - 1
.github/workflows/zizmor.yml

@@ -56,4 +56,4 @@ jobs:
             .github/
           token: ${{ github.token }}
           # min-severity: medium
-          # min-confidence: medium
+          # min-confidence: medium

+ 11 - 0
docs/guides/templating.md

@@ -136,6 +136,16 @@ In case you have a secret that contains a (partial) certificate chain you can ex
 {% include 'filtercertchain-template-v2-external-secret.yaml' %}
 ```
 
+### Extract Subject Alternative Names (SANs) from Certificate
+
+You can use the `certSANs` function to extract Subject Alternative Names from a PEM-encoded certificate. It returns a list of all SANs including DNS names, IP addresses, email addresses, and URIs. This is useful when you need to know which domains or IPs a certificate covers.
+
+You can combine `certSANs` with `filterPEM` and `filterCertChain` to first extract the leaf certificate from a chain and then get its SANs:
+
+```yaml
+{% include 'certsans-template-v2-external-secret.yaml' %}
+```
+
 ### RSA Decryption Data From Provider
 
 When a provider returns RSA-encrypted values, you can decrypt them directly in the template using the `rsaDecrypt` functions (engine v2).
@@ -200,6 +210,7 @@ In addition to that you can use over 200+ [sprig functions](http://masterminds.g
 | pemTruststoreToPKCS12Pass| Same as `pemTruststoreToPKCS12`. Uses the provided password to encrypt the PKCS#12 archive.                                                                                                                                            |
 | filterPEM        | Filters PEM blocks with a specific type from a list of PEM blocks.                                                                                                                                                           |
 | filterCertChain  | Filters PEM block(s) with a specific certificate type (`leaf`, `intermediate` or `root`)  from a certificate chain of PEM blocks (PEM blocks with type `CERTIFICATE`). |
+| certSANs         | Extracts Subject Alternative Names (SANs) from a PEM-encoded certificate and returns them as a list of strings. Includes DNS names, IP addresses, email addresses, and URIs. |
 | jwkPublicKeyPem  | Takes an json-serialized JWK and returns an PEM block of type `PUBLIC KEY` that contains the public key. [See here](https://golang.org/pkg/crypto/x509/#MarshalPKIXPublicKey) for details.                                   |
 | jwkPrivateKeyPem | Takes an json-serialized JWK as `string` and returns an PEM block of type `PRIVATE KEY` that contains the private key in PKCS #8 format. [See here](https://golang.org/pkg/crypto/x509/#MarshalPKCS8PrivateKey) for details. |
 | rsaDecrypt | Decrypts RSA ciphertext using a PEM private key. Usage: ``<rsaDecrypt "SCHEME" "HASH" ciphertext privateKeyPEM>`` or ``<privateKeyPEM \| rsaDecrypt "SCHEME" "HASH" ciphertext>``. **SCHEME**: supported values are `"None"` and `"RSA-OAEP"`. **HASH**: supported values are `"SHA1"` and `"SHA256"`. **Ciphertext** must be binary — use `b64dec` or `decodingStrategy: Base64` to convert Base64 payloads. |

+ 16 - 0
docs/snippets/certsans-template-v2-external-secret.yaml

@@ -0,0 +1,16 @@
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: cert-sans-example
+spec:
+  # ...
+  target:
+    template:
+      engineVersion: v2
+      data:
+        # Store all SANs as a comma-separated string
+        sans: '{{ .certificate | filterPEM "CERTIFICATE" | filterCertChain "leaf" | certSANs | join "," }}'
+        # Store the first SAN (e.g. primary domain)
+        primary-domain: '{{ index (.certificate | filterPEM "CERTIFICATE" | filterCertChain "leaf" | certSANs) 0 }}'
+        # Store SANs as a JSON array
+        sans-json: '{{ .certificate | filterPEM "CERTIFICATE" | filterCertChain "leaf" | certSANs | toJson }}'

+ 25 - 0
runtime/template/v2/_testdata/sans.crt

@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEJjCCAw6gAwIBAgIUVNaRTjph7AlbIJD6VaK3mJR2n5YwDQYJKoZIhvcNAQEL
+BQAwezELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcM
+DVNhbiBGcmFuY2lzY28xEjAQBgNVBAoMCU15Q29tcGFueTEVMBMGA1UECwwMSVRE
+ZXBhcnRtZW50MRQwEgYDVQQDDAtleGFtcGxlLmNvbTAeFw0yNjAzMTAxMzMwMjda
+Fw0yNzAzMTAxMzMwMjdaMHsxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9y
+bmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMRIwEAYDVQQKDAlNeUNvbXBhbnkx
+FTATBgNVBAsMDElURGVwYXJ0bWVudDEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCnU5CmUW12axeW8nWC1mW7jUtZ
+AwnmI/g+RK/nRyG01qhGqmyYpmz5T5YZXyMYfBkRK0tCCEw4y5i4f6b0KkiDX04d
+jEaoihmS7CDZtxZSVOWiddK1InEuTpDZ/ZLrIDzVMdNjDZrn49jEcxGVYt8jcsy+
+R2qn3hJLFtbKfqvv1hSIhEoVaEXwjj2RB0loyclUoQ7cxEOQ00h/pP6uOeCjGxlp
+/FvjNK6uFxYESts29RY1bwsld7mEn02LCUp8draVdkeFq5Dca0wYU8VfzUK5RweN
+n1zd1I6dKBWCsIgjctD4bujcrr/wO1kw5RXwnn/1PJihCqYvudMpKfa754kRAgMB
+AAGjgaEwgZ4wCwYDVR0PBAQDAgQwMBMGA1UdJQQMMAoGCCsGAQUFBwMBMFsGA1Ud
+EQRUMFKCC2V4YW1wbGUuY29tgg93d3cuZXhhbXBsZS5jb22HBMCoAQqHBAoAAAGB
+EWFkbWluQGV4YW1wbGUuY29thhNodHRwczovL2V4YW1wbGUuY29tMB0GA1UdDgQW
+BBQcczGlyquoe8zN4oiWzSiZr9uNFjANBgkqhkiG9w0BAQsFAAOCAQEAevRS6y8q
+foWhr7SXKKNxwD1Ujm3RTdu7lwGaW77pU6NynghyKxDFndLrKebGZ9cmizvxlNxa
+WA5m/qmh6U3kXBfqSS2HNuq+MQBYtmEVOtB73EuAtka2PKRVD6uFU6EToTkhydfM
+BJgujrQvBFWWPDm2Rbz2tb2gK8Q3ByXpeIgA57CQ67wpKd8WWmDWIxLb41Z5nCMK
+iHbYTSKpbGZ/l3LfGGaKxPIFTnhn5VM1GbdWSDxnCj3AF0RrC4WtXNy+PVFehluf
+fS0QVVUKXObJkJpWm+9YriyEWKxOfos4pXh6+r8u/4cFBeZErXmlDIgCfiy1dX7z
+Fm5RuPY4PPrt/w==
+-----END CERTIFICATE-----

+ 28 - 0
runtime/template/v2/pem.go

@@ -21,6 +21,7 @@ import (
 	"crypto/x509"
 	"encoding/pem"
 	"errors"
+	"fmt"
 	"strings"
 )
 
@@ -117,6 +118,33 @@ func isRootCertificate(cert *x509.Certificate) bool {
 	return cert.AuthorityKeyId == nil || bytes.Equal(cert.AuthorityKeyId, cert.SubjectKeyId)
 }
 
+// certSANs extracts Subject Alternative Names (SANs) from a PEM-encoded certificate.
+// It returns a list of all SANs including DNS names, IP addresses, email addresses, and URIs.
+func certSANs(input string) ([]string, error) {
+	input = trimJunk(input)
+	block, _ := pem.Decode([]byte(input))
+	if block == nil {
+		return nil, fmt.Errorf("failed to decode PEM block")
+	}
+
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse certificate: %w", err)
+	}
+
+	sans := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses)+len(cert.EmailAddresses)+len(cert.URIs))
+	sans = append(sans, cert.DNSNames...)
+	for _, ip := range cert.IPAddresses {
+		sans = append(sans, ip.String())
+	}
+	sans = append(sans, cert.EmailAddresses...)
+	for _, uri := range cert.URIs {
+		sans = append(sans, uri.String())
+	}
+
+	return sans, nil
+}
+
 func pemEncode(thing []byte, kind string) (string, error) {
 	buf := bytes.NewBuffer(nil)
 	err := pem.Encode(buf, &pem.Block{Type: kind, Bytes: thing})

+ 54 - 0
runtime/template/v2/pem_test.go

@@ -18,6 +18,7 @@ package template
 
 import (
 	"os"
+	"slices"
 	"testing"
 )
 
@@ -374,3 +375,56 @@ func readCertificates(certFiles []string) ([]byte, error) {
 	}
 	return certificates, nil
 }
+
+func TestCertSANs(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		want    []string
+		wantErr bool
+	}{
+		{
+			name:  "extract DNS SANs from cert",
+			input: certData,
+			want:  []string{"gooble.com"},
+		},
+		{
+			name:    "invalid PEM input",
+			input:   "not a pem",
+			wantErr: true,
+		},
+		{
+			name:    "empty input",
+			input:   "",
+			wantErr: true,
+		},
+		{
+			name:  "cert with junk before PEM",
+			input: "some junk\n" + certData,
+			want:  []string{"gooble.com"},
+		},
+		{
+			name: "cert from file with all types of SANs",
+			input: func() string {
+				b, err := os.ReadFile("_testdata/sans.crt")
+				if err != nil {
+					panic("test setup failed: " + err.Error())
+				}
+				return string(b)
+			}(),
+			want: []string{"example.com", "www.example.com", "192.168.1.10", "10.0.0.1", "admin@example.com", "https://example.com"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := certSANs(tt.input)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("certSANs() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr && !slices.Equal(got, tt.want) {
+				t.Errorf("certSANs() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 1 - 0
runtime/template/v2/template.go

@@ -48,6 +48,7 @@ var tplFuncs = tpl.FuncMap{
 
 	"filterPEM":       filterPEM,
 	"filterCertChain": filterCertChain,
+	"certSANs":        certSANs,
 
 	"jwkPublicKeyPem":  jwkPublicKeyPem,
 	"jwkPrivateKeyPem": jwkPrivateKeyPem,

+ 58 - 0
runtime/template/v2/template_test.go

@@ -629,6 +629,64 @@ func TestExecute(t *testing.T) {
 			},
 		},
 		{
+			name: "certSANs extract DNS SANs as comma-separated string",
+			tpl: map[string][]byte{
+				"sans": []byte(`{{ .certificate | certSANs | join "," }}`),
+			},
+			data: map[string][]byte{
+				"certificate": []byte(pkcs12Cert),
+			},
+			expectedData: map[string][]byte{
+				"sans": []byte("gooble.com"),
+			},
+		},
+		{
+			name: "certSANs extract first SAN with index",
+			tpl: map[string][]byte{
+				"primary-domain": []byte(`{{ index (.certificate | certSANs) 0 }}`),
+			},
+			data: map[string][]byte{
+				"certificate": []byte(pkcs12Cert),
+			},
+			expectedData: map[string][]byte{
+				"primary-domain": []byte("gooble.com"),
+			},
+		},
+		{
+			name: "certSANs with toJson",
+			tpl: map[string][]byte{
+				"sans-json": []byte(`{{ .certificate | certSANs | toJson }}`),
+			},
+			data: map[string][]byte{
+				"certificate": []byte(pkcs12Cert),
+			},
+			expectedData: map[string][]byte{
+				"sans-json": []byte(`["gooble.com"]`),
+			},
+		},
+		{
+			name: "certSANs combined with filterPEM pipeline",
+			tpl: map[string][]byte{
+				"sans": []byte(`{{ .secret | filterPEM "CERTIFICATE" | certSANs | join "," }}`),
+			},
+			data: map[string][]byte{
+				"secret": []byte(pkcs12Key + pkcs12Cert),
+			},
+			expectedData: map[string][]byte{
+				"sans": []byte("gooble.com"),
+			},
+		},
+		{
+			name: "certSANs with invalid PEM",
+			tpl: map[string][]byte{
+				"sans": []byte(`{{ .certificate | certSANs }}`),
+			},
+			data: map[string][]byte{
+				"certificate": []byte("not-a-pem"),
+			},
+			expErr: "failed to decode PEM block",
+		},
+		{
 			name: "htpasswd with sha1",
 			tpl: map[string][]byte{
 				".htpasswd": []byte(`{{ htpasswd .username .password "sha" }}`),