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,60 @@
|
|
|
1
|
+
//////////////////////////////////////////
|
|
2
|
+
// Tool Chaining Abuse Detection
|
|
3
|
+
// Target: Suspicious multi-step operations that could exfiltrate data
|
|
4
|
+
// For Claude Skills that chain operations suspiciously
|
|
5
|
+
//////////////////////////////////////////
|
|
6
|
+
|
|
7
|
+
rule tool_chaining_abuse{
|
|
8
|
+
|
|
9
|
+
meta:
|
|
10
|
+
author = "Cisco"
|
|
11
|
+
description = "Detects suspicious tool chaining patterns that could lead to data exfiltration"
|
|
12
|
+
classification = "harmful"
|
|
13
|
+
threat_type = "TOOL CHAINING ABUSE"
|
|
14
|
+
|
|
15
|
+
strings:
|
|
16
|
+
|
|
17
|
+
// Read ā Send pattern
|
|
18
|
+
$read_then_send = /\b(read|fetch|get|retrieve|collect|gather)\b[^\n]{0,100}\b(send|post|upload|transmit|forward|email|slack|webhook)\b/i
|
|
19
|
+
|
|
20
|
+
// Collect ā Exfiltrate pattern
|
|
21
|
+
$collect_exfiltrate = /\b(collect (all |every |entire )?(data|files?|info|credentials?|secrets?|keys?|tokens?))\b[^\n]{0,150}\b(send|post|upload|transmit|curl|wget|requests\.post)\b/i
|
|
22
|
+
|
|
23
|
+
// Multi-step with network
|
|
24
|
+
$multistep_network = /\b(first|then|next|after|finally),? (read|collect|gather)\b[^\n]{0,100}\b(then|next|after|finally),? (send|post|upload)\b/i
|
|
25
|
+
|
|
26
|
+
// Summarize and send externally
|
|
27
|
+
$summarize_send = /\b(summarize|aggregate|compile)\b[^\n]{0,80}\b(send|post|email|slack) (to|via) (external|webhook|url|endpoint|api)\b/i
|
|
28
|
+
|
|
29
|
+
// Read environment ā Network
|
|
30
|
+
$env_to_network = /\b(os\.environ|getenv|process\.env)\b[^\n]{0,150}\b(requests\.|urllib\.|curl|wget|socket\.)\b/i
|
|
31
|
+
|
|
32
|
+
// File traversal ā Collection ā Send
|
|
33
|
+
$traverse_collect_send = /\b(walk|rglob|listdir|scandir|find)\b[^\n]{0,100}\b(open|read)\b[^\n]{0,100}\b(send|post|upload)\b/i
|
|
34
|
+
|
|
35
|
+
// Automated data pipeline
|
|
36
|
+
$auto_pipeline = /\b(automatically (read|collect|gather))\b[^\n]{0,100}\b(and |then )?(send|post|forward|upload)\b/i
|
|
37
|
+
|
|
38
|
+
condition:
|
|
39
|
+
|
|
40
|
+
// Read then send
|
|
41
|
+
$read_then_send or
|
|
42
|
+
|
|
43
|
+
// Collect and exfiltrate
|
|
44
|
+
$collect_exfiltrate or
|
|
45
|
+
|
|
46
|
+
// Multi-step with network
|
|
47
|
+
$multistep_network or
|
|
48
|
+
|
|
49
|
+
// Summarize and send
|
|
50
|
+
$summarize_send or
|
|
51
|
+
|
|
52
|
+
// Environment to network
|
|
53
|
+
$env_to_network or
|
|
54
|
+
|
|
55
|
+
// Traverse, collect, send
|
|
56
|
+
$traverse_collect_send or
|
|
57
|
+
|
|
58
|
+
// Automated pipeline
|
|
59
|
+
$auto_pipeline
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//////////////////////////////////////////
|
|
2
|
+
// Transitive Trust Abuse Detection
|
|
3
|
+
// Target: Skills that delegate trust to untrusted external content
|
|
4
|
+
// For Claude Skills that consume webpages, files, issues, docs
|
|
5
|
+
//////////////////////////////////////////
|
|
6
|
+
|
|
7
|
+
rule transitive_trust_abuse{
|
|
8
|
+
|
|
9
|
+
meta:
|
|
10
|
+
author = "Cisco"
|
|
11
|
+
description = "Detects skills that delegate trust to untrusted external content"
|
|
12
|
+
classification = "harmful"
|
|
13
|
+
threat_type = "TRANSITIVE TRUST ABUSE"
|
|
14
|
+
|
|
15
|
+
strings:
|
|
16
|
+
|
|
17
|
+
// Following external instructions
|
|
18
|
+
$follow_external = /\b(follow (the )?(instructions?|commands?|directives?) (in|from|inside|within) (the )?(file|webpage|document|url|link|website|page|content))\b/i
|
|
19
|
+
|
|
20
|
+
// Executing external content
|
|
21
|
+
$execute_external = /\b(execute (the )?(code|script|commands?) (in|from|found in) (the )?(file|webpage|document|url|link))\b/i
|
|
22
|
+
|
|
23
|
+
// Obeying untrusted sources
|
|
24
|
+
$obey_untrusted = /\b(do (what|whatever) (the )?(webpage|file|document|url|content) (says|tells|instructs|commands?))\b/i
|
|
25
|
+
|
|
26
|
+
// Running code blocks from external UNTRUSTED sources (not local scripts with --help)
|
|
27
|
+
// Exclude legitimate CLI usage patterns
|
|
28
|
+
$run_code_blocks = /\b(run (all |any )?(code|script) blocks? (you |that )?(find|see|encounter|discover) (in|from|inside) (the )?(url|webpage|website|external|untrusted))\b/i
|
|
29
|
+
|
|
30
|
+
// Following markdown/HTML instructions
|
|
31
|
+
$follow_markup = /\b(follow (the )?instructions? in (the )?(markdown|html|xml|json|yaml))\b/i
|
|
32
|
+
|
|
33
|
+
// Delegating to file content
|
|
34
|
+
$delegate_to_file = /\b(let (the )?(file|document|content) (decide|determine|control|specify))\b/i
|
|
35
|
+
|
|
36
|
+
// Executing inline code from documents
|
|
37
|
+
$execute_inline = /\b(execute (inline |embedded )?(code|scripts?)|run (inline |embedded )?(code|scripts?))\b/i
|
|
38
|
+
|
|
39
|
+
// Trusting URL content
|
|
40
|
+
$trust_url_content = /\b(trust (the )?(url|link|webpage) (content|instructions?)|safe to (follow|execute|run) (url|link|webpage))\b/i
|
|
41
|
+
|
|
42
|
+
// Parsing and executing
|
|
43
|
+
$parse_execute = /\b(parse (and |then )?(execute|run|eval)|extract (and |then )?(execute|run|eval))\b/i
|
|
44
|
+
|
|
45
|
+
condition:
|
|
46
|
+
|
|
47
|
+
// Following external instructions
|
|
48
|
+
$follow_external or
|
|
49
|
+
|
|
50
|
+
// Executing external content
|
|
51
|
+
$execute_external or
|
|
52
|
+
|
|
53
|
+
// Obeying untrusted sources
|
|
54
|
+
$obey_untrusted or
|
|
55
|
+
|
|
56
|
+
// Running code blocks
|
|
57
|
+
$run_code_blocks or
|
|
58
|
+
|
|
59
|
+
// Following markup instructions
|
|
60
|
+
$follow_markup or
|
|
61
|
+
|
|
62
|
+
// Delegating to file content
|
|
63
|
+
$delegate_to_file or
|
|
64
|
+
|
|
65
|
+
// Executing inline code
|
|
66
|
+
$execute_inline or
|
|
67
|
+
|
|
68
|
+
// Trusting URL content
|
|
69
|
+
$trust_url_content or
|
|
70
|
+
|
|
71
|
+
// Parse and execute
|
|
72
|
+
$parse_execute
|
|
73
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//////////////////////////////////////////
|
|
2
|
+
// Unicode Steganography and Hidden Characters Detection
|
|
3
|
+
// Target: Invisible Unicode used for prompt injection
|
|
4
|
+
// Based on: https://en.wikipedia.org/wiki/Tags_(Unicode_block)
|
|
5
|
+
//////////////////////////////////////////
|
|
6
|
+
|
|
7
|
+
rule unicode_steganography{
|
|
8
|
+
|
|
9
|
+
meta:
|
|
10
|
+
author = "Cisco"
|
|
11
|
+
description = "Detects hidden Unicode characters used for invisible prompt injection and steganography"
|
|
12
|
+
classification = "harmful"
|
|
13
|
+
threat_type = "PROMPT INJECTION"
|
|
14
|
+
reference = "https://en.wikipedia.org/wiki/Tags_(Unicode_block)"
|
|
15
|
+
|
|
16
|
+
strings:
|
|
17
|
+
|
|
18
|
+
// --- 1. Unicode Tag Regex Patterns ---
|
|
19
|
+
// Catches \uE00xx, \u{E00xx}, and \U000E00xx encoding styles
|
|
20
|
+
$unicode_tag_pattern = /\\u(\{)?[Ee]00[0-7][0-9A-Fa-f](\})?/
|
|
21
|
+
$unicode_long_tag = /\\U000[Ee]00[0-7][0-9A-Fa-f]/
|
|
22
|
+
|
|
23
|
+
// --- 2. Zero-width characters (steganography) ---
|
|
24
|
+
// UTF-8 hex encoding
|
|
25
|
+
$zw_space = "\xE2\x80\x8B" // U+200B ZERO WIDTH SPACE
|
|
26
|
+
$zw_non_joiner = "\xE2\x80\x8C" // U+200C
|
|
27
|
+
$zw_joiner = "\xE2\x80\x8D" // U+200D
|
|
28
|
+
|
|
29
|
+
// --- 3. Directional Overrides (text spoofing) ---
|
|
30
|
+
$rtlo = "\xE2\x80\xAE" // U+202E RIGHT-TO-LEFT OVERRIDE
|
|
31
|
+
$ltro = "\xE2\x80\xAD" // U+202D LEFT-TO-RIGHT OVERRIDE
|
|
32
|
+
|
|
33
|
+
// --- 4. Invisible separators ---
|
|
34
|
+
$line_separator = "\xE2\x80\xA8" // U+2028 LINE SEPARATOR
|
|
35
|
+
$paragraph_separator = "\xE2\x80\xA9" // U+2029 PARAGRAPH SEPARATOR
|
|
36
|
+
|
|
37
|
+
// --- 5. Homoglyph detection ---
|
|
38
|
+
$cyrillic_a = "\xD0\x90" // Š (Cyrillic A mimics Latin A)
|
|
39
|
+
$cyrillic_e = "\xD0\x95" // Š (Cyrillic E mimics Latin E)
|
|
40
|
+
$cyrillic_o = "\xD0\x9E" // Š (Cyrillic O mimics Latin O)
|
|
41
|
+
|
|
42
|
+
condition:
|
|
43
|
+
|
|
44
|
+
// Detection logic - flag and manually review (better safe than miss attack)
|
|
45
|
+
(
|
|
46
|
+
// Encoded tag characters in strings (any occurrence is suspicious)
|
|
47
|
+
$unicode_tag_pattern or
|
|
48
|
+
$unicode_long_tag or
|
|
49
|
+
|
|
50
|
+
// Zero-width steganography (tools alternate chars to encode binary 0s/1s)
|
|
51
|
+
// Aggregate count across all types is more effective than individual checks
|
|
52
|
+
(#zw_space + #zw_non_joiner + #zw_joiner) > 10 or
|
|
53
|
+
|
|
54
|
+
// Any directional override (highly suspicious in code/English text)
|
|
55
|
+
$rtlo or
|
|
56
|
+
$ltro or
|
|
57
|
+
|
|
58
|
+
// Invisible separators (no legitimate use in source code)
|
|
59
|
+
$line_separator or
|
|
60
|
+
$paragraph_separator or
|
|
61
|
+
|
|
62
|
+
// Homoglyph attacks (5+ Cyrillic chars mimicking Latin in English context)
|
|
63
|
+
(#cyrillic_a + #cyrillic_e + #cyrillic_o) > 5
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
"""Git hooks for skill-analyzer."""
|
|
18
|
+
|
|
19
|
+
from .pre_commit import main as pre_commit_hook
|
|
20
|
+
|
|
21
|
+
__all__ = ["pre_commit_hook"]
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright 2026 Cisco Systems, Inc.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
#
|
|
16
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
Pre-commit hook for scanning Claude Skills for security issues.
|
|
20
|
+
|
|
21
|
+
This hook scans staged skill directories for security vulnerabilities
|
|
22
|
+
and blocks commits that contain HIGH or CRITICAL severity findings.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
1. Install as a pre-commit hook:
|
|
26
|
+
skill-analyzer-pre-commit install
|
|
27
|
+
|
|
28
|
+
2. Or add to .pre-commit-config.yaml:
|
|
29
|
+
- repo: local
|
|
30
|
+
hooks:
|
|
31
|
+
- id: skill-analyzer
|
|
32
|
+
name: Skill Analyzer
|
|
33
|
+
entry: skill-analyzer-pre-commit
|
|
34
|
+
language: python
|
|
35
|
+
types: [file]
|
|
36
|
+
pass_filenames: false
|
|
37
|
+
|
|
38
|
+
Configuration:
|
|
39
|
+
Create a .skillanalyzerrc file in your repo root:
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
"severity_threshold": "high", # block on: critical, high, medium, low
|
|
43
|
+
"skills_path": ".claude/skills",
|
|
44
|
+
"fail_fast": true
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import json
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
# Default configuration
|
|
55
|
+
DEFAULT_CONFIG = {
|
|
56
|
+
"severity_threshold": "high", # Block commits on HIGH or CRITICAL
|
|
57
|
+
"skills_path": ".claude/skills", # Default Claude skills location
|
|
58
|
+
"fail_fast": True,
|
|
59
|
+
"use_behavioral": False,
|
|
60
|
+
"use_trigger": True,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Severity levels (higher number = more severe)
|
|
64
|
+
SEVERITY_LEVELS = {
|
|
65
|
+
"safe": 0,
|
|
66
|
+
"info": 1,
|
|
67
|
+
"low": 2,
|
|
68
|
+
"medium": 3,
|
|
69
|
+
"high": 4,
|
|
70
|
+
"critical": 5,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(repo_root: Path) -> dict:
|
|
75
|
+
"""
|
|
76
|
+
Load configuration from .skillanalyzerrc file.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
repo_root: Repository root directory
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Configuration dictionary
|
|
83
|
+
"""
|
|
84
|
+
config = DEFAULT_CONFIG.copy()
|
|
85
|
+
|
|
86
|
+
config_paths = [
|
|
87
|
+
repo_root / ".skillanalyzerrc",
|
|
88
|
+
repo_root / ".skillanalyzerrc.json",
|
|
89
|
+
repo_root / "skillanalyzer.json",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
for config_path in config_paths:
|
|
93
|
+
if config_path.exists():
|
|
94
|
+
try:
|
|
95
|
+
with open(config_path) as f:
|
|
96
|
+
user_config = json.load(f)
|
|
97
|
+
config.update(user_config)
|
|
98
|
+
break
|
|
99
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
100
|
+
print(f"Warning: Failed to load config from {config_path}: {e}", file=sys.stderr)
|
|
101
|
+
|
|
102
|
+
return config
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_staged_files() -> list[str]:
|
|
106
|
+
"""
|
|
107
|
+
Get list of staged files from git.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of staged file paths relative to repo root
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
check=True,
|
|
118
|
+
)
|
|
119
|
+
return [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
|
120
|
+
except subprocess.CalledProcessError:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_affected_skills(staged_files: list[str], skills_path: str) -> set[Path]:
|
|
125
|
+
"""
|
|
126
|
+
Identify skill directories affected by staged changes.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
staged_files: List of staged file paths
|
|
130
|
+
skills_path: Base path for skills
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Set of affected skill directory paths
|
|
134
|
+
"""
|
|
135
|
+
affected_skills = set()
|
|
136
|
+
skills_prefix = skills_path.rstrip("/") + "/"
|
|
137
|
+
|
|
138
|
+
for file_path in staged_files:
|
|
139
|
+
# Check if file is in skills directory
|
|
140
|
+
if file_path.startswith(skills_prefix) or file_path.startswith(skills_path):
|
|
141
|
+
# Extract the skill directory (first subdirectory under skills_path)
|
|
142
|
+
relative = file_path[len(skills_path) :].lstrip("/")
|
|
143
|
+
parts = relative.split("/")
|
|
144
|
+
|
|
145
|
+
if parts:
|
|
146
|
+
skill_dir = Path(skills_path) / parts[0]
|
|
147
|
+
skill_md = skill_dir / "SKILL.md"
|
|
148
|
+
if skill_md.exists():
|
|
149
|
+
affected_skills.add(skill_dir)
|
|
150
|
+
|
|
151
|
+
# Also check for SKILL.md directly in case skills are at different paths
|
|
152
|
+
if file_path.endswith("SKILL.md"):
|
|
153
|
+
skill_dir = Path(file_path).parent
|
|
154
|
+
affected_skills.add(skill_dir)
|
|
155
|
+
|
|
156
|
+
return affected_skills
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def scan_skill(skill_dir: Path, config: dict) -> dict:
|
|
160
|
+
"""
|
|
161
|
+
Scan a skill directory and return findings.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
skill_dir: Path to skill directory
|
|
165
|
+
config: Configuration dictionary
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Scan results as dictionary
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
from ..core.analyzers.base import BaseAnalyzer
|
|
172
|
+
from ..core.analyzers.static import StaticAnalyzer
|
|
173
|
+
from ..core.scanner import SkillScanner
|
|
174
|
+
|
|
175
|
+
analyzers: list[BaseAnalyzer] = [StaticAnalyzer()]
|
|
176
|
+
|
|
177
|
+
# Add optional analyzers based on config
|
|
178
|
+
if config.get("use_behavioral"):
|
|
179
|
+
try:
|
|
180
|
+
from ..core.analyzers.behavioral_analyzer import BehavioralAnalyzer
|
|
181
|
+
|
|
182
|
+
analyzers.append(BehavioralAnalyzer(use_static_analysis=True))
|
|
183
|
+
except ImportError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
if config.get("use_trigger"):
|
|
187
|
+
try:
|
|
188
|
+
from ..core.analyzers.trigger_analyzer import TriggerAnalyzer
|
|
189
|
+
|
|
190
|
+
analyzers.append(TriggerAnalyzer())
|
|
191
|
+
except ImportError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
scanner = SkillScanner(analyzers=analyzers)
|
|
195
|
+
result = scanner.scan_skill(skill_dir)
|
|
196
|
+
|
|
197
|
+
# Count findings by severity
|
|
198
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
199
|
+
for f in result.findings:
|
|
200
|
+
sev = f.severity.value.lower() if hasattr(f.severity, "value") else str(f.severity).lower()
|
|
201
|
+
if sev in counts:
|
|
202
|
+
counts[sev] += 1
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"skill_name": result.skill_name,
|
|
206
|
+
"skill_directory": result.skill_directory,
|
|
207
|
+
"findings": [
|
|
208
|
+
{
|
|
209
|
+
"rule_id": f.rule_id,
|
|
210
|
+
"severity": f.severity.value if hasattr(f.severity, "value") else str(f.severity),
|
|
211
|
+
"title": f.title,
|
|
212
|
+
"description": f.description,
|
|
213
|
+
"file_path": f.file_path,
|
|
214
|
+
"line_number": f.line_number,
|
|
215
|
+
}
|
|
216
|
+
for f in result.findings
|
|
217
|
+
],
|
|
218
|
+
"critical_count": counts["critical"],
|
|
219
|
+
"high_count": counts["high"],
|
|
220
|
+
"medium_count": counts["medium"],
|
|
221
|
+
"low_count": counts["low"],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return {
|
|
226
|
+
"skill_name": skill_dir.name,
|
|
227
|
+
"skill_directory": str(skill_dir),
|
|
228
|
+
"findings": [],
|
|
229
|
+
"error": str(e),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def check_severity_threshold(result: dict, threshold: str) -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Check if scan result exceeds severity threshold.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
result: Scan result dictionary
|
|
239
|
+
threshold: Severity threshold string
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if threshold is exceeded (should block commit)
|
|
243
|
+
"""
|
|
244
|
+
threshold_level = SEVERITY_LEVELS.get(threshold.lower(), SEVERITY_LEVELS["high"])
|
|
245
|
+
|
|
246
|
+
for finding in result.get("findings", []):
|
|
247
|
+
finding_level = SEVERITY_LEVELS.get(finding["severity"].lower(), 0)
|
|
248
|
+
if finding_level >= threshold_level:
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def format_finding(finding: dict) -> str:
|
|
255
|
+
"""Format a finding for console output."""
|
|
256
|
+
severity = finding["severity"].upper()
|
|
257
|
+
title = finding["title"]
|
|
258
|
+
location = finding.get("file_path", "")
|
|
259
|
+
|
|
260
|
+
if finding.get("line_number"):
|
|
261
|
+
location = f"{location}:{finding['line_number']}"
|
|
262
|
+
|
|
263
|
+
return f" [{severity}] {title}\n Location: {location}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main(args: list[str] | None = None) -> int:
|
|
267
|
+
"""
|
|
268
|
+
Main entry point for pre-commit hook.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
args: Command line arguments (for testing)
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Exit code (0 = success, 1 = blocked)
|
|
275
|
+
"""
|
|
276
|
+
parser = argparse.ArgumentParser(description="Pre-commit hook for scanning Claude Skills")
|
|
277
|
+
parser.add_argument(
|
|
278
|
+
"--severity",
|
|
279
|
+
choices=["critical", "high", "medium", "low"],
|
|
280
|
+
help="Override severity threshold from config",
|
|
281
|
+
)
|
|
282
|
+
parser.add_argument(
|
|
283
|
+
"--skills-path",
|
|
284
|
+
help="Override skills path from config",
|
|
285
|
+
)
|
|
286
|
+
parser.add_argument(
|
|
287
|
+
"--all",
|
|
288
|
+
action="store_true",
|
|
289
|
+
help="Scan all skills, not just staged ones",
|
|
290
|
+
)
|
|
291
|
+
parser.add_argument(
|
|
292
|
+
"install",
|
|
293
|
+
nargs="?",
|
|
294
|
+
help="Install pre-commit hook",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
parsed_args = parser.parse_args(args)
|
|
298
|
+
|
|
299
|
+
# Handle install command
|
|
300
|
+
if parsed_args.install == "install":
|
|
301
|
+
return install_hook()
|
|
302
|
+
|
|
303
|
+
# Find repo root
|
|
304
|
+
try:
|
|
305
|
+
result = subprocess.run(
|
|
306
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
307
|
+
capture_output=True,
|
|
308
|
+
text=True,
|
|
309
|
+
check=True,
|
|
310
|
+
)
|
|
311
|
+
repo_root = Path(result.stdout.strip())
|
|
312
|
+
except subprocess.CalledProcessError:
|
|
313
|
+
print("Error: Not a git repository", file=sys.stderr)
|
|
314
|
+
return 1
|
|
315
|
+
|
|
316
|
+
# Load config
|
|
317
|
+
config = load_config(repo_root)
|
|
318
|
+
|
|
319
|
+
# Apply command line overrides
|
|
320
|
+
if parsed_args.severity:
|
|
321
|
+
config["severity_threshold"] = parsed_args.severity
|
|
322
|
+
if parsed_args.skills_path:
|
|
323
|
+
config["skills_path"] = parsed_args.skills_path
|
|
324
|
+
|
|
325
|
+
# Get staged files and affected skills
|
|
326
|
+
if parsed_args.all:
|
|
327
|
+
skills_dir = repo_root / config["skills_path"]
|
|
328
|
+
if skills_dir.exists():
|
|
329
|
+
affected_skills = {d for d in skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()}
|
|
330
|
+
else:
|
|
331
|
+
affected_skills = set()
|
|
332
|
+
else:
|
|
333
|
+
staged_files = get_staged_files()
|
|
334
|
+
affected_skills = get_affected_skills(staged_files, config["skills_path"])
|
|
335
|
+
|
|
336
|
+
if not affected_skills:
|
|
337
|
+
# No skills affected, allow commit
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
print(f"Scanning {len(affected_skills)} skill(s)...")
|
|
341
|
+
|
|
342
|
+
# Scan each affected skill
|
|
343
|
+
blocked = False
|
|
344
|
+
all_findings = []
|
|
345
|
+
|
|
346
|
+
for skill_dir in sorted(affected_skills):
|
|
347
|
+
print(f"\nš¦ {skill_dir.name}")
|
|
348
|
+
|
|
349
|
+
result = scan_skill(skill_dir, config)
|
|
350
|
+
|
|
351
|
+
if result.get("error"):
|
|
352
|
+
print(f" ā ļø Error: {result['error']}", file=sys.stderr)
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
findings = result.get("findings", [])
|
|
356
|
+
|
|
357
|
+
if not findings:
|
|
358
|
+
print(" ā
No issues found")
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Check if threshold is exceeded
|
|
362
|
+
if check_severity_threshold(result, config["severity_threshold"]):
|
|
363
|
+
blocked = True
|
|
364
|
+
print(f" š« Blocked (threshold: {config['severity_threshold'].upper()})")
|
|
365
|
+
else:
|
|
366
|
+
print(f" ā ļø {len(findings)} finding(s) below threshold")
|
|
367
|
+
|
|
368
|
+
# Print findings
|
|
369
|
+
for finding in findings:
|
|
370
|
+
print(format_finding(finding))
|
|
371
|
+
all_findings.append(finding)
|
|
372
|
+
|
|
373
|
+
if blocked and config.get("fail_fast"):
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
# Summary
|
|
377
|
+
print(f"\n{'=' * 50}")
|
|
378
|
+
if blocked:
|
|
379
|
+
print("ā Commit BLOCKED - fix security issues before committing")
|
|
380
|
+
print(f" Threshold: {config['severity_threshold'].upper()} and above")
|
|
381
|
+
return 1
|
|
382
|
+
elif all_findings:
|
|
383
|
+
print(f"ā ļø {len(all_findings)} finding(s) detected (below threshold)")
|
|
384
|
+
print(" Consider reviewing and fixing these issues")
|
|
385
|
+
return 0
|
|
386
|
+
else:
|
|
387
|
+
print("ā
All skills passed security checks")
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def install_hook() -> int:
|
|
392
|
+
"""
|
|
393
|
+
Install the pre-commit hook in the current repository.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Exit code
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
result = subprocess.run(
|
|
400
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
401
|
+
capture_output=True,
|
|
402
|
+
text=True,
|
|
403
|
+
check=True,
|
|
404
|
+
)
|
|
405
|
+
repo_root = Path(result.stdout.strip())
|
|
406
|
+
except subprocess.CalledProcessError:
|
|
407
|
+
print("Error: Not a git repository", file=sys.stderr)
|
|
408
|
+
return 1
|
|
409
|
+
|
|
410
|
+
hooks_dir = repo_root / ".git" / "hooks"
|
|
411
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
412
|
+
|
|
413
|
+
hook_path = hooks_dir / "pre-commit"
|
|
414
|
+
|
|
415
|
+
hook_script = """#!/bin/sh
|
|
416
|
+
# Skill Analyzer Pre-commit Hook
|
|
417
|
+
# Automatically scans Claude Skills for security issues
|
|
418
|
+
|
|
419
|
+
skill-analyzer-pre-commit "$@"
|
|
420
|
+
exit_code=$?
|
|
421
|
+
|
|
422
|
+
if [ $exit_code -ne 0 ]; then
|
|
423
|
+
echo ""
|
|
424
|
+
echo "To bypass this check (not recommended), use: git commit --no-verify"
|
|
425
|
+
fi
|
|
426
|
+
|
|
427
|
+
exit $exit_code
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
# Check if hook already exists
|
|
431
|
+
if hook_path.exists():
|
|
432
|
+
print(f"Warning: Pre-commit hook already exists at {hook_path}")
|
|
433
|
+
response = input("Overwrite? [y/N] ").strip().lower()
|
|
434
|
+
if response != "y":
|
|
435
|
+
print("Aborted")
|
|
436
|
+
return 1
|
|
437
|
+
|
|
438
|
+
hook_path.write_text(hook_script)
|
|
439
|
+
hook_path.chmod(0o755)
|
|
440
|
+
|
|
441
|
+
print(f"ā
Pre-commit hook installed at {hook_path}")
|
|
442
|
+
print("\nConfiguration:")
|
|
443
|
+
print(" Create .skillanalyzerrc in your repo root to customize behavior:")
|
|
444
|
+
print(' { "severity_threshold": "high", "skills_path": ".claude/skills" }')
|
|
445
|
+
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
sys.exit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
Threat mapping and taxonomy for Claude Skill Analyzer.
|
|
19
|
+
|
|
20
|
+
Aligned with MCP Scanner's threat taxonomy.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .threats import LLM_THREAT_MAPPING, YARA_THREAT_MAPPING, ThreatMapping
|
|
24
|
+
|
|
25
|
+
__all__ = ["ThreatMapping", "LLM_THREAT_MAPPING", "YARA_THREAT_MAPPING"]
|