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,681 @@
|
|
|
1
|
+
"""
|
|
2
|
+
static_scanner.py — Layer 2b: Open-Source Static Analysis
|
|
3
|
+
Runs Bandit, detect-secrets, pip-audit, and custom OpenGrep rules
|
|
4
|
+
against the fetched repository files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from ..schema import RepoFile
|
|
23
|
+
from .opengrep_resolver import find_opengrep_binary
|
|
24
|
+
from .schema import PackageInfo
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Use sys.executable so the current Python interpreter finds installed
|
|
29
|
+
# packages rather than looking for CLI tools on the system PATH.
|
|
30
|
+
_PY = sys.executable
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class StaticFinding:
|
|
35
|
+
tool: str # bandit | opengrep | detect_secrets | pip_audit | internal
|
|
36
|
+
rule_id: str
|
|
37
|
+
severity: str
|
|
38
|
+
message: str
|
|
39
|
+
filepath: str
|
|
40
|
+
line: int = 0
|
|
41
|
+
evidence: str = ""
|
|
42
|
+
cwe: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SkippedTool:
|
|
47
|
+
"""A tool that could not run, and why."""
|
|
48
|
+
|
|
49
|
+
tool: str
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class StaticScanResult:
|
|
55
|
+
"""
|
|
56
|
+
Findings plus a record of which tools actually contributed them.
|
|
57
|
+
|
|
58
|
+
A skipped tool means the scan is *partial* — callers are expected to
|
|
59
|
+
surface this rather than report a clean result.
|
|
60
|
+
|
|
61
|
+
Callers writing `tools_skipped` into `ScanReport.scan_metadata` must
|
|
62
|
+
convert with `dataclasses.asdict` first: the SARIF formatter passes
|
|
63
|
+
`scan_metadata` straight to `json.dumps` without an `asdict` pass, so a
|
|
64
|
+
dataclass instance left in there raises `TypeError` at output time.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
findings: list[StaticFinding]
|
|
68
|
+
tools_used: list[str]
|
|
69
|
+
tools_skipped: list[SkippedTool]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_OPENGREP_RULES_PATH = os.path.join(
|
|
73
|
+
os.path.dirname(os.path.abspath(__file__)), "config", "agent_opengrep_rules.yaml"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_opengrep_rules() -> str:
|
|
78
|
+
with open(_OPENGREP_RULES_PATH, encoding="utf-8") as f:
|
|
79
|
+
return f.read()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _module_available(module: str) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Whether a Python-packaged tool is importable.
|
|
85
|
+
|
|
86
|
+
Bandit and pip-audit are dispatched as `sys.executable -m <module>`, so
|
|
87
|
+
an absent package is not an exception — the subprocess just exits
|
|
88
|
+
non-zero with an empty stdout, which is indistinguishable from a clean
|
|
89
|
+
scan. `find_spec` is the `shutil.which` of that dispatch: it answers for
|
|
90
|
+
the very interpreter the subprocess will run.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
return importlib.util.find_spec(module) is not None
|
|
94
|
+
except Exception as e: # a broken package must not break the scan
|
|
95
|
+
logger.warning("Could not resolve the %s module: %s", module, e)
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_BANDIT_SKIP_REASON = (
|
|
100
|
+
"Bandit is not installed. Skipped the generic Python security checks; "
|
|
101
|
+
"scan coverage is incomplete. Install with `pip install bandit`."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_DETECT_SECRETS_SKIP_REASON = (
|
|
105
|
+
"detect-secrets is not installed. Skipped the hardcoded-credential scan "
|
|
106
|
+
"; scan coverage is incomplete. Install with `pip install detect-secrets`."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
_PIP_AUDIT_SKIP_REASON = (
|
|
110
|
+
"pip-audit is not installed. Skipped dependencies analysis; scan coverage "
|
|
111
|
+
"is incomplete. Install with `pip install pip-audit`."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _opengrep_skip_reason() -> str:
|
|
116
|
+
"""Built at call time so the rule count comes from the rules file."""
|
|
117
|
+
try:
|
|
118
|
+
parsed = yaml.safe_load(_load_opengrep_rules()) or {}
|
|
119
|
+
count = len(parsed.get("rules") or [])
|
|
120
|
+
except Exception as e: # a malformed rules file must not break the scan
|
|
121
|
+
logger.warning("Could not count OpenGrep agent rules: %s", e)
|
|
122
|
+
count = 0
|
|
123
|
+
rules = (
|
|
124
|
+
f"{count} agent-specific rule{'' if count == 1 else 's'}"
|
|
125
|
+
if count
|
|
126
|
+
else "the agent-specific rules"
|
|
127
|
+
)
|
|
128
|
+
return (
|
|
129
|
+
f"OpenGrep binary not found. Skipped {rules}; scan coverage is "
|
|
130
|
+
"incomplete. Install from https://github.com/opengrep/opengrep/releases."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Severity mapping ─────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
BANDIT_SEVERITY_MAP = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low"}
|
|
137
|
+
|
|
138
|
+
OPENGREP_SEVERITY_MAP = {"ERROR": "high", "WARNING": "medium", "INFO": "low"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Static analysis runners ──────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_bandit(files_dir: str) -> list[StaticFinding]:
|
|
145
|
+
"""Run Bandit on Python files and parse findings."""
|
|
146
|
+
findings = []
|
|
147
|
+
if not _module_available("bandit"):
|
|
148
|
+
logger.warning("Skipping Bandit scan. %s", _BANDIT_SKIP_REASON)
|
|
149
|
+
return findings
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
[
|
|
153
|
+
_PY,
|
|
154
|
+
"-m",
|
|
155
|
+
"bandit",
|
|
156
|
+
"-r",
|
|
157
|
+
files_dir,
|
|
158
|
+
"-f",
|
|
159
|
+
"json",
|
|
160
|
+
"-q",
|
|
161
|
+
"--severity-level",
|
|
162
|
+
"low",
|
|
163
|
+
],
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
timeout=120,
|
|
167
|
+
)
|
|
168
|
+
if not result.stdout.strip():
|
|
169
|
+
return findings
|
|
170
|
+
|
|
171
|
+
data = json.loads(result.stdout)
|
|
172
|
+
for issue in data.get("results", []):
|
|
173
|
+
rel_path = issue.get("filename", "").replace(files_dir, "").lstrip("/\\")
|
|
174
|
+
findings.append(
|
|
175
|
+
StaticFinding(
|
|
176
|
+
tool="bandit",
|
|
177
|
+
rule_id=issue.get("test_id", ""),
|
|
178
|
+
severity=BANDIT_SEVERITY_MAP.get(
|
|
179
|
+
issue.get("issue_severity", "LOW"), "low"
|
|
180
|
+
),
|
|
181
|
+
message=issue.get("issue_text", ""),
|
|
182
|
+
filepath=rel_path,
|
|
183
|
+
line=issue.get("line_number", 0),
|
|
184
|
+
evidence=issue.get("code", "")[:200],
|
|
185
|
+
cwe=issue.get("issue_cwe", {}).get("id", ""),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error("Bandit error: %s", e)
|
|
190
|
+
|
|
191
|
+
return findings
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run_opengrep(files_dir: str, rules_file: str) -> list[StaticFinding]:
|
|
195
|
+
"""Run OpenGrep with custom agent rules and parse findings."""
|
|
196
|
+
findings = []
|
|
197
|
+
opengrep_bin = find_opengrep_binary()
|
|
198
|
+
if not opengrep_bin:
|
|
199
|
+
logger.warning("Skipping OpenGrep pattern scan. %s", _opengrep_skip_reason())
|
|
200
|
+
return findings
|
|
201
|
+
try:
|
|
202
|
+
result = subprocess.run(
|
|
203
|
+
[
|
|
204
|
+
opengrep_bin,
|
|
205
|
+
"scan",
|
|
206
|
+
"--config",
|
|
207
|
+
rules_file,
|
|
208
|
+
files_dir,
|
|
209
|
+
"--json",
|
|
210
|
+
"--quiet",
|
|
211
|
+
"--no-git-ignore",
|
|
212
|
+
],
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
timeout=180,
|
|
216
|
+
)
|
|
217
|
+
# OpenGrep writes JSON to stdout on success (exit 0) and on
|
|
218
|
+
# findings-found (exit 1). On rule errors (exit 7) it may
|
|
219
|
+
# write to stderr instead. Try both.
|
|
220
|
+
raw_json = result.stdout.strip() or result.stderr.strip()
|
|
221
|
+
if not raw_json:
|
|
222
|
+
return findings
|
|
223
|
+
|
|
224
|
+
data = json.loads(raw_json)
|
|
225
|
+
for err in data.get("errors", []):
|
|
226
|
+
logger.warning("OpenGrep rule error: %s", err)
|
|
227
|
+
for issue in data.get("results", []):
|
|
228
|
+
rel_path = issue.get("path", "").replace(files_dir, "").lstrip("/\\")
|
|
229
|
+
findings.append(
|
|
230
|
+
StaticFinding(
|
|
231
|
+
tool="opengrep",
|
|
232
|
+
rule_id=issue.get("check_id", ""),
|
|
233
|
+
severity=OPENGREP_SEVERITY_MAP.get(
|
|
234
|
+
issue.get("extra", {}).get("severity", "INFO"), "low"
|
|
235
|
+
),
|
|
236
|
+
message=issue.get("extra", {}).get("message", ""),
|
|
237
|
+
filepath=rel_path,
|
|
238
|
+
line=issue.get("start", {}).get("line", 0),
|
|
239
|
+
evidence=issue.get("extra", {}).get("lines", "")[:200],
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error("OpenGrep error: %s", e)
|
|
244
|
+
|
|
245
|
+
return findings
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run_detect_secrets(files_dir: str) -> list[StaticFinding]:
|
|
249
|
+
"""Run detect-secrets to find hardcoded credentials."""
|
|
250
|
+
findings = []
|
|
251
|
+
if not _module_available("detect_secrets"):
|
|
252
|
+
logger.warning("Skipping detect-secrets scan. %s", _DETECT_SECRETS_SKIP_REASON)
|
|
253
|
+
return findings
|
|
254
|
+
try:
|
|
255
|
+
result = subprocess.run(
|
|
256
|
+
[_PY, "-m", "detect_secrets", "scan", files_dir],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
timeout=120,
|
|
260
|
+
)
|
|
261
|
+
if not result.stdout.strip():
|
|
262
|
+
return findings
|
|
263
|
+
|
|
264
|
+
data = json.loads(result.stdout)
|
|
265
|
+
for filepath, secrets in data.get("results", {}).items():
|
|
266
|
+
rel_path = filepath.replace(files_dir, "").lstrip("/\\")
|
|
267
|
+
for secret in secrets:
|
|
268
|
+
findings.append(
|
|
269
|
+
StaticFinding(
|
|
270
|
+
tool="detect_secrets",
|
|
271
|
+
rule_id=secret.get("type", "secret"),
|
|
272
|
+
severity="high",
|
|
273
|
+
message=f"Potential secret detected: {secret.get('type', 'unknown')}",
|
|
274
|
+
filepath=rel_path,
|
|
275
|
+
line=secret.get("line_number", 0),
|
|
276
|
+
evidence=f"[redacted — line {secret.get('line_number', '?')}]",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.error("detect-secrets error: %s", e)
|
|
281
|
+
|
|
282
|
+
return findings
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_pinned_packages(requirements_content: str) -> list[PackageInfo]:
|
|
286
|
+
"""
|
|
287
|
+
Parse a requirements.txt and return a list of {name, version} dicts
|
|
288
|
+
for all pinned (==) packages. Skips comments, blank lines, and unpinned entries.
|
|
289
|
+
"""
|
|
290
|
+
packages = []
|
|
291
|
+
for line in requirements_content.splitlines():
|
|
292
|
+
line = line.strip()
|
|
293
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
294
|
+
continue
|
|
295
|
+
if "==" in line:
|
|
296
|
+
# Strip inline comments
|
|
297
|
+
line = line.split("#")[0].strip()
|
|
298
|
+
parts = line.split("==")
|
|
299
|
+
if len(parts) == 2:
|
|
300
|
+
name = parts[0].strip()
|
|
301
|
+
version = parts[1].strip().split()[0] # strip any trailing extras
|
|
302
|
+
if name and version:
|
|
303
|
+
packages.append({"name": name, "version": version})
|
|
304
|
+
return packages
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _query_osv_api(packages: list[PackageInfo], filepath: str) -> list[StaticFinding]:
|
|
308
|
+
"""
|
|
309
|
+
Query the OSV.dev batch API directly for a list of {name, version} packages.
|
|
310
|
+
No virtual env, no package installation, no Python version constraints.
|
|
311
|
+
Docs: https://osv.dev/docs/#tag/api/operation/OSV_QueryAffectedBatch
|
|
312
|
+
"""
|
|
313
|
+
findings = []
|
|
314
|
+
if not packages:
|
|
315
|
+
return findings
|
|
316
|
+
|
|
317
|
+
OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
|
|
318
|
+
payload = {
|
|
319
|
+
"queries": [
|
|
320
|
+
{
|
|
321
|
+
"version": pkg["version"],
|
|
322
|
+
"package": {"name": pkg["name"], "ecosystem": "PyPI"},
|
|
323
|
+
}
|
|
324
|
+
for pkg in packages
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
data = json.dumps(payload).encode("utf-8")
|
|
330
|
+
req = urllib.request.Request(
|
|
331
|
+
OSV_BATCH_URL,
|
|
332
|
+
data=data,
|
|
333
|
+
headers={"Content-Type": "application/json"},
|
|
334
|
+
method="POST",
|
|
335
|
+
)
|
|
336
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
337
|
+
response = json.loads(resp.read().decode("utf-8"))
|
|
338
|
+
except urllib.error.URLError as e:
|
|
339
|
+
logger.error("OSV API request failed: %s", e)
|
|
340
|
+
return findings
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.error("OSV API error: %s", e)
|
|
343
|
+
return findings
|
|
344
|
+
|
|
345
|
+
results = response.get("results", [])
|
|
346
|
+
vuln_count = 0
|
|
347
|
+
for i, result in enumerate(results):
|
|
348
|
+
if i >= len(packages):
|
|
349
|
+
break
|
|
350
|
+
pkg = packages[i]
|
|
351
|
+
vulns = result.get("vulns", [])
|
|
352
|
+
for vuln in vulns:
|
|
353
|
+
vuln_count += 1
|
|
354
|
+
# Derive severity from CVSS score if available, else default to high
|
|
355
|
+
severity = "high"
|
|
356
|
+
cvss_score = None
|
|
357
|
+
for severity_entry in vuln.get("severity", []):
|
|
358
|
+
if severity_entry.get("type") == "CVSS_V3":
|
|
359
|
+
raw_score = severity_entry.get("score", 0)
|
|
360
|
+
# OSV may return a CVSS vector string instead of a numeric score
|
|
361
|
+
if isinstance(raw_score, str) and raw_score.startswith("CVSS:"):
|
|
362
|
+
break
|
|
363
|
+
try:
|
|
364
|
+
score = float(raw_score)
|
|
365
|
+
cvss_score = score # noqa: F841
|
|
366
|
+
if score >= 9.0:
|
|
367
|
+
severity = "critical"
|
|
368
|
+
elif score >= 7.0:
|
|
369
|
+
severity = "high"
|
|
370
|
+
elif score >= 4.0:
|
|
371
|
+
severity = "medium"
|
|
372
|
+
else:
|
|
373
|
+
severity = "low"
|
|
374
|
+
except (ValueError, TypeError):
|
|
375
|
+
pass
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
vuln_id = vuln.get("id", "")
|
|
379
|
+
aliases = vuln.get("aliases", [])
|
|
380
|
+
cve_id = next((a for a in aliases if a.startswith("CVE-")), vuln_id)
|
|
381
|
+
summary = vuln.get("summary", "") or vuln.get("details", "")[:200]
|
|
382
|
+
|
|
383
|
+
findings.append(
|
|
384
|
+
StaticFinding(
|
|
385
|
+
tool="pip_audit",
|
|
386
|
+
rule_id=cve_id or vuln_id,
|
|
387
|
+
severity=severity,
|
|
388
|
+
message=(f"{pkg['name']}=={pkg['version']}: {summary[:200]}"),
|
|
389
|
+
filepath=filepath,
|
|
390
|
+
line=0,
|
|
391
|
+
evidence=(
|
|
392
|
+
f"Package: {pkg['name']} {pkg['version']} | "
|
|
393
|
+
f"ID: {vuln_id} | "
|
|
394
|
+
f"Aliases: {', '.join(aliases[:3]) if aliases else 'none'}"
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
logger.info(
|
|
400
|
+
"OSV: %d CVE(s) found across %d of %d packages",
|
|
401
|
+
vuln_count,
|
|
402
|
+
len([p for p in results if p.get("vulns")]),
|
|
403
|
+
len(packages),
|
|
404
|
+
)
|
|
405
|
+
return findings
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def run_pip_audit(requirements_files: list) -> list[StaticFinding]:
|
|
409
|
+
"""
|
|
410
|
+
Scan requirements files for known CVEs.
|
|
411
|
+
Strategy:
|
|
412
|
+
1. Try pip-audit (fast, uses local pip resolver)
|
|
413
|
+
2. If pip-audit is not installed, or fails due to Python version
|
|
414
|
+
conflicts or resolver errors, fall back to querying the OSV.dev
|
|
415
|
+
batch API directly (no virtual env, no installation, no Python
|
|
416
|
+
version constraints)
|
|
417
|
+
|
|
418
|
+
An absent pip-audit is a *degraded* scan, not a failed one — the caller
|
|
419
|
+
is expected to report it via `tools_skipped` (see `run_static_scan`),
|
|
420
|
+
because the fallback only covers `==`-pinned packages.
|
|
421
|
+
"""
|
|
422
|
+
findings = []
|
|
423
|
+
pip_audit_available = _module_available("pip_audit")
|
|
424
|
+
if not pip_audit_available:
|
|
425
|
+
logger.warning("Skipping pip-audit. %s", _PIP_AUDIT_SKIP_REASON)
|
|
426
|
+
|
|
427
|
+
for req_file_path in requirements_files:
|
|
428
|
+
if not req_file_path.endswith(".txt"):
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
# ── Attempt 1: pip-audit ────────────────────────────────────────────────
|
|
432
|
+
pip_audit_succeeded = False
|
|
433
|
+
if pip_audit_available:
|
|
434
|
+
try:
|
|
435
|
+
result = subprocess.run(
|
|
436
|
+
[
|
|
437
|
+
_PY,
|
|
438
|
+
"-m",
|
|
439
|
+
"pip_audit",
|
|
440
|
+
"-r",
|
|
441
|
+
req_file_path,
|
|
442
|
+
"--format",
|
|
443
|
+
"json",
|
|
444
|
+
"-s",
|
|
445
|
+
"osv",
|
|
446
|
+
"--progress-spinner",
|
|
447
|
+
"off",
|
|
448
|
+
],
|
|
449
|
+
capture_output=True,
|
|
450
|
+
text=True,
|
|
451
|
+
timeout=120,
|
|
452
|
+
)
|
|
453
|
+
# pip-audit exits 1 when vulnerabilities are found — that is
|
|
454
|
+
# normal. Only treat return code > 1 as a hard error
|
|
455
|
+
# warranting fallback.
|
|
456
|
+
if result.returncode <= 1:
|
|
457
|
+
stdout = result.stdout.strip()
|
|
458
|
+
if stdout:
|
|
459
|
+
data = json.loads(stdout)
|
|
460
|
+
dependencies = (
|
|
461
|
+
data
|
|
462
|
+
if isinstance(data, list)
|
|
463
|
+
else data.get("dependencies", [])
|
|
464
|
+
)
|
|
465
|
+
vuln_count = 0
|
|
466
|
+
for dep in dependencies:
|
|
467
|
+
for vuln in dep.get("vulns", []):
|
|
468
|
+
vuln_count += 1
|
|
469
|
+
findings.append(
|
|
470
|
+
StaticFinding(
|
|
471
|
+
tool="pip_audit",
|
|
472
|
+
rule_id=vuln.get("id", ""),
|
|
473
|
+
severity="high",
|
|
474
|
+
message=(
|
|
475
|
+
f"{dep.get('name')}=={dep.get('version')}: "
|
|
476
|
+
f"{vuln.get('description', '')[:200]}"
|
|
477
|
+
),
|
|
478
|
+
filepath=req_file_path,
|
|
479
|
+
line=0,
|
|
480
|
+
evidence=(
|
|
481
|
+
f"Package: {dep.get('name')} {dep.get('version')} | "
|
|
482
|
+
f"CVE/ID: {vuln.get('id', 'N/A')} | "
|
|
483
|
+
f"Fix: {vuln.get('fix_versions', [])}"
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
logger.info(
|
|
488
|
+
"pip-audit: %d CVE(s) found in %s",
|
|
489
|
+
vuln_count,
|
|
490
|
+
req_file_path.split("/")[-1],
|
|
491
|
+
)
|
|
492
|
+
pip_audit_succeeded = True
|
|
493
|
+
else:
|
|
494
|
+
# Empty stdout — fall through to OSV fallback
|
|
495
|
+
stderr_preview = result.stderr.strip()[:200]
|
|
496
|
+
logger.warning(
|
|
497
|
+
"pip-audit: empty output — falling back to OSV API. Reason: %s",
|
|
498
|
+
stderr_preview,
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
stderr_preview = result.stderr.strip()[:200]
|
|
502
|
+
logger.warning(
|
|
503
|
+
"pip-audit exited %d — falling back to OSV API. Reason: %s",
|
|
504
|
+
result.returncode,
|
|
505
|
+
stderr_preview,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
except json.JSONDecodeError as e:
|
|
509
|
+
logger.warning(
|
|
510
|
+
"pip-audit JSON parse error — falling back to OSV API: %s", e
|
|
511
|
+
)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logger.warning("pip-audit error — falling back to OSV API: %s", e)
|
|
514
|
+
|
|
515
|
+
# ── Attempt 2: OSV.dev direct API fallback ───────────────────────────────
|
|
516
|
+
if not pip_audit_succeeded:
|
|
517
|
+
try:
|
|
518
|
+
content = open(req_file_path, encoding="utf-8").read()
|
|
519
|
+
packages = _parse_pinned_packages(content)
|
|
520
|
+
if packages:
|
|
521
|
+
logger.info(
|
|
522
|
+
"OSV fallback: querying %d pinned packages from %s...",
|
|
523
|
+
len(packages),
|
|
524
|
+
req_file_path.split("/")[-1],
|
|
525
|
+
)
|
|
526
|
+
findings.extend(_query_osv_api(packages, req_file_path))
|
|
527
|
+
else:
|
|
528
|
+
logger.info(
|
|
529
|
+
"OSV fallback: no pinned packages found in %s",
|
|
530
|
+
req_file_path.split("/")[-1],
|
|
531
|
+
)
|
|
532
|
+
except Exception as e:
|
|
533
|
+
logger.error("OSV fallback error: %s", e)
|
|
534
|
+
|
|
535
|
+
return findings
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def check_unpinned_dependencies(
|
|
539
|
+
requirements_content: str, filepath: str
|
|
540
|
+
) -> list[StaticFinding]:
|
|
541
|
+
"""Check for unpinned AI framework dependencies."""
|
|
542
|
+
findings = []
|
|
543
|
+
ai_packages = [
|
|
544
|
+
"crewai",
|
|
545
|
+
"autogen",
|
|
546
|
+
"pyautogen",
|
|
547
|
+
"langchain",
|
|
548
|
+
"openai",
|
|
549
|
+
"anthropic",
|
|
550
|
+
"langgraph",
|
|
551
|
+
"ag2",
|
|
552
|
+
"autogen-agentchat",
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
for line in requirements_content.splitlines():
|
|
556
|
+
line = line.strip()
|
|
557
|
+
if not line or line.startswith("#"):
|
|
558
|
+
continue
|
|
559
|
+
pkg_name = re.split(r"[>=<!~\s]", line)[0].lower()
|
|
560
|
+
if pkg_name in ai_packages:
|
|
561
|
+
# Check if pinned (==) or unpinned (>=, ~=, no version)
|
|
562
|
+
if "==" not in line:
|
|
563
|
+
findings.append(
|
|
564
|
+
StaticFinding(
|
|
565
|
+
tool="internal",
|
|
566
|
+
rule_id="unpinned-ai-dependency",
|
|
567
|
+
severity="medium",
|
|
568
|
+
message=f"Unpinned AI framework dependency: '{line}' — supply chain risk", # noqa: B950
|
|
569
|
+
filepath=filepath,
|
|
570
|
+
line=0,
|
|
571
|
+
evidence=line,
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return findings
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ── Main static scanner entry point ─────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def run_static_scan(
|
|
582
|
+
python_files: list[RepoFile], requirements_files: list[RepoFile], tmp_dir: str
|
|
583
|
+
) -> StaticScanResult:
|
|
584
|
+
"""
|
|
585
|
+
Write files to temp dir and run all static analysis tools.
|
|
586
|
+
|
|
587
|
+
Returns the combined findings alongside the tools that produced them and
|
|
588
|
+
the tools that could not run, so callers can distinguish a clean scan from
|
|
589
|
+
a partial one.
|
|
590
|
+
"""
|
|
591
|
+
all_findings = []
|
|
592
|
+
tools_used: list[str] = []
|
|
593
|
+
tools_skipped: list[SkippedTool] = []
|
|
594
|
+
|
|
595
|
+
# Write Python files to disk preserving their original relative
|
|
596
|
+
# paths. Tools then report findings with paths relative to py_dir,
|
|
597
|
+
# which match the original repo_file.path — no remapping needed.
|
|
598
|
+
py_dir = os.path.join(tmp_dir, "src")
|
|
599
|
+
os.makedirs(py_dir, exist_ok=True)
|
|
600
|
+
|
|
601
|
+
for repo_file in python_files:
|
|
602
|
+
dest = os.path.join(py_dir, repo_file.path.lstrip(os.sep))
|
|
603
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
604
|
+
with open(dest, "w", encoding="utf-8") as f:
|
|
605
|
+
f.write(repo_file.content)
|
|
606
|
+
|
|
607
|
+
# Write OpenGrep rules
|
|
608
|
+
rules_path = os.path.join(tmp_dir, "agent_rules.yaml")
|
|
609
|
+
with open(rules_path, "w") as f:
|
|
610
|
+
f.write(_load_opengrep_rules())
|
|
611
|
+
|
|
612
|
+
# Write requirements files preserving original paths.
|
|
613
|
+
req_dir = os.path.join(tmp_dir, "reqs")
|
|
614
|
+
os.makedirs(req_dir, exist_ok=True)
|
|
615
|
+
req_paths = []
|
|
616
|
+
for repo_file in requirements_files:
|
|
617
|
+
if repo_file.path.endswith(".txt"):
|
|
618
|
+
dest = os.path.join(req_dir, repo_file.path.lstrip(os.sep))
|
|
619
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
620
|
+
with open(dest, "w", encoding="utf-8") as f:
|
|
621
|
+
f.write(repo_file.content)
|
|
622
|
+
req_paths.append(dest)
|
|
623
|
+
# Also check for unpinned deps inline
|
|
624
|
+
all_findings.extend(
|
|
625
|
+
check_unpinned_dependencies(repo_file.content, repo_file.path)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if _module_available("bandit"):
|
|
629
|
+
logger.info("Running Bandit on %d files...", len(python_files))
|
|
630
|
+
all_findings.extend(run_bandit(py_dir))
|
|
631
|
+
tools_used.append("bandit")
|
|
632
|
+
else:
|
|
633
|
+
logger.warning("Skipping Bandit scan. %s", _BANDIT_SKIP_REASON)
|
|
634
|
+
tools_skipped.append(SkippedTool(tool="bandit", reason=_BANDIT_SKIP_REASON))
|
|
635
|
+
|
|
636
|
+
if find_opengrep_binary():
|
|
637
|
+
logger.info("Running OpenGrep with custom agent rules...")
|
|
638
|
+
all_findings.extend(run_opengrep(py_dir, rules_path))
|
|
639
|
+
tools_used.append("opengrep")
|
|
640
|
+
else:
|
|
641
|
+
skip_reason = _opengrep_skip_reason()
|
|
642
|
+
logger.warning("Skipping OpenGrep pattern scan. %s", skip_reason)
|
|
643
|
+
tools_skipped.append(SkippedTool(tool="opengrep", reason=skip_reason))
|
|
644
|
+
|
|
645
|
+
if _module_available("detect_secrets"):
|
|
646
|
+
logger.info("Running detect-secrets...")
|
|
647
|
+
all_findings.extend(run_detect_secrets(py_dir))
|
|
648
|
+
tools_used.append("detect-secrets")
|
|
649
|
+
else:
|
|
650
|
+
logger.warning("Skipping detect-secrets scan. %s", _DETECT_SECRETS_SKIP_REASON)
|
|
651
|
+
tools_skipped.append(
|
|
652
|
+
SkippedTool(tool="detect-secrets", reason=_DETECT_SECRETS_SKIP_REASON)
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# Only judged when there are requirements to audit: with no requirements
|
|
656
|
+
# files pip-audit was never going to run, so its absence costs no coverage
|
|
657
|
+
# and reporting it as skipped would be noise.
|
|
658
|
+
if req_paths:
|
|
659
|
+
pip_audit_available = _module_available("pip_audit")
|
|
660
|
+
if pip_audit_available:
|
|
661
|
+
logger.info("Running pip-audit on %d requirements files...", len(req_paths))
|
|
662
|
+
else:
|
|
663
|
+
# No warning here: `run_pip_audit` still runs (for the fallback)
|
|
664
|
+
# and logs the skip itself.
|
|
665
|
+
tools_skipped.append(
|
|
666
|
+
SkippedTool(tool="pip-audit", reason=_PIP_AUDIT_SKIP_REASON)
|
|
667
|
+
)
|
|
668
|
+
pip_findings = run_pip_audit(req_paths)
|
|
669
|
+
for f in pip_findings:
|
|
670
|
+
f.filepath = f.filepath.replace(req_dir, "").lstrip("/\\")
|
|
671
|
+
all_findings.extend(pip_findings)
|
|
672
|
+
# `run_pip_audit` degrades to the OSV.dev API rather than returning
|
|
673
|
+
# nothing, so name the source that actually produced the CVEs.
|
|
674
|
+
tools_used.append("pip-audit" if pip_audit_available else "osv")
|
|
675
|
+
|
|
676
|
+
logger.info("Total static findings: %d", len(all_findings))
|
|
677
|
+
return StaticScanResult(
|
|
678
|
+
findings=all_findings,
|
|
679
|
+
tools_used=tools_used,
|
|
680
|
+
tools_skipped=tools_skipped,
|
|
681
|
+
)
|