From 0217f7de654f71f361f75a9bd9dd2af63ca56fe6 Mon Sep 17 00:00:00 2001 From: Alexander Scheel Date: Thu, 17 Nov 2022 15:31:58 -0500 Subject: [PATCH] Add role-based health checks to PKI Health Check (#17877) * Add more PKI related common utilities Signed-off-by: Alexander Scheel * Add role_allows_localhost health check Signed-off-by: Alexander Scheel * Add role_allows_glob_wildcards health checks Signed-off-by: Alexander Scheel * Add role_no_store_false health check Signed-off-by: Alexander Scheel * Add new checks to the CLI Signed-off-by: Alexander Scheel * Switch to new guard style Signed-off-by: Alexander Scheel * Provision role for test Signed-off-by: Alexander Scheel * Address review feedback Signed-off-by: Alexander Scheel * Fix invalid version check Signed-off-by: Alexander Scheel * Fix message with auto-rebuild enabled Signed-off-by: Alexander Scheel Signed-off-by: Alexander Scheel --- command/healthcheck/pki.go | 46 ++++++ .../pki_role_allows_glob_wildcards.go | 132 +++++++++++++++ .../healthcheck/pki_role_allows_localhost.go | 105 ++++++++++++ .../healthcheck/pki_role_no_store_false.go | 154 ++++++++++++++++++ command/pki_health_check.go | 3 + command/pki_health_check_test.go | 6 + 6 files changed, 446 insertions(+) create mode 100644 command/healthcheck/pki_role_allows_glob_wildcards.go create mode 100644 command/healthcheck/pki_role_allows_localhost.go create mode 100644 command/healthcheck/pki_role_no_store_false.go diff --git a/command/healthcheck/pki.go b/command/healthcheck/pki.go index ece54fb6d..5fcfac219 100644 --- a/command/healthcheck/pki.go +++ b/command/healthcheck/pki.go @@ -227,3 +227,49 @@ func pkiFetchLeaf(e *Executor, serial string, versionError func()) (bool, *PathF return false, leafRet, leafRet.ParsedCache["certificate"].(*x509.Certificate), nil } + +func pkiFetchRoles(e *Executor, versionError func()) (bool, *PathFetch, []string, error) { + rolesRet, err := e.FetchIfNotFetched(logical.ListOperation, "/{{mount}}/roles") + if err != nil { + return true, nil, nil, err + } + + if !rolesRet.IsSecretOK() { + if rolesRet.IsUnsupportedPathError() { + versionError() + } + + return true, nil, nil, nil + } + + if len(rolesRet.ParsedCache) == 0 { + var roles []string + for _, roleName := range rolesRet.Secret.Data["keys"].([]interface{}) { + roles = append(roles, roleName.(string)) + } + rolesRet.ParsedCache["roles"] = roles + } + + return false, rolesRet, rolesRet.ParsedCache["roles"].([]string), nil +} + +func pkiFetchRole(e *Executor, name string, versionError func()) (bool, *PathFetch, map[string]interface{}, error) { + roleRet, err := e.FetchIfNotFetched(logical.ReadOperation, "/{{mount}}/roles/"+name) + if err != nil { + return true, nil, nil, err + } + + if !roleRet.IsSecretOK() { + if roleRet.IsUnsupportedPathError() { + versionError() + } + return true, nil, nil, nil + } + + var data map[string]interface{} = nil + if roleRet.Secret != nil && len(roleRet.Secret.Data) > 0 { + data = roleRet.Secret.Data + } + + return false, roleRet, data, nil +} diff --git a/command/healthcheck/pki_role_allows_glob_wildcards.go b/command/healthcheck/pki_role_allows_glob_wildcards.go new file mode 100644 index 000000000..05362d3ba --- /dev/null +++ b/command/healthcheck/pki_role_allows_glob_wildcards.go @@ -0,0 +1,132 @@ +package healthcheck + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-secure-stdlib/parseutil" +) + +type RoleAllowsGlobWildcards struct { + Enabled bool + UnsupportedVersion bool + + RoleEntryMap map[string]map[string]interface{} +} + +func NewRoleAllowsGlobWildcardsCheck() Check { + return &RoleAllowsGlobWildcards{ + RoleEntryMap: make(map[string]map[string]interface{}), + } +} + +func (h *RoleAllowsGlobWildcards) Name() string { + return "role_allows_glob_wildcards" +} + +func (h *RoleAllowsGlobWildcards) IsEnabled() bool { + return h.Enabled +} + +func (h *RoleAllowsGlobWildcards) DefaultConfig() map[string]interface{} { + return map[string]interface{}{} +} + +func (h *RoleAllowsGlobWildcards) LoadConfig(config map[string]interface{}) error { + enabled, err := parseutil.ParseBool(config["enabled"]) + if err != nil { + return fmt.Errorf("error parsing %v.enabled: %w", h.Name(), err) + } + h.Enabled = enabled + + return nil +} + +func (h *RoleAllowsGlobWildcards) FetchResources(e *Executor) error { + exit, _, roles, err := pkiFetchRoles(e, func() { + h.UnsupportedVersion = true + }) + if exit || err != nil { + return err + } + + for _, role := range roles { + skip, _, entry, err := pkiFetchRole(e, role, func() { + h.UnsupportedVersion = true + }) + if skip || err != nil || entry == nil { + if err != nil { + return err + } + continue + } + + h.RoleEntryMap[role] = entry + } + + return nil +} + +func (h *RoleAllowsGlobWildcards) Evaluate(e *Executor) (results []*Result, err error) { + if h.UnsupportedVersion { + // Shouldn't happen; roles have been around forever. + ret := Result{ + Status: ResultInvalidVersion, + Endpoint: "/{{mount}}/roles", + Message: "This health check requires Vault 1.11+ but an earlier version of Vault Server was contacted, preventing this health check from running.", + } + return []*Result{&ret}, nil + } + + for role, entry := range h.RoleEntryMap { + allowsWildcards, present := entry["allow_wildcard_certificates"] + if !present { + ret := Result{ + Status: ResultInvalidVersion, + Endpoint: "/{{mount}}/roles", + Message: "This health check requires a version of Vault with allow_wildcard_certificates (Vault 1.8.9+, 1.9.4+, or 1.10.0+), but an earlier version of Vault Server was contacted, preventing this health check from running.", + } + return []*Result{&ret}, nil + } + if !allowsWildcards.(bool) { + continue + } + + allowsGlobs := entry["allow_glob_domains"].(bool) + if !allowsGlobs { + continue + } + + rawAllowedDomains := entry["allowed_domains"].([]interface{}) + var allowedDomains []string + for _, rawDomain := range rawAllowedDomains { + allowedDomains = append(allowedDomains, rawDomain.(string)) + } + + if len(allowedDomains) == 0 { + continue + } + + hasGlobbedDomain := false + for _, domain := range allowedDomains { + if strings.Contains(domain, "*") { + hasGlobbedDomain = true + break + } + } + + if !hasGlobbedDomain { + continue + } + + ret := Result{ + Status: ResultWarning, + Endpoint: "/{{mount}}/role/" + role, + Message: fmt.Sprintf("Role currently allows wildcard issuance while allowing globs in allowed_domains (%v). Because globs can expand to one or more wildcard character, including wildcards under additional subdomains, these options are dangerous to enable together. If glob domains are required to be enabled, it is suggested to either disable wildcard issuance if not desired, or create two separate roles -- one with wildcard issuanced for specified domains, and one with glob matching enabled for concrete domain identifiers.", allowedDomains), + } + + results = append(results, &ret) + } + + return +} diff --git a/command/healthcheck/pki_role_allows_localhost.go b/command/healthcheck/pki_role_allows_localhost.go new file mode 100644 index 000000000..36068a88e --- /dev/null +++ b/command/healthcheck/pki_role_allows_localhost.go @@ -0,0 +1,105 @@ +package healthcheck + +import ( + "fmt" + + "github.com/hashicorp/go-secure-stdlib/parseutil" +) + +type RoleAllowsLocalhost struct { + Enabled bool + UnsupportedVersion bool + + RoleEntryMap map[string]map[string]interface{} +} + +func NewRoleAllowsLocalhostCheck() Check { + return &RoleAllowsLocalhost{ + RoleEntryMap: make(map[string]map[string]interface{}), + } +} + +func (h *RoleAllowsLocalhost) Name() string { + return "role_allows_localhost" +} + +func (h *RoleAllowsLocalhost) IsEnabled() bool { + return h.Enabled +} + +func (h *RoleAllowsLocalhost) DefaultConfig() map[string]interface{} { + return map[string]interface{}{} +} + +func (h *RoleAllowsLocalhost) LoadConfig(config map[string]interface{}) error { + enabled, err := parseutil.ParseBool(config["enabled"]) + if err != nil { + return fmt.Errorf("error parsing %v.enabled: %w", h.Name(), err) + } + h.Enabled = enabled + + return nil +} + +func (h *RoleAllowsLocalhost) FetchResources(e *Executor) error { + exit, _, roles, err := pkiFetchRoles(e, func() { + h.UnsupportedVersion = true + }) + if exit || err != nil { + return err + } + + for _, role := range roles { + skip, _, entry, err := pkiFetchRole(e, role, func() { + h.UnsupportedVersion = true + }) + if skip || err != nil || entry == nil { + if err != nil { + return err + } + continue + } + + h.RoleEntryMap[role] = entry + } + + return nil +} + +func (h *RoleAllowsLocalhost) Evaluate(e *Executor) (results []*Result, err error) { + if h.UnsupportedVersion { + // Shouldn't happen; roles have been around forever. + ret := Result{ + Status: ResultInvalidVersion, + Endpoint: "/{{mount}}/roles", + Message: "This health check requires Vault 1.11+ but an earlier version of Vault Server was contacted, preventing this health check from running.", + } + return []*Result{&ret}, nil + } + for role, entry := range h.RoleEntryMap { + allowsLocalhost := entry["allow_localhost"].(bool) + if !allowsLocalhost { + continue + } + + rawAllowedDomains := entry["allowed_domains"].([]interface{}) + var allowedDomains []string + for _, rawDomain := range rawAllowedDomains { + allowedDomains = append(allowedDomains, rawDomain.(string)) + } + + if len(allowedDomains) == 0 { + continue + } + + ret := Result{ + Status: ResultWarning, + Endpoint: "/{{mount}}/role/" + role, + Message: fmt.Sprintf("Role currently allows localhost issuance with a non-empty allowed_domains (%v): this role is intended for issuing other hostnames and the allow_localhost=true option may be overlooked by operators. If this role is intended to issue certificates valid for localhost, consider setting allow_localhost=false and explicitly adding localhost to the list of allowed domains.", allowedDomains), + } + + results = append(results, &ret) + } + + return +} diff --git a/command/healthcheck/pki_role_no_store_false.go b/command/healthcheck/pki_role_no_store_false.go new file mode 100644 index 000000000..0bef991b1 --- /dev/null +++ b/command/healthcheck/pki_role_no_store_false.go @@ -0,0 +1,154 @@ +package healthcheck + +import ( + "fmt" + + "github.com/hashicorp/vault/sdk/logical" + + "github.com/hashicorp/go-secure-stdlib/parseutil" +) + +type RoleNoStoreFalse struct { + Enabled bool + UnsupportedVersion bool + + AllowedRoles map[string]bool + + CertCounts int + RoleEntryMap map[string]map[string]interface{} + CRLConfig *PathFetch +} + +func NewRoleNoStoreFalseCheck() Check { + return &RoleNoStoreFalse{ + AllowedRoles: make(map[string]bool), + RoleEntryMap: make(map[string]map[string]interface{}), + } +} + +func (h *RoleNoStoreFalse) Name() string { + return "role_no_store_false" +} + +func (h *RoleNoStoreFalse) IsEnabled() bool { + return h.Enabled +} + +func (h *RoleNoStoreFalse) DefaultConfig() map[string]interface{} { + return map[string]interface{}{ + "allowed_roles": []string{}, + } +} + +func (h *RoleNoStoreFalse) LoadConfig(config map[string]interface{}) error { + value, present := config["allowed_roles"].([]interface{}) + if present { + for _, rawValue := range value { + h.AllowedRoles[rawValue.(string)] = true + } + } + + enabled, err := parseutil.ParseBool(config["enabled"]) + if err != nil { + return fmt.Errorf("error parsing %v.enabled: %w", h.Name(), err) + } + h.Enabled = enabled + + return nil +} + +func (h *RoleNoStoreFalse) FetchResources(e *Executor) error { + exit, _, roles, err := pkiFetchRoles(e, func() { + h.UnsupportedVersion = true + }) + if exit || err != nil { + return err + } + + for _, role := range roles { + skip, _, entry, err := pkiFetchRole(e, role, func() { + h.UnsupportedVersion = true + }) + if skip || err != nil || entry == nil { + if err != nil { + return err + } + continue + } + + h.RoleEntryMap[role] = entry + } + + exit, _, leaves, err := pkiFetchLeaves(e, func() { + h.UnsupportedVersion = true + }) + if exit || err != nil { + return err + } + h.CertCounts = len(leaves) + + // Check if the issuer is fetched yet. + configRet, err := e.FetchIfNotFetched(logical.ReadOperation, "/{{mount}}/config/crl") + if err != nil { + return err + } + + h.CRLConfig = configRet + + return nil +} + +func (h *RoleNoStoreFalse) Evaluate(e *Executor) (results []*Result, err error) { + if h.UnsupportedVersion { + // Shouldn't happen; roles have been around forever. + ret := Result{ + Status: ResultInvalidVersion, + Endpoint: "/{{mount}}/roles", + Message: "This health check requires Vault 1.11+ but an earlier version of Vault Server was contacted, preventing this health check from running.", + } + return []*Result{&ret}, nil + } + + crlAutoRebuild := false + if h.CRLConfig != nil { + if h.CRLConfig.IsSecretPermissionsError() { + ret := Result{ + Status: ResultInsufficientPermissions, + Endpoint: "/{{mount}}/config/crl", + Message: "This prevents the health check from seeing if the CRL is set to auto_rebuild=true and lowering the severity of check results appropriately.", + } + + if e.Client.Token() == "" { + ret.Message = "No token available so unable read authenticated CRL configuration for this mount. " + ret.Message + } else { + ret.Message = "This token lacks so permission to read the CRL configuration for this mount. " + ret.Message + } + + results = append(results, &ret) + } else if h.CRLConfig.Secret != nil && h.CRLConfig.Secret.Data["auto_rebuild"] != nil { + crlAutoRebuild = h.CRLConfig.Secret.Data["auto_rebuild"].(bool) + } + } + + for role, entry := range h.RoleEntryMap { + noStore := entry["no_store"].(bool) + if noStore { + continue + } + + ret := Result{ + Status: ResultWarning, + Endpoint: "/{{mount}}/role/" + role, + Message: "Role currently stores every issued certificate (no_store=false). Too many issued and/or revoked certificates can exceed Vault's storage limits and make operations slow. It is encouraged to enable auto-rebuild of CRLs to prevent every revocation from creating a new CRL, and to limit the number of certificates issued under roles with no_store=false: use shorter lifetimes and/or BYOC revocation instead.", + } + + if crlAutoRebuild { + ret.Status = ResultInformational + ret.Message = "Role currently stores every issued certificate (no_store=false). With auto-rebuild CRL enabled, less performance impact occur on CRL rebuilding, but note that too many issued and/or revoked certificates can exceed Vault's storage limits and make operations slow. It is suggested to limit the number of certificates issued under roles with no_store=false: use shorter lifetimes to avoid revocation and/or BYOC revocation instead." + } + + results = append(results, &ret) + } + + return +} diff --git a/command/pki_health_check.go b/command/pki_health_check.go index cb7faeec5..adf2f9b91 100644 --- a/command/pki_health_check.go +++ b/command/pki_health_check.go @@ -199,6 +199,9 @@ func (c *PKIHealthCheckCommand) Run(args []string) int { executor.AddCheck(healthcheck.NewCRLValidityPeriodCheck()) executor.AddCheck(healthcheck.NewHardwareBackedRootCheck()) executor.AddCheck(healthcheck.NewRootIssuedLeavesCheck()) + executor.AddCheck(healthcheck.NewRoleAllowsLocalhostCheck()) + executor.AddCheck(healthcheck.NewRoleAllowsGlobWildcardsCheck()) + executor.AddCheck(healthcheck.NewRoleNoStoreFalseCheck()) if c.flagDefaultDisabled { executor.DefaultEnabled = false } diff --git a/command/pki_health_check_test.go b/command/pki_health_check_test.go index 7c1937399..22d32ad1a 100644 --- a/command/pki_health_check_test.go +++ b/command/pki_health_check_test.go @@ -45,6 +45,12 @@ func TestPKIHC_Run(t *testing.T) { t.Fatalf("failed to rotate CRLs: %v", err) } + if _, err := client.Logical().Write("pki/roles/testing", map[string]interface{}{ + "allow_any_name": true, + }); err != nil { + t.Fatalf("failed to write role: %v", err) + } + stdout := bytes.NewBuffer(nil) stderr := bytes.NewBuffer(nil) runOpts := &RunOptions{