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,754 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tool_dispatcher.py — Tool Implementations for the Agentic Reasoning Loop
|
|
3
|
+
=========================================================================
|
|
4
|
+
Routes tool calls from the AI reasoning agent to their implementations.
|
|
5
|
+
|
|
6
|
+
DESIGN PRINCIPLES:
|
|
7
|
+
- All tools are READ-ONLY. No tool mutates state, writes files, or makes
|
|
8
|
+
network calls. The repository files are already in memory from Layer 1.
|
|
9
|
+
- report_finding() is the only "stateful" tool — it accumulates findings
|
|
10
|
+
into the session list. This is intentional and auditable.
|
|
11
|
+
- Every tool returns a string. The string becomes the tool message in the
|
|
12
|
+
conversation and is visible in the trace log.
|
|
13
|
+
- Tool errors return an "ERROR: ..." string rather than raising exceptions,
|
|
14
|
+
so the agent can decide how to handle failures gracefully.
|
|
15
|
+
|
|
16
|
+
SAFETY:
|
|
17
|
+
- fetch_file() enforces path validation — no path traversal.
|
|
18
|
+
- run_targeted_bandit() is rate-limited by TOOL_CALL_LIMITS in tools.py.
|
|
19
|
+
- The tool allowlist is enforced at the SDK level, not here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import fnmatch
|
|
24
|
+
import functools
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
import typing
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
from cvss import CVSS4
|
|
35
|
+
from flintai.schema import RepoFile
|
|
36
|
+
from flintai.scan.schema import AgentProfile, RawFinding
|
|
37
|
+
from flintai.scan.secret_anonymizer import anonymize_secrets
|
|
38
|
+
from flintai.scan.static_scanner import StaticFinding
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Lazy-loaded CVSS mapping — loaded once on first compute_cvss() call
|
|
44
|
+
_CVSS_MAPPING: dict | None = None
|
|
45
|
+
_CVSS_MAPPING_PATH = os.path.join(
|
|
46
|
+
os.path.dirname(os.path.abspath(__file__)), "config", "agentic_cvss_mapping.yaml"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_cvss_mapping() -> dict:
|
|
51
|
+
"""Load agentic_cvss_mapping.yaml once and cache it."""
|
|
52
|
+
global _CVSS_MAPPING
|
|
53
|
+
if _CVSS_MAPPING is not None:
|
|
54
|
+
return _CVSS_MAPPING
|
|
55
|
+
try:
|
|
56
|
+
with open(_CVSS_MAPPING_PATH, "r", encoding="utf-8") as f:
|
|
57
|
+
data = yaml.safe_load(f)
|
|
58
|
+
_CVSS_MAPPING = data.get("agentic_cvss_mapping", {})
|
|
59
|
+
return _CVSS_MAPPING
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.warning("Could not load CVSS mapping: %s", e)
|
|
62
|
+
_CVSS_MAPPING = {}
|
|
63
|
+
return _CVSS_MAPPING
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Token approximation ───────────────────────────────────────────────────────
|
|
67
|
+
MAX_TOKENS_PER_FILE = 4000 # per fetch_file() call
|
|
68
|
+
CHARS_PER_TOKEN = 4 # rough approximation
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _truncate(text: str, max_tokens: int = MAX_TOKENS_PER_FILE) -> str:
|
|
72
|
+
max_chars = max_tokens * CHARS_PER_TOKEN
|
|
73
|
+
if len(text) <= max_chars:
|
|
74
|
+
return text
|
|
75
|
+
return (
|
|
76
|
+
text[:max_chars]
|
|
77
|
+
+ f"\n\n[... truncated at {max_tokens} tokens. Use start_line/end_line to read more ...]" # noqa: B950
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Session state — populated by ToolDispatcher.__init__ ─────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ToolDispatcher:
|
|
85
|
+
"""
|
|
86
|
+
Stateful dispatcher for a single scan session.
|
|
87
|
+
|
|
88
|
+
Holds references to the in-memory repository data so tool implementations
|
|
89
|
+
can work without any I/O. Tracks per-tool call counts for rate limiting.
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
dispatcher = ToolDispatcher(
|
|
93
|
+
repo_files={"crew.py": RepoFile(...), ...},
|
|
94
|
+
agents=[AgentProfile(...), ...],
|
|
95
|
+
static_findings=[StaticFinding(...), ...],
|
|
96
|
+
)
|
|
97
|
+
result = dispatcher.dispatch("fetch_file", {"path": "crew.py"})
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
repo_files: dict[str, RepoFile],
|
|
103
|
+
agents: list[AgentProfile],
|
|
104
|
+
static_findings: list[StaticFinding],
|
|
105
|
+
):
|
|
106
|
+
self._files = repo_files # {relative_path: RepoFile}
|
|
107
|
+
self._agents = {a.agent_id: a for a in agents}
|
|
108
|
+
self._findings = static_findings
|
|
109
|
+
self._session_findings: list[RawFinding] = []
|
|
110
|
+
self._call_counts: dict[str, int] = {} # tool_name → call count
|
|
111
|
+
self._tokens_consumed: int = 0 # total tokens fetched via fetch_file
|
|
112
|
+
|
|
113
|
+
# Build a flattened search index: (path, line_number, line_text)
|
|
114
|
+
self._search_index: list[tuple[str, int, str]] = []
|
|
115
|
+
for path, rf in self._files.items():
|
|
116
|
+
for i, line in enumerate(rf.content.splitlines(), start=1):
|
|
117
|
+
self._search_index.append((path, i, line))
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def session_findings(self) -> list[RawFinding]:
|
|
121
|
+
"""All findings reported via report_finding() during this session."""
|
|
122
|
+
return self._session_findings
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def tokens_consumed(self) -> int:
|
|
126
|
+
"""Total approximate tokens fetched via fetch_file() during this session."""
|
|
127
|
+
return self._tokens_consumed
|
|
128
|
+
|
|
129
|
+
# ── Tool methods (ADK-facing interface) ──────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def read_source(
|
|
132
|
+
self,
|
|
133
|
+
resource_type: str,
|
|
134
|
+
path: str = "",
|
|
135
|
+
pattern: str = "",
|
|
136
|
+
start_line: int = 0,
|
|
137
|
+
end_line: int = 0,
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Read source data from the scanned repository.
|
|
140
|
+
|
|
141
|
+
Use resource_type to select what to read:
|
|
142
|
+
- 'file': Fetch file content (supports line ranges). Returns up to 4000 tokens.
|
|
143
|
+
- 'agent': Return the full extracted AgentProfile for an agent, untruncated.
|
|
144
|
+
- 'list': List all available files, optionally filtered by a glob pattern.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
resource_type: What to read — 'file', 'agent', or 'list'.
|
|
148
|
+
path: For 'file': relative file path. For 'agent': the agent_id.
|
|
149
|
+
pattern: For 'list': optional glob pattern (e.g. '*.py').
|
|
150
|
+
start_line: For 'file': first line to return (1-indexed, optional).
|
|
151
|
+
end_line: For 'file': last line to return (optional).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
File content, agent profile, or file listing. ERROR string on failure.
|
|
155
|
+
"""
|
|
156
|
+
self._call_counts["read_source"] = self._call_counts.get("read_source", 0) + 1
|
|
157
|
+
if resource_type == "file":
|
|
158
|
+
if not path:
|
|
159
|
+
return "ERROR: 'path' is required when resource_type='file'."
|
|
160
|
+
self._call_counts["fetch_file"] = self._call_counts.get("fetch_file", 0) + 1
|
|
161
|
+
return self._fetch_file(
|
|
162
|
+
path,
|
|
163
|
+
start_line if start_line else None,
|
|
164
|
+
end_line if end_line else None,
|
|
165
|
+
)
|
|
166
|
+
elif resource_type == "agent":
|
|
167
|
+
if not path:
|
|
168
|
+
return (
|
|
169
|
+
"ERROR: 'path' (agent_id) is required when resource_type='agent'."
|
|
170
|
+
)
|
|
171
|
+
return self._get_agent_profile(path)
|
|
172
|
+
elif resource_type == "list":
|
|
173
|
+
return self._list_files(pattern or None)
|
|
174
|
+
return (
|
|
175
|
+
f"ERROR: Invalid resource_type '{resource_type}'. "
|
|
176
|
+
"Use 'file', 'agent', or 'list'."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def analyze_code(
|
|
180
|
+
self,
|
|
181
|
+
mode: str,
|
|
182
|
+
pattern: str = "",
|
|
183
|
+
file_path: str = "",
|
|
184
|
+
file_extension: str = "",
|
|
185
|
+
max_results: int = 20,
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Analyze code patterns in the repository.
|
|
188
|
+
|
|
189
|
+
Use mode to select the analysis type:
|
|
190
|
+
- 'search': Search all files for lines matching a regex or substring.
|
|
191
|
+
- 'imports': List all imports in a Python file and identify repo-local ones.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
mode: 'search' for pattern matching, 'imports' for import resolution.
|
|
195
|
+
pattern: For 'search': regex or substring (case-insensitive).
|
|
196
|
+
file_path: For 'imports': relative path of the Python file.
|
|
197
|
+
file_extension: For 'search': optional filter (e.g. '.py').
|
|
198
|
+
max_results: For 'search': max matching lines (default 20).
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Matching results or import list. ERROR string on failure.
|
|
202
|
+
"""
|
|
203
|
+
self._call_counts["analyze_code"] = self._call_counts.get("analyze_code", 0) + 1
|
|
204
|
+
if mode == "search":
|
|
205
|
+
if not pattern:
|
|
206
|
+
return "ERROR: 'pattern' is required when mode='search'."
|
|
207
|
+
return self._search_codebase(pattern, file_extension or None, max_results)
|
|
208
|
+
elif mode == "imports":
|
|
209
|
+
if not file_path:
|
|
210
|
+
return "ERROR: 'file_path' is required when mode='imports'."
|
|
211
|
+
return self._resolve_imports(file_path)
|
|
212
|
+
return f"ERROR: Invalid mode '{mode}'. Use 'search' or 'imports'."
|
|
213
|
+
|
|
214
|
+
def get_findings(
|
|
215
|
+
self,
|
|
216
|
+
file_path: str,
|
|
217
|
+
mode: str = "cached",
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Retrieve security findings for a specific file.
|
|
220
|
+
|
|
221
|
+
Use mode to select the source:
|
|
222
|
+
- 'cached': Return pre-computed findings from Bandit, OpenGrep,
|
|
223
|
+
and detect-secrets (fast, no cost).
|
|
224
|
+
- 'fresh': Run Bandit now (slower, rate-limited to 5 calls/session).
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
file_path: Relative path of the file.
|
|
228
|
+
mode: 'cached' for pre-computed, 'fresh' to run Bandit. Default: 'cached'.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Security findings for the file. ERROR string on failure.
|
|
232
|
+
"""
|
|
233
|
+
self._call_counts["get_findings"] = self._call_counts.get("get_findings", 0) + 1
|
|
234
|
+
if mode == "fresh":
|
|
235
|
+
return self._run_targeted_bandit(file_path)
|
|
236
|
+
return self._get_static_findings_for_file(file_path)
|
|
237
|
+
|
|
238
|
+
# ── Tool implementations ─────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
def _fetch_file(
|
|
241
|
+
self,
|
|
242
|
+
path: str,
|
|
243
|
+
start_line: int | None = None,
|
|
244
|
+
end_line: int | None = None,
|
|
245
|
+
) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Return the content of a repository file.
|
|
248
|
+
Supports optional line range for targeted reads of large files.
|
|
249
|
+
"""
|
|
250
|
+
# Path validation — prevent traversal and reject negative line numbers.
|
|
251
|
+
# Use normpath + split to check individual path components rather than
|
|
252
|
+
# a substring match, which is bypassable with lstrip tricks.
|
|
253
|
+
normalized = os.path.normpath(path)
|
|
254
|
+
if any(part == ".." for part in normalized.replace("\\", "/").split("/")):
|
|
255
|
+
return f"ERROR: Path traversal not permitted: {path!r}"
|
|
256
|
+
|
|
257
|
+
# Exact match first
|
|
258
|
+
rf = self._files.get(path) or self._files.get(normalized)
|
|
259
|
+
|
|
260
|
+
# Fuzzy match: if path is a filename, find it in any directory
|
|
261
|
+
if rf is None:
|
|
262
|
+
basename = os.path.basename(path)
|
|
263
|
+
matches = [p for p in self._files if os.path.basename(p) == basename]
|
|
264
|
+
if len(matches) == 1:
|
|
265
|
+
rf = self._files[matches[0]]
|
|
266
|
+
path = matches[0] # update path for display
|
|
267
|
+
elif len(matches) > 1:
|
|
268
|
+
return (
|
|
269
|
+
f"ERROR: Ambiguous path '{path}' matches multiple files:\n"
|
|
270
|
+
+ "\n".join(f" {m}" for m in sorted(matches))
|
|
271
|
+
+ "\nPlease specify the full path."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if rf is None:
|
|
275
|
+
available = sorted(self._files.keys())[:20]
|
|
276
|
+
return (
|
|
277
|
+
f"ERROR: File not found: '{path}'\n"
|
|
278
|
+
f"Available files (first 20):\n"
|
|
279
|
+
+ "\n".join(f" {p}" for p in available)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
lines = rf.content.splitlines()
|
|
283
|
+
total = len(lines)
|
|
284
|
+
|
|
285
|
+
if start_line is not None or end_line is not None:
|
|
286
|
+
# Reject negative line numbers — they would silently clamp to 1
|
|
287
|
+
# via max(1, ...) and confuse the agent about what was returned.
|
|
288
|
+
if start_line is not None and start_line < 1:
|
|
289
|
+
return (
|
|
290
|
+
f"ERROR: start_line must be >= 1, got {start_line}. "
|
|
291
|
+
f"Use a value between 1 and {total}."
|
|
292
|
+
)
|
|
293
|
+
if end_line is not None and end_line < 1:
|
|
294
|
+
return (
|
|
295
|
+
f"ERROR: end_line must be >= 1, got {end_line}. "
|
|
296
|
+
f"Use a value between 1 and {total}."
|
|
297
|
+
)
|
|
298
|
+
# Fix #27: Validate line number bounds before slicing.
|
|
299
|
+
# Out-of-range requests return an empty result that confuses the agent.
|
|
300
|
+
if start_line is not None and start_line > total:
|
|
301
|
+
return (
|
|
302
|
+
f"ERROR: start_line {start_line} exceeds file length ({total} lines) "
|
|
303
|
+
f"for '{path}'. Use a value between 1 and {total}."
|
|
304
|
+
)
|
|
305
|
+
if end_line is not None and end_line > total:
|
|
306
|
+
# Clamp silently — requesting lines past EOF is a benign mistake.
|
|
307
|
+
end_line = total
|
|
308
|
+
|
|
309
|
+
s = max(1, start_line or 1) - 1 # convert to 0-indexed
|
|
310
|
+
e = min(total, end_line or total)
|
|
311
|
+
selected = lines[s:e]
|
|
312
|
+
header = f"# {path} (lines {s+1}–{e} of {total})\n"
|
|
313
|
+
content = header + "\n".join(
|
|
314
|
+
f"{s+1+i:4d} {line}" for i, line in enumerate(selected)
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
header = f"# {path} ({total} lines)\n"
|
|
318
|
+
content = header + "\n".join(
|
|
319
|
+
f"{i+1:4d} {line}" for i, line in enumerate(lines)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
result = _truncate(content)
|
|
323
|
+
result = anonymize_secrets(result)
|
|
324
|
+
self._tokens_consumed += len(result) // CHARS_PER_TOKEN
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
def _get_agent_profile(self, agent_id: str) -> str:
|
|
328
|
+
"""Return the full AgentProfile for an agent, without truncation."""
|
|
329
|
+
agent = self._agents.get(agent_id)
|
|
330
|
+
if agent is None:
|
|
331
|
+
available = sorted(self._agents.keys())
|
|
332
|
+
return (
|
|
333
|
+
f"ERROR: Agent '{agent_id}' not found.\n"
|
|
334
|
+
f"Available agents: {available}"
|
|
335
|
+
)
|
|
336
|
+
parts = [
|
|
337
|
+
f"AgentProfile: {agent.agent_id}",
|
|
338
|
+
f" Framework: {agent.framework}",
|
|
339
|
+
f" Source file: {agent.source_file}",
|
|
340
|
+
f" Role: {agent.role or 'not specified'}",
|
|
341
|
+
f" Goal: {agent.goal or 'not specified'}",
|
|
342
|
+
f" Backstory: {agent.backstory or 'not specified'}",
|
|
343
|
+
f" LLM: {agent.llm or 'not specified'}",
|
|
344
|
+
f" Memory: {agent.memory}",
|
|
345
|
+
f" Allow delegation: {agent.allow_delegation}",
|
|
346
|
+
f" Verbose: {agent.verbose}",
|
|
347
|
+
f" Tools: {agent.tools or []}",
|
|
348
|
+
f" Tool details: {agent.tool_details or []}",
|
|
349
|
+
f" System prompt: {agent.system_prompt or 'not specified'}",
|
|
350
|
+
f"\n--- Full raw_code ---\n{anonymize_secrets(agent.raw_code)}",
|
|
351
|
+
]
|
|
352
|
+
return "\n".join(parts)
|
|
353
|
+
|
|
354
|
+
def _resolve_imports(self, file_path: str) -> str:
|
|
355
|
+
"""
|
|
356
|
+
Parse a Python file's import statements and identify which imported
|
|
357
|
+
modules are also present in the repository.
|
|
358
|
+
"""
|
|
359
|
+
rf = self._files.get(file_path)
|
|
360
|
+
if rf is None:
|
|
361
|
+
return f"ERROR: File not found: '{file_path}'"
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
tree = ast.parse(rf.content)
|
|
365
|
+
except SyntaxError as e:
|
|
366
|
+
return f"ERROR: Could not parse {file_path}: {e}"
|
|
367
|
+
|
|
368
|
+
stdlib_skip = {
|
|
369
|
+
"os",
|
|
370
|
+
"sys",
|
|
371
|
+
"re",
|
|
372
|
+
"json",
|
|
373
|
+
"time",
|
|
374
|
+
"datetime",
|
|
375
|
+
"pathlib",
|
|
376
|
+
"typing",
|
|
377
|
+
"dataclasses",
|
|
378
|
+
"abc",
|
|
379
|
+
"uuid",
|
|
380
|
+
"tempfile",
|
|
381
|
+
"subprocess",
|
|
382
|
+
"logging",
|
|
383
|
+
"argparse",
|
|
384
|
+
"base64",
|
|
385
|
+
"urllib",
|
|
386
|
+
"collections",
|
|
387
|
+
"itertools",
|
|
388
|
+
"functools",
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
imported = []
|
|
392
|
+
for node in ast.walk(tree):
|
|
393
|
+
if isinstance(node, ast.Import):
|
|
394
|
+
for alias in node.names:
|
|
395
|
+
imported.append(alias.name.split(".")[0])
|
|
396
|
+
elif isinstance(node, ast.ImportFrom):
|
|
397
|
+
if node.module:
|
|
398
|
+
imported.append(node.module.split(".")[0])
|
|
399
|
+
|
|
400
|
+
results = []
|
|
401
|
+
for mod in sorted(set(imported)):
|
|
402
|
+
if mod in stdlib_skip:
|
|
403
|
+
continue
|
|
404
|
+
# Look for matching file in repo
|
|
405
|
+
candidates = [
|
|
406
|
+
p for p in self._files if os.path.basename(p).replace(".py", "") == mod
|
|
407
|
+
]
|
|
408
|
+
if candidates:
|
|
409
|
+
results.append(f" {mod} → {candidates[0]} [IN REPO — can fetch]")
|
|
410
|
+
else:
|
|
411
|
+
results.append(f" {mod} → (external package or not fetched)")
|
|
412
|
+
|
|
413
|
+
if not results:
|
|
414
|
+
return f"No non-stdlib imports found in {file_path}"
|
|
415
|
+
|
|
416
|
+
return f"Imports in {file_path}:\n" + "\n".join(results)
|
|
417
|
+
|
|
418
|
+
def _search_codebase(
|
|
419
|
+
self,
|
|
420
|
+
pattern: str,
|
|
421
|
+
file_extension: str | None = None,
|
|
422
|
+
max_results: int = 20,
|
|
423
|
+
) -> str:
|
|
424
|
+
"""
|
|
425
|
+
Search all fetched files for lines matching a pattern.
|
|
426
|
+
Returns file:line matches with surrounding context.
|
|
427
|
+
"""
|
|
428
|
+
matches = []
|
|
429
|
+
regex = None
|
|
430
|
+
try:
|
|
431
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
432
|
+
except re.error:
|
|
433
|
+
# Fall back to literal substring match
|
|
434
|
+
regex = None
|
|
435
|
+
|
|
436
|
+
for file_path, line_no, line_text in self._search_index:
|
|
437
|
+
if file_extension and not file_path.endswith(file_extension):
|
|
438
|
+
continue
|
|
439
|
+
hit = (
|
|
440
|
+
(regex.search(line_text) is not None)
|
|
441
|
+
if regex
|
|
442
|
+
else (pattern.lower() in line_text.lower())
|
|
443
|
+
)
|
|
444
|
+
if hit:
|
|
445
|
+
matches.append(f" {file_path}:{line_no}: {line_text.rstrip()}")
|
|
446
|
+
if len(matches) >= max_results:
|
|
447
|
+
break
|
|
448
|
+
|
|
449
|
+
if not matches:
|
|
450
|
+
return f"No matches found for pattern '{pattern}'"
|
|
451
|
+
|
|
452
|
+
header = f"Search results for '{pattern}' ({len(matches)} matches"
|
|
453
|
+
if len(matches) == max_results:
|
|
454
|
+
header += f", limit reached — narrow your pattern for more precision" # noqa: F541
|
|
455
|
+
header += "):"
|
|
456
|
+
return header + "\n" + "\n".join(matches)
|
|
457
|
+
|
|
458
|
+
def _run_targeted_bandit(self, file_path: str) -> str:
|
|
459
|
+
"""
|
|
460
|
+
Run Bandit on a specific file from the in-memory repository.
|
|
461
|
+
Writes the file to a temp dir, runs Bandit, returns results.
|
|
462
|
+
"""
|
|
463
|
+
rf = self._files.get(file_path)
|
|
464
|
+
if rf is None:
|
|
465
|
+
return f"ERROR: File not found: '{file_path}'"
|
|
466
|
+
|
|
467
|
+
if not file_path.endswith(".py"):
|
|
468
|
+
return f"ERROR: Bandit only works on Python files: '{file_path}'"
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
472
|
+
safe_name = os.path.basename(file_path).replace("/", "_")
|
|
473
|
+
tmp_path = os.path.join(tmp, safe_name)
|
|
474
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
475
|
+
f.write(rf.content)
|
|
476
|
+
|
|
477
|
+
result = subprocess.run(
|
|
478
|
+
["bandit", tmp_path, "-f", "json", "-q", "--severity-level", "low"],
|
|
479
|
+
capture_output=True,
|
|
480
|
+
text=True,
|
|
481
|
+
timeout=30,
|
|
482
|
+
)
|
|
483
|
+
stdout = result.stdout.strip()
|
|
484
|
+
|
|
485
|
+
# Fix #8: Guard against pathologically large Bandit output.
|
|
486
|
+
# A well-formed Python file should never produce more than ~500 KB
|
|
487
|
+
# of Bandit JSON. If it does, truncate before parsing to prevent
|
|
488
|
+
# memory exhaustion on adversarially crafted files.
|
|
489
|
+
_MAX_BANDIT_OUTPUT = 500_000 # bytes
|
|
490
|
+
if len(stdout) > _MAX_BANDIT_OUTPUT:
|
|
491
|
+
stdout = stdout[:_MAX_BANDIT_OUTPUT]
|
|
492
|
+
# The truncated JSON will fail to parse below — handle gracefully.
|
|
493
|
+
|
|
494
|
+
if not stdout:
|
|
495
|
+
return f"Bandit found no issues in {file_path}"
|
|
496
|
+
|
|
497
|
+
data = json.loads(stdout)
|
|
498
|
+
issues = data.get("results", [])
|
|
499
|
+
if not issues:
|
|
500
|
+
return f"Bandit found no issues in {file_path}"
|
|
501
|
+
|
|
502
|
+
lines = [f"Bandit results for {file_path} ({len(issues)} issues):"]
|
|
503
|
+
for issue in issues:
|
|
504
|
+
lines.append(
|
|
505
|
+
f" [{issue.get('issue_severity', '?')}] "
|
|
506
|
+
f"Line {issue.get('line_number', '?')}: "
|
|
507
|
+
f"{issue.get('test_id', '?')} — {issue.get('issue_text', '')}"
|
|
508
|
+
)
|
|
509
|
+
if issue.get("code"):
|
|
510
|
+
lines.append(f" Code: {issue['code'][:100].strip()}")
|
|
511
|
+
return "\n".join(lines)
|
|
512
|
+
|
|
513
|
+
except FileNotFoundError:
|
|
514
|
+
return "ERROR: Bandit is not installed or not on PATH"
|
|
515
|
+
except subprocess.TimeoutExpired:
|
|
516
|
+
return f"ERROR: Bandit timed out scanning {file_path}"
|
|
517
|
+
except Exception as e:
|
|
518
|
+
return f"ERROR: Bandit scan failed: {e}"
|
|
519
|
+
|
|
520
|
+
def _get_static_findings_for_file(self, file_path: str) -> str:
|
|
521
|
+
"""Return pre-computed static findings attributed to a specific file."""
|
|
522
|
+
basename = os.path.basename(file_path)
|
|
523
|
+
matching = [
|
|
524
|
+
f
|
|
525
|
+
for f in self._findings
|
|
526
|
+
if (
|
|
527
|
+
hasattr(f, "filepath")
|
|
528
|
+
and (
|
|
529
|
+
f.filepath == file_path or os.path.basename(f.filepath) == basename
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
]
|
|
533
|
+
if not matching:
|
|
534
|
+
return f"No pre-computed static findings for '{file_path}'"
|
|
535
|
+
|
|
536
|
+
lines = [f"Static findings for {file_path} ({len(matching)} findings):"]
|
|
537
|
+
for f in matching:
|
|
538
|
+
lines.append(
|
|
539
|
+
f" [{f.severity.upper()}] {f.tool.upper()} rule {f.rule_id} "
|
|
540
|
+
f"line {f.line}: {f.message}"
|
|
541
|
+
)
|
|
542
|
+
if f.evidence:
|
|
543
|
+
lines.append(
|
|
544
|
+
f" Evidence: {anonymize_secrets(f.evidence[:120].strip())}"
|
|
545
|
+
)
|
|
546
|
+
return "\n".join(lines)
|
|
547
|
+
|
|
548
|
+
def _list_files(self, pattern: str | None = None) -> str:
|
|
549
|
+
"""List all fetched file paths, optionally filtered by glob pattern."""
|
|
550
|
+
paths = sorted(self._files.keys())
|
|
551
|
+
if pattern:
|
|
552
|
+
paths = [
|
|
553
|
+
p
|
|
554
|
+
for p in paths
|
|
555
|
+
if fnmatch.fnmatch(p, pattern)
|
|
556
|
+
or fnmatch.fnmatch(os.path.basename(p), pattern)
|
|
557
|
+
]
|
|
558
|
+
if not paths:
|
|
559
|
+
return (
|
|
560
|
+
f"No files match pattern '{pattern}'"
|
|
561
|
+
if pattern
|
|
562
|
+
else "No files in repository."
|
|
563
|
+
)
|
|
564
|
+
return f"Files ({len(paths)}):\n" + "\n".join(f" {p}" for p in paths)
|
|
565
|
+
|
|
566
|
+
def compute_cvss(
|
|
567
|
+
self,
|
|
568
|
+
vuln_type: str,
|
|
569
|
+
exposed_over_network: bool = False,
|
|
570
|
+
requires_auth: bool = False,
|
|
571
|
+
user_interaction: bool = False,
|
|
572
|
+
affects_remote_system: bool = False,
|
|
573
|
+
) -> str:
|
|
574
|
+
"""Compute a deterministic CVSS v4 base score for a confirmed vulnerability.
|
|
575
|
+
|
|
576
|
+
ALWAYS call this before calling report_finding(). Use the score and
|
|
577
|
+
vector it returns in your finding. Do not estimate severity yourself.
|
|
578
|
+
vuln_type must exactly match a key in the OWASP ASI taxonomy
|
|
579
|
+
(e.g. 'hardcoded_credentials', 'arbitrary_code_execution').
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
vuln_type: Subcategory key from the OWASP ASI taxonomy.
|
|
583
|
+
exposed_over_network: True if the component is network-reachable.
|
|
584
|
+
requires_auth: True if exploitation requires authentication.
|
|
585
|
+
user_interaction: True if a user must act for exploitation.
|
|
586
|
+
affects_remote_system: True if impact extends to downstream systems.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
JSON string with vuln_type, vector, score, and severity.
|
|
590
|
+
"""
|
|
591
|
+
self._call_counts["compute_cvss"] = self._call_counts.get("compute_cvss", 0) + 1
|
|
592
|
+
mapping = _load_cvss_mapping()
|
|
593
|
+
|
|
594
|
+
if vuln_type not in mapping:
|
|
595
|
+
available = ", ".join(sorted(mapping.keys())[:10])
|
|
596
|
+
return (
|
|
597
|
+
f"ERROR: vuln_type '{vuln_type}' not found in CVSS mapping.\n"
|
|
598
|
+
f"Available types (first 10): {available}\n"
|
|
599
|
+
f"Check config/agentic_cvss_mapping.yaml for the full list."
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
base_vector = mapping[vuln_type].get("vector", "")
|
|
603
|
+
if not base_vector:
|
|
604
|
+
return f"ERROR: No base CVSS vector found for '{vuln_type}'"
|
|
605
|
+
|
|
606
|
+
# Parse vector components into a mutable dict
|
|
607
|
+
metrics = dict(re.findall(r"([A-Z]{1,2}):([A-Z]{1,2})", base_vector))
|
|
608
|
+
|
|
609
|
+
# Apply context flag overrides
|
|
610
|
+
if exposed_over_network:
|
|
611
|
+
metrics["AV"] = "N"
|
|
612
|
+
if requires_auth:
|
|
613
|
+
metrics["PR"] = "L"
|
|
614
|
+
if user_interaction:
|
|
615
|
+
metrics["UI"] = "P"
|
|
616
|
+
if affects_remote_system:
|
|
617
|
+
metrics["SC"] = "H"
|
|
618
|
+
metrics["SI"] = "H"
|
|
619
|
+
metrics["SA"] = "H"
|
|
620
|
+
|
|
621
|
+
# Reconstruct vector string
|
|
622
|
+
new_vector = "CVSS:4.0/" + "/".join(f"{k}:{v}" for k, v in metrics.items())
|
|
623
|
+
|
|
624
|
+
# Compute score using the cvss library
|
|
625
|
+
try:
|
|
626
|
+
cvss_obj = CVSS4(new_vector)
|
|
627
|
+
score = round(cvss_obj.base_score, 1)
|
|
628
|
+
severity = cvss_obj.severities()[
|
|
629
|
+
0
|
|
630
|
+
] # 'Critical' | 'High' | 'Medium' | 'Low' | 'None'
|
|
631
|
+
except Exception as e:
|
|
632
|
+
return f"ERROR: CVSS computation failed for vector '{new_vector}': {e}"
|
|
633
|
+
|
|
634
|
+
result = {
|
|
635
|
+
"vuln_type": vuln_type,
|
|
636
|
+
"vector": new_vector,
|
|
637
|
+
"score": score,
|
|
638
|
+
"severity": severity,
|
|
639
|
+
"overrides": {
|
|
640
|
+
"exposed_over_network": exposed_over_network,
|
|
641
|
+
"requires_auth": requires_auth,
|
|
642
|
+
"user_interaction": user_interaction,
|
|
643
|
+
"affects_remote_system": affects_remote_system,
|
|
644
|
+
},
|
|
645
|
+
}
|
|
646
|
+
return json.dumps(result)
|
|
647
|
+
|
|
648
|
+
def report_finding(
|
|
649
|
+
self,
|
|
650
|
+
category: str,
|
|
651
|
+
subcategory: str,
|
|
652
|
+
title: str,
|
|
653
|
+
description: str,
|
|
654
|
+
impact: str,
|
|
655
|
+
remediation: str,
|
|
656
|
+
affected_component: str,
|
|
657
|
+
evidence: str,
|
|
658
|
+
confidence: str,
|
|
659
|
+
hallucination_flag: bool,
|
|
660
|
+
evidence_file: str = "",
|
|
661
|
+
evidence_line: int = 0,
|
|
662
|
+
agent_name: str = "",
|
|
663
|
+
) -> str:
|
|
664
|
+
"""Record a confirmed security finding.
|
|
665
|
+
|
|
666
|
+
Call compute_cvss() first to get the correct score and vector, then
|
|
667
|
+
call this function to record the finding. Do not call this with
|
|
668
|
+
speculative findings — only report what you can directly evidence.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
category: ASI category key (e.g. 'asi01_agent_goal_hijack').
|
|
672
|
+
subcategory: Specific vulnerability type (e.g. 'direct_prompt_injection').
|
|
673
|
+
title: Short human-readable title.
|
|
674
|
+
description: Technical description of the issue.
|
|
675
|
+
impact: What an attacker could achieve.
|
|
676
|
+
remediation: Concrete fix guidance.
|
|
677
|
+
affected_component: File, agent ID, or tool name.
|
|
678
|
+
evidence: Direct code snippet or pattern (max 200 chars).
|
|
679
|
+
confidence: One of 'high', 'medium', 'low'.
|
|
680
|
+
hallucination_flag: True if you suspect but cannot fully prove the issue.
|
|
681
|
+
evidence_file: Relative file path where the vulnerability was found.
|
|
682
|
+
evidence_line: Line number in the file (use 0 if unknown).
|
|
683
|
+
agent_name: The Agent name from the AGENT PROFILES.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
Confirmation string with the finding number.
|
|
687
|
+
"""
|
|
688
|
+
self._call_counts["report_finding"] = (
|
|
689
|
+
self._call_counts.get("report_finding", 0) + 1
|
|
690
|
+
)
|
|
691
|
+
finding = {
|
|
692
|
+
"category": category,
|
|
693
|
+
"subcategory": subcategory,
|
|
694
|
+
"title": title,
|
|
695
|
+
"description": description,
|
|
696
|
+
"impact": impact,
|
|
697
|
+
"remediation": remediation,
|
|
698
|
+
"affected_component": affected_component,
|
|
699
|
+
"evidence": anonymize_secrets(evidence[:200]),
|
|
700
|
+
"confidence": confidence,
|
|
701
|
+
"hallucination_flag": bool(hallucination_flag),
|
|
702
|
+
"evidence_file": evidence_file,
|
|
703
|
+
"evidence_line": int(evidence_line),
|
|
704
|
+
"agent_name": agent_name,
|
|
705
|
+
}
|
|
706
|
+
self._session_findings.append(finding)
|
|
707
|
+
return (
|
|
708
|
+
f"Finding #{len(self._session_findings)} recorded: "
|
|
709
|
+
f"[{category}/{subcategory}] {title} (confidence={confidence})"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def get_adk_tools(self, tracer=None) -> list:
|
|
713
|
+
"""Return ADK-compatible tool callables for this dispatcher.
|
|
714
|
+
|
|
715
|
+
When tracer is provided, wraps each tool call with tracer.record_call()
|
|
716
|
+
so the JSONL trace file records the agent's investigation.
|
|
717
|
+
|
|
718
|
+
Resolves deferred type annotations (from __future__ import annotations)
|
|
719
|
+
so ADK's function parameter parser gets real types, not strings.
|
|
720
|
+
"""
|
|
721
|
+
tools = [
|
|
722
|
+
self.read_source,
|
|
723
|
+
self.analyze_code,
|
|
724
|
+
self.get_findings,
|
|
725
|
+
self.compute_cvss,
|
|
726
|
+
self.report_finding,
|
|
727
|
+
]
|
|
728
|
+
for tool in tools:
|
|
729
|
+
fn = tool.__func__ if hasattr(tool, "__func__") else tool
|
|
730
|
+
try:
|
|
731
|
+
fn.__annotations__ = typing.get_type_hints(fn)
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
734
|
+
|
|
735
|
+
if tracer is None:
|
|
736
|
+
return tools
|
|
737
|
+
|
|
738
|
+
def _wrap(tool_name: str, fn):
|
|
739
|
+
@functools.wraps(fn)
|
|
740
|
+
def traced(*args, **kwargs):
|
|
741
|
+
with tracer.record_call(tool_name, kwargs, tracer._iterations) as ctx:
|
|
742
|
+
result = fn(*args, **kwargs)
|
|
743
|
+
ctx.set_result(result)
|
|
744
|
+
return result
|
|
745
|
+
|
|
746
|
+
return traced
|
|
747
|
+
|
|
748
|
+
return [
|
|
749
|
+
_wrap("read_source", self.read_source),
|
|
750
|
+
_wrap("analyze_code", self.analyze_code),
|
|
751
|
+
_wrap("get_findings", self.get_findings),
|
|
752
|
+
_wrap("compute_cvss", self.compute_cvss),
|
|
753
|
+
_wrap("report_finding", self.report_finding),
|
|
754
|
+
]
|