cisco-ai-skill-scanner 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.
- cisco_ai_skill_scanner-1.0.0.dist-info/METADATA +253 -0
- cisco_ai_skill_scanner-1.0.0.dist-info/RECORD +100 -0
- cisco_ai_skill_scanner-1.0.0.dist-info/WHEEL +4 -0
- cisco_ai_skill_scanner-1.0.0.dist-info/entry_points.txt +4 -0
- cisco_ai_skill_scanner-1.0.0.dist-info/licenses/LICENSE +17 -0
- skillanalyzer/__init__.py +45 -0
- skillanalyzer/_version.py +34 -0
- skillanalyzer/api/__init__.py +25 -0
- skillanalyzer/api/api.py +34 -0
- skillanalyzer/api/api_cli.py +78 -0
- skillanalyzer/api/api_server.py +634 -0
- skillanalyzer/api/router.py +527 -0
- skillanalyzer/cli/__init__.py +25 -0
- skillanalyzer/cli/cli.py +816 -0
- skillanalyzer/config/__init__.py +26 -0
- skillanalyzer/config/config.py +149 -0
- skillanalyzer/config/config_parser.py +122 -0
- skillanalyzer/config/constants.py +85 -0
- skillanalyzer/core/__init__.py +24 -0
- skillanalyzer/core/analyzers/__init__.py +75 -0
- skillanalyzer/core/analyzers/aidefense_analyzer.py +872 -0
- skillanalyzer/core/analyzers/base.py +53 -0
- skillanalyzer/core/analyzers/behavioral/__init__.py +30 -0
- skillanalyzer/core/analyzers/behavioral/alignment/__init__.py +45 -0
- skillanalyzer/core/analyzers/behavioral/alignment/alignment_llm_client.py +240 -0
- skillanalyzer/core/analyzers/behavioral/alignment/alignment_orchestrator.py +216 -0
- skillanalyzer/core/analyzers/behavioral/alignment/alignment_prompt_builder.py +422 -0
- skillanalyzer/core/analyzers/behavioral/alignment/alignment_response_validator.py +136 -0
- skillanalyzer/core/analyzers/behavioral/alignment/threat_vulnerability_classifier.py +198 -0
- skillanalyzer/core/analyzers/behavioral_analyzer.py +453 -0
- skillanalyzer/core/analyzers/cross_skill_analyzer.py +490 -0
- skillanalyzer/core/analyzers/llm_analyzer.py +440 -0
- skillanalyzer/core/analyzers/llm_prompt_builder.py +270 -0
- skillanalyzer/core/analyzers/llm_provider_config.py +215 -0
- skillanalyzer/core/analyzers/llm_request_handler.py +284 -0
- skillanalyzer/core/analyzers/llm_response_parser.py +81 -0
- skillanalyzer/core/analyzers/meta_analyzer.py +845 -0
- skillanalyzer/core/analyzers/static.py +1105 -0
- skillanalyzer/core/analyzers/trigger_analyzer.py +341 -0
- skillanalyzer/core/analyzers/virustotal_analyzer.py +463 -0
- skillanalyzer/core/exceptions.py +77 -0
- skillanalyzer/core/loader.py +377 -0
- skillanalyzer/core/models.py +300 -0
- skillanalyzer/core/reporters/__init__.py +26 -0
- skillanalyzer/core/reporters/json_reporter.py +65 -0
- skillanalyzer/core/reporters/markdown_reporter.py +209 -0
- skillanalyzer/core/reporters/sarif_reporter.py +246 -0
- skillanalyzer/core/reporters/table_reporter.py +195 -0
- skillanalyzer/core/rules/__init__.py +19 -0
- skillanalyzer/core/rules/patterns.py +165 -0
- skillanalyzer/core/rules/yara_scanner.py +157 -0
- skillanalyzer/core/scanner.py +437 -0
- skillanalyzer/core/static_analysis/__init__.py +27 -0
- skillanalyzer/core/static_analysis/cfg/__init__.py +21 -0
- skillanalyzer/core/static_analysis/cfg/builder.py +439 -0
- skillanalyzer/core/static_analysis/context_extractor.py +742 -0
- skillanalyzer/core/static_analysis/dataflow/__init__.py +25 -0
- skillanalyzer/core/static_analysis/dataflow/forward_analysis.py +715 -0
- skillanalyzer/core/static_analysis/interprocedural/__init__.py +21 -0
- skillanalyzer/core/static_analysis/interprocedural/call_graph_analyzer.py +406 -0
- skillanalyzer/core/static_analysis/interprocedural/cross_file_analyzer.py +190 -0
- skillanalyzer/core/static_analysis/parser/__init__.py +21 -0
- skillanalyzer/core/static_analysis/parser/python_parser.py +380 -0
- skillanalyzer/core/static_analysis/semantic/__init__.py +28 -0
- skillanalyzer/core/static_analysis/semantic/name_resolver.py +206 -0
- skillanalyzer/core/static_analysis/semantic/type_analyzer.py +200 -0
- skillanalyzer/core/static_analysis/taint/__init__.py +21 -0
- skillanalyzer/core/static_analysis/taint/tracker.py +252 -0
- skillanalyzer/core/static_analysis/types/__init__.py +36 -0
- skillanalyzer/data/__init__.py +30 -0
- skillanalyzer/data/prompts/boilerplate_protection_rule_prompt.md +26 -0
- skillanalyzer/data/prompts/code_alignment_threat_analysis_prompt.md +901 -0
- skillanalyzer/data/prompts/llm_response_schema.json +71 -0
- skillanalyzer/data/prompts/skill_meta_analysis_prompt.md +303 -0
- skillanalyzer/data/prompts/skill_threat_analysis_prompt.md +263 -0
- skillanalyzer/data/prompts/unified_response_schema.md +97 -0
- skillanalyzer/data/rules/signatures.yaml +440 -0
- skillanalyzer/data/yara_rules/autonomy_abuse.yara +66 -0
- skillanalyzer/data/yara_rules/code_execution.yara +61 -0
- skillanalyzer/data/yara_rules/coercive_injection.yara +115 -0
- skillanalyzer/data/yara_rules/command_injection.yara +54 -0
- skillanalyzer/data/yara_rules/credential_harvesting.yara +115 -0
- skillanalyzer/data/yara_rules/prompt_injection.yara +71 -0
- skillanalyzer/data/yara_rules/script_injection.yara +83 -0
- skillanalyzer/data/yara_rules/skill_discovery_abuse.yara +57 -0
- skillanalyzer/data/yara_rules/sql_injection.yara +73 -0
- skillanalyzer/data/yara_rules/system_manipulation.yara +65 -0
- skillanalyzer/data/yara_rules/tool_chaining_abuse.yara +60 -0
- skillanalyzer/data/yara_rules/transitive_trust_abuse.yara +73 -0
- skillanalyzer/data/yara_rules/unicode_steganography.yara +65 -0
- skillanalyzer/hooks/__init__.py +21 -0
- skillanalyzer/hooks/pre_commit.py +450 -0
- skillanalyzer/threats/__init__.py +25 -0
- skillanalyzer/threats/threats.py +480 -0
- skillanalyzer/utils/__init__.py +28 -0
- skillanalyzer/utils/command_utils.py +129 -0
- skillanalyzer/utils/di_container.py +154 -0
- skillanalyzer/utils/file_utils.py +86 -0
- skillanalyzer/utils/logging_config.py +96 -0
- skillanalyzer/utils/logging_utils.py +71 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# Copyright 2026 Cisco Systems, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
16
|
+
"""
|
|
17
|
+
VirusTotal analyzer for scanning binary files using hash-based lookups.
|
|
18
|
+
|
|
19
|
+
This analyzer checks binary files (images, PDFs, archives, etc.) against
|
|
20
|
+
VirusTotal's database using SHA256 hash lookups. It does NOT scan code files
|
|
21
|
+
like Python, JavaScript, or Markdown files.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from ..models import Finding, Severity, Skill, ThreatCategory
|
|
31
|
+
from .base import BaseAnalyzer
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VirusTotalAnalyzer(BaseAnalyzer):
|
|
37
|
+
"""
|
|
38
|
+
Analyzer that checks binary files against VirusTotal using hash lookups.
|
|
39
|
+
|
|
40
|
+
Only scans binary file types (images, PDFs, executables, archives).
|
|
41
|
+
Excludes text-based code files (.py, .js, .md, .txt, .json, .yaml, etc.).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Binary file extensions to scan
|
|
45
|
+
BINARY_EXTENSIONS = {
|
|
46
|
+
# Images
|
|
47
|
+
".png",
|
|
48
|
+
".jpg",
|
|
49
|
+
".jpeg",
|
|
50
|
+
".gif",
|
|
51
|
+
".bmp",
|
|
52
|
+
".ico",
|
|
53
|
+
".svg",
|
|
54
|
+
".webp",
|
|
55
|
+
".tiff",
|
|
56
|
+
# Documents
|
|
57
|
+
".pdf",
|
|
58
|
+
".doc",
|
|
59
|
+
".docx",
|
|
60
|
+
".xls",
|
|
61
|
+
".xlsx",
|
|
62
|
+
".ppt",
|
|
63
|
+
".pptx",
|
|
64
|
+
# Archives
|
|
65
|
+
".zip",
|
|
66
|
+
".tar",
|
|
67
|
+
".gz",
|
|
68
|
+
".bz2",
|
|
69
|
+
".7z",
|
|
70
|
+
".rar",
|
|
71
|
+
".tgz",
|
|
72
|
+
# Executables
|
|
73
|
+
".exe",
|
|
74
|
+
".dll",
|
|
75
|
+
".so",
|
|
76
|
+
".dylib",
|
|
77
|
+
".bin",
|
|
78
|
+
".com", # .com = MS-DOS executables
|
|
79
|
+
# Other binaries
|
|
80
|
+
".wasm",
|
|
81
|
+
".class",
|
|
82
|
+
".jar",
|
|
83
|
+
".war",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Text/code extensions to EXCLUDE from scanning
|
|
87
|
+
EXCLUDED_EXTENSIONS = {
|
|
88
|
+
".py",
|
|
89
|
+
".js",
|
|
90
|
+
".ts",
|
|
91
|
+
".jsx",
|
|
92
|
+
".tsx",
|
|
93
|
+
".java",
|
|
94
|
+
".c",
|
|
95
|
+
".cpp",
|
|
96
|
+
".h",
|
|
97
|
+
".hpp",
|
|
98
|
+
".go",
|
|
99
|
+
".rs",
|
|
100
|
+
".rb",
|
|
101
|
+
".php",
|
|
102
|
+
".swift",
|
|
103
|
+
".kt",
|
|
104
|
+
".cs",
|
|
105
|
+
".vb",
|
|
106
|
+
".md",
|
|
107
|
+
".txt",
|
|
108
|
+
".json",
|
|
109
|
+
".yaml",
|
|
110
|
+
".yml",
|
|
111
|
+
".toml",
|
|
112
|
+
".ini",
|
|
113
|
+
".conf",
|
|
114
|
+
".cfg",
|
|
115
|
+
".xml",
|
|
116
|
+
".html",
|
|
117
|
+
".css",
|
|
118
|
+
".scss",
|
|
119
|
+
".sass",
|
|
120
|
+
".less",
|
|
121
|
+
".sh",
|
|
122
|
+
".bash",
|
|
123
|
+
".zsh",
|
|
124
|
+
".fish",
|
|
125
|
+
".ps1",
|
|
126
|
+
".bat",
|
|
127
|
+
".cmd",
|
|
128
|
+
".sql",
|
|
129
|
+
".graphql",
|
|
130
|
+
".proto",
|
|
131
|
+
".thrift",
|
|
132
|
+
".rst",
|
|
133
|
+
".org",
|
|
134
|
+
".adoc",
|
|
135
|
+
".tex",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def __init__(self, api_key: str | None = None, enabled: bool = True, upload_files: bool = False):
|
|
139
|
+
"""
|
|
140
|
+
Initialize VirusTotal analyzer.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
api_key: VirusTotal API key (optional, can be set via environment)
|
|
144
|
+
enabled: Whether the analyzer is enabled (default: True)
|
|
145
|
+
upload_files: If True, upload files to VT for scanning. If False (default),
|
|
146
|
+
only check existing hashes (more privacy-friendly)
|
|
147
|
+
"""
|
|
148
|
+
super().__init__("virustotal_analyzer")
|
|
149
|
+
self.api_key = api_key
|
|
150
|
+
self.enabled = enabled and api_key is not None
|
|
151
|
+
self.upload_files = upload_files
|
|
152
|
+
self.validated_binary_files = [] # Track files validated as safe by VirusTotal
|
|
153
|
+
self.base_url = "https://www.virustotal.com/api/v3"
|
|
154
|
+
self.session = httpx.Client()
|
|
155
|
+
|
|
156
|
+
if not self.api_key:
|
|
157
|
+
logger.warning("VirusTotal API key is missing!")
|
|
158
|
+
|
|
159
|
+
if self.api_key:
|
|
160
|
+
self.session.headers.update({"x-apikey": self.api_key, "Accept": "application/json"})
|
|
161
|
+
logger.info("VirusTotal API key configured (length: %d)", len(self.api_key))
|
|
162
|
+
else:
|
|
163
|
+
logger.warning("VirusTotal analyzer initialized without API key")
|
|
164
|
+
|
|
165
|
+
def analyze(self, skill: Skill) -> list[Finding]:
|
|
166
|
+
"""
|
|
167
|
+
Analyze binary files in the skill using VirusTotal hash lookups.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
skill: The skill to analyze
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of findings for malicious files. Also stores validated file paths
|
|
174
|
+
in skill metadata to allow suppression of binary file warnings.
|
|
175
|
+
"""
|
|
176
|
+
if not self.enabled:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
findings = []
|
|
180
|
+
validated_files = [] # Track files validated as safe
|
|
181
|
+
|
|
182
|
+
# Only scan binary files
|
|
183
|
+
binary_files = [f for f in skill.files if self._is_binary_file(f.relative_path)]
|
|
184
|
+
|
|
185
|
+
for skill_file in binary_files:
|
|
186
|
+
try:
|
|
187
|
+
file_path = Path(skill.directory) / skill_file.relative_path
|
|
188
|
+
file_hash = self._calculate_sha256(file_path)
|
|
189
|
+
|
|
190
|
+
logger.info("Checking file: %s (SHA256: %s)", skill_file.relative_path, file_hash)
|
|
191
|
+
|
|
192
|
+
vt_result, hash_found = self._query_virustotal(file_hash)
|
|
193
|
+
|
|
194
|
+
if hash_found:
|
|
195
|
+
total = vt_result.get("total_engines", 0)
|
|
196
|
+
malicious = vt_result.get("malicious", 0)
|
|
197
|
+
suspicious = vt_result.get("suspicious", 0)
|
|
198
|
+
|
|
199
|
+
if malicious > 0 or suspicious > 0:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Found in VT database: %d malicious, %d suspicious out of %d vendors",
|
|
202
|
+
malicious,
|
|
203
|
+
suspicious,
|
|
204
|
+
total,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
logger.info("Found in VT database: %d/%d vendors flagged (file appears safe)", malicious, total)
|
|
208
|
+
validated_files.append(skill_file.relative_path)
|
|
209
|
+
|
|
210
|
+
if vt_result.get("permalink"):
|
|
211
|
+
logger.info("Report: %s", vt_result["permalink"])
|
|
212
|
+
|
|
213
|
+
if malicious > 0:
|
|
214
|
+
findings.append(
|
|
215
|
+
self._create_finding(skill_file=skill_file, file_hash=file_hash, vt_result=vt_result)
|
|
216
|
+
)
|
|
217
|
+
elif self.upload_files:
|
|
218
|
+
logger.warning("Hash not found in VT database - uploading for analysis")
|
|
219
|
+
vt_result = self._upload_and_scan(file_path, file_hash)
|
|
220
|
+
|
|
221
|
+
if vt_result:
|
|
222
|
+
if vt_result.get("malicious", 0) > 0:
|
|
223
|
+
findings.append(
|
|
224
|
+
self._create_finding(skill_file=skill_file, file_hash=file_hash, vt_result=vt_result)
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
validated_files.append(skill_file.relative_path)
|
|
228
|
+
else:
|
|
229
|
+
logger.warning("Hash not found in VT database - upload disabled, cannot scan unknown file")
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.warning("VirusTotal scan failed for %s: %s", skill_file.relative_path, e)
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Store validated files in analyzer instance for post-processing
|
|
236
|
+
self.validated_binary_files = validated_files
|
|
237
|
+
|
|
238
|
+
return findings
|
|
239
|
+
|
|
240
|
+
def _is_binary_file(self, file_path: str) -> bool:
|
|
241
|
+
"""
|
|
242
|
+
Check if a file should be scanned (is binary, not code).
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
file_path: Path to the file
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if file should be scanned
|
|
249
|
+
"""
|
|
250
|
+
path = Path(file_path)
|
|
251
|
+
ext = path.suffix.lower()
|
|
252
|
+
|
|
253
|
+
# Explicitly exclude text/code files
|
|
254
|
+
if ext in self.EXCLUDED_EXTENSIONS:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Include known binary extensions
|
|
258
|
+
if ext in self.BINARY_EXTENSIONS:
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
# For unknown extensions, default to not scanning
|
|
262
|
+
# (conservative approach to avoid scanning code files)
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def _calculate_sha256(self, file_path: Path) -> str:
|
|
266
|
+
"""
|
|
267
|
+
Calculate SHA256 hash of a file.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
file_path: Path to the file
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
SHA256 hash as hex string
|
|
274
|
+
"""
|
|
275
|
+
sha256_hash = hashlib.sha256()
|
|
276
|
+
with open(file_path, "rb") as f:
|
|
277
|
+
# Read file in chunks for memory efficiency
|
|
278
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
279
|
+
sha256_hash.update(byte_block)
|
|
280
|
+
return sha256_hash.hexdigest()
|
|
281
|
+
|
|
282
|
+
def _query_virustotal(self, file_hash: str) -> tuple[dict | None, bool]:
|
|
283
|
+
"""
|
|
284
|
+
Query VirusTotal API for file hash.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
file_hash: SHA256 hash of the file
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Tuple of (detection stats dictionary or None, hash_found boolean)
|
|
291
|
+
- If hash found: (stats_dict, True)
|
|
292
|
+
- If hash not found (404): (None, False)
|
|
293
|
+
- If error: (None, False)
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
response = self.session.get(f"{self.base_url}/files/{file_hash}", timeout=10)
|
|
297
|
+
|
|
298
|
+
if response.status_code == 404:
|
|
299
|
+
# File hash not in VirusTotal database (never scanned before)
|
|
300
|
+
return None, False
|
|
301
|
+
|
|
302
|
+
if response.status_code == 200:
|
|
303
|
+
data = response.json()
|
|
304
|
+
|
|
305
|
+
# Extract detection statistics
|
|
306
|
+
stats = data.get("data", {}).get("attributes", {}).get("last_analysis_stats", {})
|
|
307
|
+
|
|
308
|
+
# Construct GUI URL (not API endpoint)
|
|
309
|
+
gui_url = f"https://www.virustotal.com/gui/file/{file_hash}"
|
|
310
|
+
|
|
311
|
+
result = {
|
|
312
|
+
"malicious": stats.get("malicious", 0),
|
|
313
|
+
"suspicious": stats.get("suspicious", 0),
|
|
314
|
+
"undetected": stats.get("undetected", 0),
|
|
315
|
+
"harmless": stats.get("harmless", 0),
|
|
316
|
+
"total_engines": sum(stats.values()),
|
|
317
|
+
"scan_date": data.get("data", {}).get("attributes", {}).get("last_analysis_date"),
|
|
318
|
+
"permalink": gui_url,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result, True
|
|
322
|
+
|
|
323
|
+
if response.status_code == 429:
|
|
324
|
+
logger.warning("VirusTotal rate limit exceeded. Please wait before retrying.")
|
|
325
|
+
else:
|
|
326
|
+
logger.warning("VirusTotal API returned status %d", response.status_code)
|
|
327
|
+
return None, False
|
|
328
|
+
|
|
329
|
+
except httpx.RequestError as e:
|
|
330
|
+
logger.warning("VirusTotal API request failed: %s", e)
|
|
331
|
+
return None, False
|
|
332
|
+
|
|
333
|
+
def _upload_and_scan(self, file_path: Path, file_hash: str) -> dict | None:
|
|
334
|
+
"""
|
|
335
|
+
Upload file to VirusTotal for scanning.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
file_path: Path to the file to upload
|
|
339
|
+
file_hash: SHA256 hash of the file
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Dictionary with detection stats or None if upload failed
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
import time
|
|
346
|
+
|
|
347
|
+
file_size = file_path.stat().st_size
|
|
348
|
+
if file_size > 32 * 1024 * 1024:
|
|
349
|
+
logger.warning("File too large to upload to VT: %s (%d bytes)", file_path.name, file_size)
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
with open(file_path, "rb") as f:
|
|
353
|
+
files = {"file": (file_path.name, f)}
|
|
354
|
+
response = self.session.post(f"{self.base_url}/files", files=files, timeout=60)
|
|
355
|
+
|
|
356
|
+
if response.status_code != 200:
|
|
357
|
+
logger.warning("File upload failed with status %d", response.status_code)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
upload_data = response.json()
|
|
361
|
+
analysis_id = upload_data.get("data", {}).get("id")
|
|
362
|
+
|
|
363
|
+
if not analysis_id:
|
|
364
|
+
logger.warning("No analysis ID returned from upload")
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
logger.info("File uploaded successfully. Analysis ID: %s", analysis_id)
|
|
368
|
+
|
|
369
|
+
max_retries = 6
|
|
370
|
+
for attempt in range(max_retries):
|
|
371
|
+
time.sleep(10)
|
|
372
|
+
|
|
373
|
+
analysis_response = self.session.get(f"{self.base_url}/analyses/{analysis_id}", timeout=10)
|
|
374
|
+
|
|
375
|
+
if analysis_response.status_code == 200:
|
|
376
|
+
analysis_data = analysis_response.json()
|
|
377
|
+
status = analysis_data.get("data", {}).get("attributes", {}).get("status")
|
|
378
|
+
stats = analysis_data.get("data", {}).get("attributes", {}).get("stats", {})
|
|
379
|
+
|
|
380
|
+
if status == "completed":
|
|
381
|
+
result, _ = self._query_virustotal(file_hash)
|
|
382
|
+
if result and result.get("total_engines", 0) > 0:
|
|
383
|
+
logger.info(
|
|
384
|
+
"Analysis complete: %d/%d vendors scanned",
|
|
385
|
+
result.get("malicious", 0),
|
|
386
|
+
result.get("total_engines", 0),
|
|
387
|
+
)
|
|
388
|
+
return result
|
|
389
|
+
else:
|
|
390
|
+
total_scans = sum(stats.values()) if stats else 0
|
|
391
|
+
logger.info(
|
|
392
|
+
"Status: %s (%d engines scanned, attempt %d/%d)",
|
|
393
|
+
status,
|
|
394
|
+
total_scans,
|
|
395
|
+
attempt + 1,
|
|
396
|
+
max_retries,
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
logger.warning("Analysis query failed with status %d", analysis_response.status_code)
|
|
400
|
+
|
|
401
|
+
logger.warning("Analysis still processing after %d seconds", max_retries * 10)
|
|
402
|
+
result, _ = self._query_virustotal(file_hash)
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
except httpx.RequestError as e:
|
|
406
|
+
logger.warning("File upload to VirusTotal failed: %s", e)
|
|
407
|
+
return None
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.warning("Unexpected error during file upload: %s", e)
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
def _create_finding(self, skill_file, file_hash: str, vt_result: dict) -> Finding:
|
|
413
|
+
"""
|
|
414
|
+
Create a finding for a malicious file.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
skill_file: The SkillFile object
|
|
418
|
+
file_hash: SHA256 hash of the file
|
|
419
|
+
vt_result: VirusTotal scan results
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Finding object
|
|
423
|
+
"""
|
|
424
|
+
malicious_count = vt_result.get("malicious", 0)
|
|
425
|
+
total_engines = vt_result.get("total_engines", 0)
|
|
426
|
+
|
|
427
|
+
# Determine severity based on detection ratio
|
|
428
|
+
if total_engines > 0:
|
|
429
|
+
detection_ratio = malicious_count / total_engines
|
|
430
|
+
if detection_ratio >= 0.3: # 30%+ detection rate
|
|
431
|
+
severity = Severity.CRITICAL
|
|
432
|
+
elif detection_ratio >= 0.1: # 10-30% detection rate
|
|
433
|
+
severity = Severity.HIGH
|
|
434
|
+
else:
|
|
435
|
+
severity = Severity.MEDIUM
|
|
436
|
+
else:
|
|
437
|
+
severity = Severity.MEDIUM
|
|
438
|
+
|
|
439
|
+
return Finding(
|
|
440
|
+
id=f"VT_{file_hash[:8]}",
|
|
441
|
+
rule_id="VIRUSTOTAL_MALICIOUS_FILE",
|
|
442
|
+
category=ThreatCategory.MALWARE,
|
|
443
|
+
severity=severity,
|
|
444
|
+
title=f"Malicious file detected: {skill_file.relative_path}",
|
|
445
|
+
description=(
|
|
446
|
+
f"VirusTotal detected this file as malicious. "
|
|
447
|
+
f"{malicious_count}/{total_engines} security vendors flagged this file. "
|
|
448
|
+
f"SHA256: {file_hash}"
|
|
449
|
+
),
|
|
450
|
+
file_path=skill_file.relative_path,
|
|
451
|
+
line_number=None,
|
|
452
|
+
snippet=f"File hash: {file_hash}",
|
|
453
|
+
remediation=(
|
|
454
|
+
"Remove this file from the skill package. "
|
|
455
|
+
"Binary files flagged by multiple antivirus engines should not be included."
|
|
456
|
+
),
|
|
457
|
+
analyzer="virustotal",
|
|
458
|
+
metadata={
|
|
459
|
+
"confidence": 0.95 if malicious_count >= 5 else 0.8,
|
|
460
|
+
"references": [f"https://www.virustotal.com/gui/file/{file_hash}"],
|
|
461
|
+
"file_hash": file_hash,
|
|
462
|
+
},
|
|
463
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright 2026 Cisco Systems, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
16
|
+
|
|
17
|
+
"""Skill Analyzer exceptions.
|
|
18
|
+
|
|
19
|
+
This module defines custom exceptions for Skill Analyzer operations.
|
|
20
|
+
All exceptions inherit from SkillAnalyzerError for easy catching.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> from skillanalyzer import Scanner
|
|
24
|
+
>>> from skillanalyzer.core.exceptions import SkillLoadError
|
|
25
|
+
>>>
|
|
26
|
+
>>> scanner = Scanner()
|
|
27
|
+
>>>
|
|
28
|
+
>>> try:
|
|
29
|
+
... skill = scanner.load_skill("path/to/skill")
|
|
30
|
+
... except SkillLoadError as e:
|
|
31
|
+
... print(f"Failed to load skill: {e}")
|
|
32
|
+
... except SkillAnalysisError as e:
|
|
33
|
+
... print(f"Analysis failed: {e}")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SkillAnalyzerError(Exception):
|
|
38
|
+
"""Base exception for all Skill Analyzer errors."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkillLoadError(SkillAnalyzerError):
|
|
44
|
+
"""Raised when unable to load a skill package.
|
|
45
|
+
|
|
46
|
+
This can indicate:
|
|
47
|
+
- Missing SKILL.md file
|
|
48
|
+
- Invalid YAML frontmatter
|
|
49
|
+
- Corrupted skill package
|
|
50
|
+
- File system errors
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SkillAnalysisError(SkillAnalyzerError):
|
|
57
|
+
"""Raised when skill analysis fails.
|
|
58
|
+
|
|
59
|
+
This typically indicates:
|
|
60
|
+
- Analyzer configuration errors
|
|
61
|
+
- Internal analysis errors
|
|
62
|
+
- Resource exhaustion during analysis
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SkillValidationError(SkillAnalyzerError):
|
|
69
|
+
"""Raised when skill validation fails.
|
|
70
|
+
|
|
71
|
+
This indicates:
|
|
72
|
+
- Invalid skill manifest
|
|
73
|
+
- Missing required fields
|
|
74
|
+
- Invalid skill structure
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
pass
|