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