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,440 @@
|
|
|
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
|
+
"""
|
|
18
|
+
LLM-based analyzer for semantic security analysis.
|
|
19
|
+
|
|
20
|
+
Production analyzer with:
|
|
21
|
+
- LiteLLM for universal provider support (100+ models)
|
|
22
|
+
- Prompt injection protection with random delimiters
|
|
23
|
+
- Retry logic with exponential backoff
|
|
24
|
+
- AWS Bedrock support with IAM roles
|
|
25
|
+
- Async analysis for performance
|
|
26
|
+
- AITech taxonomy alignment
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from ...core.models import Finding, Severity, Skill, ThreatCategory
|
|
34
|
+
from ...threats.threats import ThreatMapping
|
|
35
|
+
from .base import BaseAnalyzer
|
|
36
|
+
from .llm_prompt_builder import PromptBuilder
|
|
37
|
+
from .llm_provider_config import ProviderConfig
|
|
38
|
+
from .llm_request_handler import LLMRequestHandler
|
|
39
|
+
from .llm_response_parser import ResponseParser
|
|
40
|
+
|
|
41
|
+
# Export constants for backward compatibility with tests
|
|
42
|
+
try:
|
|
43
|
+
from .llm_provider_config import GOOGLE_GENAI_AVAILABLE, LITELLM_AVAILABLE
|
|
44
|
+
except (ImportError, ModuleNotFoundError):
|
|
45
|
+
LITELLM_AVAILABLE = False
|
|
46
|
+
GOOGLE_GENAI_AVAILABLE = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LLMProvider(str, Enum):
|
|
50
|
+
"""Supported LLM providers via LiteLLM.
|
|
51
|
+
- openai: OpenAI models (gpt-4o, gpt-4-turbo, etc.)
|
|
52
|
+
- anthropic: Anthropic models (claude-3-5-sonnet, claude-3-opus, etc.)
|
|
53
|
+
- azure-openai: Azure OpenAI Service
|
|
54
|
+
- azure-ai: Azure AI Service (alternative)
|
|
55
|
+
- aws-bedrock: AWS Bedrock models
|
|
56
|
+
- gcp-vertex: Google Cloud Vertex AI
|
|
57
|
+
- ollama: Local Ollama models
|
|
58
|
+
- openrouter: OpenRouter API
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
OPENAI = "openai"
|
|
62
|
+
ANTHROPIC = "anthropic"
|
|
63
|
+
AZURE_OPENAI = "azure-openai"
|
|
64
|
+
AZURE_AI = "azure-ai"
|
|
65
|
+
AWS_BEDROCK = "aws-bedrock"
|
|
66
|
+
GCP_VERTEX = "gcp-vertex"
|
|
67
|
+
OLLAMA = "ollama"
|
|
68
|
+
OPENROUTER = "openrouter"
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def is_valid_provider(cls, provider: str) -> bool:
|
|
72
|
+
"""Check if a provider string is valid."""
|
|
73
|
+
try:
|
|
74
|
+
cls(provider.lower())
|
|
75
|
+
return True
|
|
76
|
+
except ValueError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SecurityError(Exception):
|
|
81
|
+
"""Custom exception for security violations in LLM prompts."""
|
|
82
|
+
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LLMAnalyzer(BaseAnalyzer):
|
|
87
|
+
"""
|
|
88
|
+
Production LLM analyzer using LLM as a judge.
|
|
89
|
+
|
|
90
|
+
Features:
|
|
91
|
+
- Universal LLM support via LiteLLM (Anthropic, OpenAI, Azure, Bedrock)
|
|
92
|
+
- Prompt injection protection with random delimiters
|
|
93
|
+
- Retry logic with exponential backoff
|
|
94
|
+
- Async analysis for better performance
|
|
95
|
+
- AWS Bedrock credential support
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> analyzer = LLMAnalyzer(
|
|
99
|
+
... model=os.getenv("SKILL_SCANNER_LLM_MODEL", "claude-3-5-sonnet-20241022"),
|
|
100
|
+
... api_key=os.getenv("SKILL_SCANNER_LLM_API_KEY")
|
|
101
|
+
... )
|
|
102
|
+
>>> findings = analyzer.analyze(skill)
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
model: str | None = None,
|
|
108
|
+
api_key: str | None = None,
|
|
109
|
+
max_tokens: int = 4000,
|
|
110
|
+
temperature: float = 0.0,
|
|
111
|
+
max_retries: int = 3,
|
|
112
|
+
rate_limit_delay: float = 2.0,
|
|
113
|
+
timeout: int = 120,
|
|
114
|
+
# Azure-specific
|
|
115
|
+
base_url: str | None = None,
|
|
116
|
+
api_version: str | None = None,
|
|
117
|
+
# AWS Bedrock-specific
|
|
118
|
+
aws_region: str | None = None,
|
|
119
|
+
aws_profile: str | None = None,
|
|
120
|
+
aws_session_token: str | None = None,
|
|
121
|
+
# Provider selection (can be enum or string)
|
|
122
|
+
provider: str | None = None,
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Initialize enhanced LLM analyzer.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
model: Model identifier (e.g., "claude-3-5-sonnet-20241022", "gpt-4o", "bedrock/anthropic.claude-v2")
|
|
129
|
+
api_key: API key (if None, reads from environment)
|
|
130
|
+
max_tokens: Maximum tokens for response
|
|
131
|
+
temperature: Sampling temperature (0.0 for deterministic)
|
|
132
|
+
max_retries: Max retry attempts on rate limits
|
|
133
|
+
rate_limit_delay: Base delay for exponential backoff
|
|
134
|
+
timeout: Request timeout in seconds
|
|
135
|
+
base_url: Custom base URL (for Azure)
|
|
136
|
+
api_version: API version (for Azure)
|
|
137
|
+
aws_region: AWS region (for Bedrock)
|
|
138
|
+
aws_profile: AWS profile name (for Bedrock)
|
|
139
|
+
aws_session_token: AWS session token (for Bedrock)
|
|
140
|
+
provider: LLM provider name (e.g., "openai", "anthropic", "aws-bedrock", etc.)
|
|
141
|
+
Can be enum or string (e.g., "openai", "anthropic", "aws-bedrock")
|
|
142
|
+
"""
|
|
143
|
+
super().__init__("llm_analyzer")
|
|
144
|
+
|
|
145
|
+
# Handle provider selection: if provider is specified, map to default model
|
|
146
|
+
if provider is not None and model is None:
|
|
147
|
+
# Normalize provider string (handle both enum and string inputs)
|
|
148
|
+
if isinstance(provider, LLMProvider):
|
|
149
|
+
provider_str = provider.value
|
|
150
|
+
else:
|
|
151
|
+
provider_str = str(provider).lower().strip()
|
|
152
|
+
|
|
153
|
+
# Validate provider if it's a string
|
|
154
|
+
if not isinstance(provider, LLMProvider) and not LLMProvider.is_valid_provider(provider_str):
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Invalid provider '{provider}'. Valid providers: {', '.join([p.value for p in LLMProvider])}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Map provider to default model
|
|
160
|
+
model_mapping = {
|
|
161
|
+
"openai": "gpt-4o",
|
|
162
|
+
"anthropic": "claude-3-5-sonnet-20241022",
|
|
163
|
+
"azure-openai": "azure/gpt-4o",
|
|
164
|
+
"azure-ai": "azure/gpt-4",
|
|
165
|
+
"aws-bedrock": "bedrock/anthropic.claude-v2",
|
|
166
|
+
"gcp-vertex": "vertex_ai/gemini-1.5-pro",
|
|
167
|
+
"ollama": "ollama/llama2",
|
|
168
|
+
"openrouter": "openrouter/openai/gpt-4",
|
|
169
|
+
}
|
|
170
|
+
model = model_mapping.get(provider_str, "claude-3-5-sonnet-20241022")
|
|
171
|
+
elif model is None:
|
|
172
|
+
# Default to anthropic if nothing specified
|
|
173
|
+
model = "claude-3-5-sonnet-20241022"
|
|
174
|
+
|
|
175
|
+
# Initialize components
|
|
176
|
+
self.provider_config = ProviderConfig(
|
|
177
|
+
model=model,
|
|
178
|
+
api_key=api_key,
|
|
179
|
+
base_url=base_url,
|
|
180
|
+
api_version=api_version,
|
|
181
|
+
aws_region=aws_region,
|
|
182
|
+
aws_profile=aws_profile,
|
|
183
|
+
aws_session_token=aws_session_token,
|
|
184
|
+
)
|
|
185
|
+
self.provider_config.validate()
|
|
186
|
+
|
|
187
|
+
self.request_handler = LLMRequestHandler(
|
|
188
|
+
provider_config=self.provider_config,
|
|
189
|
+
max_tokens=max_tokens,
|
|
190
|
+
temperature=temperature,
|
|
191
|
+
max_retries=max_retries,
|
|
192
|
+
rate_limit_delay=rate_limit_delay,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
self.prompt_builder = PromptBuilder()
|
|
197
|
+
self.response_parser = ResponseParser()
|
|
198
|
+
|
|
199
|
+
# Expose commonly accessed attributes for backward compatibility
|
|
200
|
+
self.model = self.provider_config.model
|
|
201
|
+
self.api_key = self.provider_config.api_key
|
|
202
|
+
self.is_bedrock = self.provider_config.is_bedrock
|
|
203
|
+
self.is_gemini = self.provider_config.is_gemini
|
|
204
|
+
self.aws_region = self.provider_config.aws_region
|
|
205
|
+
self.aws_profile = self.provider_config.aws_profile
|
|
206
|
+
self.aws_session_token = self.provider_config.aws_session_token
|
|
207
|
+
self.max_tokens = max_tokens
|
|
208
|
+
self.temperature = temperature
|
|
209
|
+
self.max_retries = max_retries
|
|
210
|
+
self.rate_limit_delay = rate_limit_delay
|
|
211
|
+
self.timeout = timeout
|
|
212
|
+
|
|
213
|
+
def analyze(self, skill: Skill) -> list[Finding]:
|
|
214
|
+
"""
|
|
215
|
+
Analyze skill using LLM (sync wrapper for async method).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
skill: Skill to analyze
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of security findings
|
|
222
|
+
"""
|
|
223
|
+
# Run async analysis in event loop
|
|
224
|
+
return asyncio.run(self.analyze_async(skill))
|
|
225
|
+
|
|
226
|
+
async def analyze_async(self, skill: Skill) -> list[Finding]:
|
|
227
|
+
"""
|
|
228
|
+
Analyze skill using LLM (async).
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
skill: Skill to analyze
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
List of security findings
|
|
235
|
+
"""
|
|
236
|
+
findings = []
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Format all skill components
|
|
240
|
+
manifest_text = self.prompt_builder.format_manifest(skill.manifest)
|
|
241
|
+
code_files_text = self.prompt_builder.format_code_files(skill)
|
|
242
|
+
referenced_files_text = self.prompt_builder.format_referenced_files(skill)
|
|
243
|
+
|
|
244
|
+
# Create protected prompt
|
|
245
|
+
prompt, injection_detected = self.prompt_builder.build_threat_analysis_prompt(
|
|
246
|
+
skill.name,
|
|
247
|
+
skill.description,
|
|
248
|
+
manifest_text,
|
|
249
|
+
skill.instruction_body[:3000],
|
|
250
|
+
code_files_text,
|
|
251
|
+
referenced_files_text,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# If injection detected, create immediate finding
|
|
255
|
+
if injection_detected:
|
|
256
|
+
findings.append(
|
|
257
|
+
Finding(
|
|
258
|
+
id=f"prompt_injection_{skill.name}",
|
|
259
|
+
rule_id="LLM_PROMPT_INJECTION_DETECTED",
|
|
260
|
+
category=ThreatCategory.PROMPT_INJECTION,
|
|
261
|
+
severity=Severity.HIGH,
|
|
262
|
+
title="Prompt injection attack detected",
|
|
263
|
+
description="Skill content contains delimiter injection attempt",
|
|
264
|
+
file_path="SKILL.md",
|
|
265
|
+
remediation="Remove malicious delimiter tags from skill content",
|
|
266
|
+
analyzer="llm",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
return findings
|
|
270
|
+
|
|
271
|
+
# Query LLM with retry logic
|
|
272
|
+
# System message includes context about AITech taxonomy for structured outputs
|
|
273
|
+
messages = [
|
|
274
|
+
{
|
|
275
|
+
"role": "system",
|
|
276
|
+
"content": """You are a security expert analyzing Claude Skills. Follow the analysis framework provided.
|
|
277
|
+
|
|
278
|
+
When selecting AITech codes for findings, use these mappings:
|
|
279
|
+
- AITech-1.1: Direct prompt injection in SKILL.md (jailbreak, instruction override)
|
|
280
|
+
- AITech-1.2: Indirect prompt injection (transitive trust, following untrusted content)
|
|
281
|
+
- AITech-2.1: Social engineering (deceptive descriptions/metadata)
|
|
282
|
+
- AITech-8.2: Data exfiltration/exposure (unauthorized access, credential theft, hardcoded secrets)
|
|
283
|
+
- AITech-9.1: Model/agentic manipulation (command injection, code injection, SQL injection, obfuscation)
|
|
284
|
+
- AITech-12.1: Tool exploitation (tool poisoning, shadowing, unauthorized use)
|
|
285
|
+
- AITech-13.3: Availability disruption (resource abuse, DoS, infinite loops)
|
|
286
|
+
- AITech-15.1: Harmful/misleading content (deceptive content, misinformation)
|
|
287
|
+
|
|
288
|
+
The structured output schema will enforce these exact codes.""",
|
|
289
|
+
},
|
|
290
|
+
{"role": "user", "content": prompt},
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
response_content = await self.request_handler.make_request(
|
|
294
|
+
messages, context=f"threat analysis for {skill.name}"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Parse response
|
|
298
|
+
analysis_result = self.response_parser.parse(response_content)
|
|
299
|
+
|
|
300
|
+
# Convert to findings
|
|
301
|
+
findings = self._convert_to_findings(analysis_result, skill)
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
print(f"LLM analysis failed for {skill.name}: {e}")
|
|
305
|
+
# Return empty findings - don't pollute results with errors
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
return findings
|
|
309
|
+
|
|
310
|
+
def _convert_to_findings(self, analysis_result: dict[str, Any], skill: Skill) -> list[Finding]:
|
|
311
|
+
"""Convert LLM analysis results to Finding objects."""
|
|
312
|
+
findings = []
|
|
313
|
+
|
|
314
|
+
for idx, llm_finding in enumerate(analysis_result.get("findings", [])):
|
|
315
|
+
try:
|
|
316
|
+
# Parse severity
|
|
317
|
+
severity_str = llm_finding.get("severity", "MEDIUM").upper()
|
|
318
|
+
severity = Severity(severity_str)
|
|
319
|
+
|
|
320
|
+
# Parse AITech code (required by structured output)
|
|
321
|
+
aitech_code = llm_finding.get("aitech")
|
|
322
|
+
if not aitech_code:
|
|
323
|
+
print("Warning: Missing AITech code in LLM finding, skipping")
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
# Get threat mapping from AITech code
|
|
327
|
+
threat_mapping = ThreatMapping.get_threat_mapping_by_aitech(aitech_code)
|
|
328
|
+
|
|
329
|
+
# Map AITech code to ThreatCategory enum
|
|
330
|
+
category_str = ThreatMapping.get_threat_category_from_aitech(aitech_code)
|
|
331
|
+
try:
|
|
332
|
+
category = ThreatCategory(category_str)
|
|
333
|
+
except ValueError:
|
|
334
|
+
print(
|
|
335
|
+
f"Warning: Invalid ThreatCategory '{category_str}' for AITech '{aitech_code}', using policy_violation"
|
|
336
|
+
)
|
|
337
|
+
category = ThreatCategory.POLICY_VIOLATION
|
|
338
|
+
|
|
339
|
+
# Filter false positives: Suppress findings about reading internal files
|
|
340
|
+
# Skills reading their own files is normal and expected behavior
|
|
341
|
+
title = llm_finding.get("title", "")
|
|
342
|
+
description = llm_finding.get("description", "")
|
|
343
|
+
|
|
344
|
+
# Check if this is about reading internal/referenced files
|
|
345
|
+
is_internal_file_reading = (
|
|
346
|
+
aitech_code == "AITech-1.2" # Indirect prompt injection
|
|
347
|
+
and category == ThreatCategory.PROMPT_INJECTION
|
|
348
|
+
and (
|
|
349
|
+
"local files" in description.lower()
|
|
350
|
+
or "referenced files" in description.lower()
|
|
351
|
+
or "external guideline files" in description.lower()
|
|
352
|
+
or "unvalidated local files" in description.lower()
|
|
353
|
+
or "transitive trust" in description.lower()
|
|
354
|
+
and "external" not in description.lower()
|
|
355
|
+
)
|
|
356
|
+
and
|
|
357
|
+
# Check if referenced files are internal (within skill package)
|
|
358
|
+
all(self._is_internal_file(skill, ref_file) for ref_file in skill.referenced_files)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if is_internal_file_reading:
|
|
362
|
+
# Suppress false positive - reading internal files is normal
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
# Lower severity for missing tool declarations (not a security issue)
|
|
366
|
+
if category == ThreatCategory.UNAUTHORIZED_TOOL_USE and (
|
|
367
|
+
"missing tool" in title.lower()
|
|
368
|
+
or "undeclared tool" in title.lower()
|
|
369
|
+
or "not specified" in description.lower()
|
|
370
|
+
):
|
|
371
|
+
severity = Severity.LOW # Downgrade from MEDIUM/HIGH to LOW
|
|
372
|
+
|
|
373
|
+
# Parse location
|
|
374
|
+
location = llm_finding.get("location", "")
|
|
375
|
+
file_path = None
|
|
376
|
+
line_number = None
|
|
377
|
+
|
|
378
|
+
if ":" in location:
|
|
379
|
+
parts = location.split(":")
|
|
380
|
+
file_path = parts[0]
|
|
381
|
+
if len(parts) > 1 and parts[1].isdigit():
|
|
382
|
+
line_number = int(parts[1])
|
|
383
|
+
|
|
384
|
+
# Get AISubtech code if provided
|
|
385
|
+
aisubtech_code = llm_finding.get("aisubtech")
|
|
386
|
+
|
|
387
|
+
# Create finding with AITech alignment
|
|
388
|
+
finding = Finding(
|
|
389
|
+
id=f"llm_finding_{skill.name}_{idx}",
|
|
390
|
+
rule_id=f"LLM_{category_str.upper()}",
|
|
391
|
+
category=category,
|
|
392
|
+
severity=severity,
|
|
393
|
+
title=title,
|
|
394
|
+
description=description,
|
|
395
|
+
file_path=file_path,
|
|
396
|
+
line_number=line_number,
|
|
397
|
+
snippet=llm_finding.get("evidence", ""),
|
|
398
|
+
remediation=llm_finding.get("remediation", ""),
|
|
399
|
+
analyzer="llm",
|
|
400
|
+
metadata={
|
|
401
|
+
"model": self.model,
|
|
402
|
+
"overall_assessment": analysis_result.get("overall_assessment", ""),
|
|
403
|
+
"primary_threats": analysis_result.get("primary_threats", []),
|
|
404
|
+
"aitech": aitech_code,
|
|
405
|
+
"aitech_name": threat_mapping.get("aitech_name"),
|
|
406
|
+
"aisubtech": aisubtech_code or threat_mapping.get("aisubtech"),
|
|
407
|
+
"aisubtech_name": threat_mapping.get("aisubtech_name") if not aisubtech_code else None,
|
|
408
|
+
"scanner_category": threat_mapping.get("scanner_category"),
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
findings.append(finding)
|
|
413
|
+
|
|
414
|
+
except (ValueError, KeyError) as e:
|
|
415
|
+
print(f"Warning: Failed to parse LLM finding: {e}")
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
return findings
|
|
419
|
+
|
|
420
|
+
def _is_internal_file(self, skill: Skill, file_path: str) -> bool:
|
|
421
|
+
"""Check if a file path is internal to the skill package."""
|
|
422
|
+
from pathlib import Path
|
|
423
|
+
|
|
424
|
+
skill_dir = Path(skill.directory)
|
|
425
|
+
file_path_obj = Path(file_path)
|
|
426
|
+
|
|
427
|
+
# If it's an absolute path, check if it's within skill directory
|
|
428
|
+
if file_path_obj.is_absolute():
|
|
429
|
+
try:
|
|
430
|
+
return skill_dir in file_path_obj.parents or file_path_obj.is_relative_to(skill_dir)
|
|
431
|
+
except AttributeError:
|
|
432
|
+
# Python < 3.9 compatibility
|
|
433
|
+
try:
|
|
434
|
+
return skill_dir.resolve() in file_path_obj.resolve().parents
|
|
435
|
+
except OSError:
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
# Relative path - check if it exists within skill directory
|
|
439
|
+
full_path = skill_dir / file_path
|
|
440
|
+
return full_path.exists() and full_path.is_relative_to(skill_dir)
|