11import ast
22import textwrap
3+
34from 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
2928def 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+
4474def 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
6395def 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+
118183def 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