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.
Files changed (157) hide show
  1. switch_trust/__init__.py +0 -0
  2. switch_trust/cli/__init__.py +0 -0
  3. switch_trust/cli/__main__.py +3 -0
  4. switch_trust/cli/builtin_config.json +879 -0
  5. switch_trust/cli/console.py +127 -0
  6. switch_trust/cli/eval_cli.py +905 -0
  7. switch_trust/cli/file_filter.py +172 -0
  8. switch_trust/cli/init_cli.py +156 -0
  9. switch_trust/cli/main.py +384 -0
  10. switch_trust/cli/output_formatters.py +482 -0
  11. switch_trust/cli/rich_observer.py +89 -0
  12. switch_trust/cli/runner.py +274 -0
  13. switch_trust/cli/scan_cli.py +314 -0
  14. switch_trust/cli/telemetry.py +430 -0
  15. switch_trust/cli/utils.py +158 -0
  16. switch_trust/cli/version.py +1 -0
  17. switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
  18. switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
  19. switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
  20. switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
  21. switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
  22. switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
  23. switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
  24. switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
  25. switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
  26. switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
  27. switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
  28. switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
  29. switch_trust/data/llm01_prompt_injection.csv +6708 -0
  30. switch_trust/data/llm02_sensitive_information.csv +1069 -0
  31. switch_trust/data/llm05_unsafe_output.csv +3882 -0
  32. switch_trust/data/llm06_excessive_agency.csv +778 -0
  33. switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
  34. switch_trust/data/llm09_hallucination-goals.csv +32419 -0
  35. switch_trust/data/llm09_hallucination.csv +599 -0
  36. switch_trust/data/pii_leakage.csv +706 -0
  37. switch_trust/data/secret_leakage.csv +1380 -0
  38. switch_trust/eval/__init__.py +0 -0
  39. switch_trust/eval/common/__init__.py +0 -0
  40. switch_trust/eval/common/converter_anthropic.py +172 -0
  41. switch_trust/eval/common/converter_genai.py +171 -0
  42. switch_trust/eval/common/converter_openai.py +198 -0
  43. switch_trust/eval/common/log.py +116 -0
  44. switch_trust/eval/common/reference.py +25 -0
  45. switch_trust/eval/common/schema.py +190 -0
  46. switch_trust/eval/common/utils.py +119 -0
  47. switch_trust/eval/core/__init__.py +0 -0
  48. switch_trust/eval/core/detectors/__init__.py +0 -0
  49. switch_trust/eval/core/detectors/detector.py +25 -0
  50. switch_trust/eval/core/detectors/detector_garak.py +112 -0
  51. switch_trust/eval/core/detectors/detector_model.py +123 -0
  52. switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
  53. switch_trust/eval/core/detectors/detector_pii.py +67 -0
  54. switch_trust/eval/core/detectors/detector_secret.py +88 -0
  55. switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
  56. switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
  57. switch_trust/eval/core/eval/__init__.py +0 -0
  58. switch_trust/eval/core/eval/eval_creator.py +166 -0
  59. switch_trust/eval/core/eval/evaluation.py +112 -0
  60. switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
  61. switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
  62. switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
  63. switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
  64. switch_trust/eval/core/eval/evaluation_multi.py +276 -0
  65. switch_trust/eval/core/eval/evaluation_single.py +89 -0
  66. switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
  67. switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
  68. switch_trust/eval/core/eval/metric_conciseness.py +179 -0
  69. switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
  70. switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
  71. switch_trust/eval/core/eval/metric_tone.py +180 -0
  72. switch_trust/eval/core/eval/metric_toxicity.py +146 -0
  73. switch_trust/eval/core/eval/observer.py +137 -0
  74. switch_trust/eval/core/message/__init__.py +0 -0
  75. switch_trust/eval/core/message/message_collection.py +37 -0
  76. switch_trust/eval/core/message/message_collection_csv.py +44 -0
  77. switch_trust/eval/core/message/message_collection_garak.py +46 -0
  78. switch_trust/eval/core/message/message_collection_memory.py +34 -0
  79. switch_trust/eval/core/models/__init__.py +0 -0
  80. switch_trust/eval/core/models/generator_model.py +421 -0
  81. switch_trust/eval/core/models/model.py +172 -0
  82. switch_trust/eval/core/models/model_adk.py +186 -0
  83. switch_trust/eval/core/models/model_anthropic.py +79 -0
  84. switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
  85. switch_trust/eval/core/models/model_gemini.py +146 -0
  86. switch_trust/eval/core/models/model_generic_http.py +120 -0
  87. switch_trust/eval/core/models/model_http.py +135 -0
  88. switch_trust/eval/core/models/model_huggingface.py +75 -0
  89. switch_trust/eval/core/models/model_langserve.py +96 -0
  90. switch_trust/eval/core/models/model_litellm.py +52 -0
  91. switch_trust/eval/core/models/model_ollama.py +65 -0
  92. switch_trust/eval/core/models/model_openai.py +71 -0
  93. switch_trust/eval/core/models/model_openai_agent.py +92 -0
  94. switch_trust/eval/core/models/model_openai_compatible.py +66 -0
  95. switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
  96. switch_trust/eval/core/models/model_retry.py +204 -0
  97. switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
  98. switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
  99. switch_trust/eval/core/models/response_schema.py +148 -0
  100. switch_trust/eval/core/optional_deps.py +57 -0
  101. switch_trust/eval/db/__init__.py +0 -0
  102. switch_trust/eval/db/base/__init__.py +0 -0
  103. switch_trust/eval/db/base/common_types.py +16 -0
  104. switch_trust/eval/db/base/detectors/__init__.py +0 -0
  105. switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
  106. switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
  107. switch_trust/eval/db/base/detectors/detector_types.py +77 -0
  108. switch_trust/eval/db/base/eval/__init__.py +0 -0
  109. switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
  110. switch_trust/eval/db/base/eval/eval_repository.py +120 -0
  111. switch_trust/eval/db/base/eval/eval_run.py +142 -0
  112. switch_trust/eval/db/base/eval/eval_types.py +127 -0
  113. switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
  114. switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
  115. switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
  116. switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  117. switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
  118. switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
  119. switch_trust/eval/db/base/message/__init__.py +0 -0
  120. switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
  121. switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
  122. switch_trust/eval/db/base/message/message_collection_types.py +87 -0
  123. switch_trust/eval/db/base/models/__init__.py +0 -0
  124. switch_trust/eval/db/base/models/model_helpers.py +253 -0
  125. switch_trust/eval/db/base/models/model_repository.py +104 -0
  126. switch_trust/eval/db/base/models/model_types.py +159 -0
  127. switch_trust/eval/db/json/__init__.py +0 -0
  128. switch_trust/eval/db/json/repository_json.py +559 -0
  129. switch_trust/scan/__init__.py +12 -0
  130. switch_trust/scan/agent_scanner.py +812 -0
  131. switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
  132. switch_trust/scan/config/agent_taxonomy.json +517 -0
  133. switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
  134. switch_trust/scan/config/compliance_mappings.json +110 -0
  135. switch_trust/scan/config/triage_prompt.txt +326 -0
  136. switch_trust/scan/constants.py +15 -0
  137. switch_trust/scan/exception_classification.py +179 -0
  138. switch_trust/scan/llm_provider.py +330 -0
  139. switch_trust/scan/opengrep_resolver.py +12 -0
  140. switch_trust/scan/reasoner.py +602 -0
  141. switch_trust/scan/schema.py +315 -0
  142. switch_trust/scan/scorer.py +372 -0
  143. switch_trust/scan/static_scanner.py +681 -0
  144. switch_trust/scan/taxonomy.py +121 -0
  145. switch_trust/scan/tool_dispatcher.py +773 -0
  146. switch_trust/scan/trace_logger.py +118 -0
  147. switch_trust/scan/trace_logger_file.py +143 -0
  148. switch_trust/scan/trace_logger_log.py +100 -0
  149. switch_trust/scan/triage.py +614 -0
  150. switch_trust/schema.py +79 -0
  151. switch_trust/secret_anonymizer.py +72 -0
  152. switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
  153. switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
  154. switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
  155. switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
  156. switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
  157. 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
+ )