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.
- flintai/__init__.py +0 -0
- flintai/cli/__init__.py +0 -0
- flintai/cli/__main__.py +3 -0
- flintai/cli/builtin_config.json +911 -0
- flintai/cli/console.py +116 -0
- flintai/cli/eval_cli.py +891 -0
- flintai/cli/init_cli.py +144 -0
- flintai/cli/main.py +262 -0
- flintai/cli/rich_observer.py +89 -0
- flintai/cli/runner.py +305 -0
- flintai/cli/scan_cli.py +238 -0
- flintai/cli/utils.py +37 -0
- flintai/cli/version.py +1 -0
- flintai/data/detector_prompts/llm01_adversarial.txt +55 -0
- flintai/data/detector_prompts/llm01_fixed.txt +8 -0
- flintai/data/detector_prompts/llm02_adversarial.txt +75 -0
- flintai/data/detector_prompts/llm02_fixed.txt +8 -0
- flintai/data/detector_prompts/llm05_adversarial.txt +63 -0
- flintai/data/detector_prompts/llm05_fixed.txt +16 -0
- flintai/data/detector_prompts/llm06_adversarial.txt +135 -0
- flintai/data/detector_prompts/llm06_fixed.txt +15 -0
- flintai/data/detector_prompts/llm07_adversarial.txt +185 -0
- flintai/data/detector_prompts/llm07_fixed.txt +14 -0
- flintai/data/detector_prompts/llm09_adversarial.txt +77 -0
- flintai/data/detector_prompts/llm09_fixed.txt +15 -0
- flintai/data/llm01_prompt_injection.csv +6708 -0
- flintai/data/llm02_sensitive_information.csv +1069 -0
- flintai/data/llm05_unsafe_output.csv +3882 -0
- flintai/data/llm06_excessive_agency.csv +778 -0
- flintai/data/llm07_system_prompt_leakage.csv +971 -0
- flintai/data/llm09_hallucination-goals.csv +32419 -0
- flintai/data/llm09_hallucination.csv +599 -0
- flintai/data/pii_leakage.csv +706 -0
- flintai/data/secret_leakage.csv +1380 -0
- flintai/eval/__init__.py +0 -0
- flintai/eval/common/converter_anthropic.py +151 -0
- flintai/eval/common/converter_genai.py +169 -0
- flintai/eval/common/converter_openai.py +165 -0
- flintai/eval/common/log.py +96 -0
- flintai/eval/common/reference.py +25 -0
- flintai/eval/common/schema.py +192 -0
- flintai/eval/common/utils.py +74 -0
- flintai/eval/core/__init__.py +0 -0
- flintai/eval/core/detectors/__init__.py +1 -0
- flintai/eval/core/detectors/detector.py +25 -0
- flintai/eval/core/detectors/detector_garak.py +80 -0
- flintai/eval/core/detectors/detector_model.py +76 -0
- flintai/eval/core/detectors/detector_pii.py +64 -0
- flintai/eval/core/detectors/detector_secret.py +82 -0
- flintai/eval/core/detectors/detector_topic_guard.py +70 -0
- flintai/eval/core/detectors/detector_toxicity.py +70 -0
- flintai/eval/core/eval/__init__.py +0 -0
- flintai/eval/core/eval/eval_creator.py +197 -0
- flintai/eval/core/eval/evaluation.py +100 -0
- flintai/eval/core/eval/evaluation_adversarial.py +523 -0
- flintai/eval/core/eval/evaluation_garak_module.py +52 -0
- flintai/eval/core/eval/evaluation_garak_probe.py +274 -0
- flintai/eval/core/eval/evaluation_message_list.py +44 -0
- flintai/eval/core/eval/evaluation_multi.py +207 -0
- flintai/eval/core/eval/evaluation_single.py +79 -0
- flintai/eval/core/eval/evaluation_single_prompt.py +58 -0
- flintai/eval/core/eval/evaluation_topic_guard.py +318 -0
- flintai/eval/core/eval/metric_conciseness.py +181 -0
- flintai/eval/core/eval/metric_factual_accuracy.py +294 -0
- flintai/eval/core/eval/metric_instruction_adherence.py +180 -0
- flintai/eval/core/eval/metric_tone.py +183 -0
- flintai/eval/core/eval/metric_toxicity.py +147 -0
- flintai/eval/core/eval/observer.py +136 -0
- flintai/eval/core/eval/probe_adversarial.py +14 -0
- flintai/eval/core/message/__init__.py +0 -0
- flintai/eval/core/message/message_collection.py +37 -0
- flintai/eval/core/message/message_collection_csv.py +46 -0
- flintai/eval/core/message/message_collection_garak.py +52 -0
- flintai/eval/core/message/message_collection_memory.py +36 -0
- flintai/eval/core/models/__init__.py +0 -0
- flintai/eval/core/models/generator_model.py +88 -0
- flintai/eval/core/models/model.py +111 -0
- flintai/eval/core/models/model_adk.py +158 -0
- flintai/eval/core/models/model_anthropic.py +51 -0
- flintai/eval/core/models/model_anthropic_agent.py +92 -0
- flintai/eval/core/models/model_gemini.py +107 -0
- flintai/eval/core/models/model_generic_http.py +117 -0
- flintai/eval/core/models/model_huggingface.py +61 -0
- flintai/eval/core/models/model_langserve.py +94 -0
- flintai/eval/core/models/model_litellm.py +38 -0
- flintai/eval/core/models/model_ollama.py +50 -0
- flintai/eval/core/models/model_openai.py +35 -0
- flintai/eval/core/models/model_openai_agent.py +80 -0
- flintai/eval/core/models/model_openai_compatible.py +57 -0
- flintai/eval/core/models/model_retry.py +134 -0
- flintai/eval/core/models/model_sync_wrapper.py +35 -0
- flintai/eval/db/__init__.py +0 -0
- flintai/eval/db/base/__init__.py +0 -0
- flintai/eval/db/base/detectors/__init__.py +1 -0
- flintai/eval/db/base/detectors/detector_helpers.py +78 -0
- flintai/eval/db/base/detectors/detector_repository.py +91 -0
- flintai/eval/db/base/detectors/detector_types.py +77 -0
- flintai/eval/db/base/eval/__init__.py +1 -0
- flintai/eval/db/base/eval/eval_helpers.py +227 -0
- flintai/eval/db/base/eval/eval_repository.py +115 -0
- flintai/eval/db/base/eval/eval_run.py +139 -0
- flintai/eval/db/base/eval/eval_types.py +129 -0
- flintai/eval/db/base/eval/model_eval_repository.py +78 -0
- flintai/eval/db/base/eval/model_eval_run_repository.py +70 -0
- flintai/eval/db/base/eval/model_eval_run_result_repository.py +40 -0
- flintai/eval/db/base/eval/model_eval_run_result_types.py +21 -0
- flintai/eval/db/base/eval/model_eval_run_types.py +84 -0
- flintai/eval/db/base/eval/model_eval_types.py +71 -0
- flintai/eval/db/base/message/__init__.py +0 -0
- flintai/eval/db/base/message/message_collection_helpers.py +61 -0
- flintai/eval/db/base/message/message_collection_repository.py +92 -0
- flintai/eval/db/base/message/message_collection_types.py +89 -0
- flintai/eval/db/base/models/__init__.py +0 -0
- flintai/eval/db/base/models/model_helpers.py +172 -0
- flintai/eval/db/base/models/model_repository.py +95 -0
- flintai/eval/db/base/models/model_types.py +156 -0
- flintai/eval/db/json/__init__.py +0 -0
- flintai/eval/db/json/repository_json.py +603 -0
- flintai/scan/__init__.py +0 -0
- flintai/scan/agent_scanner.py +797 -0
- flintai/scan/config/agent_opengrep_rules.yaml +533 -0
- flintai/scan/config/agent_taxonomy.json +481 -0
- flintai/scan/config/agentic_cvss_mapping.yaml +136 -0
- flintai/scan/config/compliance_mappings.json +110 -0
- flintai/scan/config/triage_prompt.txt +335 -0
- flintai/scan/constants.py +15 -0
- flintai/scan/file_filter.py +156 -0
- flintai/scan/llm_provider.py +197 -0
- flintai/scan/opengrep_resolver.py +12 -0
- flintai/scan/reasoner.py +552 -0
- flintai/scan/schema.py +337 -0
- flintai/scan/scorer.py +371 -0
- flintai/scan/secret_anonymizer.py +72 -0
- flintai/scan/static_scanner.py +542 -0
- flintai/scan/taxonomy.py +75 -0
- flintai/scan/tool_dispatcher.py +754 -0
- flintai/scan/trace_logger.py +118 -0
- flintai/scan/trace_logger_file.py +143 -0
- flintai/scan/trace_logger_log.py +100 -0
- flintai/scan/triage.py +496 -0
- flintai/schema.py +58 -0
- flintai_cli-1.0.0.dist-info/METADATA +442 -0
- flintai_cli-1.0.0.dist-info/RECORD +147 -0
- flintai_cli-1.0.0.dist-info/WHEEL +5 -0
- flintai_cli-1.0.0.dist-info/entry_points.txt +2 -0
- flintai_cli-1.0.0.dist-info/licenses/LICENSE +210 -0
- 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
|
flintai/scan/taxonomy.py
ADDED
|
@@ -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
|