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.
- attackmap_analyzer_iac-0.1.0/LICENSE +21 -0
- attackmap_analyzer_iac-0.1.0/PKG-INFO +81 -0
- attackmap_analyzer_iac-0.1.0/README.md +41 -0
- attackmap_analyzer_iac-0.1.0/pyproject.toml +78 -0
- attackmap_analyzer_iac-0.1.0/setup.cfg +4 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac/__init__.py +3 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac/analyzer.py +452 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac/contracts.py +30 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac.egg-info/PKG-INFO +81 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac.egg-info/SOURCES.txt +13 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac.egg-info/dependency_links.txt +1 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac.egg-info/entry_points.txt +2 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac.egg-info/requires.txt +9 -0
- attackmap_analyzer_iac-0.1.0/src/attackmap_analyzer_iac.egg-info/top_level.txt +1 -0
- attackmap_analyzer_iac-0.1.0/tests/test_iac_analyzer.py +252 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attackmap_analyzer_iac
|
|
@@ -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
|