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.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. 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,5 @@
1
+ """Credential flow analysis for deterministic anchors."""
2
+
3
+ from skill_sentry_cli.core.anchors.credential_flow import analyze_credential_flows
4
+
5
+ __all__ = ["analyze_credential_flows"]
@@ -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
+ ]