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,614 @@
1
+ """
2
+ triage.py — Layer 2d: Triage Agent
3
+ Sits downstream of the primary AI reasoning layer (ai_reasoner.py) and applies
4
+ two layers of judgment to the raw findings list:
5
+
6
+ 1. Contextual relevance filtering — dismisses findings that describe expected
7
+ behaviour for this specific agent's declared purpose.
8
+
9
+ 2. Severity calibration — downgrades findings whose severity is disproportionate
10
+ to the realistic exploitability and impact given the available evidence.
11
+
12
+ This is a single, focused LLM call. No ADK, no streaming, no tools.
13
+ It receives findings + agent context, and returns a filtered + recalibrated
14
+ version of the same findings list along with triage metadata.
15
+
16
+ The LLM provider is configurable via environment variables — see llm_provider.py.
17
+
18
+ Usage (called from agent_scanner.py after AI reasoning step):
19
+
20
+ from triage import run_triage
21
+
22
+ triage_result = run_triage(
23
+ raw_findings=all_findings_as_dicts,
24
+ agent_context=build_agent_context(discovery, agents, crews),
25
+ )
26
+
27
+ if triage_result:
28
+ kept = triage_result["kept_findings"]
29
+ dismissed = triage_result["triage_dismissed"]
30
+ downgraded = triage_result["triage_downgraded"]
31
+ summary = triage_result["triage_summary"]
32
+ """
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ import re
38
+ from typing import Any
39
+
40
+ from switch_trust.scan.schema import RawFinding
41
+ from switch_trust.scan import ADKModel
42
+ from switch_trust.scan.llm_provider import complete_text, make_model
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ # ── Constants ──────────────────────────────────────────────────────────────────
48
+
49
+ MAX_TOKENS = (
50
+ 16000 # Must accommodate triage output for large finding sets (49+ findings)
51
+ )
52
+ TEMPERATURE = (
53
+ 0.0 # Fully deterministic — same input must always produce same triage result
54
+ )
55
+ TOP_P = 1.0
56
+
57
+ # Maximum CVSS base_score per severity band (FIRST.org CVSS v4 upper bounds)
58
+ _SEVERITY_SCORE_CAP: dict[str, float] = {
59
+ "Critical": 10.0,
60
+ "High": 8.9,
61
+ "Medium": 6.9,
62
+ "Low": 3.9,
63
+ "Info": 0.0,
64
+ }
65
+
66
+ # Severity levels and ranking for enforcement
67
+ _SEV_LEVELS = ["Critical", "High", "Medium", "Low"]
68
+ _SEV_RANK = {s: i for i, s in enumerate(_SEV_LEVELS)}
69
+
70
+ # Patterns that prove a flaw directly — if matched, block downgrades.
71
+ _ANCHOR_PATTERNS = [
72
+ re.compile(r"\beval\s*\("),
73
+ re.compile(r"\bexec\s*\("),
74
+ re.compile(r"shell\s*=\s*True"),
75
+ re.compile(r"pickle\.load"),
76
+ re.compile(r"yaml\.load\s*\("),
77
+ re.compile(
78
+ r"(?:password|api_key|secret|token)\s*=\s*['\"][^'\"]{8,}['\"]",
79
+ re.IGNORECASE,
80
+ ),
81
+ ]
82
+
83
+ # Path to the prompt file
84
+ _PROMPT_PATH = os.path.join(os.path.dirname(__file__), "config", "triage_prompt.txt")
85
+
86
+
87
+ # ── CVSS score capping ─────────────────────────────────────────────────────────
88
+
89
+
90
+ def _cap_cvss_score(finding: dict[str, Any], severity: str) -> None:
91
+ """Cap a finding's cvss_scores.base_score to the max of the given severity band.
92
+
93
+ Mutates the finding dict in place. If the finding has no cvss_scores or the
94
+ score is already within the band, this is a no-op.
95
+ """
96
+ cap = _SEVERITY_SCORE_CAP.get(severity)
97
+ if cap is None:
98
+ return
99
+ scores = finding.get("cvss_scores")
100
+ if not isinstance(scores, dict):
101
+ return
102
+ base = scores.get("base_score")
103
+ if isinstance(base, (int, float)) and base > cap:
104
+ logger.info(
105
+ "Capping CVSS score for %s from %.1f to %.1f (severity %s)",
106
+ finding.get("id", "?"),
107
+ base,
108
+ cap,
109
+ severity,
110
+ )
111
+ scores["base_score"] = cap
112
+
113
+
114
+ # ── Post-triage severity enforcement ──────────────────────────────────────────
115
+
116
+
117
+ def _has_anchor_evidence(finding: dict[str, Any]) -> bool:
118
+ """Return True if evidence matches a Direct Code Evidence Anchor pattern."""
119
+ for ev in finding.get("evidence", []):
120
+ snippet = ev.get("code_snippet", "") if isinstance(ev, dict) else ""
121
+ if not snippet:
122
+ continue
123
+ for pat in _ANCHOR_PATTERNS:
124
+ if pat.search(snippet):
125
+ return True
126
+ return False
127
+
128
+
129
+ def _enforce_severity(
130
+ kept_findings: list[dict[str, Any]],
131
+ original_findings: list[dict[str, Any]],
132
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
133
+ """Deterministic post-LLM severity enforcement.
134
+
135
+ Rules:
136
+ 1. ANCHOR — if evidence matches a code pattern, block downgrades.
137
+ 2. NO UPGRADE — severity may never exceed cvss_v4_severity.
138
+ 3. MAX ONE-STEP — severity may drop at most one level from CVSS.
139
+ """
140
+ originals_by_id = {f.get("id"): f for f in original_findings}
141
+ enforced: list[dict[str, Any]] = []
142
+
143
+ for f in kept_findings:
144
+ fid = f.get("id")
145
+ original = originals_by_id.get(fid, {})
146
+ cvss_sev = original.get("cvss_v4_severity", "").capitalize()
147
+
148
+ if cvss_sev not in _SEV_RANK:
149
+ continue
150
+
151
+ llm_sev = f.get("ai_spm_severity", "").capitalize()
152
+ if llm_sev not in _SEV_RANK:
153
+ llm_sev = cvss_sev
154
+
155
+ cvss_rank = _SEV_RANK[cvss_sev]
156
+ llm_rank = _SEV_RANK[llm_sev]
157
+
158
+ if _has_anchor_evidence(original) and llm_rank > cvss_rank:
159
+ enforced.append(
160
+ {
161
+ "id": fid,
162
+ "rule": "anchor",
163
+ "llm_severity": llm_sev,
164
+ "enforced_severity": cvss_sev,
165
+ }
166
+ )
167
+ f["ai_spm_severity"] = cvss_sev
168
+ continue
169
+
170
+ if llm_rank < cvss_rank:
171
+ enforced.append(
172
+ {
173
+ "id": fid,
174
+ "rule": "no_upgrade",
175
+ "llm_severity": llm_sev,
176
+ "enforced_severity": cvss_sev,
177
+ }
178
+ )
179
+ f["ai_spm_severity"] = cvss_sev
180
+ continue
181
+
182
+ if llm_rank > cvss_rank + 1:
183
+ one_step = _SEV_LEVELS[cvss_rank + 1]
184
+ enforced.append(
185
+ {
186
+ "id": fid,
187
+ "rule": "max_one_step",
188
+ "llm_severity": llm_sev,
189
+ "enforced_severity": one_step,
190
+ }
191
+ )
192
+ f["ai_spm_severity"] = one_step
193
+
194
+ return kept_findings, enforced
195
+
196
+
197
+ # ── Prompt loader ──────────────────────────────────────────────────────────────
198
+
199
+
200
+ def _load_triage_prompt() -> str:
201
+ """Load the triage system prompt from config/triage_prompt.txt."""
202
+ try:
203
+ with open(_PROMPT_PATH, encoding="utf-8") as f:
204
+ return f.read().strip()
205
+ except FileNotFoundError as e:
206
+ raise FileNotFoundError(
207
+ f"[triage] Triage prompt not found at: {_PROMPT_PATH}\n"
208
+ "Ensure config/triage_prompt.txt exists before running triage."
209
+ ) from e
210
+
211
+
212
+ # ── Context builder ────────────────────────────────────────────────────────────
213
+
214
+
215
+ def build_agent_context(
216
+ repo_name: str,
217
+ framework: str,
218
+ agent_profiles: list[dict[str, Any]],
219
+ description: str = "",
220
+ ) -> dict[str, Any]:
221
+ """
222
+ Build the agent_context dict that gives the triage agent enough information
223
+ to make contextual relevance decisions.
224
+
225
+ Called from agent_scanner.py — pass in the values already available after
226
+ the discovery and extraction steps.
227
+
228
+ Parameters
229
+ ----------
230
+ repo_name : repo name string from DiscoveryResult.repo_name
231
+ framework : primary framework string from DiscoveryResult.primary_framework
232
+ agent_profiles : list of agent profile dicts (from ScanReport.agent_profiles)
233
+ description : optional free-text description of the repo/agent purpose
234
+ (from README first paragraph or directory listing description)
235
+
236
+ Returns
237
+ -------
238
+ dict suitable for passing directly to run_triage() as agent_context.
239
+ """
240
+ tool_inventory = []
241
+ for profile in agent_profiles:
242
+ agent_id = profile.get("agent_id", "unknown")
243
+ for tool in profile.get("tools", []):
244
+ if tool not in tool_inventory:
245
+ tool_inventory.append(f"{agent_id}: {tool}")
246
+
247
+ compact_profiles = []
248
+ for p in agent_profiles:
249
+ compact_profiles.append(
250
+ {
251
+ "agent_id": p.get("agent_id"),
252
+ "role": p.get("role"),
253
+ "goal": p.get("goal"),
254
+ "backstory": p.get("backstory"),
255
+ "tools": p.get("tools", []),
256
+ "memory": p.get("memory"),
257
+ "allow_delegation": p.get("allow_delegation"),
258
+ }
259
+ )
260
+
261
+ return {
262
+ "repo_name": repo_name,
263
+ "description": description,
264
+ "framework": framework,
265
+ "agent_profiles": compact_profiles,
266
+ "tool_inventory": tool_inventory,
267
+ }
268
+
269
+
270
+ # ── Triage caller ──────────────────────────────────────────────────────────────
271
+
272
+
273
+ def run_triage(
274
+ raw_findings: list[RawFinding],
275
+ agent_context: dict[str, Any],
276
+ model: ADKModel | None = None,
277
+ ) -> dict[str, Any] | None:
278
+ """
279
+ Run the triage agent over a list of raw findings.
280
+
281
+ Parameters
282
+ ----------
283
+ raw_findings : list of finding dicts — the combined static + AI findings
284
+ produced by agent_scanner.py before triage. Each dict must
285
+ include at minimum: id, ai_spm_severity, source,
286
+ evidence (list with confidence), hallucination_flag.
287
+ agent_context : dict built by build_agent_context() — describes the agent's
288
+ declared purpose, framework, and tool inventory.
289
+ model : ADK model object from make_model(). If None, resolved
290
+ automatically via make_model() using environment variables.
291
+
292
+ Returns
293
+ -------
294
+ dict with keys:
295
+ kept_findings -- findings that survive triage (with updated severities)
296
+ triage_dismissed -- list of {id, reason}
297
+ triage_downgraded -- list of {id, original_severity, new_severity, reason}
298
+ triage_summary -- counts and severity distribution
299
+
300
+ Returns None if triage fails (caller should fall back to raw_findings).
301
+ """
302
+ if not raw_findings:
303
+ logger.info("No findings to triage — skipping.")
304
+ return None
305
+
306
+ # ── Resolve provider ───────────────────────────────────────────────────────
307
+ if model is None:
308
+ try:
309
+ model = make_model(scanner="agent", phase="triage")
310
+ except (ValueError, ImportError) as e:
311
+ logger.error("Failed to create model: %s", e)
312
+ return None
313
+
314
+ # ── Load prompt ────────────────────────────────────────────────────────────
315
+ try:
316
+ system_prompt = _load_triage_prompt()
317
+ except FileNotFoundError as e:
318
+ logger.error("Triage prompt load failed: %s", e)
319
+ return None
320
+
321
+ # CVE deduplication: OSV returns one finding per CVE per package.
322
+ # For triage, all known_vulnerable_dependency findings for the same
323
+ # package carry the same contextual decision (keep/dismiss). Collapse
324
+ # them to one representative per package, then expand back after triage.
325
+ collapsed_findings = []
326
+ cve_groups = {} # package_name -> list of original finding dicts
327
+ representative_ids = {} # package_name -> finding_id of the representative
328
+
329
+ for f in raw_findings:
330
+ is_cve_finding = (
331
+ f.get("subcategory") == "known_vulnerable_dependency"
332
+ and f.get("source", "") == "static_pip_audit"
333
+ )
334
+ if is_cve_finding:
335
+ # Extract package name from affected_components list
336
+ _ac = f.get("affected_components", [])
337
+ pkg_name = (
338
+ _ac[0].get("name", "") if _ac and isinstance(_ac[0], dict) else ""
339
+ ).strip()
340
+ if not pkg_name:
341
+ collapsed_findings.append(f)
342
+ continue
343
+ if pkg_name not in cve_groups:
344
+ cve_groups[pkg_name] = []
345
+ # Fix #7: start with the first finding as the representative;
346
+ # severity will be upgraded to the maximum across the group below.
347
+ representative = dict(f)
348
+ representative["description"] = (
349
+ f"{pkg_name}: multiple CVEs detected (representative finding — "
350
+ f"triage decision applies to all CVEs for this package)"
351
+ )
352
+ collapsed_findings.append(representative)
353
+ representative_ids[pkg_name] = representative["id"]
354
+ cve_groups[pkg_name].append(f)
355
+ else:
356
+ collapsed_findings.append(f)
357
+
358
+ # For each CVE group, set the representative's severity to the maximum
359
+ # severity across all CVEs in the group so a medium representative
360
+ # cannot mask a critical CVE in the same package.
361
+ _SEV_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4}
362
+ for pkg_name, cves in cve_groups.items():
363
+ rep_id = representative_ids[pkg_name]
364
+ max_sev = min(
365
+ (c.get("ai_spm_severity", "Info") for c in cves),
366
+ key=lambda s: _SEV_ORDER.get(s, 99),
367
+ )
368
+ for rep in collapsed_findings:
369
+ if rep.get("id") == rep_id:
370
+ rep["ai_spm_severity"] = max_sev
371
+ break
372
+
373
+ if cve_groups:
374
+ saved = sum(len(v) - 1 for v in cve_groups.values())
375
+ logger.info(
376
+ "CVE dedup: collapsed %d CVE findings into %d package representatives "
377
+ "(saving %d tokens)",
378
+ sum(len(v) for v in cve_groups.values()),
379
+ len(cve_groups),
380
+ saved,
381
+ )
382
+
383
+ triage_input = collapsed_findings
384
+
385
+ # ── Build user message ─────────────────────────────────────────────────────
386
+ finding_id_list = [f.get("id") for f in triage_input]
387
+ user_message = (
388
+ f"You must account for ALL {len(triage_input)} findings listed in "
389
+ f"ids_to_process. Every ID must appear in either "
390
+ f"kept_finding_ids or triage_dismissed. Do not omit any.\n\n"
391
+ + json.dumps(
392
+ {
393
+ "agent_context": agent_context,
394
+ "ids_to_process": finding_id_list,
395
+ "raw_findings": triage_input,
396
+ },
397
+ indent=2,
398
+ )
399
+ )
400
+
401
+ logger.info(
402
+ "Sending %d findings to triage agent (%s) [%d total before CVE dedup]...",
403
+ len(triage_input),
404
+ model,
405
+ len(raw_findings),
406
+ )
407
+
408
+ # ── LLM call ───────────────────────────────────────────────────────────────
409
+ raw_text = complete_text(
410
+ model=model,
411
+ system_prompt=system_prompt,
412
+ user_message=user_message,
413
+ max_tokens=MAX_TOKENS,
414
+ temperature=TEMPERATURE,
415
+ top_p=TOP_P,
416
+ )
417
+
418
+ if raw_text is None:
419
+ logger.info("Model returned no response — using raw findings unchanged")
420
+ return None
421
+
422
+ # Strip code fences if the model wraps output despite json mode
423
+ text = raw_text.strip()
424
+ if text.startswith("```"):
425
+ lines = text.splitlines()
426
+ text = "\n".join(
427
+ lines[1:-1] if lines[-1].strip() == "```" else lines[1:]
428
+ ).strip()
429
+ if text.startswith("json"):
430
+ text = text[4:].strip()
431
+
432
+ try:
433
+ result = json.loads(text)
434
+ except json.JSONDecodeError as e:
435
+ logger.error("JSON parse error: %s", e)
436
+ logger.error("Raw response (first 500 chars): %s", text[:500])
437
+ # On JSON parse failure, return an identity result (keep all findings,
438
+ # dismiss none) — returning None causes the caller to silently discard
439
+ # all triage work.
440
+ logger.info(
441
+ "Falling back to identity result (all findings kept, none dismissed)"
442
+ )
443
+ return {
444
+ "kept_findings": list(raw_findings),
445
+ "triage_dismissed": [],
446
+ "triage_downgraded": [],
447
+ "triage_summary": {
448
+ "parse_error": True,
449
+ "total_input": len(raw_findings),
450
+ "total_kept": len(raw_findings),
451
+ "total_dismissed": 0,
452
+ "total_downgraded": 0,
453
+ "severity_distribution": {},
454
+ },
455
+ }
456
+
457
+ # ── Validate required keys ─────────────────────────────────────────────────
458
+ required_keys = {
459
+ "kept_finding_ids",
460
+ "triage_dismissed",
461
+ "triage_downgraded",
462
+ "triage_summary",
463
+ }
464
+ missing = required_keys - set(result.keys())
465
+ if missing:
466
+ # Backward-compat: accept kept_findings if model used old schema
467
+ if "kept_findings" in result and "kept_finding_ids" not in result:
468
+ result["kept_finding_ids"] = [
469
+ {"id": f.get("id"), "ai_spm_severity": f.get("ai_spm_severity")}
470
+ for f in result["kept_findings"]
471
+ ]
472
+ else:
473
+ logger.error("Response missing required keys: %s", missing)
474
+ return None
475
+
476
+ # ── Reconstruct full finding objects from kept_finding_ids ────────────────
477
+ triage_input_by_id = {f.get("id"): f for f in triage_input}
478
+ kept_findings = []
479
+ for item in result.get("kept_finding_ids", []):
480
+ fid = item.get("id")
481
+ new_sev = item.get("ai_spm_severity")
482
+ original = triage_input_by_id.get(fid)
483
+ if original is None:
484
+ logger.warning("kept_finding_ids contains unknown id %s — skipping", fid)
485
+ continue
486
+ reconstructed = dict(original)
487
+ if new_sev:
488
+ reconstructed["ai_spm_severity"] = new_sev
489
+ _cap_cvss_score(reconstructed, new_sev)
490
+ kept_findings.append(reconstructed)
491
+ result["kept_findings"] = kept_findings
492
+
493
+ # ── Severity enforcement ──────────────────────────────────────────────────
494
+ result["kept_findings"], severity_overrides = _enforce_severity(
495
+ result["kept_findings"],
496
+ triage_input,
497
+ )
498
+ if severity_overrides:
499
+ logger.info(
500
+ "Severity enforcement: %d finding(s) clamped to CVSS baseline",
501
+ len(severity_overrides),
502
+ )
503
+ for ov in severity_overrides:
504
+ logger.info(
505
+ " %s: %s -> %s (rule: %s)",
506
+ ov["id"],
507
+ ov["llm_severity"],
508
+ ov["enforced_severity"],
509
+ ov["rule"],
510
+ )
511
+
512
+ # ── Integrity check ────────────────────────────────────────────────────────
513
+ input_ids = {f.get("id") for f in triage_input}
514
+ kept_ids = {f.get("id") for f in result["kept_findings"]}
515
+ dismissed_ids = {d.get("id") for d in result.get("triage_dismissed", [])}
516
+ unaccounted = input_ids - (kept_ids | dismissed_ids)
517
+
518
+ if unaccounted:
519
+ missing_findings = [f for f in triage_input if f.get("id") in unaccounted]
520
+ for mf in missing_findings:
521
+ logger.warning(
522
+ "finding %s (%s / %s) was silently dropped — re-adding as kept unchanged",
523
+ mf.get("id"),
524
+ mf.get("subcategory", "?"),
525
+ mf.get("ai_spm_severity", "?"),
526
+ )
527
+ result["kept_findings"].extend(missing_findings)
528
+
529
+ # ── CVE expansion ──────────────────────────────────────────────────────────
530
+ if cve_groups:
531
+ rep_severity_map = {
532
+ f.get("id"): f.get("ai_spm_severity")
533
+ for f in result.get("kept_findings", [])
534
+ }
535
+ expanded_kept = []
536
+ for f in result.get("kept_findings", []):
537
+ fid = f.get("id")
538
+ pkg_for_rep = next(
539
+ (pkg for pkg, rid in representative_ids.items() if rid == fid), None
540
+ )
541
+ if pkg_for_rep and pkg_for_rep in cve_groups:
542
+ triaged_sev = rep_severity_map.get(fid, f.get("ai_spm_severity"))
543
+ for cve_f in cve_groups[pkg_for_rep]:
544
+ expanded = dict(cve_f)
545
+ expanded["ai_spm_severity"] = triaged_sev
546
+ _cap_cvss_score(expanded, triaged_sev)
547
+ expanded_kept.append(expanded)
548
+ else:
549
+ expanded_kept.append(f)
550
+
551
+ expanded_dismissed = list(result.get("triage_dismissed", []))
552
+ for d in result.get("triage_dismissed", []):
553
+ did = d.get("id")
554
+ pkg_for_rep = next(
555
+ (pkg for pkg, rid in representative_ids.items() if rid == did), None
556
+ )
557
+ if pkg_for_rep and pkg_for_rep in cve_groups:
558
+ for cve_f in cve_groups[pkg_for_rep]:
559
+ if cve_f.get("id") != did:
560
+ expanded_dismissed.append(
561
+ {
562
+ "id": cve_f.get("id"),
563
+ "reason": d.get("reason", "")
564
+ + " (CVE group dismissal)",
565
+ }
566
+ )
567
+
568
+ expanded_downgraded = list(result.get("triage_downgraded", []))
569
+ for d in result.get("triage_downgraded", []):
570
+ did = d.get("id")
571
+ pkg_for_rep = next(
572
+ (pkg for pkg, rid in representative_ids.items() if rid == did), None
573
+ )
574
+ if pkg_for_rep and pkg_for_rep in cve_groups:
575
+ for cve_f in cve_groups[pkg_for_rep]:
576
+ if cve_f.get("id") != did:
577
+ expanded_downgraded.append(
578
+ {
579
+ "id": cve_f.get("id"),
580
+ "original_severity": d.get("original_severity"),
581
+ "new_severity": d.get("new_severity"),
582
+ "reason": d.get("reason", "")
583
+ + " (CVE group downgrade)",
584
+ }
585
+ )
586
+
587
+ result["kept_findings"] = expanded_kept
588
+ result["triage_dismissed"] = expanded_dismissed
589
+ result["triage_downgraded"] = expanded_downgraded
590
+ logger.info(
591
+ "CVE expansion: %d final kept findings (%d dismissed)",
592
+ len(expanded_kept),
593
+ len(result.get("triage_dismissed", [])),
594
+ )
595
+
596
+ # ── Log summary ────────────────────────────────────────────────────────────
597
+ summary = result.get("triage_summary", {})
598
+ dist = summary.get("severity_distribution", {})
599
+ logger.info(
600
+ "Complete — input: %d | kept: %d | dismissed: %d | downgraded: %d",
601
+ summary.get("total_input", len(raw_findings)),
602
+ summary.get("total_kept", len(result["kept_findings"])),
603
+ summary.get("total_dismissed", len(result["triage_dismissed"])),
604
+ summary.get("total_downgraded", len(result["triage_downgraded"])),
605
+ )
606
+ logger.info(
607
+ "Post-triage severity distribution — critical: %d | high: %d | medium: %d | low: %d",
608
+ dist.get("critical", 0),
609
+ dist.get("high", 0),
610
+ dist.get("medium", 0),
611
+ dist.get("low", 0),
612
+ )
613
+
614
+ return result
switch_trust/schema.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ Minimal stub for inventory schema types used by the scan module.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+
9
+ @dataclass
10
+ class RepoFile:
11
+ path: str
12
+ content: str
13
+ size: int
14
+
15
+
16
+ @dataclass
17
+ class DiscoveryResult:
18
+ repo_url: str
19
+ repo_name: str
20
+ default_branch: str = "main"
21
+ frameworks_detected: list[str] = field(default_factory=list)
22
+ primary_framework: str = "unknown"
23
+ files: list[RepoFile] = field(default_factory=list)
24
+ python_files: list[RepoFile] = field(default_factory=list)
25
+ requirements_files: list[RepoFile] = field(
26
+ default_factory=list,
27
+ )
28
+ framework_evidence: dict[str, str] = field(
29
+ default_factory=dict,
30
+ )
31
+ total_files_fetched: int = 0
32
+ error: str | None = None
33
+ source_backend: str = "unknown"
34
+
35
+
36
+ @dataclass
37
+ class AffectedComponent:
38
+ """A specific package, file, or module affected by a vulnerability."""
39
+
40
+ name: str
41
+ version: str = ""
42
+ path: str = ""
43
+
44
+
45
+ @dataclass
46
+ class Evidence:
47
+ """Structured evidence supporting a vulnerability finding."""
48
+
49
+ file: str
50
+ code_snippet: str = ""
51
+ context: str = ""
52
+ confidence: str = "" # HIGH | MEDIUM | LOW
53
+ line: int = 0
54
+ column: int = 0
55
+
56
+
57
+ class DependencyType(Enum):
58
+ LIBRARY_USAGE = "LIBRARY_USAGE"
59
+ MODEL_USAGE = "MODEL_USAGE"
60
+ SECRET_USAGE = "SECRET_USAGE"
61
+ SUBAGENT_USAGE = "SUBAGENT_USAGE"
62
+ SUPPLIER = "SUPPLIER"
63
+ VULNERABILITY_USAGE = "VULNERABILITY_USAGE"
64
+ TOOL_USAGE = "TOOL_USAGE"
65
+ TOOL_DEFINITION = "TOOL_DEFINITION"
66
+ MCP_CLIENT_USAGE = "MCP_CLIENT_USAGE"
67
+ MCP_SERVER_USAGE = "MCP_SERVER_USAGE"
68
+
69
+
70
+ class EntityType(Enum):
71
+ LIBRARY = "LIBRARY"
72
+ MODEL = "MODEL"
73
+ AGENT = "AGENT"
74
+ MCP_SERVER = "MCP_SERVER"
75
+ SECRET = "SECRET"
76
+ ORGANIZATION = "ORGANIZATION"
77
+ MCP_CLIENT = "MCP_CLIENT"
78
+ VULNERABILITY = "VULNERABILITY"
79
+ TOOL = "TOOL"