diff --git a/README.md b/README.md index f8fe388..a197d29 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # cert-payload -Support for managing embedded JSON payloads generated by a plugin from the -atc0005/check-cert project +Support for encoding and decoding certificate metadata payloads associated +with the `check_cert` plugin from the `atc0005/check-cert` project. [![Latest Release](https://img.shields.io/github/release/atc0005/cert-payload.svg?style=flat-square)](https://github.com/atc0005/cert-payload/releases/latest) [![Go Reference](https://pkg.go.dev/badge/github.com/atc0005/cert-payload.svg)](https://pkg.go.dev/github.com/atc0005/cert-payload) @@ -15,17 +15,31 @@ atc0005/check-cert project - [Overview](#overview) - [Status](#status) +- [Contributions](#contributions) - [Features](#features) - [Changelog](#changelog) - [Examples](#examples) + - [Imports](#imports) + - [Encoding a payload](#encoding-a-payload) + - [Decoding a payload](#decoding-a-payload) - [License](#license) - [Used by](#used-by) - [References](#references) ## Overview -This package provides support and functionality for managing embedded JSON -payloads generated by a plugin from the `atc0005/check-cert` project. +This library provides support and functionality for encoding and decoding +certificate metadata payloads in JSON format. + +Using this library, the `check_cert` plugin (from the `atc0005/check-cert` +project) creates fixed format version payloads. Those payloads (using a +different library), are embedded in monitoring plugin output where they can +later be extracted and decoded and then be unmarshalled back into a specific +format version of a native Go type provided by this project. + +This library exists to allow the `check_cert` plugin to easily generate +certificate metadata payloads and various other tools to unpack them for +reporting purposes. ## Status @@ -34,9 +48,42 @@ change without notice and may break client code that depends on it. You are encouraged to [vendor](#references) this package if you find it useful until such time that the API is considered stable. +The specific certificate metadata payload format versions provided by this +project are *intended* to be supported indefinitely once the format is +declared stable. Any breaking changes to a format would be provided by +releasing a new format version with those changes. + +## Contributions + +This library has a very narrow focus. While PRs may be accepted to resolve +typos, logic errors and enhance documentation, behavioral changes and feature +additions will likely be rejected as out of scope. + ## Features -- placeholder +- support for generating a JSON payload from a specified metadata payload + format version + - this can be generated by calling the `Encode` function from a specific + format version or by calling the top-level `Encode` function and + specifying a valid format version number (e.g., `0` or `1`) +- support for decoding a given (valid) certificate metadata payload + - the intent is to support decoding any given payload matching the set of + supported format versions (e.g., `0`, `1`) + - the caller provides an instance of a specific format version of + the certificate metadata payload and the `Decode` function for that + format version is used + - once a format version is stable, the intent is to support creating and + decoding it using this library indefinitely + - this should allow the sysadmin using the `check_cert` plugin to specify + what version of the payload format they wish to create + - this should allow the sysadmin using a reporting tool to consume a + certificate metadata payload generated by the `check_cert` plugin in the + same fixed version as the one they asked the `check_cert` plugin to + create + - this process should continue to work as-is until the sysadmin decides to + explicitly change the certificate metadata payload format version + they're working with; updating this dependency should not break payload + generation or consumption ## Changelog @@ -48,6 +95,8 @@ official release is also provided for further review. ## Examples +### Imports + Add this line to your imports like so: ```golang @@ -79,6 +128,18 @@ See for specific examples. See for projects that are using this library. +### Encoding a payload + +See the `check_cert` monitoring plugin from the `atc0005/check-cert` project +for an example of how a certificate metadata payload is generated and embedded +within plugin output (for later retrieval and parsing). + +### Decoding a payload + +See the Nagios XI API example in this repo for how to combine using this +library and another library to extract, decode and unmarshal an embedded +payload to a specific format version of a certificate metadata payload. + ## License From the [LICENSE](LICENSE) file: diff --git a/doc.go b/doc.go index f3ab60b..d5f7617 100644 --- a/doc.go +++ b/doc.go @@ -5,8 +5,9 @@ // Licensed under the MIT License. See LICENSE file in the project root for // full license information. -// Package payload provides support for managing embedded JSON payloads -// generated by the `check_cert` plugin within this project. +// Package payload provides support for encoding and decoding certificate +// metadata payloads associated with the check_cert plugin from the +// atc0005/check-cert project. // // See our [GitHub repo]: // diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..e886159 --- /dev/null +++ b/example_test.go @@ -0,0 +1,230 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package payload_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + payload "github.com/atc0005/cert-payload" + format0 "github.com/atc0005/cert-payload/format/v0" +) + +// Example of parsing a previously retrieved Nagios XI API response (saved to +// a JSON file) from the /nagiosxi/api/v1/objects/servicestatus endpoint, +// extracting and decoding an embedded certificate metadata payload from each +// status entry and then unmarshalling the result into a specific format +// version (in this case format 0). +// +// TODO: Update this example once format version 1 is released. +func Example_extractandDecodePayloadsFromNagiosXIAPI() { + if len(os.Args) < 2 { + fmt.Println("Missing input file") + os.Exit(1) + } + + sampleInputFile := os.Args[1] + + jsonInput, readErr := os.ReadFile(filepath.Clean(sampleInputFile)) + if readErr != nil { + fmt.Println("Failed to read sample input file:", readErr) + os.Exit(1) + } + + var serviceStatusResponse ServiceStatusResponse + decodeErr := json.Unmarshal(jsonInput, &serviceStatusResponse) + if decodeErr != nil { + fmt.Println("Failed to decode JSON input:", decodeErr) + os.Exit(1) + } + + for i, serviceStatus := range serviceStatusResponse.ServiceStatuses { + fmt.Printf("\n\nProcess service check result %d ...", i) + + longServiceOutput := serviceStatus.LongServiceOutput + + // We use a pretend nagios.ExtractAndDecodePayload implementation. + unencodedPayload, payloadDecodeErr := ExtractAndDecodePayload( + longServiceOutput, + "", + DefaultASCII85EncodingDelimiterLeft, + DefaultASCII85EncodingDelimiterRight, + ) + + if payloadDecodeErr != nil { + fmt.Println(" WARNING: Failed to extract and decode payload from original plugin output:", payloadDecodeErr) + // os.Exit(1) + continue // we have some known cases of explicitly excluding payload generation + } + + format0Payload := format0.CertChainPayload{} + jsonDecodeErr := payload.Decode(unencodedPayload, &format0Payload) + if jsonDecodeErr != nil { + fmt.Println("Failed to decode JSON payload from original plugin output:", jsonDecodeErr) + os.Exit(1) + } + + if !format0Payload.Issues.Confirmed() { + fmt.Print(" Skipping (no cert chain issues detected)") + continue + } + + fmt.Printf( + "\nJSON payload for %s (flagged as problematic):\n", + format0Payload.Server, + ) + + var prettyJSON bytes.Buffer + err := json.Indent(&prettyJSON, []byte(unencodedPayload), "", " ") + if err == nil { + _, _ = fmt.Fprintln(os.Stdout, prettyJSON.String()) + } + + } + +} + +// Pulled from go-nagios repo to remove external dependency. +const ( + // DefaultASCII85EncodingDelimiterLeft is the left delimiter often used + // with ascii85-encoded data. + DefaultASCII85EncodingDelimiterLeft string = "<~" + + // DefaultASCII85EncodingDelimiterRight is the right delimiter often used + // with ascii85-encoded data. + DefaultASCII85EncodingDelimiterRight string = "~>" +) + +// ExtractAndDecodePayload is a mockup for nagios.ExtractAndDecodePayload. +func ExtractAndDecodePayload(text string, customRegex string, leftDelimiter string, rightDelimiter string) (string, error) { + _ = text + _ = customRegex + _ = leftDelimiter + _ = rightDelimiter + + return "placeholder", nil +} + +// BoolString is a boolean value that is represented in JSON API input as a +// string value ("1" or "0"). +type BoolString bool + +// MarshalJSON implements the json.Marshaler interface. This compliments the +// custom Unmarshaler implementation to handle conversion of Go boolean field +// to JSON API expectations of a "1" or "0" string value. +func (bs BoolString) MarshalJSON() ([]byte, error) { + switch bs { + case true: + return json.Marshal("1") + case false: + return json.Marshal("0") + + } + + return nil, nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface to handle +// converting a string value of "1" or "0" to a native boolean value. +func (bs *BoolString) UnmarshalJSON(data []byte) error { + + // Per json.Unmarshaler convention we treat "null" value as a no-op. + str := string(data) + if str == "null" { + return nil + } + + // The 1 or 0 value is double-quoted, so we remove those before attempting + // to parse as a boolean value. + str = strings.Trim(str, `"`) + + boolValue, err := strconv.ParseBool(str) + if err != nil { + return err + } + + *bs = BoolString(boolValue) + + return nil +} + +// credit: https://romangaranin.net/posts/2021-02-19-json-time-and-golang/ + +// DateTimeLayout is the time layout format as used by the JSON API. +const DateTimeLayout string = "2006-01-02 15:04:05" + +// DateTime is time value as represented in the JSON API input. It uses the +// DateTimeLayout format. +type DateTime time.Time + +// String implements the fmt.Stringer interface as a convenience method. +func (dt DateTime) String() string { + return dt.Format(DateTimeLayout) +} + +// Format calls (time.Time).Format as a convenience for the caller. +func (dt DateTime) Format(layout string) string { + return time.Time(dt).Format(layout) +} + +// MarshalJSON implements the json.Marshaler interface. This compliments the +// custom Unmarshaler implementation to handle conversion of a native Go +// time.Time format to the JSON API expectations of a time value in the +// DateTimeLayout format. +func (dt DateTime) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Time(dt).Format(DateTimeLayout)) +} + +// UnmarshalJSON implements the json.Unmarshaler interface to handle +// converting a time string from the JSON API to a native Go time.Time value +// using the DateTimeLayout format. +func (dt *DateTime) UnmarshalJSON(data []byte) error { + value := strings.Trim(string(data), `"`) // get rid of " + if value == "" || value == "null" { + + // Per json.Unmarshaler convention we treat "null" value as a no-op. + return nil + } + + t, err := time.Parse(DateTimeLayout, value) // parse time + if err != nil { + return err + } + + *dt = DateTime(t) // set result using the pointer + + return nil +} + +type ServiceStatus struct { + HostAddress string `json:"host_address"` + HostAlias string `json:"host_alias"` + HostName string `json:"host_name"` + ServiceDescription string `json:"service_description"` + ActiveChecksEnabled BoolString `json:"active_checks_enabled"` + NotificationsEnabled BoolString `json:"notifications_enabled"` + LongServiceOutput string `json:"long_output"` + Notes string `json:"notes"` + StatusUpdateTime DateTime `json:"status_update_time"` + LastCheck DateTime `json:"last_check"` + NextCheck DateTime `json:"next_check"` + LastNotification DateTime `json:"last_notification"` + NextNotification DateTime `json:"next_notification"` + RawPerfData string `json:"perfdata"` +} + +type ServiceStatusResponse struct { + RecordCount int `json:"recordcount"` + ServiceStatuses []ServiceStatus `json:"servicestatus"` +} diff --git a/chain-export.go b/format/internal/shared/chain-export.go similarity index 99% rename from chain-export.go rename to format/internal/shared/chain-export.go index 21ba5c3..6eff01b 100644 --- a/chain-export.go +++ b/format/internal/shared/chain-export.go @@ -5,7 +5,7 @@ // Licensed under the MIT License. See LICENSE file in the project root for // full license information. -package payload +package shared import ( "bytes" diff --git a/format/internal/shared/constants.go b/format/internal/shared/constants.go new file mode 100644 index 0000000..57d9b09 --- /dev/null +++ b/format/internal/shared/constants.go @@ -0,0 +1,43 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package shared + +// Nagios plugin/service check state "labels". These values are used (where +// applicable) by the CertChainPayload `ServiceState` field. +// const ( +// StateOKLabel string = "OK" +// StateWARNINGLabel string = "WARNING" +// StateCRITICALLabel string = "CRITICAL" +// StateUNKNOWNLabel string = "UNKNOWN" +// StateDEPENDENTLabel string = "DEPENDENT" +// ) + +// Validity period keywords intended as human readable output. +// +// Common historical certificate lifetimes: +// +// - 5 year (1825 days, 60 months) +// - 3 year (1185 days, 39 months) +// - 2 year (825 days, 27 months) +// - 1 year (398 days, 13 months) +// +// See also: +// +// - https://www.sectigo.com/knowledge-base/detail/TLS-SSL-Certificate-Lifespan-History-2-3-and-5-year-validity/kA01N000000zFKp +// - https://support.sectigo.com/Com_KnowledgeDetailPage?Id=kA03l000000o6cv +// - https://www.digicert.com/faq/public-trust-and-certificates/how-long-are-tls-ssl-certificate-validity-periods +// - https://docs.digicert.com/en/whats-new/change-log/older-changes/change-log--2023.html#certcentral--changes-to-multi-year-plan-coverage +// - https://knowledge.digicert.com/quovadis/ssl-certificates/ssl-general-topics/maximum-validity-changes-for-tls-ssl-to-drop-to-825-days-in-q1-2018 +// - https://chromium.googlesource.com/chromium/src/+/666712ff6c7ba7aa5da380bc0a617b637c9232b3/net/docs/certificate_lifetimes.md +// - https://www.entrust.com/blog/2017/03/maximum-certificate-lifetime-drops-to-825-days-in-2018 +const ( + ValidityPeriod1Year string = "1 year" + ValidityPeriod90Days string = "90 days" + ValidityPeriod45Days string = "45 days" + ValidityPeriodUNKNOWN string = "UNKNOWN" +) diff --git a/format/internal/shared/doc.go b/format/internal/shared/doc.go new file mode 100644 index 0000000..9dd5422 --- /dev/null +++ b/format/internal/shared/doc.go @@ -0,0 +1,9 @@ +// Package shared provides common/shared utility code for all payload format +// versions. +package shared + +// Q: Rename package to `assert` ? +// +// A: Probably not. While `shared` is not a great package name as a +// measurement against best practices, it works well here since we're sharing +// common logic (as much as we can) between different format versions. diff --git a/format/internal/shared/errors.go b/format/internal/shared/errors.go new file mode 100644 index 0000000..98a6a80 --- /dev/null +++ b/format/internal/shared/errors.go @@ -0,0 +1,8 @@ +package shared + +import "errors" + +var ( + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") +) diff --git a/format/internal/shared/shared.go b/format/internal/shared/shared.go new file mode 100644 index 0000000..5a247e5 --- /dev/null +++ b/format/internal/shared/shared.go @@ -0,0 +1,357 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. +// +// Code in this file inspired by or generated with the help of ChatGPT, OpenAI. + +package shared + +import ( + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math" + "strings" + "time" + + "github.com/atc0005/cert-payload/internal/certs" +) + +// CertExpirationMetadata is a bundle of certificate expiration related +// metadata used when preparing a certificate payload for inclusion in plugin +// output. +type CertExpirationMetadata struct { + ValidityPeriodDays int + DaysRemainingTruncated int + DaysRemainingPrecise float64 + CertLifetimePercent int +} + +// LookupCertExpMetadata is a helper function used to lookup specific +// certificate expiration metadata values used when preparing a certificate +// payload for inclusion in plugin output. +func LookupCertExpMetadata(cert *x509.Certificate, certNumber int, certChain []*x509.Certificate) (CertExpirationMetadata, error) { + if cert == nil { + return CertExpirationMetadata{}, fmt.Errorf( + "cert in chain position %d of %d is nil: %w", + certNumber, + len(certChain), + certs.ErrMissingValue, + ) + } + + certLifetime, certLifeTimeErr := certs.LifeRemainingPercentageTruncated(cert) + if certLifeTimeErr != nil { + return CertExpirationMetadata{}, fmt.Errorf( + "error calculating lifetime for cert %q: %w", + cert.Subject.CommonName, + certLifeTimeErr, + ) + } + + daysRemainingTruncated, expLookupErr := certs.ExpiresInDays(cert) + if expLookupErr != nil { + return CertExpirationMetadata{}, fmt.Errorf( + "error calculating the number of days until the certificate %q expires: %w", + cert.Subject.CommonName, + expLookupErr, + ) + } + + daysRemainingPrecise, expLookupErrPrecise := certs.ExpiresInDaysPrecise(cert) + if expLookupErrPrecise != nil { + return CertExpirationMetadata{}, fmt.Errorf( + "error calculating the number of days until the certificate %q expires: %w", + cert.Subject.CommonName, + expLookupErr, + ) + } + + validityPeriodDays, lifespanLookupErr := certs.MaxLifespanInDays(cert) + if lifespanLookupErr != nil { + return CertExpirationMetadata{}, fmt.Errorf( + "error calculating the maximum lifespan in days for certificate %q: %w", + cert.Subject.CommonName, + lifespanLookupErr, + ) + } + + return CertExpirationMetadata{ + CertLifetimePercent: certLifetime, + DaysRemainingPrecise: daysRemainingPrecise, + DaysRemainingTruncated: daysRemainingTruncated, + ValidityPeriodDays: validityPeriodDays, + }, nil +} + +// LookupValidityPeriodDescription is a helper function to lookup human +// readable validity period description for a certificate's maximum lifetime +// value. +func LookupValidityPeriodDescription(cert *x509.Certificate) string { + maxLifeSpanInDays, err := certs.MaxLifespanInDays(cert) + if err != nil { + return ValidityPeriodUNKNOWN + } + + maxLifeSpanInTruncatedYears := int(math.Trunc(float64(maxLifeSpanInDays) / 365)) + + switch { + case maxLifeSpanInTruncatedYears >= 1: + return fmt.Sprintf("%d year", maxLifeSpanInTruncatedYears) + + default: + return fmt.Sprintf("%d days", maxLifeSpanInDays) + } +} + +// isBetween is a small helper function to determine whether a given value is +// between a specified minimum and maximum number (inclusive). +// func isBetween(val, min, max int) bool { +// if (val >= min) && (val <= max) { +// return true +// } +// +// return false +// } + +// HasWeakSignatureAlgorithm indicates that the certificate chain has been +// signed using a cryptographically weak hashing algorithm (e.g. MD2, MD4, +// MD5, or SHA1). These signature algorithms are known to be vulnerable to +// collision attacks. An attacker can exploit this to generate another +// certificate with the same digital signature, allowing an attacker to +// masquerade as the affected service. +// +// NOTE: This does not apply to trusted root certificates; TLS clients +// trust them by their identity instead of the signature of their hash; +// client code setting this field would need to exclude root certificates +// from the determination whether the chain is vulnerable to weak +// signature algorithms. +// +// - https://security.googleblog.com/2014/09/gradually-sunsetting-sha-1.html +// - https://security.googleblog.com/2015/12/an-update-on-sha-1-certificates-in.html +// - https://superuser.com/questions/1122069/why-are-root-cas-with-sha1-signatures-not-a-risk +// - https://developer.mozilla.org/en-US/docs/Web/Security/Weak_Signature_Algorithm +// - https://www.tenable.com/plugins/nessus/35291 +// - https://docs.ostorlab.co/kb/WEAK_HASHING_ALGO/index.html +// +// TODO: Replace with slog debug calls +func HasWeakSignatureAlgorithm(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + nonRootCerts := certs.NonRootCerts(certChain) + + // log := cfg.Log.With().Logger() + + // log.Debug().Int("num_certs", len(nonRootCerts)).Msg("Evaluating non-root certificates for weak signature algorithm") + + // logIgnored := func(cert *x509.Certificate) { + // log.Debug(). + // Bool("cert_signature_algorithm_ok", true). + // Str("cert_signature_algorithm", cert.SignatureAlgorithm.String()). + // Str("cert_common_name", cert.Subject.CommonName). + // Msg("Certificate signature algorithm ignored") + // } + + // logWeak := func(cert *x509.Certificate) { + // log.Debug(). + // Bool("cert_signature_algorithm_ok", false). + // Str("cert_signature_algorithm", cert.SignatureAlgorithm.String()). + // Str("cert_common_name", cert.Subject.CommonName). + // Msg("Certificate signature algorithm weak") + // } + // + // logOK := func(cert *x509.Certificate) { + // log.Debug(). + // Bool("cert_signature_algorithm_ok", true). + // Str("cert_signature_algorithm", cert.SignatureAlgorithm.String()). + // Str("cert_common_name", cert.Subject.CommonName). + // Msg("Certificate signature algorithm ok") + // } + + for _, cert := range nonRootCerts { + // chainPos := certs.ChainPosition(cert, certChain) + + // switch { + // // case chainPos == "root": + // // logIgnored(cert) + // + // case certs.HasWeakSignatureAlgorithm(cert, certChain, false): + // logWeak(cert) + // + // return true + // + // default: + // logOK(cert) + // } + + if certs.HasWeakSignatureAlgorithm(cert, certChain, false) { + return true + } + + } + + return false +} + +// HasSelfSignedLeaf asserts that a given certificate chain has a self-signed +// leaf certificate. +func HasSelfSignedLeaf(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + leafCerts := certs.LeafCerts(certChain) + for _, leafCert := range leafCerts { + // NOTE: We may need to perform actual signature verification here for + // the most reliable results. + // + if leafCert.Issuer.String() == leafCert.Subject.String() { + return true + } + } + + return false +} + +// HasDuplicateCertsInChain asserts that there are duplicate certificates +// within a given certificate chain. +func HasDuplicateCertsInChain(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + certIdx := make(map[string]int, len(certChain)) + + for _, cert := range certChain { + certIdx[certs.FormatCertSerialNumber(cert.SerialNumber)]++ + } + + for _, v := range certIdx { + if v > 1 { + return true + } + } + + return false +} + +// HasMissingSANsEntries asserts that the first leaf certificate for a given +// certificate chain is missing Subject Alternate Names (SANs) entries. +func HasMissingSANsEntries(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + leafCerts := certs.LeafCerts(certChain) + + if len(leafCerts) == 0 { + return false + } + + if len(leafCerts[0].DNSNames) > 0 { + return false + } + + return true +} + +// HasExpiredCerts asserts that the given certificate chain has one or more +// expired certificates. +func HasExpiredCerts(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + return certs.HasExpiredCert(certChain) +} + +// HasExpiringCerts asserts that the given certificate chain has one or more +// expiring certificates. +func HasExpiringCerts(certChain []*x509.Certificate, ageCritical time.Time, ageWarning time.Time) bool { + if len(certChain) == 0 { + return false + } + + return certs.HasExpiringCert(certChain, ageCritical, ageWarning) +} + +// HasHostnameMismatch asserts that the given hostname value is valid for the +// first certificate in the chain. If an empty hostname value or empty +// certificate chain is provided a mismatch cannot be determined and false is +// returned. +func HasHostnameMismatch(hostnameValue string, certChain []*x509.Certificate) bool { + switch { + case len(certChain) == 0: + return false + case hostnameValue == "": + return false + default: + return certChain[0].VerifyHostname(hostnameValue) != nil + } +} + +// HasMissingIntermediateCerts asserts that a given certificate chain is +// missing intermediate certificates. +func HasMissingIntermediateCerts(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + return certs.NumIntermediateCerts(certChain) == 0 +} + +// HasMisorderedCerts asserts that a given certificate chain contains +// certificates out of the expected order. +func HasMisorderedCerts(certChain []*x509.Certificate) bool { + if len(certChain) == 0 { + return false + } + + for i := 0; i < len(certChain)-1; i++ { + currentCert := certChain[i] + nextCert := certChain[i+1] + + // fmt.Printf("Comparing %s against %s\n", currentCert.Subject, nextCert.Subject) + + // Check if the issuer of the current certificate matches the subject + // of the next certificate. + if !pkixNameEqual(currentCert.Issuer, nextCert.Subject) { + // return fmt.Errorf("certificate at index %d is not signed by the certificate at index %d", i, i+1) + return true + } + + // Verify the current certificate is signed by the next certificate's + // public key. + if err := currentCert.CheckSignatureFrom(nextCert); err != nil { + // return fmt.Errorf("signature verification failed between certificate at index %d and %d: %w", i, i+1, err) + return true + } + } + + return false +} + +// pkixNameEqual compares two pkix.Name values for equality. +func pkixNameEqual(name1 pkix.Name, name2 pkix.Name) bool { + return name1.CommonName == name2.CommonName && + strings.Join(name1.Organization, ",") == strings.Join(name2.Organization, ",") && + strings.Join(name1.OrganizationalUnit, ",") == strings.Join(name2.OrganizationalUnit, ",") && + strings.Join(name1.Locality, ",") == strings.Join(name2.Locality, ",") && + strings.Join(name1.Province, ",") == strings.Join(name2.Province, ",") && + strings.Join(name1.Country, ",") == strings.Join(name2.Country, ",") +} + +// ErrorsToStrings converts a collectin of error interfaces to string values. +func ErrorsToStrings(errs []error) []string { + stringErrs := make([]string, 0, len(errs)) + for _, err := range errs { + stringErrs = append(stringErrs, err.Error()) + } + + return stringErrs +} diff --git a/format/v0/decode.go b/format/v0/decode.go new file mode 100644 index 0000000..ce06b93 --- /dev/null +++ b/format/v0/decode.go @@ -0,0 +1,43 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format0 + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +// Decode accepts a Reader which provides a certificate metadata payload and +// decodes/unmarshals it into the given destination. An error is returned if +// one occurs when decoding the payload. +func Decode(dest *CertChainPayload, input io.Reader, allowUnknownFields bool) error { + dec := json.NewDecoder(input) + + if !allowUnknownFields { + dec.DisallowUnknownFields() + } + + // Decode the first JSON object. + if err := dec.Decode(dest); err != nil { + return fmt.Errorf( + "failed to decode cert payload: %w", + err, + ) + } + + // If there is more than one object, something is off. + if dec.More() { + return errors.New( + "input contains multiple JSON objects; only one JSON object is supported", + ) + } + + return nil +} diff --git a/format/v0/doc.go b/format/v0/doc.go new file mode 100644 index 0000000..c5f2f90 --- /dev/null +++ b/format/v0/doc.go @@ -0,0 +1,12 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Package format0 implements the initial certificate payload format. +// +// NOTE: Until format v1 is released this format is subject to change +// frequently. +package format0 diff --git a/format/v0/encode.go b/format/v0/encode.go new file mode 100644 index 0000000..7d800a2 --- /dev/null +++ b/format/v0/encode.go @@ -0,0 +1,163 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format0 + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/atc0005/cert-payload/format/internal/shared" + "github.com/atc0005/cert-payload/input" + "github.com/atc0005/cert-payload/internal/certs" +) + +// Encode processes the given certificate chain and returns a JSON payload of +// the specified format version. An error is returned if one occurs during +// processing or if an invalid payload version format is specified. +func Encode(inputData input.Values) ([]byte, error) { + // FIXME: We may want to accept this as an argument for testing purposes. + now := time.Now().UTC() + + certsExpireAgeWarning := now.AddDate(0, 0, inputData.ExpirationAgeInDaysWarningThreshold) + certsExpireAgeCritical := now.AddDate(0, 0, inputData.ExpirationAgeInDaysCriticalThreshold) + + certChain := inputData.CertChain + + certChainSubset := make([]Certificate, 0, len(certChain)) + for certNumber, origCert := range certChain { + if origCert == nil { + return nil, fmt.Errorf( + "cert in chain position %d of %d is nil: %w", + certNumber, + len(certChain), + ErrMissingValue, + ) + } + + expiresText := certs.ExpirationStatus( + origCert, + certsExpireAgeCritical, + certsExpireAgeWarning, + false, + ) + + hasExpiring := shared.HasExpiringCerts(certChain, certsExpireAgeCritical, certsExpireAgeWarning) + hasExpired := shared.HasExpiredCerts(certChain) + + certStatus := CertificateStatus{ + OK: !hasExpired && !hasExpiring, + Expiring: hasExpiring, + Expired: hasExpired, + } + + certExpMeta, lookupErr := shared.LookupCertExpMetadata(origCert, certNumber, certChain) + if lookupErr != nil { + return nil, lookupErr + } + + var SANsEntries []string + if inputData.OmitSANsEntries { + SANsEntries = nil + } else { + SANsEntries = origCert.DNSNames + } + + validityPeriodDescription := shared.LookupValidityPeriodDescription(origCert) + + certSubset := Certificate{ + Subject: origCert.Subject.String(), + CommonName: origCert.Subject.CommonName, + SANsEntries: SANsEntries, + SANsEntriesCount: len(origCert.DNSNames), + Issuer: origCert.Issuer.String(), + IssuerShort: origCert.Issuer.CommonName, + SerialNumber: certs.FormatCertSerialNumber(origCert.SerialNumber), + IssuedOn: origCert.NotBefore, + ExpiresOn: origCert.NotAfter, + DaysRemaining: certExpMeta.DaysRemainingPrecise, + DaysRemainingTruncated: certExpMeta.DaysRemainingTruncated, + LifetimePercent: certExpMeta.CertLifetimePercent, + ValidityPeriodDescription: validityPeriodDescription, + ValidityPeriodDays: certExpMeta.ValidityPeriodDays, + Summary: expiresText, + Status: certStatus, + SignatureAlgorithm: origCert.SignatureAlgorithm.String(), + Type: certs.ChainPosition(origCert, certChain), + } + + certChainSubset = append(certChainSubset, certSubset) + } + + // Default to using the server FQDN or IP Address used to make the + // connection as our hostname value. + hostnameValue := inputData.Server.HostValue + + // Allow the user to explicitly specify which hostname should be used + // for comparison against the leaf certificate. This works for a + // certificate retrieved by a server as well as a certificate + // retrieved from a file. + if inputData.DNSName != "" { + hostnameValue = inputData.DNSName + } + + certChainIssues := CertificateChainIssues{ + MissingIntermediateCerts: shared.HasMissingIntermediateCerts(certChain), + MissingSANsEntries: shared.HasMissingSANsEntries(certChain), + DuplicateCerts: shared.HasDuplicateCertsInChain(certChain), + MisorderedCerts: shared.HasMisorderedCerts(certChain), + ExpiredCerts: shared.HasExpiredCerts(certChain), + HostnameMismatch: shared.HasHostnameMismatch(hostnameValue, certChain), + SelfSignedLeafCert: shared.HasSelfSignedLeaf(certChain), + WeakSignatureAlgorithm: shared.HasWeakSignatureAlgorithm(certChain), + } + + // Only if the user explicitly requested the full cert payload do we + // include it (due to significant payload size increase and risk of + // exceeding size constraints). + var certChainOriginal []string + switch { + case inputData.IncludeFullCertChain: + pemCertChain, err := shared.CertChainToPEM(certChain) + if err != nil { + return nil, fmt.Errorf("error converting original cert chain to PEM format: %w", err) + } + + certChainOriginal = pemCertChain + + default: + certChainOriginal = nil + } + + server := Server{ + HostValue: inputData.Server.HostValue, + IPAddress: inputData.Server.IPAddress, + } + + payload := CertChainPayload{ + FormatVersion: FormatVersion, + Errors: shared.ErrorsToStrings(inputData.Errors), + CertChainOriginal: certChainOriginal, + CertChainSubset: certChainSubset, + Server: server, + DNSName: inputData.DNSName, + TCPPort: inputData.TCPPort, + Issues: certChainIssues, + ServiceState: inputData.ServiceState, + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf( + "error marshaling cert chain payload as JSON: %w", + err, + ) + } + + return payloadJSON, nil +} diff --git a/format/v0/errors.go b/format/v0/errors.go new file mode 100644 index 0000000..2558774 --- /dev/null +++ b/format/v0/errors.go @@ -0,0 +1,8 @@ +package format0 + +import "errors" + +var ( + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") +) diff --git a/format/v0/helpers.go b/format/v0/helpers.go new file mode 100644 index 0000000..f159c14 --- /dev/null +++ b/format/v0/helpers.go @@ -0,0 +1,41 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format0 + +// Confirmed is a helper function to indicate whether issues are present +// with the evaluated certificate chain. +func (cci CertificateChainIssues) Confirmed() bool { + switch { + case cci.MissingIntermediateCerts: + return true + + case cci.MissingSANsEntries: + return true + + case cci.DuplicateCerts: + return true + + case cci.MisorderedCerts: + return true + + case cci.ExpiredCerts: + return true + + case cci.HostnameMismatch: + return true + + case cci.SelfSignedLeafCert: + return true + + case cci.WeakSignatureAlgorithm: + return true + + default: + return false + } +} diff --git a/format/v0/types.go b/format/v0/types.go new file mode 100644 index 0000000..0fa0cfe --- /dev/null +++ b/format/v0/types.go @@ -0,0 +1,277 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format0 + +import ( + "time" +) + +const ( + // FormatVersion indicates the format version support provided by this + // package. Version 0 is the pre-release version that we'll continue to + // use until the types in this package stabilize. + FormatVersion int = 0 +) + +// Server reflects the host value and resolved IP Address used to retrieve the +// certificate chain. +type Server struct { + // HostValue is the original hostname value. While usually a FQDN, this + // value could also be a fixed IP Address (e.g., if SNI support wasn't + // used to retrieve the certificate chain). + HostValue string `json:"host_value"` + + // IPAddress is the resolved IP Address for the hostname value used to + // retrieve a certificate chain. + IPAddress string `json:"ip_address"` +} + +// CertificateStatus is the overall status of a certificate. +// +// - no problems (ok) +// - expired +// - expiring (based on given threshold values) +// - revoked (not yet supported) +// +// TODO: Any useful status values to borrow here? +// They have `Active`, `Revoked` and then a `Pending*` variation for both. +// https://developers.cloudflare.com/ssl/reference/certificate-statuses/#client-certificates +type CertificateStatus struct { + OK bool `json:"status_ok"` // No observed issues; shouldn't this be calculated? + Expiring bool `json:"status_expiring"` // Based on given monitoring thresholds + Expired bool `json:"status_expired"` // Based on certificate NotAfter field + + // This is a feature to add later + // RevokedPerCRL bool `json:"status_revoked_per_crl"` // Based on CRL or OCSP check? + // RevokedPerOCSP bool `json:"status_revoked_per_ocsp"` // Based on CRL or OCSP check? + // ? +} + +// Certificate is a subset of the metadata for an evaluated certificate. +type Certificate struct { + // Subject is the full subject value for a certificate. This is intended + // for (non-cryptographic) comparison purposes. + Subject string `json:"subject"` + + // CommonName is the short subject value of a certificate. This is + // intended for display purposes. + CommonName string `json:"common_name"` + + // SANsEntries is the full list of Subject Alternate Names for a + // certificate. + SANsEntries []string `json:"sans_entries"` + + // SANsEntriesCount is the number of Subject Alternate Names for a + // certificate. + // + // This field allows the payload creator to omit SANs entries to conserve + // plugin output size and still indicate the number of SANs entries + // present for a certificate for use in display or for metrics purposes. + SANsEntriesCount int `json:"sans_entries_count"` + + // Issuer is the full CommonName of the signing certificate. This is + // intended for (non-cryptographic) comparison purposes. + Issuer string `json:"issuer"` + + // IssuerShort is the short CommonName of the signing certificate. This is + // intended for display purposes. + IssuerShort string `json:"issuer_short"` + + // SerialNumber is the serial number for a certificate in hex format with + // a colon inserted after each two digits. + // + // For example, `77:BD:0D:6C:DB:36:F9:1A:EA:21:0F:C4:F0:58:D3:0D`. + SerialNumber string `json:"serial_number"` + + // IssuedOn is a RFC3389 time value for when a certificate is first + // valid or usable. + IssuedOn time.Time `json:"not_before"` + + // ExpiresOn is a RFC3389 time value for when the certificate expires. + ExpiresOn time.Time `json:"not_after"` + + // DaysRemaining is the number of days remaining for a certificate in two + // digit decimal precision. + DaysRemaining float64 `json:"days_remaining"` + + // DaysRemainingTruncated is the number of days remaining for a + // certificate as a whole number rounded down. + // + // For example, if five and a half days remain then this value would be + // `5`. + DaysRemainingTruncated int `json:"days_remaining_truncated"` + + // LifetimePercent is percentage of life remaining for a certificate. + // + // For example, if 43% life is remaining for a cert (a rounded value) this + // field would be set to `43`. + LifetimePercent int `json:"lifetime_remaining_percent"` + + // ValidityPeriodDescription is the human readable value such as "90 days" + // or "1 year". + ValidityPeriodDescription string `json:"validity_period_description"` + + // ValidityPeriodDays is the number of total days a certificate is valid + // for using `Not Before` & `Not After` as the starting & ending range. + ValidityPeriodDays int `json:"validity_period_days"` + + // human readable summary such as, `[OK] 1199d 2h remaining (43%)` + Summary string `json:"summary"` + + // Status is the overall status of the certificate. + Status CertificateStatus `json:"status"` + + // SignatureAlgorithm indicates what certificate signature algorithm was + // used by a certification authority (CA)'s private key to sign a checksum + // calculated by a signature hash algorithm (i.e., what algorithm was used + // to sign the certificate). The verifying party must use the same + // algorithm to decrypt and verify the checksum using the CA's public key. + // + // A cryptographically weak hashing algorithm (e.g. MD2, MD4, MD5, SHA1) + // used to sign a certificate is considered to be a vulnerability. + SignatureAlgorithm string `json:"signature_algorithm"` + + // Type indicates the type of certificate (leaf, intermediate or root). + Type string `json:"type"` +} + +// CertificateChainIssues is an aggregated collection of problems detected for +// the certificate chain. +type CertificateChainIssues struct { + // MissingIntermediateCerts indicates that intermediate certificates are + // missing from the certificate chain. + MissingIntermediateCerts bool `json:"missing_intermediate_certs"` + + // MissingSANsEntries indicates that SANs entries are missing from a leaf + // certificate within the certificates chain. + MissingSANsEntries bool `json:"missing_sans_entries"` + + // DuplicateCerts indicates that there are one or more duplicate copies of + // a certificate in the certificate chain. + DuplicateCerts bool `json:"duplicate_certs"` + + // MisorderedCerts indicates that certificates in the chain are out of the + // expected order. + // + // E.g., instead of leaf, intermediate(s), root (technically not best + // practice) the chain has something like leaf, root, intermediate(s) or + // intermediates and then leaf. + MisorderedCerts bool `json:"misordered_certs"` + + // ExpiredCerts indicates that there are one or more expired certificates + // in the certificate chain. + ExpiredCerts bool `json:"expired_certs"` + + // HostnameMismatch indicates that the name or IP Address used to + // establish a connection to a certificate-enabled service does not match + // the list of valid host names honored by the leaf certificate. + // + // Historically the Common Name (CN) field was searched in addition to the + // Subject Alternate Names (SANs) field for a match, but this practice is + // deprecated and many clients (e.g., web browsers) no longer support + // this. + HostnameMismatch bool `json:"hostname_mismatch"` + + // SelfSignedLeafCert indicates that the leaf certificate is self-signed. + // This is fairly common for development/test environments but is not best + // practice for certificates used outside of temporary / lab environments. + SelfSignedLeafCert bool `json:"self_signed_leaf_cert"` + + // WeakSignatureAlgorithm indicates that the certificate chain has been + // signed using a cryptographically weak hashing algorithm (e.g. MD2, MD4, + // MD5, or SHA1). These signature algorithms are known to be vulnerable to + // collision attacks. An attacker can exploit this to generate another + // certificate with the same digital signature, allowing an attacker to + // masquerade as the affected service. + // + // NOTE: This does not apply to trusted root certificates; TLS clients + // trust them by their identity instead of the signature of their hash; + // client code setting this field would need to exclude root certificates + // from the determination whether the chain is vulnerable to weak + // signature algorithms. + // + // - https://security.googleblog.com/2014/09/gradually-sunsetting-sha-1.html + // - https://security.googleblog.com/2015/12/an-update-on-sha-1-certificates-in.html + // - https://superuser.com/questions/1122069/why-are-root-cas-with-sha1-signatures-not-a-risk + // - https://developer.mozilla.org/en-US/docs/Web/Security/Weak_Signature_Algorithm + // - https://www.tenable.com/plugins/nessus/35291 + // - https://docs.ostorlab.co/kb/WEAK_HASHING_ALGO/index.html + WeakSignatureAlgorithm bool `json:"weak_signature_algorithm"` + + // SelfSignedIntermediateCerts indicates that an intermediate certificate + // in the chain is self-signed. + // + // NOTE: This is unlikely to occur in practice, so we're likely not going + // to keep this field. + // + // SelfSignedIntermediateCerts bool `json:"self_signed_intermediate_certs"` + + // This is a later TODO item. + // RevokedCerts bool `json:"revoked_certs"` +} + +// CertChainPayload is the "parent" data structure which represents the +// information to be encoded as a payload and later decoded for use in +// reporting (and other) tools. +// +// This data structure is (future design) intended to be generated by this +// library and not directly by client code. Instead, client code is meant to +// pass in data using the `InputData` (name subject to change) struct. +type CertChainPayload struct { + // FormatVersion is the format version of the generated certificate + // metadata payload. + FormatVersion int `json:"format_version"` + + // Errors is intended to represent a potential collection of errors + // encountered while retrieving a certificate chain from a service. Due to + // limitations in the JSON encoding/decoding process (exported fields are + // required and interfaces do not provide those), we cannot provide this + // collection as a collection of native Go errors. + // + // See also: + // + // - https://stackoverflow.com/a/44990051/903870 + // + Errors []string `json:"errors"` + + // CertChainOriginal is the original certificate chain entries encoded in + // PEM format. + // + // Due to size constraints this field may not be populated if the user did + // not explicitly opt into bundling the full certificate chain. + CertChainOriginal []string `json:"cert_chain_original"` + + // CertChainSubset is a customized subset of the original certificate + // chain metadata. This field should always be populated. + CertChainSubset []Certificate `json:"cert_chain_subset"` + + // Server reflects the host value and resolved IP Address (which could be + // the same value) used to retrieve the certificate chain. + Server Server `json:"server"` + + // A fully-qualified domain name or IP Address in the Subject Alternate + // Names (SANs) list for the leaf certificate. + // + // Depending on how the check_cert plugin was called this value may not be + // set (e.g., the `server` flag is sufficient if specifying a valid FQDN + // associated with the leaf certificate). + DNSName string `json:"dns_name"` + + // TCPPort is the TCP port of the remote certificate-enabled service. This + // is usually 443 (HTTPS) or 636 (LDAPS). + TCPPort int `json:"tcp_port"` + + // Issues is an aggregated collection of problems detected for the + // certificate chain. + Issues CertificateChainIssues `json:"cert_chain_issues"` + + // ServiceState is the monitoring system's evaluated state for the service + // check performed against a given certificate chain (e.g., OK, CRITICAL, + // WARNING, UNKNOWN). + ServiceState string `json:"service_state"` +} diff --git a/format/v1/decode.go b/format/v1/decode.go new file mode 100644 index 0000000..9902f98 --- /dev/null +++ b/format/v1/decode.go @@ -0,0 +1,43 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format1 + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +// Decode accepts a Reader which provides a certificate metadata payload and +// decodes/unmarshals it into the given destination. An error is returned if +// one occurs when decoding the payload. +func Decode(dest *CertChainPayload, input io.Reader, allowUnknownFields bool) error { + dec := json.NewDecoder(input) + + if !allowUnknownFields { + dec.DisallowUnknownFields() + } + + // Decode the first JSON object. + if err := dec.Decode(dest); err != nil { + return fmt.Errorf( + "failed to decode cert payload: %w", + err, + ) + } + + // If there is more than one object, something is off. + if dec.More() { + return errors.New( + "input contains multiple JSON objects; only one JSON object is supported", + ) + } + + return nil +} diff --git a/format/v1/doc.go b/format/v1/doc.go new file mode 100644 index 0000000..3e6b930 --- /dev/null +++ b/format/v1/doc.go @@ -0,0 +1,12 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Package format1 implements the initial stable certificate payload format. +// +// FIXME: This is a mockup and not a real implementation. Please do not use +// this format version until this note has been removed. +package format1 diff --git a/format/v1/encode.go b/format/v1/encode.go new file mode 100644 index 0000000..43c05ce --- /dev/null +++ b/format/v1/encode.go @@ -0,0 +1,159 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format1 + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/atc0005/cert-payload/format/internal/shared" + "github.com/atc0005/cert-payload/input" + "github.com/atc0005/cert-payload/internal/certs" +) + +// Encode processes the given certificate chain and returns a JSON payload of +// the specified format version. An error is returned if one occurs during +// processing or if an invalid payload version format is specified. +func Encode(inputData input.Values) ([]byte, error) { + // FIXME: We may want to accept this as an argument for testing purposes. + now := time.Now().UTC() + + certsExpireAgeWarning := now.AddDate(0, 0, inputData.ExpirationAgeInDaysWarningThreshold) + certsExpireAgeCritical := now.AddDate(0, 0, inputData.ExpirationAgeInDaysCriticalThreshold) + + certChain := inputData.CertChain + + certChainSubset := make([]Certificate, 0, len(certChain)) + for certNumber, origCert := range certChain { + if origCert == nil { + return nil, fmt.Errorf( + "cert in chain position %d of %d is nil: %w", + certNumber, + len(certChain), + ErrMissingValue, + ) + } + + expiresText := certs.ExpirationStatus( + origCert, + certsExpireAgeCritical, + certsExpireAgeWarning, + false, + ) + + hasExpiring := shared.HasExpiringCerts(certChain, certsExpireAgeCritical, certsExpireAgeWarning) + hasExpired := shared.HasExpiredCerts(certChain) + + certStatus := CertificateStatus{ + OK: !hasExpired && !hasExpiring, + Expiring: hasExpiring, + Expired: hasExpired, + } + + certExpMeta, lookupErr := shared.LookupCertExpMetadata(origCert, certNumber, certChain) + if lookupErr != nil { + return nil, lookupErr + } + + var SANsEntries []string + if inputData.OmitSANsEntries { + SANsEntries = nil + } else { + SANsEntries = origCert.DNSNames + } + + validityPeriodDescription := shared.LookupValidityPeriodDescription(origCert) + + certSubset := Certificate{ + Subject: origCert.Subject.String(), + CommonName: origCert.Subject.CommonName, + SANsEntries: SANsEntries, + SANsEntriesCount: len(origCert.DNSNames), + Issuer: origCert.Issuer.String(), + IssuerShort: origCert.Issuer.CommonName, + SerialNumber: certs.FormatCertSerialNumber(origCert.SerialNumber), + IssuedOn: origCert.NotBefore, + ExpiresOn: origCert.NotAfter, + DaysRemaining: certExpMeta.DaysRemainingPrecise, + DaysRemainingTruncated: certExpMeta.DaysRemainingTruncated, + LifetimePercent: certExpMeta.CertLifetimePercent, + ValidityPeriodDescription: validityPeriodDescription, + ValidityPeriodDays: certExpMeta.ValidityPeriodDays, + Summary: expiresText, + Status: certStatus, + SignatureAlgorithm: origCert.SignatureAlgorithm.String(), + Type: certs.ChainPosition(origCert, certChain), + } + + certChainSubset = append(certChainSubset, certSubset) + } + + // Default to using the server FQDN or IP Address used to make the + // connection as our hostname value. + hostnameValue := inputData.Server.HostValue + + // Allow the user to explicitly specify which hostname should be used + // for comparison against the leaf certificate. This works for a + // certificate retrieved by a server as well as a certificate + // retrieved from a file. + if inputData.DNSName != "" { + hostnameValue = inputData.DNSName + } + + certChainIssues := CertificateChainIssues{ + MissingIntermediateCerts: shared.HasMissingIntermediateCerts(certChain), + MissingSANsEntries: shared.HasMissingSANsEntries(certChain), + DuplicateCerts: shared.HasDuplicateCertsInChain(certChain), + MisorderedCerts: shared.HasMisorderedCerts(certChain), + ExpiredCerts: shared.HasExpiredCerts(certChain), + HostnameMismatch: shared.HasHostnameMismatch(hostnameValue, certChain), + SelfSignedLeafCert: shared.HasSelfSignedLeaf(certChain), + WeakSignatureAlgorithm: shared.HasWeakSignatureAlgorithm(certChain), + } + + // Only if the user explicitly requested the full cert payload do we + // include it (due to significant payload size increase and risk of + // exceeding size constraints). + var certChainOriginal []string + switch { + case inputData.IncludeFullCertChain: + pemCertChain, err := shared.CertChainToPEM(certChain) + if err != nil { + return nil, fmt.Errorf("error converting original cert chain to PEM format: %w", err) + } + + certChainOriginal = pemCertChain + + default: + certChainOriginal = nil + } + + payload := CertChainPayload{ + FormatVersion: FormatVersion, + Errors: shared.ErrorsToStrings(inputData.Errors), + TestingOutofTacos: false, // fake; force payload conflict with format version 0 + CertChainOriginal: certChainOriginal, + CertChainSubset: certChainSubset, + Server: inputData.Server.HostValue, + DNSName: inputData.DNSName, + TCPPort: inputData.TCPPort, + Issues: certChainIssues, + ServiceState: inputData.ServiceState, + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf( + "error marshaling cert chain payload as JSON: %w", + err, + ) + } + + return payloadJSON, nil +} diff --git a/format/v1/errors.go b/format/v1/errors.go new file mode 100644 index 0000000..fe288a5 --- /dev/null +++ b/format/v1/errors.go @@ -0,0 +1,8 @@ +package format1 + +import "errors" + +var ( + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") +) diff --git a/format/v1/helpers.go b/format/v1/helpers.go new file mode 100644 index 0000000..201d1ff --- /dev/null +++ b/format/v1/helpers.go @@ -0,0 +1,41 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package format1 + +// Confirmed is a helper function to indicate whether issues are present +// with the evaluated certificate chain. +func (cci CertificateChainIssues) Confirmed() bool { + switch { + case cci.MissingIntermediateCerts: + return true + + case cci.MissingSANsEntries: + return true + + case cci.DuplicateCerts: + return true + + case cci.MisorderedCerts: + return true + + case cci.ExpiredCerts: + return true + + case cci.HostnameMismatch: + return true + + case cci.SelfSignedLeafCert: + return true + + case cci.WeakSignatureAlgorithm: + return true + + default: + return false + } +} diff --git a/format/v1/types.go b/format/v1/types.go new file mode 100644 index 0000000..b6fe90d --- /dev/null +++ b/format/v1/types.go @@ -0,0 +1,277 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// FIXME: This is a mockup and not a real implementation as format version 0 +// is still being actively updated; format version 1 is intended to contrast +// with format version 0 for dev/testing purposes. + +package format1 + +import ( + "time" +) + +const ( + // FormatVersion indicates the format version support provided by this + // package. Version 1 is the first stable release version that we'll + // support once the provided types & behavior stabilizes. + // + // FIXME: Format version 1 (at this time) is just a placeholder to help + // with initial testing. + // + FormatVersion int = 1 +) + +// CertificateStatus is the overall status of a certificate. +// +// - no problems (ok) +// - expired +// - expiring (based on given threshold values) +// - revoked (not yet supported) +// +// TODO: Any useful status values to borrow here? +// They have `Active`, `Revoked` and then a `Pending*` variation for both. +// https://developers.cloudflare.com/ssl/reference/certificate-statuses/#client-certificates +type CertificateStatus struct { + OK bool `json:"status_ok"` // No observed issues; shouldn't this be calculated? + Expiring bool `json:"status_expiring"` // Based on given monitoring thresholds + Expired bool `json:"status_expired"` // Based on certificate NotAfter field + + // This is a feature to add later + // RevokedPerCRL bool `json:"status_revoked_per_crl"` // Based on CRL or OCSP check? + // RevokedPerOCSP bool `json:"status_revoked_per_ocsp"` // Based on CRL or OCSP check? + // ? +} + +// Certificate is a subset of the metadata for an evaluated certificate. +type Certificate struct { + // Subject is the full subject value for a certificate. This is intended + // for (non-cryptographic) comparison purposes. + Subject string `json:"subject"` + + // CommonName is the short subject value of a certificate. This is + // intended for display purposes. + CommonName string `json:"common_name"` + + // SANsEntries is the full list of Subject Alternate Names for a + // certificate. + SANsEntries []string `json:"sans_entries"` + + // SANsEntriesCount is the number of Subject Alternate Names for a + // certificate. + // + // This field allows the payload creator to omit SANs entries to conserve + // plugin output size and still indicate the number of SANs entries + // present for a certificate for use in display or for metrics purposes. + SANsEntriesCount int `json:"sans_entries_count"` + + // Issuer is the full CommonName of the signing certificate. This is + // intended for (non-cryptographic) comparison purposes. + Issuer string `json:"issuer"` + + // IssuerShort is the short CommonName of the signing certificate. This is + // intended for display purposes. + IssuerShort string `json:"issuer_short"` + + // SerialNumber is the serial number for a certificate in hex format with + // a colon inserted after each two digits. + // + // For example, `77:BD:0D:6C:DB:36:F9:1A:EA:21:0F:C4:F0:58:D3:0D`. + SerialNumber string `json:"serial_number"` + + // IssuedOn is a RFC3389 time value for when a certificate is first + // valid or usable. + IssuedOn time.Time `json:"not_before"` + + // ExpiresOn is a RFC3389 time value for when the certificate expires. + ExpiresOn time.Time `json:"not_after"` + + // DaysRemaining is the number of days remaining for a certificate in two + // digit decimal precision. + DaysRemaining float64 `json:"days_remaining"` + + // DaysRemainingTruncated is the number of days remaining for a + // certificate as a whole number rounded down. + // + // For example, if five and a half days remain then this value would be + // `5`. + DaysRemainingTruncated int `json:"days_remaining_truncated"` + + // LifetimePercent is percentage of life remaining for a certificate. + // + // For example, if 43% life is remaining for a cert (a rounded value) this + // field would be set to `43`. + LifetimePercent int `json:"lifetime_remaining_percent"` + + // ValidityPeriodDescription is the human readable value such as "90 days" + // or "1 year". + ValidityPeriodDescription string `json:"validity_period_description"` + + // ValidityPeriodDays is the number of total days a certificate is valid + // for using `Not Before` & `Not After` as the starting & ending range. + ValidityPeriodDays int `json:"validity_period_days"` + + // human readable summary such as, `[OK] 1199d 2h remaining (43%)` + Summary string `json:"summary"` + + // Status is the overall status of the certificate. + Status CertificateStatus `json:"status"` + + // SignatureAlgorithm indicates what certificate signature algorithm was + // used by a certification authority (CA)'s private key to sign a checksum + // calculated by a signature hash algorithm (i.e., what algorithm was used + // to sign the certificate). The verifying party must use the same + // algorithm to decrypt and verify the checksum using the CA's public key. + // + // A cryptographically weak hashing algorithm (e.g. MD2, MD4, MD5, SHA1) + // used to sign a certificate is considered to be a vulnerability. + SignatureAlgorithm string `json:"signature_algorithm"` + + // Type indicates the type of certificate (leaf, intermediate or root). + Type string `json:"type"` +} + +// CertificateChainIssues is an aggregated collection of problems detected for +// the certificate chain. +type CertificateChainIssues struct { + // MissingIntermediateCerts indicates that intermediate certificates are + // missing from the certificate chain. + MissingIntermediateCerts bool `json:"missing_intermediate_certs"` + + // MissingSANsEntries indicates that SANs entries are missing from a leaf + // certificate within the certificates chain. + MissingSANsEntries bool `json:"missing_sans_entries"` + + // DuplicateCerts indicates that there are one or more duplicate copies of + // a certificate in the certificate chain. + DuplicateCerts bool `json:"duplicate_certs"` + + // MisorderedCerts indicates that certificates in the chain are out of the + // expected order. + // + // E.g., instead of leaf, intermediate(s), root (technically not best + // practice) the chain has something like leaf, root, intermediate(s) or + // intermediates and then leaf. + MisorderedCerts bool `json:"misordered_certs"` + + // ExpiredCerts indicates that there are one or more expired certificates + // in the certificate chain. + ExpiredCerts bool `json:"expired_certs"` + + // HostnameMismatch indicates that the name or IP Address used to + // establish a connection to a certificate-enabled service does not match + // the list of valid host names honored by the leaf certificate. + // + // Historically the Common Name (CN) field was searched in addition to the + // Subject Alternate Names (SANs) field for a match, but this practice is + // deprecated and many clients (e.g., web browsers) no longer support + // this. + HostnameMismatch bool `json:"hostname_mismatch"` + + // SelfSignedLeafCert indicates that the leaf certificate is self-signed. + // This is fairly common for development/test environments but is not best + // practice for certificates used outside of temporary / lab environments. + SelfSignedLeafCert bool `json:"self_signed_leaf_cert"` + + // WeakSignatureAlgorithm indicates that the certificate chain has been + // signed using a cryptographically weak hashing algorithm (e.g. MD2, MD4, + // MD5, or SHA1). These signature algorithms are known to be vulnerable to + // collision attacks. An attacker can exploit this to generate another + // certificate with the same digital signature, allowing an attacker to + // masquerade as the affected service. + // + // NOTE: This does not apply to trusted root certificates; TLS clients + // trust them by their identity instead of the signature of their hash; + // client code setting this field would need to exclude root certificates + // from the determination whether the chain is vulnerable to weak + // signature algorithms. + // + // - https://security.googleblog.com/2014/09/gradually-sunsetting-sha-1.html + // - https://security.googleblog.com/2015/12/an-update-on-sha-1-certificates-in.html + // - https://superuser.com/questions/1122069/why-are-root-cas-with-sha1-signatures-not-a-risk + // - https://developer.mozilla.org/en-US/docs/Web/Security/Weak_Signature_Algorithm + // - https://www.tenable.com/plugins/nessus/35291 + // - https://docs.ostorlab.co/kb/WEAK_HASHING_ALGO/index.html + WeakSignatureAlgorithm bool `json:"weak_signature_algorithm"` + + // SelfSignedIntermediateCerts indicates that an intermediate certificate + // in the chain is self-signed. + // + // NOTE: This is unlikely to occur in practice, so we're likely not going + // to keep this field. + // + // SelfSignedIntermediateCerts bool `json:"self_signed_intermediate_certs"` + + // This is a later TODO item. + // RevokedCerts bool `json:"revoked_certs"` +} + +// CertChainPayload is the "parent" data structure which represents the +// information to be encoded as a payload and later decoded for use in +// reporting (and other) tools. +// +// This data structure is (future design) intended to be generated by this +// library and not directly by client code. Instead, client code is meant to +// pass in data using the `InputData` (name subject to change) struct. +type CertChainPayload struct { + // FormatVersion is the format version of the generated certificate + // metadata payload. + FormatVersion int `json:"format_version"` + + // Errors is intended to represent a potential collection of errors + // encountered while retrieving a certificate chain from a service. Due to + // limitations in the JSON encoding/decoding process (exported fields are + // required and interfaces do not provide those), we cannot provide this + // collection as a collection of native Go errors. + // + // See also: + // + // - https://stackoverflow.com/a/44990051/903870 + // + Errors []string `json:"errors"` + + // TestingOutofTacos is a fake field used just to make sure that the + // "parent" type provided by this package differs from format version 0 + // for testing purposes. + TestingOutofTacos bool + + // CertChainOriginal is the original certificate chain entries encoded in + // PEM format. + // + // Due to size constraints this field may not be populated if the user did + // not explicitly opt into bundling the full certificate chain. + CertChainOriginal []string `json:"cert_chain_original"` + + // CertChainSubset is a customized subset of the original certificate + // chain metadata. This field should always be populated. + CertChainSubset []Certificate `json:"cert_chain_subset"` + + // Server is the FQDN or IP Address specified to the plugin which was used + // to retrieve the certificate chain. + Server string `json:"server"` // FIXME: Intentionally leaving this as a string instead of the Server type + + // A fully-qualified domain name or IP Address in the Subject Alternate + // Names (SANs) list for the leaf certificate. + // + // Depending on how the check_cert plugin was called this value may not be + // set (e.g., the `server` flag is sufficient if specifying a valid FQDN + // associated with the leaf certificate). + DNSName string `json:"dns_name"` + + // TCPPort is the TCP port of the remote certificate-enabled service. This + // is usually 443 (HTTPS) or 636 (LDAPS). + TCPPort int `json:"tcp_port"` + + // Issues is an aggregated collection of problems detected for the + // certificate chain. + Issues CertificateChainIssues `json:"cert_chain_issues"` + + // ServiceState is the monitoring system's evaluated state for the service + // check performed against a given certificate chain (e.g., OK, CRITICAL, + // WARNING, UNKNOWN). + ServiceState string `json:"service_state"` +} diff --git a/go.mod b/go.mod index 9857ff8..b8c1e7c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + module github.com/atc0005/cert-payload go 1.19 diff --git a/input/data.go b/input/data.go new file mode 100644 index 0000000..33e82d1 --- /dev/null +++ b/input/data.go @@ -0,0 +1,79 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package input + +import "crypto/x509" + +// Server reflects the host value and resolved IP Address (which could be +// the same value) used to retrieve the certificate chain. +type Server struct { + // HostValue is the original hostname value. While usually a FQDN, this + // value could also be a fixed IP Address (e.g., if SNI support wasn't + // used to retrieve the certificate chain). + HostValue string + + // IPAddress is the resolved IP Address for the hostname value used to + // retrieve a certificate chain. + IPAddress string +} + +// Values is a collection of input data values that was provided to the plugin +// (e.g., CLI flags), gathered by the plugin (e.g., CertChain) without any +// sysadmin-specified filtering applied (e.g., "ignore expiring intermediates" +// to create a certificate metadata payload. +type Values struct { + // CertChainOriginal is the original certificate chain entries as-is + // without any "problematic" entries removed. + CertChain []*x509.Certificate + + // Errors represents a potential collection of errors encountered while + // retrieving a certificate chain from a service. + Errors []error + + // IncludeFullCertChain indicates that the full certificate chain should + // be included in the generated metadata payload. This is not included by + // default due to the not insignificant size increase. + IncludeFullCertChain bool + + // OmitSANsEntries indicates that Subject Alternate Names entries should + // be omitted from the generate metadata payload. This option may be + // chosen to reduce the output payload size. + OmitSANsEntries bool + + // ExpirationAgeInDaysWarningThreshold is the number of days remaining + // before certificate expiration when the certificate should be considered + // to be expiring and in a WARNING state. + ExpirationAgeInDaysWarningThreshold int + + // ExpirationAgeInDaysCriticalThreshold is the number of days remaining + // before certificate expiration when the certificate should be considered + // to be expiring and in a CRITICAL state. + ExpirationAgeInDaysCriticalThreshold int + + // Server is the host value (FQDN or IP Address) and resolved IP Address + // which was used to retrieve the certificate chain. + Server Server + + // A fully-qualified domain name or IP Address in the Subject Alternate + // Names (SANs) list for the leaf certificate. + // + // Depending on how the check_cert plugin was called this value may not be + // set. For example, the `server` flag is sufficient if specifying a valid + // FQDN associated with the leaf certificate or if SNI support is not + // used. + DNSName string + + // TCPPort is the TCP port of the remote certificate-enabled service. This + // is usually 443 (HTTPS) or 636 (LDAPS). + TCPPort int + + // ServiceState is the monitoring system's evaluated state for the service + // check performed against a given certificate chain (e.g., OK, CRITICAL, + // WARNING, UNKNOWN). + ServiceState string +} diff --git a/input/doc.go b/input/doc.go new file mode 100644 index 0000000..2436f9b --- /dev/null +++ b/input/doc.go @@ -0,0 +1,10 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Package input provides the input data format used to generate certificate +// metadata payloads. +package input diff --git a/internal/certs/certs.go b/internal/certs/certs.go new file mode 100644 index 0000000..b1babf1 --- /dev/null +++ b/internal/certs/certs.go @@ -0,0 +1,659 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package certs + +import ( + "crypto" + "crypto/md5" //nolint:gosec // Used to verify MD5WithRSA signatures + "crypto/rsa" + "crypto/x509" + "errors" + "fmt" + "math" + "math/big" + "strings" + "time" + + "github.com/atc0005/cert-payload/internal/textutils" +) + +var ( + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") +) + +const ( + certChainPositionLeaf string = "leaf" + certChainPositionLeafSelfSigned string = "leaf; self-signed" + certChainPositionIntermediate string = "intermediate" + certChainPositionRoot string = "root" + certChainPositionUnknown string = "UNKNOWN cert chain position; please submit a bug report" +) + +// Nagios plugin/service check state "labels". These values are used (where +// applicable) by the CertChainPayload `ServiceState` field. +const ( + StateOKLabel string = "OK" + StateWARNINGLabel string = "WARNING" + StateCRITICALLabel string = "CRITICAL" + StateUNKNOWNLabel string = "UNKNOWN" + StateDEPENDENTLabel string = "DEPENDENT" +) + +// isSelfSigned is a helper function that attempts to validate whether a given +// certificate is self-signed by asserting that its signature can be validated +// with its own public key. Any errors encountered during signature validation +// are assumed to be an indication that a certificate is not self-signed. +func isSelfSigned(cert *x509.Certificate) bool { + if cert.Issuer.String() == cert.Subject.String() { + sigVerifyErr := cert.CheckSignature( + cert.SignatureAlgorithm, + cert.RawTBSCertificate, + cert.Signature, + ) + + switch { + // examine signature verification errors + case errors.Is(sigVerifyErr, x509.InsecureAlgorithmError(cert.SignatureAlgorithm)): + + // fmt.Println("errors.Is match") + + // Handle MD5 signature verification ourselves since Go considers + // the MD5 algorithm to be insecure (rightly so). + if cert.SignatureAlgorithm == x509.MD5WithRSA { + + // fmt.Println("SignatureAlgorithm match") + + // nolint:gosec + h := md5.New() + if _, err := h.Write(cert.RawTBSCertificate); err != nil { + // fmt.Println( + // "isSelfSigned: failed to generate MD5 hash of RawTBSCertificate:", + // err, + // ) + return false + } + hashedBytes := h.Sum(nil) + + if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok { + + // fmt.Println("type assertion for rsa.PublicKey successful") + + md5RSASigVerifyErr := rsa.VerifyPKCS1v15( + pub, crypto.MD5, hashedBytes, cert.Signature, + ) + + switch { + + case md5RSASigVerifyErr != nil: + // fmt.Println( + // "isSelfSigned: failed to validate MD5WithRSA signature:", + // md5RSASigVerifyErr, + // ) + + return false + + default: + // fmt.Println("MD5 signature verified") + + return true + } + } + } + + // TODO: Do we need to check this ourselves in Go 1.18? + // if cert.SignatureAlgorithm == x509.SHA1WithRSA { + // } + + return false + + // no problems verifying self-signed signature + case sigVerifyErr == nil: + + return true + } + } + + return false +} + +// ChainPosition receives a cert and the cert chain that it belongs to and +// returns a string indicating what position or "role" it occupies in the +// certificate chain. +// +// https://en.wikipedia.org/wiki/X.509 +// https://tools.ietf.org/html/rfc5280 +func ChainPosition(cert *x509.Certificate, certChain []*x509.Certificate) string { + + // We require a valid certificate chain. Fail if not provided. + if certChain == nil { + return certChainPositionUnknown + } + + switch cert.Version { + + // Because v1 and v2 certs lack the more descriptive "intention" + // fields of v3 certs, we are limited in what checks we can apply. We + // rely on a combination of self-signed and literal chain position to + // help determine the purpose of each v1 and v2 certificate. + case 1, 2: + + switch { + case isSelfSigned(cert): + if cert == certChain[0] { + return certChainPositionLeafSelfSigned + } + + return certChainPositionRoot + + default: + if cert == certChain[0] { + return certChainPositionLeaf + } + + return certChainPositionIntermediate + } + + case 3: + + switch { + case isSelfSigned(cert): + + // FIXME: What pattern to use for self-signed v3 leaf? + + // The cA boolean indicates whether the certified public key may be + // used to verify certificate signatures. + if cert.IsCA { + return certChainPositionRoot + } + + // The Extended key usage extension indicates one or more purposes + // for which the certified public key may be used, in addition to + // or in place of the basic purposes indicated in the key usage + // extension. In general, this extension will appear only in end + // entity certificates. + if cert.ExtKeyUsage != nil { + return certChainPositionLeafSelfSigned + } + + // CA certs are intended for cert and CRL signing. + // + // In the majority of cases (all?), the cA boolean field will + // already be set if either of these under `X509v3 Basic + // Constraints` are specified. + switch cert.KeyUsage { + case cert.KeyUsage | x509.KeyUsageCertSign | x509.KeyUsageCRLSign: + return certChainPositionRoot + case cert.KeyUsage | x509.KeyUsageCertSign: + return certChainPositionRoot + default: + return certChainPositionLeafSelfSigned + } + + default: + + // The cA boolean indicates whether the certified public key may be + // used to verify certificate signatures. + if cert.IsCA { + return certChainPositionIntermediate + } + + // The Extended key usage extension indicates one or more purposes + // for which the certified public key may be used, in addition to + // or in place of the basic purposes indicated in the key usage + // extension. In general, this extension will appear only in end + // entity certificates. + if cert.ExtKeyUsage != nil { + return certChainPositionLeaf + } + + // CA certs are intended for cert and CRL signing. + // + // In the majority (all?) of cases, the cA boolean field will + // already be set if either of these under `X509v3 Basic + // Constraints` are specified. + switch cert.KeyUsage { + case cert.KeyUsage | x509.KeyUsageCertSign | x509.KeyUsageCRLSign: + return certChainPositionIntermediate + case cert.KeyUsage | x509.KeyUsageCertSign: + return certChainPositionIntermediate + default: + return certChainPositionLeaf + } + + } + } + + // no known match, so position unknown + return certChainPositionUnknown + +} + +// MaxLifespanInDays returns the maximum lifespan in days for a given +// certificate from the date it was issued until the time it is scheduled to +// expire. +func MaxLifespanInDays(cert *x509.Certificate) (int, error) { + if cert == nil { + return 0, fmt.Errorf( + "func MaxLifespanInDays: unable to determine expiration: %w", + ErrMissingValue, + ) + } + + maxCertLifespan := cert.NotAfter.Sub(cert.NotBefore) + daysMaxLifespan := int(math.Trunc(maxCertLifespan.Hours() / 24)) + + return daysMaxLifespan, nil +} + +// NumLeafCerts receives a slice of x509 certificates and returns a count of +// leaf certificates present in the chain. +func NumLeafCerts(certChain []*x509.Certificate) int { + var num int + for _, cert := range certChain { + chainPos := ChainPosition(cert, certChain) + switch chainPos { + case certChainPositionLeaf: + num++ + case certChainPositionLeafSelfSigned: + num++ + } + } + + return num +} + +// NumIntermediateCerts receives a slice of x509 certificates and returns a +// count of intermediate certificates present in the chain. +func NumIntermediateCerts(certChain []*x509.Certificate) int { + var num int + for _, cert := range certChain { + chainPos := ChainPosition(cert, certChain) + if chainPos == certChainPositionIntermediate { + num++ + } + } + + return num +} + +// NonRootCerts receives a slice of x509 certificates and returns a collection +// of certificates present in the chain which are not root certificates. +func NonRootCerts(certChain []*x509.Certificate) []*x509.Certificate { + numPresent := NumLeafCerts(certChain) + NumIntermediateCerts(certChain) + nonRootCerts := make([]*x509.Certificate, 0, numPresent) + + for _, cert := range certChain { + chainPos := ChainPosition(cert, certChain) + if chainPos != certChainPositionRoot { + nonRootCerts = append(nonRootCerts, cert) + } + } + + return nonRootCerts +} + +// LeafCerts receives a slice of x509 certificates and returns a (potentially +// empty) collection of leaf certificates present in the chain. +func LeafCerts(certChain []*x509.Certificate) []*x509.Certificate { + numPresent := NumLeafCerts(certChain) + leafCerts := make([]*x509.Certificate, 0, numPresent) + + for _, cert := range certChain { + chainPos := ChainPosition(cert, certChain) + switch chainPos { + case certChainPositionLeaf: + leafCerts = append(leafCerts, cert) + case certChainPositionLeafSelfSigned: + leafCerts = append(leafCerts, cert) + } + + } + + return leafCerts +} + +// HasExpiringCert receives a slice of x509 certificates, CRITICAL age +// threshold and WARNING age threshold values and ignoring any certificates +// already expired, uses the provided thresholds to determine if any +// certificates are about to expire. A boolean value is returned to indicate +// the results of this check. +func HasExpiringCert(certChain []*x509.Certificate, ageCritical time.Time, ageWarning time.Time) bool { + for idx := range certChain { + switch { + case !IsExpiredCert(certChain[idx]) && certChain[idx].NotAfter.Before(ageCritical): + return true + case !IsExpiredCert(certChain[idx]) && certChain[idx].NotAfter.Before(ageWarning): + return true + } + } + + return false + +} + +// HasExpiredCert receives a slice of x509 certificates and indicates whether +// any of the certificates in the chain have expired. +func HasExpiredCert(certChain []*x509.Certificate) bool { + + for idx := range certChain { + if certChain[idx].NotAfter.Before(time.Now()) { + return true + } + } + + return false + +} + +// FormattedExpiration receives a Time value and converts it to a string +// representing the largest useful whole units of time in days and hours. For +// example, if a certificate has 1 year, 2 days and 3 hours remaining until +// expiration, this function will return the string '367d 3h remaining', but +// if only 3 hours remain then '3h remaining' will be returned. If a +// certificate has expired, the 'ago' suffix will be used instead. For +// example, if a certificate has expired 3 hours ago, '3h ago' will be +// returned. +func FormattedExpiration(expireTime time.Time) string { + + // hoursRemaining := time.Until(certificate.NotAfter)/time.Hour)/24, + timeRemaining := time.Until(expireTime).Hours() + + var certExpired bool + var formattedTimeRemainingStr string + var daysRemainingStr string + var hoursRemainingStr string + + // Flip sign back to positive, note that cert is expired for later use + if timeRemaining < 0 { + certExpired = true + timeRemaining *= -1 + } + + // Toss remainder so that we only get the whole number of days + daysRemaining := math.Trunc(timeRemaining / 24) + + if daysRemaining > 0 { + daysRemainingStr = fmt.Sprintf("%dd", int64(daysRemaining)) + } + + // Multiply the whole number of days by 24 to get the hours value, then + // subtract from the original number of hours until cert expiration to get + // the number of hours leftover from the days calculation. + hoursRemaining := math.Trunc(timeRemaining - (daysRemaining * 24)) + + hoursRemainingStr = fmt.Sprintf("%dh", int64(hoursRemaining)) + + // Only join days and hours remaining if there *are* days remaining. + switch { + case daysRemainingStr != "": + formattedTimeRemainingStr = strings.Join( + []string{daysRemainingStr, hoursRemainingStr}, + " ", + ) + default: + formattedTimeRemainingStr = hoursRemainingStr + } + + switch { + case !certExpired: + formattedTimeRemainingStr = strings.Join([]string{formattedTimeRemainingStr, "remaining"}, " ") + case certExpired: + formattedTimeRemainingStr = strings.Join([]string{formattedTimeRemainingStr, "ago"}, " ") + } + + return formattedTimeRemainingStr + +} + +// ExpirationStatus receives a certificate and the expiration threshold values +// for CRITICAL and WARNING states and returns a human-readable string +// indicating the overall status at a glance. If requested, an expiring or +// expired certificate is marked as ignored. +func ExpirationStatus(cert *x509.Certificate, ageCritical time.Time, ageWarning time.Time, ignoreExpiration bool) string { + var expiresText string + certExpiration := cert.NotAfter + + var lifeRemainingText string + if remaining, err := LifeRemainingPercentageTruncated(cert); err == nil { + lifeRemainingText = fmt.Sprintf(" (%d%%)", remaining) + } + + switch { + case certExpiration.Before(time.Now()) && ignoreExpiration: + expiresText = fmt.Sprintf( + "[EXPIRED, IGNORED] %s%s", + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + case certExpiration.Before(time.Now()): + expiresText = fmt.Sprintf( + "[EXPIRED] %s%s", + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + case certExpiration.Before(ageCritical) && ignoreExpiration: + expiresText = fmt.Sprintf( + "[EXPIRING, IGNORED] %s%s", + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + case certExpiration.Before(ageCritical): + expiresText = fmt.Sprintf( + "[%s] %s%s", + StateCRITICALLabel, + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + case certExpiration.Before(ageWarning) && ignoreExpiration: + expiresText = fmt.Sprintf( + "[EXPIRING, IGNORED] %s%s", + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + case certExpiration.Before(ageWarning): + expiresText = fmt.Sprintf( + "[%s] %s%s", + StateWARNINGLabel, + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + default: + expiresText = fmt.Sprintf( + "[%s] %s%s", + StateOKLabel, + FormattedExpiration(certExpiration), + lifeRemainingText, + ) + + } + + return expiresText +} + +// HasWeakSignatureAlgorithm evaluates the given certificate (within the +// context of a given certificate chain) and indicates whether a known weak +// signature algorithm was found. +// +// Root certificates evaluate to false (by default) as TLS clients trust them +// by their identity instead of the signature of their hash. +// +// If explicitly requested root certificates are also evaluated. +// +// - https://security.googleblog.com/2014/09/gradually-sunsetting-sha-1.html +// - https://security.googleblog.com/2015/12/an-update-on-sha-1-certificates-in.html +// - https://superuser.com/questions/1122069/why-are-root-cas-with-sha1-signatures-not-a-risk +// - https://developer.mozilla.org/en-US/docs/Web/Security/Weak_Signature_Algorithm +// - https://www.tenable.com/plugins/nessus/35291 +// - https://docs.ostorlab.co/kb/WEAK_HASHING_ALGO/index.html +func HasWeakSignatureAlgorithm(cert *x509.Certificate, certChain []*x509.Certificate, evalRoot bool) bool { + chainPos := ChainPosition(cert, certChain) + + if chainPos == certChainPositionRoot && !evalRoot { + return false + } + + switch { + case cert.SignatureAlgorithm == x509.MD2WithRSA: + return true + + case cert.SignatureAlgorithm == x509.MD5WithRSA: + return true + + case cert.SignatureAlgorithm == x509.SHA1WithRSA: + return true + + case cert.SignatureAlgorithm == x509.DSAWithSHA1: + return true + + case cert.SignatureAlgorithm == x509.ECDSAWithSHA1: + return true + + default: + return false + } +} + +// FormatCertSerialNumber receives a certificate serial number in its native +// type and formats it in the text format used by OpenSSL (and many other +// tools). +// +// Example: DE:FD:50:2B:C5:7F:79:F4 +func FormatCertSerialNumber(sn *big.Int) string { + + // convert serial number from native *bit.Int format to a hex string + // snHexStr := sn.Text(16) + // + // use Sprintf hex formatting in order to retain leading zero (GH-114) + // credit: inspired by discussion on mozilla/tls-observatory#245 + snHexStr := fmt.Sprintf("%X", sn.Bytes()) + + delimiterPosition := 2 + delimiter := ":" + + // ignore the leading negative sign if present + if sn.Sign() == -1 { + snHexStr = strings.TrimPrefix(snHexStr, "-") + } + + formattedSerialNum := textutils.InsertDelimiter(snHexStr, delimiter, delimiterPosition) + formattedSerialNum = strings.ToUpper(formattedSerialNum) + + // add back negative sign if originally present + if sn.Sign() == -1 { + return "-" + formattedSerialNum + } + + return formattedSerialNum + +} + +// IsExpiredCert receives a x509 certificate and returns a boolean value +// indicating whether the cert has expired. +func IsExpiredCert(cert *x509.Certificate) bool { + return cert.NotAfter.Before(time.Now()) +} + +// ExpiresInDays evaluates the given certificate and returns the number of +// days until the certificate expires. If already expired, a negative number +// is returned indicating how many days the certificate is past expiration. +// +// An error is returned if the pointer to the given certificate is nil. +func ExpiresInDays(cert *x509.Certificate) (int, error) { + if cert == nil { + return 0, fmt.Errorf( + "func ExpiresInDays: unable to determine expiration: %w", + ErrMissingValue, + ) + } + + timeRemaining := time.Until(cert.NotAfter).Hours() + + // Toss remainder so that we only get the whole number of days + daysRemaining := int(math.Trunc(timeRemaining / 24)) + + return daysRemaining, nil +} + +// ExpiresInDaysPrecise evaluates the given certificate and returns the number +// of days until the certificate expires as a floating point number. This +// number is rounded down. +// +// If already expired, a negative number is returned indicating how many days +// the certificate is past expiration. +// +// An error is returned if the pointer to the given certificate is nil. +func ExpiresInDaysPrecise(cert *x509.Certificate) (float64, error) { + if cert == nil { + return 0, fmt.Errorf( + "func ExpiresInDaysPrecise: unable to determine expiration: %w", + ErrMissingValue, + ) + } + + timeRemaining := time.Until(cert.NotAfter).Hours() + + // Round down to the nearest two decimal places. + daysRemaining := timeRemaining / 24 + daysRemaining = math.Floor(daysRemaining*100) / 100 + + return daysRemaining, nil +} + +// LifeRemainingPercentage returns the percentage of remaining time before a +// certificate expires. +func LifeRemainingPercentage(cert *x509.Certificate) (float64, error) { + if cert == nil { + return 0, fmt.Errorf( + "func LifeRemainingPercentage: unable to determine expiration: %w", + ErrMissingValue, + ) + } + + if IsExpiredCert(cert) { + return 0.0, nil + } + + daysMaxLifespan, err := MaxLifespanInDays(cert) + if err != nil { + return 0, err + } + + daysRemaining, err := ExpiresInDays(cert) + if err != nil { + return 0, err + } + + certLifeRemainingPercentage := float64(daysRemaining) / float64(daysMaxLifespan) * 100 + + return certLifeRemainingPercentage, nil +} + +// LifeRemainingPercentageTruncated returns the truncated percentage of +// remaining time before a certificate expires. +func LifeRemainingPercentageTruncated(cert *x509.Certificate) (int, error) { + if cert == nil { + return 0, fmt.Errorf( + "func LifeRemainingPercentageTruncated: unable to determine expiration: %w", + ErrMissingValue, + ) + } + + if IsExpiredCert(cert) { + return 0, nil + } + + certLifeRemainingPercentage, err := LifeRemainingPercentage(cert) + if err != nil { + return 0, err + } + + certLifespanRemainingTruncated := int(math.Trunc(certLifeRemainingPercentage)) + + return certLifespanRemainingTruncated, nil +} diff --git a/internal/certs/doc.go b/internal/certs/doc.go new file mode 100644 index 0000000..76b762b --- /dev/null +++ b/internal/certs/doc.go @@ -0,0 +1,9 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Package certs provides common/shared utility code to process certificates. +package certs diff --git a/internal/textutils/doc.go b/internal/textutils/doc.go new file mode 100644 index 0000000..6534495 --- /dev/null +++ b/internal/textutils/doc.go @@ -0,0 +1,10 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Package textutils provides common helper functions for text manipulation or +// output used by applications in this module. +package textutils diff --git a/internal/textutils/normalize.go b/internal/textutils/normalize.go new file mode 100644 index 0000000..0880e9d --- /dev/null +++ b/internal/textutils/normalize.go @@ -0,0 +1,45 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package textutils + +import "bytes" + +// Confirmed newline/EOL values. +const ( + WindowsEOL = "\r\n" + MacEOL = "\r" + UnixEOL = "\n" +) + +// NormalizeNewlines replaces all Windows and Mac newlines with Unix newlines. +// +// Use this with caution if applying directly to binary files (e.g., it can +// break parsing of DER binary certificate files).) +func NormalizeNewlines(input []byte) []byte { + input = bytes.ReplaceAll(input, []byte(WindowsEOL), []byte(UnixEOL)) + input = bytes.ReplaceAll(input, []byte(MacEOL), []byte(UnixEOL)) + + return input +} + +// StripBlankLines removes all blank lines from given input. Newlines are not +// normalized. +func StripBlankLines(input []byte) []byte { + input = bytes.ReplaceAll(input, []byte(WindowsEOL+WindowsEOL), []byte(WindowsEOL)) + input = bytes.ReplaceAll(input, []byte(MacEOL+MacEOL), []byte(MacEOL)) + input = bytes.ReplaceAll(input, []byte(UnixEOL+UnixEOL), []byte(UnixEOL)) + + return input +} + +// StripBlankAndNormalize removes all blank lines and normalizes all remaining +// newlines (converting Windows and Mac-specific EOLs to Unix EOLs) from given +// input. +func StripBlankAndNormalize(input []byte) []byte { + return bytes.ReplaceAll(NormalizeNewlines(input), []byte("\n\n"), []byte("\n")) +} diff --git a/internal/textutils/textutils.go b/internal/textutils/textutils.go new file mode 100644 index 0000000..136e5a4 --- /dev/null +++ b/internal/textutils/textutils.go @@ -0,0 +1,188 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package textutils + +import ( + "fmt" + "strconv" + "strings" +) + +// InList is a helper function to emulate Python's `if "x" in list:` +// functionality. The caller can optionally ignore case of compared items. +func InList(needle string, haystack []string, ignoreCase bool) bool { + for _, item := range haystack { + + if ignoreCase { + if strings.EqualFold(item, needle) { + return true + } + } + + if item == needle { + return true + } + } + return false +} + +// DedupeList returns a copy of a provided string slice with all duplicate +// entries removed. +// FIXME: Is there already a standard library version of this functionality? +func DedupeList(list []string) []string { + + // preallocate at least as much space as the original + newList := make([]string, 0, len(list)) + uniqueItems := make(map[string]struct{}) + + // build a map of unique list entries + for _, item := range list { + uniqueItems[item] = struct{}{} + } + + // generate a new, deduped list from the map + for key := range uniqueItems { + newList = append(newList, key) + } + return newList +} + +// IntSliceToStringSlice converts a slice of integers to a slice of string. +func IntSliceToStringSlice(ix []int) []string { + sx := make([]string, len(ix)) + for i, v := range ix { + sx[i] = strconv.Itoa(v) + } + return sx +} + +// LowerCaseStringSlice is a helper function to convert all provided string +// slice elements to lowercase. +// +// FIXME: There is likely a better way to do this already; replace with that +// better way. +func LowerCaseStringSlice(xs []string) []string { + lxs := make([]string, 0, len(xs)) + for idx := range xs { + lxs = append(lxs, strings.ToLower(xs[idx])) + } + + return lxs +} + +// PrintHeader prints a section header with liberal leading and trailing +// newlines to help separate otherwise potentially dense blocks of text. +func PrintHeader(headerText string) { + headerBorderStr := strings.Repeat("=", len(headerText)) + fmt.Printf( + "\n\n%s\n%s\n%s\n\n", + headerBorderStr, + headerText, + headerBorderStr, + ) +} + +// InsertDelimiter inserts a delimiter into the provided string every pos +// characters. If the length of the provided string is less than pos + 1 +// characters the original string is returned unmodified as we are unable to +// insert delimiter between blocks of characters of specified (pos) size. +func InsertDelimiter(s string, delimiter string, pos int) string { + + if len(s) < pos+1 { + return s + } + + // convert string to rune slice in order to use unicode package functions + // (which expect to work with runes). + r := []rune(s) + + // to track position in string + var ctr int + + var delimitedStr string + for i, v := range r { + c := string(v) + ctr++ + + // add delimiter when we have reached the specified position in the + // string, provided that we've not reached the end of the string. + if (ctr == pos) && (i+1 != len(r)) { + delimitedStr += c + delimiter + ctr = 0 + continue + } + delimitedStr += c + } + + return delimitedStr +} + +// BytesToDelimitedHexStr converts a byte slice to a delimited hex string. +func BytesToDelimitedHexStr(bx []byte, delimiter string) string { + // hexStr := make([]string, 0, len(bx)) + // for _, v := range bx { + // hexStr = append(hexStr, fmt.Sprintf( + // // Pad single digits with a leading zero. + // // see also atc0005/check-cert#706 + // "%02X", v, + // )) + // } + // return strings.Join(hexStr, delimiter) + + hexStr := fmt.Sprintf("%X", bx) + delimiterPosition := 2 + + formattedHexStr := InsertDelimiter(hexStr, delimiter, delimiterPosition) + formattedHexStr = strings.ToUpper(formattedHexStr) + + return formattedHexStr +} + +// Matches returns a list of successful matches and a list of failed matches +// for the provided lists of expected and total values. If specified, a +// case-insensitive comparison is used. +func Matches(expectedList []string, searchList []string, ignoreCase bool) ([]string, []string) { + + successful := make([]string, 0, len(expectedList)) + failed := make([]string, 0, len(expectedList)) + + if ignoreCase { + searchList = LowerCaseStringSlice(searchList) + } + + for _, expectedEntry := range expectedList { + switch { + case !InList(expectedEntry, searchList, ignoreCase): + failed = append(failed, expectedEntry) + + default: + successful = append(successful, expectedEntry) + } + } + + return successful, failed +} + +// FailedMatches evaluates a list of values using list of expected values. A +// list of failed matches or an empty (non-nil) list is returned. If +// specified, a case-insensitive comparison is used. +func FailedMatches(expectedList []string, searchList []string, ignoreCase bool) []string { + failed := make([]string, 0, len(expectedList)) + + if ignoreCase { + searchList = LowerCaseStringSlice(searchList) + } + + for _, expectedEntry := range expectedList { + if !InList(expectedEntry, searchList, ignoreCase) { + failed = append(failed, expectedEntry) + } + } + + return failed +} diff --git a/payload.go b/payload.go index 54ac102..0902ea4 100644 --- a/payload.go +++ b/payload.go @@ -8,275 +8,162 @@ package payload import ( + "encoding/json" "errors" - "time" -) + "fmt" + "strings" -// Nagios plugin/service check state "labels". These values are used (where -// applicable) by the CertChainPayload `ServiceState` field. -const ( - StateOKLabel string = "OK" - StateWARNINGLabel string = "WARNING" - StateCRITICALLabel string = "CRITICAL" - StateUNKNOWNLabel string = "UNKNOWN" - StateDEPENDENTLabel string = "DEPENDENT" + format0 "github.com/atc0005/cert-payload/format/v0" + format1 "github.com/atc0005/cert-payload/format/v1" + "github.com/atc0005/cert-payload/input" ) -// Validity period keywords intended as human readable output. -// -// Common historical certificate lifetimes: -// -// - 5 year (1825 days, 60 months) -// - 3 year (1185 days, 39 months) -// - 2 year (825 days, 27 months) -// - 1 year (398 days, 13 months) -// -// See also: -// -// - https://www.sectigo.com/knowledge-base/detail/TLS-SSL-Certificate-Lifespan-History-2-3-and-5-year-validity/kA01N000000zFKp -// - https://support.sectigo.com/Com_KnowledgeDetailPage?Id=kA03l000000o6cv -// - https://www.digicert.com/faq/public-trust-and-certificates/how-long-are-tls-ssl-certificate-validity-periods -// - https://docs.digicert.com/en/whats-new/change-log/older-changes/change-log--2023.html#certcentral--changes-to-multi-year-plan-coverage -// - https://knowledge.digicert.com/quovadis/ssl-certificates/ssl-general-topics/maximum-validity-changes-for-tls-ssl-to-drop-to-825-days-in-q1-2018 -// - https://chromium.googlesource.com/chromium/src/+/666712ff6c7ba7aa5da380bc0a617b637c9232b3/net/docs/certificate_lifetimes.md -// - https://www.entrust.com/blog/2017/03/maximum-certificate-lifetime-drops-to-825-days-in-2018 const ( - ValidityPeriod1Year string = "1 year" - ValidityPeriod90Days string = "90 days" - ValidityPeriod45Days string = "45 days" - ValidityPeriodUNKNOWN string = "UNKNOWN" -) - -var ( - // ErrMissingValue indicates that an expected value was missing. - ErrMissingValue = errors.New("missing expected value") -) - -// CertificateStatus is the overall status of a certificate. -// -// - no problems (ok) -// - expired -// - expiring (based on given threshold values) -// - revoked (not yet supported) -// -// TODO: Any useful status values to borrow here? -// They have `Active`, `Revoked` and then a `Pending*` variation for both. -// https://developers.cloudflare.com/ssl/reference/certificate-statuses/#client-certificates -type CertificateStatus struct { - OK bool `json:"status_ok"` // No observed issues; shouldn't this be calculated? - Expiring bool `json:"status_expiring"` // Based on given monitoring thresholds - Expired bool `json:"status_expired"` // Based on certificate NotAfter field - - // This is a feature to add later - // RevokedPerCRL bool `json:"status_revoked_per_crl"` // Based on CRL or OCSP check? - // RevokedPerOCSP bool `json:"status_revoked_per_ocsp"` // Based on CRL or OCSP check? - // ? -} - -// Certificate is a subset of the metadata for an evaluated certificate. -type Certificate struct { - // Subject is the full subject value for a certificate. This is intended - // for (non-cryptographic) comparison purposes. - Subject string `json:"subject"` - - // CommonName is the short subject value of a certificate. This is - // intended for display purposes. - CommonName string `json:"common_name"` - - // SANsEntries is the full list of Subject Alternate Names for a - // certificate. - SANsEntries []string `json:"sans_entries"` - - // SANsEntriesCount is the number of Subject Alternate Names for a - // certificate. + // MaxSupportedPayloadVersion indicates the latest payload format version + // supported by this project. Update to the very latest project release to + // support the most recent format version. // - // This field allows the payload creator to omit SANs entries to conserve - // plugin output size and still indicate the number of SANs entries - // present for a certificate for use in display or for metrics purposes. - SANsEntriesCount int `json:"sans_entries_count"` - - // Issuer is the full CommonName of the signing certificate. This is - // intended for (non-cryptographic) comparison purposes. - Issuer string `json:"issuer"` - - // IssuerShort is the short CommonName of the signing certificate. This is - // intended for display purposes. - IssuerShort string `json:"issuer_short"` - - // SerialNumber is the serial number for a certificate in hex format with - // a colon inserted after each two digits. + // FIXME: Bump to `1` once the format stabilizes. Keep bumping version to + // reflect the most recent format version. // - // For example, `77:BD:0D:6C:DB:36:F9:1A:EA:21:0F:C4:F0:58:D3:0D`. - SerialNumber string `json:"serial_number"` - - // IssuedOn is a RFC3389 time value for when a certificate is first - // valid or usable. - IssuedOn time.Time `json:"not_before"` - - // ExpiresOn is a RFC3389 time value for when the certificate expires. - ExpiresOn time.Time `json:"not_after"` + MaxSupportedPayloadVersion int = 1 // FIXME: Only for testing purposes. - // DaysRemaining is the number of days remaining for a certificate in two - // digit decimal precision. - DaysRemaining float64 `json:"days_remaining"` - - // DaysRemainingTruncated is the number of days remaining for a - // certificate as a whole number rounded down. + // MinSupportedPayloadVersion indicates the oldest payload format version + // supported by this project. Versions older than this are considered + // unstable and associated with early development releases and are no + // longer supported. // - // For example, if five and a half days remain then this value would be - // `5`. - DaysRemainingTruncated int `json:"days_remaining_truncated"` - - // LifetimePercent is percentage of life remaining for a certificate. + // FIXME: Bump to `1` once the format stabilizes. // - // For example, if 43% life is remaining for a cert (a rounded value) this - // field would be set to `43`. - LifetimePercent int `json:"lifetime_remaining_percent"` - - // ValidityPeriodDescription is the human readable value such as "90 days" - // or "1 year". - ValidityPeriodDescription string `json:"validity_period_description"` + MinSupportedPayloadVersion int = 0 +) - // ValidityPeriodDays is the number of total days a certificate is valid - // for using `Not Before` & `Not After` as the starting & ending range. - ValidityPeriodDays int `json:"validity_period_days"` +var ( + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") - // human readable summary such as, `[OK] 1199d 2h remaining (43%)` - Summary string `json:"summary"` + // ErrUnsupportedPayloadFormatVersion indicates that a specified payload + // format version is unsupported. + ErrUnsupportedPayloadFormatVersion = errors.New("requested payload format version is unsupported") - // Status is the overall status of the certificate. - Status CertificateStatus `json:"status"` + // ErrPayloadFormatVersionTooOld indicates that a specified payload format + // version is no longer supported. + ErrPayloadFormatVersionTooOld = errors.New("request payload format version is no longer supported") - // SignatureAlgorithm indicates what certificate signature algorithm was - // used by a certification authority (CA)'s private key to sign a checksum - // calculated by a signature hash algorithm (i.e., what algorithm was used - // to sign the certificate). The verifying party must use the same - // algorithm to decrypt and verify the checksum using the CA's public key. - // - // A cryptographically weak hashing algorithm (e.g. MD2, MD4, MD5, SHA1) - // used to sign a certificate is considered to be a vulnerability. - SignatureAlgorithm string `json:"signature_algorithm"` + // ErrPayloadFormatVersionTooNew indicates that a specified payload format + // version is not supported by this package release version. + ErrPayloadFormatVersionTooNew = errors.New("requested payload format version is too new for this package version; check for newer update") +) - // Type indicates the type of certificate (leaf, intermediate or root). - Type string `json:"type"` +// minimumFormat reflects the target data structure that we'll unmarshal a +// JSON payload into in order to properly identify the format version. +type minimumFormat struct { + Version int `json:"format_version"` } -// CertificateChainIssues is an aggregated collection of problems detected for -// the certificate chain. -type CertificateChainIssues struct { - // MissingIntermediateCerts indicates that intermediate certificates are - // missing from the certificate chain. - MissingIntermediateCerts bool `json:"missing_intermediate_certs"` - - // MissingSANsEntries indicates that SANs entries are missing from a leaf - // certificate within the certificates chain. - MissingSANsEntries bool `json:"missing_sans_entries"` - - // DuplicateCerts indicates that there are one or more duplicate copies of - // a certificate in the certificate chain. - DuplicateCerts bool `json:"duplicate_certs"` - - // MisorderedCerts indicates that certificates in the chain are out of the - // expected order. - // - // E.g., instead of leaf, intermediate(s), root (technically not best - // practice) the chain has something like leaf, root, intermediate(s) or - // intermediates and then leaf. - // MisorderedCerts bool `json:"misordered_certs"` - - // ExpiredCerts indicates that there are one or more expired certificates - // in the certificate chain. - ExpiredCerts bool `json:"expired_certs"` - - // HostnameMismatch indicates that the name or IP Address used to - // establish a connection to a certificate-enabled service does not match - // the list of valid host names honored by the leaf certificate. - // - // Historically the Common Name (CN) field was searched in addition to the - // Subject Alternate Names (SANs) field for a match, but this practice is - // deprecated and many clients (e.g., web browsers) no longer support - // this. - HostnameMismatch bool `json:"hostname_mismatch"` - - // SelfSignedLeafCert indicates that the leaf certificate is self-signed. - // This is fairly common for development/test environments but is not best - // practice for certificates used outside of temporary / lab environments. - SelfSignedLeafCert bool `json:"self_signed_leaf_cert"` - - // WeakSignatureAlgorithm indicates that the certificate chain has been - // signed using a cryptographically weak hashing algorithm (e.g. MD2, MD4, - // MD5, or SHA1). These signature algorithms are known to be vulnerable to - // collision attacks. An attacker can exploit this to generate another - // certificate with the same digital signature, allowing an attacker to - // masquerade as the affected service. - // - // NOTE: This does not apply to trusted root certificates; TLS clients - // trust them by their identity instead of the signature of their hash; - // client code setting this field would need to exclude root certificates - // from the determination whether the chain is vulnerable to weak - // signature algorithms. - // - // - https://security.googleblog.com/2014/09/gradually-sunsetting-sha-1.html - // - https://security.googleblog.com/2015/12/an-update-on-sha-1-certificates-in.html - // - https://superuser.com/questions/1122069/why-are-root-cas-with-sha1-signatures-not-a-risk - // - https://developer.mozilla.org/en-US/docs/Web/Security/Weak_Signature_Algorithm - // - https://www.tenable.com/plugins/nessus/35291 - // - https://docs.ostorlab.co/kb/WEAK_HASHING_ALGO/index.html - WeakSignatureAlgorithm bool `json:"weak_signature_algorithm"` - - // SelfSignedIntermediateCerts indicates that an intermediate certificate - // in the chain is self-signed. - // - // NOTE: This is unlikely to occur in practice, so we're likely not going - // to keep this field. - // - // SelfSignedIntermediateCerts bool `json:"self_signed_intermediate_certs"` - - // This is a later TODO item. - // RevokedCerts bool `json:"revoked_certs"` +// Encode processes the given certificate chain and returns a JSON payload of +// the specified format version. An error is returned if one occurs during +// processing or if an invalid payload version format is specified. +func Encode(payloadVersion int, inputData input.Values) ([]byte, error) { + switch { + case payloadVersion < MinSupportedPayloadVersion: + return nil, fmt.Errorf("payload version %d specified (min supported is %d): %w", + payloadVersion, + MinSupportedPayloadVersion, + ErrPayloadFormatVersionTooOld, + ) + + case payloadVersion > MaxSupportedPayloadVersion: + return nil, fmt.Errorf("payload version %d specified (max supported is %d): %w", + payloadVersion, + MaxSupportedPayloadVersion, + ErrPayloadFormatVersionTooNew, + ) + + case payloadVersion == 0: + return format0.Encode(inputData) + + case payloadVersion == 1: + return format1.Encode(inputData) + + default: + return nil, fmt.Errorf("payload version %d specified: %w", + payloadVersion, + ErrUnsupportedPayloadFormatVersion, + ) + } } -// CertChainPayload is the "parent" data structure which represents the -// information to be encoded as a payload and later decoded for use in -// reporting (and other) tools. -type CertChainPayload struct { - // CertChainOriginal is the original certificate chain entries encoded in - // PEM format. - // - // Due to size constraints this field may not be populated if the user did - // not explicitly opt into bundling the full certificate chain. - CertChainOriginal []string `json:"cert_chain_original"` - - // CertChainSubset is a customized subset of the original certificate - // chain metadata. This field should always be populated. - CertChainSubset []Certificate `json:"cert_chain_subset"` - - // Server is the FQDN or IP Address specified to the plugin which was used - // to retrieve the certificate chain. - // - // TODO: Considering making this a struct with fields for resolved IP - // Address and original CLI flag value (often a FQDN, but just as often a - // fixed IP Address). - Server string `json:"server"` - - // A fully-qualified domain name or IP Address in the Subject Alternate - // Names (SANs) list for the leaf certificate. +// EncodeLatest processes the given input data and returns a JSON payload in +// the latest format version. An error is returned if one occurs during +// processing or if an invalid payload version format is specified. +func EncodeLatest(inputData input.Values) ([]byte, error) { + // latestEncoder := latestVersionEncoder() // - // Depending on how the check_cert plugin was called this value may not be - // set (e.g., the `server` flag is sufficient if specifying a valid FQDN - // associated with the leaf certificate). - DNSName string `json:"dns_name"` + // return latestEncoder(inputData) - // TCPPort is the TCP port of the remote certificate-enabled service. This - // is usually 443 (HTTPS) or 636 (LDAPS). - TCPPort int `json:"tcp_port"` + return format1.Encode(inputData) +} - // Issues is an aggregated collection of problems detected for the - // certificate chain. - Issues CertificateChainIssues `json:"cert_chain_issues"` +// Decode accepts a certificate metadata payload and decodes/unmarshals it +// into the given destination. An error is returned if one occurs when +// decoding the payload or if the payload format version is unsupported. +func Decode(inputPayload string, dest interface{}) error { + var format minimumFormat + + if err := json.Unmarshal([]byte(inputPayload), &format); err != nil { + return fmt.Errorf( + "failed to identify payload version: %w", + ErrUnsupportedPayloadFormatVersion, + ) + } + + switch { + case format.Version < MinSupportedPayloadVersion: + return fmt.Errorf("payload version %d specified: %w", + format.Version, + ErrPayloadFormatVersionTooOld, + ) + + case format.Version > MaxSupportedPayloadVersion: + return fmt.Errorf("payload version %d specified: %w", + format.Version, + ErrPayloadFormatVersionTooNew, + ) + } + + inputReader := strings.NewReader(inputPayload) + + // Assert that we've been given a pointer (we need write access to the + // value) to a supported destination format to decode into. + switch v := dest.(type) { + case *format0.CertChainPayload: + return format0.Decode(v, inputReader, false) + + case *format1.CertChainPayload: + return format1.Decode(v, inputReader, false) + default: + + } + + return nil +} - // ServiceState is the monitoring system's evaluated state for the service - // check performed against a given certificate chain (e.g., OK, CRITICAL, - // WARNING, UNKNOWN). - ServiceState string `json:"service_state"` +// AvailableFormatVersions provides a list of available format versions that +// client applications may choose from when encoding or decoding certificate +// metadata payloads. +func AvailableFormatVersions() []int { + return []int{ + 0, + 1, // FIXME: Fake value for testing (for now) + 2, // FIXME: Fake value for testing + 3, // FIXME: Fake value for testing + 4, // FIXME: Fake value for testing + } } + +// latestVersionEncoder is a helper function that provides the latest format +// version Encode function. +// func latestVersionEncoder() func(input.Values) ([]byte, error) { +// return format1.Encode +// } diff --git a/payload.go.txt b/payload.go.txt new file mode 100644 index 0000000..8acad8f --- /dev/null +++ b/payload.go.txt @@ -0,0 +1,345 @@ +// Copyright 2024 Adam Chalkley +// +// https://github.com/atc0005/cert-payload +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package payload + +import ( + "crypto/x509" + "errors" + "time" +) + +// Nagios plugin/service check state "labels". These values are used (where +// applicable) by the CertChainPayload `ServiceState` field. +const ( + StateOKLabel string = "OK" + StateWARNINGLabel string = "WARNING" + StateCRITICALLabel string = "CRITICAL" + StateUNKNOWNLabel string = "UNKNOWN" + StateDEPENDENTLabel string = "DEPENDENT" +) + +// Validity period keywords intended as human readable output. +// +// Common historical certificate lifetimes: +// +// - 5 year (1825 days, 60 months) +// - 3 year (1185 days, 39 months) +// - 2 year (825 days, 27 months) +// - 1 year (398 days, 13 months) +// +// See also: +// +// - https://www.sectigo.com/knowledge-base/detail/TLS-SSL-Certificate-Lifespan-History-2-3-and-5-year-validity/kA01N000000zFKp +// - https://support.sectigo.com/Com_KnowledgeDetailPage?Id=kA03l000000o6cv +// - https://www.digicert.com/faq/public-trust-and-certificates/how-long-are-tls-ssl-certificate-validity-periods +// - https://docs.digicert.com/en/whats-new/change-log/older-changes/change-log--2023.html#certcentral--changes-to-multi-year-plan-coverage +// - https://knowledge.digicert.com/quovadis/ssl-certificates/ssl-general-topics/maximum-validity-changes-for-tls-ssl-to-drop-to-825-days-in-q1-2018 +// - https://chromium.googlesource.com/chromium/src/+/666712ff6c7ba7aa5da380bc0a617b637c9232b3/net/docs/certificate_lifetimes.md +// - https://www.entrust.com/blog/2017/03/maximum-certificate-lifetime-drops-to-825-days-in-2018 +const ( + ValidityPeriod1Year string = "1 year" + ValidityPeriod90Days string = "90 days" + ValidityPeriod45Days string = "45 days" + ValidityPeriodUNKNOWN string = "UNKNOWN" +) + +var ( + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") +) + +// CertificateStatus is the overall status of a certificate. +// +// - no problems (ok) +// - expired +// - expiring (based on given threshold values) +// - revoked (not yet supported) +// +// TODO: Any useful status values to borrow here? +// They have `Active`, `Revoked` and then a `Pending*` variation for both. +// https://developers.cloudflare.com/ssl/reference/certificate-statuses/#client-certificates +type CertificateStatus struct { + OK bool `json:"status_ok"` // No observed issues; shouldn't this be calculated? + Expiring bool `json:"status_expiring"` // Based on given monitoring thresholds + Expired bool `json:"status_expired"` // Based on certificate NotAfter field + + // This is a feature to add later + // RevokedPerCRL bool `json:"status_revoked_per_crl"` // Based on CRL or OCSP check? + // RevokedPerOCSP bool `json:"status_revoked_per_ocsp"` // Based on CRL or OCSP check? + // ? +} + +// Certificate is a subset of the metadata for an evaluated certificate. +type Certificate struct { + // Subject is the full subject value for a certificate. This is intended + // for (non-cryptographic) comparison purposes. + Subject string `json:"subject"` + + // CommonName is the short subject value of a certificate. This is + // intended for display purposes. + CommonName string `json:"common_name"` + + // SANsEntries is the full list of Subject Alternate Names for a + // certificate. + SANsEntries []string `json:"sans_entries"` + + // SANsEntriesCount is the number of Subject Alternate Names for a + // certificate. + // + // This field allows the payload creator to omit SANs entries to conserve + // plugin output size and still indicate the number of SANs entries + // present for a certificate for use in display or for metrics purposes. + SANsEntriesCount int `json:"sans_entries_count"` + + // Issuer is the full CommonName of the signing certificate. This is + // intended for (non-cryptographic) comparison purposes. + Issuer string `json:"issuer"` + + // IssuerShort is the short CommonName of the signing certificate. This is + // intended for display purposes. + IssuerShort string `json:"issuer_short"` + + // SerialNumber is the serial number for a certificate in hex format with + // a colon inserted after each two digits. + // + // For example, `77:BD:0D:6C:DB:36:F9:1A:EA:21:0F:C4:F0:58:D3:0D`. + SerialNumber string `json:"serial_number"` + + // IssuedOn is a RFC3389 time value for when a certificate is first + // valid or usable. + IssuedOn time.Time `json:"not_before"` + + // ExpiresOn is a RFC3389 time value for when the certificate expires. + ExpiresOn time.Time `json:"not_after"` + + // DaysRemaining is the number of days remaining for a certificate in two + // digit decimal precision. + DaysRemaining float64 `json:"days_remaining"` + + // DaysRemainingTruncated is the number of days remaining for a + // certificate as a whole number rounded down. + // + // For example, if five and a half days remain then this value would be + // `5`. + DaysRemainingTruncated int `json:"days_remaining_truncated"` + + // LifetimePercent is percentage of life remaining for a certificate. + // + // For example, if 43% life is remaining for a cert (a rounded value) this + // field would be set to `43`. + LifetimePercent int `json:"lifetime_remaining_percent"` + + // ValidityPeriodDescription is the human readable value such as "90 days" + // or "1 year". + ValidityPeriodDescription string `json:"validity_period_description"` + + // ValidityPeriodDays is the number of total days a certificate is valid + // for using `Not Before` & `Not After` as the starting & ending range. + ValidityPeriodDays int `json:"validity_period_days"` + + // human readable summary such as, `[OK] 1199d 2h remaining (43%)` + Summary string `json:"summary"` + + // Status is the overall status of the certificate. + Status CertificateStatus `json:"status"` + + // SignatureAlgorithm indicates what certificate signature algorithm was + // used by a certification authority (CA)'s private key to sign a checksum + // calculated by a signature hash algorithm (i.e., what algorithm was used + // to sign the certificate). The verifying party must use the same + // algorithm to decrypt and verify the checksum using the CA's public key. + // + // A cryptographically weak hashing algorithm (e.g. MD2, MD4, MD5, SHA1) + // used to sign a certificate is considered to be a vulnerability. + SignatureAlgorithm string `json:"signature_algorithm"` + + // Type indicates the type of certificate (leaf, intermediate or root). + Type string `json:"type"` +} + +// CertificateChainIssues is an aggregated collection of problems detected for +// the certificate chain. +type CertificateChainIssues struct { + // MissingIntermediateCerts indicates that intermediate certificates are + // missing from the certificate chain. + MissingIntermediateCerts bool `json:"missing_intermediate_certs"` + + // MissingSANsEntries indicates that SANs entries are missing from a leaf + // certificate within the certificates chain. + MissingSANsEntries bool `json:"missing_sans_entries"` + + // DuplicateCerts indicates that there are one or more duplicate copies of + // a certificate in the certificate chain. + DuplicateCerts bool `json:"duplicate_certs"` + + // MisorderedCerts indicates that certificates in the chain are out of the + // expected order. + // + // E.g., instead of leaf, intermediate(s), root (technically not best + // practice) the chain has something like leaf, root, intermediate(s) or + // intermediates and then leaf. + MisorderedCerts bool `json:"misordered_certs"` + + // ExpiredCerts indicates that there are one or more expired certificates + // in the certificate chain. + ExpiredCerts bool `json:"expired_certs"` + + // HostnameMismatch indicates that the name or IP Address used to + // establish a connection to a certificate-enabled service does not match + // the list of valid host names honored by the leaf certificate. + // + // Historically the Common Name (CN) field was searched in addition to the + // Subject Alternate Names (SANs) field for a match, but this practice is + // deprecated and many clients (e.g., web browsers) no longer support + // this. + HostnameMismatch bool `json:"hostname_mismatch"` + + // SelfSignedLeafCert indicates that the leaf certificate is self-signed. + // This is fairly common for development/test environments but is not best + // practice for certificates used outside of temporary / lab environments. + SelfSignedLeafCert bool `json:"self_signed_leaf_cert"` + + // WeakSignatureAlgorithm indicates that the certificate chain has been + // signed using a cryptographically weak hashing algorithm (e.g. MD2, MD4, + // MD5, or SHA1). These signature algorithms are known to be vulnerable to + // collision attacks. An attacker can exploit this to generate another + // certificate with the same digital signature, allowing an attacker to + // masquerade as the affected service. + // + // NOTE: This does not apply to trusted root certificates; TLS clients + // trust them by their identity instead of the signature of their hash; + // client code setting this field would need to exclude root certificates + // from the determination whether the chain is vulnerable to weak + // signature algorithms. + // + // - https://security.googleblog.com/2014/09/gradually-sunsetting-sha-1.html + // - https://security.googleblog.com/2015/12/an-update-on-sha-1-certificates-in.html + // - https://superuser.com/questions/1122069/why-are-root-cas-with-sha1-signatures-not-a-risk + // - https://developer.mozilla.org/en-US/docs/Web/Security/Weak_Signature_Algorithm + // - https://www.tenable.com/plugins/nessus/35291 + // - https://docs.ostorlab.co/kb/WEAK_HASHING_ALGO/index.html + WeakSignatureAlgorithm bool `json:"weak_signature_algorithm"` + + // SelfSignedIntermediateCerts indicates that an intermediate certificate + // in the chain is self-signed. + // + // NOTE: This is unlikely to occur in practice, so we're likely not going + // to keep this field. + // + // SelfSignedIntermediateCerts bool `json:"self_signed_intermediate_certs"` + + // This is a later TODO item. + // RevokedCerts bool `json:"revoked_certs"` +} + +// InputData is a collection of values provided to the plugin (e.g., CLI +// flags), gathered by the plugin (e.g., CertChain) without any +// sysadmin-specified filtering applied (e.g., "ignore expiring +// intermediates"). +// +// This data structure (at this time) is not meant to be directly included in +// the generated payload, rather it's intended to provide input data to create +// the payload; some details provided in this data structure will (very +// likely) be mapped 1:1 to the created JSON payload. +// +// FIXME: Not sure of name. +type InputData struct { + // CertChainOriginal is the original certificate chain entries as-is + // without any "problematic" entries removed. + CertChain []*x509.Certificate + + // Errors represents a potential collection of errors encountered while + // retrieving a certificate chain from a service. + Errors []error + + // Server is the FQDN or IP Address specified to the plugin which was used + // to retrieve the certificate chain. + // + // TODO: Considering making this a struct with fields for resolved IP + // Address and original CLI flag value (often a FQDN, but just as often a + // fixed IP Address). + Server string + + // A fully-qualified domain name or IP Address in the Subject Alternate + // Names (SANs) list for the leaf certificate. + // + // Depending on how the check_cert plugin was called this value may not be + // set (e.g., the `server` flag is sufficient if specifying a valid FQDN + // associated with the leaf certificate). + DNSName string + + // TCPPort is the TCP port of the remote certificate-enabled service. This + // is usually 443 (HTTPS) or 636 (LDAPS). + TCPPort int + + // ServiceState is the monitoring system's evaluated state for the service + // check performed against a given certificate chain (e.g., OK, CRITICAL, + // WARNING, UNKNOWN). + ServiceState string +} + +// CertChainPayload is the "parent" data structure which represents the +// information to be encoded as a payload and later decoded for use in +// reporting (and other) tools. +// +// This data structure is (future design) intended to be generated by this +// library and not directly by client code. Instead, client code is meant to +// pass in data using the `InputData` (name subject to change) struct. +type CertChainPayload struct { + // TODO: Review paper notes to see what field name I settled on. + // + // Format int `json:"format"` + // Format int `json:"payload_format"` + // Version int `json:"version"` + // Version int `json:"payload_version"` + // FormatVersion int `json:"format_version"` + + // Errors is intended to represent a potential collection of errors + // encountered while retrieving a certificate chain from a service. + Errors []error `json:"errors"` + + // CertChainOriginal is the original certificate chain entries encoded in + // PEM format. + // + // Due to size constraints this field may not be populated if the user did + // not explicitly opt into bundling the full certificate chain. + CertChainOriginal []string `json:"cert_chain_original"` + + // CertChainSubset is a customized subset of the original certificate + // chain metadata. This field should always be populated. + CertChainSubset []Certificate `json:"cert_chain_subset"` + + // Server is the FQDN or IP Address specified to the plugin which was used + // to retrieve the certificate chain. + // + // TODO: Considering making this a struct with fields for resolved IP + // Address and original CLI flag value (often a FQDN, but just as often a + // fixed IP Address). + Server string `json:"server"` + + // A fully-qualified domain name or IP Address in the Subject Alternate + // Names (SANs) list for the leaf certificate. + // + // Depending on how the check_cert plugin was called this value may not be + // set (e.g., the `server` flag is sufficient if specifying a valid FQDN + // associated with the leaf certificate). + DNSName string `json:"dns_name"` + + // TCPPort is the TCP port of the remote certificate-enabled service. This + // is usually 443 (HTTPS) or 636 (LDAPS). + TCPPort int `json:"tcp_port"` + + // Issues is an aggregated collection of problems detected for the + // certificate chain. + Issues CertificateChainIssues `json:"cert_chain_issues"` + + // ServiceState is the monitoring system's evaluated state for the service + // check performed against a given certificate chain (e.g., OK, CRITICAL, + // WARNING, UNKNOWN). + ServiceState string `json:"service_state"` +}