Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 125 additions & 3 deletions src/dippy/cli/kubectl.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,98 @@
}


SECRET_RESOURCES = frozenset({"secret", "secrets"})

SAFE_OUTPUT_FORMATS = frozenset({"name", "wide"})

# Flags (after the verb) that consume the next token as a value
_POST_VERB_FLAGS_WITH_ARG = frozenset(
{
"-o",
"--output",
"-n",
"--namespace",
"-l",
"--selector",
"-f",
"--filename",
"--field-selector",
"--sort-by",
"--template",
"--context",
"--cluster",
}
)


def _is_secret_data_exposure(
tokens: list[str],
rest: list[str],
word_has_expansions: tuple[bool, ...] = (),
rest_offset: int = 0,
) -> bool:
"""Check if a get command targets secrets with a data-exposing output format.

Scans rest for the resource type and full tokens for -o (which can appear
before or after the verb). Tokens built from a bash expansion are treated
conservatively, since they could resolve to a secret resource or a
data-exposing format at runtime.
"""
# Find resource type: first non-flag token in rest
resource_type = None
resource_abs_pos = None
i = 0
while i < len(rest):
token = rest[i]
if token in _POST_VERB_FLAGS_WITH_ARG:
i += 2
continue
if token.startswith("-"):
i += 1
continue
resource_type = token
resource_abs_pos = rest_offset + i
break

if resource_type is None:
return False

# If the resource came from an expansion, it could resolve to "secret"
if _has_expansion(word_has_expansions, resource_abs_pos):
return True

# Handle comma-separated resources (e.g., "secret,configmap") and
# type/name syntax (e.g., "secret/my-secret")
parts = resource_type.split(",")
if not any(p.split("/")[0] in SECRET_RESOURCES for p in parts):
return False

# Resource IS secrets -- if any remaining token came from an expansion, it
# could inject a data-exposing format like -o yaml
if _has_expansion_after(word_has_expansions, resource_abs_pos + 1):
return True

# Find output format from full token list (-o can appear before or after verb)
output_format = None
for j, token in enumerate(tokens):
if token in ("-o", "--output") and j + 1 < len(tokens):
output_format = tokens[j + 1]
break
if token.startswith("--output="):
output_format = token[len("--output=") :]
break
if len(token) > 2 and token[:2] == "-o" and token[2] != "-":
output_format = token[2:]
break

if output_format is None:
return False

# Extract format name before any = (e.g., "jsonpath='{.data}'" -> "jsonpath")
format_name = output_format.split("=")[0]
return format_name not in SAFE_OUTPUT_FORMATS


def _extract_exec_inner_command(tokens: list[str]) -> list[str] | None:
"""Extract command from kubectl exec args (after -- separator)."""
try:
Expand All @@ -108,9 +200,20 @@ def _extract_exec_inner_command(tokens: list[str]) -> list[str] | None:
return None # No -- separator


def _has_expansion(word_has_expansions: tuple[bool, ...], pos: int) -> bool:
"""Whether the token at pos was built from a bash expansion (runtime value)."""
return 0 <= pos < len(word_has_expansions) and word_has_expansions[pos]


def _has_expansion_after(word_has_expansions: tuple[bool, ...], start: int) -> bool:
"""Whether any token at or after start was built from a bash expansion."""
return any(word_has_expansions[start:])


def classify(ctx: HandlerContext) -> Classification:
"""Classify kubectl command."""
tokens = ctx.tokens
expansions = ctx.word_has_expansions
base = tokens[0] if tokens else "kubectl"
if len(tokens) < 2:
return Classification("ask", description=base)
Expand Down Expand Up @@ -147,23 +250,42 @@ def classify(ctx: HandlerContext) -> Classification:
return Classification("ask", description=base)

rest = tokens[action_idx + 1 :] if action_idx + 1 < len(tokens) else []
rest_offset = action_idx + 1
desc = f"{base} {action}"

# Check for subcommands first
# Check for subcommands first (config/auth/rollout)
if action in SAFE_SUBCOMMANDS and rest:
for token in rest:
for idx, token in enumerate(rest):
if not token.startswith("-"):
abs_pos = rest_offset + idx
if _has_expansion(expansions, abs_pos):
return Classification("ask", description=desc)
if token in SAFE_SUBCOMMANDS[action]:
# config view --raw exposes unredacted kubeconfig credentials
if action == "config" and token == "view":
if "--raw" in rest or _has_expansion_after(
expansions, abs_pos + 1
):
return Classification("ask", description=f"{desc} {token}")
return Classification("allow", description=f"{desc} {token}")
break

if action in UNSAFE_SUBCOMMANDS and rest:
for token in rest:
for idx, token in enumerate(rest):
if not token.startswith("-"):
abs_pos = rest_offset + idx
if _has_expansion(expansions, abs_pos):
return Classification("ask", description=desc)
if token in UNSAFE_SUBCOMMANDS[action]:
return Classification("ask", description=f"{desc} {token}")
break

# Sensitive data checks (before blanket safe-action approval)
if action == "get" and _is_secret_data_exposure(
tokens, rest, expansions, rest_offset
):
return Classification("ask", description=f"{desc} (secret data)")

# Simple safe actions
if action in SAFE_ACTIONS:
return Classification("allow", description=desc)
Expand Down
54 changes: 54 additions & 0 deletions tests/cli/test_kubectl.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
("kubectl get all -A", True),
("kubectl get configmaps", True),
("kubectl get secrets", True),
("kubectl get secret my-secret", True), # default table view, no values
("kubectl get ingress", True),
("kubectl get pv", True),
("kubectl get pvc", True),
Expand Down Expand Up @@ -205,6 +206,59 @@
("kubectl expose deployment nginx --port=80 --target-port=8080", False),
("kubectl expose pod nginx --port=80 --type=NodePort", False),
#
# kubectl get secret - sensitive data exposure
# Output formats that could expose secret values require confirmation
#
("kubectl get secret my-secret -o yaml", False),
("kubectl get secret my-secret -o json", False),
("kubectl get secrets -o yaml", False),
("kubectl get secrets -o json", False),
("kubectl get secret my-secret -o jsonpath='{.data.password}'", False),
("kubectl get secret my-secret -o go-template='{{.data}}'", False),
(
"kubectl get secret my-secret -o custom-columns=NAME:.metadata.name,DATA:.data",
False,
),
("kubectl get secret my-secret --output=yaml", False),
("kubectl get secret my-secret --output json", False),
("kubectl get secret/my-secret -o yaml", False), # type/name syntax
("kubectl get secret,configmap -o yaml", False), # comma-separated includes secret
("kubectl -n kube-system get secret my-secret -o yaml", False), # flags before verb
("kubectl get secret my-secret -o name", True), # -o name is safe (no values)
("kubectl get secret my-secret -o wide", True), # -o wide is safe (no values)
("kubectl describe secret my-secret", True), # describe never shows values
("kubectl get pods -o yaml", True), # non-secret resource is fine
("kubectl get configmap my-config -o yaml", True), # non-secret resource is fine
#
# kubectl get secret - opaque tokens (cmdsubs, param expansions) in arguments
#
(
'kubectl get secret somesecret $(echo "-o yaml")',
False,
), # cmdsub could inject format
("kubectl get secret somesecret `echo '-o yaml'`", False), # backtick cmdsub
("kubectl get $(echo secret) -o yaml", False), # cmdsub resource, could be secrets
("kubectl get $RESOURCE -o yaml", False), # param expansion resource
("kubectl get secret somesecret -o $FORMAT", False), # param expansion format
("kubectl get secret somesecret -o ${FORMAT}", False), # braced param expansion
("kubectl get pods $(echo '-o yaml')", True), # pods aren't secrets, always safe
("kubectl get pods -o $FORMAT", True), # pods aren't secrets, always safe
#
# kubectl config view --raw - exposes unredacted kubeconfig credentials
#
("kubectl config view --raw", False),
("kubectl config view --raw --minify", False),
('kubectl config view $(echo "--raw")', False), # cmdsub could be --raw
("kubectl config view $RAW_FLAG", False), # param expansion could be --raw
#
# kubectl config/auth/rollout - opaque subcommands
#
('kubectl config $(echo "set-context") production', False), # opaque subcommand
('kubectl describe $(echo "pod nginx")', True), # describe is always safe
("kubectl describe $RESOURCE", True), # describe is always safe
('kubectl logs $(echo "nginx")', True), # logs is always safe
("kubectl logs $POD", True), # logs is always safe
#
# kubectl exec - delegation to inner command
#
("kubectl exec pod -- cat /etc/passwd", True), # cat is safe
Expand Down
Loading