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.
- attackmap_analyzer_iac/__init__.py +3 -0
- attackmap_analyzer_iac/analyzer.py +904 -0
- attackmap_analyzer_iac/k8s.py +680 -0
- attackmap_analyzer_iac/records.py +235 -0
- attackmap_analyzer_iac-0.2.0.dist-info/METADATA +125 -0
- attackmap_analyzer_iac-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_iac-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_iac-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_iac-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_iac-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|