attackmap-analyzer-iac 0.1.0__tar.gz

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,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,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,41 @@
1
+ # attackmap-analyzer-iac
2
+
3
+ Infrastructure-as-Code analyzer plugin for [AttackMap](https://github.com/mlaify/AttackMap).
4
+
5
+ Covers the class of files that live outside application source but drive the actual runtime posture:
6
+
7
+ - **Dockerfile** — non-root user, exposed ports, `COPY`/`ADD` with credentials, `RUN curl | bash`, missing healthchecks, base-image pinning
8
+ - **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`
9
+ - **GitHub Actions workflows** — `pull_request_target` with checkout, third-party actions pinned by tag vs SHA, secret exposure to fork PRs, step-level `permissions:`
10
+ - **`.env` templates** (`.env.example`, `sample.env`) — secret inventory (the shape, not the value)
11
+ - **Shell installers** — `curl | bash` patterns, `sudo` scope, TLS material handling
12
+
13
+ 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.
14
+
15
+ 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.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install attackmap-analyzer-iac
21
+ # or as part of the bundle
22
+ pip install "attackmap[all]"
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Runs automatically via the `attackmap.analyzers` entry-point group once installed:
28
+
29
+ ```bash
30
+ attackmap analyze /path/to/deployment-repo --output reports
31
+ ```
32
+
33
+ ## Contract
34
+
35
+ Implements `AnalyzerProtocol` from `attackmap.sdk`. See the [external-analyzer guide](https://github.com/mlaify/AttackMap/blob/main/docs/external-analyzers.md).
36
+
37
+ ## Scope
38
+
39
+ **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.
40
+
41
+ **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,78 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "attackmap-analyzer-iac"
7
+ version = "0.1.0"
8
+ description = "Infrastructure-as-Code analyzer plugin for AttackMap (Dockerfile, docker-compose, GitHub Actions, .env templates, shell installers)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Matthew Davis", email = "matthewd@matthewd.xyz"},
14
+ {name = "AttackMap Contributors"},
15
+ ]
16
+ maintainers = [
17
+ {name = "Matthew Davis", email = "matthewd@matthewd.xyz"},
18
+ ]
19
+ keywords = [
20
+ "security",
21
+ "static-analysis",
22
+ "appsec",
23
+ "defensive-security",
24
+ "attackmap",
25
+ "attackmap-plugin",
26
+ "iac",
27
+ "docker",
28
+ "docker-compose",
29
+ "github-actions",
30
+ ]
31
+ classifiers = [
32
+ "Development Status :: 4 - Beta",
33
+ "Intended Audience :: Developers",
34
+ "Intended Audience :: Information Technology",
35
+ "Intended Audience :: System Administrators",
36
+ "License :: OSI Approved :: MIT License",
37
+ "Operating System :: OS Independent",
38
+ "Programming Language :: Python :: 3",
39
+ "Programming Language :: Python :: 3.11",
40
+ "Programming Language :: Python :: 3.12",
41
+ "Programming Language :: Python :: 3.13",
42
+ "Programming Language :: Python :: 3 :: Only",
43
+ "Topic :: Security",
44
+ "Topic :: Software Development :: Quality Assurance",
45
+ "Typing :: Typed",
46
+ ]
47
+ dependencies = [
48
+ "pydantic>=2.7.0",
49
+ ]
50
+
51
+ [project.optional-dependencies]
52
+ core = [
53
+ "attackmap>=0.1.1",
54
+ ]
55
+ dev = [
56
+ "pytest>=8.0.0",
57
+ "build>=1.2.0",
58
+ "attackmap>=0.1.1",
59
+ ]
60
+
61
+ [project.urls]
62
+ Homepage = "https://github.com/mlaify/attackmap-analyzer-iac"
63
+ Repository = "https://github.com/mlaify/attackmap-analyzer-iac"
64
+ Issues = "https://github.com/mlaify/attackmap-analyzer-iac/issues"
65
+ Changelog = "https://github.com/mlaify/attackmap-analyzer-iac/blob/main/CHANGELOG.md"
66
+ "AttackMap core" = "https://github.com/mlaify/AttackMap"
67
+
68
+ [project.entry-points."attackmap.analyzers"]
69
+ iac = "attackmap_analyzer_iac:IacAnalyzer"
70
+
71
+ [tool.setuptools]
72
+ package-dir = {"" = "src"}
73
+
74
+ [tool.setuptools.packages.find]
75
+ where = ["src"]
76
+
77
+ [tool.pytest.ini_options]
78
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/attackmap_analyzer_iac/__init__.py
5
+ src/attackmap_analyzer_iac/analyzer.py
6
+ src/attackmap_analyzer_iac/contracts.py
7
+ src/attackmap_analyzer_iac.egg-info/PKG-INFO
8
+ src/attackmap_analyzer_iac.egg-info/SOURCES.txt
9
+ src/attackmap_analyzer_iac.egg-info/dependency_links.txt
10
+ src/attackmap_analyzer_iac.egg-info/entry_points.txt
11
+ src/attackmap_analyzer_iac.egg-info/requires.txt
12
+ src/attackmap_analyzer_iac.egg-info/top_level.txt
13
+ tests/test_iac_analyzer.py
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ iac = attackmap_analyzer_iac:IacAnalyzer
@@ -0,0 +1,9 @@
1
+ pydantic>=2.7.0
2
+
3
+ [core]
4
+ attackmap>=0.1.1
5
+
6
+ [dev]
7
+ pytest>=8.0.0
8
+ build>=1.2.0
9
+ attackmap>=0.1.1
@@ -0,0 +1,252 @@
1
+ """Tests for attackmap-analyzer-iac (issue mlaify/AttackMap#40)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from attackmap.sdk.contracts import AnalyzerMetadata as SharedAnalyzerMetadata
8
+ from attackmap.sdk.models import ScanResult as SharedScanResult
9
+ from attackmap_analyzer_iac import IacAnalyzer
10
+ from attackmap_analyzer_iac.contracts import AnalyzerMetadata, ScanResult
11
+
12
+
13
+ FIXTURES = Path(__file__).parent / "fixtures"
14
+
15
+
16
+ def _analyze(fixture_name: str = "pds_like_repo"):
17
+ return IacAnalyzer().analyze(FIXTURES / fixture_name)
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Contract shape
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def test_contracts_use_shared_sdk_types() -> None:
26
+ assert AnalyzerMetadata is SharedAnalyzerMetadata
27
+ assert ScanResult is SharedScanResult
28
+
29
+
30
+ def test_metadata_has_expected_fields() -> None:
31
+ m = IacAnalyzer().metadata
32
+ assert m.name == "iac"
33
+ assert m.version == "0.1.0"
34
+ assert m.enabled_by_default is True
35
+ assert m.experimental is False
36
+
37
+
38
+ def test_detect_fires_on_pds_like_fixture() -> None:
39
+ assert IacAnalyzer().detect(FIXTURES / "pds_like_repo") is True
40
+
41
+
42
+ def test_detect_rejects_repo_without_any_iac_files(tmp_path: Path) -> None:
43
+ (tmp_path / "app.py").write_text("print('hi')\n", encoding="utf-8")
44
+ assert IacAnalyzer().detect(tmp_path) is False
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Dockerfile extraction
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def test_dockerfile_missing_user_directive_produces_hint() -> None:
53
+ scan = _analyze()
54
+ hints = {h.hint for h in scan.auth_hints}
55
+ assert "dockerfile_no_user_directive" in hints
56
+
57
+
58
+ def test_dockerfile_missing_healthcheck_produces_hint() -> None:
59
+ scan = _analyze()
60
+ hints = {h.hint for h in scan.auth_hints}
61
+ assert "dockerfile_no_healthcheck" in hints
62
+
63
+
64
+ def test_dockerfile_exposed_ports_become_container_routes() -> None:
65
+ scan = _analyze()
66
+ exposed = {(r.path, r.method) for r in scan.routes if r.file == "Dockerfile"}
67
+ assert ("container:3000", "EXPOSE") in exposed
68
+ assert ("container:3001", "EXPOSE") in exposed
69
+
70
+
71
+ def test_dockerfile_run_curl_pipe_shell_produces_external_and_hint() -> None:
72
+ scan = _analyze()
73
+ targets = {c.target for c in scan.external_calls}
74
+ hints = {h.hint for h in scan.auth_hints}
75
+ assert "dockerfile:curl-pipe-shell" in targets
76
+ assert "dockerfile_run_curl_pipe" in hints
77
+
78
+
79
+ def test_dockerfile_add_remote_url_produces_hint() -> None:
80
+ scan = _analyze()
81
+ hints = {h.hint for h in scan.auth_hints}
82
+ assert "dockerfile_add_remote" in hints
83
+
84
+
85
+ def test_dockerfile_unpinned_base_image_produces_hint() -> None:
86
+ """`FROM node:18-alpine` isn't SHA-pinned; flag it."""
87
+ scan = _analyze()
88
+ hints = {h.hint for h in scan.auth_hints}
89
+ assert "dockerfile_base_image_unpinned" in hints
90
+
91
+
92
+ def test_dockerfile_sha_pinned_base_image_does_not_fire_unpinned_hint(tmp_path: Path) -> None:
93
+ (tmp_path / "Dockerfile").write_text(
94
+ "FROM node@sha256:aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666\nUSER app\nHEALTHCHECK CMD echo\n",
95
+ encoding="utf-8",
96
+ )
97
+ scan = IacAnalyzer().analyze(tmp_path)
98
+ hints = {h.hint for h in scan.auth_hints}
99
+ assert "dockerfile_base_image_unpinned" not in hints
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # docker-compose extraction
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ def test_compose_services_become_service_hints() -> None:
108
+ scan = _analyze()
109
+ hints = {h.hint for h in scan.service_hints if h.file == "compose.yaml"}
110
+ assert "service_name:pds" in hints
111
+ assert "service_name:caddy" in hints
112
+ assert "service_name:watchtower" in hints
113
+
114
+
115
+ def test_compose_binds_all_interfaces_produces_hint() -> None:
116
+ scan = _analyze()
117
+ hints = {h.hint for h in scan.auth_hints}
118
+ assert "compose_port_binding_all_interfaces" in hints
119
+
120
+
121
+ def test_compose_privileged_container_produces_hint() -> None:
122
+ scan = _analyze()
123
+ hints = {h.hint for h in scan.auth_hints}
124
+ assert "compose_privileged_container" in hints
125
+
126
+
127
+ def test_compose_env_file_reference_produces_hint() -> None:
128
+ scan = _analyze()
129
+ envfile_hints = [h for h in scan.auth_hints if h.hint == "compose_env_file_reference"]
130
+ assert envfile_hints
131
+ assert "pds.env" in (envfile_hints[0].evidence_text or "")
132
+
133
+
134
+ def test_compose_host_mounted_volumes_produce_hints() -> None:
135
+ scan = _analyze()
136
+ # Hints carry the source path in the `compose_host_mount:<path>` shape
137
+ # so distinct mounts don't dedup together.
138
+ host_mount_hints = [h for h in scan.auth_hints if h.hint.startswith("compose_host_mount:")]
139
+ assert len(host_mount_hints) >= 2
140
+ hints_text = " ".join(h.hint for h in host_mount_hints)
141
+ assert "/var/run/docker.sock" in hints_text
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # GitHub Actions extraction
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def test_gha_pull_request_target_with_checkout_produces_hint() -> None:
150
+ scan = _analyze()
151
+ hints = {h.hint for h in scan.auth_hints}
152
+ assert "gha_pull_request_target_with_checkout" in hints
153
+
154
+
155
+ def test_gha_third_party_action_tag_pinned_produces_hint() -> None:
156
+ scan = _analyze()
157
+ hints = [h for h in scan.auth_hints if h.hint.startswith("gha_third_party_action_tag_pinned:")]
158
+ assert hints
159
+ evidence = " ".join((h.evidence_text or "") + " " + h.hint for h in hints)
160
+ assert "some-third-party/action" in evidence
161
+
162
+
163
+ def test_gha_first_party_actions_at_tag_do_not_fire_pin_hint() -> None:
164
+ """`actions/checkout@v4` and `github/*@v1` are first-party; broadly
165
+ trusted enough that the tag-vs-SHA rule shouldn't fire on them."""
166
+ scan = _analyze()
167
+ hints = [h for h in scan.auth_hints if h.hint.startswith("gha_third_party_action_tag_pinned:")]
168
+ for h in hints:
169
+ # Repo name is in the hint after the `:`
170
+ repo = h.hint.split(":", 1)[1]
171
+ owner = repo.split("/", 1)[0]
172
+ assert owner not in {"actions", "github"}
173
+
174
+
175
+ def test_gha_no_top_level_permissions_produces_hint() -> None:
176
+ scan = _analyze()
177
+ hints = {h.hint for h in scan.auth_hints}
178
+ assert "gha_no_top_level_permissions" in hints
179
+
180
+
181
+ def test_gha_secrets_reference_recorded_as_env_secret() -> None:
182
+ scan = _analyze()
183
+ names = {s.name for s in scan.secret_hints if "workflow" in s.file}
184
+ assert "GHCR_TOKEN" in names
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # .env template extraction
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def test_env_template_keys_become_env_template_secret_hints() -> None:
193
+ scan = _analyze()
194
+ template_hints = [s for s in scan.secret_hints if s.kind == "env_template"]
195
+ names = {s.name for s in template_hints}
196
+ assert "PDS_JWT_SECRET" in names
197
+ assert "PDS_ADMIN_PASSWORD" in names
198
+
199
+
200
+ def test_env_template_kind_distinguishes_from_env_reference() -> None:
201
+ """The template file lists the *shape* of the secret inventory —
202
+ not real values. Downstream findings shouldn't treat a
203
+ `PDS_JWT_SECRET` key in `.env.example` as an actual exposed secret."""
204
+ scan = _analyze()
205
+ env_ref_hints = [s for s in scan.secret_hints if s.file == ".env.example" and s.kind == "env_reference"]
206
+ assert env_ref_hints == []
207
+ template_hints = [s for s in scan.secret_hints if s.file == ".env.example"]
208
+ assert all(s.kind == "env_template" for s in template_hints)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Shell installer extraction
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def test_installer_curl_pipe_shell_produces_external_and_hint() -> None:
217
+ scan = _analyze()
218
+ targets = {c.target for c in scan.external_calls}
219
+ hints = {h.hint for h in scan.auth_hints}
220
+ assert "shell:curl-pipe-shell" in targets
221
+ assert "shell_curl_pipe_installer" in hints
222
+
223
+
224
+ def test_installer_sudo_produces_hint() -> None:
225
+ scan = _analyze()
226
+ hints = {h.hint for h in scan.auth_hints}
227
+ assert "shell_sudo_used" in hints
228
+
229
+
230
+ def test_installer_permissive_chmod_produces_hint() -> None:
231
+ scan = _analyze()
232
+ hints = [h for h in scan.auth_hints if h.hint == "shell_permissive_chmod"]
233
+ assert hints
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Integration: end-to-end scan on the pds-like fixture matches expectations
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def test_end_to_end_pds_like_scan_surfaces_five_iac_families() -> None:
242
+ """Sanity guard — a pds-like fixture with all five file classes should
243
+ exercise every extractor path. Files scanned should include the
244
+ Dockerfile, compose.yaml, workflow, .env.example, and installer.sh."""
245
+ scan = _analyze()
246
+ assert scan.files_scanned >= 5
247
+ # All five signal families should show at least one entry
248
+ assert scan.routes # container:3000 etc.
249
+ assert scan.external_calls # curl-pipe
250
+ assert scan.auth_hints # dozens of hints across formats
251
+ assert scan.secret_hints # env template + workflow secrets
252
+ assert scan.service_hints # compose service names