Add role-based health checks to PKI Health Check (#17877)

* Add more PKI related common utilities

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add role_allows_localhost health check

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add role_allows_glob_wildcards health checks

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add role_no_store_false health check

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add new checks to the CLI

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Switch to new guard style

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Provision role for test

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Address review feedback

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Fix invalid version check

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Fix message with auto-rebuild enabled

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
Alexander Scheel 2022-11-17 15:31:58 -05:00 committed by GitHub
parent 610a4ede82
commit 0217f7de65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 446 additions and 0 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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{