From d1d9737da6f1dabd3350e5fdd80f7003ed207a81 Mon Sep 17 00:00:00 2001 From: Sven Rebhan <36194019+srebhan@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:39:15 +0100 Subject: [PATCH] feat(inputs.x509_cert): Add tag for certificate type-classification (#12656) --- plugins/inputs/x509_cert/README.md | 1 + plugins/inputs/x509_cert/x509_cert.go | 118 +++++++++--- plugins/inputs/x509_cert/x509_cert_test.go | 201 +++++++++++++++++++++ testutil/metric.go | 15 ++ 4 files changed, 311 insertions(+), 24 deletions(-) diff --git a/plugins/inputs/x509_cert/README.md b/plugins/inputs/x509_cert/README.md index 06c492b33..56fe9c21c 100644 --- a/plugins/inputs/x509_cert/README.md +++ b/plugins/inputs/x509_cert/README.md @@ -54,6 +54,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. - x509_cert - tags: + - type - "leaf", "intermediate" or "root" classification of certificate - source - source of the certificate - organization - organizational_unit diff --git a/plugins/inputs/x509_cert/x509_cert.go b/plugins/inputs/x509_cert/x509_cert.go index e3687be9f..0bdb81d7d 100644 --- a/plugins/inputs/x509_cert/x509_cert.go +++ b/plugins/inputs/x509_cert/x509_cert.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "crypto/x509" _ "embed" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -50,6 +51,8 @@ type X509Cert struct { tlsCfg *tls.Config locations []*url.URL globpaths []*globpath.GlobPath + + classification map[string]string } func (*X509Cert) SampleConfig() string { @@ -99,15 +102,23 @@ func (c *X509Cert) Gather(acc telegraf.Accumulator) error { acc.AddError(fmt.Errorf("cannot get SSL cert '%s': %s", location, err.Error())) } - dnsName := c.serverName(location) - for i, cert := range certs { - fields := getFields(cert, now) - tags := getTags(cert, location.String()) + // Add all returned certs to the pool of intermediates except for + // the leaf node which has to come first + intermediates := x509.NewCertPool() + if len(certs) > 1 { + for _, c := range certs[1:] { + intermediates.AddCert(c) + } + } + dnsName := c.serverName(location) + results := make([]error, 0, len(certs)) + c.classification = make(map[string]string) + for _, cert := range certs { // The first certificate is the leaf/end-entity certificate which // needs DNS name validation against the URL hostname. opts := x509.VerifyOptions{ - Intermediates: x509.NewCertPool(), + Intermediates: intermediates, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, Roots: c.tlsCfg.RootCAs, DNSName: dnsName, @@ -115,29 +126,20 @@ func (c *X509Cert) Gather(acc telegraf.Accumulator) error { // Reset DNS name to only use it for the leaf node dnsName = "" - // Add all returned certs to the pool if intermediates except for - // the leaf node and ourself - for j, c := range certs[1:] { - if i+1 != j { - opts.Intermediates.AddCert(c) - } - } + // Do the processing + results = append(results, c.processCertificate(cert, opts)) + } - if _, err := cert.Verify(opts); err == nil { + for i, cert := range certs { + fields := getFields(cert, now) + tags := getTags(cert, location.String()) + + // Extract the verification result + err := results[i] + if err == nil { tags["verification"] = "valid" fields["verification_code"] = 0 } else { - c.Log.Debugf("Invalid certificate at index %2d!", i) - c.Log.Debugf(" cert DNS names: %v", cert.DNSNames) - c.Log.Debugf(" cert IP addresses: %v", cert.IPAddresses) - c.Log.Debugf(" cert subject: %v", cert.Subject) - c.Log.Debugf(" cert issuer: %v", cert.Issuer) - c.Log.Debugf(" opts.DNSName: %v", opts.DNSName) - c.Log.Debugf(" verify options: %v", opts) - c.Log.Debugf(" verify error: %v", err) - c.Log.Debugf(" location: %v", location) - c.Log.Debugf(" tlsCfg.ServerName: %v", c.tlsCfg.ServerName) - c.Log.Debugf(" ServerName: %v", c.ServerName) tags["verification"] = "invalid" fields["verification_code"] = 1 fields["verification_error"] = err.Error() @@ -192,6 +194,14 @@ func (c *X509Cert) Gather(acc telegraf.Accumulator) error { tags["ocsp_stapled"] = "no" } + // Determine the classification + sig := hex.EncodeToString(cert.Signature) + if class, found := c.classification[sig]; found { + tags["type"] = class + } else { + tags["type"] = "leaf" + } + acc.AddFields("x509_cert", fields, tags) if c.ExcludeRootCerts { break @@ -202,6 +212,66 @@ func (c *X509Cert) Gather(acc telegraf.Accumulator) error { return nil } +func (c *X509Cert) processCertificate(certificate *x509.Certificate, opts x509.VerifyOptions) error { + chains, err := certificate.Verify(opts) + if err != nil { + c.Log.Debugf("Invalid certificate %v", certificate.SerialNumber.Text(16)) + c.Log.Debugf(" cert DNS names: %v", certificate.DNSNames) + c.Log.Debugf(" cert IP addresses: %v", certificate.IPAddresses) + c.Log.Debugf(" cert subject: %v", certificate.Subject) + c.Log.Debugf(" cert issuer: %v", certificate.Issuer) + c.Log.Debugf(" opts.DNSName: %v", opts.DNSName) + c.Log.Debugf(" verify options: %v", opts) + c.Log.Debugf(" verify error: %v", err) + c.Log.Debugf(" tlsCfg.ServerName: %v", c.tlsCfg.ServerName) + c.Log.Debugf(" ServerName: %v", c.ServerName) + } + + // Check if the certificate is a root-certificate. + // The only reliable way to distinguish root certificates from + // intermediates is the fact that root certificates are self-signed, + // i.e. you can verify the certificate with its own public key. + rootErr := certificate.CheckSignature(certificate.SignatureAlgorithm, certificate.RawTBSCertificate, certificate.Signature) + if rootErr == nil { + sig := hex.EncodeToString(certificate.Signature) + c.classification[sig] = "root" + } + + // Identify intermediate certificates + for _, chain := range chains { + // All nodes except the first one are of intermediate or CA type. + // Mark them as such. We never add leaf nodes to the classification + // so in the end if a cert is NOT in the classification it is a true + // leaf node. + for _, cert := range chain[1:] { + // Never change a classification if we already have one + sig := hex.EncodeToString(cert.Signature) + if _, found := c.classification[sig]; found { + continue + } + + // We found an intermediate certificate which is not a CA. This + // should never happen actually. + if !cert.IsCA { + c.classification[sig] = "unknown" + continue + } + + // The only reliable way to distinguish root certificates from + // intermediates is the fact that root certificates are self-signed, + // i.e. you can verify the certificate with its own public key. + rootErr := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature) + if rootErr != nil { + c.classification[sig] = "intermediate" + } else { + c.classification[sig] = "root" + } + } + } + + return err +} + func (c *X509Cert) sourcesToURLs() error { for _, source := range c.Sources { if strings.HasPrefix(source, "file://") || strings.HasPrefix(source, "/") { diff --git a/plugins/inputs/x509_cert/x509_cert_test.go b/plugins/inputs/x509_cert/x509_cert_test.go index 80975f963..a346c0d99 100644 --- a/plugins/inputs/x509_cert/x509_cert_test.go +++ b/plugins/inputs/x509_cert/x509_cert_test.go @@ -1,8 +1,13 @@ package x509_cert import ( + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" + "encoding/pem" "fmt" "math/big" "net" @@ -15,11 +20,13 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/pion/dtls/v2" "github.com/stretchr/testify/require" "github.com/influxdata/telegraf" "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/metric" _tls "github.com/influxdata/telegraf/plugins/common/tls" "github.com/influxdata/telegraf/testutil" ) @@ -466,3 +473,197 @@ func TestServerName(t *testing.T) { }) } } + +// Bases on code from +// https://medium.com/@shaneutt/create-sign-x509-certificates-in-golang-8ac4ae49f903 +func TestClassification(t *testing.T) { + start := time.Now() + end := time.Now().AddDate(0, 0, 1) + tmpDir, err := os.MkdirTemp("", "telegraf-x509-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create the CA certificate + caPriv, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + + ca := &x509.Certificate{ + SerialNumber: big.NewInt(342350), + Subject: pkix.Name{ + Organization: []string{"Testing Inc."}, + Country: []string{"US"}, + CommonName: "Root CA", + }, + NotBefore: start, + NotAfter: end, + IsCA: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPriv.PublicKey, caPriv) + require.NoError(t, err) + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caBytes}) + + // Write CA cert + f, err := os.Create(filepath.Join(tmpDir, "ca.pem")) + require.NoError(t, err) + _, err = f.Write(caPEM) + require.NoError(t, err) + require.NoError(t, f.Close()) + + // Create an intermediate certificate + intermediatePriv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + intermediate := &x509.Certificate{ + SerialNumber: big.NewInt(342351), + Subject: pkix.Name{ + Organization: []string{"Testing Inc."}, + Country: []string{"US"}, + CommonName: "Intermediate CA", + }, + NotBefore: start, + NotAfter: end, + IsCA: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + intermediateBytes, err := x509.CreateCertificate(rand.Reader, intermediate, ca, &intermediatePriv.PublicKey, caPriv) + require.NoError(t, err) + intermediatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intermediateBytes}) + + // Create a leaf certificate + leafPriv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + leaf := &x509.Certificate{ + SerialNumber: big.NewInt(342352), + Subject: pkix.Name{ + Organization: []string{"Testing Inc."}, + Country: []string{"US"}, + CommonName: "My server", + }, + NotBefore: start, + NotAfter: end, + IsCA: false, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + leafBytes, err := x509.CreateCertificate(rand.Reader, leaf, intermediate, &leafPriv.PublicKey, intermediatePriv) + require.NoError(t, err) + leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafBytes}) + + // Write the chain + out := append(leafPEM, intermediatePEM...) + out = append(out, caPEM...) + f, err = os.Create(filepath.Join(tmpDir, "cert.pem")) + require.NoError(t, err) + _, err = f.Write(out) + require.NoError(t, err) + require.NoError(t, f.Close()) + + // Create the actual test + certURI := "file://" + filepath.Join(tmpDir, "cert.pem") + plugin := &X509Cert{ + Sources: []string{certURI}, + ClientConfig: _tls.ClientConfig{ + TLSCA: filepath.Join(tmpDir, "ca.pem"), + }, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + expected := []telegraf.Metric{ + metric.New( + "x509_cert", + map[string]string{ + "common_name": "My server", + "country": "US", + "issuer_common_name": "Intermediate CA", + "issuer_serial_number": "", + "organization": "Testing Inc.", + "public_key_algorithm": "RSA", + "san": "127.0.0.1", + "serial_number": "53950", + "signature_algorithm": "SHA256-RSA", + "source": filepath.ToSlash(certURI), + "type": "leaf", + "verification": "valid", + }, + map[string]interface{}{ + "age": int64(0), + "expiry": int64(86399), + "startdate": start.Unix(), + "enddate": end.Unix(), + "verification_code": int64(0), + }, + time.Unix(0, 0), + ), + metric.New( + "x509_cert", + map[string]string{ + "common_name": "Intermediate CA", + "country": "US", + "issuer_common_name": "Root CA", + "issuer_serial_number": "", + "organization": "Testing Inc.", + "public_key_algorithm": "RSA", + "san": "", + "serial_number": "5394f", + "signature_algorithm": "SHA256-RSA", + "source": filepath.ToSlash(certURI), + "type": "intermediate", + "verification": "valid", + }, + map[string]interface{}{ + "age": int64(0), + "expiry": int64(86399), + "startdate": start.Unix(), + "enddate": end.Unix(), + "verification_code": int64(0), + }, + time.Unix(0, 0), + ), + metric.New( + "x509_cert", + map[string]string{ + "common_name": "Root CA", + "country": "US", + "issuer_common_name": "Root CA", + "issuer_serial_number": "", + "organization": "Testing Inc.", + "public_key_algorithm": "RSA", + "san": "", + "serial_number": "5394e", + "signature_algorithm": "SHA256-RSA", + "source": filepath.ToSlash(certURI), + "type": "root", + "verification": "valid", + }, + map[string]interface{}{ + "age": int64(0), + "expiry": int64(86399), + "startdate": start.Unix(), + "enddate": end.Unix(), + "verification_code": int64(0), + }, + time.Unix(0, 0), + ), + } + + opts := []cmp.Option{ + testutil.SortMetrics(), + testutil.IgnoreTime(), + // We need to ignore those fields as they are timing sensitive. + testutil.IgnoreFields("age", "expiry"), + } + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsEqual(t, expected, actual, opts...) +} diff --git a/testutil/metric.go b/testutil/metric.go index ad51a210d..f5a25758c 100644 --- a/testutil/metric.go +++ b/testutil/metric.go @@ -128,6 +128,21 @@ func IgnoreTime() cmp.Option { return cmpopts.IgnoreFields(metricDiff{}, "Time") } +// IgnoreFields disables comparison of the fields with the given names. +// The field-names are case-sensitive! +func IgnoreFields(names ...string) cmp.Option { + return cmpopts.IgnoreSliceElements( + func(f *telegraf.Field) bool { + for _, n := range names { + if f.Key == n { + return true + } + } + return false + }, + ) +} + // MetricEqual returns true if the metrics are equal. func MetricEqual(expected, actual telegraf.Metric, opts ...cmp.Option) bool { var lhs, rhs *metricDiff