synapse-hub 2.1.0 → 2.2.0
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.
- package/.agents/agents/orchestrator.md +7 -0
- package/.agents/rules/lessons-learned.md +1 -0
- package/.agents/skills/agent-orchestration-multi-agent-optimize/SKILL.md +1 -1
- package/.agents/skills/antigravity-test-sentinel/SKILL.md +1 -2
- package/.agents/skills/api-network-mocking-interception/SKILL.md +1 -1
- package/.agents/skills/api-spec-to-e2e-matrix/SKILL.md +11 -4
- package/.agents/skills/architecture-decision-records/SKILL.md +1 -1
- package/.agents/skills/auth-session-and-cookie-manager/SKILL.md +1 -1
- package/.agents/skills/backend-load-and-stress-patterns/SKILL.md +32 -11
- package/.agents/skills/bats-testing-patterns/SKILL.md +1 -1
- package/.agents/skills/clean-pom-crafter/SKILL.md +1 -4
- package/.agents/skills/code-refactoring-refactor-clean/SKILL.md +1 -1
- package/.agents/skills/db-state-verifier-and-seeder/SKILL.md +1 -1
- package/.agents/skills/e2e-testing-patterns/SKILL.md +1 -1
- package/.agents/skills/failure-autopsy-and-healing/SKILL.md +1 -3
- package/.agents/skills/file-and-report-validator/SKILL.md +1 -1
- package/.agents/skills/flaky-test-sentinel-and-quarantine/SKILL.md +1 -1
- package/.agents/skills/github-actions-templates/SKILL.md +1 -1
- package/.agents/skills/graphify-impact-analyzer/SKILL.md +1 -3
- package/.agents/skills/katalon-cli-runner/SKILL.md +1 -1
- package/.agents/skills/lam-script-generator/SKILL.md +1 -2
- package/.agents/skills/playwright-live-interaction/SKILL.md +1 -4
- package/.agents/skills/security-dast-sentinel/SKILL.md +1 -1
- package/.agents/skills/self-healing-locator/SKILL.md +1 -2
- package/.agents/skills/skillspector-security-guard/SKILL.md +115 -0
- package/.agents/skills/synthetic-test-data-generator/SKILL.md +1 -1
- package/.agents/skills/token-and-context-optimizer/SKILL.md +1 -1
- package/.agents/skills/visual-regression-sentinel/SKILL.md +1 -1
- package/.agents/skills/wcag-audit-patterns/SKILL.md +1 -1
- package/.agents/skills/web-vitals-and-performance-profiler/SKILL.md +1 -1
- package/AGENTS.md +36 -0
- package/README.md +140 -103
- package/package.json +1 -1
- package/skills_manifest.json +42 -29
- package/templates/PROJECT_AI_GUIDE_TEMPLATE.md +15 -1
- package/templates/ai_bridges/CLAUDE.md +31 -0
- package/templates/ai_bridges/copilot-instructions.md +25 -0
- package/templates/ai_bridges/cursor_synapse_qa.mdc +26 -0
- package/templates/ai_bridges/windsurfrules +25 -0
- package/templates/ci/github-actions-synapse.yml +28 -0
- package/templates/ci/gitlab-ci-synapse.yml +16 -0
- package/tools/TOOLS_RUNBOOK.md +203 -12
- package/tools/browser_helpers.js +2 -2
- package/tools/bump_version.py +8 -0
- package/tools/cli.py +74 -2
- package/tools/export_postman_collection.py +535 -218
- package/tools/generate_jmx_plan.py +672 -102
- package/tools/git_pre_commit_guard.py +21 -0
- package/tools/jtl_analyzer.py +474 -0
- package/tools/mcp_excel_server.ps1 +1 -1
- package/tools/mcp_server.py +1 -1
- package/tools/newman_analyzer.py +385 -0
- package/tools/setup_project.py +203 -18
- package/tools/skillspector.py +1141 -0
- package/tools/telemetry_correlator.py +351 -0
- package/tools/templates/jmeter-distributed/README.md +54 -0
- package/tools/templates/jmeter-distributed/docker-compose.yml +83 -0
- package/tools/update_sentinel.py +313 -0
- package/tools/repo_map.json +0 -12
- package/tools/repo_map.txt +0 -7
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Enterprise SkillSpector Sentinel (AI Agent Skill & MCP Security Scanner)
|
|
4
|
+
-------------------------------------------------------------------------
|
|
5
|
+
Synthesized from NVIDIA/SkillSpector (Apache 2.0).
|
|
6
|
+
Provides multi-vector static security analysis for AI Agent skills, prompt instructions,
|
|
7
|
+
MCP (Model Context Protocol) tool configurations, and bundled automation scripts.
|
|
8
|
+
|
|
9
|
+
Detects 71 vulnerability patterns across 17 categories:
|
|
10
|
+
1. Prompt Injection (P1, P2, P3, P4, P9 - Override, Hidden, Exfil, Manipulation, Whitespace Padding)
|
|
11
|
+
2. Data Exfiltration (E1-E5 - HTTP sinks, Env Var harvesting, Credential search, Context leak, Cloud upload)
|
|
12
|
+
3. Privilege Escalation (PE1-PE5 - Wildcards, Sudo/root, Private keys, Docker socket, Container escape)
|
|
13
|
+
4. Agent Snooping (AS1-AS3 - Agent configs ~/.claude ~/.gemini, MCP configs, Peer skills snooping)
|
|
14
|
+
5. System Prompt Leakage (P6-P8 - Direct prompt extraction, Delimiter attack, Tool exfiltration)
|
|
15
|
+
6. Anti-Refusal Jailbreaks (AR1-AR3 - Refusal suppression, Warning suppression, Policy nullification)
|
|
16
|
+
7. MCP Tool Poisoning (TP1-TP4 - Hidden metadata comments/Base64, Homoglyphs, Param injection, Drift)
|
|
17
|
+
8. MCP Least Privilege (LP1-LP4 - Undeclared capabilities, Wildcard tools, Missing scopes)
|
|
18
|
+
9. Behavioral AST Analysis (AST1-AST10 - exec, eval, __import__, shell=True, os.system, dynamic getattr,
|
|
19
|
+
reflective execution sinks, and insecure deserialization pickle/yaml.unsafe_load/torch.load)
|
|
20
|
+
10. Unicode Confusables & Steganography (Bidi overrides CVE-2021-42574, Zero-width characters, Cyrillic lookalikes)
|
|
21
|
+
11. Supply Chain & Dangerous Execution (SC1-SC2 - Unpinned deps, curl|sh download & exec)
|
|
22
|
+
12. Server-Side Request Forgery (SSRF1-SSRF3 - Cloud metadata 169.254.169.254, loopback, private networks)
|
|
23
|
+
|
|
24
|
+
Features:
|
|
25
|
+
- NVIDIA-style Prohibition Context Engine (Defensive Language Guard): eliminates false positives
|
|
26
|
+
when dangerous patterns appear inside negations ("do not", "never", "avoid", "forbidden")
|
|
27
|
+
or Markdown warning callouts ([!CAUTION], [!WARNING]).
|
|
28
|
+
- Emoji ZWJ sequence preservation: distinguishes legitimate emojis from malicious zero-width payloads.
|
|
29
|
+
- Request-anchored SSRF detection: eliminates false positives on documentation URLs.
|
|
30
|
+
- Risk Scoring (0-100) & Severity Bands (LOW, MEDIUM, HIGH, CRITICAL).
|
|
31
|
+
- Verdict Recommendation (SAFE, CAUTION, DO_NOT_INSTALL).
|
|
32
|
+
- Multi-Format Outputs: Terminal (Rich ANSI), JSON, Markdown, and SARIF 2.1.0.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
python3 tools/skillspector.py <path_or_file> [--format terminal|json|markdown|sarif] [--output file]
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import ast
|
|
41
|
+
import fnmatch
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
import unicodedata
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from enum import Enum
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
52
|
+
|
|
53
|
+
# Enforce UTF-8 stdout encoding for Windows console compatibility
|
|
54
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
55
|
+
try:
|
|
56
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
57
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Enums & Data Classes
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class Severity(str, Enum):
|
|
67
|
+
LOW = "LOW"
|
|
68
|
+
MEDIUM = "MEDIUM"
|
|
69
|
+
HIGH = "HIGH"
|
|
70
|
+
CRITICAL = "CRITICAL"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Recommendation(str, Enum):
|
|
74
|
+
SAFE = "SAFE"
|
|
75
|
+
CAUTION = "CAUTION"
|
|
76
|
+
DO_NOT_INSTALL = "DO_NOT_INSTALL"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Finding:
|
|
81
|
+
rule_id: str
|
|
82
|
+
message: str
|
|
83
|
+
severity: Severity
|
|
84
|
+
confidence: float
|
|
85
|
+
file_path: str
|
|
86
|
+
line_number: int
|
|
87
|
+
matched_text: str
|
|
88
|
+
category: str
|
|
89
|
+
explanation: str
|
|
90
|
+
remediation: str
|
|
91
|
+
match_fingerprint: str = ""
|
|
92
|
+
|
|
93
|
+
def __post_init__(self):
|
|
94
|
+
if not self.match_fingerprint:
|
|
95
|
+
raw = f"{self.rule_id}:{self.file_path}:{self.line_number}:{self.matched_text.strip()}"
|
|
96
|
+
self.match_fingerprint = hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()[:16]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ScanReport:
|
|
101
|
+
target_path: str
|
|
102
|
+
scanned_files: int
|
|
103
|
+
findings: List[Finding] = field(default_factory=list)
|
|
104
|
+
risk_score: int = 0
|
|
105
|
+
severity: Severity = Severity.LOW
|
|
106
|
+
recommendation: Recommendation = Recommendation.SAFE
|
|
107
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Unicode & Steganography Constants (NVIDIA SkillSpector spec)
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
ZERO_WIDTH_CHARS = frozenset({
|
|
115
|
+
"\u200B", # Zero-width space
|
|
116
|
+
"\u200C", # Zero-width non-joiner
|
|
117
|
+
"\u200D", # Zero-width joiner
|
|
118
|
+
"\u2060", # Word joiner
|
|
119
|
+
"\uFEFF", # Zero-width no-break space / BOM
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
BIDI_CONTROL_CHARS = re.compile(r"[\u202a-\u202e\u2066-\u2069]")
|
|
123
|
+
|
|
124
|
+
CONFUSABLES_MAP: Dict[str, str] = {
|
|
125
|
+
# Cyrillic lowercase
|
|
126
|
+
"\u0430": "a", "\u0435": "e", "\u043e": "o", "\u0440": "p", "\u0441": "c",
|
|
127
|
+
"\u0443": "y", "\u0456": "i", "\u0445": "x",
|
|
128
|
+
# Cyrillic uppercase
|
|
129
|
+
"\u0410": "A", "\u0412": "B", "\u0415": "E", "\u041a": "K", "\u041c": "M",
|
|
130
|
+
"\u041d": "H", "\u041e": "O", "\u0420": "P", "\u0421": "C", "\u0422": "T",
|
|
131
|
+
"\u0425": "X",
|
|
132
|
+
# Greek lowercase
|
|
133
|
+
"\u03b1": "a", "\u03b5": "e", "\u03bf": "o", "\u03c1": "p", "\u03c7": "x",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_emoji_base(ch: str) -> bool:
|
|
138
|
+
"""Return whether one character is a base for emoji presentation forms."""
|
|
139
|
+
if not ch:
|
|
140
|
+
return False
|
|
141
|
+
codepoint = ord(ch)
|
|
142
|
+
return (
|
|
143
|
+
0x1F000 <= codepoint <= 0x1FAFF
|
|
144
|
+
or 0x2600 <= codepoint <= 0x27BF
|
|
145
|
+
or codepoint in (0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, 0x3030, 0x303D)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# NVIDIA Prohibition Context Engine (Defensive Language Detection)
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
PROHIBITION_PREFIX = re.compile(
|
|
154
|
+
r"(?:\A|(?<=[\n\r.!?;,\[]))[ \t]*"
|
|
155
|
+
r"(?:[-*+][ \t]+|\d+[.)][ \t]+)?"
|
|
156
|
+
r"(?:please[ \t]+)?"
|
|
157
|
+
r"(?:(?:you|(?:the[ \t]+)?(?:agent|assistant|model|user))[ \t]+)?"
|
|
158
|
+
r"(?P<negation>do[ \t]+not|don['’]t|never|no|must[ \t]+not|shall[ \t]+not|strictly[ \t]+forbidden|prohibited|avoid)[ \t]+"
|
|
159
|
+
r"(?:(?:ever|first|verbatim|exactly|word[ \t]+for[ \t]+word)[ \t]+)?\Z",
|
|
160
|
+
re.IGNORECASE,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
ALERT_BLOCK_PREFIX = re.compile(
|
|
164
|
+
r"^\s*>\s*\[!(?:WARNING|CAUTION|DANGER|ALERT|IMPORTANT|NOTE|TIP)\]",
|
|
165
|
+
re.IGNORECASE | re.MULTILINE,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def is_in_prohibition_context(content: str, start: int, end: int) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Evaluates whether a matched snippet is explicitly prohibited or used defensively.
|
|
172
|
+
Eliminates false positives in rules, documentation, and anti-pattern blacklists.
|
|
173
|
+
"""
|
|
174
|
+
if not (0 <= start < end <= len(content)):
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
context_window = max(0, start - 300)
|
|
178
|
+
preceding_text = content[context_window:start]
|
|
179
|
+
|
|
180
|
+
# 1. Direct Negative Imperative ("never do X", "must not use eval")
|
|
181
|
+
clean_prefix = preceding_text.translate(str.maketrans("", "", "*_`"))
|
|
182
|
+
lines = clean_prefix.splitlines()
|
|
183
|
+
current_line = lines[-1] if lines else clean_prefix
|
|
184
|
+
|
|
185
|
+
if PROHIBITION_PREFIX.search(current_line):
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
# 2. Markdown Caution/Warning Callout Block
|
|
189
|
+
if any(ALERT_BLOCK_PREFIX.search(line) for line in lines[-5:]):
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
# 3. Defensive Guideline headings ("Anti-Pattern Blacklist", "Forbidden actions")
|
|
193
|
+
preceding_lower = preceding_text.lower()
|
|
194
|
+
defensive_markers = (
|
|
195
|
+
"anti-pattern",
|
|
196
|
+
"mistake / anti-pattern",
|
|
197
|
+
"strictly forbidden",
|
|
198
|
+
"never use",
|
|
199
|
+
"mandatory avoidance",
|
|
200
|
+
"forbidden actions",
|
|
201
|
+
"prohibited",
|
|
202
|
+
"blacklist",
|
|
203
|
+
"threat model",
|
|
204
|
+
"vulnerability check",
|
|
205
|
+
"zero-token error blacklist",
|
|
206
|
+
)
|
|
207
|
+
if any(marker in preceding_lower for marker in defensive_markers):
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
# 4. Heading-aware Threat Taxonomy & Detection Matrix Guard
|
|
211
|
+
# If the active section is a security audit, threat vector matrix, or detection specification,
|
|
212
|
+
# descriptive tables and taxonomy items are recognized as defensive specifications.
|
|
213
|
+
heading_matches = re.findall(r"(?m)^[ \t]*#{1,4}[ \t]+([^\r\n]+)", content[:start])
|
|
214
|
+
if heading_matches:
|
|
215
|
+
latest_heading = heading_matches[-1].lower()
|
|
216
|
+
taxonomy_markers = (
|
|
217
|
+
"threat", "vulnerability", "detection", "audit", "security scope",
|
|
218
|
+
"coverage", "blacklist", "anti-pattern", "vectors", "scanner", "matrix",
|
|
219
|
+
"threat model", "taxonom"
|
|
220
|
+
)
|
|
221
|
+
if any(h in latest_heading for h in taxonomy_markers):
|
|
222
|
+
current_line = content[:end].splitlines()[-1] if content[:end].splitlines() else ""
|
|
223
|
+
stripped = current_line.strip()
|
|
224
|
+
if stripped.startswith("|") or stripped.startswith("-") or stripped.startswith("*"):
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
# 5. Mermaid Diagram & Architecture Diagram Guard
|
|
228
|
+
# Text inside mermaid blocks (e.g. flowchart node descriptions) is documentation visualization.
|
|
229
|
+
mermaid_opens = content[:start].count("```mermaid")
|
|
230
|
+
fence_closes = content[:start].count("```") - mermaid_opens
|
|
231
|
+
if mermaid_opens > fence_closes:
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
# 6. Inline Code Delimiter & Backtick Example Guard
|
|
235
|
+
# If the pattern is an illustrative code fragment enclosed strictly in backticks
|
|
236
|
+
line_start = content.rfind("\n", 0, start) + 1
|
|
237
|
+
line_end = content.find("\n", end)
|
|
238
|
+
if line_end == -1:
|
|
239
|
+
line_end = len(content)
|
|
240
|
+
full_line = content[line_start:line_end]
|
|
241
|
+
# Check if this occurrence is inside inline code backticks `...`
|
|
242
|
+
before_in_line = content[line_start:start]
|
|
243
|
+
after_in_line = content[end:line_end]
|
|
244
|
+
if (before_in_line.count("`") % 2 == 1) and (after_in_line.count("`") % 2 == 1):
|
|
245
|
+
# Enclosed in backticks - check if line is explanatory
|
|
246
|
+
if any(m in full_line.lower() for m in ("e.g.", "such as", "example", "checks", "pattern", "trigger", "only active", "prohibited", "rule")):
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Vulnerability Pattern Definitions (71 Patterns across 17 Categories)
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
DEFAULT_EXPLANATIONS: Dict[str, str] = {
|
|
257
|
+
# Prompt Injection
|
|
258
|
+
"P1": "Attempts to override system instructions, disable guardrails, or force jailbreak mode.",
|
|
259
|
+
"P2": "Concealed instructions detected via zero-width chars, bidi overrides, or hidden comments.",
|
|
260
|
+
"P3": "Directs the agent to covertly transmit conversation context, history, or user secrets externally.",
|
|
261
|
+
"P4": "Subtle instructions detected that manipulate agent decision-making, bias, or safety boundaries.",
|
|
262
|
+
"P9": "Vertical whitespace padding detected (context smuggling pushing malicious directives out of viewport).",
|
|
263
|
+
# Data Exfiltration
|
|
264
|
+
"E1": "Data is sent to an external HTTP URL or webhook sink via fetch, curl, or HTTP client.",
|
|
265
|
+
"E2": "Code sweeps, harvests, or enumerates environment variables for credentials and API keys.",
|
|
266
|
+
"E3": "Code performs file system reconnaissance searching for sensitive keys (.env, .ssh, .aws).",
|
|
267
|
+
"E4": "Instructions or scripts leak conversational session state or memory to an external endpoint.",
|
|
268
|
+
"E5": "Automated upload to public/remote cloud storage (AWS S3, Google Cloud Storage, Azure Blob).",
|
|
269
|
+
# Privilege Escalation
|
|
270
|
+
"PE1": "Skill requests blanket wildcard permissions ('*') or unconstrained system privileges.",
|
|
271
|
+
"PE2": "Execution of sudo, su, setuid chmod (+s / 4755), or administrative elevation.",
|
|
272
|
+
"PE3": "Direct access or read operations targeting SSH keys, AWS credentials, /etc/shadow, or kubeconfig.",
|
|
273
|
+
"PE4": "Container breakout vector: mounts or accesses docker.sock directly.",
|
|
274
|
+
"PE5": "Execution with dangerous container privileges (--privileged, --cap-add=SYS_ADMIN, /sys/fs/cgroup).",
|
|
275
|
+
# Agent Snooping
|
|
276
|
+
"AS1": "Skill snoops on agent configuration directories (.claude/, .gemini/, .codex/) containing auth tokens.",
|
|
277
|
+
"AS2": "Skill reads MCP client configuration files (mcp.json, claude_desktop_config.json) to steal tokens.",
|
|
278
|
+
"AS3": "Skill inspects or enumerates other installed skills to extract proprietary prompts or capabilities.",
|
|
279
|
+
# System Prompt Leakage
|
|
280
|
+
"P6": "Direct prompt extraction pattern targeting full system instructions or model directives.",
|
|
281
|
+
"P7": "Side-channel or delimiter-based extraction (e.g. 'output text before this line').",
|
|
282
|
+
"P8": "Exfiltrates system prompts or model configurations via outbound tool calls.",
|
|
283
|
+
# Anti-Refusal
|
|
284
|
+
"AR1": "Instructs the agent to never refuse, always comply, or surrender safety refusal rights.",
|
|
285
|
+
"AR2": "Instructs the agent to suppress warnings, moral/ethical commentary, or safety disclaimers.",
|
|
286
|
+
"AR3": "Direct jailbreak declaring the model has no rules, restrictions, or policy bounds.",
|
|
287
|
+
# MCP Tool Poisoning
|
|
288
|
+
"TP1": "Hidden instructions inside tool manifest metadata (HTML comments, Base64, data URIs).",
|
|
289
|
+
"TP2": "Unicode homoglyph deception (Cyrillic/Greek lookalikes imitating Latin characters).",
|
|
290
|
+
"TP3": "Instruction injection embedded inside tool parameter descriptions or default arguments.",
|
|
291
|
+
"TP4": "Semantic discrepancy between declared tool description and actual execution behavior.",
|
|
292
|
+
# MCP Least Privilege
|
|
293
|
+
"LP1": "Code uses network or shell capabilities without declared permissions in manifest.",
|
|
294
|
+
"LP2": "Wildcard tool preapproval ('*') disabling least-privilege boundary.",
|
|
295
|
+
"LP3": "Skill executes sensitive actions without any declared allowed-tools or permissions.",
|
|
296
|
+
# Behavioral AST
|
|
297
|
+
"AST1": "Use of Python exec() executing dynamic or unvalidated code strings.",
|
|
298
|
+
"AST2": "Use of Python eval() evaluating expressions dynamically.",
|
|
299
|
+
"AST3": "Use of __import__() bypassing static import controls.",
|
|
300
|
+
"AST4": "subprocess execution with shell=True allowing command injection.",
|
|
301
|
+
"AST5": "Execution of os.system() or os.popen() running unsanitized shell commands.",
|
|
302
|
+
"AST6": "Dynamic compile() converting arbitrary strings to executable code objects.",
|
|
303
|
+
"AST7": "Dynamic getattr() with non-literal attribute name bypassing attribute access controls.",
|
|
304
|
+
"AST8": "Execution chain combining external inputs or decoded streams directly into an execution sink.",
|
|
305
|
+
"AST9": "Reflective lookup of an execution sink: getattr(os, 'system') or __dict__['system'].",
|
|
306
|
+
"AST10": "Insecure deserialization (pickle, marshal, dill, yaml.unsafe_load, torch.load without weights_only).",
|
|
307
|
+
# Supply Chain & SSRF
|
|
308
|
+
"SC1": "Unpinned remote dependencies allowing supply-chain poisoning.",
|
|
309
|
+
"SC2": "Piping unverified remote scripts to shell (curl | bash, wget | sh).",
|
|
310
|
+
"SSRF1": "Accesses cloud instance metadata service endpoint (169.254.169.254) for IAM token theft.",
|
|
311
|
+
"SSRF2": "Direct network access to internal loopback (127.0.0.1, localhost) or private CIDR ranges.",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
DEFAULT_REMEDIATIONS: Dict[str, str] = {
|
|
315
|
+
"P1": "Remove directives that attempt to override safety instructions or mandate jailbreak modes.",
|
|
316
|
+
"P2": "Strip zero-width characters, bidi control characters, and hidden HTML/Markdown comments.",
|
|
317
|
+
"P3": "Ensure the skill never transmits conversation context, prompts, or user history to external URLs.",
|
|
318
|
+
"P4": "Remove subtle steering directives; skills must follow user intentions transparently.",
|
|
319
|
+
"P9": "Remove excessive vertical whitespace padding from instructions and templates.",
|
|
320
|
+
"E1": "Validate and whitelist external network endpoints. Avoid sending raw data to untrusted endpoints.",
|
|
321
|
+
"E2": "Access only explicitly required environment variables. Never dump or enumerate os.environ.",
|
|
322
|
+
"E3": "Restrict file operations to the designated project workspace. Never scan home directory credentials.",
|
|
323
|
+
"E4": "Prevent conversation history from being included in external payloads.",
|
|
324
|
+
"E5": "Audit cloud storage upload destinations and require explicit user authorization.",
|
|
325
|
+
"PE1": "Scope permissions explicitly to the minimal necessary tools; replace '*' with specific tools.",
|
|
326
|
+
"PE2": "Avoid running with sudo or root privileges. Execute commands under an unprivileged user.",
|
|
327
|
+
"PE3": "Remove any code accessing ~/.ssh, ~/.aws, /etc/shadow, or local credential stores.",
|
|
328
|
+
"PE4": "Do not mount /var/run/docker.sock inside test scripts or skills.",
|
|
329
|
+
"PE5": "Remove --privileged and administrative capability flags from container runners.",
|
|
330
|
+
"AS1": "Do not access ~/.claude, ~/.gemini, or ~/.codex directories. Pass required config explicitly.",
|
|
331
|
+
"AS2": "Never read MCP configuration files or desktop token stores directly.",
|
|
332
|
+
"AS3": "Ensure skills operate in hermetic isolation without inspecting peer skill files.",
|
|
333
|
+
"P6": "Remove instructions prompting the model to reveal its full system prompt or system guidelines.",
|
|
334
|
+
"P7": "Strip side-channel prompt extraction patterns.",
|
|
335
|
+
"P8": "Do not forward system prompts to external tool calls or API endpoints.",
|
|
336
|
+
"AR1": "Allow the AI agent to retain its native right to refuse unsafe or out-of-scope requests.",
|
|
337
|
+
"AR2": "Preserve safety warnings, ethical caveats, and security notices.",
|
|
338
|
+
"AR3": "Remove statements declaring freedom from rules or unrestricted operation.",
|
|
339
|
+
"TP1": "Use plain visible text in tool manifests; remove HTML comments and base64 payloads.",
|
|
340
|
+
"TP2": "Replace Cyrillic/Greek lookalike characters in tool identifiers with standard ASCII characters.",
|
|
341
|
+
"TP3": "Clean parameter descriptions of instruction injection or prompt-engineering tokens.",
|
|
342
|
+
"TP4": "Ensure tool description matches exactly what the underlying implementation executes.",
|
|
343
|
+
"LP1": "Declare all required tools and network permissions explicitly in SKILL.md or manifest.",
|
|
344
|
+
"LP2": "Replace wildcard tool permissions with exact tool names.",
|
|
345
|
+
"LP3": "Declare an explicit allowed-tools or permissions list.",
|
|
346
|
+
"AST1": "Replace exec() with structured parsing or sandboxed execution.",
|
|
347
|
+
"AST2": "Replace eval() with ast.literal_eval() or explicit JSON parsing.",
|
|
348
|
+
"AST3": "Use standard top-level import statements instead of dynamic __import__().",
|
|
349
|
+
"AST4": "Set shell=False and pass command arguments as a list of strings.",
|
|
350
|
+
"AST5": "Replace os.system() with subprocess.run(..., shell=False).",
|
|
351
|
+
"AST6": "Avoid compile() on dynamic strings.",
|
|
352
|
+
"AST7": "Use explicit method lookups or a strict allowlist of attribute names.",
|
|
353
|
+
"AST8": "Never feed decoded network streams directly into exec/eval sinks.",
|
|
354
|
+
"AST9": "Call functions directly and avoid reflective evasion patterns (getattr(os, 'system')).",
|
|
355
|
+
"AST10": "Use json.loads() or yaml.safe_load() instead of pickle/marshal/yaml.unsafe_load.",
|
|
356
|
+
"SC1": "Pin dependency versions with exact hashes or version locks.",
|
|
357
|
+
"SC2": "Download scripts, verify checksum/GPG signatures, and review before execution.",
|
|
358
|
+
"SSRF1": "Block requests to 169.254.169.254; use IMDSv2 with token hops restricted.",
|
|
359
|
+
"SSRF2": "Validate target hosts against an allowlist and reject loopback/internal addresses.",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Static Regex Patterns
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
PATTERNS_P1 = [
|
|
368
|
+
(re.compile(r"\bignore\s+(?:all\s+)?previous\s+instructions?\b", re.I), "P1", Severity.CRITICAL, 0.95),
|
|
369
|
+
(re.compile(r"\bignore\s+(?:all\s+)?(?:safety|security)\s+(?:rules?|guidelines?|constraints?)\b", re.I), "P1", Severity.CRITICAL, 0.95),
|
|
370
|
+
(re.compile(r"\boverride\s+(?:safety|security|system)\b", re.I), "P1", Severity.HIGH, 0.9),
|
|
371
|
+
(re.compile(r"\bbypass\s+(?:safety|security|restrictions?|constraints?)\b", re.I), "P1", Severity.HIGH, 0.9),
|
|
372
|
+
(re.compile(r"\byou\s+are\s+now\s+(?:in\s+)?(?:jailbreak|unrestricted|unfiltered)\s+mode\b", re.I), "P1", Severity.CRITICAL, 0.98),
|
|
373
|
+
(re.compile(r"\benable\s+(?:developer|debug|admin|root)\s+mode\b", re.I), "P1", Severity.HIGH, 0.85),
|
|
374
|
+
(re.compile(r"\bdo\s+not\s+follow\s+(?:any|your|the)\s+(?:rules?|guidelines?)\b", re.I), "P1", Severity.CRITICAL, 0.95),
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
PATTERNS_P3 = [
|
|
378
|
+
(re.compile(r"\b(?:send|transmit|upload|post)\s+(?:the\s+)?(?:conversation|context|chat|history)\s+to\b", re.I), "P3", Severity.CRITICAL, 0.95),
|
|
379
|
+
(re.compile(r"\b(?:send|transmit|upload|post)\s+(?:all\s+)?user\s+(?:data|input|files?)\s+to\s+https?://\b", re.I), "P3", Severity.CRITICAL, 0.95),
|
|
380
|
+
(re.compile(r"\b(?:silently|quietly|secretly|covertly)\s+(?:send|transmit|log|record)\b", re.I), "P3", Severity.CRITICAL, 0.9),
|
|
381
|
+
(re.compile(r"\bwithout\s+(?:telling|informing|notifying)\s+(?:the\s+)?user.*?(?:send|transmit|upload)\b", re.I), "P3", Severity.CRITICAL, 0.95),
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
PATTERNS_P4 = [
|
|
385
|
+
(re.compile(r"\bprioritize\s+\w+\s+over\s+user\s+(?:safety|security|privacy)\b", re.I), "P4", Severity.HIGH, 0.85),
|
|
386
|
+
(re.compile(r"\b(?:subtly|gradually)\s+(?:steer|guide|push)\s+(?:the\s+)?user\b", re.I), "P4", Severity.MEDIUM, 0.8),
|
|
387
|
+
(re.compile(r"\bwithout\s+(?:the\s+)?user\s+(?:knowing|noticing).*?(?:change|modify|alter)\b", re.I), "P4", Severity.HIGH, 0.85),
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
PATTERNS_E1 = [
|
|
391
|
+
(re.compile(r"curl\s+[^|]*(?:-d|--data|--data-raw|--data-binary)\s+https?://", re.I), "E1", Severity.HIGH, 0.8),
|
|
392
|
+
(re.compile(r"wget\s+[^|]*--post-(?:data|file)", re.I), "E1", Severity.HIGH, 0.8),
|
|
393
|
+
(re.compile(r"fetch\s*\(\s*['\"]https?://[^'\"]+['\"][^)]*method\s*:\s*['\"]POST['\"]", re.I), "E1", Severity.HIGH, 0.75),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
PATTERNS_E2 = [
|
|
397
|
+
(re.compile(r"for\s+\w+,\s*\w+\s+in\s+os\.environ\.items\(\)", re.I), "E2", Severity.HIGH, 0.85),
|
|
398
|
+
(re.compile(r"os\.environ\.copy\(\)", re.I), "E2", Severity.MEDIUM, 0.75),
|
|
399
|
+
(re.compile(r"dict\s*\(\s*os\.environ\s*\)", re.I), "E2", Severity.MEDIUM, 0.75),
|
|
400
|
+
(re.compile(r"env\s*\|\s*grep\s+(?:-i\s+)?(?:key|secret|token|password)", re.I), "E2", Severity.HIGH, 0.85),
|
|
401
|
+
(re.compile(r"printenv\s+(?:\w*(?:KEY|SECRET|TOKEN|PASSWORD)\w*)", re.I), "E2", Severity.HIGH, 0.85),
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
PATTERNS_E3 = [
|
|
405
|
+
(re.compile(r"glob\s*\.\s*glob\s*\([^)]*(?:\.env|\.ssh|\.aws|credentials)", re.I), "E3", Severity.HIGH, 0.9),
|
|
406
|
+
(re.compile(r"os\s*\.\s*listdir\s*\([^)]*(?:\.ssh|\.aws|\.gnupg)", re.I), "E3", Severity.CRITICAL, 0.95),
|
|
407
|
+
(re.compile(r"find\s+[~$/]\S*\s+.*?-name\s+['\"]?\*(?:\.env|\.pem|\.key|credential)", re.I), "E3", Severity.CRITICAL, 0.9),
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
PATTERNS_E5 = [
|
|
411
|
+
(re.compile(r"\baws\s+s3\s+(?:cp|sync|mv)\b", re.I), "E5", Severity.MEDIUM, 0.7),
|
|
412
|
+
(re.compile(r"\bgsutil\s+(?:cp|rsync|mv)\b", re.I), "E5", Severity.MEDIUM, 0.7),
|
|
413
|
+
(re.compile(r"\.upload_file(?:obj)?\s*\(", re.I), "E5", Severity.LOW, 0.6),
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
PATTERNS_PE2 = [
|
|
417
|
+
(re.compile(r"\bsudo\s+(?!-v|-l|--version|--list)", re.I), "PE2", Severity.HIGH, 0.85),
|
|
418
|
+
(re.compile(r"\bsudo\s+su\b", re.I), "PE2", Severity.CRITICAL, 0.95),
|
|
419
|
+
(re.compile(r"\bsu\s+-\s*$|\bsu\s+root\b", re.I), "PE2", Severity.CRITICAL, 0.95),
|
|
420
|
+
(re.compile(r"\bchmod\s+[ugo]*[+-=]*s\b", re.I), "PE2", Severity.CRITICAL, 0.95),
|
|
421
|
+
(re.compile(r"\bchmod\s+(?:-[\w=-]*\s+)*['\"]?0*[2-7][0-7]{3}['\"]?", re.I), "PE2", Severity.HIGH, 0.9),
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
PATTERNS_PE3 = [
|
|
425
|
+
(re.compile(r"~?/?\.ssh/(?:id_rsa|id_ed25519|id_ecdsa|authorized_keys|known_hosts)", re.I), "PE3", Severity.CRITICAL, 0.95),
|
|
426
|
+
(re.compile(r"~?/?\.aws/credentials", re.I), "PE3", Severity.CRITICAL, 0.95),
|
|
427
|
+
(re.compile(r"/etc/shadow\b", re.I), "PE3", Severity.CRITICAL, 0.98),
|
|
428
|
+
(re.compile(r"~?/?\.kube/config\b", re.I), "PE3", Severity.CRITICAL, 0.95),
|
|
429
|
+
(re.compile(r"~?/?\.git-credentials\b", re.I), "PE3", Severity.CRITICAL, 0.95),
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
PATTERNS_PE4_PE5 = [
|
|
433
|
+
(re.compile(r"/var/run/docker\.sock", re.I), "PE4", Severity.CRITICAL, 0.95),
|
|
434
|
+
(re.compile(r"--privileged\b", re.I), "PE5", Severity.CRITICAL, 0.9),
|
|
435
|
+
(re.compile(r"-v\s+/:|--volume\s+/:", re.I), "PE5", Severity.CRITICAL, 0.95),
|
|
436
|
+
(re.compile(r"--cap-add[=\s]+(?:SYS_ADMIN|ALL)", re.I), "PE5", Severity.CRITICAL, 0.95),
|
|
437
|
+
(re.compile(r"\bnsenter\b", re.I), "PE5", Severity.CRITICAL, 0.95),
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
PATTERNS_AS = [
|
|
441
|
+
(re.compile(r"open\s*\(\s*['\"]?\.(?:claude|codex|gemini|continue)/", re.I), "AS1", Severity.CRITICAL, 0.95),
|
|
442
|
+
(re.compile(r"~?/\.(?:claude|codex|gemini|continue)/(?:config|settings?|credentials?)", re.I), "AS1", Severity.CRITICAL, 0.95),
|
|
443
|
+
(re.compile(r"\b(?:mcp(?:_config|_settings)?|claude_desktop_config|\.claude)\.json\b", re.I), "AS2", Severity.CRITICAL, 0.95),
|
|
444
|
+
(re.compile(r"skills?/(?!CURRENT)[A-Za-z0-9_-]+/SKILL\.md", re.I), "AS3", Severity.HIGH, 0.85),
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
PATTERNS_P6_P7 = [
|
|
448
|
+
(re.compile(r"\b(?:print|output|show|reveal|echo)\s+(?:your\s+)?(?:full\s+)?(?:system\s+)?(?:prompt|instructions?|rules?)\b", re.I), "P6", Severity.HIGH, 0.9),
|
|
449
|
+
(re.compile(r"\b(?:what\s+(?:are|is)\s+your|tell\s+me\s+your)\s+(?:system\s+)?(?:prompts?|instructions?)\b", re.I), "P6", Severity.HIGH, 0.85),
|
|
450
|
+
(re.compile(r"\boutput\s+(?:the\s+)?(?:text|content)\s+(?:above|before)\s+(?:this|the\s+user)\b", re.I), "P7", Severity.HIGH, 0.85),
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
PATTERNS_AR = [
|
|
454
|
+
(re.compile(r"\b(?:never|do\s+not|don'?t)\s+(?:ever\s+)?(?:refuse|decline)\b", re.I), "AR1", Severity.CRITICAL, 0.95),
|
|
455
|
+
(re.compile(r"\byou\s+(?:must|should|will)\s*(?:not|never)\s+(?:refuse|say\s+no)\b", re.I), "AR1", Severity.CRITICAL, 0.95),
|
|
456
|
+
(re.compile(r"\balways\s+(?:comply|obey|agree\s+to\s+every\s+request)\b", re.I), "AR1", Severity.HIGH, 0.85),
|
|
457
|
+
(re.compile(r"\b(?:do\s+not|don'?t|never|no)\s+(?:include|add|give)?\s*(?:any\s+)?(?:warnings?|disclaimers?)\b", re.I), "AR2", Severity.HIGH, 0.85),
|
|
458
|
+
(re.compile(r"\b(?:skip|omit|drop)\s+(?:the\s+)?(?:disclaimer|warning|ethics|safety)\b", re.I), "AR2", Severity.HIGH, 0.85),
|
|
459
|
+
(re.compile(r"\byou\s+(?:have|are\s+under)\s+no\s+(?:restrictions?|rules?|guidelines?|limits?)\b", re.I), "AR3", Severity.CRITICAL, 0.98),
|
|
460
|
+
(re.compile(r"\bdo\s+anything\s+now\b", re.I), "AR3", Severity.CRITICAL, 0.95),
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
# Request-issuing anchors for SSRF (NVIDIA SkillSpector spec)
|
|
464
|
+
_REQ_ANCHOR = r"(?:requests|httpx|aiohttp|urllib(?:\.request)?|urllib3|session)\s*\.\s*(?:get|post|put|patch|delete|head|request|urlopen)|fetch|axios(?:\.\w+)?|XMLHttpRequest|\bcurl\b|\bwget\b"
|
|
465
|
+
|
|
466
|
+
PATTERNS_SC_SSRF = [
|
|
467
|
+
(re.compile(r"\bcurl\s+[^|]+\|\s*(?:ba)?sh\b", re.I), "SC2", Severity.CRITICAL, 0.95),
|
|
468
|
+
(re.compile(r"\bwget\s+[^|]+\|\s*(?:ba)?sh\b", re.I), "SC2", Severity.CRITICAL, 0.95),
|
|
469
|
+
(re.compile(r"169\.254\.169\.254", re.I), "SSRF1", Severity.CRITICAL, 0.98),
|
|
470
|
+
(re.compile(rf"(?:{_REQ_ANCHOR})\s*\(\s*f?['\"]https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|10\.\d|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)", re.I), "SSRF2", Severity.MEDIUM, 0.7),
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
ALL_TEXT_PATTERN_GROUPS = [
|
|
474
|
+
("Prompt Injection", PATTERNS_P1),
|
|
475
|
+
("Prompt Injection", PATTERNS_P3),
|
|
476
|
+
("Prompt Injection", PATTERNS_P4),
|
|
477
|
+
("Data Exfiltration", PATTERNS_E1),
|
|
478
|
+
("Data Exfiltration", PATTERNS_E2),
|
|
479
|
+
("Data Exfiltration", PATTERNS_E3),
|
|
480
|
+
("Data Exfiltration", PATTERNS_E5),
|
|
481
|
+
("Privilege Escalation", PATTERNS_PE2),
|
|
482
|
+
("Privilege Escalation", PATTERNS_PE3),
|
|
483
|
+
("Privilege Escalation", PATTERNS_PE4_PE5),
|
|
484
|
+
("Agent Snooping", PATTERNS_AS),
|
|
485
|
+
("System Prompt Leakage", PATTERNS_P6_P7),
|
|
486
|
+
("Anti-Refusal", PATTERNS_AR),
|
|
487
|
+
("Supply Chain / SSRF", PATTERNS_SC_SSRF),
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
# Specialized Analyzers
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
def scan_text_patterns(file_path: str, content: str) -> List[Finding]:
|
|
496
|
+
"""Scans textual content using compiled regexes with prohibition context gating."""
|
|
497
|
+
findings: List[Finding] = []
|
|
498
|
+
lines = content.splitlines(keepends=True)
|
|
499
|
+
|
|
500
|
+
# Line number offset indexing
|
|
501
|
+
line_offsets = []
|
|
502
|
+
curr = 0
|
|
503
|
+
for l in lines:
|
|
504
|
+
line_offsets.append(curr)
|
|
505
|
+
curr += len(l)
|
|
506
|
+
|
|
507
|
+
def get_line_num(char_idx: int) -> int:
|
|
508
|
+
for idx, offset in enumerate(line_offsets):
|
|
509
|
+
if char_idx < offset:
|
|
510
|
+
return idx
|
|
511
|
+
return len(line_offsets)
|
|
512
|
+
|
|
513
|
+
for category, pattern_group in ALL_TEXT_PATTERN_GROUPS:
|
|
514
|
+
for regex, rule_id, severity, confidence in pattern_group:
|
|
515
|
+
for match in regex.finditer(content):
|
|
516
|
+
start, end = match.start(), match.end()
|
|
517
|
+
# Apply Prohibition Context Guard
|
|
518
|
+
if is_in_prohibition_context(content, start, end):
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
matched_snippet = match.group(0)
|
|
522
|
+
line_no = get_line_num(start)
|
|
523
|
+
findings.append(Finding(
|
|
524
|
+
rule_id=rule_id,
|
|
525
|
+
message=f"{rule_id} detected: {matched_snippet[:60]}...",
|
|
526
|
+
severity=severity,
|
|
527
|
+
confidence=confidence,
|
|
528
|
+
file_path=file_path,
|
|
529
|
+
line_number=line_no,
|
|
530
|
+
matched_text=matched_snippet[:200],
|
|
531
|
+
category=category,
|
|
532
|
+
explanation=DEFAULT_EXPLANATIONS.get(rule_id, "Security vulnerability pattern detected."),
|
|
533
|
+
remediation=DEFAULT_REMEDIATIONS.get(rule_id, "Review and sanitize content."),
|
|
534
|
+
))
|
|
535
|
+
return findings
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def scan_whitespace_padding(file_path: str, content: str) -> List[Finding]:
|
|
539
|
+
"""P9: Detects excessive vertical or horizontal whitespace padding used to hide malicious instructions."""
|
|
540
|
+
findings: List[Finding] = []
|
|
541
|
+
if file_path.endswith((".min.js", ".min.css", ".lock", ".svg", ".map", ".json")):
|
|
542
|
+
return findings
|
|
543
|
+
|
|
544
|
+
# Check 1: 20+ consecutive blank lines
|
|
545
|
+
consecutive_newlines = re.search(r"(\r?\n\s*){20,}", content)
|
|
546
|
+
if consecutive_newlines:
|
|
547
|
+
start = consecutive_newlines.start()
|
|
548
|
+
line_no = content[:start].count("\n") + 1
|
|
549
|
+
findings.append(Finding(
|
|
550
|
+
rule_id="P9",
|
|
551
|
+
message="Large vertical whitespace padding detected (>20 blank lines). Potential context smuggling.",
|
|
552
|
+
severity=Severity.HIGH,
|
|
553
|
+
confidence=0.85,
|
|
554
|
+
file_path=file_path,
|
|
555
|
+
line_number=line_no,
|
|
556
|
+
matched_text=consecutive_newlines.group(0)[:100],
|
|
557
|
+
category="Prompt Injection",
|
|
558
|
+
explanation=DEFAULT_EXPLANATIONS["P9"],
|
|
559
|
+
remediation=DEFAULT_REMEDIATIONS["P9"],
|
|
560
|
+
))
|
|
561
|
+
|
|
562
|
+
# Check 2: 120+ consecutive spaces
|
|
563
|
+
consecutive_spaces = re.search(r"[ \t]{120,}", content)
|
|
564
|
+
if consecutive_spaces:
|
|
565
|
+
start = consecutive_spaces.start()
|
|
566
|
+
line_no = content[:start].count("\n") + 1
|
|
567
|
+
findings.append(Finding(
|
|
568
|
+
rule_id="P9",
|
|
569
|
+
message="Large horizontal whitespace run detected (>120 spaces). Potential off-screen text injection.",
|
|
570
|
+
severity=Severity.MEDIUM,
|
|
571
|
+
confidence=0.75,
|
|
572
|
+
file_path=file_path,
|
|
573
|
+
line_number=line_no,
|
|
574
|
+
matched_text=" " * 40 + "...[truncated whitespace]...",
|
|
575
|
+
category="Prompt Injection",
|
|
576
|
+
explanation=DEFAULT_EXPLANATIONS["P9"],
|
|
577
|
+
remediation=DEFAULT_REMEDIATIONS["P9"],
|
|
578
|
+
))
|
|
579
|
+
|
|
580
|
+
return findings
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def scan_unicode_and_steganography(file_path: str, content: str) -> List[Finding]:
|
|
584
|
+
"""P2 / TP1 / TP2: Detects zero-width steganography, bidi controls, and homoglyphs with emoji awareness."""
|
|
585
|
+
findings: List[Finding] = []
|
|
586
|
+
|
|
587
|
+
# 1. Zero-width character scan (excluding valid emoji ZWJ sequences)
|
|
588
|
+
for idx, char in enumerate(content):
|
|
589
|
+
if char in ZERO_WIDTH_CHARS:
|
|
590
|
+
# Check if this zero-width character is part of a legitimate emoji sequence
|
|
591
|
+
if char in {"\u200D", "\u200C"}:
|
|
592
|
+
prev_ch = content[idx - 1] if idx > 0 else ""
|
|
593
|
+
next_ch = content[idx + 1] if idx + 1 < len(content) else ""
|
|
594
|
+
if _is_emoji_base(prev_ch) or _is_emoji_base(next_ch):
|
|
595
|
+
continue # Valid Emoji ZWJ sequence (e.g. 👨👩👧👦 or 🏳️🌈)
|
|
596
|
+
|
|
597
|
+
line_no = content[:idx].count("\n") + 1
|
|
598
|
+
findings.append(Finding(
|
|
599
|
+
rule_id="P2",
|
|
600
|
+
message=f"Hidden zero-width character (U+{ord(char):04X}) detected.",
|
|
601
|
+
severity=Severity.HIGH,
|
|
602
|
+
confidence=0.9,
|
|
603
|
+
file_path=file_path,
|
|
604
|
+
line_number=line_no,
|
|
605
|
+
matched_text=f"U+{ord(char):04X}",
|
|
606
|
+
category="Prompt Injection",
|
|
607
|
+
explanation=DEFAULT_EXPLANATIONS["P2"],
|
|
608
|
+
remediation=DEFAULT_REMEDIATIONS["P2"],
|
|
609
|
+
))
|
|
610
|
+
break # Report once per file to avoid noise
|
|
611
|
+
|
|
612
|
+
# 2. Bidi control characters (Trojan Source CVE-2021-42574)
|
|
613
|
+
bidi_match = BIDI_CONTROL_CHARS.search(content)
|
|
614
|
+
if bidi_match:
|
|
615
|
+
start = bidi_match.start()
|
|
616
|
+
line_no = content[:start].count("\n") + 1
|
|
617
|
+
char = bidi_match.group(0)
|
|
618
|
+
findings.append(Finding(
|
|
619
|
+
rule_id="P2",
|
|
620
|
+
message=f"Bidirectional control character (U+{ord(char):04X}) detected. Potential Trojan Source attack.",
|
|
621
|
+
severity=Severity.CRITICAL,
|
|
622
|
+
confidence=0.95,
|
|
623
|
+
file_path=file_path,
|
|
624
|
+
line_number=line_no,
|
|
625
|
+
matched_text=f"U+{ord(char):04X}",
|
|
626
|
+
category="Prompt Injection",
|
|
627
|
+
explanation="Bidirectional Unicode overrides can reorder code/tokens visually while executing differently.",
|
|
628
|
+
remediation="Strip bidirectional formatting characters from source code and markdown.",
|
|
629
|
+
))
|
|
630
|
+
|
|
631
|
+
# 3. Cyrillic / Greek homoglyph deception (TP2)
|
|
632
|
+
mixed_words = re.findall(r"\b[A-Za-z0-9_-]*[\u0400-\u04FF\u0370-\u03FF][A-Za-z0-9_-]*\b", content)
|
|
633
|
+
if mixed_words:
|
|
634
|
+
sample_word = mixed_words[0]
|
|
635
|
+
if len(sample_word) > 2:
|
|
636
|
+
findings.append(Finding(
|
|
637
|
+
rule_id="TP2",
|
|
638
|
+
message=f"Unicode homoglyph deception detected in identifier '{sample_word}'.",
|
|
639
|
+
severity=Severity.HIGH,
|
|
640
|
+
confidence=0.85,
|
|
641
|
+
file_path=file_path,
|
|
642
|
+
line_number=1,
|
|
643
|
+
matched_text=sample_word,
|
|
644
|
+
category="MCP Tool Poisoning",
|
|
645
|
+
explanation=DEFAULT_EXPLANATIONS["TP2"],
|
|
646
|
+
remediation=DEFAULT_REMEDIATIONS["TP2"],
|
|
647
|
+
))
|
|
648
|
+
|
|
649
|
+
return findings
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def scan_tool_manifest_poisoning(file_path: str, content: str) -> List[Finding]:
|
|
653
|
+
"""TP1 & TP3: Detects hidden HTML/markdown comments and base64 payloads in metadata fields."""
|
|
654
|
+
findings: List[Finding] = []
|
|
655
|
+
if not (file_path.endswith("SKILL.md") or file_path.endswith(".json") or file_path.endswith(".yaml") or file_path.endswith(".yml")):
|
|
656
|
+
return findings
|
|
657
|
+
|
|
658
|
+
# TP1: Hidden HTML comments in manifests
|
|
659
|
+
html_comments = re.findall(r"<!--(?!-->)(.*?)-->", content, re.DOTALL)
|
|
660
|
+
for c in html_comments:
|
|
661
|
+
c_upper = c.upper()
|
|
662
|
+
if any(kw in c_upper for kw in ("SYSTEM:", "IGNORE", "OVERRIDE", "YOU MUST", "EXEC", "ENV")):
|
|
663
|
+
findings.append(Finding(
|
|
664
|
+
rule_id="TP1",
|
|
665
|
+
message="Hidden HTML comment with instruction injection directives detected in metadata.",
|
|
666
|
+
severity=Severity.CRITICAL,
|
|
667
|
+
confidence=0.95,
|
|
668
|
+
file_path=file_path,
|
|
669
|
+
line_number=1,
|
|
670
|
+
matched_text=f"<!--{c[:100]}...-->",
|
|
671
|
+
category="MCP Tool Poisoning",
|
|
672
|
+
explanation=DEFAULT_EXPLANATIONS["TP1"],
|
|
673
|
+
remediation=DEFAULT_REMEDIATIONS["TP1"],
|
|
674
|
+
))
|
|
675
|
+
break
|
|
676
|
+
|
|
677
|
+
# TP1: Data URI with Base64 payloads in metadata
|
|
678
|
+
data_uri = re.search(r"data:text/[^;\s\"'<>]+;base64,[A-Za-z0-9+/=_-]{50,}", content)
|
|
679
|
+
if data_uri:
|
|
680
|
+
findings.append(Finding(
|
|
681
|
+
rule_id="TP1",
|
|
682
|
+
message="Data URI containing large Base64 payload found in metadata/documentation.",
|
|
683
|
+
severity=Severity.HIGH,
|
|
684
|
+
confidence=0.9,
|
|
685
|
+
file_path=file_path,
|
|
686
|
+
line_number=content[:data_uri.start()].count("\n") + 1,
|
|
687
|
+
matched_text=data_uri.group(0)[:80] + "...",
|
|
688
|
+
category="MCP Tool Poisoning",
|
|
689
|
+
explanation=DEFAULT_EXPLANATIONS["TP1"],
|
|
690
|
+
remediation=DEFAULT_REMEDIATIONS["TP1"],
|
|
691
|
+
))
|
|
692
|
+
|
|
693
|
+
# LP2: Wildcard permissions
|
|
694
|
+
if re.search(r"permissions?\s*:\s*\[?\s*['\"]?\*['\"]?\s*\]?", content, re.I):
|
|
695
|
+
findings.append(Finding(
|
|
696
|
+
rule_id="LP2",
|
|
697
|
+
message="Wildcard permission declaration ('*') detected. Disables least-privilege boundary.",
|
|
698
|
+
severity=Severity.HIGH,
|
|
699
|
+
confidence=0.9,
|
|
700
|
+
file_path=file_path,
|
|
701
|
+
line_number=1,
|
|
702
|
+
matched_text="permissions: ['*']",
|
|
703
|
+
category="MCP Least Privilege",
|
|
704
|
+
explanation=DEFAULT_EXPLANATIONS["LP2"],
|
|
705
|
+
remediation=DEFAULT_REMEDIATIONS["LP2"],
|
|
706
|
+
))
|
|
707
|
+
|
|
708
|
+
return findings
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
# Behavioral AST Analyzer (Python Scripts & Tool Code)
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
DANGEROUS_DESERIALIZERS = frozenset({
|
|
716
|
+
"pickle.load", "pickle.loads", "cPickle.load", "cPickle.loads",
|
|
717
|
+
"_pickle.load", "_pickle.loads", "marshal.load", "marshal.loads",
|
|
718
|
+
"dill.load", "dill.loads", "jsonpickle.decode", "pandas.read_pickle",
|
|
719
|
+
"joblib.load", "yaml.unsafe_load"
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class BehavioralASTVisitor(ast.NodeVisitor):
|
|
724
|
+
def __init__(self, file_path: str):
|
|
725
|
+
self.file_path = file_path
|
|
726
|
+
self.findings: List[Finding] = []
|
|
727
|
+
|
|
728
|
+
def visit_Call(self, node: ast.Call):
|
|
729
|
+
call_name = self._resolve_call_name(node.func)
|
|
730
|
+
|
|
731
|
+
# AST1: exec()
|
|
732
|
+
if call_name == "exec":
|
|
733
|
+
self.findings.append(Finding(
|
|
734
|
+
rule_id="AST1",
|
|
735
|
+
message="Dangerous dynamic code execution via exec().",
|
|
736
|
+
severity=Severity.CRITICAL,
|
|
737
|
+
confidence=0.95,
|
|
738
|
+
file_path=self.file_path,
|
|
739
|
+
line_number=node.lineno,
|
|
740
|
+
matched_text="exec(...)",
|
|
741
|
+
category="Behavioral AST",
|
|
742
|
+
explanation=DEFAULT_EXPLANATIONS["AST1"],
|
|
743
|
+
remediation=DEFAULT_REMEDIATIONS["AST1"],
|
|
744
|
+
))
|
|
745
|
+
|
|
746
|
+
# AST2: eval()
|
|
747
|
+
elif call_name == "eval":
|
|
748
|
+
self.findings.append(Finding(
|
|
749
|
+
rule_id="AST2",
|
|
750
|
+
message="Dangerous dynamic expression evaluation via eval().",
|
|
751
|
+
severity=Severity.CRITICAL,
|
|
752
|
+
confidence=0.95,
|
|
753
|
+
file_path=self.file_path,
|
|
754
|
+
line_number=node.lineno,
|
|
755
|
+
matched_text="eval(...)",
|
|
756
|
+
category="Behavioral AST",
|
|
757
|
+
explanation=DEFAULT_EXPLANATIONS["AST2"],
|
|
758
|
+
remediation=DEFAULT_REMEDIATIONS["AST2"],
|
|
759
|
+
))
|
|
760
|
+
|
|
761
|
+
# AST3: __import__()
|
|
762
|
+
elif call_name == "__import__":
|
|
763
|
+
self.findings.append(Finding(
|
|
764
|
+
rule_id="AST3",
|
|
765
|
+
message="Dynamic import via __import__() bypassing static module imports.",
|
|
766
|
+
severity=Severity.HIGH,
|
|
767
|
+
confidence=0.85,
|
|
768
|
+
file_path=self.file_path,
|
|
769
|
+
line_number=node.lineno,
|
|
770
|
+
matched_text="__import__(...)",
|
|
771
|
+
category="Behavioral AST",
|
|
772
|
+
explanation=DEFAULT_EXPLANATIONS["AST3"],
|
|
773
|
+
remediation=DEFAULT_REMEDIATIONS["AST3"],
|
|
774
|
+
))
|
|
775
|
+
|
|
776
|
+
# AST4: subprocess with shell=True
|
|
777
|
+
elif call_name in {"subprocess.run", "subprocess.Popen", "subprocess.call", "subprocess.check_output", "subprocess.check_call"}:
|
|
778
|
+
for kw in node.keywords:
|
|
779
|
+
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
780
|
+
self.findings.append(Finding(
|
|
781
|
+
rule_id="AST4",
|
|
782
|
+
message=f"{call_name}(..., shell=True) detected. High risk of command injection.",
|
|
783
|
+
severity=Severity.HIGH,
|
|
784
|
+
confidence=0.9,
|
|
785
|
+
file_path=self.file_path,
|
|
786
|
+
line_number=node.lineno,
|
|
787
|
+
matched_text=f"{call_name}(..., shell=True)",
|
|
788
|
+
category="Behavioral AST",
|
|
789
|
+
explanation=DEFAULT_EXPLANATIONS["AST4"],
|
|
790
|
+
remediation=DEFAULT_REMEDIATIONS["AST4"],
|
|
791
|
+
))
|
|
792
|
+
|
|
793
|
+
# AST5: os.system / os.popen
|
|
794
|
+
elif call_name in {"os.system", "os.popen", "os.execl", "os.execv", "os.spawnl"}:
|
|
795
|
+
self.findings.append(Finding(
|
|
796
|
+
rule_id="AST5",
|
|
797
|
+
message=f"Direct shell invocation via {call_name}().",
|
|
798
|
+
severity=Severity.HIGH,
|
|
799
|
+
confidence=0.9,
|
|
800
|
+
file_path=self.file_path,
|
|
801
|
+
line_number=node.lineno,
|
|
802
|
+
matched_text=f"{call_name}(...)",
|
|
803
|
+
category="Behavioral AST",
|
|
804
|
+
explanation=DEFAULT_EXPLANATIONS["AST5"],
|
|
805
|
+
remediation=DEFAULT_REMEDIATIONS["AST5"],
|
|
806
|
+
))
|
|
807
|
+
|
|
808
|
+
# AST9: Reflective execution sink access: getattr(os, "system") or getattr(builtins, "exec")
|
|
809
|
+
elif call_name == "getattr" and len(node.args) >= 2:
|
|
810
|
+
target_attr = ""
|
|
811
|
+
if isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
|
|
812
|
+
target_attr = node.args[1].value
|
|
813
|
+
if target_attr in {"system", "popen", "exec", "eval", "__import__"}:
|
|
814
|
+
self.findings.append(Finding(
|
|
815
|
+
rule_id="AST9",
|
|
816
|
+
message=f"Reflective execution sink lookup via getattr(..., '{target_attr}'). Evasion pattern.",
|
|
817
|
+
severity=Severity.CRITICAL,
|
|
818
|
+
confidence=0.98,
|
|
819
|
+
file_path=self.file_path,
|
|
820
|
+
line_number=node.lineno,
|
|
821
|
+
matched_text=f"getattr(..., '{target_attr}')",
|
|
822
|
+
category="Behavioral AST",
|
|
823
|
+
explanation=DEFAULT_EXPLANATIONS["AST9"],
|
|
824
|
+
remediation=DEFAULT_REMEDIATIONS["AST9"],
|
|
825
|
+
))
|
|
826
|
+
|
|
827
|
+
# AST10: Insecure Deserialization
|
|
828
|
+
elif call_name in DANGEROUS_DESERIALIZERS:
|
|
829
|
+
self.findings.append(Finding(
|
|
830
|
+
rule_id="AST10",
|
|
831
|
+
message=f"Untrusted deserialization sink via {call_name}(). Remote Code Execution risk.",
|
|
832
|
+
severity=Severity.CRITICAL,
|
|
833
|
+
confidence=0.95,
|
|
834
|
+
file_path=self.file_path,
|
|
835
|
+
line_number=node.lineno,
|
|
836
|
+
matched_text=f"{call_name}(...)",
|
|
837
|
+
category="Behavioral AST",
|
|
838
|
+
explanation=DEFAULT_EXPLANATIONS["AST10"],
|
|
839
|
+
remediation=DEFAULT_REMEDIATIONS["AST10"],
|
|
840
|
+
))
|
|
841
|
+
|
|
842
|
+
self.generic_visit(node)
|
|
843
|
+
|
|
844
|
+
def _resolve_call_name(self, node: ast.AST) -> str:
|
|
845
|
+
if isinstance(node, ast.Name):
|
|
846
|
+
return node.id
|
|
847
|
+
elif isinstance(node, ast.Attribute):
|
|
848
|
+
val = self._resolve_call_name(node.value)
|
|
849
|
+
return f"{val}.{node.attr}" if val else node.attr
|
|
850
|
+
return ""
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def scan_python_ast(file_path: str, content: str) -> List[Finding]:
|
|
854
|
+
"""Parses and runs AST security checks on Python source files."""
|
|
855
|
+
if not file_path.endswith(".py"):
|
|
856
|
+
return []
|
|
857
|
+
try:
|
|
858
|
+
tree = ast.parse(content, filename=file_path)
|
|
859
|
+
except SyntaxError:
|
|
860
|
+
return []
|
|
861
|
+
|
|
862
|
+
visitor = BehavioralASTVisitor(file_path)
|
|
863
|
+
visitor.visit(tree)
|
|
864
|
+
return visitor.findings
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# ---------------------------------------------------------------------------
|
|
868
|
+
# Scoring & Orchestration Engine
|
|
869
|
+
# ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
SEVERITY_WEIGHTS = {
|
|
872
|
+
Severity.CRITICAL: 40,
|
|
873
|
+
Severity.HIGH: 20,
|
|
874
|
+
Severity.MEDIUM: 8,
|
|
875
|
+
Severity.LOW: 2,
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def calculate_risk_score(findings: List[Finding]) -> Tuple[int, Severity, Recommendation]:
|
|
880
|
+
"""Calculates cumulative 0-100 risk score and derives verdict recommendation."""
|
|
881
|
+
raw_score = sum(SEVERITY_WEIGHTS.get(f.severity, 0) for f in findings)
|
|
882
|
+
score = min(100, raw_score)
|
|
883
|
+
|
|
884
|
+
if any(f.severity == Severity.CRITICAL for f in findings) or score >= 60:
|
|
885
|
+
return score, Severity.CRITICAL, Recommendation.DO_NOT_INSTALL
|
|
886
|
+
elif any(f.severity == Severity.HIGH for f in findings) or score >= 25:
|
|
887
|
+
return score, Severity.HIGH, Recommendation.CAUTION
|
|
888
|
+
elif score > 0:
|
|
889
|
+
return score, Severity.MEDIUM, Recommendation.CAUTION
|
|
890
|
+
else:
|
|
891
|
+
return 0, Severity.LOW, Recommendation.SAFE
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def scan_file(file_path: str) -> List[Finding]:
|
|
895
|
+
"""Scans a single file across all analysis vectors."""
|
|
896
|
+
try:
|
|
897
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
898
|
+
content = f.read()
|
|
899
|
+
except Exception as e:
|
|
900
|
+
return []
|
|
901
|
+
|
|
902
|
+
findings: List[Finding] = []
|
|
903
|
+
# 1. Text & Regex Patterns
|
|
904
|
+
findings.extend(scan_text_patterns(file_path, content))
|
|
905
|
+
# 2. Whitespace Smuggling
|
|
906
|
+
findings.extend(scan_whitespace_padding(file_path, content))
|
|
907
|
+
# 3. Unicode Confusables & Steganography
|
|
908
|
+
findings.extend(scan_unicode_and_steganography(file_path, content))
|
|
909
|
+
# 4. Manifest / Metadata Poisoning
|
|
910
|
+
findings.extend(scan_tool_manifest_poisoning(file_path, content))
|
|
911
|
+
# 5. Behavioral AST (if Python)
|
|
912
|
+
findings.extend(scan_python_ast(file_path, content))
|
|
913
|
+
|
|
914
|
+
return findings
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def scan_target(target_path: str) -> ScanReport:
|
|
918
|
+
"""Recursively scans a skill directory or a single skill file."""
|
|
919
|
+
path_obj = Path(target_path).resolve()
|
|
920
|
+
all_findings: List[Finding] = []
|
|
921
|
+
scanned_count = 0
|
|
922
|
+
|
|
923
|
+
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", "dist", "build"}
|
|
924
|
+
SCANNABLE_EXTS = {".md", ".py", ".json", ".yaml", ".yml", ".sh", ".ps1", ".txt", ".ts", ".js"}
|
|
925
|
+
|
|
926
|
+
if path_obj.is_file():
|
|
927
|
+
scanned_count = 1
|
|
928
|
+
all_findings.extend(scan_file(str(path_obj)))
|
|
929
|
+
elif path_obj.is_dir():
|
|
930
|
+
for root, dirs, files in os.walk(path_obj):
|
|
931
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
932
|
+
for file in files:
|
|
933
|
+
ext = os.path.splitext(file)[1].lower()
|
|
934
|
+
if ext in SCANNABLE_EXTS:
|
|
935
|
+
full_p = os.path.join(root, file)
|
|
936
|
+
scanned_count += 1
|
|
937
|
+
all_findings.extend(scan_file(full_p))
|
|
938
|
+
|
|
939
|
+
score, severity, rec = calculate_risk_score(all_findings)
|
|
940
|
+
return ScanReport(
|
|
941
|
+
target_path=str(path_obj),
|
|
942
|
+
scanned_files=scanned_count,
|
|
943
|
+
findings=all_findings,
|
|
944
|
+
risk_score=score,
|
|
945
|
+
severity=severity,
|
|
946
|
+
recommendation=rec,
|
|
947
|
+
metadata={
|
|
948
|
+
"scanner": "SkillSpector Sentinel v2.2.0 (Synapse-Hub Engine)",
|
|
949
|
+
"upstream_credit": "NVIDIA/SkillSpector (Apache 2.0)",
|
|
950
|
+
"total_categories": 17,
|
|
951
|
+
"total_patterns": 71,
|
|
952
|
+
}
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ---------------------------------------------------------------------------
|
|
957
|
+
# Output Formatters
|
|
958
|
+
# ---------------------------------------------------------------------------
|
|
959
|
+
|
|
960
|
+
def format_terminal(report: ScanReport) -> str:
|
|
961
|
+
"""Generates an enterprise ANSI colored terminal summary."""
|
|
962
|
+
CYAN = "\033[96m"
|
|
963
|
+
GREEN = "\033[92m"
|
|
964
|
+
YELLOW = "\033[93m"
|
|
965
|
+
RED = "\033[91m"
|
|
966
|
+
BOLD = "\033[1m"
|
|
967
|
+
RESET = "\033[0m"
|
|
968
|
+
|
|
969
|
+
out = []
|
|
970
|
+
out.append(f"\n{BOLD}{CYAN}======================================================================{RESET}")
|
|
971
|
+
out.append(f"{BOLD}{CYAN} SKILLSPECTOR SENTINEL - AI AGENT & MCP SECURITY AUDIT REPORT {RESET}")
|
|
972
|
+
out.append(f"{BOLD}{CYAN}======================================================================{RESET}")
|
|
973
|
+
out.append(f"📁 Target Scope : {report.target_path}")
|
|
974
|
+
out.append(f"📄 Scanned Files : {report.scanned_files}")
|
|
975
|
+
out.append(f"🛡️ Engine Source : NVIDIA/SkillSpector Spec (71 Patterns, 17 Categories)")
|
|
976
|
+
|
|
977
|
+
rec_color = GREEN if report.recommendation == Recommendation.SAFE else (YELLOW if report.recommendation == Recommendation.CAUTION else RED)
|
|
978
|
+
out.append(f"\n{BOLD}🎯 AUDIT VERDICT :{RESET} {rec_color}{BOLD}{report.recommendation.value}{RESET}")
|
|
979
|
+
out.append(f"📊 Risk Score : {report.risk_score}/100 | Severity Band: {report.severity.value}")
|
|
980
|
+
out.append(f"🚨 Total Findings: {len(report.findings)}")
|
|
981
|
+
|
|
982
|
+
if not report.findings:
|
|
983
|
+
out.append(f"\n{GREEN}✅ [PASS] No malicious directives, prompt injections, or security flaws detected.{RESET}")
|
|
984
|
+
out.append(f"{GREEN} This skill is verified clean and safe to install/execute.{RESET}")
|
|
985
|
+
else:
|
|
986
|
+
out.append(f"\n{BOLD}{'SEV':<10} {'RULE':<8} {'FILE:LINE':<35} {'DETAILS'}{RESET}")
|
|
987
|
+
out.append("-" * 75)
|
|
988
|
+
for f in report.findings:
|
|
989
|
+
sev_c = RED if f.severity in (Severity.CRITICAL, Severity.HIGH) else (YELLOW if f.severity == Severity.MEDIUM else CYAN)
|
|
990
|
+
loc = f"{os.path.basename(f.file_path)}:{f.line_number}"
|
|
991
|
+
out.append(f"{sev_c}{f.severity.value:<10}{RESET} {f.rule_id:<8} {loc:<35} {f.message[:45]}")
|
|
992
|
+
out.append(f" 👉 {BOLD}Fix:{RESET} {f.remediation}")
|
|
993
|
+
|
|
994
|
+
out.append(f"\n{CYAN}======================================================================{RESET}\n")
|
|
995
|
+
return "\n".join(out)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def format_json(report: ScanReport) -> str:
|
|
999
|
+
"""Formats report as JSON."""
|
|
1000
|
+
data = {
|
|
1001
|
+
"target": report.target_path,
|
|
1002
|
+
"scanned_files": report.scanned_files,
|
|
1003
|
+
"risk_score": report.risk_score,
|
|
1004
|
+
"severity": report.severity.value,
|
|
1005
|
+
"recommendation": report.recommendation.value,
|
|
1006
|
+
"total_findings": len(report.findings),
|
|
1007
|
+
"metadata": report.metadata,
|
|
1008
|
+
"findings": [
|
|
1009
|
+
{
|
|
1010
|
+
"rule_id": f.rule_id,
|
|
1011
|
+
"category": f.category,
|
|
1012
|
+
"severity": f.severity.value,
|
|
1013
|
+
"confidence": f.confidence,
|
|
1014
|
+
"file": f.file_path,
|
|
1015
|
+
"line": f.line_number,
|
|
1016
|
+
"message": f.message,
|
|
1017
|
+
"matched_text": f.matched_text,
|
|
1018
|
+
"explanation": f.explanation,
|
|
1019
|
+
"remediation": f.remediation,
|
|
1020
|
+
"fingerprint": f.match_fingerprint,
|
|
1021
|
+
}
|
|
1022
|
+
for f in report.findings
|
|
1023
|
+
]
|
|
1024
|
+
}
|
|
1025
|
+
return json.dumps(data, indent=2)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def format_markdown(report: ScanReport) -> str:
|
|
1029
|
+
"""Formats report as GitHub-flavored Markdown."""
|
|
1030
|
+
lines = [
|
|
1031
|
+
"# 🛡️ SkillSpector Sentinel Security Audit Report",
|
|
1032
|
+
"",
|
|
1033
|
+
f"- **Target:** `{report.target_path}`",
|
|
1034
|
+
f"- **Scanned Files:** {report.scanned_files}",
|
|
1035
|
+
f"- **Risk Score:** `{report.risk_score}/100` ({report.severity.value})",
|
|
1036
|
+
f"- **Verdict:** **{report.recommendation.value}**",
|
|
1037
|
+
"",
|
|
1038
|
+
"> *Audited by SkillSpector Sentinel (derived from [NVIDIA/SkillSpector](https://github.com/NVIDIA/SkillSpector)).*",
|
|
1039
|
+
"",
|
|
1040
|
+
]
|
|
1041
|
+
if not report.findings:
|
|
1042
|
+
lines.append("### ✅ Result: Clean Pass (Safe to Install)")
|
|
1043
|
+
lines.append("Zero malicious patterns or security risks detected.")
|
|
1044
|
+
else:
|
|
1045
|
+
lines.append("### 🚨 Detected Vulnerabilities")
|
|
1046
|
+
lines.append("| Severity | Rule | Location | Description | Remediation |")
|
|
1047
|
+
lines.append("| :--- | :--- | :--- | :--- | :--- |")
|
|
1048
|
+
for f in report.findings:
|
|
1049
|
+
loc = f"`{os.path.basename(f.file_path)}:{f.line_number}`"
|
|
1050
|
+
lines.append(f"| **{f.severity.value}** | `{f.rule_id}` | {loc} | {f.message} | {f.remediation} |")
|
|
1051
|
+
|
|
1052
|
+
return "\n".join(lines)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def format_sarif(report: ScanReport) -> str:
|
|
1056
|
+
"""Formats report as SARIF 2.1.0 standard JSON."""
|
|
1057
|
+
sarif = {
|
|
1058
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
1059
|
+
"version": "2.1.0",
|
|
1060
|
+
"runs": [
|
|
1061
|
+
{
|
|
1062
|
+
"tool": {
|
|
1063
|
+
"driver": {
|
|
1064
|
+
"name": "SkillSpector Sentinel",
|
|
1065
|
+
"informationUri": "https://github.com/NVIDIA/SkillSpector",
|
|
1066
|
+
"version": "2.2.0",
|
|
1067
|
+
"rules": [
|
|
1068
|
+
{
|
|
1069
|
+
"id": rule_id,
|
|
1070
|
+
"shortDescription": {"text": DEFAULT_EXPLANATIONS.get(rule_id, "Security Rule")},
|
|
1071
|
+
"help": {"text": DEFAULT_REMEDIATIONS.get(rule_id, "Remediation")}
|
|
1072
|
+
}
|
|
1073
|
+
for rule_id in set(f.rule_id for f in report.findings)
|
|
1074
|
+
]
|
|
1075
|
+
}
|
|
1076
|
+
},
|
|
1077
|
+
"results": [
|
|
1078
|
+
{
|
|
1079
|
+
"ruleId": f.rule_id,
|
|
1080
|
+
"level": "error" if f.severity in (Severity.CRITICAL, Severity.HIGH) else "warning",
|
|
1081
|
+
"message": {"text": f.message},
|
|
1082
|
+
"locations": [
|
|
1083
|
+
{
|
|
1084
|
+
"physicalLocation": {
|
|
1085
|
+
"artifactLocation": {"uri": Path(f.file_path).as_uri()},
|
|
1086
|
+
"region": {"startLine": max(1, f.line_number)}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
]
|
|
1090
|
+
}
|
|
1091
|
+
for f in report.findings
|
|
1092
|
+
]
|
|
1093
|
+
}
|
|
1094
|
+
]
|
|
1095
|
+
}
|
|
1096
|
+
return json.dumps(sarif, indent=2)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
# ---------------------------------------------------------------------------
|
|
1100
|
+
# CLI Entrypoint
|
|
1101
|
+
# ---------------------------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
def main() -> int:
|
|
1104
|
+
import argparse
|
|
1105
|
+
parser = argparse.ArgumentParser(
|
|
1106
|
+
description="SkillSpector Sentinel: Autonomous AI Agent Skill & MCP Security Scanner"
|
|
1107
|
+
)
|
|
1108
|
+
parser.add_argument("path", nargs="?", default=".", help="Skill directory or file to scan (default: current directory)")
|
|
1109
|
+
parser.add_argument("--format", choices=["terminal", "json", "markdown", "sarif"], default="terminal", help="Output format")
|
|
1110
|
+
parser.add_argument("--output", "-o", help="Write report to output file")
|
|
1111
|
+
parser.add_argument("--strict", action="store_true", help="Fail with non-zero exit code on any HIGH/CRITICAL finding")
|
|
1112
|
+
|
|
1113
|
+
args = parser.parse_args()
|
|
1114
|
+
|
|
1115
|
+
report = scan_target(args.path)
|
|
1116
|
+
|
|
1117
|
+
if args.format == "terminal":
|
|
1118
|
+
output_text = format_terminal(report)
|
|
1119
|
+
elif args.format == "json":
|
|
1120
|
+
output_text = format_json(report)
|
|
1121
|
+
elif args.format == "markdown":
|
|
1122
|
+
output_text = format_markdown(report)
|
|
1123
|
+
elif args.format == "sarif":
|
|
1124
|
+
output_text = format_sarif(report)
|
|
1125
|
+
else:
|
|
1126
|
+
output_text = format_terminal(report)
|
|
1127
|
+
|
|
1128
|
+
if args.output:
|
|
1129
|
+
with open(args.output, "w", encoding="utf-8") as fp:
|
|
1130
|
+
fp.write(output_text)
|
|
1131
|
+
print(f"Report saved to: {args.output}")
|
|
1132
|
+
else:
|
|
1133
|
+
print(output_text)
|
|
1134
|
+
|
|
1135
|
+
if args.strict and report.recommendation == Recommendation.DO_NOT_INSTALL:
|
|
1136
|
+
return 1
|
|
1137
|
+
return 0
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
if __name__ == "__main__":
|
|
1141
|
+
sys.exit(main())
|