Skip to content

Commit 0d02577

Browse files
committed
feat: detect secrets in dictionary literals
1 parent d3e5264 commit 0d02577

2 files changed

Lines changed: 389 additions & 33 deletions

File tree

detectors/ast_analyzer.py

Lines changed: 94 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ast
22
import textwrap
3+
34
from detectors.models import Candidate
45

56

@@ -19,28 +20,57 @@ def parse_ast(file):
1920
file = textwrap.dedent(file)
2021

2122
try:
22-
tree = ast.parse(file)
23+
return ast.parse(file)
2324
except SyntaxError:
2425
return None
2526

26-
return tree
27-
2827

2928
def get_assignments(tree):
3029
"""
31-
Yield assignment nodes from a parsed AST.
30+
Yield supported assignment nodes from a parsed AST.
31+
32+
Supports normal assignments and annotated assignments.
3233
3334
Args:
3435
tree (ast.AST): Parsed syntax tree.
3536
3637
Yields:
37-
ast.Assign: Assignment nodes found during traversal.
38+
ast.Assign | ast.AnnAssign: Supported assignment nodes.
3839
"""
3940
for node in ast.walk(tree):
40-
if isinstance(node, ast.Assign) or isinstance(node, ast.AnnAssign):
41+
if isinstance(node, (ast.Assign, ast.AnnAssign)):
4142
yield node
4243

4344

45+
def extract_candidates_from_dict(dict_node):
46+
"""
47+
Yield candidates from string key/value pairs in a dictionary literal.
48+
49+
Example:
50+
{"password": "abcdef"}
51+
52+
Produces:
53+
Candidate(var_name="password", value="abcdef", line_number=...)
54+
"""
55+
for key_node, value_node in zip(dict_node.keys, dict_node.values):
56+
if key_node is None:
57+
continue
58+
59+
if not (
60+
isinstance(key_node, ast.Constant)
61+
and isinstance(key_node.value, str)
62+
and isinstance(value_node, ast.Constant)
63+
and isinstance(value_node.value, str)
64+
):
65+
continue
66+
67+
yield Candidate(
68+
line_number=getattr(value_node, "lineno", getattr(dict_node, "lineno", 0)),
69+
var_name=key_node.value.lower(),
70+
value=value_node.value,
71+
)
72+
73+
4474
def extract_node_value(node):
4575
"""
4676
Extract a string literal from an assignment node.
@@ -49,21 +79,38 @@ def extract_node_value(node):
4979
evaluates hardcoded string values.
5080
5181
Args:
52-
node (ast.Assign): Assignment node.
82+
node (ast.Assign | ast.AnnAssign): Assignment node.
5383
5484
Returns:
5585
str | None: Extracted string value, or None if unsupported.
5686
"""
5787
val = node.value
88+
5889
if not (isinstance(val, ast.Constant) and isinstance(val.value, str)):
5990
return None
91+
6092
return val.value
6193

6294

6395
def extract_target_nodes(node):
96+
"""
97+
Yield target nodes from normal or annotated assignments.
98+
99+
Normal assignments may have multiple targets:
100+
a = b = "secret"
101+
102+
Annotated assignments have one target:
103+
password: str = "secret"
104+
105+
Args:
106+
node (ast.Assign | ast.AnnAssign): Assignment node.
107+
108+
Yields:
109+
ast.AST: Assignment target nodes.
110+
"""
64111
if isinstance(node, ast.AnnAssign):
65112
yield node.target
66-
113+
67114
elif isinstance(node, ast.Assign):
68115
for target in node.targets:
69116
yield target
@@ -73,36 +120,34 @@ def extract_variable_path_from_target(target):
73120
"""
74121
Extract normalized variable paths from assignment targets.
75122
76-
Supports simple variables and nested attributes, such as:
77-
`password` and `self.config.password`.
123+
Supports simple variables, nested attributes, and string subscript keys:
124+
password = "secret"
125+
self.config.password = "secret"
126+
config["password"] = "secret"
78127
79128
Args:
80-
node (ast.Assign): Assignment node.
129+
target (ast.AST): Assignment target node.
81130
82131
Yields:
83132
list[str]: Lowercased variable path components.
84133
"""
85-
86134
full_path = []
87135
temp_node = target
88136

89-
# Walk nested attributes from right to left.
90137
if isinstance(temp_node, ast.Attribute):
91138
while isinstance(temp_node, ast.Attribute):
92139
full_path.append(temp_node.attr.lower())
93140
temp_node = temp_node.value
94141

95-
# Ignore unsupported roots.
96-
if not isinstance(temp_node, ast.Name) or isinstance(temp_node, ast.Subscript):
142+
if not isinstance(temp_node, ast.Name):
97143
return
98144

99145
full_path.append(temp_node.id.lower())
100146
full_path.reverse()
101147

102-
# Handle direct variable assignment.
103148
elif isinstance(target, ast.Name):
104149
full_path.append(target.id.lower())
105-
150+
106151
elif isinstance(target, ast.Subscript):
107152
key = target.slice
108153

@@ -115,12 +160,36 @@ def extract_variable_path_from_target(target):
115160
yield full_path
116161

117162

163+
def extract_assignment_candidates(node, value):
164+
"""
165+
Yield candidates from a string assignment node.
166+
167+
Args:
168+
node (ast.Assign | ast.AnnAssign): Assignment node.
169+
value (str): Extracted string value assigned to the target.
170+
171+
Yields:
172+
Candidate: Candidate generated from the assignment target and value.
173+
"""
174+
for target in extract_target_nodes(node):
175+
for full_path in extract_variable_path_from_target(target):
176+
yield Candidate(
177+
line_number=node.lineno,
178+
var_name=full_path[-1],
179+
value=value,
180+
)
181+
182+
118183
def extract_candidates(code):
119184
"""
120185
Yield candidate secret values extracted from Python source code.
121186
122-
Each candidate represents a string assignment that can be evaluated by
123-
the rule engine. Syntax errors and unsupported assignments are ignored.
187+
Supports:
188+
- normal string assignments
189+
- annotated string assignments
190+
- dictionary literals with string key/value pairs
191+
192+
Syntax errors and unsupported assignment shapes are ignored.
124193
125194
Args:
126195
code (str): Raw Python source code.
@@ -133,20 +202,12 @@ def extract_candidates(code):
133202
return
134203

135204
for node in get_assignments(tree):
136-
val = extract_node_value(node)
137-
if val is None:
205+
if isinstance(node.value, ast.Dict):
206+
yield from extract_candidates_from_dict(node.value)
138207
continue
139208

140-
line_number = node.lineno
141-
142-
targets = extract_target_nodes(node)
143-
144-
for target in targets:
145-
for full_path in extract_variable_path_from_target(target):
146-
var_name = full_path[-1]
209+
value = extract_node_value(node)
210+
if value is None:
211+
continue
147212

148-
yield Candidate(
149-
line_number=line_number,
150-
var_name=var_name,
151-
value=val,
152-
)
213+
yield from extract_assignment_candidates(node, value)

0 commit comments

Comments
 (0)