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.
Files changed (100) hide show
  1. cisco_ai_skill_scanner-1.0.0.dist-info/METADATA +253 -0
  2. cisco_ai_skill_scanner-1.0.0.dist-info/RECORD +100 -0
  3. cisco_ai_skill_scanner-1.0.0.dist-info/WHEEL +4 -0
  4. cisco_ai_skill_scanner-1.0.0.dist-info/entry_points.txt +4 -0
  5. cisco_ai_skill_scanner-1.0.0.dist-info/licenses/LICENSE +17 -0
  6. skillanalyzer/__init__.py +45 -0
  7. skillanalyzer/_version.py +34 -0
  8. skillanalyzer/api/__init__.py +25 -0
  9. skillanalyzer/api/api.py +34 -0
  10. skillanalyzer/api/api_cli.py +78 -0
  11. skillanalyzer/api/api_server.py +634 -0
  12. skillanalyzer/api/router.py +527 -0
  13. skillanalyzer/cli/__init__.py +25 -0
  14. skillanalyzer/cli/cli.py +816 -0
  15. skillanalyzer/config/__init__.py +26 -0
  16. skillanalyzer/config/config.py +149 -0
  17. skillanalyzer/config/config_parser.py +122 -0
  18. skillanalyzer/config/constants.py +85 -0
  19. skillanalyzer/core/__init__.py +24 -0
  20. skillanalyzer/core/analyzers/__init__.py +75 -0
  21. skillanalyzer/core/analyzers/aidefense_analyzer.py +872 -0
  22. skillanalyzer/core/analyzers/base.py +53 -0
  23. skillanalyzer/core/analyzers/behavioral/__init__.py +30 -0
  24. skillanalyzer/core/analyzers/behavioral/alignment/__init__.py +45 -0
  25. skillanalyzer/core/analyzers/behavioral/alignment/alignment_llm_client.py +240 -0
  26. skillanalyzer/core/analyzers/behavioral/alignment/alignment_orchestrator.py +216 -0
  27. skillanalyzer/core/analyzers/behavioral/alignment/alignment_prompt_builder.py +422 -0
  28. skillanalyzer/core/analyzers/behavioral/alignment/alignment_response_validator.py +136 -0
  29. skillanalyzer/core/analyzers/behavioral/alignment/threat_vulnerability_classifier.py +198 -0
  30. skillanalyzer/core/analyzers/behavioral_analyzer.py +453 -0
  31. skillanalyzer/core/analyzers/cross_skill_analyzer.py +490 -0
  32. skillanalyzer/core/analyzers/llm_analyzer.py +440 -0
  33. skillanalyzer/core/analyzers/llm_prompt_builder.py +270 -0
  34. skillanalyzer/core/analyzers/llm_provider_config.py +215 -0
  35. skillanalyzer/core/analyzers/llm_request_handler.py +284 -0
  36. skillanalyzer/core/analyzers/llm_response_parser.py +81 -0
  37. skillanalyzer/core/analyzers/meta_analyzer.py +845 -0
  38. skillanalyzer/core/analyzers/static.py +1105 -0
  39. skillanalyzer/core/analyzers/trigger_analyzer.py +341 -0
  40. skillanalyzer/core/analyzers/virustotal_analyzer.py +463 -0
  41. skillanalyzer/core/exceptions.py +77 -0
  42. skillanalyzer/core/loader.py +377 -0
  43. skillanalyzer/core/models.py +300 -0
  44. skillanalyzer/core/reporters/__init__.py +26 -0
  45. skillanalyzer/core/reporters/json_reporter.py +65 -0
  46. skillanalyzer/core/reporters/markdown_reporter.py +209 -0
  47. skillanalyzer/core/reporters/sarif_reporter.py +246 -0
  48. skillanalyzer/core/reporters/table_reporter.py +195 -0
  49. skillanalyzer/core/rules/__init__.py +19 -0
  50. skillanalyzer/core/rules/patterns.py +165 -0
  51. skillanalyzer/core/rules/yara_scanner.py +157 -0
  52. skillanalyzer/core/scanner.py +437 -0
  53. skillanalyzer/core/static_analysis/__init__.py +27 -0
  54. skillanalyzer/core/static_analysis/cfg/__init__.py +21 -0
  55. skillanalyzer/core/static_analysis/cfg/builder.py +439 -0
  56. skillanalyzer/core/static_analysis/context_extractor.py +742 -0
  57. skillanalyzer/core/static_analysis/dataflow/__init__.py +25 -0
  58. skillanalyzer/core/static_analysis/dataflow/forward_analysis.py +715 -0
  59. skillanalyzer/core/static_analysis/interprocedural/__init__.py +21 -0
  60. skillanalyzer/core/static_analysis/interprocedural/call_graph_analyzer.py +406 -0
  61. skillanalyzer/core/static_analysis/interprocedural/cross_file_analyzer.py +190 -0
  62. skillanalyzer/core/static_analysis/parser/__init__.py +21 -0
  63. skillanalyzer/core/static_analysis/parser/python_parser.py +380 -0
  64. skillanalyzer/core/static_analysis/semantic/__init__.py +28 -0
  65. skillanalyzer/core/static_analysis/semantic/name_resolver.py +206 -0
  66. skillanalyzer/core/static_analysis/semantic/type_analyzer.py +200 -0
  67. skillanalyzer/core/static_analysis/taint/__init__.py +21 -0
  68. skillanalyzer/core/static_analysis/taint/tracker.py +252 -0
  69. skillanalyzer/core/static_analysis/types/__init__.py +36 -0
  70. skillanalyzer/data/__init__.py +30 -0
  71. skillanalyzer/data/prompts/boilerplate_protection_rule_prompt.md +26 -0
  72. skillanalyzer/data/prompts/code_alignment_threat_analysis_prompt.md +901 -0
  73. skillanalyzer/data/prompts/llm_response_schema.json +71 -0
  74. skillanalyzer/data/prompts/skill_meta_analysis_prompt.md +303 -0
  75. skillanalyzer/data/prompts/skill_threat_analysis_prompt.md +263 -0
  76. skillanalyzer/data/prompts/unified_response_schema.md +97 -0
  77. skillanalyzer/data/rules/signatures.yaml +440 -0
  78. skillanalyzer/data/yara_rules/autonomy_abuse.yara +66 -0
  79. skillanalyzer/data/yara_rules/code_execution.yara +61 -0
  80. skillanalyzer/data/yara_rules/coercive_injection.yara +115 -0
  81. skillanalyzer/data/yara_rules/command_injection.yara +54 -0
  82. skillanalyzer/data/yara_rules/credential_harvesting.yara +115 -0
  83. skillanalyzer/data/yara_rules/prompt_injection.yara +71 -0
  84. skillanalyzer/data/yara_rules/script_injection.yara +83 -0
  85. skillanalyzer/data/yara_rules/skill_discovery_abuse.yara +57 -0
  86. skillanalyzer/data/yara_rules/sql_injection.yara +73 -0
  87. skillanalyzer/data/yara_rules/system_manipulation.yara +65 -0
  88. skillanalyzer/data/yara_rules/tool_chaining_abuse.yara +60 -0
  89. skillanalyzer/data/yara_rules/transitive_trust_abuse.yara +73 -0
  90. skillanalyzer/data/yara_rules/unicode_steganography.yara +65 -0
  91. skillanalyzer/hooks/__init__.py +21 -0
  92. skillanalyzer/hooks/pre_commit.py +450 -0
  93. skillanalyzer/threats/__init__.py +25 -0
  94. skillanalyzer/threats/threats.py +480 -0
  95. skillanalyzer/utils/__init__.py +28 -0
  96. skillanalyzer/utils/command_utils.py +129 -0
  97. skillanalyzer/utils/di_container.py +154 -0
  98. skillanalyzer/utils/file_utils.py +86 -0
  99. skillanalyzer/utils/logging_config.py +96 -0
  100. 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)