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{