|
1
|
""" |
|
2
|
Chef framework enricher. |
|
3
|
|
|
4
|
Promotes generic graph nodes created by the Ruby parser to Chef-specific |
|
5
|
semantic types: |
|
6
|
- chef_recipe — files under recipes/ |
|
7
|
- chef_cookbook — metadata.rb files under cookbooks/ |
|
8
|
- chef_resource — functions/methods in recipes/ or libraries/ matching |
|
9
|
Chef resource names (package, template, service, etc.) |
|
10
|
- include_recipe — DEPENDS_ON edges for cross-recipe includes |
|
11
|
""" |
|
12
|
|
|
13
|
from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher |
|
14
|
from navegador.graph.store import GraphStore |
|
15
|
|
|
16
|
# Built-in Chef resource types that appear as method calls in recipes |
|
17
|
_CHEF_RESOURCES = frozenset( |
|
18
|
{ |
|
19
|
"package", |
|
20
|
"template", |
|
21
|
"service", |
|
22
|
"execute", |
|
23
|
"file", |
|
24
|
"directory", |
|
25
|
"cookbook_file", |
|
26
|
"remote_file", |
|
27
|
"cron", |
|
28
|
"user", |
|
29
|
"group", |
|
30
|
"mount", |
|
31
|
"link", |
|
32
|
"bash", |
|
33
|
"ruby_block", |
|
34
|
"apt_package", |
|
35
|
"yum_package", |
|
36
|
"powershell_script", |
|
37
|
"windows_service", |
|
38
|
"chef_gem", |
|
39
|
"log", |
|
40
|
"http_request", |
|
41
|
"remote_directory", |
|
42
|
} |
|
43
|
) |
|
44
|
|
|
45
|
|
|
46
|
class ChefEnricher(FrameworkEnricher): |
|
47
|
"""Enriches a navegador graph with Chef-specific semantic types.""" |
|
48
|
|
|
49
|
def __init__(self, store: GraphStore) -> None: |
|
50
|
super().__init__(store) |
|
51
|
|
|
52
|
# ── Identity ────────────────────────────────────────────────────────────── |
|
53
|
|
|
54
|
@property |
|
55
|
def framework_name(self) -> str: |
|
56
|
return "chef" |
|
57
|
|
|
58
|
@property |
|
59
|
def detection_patterns(self) -> list[str]: |
|
60
|
return ["chef"] |
|
61
|
|
|
62
|
@property |
|
63
|
def detection_files(self) -> list[str]: |
|
64
|
return ["metadata.rb", "Berksfile"] |
|
65
|
|
|
66
|
# ── Enrichment ──────────────────────────────────────────────────────────── |
|
67
|
|
|
68
|
def enrich(self) -> EnrichmentResult: |
|
69
|
result = EnrichmentResult() |
|
70
|
|
|
71
|
recipes = self._enrich_recipes() |
|
72
|
result.promoted += recipes |
|
73
|
result.patterns_found["recipes"] = recipes |
|
74
|
|
|
75
|
cookbooks = self._enrich_cookbooks() |
|
76
|
result.promoted += cookbooks |
|
77
|
result.patterns_found["cookbooks"] = cookbooks |
|
78
|
|
|
79
|
resources = self._enrich_resources() |
|
80
|
result.promoted += resources |
|
81
|
result.patterns_found["resources"] = resources |
|
82
|
|
|
83
|
includes = self._enrich_include_recipe() |
|
84
|
result.edges_added += includes |
|
85
|
result.patterns_found["include_recipe"] = includes |
|
86
|
|
|
87
|
return result |
|
88
|
|
|
89
|
# ── Pattern helpers ─────────────────────────────────────────────────────── |
|
90
|
|
|
91
|
def _enrich_recipes(self) -> int: |
|
92
|
"""Promote File nodes under /recipes/ to chef_recipe.""" |
|
93
|
promoted = 0 |
|
94
|
query_result = self.store.query( |
|
95
|
"MATCH (n:File) WHERE n.file_path CONTAINS $pattern RETURN n.name, n.file_path", |
|
96
|
{"pattern": "/recipes/"}, |
|
97
|
) |
|
98
|
rows = query_result.result_set or [] |
|
99
|
for row in rows: |
|
100
|
name, file_path = row[0], row[1] |
|
101
|
if name and file_path: |
|
102
|
self._promote_node(name, file_path, "chef_recipe") |
|
103
|
promoted += 1 |
|
104
|
return promoted |
|
105
|
|
|
106
|
def _enrich_cookbooks(self) -> int: |
|
107
|
"""Promote metadata.rb File nodes under /cookbooks/ to chef_cookbook.""" |
|
108
|
promoted = 0 |
|
109
|
query_result = self.store.query( |
|
110
|
"MATCH (n:File) WHERE n.file_path CONTAINS $cookbooks " |
|
111
|
"AND n.name = $name " |
|
112
|
"RETURN n.name, n.file_path", |
|
113
|
{"cookbooks": "/cookbooks/", "name": "metadata.rb"}, |
|
114
|
) |
|
115
|
rows = query_result.result_set or [] |
|
116
|
for row in rows: |
|
117
|
name, file_path = row[0], row[1] |
|
118
|
if name and file_path: |
|
119
|
self._promote_node(name, file_path, "chef_cookbook") |
|
120
|
promoted += 1 |
|
121
|
return promoted |
|
122
|
|
|
123
|
def _enrich_resources(self) -> int: |
|
124
|
"""Promote Function/Method nodes in recipes/ or libraries/ whose names |
|
125
|
match Chef built-in resource types.""" |
|
126
|
promoted = 0 |
|
127
|
for path_fragment in ("/recipes/", "/libraries/"): |
|
128
|
query_result = self.store.query( |
|
129
|
"MATCH (n) WHERE (n:Function OR n:Method) " |
|
130
|
"AND n.file_path CONTAINS $pattern " |
|
131
|
"RETURN n.name, n.file_path", |
|
132
|
{"pattern": path_fragment}, |
|
133
|
) |
|
134
|
rows = query_result.result_set or [] |
|
135
|
for row in rows: |
|
136
|
name, file_path = row[0], row[1] |
|
137
|
if name and file_path and name in _CHEF_RESOURCES: |
|
138
|
self._promote_node(name, file_path, "chef_resource") |
|
139
|
promoted += 1 |
|
140
|
return promoted |
|
141
|
|
|
142
|
def _enrich_include_recipe(self) -> int: |
|
143
|
"""Link include_recipe calls to the referenced recipe File nodes. |
|
144
|
|
|
145
|
Looks for Function nodes named ``include_recipe`` and follows CALLS |
|
146
|
edges or checks node properties to find the recipe name argument, |
|
147
|
then creates a DEPENDS_ON edge to the matching recipe File node. |
|
148
|
""" |
|
149
|
edges_added = 0 |
|
150
|
|
|
151
|
# Strategy 1: follow CALLS edges from include_recipe nodes |
|
152
|
query_result = self.store.query( |
|
153
|
"MATCH (n:Function)-[:CALLS]->(target) " |
|
154
|
"WHERE n.name = $name " |
|
155
|
"RETURN n.file_path, target.name", |
|
156
|
{"name": "include_recipe"}, |
|
157
|
) |
|
158
|
rows = query_result.result_set or [] |
|
159
|
for row in rows: |
|
160
|
caller_path, recipe_ref = row[0], row[1] |
|
161
|
if caller_path and recipe_ref: |
|
162
|
# recipe_ref may be "cookbook::recipe" — extract recipe name |
|
163
|
recipe_name = recipe_ref.split("::")[-1] if "::" in recipe_ref else recipe_ref |
|
164
|
# Find the recipe File node |
|
165
|
match_result = self.store.query( |
|
166
|
"MATCH (f:File) WHERE f.file_path CONTAINS $recipes " |
|
167
|
"AND f.name CONTAINS $recipe " |
|
168
|
"RETURN f.name", |
|
169
|
{"recipes": "/recipes/", "recipe": recipe_name}, |
|
170
|
) |
|
171
|
match_rows = match_result.result_set or [] |
|
172
|
if match_rows and match_rows[0][0]: |
|
173
|
# Create DEPENDS_ON from the caller's file to the recipe file |
|
174
|
caller_file_result = self.store.query( |
|
175
|
"MATCH (f:File) WHERE f.file_path = $path RETURN f.name", |
|
176
|
{"path": caller_path}, |
|
177
|
) |
|
178
|
caller_rows = caller_file_result.result_set or [] |
|
179
|
if caller_rows and caller_rows[0][0]: |
|
180
|
self._add_semantic_edge( |
|
181
|
caller_rows[0][0], |
|
182
|
"DEPENDS_ON", |
|
183
|
match_rows[0][0], |
|
184
|
) |
|
185
|
edges_added += 1 |
|
186
|
|
|
187
|
# Strategy 2: check signature/docstring for include_recipe calls |
|
188
|
for prop in ("signature", "docstring"): |
|
189
|
query_result = self.store.query( |
|
190
|
f"MATCH (n) WHERE (n:Function OR n:Method) " |
|
191
|
f"AND n.{prop} IS NOT NULL " |
|
192
|
f"AND n.{prop} CONTAINS $pattern " |
|
193
|
"RETURN n.name, n.file_path", |
|
194
|
{"pattern": "include_recipe"}, |
|
195
|
) |
|
196
|
rows = query_result.result_set or [] |
|
197
|
for row in rows: |
|
198
|
name, file_path = row[0], row[1] |
|
199
|
if name and file_path and name == "include_recipe": |
|
200
|
# Already handled in strategy 1 via CALLS edges |
|
201
|
continue |
|
202
|
|
|
203
|
return edges_added |
|
204
|
|