openhack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,385 @@
1
+ """
2
+ Hunter swarm agent that spawns focused sub-hunters concurrently.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from .hunter import HunterAgent
10
+ from .llm import LLMClient
11
+ from .session import Session
12
+ from openhack.tools.registry import ToolRegistry
13
+ from openhack.categories import normalize_category
14
+ from openhack.config import settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _FRAMEWORK_GROUP_TEMPLATES: dict[str, dict[str, dict]] = {
19
+ "nextjs": {
20
+ "input_validation": {
21
+ "categories": ["xss", "injection", "ssrf", "open_redirect"],
22
+ "task": (
23
+ "Hunt for input validation vulnerabilities in the Next.js application"
24
+ "{root_hint}: XSS, injection flaws, SSRF, and open redirects. "
25
+ "Trace user input from entry points to dangerous sinks. "
26
+ "Focus on reading route handlers, API endpoints, and components "
27
+ "that render user-controlled data.\n\n"
28
+ "IMPORTANT -- Open Redirect Hunting Strategy:\n"
29
+ "Open redirects are a high-value, commonly exploitable vulnerability class. "
30
+ "You MUST specifically search for them:\n"
31
+ "1. Use grep to find ALL callback handlers: search for files matching '**/callback*', "
32
+ "'**/api/auth/**', '**/api/integrations/**'\n"
33
+ "2. Search for redirect sink patterns: grep for 'redirect(', 'NextResponse.redirect', "
34
+ "'res.redirect', 'window.location'\n"
35
+ "3. Search for common redirect parameter names: grep for 'returnTo', 'redirectTo', "
36
+ "'redirect_url', 'onErrorReturnTo', 'callbackUrl', 'successUrl', 'cancelUrl'\n"
37
+ "4. For each redirect found, trace whether the URL is validated.\n"
38
+ "5. Pay special attention to OAuth/payment callback handlers."
39
+ ),
40
+ },
41
+ "access_control": {
42
+ "categories": ["idor", "auth_bypass", "middleware_bypass"],
43
+ "task": (
44
+ "Hunt for access control vulnerabilities in the Next.js application"
45
+ "{root_hint}: IDOR, authentication bypass, "
46
+ "and middleware bypass. Focus on authorization checks, object ownership "
47
+ "validation, and middleware ordering."
48
+ ),
49
+ },
50
+ "data_handling": {
51
+ "categories": ["data_exposure", "csrf", "server_actions", "misconfiguration"],
52
+ "task": (
53
+ "Hunt for data handling and configuration vulnerabilities in the Next.js application"
54
+ "{root_hint}: data exposure, CSRF, server action flaws, and security "
55
+ "misconfigurations."
56
+ ),
57
+ },
58
+ },
59
+ "django": {
60
+ "input_validation": {
61
+ "categories": ["injection", "ssrf", "idor"],
62
+ "task": "Hunt for input validation vulnerabilities in the Django application{root_hint}: SQL injection via ORM escape hatches, SSRF, and IDOR.",
63
+ },
64
+ "access_control": {
65
+ "categories": ["auth_bypass", "csrf"],
66
+ "task": "Hunt for access control vulnerabilities in the Django application{root_hint}: missing @login_required, broken permissions, @csrf_exempt.",
67
+ },
68
+ "data_handling": {
69
+ "categories": ["data_exposure", "misconfiguration"],
70
+ "task": "Hunt for data handling and configuration vulnerabilities in the Django application{root_hint}: serializer exposure, DEBUG=True, hardcoded secrets.",
71
+ },
72
+ },
73
+ "express": {
74
+ "input_validation": {
75
+ "categories": ["injection", "ssrf", "idor"],
76
+ "task": "Hunt for input validation vulnerabilities in the Express.js application{root_hint}: SQL/NoSQL injection, command injection, SSRF, IDOR.",
77
+ },
78
+ "access_control": {
79
+ "categories": ["auth_bypass"],
80
+ "task": "Hunt for access control vulnerabilities in the Express.js application{root_hint}: missing auth middleware, JWT issues.",
81
+ },
82
+ "data_handling": {
83
+ "categories": ["data_exposure", "misconfiguration"],
84
+ "task": "Hunt for data handling vulnerabilities in the Express.js application{root_hint}: data leaks, CORS issues, missing helmet.",
85
+ },
86
+ },
87
+ "flask": {
88
+ "input_validation": {
89
+ "categories": ["injection", "ssrf", "idor"],
90
+ "task": "Hunt for input validation vulnerabilities in the Flask application{root_hint}: SQL injection, SSTI, command injection, SSRF, IDOR.",
91
+ },
92
+ "access_control": {
93
+ "categories": ["auth_bypass"],
94
+ "task": "Hunt for access control vulnerabilities in the Flask application{root_hint}: missing @login_required, unprotected blueprints.",
95
+ },
96
+ "data_handling": {
97
+ "categories": ["data_exposure", "misconfiguration"],
98
+ "task": "Hunt for data handling vulnerabilities in the Flask application{root_hint}: data leaks, DEBUG=True, hardcoded secrets.",
99
+ },
100
+ },
101
+ }
102
+
103
+
104
+ def build_hunter_groups(detected_frameworks: list[dict], has_supabase: bool = False) -> dict[str, dict]:
105
+ groups: dict[str, dict] = {}
106
+
107
+ for i, fw_info in enumerate(detected_frameworks):
108
+ fw_name = fw_info["framework"]
109
+ fw_root = fw_info["root"]
110
+
111
+ templates = _FRAMEWORK_GROUP_TEMPLATES.get(fw_name)
112
+ if templates is None:
113
+ continue
114
+
115
+ root_hint = (
116
+ f" located under `{fw_root}/`. Focus your file reads and grep "
117
+ f"searches within this directory"
118
+ if fw_root != "."
119
+ else ""
120
+ )
121
+
122
+ suffix = f"_{i}" if sum(1 for f in detected_frameworks if f["framework"] == fw_name) > 1 else ""
123
+
124
+ for group_key, template in templates.items():
125
+ group_name = f"{fw_name}_{group_key}{suffix}"
126
+ groups[group_name] = {
127
+ "categories": template["categories"],
128
+ "framework": fw_name,
129
+ "requires": "source_code",
130
+ "task": template["task"].format(root_hint=root_hint),
131
+ }
132
+
133
+ source_frameworks = [f for f in detected_frameworks]
134
+ if len(source_frameworks) >= 2:
135
+ fw_summary = ", ".join(f"{f['framework']} at `{f['root']}/`" for f in source_frameworks)
136
+ groups["cross_framework"] = {
137
+ "categories": ["injection", "ssrf", "auth_bypass", "idor", "data_exposure"],
138
+ "framework": None,
139
+ "requires": "source_code",
140
+ "task": (
141
+ f"This is a monorepo with multiple frameworks: {fw_summary}. "
142
+ "Hunt specifically for CROSS-SERVICE vulnerability chains."
143
+ ),
144
+ }
145
+
146
+ return groups
147
+
148
+
149
+ _CATEGORY_TO_DANGER_KEYWORDS: dict[str, list[str]] = {
150
+ "input_validation": ["SQLi", "XSS", "SSRF", "SSTI", "RCE", "Open Redirect", "Path Traversal",
151
+ "Command Injection", "Prototype Pollution"],
152
+ "access_control": ["IDOR", "Hardcoded Secret"],
153
+ "data_handling": ["Hardcoded Secret", "Race Condition"],
154
+ }
155
+
156
+
157
+ def _build_file_hints(attack_surface: dict, group_categories: list[str]) -> str:
158
+ """Build a file hint section from the attack surface for a hunter group.
159
+
160
+ Maps hunter group categories to relevant attack surface files (danger pattern
161
+ files and import dependencies) so the hunter knows exactly which files to read
162
+ instead of guessing.
163
+ """
164
+ # Determine which danger keywords are relevant for this group
165
+ relevant_keywords: set[str] = set()
166
+ for cat in group_categories:
167
+ for group_key, keywords in _CATEGORY_TO_DANGER_KEYWORDS.items():
168
+ if cat in _FRAMEWORK_GROUP_TEMPLATES.get("express", {}).get(group_key, {}).get("categories", []):
169
+ relevant_keywords.update(keywords)
170
+ if cat in _FRAMEWORK_GROUP_TEMPLATES.get("nextjs", {}).get(group_key, {}).get("categories", []):
171
+ relevant_keywords.update(keywords)
172
+ if cat in _FRAMEWORK_GROUP_TEMPLATES.get("django", {}).get(group_key, {}).get("categories", []):
173
+ relevant_keywords.update(keywords)
174
+ if cat in _FRAMEWORK_GROUP_TEMPLATES.get("flask", {}).get(group_key, {}).get("categories", []):
175
+ relevant_keywords.update(keywords)
176
+ # Fallback: if no keywords matched, include everything
177
+ if not relevant_keywords:
178
+ relevant_keywords = {"SQLi", "XSS", "SSRF", "SSTI", "RCE", "Path Traversal",
179
+ "Command Injection", "IDOR", "Open Redirect", "Prototype Pollution",
180
+ "Hardcoded Secret", "Race Condition"}
181
+
182
+ hint_files: list[str] = []
183
+ max_hints = 20
184
+
185
+ # Collect from danger_files and imported_dependencies
186
+ for source_key in ("danger_files", "imported_dependencies"):
187
+ for ep in attack_surface.get(source_key, []):
188
+ label = ep.get("label", "")
189
+ trigger = ep.get("trigger", "")
190
+ # Check if any relevant keyword appears in the label or trigger
191
+ if any(kw.lower() in label.lower() or kw.lower() in trigger.lower()
192
+ for kw in relevant_keywords):
193
+ signals = ep.get("danger_signals", [])
194
+ if signals:
195
+ signal_desc = "; ".join(
196
+ f"L{s['line']}: {s['description']}" for s in signals[:3]
197
+ )
198
+ hint_files.append(f" - `{ep['file']}` — {signal_desc}")
199
+ else:
200
+ hint_files.append(f" - `{ep['file']}` — {ep.get('trigger', label)}")
201
+
202
+ if len(hint_files) >= max_hints:
203
+ break
204
+ if len(hint_files) >= max_hints:
205
+ break
206
+
207
+ # Also include route handlers / framework entry points
208
+ route_files: list[str] = []
209
+ for source_key in ("route_handlers", "flask_routes", "django_views", "api_routes"):
210
+ for ep in attack_surface.get(source_key, []):
211
+ route_files.append(f" - `{ep['file']}`")
212
+ if len(route_files) >= 10:
213
+ break
214
+
215
+ if not hint_files and not route_files:
216
+ return ""
217
+
218
+ parts: list[str] = []
219
+ if route_files:
220
+ parts.append(
221
+ "**Entry point files** (route handlers — start your analysis here):\n"
222
+ + "\n".join(route_files[:10])
223
+ )
224
+ if hint_files:
225
+ parts.append(
226
+ "**High-signal files** (contain dangerous sinks or are imported by entry points — "
227
+ "you MUST read these):\n"
228
+ + "\n".join(hint_files)
229
+ )
230
+
231
+ return (
232
+ "\n\n## Discovered Attack Surface Files\n"
233
+ "The following files were identified by static analysis as security-relevant. "
234
+ "READ EACH of these files during your hunt — do not skip them.\n\n"
235
+ + "\n\n".join(parts)
236
+ )
237
+
238
+
239
+ class HunterSwarmAgent:
240
+ name = "hunter_swarm"
241
+ description = "Hunter swarm coordinator"
242
+
243
+ def __init__(self, llm: LLMClient, tools: ToolRegistry, session: Session):
244
+ self.llm = llm
245
+ self.tools = tools
246
+ self.session = session
247
+ self.total_cost: float = 0.0
248
+ self.total_tokens: int = 0
249
+ self.total_input_tokens: int = 0
250
+ self.total_output_tokens: int = 0
251
+
252
+ def _get_model_for_hunter(self) -> str:
253
+ return settings.hunter_model_id or self.llm.model
254
+
255
+ def _create_llm_for_sub_hunter(self) -> LLMClient:
256
+ model = self._get_model_for_hunter()
257
+ return LLMClient(model=model, temperature=0.0, max_tokens=8192, provider=self.llm.provider, prompt_cache_key=self.llm.prompt_cache_key)
258
+
259
+ @staticmethod
260
+ def _deduplicate_findings(findings: list[dict]) -> list[dict]:
261
+ if not findings:
262
+ return findings
263
+
264
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
265
+ CONFIDENCE_ORDER = {"high": 0, "medium": 1, "low": 2}
266
+
267
+ seen: dict[str, dict] = {}
268
+ for finding in findings:
269
+ file_path = (finding.get("file_path") or "").strip().lower()
270
+ raw_category = finding.get("vulnerability_type") or finding.get("category") or ""
271
+ vuln_type = normalize_category(raw_category).lower()
272
+ dedup_key = f"{file_path}::{vuln_type}"
273
+
274
+ if dedup_key not in seen:
275
+ seen[dedup_key] = finding
276
+ else:
277
+ existing = seen[dedup_key]
278
+ existing_sev = SEVERITY_ORDER.get((existing.get("severity") or "info").lower(), 4)
279
+ new_sev = SEVERITY_ORDER.get((finding.get("severity") or "info").lower(), 4)
280
+ existing_conf = CONFIDENCE_ORDER.get((existing.get("confidence") or "low").lower(), 2)
281
+ new_conf = CONFIDENCE_ORDER.get((finding.get("confidence") or "low").lower(), 2)
282
+ existing_detail = len(existing.get("description") or "")
283
+ new_detail = len(finding.get("description") or "")
284
+
285
+ if (new_sev, new_conf, -new_detail) < (existing_sev, existing_conf, -existing_detail):
286
+ seen[dedup_key] = finding
287
+
288
+ return list(seen.values())
289
+
290
+ def _determine_groups(self, context: dict) -> dict[str, dict]:
291
+ detected_frameworks = context.get("detected_frameworks", [])
292
+ if not detected_frameworks:
293
+ detected_frameworks = [{"framework": "nextjs", "root": "."}]
294
+
295
+ all_groups = build_hunter_groups(detected_frameworks)
296
+
297
+ return {name: config for name, config in all_groups.items()
298
+ if config["requires"] == "source_code"}
299
+
300
+ async def run(self, task: str, context: Optional[dict] = None) -> dict:
301
+ context = context or {}
302
+
303
+ active_groups = self._determine_groups(context)
304
+
305
+ if not active_groups:
306
+ return {"raw_output": "No hunter groups applicable", "findings": [], "type": "hunt_complete"}
307
+
308
+ self.session.add_trace(
309
+ agent=self.name, event_type="swarm_start",
310
+ content={"groups": list(active_groups.keys()), "group_count": len(active_groups)},
311
+ )
312
+
313
+ # Build file hints from attack surface for each hunter group
314
+ attack_surface = context.get("attack_surface", {})
315
+
316
+ sub_hunters: list[tuple[str, HunterAgent, str]] = []
317
+ for group_name, group_config in active_groups.items():
318
+ llm = self._create_llm_for_sub_hunter()
319
+ hunter = HunterAgent(
320
+ llm, self.tools, self.session,
321
+ vuln_categories=group_config["categories"],
322
+ group_name=group_name,
323
+ framework=group_config.get("framework"),
324
+ )
325
+ task_text = group_config["task"]
326
+ # Inject relevant file hints so the hunter knows WHERE to look
327
+ if attack_surface:
328
+ file_hints = _build_file_hints(attack_surface, group_config["categories"])
329
+ if file_hints:
330
+ task_text += file_hints
331
+ sub_hunters.append((group_name, hunter, task_text))
332
+
333
+ semaphore = asyncio.Semaphore(settings.max_concurrent_hunters)
334
+
335
+ async def run_sub_hunter(group_name, hunter, hunter_task):
336
+ async with semaphore:
337
+ try:
338
+ result = await hunter.run(hunter_task, context)
339
+ return group_name, result
340
+ except Exception as e:
341
+ logger.error(f"Sub-hunter {group_name} failed: {e}")
342
+ return group_name, {"raw_output": f"Failed: {e}", "findings": [], "type": "hunt_failed"}
343
+
344
+ tasks = [
345
+ asyncio.create_task(run_sub_hunter(name, hunter, task))
346
+ for name, hunter, task in sub_hunters
347
+ ]
348
+ try:
349
+ results = await asyncio.gather(*tasks)
350
+ except asyncio.CancelledError:
351
+ for t in tasks:
352
+ t.cancel()
353
+ await asyncio.gather(*tasks, return_exceptions=True)
354
+ raise
355
+
356
+ all_findings: list[dict] = []
357
+ all_files_analyzed: set[str] = set()
358
+ summaries: list[str] = []
359
+
360
+ for group_name, result in results:
361
+ all_findings.extend(result.get("findings", []))
362
+ all_files_analyzed.update(result.get("files_analyzed", []))
363
+ if result.get("raw_output"):
364
+ summaries.append(f"[{group_name}] {result['raw_output']}")
365
+
366
+ all_findings = self._deduplicate_findings(all_findings)
367
+
368
+ for _, hunter, _ in sub_hunters:
369
+ self.total_cost += hunter.llm.total_cost
370
+ self.total_tokens += hunter.llm.total_tokens
371
+ self.total_input_tokens += hunter.llm.total_input_tokens
372
+ self.total_output_tokens += hunter.llm.total_output_tokens
373
+
374
+ self.session.add_trace(
375
+ agent=self.name, event_type="swarm_complete",
376
+ content={"total_findings": len(all_findings), "groups_completed": len(results),
377
+ "total_cost": self.total_cost, "total_tokens": self.total_tokens},
378
+ )
379
+
380
+ return {
381
+ "raw_output": "\n\n".join(summaries),
382
+ "findings": all_findings,
383
+ "type": "hunt_complete",
384
+ "files_analyzed": sorted(all_files_analyzed),
385
+ }