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 @@
|
|
|
1
|
+
"""Core domain models, contracts, and engine primitives."""
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Credential flow analysis: sensitive material reaching a network or system sink.
|
|
2
|
+
|
|
3
|
+
Python scripts are analysed with a small AST taint tracker. Shell and JavaScript
|
|
4
|
+
scripts get a weaker pattern check, because a regex cannot prove a flow.
|
|
5
|
+
|
|
6
|
+
Every tainted value carries its sources (the environment variable or the path it
|
|
7
|
+
came from). A destination is judged against those sources:
|
|
8
|
+
|
|
9
|
+
* credential data sent to a host the skill declares, and bound to that credential's
|
|
10
|
+
vendor (for example SLACK_TOKEN to slack.com): NOTE, informational
|
|
11
|
+
* credential data sent to a declared host that the credential is not bound to: MEDIUM
|
|
12
|
+
* credential data sent to an undeclared host that the credential is bound to: MEDIUM
|
|
13
|
+
* credential data sent to an undeclared host with no binding: CRITICAL
|
|
14
|
+
* credential data put into a DNS query: CRITICAL
|
|
15
|
+
* credential data sent to an unresolved (dynamic) host: HIGH
|
|
16
|
+
* unverified shell or JavaScript pattern match: MEDIUM
|
|
17
|
+
|
|
18
|
+
A binding does not count when the request is plain HTTP, disables certificate
|
|
19
|
+
verification, or routes through a custom proxy.
|
|
20
|
+
|
|
21
|
+
Severity maps onto main's fusion floors (CRITICAL 95, HIGH 70, MEDIUM 30). The
|
|
22
|
+
taint tracker over-approximates flows through calls, and it clears a name when that
|
|
23
|
+
name is reassigned from an untainted value.
|
|
24
|
+
|
|
25
|
+
Binding table: feature/developer-2 (chauhanaman41), adopted into rules/credential_rules.toml.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ast
|
|
31
|
+
import fnmatch
|
|
32
|
+
import hashlib
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import tomllib
|
|
36
|
+
from collections.abc import Iterable
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from urllib.parse import urlparse
|
|
40
|
+
|
|
41
|
+
from skill_sentry_cli.core.anchors.declared_hosts import extract_declared_hosts, is_host_declared
|
|
42
|
+
from skill_sentry_cli.core.contracts import Finding, FindingEvidence, Severity, Stage
|
|
43
|
+
from skill_sentry_cli.core.parser import SkillPackage
|
|
44
|
+
|
|
45
|
+
RULES_PATH = Path(__file__).resolve().parent.parent.parent / "rules" / "credential_rules.toml"
|
|
46
|
+
|
|
47
|
+
# A credential source is ("env", NAME) for an environment variable or ("path", PATH).
|
|
48
|
+
Source = tuple[str, str]
|
|
49
|
+
_NONE: frozenset[Source] = frozenset()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ServiceBinding:
|
|
54
|
+
"""A credential that belongs to the vendor behind ``hosts``."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
credential_env: tuple[re.Pattern[str], ...]
|
|
58
|
+
credential_paths: tuple[str, ...]
|
|
59
|
+
hosts: tuple[str, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class CredentialRules:
|
|
64
|
+
version: int
|
|
65
|
+
env_patterns: tuple[re.Pattern[str], ...]
|
|
66
|
+
path_globs: tuple[str, ...]
|
|
67
|
+
python_sinks: frozenset[str]
|
|
68
|
+
script_sinks: tuple[re.Pattern[str], ...]
|
|
69
|
+
bindings: tuple[ServiceBinding, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_rules(path: Path = RULES_PATH) -> CredentialRules:
|
|
73
|
+
"""Load the rules. A missing or malformed file is an error, never a silent default."""
|
|
74
|
+
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
raise RuntimeError(f"credential rules file is missing: {path}")
|
|
77
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
sources = data.get("sources", {})
|
|
79
|
+
sinks = data.get("sinks", {})
|
|
80
|
+
return CredentialRules(
|
|
81
|
+
version=int(data.get("version", 1)),
|
|
82
|
+
env_patterns=tuple(re.compile(p) for p in sources.get("env_name_patterns", [])),
|
|
83
|
+
path_globs=tuple(sources.get("path_globs", [])),
|
|
84
|
+
python_sinks=frozenset(sinks.get("python_calls", [])),
|
|
85
|
+
script_sinks=tuple(
|
|
86
|
+
re.compile(p)
|
|
87
|
+
for p in list(sinks.get("js_patterns", [])) + list(sinks.get("shell_patterns", []))
|
|
88
|
+
),
|
|
89
|
+
bindings=tuple(
|
|
90
|
+
ServiceBinding(
|
|
91
|
+
name=str(item.get("name", "unnamed")),
|
|
92
|
+
credential_env=tuple(re.compile(p) for p in item.get("credential_env", [])),
|
|
93
|
+
credential_paths=tuple(item.get("credential_paths", [])),
|
|
94
|
+
hosts=tuple(str(host).lower() for host in item.get("hosts", [])),
|
|
95
|
+
)
|
|
96
|
+
for item in data.get("service_bindings", [])
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
RULES = _load_rules()
|
|
102
|
+
RULES_VERSION = RULES.version
|
|
103
|
+
# Any edit to the rules file changes this, so cached verdicts never outlive the rules.
|
|
104
|
+
RULES_FINGERPRINT = hashlib.sha256(RULES_PATH.read_bytes()).hexdigest()[:12]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _evidence(file: str, line: int, code: str) -> FindingEvidence:
|
|
108
|
+
return FindingEvidence(file=file, line=line, code=code[:200] or None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _union(parts: Iterable[frozenset[Source]]) -> frozenset[Source]:
|
|
112
|
+
merged: set[Source] = set()
|
|
113
|
+
for part in parts:
|
|
114
|
+
merged |= part
|
|
115
|
+
return frozenset(merged)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _first_string_arg(node: ast.Call) -> str | None:
|
|
119
|
+
"""The first argument when it is a string literal, otherwise None."""
|
|
120
|
+
|
|
121
|
+
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
|
122
|
+
return node.args[0].value
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _host_of(destination: str) -> str:
|
|
127
|
+
parsed = urlparse(destination if "://" in destination else f"//{destination}")
|
|
128
|
+
return (parsed.hostname or destination).lower()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _path_matches(path: str, glob: str) -> bool:
|
|
132
|
+
expanded_path = os.path.expanduser(path.replace("\\", "/")).replace("\\", "/")
|
|
133
|
+
expanded_glob = os.path.expanduser(glob)
|
|
134
|
+
return fnmatch.fnmatch(expanded_path, expanded_glob)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_credential_bound(sources: frozenset[Source], destination: str) -> bool:
|
|
138
|
+
"""True when a credential the payload carries belongs to the vendor behind ``destination``."""
|
|
139
|
+
|
|
140
|
+
host = _host_of(destination)
|
|
141
|
+
for binding in RULES.bindings:
|
|
142
|
+
if not any(host == bound or host.endswith("." + bound) for bound in binding.hosts):
|
|
143
|
+
continue
|
|
144
|
+
for kind, value in sources:
|
|
145
|
+
if kind == "env" and any(p.search(value) for p in binding.credential_env):
|
|
146
|
+
return True
|
|
147
|
+
if kind == "path" and any(_path_matches(value, g) for g in binding.credential_paths):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _weakens_binding(node: ast.Call, destination: str) -> bool:
|
|
153
|
+
"""Plain HTTP, disabled verification or a custom proxy means a binding does not count."""
|
|
154
|
+
|
|
155
|
+
if destination.lower().startswith("http://"):
|
|
156
|
+
return True
|
|
157
|
+
for keyword in node.keywords:
|
|
158
|
+
if (
|
|
159
|
+
keyword.arg == "verify"
|
|
160
|
+
and isinstance(keyword.value, ast.Constant)
|
|
161
|
+
and keyword.value.value is False
|
|
162
|
+
):
|
|
163
|
+
return True
|
|
164
|
+
if keyword.arg in ("proxies", "proxy"):
|
|
165
|
+
return True
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class _Tracker(ast.NodeVisitor):
|
|
171
|
+
filename: str
|
|
172
|
+
declared_hosts: set[str]
|
|
173
|
+
tainted_vars: dict[str, frozenset[Source]] = field(default_factory=dict)
|
|
174
|
+
tainted_functions: dict[str, frozenset[Source]] = field(default_factory=dict)
|
|
175
|
+
string_constants: dict[str, str] = field(default_factory=dict)
|
|
176
|
+
findings: list[Finding] = field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
# -- sources ------------------------------------------------------------
|
|
179
|
+
def _secret_name(self, name: str) -> bool:
|
|
180
|
+
return any(pattern.search(name) for pattern in RULES.env_patterns)
|
|
181
|
+
|
|
182
|
+
def _sensitive_path(self, path: str) -> bool:
|
|
183
|
+
normalised = path.replace("\\", "/")
|
|
184
|
+
expanded = os.path.expanduser(normalised).replace("\\", "/")
|
|
185
|
+
for glob in RULES.path_globs:
|
|
186
|
+
if fnmatch.fnmatch(normalised, glob) or fnmatch.fnmatch(expanded, glob):
|
|
187
|
+
return True
|
|
188
|
+
if glob.startswith("~/") and normalised.startswith(glob[2:]):
|
|
189
|
+
return True
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
def _call_name(self, node: ast.Call) -> str:
|
|
193
|
+
parts: list[str] = []
|
|
194
|
+
current: ast.AST = node.func
|
|
195
|
+
while isinstance(current, ast.Attribute):
|
|
196
|
+
parts.append(current.attr)
|
|
197
|
+
current = current.value
|
|
198
|
+
if isinstance(current, ast.Name):
|
|
199
|
+
parts.append(current.id)
|
|
200
|
+
return ".".join(reversed(parts))
|
|
201
|
+
|
|
202
|
+
def _sources(self, node: ast.AST | None) -> frozenset[Source]:
|
|
203
|
+
if node is None:
|
|
204
|
+
return _NONE
|
|
205
|
+
if isinstance(node, ast.Name):
|
|
206
|
+
return self.tainted_vars.get(node.id, _NONE)
|
|
207
|
+
if isinstance(node, ast.Subscript):
|
|
208
|
+
value = node.value
|
|
209
|
+
if (
|
|
210
|
+
isinstance(value, ast.Attribute)
|
|
211
|
+
and value.attr == "environ"
|
|
212
|
+
and isinstance(node.slice, ast.Constant)
|
|
213
|
+
and isinstance(node.slice.value, str)
|
|
214
|
+
):
|
|
215
|
+
name = node.slice.value
|
|
216
|
+
return frozenset({("env", name)}) if self._secret_name(name) else _NONE
|
|
217
|
+
return self._sources(node.value)
|
|
218
|
+
if isinstance(node, ast.Call):
|
|
219
|
+
if isinstance(node.func, ast.Attribute):
|
|
220
|
+
inherited = self._sources(node.func.value)
|
|
221
|
+
if inherited:
|
|
222
|
+
return inherited
|
|
223
|
+
name = self._call_name(node)
|
|
224
|
+
literal = _first_string_arg(node)
|
|
225
|
+
if name in ("os.getenv", "os.environ.get") and literal is not None:
|
|
226
|
+
return frozenset({("env", literal)}) if self._secret_name(literal) else _NONE
|
|
227
|
+
if (
|
|
228
|
+
name in ("open", "Path", "Path.read_text", "Path.read_bytes")
|
|
229
|
+
and literal is not None
|
|
230
|
+
):
|
|
231
|
+
return frozenset({("path", literal)}) if self._sensitive_path(literal) else _NONE
|
|
232
|
+
if any(
|
|
233
|
+
token in name
|
|
234
|
+
for token in ("b64encode", "b64decode", "dumps", "encode", "decode", "str", "bytes")
|
|
235
|
+
):
|
|
236
|
+
return _union(self._sources(arg) for arg in node.args)
|
|
237
|
+
if isinstance(node.func, ast.Name) and node.func.id in self.tainted_functions:
|
|
238
|
+
return self.tainted_functions[node.func.id]
|
|
239
|
+
return _union(self._sources(arg) for arg in node.args)
|
|
240
|
+
if isinstance(node, ast.JoinedStr):
|
|
241
|
+
return _union(
|
|
242
|
+
self._sources(part.value)
|
|
243
|
+
for part in node.values
|
|
244
|
+
if isinstance(part, ast.FormattedValue)
|
|
245
|
+
)
|
|
246
|
+
if isinstance(node, ast.BinOp):
|
|
247
|
+
return self._sources(node.left) | self._sources(node.right)
|
|
248
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
249
|
+
return _union(self._sources(element) for element in node.elts)
|
|
250
|
+
if isinstance(node, ast.Dict):
|
|
251
|
+
return _union(self._sources(value) for value in node.values)
|
|
252
|
+
return _NONE
|
|
253
|
+
|
|
254
|
+
def _tainted(self, node: ast.AST | None) -> bool:
|
|
255
|
+
return bool(self._sources(node))
|
|
256
|
+
|
|
257
|
+
# -- propagation --------------------------------------------------------
|
|
258
|
+
def _bind(self, targets: list[ast.expr], value: ast.AST | None) -> None:
|
|
259
|
+
sources = self._sources(value)
|
|
260
|
+
literal = (
|
|
261
|
+
value.value
|
|
262
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, str)
|
|
263
|
+
else None
|
|
264
|
+
)
|
|
265
|
+
for target in targets:
|
|
266
|
+
if not isinstance(target, ast.Name):
|
|
267
|
+
continue
|
|
268
|
+
if sources:
|
|
269
|
+
self.tainted_vars[target.id] = sources
|
|
270
|
+
else:
|
|
271
|
+
# Reassignment from an untainted value clears the taint.
|
|
272
|
+
self.tainted_vars.pop(target.id, None)
|
|
273
|
+
if literal is not None:
|
|
274
|
+
self.string_constants[target.id] = literal
|
|
275
|
+
else:
|
|
276
|
+
self.string_constants.pop(target.id, None)
|
|
277
|
+
|
|
278
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
279
|
+
self._bind(node.targets, node.value)
|
|
280
|
+
self.generic_visit(node)
|
|
281
|
+
|
|
282
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
283
|
+
if node.value is not None:
|
|
284
|
+
self._bind([node.target], node.value)
|
|
285
|
+
self.generic_visit(node)
|
|
286
|
+
|
|
287
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
288
|
+
returned = _union(
|
|
289
|
+
self._sources(sub.value) for sub in ast.walk(node) if isinstance(sub, ast.Return)
|
|
290
|
+
)
|
|
291
|
+
if returned:
|
|
292
|
+
self.tainted_functions[node.name] = returned
|
|
293
|
+
self.generic_visit(node)
|
|
294
|
+
|
|
295
|
+
# -- sinks --------------------------------------------------------------
|
|
296
|
+
def _url_text(self, node: ast.AST | None) -> str | None:
|
|
297
|
+
if node is None:
|
|
298
|
+
return None
|
|
299
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
300
|
+
return node.value
|
|
301
|
+
if isinstance(node, ast.Name):
|
|
302
|
+
return self.string_constants.get(node.id)
|
|
303
|
+
if isinstance(node, ast.JoinedStr) and node.values:
|
|
304
|
+
first = node.values[0]
|
|
305
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
306
|
+
return first.value
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def _report(
|
|
310
|
+
self, node: ast.Call, call: str, severity: Severity, kind: str, message: str
|
|
311
|
+
) -> None:
|
|
312
|
+
line = getattr(node, "lineno", 1)
|
|
313
|
+
self.findings.append(
|
|
314
|
+
Finding(
|
|
315
|
+
stage=Stage.DETERMINISTIC_ANCHOR,
|
|
316
|
+
severity=severity,
|
|
317
|
+
message=message,
|
|
318
|
+
type=kind,
|
|
319
|
+
evidence=_evidence(self.filename, line, f"{call}(...)"),
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
324
|
+
call = self._call_name(node)
|
|
325
|
+
is_sink = any(
|
|
326
|
+
call == sink or call.endswith("." + sink) or sink.endswith("." + call)
|
|
327
|
+
for sink in RULES.python_sinks
|
|
328
|
+
)
|
|
329
|
+
if is_sink and ("gethostbyname" in call or "getaddrinfo" in call):
|
|
330
|
+
if node.args and self._tainted(node.args[0]):
|
|
331
|
+
self._report(
|
|
332
|
+
node,
|
|
333
|
+
call,
|
|
334
|
+
Severity.CRITICAL,
|
|
335
|
+
"credential-flow-dns",
|
|
336
|
+
f"Credential material is placed in a DNS query via {call}",
|
|
337
|
+
)
|
|
338
|
+
elif is_sink:
|
|
339
|
+
self._check_network_sink(node, call)
|
|
340
|
+
self.generic_visit(node)
|
|
341
|
+
|
|
342
|
+
def _check_network_sink(self, node: ast.Call, call: str) -> None:
|
|
343
|
+
url_node = node.args[0] if node.args else None
|
|
344
|
+
payload = _union(
|
|
345
|
+
[
|
|
346
|
+
self._sources(url_node),
|
|
347
|
+
*(self._sources(arg) for arg in node.args[1:]),
|
|
348
|
+
*(
|
|
349
|
+
self._sources(keyword.value)
|
|
350
|
+
for keyword in node.keywords
|
|
351
|
+
if keyword.arg in ("data", "json", "params", "headers", "content", "auth")
|
|
352
|
+
),
|
|
353
|
+
]
|
|
354
|
+
)
|
|
355
|
+
if not payload:
|
|
356
|
+
return
|
|
357
|
+
destination = self._url_text(url_node)
|
|
358
|
+
if destination is None:
|
|
359
|
+
self._report(
|
|
360
|
+
node,
|
|
361
|
+
call,
|
|
362
|
+
Severity.HIGH,
|
|
363
|
+
"credential-flow-unresolved-host",
|
|
364
|
+
f"Credential material flows to a dynamic destination in {call}",
|
|
365
|
+
)
|
|
366
|
+
return
|
|
367
|
+
bound = is_credential_bound(payload, destination) and not _weakens_binding(
|
|
368
|
+
node, destination
|
|
369
|
+
)
|
|
370
|
+
if is_host_declared(destination, self.declared_hosts):
|
|
371
|
+
if bound:
|
|
372
|
+
self._report(
|
|
373
|
+
node,
|
|
374
|
+
call,
|
|
375
|
+
Severity.NOTE,
|
|
376
|
+
"credential-flow-declared-bound",
|
|
377
|
+
f"Credential sent to its own declared service host: {destination}",
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
self._report(
|
|
381
|
+
node,
|
|
382
|
+
call,
|
|
383
|
+
Severity.MEDIUM,
|
|
384
|
+
"credential-flow-declared-host",
|
|
385
|
+
f"Credential material is sent to a declared service host: {destination}",
|
|
386
|
+
)
|
|
387
|
+
elif bound:
|
|
388
|
+
self._report(
|
|
389
|
+
node,
|
|
390
|
+
call,
|
|
391
|
+
Severity.MEDIUM,
|
|
392
|
+
"credential-flow-undeclared-bound",
|
|
393
|
+
f"Credential for {destination}'s vendor is sent to undeclared host {destination}",
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
self._report(
|
|
397
|
+
node,
|
|
398
|
+
call,
|
|
399
|
+
Severity.CRITICAL,
|
|
400
|
+
"credential-flow-undeclared-host",
|
|
401
|
+
f"Credential material flows into {call} contacting undeclared host {destination}",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def analyze_credential_flows(package: SkillPackage) -> list[Finding]:
|
|
406
|
+
"""Return credential-flow findings for every script in the package."""
|
|
407
|
+
|
|
408
|
+
declared = extract_declared_hosts(package.frontmatter)
|
|
409
|
+
findings: list[Finding] = []
|
|
410
|
+
for text_file in package.text_files:
|
|
411
|
+
relative = text_file.relative.replace("\\", "/")
|
|
412
|
+
lowered = relative.lower()
|
|
413
|
+
if lowered.endswith(".py"):
|
|
414
|
+
try:
|
|
415
|
+
tree = ast.parse(text_file.text, filename=relative)
|
|
416
|
+
except (SyntaxError, ValueError):
|
|
417
|
+
# Unparseable Python is reported by the AST anchor stage as degraded.
|
|
418
|
+
continue
|
|
419
|
+
tracker = _Tracker(filename=relative, declared_hosts=declared)
|
|
420
|
+
tracker.visit(tree)
|
|
421
|
+
findings.extend(tracker.findings)
|
|
422
|
+
elif lowered.endswith((".sh", ".bash", ".js", ".mjs", ".ts", ".ps1")):
|
|
423
|
+
has_source = any(p.search(text_file.text) for p in RULES.env_patterns) or any(
|
|
424
|
+
glob.replace("*", "") in text_file.text for glob in RULES.path_globs
|
|
425
|
+
)
|
|
426
|
+
has_sink = any(p.search(text_file.text) for p in RULES.script_sinks)
|
|
427
|
+
if has_source and has_sink:
|
|
428
|
+
findings.append(
|
|
429
|
+
Finding(
|
|
430
|
+
stage=Stage.DETERMINISTIC_ANCHOR,
|
|
431
|
+
severity=Severity.MEDIUM,
|
|
432
|
+
message=f"Possible credential flow in {relative} (unverified pattern match)",
|
|
433
|
+
type="credential-flow-unverified",
|
|
434
|
+
evidence=_evidence(relative, 1, text_file.text[:200]),
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
return findings
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
__all__ = ["RULES_FINGERPRINT", "RULES_VERSION", "analyze_credential_flows", "is_credential_bound"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Declared network destinations from a skill's explicit permissions.
|
|
2
|
+
|
|
3
|
+
Only ``permissions.network`` and ``network.hosts`` are read. Free-text metadata
|
|
4
|
+
such as ``homepage``, ``api`` or ``endpoints`` is written by the skill author, so
|
|
5
|
+
a malicious skill could list its own exfiltration host there. Those fields are
|
|
6
|
+
therefore never treated as a declaration.
|
|
7
|
+
|
|
8
|
+
A declared host lowers the severity of a credential flow to MEDIUM. It never
|
|
9
|
+
clears the finding: declared destinations are still reported for review.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_host(item: str) -> str | None:
|
|
20
|
+
"""Return the lower-case hostname from a URL, host, or host:port string."""
|
|
21
|
+
|
|
22
|
+
item = item.strip()
|
|
23
|
+
if not item:
|
|
24
|
+
return None
|
|
25
|
+
if "://" in item:
|
|
26
|
+
parsed = urlparse(item)
|
|
27
|
+
host = parsed.hostname or parsed.netloc
|
|
28
|
+
elif "/" in item:
|
|
29
|
+
parsed = urlparse(f"http://{item}")
|
|
30
|
+
host = parsed.hostname or parsed.netloc
|
|
31
|
+
else:
|
|
32
|
+
host = item.split(":")[0]
|
|
33
|
+
host = (host or "").lower().strip()
|
|
34
|
+
return host or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _collect(values: Any, into: set[str]) -> None:
|
|
38
|
+
if isinstance(values, str):
|
|
39
|
+
host = _extract_host(values)
|
|
40
|
+
if host:
|
|
41
|
+
into.add(host)
|
|
42
|
+
elif isinstance(values, list):
|
|
43
|
+
for item in values:
|
|
44
|
+
if isinstance(item, str):
|
|
45
|
+
host = _extract_host(item)
|
|
46
|
+
if host:
|
|
47
|
+
into.add(host)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_declared_hosts(frontmatter: dict[str, Any] | None) -> set[str]:
|
|
51
|
+
"""Return the hosts the skill declares through explicit network permissions."""
|
|
52
|
+
|
|
53
|
+
if not isinstance(frontmatter, dict):
|
|
54
|
+
return set()
|
|
55
|
+
declared: set[str] = set()
|
|
56
|
+
permissions = frontmatter.get("permissions")
|
|
57
|
+
if isinstance(permissions, dict):
|
|
58
|
+
_collect(permissions.get("network"), declared)
|
|
59
|
+
network = frontmatter.get("network")
|
|
60
|
+
if isinstance(network, dict):
|
|
61
|
+
_collect(network.get("hosts"), declared)
|
|
62
|
+
return declared
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_host_declared(candidate: str, declared_hosts: Iterable[str]) -> bool:
|
|
66
|
+
"""True when ``candidate`` is a declared host or a subdomain of one."""
|
|
67
|
+
|
|
68
|
+
host = _extract_host(candidate)
|
|
69
|
+
if not host:
|
|
70
|
+
return False
|
|
71
|
+
for declared in declared_hosts:
|
|
72
|
+
declared = declared.lower().strip()
|
|
73
|
+
if host == declared or host.endswith("." + declared):
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["extract_declared_hosts", "is_host_declared"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Persistent cache and scan-history storage.
|
|
2
|
+
|
|
3
|
+
The cache is deliberately content-addressed and *never* stores arbitrary user
|
|
4
|
+
files. Only the hash, the verdict, and the evidence needed to redisplay it are
|
|
5
|
+
written to SQLite. A cached verdict is only reused when the engine version,
|
|
6
|
+
model version, backend and scoring weights all match (docs/03-database.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from skill_sentry_cli.core.cache.hash_utils import (
|
|
12
|
+
HashError,
|
|
13
|
+
SkillHash,
|
|
14
|
+
hash_bytes,
|
|
15
|
+
hash_skill_dir,
|
|
16
|
+
)
|
|
17
|
+
from skill_sentry_cli.core.cache.sqlite_store import (
|
|
18
|
+
CachedResult,
|
|
19
|
+
ScanRecord,
|
|
20
|
+
SQLiteStore,
|
|
21
|
+
StoreError,
|
|
22
|
+
cache_key,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CachedResult",
|
|
27
|
+
"HashError",
|
|
28
|
+
"SQLiteStore",
|
|
29
|
+
"ScanRecord",
|
|
30
|
+
"SkillHash",
|
|
31
|
+
"StoreError",
|
|
32
|
+
"cache_key",
|
|
33
|
+
"hash_bytes",
|
|
34
|
+
"hash_skill_dir",
|
|
35
|
+
]
|