attackmap-analyzer-terraform 0.2.1__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_terraform/__init__.py +3 -0
- attackmap_analyzer_terraform/analyzer.py +2079 -0
- attackmap_analyzer_terraform-0.2.1.dist-info/METADATA +119 -0
- attackmap_analyzer_terraform-0.2.1.dist-info/RECORD +8 -0
- attackmap_analyzer_terraform-0.2.1.dist-info/WHEEL +5 -0
- attackmap_analyzer_terraform-0.2.1.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_terraform-0.2.1.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_terraform-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2079 @@
|
|
|
1
|
+
"""Terraform / HCL infrastructure-as-code analyzer for AttackMap.
|
|
2
|
+
|
|
3
|
+
This analyzer is shaped differently from language analyzers — Terraform doesn't
|
|
4
|
+
have routes in the application sense. Instead, the value is in:
|
|
5
|
+
|
|
6
|
+
- **Public ingress** — security groups (open CIDRs in `ingress` only; open
|
|
7
|
+
egress is ignored) / Lambda function URLs / API Gateway methods with no
|
|
8
|
+
auth / public S3 ACLs and bucket policies → `entrypoint_hints`
|
|
9
|
+
- **Asset inventory** — S3 buckets, RDS, DynamoDB, Cognito user pools, KMS keys
|
|
10
|
+
→ `service_hints` and `database_hints`
|
|
11
|
+
- **Secrets** — `aws_secretsmanager_secret`, `aws_ssm_parameter` (SecureString),
|
|
12
|
+
`variable` blocks marked `sensitive = true` or with secret-shaped names,
|
|
13
|
+
and secret-shaped string literals in `.tfvars` (`kind="hardcoded"`)
|
|
14
|
+
→ `secret_hints`
|
|
15
|
+
- **IAM blast radius** — `Action "*"`, `Resource "*"` and Allow-`Principal "*"`
|
|
16
|
+
in `aws_iam_*` policies, role trust policies and `aws_iam_policy_document`
|
|
17
|
+
data sources → `auth_hints`
|
|
18
|
+
- **Database engines** — `aws_db_instance.engine = "postgres"` → `database_hints`
|
|
19
|
+
with the engine as the kind
|
|
20
|
+
- **Misconfigurations** (#4) — unpinned module sources, public endpoints and
|
|
21
|
+
data, missing authorizers, unencrypted storage, kubernetes provider pods /
|
|
22
|
+
RBAC / services → `cloud_misconfigs` (core's `CloudMisconfig` record, which
|
|
23
|
+
becomes a finding). Records carry `for_each`/`count` and, for resources in a
|
|
24
|
+
local module, the calling `module` blocks.
|
|
25
|
+
|
|
26
|
+
All emissions populate Signal v2 fields (line numbers, evidence snippets) so
|
|
27
|
+
downstream insights can cite `infra/main.tf:NN`.
|
|
28
|
+
|
|
29
|
+
`.tf.json` files are parsed with `json` and each block is rendered back to HCL
|
|
30
|
+
and sent through the same handlers, so both syntaxes yield the same signals.
|
|
31
|
+
|
|
32
|
+
HCL parsing uses brace-depth counting on top of regex. This is approximate but
|
|
33
|
+
sufficient for the resource-level introspection the analyzer needs. Variable
|
|
34
|
+
interpolation (`${var.foo}`) is not resolved — string literals and direct
|
|
35
|
+
attribute values are what we extract.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import posixpath
|
|
42
|
+
import re
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from bisect import bisect_right
|
|
46
|
+
|
|
47
|
+
from attackmap.sdk import (
|
|
48
|
+
DEFAULT_SKIP_DIRS,
|
|
49
|
+
AnalyzerMetadata,
|
|
50
|
+
AuthHint,
|
|
51
|
+
DatabaseHint,
|
|
52
|
+
EntrypointHint,
|
|
53
|
+
ExternalCall,
|
|
54
|
+
FrameworkHint,
|
|
55
|
+
iter_repo_files,
|
|
56
|
+
read_source,
|
|
57
|
+
rel,
|
|
58
|
+
Route,
|
|
59
|
+
ScanResult,
|
|
60
|
+
SecretHint,
|
|
61
|
+
ServiceHint,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try: # cores before 0.6 don't have the record; then no records are emitted
|
|
65
|
+
from attackmap.sdk import CloudMisconfig
|
|
66
|
+
except ImportError: # pragma: no cover - exercised against older cores
|
|
67
|
+
CloudMisconfig = None # type: ignore[assignment,misc]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
CODE_SUFFIXES = {".tf", ".tf.json", ".tfvars"}
|
|
71
|
+
# Terraform's provider/module cache on top of the shared skip list (which
|
|
72
|
+
# already covers .git/, node_modules/, vendor/, ...). Matched against directory
|
|
73
|
+
# names *inside* the repo only.
|
|
74
|
+
SKIP_DIRS = DEFAULT_SKIP_DIRS | {".terraform"}
|
|
75
|
+
# ``.tf.json`` can't be expressed as a single suffix, so walk ``.json`` too and
|
|
76
|
+
# narrow with ``_is_terraform_file``.
|
|
77
|
+
_WALK_SUFFIXES = {".tf", ".tfvars", ".json"}
|
|
78
|
+
_SNIPPET_MAX_CHARS = 160
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------- Linear-time bookkeeping (hostile input) ----------
|
|
82
|
+
|
|
83
|
+
# Dedup keys per result: a linear `any(...)` scan per append is quadratic on
|
|
84
|
+
# a file with tens of thousands of blocks. Keyed by id(); the result object
|
|
85
|
+
# is kept to rule out id reuse, and dropped at the end of `analyze`.
|
|
86
|
+
_SEEN: dict[int, tuple[object, set]] = {}
|
|
87
|
+
|
|
88
|
+
# Feature detection: a core older than AttackMap#334 neither exports the
|
|
89
|
+
# record nor has the field, and ignores records. The pre-existing exposure
|
|
90
|
+
# hints (s3_public_*, rds_publicly_accessible, apigw*_open, ...) are emitted
|
|
91
|
+
# on every core either way.
|
|
92
|
+
_CLOUD_RECORDS = CloudMisconfig is not None and "cloud_misconfigs" in getattr(ScanResult, "model_fields", {})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _first_seen(owner: object, key: tuple) -> bool:
|
|
96
|
+
entry = _SEEN.get(id(owner))
|
|
97
|
+
if entry is None or entry[0] is not owner:
|
|
98
|
+
entry = _SEEN[id(owner)] = (owner, set())
|
|
99
|
+
if key in entry[1]:
|
|
100
|
+
return False
|
|
101
|
+
entry[1].add(key)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Line starts of the file being analyzed. `attackmap.sdk.line_of` recounts
|
|
106
|
+
# newlines from the top on every call (quadratic for many blocks); files are
|
|
107
|
+
# analyzed one at a time, so one cached index is enough.
|
|
108
|
+
_LINES: list = [None, [0]]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def line_of(content: str, offset: int) -> int:
|
|
112
|
+
"""1-indexed line of ``offset`` (same numbering as `attackmap.sdk.line_of`)."""
|
|
113
|
+
if _LINES[0] is not content:
|
|
114
|
+
starts = [0]
|
|
115
|
+
index = content.find("\n")
|
|
116
|
+
while index != -1:
|
|
117
|
+
starts.append(index + 1)
|
|
118
|
+
index = content.find("\n", index + 1)
|
|
119
|
+
_LINES[0], _LINES[1] = content, starts
|
|
120
|
+
return bisect_right(_LINES[1], max(offset, 0))
|
|
121
|
+
|
|
122
|
+
# ---------- Patterns ----------
|
|
123
|
+
|
|
124
|
+
# Top-level resource block header: resource "type" "name" {
|
|
125
|
+
RESOURCE_BLOCK_PATTERN = re.compile(
|
|
126
|
+
r'\bresource\s+"([a-z][a-z0-9_]+)"\s+"([a-zA-Z0-9_-]+)"\s*\{',
|
|
127
|
+
)
|
|
128
|
+
# Top-level variable block header: variable "name" {
|
|
129
|
+
VARIABLE_BLOCK_PATTERN = re.compile(
|
|
130
|
+
r'\bvariable\s+"([a-zA-Z0-9_-]+)"\s*\{',
|
|
131
|
+
)
|
|
132
|
+
# Top-level data source: data "type" "name" {
|
|
133
|
+
DATA_BLOCK_PATTERN = re.compile(
|
|
134
|
+
r'\bdata\s+"([a-z][a-z0-9_]+)"\s+"([a-zA-Z0-9_-]+)"\s*\{',
|
|
135
|
+
)
|
|
136
|
+
# Module block: module "name" {
|
|
137
|
+
MODULE_BLOCK_PATTERN = re.compile(
|
|
138
|
+
r'\bmodule\s+"([a-zA-Z0-9_-]+)"\s*\{',
|
|
139
|
+
)
|
|
140
|
+
# Provider declaration: provider "aws" { ... }
|
|
141
|
+
PROVIDER_BLOCK_PATTERN = re.compile(
|
|
142
|
+
r'\bprovider\s+"([a-z][a-z0-9_]+)"\s*\{',
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Common attribute extraction inside block bodies
|
|
146
|
+
_ATTR_RE = re.compile(r'^\s*(\w+)\s*=\s*(.+?)\s*$', re.MULTILINE)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_terraform_file(path: Path) -> bool:
|
|
150
|
+
return path.suffix == ".tf" or path.name.endswith(".tf.json") or path.suffix == ".tfvars"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
154
|
+
# Kept local rather than ``attackmap.sdk.line_snippet(content, line_of(...))``:
|
|
155
|
+
# the SDK helper indexes ``str.splitlines()``, which also breaks on form
|
|
156
|
+
# feeds and lone ``\r``, so its line numbering can disagree with
|
|
157
|
+
# ``line_of`` (which counts ``\n`` only).
|
|
158
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
159
|
+
line_end = content.find("\n", offset)
|
|
160
|
+
if line_end == -1:
|
|
161
|
+
line_end = len(content)
|
|
162
|
+
line = content[line_start:line_end].strip()
|
|
163
|
+
if len(line) > max_chars:
|
|
164
|
+
line = line[: max_chars - 1] + "…"
|
|
165
|
+
return line
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _skip_non_code(content: str, i: int) -> int | None:
|
|
169
|
+
"""If a comment (``#``, ``//``, ``/* */``) or heredoc starts at ``i``,
|
|
170
|
+
the offset just past it; else None. Braces and quotes in them don't
|
|
171
|
+
count (a stray ``{`` in a comment used to swallow the rest of the file)."""
|
|
172
|
+
ch = content[i]
|
|
173
|
+
if ch == "#" or content.startswith("//", i):
|
|
174
|
+
end = content.find("\n", i)
|
|
175
|
+
return len(content) if end == -1 else end
|
|
176
|
+
if content.startswith("/*", i):
|
|
177
|
+
end = content.find("*/", i + 2)
|
|
178
|
+
return len(content) if end == -1 else end + 2
|
|
179
|
+
if content.startswith("<<", i):
|
|
180
|
+
heredoc = _HEREDOC_RE.match(content, i)
|
|
181
|
+
if heredoc:
|
|
182
|
+
terminator = re.compile(rf"^[ \t]*{re.escape(heredoc.group(1))}[ \t]*$", re.MULTILINE)
|
|
183
|
+
end = terminator.search(content, heredoc.end())
|
|
184
|
+
return len(content) if end is None else end.end()
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _brace_pairs(content: str) -> tuple[dict[int, int], bytearray]:
|
|
189
|
+
"""One linear pass: the closing ``}`` of every code ``{``, and a mask of
|
|
190
|
+
the characters inside comments, heredocs and strings. Strings end at a
|
|
191
|
+
newline (HCL quoted strings are single-line), so a stray ``"`` can't
|
|
192
|
+
swallow the file either."""
|
|
193
|
+
closes: dict[int, int] = {}
|
|
194
|
+
ignored = bytearray(len(content))
|
|
195
|
+
stack: list[int] = []
|
|
196
|
+
i, n = 0, len(content)
|
|
197
|
+
while i < n:
|
|
198
|
+
ch = content[i]
|
|
199
|
+
if ch == '"':
|
|
200
|
+
j = i + 1
|
|
201
|
+
while j < n and content[j] not in '"\n':
|
|
202
|
+
j += 2 if content[j] == "\\" else 1
|
|
203
|
+
end = min(j + 1, n)
|
|
204
|
+
ignored[i:end] = b"\x01" * (end - i)
|
|
205
|
+
i = end
|
|
206
|
+
continue
|
|
207
|
+
skipped = _skip_non_code(content, i) if ch in "#/<" else None
|
|
208
|
+
if skipped is not None:
|
|
209
|
+
ignored[i:skipped] = b"\x01" * (skipped - i)
|
|
210
|
+
i = skipped
|
|
211
|
+
continue
|
|
212
|
+
if ch == "{":
|
|
213
|
+
stack.append(i)
|
|
214
|
+
elif ch == "}" and stack:
|
|
215
|
+
closes[stack.pop()] = i
|
|
216
|
+
i += 1
|
|
217
|
+
return closes, ignored
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _top_blocks(pattern: re.Pattern[str], content: str):
|
|
221
|
+
"""``(match, body)`` for each block header ``pattern`` finds outside
|
|
222
|
+
comments and strings. Top-level blocks don't nest, so headers inside a
|
|
223
|
+
terminated block are skipped. An unterminated block's body ends at the
|
|
224
|
+
next header, and scanning resumes after its own header, so one broken
|
|
225
|
+
block no longer hides the rest of the file. Linear overall."""
|
|
226
|
+
closes, ignored = _brace_pairs(content)
|
|
227
|
+
headers = [m for m in pattern.finditer(content) if not ignored[m.start()]]
|
|
228
|
+
resume = 0
|
|
229
|
+
for index, match in enumerate(headers):
|
|
230
|
+
if match.start() < resume:
|
|
231
|
+
continue
|
|
232
|
+
close = closes.get(match.end() - 1)
|
|
233
|
+
if close is None:
|
|
234
|
+
end = headers[index + 1].start() if index + 1 < len(headers) else len(content)
|
|
235
|
+
yield match, content[match.end():end]
|
|
236
|
+
resume = match.end()
|
|
237
|
+
else:
|
|
238
|
+
yield match, content[match.end():close]
|
|
239
|
+
resume = close + 1
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _block_body(content: str, body_start: int, *, heredocs: bool = True) -> tuple[str, int]:
|
|
243
|
+
"""Return the body of a block whose `{` is at body_start-1, plus the offset
|
|
244
|
+
of the closing `}`. Tracks brace depth, string literals, comments and
|
|
245
|
+
(with ``heredocs``) heredocs, so nested `ingress { ... }` blocks are
|
|
246
|
+
included and a `{` in a comment doesn't count. ``heredocs`` must match
|
|
247
|
+
the `_scan_text` mode of the caller."""
|
|
248
|
+
depth = 1
|
|
249
|
+
i = body_start
|
|
250
|
+
in_string = False
|
|
251
|
+
escape = False
|
|
252
|
+
while i < len(content) and depth > 0:
|
|
253
|
+
ch = content[i]
|
|
254
|
+
if in_string:
|
|
255
|
+
if escape:
|
|
256
|
+
escape = False
|
|
257
|
+
elif ch == "\\":
|
|
258
|
+
escape = True
|
|
259
|
+
elif ch == '"':
|
|
260
|
+
in_string = False
|
|
261
|
+
else:
|
|
262
|
+
if ch in "#/<" and (heredocs or ch != "<"):
|
|
263
|
+
skipped = _skip_non_code(content, i)
|
|
264
|
+
if skipped is not None:
|
|
265
|
+
i = skipped
|
|
266
|
+
continue
|
|
267
|
+
if ch == '"':
|
|
268
|
+
in_string = True
|
|
269
|
+
elif ch == "{":
|
|
270
|
+
depth += 1
|
|
271
|
+
elif ch == "}":
|
|
272
|
+
depth -= 1
|
|
273
|
+
if depth == 0:
|
|
274
|
+
return content[body_start:i], i
|
|
275
|
+
i += 1
|
|
276
|
+
return content[body_start:i], i
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _scan_text(text: str, *, heredocs: bool = True) -> tuple[list[int], list[bool]]:
|
|
280
|
+
"""Per-character brace depth and string mask for ``text``.
|
|
281
|
+
|
|
282
|
+
``depths[i]`` is the ``{ }`` nesting depth of the code around character
|
|
283
|
+
``i`` (an opening ``{`` and its matching ``}`` both carry the outer depth);
|
|
284
|
+
``in_string[i]`` is True for characters of a ``"..."`` literal (quotes
|
|
285
|
+
included), a comment, or (with ``heredocs``) a heredoc body. Braces in
|
|
286
|
+
any of them don't count. Policy scanning passes ``heredocs=False`` to see
|
|
287
|
+
the JSON inside a heredoc."""
|
|
288
|
+
depths = [0] * len(text)
|
|
289
|
+
in_string = [False] * len(text)
|
|
290
|
+
depth = 0
|
|
291
|
+
inside = False
|
|
292
|
+
escape = False
|
|
293
|
+
skip_to = 0
|
|
294
|
+
for i, ch in enumerate(text):
|
|
295
|
+
if i < skip_to:
|
|
296
|
+
in_string[i] = True
|
|
297
|
+
depths[i] = depth
|
|
298
|
+
continue
|
|
299
|
+
if inside:
|
|
300
|
+
in_string[i] = True
|
|
301
|
+
depths[i] = depth
|
|
302
|
+
if escape:
|
|
303
|
+
escape = False
|
|
304
|
+
elif ch == "\\":
|
|
305
|
+
escape = True
|
|
306
|
+
elif ch == '"':
|
|
307
|
+
inside = False
|
|
308
|
+
continue
|
|
309
|
+
if ch in "#/<" and (heredocs or ch != "<"):
|
|
310
|
+
skipped = _skip_non_code(text, i)
|
|
311
|
+
if skipped is not None:
|
|
312
|
+
skip_to = skipped
|
|
313
|
+
in_string[i] = True
|
|
314
|
+
depths[i] = depth
|
|
315
|
+
continue
|
|
316
|
+
if ch == '"':
|
|
317
|
+
inside = True
|
|
318
|
+
in_string[i] = True
|
|
319
|
+
elif ch == "}":
|
|
320
|
+
depth = max(depth - 1, 0)
|
|
321
|
+
depths[i] = depth
|
|
322
|
+
if ch == "{":
|
|
323
|
+
depth += 1
|
|
324
|
+
return depths, in_string
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _blank_comments(text: str) -> str:
|
|
328
|
+
"""``text`` with every ``#``, ``//`` and ``/* */`` comment replaced by
|
|
329
|
+
spaces (newlines kept, so offsets and line numbers don't move). Strings
|
|
330
|
+
(``"a#b"``) and heredoc bodies are left as they are. Attribute lookups
|
|
331
|
+
read a value up to the end of its line, so ``x = true # note`` would
|
|
332
|
+
otherwise read as ``true # note``."""
|
|
333
|
+
out = list(text)
|
|
334
|
+
i, n = 0, len(text)
|
|
335
|
+
while i < n:
|
|
336
|
+
ch = text[i]
|
|
337
|
+
if ch == '"':
|
|
338
|
+
j = i + 1
|
|
339
|
+
while j < n and text[j] not in '"\n':
|
|
340
|
+
j += 2 if text[j] == "\\" else 1
|
|
341
|
+
i = j + 1
|
|
342
|
+
continue
|
|
343
|
+
if ch in "#/<":
|
|
344
|
+
end = _skip_non_code(text, i)
|
|
345
|
+
if end is not None:
|
|
346
|
+
if ch != "<": # a comment; heredocs are kept
|
|
347
|
+
for k in range(i, end):
|
|
348
|
+
if out[k] != "\n":
|
|
349
|
+
out[k] = " "
|
|
350
|
+
i = end
|
|
351
|
+
continue
|
|
352
|
+
i += 1
|
|
353
|
+
return "".join(out)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _top_level(body: str) -> str:
|
|
357
|
+
"""``body`` with the contents of every nested ``{ ... }`` removed (the
|
|
358
|
+
braces themselves are kept), so attribute lookups only see the block's own
|
|
359
|
+
attributes and not those of nested blocks or object values. Comments are
|
|
360
|
+
blanked (``_blank_comments``)."""
|
|
361
|
+
body = _blank_comments(body)
|
|
362
|
+
depths, _ = _scan_text(body)
|
|
363
|
+
return "".join(ch for ch, d in zip(body, depths) if d == 0)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _at_top(depths: list[int], in_string: list[bool], text: str, i: int) -> bool:
|
|
367
|
+
"""True if a token starting at ``i`` is top-level code (or the opening
|
|
368
|
+
quote of a top-level string key)."""
|
|
369
|
+
if depths[i] != 0:
|
|
370
|
+
return False
|
|
371
|
+
if not in_string[i]:
|
|
372
|
+
return True
|
|
373
|
+
return text[i] == '"' and (i == 0 or not in_string[i - 1])
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _extract_attr(body: str, name: str) -> str | None:
|
|
377
|
+
"""Return the value of a top-level attribute `name = ...` in a block body
|
|
378
|
+
(does not descend into nested blocks). Strips matching quotes."""
|
|
379
|
+
top = _top_level(body)
|
|
380
|
+
pattern = re.compile(rf'^\s*{re.escape(name)}\s*=\s*"([^"]*)"\s*$', re.MULTILINE)
|
|
381
|
+
match = pattern.search(top)
|
|
382
|
+
if match:
|
|
383
|
+
return match.group(1)
|
|
384
|
+
# Try unquoted form (booleans, references, numbers, lists)
|
|
385
|
+
raw_pattern = re.compile(rf'^\s*{re.escape(name)}\s*=\s*([^\n]+?)\s*$', re.MULTILINE)
|
|
386
|
+
match = raw_pattern.search(top)
|
|
387
|
+
if match:
|
|
388
|
+
return match.group(1).strip()
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _sub_blocks(body: str, name: str) -> list[str]:
|
|
393
|
+
"""Bodies of the nested blocks called ``name`` directly inside ``body``.
|
|
394
|
+
|
|
395
|
+
Covers ``name { ... }``, ``dynamic "name" { ... }`` and the
|
|
396
|
+
attributes-as-blocks form ``name = [{ ... }, { ... }]``. Blocks nested
|
|
397
|
+
deeper than one level are not returned."""
|
|
398
|
+
depths, in_string = _scan_text(body)
|
|
399
|
+
header = re.compile(rf'(?:(?<![\w.]){re.escape(name)}|\bdynamic\s+"{re.escape(name)}")\s*(\{{|=\s*\[)')
|
|
400
|
+
blocks: list[str] = []
|
|
401
|
+
for match in header.finditer(body):
|
|
402
|
+
if not _at_top(depths, in_string, body, match.start()):
|
|
403
|
+
continue
|
|
404
|
+
if match.group(1) == "{":
|
|
405
|
+
inner, _ = _block_body(body, match.end())
|
|
406
|
+
blocks.append(inner)
|
|
407
|
+
continue
|
|
408
|
+
i = match.end()
|
|
409
|
+
while i < len(body):
|
|
410
|
+
ch = body[i]
|
|
411
|
+
if depths[i] == 0 and not in_string[i] and ch == "]":
|
|
412
|
+
break
|
|
413
|
+
if depths[i] == 0 and not in_string[i] and ch == "{":
|
|
414
|
+
inner, end = _block_body(body, i + 1)
|
|
415
|
+
blocks.append(inner)
|
|
416
|
+
i = end + 1
|
|
417
|
+
continue
|
|
418
|
+
i += 1
|
|
419
|
+
return blocks
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _attr_value(text: str, key: str) -> str | None:
|
|
423
|
+
"""Raw value of ``key = value`` / ``"key": value`` at the top level of
|
|
424
|
+
``text`` (case-insensitive key). Returns the string literal, the whole
|
|
425
|
+
``[...]`` list or the whole ``{...}`` object, or None when absent."""
|
|
426
|
+
depths, in_string = _scan_text(text)
|
|
427
|
+
pattern = re.compile(rf'(?<![\w.])"?{re.escape(key)}"?\s*[:=](?!=)\s*', re.IGNORECASE)
|
|
428
|
+
for match in pattern.finditer(text):
|
|
429
|
+
if not _at_top(depths, in_string, text, match.start()):
|
|
430
|
+
continue
|
|
431
|
+
i = match.end()
|
|
432
|
+
if i >= len(text):
|
|
433
|
+
return None
|
|
434
|
+
ch = text[i]
|
|
435
|
+
if ch == '"':
|
|
436
|
+
end = i + 1
|
|
437
|
+
while end < len(text) and not (text[end] == '"' and text[end - 1] != "\\"):
|
|
438
|
+
end += 1
|
|
439
|
+
return text[i : end + 1]
|
|
440
|
+
if ch == "{":
|
|
441
|
+
inner, _ = _block_body(text, i + 1)
|
|
442
|
+
return "{" + inner + "}"
|
|
443
|
+
heredoc = _HEREDOC_RE.match(text, i)
|
|
444
|
+
if heredoc:
|
|
445
|
+
marker = heredoc.group(1)
|
|
446
|
+
terminator = re.compile(rf"^[ \t]*{re.escape(marker)}[ \t]*$", re.MULTILINE)
|
|
447
|
+
end = terminator.search(text, heredoc.end())
|
|
448
|
+
return text[i : end.end() if end else len(text)]
|
|
449
|
+
# Lists, function calls (`jsonencode({...})`), references: read up to
|
|
450
|
+
# the end of the line or a top-level `,` (one-line objects), extended
|
|
451
|
+
# across balanced ( [ { and strings.
|
|
452
|
+
level = 0
|
|
453
|
+
quoted = False
|
|
454
|
+
end = i
|
|
455
|
+
while end < len(text):
|
|
456
|
+
c = text[end]
|
|
457
|
+
if quoted:
|
|
458
|
+
if c == "\\":
|
|
459
|
+
end += 2
|
|
460
|
+
continue
|
|
461
|
+
if c == '"':
|
|
462
|
+
quoted = False
|
|
463
|
+
elif c == '"':
|
|
464
|
+
quoted = True
|
|
465
|
+
elif c in "([{":
|
|
466
|
+
level += 1
|
|
467
|
+
elif c in ")]}":
|
|
468
|
+
level -= 1
|
|
469
|
+
if level < 0:
|
|
470
|
+
break
|
|
471
|
+
elif c in "\n," and level == 0:
|
|
472
|
+
break
|
|
473
|
+
end += 1
|
|
474
|
+
return text[i:end].strip()
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
_HEREDOC_RE = re.compile(r"<<-?\s*([A-Za-z_]\w*)[ \t]*\n")
|
|
479
|
+
_WILDCARD_RE = re.compile(r'"\*"')
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _has_wildcard(value: str | None) -> bool:
|
|
483
|
+
"""True if ``value`` (a string literal, list or object) contains the bare
|
|
484
|
+
``"*"`` element. ``"s3:*"`` and similar service wildcards don't count."""
|
|
485
|
+
return value is not None and _WILDCARD_RE.search(value) is not None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _has_open_cidr(body: str) -> bool:
|
|
489
|
+
"""True if the body references an open CIDR (`0.0.0.0/0` or `::/0`)
|
|
490
|
+
as a list element."""
|
|
491
|
+
return '"0.0.0.0/0"' in body or '"::/0"' in body
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _has_open_ingress(sg_body: str) -> bool:
|
|
495
|
+
"""True if any ``ingress`` sub-block of an ``aws_security_group`` allows
|
|
496
|
+
an open CIDR. Open ``egress`` (the near-universal default) is ignored."""
|
|
497
|
+
return any(_has_open_cidr(block) for block in _sub_blocks(sg_body, "ingress"))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
_STATEMENT_KEY_RE = re.compile(
|
|
501
|
+
r'(?<![\w.])"?(?:Effect|Action|NotAction|Principal|NotPrincipal)"?\s*[:=](?!=)', re.IGNORECASE,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
_POLICY_MAX_DEPTH = 8
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _json_policy_statements(text: str) -> list[str]:
|
|
509
|
+
"""Bodies of IAM policy statement objects found anywhere in ``text``
|
|
510
|
+
(``jsonencode({...})``, heredoc JSON or a JSON string rendered as a
|
|
511
|
+
heredoc). A statement is any ``{ ... }`` whose own keys include Effect,
|
|
512
|
+
Action, NotAction, Principal or NotPrincipal (case-insensitive)."""
|
|
513
|
+
depths, in_string = _scan_text(text, heredocs=False)
|
|
514
|
+
statements: list[str] = []
|
|
515
|
+
for i, ch in enumerate(text):
|
|
516
|
+
# Statements sit a few levels deep (jsonencode({ Statement = [{...}] })).
|
|
517
|
+
# Bounding the depth keeps this linear: every `{` used to re-scan its
|
|
518
|
+
# whole body, quadratic in nesting.
|
|
519
|
+
if ch != "{" or in_string[i] or depths[i] > _POLICY_MAX_DEPTH:
|
|
520
|
+
continue
|
|
521
|
+
inner, _ = _block_body(text, i + 1, heredocs=False)
|
|
522
|
+
if _STATEMENT_KEY_RE.search(_top_level(inner)):
|
|
523
|
+
statements.append(inner)
|
|
524
|
+
return statements
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _statement_is_deny(effect: str | None) -> bool:
|
|
528
|
+
return (effect or "").strip().strip('"').lower() == "deny"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class _PolicyFindings:
|
|
532
|
+
__slots__ = ("wildcard_action", "wildcard_resource", "public_principal")
|
|
533
|
+
|
|
534
|
+
def __init__(self) -> None:
|
|
535
|
+
self.wildcard_action = False
|
|
536
|
+
self.wildcard_resource = False
|
|
537
|
+
# Allow + Principal "*" with no Condition: anyone can use it.
|
|
538
|
+
self.public_principal = False
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _scan_json_policies(text: str) -> _PolicyFindings:
|
|
542
|
+
findings = _PolicyFindings()
|
|
543
|
+
for statement in _json_policy_statements(text):
|
|
544
|
+
if _statement_is_deny(_attr_value(statement, "Effect")):
|
|
545
|
+
continue
|
|
546
|
+
if _has_wildcard(_attr_value(statement, "Action")):
|
|
547
|
+
findings.wildcard_action = True
|
|
548
|
+
if _has_wildcard(_attr_value(statement, "Resource")):
|
|
549
|
+
findings.wildcard_resource = True
|
|
550
|
+
if _has_wildcard(_attr_value(statement, "Principal")) and _attr_value(statement, "Condition") is None:
|
|
551
|
+
findings.public_principal = True
|
|
552
|
+
return findings
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _scan_policy_document(body: str) -> _PolicyFindings:
|
|
556
|
+
"""``data "aws_iam_policy_document"``: HCL ``statement { ... }`` blocks."""
|
|
557
|
+
findings = _PolicyFindings()
|
|
558
|
+
for statement in _sub_blocks(body, "statement"):
|
|
559
|
+
if _statement_is_deny(_extract_attr(statement, "effect")):
|
|
560
|
+
continue
|
|
561
|
+
top = _top_level(statement)
|
|
562
|
+
if _has_wildcard(_attr_value(top, "actions")):
|
|
563
|
+
findings.wildcard_action = True
|
|
564
|
+
if _has_wildcard(_attr_value(top, "resources")):
|
|
565
|
+
findings.wildcard_resource = True
|
|
566
|
+
if not _sub_blocks(statement, "condition"):
|
|
567
|
+
for principals in _sub_blocks(statement, "principals"):
|
|
568
|
+
if _has_wildcard(_attr_value(principals, "identifiers")) or _has_wildcard(
|
|
569
|
+
_attr_value(principals, "type")
|
|
570
|
+
):
|
|
571
|
+
findings.public_principal = True
|
|
572
|
+
return findings
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# Provider/framework labels — used both as framework_hints and to gate cloud-specific extractors.
|
|
576
|
+
_PROVIDER_LABEL = {
|
|
577
|
+
"aws": "terraform-aws",
|
|
578
|
+
"azurerm": "terraform-azure",
|
|
579
|
+
"google": "terraform-gcp",
|
|
580
|
+
"kubernetes": "terraform-kubernetes",
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# Database engine inference for aws_db_instance / aws_rds_cluster
|
|
585
|
+
_ENGINE_KIND_MAP = {
|
|
586
|
+
"postgres": "postgresql",
|
|
587
|
+
"postgresql": "postgresql",
|
|
588
|
+
"aurora-postgresql": "postgresql",
|
|
589
|
+
"mysql": "mysql",
|
|
590
|
+
"aurora-mysql": "mysql",
|
|
591
|
+
"mariadb": "mariadb",
|
|
592
|
+
"oracle-ee": "oracle",
|
|
593
|
+
"oracle-se": "oracle",
|
|
594
|
+
"oracle-se2": "oracle",
|
|
595
|
+
"sqlserver-ee": "sqlserver",
|
|
596
|
+
"sqlserver-se": "sqlserver",
|
|
597
|
+
"sqlserver-ex": "sqlserver",
|
|
598
|
+
"sqlserver-web": "sqlserver",
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
_SECRET_KEYWORDS = ("secret", "token", "key", "password", "pass", "pwd", "credential", "apikey")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _looks_secret_shaped(name: str) -> bool:
|
|
606
|
+
lowered = name.lower()
|
|
607
|
+
return any(kw in lowered for kw in _SECRET_KEYWORDS)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
_PUBLIC_S3_ACLS = {"public-read", "public-read-write"}
|
|
611
|
+
_IAM_POLICY_RESOURCES = {
|
|
612
|
+
"aws_iam_policy",
|
|
613
|
+
"aws_iam_role_policy",
|
|
614
|
+
"aws_iam_user_policy",
|
|
615
|
+
"aws_iam_group_policy",
|
|
616
|
+
"aws_iam_role",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
# `key = "literal"` in a .tfvars file (string values only; numbers/bools are
|
|
620
|
+
# never credentials and references aren't allowed in tfvars).
|
|
621
|
+
_TFVARS_ASSIGN_RE = re.compile(r'^[ \t]*([A-Za-z_][\w-]*)[ \t]*=[ \t]*"((?:[^"\\\n]|\\.)*)"', re.MULTILINE)
|
|
622
|
+
# Secret-shaped names whose value is an identifier/locator, not a credential
|
|
623
|
+
# (`kms_key_id`, `ssh_key_name`, `secret_arn`, `token_ttl`, ...).
|
|
624
|
+
_NON_SECRET_LAST_TOKENS = {
|
|
625
|
+
"name", "names", "id", "ids", "arn", "arns", "path", "file", "type", "length",
|
|
626
|
+
"size", "version", "count", "enabled", "enable", "days", "period", "ttl",
|
|
627
|
+
"alias", "prefix", "suffix", "region", "rotation", "policy", "usage", "spec",
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _tfvars_key_is_secret(key: str) -> bool:
|
|
632
|
+
if not _looks_secret_shaped(key):
|
|
633
|
+
return False
|
|
634
|
+
last = re.split(r"[_-]", key.lower())[-1]
|
|
635
|
+
return last not in _NON_SECRET_LAST_TOKENS
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# `default = "literal"` in a variable block (heredoc / list / map defaults are
|
|
639
|
+
# not credentials).
|
|
640
|
+
_VARIABLE_STRING_DEFAULT_RE = re.compile(r'^[ \t]*default[ \t]*=[ \t]*"((?:[^"\\\n]|\\.)*)"', re.MULTILINE)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
_PUBLIC_KEY_PREFIXES = ("ssh-rsa ", "ssh-ed25519 ", "ssh-dss ", "ecdsa-sha2-", "sk-ssh-", "-----BEGIN PUBLIC KEY", "-----BEGIN CERTIFICATE")
|
|
644
|
+
_URL_WITH_PASSWORD_RE = re.compile(r"://[^/@\s:]{1,256}:[^/@\s]{1,256}@")
|
|
645
|
+
_PLAIN_WORD_RE = re.compile(r"[A-Za-z]{1,12}")
|
|
646
|
+
_IDENTIFIER_NAME_RE = re.compile(r"user|login|account|email|owner", re.IGNORECASE)
|
|
647
|
+
_CREDENTIAL_NAME_RE = re.compile(r"pass|pwd|secret|token|private", re.IGNORECASE)
|
|
648
|
+
_MIN_CREDENTIAL_LEN = 6
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _credential_shaped_default(name: str, value: str) -> bool:
|
|
652
|
+
"""Whether a secret variable's literal default looks like a credential
|
|
653
|
+
rather than a public key, URL, username or short setting (`off`,
|
|
654
|
+
`UserId`, `master`)."""
|
|
655
|
+
value = value.strip()
|
|
656
|
+
if not value or _looks_like_reference(value) or value.startswith(_PUBLIC_KEY_PREFIXES):
|
|
657
|
+
return False
|
|
658
|
+
if "://" in value:
|
|
659
|
+
return bool(_URL_WITH_PASSWORD_RE.search(value)) # only a URL with an embedded password
|
|
660
|
+
if len(value) < _MIN_CREDENTIAL_LEN or _PLAIN_WORD_RE.fullmatch(value):
|
|
661
|
+
return False
|
|
662
|
+
return not (_IDENTIFIER_NAME_RE.search(name) and not _CREDENTIAL_NAME_RE.search(name))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _looks_like_reference(value: str) -> bool:
|
|
666
|
+
return value.startswith(("${", "arn:"))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _json_objects(value: object) -> list[dict]:
|
|
670
|
+
"""Terraform JSON lets any block level be an object or a list of objects."""
|
|
671
|
+
if isinstance(value, dict):
|
|
672
|
+
return [value]
|
|
673
|
+
if isinstance(value, list):
|
|
674
|
+
return [item for item in value if isinstance(item, dict)]
|
|
675
|
+
return []
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _json_scalar_to_hcl(value: object) -> str:
|
|
679
|
+
if isinstance(value, str):
|
|
680
|
+
stripped = value.strip()
|
|
681
|
+
if "\n" in value or (stripped.startswith(("{", "${")) and '"' in value):
|
|
682
|
+
# Embedded policy JSON / `${jsonencode(...)}`: emit as a heredoc so
|
|
683
|
+
# the policy scanners see its structure, not an escaped string.
|
|
684
|
+
try:
|
|
685
|
+
parsed = json.loads(value)
|
|
686
|
+
except ValueError:
|
|
687
|
+
text = value
|
|
688
|
+
else:
|
|
689
|
+
text = json.dumps(parsed, indent=2)
|
|
690
|
+
return f"<<EOT\n{text}\nEOT"
|
|
691
|
+
return json.dumps(value)
|
|
692
|
+
if isinstance(value, bool):
|
|
693
|
+
return "true" if value else "false"
|
|
694
|
+
if value is None:
|
|
695
|
+
return "null"
|
|
696
|
+
return json.dumps(value)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _json_to_hcl(obj: dict, indent: int = 0) -> str:
|
|
700
|
+
"""Render a Terraform JSON block body as HCL. Object values and lists of
|
|
701
|
+
objects become nested blocks (how Terraform JSON spells `ingress { }`,
|
|
702
|
+
`statement { }`, ...); everything else is an attribute."""
|
|
703
|
+
pad = " " * indent
|
|
704
|
+
lines: list[str] = []
|
|
705
|
+
for key, value in obj.items():
|
|
706
|
+
blocks = _json_objects(value)
|
|
707
|
+
if blocks and (isinstance(value, dict) or len(blocks) == len(value)):
|
|
708
|
+
for block in blocks:
|
|
709
|
+
lines.append(f"{pad}{key} {{")
|
|
710
|
+
lines.append(_json_to_hcl(block, indent + 1))
|
|
711
|
+
lines.append(f"{pad}}}")
|
|
712
|
+
else:
|
|
713
|
+
lines.append(f"{pad}{key} = {_json_scalar_to_hcl(value)}")
|
|
714
|
+
return "\n".join(lines)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class _JsonLocator:
|
|
718
|
+
"""Find the source offset of a JSON object key, scanning forward so that
|
|
719
|
+
repeated names (same resource name under two types) resolve in order."""
|
|
720
|
+
|
|
721
|
+
def __init__(self, content: str) -> None:
|
|
722
|
+
self.content = content
|
|
723
|
+
self.pos = 0
|
|
724
|
+
|
|
725
|
+
def find(self, key: str, *, from_start: bool = False) -> int:
|
|
726
|
+
if from_start:
|
|
727
|
+
self.pos = 0
|
|
728
|
+
pattern = re.compile(r'"' + re.escape(key) + r'"\s*:')
|
|
729
|
+
match = pattern.search(self.content, self.pos) or pattern.search(self.content)
|
|
730
|
+
if match is None:
|
|
731
|
+
return self.pos
|
|
732
|
+
self.pos = match.end()
|
|
733
|
+
return match.start()
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ---------- Module sources (#4) ----------
|
|
737
|
+
|
|
738
|
+
# go-getter git sources: forced `git::`, scp-like `git@host:`, the GitHub /
|
|
739
|
+
# Bitbucket shorthands, or a `.git` repository path.
|
|
740
|
+
_GIT_SOURCE_RE = re.compile(r"^(?:git::|git@|(?:github\.com|bitbucket\.org)/)", re.IGNORECASE)
|
|
741
|
+
_GIT_SUFFIX_RE = re.compile(r"\.git(?:$|[/?])", re.IGNORECASE)
|
|
742
|
+
# Registry address: [<host>/]<namespace>/<name>/<provider>[//<subdir>].
|
|
743
|
+
_REGISTRY_SOURCE_RE = re.compile(
|
|
744
|
+
r"^(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/)?[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+(?://[^?]*)?$"
|
|
745
|
+
)
|
|
746
|
+
_GIT_REF_RE = re.compile(r"[?&]ref=([^&#]+)")
|
|
747
|
+
_COMMIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$")
|
|
748
|
+
_BRANCH_REFS = frozenset({
|
|
749
|
+
"main", "master", "develop", "development", "dev", "trunk", "head", "latest",
|
|
750
|
+
"stable", "release", "next", "prod", "production", "staging",
|
|
751
|
+
})
|
|
752
|
+
_MODULE_ESCALATION = "whoever can move that reference changes what `terraform apply` builds, with the deployer's cloud credentials"
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _module_pin_problem(source: str, version: str | None) -> tuple[str, str, float] | None:
|
|
756
|
+
"""``(severity, reason, confidence)`` when a remote module source isn't
|
|
757
|
+
pinned to an immutable version, else None. Local paths and other
|
|
758
|
+
getters (s3::, gcs::, http archives) are not judged."""
|
|
759
|
+
src = source.strip()
|
|
760
|
+
if src.startswith(("./", "../")):
|
|
761
|
+
return None
|
|
762
|
+
if _GIT_SOURCE_RE.match(src) or _GIT_SUFFIX_RE.search(src.split("?", 1)[0]):
|
|
763
|
+
ref = _GIT_REF_RE.search(src)
|
|
764
|
+
if ref is None:
|
|
765
|
+
return "medium", "git source without ?ref= follows the default branch", 0.9
|
|
766
|
+
value = ref.group(1)
|
|
767
|
+
if _COMMIT_SHA_RE.match(value):
|
|
768
|
+
return None
|
|
769
|
+
if value.lower() in _BRANCH_REFS:
|
|
770
|
+
return "medium", f"git source pinned to the branch {value!r}", 0.85
|
|
771
|
+
if any(ch.isdigit() for ch in value):
|
|
772
|
+
return None # a release tag (v1.2.3, 2024.01)
|
|
773
|
+
return "low", f"git ref {value!r} looks like a branch, not a tag or commit", 0.5
|
|
774
|
+
if not src.startswith(("git::", "hg::", "s3::", "gcs::", "http://", "https://")) and _REGISTRY_SOURCE_RE.match(src):
|
|
775
|
+
if not (version or "").strip():
|
|
776
|
+
return "medium", "registry module without a version constraint installs the latest release", 0.9
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
_NESTED_BLOCK_RE = re.compile(r'(?m)^[ \t]*(?:[A-Za-z_][\w-]*|dynamic[ \t]+"[^"\n]*")[ \t]*\{')
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _local_module_dir(caller: str, source: str) -> str | None:
|
|
784
|
+
"""Repo-relative directory a local ``source`` points at, or None when it
|
|
785
|
+
isn't local or leaves the repo."""
|
|
786
|
+
src = source.strip()
|
|
787
|
+
if not src.startswith(("./", "../")):
|
|
788
|
+
return None
|
|
789
|
+
target = posixpath.normpath(posixpath.join(posixpath.dirname(caller), src.split("//", 1)[0]))
|
|
790
|
+
if target == ".." or target.startswith("../") or target.startswith("/"):
|
|
791
|
+
return None
|
|
792
|
+
return target
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _file_dir(relative: str) -> str:
|
|
796
|
+
return posixpath.dirname(relative) or "."
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _instancing(body: str) -> str | None:
|
|
800
|
+
"""``for_each = ...`` / ``count = ...`` of a block, whitespace-folded, so
|
|
801
|
+
a finding can say it applies to every instance."""
|
|
802
|
+
for name in ("for_each", "count"):
|
|
803
|
+
value = _attr_value(_top_level_with_values(body), name)
|
|
804
|
+
if value:
|
|
805
|
+
line = f"{name} = {' '.join(value.split())}"
|
|
806
|
+
return line if len(line) <= 80 else line[:79] + "…"
|
|
807
|
+
return None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _top_level_with_values(body: str) -> str:
|
|
811
|
+
"""``body`` without its nested *blocks* (``name {`` / ``dynamic "x" {``)
|
|
812
|
+
but keeping ``attr = { ... }`` object values, so a ``for_each = {...}``
|
|
813
|
+
is read whole while a nested block's own ``count`` is not. Comments are
|
|
814
|
+
blanked (``_blank_comments``)."""
|
|
815
|
+
body = _blank_comments(body)
|
|
816
|
+
depths, in_string = _scan_text(body)
|
|
817
|
+
keep = [True] * len(body)
|
|
818
|
+
for match in _NESTED_BLOCK_RE.finditer(body):
|
|
819
|
+
start = match.end() - 1 # the `{`
|
|
820
|
+
if depths[match.start()] != 0 or in_string[start]:
|
|
821
|
+
continue
|
|
822
|
+
_, end = _block_body(body, start + 1)
|
|
823
|
+
for i in range(start + 1, min(end, len(body))):
|
|
824
|
+
keep[i] = False
|
|
825
|
+
return "".join(ch for ch, k in zip(body, keep) if k)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ---------- Misconfiguration helpers (#4) ----------
|
|
829
|
+
|
|
830
|
+
_PLATFORM_BY_PREFIX = (("aws_", "aws"), ("azurerm_", "azure"), ("google_", "gcp"), ("kubernetes_", "kubernetes"))
|
|
831
|
+
_PUBLIC_MEMBERS = ("allUsers", "allAuthenticatedUsers")
|
|
832
|
+
_TRUE_RE_CACHE: dict[str, re.Pattern[str]] = {}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _platform(resource_type: str) -> str:
|
|
836
|
+
return next((p for prefix, p in _PLATFORM_BY_PREFIX if resource_type.startswith(prefix)), "terraform")
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _is_true(body: str, name: str) -> bool:
|
|
840
|
+
return (_extract_attr(body, name) or "").strip().lower() == "true"
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _is_literal_false(value: str | None) -> bool:
|
|
844
|
+
return (value or "").strip().lower() == "false"
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _public_members(body: str) -> list[str]:
|
|
848
|
+
"""``allUsers`` / ``allAuthenticatedUsers`` granted by an IAM member,
|
|
849
|
+
binding (``members = [...]``) or ACL ``entity``."""
|
|
850
|
+
found: list[str] = []
|
|
851
|
+
for attr in ("member", "members", "entity", "role_entity"):
|
|
852
|
+
value = _attr_value(_top_level(body), attr) or ""
|
|
853
|
+
found += [m for m in _PUBLIC_MEMBERS if f'"{m}"' in value or f':{m}"' in value]
|
|
854
|
+
return sorted(set(found))
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _set_true_re(name: str) -> re.Pattern[str]:
|
|
858
|
+
pattern = _TRUE_RE_CACHE.get(name)
|
|
859
|
+
if pattern is None:
|
|
860
|
+
pattern = _TRUE_RE_CACHE[name] = re.compile(rf"^[ \t]*{re.escape(name)}[ \t]*=[ \t]*true\b", re.MULTILINE)
|
|
861
|
+
return pattern
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
# Host paths whose mount hands a container the node (kubernetes provider pods).
|
|
865
|
+
_SENSITIVE_HOST_PATHS = (
|
|
866
|
+
"/var/run/docker.sock", "/run/docker.sock", "/var/run/containerd", "/run/containerd",
|
|
867
|
+
"/var/run/crio", "/var/lib/kubelet", "/etc", "/root", "/proc", "/sys", "/dev", "/boot",
|
|
868
|
+
)
|
|
869
|
+
_HOST_PATH_RE = re.compile(r"\bhost_path\s*\{")
|
|
870
|
+
_HOST_PATH_PATH_RE = re.compile(r'^[ \t]*path[ \t]*=[ \t]*"([^"\n]*)"', re.MULTILINE)
|
|
871
|
+
_K8S_BLOCK_SCAN_LIMIT = 2000
|
|
872
|
+
_TF_K8S_WORKLOADS = frozenset(
|
|
873
|
+
f"kubernetes_{kind}{suffix}"
|
|
874
|
+
for kind in ("pod", "deployment", "daemonset", "daemon_set", "stateful_set", "job", "cron_job", "replication_controller", "replica_set")
|
|
875
|
+
for suffix in ("", "_v1")
|
|
876
|
+
)
|
|
877
|
+
_TF_K8S_ROLES = frozenset({"kubernetes_cluster_role", "kubernetes_cluster_role_v1", "kubernetes_role", "kubernetes_role_v1"})
|
|
878
|
+
_TF_K8S_BINDINGS = frozenset({
|
|
879
|
+
"kubernetes_cluster_role_binding", "kubernetes_cluster_role_binding_v1",
|
|
880
|
+
"kubernetes_role_binding", "kubernetes_role_binding_v1",
|
|
881
|
+
})
|
|
882
|
+
_TF_K8S_SERVICES = frozenset({"kubernetes_service", "kubernetes_service_v1"})
|
|
883
|
+
_BROAD_K8S_GROUPS = frozenset({"system:authenticated", "system:unauthenticated", "system:anonymous"})
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
# `aws_lb.x.arn`, `aws_lb.x[0].arn`, `aws_lb.x["a"].arn`
|
|
887
|
+
_LB_REF_RE = re.compile(r'^aws_(?:lb|alb)\.([\w-]+)(?:\[[^\]\n]{0,64}\])?\.(?:arn|id)$')
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
_V2_API_REF_RE = re.compile(r'^aws_apigatewayv2_api\.([\w-]+)(?:\[[^\]\n]{0,64}\])?\.id$')
|
|
891
|
+
_ACTION_TYPE_RE = re.compile(r"[\w-]{1,64}")
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _action_type(block: str) -> str:
|
|
895
|
+
"""Lower-cased literal `type` of a `default_action` (or the `content` of
|
|
896
|
+
a `dynamic "default_action"`), or ``?`` when it is computed."""
|
|
897
|
+
content = _sub_blocks(block, "content")
|
|
898
|
+
raw = _extract_attr(content[0] if content else block, "type")
|
|
899
|
+
if raw is None:
|
|
900
|
+
return "?" if content or "for_each" in block else ""
|
|
901
|
+
value = raw.strip()
|
|
902
|
+
if not _ACTION_TYPE_RE.fullmatch(value):
|
|
903
|
+
return "?"
|
|
904
|
+
return value.lower()
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# Read-only system data under the sensitive trees: CA bundles, TLS certs,
|
|
908
|
+
# time zone, the syslog socket, GPUs. Same list as attackmap-analyzer-iac.
|
|
909
|
+
_BENIGN_HOST_PATHS = (
|
|
910
|
+
"/etc/ssl", "/etc/pki", "/etc/ca-certificates", "/etc/localtime", "/etc/timezone", "/etc/machine-id",
|
|
911
|
+
"/etc/os-release", "/etc/letsencrypt", "/dev/log", "/dev/dri", "/usr/share/ca-certificates",
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Kubernetes RBAC: same rules as attackmap-analyzer-iac (records.py).
|
|
915
|
+
_SENSITIVE_API_GROUPS = frozenset({
|
|
916
|
+
"", "*", "apps", "batch", "extensions", "policy", "networking.k8s.io", "rbac.authorization.k8s.io",
|
|
917
|
+
"admissionregistration.k8s.io", "certificates.k8s.io", "apiextensions.k8s.io",
|
|
918
|
+
})
|
|
919
|
+
_SENSITIVE_RESOURCES = frozenset({
|
|
920
|
+
"*", "secrets", "pods", "pods/exec", "pods/attach", "pods/ephemeralcontainers", "deployments", "daemonsets",
|
|
921
|
+
"statefulsets", "replicasets", "jobs", "cronjobs", "replicationcontrollers", "serviceaccounts",
|
|
922
|
+
"serviceaccounts/token", "roles", "rolebindings", "clusterroles", "clusterrolebindings", "nodes",
|
|
923
|
+
"nodes/proxy", "persistentvolumes", "mutatingwebhookconfigurations", "validatingwebhookconfigurations",
|
|
924
|
+
"customresourcedefinitions", "certificatesigningrequests", "certificatesigningrequests/approval",
|
|
925
|
+
"configmaps", "namespaces", "networkpolicies",
|
|
926
|
+
})
|
|
927
|
+
_ADMIN_LIKE_ROLES = frozenset({"cluster-admin", "admin", "edit"})
|
|
928
|
+
_INTERNAL_LB_ANNOTATIONS = {
|
|
929
|
+
"aws-load-balancer-internal": {"true", "0.0.0.0/0"},
|
|
930
|
+
"aws-load-balancer-scheme": {"internal"},
|
|
931
|
+
"azure-load-balancer-internal": {"true"},
|
|
932
|
+
"oci-load-balancer-internal": {"true"},
|
|
933
|
+
"openstack-internal-load-balancer": {"true"},
|
|
934
|
+
"load-balancer-type": {"internal"},
|
|
935
|
+
"ibm-load-balancer-cloud-provider-ip-type": {"private"},
|
|
936
|
+
"alibaba-cloud-loadbalancer-address-type": {"intranet"},
|
|
937
|
+
}
|
|
938
|
+
_ANNOTATION_PAIR_RE = re.compile(r'"([^"\n]{1,253})"[ \t]*[=:][ \t]*("[^"\n]{0,253}"|[^\s,}]{1,253})')
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _rbac_wildcard(groups: set[str] | None, verbs: set[str], resources: set[str]) -> bool:
|
|
942
|
+
"""A `*` on a control-plane API group, for every resource or a sensitive
|
|
943
|
+
one (not `verbs = ["*"]` on `events` or `metrics.k8s.io`)."""
|
|
944
|
+
if "*" not in verbs and "*" not in resources:
|
|
945
|
+
return False
|
|
946
|
+
if groups is not None and not groups & _SENSITIVE_API_GROUPS:
|
|
947
|
+
return False
|
|
948
|
+
return "*" in resources or bool(resources & _SENSITIVE_RESOURCES)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _internal_lb(annotations: str) -> bool:
|
|
952
|
+
"""A cloud's internal-LB annotation set to an internal value (an explicit
|
|
953
|
+
`"false"` / `internet-facing` is external; a computed value counts as
|
|
954
|
+
internal, i.e. not reported)."""
|
|
955
|
+
for key, raw in _ANNOTATION_PAIR_RE.findall(annotations):
|
|
956
|
+
accepted = _INTERNAL_LB_ANNOTATIONS.get(key.rsplit("/", 1)[-1].lower())
|
|
957
|
+
if accepted is None:
|
|
958
|
+
continue
|
|
959
|
+
value = raw.strip().strip('"').lower()
|
|
960
|
+
if not raw.startswith('"') and value not in {"true", "false"}:
|
|
961
|
+
return True # var.x / local.y
|
|
962
|
+
if value in accepted or "${" in value:
|
|
963
|
+
return True
|
|
964
|
+
return False
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _host_path_severity(path: str) -> str:
|
|
968
|
+
normalized = posixpath.normpath(path) if path else path
|
|
969
|
+
if any(normalized == p or normalized.startswith(p + "/") for p in _BENIGN_HOST_PATHS):
|
|
970
|
+
return "low"
|
|
971
|
+
if normalized == "/" or any(normalized == p or normalized.startswith(p + "/") for p in _SENSITIVE_HOST_PATHS):
|
|
972
|
+
return "high"
|
|
973
|
+
return "low"
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _string_list(value: str | None) -> list[str]:
|
|
977
|
+
return re.findall(r'"([^"\n]*)"', value or "")
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
# ---------- Route auth (AttackMap#256) ----------
|
|
981
|
+
|
|
982
|
+
REQUIRED, ANONYMOUS, UNKNOWN = "required", "anonymous", "unknown"
|
|
983
|
+
# API Gateway / Lambda URL authorization values that authenticate the caller.
|
|
984
|
+
_APIGW_GUARDS = frozenset({"AWS_IAM", "COGNITO_USER_POOLS", "CUSTOM", "JWT"})
|
|
985
|
+
_APIGW_ROOT_REF = re.compile(r"^aws_api_gateway_rest_api\.[\w-]+\.root_resource_id$")
|
|
986
|
+
_APIGW_RESOURCE_REF = re.compile(r"^aws_api_gateway_resource\.([\w-]+)\.id$")
|
|
987
|
+
_APIGW_PATH_DEPTH = 32
|
|
988
|
+
_APIGW_PATH_PART = re.compile(r"^[\w.~+{}-]+$")
|
|
989
|
+
_TF_REFERENCE = re.compile(r"^(?:var|local|module|data|each|count|aws_\w+)\.")
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _attr_line(body: str, name: str) -> str | None:
|
|
993
|
+
"""The source text of a top-level ``name = ...`` attribute line."""
|
|
994
|
+
match = re.search(rf"^[ \t]*{re.escape(name)}[ \t]*=[^\n]*", _top_level(body), re.MULTILINE)
|
|
995
|
+
return " ".join(match.group(0).split()) if match else None
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _route_auth(body: str, attr: str, extra_guards: tuple[str, ...] = ()) -> tuple[str, list[str], str | None]:
|
|
999
|
+
"""``(auth, guards, evidence)`` from an ``authorization`` /
|
|
1000
|
+
``authorization_type`` attribute. ``NONE`` written out is explicitly
|
|
1001
|
+
public; an absent or computed value (``var.x``) is unknown."""
|
|
1002
|
+
value = (_extract_attr(body, attr) or "").strip()
|
|
1003
|
+
evidence = _attr_line(body, attr)
|
|
1004
|
+
guards: list[str] = []
|
|
1005
|
+
lines: list[str] = []
|
|
1006
|
+
if value.upper() in _APIGW_GUARDS:
|
|
1007
|
+
authorizer = _extract_attr(body, "authorizer_id")
|
|
1008
|
+
guards.append(value.upper() + (f" ({authorizer})" if authorizer else ""))
|
|
1009
|
+
lines += [line for line in (evidence, _attr_line(body, "authorizer_id")) if line]
|
|
1010
|
+
for name in extra_guards:
|
|
1011
|
+
if (_extract_attr(body, name) or "").lower() == "true":
|
|
1012
|
+
guards.append(name)
|
|
1013
|
+
lines += [line for line in (_attr_line(body, name),) if line]
|
|
1014
|
+
if guards:
|
|
1015
|
+
return REQUIRED, guards, "; ".join(lines) or None
|
|
1016
|
+
if value.upper() == "NONE" and evidence:
|
|
1017
|
+
return ANONYMOUS, [], evidence
|
|
1018
|
+
return UNKNOWN, [], None
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
class TerraformAnalyzer:
|
|
1022
|
+
metadata = AnalyzerMetadata(
|
|
1023
|
+
name="terraform",
|
|
1024
|
+
display_name="Terraform / HCL Analyzer",
|
|
1025
|
+
version="0.2.1",
|
|
1026
|
+
description="Terraform infrastructure-as-code analyzer covering AWS, Azure, and GCP resources, IAM wildcards, open security groups, and secrets.",
|
|
1027
|
+
scope="Terraform / OpenTofu projects (.tf files). Detects provider-level resources, public ingress, secret resources, and database engines.",
|
|
1028
|
+
targets=["terraform", "iac", "hcl", "aws", "azure", "gcp"],
|
|
1029
|
+
languages=["hcl"],
|
|
1030
|
+
priority=20,
|
|
1031
|
+
experimental=False,
|
|
1032
|
+
enabled_by_default=True,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
@property
|
|
1036
|
+
def name(self) -> str:
|
|
1037
|
+
return self.metadata.name
|
|
1038
|
+
|
|
1039
|
+
# ---------- Public entry points ----------
|
|
1040
|
+
|
|
1041
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
1042
|
+
root = Path(repo_path).resolve()
|
|
1043
|
+
if not root.exists() or not root.is_dir():
|
|
1044
|
+
return False
|
|
1045
|
+
for path in iter_repo_files(root, suffixes=_WALK_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
1046
|
+
if _is_terraform_file(path):
|
|
1047
|
+
return True
|
|
1048
|
+
return False
|
|
1049
|
+
|
|
1050
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
1051
|
+
root = Path(repo_path).resolve()
|
|
1052
|
+
result = ScanResult(root=str(root))
|
|
1053
|
+
if not root.exists() or not root.is_dir():
|
|
1054
|
+
return result
|
|
1055
|
+
# REST API (v1) methods become routes after the walk, once every
|
|
1056
|
+
# aws_api_gateway_resource (path_part / parent_id) has been seen.
|
|
1057
|
+
self._apigw_resources: dict[str, tuple[str, str]] = {}
|
|
1058
|
+
self._apigw_methods: list[tuple[str, str, tuple[str, list[str], str | None], str, int]] = []
|
|
1059
|
+
# HTTP listeners are judged after the walk, once every aws_lb's
|
|
1060
|
+
# `internal` flag has been seen.
|
|
1061
|
+
# (module directory, aws_lb name) -> internal? (None: computed value)
|
|
1062
|
+
self._lb_internal: dict[tuple[str, str], bool | None] = {}
|
|
1063
|
+
self._http_listeners: list[tuple[str, str, str, int, str, str, str | None]] = []
|
|
1064
|
+
# API Gateway v2 `$default` routes, judged once every API's protocol is known.
|
|
1065
|
+
self._v2_protocols: dict[tuple[str, str], str | None] = {}
|
|
1066
|
+
self._v2_default_routes: list[tuple[str, str, str, str, int, str | None]] = []
|
|
1067
|
+
# Context of the resource being dispatched: for_each/count and, for a
|
|
1068
|
+
# file inside a local module, the module blocks that call it.
|
|
1069
|
+
self._context: str | None = None
|
|
1070
|
+
self._body_line: int | None = None
|
|
1071
|
+
|
|
1072
|
+
files: list[tuple[Path, str, str]] = []
|
|
1073
|
+
for file_path in iter_repo_files(root, suffixes=_WALK_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
1074
|
+
if not _is_terraform_file(file_path):
|
|
1075
|
+
continue
|
|
1076
|
+
content = read_source(file_path)
|
|
1077
|
+
if content is None:
|
|
1078
|
+
continue
|
|
1079
|
+
files.append((file_path, rel(file_path, root), content))
|
|
1080
|
+
self._module_callers = self._collect_module_callers(files)
|
|
1081
|
+
|
|
1082
|
+
for file_path, relative, content in files:
|
|
1083
|
+
result.files_scanned += 1
|
|
1084
|
+
if "hcl" not in result.languages:
|
|
1085
|
+
result.languages.append("hcl")
|
|
1086
|
+
|
|
1087
|
+
if file_path.name.endswith(".tf.json"):
|
|
1088
|
+
self._analyze_tf_json(content, relative, result)
|
|
1089
|
+
continue
|
|
1090
|
+
if file_path.suffix == ".tfvars":
|
|
1091
|
+
self._extract_tfvars_secrets(content, relative, result)
|
|
1092
|
+
self._extract_providers(content, relative, result)
|
|
1093
|
+
self._extract_resources(content, relative, result)
|
|
1094
|
+
self._extract_variables(content, relative, result)
|
|
1095
|
+
self._extract_modules(content, relative, result)
|
|
1096
|
+
self._extract_data_sources(content, relative, result)
|
|
1097
|
+
|
|
1098
|
+
self._emit_http_listeners(result)
|
|
1099
|
+
self._emit_v2_default_routes(result)
|
|
1100
|
+
for resource_ref, http_method, auth, file, line in self._apigw_methods:
|
|
1101
|
+
path = self._apigw_path(resource_ref)
|
|
1102
|
+
if path is not None:
|
|
1103
|
+
self._append_unique_route(
|
|
1104
|
+
result, path, http_method, file, line,
|
|
1105
|
+
auth=auth[0], guards=auth[1], guard_evidence=auth[2],
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
result.languages.sort()
|
|
1109
|
+
_SEEN.pop(id(result), None)
|
|
1110
|
+
return result
|
|
1111
|
+
|
|
1112
|
+
def _apigw_path(self, resource_ref: str) -> str | None:
|
|
1113
|
+
"""Full path of a REST API resource reference, or None when a parent
|
|
1114
|
+
isn't a literal resource in this repo."""
|
|
1115
|
+
parts: list[str] = []
|
|
1116
|
+
ref = resource_ref.strip()
|
|
1117
|
+
for _ in range(_APIGW_PATH_DEPTH):
|
|
1118
|
+
if _APIGW_ROOT_REF.match(ref):
|
|
1119
|
+
return "/" + "/".join(reversed(parts))
|
|
1120
|
+
match = _APIGW_RESOURCE_REF.match(ref)
|
|
1121
|
+
resource = self._apigw_resources.get(match.group(1)) if match else None
|
|
1122
|
+
if resource is None:
|
|
1123
|
+
return None
|
|
1124
|
+
parent, path_part = resource
|
|
1125
|
+
if not _APIGW_PATH_PART.match(path_part) or _TF_REFERENCE.match(path_part):
|
|
1126
|
+
return None # interpolated or computed
|
|
1127
|
+
parts.append(path_part)
|
|
1128
|
+
ref = parent.strip()
|
|
1129
|
+
return None
|
|
1130
|
+
|
|
1131
|
+
# ---------- Extractors ----------
|
|
1132
|
+
|
|
1133
|
+
def _extract_providers(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1134
|
+
for match in PROVIDER_BLOCK_PATTERN.finditer(content):
|
|
1135
|
+
self._handle_provider(
|
|
1136
|
+
match.group(1), relative,
|
|
1137
|
+
line_of(content, match.start()),
|
|
1138
|
+
_line_snippet(content, match.start()),
|
|
1139
|
+
result,
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
def _extract_resources(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1143
|
+
for match, body in _top_blocks(RESOURCE_BLOCK_PATTERN, content):
|
|
1144
|
+
resource_type, resource_name = match.group(1), match.group(2)
|
|
1145
|
+
line = line_of(content, match.start())
|
|
1146
|
+
self._dispatch_with_context(
|
|
1147
|
+
resource_type, resource_name, body, relative, line,
|
|
1148
|
+
_line_snippet(content, match.start()), result, body_line=line,
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
def _dispatch_with_context(
|
|
1152
|
+
self, resource_type: str, resource_name: str, body: str, file: str, line: int, ev: str,
|
|
1153
|
+
result: ScanResult, *, body_line: int | None,
|
|
1154
|
+
) -> None:
|
|
1155
|
+
"""Dispatch one resource with its instancing and module context set.
|
|
1156
|
+
``for_each`` / ``count`` is also appended to the hint evidence, since
|
|
1157
|
+
one open security group may be N instances. ``body_line`` is the
|
|
1158
|
+
source line the body starts on (None for ``.tf.json``)."""
|
|
1159
|
+
instancing = _instancing(body)
|
|
1160
|
+
self._context = self._resource_context(file, instancing)
|
|
1161
|
+
self._body_line = body_line
|
|
1162
|
+
try:
|
|
1163
|
+
self._dispatch_resource(
|
|
1164
|
+
resource_type, resource_name, body, file, line,
|
|
1165
|
+
f"{ev} [{instancing}]" if instancing else ev, result,
|
|
1166
|
+
)
|
|
1167
|
+
finally:
|
|
1168
|
+
self._context = None
|
|
1169
|
+
self._body_line = None
|
|
1170
|
+
|
|
1171
|
+
def _resource_context(self, file: str, instancing: str | None) -> str | None:
|
|
1172
|
+
parts = [instancing] if instancing else []
|
|
1173
|
+
callers = self._module_callers.get(_file_dir(file), [])
|
|
1174
|
+
if callers:
|
|
1175
|
+
more = f" +{len(callers) - 2} more" if len(callers) > 2 else ""
|
|
1176
|
+
parts.append("in local module called as " + ", ".join(callers[:2]) + more)
|
|
1177
|
+
return "; ".join(parts) or None
|
|
1178
|
+
|
|
1179
|
+
def _attr_lineno(self, body: str, name: str, default: int) -> int:
|
|
1180
|
+
"""Source line of the first ``name = ...`` in ``body`` (at any depth),
|
|
1181
|
+
or ``default`` (the block header) when unknown."""
|
|
1182
|
+
if self._body_line is None:
|
|
1183
|
+
return default
|
|
1184
|
+
match = re.search(rf"^[ \t]*{re.escape(name)}[ \t]*=", body, re.MULTILINE)
|
|
1185
|
+
return self._body_line + body.count("\n", 0, match.start()) if match else default
|
|
1186
|
+
|
|
1187
|
+
def _extract_variables(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1188
|
+
for match, body in _top_blocks(VARIABLE_BLOCK_PATTERN, content):
|
|
1189
|
+
self._handle_variable(
|
|
1190
|
+
match.group(1), body, relative,
|
|
1191
|
+
line_of(content, match.start()),
|
|
1192
|
+
_line_snippet(content, match.start()),
|
|
1193
|
+
result,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
def _extract_modules(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1197
|
+
for match, body in _top_blocks(MODULE_BLOCK_PATTERN, content):
|
|
1198
|
+
self._handle_module(
|
|
1199
|
+
match.group(1), body, relative, line_of(content, match.start()), result, body_line=line_of(content, match.start()),
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
@staticmethod
|
|
1203
|
+
def _collect_module_callers(files: list[tuple[Path, str, str]]) -> dict[str, list[str]]:
|
|
1204
|
+
"""Directory of each local module (``source = "./modules/x"``) →
|
|
1205
|
+
``module.<name> (<caller file>:<line>)`` for every block calling it,
|
|
1206
|
+
so resources found in that directory are attributed to their caller."""
|
|
1207
|
+
callers: dict[str, list[str]] = {}
|
|
1208
|
+
seen: set[tuple[str, str]] = set()
|
|
1209
|
+
for file_path, relative, content in files:
|
|
1210
|
+
if file_path.suffix != ".tf":
|
|
1211
|
+
continue
|
|
1212
|
+
for match, body in _top_blocks(MODULE_BLOCK_PATTERN, content):
|
|
1213
|
+
target = _local_module_dir(relative, _extract_attr(body, "source") or "")
|
|
1214
|
+
if target is None:
|
|
1215
|
+
continue
|
|
1216
|
+
label = f"module.{match.group(1)} ({relative}:{line_of(content, match.start())})"
|
|
1217
|
+
bucket = callers.setdefault(target, [])
|
|
1218
|
+
if (target, label) not in seen:
|
|
1219
|
+
seen.add((target, label))
|
|
1220
|
+
bucket.append(label)
|
|
1221
|
+
return callers
|
|
1222
|
+
|
|
1223
|
+
def _handle_module(
|
|
1224
|
+
self, name: str, body: str, file: str, line: int, result: ScanResult, *, body_line: int | None,
|
|
1225
|
+
) -> None:
|
|
1226
|
+
self._append_unique_service(result, f"module:{name}", file)
|
|
1227
|
+
source = _extract_attr(body, "source")
|
|
1228
|
+
if not source:
|
|
1229
|
+
return
|
|
1230
|
+
problem = _module_pin_problem(source, _extract_attr(body, "version"))
|
|
1231
|
+
if problem is None:
|
|
1232
|
+
return
|
|
1233
|
+
severity, reason, confidence = problem
|
|
1234
|
+
self._body_line = body_line
|
|
1235
|
+
try:
|
|
1236
|
+
source_line = self._attr_lineno(body, "source", line)
|
|
1237
|
+
finally:
|
|
1238
|
+
self._body_line = None
|
|
1239
|
+
self._append_misconfig(
|
|
1240
|
+
result, "iac_module_unpinned", f"module.{name}", "terraform", file, source_line,
|
|
1241
|
+
f"{_attr_line(body, 'source')} ({reason})",
|
|
1242
|
+
severity=severity, confidence=confidence, escalation=_MODULE_ESCALATION,
|
|
1243
|
+
context=_instancing(body),
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
def _extract_data_sources(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1247
|
+
for match, body in _top_blocks(DATA_BLOCK_PATTERN, content):
|
|
1248
|
+
self._handle_data_source(
|
|
1249
|
+
match.group(1), match.group(2), body, relative,
|
|
1250
|
+
line_of(content, match.start()),
|
|
1251
|
+
_line_snippet(content, match.start()),
|
|
1252
|
+
result,
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
def _extract_tfvars_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1256
|
+
"""``.tfvars`` assign values to variables: a secret-shaped key with a
|
|
1257
|
+
non-empty string literal is a credential committed to the repo."""
|
|
1258
|
+
depths, in_string = _scan_text(content)
|
|
1259
|
+
for match in _TFVARS_ASSIGN_RE.finditer(content):
|
|
1260
|
+
if not _at_top(depths, in_string, content, match.start(1)):
|
|
1261
|
+
continue
|
|
1262
|
+
key, value = match.group(1), match.group(2)
|
|
1263
|
+
if not value.strip() or not _tfvars_key_is_secret(key) or _looks_like_reference(value):
|
|
1264
|
+
continue
|
|
1265
|
+
line = line_of(content, match.start(1))
|
|
1266
|
+
self._append_unique_secret(
|
|
1267
|
+
result, key, relative, line,
|
|
1268
|
+
# Never echo the literal itself into the report.
|
|
1269
|
+
f'{key} = "<redacted>"',
|
|
1270
|
+
kind="hardcoded",
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
def _analyze_tf_json(self, content: str, relative: str, result: ScanResult) -> None:
|
|
1274
|
+
"""Terraform JSON syntax (``*.tf.json``, e.g. CDKTF output). Each block
|
|
1275
|
+
is rendered back to HCL and sent through the same handlers as ``.tf``
|
|
1276
|
+
files, with line/evidence taken from the JSON source."""
|
|
1277
|
+
try:
|
|
1278
|
+
document = json.loads(content)
|
|
1279
|
+
except ValueError:
|
|
1280
|
+
return
|
|
1281
|
+
if not isinstance(document, dict):
|
|
1282
|
+
return
|
|
1283
|
+
locator = _JsonLocator(content)
|
|
1284
|
+
locator.find("provider")
|
|
1285
|
+
|
|
1286
|
+
for provider_obj in _json_objects(document.get("provider")):
|
|
1287
|
+
for provider in provider_obj:
|
|
1288
|
+
offset = locator.find(provider)
|
|
1289
|
+
self._handle_provider(
|
|
1290
|
+
provider, relative, line_of(content, offset), _line_snippet(content, offset), result,
|
|
1291
|
+
)
|
|
1292
|
+
for kind in ("resource", "data"):
|
|
1293
|
+
locator.find(kind, from_start=True)
|
|
1294
|
+
for type_obj in _json_objects(document.get(kind)):
|
|
1295
|
+
for block_type, named in type_obj.items():
|
|
1296
|
+
locator.find(block_type)
|
|
1297
|
+
for named_obj in _json_objects(named):
|
|
1298
|
+
for block_name, raw_body in named_obj.items():
|
|
1299
|
+
offset = locator.find(block_name)
|
|
1300
|
+
line = line_of(content, offset)
|
|
1301
|
+
ev = _line_snippet(content, offset)
|
|
1302
|
+
for body_obj in _json_objects(raw_body):
|
|
1303
|
+
body = _json_to_hcl(body_obj)
|
|
1304
|
+
if kind == "resource":
|
|
1305
|
+
self._dispatch_with_context(
|
|
1306
|
+
block_type, block_name, body, relative, line, ev, result, body_line=None,
|
|
1307
|
+
)
|
|
1308
|
+
else:
|
|
1309
|
+
self._handle_data_source(block_type, block_name, body, relative, line, ev, result)
|
|
1310
|
+
locator.find("variable", from_start=True)
|
|
1311
|
+
for var_obj in _json_objects(document.get("variable")):
|
|
1312
|
+
for var_name, raw_body in var_obj.items():
|
|
1313
|
+
offset = locator.find(var_name)
|
|
1314
|
+
for body_obj in _json_objects(raw_body) or [{}]:
|
|
1315
|
+
self._handle_variable(
|
|
1316
|
+
var_name, _json_to_hcl(body_obj), relative,
|
|
1317
|
+
line_of(content, offset), _line_snippet(content, offset), result,
|
|
1318
|
+
)
|
|
1319
|
+
locator.find("module", from_start=True)
|
|
1320
|
+
for module_obj in _json_objects(document.get("module")):
|
|
1321
|
+
for module_name, raw_body in module_obj.items():
|
|
1322
|
+
offset = locator.find(module_name)
|
|
1323
|
+
for body_obj in _json_objects(raw_body) or [{}]:
|
|
1324
|
+
self._handle_module(
|
|
1325
|
+
module_name, _json_to_hcl(body_obj), relative, line_of(content, offset), result, body_line=None,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
# ---------- Block handlers (shared by HCL and JSON syntax) ----------
|
|
1329
|
+
|
|
1330
|
+
def _handle_provider(self, provider: str, file: str, line: int, ev: str, result: ScanResult) -> None:
|
|
1331
|
+
label = _PROVIDER_LABEL.get(provider, f"terraform-{provider}")
|
|
1332
|
+
self._append_unique_framework(result, label, file, line, ev)
|
|
1333
|
+
|
|
1334
|
+
def _handle_variable(self, var_name: str, body: str, file: str, line: int, ev: str, result: ScanResult) -> None:
|
|
1335
|
+
sensitive = (_extract_attr(body, "sensitive") or "").lower() == "true"
|
|
1336
|
+
if not (sensitive or _looks_secret_shaped(var_name)):
|
|
1337
|
+
return
|
|
1338
|
+
# A literal string `default` on a secret variable is a credential
|
|
1339
|
+
# committed to the repo (#4); a bare declaration only names one.
|
|
1340
|
+
default = _VARIABLE_STRING_DEFAULT_RE.search(_top_level(body))
|
|
1341
|
+
if default and _credential_shaped_default(var_name, default.group(1)) and (
|
|
1342
|
+
sensitive or _tfvars_key_is_secret(var_name)
|
|
1343
|
+
):
|
|
1344
|
+
self._append_unique_secret(
|
|
1345
|
+
result, var_name, file, line,
|
|
1346
|
+
# Never echo the literal itself into the report.
|
|
1347
|
+
f'variable "{var_name}" {{ default = "<redacted>" }}',
|
|
1348
|
+
kind="hardcoded",
|
|
1349
|
+
)
|
|
1350
|
+
return
|
|
1351
|
+
self._append_unique_secret(result, var_name, file, line, ev)
|
|
1352
|
+
|
|
1353
|
+
def _handle_data_source(
|
|
1354
|
+
self, data_type: str, data_name: str, body: str, file: str, line: int, ev: str, result: ScanResult,
|
|
1355
|
+
) -> None:
|
|
1356
|
+
# data "aws_secretsmanager_secret" "x" { ... } — surface secret references via data.
|
|
1357
|
+
if data_type in {"aws_secretsmanager_secret", "aws_secretsmanager_secret_version", "aws_ssm_parameter"}:
|
|
1358
|
+
self._append_unique_secret(result, f"data:{data_type}:{data_name}", file, line, ev)
|
|
1359
|
+
return
|
|
1360
|
+
if data_type == "aws_iam_policy_document":
|
|
1361
|
+
self._emit_policy_findings(_scan_policy_document(body), data_name, file, line, ev, result)
|
|
1362
|
+
|
|
1363
|
+
def _emit_policy_findings(
|
|
1364
|
+
self, findings: _PolicyFindings, name: str, file: str, line: int, ev: str, result: ScanResult,
|
|
1365
|
+
) -> None:
|
|
1366
|
+
if findings.wildcard_action:
|
|
1367
|
+
self._append_unique_auth(result, f"iam_wildcard_action:{name}", file, line, ev, 0.7)
|
|
1368
|
+
if findings.wildcard_resource:
|
|
1369
|
+
# Resource "*" with scoped actions is common (Describe*, logs), so
|
|
1370
|
+
# this is a weaker smell than a wildcard action.
|
|
1371
|
+
self._append_unique_auth(result, f"iam_wildcard_resource:{name}", file, line, ev, 0.5)
|
|
1372
|
+
if findings.public_principal:
|
|
1373
|
+
self._append_unique_auth(result, f"iam_wildcard_principal:{name}", file, line, ev, 0.7)
|
|
1374
|
+
|
|
1375
|
+
# ---------- Resource dispatchers ----------
|
|
1376
|
+
|
|
1377
|
+
def _dispatch_resource(
|
|
1378
|
+
self,
|
|
1379
|
+
resource_type: str,
|
|
1380
|
+
resource_name: str,
|
|
1381
|
+
body: str,
|
|
1382
|
+
file: str,
|
|
1383
|
+
line: int,
|
|
1384
|
+
ev: str,
|
|
1385
|
+
result: ScanResult,
|
|
1386
|
+
) -> None:
|
|
1387
|
+
self._misconfig_checks(resource_type, resource_name, body, file, line, result)
|
|
1388
|
+
resource = f"{resource_type}.{resource_name}"
|
|
1389
|
+
# ---- AWS ----
|
|
1390
|
+
if resource_type == "aws_security_group":
|
|
1391
|
+
if _has_open_ingress(body):
|
|
1392
|
+
self._append_unique_entrypoint(
|
|
1393
|
+
result, f"sg_open_ingress:{resource_name}", file, line, ev,
|
|
1394
|
+
)
|
|
1395
|
+
return
|
|
1396
|
+
if resource_type in {"aws_security_group_rule", "aws_vpc_security_group_ingress_rule"} and _has_open_cidr(body):
|
|
1397
|
+
direction = (_extract_attr(body, "type") or "").lower() or "ingress"
|
|
1398
|
+
if direction == "egress":
|
|
1399
|
+
# Open egress is the default posture, not exposure.
|
|
1400
|
+
return
|
|
1401
|
+
self._append_unique_entrypoint(
|
|
1402
|
+
result, f"sg_rule_{direction}_open:{resource_name}", file, line, ev,
|
|
1403
|
+
)
|
|
1404
|
+
return
|
|
1405
|
+
if resource_type == "aws_lambda_function":
|
|
1406
|
+
self._append_unique_entrypoint(
|
|
1407
|
+
result, f"lambda:{resource_name}", file, line, ev,
|
|
1408
|
+
)
|
|
1409
|
+
self._append_unique_service(result, f"function:{resource_name}", file)
|
|
1410
|
+
return
|
|
1411
|
+
if resource_type == "aws_lambda_function_url":
|
|
1412
|
+
authorization_type = (_extract_attr(body, "authorization_type") or "").upper()
|
|
1413
|
+
label = "lambda_url_open" if authorization_type == "NONE" else "lambda_url"
|
|
1414
|
+
self._append_unique_entrypoint(
|
|
1415
|
+
result, f"{label}:{resource_name}", file, line, ev,
|
|
1416
|
+
)
|
|
1417
|
+
if authorization_type == "NONE":
|
|
1418
|
+
self._append_misconfig(
|
|
1419
|
+
result, "public_invoker", resource, "aws", file, self._attr_lineno(body, "authorization_type", line),
|
|
1420
|
+
'authorization_type = "NONE"', severity="low", confidence=0.85,
|
|
1421
|
+
escalation="anyone on the internet can invoke the function; its own code is the only access control",
|
|
1422
|
+
)
|
|
1423
|
+
# A function URL serves every method and path of the function.
|
|
1424
|
+
auth, guards, evidence = _route_auth(body, "authorization_type")
|
|
1425
|
+
self._append_unique_route(
|
|
1426
|
+
result, "/", "ANY", file, line, auth=auth, guards=guards, guard_evidence=evidence,
|
|
1427
|
+
)
|
|
1428
|
+
return
|
|
1429
|
+
if resource_type == "aws_api_gateway_resource":
|
|
1430
|
+
parent = _extract_attr(body, "parent_id") or ""
|
|
1431
|
+
self._apigw_resources.setdefault(resource_name, (parent, _extract_attr(body, "path_part") or ""))
|
|
1432
|
+
return
|
|
1433
|
+
if resource_type == "aws_api_gateway_method":
|
|
1434
|
+
http_method = (_extract_attr(body, "http_method") or "ANY").upper()
|
|
1435
|
+
authorization = (_extract_attr(body, "authorization") or "").upper()
|
|
1436
|
+
if authorization == "NONE":
|
|
1437
|
+
self._append_unique_entrypoint(
|
|
1438
|
+
result, f"apigw_open_method:{http_method}:{resource_name}", file, line, ev,
|
|
1439
|
+
)
|
|
1440
|
+
# CORS preflight (OPTIONS) is unauthenticated by design; an
|
|
1441
|
+
# API key is a (weak) caller check.
|
|
1442
|
+
if http_method != "OPTIONS" and not _is_true(body, "api_key_required"):
|
|
1443
|
+
self._append_misconfig(
|
|
1444
|
+
result, "api_no_authorizer", resource, "aws", file, self._attr_lineno(body, "authorization", line),
|
|
1445
|
+
f'{http_method} method with authorization = "NONE" and no API key', severity="medium", confidence=0.8,
|
|
1446
|
+
escalation="anyone on the internet can call the integration behind this method",
|
|
1447
|
+
)
|
|
1448
|
+
else:
|
|
1449
|
+
self._append_unique_entrypoint(
|
|
1450
|
+
result, f"apigw_method:{http_method}:{resource_name}", file, line, ev,
|
|
1451
|
+
)
|
|
1452
|
+
resource_id = _extract_attr(body, "resource_id")
|
|
1453
|
+
if resource_id:
|
|
1454
|
+
auth = _route_auth(body, "authorization", ("api_key_required",))
|
|
1455
|
+
self._apigw_methods.append((resource_id, http_method, auth, file, line))
|
|
1456
|
+
return
|
|
1457
|
+
if resource_type == "aws_apigatewayv2_route":
|
|
1458
|
+
route_key = _extract_attr(body, "route_key") or ""
|
|
1459
|
+
authorization_type = (_extract_attr(body, "authorization_type") or "").upper()
|
|
1460
|
+
if route_key.strip() == "$default":
|
|
1461
|
+
# The catch-all route: on an HTTP API it serves every method
|
|
1462
|
+
# and path no other route matches. A WebSocket API has a
|
|
1463
|
+
# `$default` too, which can't take an authorizer, so the API's
|
|
1464
|
+
# protocol is resolved after the walk.
|
|
1465
|
+
self._v2_default_routes.append(
|
|
1466
|
+
(resource, _extract_attr(body, "api_id") or "", body, file, line, self._context)
|
|
1467
|
+
)
|
|
1468
|
+
return
|
|
1469
|
+
if route_key:
|
|
1470
|
+
# route_key is "GET /users" or "POST /things" — split into method + path.
|
|
1471
|
+
parts = route_key.strip().split(maxsplit=1)
|
|
1472
|
+
if len(parts) == 2:
|
|
1473
|
+
method, path = parts[0].upper(), parts[1]
|
|
1474
|
+
auth, guards, evidence = _route_auth(body, "authorization_type")
|
|
1475
|
+
self._append_unique_route(
|
|
1476
|
+
result, path, method, file, line,
|
|
1477
|
+
auth=auth, guards=guards, guard_evidence=evidence,
|
|
1478
|
+
)
|
|
1479
|
+
if authorization_type == "NONE":
|
|
1480
|
+
self._append_unique_entrypoint(
|
|
1481
|
+
result, f"apigwv2_open:{resource_name}", file, line, ev,
|
|
1482
|
+
)
|
|
1483
|
+
# HTTP API routes default to authorization_type NONE.
|
|
1484
|
+
raw_auth = _extract_attr(body, "authorization_type")
|
|
1485
|
+
if method != "OPTIONS" and (authorization_type == "NONE" or raw_auth is None):
|
|
1486
|
+
self._append_misconfig(
|
|
1487
|
+
result, "api_no_authorizer", resource, "aws", file,
|
|
1488
|
+
self._attr_lineno(body, "authorization_type" if raw_auth else "route_key", line),
|
|
1489
|
+
f'route "{route_key}" with '
|
|
1490
|
+
+ ('authorization_type = "NONE"' if raw_auth else "no authorization_type (defaults to NONE)"),
|
|
1491
|
+
severity="medium", confidence=0.85 if raw_auth else 0.7,
|
|
1492
|
+
escalation="anyone on the internet can call the integration behind this route",
|
|
1493
|
+
)
|
|
1494
|
+
return
|
|
1495
|
+
if resource_type == "aws_apigatewayv2_api":
|
|
1496
|
+
protocol = (_extract_attr(body, "protocol_type") or "").strip().upper()
|
|
1497
|
+
self._v2_protocols[(_file_dir(file), resource_name)] = protocol if protocol in {"HTTP", "WEBSOCKET"} else None
|
|
1498
|
+
if resource_type in {"aws_lb", "aws_alb"}:
|
|
1499
|
+
internal = (_extract_attr(body, "internal") or "false").strip().lower()
|
|
1500
|
+
self._lb_internal[(_file_dir(file), resource_name)] = (
|
|
1501
|
+
True if internal == "true" else False if internal == "false" else None
|
|
1502
|
+
)
|
|
1503
|
+
if resource_type in {"aws_lb", "aws_alb", "aws_cloudfront_distribution"}:
|
|
1504
|
+
self._append_unique_entrypoint(
|
|
1505
|
+
result, f"{resource_type}:{resource_name}", file, line, ev,
|
|
1506
|
+
)
|
|
1507
|
+
return
|
|
1508
|
+
if resource_type == "aws_s3_bucket":
|
|
1509
|
+
self._append_unique_service(result, f"s3_bucket:{resource_name}", file)
|
|
1510
|
+
# AWS provider <= v3 inline `acl` / `policy` arguments.
|
|
1511
|
+
acl = (_extract_attr(body, "acl") or "").lower()
|
|
1512
|
+
if acl in _PUBLIC_S3_ACLS:
|
|
1513
|
+
self._append_unique_entrypoint(
|
|
1514
|
+
result, f"s3_public_acl:{resource_name}", file, line, ev,
|
|
1515
|
+
)
|
|
1516
|
+
self._s3_public_acl(result, resource, body, file, line, acl)
|
|
1517
|
+
if _scan_json_policies(_attr_value(body, "policy") or "").public_principal:
|
|
1518
|
+
self._append_unique_entrypoint(
|
|
1519
|
+
result, f"s3_public_policy:{resource_name}", file, line, ev,
|
|
1520
|
+
)
|
|
1521
|
+
self._s3_public_policy(result, resource, body, file, line)
|
|
1522
|
+
return
|
|
1523
|
+
if resource_type == "aws_s3_bucket_policy":
|
|
1524
|
+
if _scan_json_policies(body).public_principal:
|
|
1525
|
+
self._append_unique_entrypoint(
|
|
1526
|
+
result, f"s3_public_policy:{resource_name}", file, line, ev,
|
|
1527
|
+
)
|
|
1528
|
+
self._s3_public_policy(result, resource, body, file, line)
|
|
1529
|
+
return
|
|
1530
|
+
if resource_type == "aws_s3_bucket_acl":
|
|
1531
|
+
acl = (_extract_attr(body, "acl") or "").lower()
|
|
1532
|
+
if acl in _PUBLIC_S3_ACLS:
|
|
1533
|
+
self._append_unique_entrypoint(
|
|
1534
|
+
result, f"s3_public_acl:{resource_name}", file, line, ev,
|
|
1535
|
+
)
|
|
1536
|
+
self._s3_public_acl(result, resource, body, file, line, acl)
|
|
1537
|
+
return
|
|
1538
|
+
if resource_type == "aws_s3_bucket_public_access_block":
|
|
1539
|
+
for attr in ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"):
|
|
1540
|
+
value = (_extract_attr(body, attr) or "true").lower()
|
|
1541
|
+
if value == "false":
|
|
1542
|
+
self._append_unique_entrypoint(
|
|
1543
|
+
result, f"s3_public_block_disabled:{resource_name}", file, line, ev,
|
|
1544
|
+
)
|
|
1545
|
+
self._append_misconfig(
|
|
1546
|
+
result, "public_data_access", resource, "aws", file, self._attr_lineno(body, attr, line),
|
|
1547
|
+
f"{attr} = false", severity="low", confidence=0.7,
|
|
1548
|
+
escalation="the bucket's public-access guard is off, so a public ACL or policy takes effect",
|
|
1549
|
+
)
|
|
1550
|
+
return
|
|
1551
|
+
return
|
|
1552
|
+
if resource_type == "aws_db_instance" or resource_type == "aws_rds_cluster":
|
|
1553
|
+
engine = (_extract_attr(body, "engine") or "").lower()
|
|
1554
|
+
kind = _ENGINE_KIND_MAP.get(engine, "sql")
|
|
1555
|
+
self._append_unique_database(result, kind, file, line, ev)
|
|
1556
|
+
self._append_unique_service(result, f"rds:{resource_name}", file)
|
|
1557
|
+
publicly_accessible = (_extract_attr(body, "publicly_accessible") or "false").lower()
|
|
1558
|
+
if publicly_accessible == "true":
|
|
1559
|
+
self._append_unique_entrypoint(
|
|
1560
|
+
result, f"rds_publicly_accessible:{resource_name}", file, line, ev,
|
|
1561
|
+
)
|
|
1562
|
+
self._append_misconfig(
|
|
1563
|
+
result, "public_network_access", resource, "aws", file, self._attr_lineno(body, "publicly_accessible", line),
|
|
1564
|
+
"publicly_accessible = true", severity="medium", confidence=0.85,
|
|
1565
|
+
escalation="the database gets a public DNS name and address; only its security groups and password protect it",
|
|
1566
|
+
)
|
|
1567
|
+
self._check_rds_encryption(result, resource_type, resource, body, file, line)
|
|
1568
|
+
return
|
|
1569
|
+
if resource_type in {"aws_dynamodb_table", "aws_dynamodb_global_table"}:
|
|
1570
|
+
self._append_unique_database(result, "dynamodb", file, line, ev)
|
|
1571
|
+
self._append_unique_service(result, f"dynamodb:{resource_name}", file)
|
|
1572
|
+
return
|
|
1573
|
+
if resource_type in {"aws_elasticache_cluster", "aws_elasticache_replication_group"}:
|
|
1574
|
+
self._append_unique_database(result, "redis", file, line, ev)
|
|
1575
|
+
self._append_unique_service(result, f"elasticache:{resource_name}", file)
|
|
1576
|
+
return
|
|
1577
|
+
if resource_type in {"aws_documentdb_cluster", "aws_docdb_cluster"}:
|
|
1578
|
+
self._append_unique_database(result, "mongodb", file, line, ev)
|
|
1579
|
+
return
|
|
1580
|
+
if resource_type == "aws_secretsmanager_secret":
|
|
1581
|
+
self._append_unique_secret(result, f"secretsmanager:{resource_name}", file, line, ev)
|
|
1582
|
+
return
|
|
1583
|
+
if resource_type == "aws_ssm_parameter":
|
|
1584
|
+
param_type = (_extract_attr(body, "type") or "").lower()
|
|
1585
|
+
if param_type == "securestring":
|
|
1586
|
+
self._append_unique_secret(result, f"ssm:{resource_name}", file, line, ev)
|
|
1587
|
+
return
|
|
1588
|
+
if resource_type == "aws_kms_key" or resource_type == "aws_kms_alias":
|
|
1589
|
+
self._append_unique_service(result, f"kms:{resource_name}", file)
|
|
1590
|
+
return
|
|
1591
|
+
if resource_type == "aws_cognito_user_pool":
|
|
1592
|
+
self._append_unique_auth(result, f"cognito_user_pool:{resource_name}", file, line, ev, 0.85)
|
|
1593
|
+
return
|
|
1594
|
+
if resource_type in _IAM_POLICY_RESOURCES:
|
|
1595
|
+
# Identity policies (Action/Resource) and, for aws_iam_role, the
|
|
1596
|
+
# assume_role_policy trust document (Principal) plus inline_policy.
|
|
1597
|
+
self._emit_policy_findings(_scan_json_policies(body), resource_name, file, line, ev, result)
|
|
1598
|
+
return
|
|
1599
|
+
|
|
1600
|
+
# ---- Azure ----
|
|
1601
|
+
if resource_type == "azurerm_storage_account":
|
|
1602
|
+
self._append_unique_service(result, f"azure_storage:{resource_name}", file)
|
|
1603
|
+
return
|
|
1604
|
+
if resource_type == "azurerm_key_vault":
|
|
1605
|
+
self._append_unique_service(result, f"key_vault:{resource_name}", file)
|
|
1606
|
+
return
|
|
1607
|
+
if (
|
|
1608
|
+
resource_type == "azurerm_network_security_rule"
|
|
1609
|
+
and _has_open_cidr(body)
|
|
1610
|
+
and (_extract_attr(body, "direction") or "inbound").lower() != "outbound"
|
|
1611
|
+
):
|
|
1612
|
+
self._append_unique_entrypoint(
|
|
1613
|
+
result, f"azure_nsg_open:{resource_name}", file, line, ev,
|
|
1614
|
+
)
|
|
1615
|
+
return
|
|
1616
|
+
if resource_type in {"azurerm_postgresql_server", "azurerm_postgresql_flexible_server"}:
|
|
1617
|
+
self._append_unique_database(result, "postgresql", file, line, ev)
|
|
1618
|
+
return
|
|
1619
|
+
if resource_type in {"azurerm_mysql_server", "azurerm_mysql_flexible_server"}:
|
|
1620
|
+
self._append_unique_database(result, "mysql", file, line, ev)
|
|
1621
|
+
return
|
|
1622
|
+
if resource_type == "azurerm_cosmosdb_account":
|
|
1623
|
+
self._append_unique_database(result, "cosmosdb", file, line, ev)
|
|
1624
|
+
return
|
|
1625
|
+
|
|
1626
|
+
# ---- GCP ----
|
|
1627
|
+
if resource_type == "google_storage_bucket":
|
|
1628
|
+
self._append_unique_service(result, f"gcs_bucket:{resource_name}", file)
|
|
1629
|
+
return
|
|
1630
|
+
if (
|
|
1631
|
+
resource_type == "google_compute_firewall"
|
|
1632
|
+
and _has_open_cidr(body)
|
|
1633
|
+
and (_extract_attr(body, "direction") or "ingress").lower() != "egress"
|
|
1634
|
+
):
|
|
1635
|
+
self._append_unique_entrypoint(
|
|
1636
|
+
result, f"gcp_firewall_open:{resource_name}", file, line, ev,
|
|
1637
|
+
)
|
|
1638
|
+
return
|
|
1639
|
+
if resource_type == "google_sql_database_instance":
|
|
1640
|
+
db_version = (_extract_attr(body, "database_version") or "").lower()
|
|
1641
|
+
kind = "sql"
|
|
1642
|
+
if "postgres" in db_version:
|
|
1643
|
+
kind = "postgresql"
|
|
1644
|
+
elif "mysql" in db_version:
|
|
1645
|
+
kind = "mysql"
|
|
1646
|
+
elif "sqlserver" in db_version:
|
|
1647
|
+
kind = "sqlserver"
|
|
1648
|
+
self._append_unique_database(result, kind, file, line, ev)
|
|
1649
|
+
return
|
|
1650
|
+
|
|
1651
|
+
# ---------- Misconfiguration checks (#4) ----------
|
|
1652
|
+
|
|
1653
|
+
def _misconfig_checks(
|
|
1654
|
+
self, resource_type: str, name: str, body: str, file: str, line: int, result: ScanResult,
|
|
1655
|
+
) -> None:
|
|
1656
|
+
"""Resource types whose only output is a `CloudMisconfig`. Checks on
|
|
1657
|
+
types the dispatcher already handles live in its branches."""
|
|
1658
|
+
resource = f"{resource_type}.{name}"
|
|
1659
|
+
platform = _platform(resource_type)
|
|
1660
|
+
|
|
1661
|
+
def emit(kind: str, attr: str, evidence: str, *, at: int | None = None, **kw: object) -> None:
|
|
1662
|
+
# `at`: offset in `body` of the specific block (one hostPath of
|
|
1663
|
+
# several), else the line of the first `attr = ...`.
|
|
1664
|
+
if at is not None and at >= 0 and self._body_line is not None:
|
|
1665
|
+
where = self._body_line + body.count("\n", 0, at)
|
|
1666
|
+
else:
|
|
1667
|
+
where = self._attr_lineno(body, attr, line)
|
|
1668
|
+
self._append_misconfig(result, kind, resource, platform, file, where, evidence, **kw) # type: ignore[arg-type]
|
|
1669
|
+
|
|
1670
|
+
# ---- AWS ----
|
|
1671
|
+
if resource_type in {"aws_instance", "aws_launch_configuration"}:
|
|
1672
|
+
if _is_true(body, "associate_public_ip_address"):
|
|
1673
|
+
emit(
|
|
1674
|
+
"public_network_access", "associate_public_ip_address", "associate_public_ip_address = true",
|
|
1675
|
+
severity="low", confidence=0.8,
|
|
1676
|
+
escalation="the instance gets a public IPv4 address, reachable wherever its security groups allow",
|
|
1677
|
+
)
|
|
1678
|
+
return
|
|
1679
|
+
if resource_type == "aws_launch_template":
|
|
1680
|
+
if any(_is_true(ni, "associate_public_ip_address") for ni in _sub_blocks(body, "network_interfaces")):
|
|
1681
|
+
emit(
|
|
1682
|
+
"public_network_access", "associate_public_ip_address", "network_interfaces { associate_public_ip_address = true }",
|
|
1683
|
+
severity="low", confidence=0.8,
|
|
1684
|
+
escalation="every instance launched from it gets a public IPv4 address",
|
|
1685
|
+
)
|
|
1686
|
+
return
|
|
1687
|
+
if resource_type == "aws_eks_cluster":
|
|
1688
|
+
self._check_eks_endpoint(body, emit)
|
|
1689
|
+
return
|
|
1690
|
+
if resource_type in {"aws_lb_listener", "aws_alb_listener"}:
|
|
1691
|
+
protocol = (_extract_attr(body, "protocol") or "").upper()
|
|
1692
|
+
if protocol != "HTTP":
|
|
1693
|
+
return
|
|
1694
|
+
actions = [_action_type(a) for a in _sub_blocks(body, "default_action")]
|
|
1695
|
+
if "redirect" in actions or "?" in actions:
|
|
1696
|
+
return # redirects, or a computed type that may be one
|
|
1697
|
+
lb_ref = _extract_attr(body, "load_balancer_arn") or ""
|
|
1698
|
+
self._http_listeners.append(
|
|
1699
|
+
(resource, lb_ref, file, self._attr_lineno(body, "protocol", line), _attr_line(body, "protocol") or 'protocol = "HTTP"',
|
|
1700
|
+
"fixed-response" if actions and all(a == "fixed-response" for a in actions) else "forward", self._context),
|
|
1701
|
+
)
|
|
1702
|
+
return
|
|
1703
|
+
if resource_type in {"aws_elasticsearch_domain", "aws_opensearch_domain"}:
|
|
1704
|
+
if _scan_json_policies(_attr_value(body, "access_policies") or "").public_principal:
|
|
1705
|
+
in_vpc = bool(_sub_blocks(body, "vpc_options"))
|
|
1706
|
+
emit(
|
|
1707
|
+
"public_data_access", "access_policies", 'access_policies allow Principal "*" with no Condition',
|
|
1708
|
+
severity="medium" if in_vpc else "high", confidence=0.85,
|
|
1709
|
+
escalation="anyone who can reach the domain endpoint can read and write its indices"
|
|
1710
|
+
+ (" (endpoint is inside a VPC)" if in_vpc else ""),
|
|
1711
|
+
)
|
|
1712
|
+
return
|
|
1713
|
+
if resource_type in {"aws_elasticsearch_domain_policy", "aws_opensearch_domain_policy"}:
|
|
1714
|
+
if _scan_json_policies(body).public_principal:
|
|
1715
|
+
emit(
|
|
1716
|
+
"public_data_access", "access_policies", 'access_policies allow Principal "*" with no Condition',
|
|
1717
|
+
severity="medium", confidence=0.8,
|
|
1718
|
+
escalation="anyone who can reach the domain endpoint can read and write its indices",
|
|
1719
|
+
)
|
|
1720
|
+
return
|
|
1721
|
+
|
|
1722
|
+
# ---- GCP ----
|
|
1723
|
+
if resource_type.startswith(("google_cloud_run_service_iam_", "google_cloud_run_v2_service_iam_",
|
|
1724
|
+
"google_cloudfunctions_function_iam_", "google_cloudfunctions2_function_iam_")):
|
|
1725
|
+
members = _public_members(body)
|
|
1726
|
+
if members and not resource_type.endswith("_policy"):
|
|
1727
|
+
emit(
|
|
1728
|
+
"public_invoker", "member" if _extract_attr(body, "member") else "members",
|
|
1729
|
+
f"{_attr_line(body, 'role') or 'role'} granted to {', '.join(members)}",
|
|
1730
|
+
severity="low", confidence=0.85,
|
|
1731
|
+
escalation="anyone on the internet can invoke the service; its own code is the only access control",
|
|
1732
|
+
)
|
|
1733
|
+
return
|
|
1734
|
+
if resource_type.startswith(("google_storage_bucket_iam_", "google_storage_bucket_access_control",
|
|
1735
|
+
"google_storage_default_object_access_control", "google_storage_object_access_control",
|
|
1736
|
+
"google_storage_bucket_acl")):
|
|
1737
|
+
members = _public_members(body)
|
|
1738
|
+
predefined = (_extract_attr(body, "predefined_acl") or "").lower()
|
|
1739
|
+
if predefined in {"publicread", "publicreadwrite"}:
|
|
1740
|
+
members.append(f"predefined_acl {predefined}")
|
|
1741
|
+
if not members or resource_type.endswith("_policy"):
|
|
1742
|
+
return
|
|
1743
|
+
role = (_extract_attr(body, "role") or "") + " " + (_attr_value(_top_level(body), "role_entity") or "")
|
|
1744
|
+
writable = any(w in role.lower() for w in ("admin", "writer", "creator", "owner")) or predefined == "publicreadwrite"
|
|
1745
|
+
emit(
|
|
1746
|
+
"public_data_access", "role", f"{_attr_line(body, 'role') or resource_type} granted to {', '.join(members)}",
|
|
1747
|
+
severity="high" if writable else "medium", confidence=0.9,
|
|
1748
|
+
escalation="anyone on the internet can " + ("modify" if writable else "read") + " the bucket's objects",
|
|
1749
|
+
)
|
|
1750
|
+
return
|
|
1751
|
+
|
|
1752
|
+
# ---- Azure ----
|
|
1753
|
+
if resource_type.startswith("azurerm_"):
|
|
1754
|
+
if _is_true(body, "public_network_access_enabled"):
|
|
1755
|
+
emit(
|
|
1756
|
+
"public_network_access", "public_network_access_enabled", "public_network_access_enabled = true",
|
|
1757
|
+
severity="medium", confidence=0.85,
|
|
1758
|
+
escalation="the service endpoint accepts connections from any network; only its own auth protects it",
|
|
1759
|
+
)
|
|
1760
|
+
if resource_type == "azurerm_storage_account" and _is_true(body, "allow_nested_items_to_be_public"):
|
|
1761
|
+
emit(
|
|
1762
|
+
"public_data_access", "allow_nested_items_to_be_public", "allow_nested_items_to_be_public = true",
|
|
1763
|
+
severity="medium", confidence=0.8,
|
|
1764
|
+
escalation="containers in the account can be made publicly readable without further review",
|
|
1765
|
+
)
|
|
1766
|
+
if resource_type == "azurerm_storage_container":
|
|
1767
|
+
access = (_extract_attr(body, "container_access_type") or "").lower()
|
|
1768
|
+
if access in {"blob", "container"}:
|
|
1769
|
+
emit(
|
|
1770
|
+
"public_data_access", "container_access_type", f'container_access_type = "{access}"',
|
|
1771
|
+
severity="high" if access == "container" else "medium", confidence=0.9,
|
|
1772
|
+
escalation="anonymous clients can read its blobs" + (" and list them" if access == "container" else ""),
|
|
1773
|
+
)
|
|
1774
|
+
return
|
|
1775
|
+
|
|
1776
|
+
# ---- Kubernetes provider ----
|
|
1777
|
+
if resource_type in _TF_K8S_WORKLOADS:
|
|
1778
|
+
self._check_k8s_workload(body, emit)
|
|
1779
|
+
elif resource_type in _TF_K8S_ROLES:
|
|
1780
|
+
self._check_k8s_role(resource_type, body, emit)
|
|
1781
|
+
elif resource_type in _TF_K8S_BINDINGS:
|
|
1782
|
+
self._check_k8s_binding(body, emit)
|
|
1783
|
+
elif resource_type in _TF_K8S_SERVICES:
|
|
1784
|
+
spec = next(iter(_sub_blocks(body, "spec")), "")
|
|
1785
|
+
svc_type = _extract_attr(spec, "type") or ""
|
|
1786
|
+
if svc_type in {"LoadBalancer", "NodePort"} and _attr_value(_top_level(spec), "load_balancer_source_ranges") is None:
|
|
1787
|
+
annotations = _attr_value(next(iter(_sub_blocks(body, "metadata")), ""), "annotations") or ""
|
|
1788
|
+
if not _internal_lb(annotations):
|
|
1789
|
+
emit(
|
|
1790
|
+
"exposed_service", "type", f'type = "{svc_type}" with no load_balancer_source_ranges',
|
|
1791
|
+
severity="low", confidence=0.7,
|
|
1792
|
+
escalation="the service is reachable from outside the cluster by any source address",
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
@staticmethod
|
|
1796
|
+
def _check_eks_endpoint(body: str, emit) -> None: # type: ignore[no-untyped-def]
|
|
1797
|
+
"""EKS: `endpoint_public_access` defaults to true and
|
|
1798
|
+
`public_access_cidrs` to 0.0.0.0/0. Only literal (or defaulted)
|
|
1799
|
+
values are judged; a variable is not."""
|
|
1800
|
+
vpc = next(iter(_sub_blocks(body, "vpc_config")), None)
|
|
1801
|
+
if vpc is None:
|
|
1802
|
+
return
|
|
1803
|
+
public = _extract_attr(vpc, "endpoint_public_access")
|
|
1804
|
+
if public is not None and public.strip().lower() != "true":
|
|
1805
|
+
return # false, or computed
|
|
1806
|
+
cidrs = _attr_value(_top_level(vpc), "public_access_cidrs")
|
|
1807
|
+
if cidrs is not None and not _has_open_cidr(cidrs):
|
|
1808
|
+
return # restricted, or computed
|
|
1809
|
+
defaulted = [n for n, v in (("endpoint_public_access", public), ("public_access_cidrs", cidrs)) if v is None]
|
|
1810
|
+
evidence = (
|
|
1811
|
+
f"{_attr_line(vpc, 'endpoint_public_access') or 'endpoint_public_access not set (defaults to true)'}; "
|
|
1812
|
+
f"{_attr_line(vpc, 'public_access_cidrs') or 'public_access_cidrs not set (defaults to 0.0.0.0/0)'}"
|
|
1813
|
+
)
|
|
1814
|
+
emit(
|
|
1815
|
+
"public_cluster_endpoint", "endpoint_public_access" if public is not None else "vpc_config", evidence,
|
|
1816
|
+
severity="medium", confidence=0.6 if defaulted else 0.9,
|
|
1817
|
+
escalation="the Kubernetes API server is reachable from the internet; a leaked kubeconfig or token, or an API server bug, is enough to get in",
|
|
1818
|
+
)
|
|
1819
|
+
|
|
1820
|
+
@staticmethod
|
|
1821
|
+
def _check_k8s_workload(body: str, emit) -> None: # type: ignore[no-untyped-def]
|
|
1822
|
+
"""kubernetes provider pods / pod templates (snake_case fields)."""
|
|
1823
|
+
if _set_true_re("privileged").search(body):
|
|
1824
|
+
emit("privileged_container", "privileged", "privileged = true", severity="high", confidence=0.9,
|
|
1825
|
+
escalation="a privileged container has every device and capability of the node: root on the host")
|
|
1826
|
+
if _set_true_re("allow_privilege_escalation").search(body):
|
|
1827
|
+
emit("privilege_escalation_allowed", "allow_privilege_escalation", "allow_privilege_escalation = true",
|
|
1828
|
+
severity="medium", confidence=0.9, escalation="setuid binaries in the image can raise the process to root")
|
|
1829
|
+
for attr, severity in (("host_pid", "high"), ("host_network", "medium"), ("host_ipc", "medium")):
|
|
1830
|
+
if _set_true_re(attr).search(body):
|
|
1831
|
+
emit("host_namespace_shared", attr, f"{attr} = true", severity=severity, confidence=0.9,
|
|
1832
|
+
escalation=f"the pod shares the node's {attr.removeprefix('host_').upper()} namespace")
|
|
1833
|
+
for match in _HOST_PATH_RE.finditer(body):
|
|
1834
|
+
window = body[match.end(): match.end() + _K8S_BLOCK_SCAN_LIMIT]
|
|
1835
|
+
inner, _ = _block_body(window, 0)
|
|
1836
|
+
path = _HOST_PATH_PATH_RE.search(inner)
|
|
1837
|
+
if path is None:
|
|
1838
|
+
continue
|
|
1839
|
+
value = path.group(1)
|
|
1840
|
+
severity = _host_path_severity(value)
|
|
1841
|
+
emit("host_path_mount", "path", f'host_path {{ path = "{value}" }}', at=match.end() + path.start(), severity=severity,
|
|
1842
|
+
confidence=0.9 if severity == "high" else 0.6,
|
|
1843
|
+
escalation="host filesystem access from the pod" + (": node root / container runtime control" if severity == "high" else ""))
|
|
1844
|
+
|
|
1845
|
+
@staticmethod
|
|
1846
|
+
def _check_k8s_role(resource_type: str, body: str, emit) -> None: # type: ignore[no-untyped-def]
|
|
1847
|
+
cluster = "cluster_role" in resource_type
|
|
1848
|
+
for rule in _sub_blocks(body, "rule"):
|
|
1849
|
+
top = _top_level(rule)
|
|
1850
|
+
verbs = {v.lower() for v in _string_list(_attr_value(top, "verbs"))}
|
|
1851
|
+
resources = {r.lower() for r in _string_list(_attr_value(top, "resources"))}
|
|
1852
|
+
groups_value = _attr_value(top, "api_groups")
|
|
1853
|
+
groups = None if groups_value is None else set(_string_list(groups_value))
|
|
1854
|
+
if _rbac_wildcard(groups, verbs, resources):
|
|
1855
|
+
emit("rbac_wildcard", "verbs", at=body.find(rule), evidence=f"rule verbs={sorted(verbs)} resources={sorted(resources)}",
|
|
1856
|
+
severity="high" if cluster else "medium", confidence=0.9,
|
|
1857
|
+
escalation="the role grants every verb or resource, including secrets and RBAC objects")
|
|
1858
|
+
continue
|
|
1859
|
+
escalating = sorted(verbs & {"escalate", "bind", "impersonate"})
|
|
1860
|
+
if escalating or ({"pods/exec", "pods/attach"} & resources and "create" in verbs):
|
|
1861
|
+
emit("rbac_escalation", "verbs", at=body.find(rule), evidence=f"rule verbs={sorted(verbs)} resources={sorted(resources)}",
|
|
1862
|
+
severity="high", confidence=0.85,
|
|
1863
|
+
escalation="a subject bound to this role can grant itself more permissions or run commands in other pods")
|
|
1864
|
+
elif "secrets" in resources and verbs & {"get", "list", "watch"} and not _string_list(
|
|
1865
|
+
_attr_value(top, "resource_names")
|
|
1866
|
+
): # resource_names narrows it to named Secrets
|
|
1867
|
+
emit("rbac_secret_access", "verbs", at=body.find(rule), evidence=f"rule verbs={sorted(verbs)} resources=[secrets]",
|
|
1868
|
+
severity="medium" if cluster else "low", confidence=0.8,
|
|
1869
|
+
escalation="a subject bound to this role can read Secret values (service-account tokens, credentials)")
|
|
1870
|
+
|
|
1871
|
+
@staticmethod
|
|
1872
|
+
def _check_k8s_binding(body: str, emit) -> None: # type: ignore[no-untyped-def]
|
|
1873
|
+
role_ref = next(iter(_sub_blocks(body, "role_ref")), "")
|
|
1874
|
+
role = _extract_attr(role_ref, "name") or ""
|
|
1875
|
+
subjects = [(_extract_attr(s, "kind") or "", _extract_attr(s, "name") or "") for s in _sub_blocks(body, "subject")]
|
|
1876
|
+
broad = [name for _, name in subjects if name in _BROAD_K8S_GROUPS]
|
|
1877
|
+
# system:authenticated bound to system:basic-user and the like is the
|
|
1878
|
+
# default; only an admin-like role makes it a takeover.
|
|
1879
|
+
if role == "cluster-admin" or (broad and role in _ADMIN_LIKE_ROLES):
|
|
1880
|
+
who = ", ".join(f"{k}/{n}" for k, n in subjects) or "subjects"
|
|
1881
|
+
emit("cluster_admin_binding", "name", f"role_ref {role or '?'} bound to {who}", severity="high", confidence=0.9,
|
|
1882
|
+
escalation="every bound subject (and any pod running as it) has full control of the cluster")
|
|
1883
|
+
|
|
1884
|
+
def _emit_v2_default_routes(self, result: ScanResult) -> None:
|
|
1885
|
+
for resource, api_ref, body, file, line, context in self._v2_default_routes:
|
|
1886
|
+
match = _V2_API_REF_RE.match(api_ref.strip())
|
|
1887
|
+
if match is None or self._v2_protocols.get((_file_dir(file), match.group(1))) != "HTTP":
|
|
1888
|
+
continue # WebSocket API, or an API we can't resolve
|
|
1889
|
+
auth, guards, evidence = _route_auth(body, "authorization_type")
|
|
1890
|
+
self._append_unique_route(
|
|
1891
|
+
result, "/{proxy+}", "ANY", file, line, auth=auth, guards=guards, guard_evidence=evidence,
|
|
1892
|
+
)
|
|
1893
|
+
raw_auth = _extract_attr(body, "authorization_type")
|
|
1894
|
+
if raw_auth is None or raw_auth.strip().upper() == "NONE":
|
|
1895
|
+
self._body_line = None
|
|
1896
|
+
self._append_misconfig(
|
|
1897
|
+
result, "api_no_authorizer", resource, "aws", file, line,
|
|
1898
|
+
"catch-all $default route with "
|
|
1899
|
+
+ ('authorization_type = "NONE"' if raw_auth else "no authorization_type (defaults to NONE)"),
|
|
1900
|
+
severity="medium", confidence=0.85 if raw_auth else 0.7,
|
|
1901
|
+
escalation="every method and path no other route matches reaches the default integration unauthenticated",
|
|
1902
|
+
context=context,
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
def _emit_http_listeners(self, result: ScanResult) -> None:
|
|
1906
|
+
for resource, lb_ref, file, line, evidence, action, context in self._http_listeners:
|
|
1907
|
+
match = _LB_REF_RE.match(lb_ref.strip())
|
|
1908
|
+
internal = self._lb_internal.get((_file_dir(file), match.group(1))) if match else False
|
|
1909
|
+
if internal is not False:
|
|
1910
|
+
continue # internal, or `internal` is computed
|
|
1911
|
+
self._append_misconfig(
|
|
1912
|
+
result, "plaintext_listener", resource, "aws", file, line,
|
|
1913
|
+
f"{evidence} with no redirect to HTTPS",
|
|
1914
|
+
severity="low" if action == "fixed-response" else "medium",
|
|
1915
|
+
# A load balancer we couldn't resolve (var./module output) may be internal.
|
|
1916
|
+
confidence=0.8 if match else 0.6,
|
|
1917
|
+
escalation="traffic (cookies, tokens, form posts) crosses the network in clear text",
|
|
1918
|
+
context=context,
|
|
1919
|
+
)
|
|
1920
|
+
|
|
1921
|
+
def _s3_public_acl(self, result: ScanResult, resource: str, body: str, file: str, line: int, acl: str) -> None:
|
|
1922
|
+
writable = acl == "public-read-write"
|
|
1923
|
+
self._append_misconfig(
|
|
1924
|
+
result, "public_data_access", resource, "aws", file, self._attr_lineno(body, "acl", line),
|
|
1925
|
+
f'acl = "{acl}"', severity="high" if writable else "medium", confidence=0.9,
|
|
1926
|
+
escalation="anyone on the internet can " + ("list and overwrite" if writable else "list and read") + " the bucket",
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1929
|
+
def _s3_public_policy(self, result: ScanResult, resource: str, body: str, file: str, line: int) -> None:
|
|
1930
|
+
self._append_misconfig(
|
|
1931
|
+
result, "public_data_access", resource, "aws", file, self._attr_lineno(body, "policy", line),
|
|
1932
|
+
'bucket policy allows Principal "*" with no Condition', severity="medium", confidence=0.85,
|
|
1933
|
+
escalation="anyone on the internet gets the actions the statement allows on the bucket",
|
|
1934
|
+
)
|
|
1935
|
+
|
|
1936
|
+
def _check_rds_encryption(
|
|
1937
|
+
self, result: ScanResult, resource_type: str, resource: str, body: str, file: str, line: int,
|
|
1938
|
+
) -> None:
|
|
1939
|
+
"""``storage_encrypted`` defaults to false on ``aws_db_instance``.
|
|
1940
|
+
Replicas and snapshot restores inherit their source's encryption, and
|
|
1941
|
+
a ``kms_key_id`` implies encryption, so those are left alone."""
|
|
1942
|
+
value = _extract_attr(body, "storage_encrypted")
|
|
1943
|
+
if _is_literal_false(value):
|
|
1944
|
+
self._append_misconfig(
|
|
1945
|
+
result, "unencrypted_storage", resource, "aws", file, self._attr_lineno(body, "storage_encrypted", line),
|
|
1946
|
+
"storage_encrypted = false", severity="medium", confidence=0.9,
|
|
1947
|
+
escalation="snapshots, backups and the underlying volumes hold the data in clear text",
|
|
1948
|
+
)
|
|
1949
|
+
return
|
|
1950
|
+
if (
|
|
1951
|
+
value is None
|
|
1952
|
+
and resource_type == "aws_db_instance"
|
|
1953
|
+
and not any(_extract_attr(body, a) for a in ("replicate_source_db", "snapshot_identifier", "kms_key_id"))
|
|
1954
|
+
):
|
|
1955
|
+
self._append_misconfig(
|
|
1956
|
+
result, "unencrypted_storage", resource, "aws", file, line,
|
|
1957
|
+
"storage_encrypted not set (defaults to false)", severity="low", confidence=0.6,
|
|
1958
|
+
escalation="snapshots, backups and the underlying volumes hold the data in clear text",
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
def _append_misconfig(
|
|
1962
|
+
self,
|
|
1963
|
+
result: ScanResult,
|
|
1964
|
+
kind: str,
|
|
1965
|
+
resource: str,
|
|
1966
|
+
platform: str,
|
|
1967
|
+
file: str,
|
|
1968
|
+
line: int | None,
|
|
1969
|
+
evidence: str,
|
|
1970
|
+
*,
|
|
1971
|
+
severity: str,
|
|
1972
|
+
confidence: float = 0.8,
|
|
1973
|
+
escalation: str | None = None,
|
|
1974
|
+
context: str | None = None,
|
|
1975
|
+
) -> None:
|
|
1976
|
+
"""One `CloudMisconfig` per (kind, file, line, resource). ``context``
|
|
1977
|
+
defaults to the dispatched resource's for_each/count and module."""
|
|
1978
|
+
if not _CLOUD_RECORDS:
|
|
1979
|
+
return # AttackMap core older than the CloudMisconfig contract
|
|
1980
|
+
if not _first_seen(result, ("misconfig", kind, file, line, resource, evidence)):
|
|
1981
|
+
return
|
|
1982
|
+
result.cloud_misconfigs.append(
|
|
1983
|
+
CloudMisconfig(
|
|
1984
|
+
kind=kind, file=file, line=line, resource=resource, platform=platform,
|
|
1985
|
+
context=context if context is not None else self._context,
|
|
1986
|
+
escalation_path=escalation, evidence_text=evidence,
|
|
1987
|
+
severity=severity, confidence=confidence,
|
|
1988
|
+
)
|
|
1989
|
+
)
|
|
1990
|
+
|
|
1991
|
+
# ---------- Append helpers ----------
|
|
1992
|
+
|
|
1993
|
+
@staticmethod
|
|
1994
|
+
def _append_unique_route(
|
|
1995
|
+
result: ScanResult,
|
|
1996
|
+
path: str,
|
|
1997
|
+
method: str,
|
|
1998
|
+
file: str,
|
|
1999
|
+
line: int | None,
|
|
2000
|
+
*,
|
|
2001
|
+
auth: str = UNKNOWN,
|
|
2002
|
+
guards: list[str] | None = None,
|
|
2003
|
+
guard_evidence: str | None = None,
|
|
2004
|
+
) -> None:
|
|
2005
|
+
# Route.auth / guards / guard_evidence (AttackMap#256): core trusts
|
|
2006
|
+
# them over its own resolution; an older core ignores the fields. The
|
|
2007
|
+
# apigw*_open / lambda_url_open entrypoint hints stay for one release.
|
|
2008
|
+
key = (path, method, file)
|
|
2009
|
+
if not _first_seen(result, ("routes", *key)):
|
|
2010
|
+
return
|
|
2011
|
+
result.routes.append(
|
|
2012
|
+
Route(
|
|
2013
|
+
path=path, method=method, file=file, line=line,
|
|
2014
|
+
auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
|
|
2015
|
+
)
|
|
2016
|
+
)
|
|
2017
|
+
|
|
2018
|
+
@staticmethod
|
|
2019
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
2020
|
+
key = (kind, file)
|
|
2021
|
+
if not _first_seen(result, ("databases", *key)):
|
|
2022
|
+
return
|
|
2023
|
+
result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
|
|
2024
|
+
|
|
2025
|
+
@staticmethod
|
|
2026
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
|
|
2027
|
+
key = (hint, file)
|
|
2028
|
+
if not _first_seen(result, ("auth_hints", *key)):
|
|
2029
|
+
return
|
|
2030
|
+
result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
|
|
2031
|
+
|
|
2032
|
+
@staticmethod
|
|
2033
|
+
def _append_unique_secret(
|
|
2034
|
+
result: ScanResult,
|
|
2035
|
+
name: str,
|
|
2036
|
+
file: str,
|
|
2037
|
+
line: int | None,
|
|
2038
|
+
evidence: str | None,
|
|
2039
|
+
*,
|
|
2040
|
+
kind: str | None = None,
|
|
2041
|
+
) -> None:
|
|
2042
|
+
key = (name, file)
|
|
2043
|
+
if not _first_seen(result, ("secret_hints", *key)):
|
|
2044
|
+
return
|
|
2045
|
+
extra = {"kind": kind} if kind is not None else {}
|
|
2046
|
+
result.secret_hints.append(
|
|
2047
|
+
SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85, **extra)
|
|
2048
|
+
)
|
|
2049
|
+
|
|
2050
|
+
@staticmethod
|
|
2051
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
2052
|
+
key = (target, file)
|
|
2053
|
+
if not _first_seen(result, ("external_calls", *key)):
|
|
2054
|
+
return
|
|
2055
|
+
result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
|
|
2056
|
+
|
|
2057
|
+
@staticmethod
|
|
2058
|
+
def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
2059
|
+
key = (hint, file)
|
|
2060
|
+
if not _first_seen(result, ("framework_hints", *key)):
|
|
2061
|
+
return
|
|
2062
|
+
result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
2063
|
+
|
|
2064
|
+
@staticmethod
|
|
2065
|
+
def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
2066
|
+
key = (hint, file)
|
|
2067
|
+
if not _first_seen(result, ("entrypoint_hints", *key)):
|
|
2068
|
+
return
|
|
2069
|
+
result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
2070
|
+
|
|
2071
|
+
@staticmethod
|
|
2072
|
+
def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
|
|
2073
|
+
key = (hint, file)
|
|
2074
|
+
if not _first_seen(result, ("service_hints", *key)):
|
|
2075
|
+
return
|
|
2076
|
+
result.service_hints.append(ServiceHint(hint=hint, file=file))
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
__all__ = ["TerraformAnalyzer"]
|