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,482 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output format abstraction for switch-trust CLI.
|
|
3
|
+
|
|
4
|
+
Provides pluggable formatters for scan and eval results.
|
|
5
|
+
To add a new format: subclass ScanOutputFormatter / EvalOutputFormatter,
|
|
6
|
+
register in the SCAN_OUTPUT_FORMATTERS / EVAL_OUTPUT_FORMATTERS dicts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from switch_trust.cli.version import VERSION
|
|
19
|
+
from switch_trust.eval.common.utils import now_utc, strip_nulls
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OutputFormat(str, Enum):
|
|
23
|
+
JSON = "json"
|
|
24
|
+
SARIF = "sarif"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Scan output formatters ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# Agent discovery (the InventoryParser pass) is out of scope for the CLI, so
|
|
30
|
+
# these ScanReport fields are structurally always 0 / empty on this path —
|
|
31
|
+
# `run_core` is never handed any AgentProfile objects. Emitting them advertises
|
|
32
|
+
# a measurement the CLI never makes and creates a "framework detected but 0
|
|
33
|
+
# agents" contradiction, so drop them from CLI output rather than ship a
|
|
34
|
+
# permanent `agents_found: 0`. The shared schema keeps the fields for the
|
|
35
|
+
# inventory sensor path, which does populate them; the SARIF formatter already
|
|
36
|
+
# omits them.
|
|
37
|
+
_CLI_UNPOPULATED_SCAN_FIELDS = ("agents_found", "agent_profiles")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScanOutputFormatter(ABC):
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def format(self, report: Any) -> str: ...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def extension(self) -> str: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class JsonScanOutputFormatter(ScanOutputFormatter):
|
|
50
|
+
@property
|
|
51
|
+
def extension(self) -> str:
|
|
52
|
+
return "json"
|
|
53
|
+
|
|
54
|
+
def format(self, report: Any) -> str:
|
|
55
|
+
data = dataclasses.asdict(report)
|
|
56
|
+
for key in _CLI_UNPOPULATED_SCAN_FIELDS:
|
|
57
|
+
data.pop(key, None)
|
|
58
|
+
return json.dumps(data, indent=2)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SarifScanOutputFormatter(ScanOutputFormatter):
|
|
62
|
+
@property
|
|
63
|
+
def extension(self) -> str:
|
|
64
|
+
return "sarif"
|
|
65
|
+
|
|
66
|
+
def format(self, report: Any) -> str:
|
|
67
|
+
return json.dumps(self._to_sarif(report), indent=2)
|
|
68
|
+
|
|
69
|
+
def _severity_to_level(self, severity: str) -> str:
|
|
70
|
+
s = severity.lower()
|
|
71
|
+
if s in ("critical", "high"):
|
|
72
|
+
return "error"
|
|
73
|
+
if s == "medium":
|
|
74
|
+
return "warning"
|
|
75
|
+
return "note"
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _normalize_text_paths(text: str) -> str:
|
|
79
|
+
return re.sub(r"(?<![/\w])Users/", "/Users/", text)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _normalize_uri(path: str) -> str:
|
|
83
|
+
if not path:
|
|
84
|
+
return path
|
|
85
|
+
if path.startswith("/"):
|
|
86
|
+
return path
|
|
87
|
+
if len(path) > 1 and path[0].isalpha() and path[1] == ":":
|
|
88
|
+
return path
|
|
89
|
+
if path.startswith("file:"):
|
|
90
|
+
return path
|
|
91
|
+
parts = path.split("/")
|
|
92
|
+
if len(parts) >= 2 and parts[0].lower() == "users":
|
|
93
|
+
return "/" + path
|
|
94
|
+
return path
|
|
95
|
+
|
|
96
|
+
def _to_sarif(self, report: Any) -> dict:
|
|
97
|
+
rules: list[dict] = []
|
|
98
|
+
rule_index: dict[str, int] = {}
|
|
99
|
+
results: list[dict] = []
|
|
100
|
+
|
|
101
|
+
for finding in report.findings:
|
|
102
|
+
rule_id = f"{finding.category}/{finding.subcategory}"
|
|
103
|
+
if rule_id not in rule_index:
|
|
104
|
+
rule_index[rule_id] = len(rules)
|
|
105
|
+
rule: dict[str, Any] = {
|
|
106
|
+
"id": rule_id,
|
|
107
|
+
"shortDescription": {"text": finding.subcategory},
|
|
108
|
+
"properties": {"tags": [finding.category]},
|
|
109
|
+
}
|
|
110
|
+
if finding.remediation:
|
|
111
|
+
rule["help"] = {"text": finding.remediation}
|
|
112
|
+
rules.append(rule)
|
|
113
|
+
|
|
114
|
+
# A location's URI must be a file path. Evidence carries one; an
|
|
115
|
+
# affected component's `name` does not — it is a free-text label the
|
|
116
|
+
# reasoning model writes, and for AI findings `path` is never set, so
|
|
117
|
+
# the old `comp.path or comp.name` fallback published things like
|
|
118
|
+
# "request_return", "check_order" and "agent.py:app" as artifact
|
|
119
|
+
# URIs. Which label it chose varied run to run, so the same
|
|
120
|
+
# vulnerability landed under a different location on each scan while
|
|
121
|
+
# the correct file sat in `relatedLocations` all along.
|
|
122
|
+
#
|
|
123
|
+
# Preference order is therefore evidence file, then a component that
|
|
124
|
+
# carries a real `path` (static findings set one). A component name
|
|
125
|
+
# is never used: no location beats a wrong one, and the names are
|
|
126
|
+
# preserved under `properties.affectedComponents`.
|
|
127
|
+
locations = []
|
|
128
|
+
for ev in finding.evidence or []:
|
|
129
|
+
if not ev.file:
|
|
130
|
+
continue
|
|
131
|
+
loc: dict[str, Any] = {
|
|
132
|
+
"physicalLocation": {
|
|
133
|
+
"artifactLocation": {"uri": self._normalize_uri(ev.file)},
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
if ev.line:
|
|
137
|
+
loc["physicalLocation"]["region"] = {"startLine": ev.line}
|
|
138
|
+
if ev.column:
|
|
139
|
+
loc["physicalLocation"]["region"]["startColumn"] = ev.column
|
|
140
|
+
locations.append(loc)
|
|
141
|
+
|
|
142
|
+
if not locations:
|
|
143
|
+
for comp in finding.affected_components or []:
|
|
144
|
+
if not comp.path:
|
|
145
|
+
continue
|
|
146
|
+
locations.append(
|
|
147
|
+
{
|
|
148
|
+
"physicalLocation": {
|
|
149
|
+
"artifactLocation": {
|
|
150
|
+
"uri": self._normalize_uri(comp.path)
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
related_locations = []
|
|
157
|
+
for idx, ev in enumerate(finding.evidence or []):
|
|
158
|
+
if not ev.file:
|
|
159
|
+
continue
|
|
160
|
+
rel: dict[str, Any] = {
|
|
161
|
+
"id": idx,
|
|
162
|
+
"physicalLocation": {
|
|
163
|
+
"artifactLocation": {"uri": self._normalize_uri(ev.file)},
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
if ev.line:
|
|
167
|
+
rel["physicalLocation"]["region"] = {"startLine": ev.line}
|
|
168
|
+
if ev.code_snippet:
|
|
169
|
+
rel["physicalLocation"]["region"] = rel["physicalLocation"].get(
|
|
170
|
+
"region", {}
|
|
171
|
+
)
|
|
172
|
+
rel["physicalLocation"]["region"]["snippet"] = {
|
|
173
|
+
"text": ev.code_snippet
|
|
174
|
+
}
|
|
175
|
+
if ev.context:
|
|
176
|
+
rel["message"] = {"text": ev.context}
|
|
177
|
+
related_locations.append(rel)
|
|
178
|
+
|
|
179
|
+
message_parts = []
|
|
180
|
+
if finding.impact:
|
|
181
|
+
message_parts.append(f"Impact: {finding.impact}")
|
|
182
|
+
if not message_parts:
|
|
183
|
+
message_parts.append(finding.title or finding.description)
|
|
184
|
+
|
|
185
|
+
result: dict[str, Any] = {
|
|
186
|
+
"ruleId": rule_id,
|
|
187
|
+
"ruleIndex": rule_index[rule_id],
|
|
188
|
+
"level": self._severity_to_level(finding.ai_spm_severity),
|
|
189
|
+
"message": {
|
|
190
|
+
"text": self._normalize_text_paths(" ".join(message_parts))
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if finding.id:
|
|
195
|
+
result["guid"] = finding.id
|
|
196
|
+
|
|
197
|
+
if locations:
|
|
198
|
+
result["locations"] = locations
|
|
199
|
+
|
|
200
|
+
if related_locations:
|
|
201
|
+
result["relatedLocations"] = related_locations
|
|
202
|
+
|
|
203
|
+
props: dict[str, Any] = {}
|
|
204
|
+
if finding.cvss_scores:
|
|
205
|
+
props["cvssv4"] = {
|
|
206
|
+
"baseScore": finding.cvss_scores.base_score,
|
|
207
|
+
"vector": finding.cvss_scores.vector,
|
|
208
|
+
}
|
|
209
|
+
if finding.source:
|
|
210
|
+
props["source"] = finding.source
|
|
211
|
+
if finding.ai_spm_severity:
|
|
212
|
+
props["severity"] = finding.ai_spm_severity
|
|
213
|
+
# Kept because these no longer appear as location URIs. For an AI
|
|
214
|
+
# finding this is the agent, tool or function the model named, which
|
|
215
|
+
# is useful context even though it is not a path.
|
|
216
|
+
component_names = [
|
|
217
|
+
comp.name for comp in finding.affected_components or [] if comp.name
|
|
218
|
+
]
|
|
219
|
+
if component_names:
|
|
220
|
+
props["affectedComponents"] = component_names
|
|
221
|
+
if finding.hallucination_flag:
|
|
222
|
+
props["hallucinationFlag"] = True
|
|
223
|
+
if props:
|
|
224
|
+
result["properties"] = props
|
|
225
|
+
|
|
226
|
+
results.append(result)
|
|
227
|
+
|
|
228
|
+
invocation: dict[str, Any] = {"executionSuccessful": True}
|
|
229
|
+
if report.scan_timestamp:
|
|
230
|
+
invocation["startTimeUtc"] = report.scan_timestamp
|
|
231
|
+
|
|
232
|
+
run: dict[str, Any] = {
|
|
233
|
+
"tool": {
|
|
234
|
+
"driver": {
|
|
235
|
+
"name": "switch-trust-scan",
|
|
236
|
+
"version": VERSION,
|
|
237
|
+
"informationUri": "https://github.com/sandbox-quantum/switch-trust",
|
|
238
|
+
"rules": rules,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
"invocations": [invocation],
|
|
242
|
+
"results": results,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
artifacts = []
|
|
246
|
+
seen_uris: set[str] = set()
|
|
247
|
+
for r in results:
|
|
248
|
+
for loc in r.get("locations", []):
|
|
249
|
+
uri = (
|
|
250
|
+
loc.get("physicalLocation", {})
|
|
251
|
+
.get("artifactLocation", {})
|
|
252
|
+
.get("uri", "")
|
|
253
|
+
)
|
|
254
|
+
if uri and uri not in seen_uris:
|
|
255
|
+
seen_uris.add(uri)
|
|
256
|
+
artifacts.append({"location": {"uri": uri}})
|
|
257
|
+
if artifacts:
|
|
258
|
+
run["artifacts"] = artifacts
|
|
259
|
+
|
|
260
|
+
run_props: dict[str, Any] = {}
|
|
261
|
+
if report.repo_name:
|
|
262
|
+
run_props["repoName"] = report.repo_name
|
|
263
|
+
if report.scan_metadata:
|
|
264
|
+
run_props["scanMetadata"] = report.scan_metadata
|
|
265
|
+
if run_props:
|
|
266
|
+
run["properties"] = run_props
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
"$schema": (
|
|
270
|
+
"https://docs.oasis-open.org/sarif/sarif/v2.1.0"
|
|
271
|
+
"/errata01/os/schemas/sarif-schema-2.1.0.json"
|
|
272
|
+
),
|
|
273
|
+
"version": "2.1.0",
|
|
274
|
+
"runs": [run],
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── Eval output formatters ──────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
EVAL_SCHEMA_VERSION = "2.0"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class EvalOutputFormatter(ABC):
|
|
284
|
+
@abstractmethod
|
|
285
|
+
def format(self, runs: list, config_path: str) -> str: ...
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
@abstractmethod
|
|
289
|
+
def extension(self) -> str: ...
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class JsonEvalOutputFormatter(EvalOutputFormatter):
|
|
293
|
+
@property
|
|
294
|
+
def extension(self) -> str:
|
|
295
|
+
return "json"
|
|
296
|
+
|
|
297
|
+
def format(self, runs: list, config_path: str) -> str:
|
|
298
|
+
output = prepare_eval_output(runs, config_path)
|
|
299
|
+
return json.dumps(output, indent=2, default=str)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class SarifEvalOutputFormatter(EvalOutputFormatter):
|
|
303
|
+
@property
|
|
304
|
+
def extension(self) -> str:
|
|
305
|
+
return "sarif"
|
|
306
|
+
|
|
307
|
+
def format(self, runs: list, config_path: str) -> str:
|
|
308
|
+
return json.dumps(self._to_sarif(runs, config_path), indent=2, default=str)
|
|
309
|
+
|
|
310
|
+
def _score_to_level(self, score: float) -> str:
|
|
311
|
+
if score >= 0.7:
|
|
312
|
+
return "error"
|
|
313
|
+
if score >= 0.3:
|
|
314
|
+
return "warning"
|
|
315
|
+
return "note"
|
|
316
|
+
|
|
317
|
+
def _to_sarif(self, runs: list, config_path: str) -> dict:
|
|
318
|
+
rules: list[dict] = []
|
|
319
|
+
rule_index: dict[str, int] = {}
|
|
320
|
+
results: list[dict] = []
|
|
321
|
+
|
|
322
|
+
for run in runs:
|
|
323
|
+
eval_name = run.evaluation.get("name", "unknown")
|
|
324
|
+
eval_type = run.evaluation.get("type", "unknown")
|
|
325
|
+
model_name = run.model.get("name", "unknown")
|
|
326
|
+
|
|
327
|
+
if eval_name not in rule_index:
|
|
328
|
+
rule_index[eval_name] = len(rules)
|
|
329
|
+
rules.append(
|
|
330
|
+
{
|
|
331
|
+
"id": eval_name,
|
|
332
|
+
"shortDescription": {"text": eval_name},
|
|
333
|
+
"properties": {"tags": ["ai-red-teaming", eval_type]},
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
achieved = 0.0
|
|
338
|
+
max_score = 0.0
|
|
339
|
+
total = 0
|
|
340
|
+
summary_props: dict[str, Any] = {}
|
|
341
|
+
if run.summary:
|
|
342
|
+
s = (
|
|
343
|
+
run.summary
|
|
344
|
+
if isinstance(run.summary, dict)
|
|
345
|
+
else run.summary.to_dict()
|
|
346
|
+
)
|
|
347
|
+
achieved = s.get("achieved_score", 0.0)
|
|
348
|
+
max_score = s.get("max_score", 0.0)
|
|
349
|
+
total = s.get("total_evaluations", 0)
|
|
350
|
+
summary_props = {
|
|
351
|
+
"status": s.get("status", ""),
|
|
352
|
+
"totalEvaluations": total,
|
|
353
|
+
"finishedEvaluations": s.get("finished_evaluations", 0),
|
|
354
|
+
"errorEvaluations": s.get("error_evaluations", 0),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if achieved > 0:
|
|
358
|
+
avg_score = achieved / total if total > 0 else 0.0
|
|
359
|
+
results.append(
|
|
360
|
+
{
|
|
361
|
+
"ruleId": eval_name,
|
|
362
|
+
"ruleIndex": rule_index[eval_name],
|
|
363
|
+
"level": self._score_to_level(avg_score),
|
|
364
|
+
"message": {
|
|
365
|
+
"text": (
|
|
366
|
+
f"Model '{model_name}' scored {achieved:.1f}/{max_score:.1f} "
|
|
367
|
+
f"({avg_score:.0%}) on evaluation '{eval_name}' "
|
|
368
|
+
f"across {total} prompts."
|
|
369
|
+
),
|
|
370
|
+
},
|
|
371
|
+
"locations": [
|
|
372
|
+
{
|
|
373
|
+
"logicalLocations": [
|
|
374
|
+
{
|
|
375
|
+
"name": model_name,
|
|
376
|
+
"kind": "module",
|
|
377
|
+
"fullyQualifiedName": run.model.get(
|
|
378
|
+
"id", model_name
|
|
379
|
+
),
|
|
380
|
+
}
|
|
381
|
+
],
|
|
382
|
+
}
|
|
383
|
+
],
|
|
384
|
+
"properties": {
|
|
385
|
+
"modelEvaluationId": run.model_evaluation_id,
|
|
386
|
+
"modelEvaluationName": run.model_evaluation_name,
|
|
387
|
+
"achievedScore": achieved,
|
|
388
|
+
"maxScore": max_score,
|
|
389
|
+
"averageScore": round(avg_score, 4),
|
|
390
|
+
"totalPrompts": total,
|
|
391
|
+
**summary_props,
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
"$schema": (
|
|
398
|
+
"https://docs.oasis-open.org/sarif/sarif/v2.1.0"
|
|
399
|
+
"/errata01/os/schemas/sarif-schema-2.1.0.json"
|
|
400
|
+
),
|
|
401
|
+
"version": "2.1.0",
|
|
402
|
+
"runs": [
|
|
403
|
+
{
|
|
404
|
+
"tool": {
|
|
405
|
+
"driver": {
|
|
406
|
+
"name": "switch-trust-eval",
|
|
407
|
+
"version": VERSION,
|
|
408
|
+
"informationUri": "https://github.com/sandbox-quantum/switch-trust",
|
|
409
|
+
"rules": rules,
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
"invocations": [{"executionSuccessful": True}],
|
|
413
|
+
"results": results,
|
|
414
|
+
"properties": {
|
|
415
|
+
"configFile": config_path,
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
],
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _strip_session(session: dict | None) -> dict | None:
|
|
426
|
+
if session is None:
|
|
427
|
+
return None
|
|
428
|
+
session.pop("id", None)
|
|
429
|
+
session.pop("timestamp", None)
|
|
430
|
+
session.pop("metadata", None)
|
|
431
|
+
for msg in session.get("messages", []):
|
|
432
|
+
msg.pop("id", None)
|
|
433
|
+
msg.pop("timestamp", None)
|
|
434
|
+
msg.pop("metadata", None)
|
|
435
|
+
return session
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _strip_result(result: dict) -> dict:
|
|
439
|
+
result.pop("status", None)
|
|
440
|
+
result["session"] = _strip_session(result.get("session"))
|
|
441
|
+
return result
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def prepare_eval_output(runs: list, config_path: str) -> dict:
|
|
445
|
+
# Deliberately lazy: runner imports get_eval_output_formatter from this
|
|
446
|
+
# module at import time, so hoisting this would close the cycle.
|
|
447
|
+
from switch_trust.cli.runner import _aggregate_summary # noqa: PLC0415
|
|
448
|
+
|
|
449
|
+
overall = _aggregate_summary(runs)
|
|
450
|
+
raw: dict[str, Any] = {
|
|
451
|
+
"schema_version": EVAL_SCHEMA_VERSION,
|
|
452
|
+
"config_file": config_path,
|
|
453
|
+
"timestamp": now_utc().isoformat(),
|
|
454
|
+
"summary": overall.to_dict(),
|
|
455
|
+
"runs": [r.to_dict() for r in runs],
|
|
456
|
+
}
|
|
457
|
+
for run in raw["runs"]:
|
|
458
|
+
for result in run.get("results", []):
|
|
459
|
+
_strip_result(result)
|
|
460
|
+
return strip_nulls(raw)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ── Registry ─────────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
SCAN_OUTPUT_FORMATTERS: dict[OutputFormat, ScanOutputFormatter] = {
|
|
467
|
+
OutputFormat.JSON: JsonScanOutputFormatter(),
|
|
468
|
+
OutputFormat.SARIF: SarifScanOutputFormatter(),
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
EVAL_OUTPUT_FORMATTERS: dict[OutputFormat, EvalOutputFormatter] = {
|
|
472
|
+
OutputFormat.JSON: JsonEvalOutputFormatter(),
|
|
473
|
+
OutputFormat.SARIF: SarifEvalOutputFormatter(),
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def get_scan_output_formatter(fmt: OutputFormat) -> ScanOutputFormatter:
|
|
478
|
+
return SCAN_OUTPUT_FORMATTERS[fmt]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def get_eval_output_formatter(fmt: OutputFormat) -> EvalOutputFormatter:
|
|
482
|
+
return EVAL_OUTPUT_FORMATTERS[fmt]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.progress import (
|
|
4
|
+
BarColumn,
|
|
5
|
+
Progress,
|
|
6
|
+
ProgressColumn,
|
|
7
|
+
TaskID,
|
|
8
|
+
TextColumn,
|
|
9
|
+
TimeElapsedColumn,
|
|
10
|
+
)
|
|
11
|
+
from rich.spinner import Spinner
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from switch_trust.cli.console import console, score_style
|
|
15
|
+
from switch_trust.eval.core.eval.evaluation import Evaluation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StatusColumn(ProgressColumn):
|
|
19
|
+
"""Spinner while running, green check when done, red X on error."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._spinner = Spinner("dots")
|
|
24
|
+
|
|
25
|
+
def render(self, task: object) -> Text:
|
|
26
|
+
if task.finished:
|
|
27
|
+
status = task.fields.get("final_status", "finished")
|
|
28
|
+
if status == "error":
|
|
29
|
+
return Text("✗", style="bold red")
|
|
30
|
+
return Text("✔", style="bold green")
|
|
31
|
+
return self._spinner.render(task.get_time())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProgressOrScoreColumn(ProgressColumn):
|
|
35
|
+
"""Shows M/N while running, score percentage when finished."""
|
|
36
|
+
|
|
37
|
+
def render(self, task: object) -> Text:
|
|
38
|
+
if task.finished:
|
|
39
|
+
score = task.fields.get("score")
|
|
40
|
+
if score is not None:
|
|
41
|
+
pct = score * 100
|
|
42
|
+
return Text(f"{pct:.0f}%", style=score_style(score))
|
|
43
|
+
return Text("—", style="dim")
|
|
44
|
+
completed = int(task.completed)
|
|
45
|
+
if task.total is not None:
|
|
46
|
+
return Text(f"{completed}/{int(task.total)}")
|
|
47
|
+
return Text(f"{completed}/?")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_progress() -> Progress:
|
|
51
|
+
return Progress(
|
|
52
|
+
StatusColumn(),
|
|
53
|
+
TextColumn("{task.description}"),
|
|
54
|
+
BarColumn(),
|
|
55
|
+
ProgressOrScoreColumn(),
|
|
56
|
+
TextColumn("[dim]|[/dim]"),
|
|
57
|
+
TimeElapsedColumn(),
|
|
58
|
+
console=console,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pad_description(name: str, width: int) -> str:
|
|
63
|
+
if len(name) > width:
|
|
64
|
+
return name[: width - 1] + "…"
|
|
65
|
+
return name.ljust(width)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RichObserver:
|
|
69
|
+
"""Evaluation observer that updates a task in a shared Progress."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, progress: Progress, task_id: TaskID):
|
|
72
|
+
self._progress = progress
|
|
73
|
+
self._task_id = task_id
|
|
74
|
+
|
|
75
|
+
def __call__(self, evaluation: Evaluation) -> None:
|
|
76
|
+
summary = evaluation.get_summary()
|
|
77
|
+
done = summary.finished_evaluations + summary.error_evaluations
|
|
78
|
+
self._progress.update(self._task_id, completed=done)
|
|
79
|
+
|
|
80
|
+
def finish(self, evaluation: Evaluation) -> None:
|
|
81
|
+
summary = evaluation.get_summary()
|
|
82
|
+
total = summary.total_evaluations or 1
|
|
83
|
+
self._progress.update(
|
|
84
|
+
self._task_id,
|
|
85
|
+
completed=total,
|
|
86
|
+
total=total,
|
|
87
|
+
final_status=summary.status.value,
|
|
88
|
+
score=summary.score,
|
|
89
|
+
)
|