code-atlas-forge 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
"""Núcleo de extração do adapter Terraform (lf-0115, Phase 7).
|
|
2
|
+
|
|
3
|
+
Fluxo: artefatos ``.tf``/``.tf.json``/``.tfvars``(.json) do request →
|
|
4
|
+
parse estrutural → entidades ``TERRAFORM_RESOURCE``/``TERRAFORM_MODULE``/
|
|
5
|
+
``IAM_ROLE`` + relações ``DEPENDS_ON``/``PROVISIONS``/``USES_RESOURCE``/
|
|
6
|
+
``ASSUMES_ROLE``/``REFERENCES``.
|
|
7
|
+
|
|
8
|
+
Limites declarados (evidence-first, sem execução):
|
|
9
|
+
|
|
10
|
+
- identidade de recurso = ``type.name`` + module path (``namespace``)
|
|
11
|
+
+ ``environment`` quando estaticamente conhecido
|
|
12
|
+
(``request.environment`` ou default literal de variável de ambiente);
|
|
13
|
+
- ``depends_on`` explícito + refs implícitas ``tipo.nome`` →
|
|
14
|
+
``DEPENDS_ON``; refs dentro de atributos de documento (policy,
|
|
15
|
+
definition, statement…) → ``USES_RESOURCE``; refs ``data.tipo.nome``
|
|
16
|
+
→ ``REFERENCES``; leituras ``terraform_remote_state``, ``backend`` e
|
|
17
|
+
``module`` source remoto → ``REFERENCES`` com resolução ``external``;
|
|
18
|
+
- ``count``/``for_each`` → ``multiplicity=dynamic`` declarado — nenhuma
|
|
19
|
+
expansão fabricada;
|
|
20
|
+
- defaults/atribuições com chave casando ``password|secret|token|key|
|
|
21
|
+
credential`` nunca saem — registrados como ``[REDACTED]``;
|
|
22
|
+
- ``${…}`` em qualquer valor persistido → ``${dynamic}``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import bisect
|
|
28
|
+
import hashlib
|
|
29
|
+
import posixpath
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
|
|
35
|
+
from code_atlas.discovery.scanner import (
|
|
36
|
+
artifact_path_confined,
|
|
37
|
+
read_file_verified,
|
|
38
|
+
)
|
|
39
|
+
from code_atlas.domains.contracts import (
|
|
40
|
+
DomainDiagnostic,
|
|
41
|
+
DomainExtractionRequest,
|
|
42
|
+
DomainExtractionResult,
|
|
43
|
+
ExtractedDomainEntity,
|
|
44
|
+
ExtractedDomainRelation,
|
|
45
|
+
)
|
|
46
|
+
from code_atlas.domains.evidence import domain_extractor_id, evidence_record
|
|
47
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
48
|
+
from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
|
|
49
|
+
from code_atlas.domains.resolution import DomainResolution
|
|
50
|
+
from code_atlas.domains.terraform.catalog import provider_of, resource_class
|
|
51
|
+
from code_atlas.domains.terraform.hcl import HclBlock, HclFile, parse_hcl
|
|
52
|
+
from code_atlas.domains.terraform.refs import (
|
|
53
|
+
RefMatch,
|
|
54
|
+
is_sensitive_name,
|
|
55
|
+
looks_like_secret_value,
|
|
56
|
+
sanitize_value,
|
|
57
|
+
scan_refs,
|
|
58
|
+
)
|
|
59
|
+
from code_atlas.domains.terraform.tfjson import parse_tfjson
|
|
60
|
+
from code_atlas.domains.terraform.tfvars import parse_tfvars, parse_tfvars_json
|
|
61
|
+
from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
|
|
62
|
+
|
|
63
|
+
DOMAIN_ID = "terraform"
|
|
64
|
+
|
|
65
|
+
# Atributos cujo conteúdo é documento/payload — refs dentro deles são
|
|
66
|
+
# uso semântico do recurso (ARN em policy/definition), não wiring de
|
|
67
|
+
# dependência direta.
|
|
68
|
+
_DOC_ATTRS = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"policy", "policy_document", "assume_role_policy", "definition",
|
|
71
|
+
"document", "inline_policy", "statement", "statements", "json",
|
|
72
|
+
"template", "body", "user_data", "container_definitions", "input",
|
|
73
|
+
"resources", "actions", "principals", "identifiers",
|
|
74
|
+
"managed_policy_arns", "policy_arns", "values",
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Atributos que carregam uma role — ref a ``aws_iam_role`` nesses attrs
|
|
79
|
+
# é ``ASSUMES_ROLE`` (o recurso opera sob aquela identidade).
|
|
80
|
+
_ROLE_ATTRS = frozenset(
|
|
81
|
+
{
|
|
82
|
+
"role", "role_arn", "service_role", "service_role_arn",
|
|
83
|
+
"task_role", "task_role_arn", "execution_role_arn",
|
|
84
|
+
"job_role_arn", "instance_role", "instance_profile",
|
|
85
|
+
"delivery_stream_role_arn", "crawler_role", "role_name",
|
|
86
|
+
"service_linked_role_arn",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Attachments vinculam policy↔role — não é "assumir" a role.
|
|
91
|
+
_ROLE_BINDING_TYPES = frozenset(
|
|
92
|
+
{"aws_iam_role_policy_attachment", "aws_iam_policy_attachment"}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Variáveis que qualificam ambiente quando o default é literal estático.
|
|
96
|
+
_ENV_VAR_NAMES = frozenset({"environment", "env", "stage", "workspace"})
|
|
97
|
+
|
|
98
|
+
# Princípios de serviço literais em trust policies ("Service": "x").
|
|
99
|
+
_SERVICE_PRINCIPAL_RE = re.compile(
|
|
100
|
+
r'"?Service"?\s*[:=]\s*(?:"([^"\[\]]+?)"|\[\s*"([^"\[\]]+?)")'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_MAX_LISTED = 40
|
|
104
|
+
_MAX_DEPTH = 8
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class _Parsed:
|
|
109
|
+
"""Artefato parseado — conteúdo lido somente via ``base_path``."""
|
|
110
|
+
|
|
111
|
+
path: str # rel POSIX
|
|
112
|
+
directory: str # dir POSIX rel ("" = raiz do recorte)
|
|
113
|
+
sha256: str
|
|
114
|
+
kind: str # tf | tfjson | tfvars
|
|
115
|
+
parsed: HclFile
|
|
116
|
+
tfvars: tuple[tuple[str, str, bool], ...] = () # (nome, valor, redigido)
|
|
117
|
+
line_starts: tuple[int, ...] = () # byte offsets de "\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _line_starts(text: str) -> tuple[int, ...]:
|
|
121
|
+
"""Byte offsets do início de cada linha (para spans de refs)."""
|
|
122
|
+
starts = [0]
|
|
123
|
+
off = 0
|
|
124
|
+
for ch in text:
|
|
125
|
+
off += len(ch.encode("utf-8"))
|
|
126
|
+
if ch == "\n":
|
|
127
|
+
starts.append(off)
|
|
128
|
+
return tuple(starts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _byte_span(
|
|
132
|
+
line_starts: tuple[int, ...], start: int, end: int
|
|
133
|
+
) -> SourceSpan:
|
|
134
|
+
"""Byte offsets → ``SourceSpan`` com linha/col calculados."""
|
|
135
|
+
li = bisect.bisect_right(line_starts, start) - 1
|
|
136
|
+
lj = bisect.bisect_right(line_starts, end) - 1
|
|
137
|
+
return SourceSpan(
|
|
138
|
+
start_byte=start, end_byte=end,
|
|
139
|
+
start_line=li + 1, start_col=start - line_starts[li],
|
|
140
|
+
end_line=lj + 1, end_col=end - line_starts[lj],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class _Builder:
|
|
146
|
+
"""Acumulador do resultado — evidência/entidades/relações/diags."""
|
|
147
|
+
|
|
148
|
+
request: DomainExtractionRequest
|
|
149
|
+
adapter_version: str
|
|
150
|
+
evidence: list[EvidenceRecord] = field(default_factory=list)
|
|
151
|
+
_evi_index: dict[tuple[str, tuple[int, ...] | None], int] = field(
|
|
152
|
+
default_factory=dict
|
|
153
|
+
)
|
|
154
|
+
entities: list[ExtractedDomainEntity] = field(default_factory=list)
|
|
155
|
+
relations: list[ExtractedDomainRelation] = field(default_factory=list)
|
|
156
|
+
diagnostics: list[DomainDiagnostic] = field(default_factory=list)
|
|
157
|
+
stats: dict[str, int] = field(default_factory=dict)
|
|
158
|
+
id_by_addr: dict[tuple[str, str], DomainIdentity] = field(
|
|
159
|
+
default_factory=dict
|
|
160
|
+
)
|
|
161
|
+
mod_addr: dict[str, DomainIdentity] = field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
def evi(self, path: str, sha: str, span: SourceSpan | None) -> int:
|
|
164
|
+
"""Índice de evidência dedupado por (path, span)."""
|
|
165
|
+
key = (
|
|
166
|
+
path,
|
|
167
|
+
None
|
|
168
|
+
if span is None
|
|
169
|
+
else (
|
|
170
|
+
span.start_byte, span.end_byte, span.start_line,
|
|
171
|
+
span.start_col, span.end_line, span.end_col,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
idx = self._evi_index.get(key)
|
|
175
|
+
if idx is not None:
|
|
176
|
+
return idx
|
|
177
|
+
self.evidence.append(
|
|
178
|
+
evidence_record(
|
|
179
|
+
adapter_domain=DOMAIN_ID,
|
|
180
|
+
adapter_version=self.adapter_version,
|
|
181
|
+
path=path, content_hash=sha, span=span,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
idx = len(self.evidence) - 1
|
|
185
|
+
self._evi_index[key] = idx
|
|
186
|
+
return idx
|
|
187
|
+
|
|
188
|
+
def diag(
|
|
189
|
+
self, code: str, severity: str, message: str, path: str = ""
|
|
190
|
+
) -> None:
|
|
191
|
+
self.diagnostics.append(
|
|
192
|
+
DomainDiagnostic(
|
|
193
|
+
code=code, severity=severity, message=message,
|
|
194
|
+
path=path, adapter_id=DOMAIN_ID,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def ident(self, namespace: str, env: str, qname: str) -> DomainIdentity:
|
|
199
|
+
return DomainIdentity(
|
|
200
|
+
domain=DOMAIN_ID,
|
|
201
|
+
repository=self.request.repository,
|
|
202
|
+
root=self.request.root,
|
|
203
|
+
namespace=namespace,
|
|
204
|
+
environment=env,
|
|
205
|
+
qualified_name=qname,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _res(
|
|
209
|
+
self,
|
|
210
|
+
status: ResolutionStatus,
|
|
211
|
+
*,
|
|
212
|
+
target: DomainIdentity | None = None,
|
|
213
|
+
unresolved_text: str = "",
|
|
214
|
+
) -> DomainResolution:
|
|
215
|
+
return DomainResolution(
|
|
216
|
+
status=status,
|
|
217
|
+
confidence_class=ConfidenceClass.SYNTACTIC,
|
|
218
|
+
resolver=domain_extractor_id(DOMAIN_ID),
|
|
219
|
+
target=target,
|
|
220
|
+
unresolved_text=unresolved_text,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def rel_to_entity(
|
|
224
|
+
self,
|
|
225
|
+
rtype: str,
|
|
226
|
+
path: str,
|
|
227
|
+
from_id: DomainIdentity,
|
|
228
|
+
namespace: str,
|
|
229
|
+
qname: str,
|
|
230
|
+
span: SourceSpan | None,
|
|
231
|
+
evi: tuple[int, ...],
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Aresta para alvo interno — resolve contra entidades extraídas."""
|
|
234
|
+
target = self.id_by_addr.get((namespace, qname))
|
|
235
|
+
if target is not None:
|
|
236
|
+
self.relations.append(
|
|
237
|
+
ExtractedDomainRelation(
|
|
238
|
+
relation_type=rtype, path=path,
|
|
239
|
+
from_identity=from_id, to_identity=target,
|
|
240
|
+
resolution=self._res(
|
|
241
|
+
ResolutionStatus.RESOLVED, target=target,
|
|
242
|
+
),
|
|
243
|
+
span=span, evidence=evi,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
return
|
|
247
|
+
text = f"{namespace}.{qname}" if namespace else qname
|
|
248
|
+
self.relations.append(
|
|
249
|
+
ExtractedDomainRelation(
|
|
250
|
+
relation_type=rtype, path=path,
|
|
251
|
+
from_identity=from_id,
|
|
252
|
+
to_unresolved=text,
|
|
253
|
+
resolution=self._res(
|
|
254
|
+
ResolutionStatus.UNRESOLVED, unresolved_text=text,
|
|
255
|
+
),
|
|
256
|
+
span=span, evidence=evi,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
self.stats["unresolved_targets"] = (
|
|
260
|
+
self.stats.get("unresolved_targets", 0) + 1
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def rel_external(
|
|
264
|
+
self,
|
|
265
|
+
rtype: str,
|
|
266
|
+
path: str,
|
|
267
|
+
from_id: DomainIdentity,
|
|
268
|
+
text: str,
|
|
269
|
+
span: SourceSpan | None,
|
|
270
|
+
evi: tuple[int, ...],
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Aresta para alvo fora do snapshot — ``external`` declarado."""
|
|
273
|
+
clean = sanitize_value(text)
|
|
274
|
+
self.relations.append(
|
|
275
|
+
ExtractedDomainRelation(
|
|
276
|
+
relation_type=rtype, path=path,
|
|
277
|
+
from_identity=from_id,
|
|
278
|
+
to_unresolved=clean,
|
|
279
|
+
resolution=self._res(
|
|
280
|
+
ResolutionStatus.EXTERNAL, unresolved_text=clean,
|
|
281
|
+
),
|
|
282
|
+
span=span, evidence=evi,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
self.stats["external_refs"] = self.stats.get("external_refs", 0) + 1
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------
|
|
289
|
+
# leitura e roteamento de artefatos
|
|
290
|
+
# ---------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _artifact_kind(path: str) -> str:
|
|
294
|
+
lower = path.lower()
|
|
295
|
+
if lower.endswith(".tfvars.json"):
|
|
296
|
+
return "tfvars"
|
|
297
|
+
if lower.endswith(".tf.json"):
|
|
298
|
+
return "tfjson"
|
|
299
|
+
if lower.endswith(".tfvars"):
|
|
300
|
+
return "tfvars"
|
|
301
|
+
if lower.endswith(".tf"):
|
|
302
|
+
return "tf"
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _read_one(
|
|
307
|
+
b: _Builder, rel_path: str, base_path: Path | None
|
|
308
|
+
) -> bytes | None:
|
|
309
|
+
"""Bytes do artefato — ``None`` + diag em falha/fora-da-raiz.
|
|
310
|
+
|
|
311
|
+
Com ``base_path`` o path do request é hostil: absoluto, ``..`` ou
|
|
312
|
+
symlink trocado nunca sai da raiz (``artifact_path_confined`` +
|
|
313
|
+
``read_file_verified``, abertura O_NOFOLLOW — lf-c8 F7).
|
|
314
|
+
"""
|
|
315
|
+
if base_path is None:
|
|
316
|
+
try:
|
|
317
|
+
return Path(rel_path).read_bytes()
|
|
318
|
+
except OSError as exc:
|
|
319
|
+
b.diag(
|
|
320
|
+
"artifact-unreadable", "error",
|
|
321
|
+
f"cannot read {rel_path}: {exc}", rel_path,
|
|
322
|
+
)
|
|
323
|
+
return None
|
|
324
|
+
base = base_path.resolve()
|
|
325
|
+
fs = artifact_path_confined(rel_path, base)
|
|
326
|
+
if fs is None:
|
|
327
|
+
b.diag(
|
|
328
|
+
"artifact-outside-root", "error",
|
|
329
|
+
f"artifact path escapes repository root: {rel_path}",
|
|
330
|
+
rel_path,
|
|
331
|
+
)
|
|
332
|
+
return None
|
|
333
|
+
raw = read_file_verified(fs, base)
|
|
334
|
+
if raw is None:
|
|
335
|
+
b.diag(
|
|
336
|
+
"artifact-unreadable", "error",
|
|
337
|
+
f"cannot read {rel_path}: unsafe or vanished",
|
|
338
|
+
rel_path,
|
|
339
|
+
)
|
|
340
|
+
return None
|
|
341
|
+
return raw
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _read_artifacts(
|
|
345
|
+
b: _Builder, base_path: Path | None
|
|
346
|
+
) -> list[_Parsed]:
|
|
347
|
+
out: list[_Parsed] = []
|
|
348
|
+
for art in b.request.artifacts:
|
|
349
|
+
kind = _artifact_kind(art.path)
|
|
350
|
+
if not kind:
|
|
351
|
+
b.diag(
|
|
352
|
+
"unsupported-artifact", "warning",
|
|
353
|
+
f"artifact not handled by terraform adapter: {art.path}",
|
|
354
|
+
art.path,
|
|
355
|
+
)
|
|
356
|
+
continue
|
|
357
|
+
raw = _read_one(b, art.path, base_path)
|
|
358
|
+
if raw is None:
|
|
359
|
+
continue
|
|
360
|
+
sha = hashlib.sha256(raw).hexdigest()
|
|
361
|
+
try:
|
|
362
|
+
text = raw.decode("utf-8")
|
|
363
|
+
except UnicodeDecodeError:
|
|
364
|
+
text = raw.decode("utf-8", errors="replace")
|
|
365
|
+
b.diag(
|
|
366
|
+
"encoding-warning", "warning",
|
|
367
|
+
"non-UTF-8 bytes replaced during decode", art.path,
|
|
368
|
+
)
|
|
369
|
+
tfvars: tuple[tuple[str, str, bool], ...] = ()
|
|
370
|
+
if kind == "tf":
|
|
371
|
+
parsed = parse_hcl(text)
|
|
372
|
+
elif kind == "tfjson":
|
|
373
|
+
jf = parse_tfjson(text)
|
|
374
|
+
parsed = HclFile(blocks=jf.blocks, diagnostics=jf.diagnostics)
|
|
375
|
+
else:
|
|
376
|
+
tv = (
|
|
377
|
+
parse_tfvars_json(text)
|
|
378
|
+
if art.path.lower().endswith(".json")
|
|
379
|
+
else parse_tfvars(text)
|
|
380
|
+
)
|
|
381
|
+
parsed = HclFile(diagnostics=tv.diagnostics)
|
|
382
|
+
tfvars = tuple((a.name, a.value, a.redacted) for a in tv.assignments)
|
|
383
|
+
for d in parsed.diagnostics:
|
|
384
|
+
b.diag(
|
|
385
|
+
d.code,
|
|
386
|
+
"error" if "unterminated" in d.code or "error" in d.code
|
|
387
|
+
else "warning",
|
|
388
|
+
f"{d.message} (line {d.line})" if d.line else d.message,
|
|
389
|
+
art.path,
|
|
390
|
+
)
|
|
391
|
+
out.append(
|
|
392
|
+
_Parsed(
|
|
393
|
+
path=art.path, directory=posixpath.dirname(art.path),
|
|
394
|
+
sha256=sha, kind=kind, parsed=parsed, tfvars=tfvars,
|
|
395
|
+
line_starts=_line_starts(text),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
b.stats[f"{kind}_files"] = b.stats.get(f"{kind}_files", 0) + 1
|
|
399
|
+
return out
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------
|
|
403
|
+
# módulos: caminhos de instanciação resolvidos estaticamente
|
|
404
|
+
# ---------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _unquote(value: str) -> str:
|
|
408
|
+
v = value.strip()
|
|
409
|
+
if len(v) >= 2 and v.startswith('"') and v.endswith('"'):
|
|
410
|
+
return v[1:-1]
|
|
411
|
+
return v
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _norm_source(directory: str, source: str) -> str:
|
|
415
|
+
"""Source local (``./``/``../``) → dir POSIX normalizado; "" senão."""
|
|
416
|
+
if not source.startswith(("./", "../")):
|
|
417
|
+
return ""
|
|
418
|
+
joined = posixpath.normpath(posixpath.join(directory, source))
|
|
419
|
+
return "" if joined == "." else joined
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass(frozen=True)
|
|
423
|
+
class _ModuleCall:
|
|
424
|
+
name: str
|
|
425
|
+
directory: str # dir do arquivo que contém o bloco
|
|
426
|
+
source: str # valor bruto (unquoted)
|
|
427
|
+
local_dir: str # dir normalizado quando source é local, "" senão
|
|
428
|
+
block: HclBlock
|
|
429
|
+
parsed: _Parsed
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _module_calls(parsed: list[_Parsed]) -> list[_ModuleCall]:
|
|
433
|
+
calls: list[_ModuleCall] = []
|
|
434
|
+
for p in parsed:
|
|
435
|
+
if p.kind not in ("tf", "tfjson"):
|
|
436
|
+
continue
|
|
437
|
+
for blk in p.parsed.blocks:
|
|
438
|
+
if blk.type != "module" or not blk.labels:
|
|
439
|
+
continue
|
|
440
|
+
src_attr = blk.attribute("source")
|
|
441
|
+
source = _unquote(src_attr.value) if src_attr else ""
|
|
442
|
+
calls.append(
|
|
443
|
+
_ModuleCall(
|
|
444
|
+
name=blk.labels[0], directory=p.directory,
|
|
445
|
+
source=source,
|
|
446
|
+
local_dir=_norm_source(p.directory, source),
|
|
447
|
+
block=blk, parsed=p,
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
return calls
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _module_addresses(
|
|
454
|
+
parsed: list[_Parsed], calls: list[_ModuleCall]
|
|
455
|
+
) -> dict[str, tuple[str, ...]]:
|
|
456
|
+
"""dir → endereços terraform estáticos ("" = root module).
|
|
457
|
+
|
|
458
|
+
Fixpoint sobre chamadas com source local: ``module x {source="./m"}``
|
|
459
|
+
num diretório com endereço ``a`` dá ao dir ``m`` o endereço
|
|
460
|
+
``a.module.x``. Dirs não alcançados → ``("unresolved:<dir>",)``.
|
|
461
|
+
Um dir instanciado por N chamadas produz N endereços — nenhuma
|
|
462
|
+
expansão é fabricada, os endereços são literais.
|
|
463
|
+
"""
|
|
464
|
+
tf_dirs = sorted(
|
|
465
|
+
{p.directory for p in parsed if p.kind in ("tf", "tfjson")}
|
|
466
|
+
)
|
|
467
|
+
targeted = {c.local_dir for c in calls if c.local_dir}
|
|
468
|
+
candidates = [d for d in tf_dirs if d not in targeted]
|
|
469
|
+
pool = candidates or tf_dirs
|
|
470
|
+
root = min(pool, key=lambda d: (d.count("/"), d), default="")
|
|
471
|
+
addrs: dict[str, set[str]] = {d: set() for d in tf_dirs}
|
|
472
|
+
if root in addrs:
|
|
473
|
+
addrs[root].add("")
|
|
474
|
+
for _ in range(len(tf_dirs) + 2):
|
|
475
|
+
grew = False
|
|
476
|
+
for c in calls:
|
|
477
|
+
if not c.local_dir or c.local_dir not in addrs:
|
|
478
|
+
continue
|
|
479
|
+
for base in sorted(addrs.get(c.directory, ())):
|
|
480
|
+
addr = (
|
|
481
|
+
f"{base}.module.{c.name}" if base else f"module.{c.name}"
|
|
482
|
+
)
|
|
483
|
+
if addr not in addrs[c.local_dir]:
|
|
484
|
+
addrs[c.local_dir].add(addr)
|
|
485
|
+
grew = True
|
|
486
|
+
if not grew:
|
|
487
|
+
break
|
|
488
|
+
return {
|
|
489
|
+
d: tuple(sorted(v)) if v else (f"unresolved:{d}",)
|
|
490
|
+
for d, v in addrs.items()
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _dir_env(
|
|
495
|
+
b: _Builder, parsed: list[_Parsed]
|
|
496
|
+
) -> dict[str, tuple[str, str]]:
|
|
497
|
+
"""dir → ``(environment, source)`` — env estático por diretório.
|
|
498
|
+
|
|
499
|
+
``request.environment`` vence sempre; depois, default literal de
|
|
500
|
+
variável de ambiente (``environment|env|stage|workspace``).
|
|
501
|
+
"""
|
|
502
|
+
env: dict[str, tuple[str, str]] = {}
|
|
503
|
+
if b.request.environment:
|
|
504
|
+
for p in parsed:
|
|
505
|
+
env[p.directory] = (b.request.environment, "request")
|
|
506
|
+
return env
|
|
507
|
+
for p in parsed:
|
|
508
|
+
if p.kind not in ("tf", "tfjson") or p.directory in env:
|
|
509
|
+
continue
|
|
510
|
+
for blk in p.parsed.blocks:
|
|
511
|
+
raw = _env_var_default(blk)
|
|
512
|
+
if raw is not None:
|
|
513
|
+
env[p.directory] = (raw, "variable-default")
|
|
514
|
+
break
|
|
515
|
+
return env
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _env_var_default(blk: HclBlock) -> str | None:
|
|
519
|
+
"""Default literal de variável de ambiente — ``None`` se ausente
|
|
520
|
+
ou não-literal (interpolação nunca é avaliada como valor)."""
|
|
521
|
+
if blk.type != "variable" or not blk.labels:
|
|
522
|
+
return None
|
|
523
|
+
if blk.labels[0].lower() not in _ENV_VAR_NAMES:
|
|
524
|
+
return None
|
|
525
|
+
default = blk.attribute("default")
|
|
526
|
+
if default is None:
|
|
527
|
+
return None
|
|
528
|
+
raw = _unquote(default.value)
|
|
529
|
+
if "${" in raw or not raw or len(raw) > 64:
|
|
530
|
+
return None
|
|
531
|
+
if not all(ch.isalnum() or ch in "._-" for ch in raw):
|
|
532
|
+
return None
|
|
533
|
+
# default com formato de credencial (``environment = "AKIA…"``)
|
|
534
|
+
# não é nome de ambiente — recusado, nunca redigido como env (F5).
|
|
535
|
+
if looks_like_secret_value(raw):
|
|
536
|
+
return None
|
|
537
|
+
return raw
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ---------------------------------------------------------------------
|
|
541
|
+
# entidades
|
|
542
|
+
# ---------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _multiplicity(block: HclBlock) -> str:
|
|
546
|
+
"""count/for_each → multiplicidade declarada, nunca expandida."""
|
|
547
|
+
if (
|
|
548
|
+
block.attribute("count") is not None
|
|
549
|
+
or block.attribute("for_each") is not None
|
|
550
|
+
):
|
|
551
|
+
return "dynamic"
|
|
552
|
+
return "single"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _trusted_services(block: HclBlock, _depth: int = 0) -> list[str]:
|
|
556
|
+
"""Principals ``Service:`` literais do trust policy — strings curtas."""
|
|
557
|
+
found: list[str] = []
|
|
558
|
+
for attr in block.attributes:
|
|
559
|
+
if attr.name != "assume_role_policy":
|
|
560
|
+
continue
|
|
561
|
+
for m in _SERVICE_PRINCIPAL_RE.finditer(attr.value):
|
|
562
|
+
svc = m.group(1) or m.group(2) or ""
|
|
563
|
+
if svc and svc not in found:
|
|
564
|
+
found.append(svc)
|
|
565
|
+
if _depth < _MAX_DEPTH:
|
|
566
|
+
for child in block.children:
|
|
567
|
+
for svc in _trusted_services(child, _depth + 1):
|
|
568
|
+
if svc not in found:
|
|
569
|
+
found.append(svc)
|
|
570
|
+
return sorted(found)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _resource_attrs(block: HclBlock, ns: str, env_src: str) -> dict[str, str]:
|
|
574
|
+
rtype, rname = block.labels[0], block.labels[1]
|
|
575
|
+
attrs = {
|
|
576
|
+
"resource_type": rtype,
|
|
577
|
+
"resource_name": rname,
|
|
578
|
+
"resource_class": resource_class(rtype),
|
|
579
|
+
"provider": provider_of(rtype),
|
|
580
|
+
"module_path": ns,
|
|
581
|
+
"multiplicity": _multiplicity(block),
|
|
582
|
+
"mode": "managed",
|
|
583
|
+
}
|
|
584
|
+
if env_src:
|
|
585
|
+
attrs["env_source"] = env_src
|
|
586
|
+
return attrs
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _is_sensitive_var(blk: HclBlock) -> bool:
|
|
590
|
+
"""Variável sensível: nome casa o padrão de segredos ou
|
|
591
|
+
``sensitive = true`` declarado."""
|
|
592
|
+
if is_sensitive_name(blk.labels[0]):
|
|
593
|
+
return True
|
|
594
|
+
sattr = blk.attribute("sensitive")
|
|
595
|
+
return sattr is not None and _unquote(sattr.value) == "true"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _scan_var_block(
|
|
599
|
+
b: _Builder,
|
|
600
|
+
blk: HclBlock,
|
|
601
|
+
names: list[str],
|
|
602
|
+
sensitive: list[str],
|
|
603
|
+
) -> str:
|
|
604
|
+
"""Um bloco ``variable``: registra nome/flag sensível e devolve
|
|
605
|
+
env-default literal (``""`` quando sensível ou não-literal)."""
|
|
606
|
+
vname = blk.labels[0]
|
|
607
|
+
names.append(vname)
|
|
608
|
+
if _is_sensitive_var(blk):
|
|
609
|
+
sensitive.append(vname)
|
|
610
|
+
b.stats["secrets_redacted"] = (
|
|
611
|
+
b.stats.get("secrets_redacted", 0) + 1
|
|
612
|
+
)
|
|
613
|
+
return ""
|
|
614
|
+
return _env_var_default(blk) or ""
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _attach_var_attrs(
|
|
618
|
+
b: _Builder,
|
|
619
|
+
parsed: list[_Parsed],
|
|
620
|
+
directory: str,
|
|
621
|
+
attrs: dict[str, str],
|
|
622
|
+
env_hints: dict[str, list[str]],
|
|
623
|
+
) -> None:
|
|
624
|
+
"""Variáveis do módulo (dir): nomes + flags — defaults sensíveis
|
|
625
|
+
nunca saem (``[REDACTED]``), interpolações viram ``${dynamic}``."""
|
|
626
|
+
names: list[str] = []
|
|
627
|
+
sensitive: list[str] = []
|
|
628
|
+
env_default = ""
|
|
629
|
+
for p in parsed:
|
|
630
|
+
if p.directory != directory or p.kind not in ("tf", "tfjson"):
|
|
631
|
+
continue
|
|
632
|
+
for blk in p.parsed.blocks:
|
|
633
|
+
if blk.type != "variable" or not blk.labels:
|
|
634
|
+
continue
|
|
635
|
+
default = _scan_var_block(b, blk, names, sensitive)
|
|
636
|
+
if default:
|
|
637
|
+
env_default = default
|
|
638
|
+
_var_summary_attrs(
|
|
639
|
+
attrs, names, sensitive, env_default, env_hints.get(directory)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _var_summary_attrs(
|
|
644
|
+
attrs: dict[str, str],
|
|
645
|
+
names: list[str],
|
|
646
|
+
sensitive: list[str],
|
|
647
|
+
env_default: str,
|
|
648
|
+
hints: list[str] | None,
|
|
649
|
+
) -> None:
|
|
650
|
+
"""Grava no ``attrs`` do módulo o resumo das variáveis declaradas."""
|
|
651
|
+
if names:
|
|
652
|
+
shown = sorted(set(names))[:_MAX_LISTED]
|
|
653
|
+
attrs["variables"] = ",".join(shown)
|
|
654
|
+
if len(set(names)) > _MAX_LISTED:
|
|
655
|
+
attrs["variables_truncated"] = "true"
|
|
656
|
+
if sensitive:
|
|
657
|
+
attrs["sensitive_variables"] = ",".join(sorted(set(sensitive)))
|
|
658
|
+
if env_default:
|
|
659
|
+
attrs["env_default"] = env_default
|
|
660
|
+
if hints:
|
|
661
|
+
attrs["tfvars_env_hints"] = ",".join(sorted(hints))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _source_kind(source: str) -> str:
|
|
665
|
+
if source.startswith(("./", "../")):
|
|
666
|
+
return "local"
|
|
667
|
+
if source.startswith(("git::", "github.com/")) or ".git" in source:
|
|
668
|
+
return "git"
|
|
669
|
+
if source.startswith(("s3::", "s3://")):
|
|
670
|
+
return "s3"
|
|
671
|
+
if source.startswith(("gcs::", "gs://")):
|
|
672
|
+
return "gcs"
|
|
673
|
+
if source.startswith(("http://", "https://")):
|
|
674
|
+
return "http"
|
|
675
|
+
return "registry"
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _module_attrs(
|
|
679
|
+
block: HclBlock, ns: str, env_src: str, source: str
|
|
680
|
+
) -> dict[str, str]:
|
|
681
|
+
attrs: dict[str, str] = {
|
|
682
|
+
"module_path": ns,
|
|
683
|
+
"multiplicity": _multiplicity(block),
|
|
684
|
+
}
|
|
685
|
+
if env_src:
|
|
686
|
+
attrs["env_source"] = env_src
|
|
687
|
+
if source:
|
|
688
|
+
attrs["source"] = sanitize_value(source)
|
|
689
|
+
attrs["source_kind"] = _source_kind(source)
|
|
690
|
+
return attrs
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _emit_entities(
|
|
694
|
+
b: _Builder,
|
|
695
|
+
parsed: list[_Parsed],
|
|
696
|
+
addresses: dict[str, tuple[str, ...]],
|
|
697
|
+
env: dict[str, tuple[str, str]],
|
|
698
|
+
env_hints: dict[str, list[str]],
|
|
699
|
+
) -> None:
|
|
700
|
+
"""Entidades de ``resource``/``data``/``module`` + módulo root."""
|
|
701
|
+
for p in parsed:
|
|
702
|
+
if p.kind not in ("tf", "tfjson"):
|
|
703
|
+
continue
|
|
704
|
+
p_env, p_src = env.get(p.directory, ("", ""))
|
|
705
|
+
for ns in addresses.get(p.directory, (f"unresolved:{p.directory}",)):
|
|
706
|
+
for blk in p.parsed.blocks:
|
|
707
|
+
evi = b.evi(p.path, p.sha256, blk.header_span or blk.span)
|
|
708
|
+
if blk.type == "resource" and len(blk.labels) >= 2:
|
|
709
|
+
_emit_resource(b, p, blk, ns, p_env, p_src, evi)
|
|
710
|
+
elif blk.type == "data" and len(blk.labels) >= 2:
|
|
711
|
+
_emit_data(b, p, blk, ns, p_env, p_src, evi)
|
|
712
|
+
elif blk.type == "module" and blk.labels:
|
|
713
|
+
_emit_module(
|
|
714
|
+
b, p, blk, ns, parsed, env, env_hints, evi
|
|
715
|
+
)
|
|
716
|
+
elif blk.type in ("resource", "data", "module"):
|
|
717
|
+
b.diag(
|
|
718
|
+
"malformed-block", "error",
|
|
719
|
+
f"{blk.type} block missing labels", p.path,
|
|
720
|
+
)
|
|
721
|
+
_emit_root_module(b, parsed, addresses, env, env_hints)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _emit_resource(
|
|
725
|
+
b: _Builder, p: _Parsed, blk: HclBlock, ns: str,
|
|
726
|
+
env_val: str, env_src: str, evi: int,
|
|
727
|
+
) -> None:
|
|
728
|
+
rtype, rname = blk.labels[0], blk.labels[1]
|
|
729
|
+
kind = (
|
|
730
|
+
DomainEntityKind.IAM_ROLE
|
|
731
|
+
if rtype == "aws_iam_role"
|
|
732
|
+
else DomainEntityKind.TERRAFORM_RESOURCE
|
|
733
|
+
)
|
|
734
|
+
qname = f"{rtype}.{rname}"
|
|
735
|
+
ident = b.ident(ns, env_val, qname)
|
|
736
|
+
attrs = _resource_attrs(blk, ns, env_src)
|
|
737
|
+
if kind is DomainEntityKind.IAM_ROLE:
|
|
738
|
+
svc = _trusted_services(blk)
|
|
739
|
+
if svc:
|
|
740
|
+
attrs["trusted_services"] = ",".join(svc)
|
|
741
|
+
b.entities.append(
|
|
742
|
+
ExtractedDomainEntity(
|
|
743
|
+
identity=ident, kind=kind, name=qname,
|
|
744
|
+
path=p.path, span=blk.span, content_hash=p.sha256,
|
|
745
|
+
attributes=attrs, evidence=(evi,),
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
b.id_by_addr[(ns, qname)] = ident
|
|
749
|
+
key = "iam_roles" if kind is DomainEntityKind.IAM_ROLE else "resources"
|
|
750
|
+
b.stats[key] = b.stats.get(key, 0) + 1
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _emit_data(
|
|
754
|
+
b: _Builder, p: _Parsed, blk: HclBlock, ns: str,
|
|
755
|
+
env_val: str, env_src: str, evi: int,
|
|
756
|
+
) -> None:
|
|
757
|
+
rtype, rname = blk.labels[0], blk.labels[1]
|
|
758
|
+
qname = f"data.{rtype}.{rname}"
|
|
759
|
+
ident = b.ident(ns, env_val, qname)
|
|
760
|
+
attrs = _resource_attrs(blk, ns, env_src)
|
|
761
|
+
attrs["mode"] = "data"
|
|
762
|
+
if rtype == "terraform_remote_state":
|
|
763
|
+
attrs["remote_state"] = "true"
|
|
764
|
+
backend = blk.attribute("backend")
|
|
765
|
+
if backend is not None:
|
|
766
|
+
attrs["backend"] = sanitize_value(_unquote(backend.value))
|
|
767
|
+
b.entities.append(
|
|
768
|
+
ExtractedDomainEntity(
|
|
769
|
+
identity=ident, kind=DomainEntityKind.TERRAFORM_RESOURCE,
|
|
770
|
+
name=qname, path=p.path, span=blk.span,
|
|
771
|
+
content_hash=p.sha256, attributes=attrs, evidence=(evi,),
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
b.id_by_addr[(ns, qname)] = ident
|
|
775
|
+
b.stats["data_sources"] = b.stats.get("data_sources", 0) + 1
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _emit_module(
|
|
779
|
+
b: _Builder, p: _Parsed, blk: HclBlock, ns: str,
|
|
780
|
+
parsed: list[_Parsed],
|
|
781
|
+
env: dict[str, tuple[str, str]],
|
|
782
|
+
env_hints: dict[str, list[str]],
|
|
783
|
+
evi: int,
|
|
784
|
+
) -> None:
|
|
785
|
+
name = blk.labels[0]
|
|
786
|
+
qname = f"{ns}.module.{name}" if ns else f"module.{name}"
|
|
787
|
+
src_attr = blk.attribute("source")
|
|
788
|
+
source = _unquote(src_attr.value) if src_attr else ""
|
|
789
|
+
local_dir = _norm_source(p.directory, source)
|
|
790
|
+
# env da entidade-módulo = env do dir alvo quando local
|
|
791
|
+
env_key = local_dir if local_dir else p.directory
|
|
792
|
+
env_val, env_src = env.get(env_key, env.get(p.directory, ("", "")))
|
|
793
|
+
ident = b.ident(ns, env_val, qname)
|
|
794
|
+
attrs = _module_attrs(blk, ns, env_src, source)
|
|
795
|
+
if local_dir:
|
|
796
|
+
_attach_var_attrs(b, parsed, local_dir, attrs, env_hints)
|
|
797
|
+
b.entities.append(
|
|
798
|
+
ExtractedDomainEntity(
|
|
799
|
+
identity=ident, kind=DomainEntityKind.TERRAFORM_MODULE,
|
|
800
|
+
name=qname, path=p.path, span=blk.span,
|
|
801
|
+
content_hash=p.sha256, attributes=attrs, evidence=(evi,),
|
|
802
|
+
)
|
|
803
|
+
)
|
|
804
|
+
b.id_by_addr[(ns, qname)] = ident
|
|
805
|
+
b.mod_addr[qname] = ident
|
|
806
|
+
b.stats["modules"] = b.stats.get("modules", 0) + 1
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _emit_root_module(
|
|
810
|
+
b: _Builder,
|
|
811
|
+
parsed: list[_Parsed],
|
|
812
|
+
addresses: dict[str, tuple[str, ...]],
|
|
813
|
+
env: dict[str, tuple[str, str]],
|
|
814
|
+
env_hints: dict[str, list[str]],
|
|
815
|
+
) -> None:
|
|
816
|
+
"""Módulo root sintetizado — derivação declarada (``path=""``)."""
|
|
817
|
+
root_dir = ""
|
|
818
|
+
for d, addrs in addresses.items():
|
|
819
|
+
if "" in addrs:
|
|
820
|
+
root_dir = d
|
|
821
|
+
break
|
|
822
|
+
if not any(p.kind in ("tf", "tfjson") for p in parsed):
|
|
823
|
+
return
|
|
824
|
+
env_val, env_src = env.get(root_dir, ("", ""))
|
|
825
|
+
ident = b.ident("", env_val, "module.root")
|
|
826
|
+
attrs: dict[str, str] = {
|
|
827
|
+
"module_role": "root", "module_path": "",
|
|
828
|
+
"declared": "synthesized",
|
|
829
|
+
}
|
|
830
|
+
if env_src:
|
|
831
|
+
attrs["env_source"] = env_src
|
|
832
|
+
_attach_var_attrs(b, parsed, root_dir, attrs, env_hints)
|
|
833
|
+
b.entities.append(
|
|
834
|
+
ExtractedDomainEntity(
|
|
835
|
+
identity=ident, kind=DomainEntityKind.TERRAFORM_MODULE,
|
|
836
|
+
name="module.root", path="", attributes=attrs,
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
b.id_by_addr[("", "module.root")] = ident
|
|
840
|
+
b.mod_addr[""] = ident
|
|
841
|
+
b.stats["modules"] = b.stats.get("modules", 0) + 1
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# ---------------------------------------------------------------------
|
|
845
|
+
# relações
|
|
846
|
+
# ---------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _attr_index(block: HclBlock) -> list[tuple[int, int, str]]:
|
|
850
|
+
"""``(start_byte, end_byte, attr_name)`` dos valores do bloco."""
|
|
851
|
+
out: list[tuple[int, int, str]] = []
|
|
852
|
+
|
|
853
|
+
def walk(blk: HclBlock, depth: int) -> None:
|
|
854
|
+
if depth > _MAX_DEPTH:
|
|
855
|
+
return
|
|
856
|
+
for a in blk.attributes:
|
|
857
|
+
out.append(
|
|
858
|
+
(a.value_span.start_byte, a.value_span.end_byte, a.name)
|
|
859
|
+
)
|
|
860
|
+
for c in blk.children:
|
|
861
|
+
walk(c, depth + 1)
|
|
862
|
+
|
|
863
|
+
walk(block, 0)
|
|
864
|
+
return out
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _enclosing_attr(spans: list[tuple[int, int, str]], offset: int) -> str:
|
|
868
|
+
for s, e, name in spans:
|
|
869
|
+
if s <= offset <= e:
|
|
870
|
+
return name
|
|
871
|
+
return ""
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _block_from_ids(
|
|
875
|
+
b: _Builder,
|
|
876
|
+
blk: HclBlock,
|
|
877
|
+
ns_list: tuple[str, ...],
|
|
878
|
+
) -> list[DomainIdentity]:
|
|
879
|
+
"""Identidades do bloco em cada endereço do seu diretório."""
|
|
880
|
+
out: list[DomainIdentity] = []
|
|
881
|
+
for ns in ns_list:
|
|
882
|
+
if blk.type == "module":
|
|
883
|
+
qn = f"{ns}.module.{blk.labels[0]}" if ns else f"module.{blk.labels[0]}"
|
|
884
|
+
else:
|
|
885
|
+
prefix = "data." if blk.type == "data" else ""
|
|
886
|
+
qn = f"{prefix}{blk.labels[0]}.{blk.labels[1]}"
|
|
887
|
+
ident = b.id_by_addr.get((ns, qn))
|
|
888
|
+
if ident is not None:
|
|
889
|
+
out.append(ident)
|
|
890
|
+
return out
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _role_chain_edge(
|
|
894
|
+
b: _Builder,
|
|
895
|
+
p: _Parsed,
|
|
896
|
+
block: HclBlock,
|
|
897
|
+
m: RefMatch,
|
|
898
|
+
enclosing: str,
|
|
899
|
+
from_id: DomainIdentity,
|
|
900
|
+
span: SourceSpan | None,
|
|
901
|
+
evi: tuple[int, ...],
|
|
902
|
+
seen: set[tuple[DomainIdentity, str, str]],
|
|
903
|
+
) -> bool:
|
|
904
|
+
"""Trust chain: trust policy de R lista principal ``aws_iam_role.P``
|
|
905
|
+
→ aresta ``P ASSUMES_ROLE R``. ``True`` quando aplicado."""
|
|
906
|
+
if not (
|
|
907
|
+
block.type == "resource"
|
|
908
|
+
and block.labels
|
|
909
|
+
and block.labels[0] == "aws_iam_role"
|
|
910
|
+
and enclosing == "assume_role_policy"
|
|
911
|
+
and m.type_name == "aws_iam_role"
|
|
912
|
+
):
|
|
913
|
+
return False
|
|
914
|
+
ns = from_id.namespace
|
|
915
|
+
target_qname = f"{m.type_name}.{m.name}"
|
|
916
|
+
prin = b.id_by_addr.get((ns, target_qname))
|
|
917
|
+
if prin is None:
|
|
918
|
+
b.diag(
|
|
919
|
+
"role-principal-unresolved", "info",
|
|
920
|
+
f"trust policy principal {target_qname} not extracted "
|
|
921
|
+
"in this snapshot", p.path,
|
|
922
|
+
)
|
|
923
|
+
return True
|
|
924
|
+
key = (prin, str(from_id), "ASSUMES_ROLE")
|
|
925
|
+
if key not in seen:
|
|
926
|
+
seen.add(key)
|
|
927
|
+
b.relations.append(
|
|
928
|
+
ExtractedDomainRelation(
|
|
929
|
+
relation_type=str(DomainRelationKind.ASSUMES_ROLE),
|
|
930
|
+
path=p.path,
|
|
931
|
+
from_identity=prin,
|
|
932
|
+
to_identity=from_id,
|
|
933
|
+
resolution=b._res(
|
|
934
|
+
ResolutionStatus.RESOLVED, target=from_id,
|
|
935
|
+
),
|
|
936
|
+
span=span, evidence=evi,
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
return True
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _managed_ref_kinds(
|
|
943
|
+
block: HclBlock, m: RefMatch, explicit: bool, enclosing: str
|
|
944
|
+
) -> tuple[str, ...]:
|
|
945
|
+
"""Classifica ref a recurso managed: atributo de role →
|
|
946
|
+
``ASSUMES_ROLE`` + ``DEPENDS_ON``; doc/payload → ``USES_RESOURCE``;
|
|
947
|
+
demais → ``DEPENDS_ON``."""
|
|
948
|
+
if (
|
|
949
|
+
not explicit
|
|
950
|
+
and enclosing in _ROLE_ATTRS
|
|
951
|
+
and m.type_name == "aws_iam_role"
|
|
952
|
+
and block.labels
|
|
953
|
+
and block.labels[0] not in _ROLE_BINDING_TYPES
|
|
954
|
+
):
|
|
955
|
+
return (
|
|
956
|
+
str(DomainRelationKind.ASSUMES_ROLE),
|
|
957
|
+
str(DomainRelationKind.DEPENDS_ON),
|
|
958
|
+
)
|
|
959
|
+
if not explicit and enclosing in _DOC_ATTRS:
|
|
960
|
+
return (str(DomainRelationKind.USES_RESOURCE),)
|
|
961
|
+
return (str(DomainRelationKind.DEPENDS_ON),)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _emit_one_ref(
|
|
965
|
+
b: _Builder,
|
|
966
|
+
p: _Parsed,
|
|
967
|
+
block: HclBlock,
|
|
968
|
+
m: RefMatch,
|
|
969
|
+
explicit: bool,
|
|
970
|
+
enclosing: str,
|
|
971
|
+
from_id: DomainIdentity,
|
|
972
|
+
seen: set[tuple[DomainIdentity, str, str]],
|
|
973
|
+
) -> None:
|
|
974
|
+
"""Uma referência → aresta(s) honestas a partir de ``from_id``."""
|
|
975
|
+
ns = from_id.namespace
|
|
976
|
+
# tf: match em offsets de arquivo reais; tfjson: offsets no body
|
|
977
|
+
# serializado → evidência cai para o span do bloco.
|
|
978
|
+
span = (
|
|
979
|
+
_byte_span(p.line_starts, m.start, m.end)
|
|
980
|
+
if p.kind == "tf"
|
|
981
|
+
else block.span
|
|
982
|
+
)
|
|
983
|
+
evi = (b.evi(p.path, p.sha256, span or block.header_span),)
|
|
984
|
+
if m.kind == "arn":
|
|
985
|
+
key = (from_id, f"arn:{m.name}", "REFERENCES")
|
|
986
|
+
if key not in seen:
|
|
987
|
+
seen.add(key)
|
|
988
|
+
b.rel_external(
|
|
989
|
+
str(DomainRelationKind.REFERENCES), p.path, from_id,
|
|
990
|
+
m.name, span, evi,
|
|
991
|
+
)
|
|
992
|
+
return
|
|
993
|
+
if m.kind == "data":
|
|
994
|
+
tqn = f"data.{m.type_name}.{m.name}"
|
|
995
|
+
dtype = (
|
|
996
|
+
"DEPENDS_ON" if explicit else str(DomainRelationKind.REFERENCES)
|
|
997
|
+
)
|
|
998
|
+
key = (from_id, f"{ns}.{tqn}", dtype)
|
|
999
|
+
if key not in seen:
|
|
1000
|
+
seen.add(key)
|
|
1001
|
+
b.rel_to_entity(dtype, p.path, from_id, ns, tqn, span, evi)
|
|
1002
|
+
return
|
|
1003
|
+
if m.kind == "module":
|
|
1004
|
+
mqname = f"{ns}.module.{m.name}" if ns else f"module.{m.name}"
|
|
1005
|
+
key = (from_id, mqname, "DEPENDS_ON")
|
|
1006
|
+
if key not in seen:
|
|
1007
|
+
seen.add(key)
|
|
1008
|
+
b.rel_to_entity(
|
|
1009
|
+
str(DomainRelationKind.DEPENDS_ON), p.path, from_id,
|
|
1010
|
+
ns, mqname, span, evi,
|
|
1011
|
+
)
|
|
1012
|
+
return
|
|
1013
|
+
# ref a recurso managed
|
|
1014
|
+
if not explicit and _role_chain_edge(
|
|
1015
|
+
b, p, block, m, enclosing, from_id, span, evi, seen
|
|
1016
|
+
):
|
|
1017
|
+
return
|
|
1018
|
+
target_qname = f"{m.type_name}.{m.name}"
|
|
1019
|
+
for rtype in _managed_ref_kinds(block, m, explicit, enclosing):
|
|
1020
|
+
key = (from_id, f"{ns}.{target_qname}", rtype)
|
|
1021
|
+
if key in seen:
|
|
1022
|
+
continue
|
|
1023
|
+
seen.add(key)
|
|
1024
|
+
b.rel_to_entity(rtype, p.path, from_id, ns, target_qname, span, evi)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _emit_block_relations(
|
|
1028
|
+
b: _Builder,
|
|
1029
|
+
p: _Parsed,
|
|
1030
|
+
block: HclBlock,
|
|
1031
|
+
from_ids: list[DomainIdentity],
|
|
1032
|
+
) -> None:
|
|
1033
|
+
"""Refs do corpo do bloco → arestas por instância de identidade."""
|
|
1034
|
+
attr_spans = _attr_index(block)
|
|
1035
|
+
dep_attr = block.attribute("depends_on")
|
|
1036
|
+
dep_span = (
|
|
1037
|
+
(dep_attr.value_span.start_byte, dep_attr.value_span.end_byte)
|
|
1038
|
+
if dep_attr is not None
|
|
1039
|
+
else None
|
|
1040
|
+
)
|
|
1041
|
+
refs = scan_refs(block.body, base_byte=block.body_start)
|
|
1042
|
+
seen: set[tuple[DomainIdentity, str, str]] = set()
|
|
1043
|
+
for m in refs:
|
|
1044
|
+
explicit = (
|
|
1045
|
+
dep_span is not None and dep_span[0] <= m.start <= dep_span[1]
|
|
1046
|
+
)
|
|
1047
|
+
enclosing = "" if explicit else _enclosing_attr(attr_spans, m.start)
|
|
1048
|
+
for from_id in from_ids:
|
|
1049
|
+
_emit_one_ref(
|
|
1050
|
+
b, p, block, m, explicit, enclosing, from_id, seen
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _emit_terraform_block(
|
|
1055
|
+
b: _Builder,
|
|
1056
|
+
p: _Parsed,
|
|
1057
|
+
block: HclBlock,
|
|
1058
|
+
from_ids: list[DomainIdentity],
|
|
1059
|
+
) -> None:
|
|
1060
|
+
"""``terraform { backend "x" {} / cloud {} }`` → REFERENCES external."""
|
|
1061
|
+
for child in block.children:
|
|
1062
|
+
if child.type not in ("backend", "cloud"):
|
|
1063
|
+
continue
|
|
1064
|
+
label = child.labels[0] if child.labels else ""
|
|
1065
|
+
target = f"{child.type}:{label}" if label else child.type
|
|
1066
|
+
for from_id in from_ids:
|
|
1067
|
+
evi = (b.evi(p.path, p.sha256, child.header_span or child.span),)
|
|
1068
|
+
b.rel_external(
|
|
1069
|
+
str(DomainRelationKind.REFERENCES), p.path, from_id,
|
|
1070
|
+
target, child.header_span, evi,
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _emit_relations_for_block(
|
|
1075
|
+
b: _Builder,
|
|
1076
|
+
p: _Parsed,
|
|
1077
|
+
blk: HclBlock,
|
|
1078
|
+
ns_list: tuple[str, ...],
|
|
1079
|
+
) -> None:
|
|
1080
|
+
"""Despacha relações de um bloco — entidade própria ou módulo dono."""
|
|
1081
|
+
if blk.type in ("resource", "data") and len(blk.labels) < 2:
|
|
1082
|
+
return
|
|
1083
|
+
if blk.type in ("resource", "data", "module") and blk.labels:
|
|
1084
|
+
from_ids = _block_from_ids(b, blk, ns_list)
|
|
1085
|
+
if not from_ids:
|
|
1086
|
+
return
|
|
1087
|
+
if blk.type == "module":
|
|
1088
|
+
_emit_module_relations(b, p, blk, from_ids)
|
|
1089
|
+
elif blk.type == "data" and blk.labels[0] == "terraform_remote_state":
|
|
1090
|
+
_emit_remote_state_relations(b, p, blk, from_ids)
|
|
1091
|
+
_emit_block_relations(b, p, blk, from_ids)
|
|
1092
|
+
return
|
|
1093
|
+
if blk.type in ("output", "provider", "terraform"):
|
|
1094
|
+
owners = [b.mod_addr[ns] for ns in ns_list if ns in b.mod_addr]
|
|
1095
|
+
if not owners:
|
|
1096
|
+
return
|
|
1097
|
+
if blk.type == "terraform":
|
|
1098
|
+
_emit_terraform_block(b, p, blk, owners)
|
|
1099
|
+
_emit_block_relations(b, p, blk, owners)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _emit_relations(
|
|
1103
|
+
b: _Builder,
|
|
1104
|
+
parsed: list[_Parsed],
|
|
1105
|
+
calls: list[_ModuleCall],
|
|
1106
|
+
addresses: dict[str, tuple[str, ...]],
|
|
1107
|
+
) -> None:
|
|
1108
|
+
for p in parsed:
|
|
1109
|
+
if p.kind not in ("tf", "tfjson"):
|
|
1110
|
+
continue
|
|
1111
|
+
ns_list = addresses.get(
|
|
1112
|
+
p.directory, (f"unresolved:{p.directory}",)
|
|
1113
|
+
)
|
|
1114
|
+
for blk in p.parsed.blocks:
|
|
1115
|
+
_emit_relations_for_block(b, p, blk, ns_list)
|
|
1116
|
+
_emit_provisions(b, calls, addresses)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _emit_module_relations(
|
|
1120
|
+
b: _Builder,
|
|
1121
|
+
p: _Parsed,
|
|
1122
|
+
blk: HclBlock,
|
|
1123
|
+
from_ids: list[DomainIdentity],
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""``module`` com source remoto → REFERENCES external (declarado)."""
|
|
1126
|
+
src_attr = blk.attribute("source")
|
|
1127
|
+
if src_attr is None:
|
|
1128
|
+
return
|
|
1129
|
+
source = _unquote(src_attr.value)
|
|
1130
|
+
if _norm_source(p.directory, source):
|
|
1131
|
+
return # local — resolvido via PROVISIONS, nada externo
|
|
1132
|
+
for from_id in from_ids:
|
|
1133
|
+
evi = (b.evi(p.path, p.sha256, src_attr.value_span),)
|
|
1134
|
+
b.rel_external(
|
|
1135
|
+
str(DomainRelationKind.REFERENCES), p.path, from_id,
|
|
1136
|
+
source, src_attr.value_span, evi,
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _emit_remote_state_relations(
|
|
1141
|
+
b: _Builder,
|
|
1142
|
+
p: _Parsed,
|
|
1143
|
+
blk: HclBlock,
|
|
1144
|
+
from_ids: list[DomainIdentity],
|
|
1145
|
+
) -> None:
|
|
1146
|
+
"""``data "terraform_remote_state"`` → leitura externa declarada."""
|
|
1147
|
+
backend = blk.attribute("backend")
|
|
1148
|
+
bname = _unquote(backend.value) if backend is not None else ""
|
|
1149
|
+
target = f"remote-state:{bname}" if bname else "remote-state"
|
|
1150
|
+
for from_id in from_ids:
|
|
1151
|
+
evi = (b.evi(p.path, p.sha256, blk.header_span or blk.span),)
|
|
1152
|
+
b.rel_external(
|
|
1153
|
+
str(DomainRelationKind.REFERENCES), p.path, from_id,
|
|
1154
|
+
target, blk.header_span, evi,
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _provision_call(
|
|
1159
|
+
b: _Builder,
|
|
1160
|
+
call: _ModuleCall,
|
|
1161
|
+
addresses: dict[str, tuple[str, ...]],
|
|
1162
|
+
res_by_ns: dict[str, list[DomainIdentity]],
|
|
1163
|
+
prov_seen: set[tuple[DomainIdentity, DomainIdentity]],
|
|
1164
|
+
) -> None:
|
|
1165
|
+
"""``module x { source = "./dir" }`` → ``PROVISIONS`` por recurso."""
|
|
1166
|
+
for parent_addr in addresses.get(call.directory, ()):
|
|
1167
|
+
qname = (
|
|
1168
|
+
f"{parent_addr}.module.{call.name}"
|
|
1169
|
+
if parent_addr
|
|
1170
|
+
else f"module.{call.name}"
|
|
1171
|
+
)
|
|
1172
|
+
mod_id = b.mod_addr.get(qname)
|
|
1173
|
+
if mod_id is None:
|
|
1174
|
+
continue
|
|
1175
|
+
evi = (
|
|
1176
|
+
b.evi(call.parsed.path, call.parsed.sha256,
|
|
1177
|
+
call.block.header_span or call.block.span),
|
|
1178
|
+
)
|
|
1179
|
+
for target in res_by_ns.get(qname, []):
|
|
1180
|
+
if (mod_id, target) in prov_seen:
|
|
1181
|
+
continue
|
|
1182
|
+
prov_seen.add((mod_id, target))
|
|
1183
|
+
b.relations.append(
|
|
1184
|
+
ExtractedDomainRelation(
|
|
1185
|
+
relation_type=str(DomainRelationKind.PROVISIONS),
|
|
1186
|
+
path=call.parsed.path, from_identity=mod_id,
|
|
1187
|
+
to_identity=target,
|
|
1188
|
+
resolution=b._res(
|
|
1189
|
+
ResolutionStatus.RESOLVED, target=target,
|
|
1190
|
+
),
|
|
1191
|
+
span=call.block.header_span, evidence=evi,
|
|
1192
|
+
)
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _emit_provisions(
|
|
1197
|
+
b: _Builder,
|
|
1198
|
+
calls: list[_ModuleCall],
|
|
1199
|
+
addresses: dict[str, tuple[str, ...]],
|
|
1200
|
+
) -> None:
|
|
1201
|
+
"""``module`` call ``PROVISIONS`` recursos do source local; root idem."""
|
|
1202
|
+
res_by_ns: dict[str, list[DomainIdentity]] = {}
|
|
1203
|
+
for ent in b.entities:
|
|
1204
|
+
if ent.kind in (
|
|
1205
|
+
DomainEntityKind.TERRAFORM_RESOURCE, DomainEntityKind.IAM_ROLE,
|
|
1206
|
+
):
|
|
1207
|
+
res_by_ns.setdefault(ent.identity.namespace, []).append(
|
|
1208
|
+
ent.identity
|
|
1209
|
+
)
|
|
1210
|
+
prov_seen: set[tuple[DomainIdentity, DomainIdentity]] = set()
|
|
1211
|
+
for call in calls:
|
|
1212
|
+
if call.local_dir:
|
|
1213
|
+
_provision_call(b, call, addresses, res_by_ns, prov_seen)
|
|
1214
|
+
root = b.mod_addr.get("")
|
|
1215
|
+
if root is not None:
|
|
1216
|
+
for target in res_by_ns.get("", []):
|
|
1217
|
+
b.relations.append(
|
|
1218
|
+
ExtractedDomainRelation(
|
|
1219
|
+
relation_type=str(DomainRelationKind.PROVISIONS),
|
|
1220
|
+
path="", from_identity=root, to_identity=target,
|
|
1221
|
+
resolution=b._res(ResolutionStatus.RESOLVED, target=target),
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _emit_tfvars(
|
|
1227
|
+
b: _Builder, parsed: list[_Parsed]
|
|
1228
|
+
) -> dict[str, list[str]]:
|
|
1229
|
+
"""Hints de ambiente vindos de tfvars — valores sensíveis nunca saem."""
|
|
1230
|
+
env_hints: dict[str, list[str]] = {}
|
|
1231
|
+
for p in parsed:
|
|
1232
|
+
if p.kind != "tfvars":
|
|
1233
|
+
continue
|
|
1234
|
+
for name, value, redacted in p.tfvars:
|
|
1235
|
+
if redacted:
|
|
1236
|
+
b.stats["secrets_redacted"] = (
|
|
1237
|
+
b.stats.get("secrets_redacted", 0) + 1
|
|
1238
|
+
)
|
|
1239
|
+
continue
|
|
1240
|
+
if name.lower() in _ENV_VAR_NAMES and value:
|
|
1241
|
+
vals = env_hints.setdefault(p.directory, [])
|
|
1242
|
+
if value not in vals:
|
|
1243
|
+
vals.append(value)
|
|
1244
|
+
return env_hints
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
# ---------------------------------------------------------------------
|
|
1248
|
+
# pipeline
|
|
1249
|
+
# ---------------------------------------------------------------------
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def extract(
|
|
1253
|
+
request: DomainExtractionRequest,
|
|
1254
|
+
adapter_version: str,
|
|
1255
|
+
base_path: Path | None,
|
|
1256
|
+
) -> DomainExtractionResult:
|
|
1257
|
+
"""Orquestra leitura→parse→entidades→relações; nunca levanta."""
|
|
1258
|
+
b = _Builder(request=request, adapter_version=adapter_version)
|
|
1259
|
+
parsed = _read_artifacts(b, base_path)
|
|
1260
|
+
env_hints = _emit_tfvars(b, parsed)
|
|
1261
|
+
calls = _module_calls(parsed)
|
|
1262
|
+
addresses = _module_addresses(parsed, calls)
|
|
1263
|
+
env = _dir_env(b, parsed)
|
|
1264
|
+
_emit_entities(b, parsed, addresses, env, env_hints)
|
|
1265
|
+
_emit_relations(b, parsed, calls, addresses)
|
|
1266
|
+
b.stats["entities"] = len(b.entities)
|
|
1267
|
+
b.stats["relations"] = len(b.relations)
|
|
1268
|
+
b.stats["files"] = len(parsed)
|
|
1269
|
+
return DomainExtractionResult(
|
|
1270
|
+
domain_id=DOMAIN_ID,
|
|
1271
|
+
adapter_version=adapter_version,
|
|
1272
|
+
entities=tuple(b.entities),
|
|
1273
|
+
relations=tuple(b.relations),
|
|
1274
|
+
evidence=tuple(b.evidence),
|
|
1275
|
+
diagnostics=tuple(b.diagnostics),
|
|
1276
|
+
stats=b.stats,
|
|
1277
|
+
)
|