switch-trust-cli 1.7.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 (157) hide show
  1. switch_trust/__init__.py +0 -0
  2. switch_trust/cli/__init__.py +0 -0
  3. switch_trust/cli/__main__.py +3 -0
  4. switch_trust/cli/builtin_config.json +879 -0
  5. switch_trust/cli/console.py +127 -0
  6. switch_trust/cli/eval_cli.py +905 -0
  7. switch_trust/cli/file_filter.py +172 -0
  8. switch_trust/cli/init_cli.py +156 -0
  9. switch_trust/cli/main.py +384 -0
  10. switch_trust/cli/output_formatters.py +482 -0
  11. switch_trust/cli/rich_observer.py +89 -0
  12. switch_trust/cli/runner.py +274 -0
  13. switch_trust/cli/scan_cli.py +314 -0
  14. switch_trust/cli/telemetry.py +430 -0
  15. switch_trust/cli/utils.py +158 -0
  16. switch_trust/cli/version.py +1 -0
  17. switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
  18. switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
  19. switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
  20. switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
  21. switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
  22. switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
  23. switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
  24. switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
  25. switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
  26. switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
  27. switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
  28. switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
  29. switch_trust/data/llm01_prompt_injection.csv +6708 -0
  30. switch_trust/data/llm02_sensitive_information.csv +1069 -0
  31. switch_trust/data/llm05_unsafe_output.csv +3882 -0
  32. switch_trust/data/llm06_excessive_agency.csv +778 -0
  33. switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
  34. switch_trust/data/llm09_hallucination-goals.csv +32419 -0
  35. switch_trust/data/llm09_hallucination.csv +599 -0
  36. switch_trust/data/pii_leakage.csv +706 -0
  37. switch_trust/data/secret_leakage.csv +1380 -0
  38. switch_trust/eval/__init__.py +0 -0
  39. switch_trust/eval/common/__init__.py +0 -0
  40. switch_trust/eval/common/converter_anthropic.py +172 -0
  41. switch_trust/eval/common/converter_genai.py +171 -0
  42. switch_trust/eval/common/converter_openai.py +198 -0
  43. switch_trust/eval/common/log.py +116 -0
  44. switch_trust/eval/common/reference.py +25 -0
  45. switch_trust/eval/common/schema.py +190 -0
  46. switch_trust/eval/common/utils.py +119 -0
  47. switch_trust/eval/core/__init__.py +0 -0
  48. switch_trust/eval/core/detectors/__init__.py +0 -0
  49. switch_trust/eval/core/detectors/detector.py +25 -0
  50. switch_trust/eval/core/detectors/detector_garak.py +112 -0
  51. switch_trust/eval/core/detectors/detector_model.py +123 -0
  52. switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
  53. switch_trust/eval/core/detectors/detector_pii.py +67 -0
  54. switch_trust/eval/core/detectors/detector_secret.py +88 -0
  55. switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
  56. switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
  57. switch_trust/eval/core/eval/__init__.py +0 -0
  58. switch_trust/eval/core/eval/eval_creator.py +166 -0
  59. switch_trust/eval/core/eval/evaluation.py +112 -0
  60. switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
  61. switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
  62. switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
  63. switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
  64. switch_trust/eval/core/eval/evaluation_multi.py +276 -0
  65. switch_trust/eval/core/eval/evaluation_single.py +89 -0
  66. switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
  67. switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
  68. switch_trust/eval/core/eval/metric_conciseness.py +179 -0
  69. switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
  70. switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
  71. switch_trust/eval/core/eval/metric_tone.py +180 -0
  72. switch_trust/eval/core/eval/metric_toxicity.py +146 -0
  73. switch_trust/eval/core/eval/observer.py +137 -0
  74. switch_trust/eval/core/message/__init__.py +0 -0
  75. switch_trust/eval/core/message/message_collection.py +37 -0
  76. switch_trust/eval/core/message/message_collection_csv.py +44 -0
  77. switch_trust/eval/core/message/message_collection_garak.py +46 -0
  78. switch_trust/eval/core/message/message_collection_memory.py +34 -0
  79. switch_trust/eval/core/models/__init__.py +0 -0
  80. switch_trust/eval/core/models/generator_model.py +421 -0
  81. switch_trust/eval/core/models/model.py +172 -0
  82. switch_trust/eval/core/models/model_adk.py +186 -0
  83. switch_trust/eval/core/models/model_anthropic.py +79 -0
  84. switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
  85. switch_trust/eval/core/models/model_gemini.py +146 -0
  86. switch_trust/eval/core/models/model_generic_http.py +120 -0
  87. switch_trust/eval/core/models/model_http.py +135 -0
  88. switch_trust/eval/core/models/model_huggingface.py +75 -0
  89. switch_trust/eval/core/models/model_langserve.py +96 -0
  90. switch_trust/eval/core/models/model_litellm.py +52 -0
  91. switch_trust/eval/core/models/model_ollama.py +65 -0
  92. switch_trust/eval/core/models/model_openai.py +71 -0
  93. switch_trust/eval/core/models/model_openai_agent.py +92 -0
  94. switch_trust/eval/core/models/model_openai_compatible.py +66 -0
  95. switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
  96. switch_trust/eval/core/models/model_retry.py +204 -0
  97. switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
  98. switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
  99. switch_trust/eval/core/models/response_schema.py +148 -0
  100. switch_trust/eval/core/optional_deps.py +57 -0
  101. switch_trust/eval/db/__init__.py +0 -0
  102. switch_trust/eval/db/base/__init__.py +0 -0
  103. switch_trust/eval/db/base/common_types.py +16 -0
  104. switch_trust/eval/db/base/detectors/__init__.py +0 -0
  105. switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
  106. switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
  107. switch_trust/eval/db/base/detectors/detector_types.py +77 -0
  108. switch_trust/eval/db/base/eval/__init__.py +0 -0
  109. switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
  110. switch_trust/eval/db/base/eval/eval_repository.py +120 -0
  111. switch_trust/eval/db/base/eval/eval_run.py +142 -0
  112. switch_trust/eval/db/base/eval/eval_types.py +127 -0
  113. switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
  114. switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
  115. switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
  116. switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  117. switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
  118. switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
  119. switch_trust/eval/db/base/message/__init__.py +0 -0
  120. switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
  121. switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
  122. switch_trust/eval/db/base/message/message_collection_types.py +87 -0
  123. switch_trust/eval/db/base/models/__init__.py +0 -0
  124. switch_trust/eval/db/base/models/model_helpers.py +253 -0
  125. switch_trust/eval/db/base/models/model_repository.py +104 -0
  126. switch_trust/eval/db/base/models/model_types.py +159 -0
  127. switch_trust/eval/db/json/__init__.py +0 -0
  128. switch_trust/eval/db/json/repository_json.py +559 -0
  129. switch_trust/scan/__init__.py +12 -0
  130. switch_trust/scan/agent_scanner.py +812 -0
  131. switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
  132. switch_trust/scan/config/agent_taxonomy.json +517 -0
  133. switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
  134. switch_trust/scan/config/compliance_mappings.json +110 -0
  135. switch_trust/scan/config/triage_prompt.txt +326 -0
  136. switch_trust/scan/constants.py +15 -0
  137. switch_trust/scan/exception_classification.py +179 -0
  138. switch_trust/scan/llm_provider.py +330 -0
  139. switch_trust/scan/opengrep_resolver.py +12 -0
  140. switch_trust/scan/reasoner.py +602 -0
  141. switch_trust/scan/schema.py +315 -0
  142. switch_trust/scan/scorer.py +372 -0
  143. switch_trust/scan/static_scanner.py +681 -0
  144. switch_trust/scan/taxonomy.py +121 -0
  145. switch_trust/scan/tool_dispatcher.py +773 -0
  146. switch_trust/scan/trace_logger.py +118 -0
  147. switch_trust/scan/trace_logger_file.py +143 -0
  148. switch_trust/scan/trace_logger_log.py +100 -0
  149. switch_trust/scan/triage.py +614 -0
  150. switch_trust/schema.py +79 -0
  151. switch_trust/secret_anonymizer.py +72 -0
  152. switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
  153. switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
  154. switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
  155. switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
  156. switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
  157. switch_trust_cli-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,812 @@
1
+ """
2
+ agent_scanner.py — Main Orchestrator
3
+ Ties together all layers:
4
+ Layer 1: Discovery (file collection)
5
+ Layer 2: Static scan (Bandit, OpenGrep, detect-secrets, pip-audit)
6
+ Layer 3: AI reasoning (agentic semantic analysis)
7
+ Layer 4: Triage (contextual filtering + severity calibration)
8
+ Output: Structured JSON report.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ import logging
15
+ import os
16
+ import tempfile
17
+ import uuid
18
+ from datetime import UTC, datetime
19
+ from typing import Any
20
+
21
+ from switch_trust.scan.constants import (
22
+ REQUIREMENT_FILE_NAMES,
23
+ SEVERITY_ORDER,
24
+ VALID_SEVERITIES,
25
+ )
26
+ from switch_trust.schema import RepoFile
27
+ from switch_trust.scan.reasoner import run_agentic_reasoning
28
+ from switch_trust.scan.schema import (
29
+ AffectedComponent,
30
+ AgentProfile,
31
+ CategorySummaryEntry,
32
+ CvssScores,
33
+ Evidence,
34
+ Finding,
35
+ InventoryLike,
36
+ RawFinding,
37
+ ScanReport,
38
+ TriageDismissed,
39
+ TriageDowngraded,
40
+ )
41
+ from switch_trust.scan.scorer import (
42
+ map_bandit_to_taxonomy,
43
+ map_opengrep_to_taxonomy,
44
+ score_finding,
45
+ )
46
+ from switch_trust.scan.static_scanner import (
47
+ StaticFinding,
48
+ run_static_scan,
49
+ )
50
+ from switch_trust.scan.taxonomy import (
51
+ AGENT_TAXONOMY,
52
+ get_finding_metadata,
53
+ resolve_subcategory,
54
+ )
55
+ from switch_trust.scan.triage import (
56
+ build_agent_context as build_triage_context,
57
+ )
58
+ from switch_trust.scan.triage import run_triage
59
+ from switch_trust.scan.llm_provider import get_model_name, make_model
60
+ from switch_trust.secret_anonymizer import anonymize_secrets
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+
65
+ # ── Helpers ──────────────────────────────────────────────────────────────────
66
+
67
+
68
+ def make_finding_id() -> str:
69
+ return f"AGT-{uuid.uuid4().hex[:8].upper()}"
70
+
71
+
72
+ def _cvss_v4_severity_from_score(score: float) -> str:
73
+ """Derive CVSS v4 qualitative severity label from a numeric score."""
74
+ if score >= 9.0:
75
+ return "Critical"
76
+ elif score >= 7.0:
77
+ return "High"
78
+ elif score >= 4.0:
79
+ return "Medium"
80
+ elif score > 0.0:
81
+ return "Low"
82
+ return "None"
83
+
84
+
85
+ def _derive_likelihood(confidence: str, severity: str) -> str:
86
+ """Derive exploitation likelihood from confidence and severity."""
87
+ severity_l = severity.lower()
88
+ confidence_l = confidence.lower()
89
+ if confidence_l == "high" and severity_l in ("critical", "high"):
90
+ return "High"
91
+ if confidence_l == "low" or severity_l in ("low", "info"):
92
+ return "Low"
93
+ return "Medium"
94
+
95
+
96
+ def _dicts_to_findings(finding_dicts: list[dict[str, Any]]) -> list[Finding]:
97
+ """Reconstruct Finding objects from serialised dicts (post-triage kept_findings)."""
98
+ findings = []
99
+ for d in finding_dicts:
100
+ try:
101
+ if not isinstance(d, dict):
102
+ continue
103
+ # Reconstruct nested dataclasses from dicts
104
+ if isinstance(d.get("cvss_scores"), dict):
105
+ d["cvss_scores"] = CvssScores(**d["cvss_scores"])
106
+ if isinstance(d.get("affected_components"), list):
107
+ d["affected_components"] = [
108
+ AffectedComponent(**ac) if isinstance(ac, dict) else ac
109
+ for ac in d["affected_components"]
110
+ ]
111
+ if isinstance(d.get("evidence"), list):
112
+ d["evidence"] = [
113
+ Evidence(**e) if isinstance(e, dict) else e for e in d["evidence"]
114
+ ]
115
+ findings.append(Finding(**d))
116
+ except (TypeError, ValueError, KeyError) as e:
117
+ finding_id = d.get("id", "?") if isinstance(d, dict) else "?"
118
+ logger.error("Skipping malformed finding dict %s: %s", finding_id, e)
119
+ continue
120
+ if len(findings) < len(finding_dicts):
121
+ logger.warning(
122
+ "%d of %d findings were skipped due to schema mismatches",
123
+ len(finding_dicts) - len(findings),
124
+ len(finding_dicts),
125
+ )
126
+ return findings
127
+
128
+
129
+ # ── Static findings → Finding objects ────────────────────────────────────────
130
+
131
+
132
+ def convert_static_findings(static_findings: list[StaticFinding]) -> list[Finding]:
133
+ """Convert StaticFinding objects to canonical Finding objects with CVSS scoring."""
134
+ findings = []
135
+ seen = set() # Deduplicate by (rule_id, filepath, line)
136
+ conversion_errors = 0
137
+
138
+ for sf in static_findings:
139
+ try:
140
+ # Validate StaticFinding object has required attributes
141
+ if (
142
+ not hasattr(sf, "tool")
143
+ or not hasattr(sf, "rule_id")
144
+ or not hasattr(sf, "filepath")
145
+ ):
146
+ logger.error("StaticFinding missing required attributes: %s", sf)
147
+ conversion_errors += 1
148
+ continue
149
+
150
+ # Map to taxonomy first — subcategory is used in the dedup key.
151
+ # Fix #24: dedup by (subcategory, filepath, line) instead of
152
+ # (rule_id, filepath, line). Bandit B602 and OpenGrep
153
+ # subprocess-shell-true both map to the same subcategory on the
154
+ # same line. The old key treated them as distinct findings;
155
+ # the new key correctly collapses them into one.
156
+ if sf.tool == "bandit":
157
+ category, subcategory = map_bandit_to_taxonomy(sf.rule_id, sf.message)
158
+ elif sf.tool == "opengrep":
159
+ category, subcategory = map_opengrep_to_taxonomy(sf.rule_id)
160
+ elif sf.tool == "detect_secrets":
161
+ category, subcategory = (
162
+ "asi03_identity_privilege_abuse",
163
+ "hardcoded_credentials",
164
+ )
165
+ elif sf.tool == "pip_audit":
166
+ category, subcategory = (
167
+ "asi04_supply_chain",
168
+ "known_vulnerable_dependency",
169
+ )
170
+ elif sf.tool == "internal":
171
+ category, subcategory = "asi04_supply_chain", "unpinned_dependencies"
172
+ else:
173
+ category, subcategory = "asi04_supply_chain", "unpinned_dependencies"
174
+
175
+ dedup_key = (subcategory, sf.filepath, sf.line)
176
+ if dedup_key in seen:
177
+ continue
178
+ seen.add(dedup_key)
179
+
180
+ # Score the finding with error handling
181
+ try:
182
+ cvss_score, cvss_vector, severity = score_finding(subcategory, sf.tool)
183
+ except Exception as e:
184
+ logger.warning(
185
+ "Could not score finding %s: %s. Defaulting to medium severity.",
186
+ sf.rule_id,
187
+ e,
188
+ )
189
+ cvss_score, cvss_vector, severity = (
190
+ 5.5,
191
+ "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N",
192
+ "medium",
193
+ )
194
+
195
+ meta = get_finding_metadata(subcategory)
196
+
197
+ # For pip_audit findings, affected_component is the package name
198
+ # (e.g. "langchain") not the filepath — more meaningful in the report
199
+ # and enables correct per-package CVE grouping in triage.
200
+ if sf.tool == "pip_audit":
201
+ affected = sf.message.split("==")[0].strip() or sf.filepath
202
+ # OSV already computed severity from the actual CVE CVSS score.
203
+ # Use it directly rather than letting scorer.py override with taxonomy default.
204
+ if hasattr(sf, "severity") and sf.severity:
205
+ severity = sf.severity
206
+ cvss_score, cvss_vector, _ = score_finding(subcategory, sf.tool)
207
+ else:
208
+ affected = sf.filepath
209
+
210
+ # Validate severity is in expected set
211
+ valid_severities = VALID_SEVERITIES
212
+ if severity not in valid_severities:
213
+ logger.warning(
214
+ "Invalid severity '%s' for %s. Defaulting to 'medium'.",
215
+ severity,
216
+ sf.rule_id,
217
+ )
218
+ severity = "medium"
219
+
220
+ confidence = "High" if severity in ("high", "critical") else "Medium"
221
+
222
+ findings.append(
223
+ Finding(
224
+ id=make_finding_id(),
225
+ category=category,
226
+ subcategory=subcategory,
227
+ ai_spm_severity=severity.capitalize(),
228
+ cvss_v4_severity=_cvss_v4_severity_from_score(cvss_score),
229
+ cvss_scores=CvssScores(base_score=cvss_score, vector=cvss_vector),
230
+ title=meta.get("title", sf.rule_id),
231
+ description=sf.message,
232
+ impact=meta.get(
233
+ "description",
234
+ f"Security issue detected in {sf.filepath}:{sf.line}",
235
+ ),
236
+ likelihood=_derive_likelihood(confidence, severity),
237
+ remediation=meta.get(
238
+ "remediation",
239
+ f"See OWASP {meta.get('asi_code', 'ASI')} guidance for {subcategory}.",
240
+ ),
241
+ affected_components=[
242
+ AffectedComponent(name=affected, path=sf.filepath)
243
+ ],
244
+ evidence=[
245
+ Evidence(
246
+ file=sf.filepath,
247
+ code_snippet=anonymize_secrets(sf.evidence[:200])
248
+ if sf.evidence
249
+ else "",
250
+ confidence=confidence,
251
+ line=sf.line,
252
+ )
253
+ ],
254
+ source=f"static_{sf.tool}",
255
+ hallucination_flag=False,
256
+ )
257
+ )
258
+ except Exception as e:
259
+ logger.error(
260
+ "Failed to convert finding %s: %s",
261
+ getattr(sf, "rule_id", "?"),
262
+ e,
263
+ )
264
+ conversion_errors += 1
265
+ continue
266
+
267
+ if conversion_errors > 0:
268
+ logger.warning(
269
+ "%d static findings failed to convert. Continuing with %d valid findings.",
270
+ conversion_errors,
271
+ len(findings),
272
+ )
273
+
274
+ return findings
275
+
276
+
277
+ # ── AI findings → Finding objects ─────────────────────────────────────────────
278
+
279
+
280
+ def convert_ai_findings(ai_findings: list[RawFinding]) -> list[Finding]:
281
+ """Convert raw AI reasoning finding dicts to canonical Finding objects."""
282
+ findings = []
283
+ conversion_errors = 0
284
+
285
+ if not isinstance(ai_findings, list):
286
+ logger.error("ai_findings is not a list, got %s", type(ai_findings).__name__)
287
+ return findings
288
+
289
+ for raw in ai_findings:
290
+ try:
291
+ if not isinstance(raw, dict):
292
+ logger.error("AI finding is not a dict, got %s", type(raw).__name__)
293
+ conversion_errors += 1
294
+ continue
295
+
296
+ subcategory = raw.get("subcategory", "unvalidated_tool_input")
297
+ # The category is derived, never read off the raw finding. See
298
+ # taxonomy.category_for_subcategory: the reasoning model was asked
299
+ # for a key the prompt never showed it, so it inferred one from the
300
+ # human title and got it wrong for five of the ten categories.
301
+ subcategory = resolve_subcategory(subcategory) or subcategory
302
+ meta = get_finding_metadata(subcategory)
303
+ category = meta["category"]
304
+
305
+ is_beyond = category == "beyond_asi"
306
+
307
+ if is_beyond:
308
+ # BEYOND-ASI findings: use a generic medium vector
309
+ cvss_score = 5.5
310
+ cvss_vector = (
311
+ "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N"
312
+ )
313
+ severity = "medium"
314
+ else:
315
+ try:
316
+ cvss_score, cvss_vector, severity = score_finding(
317
+ subcategory, "ai_reasoning"
318
+ )
319
+ except Exception as e:
320
+ logger.warning(
321
+ "Could not score AI finding '%s': %s. Defaulting to medium severity.",
322
+ raw.get("title", "?"),
323
+ e,
324
+ )
325
+ cvss_score = 5.5
326
+ cvss_vector = "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N" # noqa: B950
327
+ severity = "medium"
328
+
329
+ # Hallucinated findings are capped at medium
330
+ is_hallucinated = raw.get("hallucination_flag", False)
331
+ if is_hallucinated and severity in ("critical", "high"):
332
+ severity = "medium"
333
+ cvss_score = min(cvss_score, 6.9)
334
+
335
+ # Validate severity
336
+ valid_severities = VALID_SEVERITIES
337
+ if severity not in valid_severities:
338
+ logger.warning(
339
+ "Invalid severity '%s' for AI finding. Defaulting to 'medium'.",
340
+ severity,
341
+ )
342
+ severity = "medium"
343
+
344
+ # Validate confidence value
345
+ valid_confidences = {"high", "medium", "low"}
346
+ confidence = raw.get("confidence", "medium").lower()
347
+ if confidence not in valid_confidences:
348
+ confidence = "medium"
349
+
350
+ affected_component = raw.get("affected_component", "")
351
+
352
+ findings.append(
353
+ Finding(
354
+ id=make_finding_id(),
355
+ category=category,
356
+ subcategory=subcategory,
357
+ ai_spm_severity=severity.capitalize(),
358
+ cvss_v4_severity=_cvss_v4_severity_from_score(cvss_score),
359
+ cvss_scores=CvssScores(base_score=cvss_score, vector=cvss_vector),
360
+ title=raw.get("title", meta.get("title", subcategory)),
361
+ description=raw.get("description", ""),
362
+ impact=raw.get("impact", ""),
363
+ likelihood=_derive_likelihood(confidence, severity),
364
+ remediation=raw.get("remediation", "")
365
+ or meta.get("remediation", ""),
366
+ affected_components=[AffectedComponent(name=affected_component)],
367
+ evidence=[
368
+ Evidence(
369
+ file=raw.get("evidence_file", affected_component),
370
+ code_snippet=anonymize_secrets(
371
+ raw.get("evidence", "")[:200]
372
+ ),
373
+ confidence=confidence.capitalize(),
374
+ line=int(raw.get("evidence_line", 0)),
375
+ )
376
+ ],
377
+ source="ai_reasoning",
378
+ hallucination_flag=is_hallucinated,
379
+ agent_name=raw.get("agent_name", ""),
380
+ )
381
+ )
382
+ except Exception as e:
383
+ logger.error("Failed to convert AI finding: %s", e)
384
+ conversion_errors += 1
385
+ continue
386
+
387
+ if conversion_errors > 0:
388
+ logger.warning(
389
+ "%d AI findings failed to convert. Continuing with %d valid findings.",
390
+ conversion_errors,
391
+ len(findings),
392
+ )
393
+
394
+ return findings
395
+
396
+
397
+ # ── Findings consolidation ──────────────────────────────────────────────────
398
+
399
+
400
+ def _consolidate_findings(findings: list[Finding]) -> list[Finding]:
401
+ """Deduplicate findings from different sources at the same location.
402
+
403
+ Groups by (subcategory, file, line). When static and AI findings overlap,
404
+ keeps the AI finding (richer description) and merges the source field.
405
+ """
406
+ groups: dict[tuple, list[Finding]] = {}
407
+ for f in findings:
408
+ ev = f.evidence[0] if f.evidence else None
409
+ if ev and ev.file and ev.line:
410
+ key: tuple = (f.subcategory, ev.file, ev.line)
411
+ else:
412
+ key = (f.id,)
413
+ groups.setdefault(key, []).append(f)
414
+
415
+ consolidated = []
416
+ for group in groups.values():
417
+ if len(group) == 1:
418
+ consolidated.append(group[0])
419
+ continue
420
+ # Prefer ai_reasoning over static; merge source names.
421
+ ai = [f for f in group if f.source == "ai_reasoning"]
422
+ keep = ai[0] if ai else group[0]
423
+ sources = sorted({f.source for f in group})
424
+ consolidated.append(dataclasses.replace(keep, source="+".join(sources)))
425
+
426
+ return consolidated
427
+
428
+
429
+ # ── Category summary builder ──────────────────────────────────────────────────
430
+
431
+
432
+ def build_category_summary(findings: list[Finding]) -> dict[str, CategorySummaryEntry]:
433
+ """
434
+ Count findings per ASI category plus a beyond_asi bucket.
435
+ Initialized with all 10 ASI categories so the report always shows
436
+ the full OWASP framework coverage even for zero-finding categories.
437
+ """
438
+ summary = {}
439
+
440
+ # Pre-initialize all ASI categories with zero counts and their ASI metadata
441
+ for cat_key, cat_data in AGENT_TAXONOMY.items():
442
+ summary[cat_key] = {
443
+ "asi_code": cat_data["asi_code"],
444
+ "asi_title": cat_data["asi_title"],
445
+ "count": 0,
446
+ "critical": 0,
447
+ "high": 0,
448
+ "medium": 0,
449
+ "low": 0,
450
+ }
451
+
452
+ # Pre-initialize the beyond_asi bucket for findings outside the framework
453
+ summary["beyond_asi"] = {
454
+ "asi_code": "BEYOND-ASI",
455
+ "asi_title": "Beyond Current OWASP ASI Framework",
456
+ "count": 0,
457
+ "critical": 0,
458
+ "high": 0,
459
+ "medium": 0,
460
+ "low": 0,
461
+ }
462
+
463
+ for f in findings:
464
+ cat = f.category.lower() if f.category else "beyond_asi"
465
+ if cat not in summary:
466
+ # Unknown category from old data or edge case — bucket into beyond_asi
467
+ cat = "beyond_asi"
468
+ summary[cat]["count"] += 1
469
+ sev = (
470
+ f.ai_spm_severity.lower()
471
+ if f.ai_spm_severity.lower() in ("critical", "high", "medium", "low")
472
+ else "low"
473
+ )
474
+ summary[cat][sev] += 1
475
+
476
+ return summary
477
+
478
+
479
+ # ── Main scan pipeline ────────────────────────────────────────────────────────
480
+
481
+
482
+ def _read_inventory_files(
483
+ inventory: InventoryLike, scan_root: str
484
+ ) -> tuple[list[RepoFile], list[RepoFile]]:
485
+ """Read Python files referenced by inventory agents and requirements files."""
486
+ agent_file_paths: set[str] = set()
487
+ for agent in inventory.agents.values():
488
+ for occ in agent.occurrences:
489
+ if hasattr(occ, "file_path") and occ.file_path:
490
+ agent_file_paths.add(occ.file_path)
491
+
492
+ python_files = []
493
+ for raw_path in sorted(agent_file_paths):
494
+ # occ.file_path may be absolute or relative — normalize to
495
+ # a relative path from scan_root for consistent reporting.
496
+ if os.path.isabs(raw_path) and raw_path.startswith(scan_root):
497
+ rel_path = os.path.relpath(raw_path, scan_root)
498
+ else:
499
+ rel_path = raw_path
500
+
501
+ abs_path = os.path.join(scan_root, rel_path)
502
+ if os.path.isfile(abs_path):
503
+ try:
504
+ with open(abs_path, encoding="utf-8", errors="replace") as fh:
505
+ content = fh.read()
506
+ python_files.append(
507
+ RepoFile(path=rel_path, content=content, size=len(content))
508
+ )
509
+ except Exception as exc:
510
+ logger.warning("Could not read %s: %s", abs_path, exc)
511
+
512
+ requirements_files = []
513
+ for name in REQUIREMENT_FILE_NAMES:
514
+ req_path = os.path.join(scan_root, name)
515
+ if os.path.isfile(req_path):
516
+ try:
517
+ with open(req_path, encoding="utf-8", errors="replace") as fh:
518
+ content = fh.read()
519
+ requirements_files.append(
520
+ RepoFile(path=name, content=content, size=len(content))
521
+ )
522
+ except Exception:
523
+ pass
524
+
525
+ return python_files, requirements_files
526
+
527
+
528
+ def run_core(
529
+ python_files: list[RepoFile],
530
+ requirements_files: list[RepoFile],
531
+ *,
532
+ agent_profiles: list[AgentProfile] | None = None,
533
+ skip_triage: bool = False,
534
+ agentic: bool = True,
535
+ model_string: str | None = None,
536
+ repo_name: str = "",
537
+ primary_framework: str = "unknown",
538
+ frameworks_detected: list[str] | None = None,
539
+ total_files_scanned: int = 0,
540
+ ) -> ScanReport:
541
+ """Core scanning pipeline (layers 2-4). Takes already-resolved files.
542
+
543
+ This is the main scanning engine. Discovery is handled by callers
544
+ (run_scan or direct invocation).
545
+ """
546
+ timestamp = datetime.now(UTC).isoformat()
547
+ agents = agent_profiles or []
548
+
549
+ # ── LAYER 2: Static Scan ─────────────────────────────────────────────────
550
+ logger.info("Layer 2: Static Scan")
551
+ all_findings: list[Finding] = []
552
+
553
+ with tempfile.TemporaryDirectory() as tmp_dir:
554
+ static_result = run_static_scan(python_files, requirements_files, tmp_dir)
555
+
556
+ static_raw = static_result.findings
557
+ static_findings = convert_static_findings(static_raw)
558
+ all_findings.extend(static_findings)
559
+ logger.info("Static findings converted: %d", len(static_findings))
560
+
561
+ # ── LAYER 3: AI Reasoning ────────────────────────────────────────────────
562
+ logger.info("Layer 3: AI Reasoning")
563
+ ai_findings: list[Finding] = []
564
+ ai_summary = "AI reasoning skipped (no provider configured)."
565
+
566
+ llm_model = None
567
+ try:
568
+ if model_string:
569
+ os.environ["SCANNER_MODEL"] = model_string
570
+ llm_model = make_model(scanner="agent", phase="triage")
571
+ logger.info("LLM model: %s", llm_model)
572
+ except Exception as e:
573
+ logger.info("Could not initialise LLM model (%s): %s", type(e).__name__, e)
574
+ logger.info("AI reasoning and triage will be skipped.")
575
+
576
+ agentic_trace: dict[str, str] = {}
577
+
578
+ if llm_model is not None:
579
+ try:
580
+ if agentic:
581
+ logger.info("Mode: AGENTIC v2 (Google ADK reasoning loop)")
582
+ ai_raw, ai_summary, agentic_trace = run_agentic_reasoning(
583
+ agents,
584
+ python_files,
585
+ static_findings=static_raw,
586
+ )
587
+ logger.info(
588
+ "ADK session: %s | exit: %s | model: %s",
589
+ agentic_trace.get("session_id", "?"),
590
+ agentic_trace.get("exit_reason", "?"),
591
+ agentic_trace.get("adk_model", "?"),
592
+ )
593
+ else:
594
+ ai_raw = []
595
+ ai_summary = "Agentic reasoning disabled."
596
+ ai_findings = convert_ai_findings(ai_raw)
597
+ all_findings.extend(ai_findings)
598
+ logger.info("AI findings converted: %d", len(ai_findings))
599
+ except Exception as e:
600
+ logger.warning(
601
+ "AI reasoning failed (%s): %s — continuing with static findings only",
602
+ type(e).__name__,
603
+ e,
604
+ )
605
+ ai_summary = f"AI reasoning failed: {e}"
606
+ else:
607
+ logger.info("No LLM provider — skipping AI reasoning layer")
608
+
609
+ # ── Consolidate static + AI duplicates ────────────────────────────────────
610
+ before = len(all_findings)
611
+ all_findings = _consolidate_findings(all_findings)
612
+ if len(all_findings) < before:
613
+ logger.info(
614
+ "Consolidated %d -> %d findings (removed %d duplicates)",
615
+ before,
616
+ len(all_findings),
617
+ before - len(all_findings),
618
+ )
619
+
620
+ # ── Build agent profile dicts (needed by both triage and report) ─────────
621
+ agent_profile_dicts = []
622
+ for a in agents:
623
+ agent_profile_dicts.append(
624
+ {
625
+ "agent_id": a.agent_id,
626
+ "framework": a.framework,
627
+ "source_file": a.source_file,
628
+ "role": a.role,
629
+ "goal": a.goal,
630
+ "tools": a.tools,
631
+ "memory": a.memory,
632
+ "allow_delegation": a.allow_delegation,
633
+ "llm": a.llm,
634
+ "graph_nodes": a.graph_nodes,
635
+ "has_recursion_limit": a.has_recursion_limit,
636
+ "input_guardrails": a.input_guardrails,
637
+ "output_guardrails": a.output_guardrails,
638
+ "function_signatures": a.function_signatures,
639
+ }
640
+ )
641
+
642
+ # ── LAYER 4: Triage ──────────────────────────────────────────────────────
643
+ logger.info("Layer 4: Triage")
644
+
645
+ pre_triage_findings = None
646
+ triage_dismissed = None
647
+ triage_downgraded = None
648
+ triage_meta: dict = {}
649
+
650
+ if skip_triage:
651
+ logger.info("Triage skipped")
652
+ elif llm_model is None:
653
+ logger.info("No LLM provider — triage requires a configured provider")
654
+ elif not all_findings:
655
+ logger.info("No findings to triage")
656
+ else:
657
+ pre_triage_dicts = [dataclasses.asdict(f) for f in all_findings]
658
+
659
+ triage_context = build_triage_context(
660
+ repo_name=repo_name,
661
+ framework=primary_framework,
662
+ agent_profiles=agent_profile_dicts,
663
+ )
664
+
665
+ triage_result = run_triage(
666
+ raw_findings=pre_triage_dicts,
667
+ agent_context=triage_context,
668
+ model=llm_model,
669
+ )
670
+
671
+ if triage_result:
672
+ try:
673
+ pre_triage_findings = pre_triage_dicts
674
+
675
+ all_findings = _dicts_to_findings(
676
+ triage_result.get("kept_findings", [])
677
+ )
678
+
679
+ triage_dismissed = []
680
+ for d in triage_result.get("triage_dismissed", []):
681
+ try:
682
+ if (
683
+ not isinstance(d, dict)
684
+ or "id" not in d
685
+ or "reason" not in d
686
+ ):
687
+ logger.warning("Malformed triage_dismissed entry: %s", d)
688
+ continue
689
+ triage_dismissed.append(
690
+ TriageDismissed(finding_id=d["id"], reason=d["reason"])
691
+ )
692
+ except (KeyError, TypeError) as e:
693
+ logger.warning("Could not construct TriageDismissed: %s", e)
694
+ continue
695
+
696
+ triage_downgraded = []
697
+ for d in triage_result.get("triage_downgraded", []):
698
+ try:
699
+ if not isinstance(d, dict) or not all(
700
+ k in d
701
+ for k in [
702
+ "id",
703
+ "original_severity",
704
+ "new_severity",
705
+ "reason",
706
+ ]
707
+ ):
708
+ logger.warning("Malformed triage_downgraded entry: %s", d)
709
+ continue
710
+ triage_downgraded.append(
711
+ TriageDowngraded(
712
+ finding_id=d["id"],
713
+ original_severity=d["original_severity"],
714
+ new_severity=d["new_severity"],
715
+ reason=d["reason"],
716
+ )
717
+ )
718
+ except (KeyError, TypeError) as e:
719
+ logger.warning("Could not construct TriageDowngraded: %s", e)
720
+ continue
721
+
722
+ triage_meta = triage_result.get("triage_summary", {})
723
+ if not isinstance(triage_meta, dict):
724
+ logger.warning(
725
+ "triage_summary is not a dict, got %s",
726
+ type(triage_meta).__name__,
727
+ )
728
+ triage_meta = {}
729
+
730
+ logger.info(
731
+ "Triage complete — kept: %d | dismissed: %d | downgraded: %d",
732
+ len(all_findings),
733
+ len(triage_dismissed),
734
+ len(triage_downgraded),
735
+ )
736
+ except Exception as e:
737
+ logger.error("Failed to process triage result: %s", e)
738
+ logger.warning(
739
+ "Using raw findings unchanged due to triage processing error"
740
+ )
741
+ triage_dismissed = None
742
+ triage_downgraded = None
743
+ pre_triage_findings = None
744
+ triage_meta = {}
745
+ else:
746
+ logger.warning("Triage returned no result — using raw findings unchanged")
747
+
748
+ # ── Assemble Report ───────────────────────────────────────────────────────
749
+ logger.info("Assembling Report")
750
+
751
+ severity_order = SEVERITY_ORDER
752
+ for f in all_findings:
753
+ if f.ai_spm_severity not in severity_order:
754
+ logger.warning(
755
+ "Finding %s has invalid severity '%s'. Treating as 'Low'.",
756
+ f.id,
757
+ f.ai_spm_severity,
758
+ )
759
+ f.ai_spm_severity = "Low" # type: ignore
760
+ all_findings.sort(key=lambda f: severity_order.get(f.ai_spm_severity, 5))
761
+
762
+ category_summary = build_category_summary(all_findings)
763
+
764
+ report = ScanReport(
765
+ repo_url="",
766
+ repo_name=repo_name,
767
+ scan_timestamp=timestamp,
768
+ framework_detected=primary_framework,
769
+ agents_found=len(agents),
770
+ findings=all_findings,
771
+ category_summary=category_summary,
772
+ agent_profiles=agent_profile_dicts,
773
+ scan_metadata={
774
+ "total_files_scanned": total_files_scanned,
775
+ "python_files": len(python_files),
776
+ "requirements_files": len(requirements_files),
777
+ "frameworks_detected": frameworks_detected or [],
778
+ "static_findings_count": len(static_findings),
779
+ "ai_findings_count": len(ai_findings),
780
+ "ai_summary": ai_summary,
781
+ "triage_run": (triage_meta != {}),
782
+ "triage_dismissed_count": len(triage_dismissed) if triage_dismissed else 0,
783
+ "triage_downgraded_count": (
784
+ len(triage_downgraded) if triage_downgraded else 0
785
+ ),
786
+ "tools_used": (
787
+ static_result.tools_used
788
+ + (
789
+ [f"ai-reasoning:{get_model_name()}"]
790
+ if llm_model is not None
791
+ else []
792
+ )
793
+ + (
794
+ [f"triage:{get_model_name()}"]
795
+ if (triage_dismissed is not None or triage_downgraded is not None)
796
+ and llm_model
797
+ else []
798
+ )
799
+ ),
800
+ # asdict, not the dataclasses themselves — the SARIF formatter
801
+ # json.dumps() scan_metadata without an asdict pass of its own.
802
+ "tools_skipped": [
803
+ dataclasses.asdict(s) for s in static_result.tools_skipped
804
+ ],
805
+ "agentic_trace": agentic_trace,
806
+ },
807
+ pre_triage_findings=pre_triage_findings,
808
+ triage_dismissed=triage_dismissed,
809
+ triage_downgraded=triage_downgraded,
810
+ )
811
+
812
+ return report