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,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
|
+
)
|