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,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI evaluation runner.
|
|
3
|
+
|
|
4
|
+
Runs evaluations without a database, using the JsonRepository for
|
|
5
|
+
configuration and printing progress to the console.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from dataclasses_json import dataclass_json
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.progress import Progress
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from switch_trust.cli.console import CLI_WIDTH, console, score_style, status_style
|
|
19
|
+
from switch_trust.cli.output_formatters import (
|
|
20
|
+
OutputFormat,
|
|
21
|
+
get_eval_output_formatter,
|
|
22
|
+
)
|
|
23
|
+
from switch_trust.cli.rich_observer import RichObserver, pad_description
|
|
24
|
+
from switch_trust.eval.core.eval.evaluation import (
|
|
25
|
+
EvaluationResult,
|
|
26
|
+
EvaluationStatus,
|
|
27
|
+
EvaluationSummary,
|
|
28
|
+
)
|
|
29
|
+
from switch_trust.eval.db.base.eval.model_eval_types import DbModelEvaluation
|
|
30
|
+
from switch_trust.eval.db.json.repository_json import JsonRepository
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
MAX_CONSECUTIVE_RUN_FAILURES = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass_json
|
|
38
|
+
@dataclass
|
|
39
|
+
class CliRunResult:
|
|
40
|
+
model_evaluation_id: str
|
|
41
|
+
model_evaluation_name: str
|
|
42
|
+
model: dict = field(default_factory=dict)
|
|
43
|
+
evaluation: dict = field(default_factory=dict)
|
|
44
|
+
summary: EvaluationSummary | None = None
|
|
45
|
+
results: list[EvaluationResult] = field(
|
|
46
|
+
default_factory=list,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def run_cli_evaluation(
|
|
51
|
+
model_evaluation: DbModelEvaluation,
|
|
52
|
+
store: JsonRepository,
|
|
53
|
+
concurrency: int,
|
|
54
|
+
progress: Progress,
|
|
55
|
+
desc_width: int,
|
|
56
|
+
) -> CliRunResult:
|
|
57
|
+
db_eval = store.evaluations.get(
|
|
58
|
+
model_evaluation.evaluation_id,
|
|
59
|
+
)
|
|
60
|
+
db_model = store.models.get(
|
|
61
|
+
model_evaluation.model_id,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
logger.info("Running evaluation: %s", db_eval.name)
|
|
65
|
+
|
|
66
|
+
desc = pad_description(db_eval.name, desc_width)
|
|
67
|
+
task_id = progress.add_task(desc, total=None)
|
|
68
|
+
|
|
69
|
+
evaluation = store.evaluations.get_evaluation(
|
|
70
|
+
model_evaluation.evaluation_id,
|
|
71
|
+
message_collection_repo=store.message_collections,
|
|
72
|
+
detector_repo=store.detectors,
|
|
73
|
+
)
|
|
74
|
+
model = store.models.get_model(model_evaluation.model_id)
|
|
75
|
+
|
|
76
|
+
observer = RichObserver(progress, task_id)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
await evaluation.init()
|
|
80
|
+
total = evaluation.get_summary().total_evaluations
|
|
81
|
+
progress.update(task_id, total=total)
|
|
82
|
+
evaluation.add_observer(observer)
|
|
83
|
+
await evaluation.run(model, concurrency)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(
|
|
86
|
+
"Evaluation failed: %s (%s: %s)",
|
|
87
|
+
db_eval.name,
|
|
88
|
+
type(e).__name__,
|
|
89
|
+
e,
|
|
90
|
+
)
|
|
91
|
+
progress.console.print(
|
|
92
|
+
f"[red]Error ({db_eval.name}): {e}[/red]",
|
|
93
|
+
)
|
|
94
|
+
finally:
|
|
95
|
+
evaluation.remove_observer(observer)
|
|
96
|
+
observer.finish(evaluation)
|
|
97
|
+
|
|
98
|
+
summary = evaluation.get_summary()
|
|
99
|
+
results = evaluation.get_results()
|
|
100
|
+
|
|
101
|
+
logger.info(
|
|
102
|
+
"Evaluation finished: %s (score=%s)",
|
|
103
|
+
db_eval.name,
|
|
104
|
+
summary.score if summary else "N/A",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return CliRunResult(
|
|
108
|
+
model_evaluation_id=model_evaluation.id,
|
|
109
|
+
model_evaluation_name=model_evaluation.name,
|
|
110
|
+
model={
|
|
111
|
+
"id": db_model.id,
|
|
112
|
+
"name": db_model.name,
|
|
113
|
+
"type": db_model.type.value,
|
|
114
|
+
},
|
|
115
|
+
evaluation={
|
|
116
|
+
"id": db_eval.id,
|
|
117
|
+
"name": db_eval.name,
|
|
118
|
+
"type": db_eval.type.value,
|
|
119
|
+
},
|
|
120
|
+
summary=summary,
|
|
121
|
+
results=results,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _aggregate_summary(
|
|
126
|
+
results: list[CliRunResult],
|
|
127
|
+
) -> EvaluationSummary:
|
|
128
|
+
total = 0
|
|
129
|
+
finished = 0
|
|
130
|
+
errors = 0
|
|
131
|
+
max_score = 0.0
|
|
132
|
+
achieved = 0.0
|
|
133
|
+
error_msgs: list[str] = []
|
|
134
|
+
has_error = False
|
|
135
|
+
has_non_error = False
|
|
136
|
+
|
|
137
|
+
for r in results:
|
|
138
|
+
s = r.summary
|
|
139
|
+
if s is None:
|
|
140
|
+
continue
|
|
141
|
+
total += s.total_evaluations
|
|
142
|
+
finished += s.finished_evaluations
|
|
143
|
+
errors += s.error_evaluations
|
|
144
|
+
error_msgs.extend(s.error_messages)
|
|
145
|
+
|
|
146
|
+
if s.status == EvaluationStatus.ERROR:
|
|
147
|
+
has_error = True
|
|
148
|
+
else:
|
|
149
|
+
has_non_error = True
|
|
150
|
+
max_score += s.max_score
|
|
151
|
+
achieved += s.achieved_score
|
|
152
|
+
|
|
153
|
+
if has_error and not has_non_error:
|
|
154
|
+
status = EvaluationStatus.ERROR
|
|
155
|
+
elif total > 0 and finished + errors >= total:
|
|
156
|
+
status = EvaluationStatus.FINISHED
|
|
157
|
+
else:
|
|
158
|
+
status = EvaluationStatus.RUNNING
|
|
159
|
+
|
|
160
|
+
return EvaluationSummary(
|
|
161
|
+
status=status,
|
|
162
|
+
total_evaluations=total,
|
|
163
|
+
finished_evaluations=finished,
|
|
164
|
+
error_evaluations=errors,
|
|
165
|
+
max_score=max_score,
|
|
166
|
+
achieved_score=achieved,
|
|
167
|
+
error_messages=error_msgs,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _make_summary_panel(
|
|
172
|
+
summary: EvaluationSummary,
|
|
173
|
+
title: str,
|
|
174
|
+
width: int | None = None,
|
|
175
|
+
) -> Panel:
|
|
176
|
+
grid = Table.grid(padding=(0, 1))
|
|
177
|
+
grid.add_column(style="key", justify="right")
|
|
178
|
+
grid.add_column()
|
|
179
|
+
|
|
180
|
+
st = summary.status.value.upper()
|
|
181
|
+
grid.add_row("Status", f"[{status_style(summary.status.value)}]{st}[/]")
|
|
182
|
+
|
|
183
|
+
if summary.score is not None:
|
|
184
|
+
pct = summary.score * 100
|
|
185
|
+
style = score_style(summary.score)
|
|
186
|
+
grid.add_row("Score", f"[{style}]{summary.score:.2f} ({pct:.0f}%)[/]")
|
|
187
|
+
|
|
188
|
+
completed = summary.finished_evaluations
|
|
189
|
+
total = summary.total_evaluations
|
|
190
|
+
errors = summary.error_evaluations
|
|
191
|
+
grid.add_row(
|
|
192
|
+
"Completed",
|
|
193
|
+
f"{completed} [dim]/ {total}[/dim]",
|
|
194
|
+
)
|
|
195
|
+
if errors:
|
|
196
|
+
grid.add_row("Errors", f"[red]{errors}[/red]")
|
|
197
|
+
|
|
198
|
+
return Panel(grid, title=f"[bold]{title}[/bold]", width=width)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def log_run_summary(results: list[CliRunResult]) -> None:
|
|
202
|
+
for r in results:
|
|
203
|
+
s = r.summary
|
|
204
|
+
name = r.evaluation.get("name", "?")
|
|
205
|
+
if s is None:
|
|
206
|
+
logger.info("Evaluation: %s | status=no_result", name)
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
score_str = f"{s.score:.2f}" if s.score is not None else "N/A"
|
|
210
|
+
error_str = f" | error={s.error_messages[0]}" if s.error_messages else ""
|
|
211
|
+
logger.info(
|
|
212
|
+
"Evaluation: %s | status=%s | score=%s | completed=%d/%d%s",
|
|
213
|
+
name,
|
|
214
|
+
s.status.value,
|
|
215
|
+
score_str,
|
|
216
|
+
s.finished_evaluations,
|
|
217
|
+
s.total_evaluations,
|
|
218
|
+
error_str,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
overall = _aggregate_summary(results)
|
|
222
|
+
overall_score = f"{overall.score:.2f}" if overall.score is not None else "N/A"
|
|
223
|
+
logger.info(
|
|
224
|
+
"Overall: status=%s | score=%s | completed=%d/%d | errors=%d",
|
|
225
|
+
overall.status.value,
|
|
226
|
+
overall_score,
|
|
227
|
+
overall.finished_evaluations,
|
|
228
|
+
overall.total_evaluations,
|
|
229
|
+
overall.error_evaluations,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def print_results(results: list[CliRunResult]) -> None:
|
|
234
|
+
with_summary = [r for r in results if r.summary is not None]
|
|
235
|
+
console.print()
|
|
236
|
+
|
|
237
|
+
if with_summary:
|
|
238
|
+
cols_per_row = min(len(with_summary), 2)
|
|
239
|
+
col_width = CLI_WIDTH // cols_per_row
|
|
240
|
+
|
|
241
|
+
renderables = [
|
|
242
|
+
_make_summary_panel(
|
|
243
|
+
r.summary,
|
|
244
|
+
r.evaluation.get("name", "?"),
|
|
245
|
+
width=col_width,
|
|
246
|
+
)
|
|
247
|
+
for r in with_summary
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
for row_start in range(0, len(renderables), cols_per_row):
|
|
251
|
+
row = renderables[row_start : row_start + cols_per_row]
|
|
252
|
+
grid = Table.grid(padding=0)
|
|
253
|
+
for _ in row:
|
|
254
|
+
grid.add_column()
|
|
255
|
+
grid.add_row(*row)
|
|
256
|
+
console.print(grid)
|
|
257
|
+
|
|
258
|
+
overall = _aggregate_summary(results)
|
|
259
|
+
console.print(
|
|
260
|
+
_make_summary_panel(overall, "Overall", width=CLI_WIDTH),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def write_output(
|
|
265
|
+
runs: list[CliRunResult],
|
|
266
|
+
config_path: str,
|
|
267
|
+
output_path: str,
|
|
268
|
+
fmt: str = "json",
|
|
269
|
+
) -> None:
|
|
270
|
+
|
|
271
|
+
formatter = get_eval_output_formatter(OutputFormat(fmt))
|
|
272
|
+
content = formatter.format(runs, config_path)
|
|
273
|
+
with open(output_path, "w") as f:
|
|
274
|
+
f.write(content)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subcommands for `switch-trust scan`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import dataclasses
|
|
9
|
+
import datetime
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from switch_trust.cli.console import CLI_WIDTH, console, severity_style
|
|
18
|
+
from switch_trust.cli.file_filter import FileType, RelevantFile, find_relevant_files
|
|
19
|
+
from switch_trust.cli.output_formatters import (
|
|
20
|
+
OutputFormat,
|
|
21
|
+
get_scan_output_formatter,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from switch_trust.scan.schema import ScanReport
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
31
|
+
scan_parser = subparsers.add_parser(
|
|
32
|
+
"scan",
|
|
33
|
+
help="Agent security scanning commands",
|
|
34
|
+
)
|
|
35
|
+
scan_parser.add_argument(
|
|
36
|
+
"path",
|
|
37
|
+
help="Path to a file or folder to scan",
|
|
38
|
+
)
|
|
39
|
+
scan_parser.add_argument(
|
|
40
|
+
"--output",
|
|
41
|
+
"-o",
|
|
42
|
+
help="Output file path (default: scan_<timestamp>.<format>)",
|
|
43
|
+
)
|
|
44
|
+
scan_parser.add_argument(
|
|
45
|
+
"--format",
|
|
46
|
+
"-f",
|
|
47
|
+
choices=["json", "sarif"],
|
|
48
|
+
default="json",
|
|
49
|
+
help="Output format (default: json)",
|
|
50
|
+
)
|
|
51
|
+
scan_parser.add_argument(
|
|
52
|
+
"--log",
|
|
53
|
+
help="Log file path (default: switch_trust_<timestamp>.log)",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_output(report: ScanReport, output_path: str, fmt: str = "json"):
|
|
58
|
+
|
|
59
|
+
formatter = get_scan_output_formatter(OutputFormat(fmt))
|
|
60
|
+
content = formatter.format(report)
|
|
61
|
+
with open(output_path, "w") as f:
|
|
62
|
+
f.write(content)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def print_report(report: ScanReport) -> None:
|
|
66
|
+
console.print()
|
|
67
|
+
|
|
68
|
+
# ── Summary panel ────────────────────────────────────────────
|
|
69
|
+
grid = Table.grid(padding=(0, 2))
|
|
70
|
+
grid.add_column(style="key", justify="right")
|
|
71
|
+
grid.add_column()
|
|
72
|
+
|
|
73
|
+
grid.add_row("Framework", report.framework_detected or "unknown")
|
|
74
|
+
|
|
75
|
+
meta = report.scan_metadata or {}
|
|
76
|
+
py_files = meta.get("python_files", 0)
|
|
77
|
+
total_files = meta.get("total_files_scanned", 0)
|
|
78
|
+
grid.add_row("Files", f"{py_files} Python, {total_files} total scanned")
|
|
79
|
+
|
|
80
|
+
total = len(report.findings)
|
|
81
|
+
pre_triage = (
|
|
82
|
+
len(report.pre_triage_findings) if report.pre_triage_findings else total
|
|
83
|
+
)
|
|
84
|
+
if pre_triage != total:
|
|
85
|
+
grid.add_row("Findings", f"{total} [dim]({pre_triage} pre-triage)[/dim]")
|
|
86
|
+
else:
|
|
87
|
+
grid.add_row("Findings", str(total))
|
|
88
|
+
|
|
89
|
+
tools = meta.get("tools_used", [])
|
|
90
|
+
if tools:
|
|
91
|
+
grid.add_row("Tools", ", ".join(tools))
|
|
92
|
+
|
|
93
|
+
skipped = meta.get("tools_skipped") or []
|
|
94
|
+
if skipped:
|
|
95
|
+
grid.add_row(
|
|
96
|
+
"Skipped",
|
|
97
|
+
"[bold yellow]"
|
|
98
|
+
+ ", ".join(s.get("tool", "unknown") for s in skipped)
|
|
99
|
+
+ "[/bold yellow]",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if meta.get("ai_summary"):
|
|
103
|
+
grid.add_row("AI summary", meta["ai_summary"])
|
|
104
|
+
|
|
105
|
+
console.print(Panel(grid, title="[bold]Scan Summary[/bold]", width=CLI_WIDTH))
|
|
106
|
+
|
|
107
|
+
# ── Partial-scan warning ─────────────────────────────────────
|
|
108
|
+
# A skipped tool means whole rule families never ran, so the findings
|
|
109
|
+
# below are incomplete. Say so loudly — the log file is not enough.
|
|
110
|
+
if skipped:
|
|
111
|
+
warn = Table.grid(padding=(0, 1))
|
|
112
|
+
warn.add_column()
|
|
113
|
+
plural = "s were" if len(skipped) > 1 else " was"
|
|
114
|
+
warn.add_row(
|
|
115
|
+
f"[bold yellow]This scan is incomplete — "
|
|
116
|
+
f"{len(skipped)} analysis tool{plural} skipped.[/bold yellow]"
|
|
117
|
+
)
|
|
118
|
+
for s in skipped:
|
|
119
|
+
warn.add_row("")
|
|
120
|
+
warn.add_row(
|
|
121
|
+
f"[bold]{s.get('tool', 'unknown')}[/bold] — {s.get('reason', '')}"
|
|
122
|
+
)
|
|
123
|
+
console.print(
|
|
124
|
+
Panel(
|
|
125
|
+
warn,
|
|
126
|
+
title="[bold yellow]Warning[/bold yellow]",
|
|
127
|
+
border_style="yellow",
|
|
128
|
+
width=CLI_WIDTH,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# ── Findings table ───────────────────────────────────────────
|
|
133
|
+
if not report.findings:
|
|
134
|
+
console.print("[dim]No findings.[/dim]")
|
|
135
|
+
console.print()
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
table = Table(
|
|
139
|
+
title="Findings",
|
|
140
|
+
show_lines=True,
|
|
141
|
+
width=CLI_WIDTH,
|
|
142
|
+
)
|
|
143
|
+
table.add_column("Sev", max_width=10, ratio=1)
|
|
144
|
+
table.add_column("CVSS", max_width=6, ratio=1)
|
|
145
|
+
table.add_column("Title", style="bold", ratio=5)
|
|
146
|
+
table.add_column("File", ratio=3)
|
|
147
|
+
table.add_column("Source", style="dim", ratio=1)
|
|
148
|
+
|
|
149
|
+
for f in report.findings:
|
|
150
|
+
sev = (
|
|
151
|
+
f.ai_spm_severity
|
|
152
|
+
if isinstance(f, dataclasses.Field) or hasattr(f, "ai_spm_severity")
|
|
153
|
+
else f.get("ai_spm_severity", "")
|
|
154
|
+
)
|
|
155
|
+
cvss = ""
|
|
156
|
+
title = ""
|
|
157
|
+
source = ""
|
|
158
|
+
file_name = ""
|
|
159
|
+
|
|
160
|
+
if isinstance(f, dict):
|
|
161
|
+
sev = f.get("ai_spm_severity", "")
|
|
162
|
+
cvss_scores = f.get("cvss_scores", {})
|
|
163
|
+
cvss = str(cvss_scores.get("base_score", "")) if cvss_scores else ""
|
|
164
|
+
title = f.get("title", "") or f.get("description", "")[:80]
|
|
165
|
+
source = f.get("source", "")
|
|
166
|
+
components = f.get("affected_components", [])
|
|
167
|
+
if components:
|
|
168
|
+
file_name = os.path.basename(components[0].get("name", ""))
|
|
169
|
+
else:
|
|
170
|
+
sev = f.ai_spm_severity
|
|
171
|
+
cvss = str(f.cvss_scores.base_score) if f.cvss_scores else ""
|
|
172
|
+
title = f.title or f.description[:80]
|
|
173
|
+
source = f.source
|
|
174
|
+
if f.affected_components:
|
|
175
|
+
file_name = os.path.basename(f.affected_components[0].name)
|
|
176
|
+
|
|
177
|
+
sev_display = f"[{severity_style(sev)}]{sev}[/]"
|
|
178
|
+
source_label = (
|
|
179
|
+
source.replace("_", " ")
|
|
180
|
+
.replace("ai reasoning", "AI")
|
|
181
|
+
.replace("static ", "")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
table.add_row(sev_display, cvss, title, file_name, source_label)
|
|
185
|
+
|
|
186
|
+
console.print(table)
|
|
187
|
+
|
|
188
|
+
# ── Category summary ─────────────────────────────────────────
|
|
189
|
+
non_zero = {
|
|
190
|
+
k: v
|
|
191
|
+
for k, v in (report.category_summary or {}).items()
|
|
192
|
+
if (v.get("count", 0) if isinstance(v, dict) else getattr(v, "count", 0)) > 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if non_zero:
|
|
196
|
+
cat_table = Table(
|
|
197
|
+
title="Category Summary",
|
|
198
|
+
show_lines=False,
|
|
199
|
+
width=CLI_WIDTH,
|
|
200
|
+
)
|
|
201
|
+
cat_table.add_column("Category", style="bold", ratio=5)
|
|
202
|
+
cat_table.add_column("Total", justify="right", ratio=1)
|
|
203
|
+
cat_table.add_column("Crit", justify="right", ratio=1)
|
|
204
|
+
cat_table.add_column("High", justify="right", ratio=1)
|
|
205
|
+
cat_table.add_column("Med", justify="right", ratio=1)
|
|
206
|
+
cat_table.add_column("Low", justify="right", ratio=1)
|
|
207
|
+
|
|
208
|
+
for v in non_zero.values():
|
|
209
|
+
if isinstance(v, dict):
|
|
210
|
+
asi_title = v.get("asi_title", "")
|
|
211
|
+
count = v.get("count", 0)
|
|
212
|
+
crit = v.get("critical", 0)
|
|
213
|
+
high = v.get("high", 0)
|
|
214
|
+
med = v.get("medium", 0)
|
|
215
|
+
low = v.get("low", 0)
|
|
216
|
+
else:
|
|
217
|
+
asi_title = getattr(v, "asi_title", "")
|
|
218
|
+
count = getattr(v, "count", 0)
|
|
219
|
+
crit = getattr(v, "critical", 0)
|
|
220
|
+
high = getattr(v, "high", 0)
|
|
221
|
+
med = getattr(v, "medium", 0)
|
|
222
|
+
low = getattr(v, "low", 0)
|
|
223
|
+
|
|
224
|
+
cat_table.add_row(
|
|
225
|
+
asi_title,
|
|
226
|
+
str(count),
|
|
227
|
+
f"[{severity_style('critical')}]{crit}[/]" if crit else str(crit),
|
|
228
|
+
f"[{severity_style('high')}]{high}[/]" if high else str(high),
|
|
229
|
+
f"[{severity_style('medium')}]{med}[/]" if med else str(med),
|
|
230
|
+
str(low),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
console.print(cat_table)
|
|
234
|
+
|
|
235
|
+
console.print()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def handle_scan(args: argparse.Namespace) -> str:
|
|
239
|
+
# Imported lazily: pulling in the agent scanner loads the Google ADK stack,
|
|
240
|
+
# which is expensive and only needed when a scan actually runs.
|
|
241
|
+
from switch_trust.scan.agent_scanner import ( # noqa: PLC0415 - deferred import cost
|
|
242
|
+
run_core,
|
|
243
|
+
)
|
|
244
|
+
from switch_trust.schema import RepoFile
|
|
245
|
+
|
|
246
|
+
path: str = os.path.abspath(os.path.expanduser(args.path))
|
|
247
|
+
|
|
248
|
+
if not os.path.exists(path):
|
|
249
|
+
# Raise instead of sys.exit so the error flows through main's unified
|
|
250
|
+
# exception handler (record_error -> telemetry, error panel, logging).
|
|
251
|
+
# sys.exit raises SystemExit, a BaseException that bypasses that path.
|
|
252
|
+
raise FileNotFoundError(f"Path does not exist: {path}")
|
|
253
|
+
|
|
254
|
+
files: list[RelevantFile] = find_relevant_files(path)
|
|
255
|
+
|
|
256
|
+
if not files:
|
|
257
|
+
# Raise (rather than sys.exit) so the error reaches main's unified
|
|
258
|
+
# handler and is recorded to telemetry. See the path check above.
|
|
259
|
+
raise ValueError("No scannable targets found")
|
|
260
|
+
|
|
261
|
+
model = os.environ.get("GENERATOR_MODEL")
|
|
262
|
+
|
|
263
|
+
logger.info(
|
|
264
|
+
"Starting agent scan on %s files with model '%s'", len(files), model or "none"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
python_files: list[RepoFile] = []
|
|
268
|
+
requirements_files: list[RepoFile] = []
|
|
269
|
+
for rf in files:
|
|
270
|
+
with open(rf.path, encoding="utf-8", errors="replace") as fh:
|
|
271
|
+
content = fh.read()
|
|
272
|
+
repo_file = RepoFile(path=rf.path, content=content, size=len(content))
|
|
273
|
+
if rf.type == FileType.REQUIREMENTS:
|
|
274
|
+
requirements_files.append(repo_file)
|
|
275
|
+
elif rf.type == FileType.PYTHON:
|
|
276
|
+
python_files.append(repo_file)
|
|
277
|
+
|
|
278
|
+
logger.info(
|
|
279
|
+
"Files categorized: %d Python, %d requirements",
|
|
280
|
+
len(python_files),
|
|
281
|
+
len(requirements_files),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
fw_counts: dict[str, int] = {}
|
|
285
|
+
for rf in files:
|
|
286
|
+
if rf.framework:
|
|
287
|
+
fw_counts[rf.framework] = fw_counts.get(rf.framework, 0) + 1
|
|
288
|
+
frameworks = list(fw_counts)
|
|
289
|
+
primary = max(fw_counts, key=fw_counts.get) if fw_counts else "unknown"
|
|
290
|
+
|
|
291
|
+
logger.info("Frameworks detected: %s (primary: %s)", ", ".join(frameworks), primary)
|
|
292
|
+
|
|
293
|
+
# Let a scan failure propagate to main's unified exception handler so it is
|
|
294
|
+
# recorded to telemetry; previously it was swallowed into sys.exit(1).
|
|
295
|
+
report = run_core(
|
|
296
|
+
python_files,
|
|
297
|
+
requirements_files,
|
|
298
|
+
skip_triage=False,
|
|
299
|
+
agentic=True,
|
|
300
|
+
model_string=model,
|
|
301
|
+
repo_name=os.path.basename(path),
|
|
302
|
+
primary_framework=primary,
|
|
303
|
+
frameworks_detected=frameworks,
|
|
304
|
+
total_files_scanned=len(files),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
print_report(report)
|
|
308
|
+
fmt = getattr(args, "format", "json") or "json"
|
|
309
|
+
ext = fmt if fmt != "json" else "json"
|
|
310
|
+
output_path = args.output or (
|
|
311
|
+
f"scan_{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}.{ext}"
|
|
312
|
+
)
|
|
313
|
+
write_output(report, output_path, fmt=fmt)
|
|
314
|
+
return output_path
|