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.
This commit is contained in:
@@ -85,21 +85,25 @@ dokku certs:report
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
=====> node-js-app
|
=====> node-js-app ssl information
|
||||||
Ssl dir: /home/dokku/node-js-app/tls
|
Ssl dir: /home/dokku/node-js-app/tls
|
||||||
Ssl enabled: true
|
Ssl enabled: true
|
||||||
Ssl hostnames: *.node-js-app.org node-js-app.org
|
Ssl expires at: Oct 5 23:59:59 2019 GMT
|
||||||
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 issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA
|
Ssl hostnames: *.node-js-app.org node-js-app.org
|
||||||
Ssl starts at: Oct 5 00:00:00 2016 GMT
|
Ssl issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA
|
||||||
Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.node-js-app.org
|
Ssl serial: 2F4B8C1D6E9A0357B8C2D4E6F8091A2B
|
||||||
Ssl verified: self signed.
|
Ssl starts at: Oct 5 00:00:00 2016 GMT
|
||||||
=====> python-app
|
Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.node-js-app.org
|
||||||
Ssl dir: /home/dokku/python-app/tls
|
Ssl verified: self signed
|
||||||
Ssl enabled: false
|
=====> python-app ssl information
|
||||||
Ssl hostnames:
|
Ssl dir: /home/dokku/python-app/tls
|
||||||
|
Ssl enabled: false
|
||||||
Ssl expires at:
|
Ssl expires at:
|
||||||
|
Ssl fingerprint:
|
||||||
|
Ssl hostnames:
|
||||||
Ssl issuer:
|
Ssl issuer:
|
||||||
|
Ssl serial:
|
||||||
Ssl starts at:
|
Ssl starts at:
|
||||||
Ssl subject:
|
Ssl subject:
|
||||||
Ssl verified:
|
Ssl verified:
|
||||||
@@ -113,14 +117,16 @@ dokku certs:report node-js-app
|
|||||||
|
|
||||||
```
|
```
|
||||||
=====> node-js-app ssl information
|
=====> node-js-app ssl information
|
||||||
Ssl dir: /home/dokku/node-js-app/tls
|
Ssl dir: /home/dokku/node-js-app/tls
|
||||||
Ssl enabled: true
|
Ssl enabled: true
|
||||||
Ssl hostnames: *.dokku.org dokku.org
|
Ssl expires at: Oct 5 23:59:59 2019 GMT
|
||||||
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 issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA
|
Ssl hostnames: *.dokku.org dokku.org
|
||||||
Ssl starts at: Oct 5 00:00:00 2016 GMT
|
Ssl issuer: C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO RSA Domain Validation Secure Server CA
|
||||||
Ssl subject: OU=Domain Control Validated; OU=PositiveSSL Wildcard; CN=*.dokku.org
|
Ssl serial: 2F4B8C1D6E9A0357B8C2D4E6F8091A2B
|
||||||
Ssl verified: self signed.
|
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:
|
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-hostnames` | Hostnames the certificate covers (CN plus Subject Alternative Names) |
|
||||||
| `--ssl-issuer` | Certificate issuer DN |
|
| `--ssl-issuer` | Certificate issuer DN |
|
||||||
| `--ssl-subject` | Certificate subject 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-expires-at` | Certificate `notAfter` timestamp |
|
||||||
| `--ssl-starts-at` | Certificate `notBefore` timestamp |
|
| `--ssl-starts-at` | Certificate `notBefore` timestamp |
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ func ReportSingleApp(appName string, format string, infoFlag string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
flags = map[string]common.ReportFunc{
|
flags = map[string]common.ReportFunc{
|
||||||
"--ssl-dir": reportSSLDir,
|
"--ssl-dir": reportSSLDir,
|
||||||
"--ssl-enabled": reportSSLEnabled,
|
"--ssl-enabled": reportSSLEnabled,
|
||||||
"--ssl-hostnames": reportSSLHostnames,
|
"--ssl-hostnames": reportSSLHostnames,
|
||||||
"--ssl-expires-at": reportSSLExpiresAt,
|
"--ssl-expires-at": reportSSLExpiresAt,
|
||||||
"--ssl-issuer": reportSSLIssuer,
|
"--ssl-fingerprint": reportSSLFingerprint,
|
||||||
"--ssl-starts-at": reportSSLStartsAt,
|
"--ssl-issuer": reportSSLIssuer,
|
||||||
"--ssl-subject": reportSSLSubject,
|
"--ssl-serial": reportSSLSerial,
|
||||||
"--ssl-verified": reportSSLVerified,
|
"--ssl-starts-at": reportSSLStartsAt,
|
||||||
|
"--ssl-subject": reportSSLSubject,
|
||||||
|
"--ssl-verified": reportSSLVerified,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,21 @@ func opensslCertText(appName string) string {
|
|||||||
return result.Stdout
|
return result.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opensslCertOption runs "openssl x509 -in <server.crt> -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 {
|
func reportSSLDir(appName string) string {
|
||||||
return certTLSPath(appName)
|
return certTLSPath(appName)
|
||||||
}
|
}
|
||||||
@@ -124,6 +141,37 @@ func reportSSLStartsAt(appName string) string {
|
|||||||
return ""
|
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 {
|
func reportSSLIssuer(appName string) string {
|
||||||
if !isSSLEnabled(appName) {
|
if !isSSLEnabled(appName) {
|
||||||
return ""
|
return ""
|
||||||
@@ -144,15 +192,7 @@ func reportSSLSubject(appName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := common.CallExecCommand(common.ExecCommandInput{
|
return formatSSLSubject(opensslCertOption(appName, "-subject", "-nameopt", "compat"))
|
||||||
Command: "openssl",
|
|
||||||
Args: []string{"x509", "-in", filepath.Join(certTLSPath(appName), "server.crt"), "-noout", "-subject", "-nameopt", "compat"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatSSLSubject(result.StdoutContents())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatSSLSubject normalizes an openssl "-subject -nameopt compat" line into a
|
// formatSSLSubject normalizes an openssl "-subject -nameopt compat" line into a
|
||||||
@@ -203,14 +243,7 @@ func reportSSLHostnames(appName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := ""
|
subject := opensslCertOption(appName, "-subject", "-nameopt", "RFC2253")
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(sslHostnames(subject, opensslCertText(appName)), " ")
|
return strings.Join(sslHostnames(subject, opensslCertText(appName)), " ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ teardown() {
|
|||||||
assert_success
|
assert_success
|
||||||
assert_output_contains "/$TEST_APP/tls"
|
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"
|
run /bin/bash -c "dokku certs:report $TEST_APP --ssl-invalid-flag"
|
||||||
echo "output: $output"
|
echo "output: $output"
|
||||||
echo "status: $status"
|
echo "status: $status"
|
||||||
@@ -118,6 +130,75 @@ teardown() {
|
|||||||
assert_output "node-js-app.dokku.me"
|
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" {
|
@test "(certs:report) reports the CN and all SANs as hostnames" {
|
||||||
local SANS_TLS="$BATS_TMPDIR/tls-sans"
|
local SANS_TLS="$BATS_TMPDIR/tls-sans"
|
||||||
mkdir -p "$SANS_TLS"
|
mkdir -p "$SANS_TLS"
|
||||||
|
|||||||
Reference in New Issue
Block a user