From 326607e356804aa2628dc42cbdbfe5cf08798287 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 2 Sep 2026 03:56:02 -0400 Subject: [PATCH] feat: add fingerprint and serial to certs:report The report described an installed certificate without carrying anything that identified it, so the only exact way to tell whether an app was serving a given certificate was to pull the full PEM back with `certs:show` and compare bytes. That moves certificate material off the server on every check, and the same command with `key` returns the private key. The new `--ssl-fingerprint` and `--ssl-serial` flags answer the same question in a single report read that discloses nothing sensitive. The sample output in the ssl documentation has also been corrected to match what the command actually prints. --- docs/configuration/ssl.md | 52 ++++++++++++---------- plugins/certs/report.go | 83 +++++++++++++++++++++++++----------- plugins/certs/report_test.go | 27 ++++++++++++ tests/unit/certs.bats | 81 +++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 47 deletions(-) diff --git a/docs/configuration/ssl.md b/docs/configuration/ssl.md index a5f492f24..e9983bd0d 100644 --- a/docs/configuration/ssl.md +++ b/docs/configuration/ssl.md @@ -85,21 +85,25 @@ dokku certs:report ``` ``` -=====> node-js-app - Ssl dir: /home/dokku/node-js-app/tls - Ssl enabled: true - Ssl hostnames: *.node-js-app.org node-js-app.org - Ssl expires at: Oct 5 23:59:59 2019 GMT - Ssl issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA - Ssl starts at: Oct 5 00:00:00 2016 GMT - Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.node-js-app.org - Ssl verified: self signed. -=====> python-app - Ssl dir: /home/dokku/python-app/tls - Ssl enabled: false - Ssl hostnames: +=====> node-js-app ssl information + Ssl dir: /home/dokku/node-js-app/tls + Ssl enabled: true + Ssl expires at: Oct 5 23:59:59 2019 GMT + Ssl fingerprint: 9E:2F:0B:74:C3:5A:81:D6:4F:AA:12:38:E7:6B:90:5C:D1:47:2E:8A:B3:65:F9:0D:28:C4:71:AE:53:80:6F:12 + Ssl hostnames: *.node-js-app.org node-js-app.org + Ssl issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA + Ssl serial: 2F4B8C1D6E9A0357B8C2D4E6F8091A2B + Ssl starts at: Oct 5 00:00:00 2016 GMT + Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.node-js-app.org + Ssl verified: self signed +=====> python-app ssl information + Ssl dir: /home/dokku/python-app/tls + Ssl enabled: false Ssl expires at: + Ssl fingerprint: + Ssl hostnames: Ssl issuer: + Ssl serial: Ssl starts at: Ssl subject: Ssl verified: @@ -113,14 +117,16 @@ dokku certs:report node-js-app ``` =====> node-js-app ssl information - Ssl dir: /home/dokku/node-js-app/tls - Ssl enabled: true - Ssl hostnames: *.dokku.org dokku.org - Ssl expires at: Oct 5 23:59:59 2019 GMT - Ssl issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA - Ssl starts at: Oct 5 00:00:00 2016 GMT - Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.dokku.org - Ssl verified: self signed. + Ssl dir: /home/dokku/node-js-app/tls + Ssl enabled: true + Ssl expires at: Oct 5 23:59:59 2019 GMT + Ssl fingerprint: 9E:2F:0B:74:C3:5A:81:D6:4F:AA:12:38:E7:6B:90:5C:D1:47:2E:8A:B3:65:F9:0D:28:C4:71:AE:53:80:6F:12 + Ssl hostnames: *.dokku.org dokku.org + Ssl issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA + Ssl serial: 2F4B8C1D6E9A0357B8C2D4E6F8091A2B + Ssl starts at: Oct 5 00:00:00 2016 GMT + Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.dokku.org + Ssl verified: self signed ``` You can pass flags which will output only the value of the specific information you want. For example: @@ -164,6 +170,8 @@ The following flags surface in `certs:report` but are not managed by `certs:set` | `--ssl-hostnames` | Hostnames the certificate covers (CN plus Subject Alternative Names) | | `--ssl-issuer` | Certificate issuer DN | | `--ssl-subject` | Certificate subject DN | -| `--ssl-verified` | `true` if the certificate chain verifies against the system CA bundle | +| `--ssl-fingerprint` | SHA-256 digest of the leaf certificate's DER encoding, the value `openssl x509 -noout -fingerprint -sha256` prints | +| `--ssl-serial` | Serial number of the leaf certificate | +| `--ssl-verified` | `verified by a certificate authority` when the certificate chain verifies against the system CA bundle, `self signed` otherwise | | `--ssl-expires-at` | Certificate `notAfter` timestamp | | `--ssl-starts-at` | Certificate `notBefore` timestamp | diff --git a/plugins/certs/report.go b/plugins/certs/report.go index 6bf3f2392..206656c5e 100644 --- a/plugins/certs/report.go +++ b/plugins/certs/report.go @@ -20,14 +20,16 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { return err } flags = map[string]common.ReportFunc{ - "--ssl-dir": reportSSLDir, - "--ssl-enabled": reportSSLEnabled, - "--ssl-hostnames": reportSSLHostnames, - "--ssl-expires-at": reportSSLExpiresAt, - "--ssl-issuer": reportSSLIssuer, - "--ssl-starts-at": reportSSLStartsAt, - "--ssl-subject": reportSSLSubject, - "--ssl-verified": reportSSLVerified, + "--ssl-dir": reportSSLDir, + "--ssl-enabled": reportSSLEnabled, + "--ssl-hostnames": reportSSLHostnames, + "--ssl-expires-at": reportSSLExpiresAt, + "--ssl-fingerprint": reportSSLFingerprint, + "--ssl-issuer": reportSSLIssuer, + "--ssl-serial": reportSSLSerial, + "--ssl-starts-at": reportSSLStartsAt, + "--ssl-subject": reportSSLSubject, + "--ssl-verified": reportSSLVerified, } } @@ -78,6 +80,21 @@ func opensslCertText(appName string) string { return result.Stdout } +// opensslCertOption runs "openssl x509 -in -noout" with the given +// output options and returns the trimmed stdout, or an empty string when openssl +// cannot read the certificate. +func opensslCertOption(appName string, args ...string) string { + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: "openssl", + Args: append([]string{"x509", "-in", filepath.Join(certTLSPath(appName), "server.crt"), "-noout"}, args...), + }) + if err != nil { + return "" + } + + return result.StdoutContents() +} + func reportSSLDir(appName string) string { return certTLSPath(appName) } @@ -124,6 +141,37 @@ func reportSSLStartsAt(appName string) string { return "" } +func reportSSLFingerprint(appName string) string { + if !isSSLEnabled(appName) { + return "" + } + + return formatSSLHexField(opensslCertOption(appName, "-fingerprint", "-sha256")) +} + +func reportSSLSerial(appName string) string { + if !isSSLEnabled(appName) { + return "" + } + + return formatSSLHexField(opensslCertOption(appName, "-serial")) +} + +// formatSSLHexField extracts the value from an openssl "key=HEX" output line, as +// printed by "-fingerprint" and "-serial". The key is discarded rather than +// matched against a literal: OpenSSL 3.x labels the digest "sha256" where +// OpenSSL 1.x and LibreSSL label it "SHA256". Neither a fingerprint nor a serial +// can contain an "=", so cutting at the first one is unambiguous. Only hex +// values may be passed through here, as uppercasing would corrupt a subject. +func formatSSLHexField(out string) string { + _, value, found := strings.Cut(strings.TrimSpace(out), "=") + if !found { + return "" + } + + return strings.ToUpper(strings.TrimSpace(value)) +} + func reportSSLIssuer(appName string) string { if !isSSLEnabled(appName) { return "" @@ -144,15 +192,7 @@ func reportSSLSubject(appName string) string { return "" } - result, err := common.CallExecCommand(common.ExecCommandInput{ - Command: "openssl", - Args: []string{"x509", "-in", filepath.Join(certTLSPath(appName), "server.crt"), "-noout", "-subject", "-nameopt", "compat"}, - }) - if err != nil { - return "" - } - - return formatSSLSubject(result.StdoutContents()) + return formatSSLSubject(opensslCertOption(appName, "-subject", "-nameopt", "compat")) } // formatSSLSubject normalizes an openssl "-subject -nameopt compat" line into a @@ -203,14 +243,7 @@ func reportSSLHostnames(appName string) string { return "" } - subject := "" - subjectResult, err := common.CallExecCommand(common.ExecCommandInput{ - Command: "openssl", - Args: []string{"x509", "-in", filepath.Join(certTLSPath(appName), "server.crt"), "-noout", "-subject", "-nameopt", "RFC2253"}, - }) - if err == nil { - subject = subjectResult.StdoutContents() - } + subject := opensslCertOption(appName, "-subject", "-nameopt", "RFC2253") return strings.Join(sslHostnames(subject, opensslCertText(appName)), " ") } diff --git a/plugins/certs/report_test.go b/plugins/certs/report_test.go index e5182d097..e293b99f6 100644 --- a/plugins/certs/report_test.go +++ b/plugins/certs/report_test.go @@ -112,3 +112,30 @@ func TestFormatSSLSubject(t *testing.T) { }) } } + +func TestFormatSSLHexField(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"fingerprint openssl 1.x label", "SHA256 Fingerprint=B7:DF:D5:84:C6:2E:27:BF", "B7:DF:D5:84:C6:2E:27:BF"}, + {"fingerprint openssl 3.x label", "sha256 Fingerprint=B7:DF:D5:84:C6:2E:27:BF", "B7:DF:D5:84:C6:2E:27:BF"}, + {"fingerprint lowercase digest", "sha256 Fingerprint=b7:df:d5:84:c6:2e:27:bf", "B7:DF:D5:84:C6:2E:27:BF"}, + {"serial", "serial=322844AD8CD6D4FF76B05C50833AB91DEEDA2AD1", "322844AD8CD6D4FF76B05C50833AB91DEEDA2AD1"}, + {"serial shorter", "serial=C46823E5D7C09FC9", "C46823E5D7C09FC9"}, + {"serial lowercase", "serial=c46823e5d7c09fc9", "C46823E5D7C09FC9"}, + {"serial negative", "serial=-1F2E", "-1F2E"}, + {"surrounding whitespace", " serial=C46823E5D7C09FC9 \n", "C46823E5D7C09FC9"}, + {"no separator", "unable to load certificate", ""}, + {"empty", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := formatSSLHexField(tc.in); got != tc.want { + t.Errorf("formatSSLHexField(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/tests/unit/certs.bats b/tests/unit/certs.bats index eb9d74802..cecb754ab 100644 --- a/tests/unit/certs.bats +++ b/tests/unit/certs.bats @@ -53,6 +53,18 @@ teardown() { assert_success assert_output_contains "/$TEST_APP/tls" + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-fingerprint" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-serial" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-invalid-flag" echo "output: $output" echo "status: $status" @@ -118,6 +130,75 @@ teardown() { assert_output "node-js-app.dokku.me" } +@test "(certs:report) reports the certificate fingerprint and serial" { + local EXPECTED_FINGERPRINT EXPECTED_SERIAL + EXPECTED_FINGERPRINT="$(openssl x509 -in "$BATS_TMPDIR/tls/server.crt" -noout -fingerprint -sha256 | cut -d= -f2-)" + EXPECTED_SERIAL="$(openssl x509 -in "$BATS_TMPDIR/tls/server.crt" -noout -serial | cut -d= -f2-)" + + run /bin/bash -c "dokku certs:add $TEST_APP $BATS_TMPDIR/tls/server.crt $BATS_TMPDIR/tls/server.key" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-fingerprint" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$EXPECTED_FINGERPRINT" + + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-serial" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$EXPECTED_SERIAL" + + run /bin/bash -c "dokku certs:report $TEST_APP --format json | jq -r '.fingerprint'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$EXPECTED_FINGERPRINT" + + run /bin/bash -c "dokku certs:report $TEST_APP --format json | jq -r '.serial'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$EXPECTED_SERIAL" +} + +@test "(certs:report) fingerprint and serial follow a replaced certificate" { + local EXPECTED_FINGERPRINT EXPECTED_SERIAL + EXPECTED_FINGERPRINT="$(openssl x509 -in "$BATS_TMPDIR/tls/domain.com.crt" -noout -fingerprint -sha256 | cut -d= -f2-)" + EXPECTED_SERIAL="$(openssl x509 -in "$BATS_TMPDIR/tls/domain.com.crt" -noout -serial | cut -d= -f2-)" + + run /bin/bash -c "dokku certs:add $TEST_APP $BATS_TMPDIR/tls/server.crt $BATS_TMPDIR/tls/server.key" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-fingerprint" + echo "output: $output" + echo "status: $status" + assert_success + assert_not_output "$EXPECTED_FINGERPRINT" + + run /bin/bash -c "dokku certs:update $TEST_APP $BATS_TMPDIR/tls/domain.com.crt $BATS_TMPDIR/tls/domain.com.key" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-fingerprint" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$EXPECTED_FINGERPRINT" + + run /bin/bash -c "dokku certs:report $TEST_APP --ssl-serial" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$EXPECTED_SERIAL" +} + @test "(certs:report) reports the CN and all SANs as hostnames" { local SANS_TLS="$BATS_TMPDIR/tls-sans" mkdir -p "$SANS_TLS"