attackmap-analyzer-iac 0.1.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.

Potentially problematic release.


This version of attackmap-analyzer-iac might be problematic. Click here for more details.

@@ -0,0 +1,3 @@
1
+ from .analyzer import IacAnalyzer
2
+
3
+ __all__ = ["IacAnalyzer"]
@@ -0,0 +1,452 @@
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, and shell installers. All extraction is regex-based —
6
+ intentionally shallow and dependency-free (no yaml/toml parser needed).
7
+
8
+ See the Bluesky FINDINGS §2 for the motivating gap: `bluesky-social/pds`
9
+ was 95% invisible to AttackMap because none of these file types had
10
+ analyzer coverage.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from .contracts import (
19
+ AnalyzerMetadata,
20
+ AuthHint,
21
+ ExternalCall,
22
+ Route,
23
+ ScanResult,
24
+ SecretHint,
25
+ ServiceHint,
26
+ )
27
+
28
+
29
+ _SKIP_DIRS = frozenset(
30
+ {"node_modules", ".git", ".venv", "venv", "dist", "build", ".turbo", "out", "target"}
31
+ )
32
+
33
+
34
+ # ---- Dockerfile patterns -----------------------------------------------------
35
+
36
+ DOCKERFILE_NAMES = {"Dockerfile", "dockerfile", "Containerfile"}
37
+
38
+ _DF_FROM = re.compile(r"^\s*FROM\s+(?P<image>[\S]+)(?:\s+AS\s+(?P<alias>\S+))?", re.IGNORECASE | re.MULTILINE)
39
+ _DF_USER = re.compile(r"^\s*USER\s+(?P<user>\S+)", re.IGNORECASE | re.MULTILINE)
40
+ _DF_EXPOSE = re.compile(r"^\s*EXPOSE\s+(?P<ports>[0-9\s/tcpud]+)", re.IGNORECASE | re.MULTILINE)
41
+ _DF_HEALTHCHECK = re.compile(r"^\s*HEALTHCHECK\s+", re.IGNORECASE | re.MULTILINE)
42
+ _DF_RUN_CURL_PIPE = re.compile(
43
+ r"^\s*RUN\s+.*?(?:curl|wget)\s+[^|;\n]*\s*[|;]\s*(?:bash|sh|zsh|python)",
44
+ re.IGNORECASE | re.MULTILINE,
45
+ )
46
+ _DF_COPY_CHOWN = re.compile(r"^\s*COPY\s+--chown=", re.IGNORECASE | re.MULTILINE)
47
+ _DF_ADD_REMOTE = re.compile(r"^\s*ADD\s+https?://", re.IGNORECASE | re.MULTILINE)
48
+
49
+
50
+ # ---- docker-compose patterns -------------------------------------------------
51
+
52
+ COMPOSE_NAMES = {"docker-compose.yaml", "docker-compose.yml", "compose.yaml", "compose.yml"}
53
+
54
+ # service block: `services:\n <name>:` — we grab each `<name>:` at
55
+ # the second indent level. Cheap; doesn't need a real YAML parser.
56
+ _COMPOSE_SERVICE_NAME = re.compile(
57
+ r"^(?P<indent>[ \t]{2,4})(?P<name>[a-z0-9_-]+):\s*$",
58
+ re.MULTILINE,
59
+ )
60
+ _COMPOSE_SERVICES_HEADER = re.compile(r"^services:\s*$", re.MULTILINE)
61
+ _COMPOSE_IMAGE = re.compile(r"^\s{2,}image:\s*['\"]?(?P<image>[^\s'\"#]+)", re.MULTILINE)
62
+ _COMPOSE_PORT_BINDING = re.compile(
63
+ r"['\"]?(?P<host>0\.0\.0\.0|[0-9]+):(?P<container>[0-9]+)(?:/(?:tcp|udp))?['\"]?",
64
+ re.IGNORECASE,
65
+ )
66
+ _COMPOSE_HOST_MOUNT = re.compile(r"['\"]?(?P<host_path>/[^:\s]*):[^:\s]+['\"]?", re.MULTILINE)
67
+ _COMPOSE_PRIVILEGED = re.compile(r"^\s+privileged:\s*true", re.IGNORECASE | re.MULTILINE)
68
+ _COMPOSE_NETWORK_HOST = re.compile(r"^\s+network_mode:\s*['\"]?host['\"]?", re.IGNORECASE | re.MULTILINE)
69
+ # Matches both `env_file: pds.env` (inline) and the list form:
70
+ # env_file:
71
+ # - pds.env
72
+ # The `following` group captures either the inline value or the entire
73
+ # list block; we normalize downstream to pull the filename(s) out.
74
+ _COMPOSE_ENV_FILE = re.compile(
75
+ # Use [ \t]* (not \s*) so we don't accidentally jump over the newline
76
+ # and treat the first list-item as the inline value.
77
+ r"^\s+env_file:[ \t]*(?P<inline>[^\s#\n][^\n]*)?\n(?P<listed>(?:\s+-\s+[^\n]+\n)*)",
78
+ re.IGNORECASE | re.MULTILINE,
79
+ )
80
+
81
+
82
+ # ---- GitHub Actions patterns -------------------------------------------------
83
+
84
+ _GHA_PATH = re.compile(r"\.github/workflows/[^/]+\.ya?ml$", re.IGNORECASE)
85
+ _GHA_PR_TARGET = re.compile(r"^\s*pull_request_target\s*:", re.IGNORECASE | re.MULTILINE)
86
+ _GHA_CHECKOUT = re.compile(r"uses:\s+actions/checkout@", re.IGNORECASE)
87
+ # `uses: owner/repo@ref` where ref is not a 40-char SHA
88
+ _GHA_USES = re.compile(
89
+ r"uses:\s+(?P<repo>[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+)@(?P<ref>\S+)",
90
+ re.IGNORECASE,
91
+ )
92
+ _SHA40 = re.compile(r"^[a-f0-9]{40}$", re.IGNORECASE)
93
+ _GHA_SECRETS_REF = re.compile(r"\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}", re.IGNORECASE)
94
+ _GHA_PERMISSIONS = re.compile(r"^\s*permissions\s*:", re.IGNORECASE | re.MULTILINE)
95
+
96
+
97
+ # ---- .env template names -----------------------------------------------------
98
+
99
+ ENV_TEMPLATE_NAMES = {".env.example", ".env.sample", ".env.template", "sample.env"}
100
+ _ENV_KEY = re.compile(r"^\s*(?P<key>[A-Z][A-Z0-9_]*)\s*=", re.MULTILINE)
101
+
102
+
103
+ # ---- Shell installer patterns ------------------------------------------------
104
+
105
+ SHELL_SUFFIXES = {".sh", ".bash", ".zsh"}
106
+ _SH_CURL_PIPE = re.compile(
107
+ r"(?:curl|wget)\s+[^|;\n]*\s*[|;]\s*(?:bash|sh|zsh|python)",
108
+ re.IGNORECASE,
109
+ )
110
+ _SH_SUDO = re.compile(r"^\s*sudo\s+", re.MULTILINE)
111
+ _SH_CHMOD_777 = re.compile(r"\bchmod\s+(?:0?7[0-7]{2}|a\+w)\b")
112
+
113
+
114
+ class IacAnalyzer:
115
+ metadata = AnalyzerMetadata(
116
+ name="iac",
117
+ display_name="IaC / Deployment-Surface Analyzer",
118
+ version="0.1.0",
119
+ description="Analyzer for infrastructure-as-code and deployment files: Dockerfile, docker-compose, GitHub Actions, .env templates, shell installers.",
120
+ 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.",
121
+ targets=["docker", "docker-compose", "github-actions", "env-template", "shell-installer"],
122
+ languages=[],
123
+ priority=40,
124
+ experimental=False,
125
+ enabled_by_default=True,
126
+ )
127
+
128
+ @property
129
+ def name(self) -> str:
130
+ return self.metadata.name
131
+
132
+ def detect(self, repo_path: str | Path) -> bool:
133
+ repo = Path(repo_path).resolve()
134
+ if not repo.exists() or not repo.is_dir():
135
+ return False
136
+ for candidate in repo.rglob("*"):
137
+ if not candidate.is_file():
138
+ continue
139
+ if any(part in _SKIP_DIRS for part in candidate.parts):
140
+ continue
141
+ if _is_iac_file(candidate):
142
+ return True
143
+ return False
144
+
145
+ def analyze(self, repo_path: str | Path) -> ScanResult:
146
+ repo = Path(repo_path).resolve()
147
+ result = ScanResult(root=str(repo))
148
+ if not repo.exists() or not repo.is_dir():
149
+ return result
150
+
151
+ for path in repo.rglob("*"):
152
+ if not path.is_file():
153
+ continue
154
+ if any(part in _SKIP_DIRS for part in path.parts):
155
+ continue
156
+ if not _is_iac_file(path):
157
+ continue
158
+ content = _read_text(path)
159
+ if content is None:
160
+ continue
161
+ relative = str(path.relative_to(repo)).replace("\\", "/")
162
+ result.files_scanned += 1
163
+ if path.name in DOCKERFILE_NAMES:
164
+ _analyze_dockerfile(content, relative, result)
165
+ elif path.name in COMPOSE_NAMES:
166
+ _analyze_compose(content, relative, result)
167
+ elif _GHA_PATH.search(relative):
168
+ _analyze_gha_workflow(content, relative, result)
169
+ elif path.name in ENV_TEMPLATE_NAMES:
170
+ _analyze_env_template(content, relative, result)
171
+ elif path.suffix in SHELL_SUFFIXES:
172
+ _analyze_shell_installer(content, relative, result)
173
+ return result
174
+
175
+
176
+ def _is_iac_file(path: Path) -> bool:
177
+ if path.name in DOCKERFILE_NAMES:
178
+ return True
179
+ if path.name in COMPOSE_NAMES:
180
+ return True
181
+ if _GHA_PATH.search(str(path).replace("\\", "/")):
182
+ return True
183
+ if path.name in ENV_TEMPLATE_NAMES:
184
+ return True
185
+ if path.suffix in SHELL_SUFFIXES:
186
+ return True
187
+ return False
188
+
189
+
190
+ # ---- File-specific extractors ------------------------------------------------
191
+
192
+
193
+ def _analyze_dockerfile(content: str, relative: str, result: ScanResult) -> None:
194
+ # USER directive absence is a signal: containers default to root.
195
+ if not _DF_USER.search(content):
196
+ _append_auth(result, "dockerfile_no_user_directive", relative)
197
+ else:
198
+ # If USER is present but set to root explicitly, flag it.
199
+ for match in _DF_USER.finditer(content):
200
+ user = match.group("user").strip("'\"")
201
+ if user.lower() in {"root", "0"}:
202
+ _append_auth(result, "dockerfile_user_root", relative, line=_line_of(content, match.start()))
203
+
204
+ # EXPOSE — each exposed port is a route-like entry point.
205
+ for match in _DF_EXPOSE.finditer(content):
206
+ ports = re.findall(r"\d+", match.group("ports"))
207
+ for port in ports:
208
+ _append_route(result, f"container:{port}", "EXPOSE", relative, line=_line_of(content, match.start()))
209
+
210
+ # HEALTHCHECK absence signals thin operational monitoring.
211
+ if not _DF_HEALTHCHECK.search(content):
212
+ _append_auth(result, "dockerfile_no_healthcheck", relative)
213
+
214
+ # RUN curl|bash — fetching + executing remote content in the image build.
215
+ for match in _DF_RUN_CURL_PIPE.finditer(content):
216
+ _append_external(result, "dockerfile:curl-pipe-shell", relative, line=_line_of(content, match.start()))
217
+ _append_auth(result, "dockerfile_run_curl_pipe", relative)
218
+
219
+ # ADD https://... — remote fetch during build, no signature check by default.
220
+ for match in _DF_ADD_REMOTE.finditer(content):
221
+ _append_auth(result, "dockerfile_add_remote", relative, line=_line_of(content, match.start()))
222
+
223
+ # FROM image tag vs SHA pinning.
224
+ for match in _DF_FROM.finditer(content):
225
+ image = match.group("image")
226
+ if "@sha256:" not in image:
227
+ _append_auth(
228
+ result,
229
+ "dockerfile_base_image_unpinned",
230
+ relative,
231
+ line=_line_of(content, match.start()),
232
+ evidence=image,
233
+ )
234
+
235
+
236
+ def _analyze_compose(content: str, relative: str, result: ScanResult) -> None:
237
+ # Find the services block; only match keys under that block as service names.
238
+ services_start = _COMPOSE_SERVICES_HEADER.search(content)
239
+ if services_start:
240
+ services_block = content[services_start.end():]
241
+ top_indent = None
242
+ for match in _COMPOSE_SERVICE_NAME.finditer(services_block):
243
+ indent = len(match.group("indent"))
244
+ if top_indent is None:
245
+ top_indent = indent
246
+ if indent != top_indent:
247
+ continue # nested (env vars, labels, etc.) — not a service
248
+ name = match.group("name")
249
+ _append_service(result, f"service_name:{name}", relative)
250
+
251
+ # Port bindings.
252
+ for line_match in re.finditer(r"^\s*-\s*(?P<binding>[^#\n]+)", content, re.MULTILINE):
253
+ binding = line_match.group("binding").strip()
254
+ port_match = _COMPOSE_PORT_BINDING.search(binding)
255
+ if port_match:
256
+ host, container = port_match.group("host"), port_match.group("container")
257
+ if host == "0.0.0.0":
258
+ _append_auth(
259
+ result,
260
+ "compose_port_binding_all_interfaces",
261
+ relative,
262
+ line=_line_of(content, line_match.start()),
263
+ evidence=binding,
264
+ )
265
+ _append_route(
266
+ result,
267
+ f"container:{container}",
268
+ "EXPOSE",
269
+ relative,
270
+ line=_line_of(content, line_match.start()),
271
+ )
272
+
273
+ if _COMPOSE_PRIVILEGED.search(content):
274
+ _append_auth(result, "compose_privileged_container", relative)
275
+ if _COMPOSE_NETWORK_HOST.search(content):
276
+ _append_auth(result, "compose_network_mode_host", relative)
277
+
278
+ for match in _COMPOSE_ENV_FILE.finditer(content):
279
+ inline = (match.group("inline") or "").strip()
280
+ listed = match.group("listed") or ""
281
+ # Pull filenames from either the inline value or the list block.
282
+ env_files = []
283
+ if inline and not inline.startswith("-"):
284
+ env_files.append(inline.strip("'\""))
285
+ for m in re.finditer(r"-\s+(?P<f>[^\s\n]+)", listed):
286
+ env_files.append(m.group("f").strip("'\""))
287
+ for env_file in env_files or ["(unnamed)"]:
288
+ _append_auth(
289
+ result,
290
+ "compose_env_file_reference",
291
+ relative,
292
+ line=_line_of(content, match.start()),
293
+ evidence=env_file,
294
+ )
295
+
296
+ # Host-mounted volumes — anything starting with `/` is a bind mount from
297
+ # the host filesystem into the container. Highly variable in scope; we
298
+ # emit a per-mount hint so each shows up in the report — dedup at the
299
+ # (hint, file) level would collapse them to one otherwise.
300
+ for line_match in re.finditer(r"^\s+-\s*(?P<mount>['\"]?/[^:\n]+:/[^\s]+)", content, re.MULTILINE):
301
+ mount = line_match.group("mount").strip("'\"")
302
+ # Encode the source path into the hint so different mounts don't
303
+ # dedup against each other. Downstream findings can key off the
304
+ # `compose_host_mount:` prefix.
305
+ _append_auth(
306
+ result,
307
+ f"compose_host_mount:{mount.split(':')[0]}",
308
+ relative,
309
+ line=_line_of(content, line_match.start()),
310
+ evidence=mount,
311
+ )
312
+
313
+
314
+ def _analyze_gha_workflow(content: str, relative: str, result: ScanResult) -> None:
315
+ has_pr_target = bool(_GHA_PR_TARGET.search(content))
316
+ has_checkout = bool(_GHA_CHECKOUT.search(content))
317
+ if has_pr_target and has_checkout:
318
+ _append_auth(result, "gha_pull_request_target_with_checkout", relative)
319
+
320
+ for match in _GHA_USES.finditer(content):
321
+ repo = match.group("repo")
322
+ ref = match.group("ref").strip("'\"")
323
+ # actions/* and github/* are first-party; skip the pin check for them
324
+ # since they're widely trusted. Third parties get flagged when tag-pinned.
325
+ owner = repo.split("/", 1)[0].lower()
326
+ if owner in {"actions", "github"}:
327
+ continue
328
+ if not _SHA40.match(ref):
329
+ # Encode the repo into the hint so multiple unpinned actions
330
+ # in one workflow file don't dedup against each other.
331
+ _append_auth(
332
+ result,
333
+ f"gha_third_party_action_tag_pinned:{repo}",
334
+ relative,
335
+ line=_line_of(content, match.start()),
336
+ evidence=f"{repo}@{ref}",
337
+ )
338
+
339
+ if not _GHA_PERMISSIONS.search(content):
340
+ _append_auth(result, "gha_no_top_level_permissions", relative)
341
+
342
+ # Every ${{ secrets.X }} reference is an outbound trust — feeds
343
+ # into the secret inventory context.
344
+ for match in _GHA_SECRETS_REF.finditer(content):
345
+ secret_name = re.search(r"secrets\.([A-Z0-9_]+)", match.group()).group(1)
346
+ _append_secret(
347
+ result,
348
+ name=secret_name,
349
+ file=relative,
350
+ line=_line_of(content, match.start()),
351
+ kind="env_reference",
352
+ )
353
+
354
+
355
+ def _analyze_env_template(content: str, relative: str, result: ScanResult) -> None:
356
+ """`.env.example` and friends define the shape of the runtime secret
357
+ inventory. Every KEY=... entry becomes a `SecretHint` with `kind`
358
+ set to `env_template` so downstream consumers know the value is a
359
+ template placeholder, not a real secret."""
360
+ for match in _ENV_KEY.finditer(content):
361
+ key = match.group("key")
362
+ _append_secret(
363
+ result,
364
+ name=key,
365
+ file=relative,
366
+ line=_line_of(content, match.start()),
367
+ kind="env_template",
368
+ )
369
+
370
+
371
+ def _analyze_shell_installer(content: str, relative: str, result: ScanResult) -> None:
372
+ for match in _SH_CURL_PIPE.finditer(content):
373
+ _append_external(
374
+ result,
375
+ "shell:curl-pipe-shell",
376
+ relative,
377
+ line=_line_of(content, match.start()),
378
+ )
379
+ _append_auth(result, "shell_curl_pipe_installer", relative)
380
+
381
+ if _SH_SUDO.search(content):
382
+ _append_auth(result, "shell_sudo_used", relative)
383
+
384
+ for match in _SH_CHMOD_777.finditer(content):
385
+ _append_auth(
386
+ result,
387
+ "shell_permissive_chmod",
388
+ relative,
389
+ line=_line_of(content, match.start()),
390
+ evidence=match.group(),
391
+ )
392
+
393
+
394
+ # ---- Small append helpers with (file, key) dedup ----------------------------
395
+
396
+
397
+ def _line_of(content: str, offset: int) -> int:
398
+ return content.count("\n", 0, offset) + 1
399
+
400
+
401
+ def _append_route(
402
+ result: ScanResult, path: str, method: str, file: str, line: int | None = None
403
+ ) -> None:
404
+ key = (path, method, file)
405
+ if any((r.path, r.method, r.file) == key for r in result.routes):
406
+ return
407
+ result.routes.append(Route(path=path, method=method, file=file, line=line))
408
+
409
+
410
+ def _append_external(
411
+ result: ScanResult, target: str, file: str, line: int | None = None
412
+ ) -> None:
413
+ key = (target, file)
414
+ if any((c.target, c.file) == key for c in result.external_calls):
415
+ return
416
+ result.external_calls.append(ExternalCall(target=target, file=file, line=line))
417
+
418
+
419
+ def _append_auth(
420
+ result: ScanResult,
421
+ hint: str,
422
+ file: str,
423
+ line: int | None = None,
424
+ evidence: str | None = None,
425
+ ) -> None:
426
+ key = (hint, file)
427
+ if any((h.hint, h.file) == key for h in result.auth_hints):
428
+ return
429
+ result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence))
430
+
431
+
432
+ def _append_service(result: ScanResult, hint: str, file: str) -> None:
433
+ key = (hint, file)
434
+ if any((h.hint, h.file) == key for h in result.service_hints):
435
+ return
436
+ result.service_hints.append(ServiceHint(hint=hint, file=file))
437
+
438
+
439
+ def _append_secret(
440
+ result: ScanResult, name: str, file: str, line: int | None = None, kind: str = "env_reference"
441
+ ) -> None:
442
+ key = (name, file, line or 0)
443
+ if any((s.name, s.file, s.line or 0) == key for s in result.secret_hints):
444
+ return
445
+ result.secret_hints.append(SecretHint(name=name, file=file, line=line, kind=kind))
446
+
447
+
448
+ def _read_text(path: Path) -> str | None:
449
+ try:
450
+ return path.read_text(encoding="utf-8")
451
+ except (UnicodeDecodeError, OSError):
452
+ return None
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from attackmap.sdk.contracts import AnalyzerMetadata, AnalyzerProtocol
4
+ from attackmap.sdk.models import (
5
+ AuthHint,
6
+ DatabaseHint,
7
+ EntrypointHint,
8
+ ExternalCall,
9
+ FrameworkHint,
10
+ Route,
11
+ ScanResult,
12
+ SecretHint,
13
+ ServiceHint,
14
+ )
15
+
16
+ AttackMapAnalyzerProtocol = AnalyzerProtocol
17
+
18
+ __all__ = [
19
+ "AnalyzerMetadata",
20
+ "AttackMapAnalyzerProtocol",
21
+ "Route",
22
+ "ExternalCall",
23
+ "DatabaseHint",
24
+ "AuthHint",
25
+ "EntrypointHint",
26
+ "FrameworkHint",
27
+ "ServiceHint",
28
+ "SecretHint",
29
+ "ScanResult",
30
+ ]
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: attackmap-analyzer-iac
3
+ Version: 0.1.0
4
+ Summary: Infrastructure-as-Code analyzer plugin for AttackMap (Dockerfile, docker-compose, GitHub Actions, .env templates, shell installers)
5
+ Author: AttackMap Contributors
6
+ Author-email: Matthew Davis <matthewd@matthewd.xyz>
7
+ Maintainer-email: Matthew Davis <matthewd@matthewd.xyz>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/mlaify/attackmap-analyzer-iac
10
+ Project-URL: Repository, https://github.com/mlaify/attackmap-analyzer-iac
11
+ Project-URL: Issues, https://github.com/mlaify/attackmap-analyzer-iac/issues
12
+ Project-URL: Changelog, https://github.com/mlaify/attackmap-analyzer-iac/blob/main/CHANGELOG.md
13
+ Project-URL: AttackMap core, https://github.com/mlaify/AttackMap
14
+ Keywords: security,static-analysis,appsec,defensive-security,attackmap,attackmap-plugin,iac,docker,docker-compose,github-actions
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Information Technology
18
+ Classifier: Intended Audience :: System Administrators
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3 :: Only
26
+ Classifier: Topic :: Security
27
+ Classifier: Topic :: Software Development :: Quality Assurance
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.11
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Requires-Dist: pydantic>=2.7.0
33
+ Provides-Extra: core
34
+ Requires-Dist: attackmap>=0.1.1; extra == "core"
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
37
+ Requires-Dist: build>=1.2.0; extra == "dev"
38
+ Requires-Dist: attackmap>=0.1.1; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # attackmap-analyzer-iac
42
+
43
+ Infrastructure-as-Code analyzer plugin for [AttackMap](https://github.com/mlaify/AttackMap).
44
+
45
+ Covers the class of files that live outside application source but drive the actual runtime posture:
46
+
47
+ - **Dockerfile** — non-root user, exposed ports, `COPY`/`ADD` with credentials, `RUN curl | bash`, missing healthchecks, base-image pinning
48
+ - **docker-compose.yaml** — service list (feeds the topology graph), `env_file` references, host-mount volumes, privileged containers, `0.0.0.0` port bindings, `network_mode: host`
49
+ - **GitHub Actions workflows** — `pull_request_target` with checkout, third-party actions pinned by tag vs SHA, secret exposure to fork PRs, step-level `permissions:`
50
+ - **`.env` templates** (`.env.example`, `sample.env`) — secret inventory (the shape, not the value)
51
+ - **Shell installers** — `curl | bash` patterns, `sudo` scope, TLS material handling
52
+
53
+ Emits routes for exposed ports, external calls for third-party actions and `curl` fetches, `SecretHint` for env-template inventory, service-topology hints for compose `services:` — feeding the same pipeline as the source-code analyzers.
54
+
55
+ Bluesky FINDINGS §2 documented this as the biggest coverage gap: `bluesky-social/pds` (a deployment repo) was 95% invisible to AttackMap because every non-JS file was outside the analyzer model. This plugin closes that gap.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install attackmap-analyzer-iac
61
+ # or as part of the bundle
62
+ pip install "attackmap[all]"
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ Runs automatically via the `attackmap.analyzers` entry-point group once installed:
68
+
69
+ ```bash
70
+ attackmap analyze /path/to/deployment-repo --output reports
71
+ ```
72
+
73
+ ## Contract
74
+
75
+ Implements `AnalyzerProtocol` from `attackmap.sdk`. See the [external-analyzer guide](https://github.com/mlaify/AttackMap/blob/main/docs/external-analyzers.md).
76
+
77
+ ## Scope
78
+
79
+ **In:** patterns any competent operator recognizes on inspection — the docker-compose service graph, `pull_request_target` combined with untrusted checkout, non-root User directive presence, curl-piped installer scripts.
80
+
81
+ **Out:** deep semantic analysis of container image contents, live registry scanning, executed-runtime introspection. Those belong in other tools; AttackMap's differentiator is narrative attack-path reasoning over the code you can see.
@@ -0,0 +1,9 @@
1
+ attackmap_analyzer_iac/__init__.py,sha256=LfcfmMs_g8mrDejtXJ_ZKd7gFxW-hfoAwnK9h_L_ggU,61
2
+ attackmap_analyzer_iac/analyzer.py,sha256=UVOGGGNQyfyHypCMmpk6YeVh7Gto8qqFYSSALwkYBjE,17831
3
+ attackmap_analyzer_iac/contracts.py,sha256=dsveFe7szz7xceev0PIfyc5jQlNiES0whsdxwu9D7YI,578
4
+ attackmap_analyzer_iac-0.1.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
5
+ attackmap_analyzer_iac-0.1.0.dist-info/METADATA,sha256=-roDq_vzvGgwwVkVqRvwKGi8CGAaXLV7nIazO7eHYnY,4212
6
+ attackmap_analyzer_iac-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ attackmap_analyzer_iac-0.1.0.dist-info/entry_points.txt,sha256=8s0ttnoJWChZWfjezqokU7l3wyyF2UQne_Xj0Nlu-fo,63
8
+ attackmap_analyzer_iac-0.1.0.dist-info/top_level.txt,sha256=0RcqPMapdhERWyJIirMDbHofmUmxrj7H-4Vp6dlTmsw,23
9
+ attackmap_analyzer_iac-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ iac = attackmap_analyzer_iac:IacAnalyzer
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Davis and AttackMap Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ attackmap_analyzer_iac