From 87b4c12274a86d99f368e8be5f8ba5c094d5766c Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Fri, 19 Feb 2021 20:42:00 +0000 Subject: [PATCH] Analyze TLS certificate --- examples/preflight/host/certificate.yaml | 30 ++ examples/preflight/host/sample.yaml | 23 ++ pkg/analyze/analyzer.go | 7 + pkg/analyze/host_certificate.go | 59 ++++ pkg/analyze/host_certificate_test.go | 333 ++++++++++++++++++ .../v1beta2/hostanalyzer_shared.go | 8 + .../v1beta2/hostcollector_shared.go | 7 + .../v1beta2/zz_generated.deepcopy.go | 53 +++ pkg/collect/host_certificate.go | 59 ++++ pkg/collect/host_collector.go | 2 + 10 files changed, 581 insertions(+) create mode 100644 examples/preflight/host/certificate.yaml create mode 100644 pkg/analyze/host_certificate.go create mode 100644 pkg/analyze/host_certificate_test.go create mode 100644 pkg/collect/host_certificate.go diff --git a/examples/preflight/host/certificate.yaml b/examples/preflight/host/certificate.yaml new file mode 100644 index 000000000..1c750813d --- /dev/null +++ b/examples/preflight/host/certificate.yaml @@ -0,0 +1,30 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: HostPreflight +metadata: + name: certificate +spec: + collectors: + - certificate: + certificatePath: /etc/ssl/corp.crt + keyPath: /etc/ssl/corp.key + analyzers: + - certificate: + outcomes: + - fail: + when: "key-pair-missing" + message: Certificate key pair not found in /etc/ssl + - fail: + when: "key-pair-switched" + message: Cert and key pair are switched + - fail: + when: "key-pair-encrypted" + message: Private key is encrypted + - fail: + when: "key-pair-mismatch" + message: Cert and key do not match + - fail: + when: "key-pair-invalid" + message: Certificate key pair is invalid + - pass: + when: "key-pair-valid" + message: Certificate key pair is valid diff --git a/examples/preflight/host/sample.yaml b/examples/preflight/host/sample.yaml index 102862f84..b7f5d3303 100644 --- a/examples/preflight/host/sample.yaml +++ b/examples/preflight/host/sample.yaml @@ -5,6 +5,9 @@ metadata: spec: collectors: - blockDevices: {} + - certificate: + certificatePath: /etc/ssl/corp.crt + keyPath: /etc/ssl/corp.key - cpu: {} - diskUsage: collectorName: ephemeral @@ -50,6 +53,26 @@ spec: message: Multiple available block devices - fail: message: No available block devices + - certificate: + outcomes: + - fail: + when: "key-pair-missing" + message: Certificate key pair not found in /etc/ssl + - fail: + when: "key-pair-switched" + message: Cert and key pair are switched + - fail: + when: "key-pair-encrypted" + message: Private key is encrypted + - fail: + when: "key-pair-mismatch" + message: Cert and key do not match + - fail: + when: "key-pair-invalid" + message: Certificate key pair is invalid + - pass: + when: "key-pair-valid" + message: Certificate key pair is valid - cpu: outcomes: - fail: diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go index dc91fdaa2..81acb94ce 100644 --- a/pkg/analyze/analyzer.go +++ b/pkg/analyze/analyzer.go @@ -125,6 +125,13 @@ func HostAnalyze(hostAnalyzer *troubleshootv1beta2.HostAnalyze, getFile getColle } return []*AnalyzeResult{result}, nil } + if hostAnalyzer.Certificate != nil { + result, err := analyzeHostCertificate(hostAnalyzer.Certificate, getFile) + if err != nil { + return nil, err + } + return []*AnalyzeResult{result}, nil + } return nil, errors.New("invalid analyzer") } diff --git a/pkg/analyze/host_certificate.go b/pkg/analyze/host_certificate.go new file mode 100644 index 000000000..a31a79be5 --- /dev/null +++ b/pkg/analyze/host_certificate.go @@ -0,0 +1,59 @@ +package analyzer + +import ( + "path/filepath" + + "github.com/pkg/errors" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" +) + +func analyzeHostCertificate(hostAnalyzer *troubleshootv1beta2.CertificateAnalyze, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) { + collectorName := hostAnalyzer.CollectorName + if collectorName == "" { + collectorName = "certificate" + } + name := filepath.Join("certificate", collectorName+".json") + contents, err := getCollectedFileContents(name) + if err != nil { + return nil, errors.Wrap(err, "failed to get collected file") + } + status := string(contents) + + result := AnalyzeResult{} + + title := hostAnalyzer.CheckName + if title == "" { + title = "Certificate Key Pair" + } + result.Title = title + + for _, outcome := range hostAnalyzer.Outcomes { + if outcome.Fail != nil { + if outcome.Fail.When == "" || outcome.Fail.When == status { + result.IsFail = true + result.Message = outcome.Fail.Message + result.URI = outcome.Fail.URI + + return &result, nil + } + } else if outcome.Warn != nil { + if outcome.Warn.When == "" || outcome.Warn.When == status { + result.IsWarn = true + result.Message = outcome.Warn.Message + result.URI = outcome.Warn.URI + + return &result, nil + } + } else if outcome.Pass != nil { + if outcome.Pass.When == "" || outcome.Pass.When == status { + result.IsPass = true + result.Message = outcome.Pass.Message + result.URI = outcome.Pass.URI + + return &result, nil + } + } + } + + return &result, nil +} diff --git a/pkg/analyze/host_certificate_test.go b/pkg/analyze/host_certificate_test.go new file mode 100644 index 000000000..d7a001444 --- /dev/null +++ b/pkg/analyze/host_certificate_test.go @@ -0,0 +1,333 @@ +package analyzer + +import ( + "testing" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnalyzeCertificate(t *testing.T) { + tests := []struct { + name string + status string + hostAnalyzer *troubleshootv1beta2.CertificateAnalyze + result *AnalyzeResult + expectErr bool + }{ + { + name: "key-pair-valid", + status: collect.KeyPairValid, + hostAnalyzer: &troubleshootv1beta2.CertificateAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-missing", + Message: "Certificate key pair not found", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-switched", + Message: "Public and private keys switched", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-encrypted", + Message: "Private key is encrypted", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-mismatch", + Message: "Public and private keys don't match", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-invalid", + Message: "Certificate key pair is invalid", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-valid", + Message: "Certificate key pair is valid", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Certificate Key Pair", + IsPass: true, + Message: "Certificate key pair is valid", + }, + }, + { + name: "key-pair-invalid", + status: collect.KeyPairInvalid, + hostAnalyzer: &troubleshootv1beta2.CertificateAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-missing", + Message: "Certificate key pair not found", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-switched", + Message: "Public and private keys switched", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-encrypted", + Message: "Private key is encrypted", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-mismatch", + Message: "Public and private keys don't match", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-invalid", + Message: "Certificate key pair is invalid", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-valid", + Message: "Certificate key pair is valid", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Certificate Key Pair", + IsFail: true, + Message: "Certificate key pair is invalid", + }, + }, + { + name: "key-pair-missing", + status: collect.KeyPairMissing, + hostAnalyzer: &troubleshootv1beta2.CertificateAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-missing", + Message: "Certificate key pair not found", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-switched", + Message: "Public and private keys switched", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-encrypted", + Message: "Private key is encrypted", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-mismatch", + Message: "Public and private keys don't match", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-invalid", + Message: "Certificate key pair is invalid", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-valid", + Message: "Certificate key pair is valid", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Certificate Key Pair", + IsFail: true, + Message: "Certificate key pair not found", + }, + }, + { + name: "key-pair-switched", + status: collect.KeyPairSwitched, + hostAnalyzer: &troubleshootv1beta2.CertificateAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-missing", + Message: "Certificate key pair not found", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-switched", + Message: "Public and private keys switched", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-encrypted", + Message: "Private key is encrypted", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-mismatch", + Message: "Public and private keys don't match", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-invalid", + Message: "Certificate key pair is invalid", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-valid", + Message: "Certificate key pair is valid", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Certificate Key Pair", + IsFail: true, + Message: "Public and private keys switched", + }, + }, + { + name: "key-pair-encrypted", + status: collect.KeyPairEncrypted, + hostAnalyzer: &troubleshootv1beta2.CertificateAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-missing", + Message: "Certificate key pair not found", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-switched", + Message: "Public and private keys switched", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-encrypted", + Message: "Private key is encrypted", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-mismatch", + Message: "Public and private keys don't match", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-invalid", + Message: "Certificate key pair is invalid", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-valid", + Message: "Certificate key pair is valid", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Certificate Key Pair", + IsFail: true, + Message: "Private key is encrypted", + }, + }, + { + name: "key-pair-mismatch", + status: collect.KeyPairMismatch, + hostAnalyzer: &troubleshootv1beta2.CertificateAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-missing", + Message: "Certificate key pair not found", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-switched", + Message: "Public and private keys switched", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-encrypted", + Message: "Private key is mismatch", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-mismatch", + Message: "Public and private keys don't match", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-invalid", + Message: "Certificate key pair is invalid", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "key-pair-valid", + Message: "Certificate key pair is valid", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Certificate Key Pair", + IsFail: true, + Message: "Public and private keys don't match", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + + getCollectedFileContents := func(filename string) ([]byte, error) { + return []byte(test.status), nil + } + + result, err := analyzeHostCertificate(test.hostAnalyzer, getCollectedFileContents) + if test.expectErr { + req.Error(err) + } else { + req.NoError(err) + } + + assert.Equal(t, test.result, result) + }) + } +} diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go index aebed8333..5cc25c80c 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go @@ -67,6 +67,12 @@ type FilesystemPerformanceAnalyze struct { Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` } +type CertificateAnalyze struct { + AnalyzeMeta `json:",inline" yaml:",inline"` + CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` + Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` +} + type HostAnalyze struct { CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"` // @@ -90,4 +96,6 @@ type HostAnalyze struct { IPV4Interfaces *IPV4InterfacesAnalyze `json:"ipv4Interfaces" yaml:"ipv4Interfaces"` FilesystemPerformance *FilesystemPerformanceAnalyze `json:"filesystemPerformance" yaml:"filesystemPerformance"` + + Certificate *CertificateAnalyze `json:"certificate" yaml:"certificate"` } diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index 013b6c3fd..b681b83a1 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -82,6 +82,12 @@ type FilesystemPerformance struct { Datasync bool `json:"datasync,omitempty"` } +type Certificate struct { + HostCollectorMeta `json:",inline" yaml:",inline"` + CertificatePath string `json:"certificatePath" yaml:"certificatepath"` + KeyPath string `json:"keyPath" yaml:"keyPath"` +} + type HostCollect struct { CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` @@ -96,6 +102,7 @@ type HostCollect struct { BlockDevices *HostBlockDevices `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` TCPConnect *TCPConnect `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance" yaml:"filesystemPerformance"` + Certificate *Certificate `json:"certificate" yaml:"certificate" yaml:"certificate"` } func (c *HostCollect) GetName() string { diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 44250298a..e4dbed222 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -400,6 +400,49 @@ func (in *CephStatusAnalyze) DeepCopy() *CephStatusAnalyze { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Certificate) DeepCopyInto(out *Certificate) { + *out = *in + out.HostCollectorMeta = in.HostCollectorMeta +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Certificate. +func (in *Certificate) DeepCopy() *Certificate { + if in == nil { + return nil + } + out := new(Certificate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CertificateAnalyze) DeepCopyInto(out *CertificateAnalyze) { + *out = *in + out.AnalyzeMeta = in.AnalyzeMeta + if in.Outcomes != nil { + in, out := &in.Outcomes, &out.Outcomes + *out = make([]*Outcome, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Outcome) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CertificateAnalyze. +func (in *CertificateAnalyze) DeepCopy() *CertificateAnalyze { + if in == nil { + return nil + } + out := new(CertificateAnalyze) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterInfo) DeepCopyInto(out *ClusterInfo) { *out = *in @@ -1230,6 +1273,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) { *out = new(FilesystemPerformanceAnalyze) (*in).DeepCopyInto(*out) } + if in.Certificate != nil { + in, out := &in.Certificate, &out.Certificate + *out = new(CertificateAnalyze) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze. @@ -1326,6 +1374,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(FilesystemPerformance) **out = **in } + if in.Certificate != nil { + in, out := &in.Certificate, &out.Certificate + *out = new(Certificate) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect. diff --git a/pkg/collect/host_certificate.go b/pkg/collect/host_certificate.go new file mode 100644 index 000000000..16ffa6399 --- /dev/null +++ b/pkg/collect/host_certificate.go @@ -0,0 +1,59 @@ +package collect + +import ( + "bytes" + "crypto/tls" + "io/ioutil" + "path/filepath" + "strings" +) + +const KeyPairMissing = "key-pair-missing" +const KeyPairSwitched = "key-pair-switched" +const KeyPairEncrypted = "key-pair-encrypted" +const KeyPairMismatch = "key-pair-mismatch" +const KeyPairInvalid = "key-pair-invalid" +const KeyPairValid = "key-pair-valid" + +func HostCertificate(c *HostCollector) (map[string][]byte, error) { + var result = KeyPairValid + + _, err := tls.LoadX509KeyPair(c.Collect.Certificate.CertificatePath, c.Collect.Certificate.KeyPath) + if err != nil { + if strings.Contains(err.Error(), "no such file") { + result = KeyPairMissing + } else if strings.Contains(err.Error(), "PEM inputs may have been switched") { + result = KeyPairSwitched + } else if strings.Contains(err.Error(), "found a certificate rather than a key") { + result = KeyPairSwitched + } else if strings.Contains(err.Error(), "private key does not match public key") { + result = KeyPairMismatch + } else if strings.Contains(err.Error(), "failed to parse private key") { + if encrypted, _ := isEncryptedKey(c.Collect.Certificate.KeyPath); encrypted { + result = KeyPairEncrypted + } else { + result = KeyPairInvalid + } + } else { + result = KeyPairInvalid + } + } + + collectorName := c.Collect.Certificate.CollectorName + if collectorName == "" { + collectorName = "certificate" + } + name := filepath.Join("certificate", collectorName+".json") + + return map[string][]byte{ + name: []byte(result), + }, nil +} + +func isEncryptedKey(filename string) (bool, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + return false, err + } + return bytes.Contains(data, []byte("ENCRYPTED")), nil +} diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index 6fddc9b59..a28fb0e8c 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -42,6 +42,8 @@ func (c *HostCollector) RunCollectorSync() (result map[string][]byte, err error) result, err = HostIPV4Interfaces(c) } else if c.Collect.FilesystemPerformance != nil { result, err = HostFilesystemPerformance(c) + } else if c.Collect.Certificate != nil { + result, err = HostCertificate(c) } else { err = errors.New("no spec found to run") return