Skip to main content

GCP Minimize Access Secrets - Security Rule

More Info:​

The Kubernetes API stores secrets, which may be service account tokens for the Kubernetes API or credentials used by workloads in the cluster. Access to these secrets should be restricted to the smallest possible group of users to reduce the risk of privilege escalation.

Risk Level​

High

Address​

Security

Compliance Standards​

  • APRA CPS 234 (Australia)
  • BSI C5 (Germany)
  • Brazil LGPD
  • CCPA / CPRA (California)
  • CIS Critical Security Controls v8
  • CIS GKE
  • CMMC 2.0
  • CSA Cloud Controls Matrix v4
  • DPDPA
  • Digital Operational Resilience Act (EU)
  • Essential 8
  • ISO/IEC 27017
  • ISO/IEC 27018
  • ISO/IEC 27701
  • KSA PDPL
  • MAS Technology Risk Management (Singapore)
  • MITRE ATT&CK (Cloud)
  • NIS2 Directive
  • NIST SP 800-171
  • NYDFS 23 NYCRR 500
  • SWIFT Customer Security Controls Framework
  • Sarbanes-Oxley IT General Controls
  • UK NCSC Cyber Assessment Framework

Triage and Remediation​

Remediation​

Manual Steps
  1. Identify roles with sensitive Secret access

    • Run on: any machine with kubectl access
    kubectl get roles --all-namespaces -o json \
    | jq -r '
    .items[]
    | select(.rules[]?
    | (.resources[]? == "secrets")
    and ((.verbs[]? == "get") or (.verbs[]? == "list") or (.verbs[]? == "watch"))
    )
    | "\(.metadata.namespace) \(.metadata.name)"
    ' | sort -u
  2. Review who is bound to each identified Role

    • Run on: any machine with kubectl access
    # Replace <NAMESPACE> and <ROLE_NAME> from step 1
    kubectl get rolebindings -n <NAMESPACE> -o json \
    | jq -r '
    .items[]
    | select(.roleRef.kind == "Role" and .roleRef.name == "<ROLE_NAME>")
    | "RoleBinding: \(.metadata.name) | Subjects: \(.subjects[]? | "\(.kind)/\(.name)")"
    '
    • Manually decide which users/service accounts/groups truly require get/list/watch on secrets.
  3. Inspect the exact Secret permissions in a Role

    • Run on: any machine with kubectl access
    # Replace <NAMESPACE> and <ROLE_NAME>
    kubectl get role -n <NAMESPACE> <ROLE_NAME> -o yaml
    • Manually determine which rules for resources: ["secrets"] and verbs: ["get","list","watch"] can be removed or narrowed (e.g., by resourceNames or fewer verbs).
  4. Edit Roles to minimize Secret access

    • Run on: any machine with kubectl access
      For each Role needing change:
    kubectl edit role -n <NAMESPACE> <ROLE_NAME>

    In the editor:

    • Locate rules: entries where resources includes secrets.
    • Remove unneeded verbs get, list, watch from verbs:.
    • If Secret access is not required, delete the entire rule containing secrets. Save and exit to apply changes.
  5. If necessary, adjust RoleBindings instead of Roles

    • Run on: any machine with kubectl access
      If some subjects no longer need Secret access but others still do:
    • Create a new, reduced-privilege Role without Secret access:
      kubectl get role -n <NAMESPACE> <ROLE_NAME> -o yaml \
      | sed 's/name: <ROLE_NAME>/name: <NEW_ROLE_NAME>/' \
      | yq 'del(.rules[] | select(.resources[] == "secrets"))' \
      | kubectl apply -f -
    • Rebind subjects that should not see secrets:
      kubectl create rolebinding <NEW_RB_NAME> \
      -n <NAMESPACE> \
      --role=<NEW_ROLE_NAME> \
      --user=<USER_OR_GROUP_OR_SA>
    • Then remove those subjects from the original RoleBinding:
      kubectl edit rolebinding -n <NAMESPACE> <ROLEBINDING_NAME>
  6. Verify minimized Secret access

    • Run on: any machine with kubectl access
    count=$(kubectl get roles --all-namespaces -o json | jq '
    .items[]
    | select(.rules[]?
    | (.resources[]? == "secrets")
    and ((.verbs[]? == "get") or (.verbs[]? == "list") or (.verbs[]? == "watch"))
    )' | wc -l)

    if [ "$count" -gt 0 ]; then
    echo "SECRETS_ACCESS_FOUND"
    else
    echo "NO_SECRETS_ACCESS_WITH_GET_LIST_WATCH_IN_ROLES"
    fi
Using kubectl

On any machine with kubectl access:

  1. Discover roles that can get/list/watch secrets

    kubectl get roles --all-namespaces -o json \
    | jq -r '
    .items[]
    | select(.rules[]?
    | (.resources[]? == "secrets")
    and ((.verbs[]? == "get") or (.verbs[]? == "list") or (.verbs[]? == "watch"))
    )
    | "\(.metadata.namespace) \(.metadata.name)"
    ' | sort -u
  2. For each role, review who uses it (RoleBindings)

    # Replace <NAMESPACE> and <ROLE_NAME> from previous output
    kubectl get rolebindings -n <NAMESPACE> -o yaml \
    | yq '.items[] | select(.roleRef.kind=="Role" and .roleRef.name=="<ROLE_NAME>")'
  3. Remove unnecessary get/list/watch verbs from a role (example)
    Export the current role:

    kubectl get role -n <NAMESPACE> <ROLE_NAME> -o yaml > /tmp/role-<NAMESPACE>-<ROLE_NAME>.yaml

    Edit /tmp/role-<NAMESPACE>-<ROLE_NAME>.yaml and, in any rule that includes resources: ["secrets"], remove get, list, and watch from the verbs list if not strictly required. For example, change:

    rules:
    - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list", "watch", "create"]

    to:

    rules:
    - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["create"]

    Apply the updated role:

    kubectl apply -f /tmp/role-<NAMESPACE>-<ROLE_NAME>.yaml
  4. If a role’s secret access is not needed at all, remove the rule entirely
    In the same exported YAML, delete any rules entry where resources includes secrets, then apply:

    kubectl apply -f /tmp/role-<NAMESPACE>-<ROLE_NAME>.yaml
  5. If access is still needed by a smaller group, create a more scoped role and re-bind
    Example minimal role allowing get on a specific secret only:

    # /tmp/role-secrets-reader-specific.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
    name: secrets-reader-specific
    namespace: <NAMESPACE>
    rules:
    - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["<SECRET_NAME>"]
    verbs: ["get"]

    Apply it:

    kubectl apply -f /tmp/role-secrets-reader-specific.yaml

    Then update or create a RoleBinding to use this scoped role instead of a broader one:

    # /tmp/rb-secrets-reader-specific.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
    name: secrets-reader-specific-binding
    namespace: <NAMESPACE>
    subjects:
    - kind: User
    name: <USER_NAME>
    apiGroup: rbac.authorization.k8s.io
    roleRef:
    kind: Role
    name: secrets-reader-specific
    apiGroup: rbac.authorization.k8s.io
    kubectl apply -f /tmp/rb-secrets-reader-specific.yaml
  6. Verification (re-run the audit logic)

    count=$(kubectl get roles --all-namespaces -o json | jq '
    .items[]
    | select(.rules[]?
    | (.resources[]? == "secrets")
    and ((.verbs[]? == "get") or (.verbs[]? == "list") or (.verbs[]? == "watch"))
    )' | wc -l)

    if [ "$count" -gt 0 ]; then
    echo "SECRETS_ACCESS_FOUND"
    else
    echo "NO_SECRETS_ACCESS_FOUND_FOR_GET_LIST_WATCH_IN_ROLES"
    fi
Automation
#!/usr/bin/env bash
#
# Minimize access to secrets (CIS GKE 4.1.2)
#
# Runs on: any machine with kubectl access and jq installed
#
# Behavior:
# - Enumerates all Roles that grant get/list/watch on "secrets"
# - Prints them for review
# - Interactively offers to remove get/list/watch on "secrets" from each matching rule
# - Applies changes via kubectl patch (idempotent)
# - Verifies that no Roles still grant get/list/watch on "secrets"
#
# NOTE: This is a MANUAL control; this script assists review but
# final decisions must be made by an administrator who understands
# which Roles truly need access to secrets (for example, controllers
# or operators that must read secrets to function).

set -euo pipefail

require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: '$1' is required but not installed or not in PATH." >&2
exit 1
fi
}

require_cmd kubectl
require_cmd jq

echo "=== Enumerating Roles with get/list/watch access to secrets ==="

# Collect Roles with get/list/watch on secrets
roles_json=$(kubectl get roles --all-namespaces -o json)
matches=$(echo "$roles_json" | jq -r '
.items[]
| select(
.rules[]?
| (.resources[]? == "secrets")
and ((.verbs[]? == "get") or (.verbs[]? == "list") or (.verbs[]? == "watch"))
)
| "\(.metadata.namespace) \(.metadata.name)"
')

if [ -z "$matches" ]; then
echo "No Roles currently grant get/list/watch on secrets."
exit 0
fi

echo "The following Roles grant get/list/watch access to secrets:"
echo "$matches" | awk '{printf " - Namespace: %-30s Role: %s\n", $1, $2}'

echo
echo "For each Role, you must decide if it truly requires get/list/watch on secrets."
echo "This script can remove those verbs only from rules that target the \"secrets\" resource."
echo "Other resources and verbs in the same Role are preserved."

read -r -p "Proceed with interactive cleanup of these Roles? [y/N]: " PROCEED
PROCEED=${PROCEED:-n}
if [[ ! "$PROCEED" =~ ^[Yy]$ ]]; then
echo "Aborting. No changes made."
exit 0
fi

# Function to build a patched Role JSON with minimized secret access
build_patched_role() {
local ns="$1"
local name="$2"

# Extract the Role, then rewrite rules:
# - For each rule that includes "secrets":
# * remove get/list/watch from verbs for that rule
# * if no verbs remain, drop the rule entirely
# - Other rules untouched
kubectl get role "$name" -n "$ns" -o json | jq '
.rules |= (
map(
if (.resources // [] | index("secrets")) != null then
# rule targeting secrets: strip get/list/watch
.verbs |= map(select(. != "get" and . != "list" and . != "watch")) |
if (.verbs | length) == 0 then
# drop rule with no remaining verbs
empty
else
.
end
else
.
end
)
)
'
}

# Process each Role interactively
echo
echo "=== Processing Roles ==="
echo

while read -r ns name; do
[ -z "$ns" ] && continue
echo "Role: $name"
echo "Namespace: $ns"
echo

echo "Current rules for this Role:"
kubectl get role "$name" -n "$ns" -o json | jq '.rules' | sed 's/^/ /'
echo

read -r -p "Remove get/list/watch access on secrets for this Role? [y/N]: " ANSWER
ANSWER=${ANSWER:-n}
if [[ ! "$ANSWER" =~ ^[Yy]$ ]]; then
echo "Skipping $ns/$name"
echo
continue
fi

echo "Building patched Role without get/list/watch on secrets..."
patched_json=$(build_patched_role "$ns" "$name")

echo "Resulting rules will be:"
echo "$patched_json" | jq '.rules' | sed 's/^/ /'
echo

read -r -p "Apply this change to $ns/$name? [y/N]: " APPLY
APPLY=${APPLY:-n}
if [[ ! "$APPLY" =~ ^[Yy]$ ]]; then
echo "Skipping apply for $ns/$name"
echo
continue
fi

# Apply patch idempotently
echo "$patched_json" | kubectl apply -n "$ns" -f -
echo "Updated $ns/$name"
echo
done <<< "$matches"

echo "=== Verification ==="
echo "Re-running check for Roles with get/list/watch on secrets ..."

count=$(kubectl get roles --all-namespaces -o json | jq '
.items[]
| select(.rules[]?
| (.resources[]? == "secrets")
and ((.verbs[]? == "get") or (.verbs[]? == "list") or (.verbs[]? == "watch"))
)' | wc -l | tr -d ' ')

if [ "$count" -gt 0 ]; then
echo "SECRETS_ACCESS_FOUND"
echo "There are still Roles with get/list/watch on secrets."
echo "Review them manually with:"
echo " kubectl get roles --all-namespaces -o json | jq -r '.items[] | select(.rules[]? | (.resources[]? == \"secrets\") and ((.verbs[]? == \"get\") or (.verbs[]? == \"list\") or (.verbs[]? == \"watch\"))) | \"Namespace: \(.metadata.namespace) Role: \(.metadata.name)\"'"
exit 1
else
echo "No Roles currently grant get/list/watch on secrets."
fi

echo "Done."