attackmap-analyzer-iac 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,680 @@
1
+ """Kubernetes manifests and Helm charts (iac#1).
2
+
3
+ Raw manifests are parsed with PyYAML's composer, which keeps line numbers.
4
+ Helm templates are not rendered: each ``{{ ... }}`` action is stripped first
5
+ (line count preserved) so the YAML around it still parses:
6
+
7
+ - an action inside a value (``image: "{{ .Values.repo }}:{{ .Values.tag }}"``)
8
+ becomes the token ``__TPL__``, and a value containing it is *templated*:
9
+ never reported, because the chart's values decide it;
10
+ - a line holding only an output action (``{{- toYaml .Values.securityContext
11
+ | nindent 12 }}``) becomes ``__templated__: true`` at the action's indent,
12
+ so the mapping it fills is known to be templated (no "missing" findings);
13
+ - a line holding only control actions (``if`` / ``end`` / ``range`` / ``$x :=``)
14
+ becomes blank. A setting inside ``if`` / ``with`` may be off by default, so
15
+ it is reported with low confidence.
16
+
17
+ If the marked text doesn't parse (a marker can land inside a list), the
18
+ document is parsed again with those lines blank and absence checks are
19
+ skipped for it. A document that still doesn't parse is skipped. ``values.yaml``
20
+ next to a ``Chart.yaml`` is scanned for insecure defaults (low confidence:
21
+ the templates may not use them).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from pathlib import Path
28
+
29
+ import yaml
30
+
31
+ from attackmap.sdk import (
32
+ EntrypointHint,
33
+ Route,
34
+ ScanResult,
35
+ SecretHint,
36
+ )
37
+ from .records import (
38
+ ADMIN_LIKE_ROLES,
39
+ append_misconfig,
40
+ capability_severity,
41
+ first_seen,
42
+ host_path_risk,
43
+ internal_lb,
44
+ rbac_wildcard,
45
+ )
46
+
47
+ _TPL = "__TPL__"
48
+ _TEMPLATED_KEY = "__templated__"
49
+ _ACTION_MAX = 4096 # an unterminated `{{` blanks at most this much
50
+ _DOC_MAX = 2000 # documents per file
51
+ _WALK_BUDGET = 20000 # nodes visited per values.yaml
52
+
53
+ # `{{- if ...}}`, `{{ end }}`, `{{/* comment */}}`, `{{- $x := ... }}`: produce no output.
54
+ _CONTROL_RE = re.compile(r"\{\{-?\s*(?:/\*|(?:if|else|end|range|with|define|block|break|continue)\b|\$\w*\s*:?=)")
55
+ _OPENS_CONDITIONAL_RE = re.compile(r"\{\{-?\s*(?:if|with)\b")
56
+ _OPENS_BLOCK_RE = re.compile(r"\{\{-?\s*(?:range|define|block)\b")
57
+ _ENDS_BLOCK_RE = re.compile(r"\{\{-?\s*end\b")
58
+ _NINDENT_RE = re.compile(r"\bn?indent\s+(\d{1,3})\b")
59
+ _DOC_SEPARATOR_RE = re.compile(r"^---(?:[ \t]|$)")
60
+ _K8S_MARKER_RE = re.compile(r"^apiVersion:", re.MULTILINE)
61
+ _K8S_KIND_RE = re.compile(r"^kind:", re.MULTILINE)
62
+ _IMAGE_TAG_RE = re.compile(r":([\w][\w.-]{0,127})$")
63
+ # Secret data keys that hold credentials (not `username`, `host`, `tls.crt`).
64
+ _SECRET_DATA_KEY_RE = re.compile(r"secret|token|passw|pwd|credential|private|key|auth|dockerconfig|htpasswd", re.IGNORECASE)
65
+ # ...except identifiers next to them (`auth.username`, `api-key-id`).
66
+ _SECRET_DATA_NON_SECRET_RE = re.compile(r"user|(?:^|[._-])(?:name|host|port|url|endpoint|id)$", re.IGNORECASE)
67
+ _SECRET_DATA_STRONG_RE = re.compile(r"passw|secret|token", re.IGNORECASE)
68
+
69
+ # The pure-Python loader, never libyaml (CSafeLoader): on hostile nesting the
70
+ # C parser overflows the C stack and kills the interpreter (SIGSEGV), while the
71
+ # Python composer raises a catchable RecursionError. `too_nested` below is
72
+ # only a fast reject; correctness doesn't depend on it.
73
+ _Loader = yaml.SafeLoader
74
+ # PyYAML's composer recurses per nesting level. Real manifests nest a few
75
+ # dozen levels; skip anything far deeper before parsing (a fast reject).
76
+ _MAX_FLOW_DEPTH = 200
77
+ _MAX_INDENT = 400
78
+
79
+ _POD_SPEC_PATHS: dict[str, tuple[str, ...]] = {
80
+ "Pod": ("spec",),
81
+ "Deployment": ("spec", "template", "spec"),
82
+ "ReplicaSet": ("spec", "template", "spec"),
83
+ "StatefulSet": ("spec", "template", "spec"),
84
+ "DaemonSet": ("spec", "template", "spec"),
85
+ "Job": ("spec", "template", "spec"),
86
+ "ReplicationController": ("spec", "template", "spec"),
87
+ "CronJob": ("spec", "jobTemplate", "spec", "template", "spec"),
88
+ }
89
+ _CONTAINER_LISTS = ("initContainers", "containers", "ephemeralContainers")
90
+ _BROAD_GROUPS = frozenset({"system:authenticated", "system:unauthenticated", "system:anonymous"})
91
+ _NAMESPACES = (
92
+ ("hostPID", "high", "host PID namespace → see and signal node processes and read their environment"),
93
+ ("hostNetwork", "medium", "host network namespace → reach node-local services (kubelet, cloud metadata) and other pods' traffic"),
94
+ ("hostIPC", "medium", "host IPC namespace → read other processes' shared memory"),
95
+ )
96
+
97
+
98
+ def looks_like_k8s(content: str) -> bool:
99
+ return bool(_K8S_MARKER_RE.search(content) and _K8S_KIND_RE.search(content))
100
+
101
+
102
+ # ---------- Helm stripping ----------
103
+
104
+
105
+ class _Stripped:
106
+ __slots__ = ("marked", "blanked", "conditional", "templated")
107
+
108
+ def __init__(self, marked: list[str], blanked: list[str], conditional: set[int], templated: bool) -> None:
109
+ self.marked = marked # lines, output-only directive lines as `__templated__: true`
110
+ self.blanked = blanked # lines, every directive-only line blank
111
+ self.conditional = conditional # 1-based lines inside {{ if }} / {{ with }}
112
+ self.templated = templated
113
+
114
+
115
+ def strip_helm(text: str) -> _Stripped:
116
+ """Strip Go-template actions from ``text`` (see the module docstring)."""
117
+ lines = text.split("\n")
118
+ if "{{" not in text:
119
+ return _Stripped(lines, lines, set(), False)
120
+ pieces: list[str] = []
121
+ # Per line: output actions' requested indent (or None), whether any action started there.
122
+ line_outputs: dict[int, list[int | None]] = {}
123
+ line_has_action: set[int] = set()
124
+ conditional: set[int] = set()
125
+ stack: list[bool] = [] # True for if/with frames
126
+ open_conditionals = [0] # how many True frames are on the stack (O(1) check)
127
+ pos = 0
128
+ line_no = 0
129
+ while True:
130
+ start = text.find("{{", pos)
131
+ if start == -1:
132
+ chunk = text[pos:]
133
+ _mark_conditional(conditional, line_no, chunk.count("\n"), open_conditionals[0])
134
+ pieces.append(chunk)
135
+ break
136
+ chunk = text[pos:start]
137
+ _mark_conditional(conditional, line_no, chunk.count("\n"), open_conditionals[0])
138
+ line_no += chunk.count("\n")
139
+ pieces.append(chunk)
140
+ end = text.find("}}", start + 2, start + _ACTION_MAX)
141
+ end = min(len(text), start + _ACTION_MAX) if end == -1 else end + 2
142
+ action = text[start:end]
143
+ line_has_action.add(line_no)
144
+ if _CONTROL_RE.match(action):
145
+ if _OPENS_CONDITIONAL_RE.match(action):
146
+ stack.append(True)
147
+ open_conditionals[0] += 1
148
+ elif _OPENS_BLOCK_RE.match(action):
149
+ stack.append(False)
150
+ elif _ENDS_BLOCK_RE.match(action) and stack:
151
+ open_conditionals[0] -= stack.pop()
152
+ replacement = ""
153
+ else:
154
+ indent = _NINDENT_RE.search(action)
155
+ line_outputs.setdefault(line_no, []).append(int(indent.group(1)) if indent else None)
156
+ replacement = _TPL
157
+ newlines = action.count("\n")
158
+ pieces.append(replacement + "\n" * newlines)
159
+ _mark_conditional(conditional, line_no, newlines, open_conditionals[0])
160
+ line_no += newlines
161
+ pos = end
162
+ cleaned = "".join(pieces).split("\n")
163
+ marked = list(cleaned)
164
+ blanked = list(cleaned)
165
+ for index in line_has_action:
166
+ if index >= len(cleaned):
167
+ continue
168
+ line = cleaned[index]
169
+ if line.replace(_TPL, "").strip():
170
+ continue # real YAML on the line: keep it with its __TPL__ tokens
171
+ blanked[index] = ""
172
+ outputs = line_outputs.get(index)
173
+ if outputs:
174
+ indent = next((i for i in outputs if i is not None), len(line) - len(line.lstrip()))
175
+ marked[index] = " " * indent + f"{_TEMPLATED_KEY}: true"
176
+ else:
177
+ marked[index] = ""
178
+ return _Stripped(marked, blanked, conditional, True)
179
+
180
+
181
+ def _mark_conditional(conditional: set[int], first_line: int, newlines: int, open_conditionals: int) -> None:
182
+ if open_conditionals:
183
+ conditional.update(range(first_line + 1, first_line + newlines + 2))
184
+
185
+
186
+ def _documents(lines: list[str]) -> list[tuple[int, int]]:
187
+ """``(first, end)`` line index ranges of the ``---``-separated documents."""
188
+ docs: list[tuple[int, int]] = []
189
+ start = 0
190
+ for index, line in enumerate(lines):
191
+ if _DOC_SEPARATOR_RE.match(line):
192
+ docs.append((start, index))
193
+ start = index + 1
194
+ if len(docs) >= _DOC_MAX:
195
+ return docs
196
+ docs.append((start, len(lines)))
197
+ return docs
198
+
199
+
200
+ def too_nested(text: str) -> bool:
201
+ """Cheap pre-check (a fast reject) before parsing: flow-collection
202
+ depth (``[``/``{`` outside quotes) or block nesting (indentation plus
203
+ compact ``- `` / ``? `` / ``: `` indicators at the start of a line)
204
+ beyond what any real manifest uses. Linear in the text."""
205
+ depth = 0
206
+ quote = ""
207
+ for ch in text:
208
+ if quote:
209
+ if ch == quote or ch == "\n":
210
+ quote = ""
211
+ elif ch in "[{":
212
+ depth += 1
213
+ if depth > _MAX_FLOW_DEPTH:
214
+ return True
215
+ elif ch in "]}":
216
+ depth = max(depth - 1, 0)
217
+ elif ch in "\"'":
218
+ quote = ch
219
+ elif ch == "\n":
220
+ pass
221
+ # Block nesting: indentation *and* compact indicators on one line
222
+ # (`- - - … 1`, `? ? ? … a`) each open a level in the parser.
223
+ return any(len(line) - len(line.lstrip(" -?:\t")) > _MAX_INDENT for line in text.split("\n"))
224
+
225
+
226
+ def _compose(lines: list[str], first: int, end: int):
227
+ text = "\n".join(lines[first:end])
228
+ if not text.strip() or too_nested(text):
229
+ return None
230
+ try:
231
+ return yaml.compose(text, Loader=_Loader)
232
+ except Exception: # YAMLError, RecursionError, bad tags: hostile or templated input
233
+ return None
234
+
235
+
236
+ # ---------- YAML node helpers ----------
237
+
238
+
239
+ def _get(node, key: str):
240
+ if isinstance(node, yaml.MappingNode):
241
+ for k, v in node.value:
242
+ if isinstance(k, yaml.ScalarNode) and k.value == key:
243
+ return v
244
+ return None
245
+
246
+
247
+ def _path(node, keys: tuple[str, ...]):
248
+ for key in keys:
249
+ node = _get(node, key)
250
+ if node is None:
251
+ return None
252
+ return node
253
+
254
+
255
+ def _items(node) -> list:
256
+ return list(node.value) if isinstance(node, yaml.SequenceNode) else []
257
+
258
+
259
+ def _scalar(node) -> str | None:
260
+ return node.value if isinstance(node, yaml.ScalarNode) else None
261
+
262
+
263
+ def _literal(node) -> str | None:
264
+ """A scalar's value unless templated."""
265
+ value = _scalar(node)
266
+ return None if value is None or _TPL in value else value
267
+
268
+
269
+ def _is_true(node) -> bool:
270
+ return isinstance(node, yaml.ScalarNode) and node.tag.endswith(":bool") and node.value.lower() in {"true", "yes", "on"}
271
+
272
+
273
+ def _is_false(node) -> bool:
274
+ return isinstance(node, yaml.ScalarNode) and node.tag.endswith(":bool") and node.value.lower() in {"false", "no", "off"}
275
+
276
+
277
+ def _int(node) -> int | None:
278
+ if isinstance(node, yaml.ScalarNode) and node.tag.endswith(":int"):
279
+ try:
280
+ return int(node.value.replace("_", ""), 0)
281
+ except ValueError:
282
+ return None
283
+ return None
284
+
285
+
286
+ def _line(node, offset: int) -> int:
287
+ return offset + node.start_mark.line + 1
288
+
289
+
290
+ def _templated(node, budget: int = 500) -> bool:
291
+ """True if ``node`` or anything under it came from a template action."""
292
+ stack = [node]
293
+ seen: set[int] = set()
294
+ while stack and budget > 0:
295
+ current = stack.pop()
296
+ if id(current) in seen:
297
+ continue
298
+ seen.add(id(current))
299
+ budget -= 1
300
+ if isinstance(current, yaml.ScalarNode):
301
+ if _TPL in current.value or current.value == _TEMPLATED_KEY:
302
+ return True
303
+ elif isinstance(current, yaml.MappingNode):
304
+ for k, v in current.value:
305
+ stack += [k, v]
306
+ elif isinstance(current, yaml.SequenceNode):
307
+ stack += current.value
308
+ return budget <= 0
309
+
310
+
311
+ def _display(value: str | None) -> str:
312
+ return (value or "?").replace(_TPL, "{{…}}")
313
+
314
+
315
+ # ---------- Manifest analysis ----------
316
+
317
+
318
+ class _Doc:
319
+ """One parsed document plus where it came from."""
320
+
321
+ __slots__ = ("root", "offset", "file", "templated", "chart", "precise", "conditional", "result")
322
+
323
+ def __init__(self, root, offset: int, file: str, templated: bool, chart: bool, precise: bool,
324
+ conditional: set[int], result: ScanResult) -> None:
325
+ self.root = root
326
+ self.offset = offset
327
+ self.file = file
328
+ self.templated = templated
329
+ self.chart = chart
330
+ self.precise = precise # parsed with templated markers: absence checks are safe
331
+ self.conditional = conditional
332
+ self.result = result
333
+
334
+ def record(self, kind: str, node, evidence: str, *, resource: str, severity: str,
335
+ confidence: float, escalation: str | None = None) -> None:
336
+ line = _line(node, self.offset)
337
+ context = []
338
+ if self.chart:
339
+ context.append("Helm template")
340
+ elif self.templated:
341
+ context.append("templated manifest")
342
+ if line in self.conditional:
343
+ context.append("inside an {{ if }}/{{ with }} block: may be off by default")
344
+ confidence = min(confidence, 0.4)
345
+ append_misconfig(
346
+ self.result, kind, self.file, line, evidence, severity=severity, confidence=confidence,
347
+ resource=resource, platform="helm" if self.chart else "kubernetes", escalation=escalation,
348
+ context="; ".join(context) or None,
349
+ )
350
+
351
+
352
+ def analyze_manifest(content: str, relative: str, result: ScanResult, *, chart: bool = False) -> None:
353
+ """Kubernetes manifest or Helm template (``chart``): workloads, RBAC,
354
+ Services, Ingresses and literal Secrets."""
355
+ stripped = strip_helm(content)
356
+ for first, end in _documents(stripped.marked):
357
+ root = _compose(stripped.marked, first, end)
358
+ precise = root is not None
359
+ if root is None and stripped.templated:
360
+ root = _compose(stripped.blanked, first, end)
361
+ if not isinstance(root, yaml.MappingNode):
362
+ continue
363
+ doc = _Doc(root, first, relative, stripped.templated, chart, precise, stripped.conditional, result)
364
+ try:
365
+ _analyze_object(doc)
366
+ except RecursionError: # pathological nesting
367
+ continue
368
+
369
+
370
+ def _analyze_object(doc: _Doc) -> None:
371
+ kind = _literal(_get(doc.root, "kind"))
372
+ if not kind:
373
+ return
374
+ name = _display(_scalar(_path(doc.root, ("metadata", "name"))))
375
+ resource = f"{kind}/{name}"
376
+ if kind in _POD_SPEC_PATHS:
377
+ pod = _path(doc.root, _POD_SPEC_PATHS[kind])
378
+ if isinstance(pod, yaml.MappingNode):
379
+ _check_pod(doc, pod, resource)
380
+ elif kind in {"ClusterRole", "Role"}:
381
+ _check_role(doc, kind, resource)
382
+ elif kind in {"ClusterRoleBinding", "RoleBinding"}:
383
+ _check_binding(doc, resource)
384
+ elif kind == "Service":
385
+ _check_service(doc, name, resource)
386
+ elif kind == "Ingress":
387
+ _check_ingress(doc, name)
388
+ elif kind == "Secret":
389
+ _check_secret(doc, name)
390
+
391
+
392
+ def _check_pod(doc: _Doc, pod, resource: str) -> None:
393
+ for key, severity, escalation in _NAMESPACES:
394
+ node = _get(pod, key)
395
+ if _is_true(node):
396
+ doc.record("host_namespace_shared", node, f"{key}: true", resource=resource, severity=severity,
397
+ confidence=0.9, escalation=escalation)
398
+ for volume in _items(_get(pod, "volumes")):
399
+ path_node = _path(volume, ("hostPath", "path"))
400
+ path = _literal(path_node)
401
+ if path:
402
+ severity, escalation = host_path_risk(path)
403
+ doc.record("host_path_mount", path_node, f"hostPath: {path}", resource=resource, severity=severity,
404
+ confidence=0.9 if severity == "high" else 0.6, escalation=escalation)
405
+ pod_sc = _get(pod, "securityContext")
406
+ _check_profiles(doc, pod_sc, resource)
407
+ pod_user = _int(_get(pod_sc, "runAsUser"))
408
+ pod_nonroot = _is_true(_get(pod_sc, "runAsNonRoot")) or (pod_user is not None and pod_user > 0)
409
+
410
+ unhardened: list[str] = []
411
+ for list_key in _CONTAINER_LISTS:
412
+ for container in _items(_get(pod, list_key)):
413
+ cname = _display(_scalar(_get(container, "name")))
414
+ where = f"{resource} container {cname}"
415
+ sc = _get(container, "securityContext")
416
+ privileged = _get(sc, "privileged")
417
+ if _is_true(privileged):
418
+ doc.record("privileged_container", privileged, "privileged: true", resource=where, severity="high",
419
+ confidence=0.9,
420
+ escalation="privileged container → every device and capability of the node → root on the host")
421
+ escalation_node = _get(sc, "allowPrivilegeEscalation")
422
+ if _is_true(escalation_node):
423
+ doc.record("privilege_escalation_allowed", escalation_node, "allowPrivilegeEscalation: true",
424
+ resource=where, severity="medium", confidence=0.9,
425
+ escalation="setuid binaries in the image can raise the process to root")
426
+ for cap in _items(_path(sc, ("capabilities", "add"))):
427
+ value = _literal(cap)
428
+ severity = capability_severity(value or "")
429
+ if severity:
430
+ doc.record("dangerous_capability", cap, f"capabilities.add: {value}", resource=where,
431
+ severity=severity, confidence=0.9,
432
+ escalation=f"CAP_{value.upper().removeprefix('CAP_')} → kernel interfaces that allow a container escape"
433
+ if severity == "high" else f"CAP_{value.upper().removeprefix('CAP_')} → control of the pod's network stack")
434
+ user_node = _get(sc, "runAsUser")
435
+ user = _int(user_node)
436
+ if user == 0:
437
+ doc.record("container_runs_as_root", user_node, "runAsUser: 0", resource=where, severity="medium",
438
+ confidence=0.9, escalation="a compromise of the process is root inside the container")
439
+ elif user is None and pod_user == 0:
440
+ doc.record("container_runs_as_root", _get(pod_sc, "runAsUser"), "pod runAsUser: 0", resource=where,
441
+ severity="medium", confidence=0.9,
442
+ escalation="a compromise of the process is root inside the container")
443
+ _check_profiles(doc, sc, where)
444
+ _check_image(doc, container, where)
445
+ if list_key == "ephemeralContainers":
446
+ continue
447
+ nonroot = _is_true(_get(sc, "runAsNonRoot")) or (user is not None and user > 0) or (
448
+ user is None and pod_nonroot
449
+ )
450
+ missing = []
451
+ if not nonroot and not _is_false(_get(sc, "runAsNonRoot")):
452
+ missing.append("runAsNonRoot")
453
+ if not _is_false(escalation_node) and not _is_true(escalation_node) and not _is_true(privileged):
454
+ missing.append("allowPrivilegeEscalation: false")
455
+ # A container inside {{ if }} may not exist; a templated one may
456
+ # get its securityContext from values.
457
+ if missing and not _templated(container) and _line(container, doc.offset) not in doc.conditional:
458
+ unhardened.append(f"{cname} (no {' / '.join(missing)})")
459
+ pod_sc_templated = pod_sc is not None and _templated(pod_sc)
460
+ if unhardened and doc.precise and not pod_sc_templated:
461
+ doc.record("missing_security_context", pod, "containers " + ", ".join(unhardened[:4])
462
+ + (f" +{len(unhardened) - 4} more" if len(unhardened) > 4 else ""),
463
+ resource=resource, severity="low", confidence=0.4,
464
+ escalation="a compromised process keeps root or can regain it through setuid binaries")
465
+
466
+
467
+ def _check_profiles(doc: _Doc, sc, resource: str) -> None:
468
+ for key in ("seccompProfile", "appArmorProfile"):
469
+ type_node = _path(sc, (key, "type"))
470
+ if (_literal(type_node) or "").lower() == "unconfined":
471
+ doc.record("security_profile_disabled", type_node, f"{key}.type: Unconfined", resource=resource,
472
+ severity="medium", confidence=0.9,
473
+ escalation="no syscall/LSM filter: kernel exploits and escape techniques aren't blocked")
474
+
475
+
476
+ def _check_image(doc: _Doc, container, where: str) -> None:
477
+ node = _get(container, "image")
478
+ image = _literal(node)
479
+ if not image or "@" in image or "$" in image:
480
+ return
481
+ last = image.rsplit("/", 1)[-1]
482
+ tag = _IMAGE_TAG_RE.search(last)
483
+ if tag is None or tag.group(1) == "latest":
484
+ doc.record("unpinned_container_image", node, f"image: {image}", resource=where, severity="low",
485
+ confidence=0.8, escalation="whoever can push the tag changes what runs, without a deploy")
486
+
487
+
488
+ def _check_role(doc: _Doc, kind: str, resource: str) -> None:
489
+ cluster = kind == "ClusterRole"
490
+ for rule in _items(_get(doc.root, "rules")):
491
+ verbs = {(_literal(v) or "").lower() for v in _items(_get(rule, "verbs"))}
492
+ resources = {(_literal(r) or "").lower() for r in _items(_get(rule, "resources"))}
493
+ summary = f"verbs {sorted(verbs - {''})} on {sorted(resources - {''})}"
494
+ groups_node = _get(rule, "apiGroups")
495
+ groups = None if groups_node is None else {_literal(g) or "" for g in _items(groups_node)}
496
+ if rbac_wildcard(groups, verbs, resources):
497
+ doc.record("rbac_wildcard", rule, summary, resource=resource, severity="high" if cluster else "medium",
498
+ confidence=0.9,
499
+ escalation="every verb or resource, including Secrets and RBAC objects → full control"
500
+ + (" of the cluster" if cluster else " of the namespace"))
501
+ continue
502
+ escalating = sorted(verbs & {"escalate", "bind", "impersonate"})
503
+ exec_like = sorted(resources & {"pods/exec", "pods/attach"}) if "create" in verbs else []
504
+ if escalating or exec_like:
505
+ escalation = (
506
+ "impersonate → act as any user or group, including system:masters" if "impersonate" in escalating
507
+ else "escalate/bind → grant itself any role, up to cluster-admin" if escalating
508
+ else "pods/exec → a shell in any pod in scope → that pod's service-account token and secrets"
509
+ )
510
+ doc.record("rbac_escalation", rule, summary, resource=resource, severity="high", confidence=0.85,
511
+ escalation=escalation)
512
+ elif "secrets" in resources and verbs & {"get", "list", "watch"} and not _items(_get(rule, "resourceNames")):
513
+ # `resourceNames` narrows it to named Secrets the workload owns.
514
+ doc.record("rbac_secret_access", rule, summary, resource=resource,
515
+ severity="medium" if cluster else "low", confidence=0.8,
516
+ escalation="read Secrets → service-account tokens and credentials of every workload in scope")
517
+
518
+
519
+ def _check_binding(doc: _Doc, resource: str) -> None:
520
+ role_node = _path(doc.root, ("roleRef", "name"))
521
+ role = _literal(role_node) or ""
522
+ subjects = []
523
+ broad = []
524
+ for subject in _items(_get(doc.root, "subjects")):
525
+ skind = _display(_scalar(_get(subject, "kind")))
526
+ sname = _display(_scalar(_get(subject, "name")))
527
+ subjects.append(f"{skind} {sname}")
528
+ if sname in _BROAD_GROUPS:
529
+ broad.append(subject)
530
+ # The default system:basic-user / system:discovery bindings to
531
+ # system:authenticated are fine; an admin-like role is not.
532
+ if role == "cluster-admin" or (broad and role in ADMIN_LIKE_ROLES):
533
+ who = ", ".join(subjects[:4]) or "no subjects"
534
+ node = role_node if role == "cluster-admin" else broad[0]
535
+ doc.record("cluster_admin_binding", node, f"roleRef {role or '?'} → {who}", resource=resource,
536
+ severity="high", confidence=0.9,
537
+ escalation=("cluster-admin → full control of every namespace and node" if role == "cluster-admin"
538
+ else "every authenticated (or anonymous) caller gets this role"))
539
+
540
+
541
+ def _check_service(doc: _Doc, name: str, resource: str) -> None:
542
+ type_node = _path(doc.root, ("spec", "type"))
543
+ svc_type = _literal(type_node)
544
+ if svc_type not in {"LoadBalancer", "NodePort"}:
545
+ return
546
+ annotations = _path(doc.root, ("metadata", "annotations"))
547
+ if isinstance(annotations, yaml.MappingNode):
548
+ pairs = {_scalar(k) or "": _scalar(v) or "" for k, v in annotations.value}
549
+ if internal_lb(pairs):
550
+ return # internal load balancer
551
+ hint = f"k8s_service_{svc_type.lower()}:{name}"
552
+ line = _line(type_node, doc.offset)
553
+ if first_seen(doc.result, ("entrypoint", hint, doc.file)):
554
+ doc.result.entrypoint_hints.append(
555
+ EntrypointHint(hint=hint, file=doc.file, line=line, evidence_text=f"type: {svc_type}", confidence=0.8)
556
+ )
557
+ if _path(doc.root, ("spec", "loadBalancerSourceRanges")) is not None:
558
+ return
559
+ doc.record("exposed_service", type_node, f"type: {svc_type} with no loadBalancerSourceRanges", resource=resource,
560
+ severity="low", confidence=0.7 if svc_type == "LoadBalancer" else 0.6,
561
+ escalation="reachable from outside the cluster by any source address")
562
+
563
+
564
+ def _check_ingress(doc: _Doc, name: str) -> None:
565
+ for rule in _items(_path(doc.root, ("spec", "rules"))):
566
+ host = _literal(_get(rule, "host")) or "*"
567
+ hint = f"k8s_ingress:{host}"
568
+ if first_seen(doc.result, ("entrypoint", hint, doc.file)):
569
+ doc.result.entrypoint_hints.append(
570
+ EntrypointHint(hint=hint, file=doc.file, line=_line(rule, doc.offset),
571
+ evidence_text=f"Ingress {name} host {host}", confidence=0.8)
572
+ )
573
+ for item in _items(_path(rule, ("http", "paths"))):
574
+ path_node = _get(item, "path")
575
+ path = _literal(path_node) if path_node is not None else "/"
576
+ if not path or not path.startswith("/"):
577
+ continue
578
+ key = (path, "ANY", doc.file)
579
+ if first_seen(doc.result, ("route", *key)):
580
+ doc.result.routes.append(Route(path=path, method="ANY", file=doc.file,
581
+ line=_line(path_node or item, doc.offset)))
582
+
583
+
584
+ def _check_secret(doc: _Doc, name: str) -> None:
585
+ for field in ("data", "stringData"):
586
+ data = _get(doc.root, field)
587
+ if not isinstance(data, yaml.MappingNode):
588
+ continue
589
+ for k, v in data.value:
590
+ key = _literal(k)
591
+ value = _literal(v)
592
+ if not key or not value or not value.strip() or value.strip().startswith(("<", "${", "$(")):
593
+ continue
594
+ if not _SECRET_DATA_KEY_RE.search(key) or (
595
+ _SECRET_DATA_NON_SECRET_RE.search(key) and not _SECRET_DATA_STRONG_RE.search(key)
596
+ ):
597
+ continue
598
+ line = _line(k, doc.offset)
599
+ secret_name = f"k8s_secret:{name}/{key}"
600
+ if not first_seen(doc.result, ("secret", secret_name, doc.file, line)):
601
+ continue
602
+ doc.result.secret_hints.append(
603
+ SecretHint(name=secret_name, file=doc.file, line=line, kind="hardcoded",
604
+ evidence_text=f"{field}.{key}: <redacted>")
605
+ )
606
+
607
+
608
+ # ---------- Helm values ----------
609
+
610
+ _VALUES_FLAGS = {
611
+ "privileged": ("privileged_container", "high", "privileged container → root on the node, if the chart applies it"),
612
+ "allowPrivilegeEscalation": ("privilege_escalation_allowed", "medium", "setuid binaries can raise the process to root"),
613
+ "hostPID": ("host_namespace_shared", "high", "host PID namespace, if the chart applies it"),
614
+ "hostNetwork": ("host_namespace_shared", "medium", "host network namespace, if the chart applies it"),
615
+ "hostIPC": ("host_namespace_shared", "medium", "host IPC namespace, if the chart applies it"),
616
+ }
617
+
618
+
619
+ def analyze_values(content: str, relative: str, result: ScanResult) -> None:
620
+ """Insecure defaults in a chart's ``values.yaml``. Reported with low
621
+ confidence: a default only matters if a template uses it."""
622
+ if too_nested(content):
623
+ return
624
+ try:
625
+ root = yaml.compose(content, Loader=_Loader)
626
+ except Exception:
627
+ return
628
+ stack: list[tuple[object, str]] = [(root, "")]
629
+ seen: set[int] = set()
630
+ budget = _WALK_BUDGET
631
+ while stack and budget > 0:
632
+ node, path = stack.pop()
633
+ budget -= 1
634
+ if id(node) in seen or not isinstance(node, (yaml.MappingNode, yaml.SequenceNode)):
635
+ continue
636
+ seen.add(id(node))
637
+ if isinstance(node, yaml.SequenceNode):
638
+ stack += [(item, f"{path}[]") for item in node.value]
639
+ continue
640
+ for k, v in node.value:
641
+ key = _scalar(k) or ""
642
+ dotted = f"{path}.{key}" if path else key
643
+ if key in _VALUES_FLAGS and _is_true(v):
644
+ kind, severity, escalation = _VALUES_FLAGS[key]
645
+ _values_record(result, relative, kind, v, f"{dotted}: true", severity, escalation)
646
+ elif key == "runAsUser" and _int(v) == 0:
647
+ _values_record(result, relative, "container_runs_as_root", v, f"{dotted}: 0", "medium",
648
+ "the container runs as root, if the chart applies it")
649
+ elif key == "add" and path.endswith("capabilities"):
650
+ for cap in _items(v):
651
+ value = _literal(cap) or ""
652
+ severity = capability_severity(value)
653
+ if severity:
654
+ _values_record(result, relative, "dangerous_capability", cap, f"{dotted}: {value}", severity,
655
+ f"CAP_{value.upper().removeprefix('CAP_')}, if the chart applies it")
656
+ else:
657
+ stack.append((v, dotted))
658
+
659
+
660
+ def _values_record(result: ScanResult, relative: str, kind: str, node, evidence: str, severity: str,
661
+ escalation: str) -> None:
662
+ append_misconfig(
663
+ result, kind, relative, node.start_mark.line + 1, evidence, severity=severity, confidence=0.5,
664
+ resource=f"values {evidence.split(':', 1)[0]}", platform="helm", escalation=escalation,
665
+ context="Helm values default",
666
+ )
667
+
668
+
669
+ def is_chart_values(path: Path) -> bool:
670
+ return path.name in {"values.yaml", "values.yml"} and (path.parent / "Chart.yaml").is_file()
671
+
672
+
673
+ def is_chart_template(path: Path, repo: Path) -> bool:
674
+ """Under a chart's ``templates/`` directory (``templates/tests/`` too)."""
675
+ for ancestor in path.parents:
676
+ if ancestor == repo or len(ancestor.parts) <= len(repo.parts):
677
+ return False
678
+ if ancestor.name == "templates" and (ancestor.parent / "Chart.yaml").is_file():
679
+ return True
680
+ return False