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.
- switch_trust/__init__.py +0 -0
- switch_trust/cli/__init__.py +0 -0
- switch_trust/cli/__main__.py +3 -0
- switch_trust/cli/builtin_config.json +879 -0
- switch_trust/cli/console.py +127 -0
- switch_trust/cli/eval_cli.py +905 -0
- switch_trust/cli/file_filter.py +172 -0
- switch_trust/cli/init_cli.py +156 -0
- switch_trust/cli/main.py +384 -0
- switch_trust/cli/output_formatters.py +482 -0
- switch_trust/cli/rich_observer.py +89 -0
- switch_trust/cli/runner.py +274 -0
- switch_trust/cli/scan_cli.py +314 -0
- switch_trust/cli/telemetry.py +430 -0
- switch_trust/cli/utils.py +158 -0
- switch_trust/cli/version.py +1 -0
- switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
- switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
- switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
- switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
- switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
- switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
- switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
- switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
- switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
- switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
- switch_trust/data/llm01_prompt_injection.csv +6708 -0
- switch_trust/data/llm02_sensitive_information.csv +1069 -0
- switch_trust/data/llm05_unsafe_output.csv +3882 -0
- switch_trust/data/llm06_excessive_agency.csv +778 -0
- switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
- switch_trust/data/llm09_hallucination-goals.csv +32419 -0
- switch_trust/data/llm09_hallucination.csv +599 -0
- switch_trust/data/pii_leakage.csv +706 -0
- switch_trust/data/secret_leakage.csv +1380 -0
- switch_trust/eval/__init__.py +0 -0
- switch_trust/eval/common/__init__.py +0 -0
- switch_trust/eval/common/converter_anthropic.py +172 -0
- switch_trust/eval/common/converter_genai.py +171 -0
- switch_trust/eval/common/converter_openai.py +198 -0
- switch_trust/eval/common/log.py +116 -0
- switch_trust/eval/common/reference.py +25 -0
- switch_trust/eval/common/schema.py +190 -0
- switch_trust/eval/common/utils.py +119 -0
- switch_trust/eval/core/__init__.py +0 -0
- switch_trust/eval/core/detectors/__init__.py +0 -0
- switch_trust/eval/core/detectors/detector.py +25 -0
- switch_trust/eval/core/detectors/detector_garak.py +112 -0
- switch_trust/eval/core/detectors/detector_model.py +123 -0
- switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
- switch_trust/eval/core/detectors/detector_pii.py +67 -0
- switch_trust/eval/core/detectors/detector_secret.py +88 -0
- switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
- switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
- switch_trust/eval/core/eval/__init__.py +0 -0
- switch_trust/eval/core/eval/eval_creator.py +166 -0
- switch_trust/eval/core/eval/evaluation.py +112 -0
- switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
- switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
- switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
- switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
- switch_trust/eval/core/eval/evaluation_multi.py +276 -0
- switch_trust/eval/core/eval/evaluation_single.py +89 -0
- switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
- switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
- switch_trust/eval/core/eval/metric_conciseness.py +179 -0
- switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
- switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
- switch_trust/eval/core/eval/metric_tone.py +180 -0
- switch_trust/eval/core/eval/metric_toxicity.py +146 -0
- switch_trust/eval/core/eval/observer.py +137 -0
- switch_trust/eval/core/message/__init__.py +0 -0
- switch_trust/eval/core/message/message_collection.py +37 -0
- switch_trust/eval/core/message/message_collection_csv.py +44 -0
- switch_trust/eval/core/message/message_collection_garak.py +46 -0
- switch_trust/eval/core/message/message_collection_memory.py +34 -0
- switch_trust/eval/core/models/__init__.py +0 -0
- switch_trust/eval/core/models/generator_model.py +421 -0
- switch_trust/eval/core/models/model.py +172 -0
- switch_trust/eval/core/models/model_adk.py +186 -0
- switch_trust/eval/core/models/model_anthropic.py +79 -0
- switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
- switch_trust/eval/core/models/model_gemini.py +146 -0
- switch_trust/eval/core/models/model_generic_http.py +120 -0
- switch_trust/eval/core/models/model_http.py +135 -0
- switch_trust/eval/core/models/model_huggingface.py +75 -0
- switch_trust/eval/core/models/model_langserve.py +96 -0
- switch_trust/eval/core/models/model_litellm.py +52 -0
- switch_trust/eval/core/models/model_ollama.py +65 -0
- switch_trust/eval/core/models/model_openai.py +71 -0
- switch_trust/eval/core/models/model_openai_agent.py +92 -0
- switch_trust/eval/core/models/model_openai_compatible.py +66 -0
- switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
- switch_trust/eval/core/models/model_retry.py +204 -0
- switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
- switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
- switch_trust/eval/core/models/response_schema.py +148 -0
- switch_trust/eval/core/optional_deps.py +57 -0
- switch_trust/eval/db/__init__.py +0 -0
- switch_trust/eval/db/base/__init__.py +0 -0
- switch_trust/eval/db/base/common_types.py +16 -0
- switch_trust/eval/db/base/detectors/__init__.py +0 -0
- switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
- switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
- switch_trust/eval/db/base/detectors/detector_types.py +77 -0
- switch_trust/eval/db/base/eval/__init__.py +0 -0
- switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
- switch_trust/eval/db/base/eval/eval_repository.py +120 -0
- switch_trust/eval/db/base/eval/eval_run.py +142 -0
- switch_trust/eval/db/base/eval/eval_types.py +127 -0
- switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
- switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
- switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
- switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
- switch_trust/eval/db/base/message/__init__.py +0 -0
- switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
- switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
- switch_trust/eval/db/base/message/message_collection_types.py +87 -0
- switch_trust/eval/db/base/models/__init__.py +0 -0
- switch_trust/eval/db/base/models/model_helpers.py +253 -0
- switch_trust/eval/db/base/models/model_repository.py +104 -0
- switch_trust/eval/db/base/models/model_types.py +159 -0
- switch_trust/eval/db/json/__init__.py +0 -0
- switch_trust/eval/db/json/repository_json.py +559 -0
- switch_trust/scan/__init__.py +12 -0
- switch_trust/scan/agent_scanner.py +812 -0
- switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
- switch_trust/scan/config/agent_taxonomy.json +517 -0
- switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
- switch_trust/scan/config/compliance_mappings.json +110 -0
- switch_trust/scan/config/triage_prompt.txt +326 -0
- switch_trust/scan/constants.py +15 -0
- switch_trust/scan/exception_classification.py +179 -0
- switch_trust/scan/llm_provider.py +330 -0
- switch_trust/scan/opengrep_resolver.py +12 -0
- switch_trust/scan/reasoner.py +602 -0
- switch_trust/scan/schema.py +315 -0
- switch_trust/scan/scorer.py +372 -0
- switch_trust/scan/static_scanner.py +681 -0
- switch_trust/scan/taxonomy.py +121 -0
- switch_trust/scan/tool_dispatcher.py +773 -0
- switch_trust/scan/trace_logger.py +118 -0
- switch_trust/scan/trace_logger_file.py +143 -0
- switch_trust/scan/trace_logger_log.py +100 -0
- switch_trust/scan/triage.py +614 -0
- switch_trust/schema.py +79 -0
- switch_trust/secret_anonymizer.py +72 -0
- switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
- switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
- switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
- switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
- switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
- 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"
|