diff --git a/graphify/extractors/terraform.py b/graphify/extractors/terraform.py index d9dcb7e426..624cd68541 100644 --- a/graphify/extractors/terraform.py +++ b/graphify/extractors/terraform.py @@ -35,12 +35,26 @@ def _redact_value(key: str, value: object) -> object: - """Redact a sensitive attribute value; recurse into map values so a nested - `password` inside a `tags`/`connection` map is redacted too.""" + """Redact a sensitive attribute value; recurse into map AND list values so a + nested `password` inside a `tags`/`connection` map — or inside a list of + objects — is redacted too. + + HCL routinely nests objects inside tuples (`list(object(...))` variables, + `dynamic` blocks, tuple defaults), and `_parse_attr_value` turns those into + Python lists of dicts. Recursing into dicts but not lists left + `configs = [{ password = "x" }]` leaking verbatim while the map form + `config = { password = "x" }` was redacted — the value still reaches + graph.json and the MCP query/get_node surface unsanitized (#3644 follow-up). + List elements are recursed under the same key: the list branch is only + reached when `key` is NOT itself sensitive (a sensitive key redacts the whole + value above), so a scalar element carries no key signal and is returned + as-is, while a dict element is checked against its own inner keys.""" 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()} + 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 b01b35cde5..d4aa2ea44e 100644 --- a/tests/test_terraform.py +++ b/tests/test_terraform.py @@ -359,3 +359,44 @@ def test_terraform_sensitive_attribute_values_are_redacted(tmp_path): assert "hunter2-super-secret" not in _json.dumps(node) assert "AKIAWHATEVER" not in _json.dumps(node) assert "leaky" not in _json.dumps(node) + + +def test_terraform_redact_value_recurses_into_lists(): + """A secret nested inside a list — a list of objects, or a nested list — must + be redacted, matching the map case. HCL routinely nests objects in tuples + (`list(object(...))`, dynamic blocks), and recursing into dicts but not lists + left those values leaking (#3644 follow-up).""" + from graphify.extractors.terraform import _redact_value + + # list of objects: the secret-named inner key is redacted, ordinary key kept + assert _redact_value("connections", [{"host": "db", "password": "x"}]) == [ + {"host": "db", "password": "[redacted]"} + ] + # nested list -> list -> object + assert _redact_value("stages", [[{"api_key": "sk-1"}]]) == [[{"api_key": "[redacted]"}]] + # a sensitive KEY still redacts the whole list value + assert _redact_value("passwords", ["a", "b"]) == "[redacted]" + # a non-secret list of scalars is left intact + assert _redact_value("ports", [80, 443]) == [80, 443] + + +def test_terraform_secret_in_list_of_objects_is_redacted(tmp_path): + """End-to-end: a `password` inside a tuple-of-objects attribute must not reach + the graph verbatim, exactly as it wouldn't inside a map (#3644 follow-up).""" + body = """\ +resource "aws_x" "y" { + name = "app" + connections = [ + { host = "db1", password = "leaky-in-list" }, + { host = "db2", token = "tok-in-list" }, + ] +} +""" + r = extract_terraform(_write(tmp_path, "conns.tf", body)) + node = next(n for n in r["nodes"] if n["label"] == "aws_x.y") + conns = node["attributes"]["connections"] + assert conns[0]["host"] == "db1" and conns[0]["password"] == "[redacted]" + assert conns[1]["host"] == "db2" and conns[1]["token"] == "[redacted]" + import json as _json + assert "leaky-in-list" not in _json.dumps(node) + assert "tok-in-list" not in _json.dumps(node)