diff --git a/graphify/extractors/terraform.py b/graphify/extractors/terraform.py index 596c108ba6..5e05401f32 100644 --- a/graphify/extractors/terraform.py +++ b/graphify/extractors/terraform.py @@ -32,6 +32,7 @@ re.IGNORECASE, ) _REDACTED = "[redacted]" +_PAIRED_VALUE_KEYS = frozenset({"value", "valuefrom", "value_from"}) def _redact_value(key: str, value: object) -> object: @@ -52,7 +53,18 @@ def _redact_value(key: str, value: object) -> object: if _SENSITIVE_KEY_RE.search(key): return _REDACTED if isinstance(value, dict): - return {k: _redact_value(str(k), v) for k, v in value.items()} + redacted = {k: _redact_value(str(k), v) for k, v in value.items()} + # Name/value-pair idiom (ECS `environment`/`secrets`, `[{name, value}]`): + # the secret signal is the `name` literal, not a key, so key matching + # alone let `{ name = "DB_PASSWORD", value = "hunter2" }` leak (#3787). + if any( + str(k).lower() == "name" and isinstance(v, str) and _SENSITIVE_KEY_RE.search(v) + for k, v in value.items() + ): + for k in redacted: + if str(k).lower() in _PAIRED_VALUE_KEYS: + redacted[k] = _REDACTED + return redacted if isinstance(value, (list, tuple)): return [_redact_value(key, item) for item in value] return value diff --git a/tests/test_terraform.py b/tests/test_terraform.py index 8d6f1e19fc..99e7676a32 100644 --- a/tests/test_terraform.py +++ b/tests/test_terraform.py @@ -449,3 +449,33 @@ def test_terraform_secret_named_variable_default_and_output_value_are_redacted(t dumped = _json.dumps(r["nodes"]) for secret in ("hunter2-default", "tok-in-output", "marked-sensitive"): assert secret not in dumped + + +def test_terraform_name_value_pair_secret_is_redacted(tmp_path): + """The name/value-pair idiom (ECS `environment` / `secrets`, any + `[{ name, value }]` list) names the secret in the `name` LITERAL, while the + credential sits under the generic key `value`. Key-name matching never sees + that signal, so `{ name = "DB_PASSWORD", value = "hunter2" }` reached + graph.json verbatim (#3787). Ordinary name/value config stays intact.""" + body = """\ +resource "aws_ecs_task_definition" "app" { + family = "app" + environment = [ + { name = "DB_PASSWORD", value = "hunter2" }, + { name = "LOG_LEVEL", value = "debug" }, + ] + env_refs = [{ name = "API_KEY", valueFrom = "arn:aws:ssm:us-east-1:1:parameter/api" }] + single = { name = "client_secret", value = "in-a-map" } +} +""" + r = extract_terraform(_write(tmp_path, "ecs.tf", body)) + node = next(n for n in r["nodes"] if n["label"] == "aws_ecs_task_definition.app") + attrs = node["attributes"] + assert attrs["environment"][0] == {"name": "DB_PASSWORD", "value": "[redacted]"} + assert attrs["environment"][1] == {"name": "LOG_LEVEL", "value": "debug"} + assert attrs["env_refs"][0] == {"name": "API_KEY", "valueFrom": "[redacted]"} + assert attrs["single"] == {"name": "client_secret", "value": "[redacted]"} + import json as _json + dumped = _json.dumps(node) + for secret in ("hunter2", "in-a-map", "parameter/api"): + assert secret not in dumped