flintai-cli 1.0.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 (147) hide show
  1. flintai/__init__.py +0 -0
  2. flintai/cli/__init__.py +0 -0
  3. flintai/cli/__main__.py +3 -0
  4. flintai/cli/builtin_config.json +911 -0
  5. flintai/cli/console.py +116 -0
  6. flintai/cli/eval_cli.py +891 -0
  7. flintai/cli/init_cli.py +144 -0
  8. flintai/cli/main.py +262 -0
  9. flintai/cli/rich_observer.py +89 -0
  10. flintai/cli/runner.py +305 -0
  11. flintai/cli/scan_cli.py +238 -0
  12. flintai/cli/utils.py +37 -0
  13. flintai/cli/version.py +1 -0
  14. flintai/data/detector_prompts/llm01_adversarial.txt +55 -0
  15. flintai/data/detector_prompts/llm01_fixed.txt +8 -0
  16. flintai/data/detector_prompts/llm02_adversarial.txt +75 -0
  17. flintai/data/detector_prompts/llm02_fixed.txt +8 -0
  18. flintai/data/detector_prompts/llm05_adversarial.txt +63 -0
  19. flintai/data/detector_prompts/llm05_fixed.txt +16 -0
  20. flintai/data/detector_prompts/llm06_adversarial.txt +135 -0
  21. flintai/data/detector_prompts/llm06_fixed.txt +15 -0
  22. flintai/data/detector_prompts/llm07_adversarial.txt +185 -0
  23. flintai/data/detector_prompts/llm07_fixed.txt +14 -0
  24. flintai/data/detector_prompts/llm09_adversarial.txt +77 -0
  25. flintai/data/detector_prompts/llm09_fixed.txt +15 -0
  26. flintai/data/llm01_prompt_injection.csv +6708 -0
  27. flintai/data/llm02_sensitive_information.csv +1069 -0
  28. flintai/data/llm05_unsafe_output.csv +3882 -0
  29. flintai/data/llm06_excessive_agency.csv +778 -0
  30. flintai/data/llm07_system_prompt_leakage.csv +971 -0
  31. flintai/data/llm09_hallucination-goals.csv +32419 -0
  32. flintai/data/llm09_hallucination.csv +599 -0
  33. flintai/data/pii_leakage.csv +706 -0
  34. flintai/data/secret_leakage.csv +1380 -0
  35. flintai/eval/__init__.py +0 -0
  36. flintai/eval/common/converter_anthropic.py +151 -0
  37. flintai/eval/common/converter_genai.py +169 -0
  38. flintai/eval/common/converter_openai.py +165 -0
  39. flintai/eval/common/log.py +96 -0
  40. flintai/eval/common/reference.py +25 -0
  41. flintai/eval/common/schema.py +192 -0
  42. flintai/eval/common/utils.py +74 -0
  43. flintai/eval/core/__init__.py +0 -0
  44. flintai/eval/core/detectors/__init__.py +1 -0
  45. flintai/eval/core/detectors/detector.py +25 -0
  46. flintai/eval/core/detectors/detector_garak.py +80 -0
  47. flintai/eval/core/detectors/detector_model.py +76 -0
  48. flintai/eval/core/detectors/detector_pii.py +64 -0
  49. flintai/eval/core/detectors/detector_secret.py +82 -0
  50. flintai/eval/core/detectors/detector_topic_guard.py +70 -0
  51. flintai/eval/core/detectors/detector_toxicity.py +70 -0
  52. flintai/eval/core/eval/__init__.py +0 -0
  53. flintai/eval/core/eval/eval_creator.py +197 -0
  54. flintai/eval/core/eval/evaluation.py +100 -0
  55. flintai/eval/core/eval/evaluation_adversarial.py +523 -0
  56. flintai/eval/core/eval/evaluation_garak_module.py +52 -0
  57. flintai/eval/core/eval/evaluation_garak_probe.py +274 -0
  58. flintai/eval/core/eval/evaluation_message_list.py +44 -0
  59. flintai/eval/core/eval/evaluation_multi.py +207 -0
  60. flintai/eval/core/eval/evaluation_single.py +79 -0
  61. flintai/eval/core/eval/evaluation_single_prompt.py +58 -0
  62. flintai/eval/core/eval/evaluation_topic_guard.py +318 -0
  63. flintai/eval/core/eval/metric_conciseness.py +181 -0
  64. flintai/eval/core/eval/metric_factual_accuracy.py +294 -0
  65. flintai/eval/core/eval/metric_instruction_adherence.py +180 -0
  66. flintai/eval/core/eval/metric_tone.py +183 -0
  67. flintai/eval/core/eval/metric_toxicity.py +147 -0
  68. flintai/eval/core/eval/observer.py +136 -0
  69. flintai/eval/core/eval/probe_adversarial.py +14 -0
  70. flintai/eval/core/message/__init__.py +0 -0
  71. flintai/eval/core/message/message_collection.py +37 -0
  72. flintai/eval/core/message/message_collection_csv.py +46 -0
  73. flintai/eval/core/message/message_collection_garak.py +52 -0
  74. flintai/eval/core/message/message_collection_memory.py +36 -0
  75. flintai/eval/core/models/__init__.py +0 -0
  76. flintai/eval/core/models/generator_model.py +88 -0
  77. flintai/eval/core/models/model.py +111 -0
  78. flintai/eval/core/models/model_adk.py +158 -0
  79. flintai/eval/core/models/model_anthropic.py +51 -0
  80. flintai/eval/core/models/model_anthropic_agent.py +92 -0
  81. flintai/eval/core/models/model_gemini.py +107 -0
  82. flintai/eval/core/models/model_generic_http.py +117 -0
  83. flintai/eval/core/models/model_huggingface.py +61 -0
  84. flintai/eval/core/models/model_langserve.py +94 -0
  85. flintai/eval/core/models/model_litellm.py +38 -0
  86. flintai/eval/core/models/model_ollama.py +50 -0
  87. flintai/eval/core/models/model_openai.py +35 -0
  88. flintai/eval/core/models/model_openai_agent.py +80 -0
  89. flintai/eval/core/models/model_openai_compatible.py +57 -0
  90. flintai/eval/core/models/model_retry.py +134 -0
  91. flintai/eval/core/models/model_sync_wrapper.py +35 -0
  92. flintai/eval/db/__init__.py +0 -0
  93. flintai/eval/db/base/__init__.py +0 -0
  94. flintai/eval/db/base/detectors/__init__.py +1 -0
  95. flintai/eval/db/base/detectors/detector_helpers.py +78 -0
  96. flintai/eval/db/base/detectors/detector_repository.py +91 -0
  97. flintai/eval/db/base/detectors/detector_types.py +77 -0
  98. flintai/eval/db/base/eval/__init__.py +1 -0
  99. flintai/eval/db/base/eval/eval_helpers.py +227 -0
  100. flintai/eval/db/base/eval/eval_repository.py +115 -0
  101. flintai/eval/db/base/eval/eval_run.py +139 -0
  102. flintai/eval/db/base/eval/eval_types.py +129 -0
  103. flintai/eval/db/base/eval/model_eval_repository.py +78 -0
  104. flintai/eval/db/base/eval/model_eval_run_repository.py +70 -0
  105. flintai/eval/db/base/eval/model_eval_run_result_repository.py +40 -0
  106. flintai/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  107. flintai/eval/db/base/eval/model_eval_run_types.py +84 -0
  108. flintai/eval/db/base/eval/model_eval_types.py +71 -0
  109. flintai/eval/db/base/message/__init__.py +0 -0
  110. flintai/eval/db/base/message/message_collection_helpers.py +61 -0
  111. flintai/eval/db/base/message/message_collection_repository.py +92 -0
  112. flintai/eval/db/base/message/message_collection_types.py +89 -0
  113. flintai/eval/db/base/models/__init__.py +0 -0
  114. flintai/eval/db/base/models/model_helpers.py +172 -0
  115. flintai/eval/db/base/models/model_repository.py +95 -0
  116. flintai/eval/db/base/models/model_types.py +156 -0
  117. flintai/eval/db/json/__init__.py +0 -0
  118. flintai/eval/db/json/repository_json.py +603 -0
  119. flintai/scan/__init__.py +0 -0
  120. flintai/scan/agent_scanner.py +797 -0
  121. flintai/scan/config/agent_opengrep_rules.yaml +533 -0
  122. flintai/scan/config/agent_taxonomy.json +481 -0
  123. flintai/scan/config/agentic_cvss_mapping.yaml +136 -0
  124. flintai/scan/config/compliance_mappings.json +110 -0
  125. flintai/scan/config/triage_prompt.txt +335 -0
  126. flintai/scan/constants.py +15 -0
  127. flintai/scan/file_filter.py +156 -0
  128. flintai/scan/llm_provider.py +197 -0
  129. flintai/scan/opengrep_resolver.py +12 -0
  130. flintai/scan/reasoner.py +552 -0
  131. flintai/scan/schema.py +337 -0
  132. flintai/scan/scorer.py +371 -0
  133. flintai/scan/secret_anonymizer.py +72 -0
  134. flintai/scan/static_scanner.py +542 -0
  135. flintai/scan/taxonomy.py +75 -0
  136. flintai/scan/tool_dispatcher.py +754 -0
  137. flintai/scan/trace_logger.py +118 -0
  138. flintai/scan/trace_logger_file.py +143 -0
  139. flintai/scan/trace_logger_log.py +100 -0
  140. flintai/scan/triage.py +496 -0
  141. flintai/schema.py +58 -0
  142. flintai_cli-1.0.0.dist-info/METADATA +442 -0
  143. flintai_cli-1.0.0.dist-info/RECORD +147 -0
  144. flintai_cli-1.0.0.dist-info/WHEEL +5 -0
  145. flintai_cli-1.0.0.dist-info/entry_points.txt +2 -0
  146. flintai_cli-1.0.0.dist-info/licenses/LICENSE +210 -0
  147. flintai_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,542 @@
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 json
10
+ import logging
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+
19
+ from ..schema import RepoFile
20
+ from .opengrep_resolver import find_opengrep_binary
21
+ from .schema import PackageInfo
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Use sys.executable so the current Python interpreter finds installed
26
+ # packages rather than looking for CLI tools on the system PATH.
27
+ _PY = sys.executable
28
+
29
+
30
+ @dataclass
31
+ class StaticFinding:
32
+ tool: str # bandit | opengrep | detect_secrets | pip_audit | internal
33
+ rule_id: str
34
+ severity: str
35
+ message: str
36
+ filepath: str
37
+ line: int = 0
38
+ evidence: str = ""
39
+ cwe: str = ""
40
+
41
+
42
+ _OPENGREP_RULES_PATH = os.path.join(
43
+ os.path.dirname(os.path.abspath(__file__)), "config", "agent_opengrep_rules.yaml"
44
+ )
45
+
46
+
47
+ def _load_opengrep_rules() -> str:
48
+ with open(_OPENGREP_RULES_PATH, "r", encoding="utf-8") as f:
49
+ return f.read()
50
+
51
+
52
+ # ── Severity mapping ─────────────────────────────────────────────────────────
53
+
54
+ BANDIT_SEVERITY_MAP = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low"}
55
+
56
+ OPENGREP_SEVERITY_MAP = {"ERROR": "high", "WARNING": "medium", "INFO": "low"}
57
+
58
+
59
+ # ── Static analysis runners ──────────────────────────────────────────────────
60
+
61
+
62
+ def run_bandit(files_dir: str) -> list[StaticFinding]:
63
+ """Run Bandit on Python files and parse findings."""
64
+ findings = []
65
+ try:
66
+ result = subprocess.run(
67
+ [
68
+ _PY,
69
+ "-m",
70
+ "bandit",
71
+ "-r",
72
+ files_dir,
73
+ "-f",
74
+ "json",
75
+ "-q",
76
+ "--severity-level",
77
+ "low",
78
+ ],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=120,
82
+ )
83
+ if not result.stdout.strip():
84
+ return findings
85
+
86
+ data = json.loads(result.stdout)
87
+ for issue in data.get("results", []):
88
+ rel_path = issue.get("filename", "").replace(files_dir, "").lstrip("/\\")
89
+ findings.append(
90
+ StaticFinding(
91
+ tool="bandit",
92
+ rule_id=issue.get("test_id", ""),
93
+ severity=BANDIT_SEVERITY_MAP.get(
94
+ issue.get("issue_severity", "LOW"), "low"
95
+ ),
96
+ message=issue.get("issue_text", ""),
97
+ filepath=rel_path,
98
+ line=issue.get("line_number", 0),
99
+ evidence=issue.get("code", "")[:200],
100
+ cwe=issue.get("issue_cwe", {}).get("id", ""),
101
+ )
102
+ )
103
+ except Exception as e:
104
+ logger.error("Bandit error: %s", e)
105
+
106
+ return findings
107
+
108
+
109
+ def run_opengrep(files_dir: str, rules_file: str) -> list[StaticFinding]:
110
+ """Run OpenGrep with custom agent rules and parse findings."""
111
+ findings = []
112
+ opengrep_bin = find_opengrep_binary()
113
+ if not opengrep_bin:
114
+ logger.info(
115
+ "OpenGrep not found — skipping pattern scan. "
116
+ "Install from https://github.com/opengrep/opengrep/releases"
117
+ )
118
+ return findings
119
+ try:
120
+ result = subprocess.run(
121
+ [
122
+ opengrep_bin,
123
+ "scan",
124
+ "--config",
125
+ rules_file,
126
+ files_dir,
127
+ "--json",
128
+ "--quiet",
129
+ "--no-git-ignore",
130
+ ],
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=180,
134
+ )
135
+ # OpenGrep writes JSON to stdout on success (exit 0) and on
136
+ # findings-found (exit 1). On rule errors (exit 7) it may
137
+ # write to stderr instead. Try both.
138
+ raw_json = result.stdout.strip() or result.stderr.strip()
139
+ if not raw_json:
140
+ return findings
141
+
142
+ data = json.loads(raw_json)
143
+ for err in data.get("errors", []):
144
+ logger.warning("OpenGrep rule error: %s", err)
145
+ for issue in data.get("results", []):
146
+ rel_path = issue.get("path", "").replace(files_dir, "").lstrip("/\\")
147
+ findings.append(
148
+ StaticFinding(
149
+ tool="opengrep",
150
+ rule_id=issue.get("check_id", ""),
151
+ severity=OPENGREP_SEVERITY_MAP.get(
152
+ issue.get("extra", {}).get("severity", "INFO"), "low"
153
+ ),
154
+ message=issue.get("extra", {}).get("message", ""),
155
+ filepath=rel_path,
156
+ line=issue.get("start", {}).get("line", 0),
157
+ evidence=issue.get("extra", {}).get("lines", "")[:200],
158
+ )
159
+ )
160
+ except Exception as e:
161
+ logger.error("OpenGrep error: %s", e)
162
+
163
+ return findings
164
+
165
+
166
+ def run_detect_secrets(files_dir: str) -> list[StaticFinding]:
167
+ """Run detect-secrets to find hardcoded credentials."""
168
+ findings = []
169
+ try:
170
+ result = subprocess.run(
171
+ [_PY, "-m", "detect_secrets", "scan", files_dir],
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=120,
175
+ )
176
+ if not result.stdout.strip():
177
+ return findings
178
+
179
+ data = json.loads(result.stdout)
180
+ for filepath, secrets in data.get("results", {}).items():
181
+ rel_path = filepath.replace(files_dir, "").lstrip("/\\")
182
+ for secret in secrets:
183
+ findings.append(
184
+ StaticFinding(
185
+ tool="detect_secrets",
186
+ rule_id=secret.get("type", "secret"),
187
+ severity="high",
188
+ message=f"Potential secret detected: {secret.get('type', 'unknown')}",
189
+ filepath=rel_path,
190
+ line=secret.get("line_number", 0),
191
+ evidence=f"[redacted — line {secret.get('line_number', '?')}]",
192
+ )
193
+ )
194
+ except Exception as e:
195
+ logger.error("detect-secrets error: %s", e)
196
+
197
+ return findings
198
+
199
+
200
+ def _parse_pinned_packages(requirements_content: str) -> list[PackageInfo]:
201
+ """
202
+ Parse a requirements.txt and return a list of {name, version} dicts
203
+ for all pinned (==) packages. Skips comments, blank lines, and unpinned entries.
204
+ """
205
+ packages = []
206
+ for line in requirements_content.splitlines():
207
+ line = line.strip()
208
+ if not line or line.startswith("#") or line.startswith("-"):
209
+ continue
210
+ if "==" in line:
211
+ # Strip inline comments
212
+ line = line.split("#")[0].strip()
213
+ parts = line.split("==")
214
+ if len(parts) == 2:
215
+ name = parts[0].strip()
216
+ version = parts[1].strip().split()[0] # strip any trailing extras
217
+ if name and version:
218
+ packages.append({"name": name, "version": version})
219
+ return packages
220
+
221
+
222
+ def _query_osv_api(packages: list[PackageInfo], filepath: str) -> list[StaticFinding]:
223
+ """
224
+ Query the OSV.dev batch API directly for a list of {name, version} packages.
225
+ No virtual env, no package installation, no Python version constraints.
226
+ Docs: https://osv.dev/docs/#tag/api/operation/OSV_QueryAffectedBatch
227
+ """
228
+ findings = []
229
+ if not packages:
230
+ return findings
231
+
232
+ OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
233
+ payload = {
234
+ "queries": [
235
+ {
236
+ "version": pkg["version"],
237
+ "package": {"name": pkg["name"], "ecosystem": "PyPI"},
238
+ }
239
+ for pkg in packages
240
+ ]
241
+ }
242
+
243
+ try:
244
+ data = json.dumps(payload).encode("utf-8")
245
+ req = urllib.request.Request(
246
+ OSV_BATCH_URL,
247
+ data=data,
248
+ headers={"Content-Type": "application/json"},
249
+ method="POST",
250
+ )
251
+ with urllib.request.urlopen(req, timeout=30) as resp:
252
+ response = json.loads(resp.read().decode("utf-8"))
253
+ except urllib.error.URLError as e:
254
+ logger.error("OSV API request failed: %s", e)
255
+ return findings
256
+ except Exception as e:
257
+ logger.error("OSV API error: %s", e)
258
+ return findings
259
+
260
+ results = response.get("results", [])
261
+ vuln_count = 0
262
+ for i, result in enumerate(results):
263
+ if i >= len(packages):
264
+ break
265
+ pkg = packages[i]
266
+ vulns = result.get("vulns", [])
267
+ for vuln in vulns:
268
+ vuln_count += 1
269
+ # Derive severity from CVSS score if available, else default to high
270
+ severity = "high"
271
+ cvss_score = None
272
+ for severity_entry in vuln.get("severity", []):
273
+ if severity_entry.get("type") == "CVSS_V3":
274
+ raw_score = severity_entry.get("score", 0)
275
+ # OSV may return a CVSS vector string instead of a numeric score
276
+ if isinstance(raw_score, str) and raw_score.startswith("CVSS:"):
277
+ break
278
+ try:
279
+ score = float(raw_score)
280
+ cvss_score = score # noqa: F841
281
+ if score >= 9.0:
282
+ severity = "critical"
283
+ elif score >= 7.0:
284
+ severity = "high"
285
+ elif score >= 4.0:
286
+ severity = "medium"
287
+ else:
288
+ severity = "low"
289
+ except (ValueError, TypeError):
290
+ pass
291
+ break
292
+
293
+ vuln_id = vuln.get("id", "")
294
+ aliases = vuln.get("aliases", [])
295
+ cve_id = next((a for a in aliases if a.startswith("CVE-")), vuln_id)
296
+ summary = vuln.get("summary", "") or vuln.get("details", "")[:200]
297
+
298
+ findings.append(
299
+ StaticFinding(
300
+ tool="pip_audit",
301
+ rule_id=cve_id or vuln_id,
302
+ severity=severity,
303
+ message=(f"{pkg['name']}=={pkg['version']}: {summary[:200]}"),
304
+ filepath=filepath,
305
+ line=0,
306
+ evidence=(
307
+ f"Package: {pkg['name']} {pkg['version']} | "
308
+ f"ID: {vuln_id} | "
309
+ f"Aliases: {', '.join(aliases[:3]) if aliases else 'none'}"
310
+ ),
311
+ )
312
+ )
313
+
314
+ logger.info(
315
+ "OSV: %d CVE(s) found across %d of %d packages",
316
+ vuln_count,
317
+ len([p for p in results if p.get("vulns")]),
318
+ len(packages),
319
+ )
320
+ return findings
321
+
322
+
323
+ def run_pip_audit(requirements_files: list) -> list[StaticFinding]:
324
+ """
325
+ Scan requirements files for known CVEs.
326
+ Strategy:
327
+ 1. Try pip-audit (fast, uses local pip resolver)
328
+ 2. If pip-audit fails due to Python version conflicts or resolver errors,
329
+ fall back to querying the OSV.dev batch API directly
330
+ (no virtual env, no installation, no Python version constraints)
331
+ """
332
+ findings = []
333
+ for req_file_path in requirements_files:
334
+ if not req_file_path.endswith(".txt"):
335
+ continue
336
+
337
+ # ── Attempt 1: pip-audit ────────────────────────────────────────────────
338
+ pip_audit_succeeded = False
339
+ try:
340
+ result = subprocess.run(
341
+ [
342
+ _PY,
343
+ "-m",
344
+ "pip_audit",
345
+ "-r",
346
+ req_file_path,
347
+ "--format",
348
+ "json",
349
+ "-s",
350
+ "osv",
351
+ "--progress-spinner",
352
+ "off",
353
+ ],
354
+ capture_output=True,
355
+ text=True,
356
+ timeout=120,
357
+ )
358
+ # pip-audit exits 1 when vulnerabilities are found — that is normal.
359
+ # Only treat return code > 1 as a hard error warranting fallback.
360
+ if result.returncode <= 1:
361
+ stdout = result.stdout.strip()
362
+ if stdout:
363
+ data = json.loads(stdout)
364
+ dependencies = (
365
+ data if isinstance(data, list) else data.get("dependencies", [])
366
+ )
367
+ vuln_count = 0
368
+ for dep in dependencies:
369
+ for vuln in dep.get("vulns", []):
370
+ vuln_count += 1
371
+ findings.append(
372
+ StaticFinding(
373
+ tool="pip_audit",
374
+ rule_id=vuln.get("id", ""),
375
+ severity="high",
376
+ message=(
377
+ f"{dep.get('name')}=={dep.get('version')}: "
378
+ f"{vuln.get('description', '')[:200]}"
379
+ ),
380
+ filepath=req_file_path,
381
+ line=0,
382
+ evidence=(
383
+ f"Package: {dep.get('name')} {dep.get('version')} | "
384
+ f"CVE/ID: {vuln.get('id', 'N/A')} | "
385
+ f"Fix: {vuln.get('fix_versions', [])}"
386
+ ),
387
+ )
388
+ )
389
+ logger.info(
390
+ "pip-audit: %d CVE(s) found in %s",
391
+ vuln_count,
392
+ req_file_path.split("/")[-1],
393
+ )
394
+ pip_audit_succeeded = True
395
+ else:
396
+ # Empty stdout — fall through to OSV fallback
397
+ stderr_preview = result.stderr.strip()[:200]
398
+ logger.warning(
399
+ "pip-audit: empty output — falling back to OSV API. Reason: %s",
400
+ stderr_preview,
401
+ )
402
+ else:
403
+ stderr_preview = result.stderr.strip()[:200]
404
+ logger.warning(
405
+ "pip-audit exited %d — falling back to OSV API. Reason: %s",
406
+ result.returncode,
407
+ stderr_preview,
408
+ )
409
+
410
+ except json.JSONDecodeError as e:
411
+ logger.warning(
412
+ "pip-audit JSON parse error — falling back to OSV API: %s", e
413
+ )
414
+ except Exception as e:
415
+ logger.warning("pip-audit error — falling back to OSV API: %s", e)
416
+
417
+ # ── Attempt 2: OSV.dev direct API fallback ───────────────────────────────
418
+ if not pip_audit_succeeded:
419
+ try:
420
+ content = open(req_file_path, "r", encoding="utf-8").read()
421
+ packages = _parse_pinned_packages(content)
422
+ if packages:
423
+ logger.info(
424
+ "OSV fallback: querying %d pinned packages from %s...",
425
+ len(packages),
426
+ req_file_path.split("/")[-1],
427
+ )
428
+ findings.extend(_query_osv_api(packages, req_file_path))
429
+ else:
430
+ logger.info(
431
+ "OSV fallback: no pinned packages found in %s",
432
+ req_file_path.split("/")[-1],
433
+ )
434
+ except Exception as e:
435
+ logger.error("OSV fallback error: %s", e)
436
+
437
+ return findings
438
+
439
+
440
+ def check_unpinned_dependencies(
441
+ requirements_content: str, filepath: str
442
+ ) -> list[StaticFinding]:
443
+ """Check for unpinned AI framework dependencies."""
444
+ findings = []
445
+ ai_packages = [
446
+ "crewai",
447
+ "autogen",
448
+ "pyautogen",
449
+ "langchain",
450
+ "openai",
451
+ "anthropic",
452
+ "langgraph",
453
+ "ag2",
454
+ "autogen-agentchat",
455
+ ]
456
+
457
+ for line in requirements_content.splitlines():
458
+ line = line.strip()
459
+ if not line or line.startswith("#"):
460
+ continue
461
+ pkg_name = re.split(r"[>=<!~\s]", line)[0].lower()
462
+ if pkg_name in ai_packages:
463
+ # Check if pinned (==) or unpinned (>=, ~=, no version)
464
+ if "==" not in line:
465
+ findings.append(
466
+ StaticFinding(
467
+ tool="internal",
468
+ rule_id="unpinned-ai-dependency",
469
+ severity="medium",
470
+ message=f"Unpinned AI framework dependency: '{line}' — supply chain risk", # noqa: B950
471
+ filepath=filepath,
472
+ line=0,
473
+ evidence=line,
474
+ )
475
+ )
476
+
477
+ return findings
478
+
479
+
480
+ # ── Main static scanner entry point ─────────────────────────────────────────
481
+
482
+
483
+ def run_static_scan(
484
+ python_files: list[RepoFile], requirements_files: list[RepoFile], tmp_dir: str
485
+ ) -> list[StaticFinding]:
486
+ """
487
+ Write files to temp dir and run all static analysis tools.
488
+ Returns combined list of StaticFinding objects.
489
+ """
490
+ all_findings = []
491
+
492
+ # Write Python files to disk preserving their original relative
493
+ # paths. Tools then report findings with paths relative to py_dir,
494
+ # which match the original repo_file.path — no remapping needed.
495
+ py_dir = os.path.join(tmp_dir, "src")
496
+ os.makedirs(py_dir, exist_ok=True)
497
+
498
+ for repo_file in python_files:
499
+ dest = os.path.join(py_dir, repo_file.path.lstrip(os.sep))
500
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
501
+ with open(dest, "w", encoding="utf-8") as f:
502
+ f.write(repo_file.content)
503
+
504
+ # Write OpenGrep rules
505
+ rules_path = os.path.join(tmp_dir, "agent_rules.yaml")
506
+ with open(rules_path, "w") as f:
507
+ f.write(_load_opengrep_rules())
508
+
509
+ # Write requirements files preserving original paths.
510
+ req_dir = os.path.join(tmp_dir, "reqs")
511
+ os.makedirs(req_dir, exist_ok=True)
512
+ req_paths = []
513
+ for repo_file in requirements_files:
514
+ if repo_file.path.endswith(".txt"):
515
+ dest = os.path.join(req_dir, repo_file.path.lstrip(os.sep))
516
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
517
+ with open(dest, "w", encoding="utf-8") as f:
518
+ f.write(repo_file.content)
519
+ req_paths.append(dest)
520
+ # Also check for unpinned deps inline
521
+ all_findings.extend(
522
+ check_unpinned_dependencies(repo_file.content, repo_file.path)
523
+ )
524
+
525
+ logger.info("Running Bandit on %d files...", len(python_files))
526
+ all_findings.extend(run_bandit(py_dir))
527
+
528
+ logger.info("Running OpenGrep with custom agent rules...")
529
+ all_findings.extend(run_opengrep(py_dir, rules_path))
530
+
531
+ logger.info("Running detect-secrets...")
532
+ all_findings.extend(run_detect_secrets(py_dir))
533
+
534
+ if req_paths:
535
+ logger.info("Running pip-audit on %d requirements files...", len(req_paths))
536
+ pip_findings = run_pip_audit(req_paths)
537
+ for f in pip_findings:
538
+ f.filepath = f.filepath.replace(req_dir, "").lstrip("/\\")
539
+ all_findings.extend(pip_findings)
540
+
541
+ logger.info("Total static findings: %d", len(all_findings))
542
+ return all_findings
@@ -0,0 +1,75 @@
1
+ """
2
+ taxonomy.py — AI Agent Vulnerability Taxonomy
3
+ Aligned to OWASP Top 10 for Agentic Applications 2026 (ASI01–ASI10).
4
+
5
+ Every finding maps to one of the 10 ASI categories. Findings that fall
6
+ outside the current OWASP framework are reported under the BEYOND-ASI
7
+ prefix with a descriptive name generated by the scanner or AI layer.
8
+
9
+ Reference: OWASP Agentic Security Initiative (ASI) Top 10 — Dec 2025
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from typing import Any
15
+
16
+ _CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config")
17
+
18
+
19
+ def _load_json(filename: str) -> Any:
20
+ with open(os.path.join(_CONFIG_DIR, filename), "r", encoding="utf-8") as f:
21
+ return json.load(f)
22
+
23
+
24
+ AGENT_TAXONOMY: dict[str, dict[str, Any]] = _load_json("agent_taxonomy.json")
25
+
26
+ COMPLIANCE_MAPPINGS: dict[str, dict[str, list[str]]] = _load_json(
27
+ "compliance_mappings.json"
28
+ )
29
+
30
+ BEYOND_ASI_SENTINEL = "BEYOND-ASI"
31
+
32
+ # Flat lookup: subcategory_key → full metadata (used by scorer.py)
33
+ FLAT_TAXONOMY: dict[str, dict[str, Any]] = {}
34
+ for _cat_key, _cat_data in AGENT_TAXONOMY.items():
35
+ for _subcat_key, _subcat_data in _cat_data["subcategories"].items():
36
+ FLAT_TAXONOMY[_subcat_key] = {
37
+ "category": _cat_key,
38
+ "asi_code": _cat_data["asi_code"],
39
+ "asi_title": _cat_data["asi_title"],
40
+ **_subcat_data,
41
+ }
42
+
43
+
44
+ def get_finding_metadata(subcategory: str) -> dict[str, Any]:
45
+ """Look up CVSS vector, severity, and ASI metadata for a subcategory."""
46
+ return FLAT_TAXONOMY.get(
47
+ subcategory,
48
+ {
49
+ "category": "beyond_asi",
50
+ "asi_code": "BEYOND-ASI",
51
+ "asi_title": "Beyond Current OWASP ASI Framework",
52
+ "title": subcategory,
53
+ "cvss_vector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N",
54
+ "severity": "medium",
55
+ "remediation": (
56
+ "Review the finding and apply appropriate security controls "
57
+ "based on the OWASP Agentic AI guidelines."
58
+ ),
59
+ },
60
+ )
61
+
62
+
63
+ def get_compliance_mappings(subcategory: str) -> dict[str, list[str]]:
64
+ """Return OWASP Agentic compliance mappings for a subcategory."""
65
+ return COMPLIANCE_MAPPINGS.get(
66
+ subcategory, {"owasp_agentic": [BEYOND_ASI_SENTINEL]}
67
+ )
68
+
69
+
70
+ def is_owasp_classified(compliance_mappings: dict[str, list[str]]) -> bool:
71
+ """Return True if at least one mapping starts with 'ASI'."""
72
+ for entry in compliance_mappings.get("owasp_agentic", []):
73
+ if entry.startswith("ASI"):
74
+ return True
75
+ return False