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,235 @@
|
|
|
1
|
+
"""Misconfiguration records (iac#1).
|
|
2
|
+
|
|
3
|
+
Container and cluster misconfigurations go to core's `CloudMisconfig` record
|
|
4
|
+
(AttackMap#334), which core turns into findings with CWE/OWASP ids. They used
|
|
5
|
+
to be `AuthHint`s, which core reads as recon: an `AuthHint` in a Dockerfile
|
|
6
|
+
even counted as an auth control for the `EXPOSE` route next to it.
|
|
7
|
+
|
|
8
|
+
With a core that predates `CloudMisconfig` (feature-detected: the SDK doesn't
|
|
9
|
+
export it, or `ScanResult` has no `cloud_misconfigs`), the legacy `AuthHint`
|
|
10
|
+
(if the check had one) is emitted instead. The `gha_*` / `shell_*` posture
|
|
11
|
+
hints are `FrameworkHint`s on every core, old ones included: they were never
|
|
12
|
+
auth signals and no core version consumes them by name.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from bisect import bisect_right
|
|
18
|
+
|
|
19
|
+
from attackmap.sdk import AuthHint, ScanResult
|
|
20
|
+
|
|
21
|
+
try: # cores before 0.6 don't have the record; then no records are emitted
|
|
22
|
+
from attackmap.sdk import CloudMisconfig
|
|
23
|
+
except ImportError: # pragma: no cover - exercised against older cores
|
|
24
|
+
CloudMisconfig = None # type: ignore[assignment,misc]
|
|
25
|
+
|
|
26
|
+
_EVIDENCE_MAX = 200
|
|
27
|
+
# Feature detection: an older core neither exports the record nor has the field.
|
|
28
|
+
CLOUD_RECORDS = CloudMisconfig is not None and "cloud_misconfigs" in getattr(ScanResult, "model_fields", {})
|
|
29
|
+
|
|
30
|
+
# Dedup keys per result, so appending stays O(1) on hostile input with
|
|
31
|
+
# tens of thousands of signals (a linear `any(...)` scan per append is
|
|
32
|
+
# quadratic). Keyed by id(); the result object is kept to rule out reuse.
|
|
33
|
+
_SEEN: dict[int, tuple[object, set]] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def first_seen(owner: object, key: tuple) -> bool:
|
|
37
|
+
"""True the first time ``key`` is offered for ``owner`` (a result, or a
|
|
38
|
+
hint list of one)."""
|
|
39
|
+
entry = _SEEN.get(id(owner))
|
|
40
|
+
if entry is None or entry[0] is not owner:
|
|
41
|
+
entry = _SEEN[id(owner)] = (owner, set())
|
|
42
|
+
if key in entry[1]:
|
|
43
|
+
return False
|
|
44
|
+
entry[1].add(key)
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Line starts of the file being analyzed: `line_of` / `line_snippet` from the
|
|
49
|
+
# SDK rescan the text on every call, which is quadratic for a file with
|
|
50
|
+
# tens of thousands of matches. Files are analyzed one at a time, so one
|
|
51
|
+
# cached index is enough.
|
|
52
|
+
_LINES: list = [None, [0]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _starts(content: str) -> list[int]:
|
|
56
|
+
if _LINES[0] is not content:
|
|
57
|
+
starts = [0]
|
|
58
|
+
index = content.find("\n")
|
|
59
|
+
while index != -1:
|
|
60
|
+
starts.append(index + 1)
|
|
61
|
+
index = content.find("\n", index + 1)
|
|
62
|
+
_LINES[0], _LINES[1] = content, starts
|
|
63
|
+
return _LINES[1]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def line_of(content: str, offset: int) -> int:
|
|
67
|
+
"""1-indexed line of ``offset`` (same numbering as `attackmap.sdk.line_of`)."""
|
|
68
|
+
return bisect_right(_starts(content), max(offset, 0))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def line_snippet(content: str, line: int, max_len: int = 200) -> str:
|
|
72
|
+
"""Stripped text of 1-indexed ``line`` (as `attackmap.sdk.line_snippet`)."""
|
|
73
|
+
starts = _starts(content)
|
|
74
|
+
if line < 1 or line > len(starts):
|
|
75
|
+
return ""
|
|
76
|
+
end = starts[line] - 1 if line < len(starts) else len(content)
|
|
77
|
+
text = content[starts[line - 1]:end].strip()
|
|
78
|
+
return text if len(text) <= max_len else text[: max_len - 1] + "…"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def release(*owners: object) -> None:
|
|
82
|
+
for owner in owners:
|
|
83
|
+
_SEEN.pop(id(owner), None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def append_misconfig(
|
|
87
|
+
result: ScanResult,
|
|
88
|
+
kind: str,
|
|
89
|
+
file: str,
|
|
90
|
+
line: int | None,
|
|
91
|
+
evidence: str,
|
|
92
|
+
*,
|
|
93
|
+
severity: str,
|
|
94
|
+
confidence: float = 0.8,
|
|
95
|
+
resource: str | None = None,
|
|
96
|
+
platform: str | None = None,
|
|
97
|
+
escalation: str | None = None,
|
|
98
|
+
context: str | None = None,
|
|
99
|
+
legacy_auth_hint: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""One record per (kind, file, line, resource)."""
|
|
102
|
+
evidence = " ".join(evidence.split())
|
|
103
|
+
if len(evidence) > _EVIDENCE_MAX:
|
|
104
|
+
evidence = evidence[: _EVIDENCE_MAX - 1] + "…"
|
|
105
|
+
if not CLOUD_RECORDS:
|
|
106
|
+
if legacy_auth_hint and first_seen(result, ("auth", legacy_auth_hint, file)):
|
|
107
|
+
result.auth_hints.append(
|
|
108
|
+
AuthHint(hint=legacy_auth_hint, file=file, line=line, evidence_text=evidence, confidence=0.9)
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
if not first_seen(result, ("misconfig", kind, file, line, resource, evidence)):
|
|
112
|
+
return
|
|
113
|
+
result.cloud_misconfigs.append(
|
|
114
|
+
CloudMisconfig(
|
|
115
|
+
kind=kind, file=file, line=line, resource=resource, platform=platform, context=context,
|
|
116
|
+
escalation_path=escalation, evidence_text=evidence, severity=severity, confidence=confidence,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Linux capabilities that let a process break out of its container or take
|
|
122
|
+
# over the node (high) or its network (medium). `CAP_` prefix stripped.
|
|
123
|
+
DANGEROUS_CAPABILITIES = {
|
|
124
|
+
"ALL": "high",
|
|
125
|
+
"SYS_ADMIN": "high",
|
|
126
|
+
"SYS_MODULE": "high",
|
|
127
|
+
"SYS_PTRACE": "high",
|
|
128
|
+
"SYS_RAWIO": "high",
|
|
129
|
+
"DAC_READ_SEARCH": "high",
|
|
130
|
+
"BPF": "high",
|
|
131
|
+
"NET_ADMIN": "medium",
|
|
132
|
+
"SYS_TIME": "medium",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Host paths whose mount hands a container the node: runtime sockets, the
|
|
136
|
+
# root filesystem, kubelet state, kernel interfaces.
|
|
137
|
+
_RUNTIME_SOCKETS = ("/var/run/docker.sock", "/run/docker.sock", "/var/run/containerd", "/run/containerd",
|
|
138
|
+
"/var/run/crio", "/run/crio", "/var/run/cri-dockerd.sock")
|
|
139
|
+
_SENSITIVE_HOST_PATHS = (*_RUNTIME_SOCKETS, "/etc", "/root", "/proc", "/sys", "/dev", "/boot",
|
|
140
|
+
"/var/lib/kubelet", "/var/lib/docker", "/var/lib/containerd")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Read-only system data under the sensitive trees above: CA bundles, time
|
|
144
|
+
# zone, the syslog socket.
|
|
145
|
+
_BENIGN_HOST_PATHS = ("/etc/ssl", "/etc/pki", "/etc/ca-certificates", "/etc/localtime", "/etc/timezone",
|
|
146
|
+
"/etc/machine-id", "/etc/os-release", "/etc/letsencrypt", "/dev/log", "/dev/dri",
|
|
147
|
+
"/usr/share/ca-certificates")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize(path: str) -> str:
|
|
151
|
+
path = path.strip().strip("'\"")
|
|
152
|
+
while "//" in path:
|
|
153
|
+
path = path.replace("//", "/")
|
|
154
|
+
return path.rstrip("/") or "/"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def host_path_risk(path: str) -> tuple[str, str]:
|
|
158
|
+
"""``(severity, escalation)`` for mounting host ``path``."""
|
|
159
|
+
norm = _normalize(path)
|
|
160
|
+
if any(norm == s or norm.startswith(s + "/") for s in _RUNTIME_SOCKETS):
|
|
161
|
+
return "high", "container runtime socket → start a privileged container → root on the node"
|
|
162
|
+
if norm == "/":
|
|
163
|
+
return "high", "host root filesystem → node root, kubelet credentials and every pod's secrets"
|
|
164
|
+
if any(norm == s or norm.startswith(s + "/") for s in _BENIGN_HOST_PATHS):
|
|
165
|
+
return "low", f"host {norm} is readable from the container"
|
|
166
|
+
if any(norm == s or norm.startswith(s + "/") for s in _SENSITIVE_HOST_PATHS):
|
|
167
|
+
return "high", f"host {norm} → node credentials or kernel interfaces → root on the node"
|
|
168
|
+
return "low", f"host {norm} is readable (and possibly writable) from the container"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def capability_severity(cap: str) -> str | None:
|
|
172
|
+
return DANGEROUS_CAPABILITIES.get(cap.strip().strip("'\"").upper().removeprefix("CAP_"))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------- Kubernetes RBAC and Service exposure (shared by manifests) ----------
|
|
176
|
+
|
|
177
|
+
# API groups whose objects are cluster control. A wildcard on a CRD group an
|
|
178
|
+
# operator owns (`monitoring.coreos.com`) or on `metrics.k8s.io` is normal.
|
|
179
|
+
SENSITIVE_API_GROUPS = frozenset({
|
|
180
|
+
"", "*", "apps", "batch", "extensions", "policy", "networking.k8s.io", "rbac.authorization.k8s.io",
|
|
181
|
+
"admissionregistration.k8s.io", "certificates.k8s.io", "apiextensions.k8s.io",
|
|
182
|
+
})
|
|
183
|
+
# Resources whose write (or read, for secrets) is a step toward cluster control.
|
|
184
|
+
SENSITIVE_RESOURCES = frozenset({
|
|
185
|
+
"*", "secrets", "pods", "pods/exec", "pods/attach", "pods/ephemeralcontainers", "deployments", "daemonsets",
|
|
186
|
+
"statefulsets", "replicasets", "jobs", "cronjobs", "replicationcontrollers", "serviceaccounts",
|
|
187
|
+
"serviceaccounts/token", "roles", "rolebindings", "clusterroles", "clusterrolebindings", "nodes",
|
|
188
|
+
"nodes/proxy", "persistentvolumes", "mutatingwebhookconfigurations", "validatingwebhookconfigurations",
|
|
189
|
+
"customresourcedefinitions", "certificatesigningrequests", "certificatesigningrequests/approval",
|
|
190
|
+
"configmaps", "namespaces", "networkpolicies",
|
|
191
|
+
})
|
|
192
|
+
# Roles whose grant to every authenticated user is a takeover.
|
|
193
|
+
ADMIN_LIKE_ROLES = frozenset({"cluster-admin", "admin", "edit"})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def rbac_wildcard(groups: set[str] | None, verbs: set[str], resources: set[str]) -> bool:
|
|
197
|
+
"""A `*` that matters: on a control-plane API group (``groups`` None
|
|
198
|
+
means the rule names none), and either every resource or a sensitive
|
|
199
|
+
one. `verbs: ["*"]` on `events` or `metrics.k8s.io` is not reported."""
|
|
200
|
+
if "*" not in verbs and "*" not in resources:
|
|
201
|
+
return False
|
|
202
|
+
if groups is not None and not groups & SENSITIVE_API_GROUPS:
|
|
203
|
+
return False
|
|
204
|
+
return "*" in resources or bool(resources & SENSITIVE_RESOURCES)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# Annotation key suffix → values that make a LoadBalancer internal.
|
|
208
|
+
_INTERNAL_LB_ANNOTATIONS = {
|
|
209
|
+
"aws-load-balancer-internal": {"true", "0.0.0.0/0"},
|
|
210
|
+
"aws-load-balancer-scheme": {"internal"},
|
|
211
|
+
"azure-load-balancer-internal": {"true"},
|
|
212
|
+
"oci-load-balancer-internal": {"true"},
|
|
213
|
+
"openstack-internal-load-balancer": {"true"},
|
|
214
|
+
"load-balancer-type": {"internal"}, # networking.gke.io/, cloud.google.com/
|
|
215
|
+
"ibm-load-balancer-cloud-provider-ip-type": {"private"},
|
|
216
|
+
"alibaba-cloud-loadbalancer-address-type": {"intranet"},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def internal_lb(annotations: dict[str, str]) -> bool:
|
|
221
|
+
"""True when a cloud's internal-load-balancer annotation says so. An
|
|
222
|
+
explicit ``"false"`` / ``internet-facing`` is external; a templated
|
|
223
|
+
value (``__TPL__`` / ``${...}``) is unknown, so treated as internal
|
|
224
|
+
(not reported)."""
|
|
225
|
+
for key, value in annotations.items():
|
|
226
|
+
suffix = key.rsplit("/", 1)[-1].lower()
|
|
227
|
+
accepted = _INTERNAL_LB_ANNOTATIONS.get(suffix)
|
|
228
|
+
if accepted is None:
|
|
229
|
+
continue
|
|
230
|
+
text = value.strip().strip("\"'").lower()
|
|
231
|
+
if "__tpl__" in text or "${" in text or text.startswith("var."):
|
|
232
|
+
return True
|
|
233
|
+
if text in accepted:
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attackmap-analyzer-iac
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Infrastructure-as-Code analyzer plugin for AttackMap (Dockerfile, docker-compose, Kubernetes, Helm, GitHub Actions, .env templates, shell installers)
|
|
5
|
+
Author: AttackMap Contributors
|
|
6
|
+
Author-email: Matthew Davis <mlaify@mlaify.io>
|
|
7
|
+
Maintainer-email: Matthew Davis <mlaify@mlaify.io>
|
|
8
|
+
License-Expression: 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,kubernetes,helm
|
|
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: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
25
|
+
Classifier: Topic :: Security
|
|
26
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: pydantic>=2.7.0
|
|
32
|
+
Requires-Dist: PyYAML>=6.0
|
|
33
|
+
Provides-Extra: core
|
|
34
|
+
Requires-Dist: attackmap>=0.6.0; 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.6.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# attackmap-analyzer-iac
|
|
42
|
+
|
|
43
|
+
> [!IMPORTANT]
|
|
44
|
+
> **Active development, slow pace.** AttackMap is under active development, but
|
|
45
|
+
> progress may be slow until more contributors or co-maintainers join. Help is
|
|
46
|
+
> very welcome with the core engine, an analyzer, the macOS app, or the docs —
|
|
47
|
+
> see [CONTRIBUTING.md](CONTRIBUTING.md) or open an issue on
|
|
48
|
+
> [mlaify/AttackMap](https://github.com/mlaify/AttackMap/issues) to say hello.
|
|
49
|
+
> Security reports are still welcome at [security@mlaify.io](mailto:security@mlaify.io).
|
|
50
|
+
|
|
51
|
+
Infrastructure-as-Code analyzer plugin for [AttackMap](https://github.com/mlaify/AttackMap).
|
|
52
|
+
|
|
53
|
+
Covers the class of files that live outside application source but drive the actual runtime posture:
|
|
54
|
+
|
|
55
|
+
- **Dockerfile** (also `Dockerfile.*`, `Dockerfile-*`, `*.Dockerfile`, `*.dockerfile`, `Containerfile*`): non-root user and healthcheck, both checked on the *final* build stage, including what it inherits from an earlier stage it is built `FROM`; exposed ports; `RUN curl | bash`; `ADD <url>`; base-image pinning, one hint per unpinned image, skipping `scratch` and stage aliases; literal secrets in `ENV`/`ARG`
|
|
56
|
+
- **docker-compose** (`docker-compose*.y[a]ml`, `compose*.y[a]ml`, so overrides like `docker-compose.override.yml` too): service list (feeds the topology graph), `env_file` references, host-mount volumes, privileged containers, Docker socket mounts, `cap_add: SYS_ADMIN`/`ALL`, `pid: host`, `seccomp:unconfined`, published ports that bind all interfaces (explicit `0.0.0.0`, or short syntax with no host IP such as `"5432:5432"`), `network_mode: host`, and literal secrets in `environment:`
|
|
57
|
+
- **GitHub Actions workflows** — `pull_request_target` with checkout, third-party actions pinned by tag vs SHA, secret exposure to fork PRs, step-level `permissions:`
|
|
58
|
+
- **`.env` templates** (`.env.example`, `sample.env`) — secret inventory (the shape, not the value)
|
|
59
|
+
- **Shell installers** — `curl | bash` patterns, `sudo` scope, TLS material handling
|
|
60
|
+
- **Kubernetes manifests** (any `.yaml`/`.yml` with top-level `apiVersion:` and `kind:`) — workloads (`Pod`, `Deployment`, `StatefulSet`, `DaemonSet`, `ReplicaSet`, `Job`, `CronJob`, `ReplicationController`): `privileged`, `allowPrivilegeEscalation: true`, `runAsUser: 0`, missing `runAsNonRoot` / `allowPrivilegeEscalation: false`, `hostPath` volumes (high for the runtime socket, `/`, `/etc`, `/proc`, `/sys`, `/dev`, `/var/lib/kubelet`…), `hostNetwork`/`hostPID`/`hostIPC`, dangerous capabilities, `Unconfined` seccomp/AppArmor, `:latest`/untagged images; RBAC roles with `*` on core/RBAC/apps groups, `escalate`/`bind`/`impersonate`, `pods/exec`, Secret reads; bindings to `cluster-admin` or `system:authenticated`/`unauthenticated`/`anonymous`; `LoadBalancer`/`NodePort` Services without `loadBalancerSourceRanges` (entrypoints too; internal load balancers skipped); `Ingress` host/path rules (routes); literal `Secret` values
|
|
61
|
+
- **Helm charts** — templates under `templates/` next to a `Chart.yaml` are read without rendering: `{{ }}` actions are stripped (line numbers kept) and a templated value is never reported, because the chart's values decide it. A setting inside `{{ if }}`/`{{ with }}` is reported with low confidence (it may be off by default). `values.yaml` defaults (`privileged`, `hostNetwork`/`hostPID`/`hostIPC`, `allowPrivilegeEscalation`, `runAsUser: 0`, capabilities) are reported with confidence 0.5
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
### Misconfigurations and posture signals
|
|
66
|
+
|
|
67
|
+
Every signal carries `file`, `line` and `evidence_text`. Signals about something *missing* (no `USER`, no `HEALTHCHECK`, no `permissions:`) point at the final `FROM` line or line 1.
|
|
68
|
+
|
|
69
|
+
Container and cluster misconfigurations are `CloudMisconfig` records (`cloud_misconfigs`, iac#1), which AttackMap core turns into findings with CWE/OWASP ids. Each names the `resource` (`Deployment/web container app`, `service agent`), a per-instance `severity` and `confidence`, and an `escalation_path` (e.g. `container runtime socket → start a privileged container → root on the node`):
|
|
70
|
+
|
|
71
|
+
| Kind | Dockerfile / compose | Kubernetes / Helm |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `container_runs_as_root` | no `USER` in the final stage (low), `USER root` (medium) | `runAsUser: 0` |
|
|
74
|
+
| `privileged_container` | `privileged: true` | `securityContext.privileged: true` |
|
|
75
|
+
| `host_path_mount` | runtime socket and `/etc`, `/proc`, … bind mounts (`/srv/data` is not reported) | `hostPath` volumes |
|
|
76
|
+
| `host_namespace_shared` | `pid: host`, `ipc: host`, `network_mode: host` | `hostPID`, `hostNetwork`, `hostIPC` |
|
|
77
|
+
| `dangerous_capability` | `cap_add` | `capabilities.add` (`SYS_ADMIN`, `ALL`, `SYS_MODULE`, `SYS_PTRACE`, … high; `NET_ADMIN` medium) |
|
|
78
|
+
| `security_profile_disabled` | `seccomp:unconfined`, `apparmor:unconfined` | `seccompProfile`/`appArmorProfile` `Unconfined` |
|
|
79
|
+
| `unpinned_container_image` | `FROM image:latest` or untagged | `image:` `:latest` or untagged |
|
|
80
|
+
| `dockerfile_remote_add` / `dockerfile_curl_pipe_shell` | `ADD <url>` without `--checksum`, `RUN curl \| sh` | |
|
|
81
|
+
| `privilege_escalation_allowed`, `missing_security_context` (low) | | `allowPrivilegeEscalation: true`; no `runAsNonRoot` / `allowPrivilegeEscalation: false` |
|
|
82
|
+
| `rbac_wildcard`, `rbac_escalation`, `rbac_secret_access`, `cluster_admin_binding`, `exposed_service` | | RBAC and Services |
|
|
83
|
+
|
|
84
|
+
No `AuthHint`s are emitted: core reads those as auth signals, and one in a Dockerfile counted as an auth control for its `EXPOSE` route. With an AttackMap core older than the `CloudMisconfig` record, the old `AuthHint`s (`dockerfile_no_user_directive`, `dockerfile_user_root`, `compose_privileged_container`, `compose_docker_socket_mount`, `compose_cap_add_sys_admin`, `compose_pid_host`, `compose_seccomp_unconfined`) are emitted instead. The other signals are split by meaning (AttackMap#258):
|
|
85
|
+
|
|
86
|
+
| Signal list | Hints |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `entrypoint_hints` (network exposure) | `compose_port_binding_all_interfaces:<binding>`, `compose_network_mode_host`, `k8s_service_loadbalancer:<name>`, `k8s_service_nodeport:<name>`, `k8s_ingress:<host>` |
|
|
89
|
+
| `framework_hints` (build, supply-chain, CI and deployment config) | `dockerfile_no_healthcheck`, `dockerfile_run_curl_pipe`, `dockerfile_add_remote`, `dockerfile_base_image_unpinned:<image>`, `compose_env_file_reference`, `compose_host_mount:<host path>`, `gha_third_party_action_tag_pinned:<owner/repo>`, `gha_pull_request_target_with_checkout`, `gha_no_top_level_permissions`, `shell_curl_pipe_installer`, `shell_sudo_used`, `shell_permissive_chmod` |
|
|
90
|
+
| `service_hints` | `service_name:<compose service>` |
|
|
91
|
+
|
|
92
|
+
The Dockerfile user check looks at the final stage's effective user (the last `USER`, inherited through `FROM <stage>`). A `USER nobody` in a builder stage doesn't hide a root final image, and `USER root` followed by `USER app` isn't flagged. A final base tagged `:nonroot` counts as setting a user.
|
|
93
|
+
|
|
94
|
+
Secret-shaped `ENV`/`ARG` names (`*SECRET*`, `*TOKEN*`, `*PASSWORD*`, `*_KEY`, …) and `environment:` entries with a literal value become `SecretHint(kind="hardcoded")`. `*_FILE` paths, `${VAR}` references, booleans and `ARG` without a default are not flagged.
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
## Install
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv tool install attackmap --with attackmap-analyzer-iac \
|
|
102
|
+
--index mlaify=https://pkg.mlaify.io/pypi/simple/ --index-strategy first-index
|
|
103
|
+
# or from source:
|
|
104
|
+
pip install git+https://github.com/mlaify/attackmap-analyzer-iac.git
|
|
105
|
+
# or as part of the bundle
|
|
106
|
+
pip install "attackmap[all] @ git+https://github.com/mlaify/AttackMap.git"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Usage
|
|
110
|
+
|
|
111
|
+
Runs automatically via the `attackmap.analyzers` entry-point group once installed:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
attackmap analyze /path/to/deployment-repo --output reports
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Contract
|
|
118
|
+
|
|
119
|
+
Implements `AnalyzerProtocol` from `attackmap.sdk`. See the [external-analyzer guide](https://github.com/mlaify/AttackMap/blob/main/docs/external-analyzers.md).
|
|
120
|
+
|
|
121
|
+
## Scope
|
|
122
|
+
|
|
123
|
+
**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.
|
|
124
|
+
|
|
125
|
+
**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,10 @@
|
|
|
1
|
+
attackmap_analyzer_iac/__init__.py,sha256=LfcfmMs_g8mrDejtXJ_ZKd7gFxW-hfoAwnK9h_L_ggU,61
|
|
2
|
+
attackmap_analyzer_iac/analyzer.py,sha256=CaruInKUt_26Ex2oz_p4tvc7Hrqe65YWPIq3dkzElj0,40061
|
|
3
|
+
attackmap_analyzer_iac/k8s.py,sha256=ob1tP_I4MlJQL4V1fxOmBTETqyAetMze_NqHFc2KMlA,30981
|
|
4
|
+
attackmap_analyzer_iac/records.py,sha256=xEi6EhKv8Skt6PNzWDD73bglHCbFGxT1EPgKE7LweeM,9974
|
|
5
|
+
attackmap_analyzer_iac-0.2.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
|
|
6
|
+
attackmap_analyzer_iac-0.2.0.dist-info/METADATA,sha256=qKXMUTd80RB5BlupL7qmGf7Xv8_MJiHO8iBOmYpEL_E,10657
|
|
7
|
+
attackmap_analyzer_iac-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
attackmap_analyzer_iac-0.2.0.dist-info/entry_points.txt,sha256=8s0ttnoJWChZWfjezqokU7l3wyyF2UQne_Xj0Nlu-fo,63
|
|
9
|
+
attackmap_analyzer_iac-0.2.0.dist-info/top_level.txt,sha256=0RcqPMapdhERWyJIirMDbHofmUmxrj7H-4Vp6dlTmsw,23
|
|
10
|
+
attackmap_analyzer_iac-0.2.0.dist-info/RECORD,,
|
|
@@ -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
|