diff --git a/README.md b/README.md index 8a924b2f..cc1f3cbe 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ accessible to this tool. Use FQDNs in order to retrieve certificates using - Optional support for ignoring expiring intermediate certificates - Optional support for ignoring expired intermediate certificates - Optional support for ignoring expiring root certificates -- Optional support for ignoring expired of root certificates +- Optional support for ignoring expired root certificates - Optional support for omitting Subject Alternate Names (SANs) entries from plugin output - Optional support for embedding an encoded certificate metadata payload @@ -326,6 +326,8 @@ accessible to this tool. Use FQDNs in order to retrieve certificates using the original certificate chain included* in PEM encoded format - this is not enabled by default due to the significant increase in plugin output size +- Optional support for overriding the default certificate metadata format + version used when generating payloads ### `lscert` @@ -696,6 +698,7 @@ validation checks and any behavior changes at that time noted. | `v`, `verbose` | No | `false` | No | `v`, `verbose` | Toggles emission of detailed certificate metadata. This level of output is disabled by default. | | `payload` | No | `false` | No | `true`, `false` | Toggles emission of encoded certificate chain payload. This output is disabled by default. | | `payload-with-full-chain` | No | `false` | No | `true`, `false` | Toggles emission of encoded certificate chain payload with the full certificate chain included. This option is disabled by default due to the significant increase in payload size. | +| `payload-format` | No | `false` | No | *positive whole number for valid payload format version* | Specifies the format version to use when generating the (optional) certificate metadata payload. | | `omit-sans-list`, `omit-sans-entries` | No | `false` | No | `true`, `false` | Toggles listing of SANs entries list items in certificate metadata output. This list is included by default. | | `version` | No | `false` | No | `version` | Whether to display application version and then immediately exit application. | | `c`, `age-critical` | No | 15 | No | *positive whole number of days* | The threshold for the certificate check's `CRITICAL` state. If the certificate expires before this number of days then the service check will be considered in a `CRITICAL` state. | diff --git a/cmd/check_cert/main.go b/cmd/check_cert/main.go index 70e5a520..84914ebc 100644 --- a/cmd/check_cert/main.go +++ b/cmd/check_cert/main.go @@ -67,13 +67,10 @@ func main() { // configuration overrides (to either a user-specified value or Info as an // app default). if zerolog.GlobalLevel() == zerolog.DebugLevel || zerolog.GlobalLevel() == zerolog.TraceLevel { - plugin.DebugLoggingEnablePluginOutputSize() + // plugin.DebugLoggingEnablePluginOutputSize() + plugin.DebugLoggingEnableAll() } - // Annotate all errors (if any) with remediation advice just before ending - // plugin execution. - defer annotateErrors(plugin) - if cfg.EmitBranding { // If enabled, show application details at end of notification plugin.BrandingCallback = config.Branding("Notification generated by ") @@ -83,9 +80,53 @@ func main() { Str("expected_sans_entries", cfg.SANsEntries.String()). Logger() - var certChain []*x509.Certificate - - var certChainSource string + // We declare these earlier so that they can be referenced by closures + // (e.g., adding certificate metadata payload to plugin). + var ( + certChain []*x509.Certificate + certChainSource string + ipAddr string + ) + + // We run this function next to last so that we have access to the latest + // state of the plugin, including any errors registered with the plugin + // (e.g., after any annotations have been applied). + defer func(cc *[]*x509.Certificate, p *nagios.Plugin, c *config.Config, ip *string) { + if cfg.EmitPayload || cfg.EmitPayloadWithFullChain { + // We intentionally use different var names to prevent capturing + // outside variable values at time of deferring this closure. + payloadErr := addCertChainPayload(*cc, p, c, *ip) + if payloadErr != nil { + log.Error(). + Err(payloadErr). + Msg("failed to add encoded payload") + + // addCertChainPayload will record any errors in the generated + // payload that were previously registered with the plugin (e.g., + // failure to connect to a service, timeout, etc.). This error is + // registered for display in plugin output and not for the payload + // (since at this point we failed to create the payload). + plugin.Errors = append(plugin.Errors, payloadErr) + + plugin.ExitStatusCode = nagios.StateUNKNOWNExitCode + plugin.ServiceOutput = fmt.Sprintf( + "%s: Failed to add encoded payload", + nagios.StateUNKNOWNLabel, + ) + + return + } + } + // We use pointers so that the deferred function will access the + // latest value for the variable at the time of execution (otherwise + // it would capture only the value at the time the function is + // deferred). + }(&certChain, plugin, cfg, &ipAddr) + + // Annotate all errors (if any) with remediation advice just before + // generating the certificate metadata payload and ending plugin + // execution. + defer annotateErrors(plugin) // Honor request to parse filename first switch { @@ -230,7 +271,7 @@ func main() { // Grab first IP Address from the resolved collection. We'll // explicitly use it for cert retrieval and note it in the report // output. - ipAddr := expandedHost.Expanded[0] + ipAddr = expandedHost.Expanded[0] // Server Name Indication (SNI) support is used to request a specific // certificate chain from a remote server. @@ -423,25 +464,6 @@ func main() { return } - if cfg.EmitPayload || cfg.EmitPayloadWithFullChain { - payloadErr := addCertChainPayload(plugin, cfg, validationResults) - if payloadErr != nil { - log.Error(). - Err(payloadErr). - Msg("failed to add encoded payload") - - plugin.Errors = append(plugin.Errors, payloadErr) - - plugin.ExitStatusCode = nagios.StateUNKNOWNExitCode - plugin.ServiceOutput = fmt.Sprintf( - "%s: Failed to add encoded payload", - nagios.StateUNKNOWNLabel, - ) - - return - } - } - switch { case validationResults.HasFailed(): diff --git a/cmd/check_cert/paypload.go b/cmd/check_cert/paypload.go index 43b58b50..33696e2f 100644 --- a/cmd/check_cert/paypload.go +++ b/cmd/check_cert/paypload.go @@ -8,445 +8,86 @@ package main import ( + "bytes" "crypto/x509" "encoding/json" "fmt" - "math" + "os" + "strconv" + "strings" payload "github.com/atc0005/cert-payload" - "github.com/atc0005/check-cert/internal/certs" + "github.com/atc0005/cert-payload/input" "github.com/atc0005/check-cert/internal/config" "github.com/atc0005/go-nagios" + "github.com/rs/zerolog" ) -// 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 -} - -// extractExpValResult is a helper function used to extract the expiration -// validation result from a collection of previously applied certificate -// validation check results. -func extractExpValResult(validationResults certs.CertChainValidationResults) (certs.ExpirationValidationResult, error) { - var expirationValidationResult certs.ExpirationValidationResult - - for _, validationResult := range validationResults { - if expResult, ok := validationResult.(certs.ExpirationValidationResult); ok { - expirationValidationResult = expResult - break - } - } - - // Assert that we're working with a non-zero value. - if len(expirationValidationResult.CertChain()) == 0 { - // We're working with an uninitialized value; abort! - return certs.ExpirationValidationResult{}, fmt.Errorf( - "unable to extract expiration validation results"+ - " from collection of %d values: %w", - len(validationResults), - certs.ErrMissingValue, - ) - } - - return expirationValidationResult, nil -} - -// extractHostnameValResult is a helper function used to extract the expiration -// validation result from a collection of previously applied certificate -// validation check results. -func extractHostnameValResult(validationResults certs.CertChainValidationResults) (certs.HostnameValidationResult, error) { - var hostnameValidationResult certs.HostnameValidationResult - - for _, validationResult := range validationResults { - if hostnameResult, ok := validationResult.(certs.HostnameValidationResult); ok { - hostnameValidationResult = hostnameResult - break - } - } - - // Assert that we're working with a non-zero value. - if len(hostnameValidationResult.CertChain()) == 0 { - // We're working with an uninitialized value; abort! - return certs.HostnameValidationResult{}, fmt.Errorf( - "unable to extract hostname validation results"+ - " from collection of %d values: %w", - len(validationResults), - certs.ErrMissingValue, - ) - } - - return hostnameValidationResult, nil -} - -// buildCertSummary is a helper function that coordinates retrieving, -// collecting, evaluating and encoding certificate metadata as a JSON encoded -// string for inclusion in plugin output. -func buildCertSummary(cfg *config.Config, validationResults certs.CertChainValidationResults) (string, error) { - expirationValidationResult, expExtractErr := extractExpValResult(validationResults) - if expExtractErr != nil { - return "", fmt.Errorf( - "failed to generate certificate summary: %w", - expExtractErr, - ) - } - - hostnameValidationResult, hostnameExtractErr := extractHostnameValResult(validationResults) - if hostnameExtractErr != nil { - return "", fmt.Errorf( - "failed to generate certificate summary: %w", - hostnameExtractErr, - ) - } - - certsExpireAgeCritical := expirationValidationResult.AgeCriticalThreshold() - certsExpireAgeWarning := expirationValidationResult.AgeWarningThreshold() - - // Question: Should we use the customized certificate chain with any - // user-specified certificates to exclude (for whatever reason) removed so - // that we do not report on values which are problematic? - // - // certChain := expirationValidationResult.FilteredCertificateChain() - // - // Answer: No, we use the full chain so that any "downstream" reporting - // tools retrieving the certificate payload from the monitoring system can - // perform their own analysis with the full chain available for review. - certChain := expirationValidationResult.CertChain() - - certChainSubset := make([]payload.Certificate, 0, len(certChain)) - for certNumber, origCert := range certChain { - if origCert == nil { - return "", fmt.Errorf( - "cert in chain position %d of %d is nil: %w", - certNumber, - len(certChain), - certs.ErrMissingValue, - ) - } - - expiresText := certs.ExpirationStatus( - origCert, - certsExpireAgeCritical, - certsExpireAgeWarning, - false, - ) - - certStatus := payload.CertificateStatus{ - OK: expirationValidationResult.IsOKState(), - Expiring: expirationValidationResult.HasExpiringCerts(), - Expired: expirationValidationResult.HasExpiredCerts(), - } - - certExpMeta, lookupErr := lookupCertExpMetadata(origCert, certNumber, certChain) - if lookupErr != nil { - return "", lookupErr - } - - var SANsEntries []string - if cfg.OmitSANsEntries { - SANsEntries = nil - } else { - SANsEntries = origCert.DNSNames - } - - validityPeriodDescription := lookupValidityPeriodDescription(origCert) - - certSubset := payload.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) - } - - hasMissingIntermediateCerts := certs.NumIntermediateCerts(certChain) == 0 - hasExpiredCerts := certs.HasExpiredCert(certChain) - hasHostnameMismatch := !hostnameValidationResult.IsOKState() - hasMissingSANsEntries := func(certChain []*x509.Certificate) bool { - leafCerts := certs.LeafCerts(certChain) - for _, leafCert := range leafCerts { - if len(leafCert.DNSNames) > 0 { - return false - } - } - - return true - }(certChain) - - hasDuplicateCertsInChain := func(certChain []*x509.Certificate) bool { - 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 - }(certChain) - - hasSelfSignedLeaf := func(certChain []*x509.Certificate) bool { - 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 - }(certChain) - - // 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 - hasWeakSignatureAlgorithm := func(certChain []*x509.Certificate) bool { - 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 +// addCertChainPayload appends a given certificate chain payload (as a JSON +// encoded value) to plugin output. +func addCertChainPayload(certChain []*x509.Certificate, plugin *nagios.Plugin, cfg *config.Config, ipAddr string) error { + log := cfg.Log.With().Logger() - default: - logOK(cert) + // We convert the last exit code registered with the plugin to a suitable + // service check state. + serviceState := nagios.ExitCodeToStateLabel(plugin.ExitStatusCode) + + log.Debug().Msgf("%d errors registered with plugin", len(plugin.Errors)) + + inputData := input.Values{ + CertChain: certChain, + Errors: plugin.Errors, + IncludeFullCertChain: cfg.EmitPayloadWithFullChain, + OmitSANsEntries: cfg.OmitSANsEntries, + ExpirationAgeInDaysWarningThreshold: cfg.AgeWarning, + ExpirationAgeInDaysCriticalThreshold: cfg.AgeCritical, + Server: input.Server{HostValue: cfg.Server, IPAddress: ipAddr}, + DNSName: cfg.DNSName, + TCPPort: cfg.Port, + ServiceState: serviceState, + } + + availableFormats := payload.AvailableFormatVersions() + stableFormats := func() string { + items := make([]string, 0, len(availableFormats)-1) + for _, format := range availableFormats { + if format != 0 { + items = append(items, strconv.Itoa(format)) } } + return strings.Join(items, ",") + }() - return false - }(certChain) - - certChainIssues := payload.CertificateChainIssues{ - MissingIntermediateCerts: hasMissingIntermediateCerts, - MissingSANsEntries: hasMissingSANsEntries, - DuplicateCerts: hasDuplicateCertsInChain, - // MisorderedCerts: false, // FIXME: Placeholder value - ExpiredCerts: hasExpiredCerts, - HostnameMismatch: hasHostnameMismatch, - SelfSignedLeafCert: hasSelfSignedLeaf, - WeakSignatureAlgorithm: hasWeakSignatureAlgorithm, + // Advise against using pre-release format if other options are available. + if cfg.PayloadFormatVersion == 0 && len(availableFormats) > 1 { + log.Warn().Msg("Pre-release payload format version chosen.") + log.Warn().Msgf("It is recommended that you use one of payload format versions %s", stableFormats) } - // 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 cfg.EmitPayloadWithFullChain: - pemCertChain, err := payload.CertChainToPEM(certChain) - if err != nil { - return "", fmt.Errorf("error converting original cert chain to PEM format: %w", err) - } - - certChainOriginal = pemCertChain - - default: - certChainOriginal = nil - } - - payload := payload.CertChainPayload{ - CertChainOriginal: certChainOriginal, - CertChainSubset: certChainSubset, - Server: cfg.Server, - DNSName: cfg.DNSName, - TCPPort: cfg.Port, - Issues: certChainIssues, - ServiceState: expirationValidationResult.ServiceState().Label, - } + certChainSummary, certSummaryErr := payload.Encode(cfg.PayloadFormatVersion, inputData) - payloadJSON, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf( - "error marshaling cert chain payload as JSON: %w", - err, - ) + if certSummaryErr != nil { + return certSummaryErr } - return string(payloadJSON), nil -} - -// addCertChainPayload is a helper function that prepares a certificate chain -// payload as a JSON encoded value for inclusion in plugin output. -func addCertChainPayload(plugin *nagios.Plugin, cfg *config.Config, validationResults certs.CertChainValidationResults) error { - certChainSummary, certSummaryErr := buildCertSummary(cfg, validationResults) + // fmt.Fprintln(os.Stderr, string(certChainSummary)) + // log.Debug().Str("json_payload", string(certChainSummary)).Msg("JSON payload before encoding") - log := cfg.Log.With().Logger() + if zerolog.GlobalLevel() == zerolog.DebugLevel || zerolog.GlobalLevel() == zerolog.TraceLevel { + log.Debug().Msg("JSON payload before encoding") - if certSummaryErr != nil { - return certSummaryErr + var prettyJSON bytes.Buffer + err := json.Indent(&prettyJSON, certChainSummary, "", " ") + if err == nil { + fmt.Fprintln(os.Stderr, prettyJSON.String()) + } } - // fmt.Fprintln(os.Stderr, certChainSummary) - log.Debug().Str("json_payload", certChainSummary).Msg("JSON payload before encoding") - // NOTE: AddPayloadString will NOT return an error if empty input is // provided. - if _, err := plugin.AddPayloadString(certChainSummary); err != nil { + if _, err := plugin.AddPayloadBytes(certChainSummary); err != nil { return err } return 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 payload.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 -// } diff --git a/go.mod b/go.mod index 73911333..5dee0f08 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/atc0005/check-cert go 1.20 require ( - github.com/atc0005/cert-payload v0.6.1 + github.com/atc0005/cert-payload v0.7.0-alpha.1 github.com/atc0005/go-nagios v0.18.1 github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b github.com/rs/zerolog v1.33.0 diff --git a/go.sum b/go.sum index 3beed3d7..a4dfd19a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/atc0005/cert-payload v0.6.1 h1:4LuN7kcn2SFRMR3lU1FSbhJmWj4ono7vsDaK5mk1AAg= -github.com/atc0005/cert-payload v0.6.1/go.mod h1:46vw6K3bJ3zODjGW7xIilssL8r2Sg41hRAGideGH0kU= +github.com/atc0005/cert-payload v0.7.0-alpha.1 h1:nnYQaFD1iMNVowm72YJOJQEXDER0CDJ7y708IQs9MJQ= +github.com/atc0005/cert-payload v0.7.0-alpha.1/go.mod h1:zgKxu51OfQJvWRgBCdMk4b/LMotprdFeidietjopVgY= github.com/atc0005/go-nagios v0.18.1 h1:YGYNTyjNJiGXcCXYMhatHKzddjij06Peeb0va/wX45g= github.com/atc0005/go-nagios v0.18.1/go.mod h1:n2RHhsrgI8xiapqkJ240dKLwMXWbWvkOPLE92x0IGaM= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= diff --git a/internal/config/config.go b/internal/config/config.go index 2d88a5e3..807eedfd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -273,6 +273,10 @@ type Config struct { // field as a CRITICAL state. AgeCritical int + // PayloadFormatVersion indicates the chosen format version to use when + // creating a certificate metadata payload. + PayloadFormatVersion int + // timeout is the number of seconds allowed before the connection attempt // to a remote certificate-enabled service is abandoned and an error // returned. diff --git a/internal/config/constants.go b/internal/config/constants.go index 6f5d32e5..56c66495 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -42,6 +42,7 @@ const ( certExpireAgeWarningFlagHelp string = "The number of days remaining before certificate expiration when this application will will flag the NotAfter certificate field as a WARNING state." certExpireAgeCriticalFlagHelp string = "The number of days remaining before certificate expiration when this application will will flag the NotAfter certificate field as a CRITICAL state." brandingFlagHelp string = "Toggles emission of branding details with plugin status details. This output is disabled by default." + payloadFormatVersionFlagHelp string = "Specifies the format version to use when generating the (optional) certificate metadata payload." payloadFlagHelp string = "Toggles emission of encoded certificate chain payload. This output is disabled by default." payloadWithFullChainFlagHelp string = "Toggles emission of encoded certificate chain payload with the full certificate chain included. This option is disabled by default due to the significant increase in payload size." verboseOutputFlagHelp string = "Toggles emission of detailed certificate metadata. This level of output is disabled by default." @@ -97,6 +98,7 @@ const ( BrandingFlag string = "branding" PayloadFlag string = "payload" PayloadWithFullChainFlag string = "payload-with-full-chain" + PayloadFormatVersionFlag string = "payload-format" ServerFlagLong string = "server" ServerFlagShort string = "s" PortFlagLong string = "port" @@ -179,6 +181,7 @@ const ( defaultBranding bool = false defaultPayload bool = false defaultPayloadWithFullChain bool = false + defaultPayloadFormatVersion int = 0 defaultVerboseOutput bool = false defaultOmitSANsEntriesList bool = false defaultDisplayVersionAndExit bool = false diff --git a/internal/config/flags.go b/internal/config/flags.go index e3f903ef..aac0645d 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -60,6 +60,8 @@ func (c *Config) handleFlagsConfig(appType AppType) { flag.BoolVar(&c.EmitPayload, PayloadFlag, defaultPayload, payloadFlagHelp) flag.BoolVar(&c.EmitPayloadWithFullChain, PayloadWithFullChainFlag, defaultPayloadWithFullChain, payloadWithFullChainFlagHelp) + flag.IntVar(&c.PayloadFormatVersion, PayloadFormatVersionFlag, defaultPayloadFormatVersion, payloadFormatVersionFlagHelp) + flag.BoolVar(&c.EmitBranding, BrandingFlag, defaultBranding, brandingFlagHelp) flag.BoolVar( &c.IgnoreHostnameVerificationFailureIfEmptySANsList, diff --git a/internal/config/validate.go b/internal/config/validate.go index cf4b89ff..48b41996 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -60,6 +60,20 @@ func validatePort(c Config) error { return nil } +func validatePayloadFormatVersion(c Config) error { + // Format version 0 is valid, but anything less than that is not; in order + // to have the value set to less than zero someone has to explicitly + // choose that value (0 is the default). + if c.PayloadFormatVersion < 0 { + return fmt.Errorf( + "invalid certificate metadata payload format version %d", + c.PayloadFormatVersion, + ) + } + + return nil +} + // validate verifies all Config struct fields have been set to an acceptable // state. Positional argument handling AND validation is handled earlier in // the configuration initialization process. @@ -181,6 +195,10 @@ func (c Config) validate(appType AppType) error { return err } + if err := validatePayloadFormatVersion(c); err != nil { + return err + } + supportedValidationKeywords := supportedValidationCheckResultKeywords() // Validate the specified explicit "ignore" validation check results diff --git a/vendor/github.com/atc0005/cert-payload/README.md b/vendor/github.com/atc0005/cert-payload/README.md index f8fe3881..a197d297 100644 --- a/vendor/github.com/atc0005/cert-payload/README.md +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/doc.go b/vendor/github.com/atc0005/cert-payload/doc.go index f3ab60be..d5f76177 100644 --- a/vendor/github.com/atc0005/cert-payload/doc.go +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/chain-export.go b/vendor/github.com/atc0005/cert-payload/format/internal/shared/chain-export.go similarity index 99% rename from vendor/github.com/atc0005/cert-payload/chain-export.go rename to vendor/github.com/atc0005/cert-payload/format/internal/shared/chain-export.go index 21ba5c37..6eff01b6 100644 --- a/vendor/github.com/atc0005/cert-payload/chain-export.go +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/internal/shared/constants.go b/vendor/github.com/atc0005/cert-payload/format/internal/shared/constants.go new file mode 100644 index 00000000..57d9b095 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/internal/shared/doc.go b/vendor/github.com/atc0005/cert-payload/format/internal/shared/doc.go new file mode 100644 index 00000000..9dd54223 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/internal/shared/errors.go b/vendor/github.com/atc0005/cert-payload/format/internal/shared/errors.go new file mode 100644 index 00000000..98a6a80f --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/internal/shared/shared.go b/vendor/github.com/atc0005/cert-payload/format/internal/shared/shared.go new file mode 100644 index 00000000..5a247e57 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v0/decode.go b/vendor/github.com/atc0005/cert-payload/format/v0/decode.go new file mode 100644 index 00000000..ce06b937 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v0/doc.go b/vendor/github.com/atc0005/cert-payload/format/v0/doc.go new file mode 100644 index 00000000..c5f2f90c --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v0/encode.go b/vendor/github.com/atc0005/cert-payload/format/v0/encode.go new file mode 100644 index 00000000..7d800a2a --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v0/errors.go b/vendor/github.com/atc0005/cert-payload/format/v0/errors.go new file mode 100644 index 00000000..25587746 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v0/helpers.go b/vendor/github.com/atc0005/cert-payload/format/v0/helpers.go new file mode 100644 index 00000000..f159c14c --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v0/types.go b/vendor/github.com/atc0005/cert-payload/format/v0/types.go new file mode 100644 index 00000000..0fa0cfe7 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v1/decode.go b/vendor/github.com/atc0005/cert-payload/format/v1/decode.go new file mode 100644 index 00000000..9902f985 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v1/doc.go b/vendor/github.com/atc0005/cert-payload/format/v1/doc.go new file mode 100644 index 00000000..3e6b930e --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v1/encode.go b/vendor/github.com/atc0005/cert-payload/format/v1/encode.go new file mode 100644 index 00000000..43c05ce8 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v1/errors.go b/vendor/github.com/atc0005/cert-payload/format/v1/errors.go new file mode 100644 index 00000000..fe288a50 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v1/helpers.go b/vendor/github.com/atc0005/cert-payload/format/v1/helpers.go new file mode 100644 index 00000000..201d1ff7 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/format/v1/types.go b/vendor/github.com/atc0005/cert-payload/format/v1/types.go new file mode 100644 index 00000000..b6fe90d7 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/input/data.go b/vendor/github.com/atc0005/cert-payload/input/data.go new file mode 100644 index 00000000..33e82d1b --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/input/doc.go b/vendor/github.com/atc0005/cert-payload/input/doc.go new file mode 100644 index 00000000..2436f9b5 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/internal/certs/certs.go b/vendor/github.com/atc0005/cert-payload/internal/certs/certs.go new file mode 100644 index 00000000..b1babf16 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/internal/certs/doc.go b/vendor/github.com/atc0005/cert-payload/internal/certs/doc.go new file mode 100644 index 00000000..76b762b7 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/internal/textutils/doc.go b/vendor/github.com/atc0005/cert-payload/internal/textutils/doc.go new file mode 100644 index 00000000..6534495d --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/internal/textutils/normalize.go b/vendor/github.com/atc0005/cert-payload/internal/textutils/normalize.go new file mode 100644 index 00000000..0880e9d2 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/internal/textutils/textutils.go b/vendor/github.com/atc0005/cert-payload/internal/textutils/textutils.go new file mode 100644 index 00000000..136e5a41 --- /dev/null +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/github.com/atc0005/cert-payload/payload.go b/vendor/github.com/atc0005/cert-payload/payload.go index 2ac58b1c..0902ea46 100644 --- a/vendor/github.com/atc0005/cert-payload/payload.go +++ b/vendor/github.com/atc0005/cert-payload/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/vendor/modules.txt b/vendor/modules.txt index 155bc5df..257f360a 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,6 +1,12 @@ -# github.com/atc0005/cert-payload v0.6.1 +# github.com/atc0005/cert-payload v0.7.0-alpha.1 ## explicit; go 1.19 github.com/atc0005/cert-payload +github.com/atc0005/cert-payload/format/internal/shared +github.com/atc0005/cert-payload/format/v0 +github.com/atc0005/cert-payload/format/v1 +github.com/atc0005/cert-payload/input +github.com/atc0005/cert-payload/internal/certs +github.com/atc0005/cert-payload/internal/textutils # github.com/atc0005/go-nagios v0.18.1 ## explicit; go 1.19 github.com/atc0005/go-nagios