skill-sentry-cli 2.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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Deterministic package hashing used by the verdict cache.
|
|
2
|
+
|
|
3
|
+
The algorithm is documented in ``docs/03-database.md``: every regular file is
|
|
4
|
+
visited in sorted POSIX-relative-path order and contributes its path, Unix mode
|
|
5
|
+
bits and the SHA-256 of its bytes. Directory entries are included as well so
|
|
6
|
+
adding or removing an empty directory invalidates the cache.
|
|
7
|
+
|
|
8
|
+
Symlinks are rejected. A skill must never be able to make the scanner hash (or
|
|
9
|
+
later read) a file outside the package root, and following links would also
|
|
10
|
+
make a package's identity depend on mutable host state.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import stat
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
|
|
23
|
+
".git",
|
|
24
|
+
".git/*",
|
|
25
|
+
".hg",
|
|
26
|
+
".svn",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
"__pycache__/*",
|
|
29
|
+
"*.pyc",
|
|
30
|
+
".pytest_cache",
|
|
31
|
+
".pytest_cache/*",
|
|
32
|
+
".ruff_cache",
|
|
33
|
+
".ruff_cache/*",
|
|
34
|
+
".mypy_cache",
|
|
35
|
+
".mypy_cache/*",
|
|
36
|
+
".venv",
|
|
37
|
+
".venv/*",
|
|
38
|
+
"venv",
|
|
39
|
+
"venv/*",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"node_modules/*",
|
|
42
|
+
".DS_Store",
|
|
43
|
+
"Thumbs.db",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
MAX_FILES = 5_000
|
|
47
|
+
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
|
48
|
+
CHUNK_SIZE = 128 * 1024
|
|
49
|
+
IGNORE_FILE = ".skillsentryignore"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HashError(RuntimeError):
|
|
53
|
+
"""The package cannot be hashed safely or exceeds a hard limit."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class SkillHash:
|
|
58
|
+
digest: str
|
|
59
|
+
file_count: int
|
|
60
|
+
total_bytes: int
|
|
61
|
+
root: Path
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def short(self) -> str:
|
|
65
|
+
return self.digest[:12]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def hash_bytes(data: bytes) -> str:
|
|
69
|
+
"""Return the lower-case SHA-256 hex digest of ``data``."""
|
|
70
|
+
|
|
71
|
+
return hashlib.sha256(data).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_ignore_patterns(root: Path) -> tuple[str, ...]:
|
|
75
|
+
patterns = list(DEFAULT_IGNORE_PATTERNS)
|
|
76
|
+
ignore_file = root / IGNORE_FILE
|
|
77
|
+
if ignore_file.is_file() and not ignore_file.is_symlink():
|
|
78
|
+
try:
|
|
79
|
+
text = ignore_file.read_text(encoding="utf-8", errors="replace")
|
|
80
|
+
except OSError as exc: # pragma: no cover - unusual filesystem error
|
|
81
|
+
raise HashError(f"could not read {IGNORE_FILE}: {exc}") from exc
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line or line.startswith("#"):
|
|
85
|
+
continue
|
|
86
|
+
patterns.append(line)
|
|
87
|
+
return tuple(patterns)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ignored(rel: str, patterns: tuple[str, ...]) -> bool:
|
|
91
|
+
# A pattern matching any parent directory excludes the whole subtree.
|
|
92
|
+
parts = rel.split("/")
|
|
93
|
+
candidates = ["/".join(parts[: i + 1]) for i in range(len(parts))]
|
|
94
|
+
return any(
|
|
95
|
+
fnmatch.fnmatchcase(candidate, pattern) for pattern in patterns for candidate in candidates
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _mode(path: Path) -> int:
|
|
100
|
+
"""Return stable permission bits.
|
|
101
|
+
|
|
102
|
+
Windows does not expose Unix execute bits meaningfully; masking the mode to
|
|
103
|
+
``0o777`` keeps hashes stable across checkouts while still changing when a
|
|
104
|
+
POSIX file becomes executable.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
return stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise HashError(f"could not stat {path}: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def hash_skill_dir(path: str | Path) -> SkillHash:
|
|
114
|
+
"""Hash a skill directory without following symlinks.
|
|
115
|
+
|
|
116
|
+
Symlinks are hashed as links (name and target), never followed. Raises
|
|
117
|
+
:class:`HashError` for a missing directory, unreadable files, or packages
|
|
118
|
+
over the file/size caps.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
root = Path(path).expanduser()
|
|
122
|
+
try:
|
|
123
|
+
root = root.resolve(strict=True)
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
raise HashError(f"skill directory does not exist: {path}") from exc
|
|
126
|
+
if not root.is_dir():
|
|
127
|
+
raise HashError(f"not a directory: {root}")
|
|
128
|
+
|
|
129
|
+
patterns = _load_ignore_patterns(root)
|
|
130
|
+
digest = hashlib.sha256()
|
|
131
|
+
file_count = 0
|
|
132
|
+
total_bytes = 0
|
|
133
|
+
|
|
134
|
+
def walk(directory: Path, prefix: str) -> None:
|
|
135
|
+
nonlocal file_count, total_bytes
|
|
136
|
+
try:
|
|
137
|
+
entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise HashError(f"could not read {directory}: {exc}") from exc
|
|
140
|
+
|
|
141
|
+
for entry in entries:
|
|
142
|
+
rel = f"{prefix}/{entry.name}" if prefix else entry.name
|
|
143
|
+
if _ignored(rel, patterns):
|
|
144
|
+
continue
|
|
145
|
+
entry_path = Path(entry.path)
|
|
146
|
+
if entry.is_symlink():
|
|
147
|
+
# Never followed. The link and its target text are part of the
|
|
148
|
+
# key, and the parser reports the link as a HIGH finding, so a
|
|
149
|
+
# skill with a link is still scanned and reported.
|
|
150
|
+
digest.update(b"link\0")
|
|
151
|
+
digest.update(rel.encode("utf-8", "surrogateescape"))
|
|
152
|
+
digest.update(b"\0")
|
|
153
|
+
digest.update(os.readlink(entry_path).encode("utf-8", "surrogateescape"))
|
|
154
|
+
digest.update(b"\n")
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
if entry.is_dir(follow_symlinks=False):
|
|
158
|
+
digest.update(b"dir\0")
|
|
159
|
+
digest.update(rel.encode("utf-8", "surrogateescape"))
|
|
160
|
+
digest.update(f"\0{_mode(entry_path):o}\n".encode())
|
|
161
|
+
walk(entry_path, rel)
|
|
162
|
+
continue
|
|
163
|
+
if not entry.is_file(follow_symlinks=False):
|
|
164
|
+
raise HashError(f"unsupported file type in skill package: {rel}")
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
raise HashError(f"could not inspect {rel}: {exc}") from exc
|
|
167
|
+
|
|
168
|
+
file_count += 1
|
|
169
|
+
if file_count > MAX_FILES:
|
|
170
|
+
raise HashError(f"skill package exceeds {MAX_FILES} files")
|
|
171
|
+
size = entry.stat(follow_symlinks=False).st_size
|
|
172
|
+
total_bytes += size
|
|
173
|
+
if total_bytes > MAX_TOTAL_BYTES:
|
|
174
|
+
raise HashError(f"skill package exceeds {MAX_TOTAL_BYTES // (1024 * 1024)} MiB")
|
|
175
|
+
|
|
176
|
+
digest.update(b"file\0")
|
|
177
|
+
digest.update(rel.encode("utf-8", "surrogateescape"))
|
|
178
|
+
digest.update(f"\0{_mode(entry_path):o}\0".encode())
|
|
179
|
+
file_digest = hashlib.sha256()
|
|
180
|
+
try:
|
|
181
|
+
with open(entry_path, "rb") as handle:
|
|
182
|
+
while chunk := handle.read(CHUNK_SIZE):
|
|
183
|
+
file_digest.update(chunk)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise HashError(f"could not read {rel}: {exc}") from exc
|
|
186
|
+
digest.update(file_digest.digest())
|
|
187
|
+
digest.update(b"\n")
|
|
188
|
+
|
|
189
|
+
walk(root, "")
|
|
190
|
+
return SkillHash(
|
|
191
|
+
digest=digest.hexdigest(),
|
|
192
|
+
file_count=file_count,
|
|
193
|
+
total_bytes=total_bytes,
|
|
194
|
+
root=root,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = [
|
|
199
|
+
"DEFAULT_IGNORE_PATTERNS",
|
|
200
|
+
"MAX_FILES",
|
|
201
|
+
"MAX_TOTAL_BYTES",
|
|
202
|
+
"HashError",
|
|
203
|
+
"SkillHash",
|
|
204
|
+
"hash_bytes",
|
|
205
|
+
"hash_skill_dir",
|
|
206
|
+
]
|