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.
@@ -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"]