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,904 @@
1
+ """Infrastructure-as-Code analyzer for AttackMap.
2
+
3
+ Covers files that drive the runtime posture but aren't application
4
+ source: Dockerfile, docker-compose, GitHub Actions workflows, `.env`
5
+ templates, shell installers, and Kubernetes manifests / Helm charts. The
6
+ first five are regex-based and intentionally shallow; Kubernetes and Helm
7
+ are parsed with PyYAML (see `k8s.py`).
8
+
9
+ See the Bluesky FINDINGS §2 for the motivating gap: `bluesky-social/pds`
10
+ was 95% invisible to AttackMap because none of these file types had
11
+ analyzer coverage.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from attackmap.sdk import (
20
+ DEFAULT_SKIP_DIRS,
21
+ AnalyzerMetadata,
22
+ EntrypointHint,
23
+ ExternalCall,
24
+ FrameworkHint,
25
+ iter_repo_files,
26
+ read_source,
27
+ rel,
28
+ Route,
29
+ ScanResult,
30
+ SecretHint,
31
+ ServiceHint,
32
+ )
33
+
34
+ from .k8s import analyze_manifest, analyze_values, is_chart_template, is_chart_values, looks_like_k8s
35
+ from .records import (
36
+ append_misconfig,
37
+ capability_severity,
38
+ first_seen,
39
+ host_path_risk,
40
+ line_of,
41
+ line_snippet,
42
+ release,
43
+ )
44
+
45
+ # Where each posture signal goes:
46
+ # - container and cluster misconfigurations (root user, privileged, host
47
+ # mounts and namespaces, capabilities, remote build inputs, RBAC) are
48
+ # `CloudMisconfig` records (iac#1, see records.py), which core turns into
49
+ # findings. They were `AuthHint`s, which core reads as auth signals;
50
+ # - network exposure is an EntrypointHint;
51
+ # - build / supply-chain / deployment-config posture, and CI / shell posture,
52
+ # is a FrameworkHint (AttackMap#258). No AuthHints are emitted.
53
+
54
+
55
+ # Every directory this plugin used to skip is in the shared list; matched
56
+ # against directory names *inside* the repo only.
57
+ _SKIP_DIRS = DEFAULT_SKIP_DIRS
58
+
59
+
60
+ # ---- Dockerfile patterns -----------------------------------------------------
61
+
62
+ # Exact canonical names; `_is_dockerfile` also accepts `Dockerfile.prod`,
63
+ # `Dockerfile-dev`, `api.Dockerfile`, `*.dockerfile` and `Containerfile.*`.
64
+ DOCKERFILE_NAMES = {"Dockerfile", "dockerfile", "Containerfile"}
65
+ # `Dockerfile.dockerignore` is BuildKit's per-Dockerfile ignore file; docs
66
+ # named after a Dockerfile aren't Dockerfiles either.
67
+ _DOCKERFILE_NON_SOURCE_SUFFIXES = (".dockerignore", ".md", ".rst", ".txt")
68
+
69
+ # `FROM [--platform=...] <image> [AS <alias>]`
70
+ _DF_FROM = re.compile(
71
+ r"^\s*FROM\s+(?:--\S+\s+)*(?P<image>[^\s-][\S]*)(?:\s+AS\s+(?P<alias>\S+))?",
72
+ re.IGNORECASE | re.MULTILINE,
73
+ )
74
+ _DF_USER = re.compile(r"^\s*USER\s+(?P<user>\S+)", re.IGNORECASE | re.MULTILINE)
75
+ _DF_EXPOSE = re.compile(r"^\s*EXPOSE\s+(?P<ports>[0-9\s/tcpud]+)", re.IGNORECASE | re.MULTILINE)
76
+ _DF_HEALTHCHECK = re.compile(r"^\s*HEALTHCHECK\s+", re.IGNORECASE | re.MULTILINE)
77
+ _DF_RUN_CURL_PIPE = re.compile(
78
+ r"^\s*RUN\s+.*?(?:curl|wget)\s+[^|;\n]*\s*[|;]\s*(?:bash|sh|zsh|python)",
79
+ re.IGNORECASE | re.MULTILINE,
80
+ )
81
+ _DF_COPY_CHOWN = re.compile(r"^\s*COPY\s+--chown=", re.IGNORECASE | re.MULTILINE)
82
+ # `ADD [--flag ...] https://...`; `--checksum=` verifies the download.
83
+ _DF_ADD_REMOTE = re.compile(r"^[ \t]*ADD[ \t]+(?P<flags>(?:--\S+[ \t]+)*)https?://\S*", re.IGNORECASE | re.MULTILINE)
84
+ _IMAGE_TAG = re.compile(r":([\w][\w.-]{0,127})$")
85
+
86
+
87
+ # ---- docker-compose patterns -------------------------------------------------
88
+
89
+ # Canonical names; `_is_compose_file` also accepts overrides and variants
90
+ # (`docker-compose.override.yml`, `docker-compose.prod.yml`, `compose.dev.yaml`).
91
+ COMPOSE_NAMES = {"docker-compose.yaml", "docker-compose.yml", "compose.yaml", "compose.yml"}
92
+ _COMPOSE_NAME = re.compile(r"^(?:docker-)?compose(?:[.-][^/]+)?\.ya?ml$", re.IGNORECASE)
93
+
94
+ # service block: `services:\n <name>:` — we grab each `<name>:` at
95
+ # the second indent level. Cheap; doesn't need a real YAML parser.
96
+ _COMPOSE_SERVICE_NAME = re.compile(
97
+ r"^(?P<indent>[ \t]{2,4})(?P<name>[a-z0-9_-]+):\s*$",
98
+ re.MULTILINE,
99
+ )
100
+ _COMPOSE_SERVICES_HEADER = re.compile(r"^services:\s*$", re.MULTILINE)
101
+ _COMPOSE_IMAGE = re.compile(r"^\s{2,}image:\s*['\"]?(?P<image>[^\s'\"#]+)", re.MULTILINE)
102
+ _COMPOSE_HOST_MOUNT = re.compile(r"['\"]?(?P<host_path>/[^:\s]*):[^:\s]+['\"]?", re.MULTILINE)
103
+ _COMPOSE_DOCKER_SOCKET = re.compile(r"(?:^|[\s'\"=:])(?P<sock>/(?:var/)?run/docker\.sock)\b", re.MULTILINE)
104
+ _COMPOSE_PID_HOST = re.compile(r"^\s+pid:\s*['\"]?host['\"]?\s*(?:#.*)?$", re.IGNORECASE | re.MULTILINE)
105
+ _COMPOSE_SECCOMP_UNCONFINED = re.compile(r"seccomp[:=]\s*['\"]?unconfined\b", re.IGNORECASE)
106
+ _COMPOSE_PRIVILEGED = re.compile(r"^\s+privileged:\s*true", re.IGNORECASE | re.MULTILINE)
107
+ _COMPOSE_NETWORK_HOST = re.compile(r"^\s+network_mode:\s*['\"]?host['\"]?", re.IGNORECASE | re.MULTILINE)
108
+ _COMPOSE_IPC_HOST = re.compile(r"^[ \t]+ipc:[ \t]*['\"]?host['\"]?[ \t]*(?:#.*)?$", re.IGNORECASE | re.MULTILINE)
109
+ _COMPOSE_APPARMOR_UNCONFINED = re.compile(r"apparmor[:=][ \t]*['\"]?unconfined\b", re.IGNORECASE)
110
+ # Matches both `env_file: pds.env` (inline) and the list form:
111
+ # env_file:
112
+ # - pds.env
113
+ # The `following` group captures either the inline value or the entire
114
+ # list block; we normalize downstream to pull the filename(s) out.
115
+ _COMPOSE_ENV_FILE = re.compile(
116
+ # Use [ \t]* (not \s*) so we don't accidentally jump over the newline
117
+ # and treat the first list-item as the inline value.
118
+ r"^\s+env_file:[ \t]*(?P<inline>[^\s#\n][^\n]*)?\n(?P<listed>(?:\s+-\s+[^\n]+\n)*)",
119
+ re.IGNORECASE | re.MULTILINE,
120
+ )
121
+
122
+
123
+ # Secret-shaped variable names (Dockerfile ENV/ARG, compose `environment:`).
124
+ # `*_FILE` variants hold a path to a mounted secret, and `PUBLIC` keys aren't
125
+ # secret.
126
+ _SECRET_NAME = re.compile(
127
+ r"SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_?KEY|ACCESS_?KEY|API_?KEY|(?:^|_)KEY(?:_|$)",
128
+ re.IGNORECASE,
129
+ )
130
+ _NON_SECRET_VALUES = {"", "true", "false", "yes", "no", "on", "off", "0", "1", "null", "~"}
131
+
132
+
133
+ # ---- GitHub Actions patterns -------------------------------------------------
134
+
135
+ _GHA_PATH = re.compile(r"\.github/workflows/[^/]+\.ya?ml$", re.IGNORECASE)
136
+ _GHA_PR_TARGET = re.compile(r"^\s*pull_request_target\s*:", re.IGNORECASE | re.MULTILINE)
137
+ _GHA_CHECKOUT = re.compile(r"uses:\s+actions/checkout@", re.IGNORECASE)
138
+ # `uses: owner/repo@ref` where ref is not a 40-char SHA
139
+ _GHA_USES = re.compile(
140
+ r"uses:\s+(?P<repo>[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+)@(?P<ref>\S+)",
141
+ re.IGNORECASE,
142
+ )
143
+ _SHA40 = re.compile(r"^[a-f0-9]{40}$", re.IGNORECASE)
144
+ _GHA_SECRETS_REF = re.compile(r"\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}", re.IGNORECASE)
145
+ _GHA_PERMISSIONS = re.compile(r"^\s*permissions\s*:", re.IGNORECASE | re.MULTILINE)
146
+
147
+
148
+ # ---- .env template names -----------------------------------------------------
149
+
150
+ ENV_TEMPLATE_NAMES = {".env.example", ".env.sample", ".env.template", "sample.env"}
151
+ _ENV_KEY = re.compile(r"^\s*(?P<key>[A-Z][A-Z0-9_]*)\s*=", re.MULTILINE)
152
+
153
+
154
+ # ---- Shell installer patterns ------------------------------------------------
155
+
156
+ SHELL_SUFFIXES = {".sh", ".bash", ".zsh"}
157
+ _SH_CURL_PIPE = re.compile(
158
+ r"(?:curl|wget)\s+[^|;\n]*\s*[|;]\s*(?:bash|sh|zsh|python)",
159
+ re.IGNORECASE,
160
+ )
161
+ _SH_SUDO = re.compile(r"^\s*sudo\s+", re.MULTILINE)
162
+ _SH_CHMOD_777 = re.compile(r"\bchmod\s+(?:0?7[0-7]{2}|a\+w)\b")
163
+
164
+
165
+ class IacAnalyzer:
166
+ metadata = AnalyzerMetadata(
167
+ name="iac",
168
+ display_name="IaC / Deployment-Surface Analyzer",
169
+ version="0.2.0",
170
+ description="Analyzer for infrastructure-as-code and deployment files: Dockerfile, docker-compose, Kubernetes manifests, Helm charts, GitHub Actions, .env templates, shell installers.",
171
+ scope="Deployment / operations repositories where the runtime posture is defined outside application source. Closes the coverage gap Bluesky FINDINGS §2 documented for bluesky-social/pds.",
172
+ targets=["docker", "docker-compose", "kubernetes", "helm", "github-actions", "env-template", "shell-installer"],
173
+ languages=[],
174
+ priority=40,
175
+ experimental=False,
176
+ enabled_by_default=True,
177
+ )
178
+
179
+ @property
180
+ def name(self) -> str:
181
+ return self.metadata.name
182
+
183
+ def detect(self, repo_path: str | Path) -> bool:
184
+ repo = Path(repo_path).resolve()
185
+ if not repo.exists() or not repo.is_dir():
186
+ return False
187
+ for candidate in _iter_candidates(repo):
188
+ if _is_iac_file(candidate, rel(candidate, repo)):
189
+ return True
190
+ if _is_yaml(candidate):
191
+ content = read_source(candidate)
192
+ if content is not None and looks_like_k8s(content):
193
+ return True
194
+ return False
195
+
196
+ def analyze(self, repo_path: str | Path) -> ScanResult:
197
+ repo = Path(repo_path).resolve()
198
+ result = ScanResult(root=str(repo))
199
+ if not repo.exists() or not repo.is_dir():
200
+ return result
201
+
202
+ try:
203
+ for path in _iter_candidates(repo):
204
+ relative = rel(path, repo)
205
+ named = _is_iac_file(path, relative)
206
+ if not named and not _is_yaml(path):
207
+ continue
208
+ content = read_source(path)
209
+ if content is None:
210
+ continue
211
+ if not named:
212
+ # Kubernetes manifests, Helm templates and chart values (iac#1).
213
+ if is_chart_values(path):
214
+ analyze_values(content, relative, result)
215
+ elif looks_like_k8s(content):
216
+ analyze_manifest(content, relative, result, chart=is_chart_template(path, repo))
217
+ else:
218
+ continue
219
+ result.files_scanned += 1
220
+ continue
221
+ result.files_scanned += 1
222
+ if _is_dockerfile(path.name):
223
+ _analyze_dockerfile(content, relative, result)
224
+ elif _is_compose_file(path.name):
225
+ _analyze_compose(content, relative, result)
226
+ elif _GHA_PATH.search(relative):
227
+ _analyze_gha_workflow(content, relative, result)
228
+ elif path.name in ENV_TEMPLATE_NAMES:
229
+ _analyze_env_template(content, relative, result)
230
+ elif path.suffix in SHELL_SUFFIXES:
231
+ _analyze_shell_installer(content, relative, result)
232
+ finally:
233
+ release(result, result.entrypoint_hints, result.framework_hints, result.service_hints, result.auth_hints)
234
+ return result
235
+
236
+
237
+ def _iter_candidates(repo: Path):
238
+ """Files that might be IaC files; ``_is_iac_file`` makes the final call.
239
+
240
+ Unfiltered: Dockerfile variants (`Dockerfile.prod`) have arbitrary
241
+ suffixes, so they can't be selected by name or suffix up front.
242
+ """
243
+ return iter_repo_files(repo, skip_dirs=_SKIP_DIRS)
244
+
245
+
246
+ def _is_dockerfile(name: str) -> bool:
247
+ lower = name.lower()
248
+ if lower.endswith(_DOCKERFILE_NON_SOURCE_SUFFIXES):
249
+ return False
250
+ return (
251
+ lower.startswith(("dockerfile", "containerfile"))
252
+ or lower.endswith((".dockerfile", ".containerfile"))
253
+ )
254
+
255
+
256
+ def _is_yaml(path: Path) -> bool:
257
+ return path.suffix.lower() in {".yaml", ".yml"}
258
+
259
+
260
+ def _is_compose_file(name: str) -> bool:
261
+ return name in COMPOSE_NAMES or bool(_COMPOSE_NAME.match(name))
262
+
263
+
264
+ def _is_iac_file(path: Path, relative: str) -> bool:
265
+ if _is_dockerfile(path.name):
266
+ return True
267
+ if _is_compose_file(path.name):
268
+ return True
269
+ if _GHA_PATH.search(relative):
270
+ return True
271
+ if path.name in ENV_TEMPLATE_NAMES:
272
+ return True
273
+ if path.suffix in SHELL_SUFFIXES:
274
+ return True
275
+ return False
276
+
277
+
278
+ # ---- File-specific extractors ------------------------------------------------
279
+
280
+
281
+ def _dockerfile_stages(content: str, from_matches: list[re.Match]) -> list[dict]:
282
+ """Split a Dockerfile at each FROM into stages.
283
+
284
+ Each stage records its base image, alias, the offset of its FROM, its
285
+ body text (up to the next FROM) and that body's offset in ``content``.
286
+ """
287
+ stages = []
288
+ for index, match in enumerate(from_matches):
289
+ body_end = from_matches[index + 1].start() if index + 1 < len(from_matches) else len(content)
290
+ stages.append(
291
+ {
292
+ "image": match.group("image"),
293
+ "alias": (match.group("alias") or "").lower() or None,
294
+ "offset": _at(match),
295
+ "body": content[match.end():body_end],
296
+ "body_offset": match.end(),
297
+ }
298
+ )
299
+ return stages
300
+
301
+
302
+ def _effective_stage_directive(stages: list[dict], index: int, pattern: re.Pattern) -> tuple[re.Match, int] | None:
303
+ """Last ``pattern`` match that applies to stage ``index``.
304
+
305
+ A stage built `FROM <earlier-stage-alias>` inherits that stage's USER and
306
+ HEALTHCHECK, so walk back through aliases. Returns (match, body_offset).
307
+ """
308
+ seen: set[int] = set()
309
+ while index not in seen:
310
+ seen.add(index)
311
+ stage = stages[index]
312
+ matches = list(pattern.finditer(stage["body"]))
313
+ if matches:
314
+ return matches[-1], stage["body_offset"]
315
+ image = stage["image"].lower()
316
+ parent = next(
317
+ (i for i in range(index - 1, -1, -1) if stages[i]["alias"] == image),
318
+ None,
319
+ )
320
+ if parent is None:
321
+ return None
322
+ index = parent
323
+ return None
324
+
325
+
326
+ def _analyze_dockerfile(content: str, relative: str, result: ScanResult) -> None:
327
+ from_matches = list(_DF_FROM.finditer(content))
328
+ stages = _dockerfile_stages(content, from_matches)
329
+ # Absence signals apply to the final build stage: anchor them at its FROM.
330
+ final_stage = stages[-1]["offset"] if stages else None
331
+
332
+ # USER: only the final stage's effective user matters. A `USER nobody`
333
+ # in a builder stage says nothing about the image that ships.
334
+ if stages:
335
+ effective_user = _effective_stage_directive(stages, len(stages) - 1, _DF_USER)
336
+ else:
337
+ user_matches = list(_DF_USER.finditer(content))
338
+ effective_user = (user_matches[-1], 0) if user_matches else None
339
+ final_image = stages[-1]["image"].lower() if stages else ""
340
+ if effective_user is None:
341
+ # A `:nonroot` / `:nonroot-…` distroless-style base sets its own user.
342
+ if not re.search(r":nonroot\b", final_image):
343
+ _misconfig(
344
+ result, "container_runs_as_root", relative, content, final_stage,
345
+ "no USER directive in the final stage: the container runs as root"
346
+ if len(stages) > 1
347
+ else "no USER directive: the container runs as root",
348
+ severity="low", confidence=0.6, platform="docker",
349
+ escalation="a compromise of the process is root inside the container",
350
+ legacy_auth_hint="dockerfile_no_user_directive",
351
+ )
352
+ else:
353
+ match, base = effective_user
354
+ user = match.group("user").strip("'\"").split(":", 1)[0]
355
+ if user.lower() in {"root", "0"}:
356
+ _misconfig(
357
+ result, "container_runs_as_root", relative, content, base + _at(match), None,
358
+ severity="medium", confidence=0.9, platform="docker",
359
+ escalation="a compromise of the process is root inside the container",
360
+ legacy_auth_hint="dockerfile_user_root",
361
+ )
362
+
363
+ # EXPOSE — each exposed port is a route-like entry point.
364
+ for match in _DF_EXPOSE.finditer(content):
365
+ ports = re.findall(r"\d+", match.group("ports"))
366
+ for port in ports:
367
+ _append_route(result, f"container:{port}", "EXPOSE", relative, line=line_of(content, _at(match)))
368
+
369
+ # HEALTHCHECK absence signals thin operational monitoring (final stage).
370
+ has_healthcheck = (
371
+ _effective_stage_directive(stages, len(stages) - 1, _DF_HEALTHCHECK) is not None
372
+ if stages
373
+ else bool(_DF_HEALTHCHECK.search(content))
374
+ )
375
+ if not has_healthcheck:
376
+ _append_hint(
377
+ result.framework_hints, FrameworkHint, "dockerfile_no_healthcheck", relative, content, final_stage,
378
+ evidence="no HEALTHCHECK directive", confidence=0.6,
379
+ )
380
+
381
+ # RUN curl|bash — fetching + executing remote content in the image build.
382
+ for match in _DF_RUN_CURL_PIPE.finditer(content):
383
+ _append_external(result, "dockerfile:curl-pipe-shell", relative, content, _at(match))
384
+ _append_hint(
385
+ result.framework_hints, FrameworkHint, "dockerfile_run_curl_pipe", relative, content, _at(match),
386
+ confidence=0.9,
387
+ )
388
+ _misconfig(
389
+ result, "dockerfile_curl_pipe_shell", relative, content, _at(match), None,
390
+ severity="medium", confidence=0.9, platform="docker",
391
+ escalation="whoever controls the URL (or the network path to it) runs code in the image build",
392
+ )
393
+
394
+ # ADD https://... — remote fetch during build, no integrity check unless
395
+ # `--checksum=` is given.
396
+ for match in _DF_ADD_REMOTE.finditer(content):
397
+ _append_hint(
398
+ result.framework_hints, FrameworkHint, "dockerfile_add_remote", relative, content, _at(match),
399
+ confidence=0.9,
400
+ )
401
+ if "--checksum=" not in match.group("flags").lower():
402
+ _misconfig(
403
+ result, "dockerfile_remote_add", relative, content, _at(match), None,
404
+ severity="medium", confidence=0.9, platform="docker",
405
+ escalation="whoever controls the URL changes a file baked into the image",
406
+ )
407
+
408
+ # FROM image tag vs SHA pinning. `scratch` is not an image and
409
+ # `FROM <earlier-stage-alias>` refers to a stage in this file. One hint
410
+ # per image: the image is part of the hint so they don't dedup together.
411
+ aliases: set[str] = set()
412
+ for stage in stages:
413
+ image = stage["image"]
414
+ if image.lower() != "scratch" and image.lower() not in aliases and "@sha256:" not in image:
415
+ _append_hint(
416
+ result.framework_hints, FrameworkHint, f"dockerfile_base_image_unpinned:{image}", relative, content,
417
+ stage["offset"], evidence=image, confidence=0.8,
418
+ )
419
+ # `:latest`, or no tag at all (which means latest). Build-arg
420
+ # images (`FROM ${BASE}`) are not judged.
421
+ tag = _IMAGE_TAG.search(image.rsplit("/", 1)[-1])
422
+ if "$" not in image and (tag is None or tag.group(1).lower() == "latest"):
423
+ _misconfig(
424
+ result, "unpinned_container_image", relative, content, stage["offset"], f"FROM {image}",
425
+ severity="low", confidence=0.8, platform="docker",
426
+ escalation="whoever can push the tag changes the base of every rebuild",
427
+ )
428
+ if stage["alias"]:
429
+ aliases.add(stage["alias"])
430
+
431
+ # ENV / ARG with a secret-shaped name and a literal value is baked into
432
+ # the image config / build history.
433
+ for name, value, offset in _dockerfile_env_assignments(content):
434
+ if _is_literal_secret(name, value):
435
+ _append_secret(result, name=name, file=relative, content=content, offset=offset, kind="hardcoded")
436
+
437
+
438
+ _DF_ENV_ARG = re.compile(r"^[ \t]*(?P<kw>ENV|ARG)[ \t]+(?P<rest>(?:[^\n]*\\\n)*[^\n]*)", re.IGNORECASE | re.MULTILINE)
439
+ _DF_KV = re.compile(r"""(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>"(?:[^"\\]|\\.)*"|'[^']*'|\S*)""")
440
+
441
+
442
+ def _dockerfile_env_assignments(content: str):
443
+ """Yield (name, value, offset) for each ENV/ARG assignment.
444
+
445
+ Handles `ENV A=1 B=2`, the legacy `ENV NAME value` form, `ARG NAME=default`
446
+ and backslash line continuations. `ARG NAME` without a default yields
447
+ nothing.
448
+ """
449
+ for match in _DF_ENV_ARG.finditer(content):
450
+ rest = match.group("rest")
451
+ rest_offset = match.start("rest")
452
+ parts = rest.strip().split(None, 1)
453
+ if not parts:
454
+ continue
455
+ if "=" not in parts[0]:
456
+ # Legacy `ENV NAME value` (ARG always uses `=` for a default).
457
+ if match.group("kw").upper() == "ENV" and len(parts) == 2:
458
+ yield parts[0], parts[1].strip(), rest_offset + rest.find(parts[0])
459
+ continue
460
+ for kv in _DF_KV.finditer(rest):
461
+ yield kv.group("key"), kv.group("value"), rest_offset + kv.start()
462
+
463
+
464
+ def _is_literal_secret(name: str, value: str) -> bool:
465
+ if not _SECRET_NAME.search(name):
466
+ return False
467
+ upper = name.upper()
468
+ if upper.endswith("_FILE") or "PUBLIC" in upper:
469
+ return False
470
+ value = value.strip().strip("'\"").strip()
471
+ if value.lower() in _NON_SECRET_VALUES:
472
+ return False
473
+ # `${VAR}` / `$VAR` is a reference resolved at build or run time.
474
+ return not value.startswith("$")
475
+
476
+
477
+ def _analyze_compose(content: str, relative: str, result: ScanResult) -> None:
478
+ # Find the services block; only match keys under that block as service names.
479
+ services_start = _COMPOSE_SERVICES_HEADER.search(content)
480
+ if services_start:
481
+ services_block = content[services_start.end():]
482
+ top_indent = None
483
+ for match in _COMPOSE_SERVICE_NAME.finditer(services_block):
484
+ indent = len(match.group("indent"))
485
+ if top_indent is None:
486
+ top_indent = indent
487
+ if indent != top_indent:
488
+ continue # nested (env vars, labels, etc.) — not a service
489
+ name = match.group("name")
490
+ _append_hint(
491
+ result.service_hints, ServiceHint, f"service_name:{name}", relative, content,
492
+ services_start.end() + match.start(), confidence=0.9,
493
+ )
494
+
495
+ # Port bindings, read from `ports:` blocks only. Short syntax without a
496
+ # host IP (`"5432:5432"`, or a bare `"3000"`) publishes on every
497
+ # interface, exactly like an explicit `0.0.0.0`. One hint per binding:
498
+ # the binding is part of the hint, since core merges hints by (hint, file).
499
+ for binding, offset in _compose_port_bindings(content):
500
+ host_ip, container = _parse_port_binding(binding)
501
+ if container is None:
502
+ continue
503
+ if host_ip in {"", "0.0.0.0", "::"}:
504
+ _append_hint(
505
+ result.entrypoint_hints, EntrypointHint, f"compose_port_binding_all_interfaces:{binding}", relative,
506
+ content, offset, evidence=binding, confidence=0.9,
507
+ )
508
+ _append_route(result, f"container:{container}", "EXPOSE", relative, line=line_of(content, offset))
509
+
510
+ # Literal secrets in `environment:` (map or list form).
511
+ for name, value, offset in _compose_environment(content):
512
+ if _is_literal_secret(name, value):
513
+ _append_secret(result, name=name, file=relative, content=content, offset=offset, kind="hardcoded")
514
+
515
+ # Host-root-equivalent settings: Docker socket mounts, SYS_ADMIN/ALL
516
+ # capabilities, the host PID namespace, seccomp disabled.
517
+ services = _compose_services(content)
518
+
519
+ def record(kind: str, offset: int, severity: str, escalation: str, legacy: str | None = None) -> None:
520
+ line_start = content.rfind("\n", 0, offset) + 1
521
+ if "#" in content[line_start:offset]:
522
+ return # commented out
523
+ _misconfig(
524
+ result, kind, relative, content, offset, None, severity=severity, confidence=0.9, platform="compose",
525
+ resource=_service_at(services, offset), escalation=escalation, legacy_auth_hint=legacy,
526
+ )
527
+
528
+ for sock in _COMPOSE_DOCKER_SOCKET.finditer(content):
529
+ line_start = content.rfind("\n", 0, sock.start("sock")) + 1
530
+ if "target:" in content[line_start:sock.start("sock")]:
531
+ continue # long syntax: the container side of the mount
532
+ record("host_path_mount", sock.start("sock"), "high", host_path_risk(sock.group("sock"))[1],
533
+ "compose_docker_socket_mount")
534
+ for cap, offset in _compose_cap_add(content):
535
+ severity = capability_severity(cap)
536
+ if severity:
537
+ name = cap.upper().removeprefix("CAP_")
538
+ record("dangerous_capability", offset, severity,
539
+ f"CAP_{name} → kernel interfaces that allow a container escape" if severity == "high"
540
+ else f"CAP_{name} → control of the host's or container's network stack",
541
+ "compose_cap_add_sys_admin" if name in {"SYS_ADMIN", "ALL"} else None)
542
+ for pid_host in _COMPOSE_PID_HOST.finditer(content):
543
+ record("host_namespace_shared", _at(pid_host), "high",
544
+ "host PID namespace → see and signal host processes and read their environment", "compose_pid_host")
545
+ for ipc_host in _COMPOSE_IPC_HOST.finditer(content):
546
+ record("host_namespace_shared", _at(ipc_host), "medium", "host IPC namespace → read host processes' shared memory")
547
+ for profile in (*_COMPOSE_SECCOMP_UNCONFINED.finditer(content), *_COMPOSE_APPARMOR_UNCONFINED.finditer(content)):
548
+ record("security_profile_disabled", profile.start(), "medium",
549
+ "no syscall/LSM filter: kernel exploits and escape techniques aren't blocked",
550
+ "compose_seccomp_unconfined" if profile.group().lower().startswith("seccomp") else None)
551
+
552
+ for privileged in _COMPOSE_PRIVILEGED.finditer(content):
553
+ record("privileged_container", _at(privileged), "high",
554
+ "privileged container → every device and capability of the host → root on the host",
555
+ "compose_privileged_container")
556
+ network_host = _COMPOSE_NETWORK_HOST.search(content)
557
+ if network_host:
558
+ _append_hint(
559
+ result.entrypoint_hints, EntrypointHint, "compose_network_mode_host", relative, content,
560
+ _at(network_host), confidence=0.9,
561
+ )
562
+ for network_host in _COMPOSE_NETWORK_HOST.finditer(content):
563
+ record("host_namespace_shared", _at(network_host), "medium",
564
+ "host network namespace → reach host-local services and bind any host port")
565
+
566
+ for match in _COMPOSE_ENV_FILE.finditer(content):
567
+ inline = (match.group("inline") or "").strip()
568
+ listed = match.group("listed") or ""
569
+ # Pull filenames from either the inline value or the list block.
570
+ env_files = []
571
+ if inline and not inline.startswith("-"):
572
+ env_files.append(inline.strip("'\""))
573
+ for m in re.finditer(r"-\s+(?P<f>[^\s\n]+)", listed):
574
+ env_files.append(m.group("f").strip("'\""))
575
+ for env_file in env_files or ["(unnamed)"]:
576
+ _append_hint(
577
+ result.framework_hints, FrameworkHint, "compose_env_file_reference", relative, content,
578
+ _at(match), evidence=env_file, confidence=0.8,
579
+ )
580
+
581
+ # Host-mounted volumes — anything starting with `/` is a bind mount from
582
+ # the host filesystem into the container. Highly variable in scope; we
583
+ # emit a per-mount hint so each shows up in the report — dedup at the
584
+ # (hint, file) level would collapse them to one otherwise.
585
+ for line_match in re.finditer(r"^\s+-\s*(?P<mount>['\"]?/[^:\n]+:/[^\s]+)", content, re.MULTILINE):
586
+ mount = line_match.group("mount").strip("'\"")
587
+ # Encode the source path into the hint so different mounts don't
588
+ # dedup against each other. Downstream findings can key off the
589
+ # `compose_host_mount:` prefix.
590
+ _append_hint(
591
+ result.framework_hints, FrameworkHint, f"compose_host_mount:{mount.split(':')[0]}", relative, content,
592
+ _at(line_match), evidence=mount, confidence=0.9,
593
+ )
594
+ # Sensitive host paths only (the runtime socket is reported above):
595
+ # bind-mounting /srv/data or /var/log is ordinary self-hosting.
596
+ severity, escalation = host_path_risk(mount.split(":")[0])
597
+ if severity == "high" and not _COMPOSE_DOCKER_SOCKET.search(mount):
598
+ record("host_path_mount", _at(line_match), severity, escalation)
599
+
600
+
601
+ def _yaml_block(content: str, key: str):
602
+ """Yield (key_match, block_text, block_offset) for each `key:` mapping.
603
+
604
+ The block is every following line indented deeper than the key (blank
605
+ and comment lines included). Inline values (`key: [..]`) give an empty
606
+ block; the caller reads them from ``key_match``.
607
+ """
608
+ pattern = re.compile(rf"^(?P<indent>[ \t]*){re.escape(key)}:[ \t]*(?P<inline>[^\n#]*)", re.MULTILINE)
609
+ for match in pattern.finditer(content):
610
+ indent = len(match.group("indent"))
611
+ pos = match.end()
612
+ newline = content.find("\n", pos)
613
+ start = len(content) if newline == -1 else newline + 1
614
+ end = start
615
+ while end < len(content):
616
+ line_end = content.find("\n", end)
617
+ line_end = len(content) if line_end == -1 else line_end
618
+ line = content[end:line_end]
619
+ stripped = line.strip()
620
+ if stripped and not stripped.startswith("#") and len(line) - len(line.lstrip()) <= indent:
621
+ break
622
+ end = line_end + 1
623
+ yield match, content[start:min(end, len(content))], start
624
+
625
+
626
+ def _compose_port_bindings(content: str):
627
+ """Yield (binding, offset) for each published port in `ports:` blocks."""
628
+ for key, block, base in _yaml_block(content, "ports"):
629
+ inline = key.group("inline").strip()
630
+ if inline.startswith("["):
631
+ for item in re.finditer(r"[^,\[\]\s][^,\[\]]*", inline):
632
+ yield item.group().strip().strip("'\""), key.start("inline") + item.start()
633
+ continue
634
+ # Long syntax: `- target: 80` / `published: 8080` / `host_ip: ...`.
635
+ for item in re.finditer(r"^[ \t]*-[ \t]*(?P<entry>[^\n]*(?:\n(?![ \t]*-)[^\n]*)*)", block, re.MULTILINE):
636
+ entry = item.group("entry")
637
+ offset = base + item.start("entry")
638
+ if re.match(r"(?:target|published|host_ip|protocol|mode)\s*:", entry.strip()):
639
+ fields = dict(re.findall(r"(target|published|host_ip)\s*:\s*['\"]?([^'\"\s#]+)", entry))
640
+ if "published" not in fields:
641
+ continue # not published on the host
642
+ host_ip = fields.get("host_ip", "")
643
+ prefix = f"{host_ip}:" if host_ip else ""
644
+ yield f"{prefix}{fields['published']}:{fields.get('target', '')}", offset
645
+ continue
646
+ value = entry.split("#", 1)[0].strip().strip("'\"")
647
+ if value:
648
+ yield value, offset
649
+
650
+
651
+ def _parse_port_binding(binding: str) -> tuple[str, str | None]:
652
+ """Split a short-syntax binding into (host_ip, container_port).
653
+
654
+ `"5432:5432"` / `"3000"` → ("", ...): no IP means every interface.
655
+ `"127.0.0.1:5432:5432"` → ("127.0.0.1", "5432"); `"[::1]:80:80"` → ("::1", "80").
656
+ """
657
+ value = binding.strip().strip("'\"").split("/", 1)[0]
658
+ host_ip = ""
659
+ if value.startswith("["):
660
+ close = value.find("]")
661
+ host_ip, value = value[1:close], value[close + 2:]
662
+ parts = value.split(":")
663
+ else:
664
+ parts = value.split(":")
665
+ if len(parts) >= 3:
666
+ host_ip, parts = ":".join(parts[:-2]), parts[-2:]
667
+ container = parts[-1] if parts and re.fullmatch(r"[0-9]+(?:-[0-9]+)?", parts[-1] or "") else None
668
+ if host_ip.lower() == "localhost" or host_ip.startswith("127.") or host_ip == "::1":
669
+ host_ip = "loopback"
670
+ return host_ip, container
671
+
672
+
673
+ def _compose_environment(content: str):
674
+ """Yield (name, value, offset) from `environment:` blocks (map or list)."""
675
+ for _key, block, base in _yaml_block(content, "environment"):
676
+ for item in re.finditer(
677
+ r"^[ \t]*(?:-[ \t]*['\"]?(?P<lname>[A-Za-z_][A-Za-z0-9_]*)=(?P<lvalue>[^\n]*?)['\"]?[ \t]*$"
678
+ r"|(?P<mname>[A-Za-z_][A-Za-z0-9_]*):[ \t]*(?P<mvalue>[^\n#]*))",
679
+ block,
680
+ re.MULTILINE,
681
+ ):
682
+ name = item.group("lname") or item.group("mname")
683
+ value = item.group("lvalue") if item.group("lname") else item.group("mvalue")
684
+ yield name, (value or "").strip(), base + item.start("lname" if item.group("lname") else "mname")
685
+
686
+
687
+ def _compose_cap_add(content: str):
688
+ """Yield (capability, offset) from `cap_add:` (inline or list form)."""
689
+ for key, block, base in _yaml_block(content, "cap_add"):
690
+ inline = key.group("inline").strip()
691
+ if inline.startswith("["):
692
+ for cap in re.finditer(r"[A-Za-z_]+", inline):
693
+ yield cap.group(), key.start("inline") + cap.start()
694
+ continue
695
+ for item in re.finditer(r"^[ \t]*-[ \t]*['\"]?(?P<cap>[A-Za-z_]+)", block, re.MULTILINE):
696
+ yield item.group("cap"), base + item.start("cap")
697
+
698
+
699
+ def _analyze_gha_workflow(content: str, relative: str, result: ScanResult) -> None:
700
+ pr_target = _GHA_PR_TARGET.search(content)
701
+ has_checkout = bool(_GHA_CHECKOUT.search(content))
702
+ if pr_target and has_checkout:
703
+ _append_hint(
704
+ result.framework_hints, FrameworkHint, "gha_pull_request_target_with_checkout", relative, content,
705
+ _at(pr_target), confidence=0.8,
706
+ )
707
+
708
+ for match in _GHA_USES.finditer(content):
709
+ repo = match.group("repo")
710
+ ref = match.group("ref").strip("'\"")
711
+ # actions/* and github/* are first-party; skip the pin check for them
712
+ # since they're widely trusted. Third parties get flagged when tag-pinned.
713
+ owner = repo.split("/", 1)[0].lower()
714
+ if owner in {"actions", "github"}:
715
+ continue
716
+ if not _SHA40.match(ref):
717
+ # Encode the repo into the hint so multiple unpinned actions
718
+ # in one workflow file don't dedup against each other.
719
+ _append_hint(
720
+ result.framework_hints, FrameworkHint, f"gha_third_party_action_tag_pinned:{repo}", relative,
721
+ content, _at(match), evidence=f"{repo}@{ref}", confidence=0.9,
722
+ )
723
+
724
+ if not _GHA_PERMISSIONS.search(content):
725
+ # Absent block: anchor at the top of the workflow.
726
+ _append_hint(
727
+ result.framework_hints, FrameworkHint, "gha_no_top_level_permissions", relative, content, None,
728
+ evidence="no top-level permissions: block (GITHUB_TOKEN gets the repo default scope)",
729
+ confidence=0.6,
730
+ )
731
+
732
+ # Every ${{ secrets.X }} reference is an outbound trust — feeds
733
+ # into the secret inventory context.
734
+ for match in _GHA_SECRETS_REF.finditer(content):
735
+ secret_name = re.search(r"secrets\.([A-Z0-9_]+)", match.group()).group(1)
736
+ _append_secret(
737
+ result,
738
+ name=secret_name,
739
+ file=relative,
740
+ content=content,
741
+ offset=_at(match),
742
+ kind="env_reference",
743
+ )
744
+
745
+
746
+ def _analyze_env_template(content: str, relative: str, result: ScanResult) -> None:
747
+ """`.env.example` and friends define the shape of the runtime secret
748
+ inventory. Every KEY=... entry becomes a `SecretHint` with `kind`
749
+ set to `env_template` so downstream consumers know the value is a
750
+ template placeholder, not a real secret."""
751
+ for match in _ENV_KEY.finditer(content):
752
+ key = match.group("key")
753
+ _append_secret(
754
+ result,
755
+ name=key,
756
+ file=relative,
757
+ content=content,
758
+ # `^\s*` can swallow blank lines before the key; point at the key.
759
+ offset=match.start("key"),
760
+ kind="env_template",
761
+ )
762
+
763
+
764
+ def _analyze_shell_installer(content: str, relative: str, result: ScanResult) -> None:
765
+ for match in _SH_CURL_PIPE.finditer(content):
766
+ _append_external(result, "shell:curl-pipe-shell", relative, content, _at(match))
767
+ _append_hint(
768
+ result.framework_hints, FrameworkHint, "shell_curl_pipe_installer", relative, content, _at(match),
769
+ confidence=0.9,
770
+ )
771
+
772
+ sudo = _SH_SUDO.search(content)
773
+ if sudo:
774
+ _append_hint(
775
+ result.framework_hints, FrameworkHint, "shell_sudo_used", relative, content, _at(sudo),
776
+ confidence=0.8,
777
+ )
778
+
779
+ for match in _SH_CHMOD_777.finditer(content):
780
+ _append_hint(
781
+ result.framework_hints, FrameworkHint, "shell_permissive_chmod", relative, content, _at(match),
782
+ evidence=match.group(), confidence=0.9,
783
+ )
784
+
785
+
786
+ # ---- Small append helpers with (file, key) dedup ----------------------------
787
+
788
+
789
+ def _at(match: re.Match) -> int:
790
+ """Offset of the first non-blank character of ``match``.
791
+
792
+ Most patterns here start with ``^\\s*`` in MULTILINE mode, which also
793
+ swallows preceding blank lines, so ``match.start()`` can sit one or more
794
+ lines above the directive it matched.
795
+ """
796
+ text = match.group()
797
+ return match.start() + (len(text) - len(text.lstrip()))
798
+
799
+
800
+ def _misconfig(
801
+ result: ScanResult,
802
+ kind: str,
803
+ file: str,
804
+ content: str,
805
+ offset: int | None,
806
+ evidence: str | None,
807
+ *,
808
+ severity: str,
809
+ confidence: float,
810
+ platform: str,
811
+ escalation: str,
812
+ resource: str | None = None,
813
+ legacy_auth_hint: str | None = None,
814
+ ) -> None:
815
+ """A `CloudMisconfig` at ``offset`` (``None``: line 1, for something
816
+ missing from the file); ``evidence`` defaults to the source line."""
817
+ line = line_of(content, offset) if offset is not None else 1
818
+ append_misconfig(
819
+ result, kind, file, line, evidence or line_snippet(content, line) or kind,
820
+ severity=severity, confidence=confidence, resource=resource, platform=platform,
821
+ escalation=escalation, legacy_auth_hint=legacy_auth_hint,
822
+ )
823
+
824
+
825
+ def _compose_services(content: str) -> list[tuple[int, str]]:
826
+ """``(offset, name)`` of each top-level service under ``services:``."""
827
+ header = _COMPOSE_SERVICES_HEADER.search(content)
828
+ if header is None:
829
+ return []
830
+ found: list[tuple[int, str]] = []
831
+ top_indent = None
832
+ for match in _COMPOSE_SERVICE_NAME.finditer(content, header.end()):
833
+ indent = len(match.group("indent"))
834
+ if top_indent is None:
835
+ top_indent = indent
836
+ if indent == top_indent:
837
+ found.append((match.start("name"), match.group("name")))
838
+ return found
839
+
840
+
841
+ def _service_at(services: list[tuple[int, str]], offset: int) -> str | None:
842
+ name = None
843
+ for start, service in services:
844
+ if start > offset:
845
+ break
846
+ name = service
847
+ return f"service {name}" if name else None
848
+
849
+
850
+ def _append_route(
851
+ result: ScanResult, path: str, method: str, file: str, line: int | None = None
852
+ ) -> None:
853
+ key = (path, method, file)
854
+ if not first_seen(result, ("route", *key)):
855
+ return
856
+ result.routes.append(Route(path=path, method=method, file=file, line=line))
857
+
858
+
859
+ def _append_external(result: ScanResult, target: str, file: str, content: str, offset: int) -> None:
860
+ key = (target, file)
861
+ if not first_seen(result, ("external", *key)):
862
+ return
863
+ line = line_of(content, offset)
864
+ result.external_calls.append(
865
+ ExternalCall(target=target, file=file, line=line, evidence_text=line_snippet(content, line) or target)
866
+ )
867
+
868
+
869
+ def _append_hint(
870
+ bucket: list,
871
+ model: type,
872
+ hint: str,
873
+ file: str,
874
+ content: str,
875
+ offset: int | None,
876
+ *,
877
+ evidence: str | None = None,
878
+ confidence: float | None = None,
879
+ ) -> None:
880
+ """Append an Auth/Service/Entrypoint/Framework hint once per (hint, file).
881
+
882
+ ``offset`` locates the hint (``None`` anchors it at line 1, for signals
883
+ about something *missing* from the file). ``evidence`` overrides the
884
+ quoted source line when a more specific value (an image, a mount, an
885
+ ``owner/repo@ref``) identifies the signal better.
886
+ """
887
+ if not first_seen(bucket, (hint, file)):
888
+ return
889
+ line = line_of(content, offset) if offset is not None else 1
890
+ evidence_text = evidence or line_snippet(content, line) or hint
891
+ extra = {"confidence": confidence} if confidence is not None else {}
892
+ bucket.append(model(hint=hint, file=file, line=line, evidence_text=evidence_text, **extra))
893
+
894
+
895
+ def _append_secret(
896
+ result: ScanResult, name: str, file: str, content: str, offset: int, kind: str = "env_reference"
897
+ ) -> None:
898
+ line = line_of(content, offset)
899
+ key = (name, file, line)
900
+ if not first_seen(result, ("secret", *key)):
901
+ return
902
+ result.secret_hints.append(
903
+ SecretHint(name=name, file=file, line=line, evidence_text=line_snippet(content, line) or name, kind=kind)
904
+ )