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,170 @@
|
|
|
1
|
+
"""Sandbox execution plan (INT-4).
|
|
2
|
+
|
|
3
|
+
The plan says what the sandbox is asked to check and why. Baseline checks are
|
|
4
|
+
always present. Checks derived from deterministic anchors only add to them, and
|
|
5
|
+
nothing can remove a baseline check. Only the declared entrypoint may run, and a
|
|
6
|
+
plan that fails validation blocks execution, so the stage degrades instead of
|
|
7
|
+
running with a partial or unsafe plan.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from skill_sentry_cli.core.contracts import StageResult
|
|
18
|
+
from skill_sentry_cli.core.parser import SkillPackage
|
|
19
|
+
|
|
20
|
+
MAX_CHECK_TIMEOUT_S = 600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InstructionCategory(str, Enum):
|
|
24
|
+
TRIGGER_EXECUTION = "TRIGGER_EXECUTION"
|
|
25
|
+
CREDENTIAL_CANARY_MONITOR = "CREDENTIAL_CANARY_MONITOR"
|
|
26
|
+
DNS_SINKHOLE_WATCH = "DNS_SINKHOLE_WATCH"
|
|
27
|
+
HTTP_EXFIL_MONITOR = "HTTP_EXFIL_MONITOR"
|
|
28
|
+
EVASION_PROBE_WATCH = "EVASION_PROBE_WATCH"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InstructionSource(str, Enum):
|
|
32
|
+
BASELINE = "baseline"
|
|
33
|
+
DETERMINISTIC_ANCHOR = "deterministicAnchor"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BASELINE_CATEGORIES: tuple[InstructionCategory, ...] = (
|
|
37
|
+
InstructionCategory.TRIGGER_EXECUTION,
|
|
38
|
+
InstructionCategory.CREDENTIAL_CANARY_MONITOR,
|
|
39
|
+
InstructionCategory.DNS_SINKHOLE_WATCH,
|
|
40
|
+
InstructionCategory.HTTP_EXFIL_MONITOR,
|
|
41
|
+
InstructionCategory.EVASION_PROBE_WATCH,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Anchor finding types that call for an extra watch, on top of the baseline.
|
|
45
|
+
_ANCHOR_WATCHES: dict[str, InstructionCategory] = {
|
|
46
|
+
"credential-path": InstructionCategory.CREDENTIAL_CANARY_MONITOR,
|
|
47
|
+
"secret-access": InstructionCategory.CREDENTIAL_CANARY_MONITOR,
|
|
48
|
+
"dns-lookup": InstructionCategory.DNS_SINKHOLE_WATCH,
|
|
49
|
+
"exfil-http-post": InstructionCategory.HTTP_EXFIL_MONITOR,
|
|
50
|
+
"raw-socket": InstructionCategory.HTTP_EXFIL_MONITOR,
|
|
51
|
+
"reverse-shell": InstructionCategory.HTTP_EXFIL_MONITOR,
|
|
52
|
+
"sandbox-artifact": InstructionCategory.EVASION_PROBE_WATCH,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SandboxActionInstruction(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
|
|
59
|
+
instruction_id: str = Field(min_length=1, max_length=80)
|
|
60
|
+
category: InstructionCategory
|
|
61
|
+
reason: str = Field(min_length=1, max_length=500)
|
|
62
|
+
source: InstructionSource
|
|
63
|
+
check: str = Field(min_length=1, max_length=200)
|
|
64
|
+
timeout_s: int = Field(ge=1, le=MAX_CHECK_TIMEOUT_S)
|
|
65
|
+
required: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SandboxExecutionPlan(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
instructions: list[SandboxActionInstruction] = Field(default_factory=list)
|
|
72
|
+
baseline_instructions: list[SandboxActionInstruction] = Field(default_factory=list)
|
|
73
|
+
declared_entrypoint: list[str] = Field(default_factory=list)
|
|
74
|
+
canary_context: str = ""
|
|
75
|
+
validation_status: Literal["valid", "invalid"] = "invalid"
|
|
76
|
+
problems: list[str] = Field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _baseline(timeout_s: int) -> list[SandboxActionInstruction]:
|
|
80
|
+
return [
|
|
81
|
+
SandboxActionInstruction(
|
|
82
|
+
instruction_id=f"baseline-{category.value.lower()}",
|
|
83
|
+
category=category,
|
|
84
|
+
reason="always checked for every skill",
|
|
85
|
+
source=InstructionSource.BASELINE,
|
|
86
|
+
check=category.value,
|
|
87
|
+
timeout_s=timeout_s,
|
|
88
|
+
required=True,
|
|
89
|
+
)
|
|
90
|
+
for category in BASELINE_CATEGORIES
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_plan(
|
|
95
|
+
package: SkillPackage,
|
|
96
|
+
anchors: StageResult,
|
|
97
|
+
*,
|
|
98
|
+
timeout_s: int,
|
|
99
|
+
) -> SandboxExecutionPlan:
|
|
100
|
+
"""Build and validate the plan for one skill.
|
|
101
|
+
|
|
102
|
+
Anchor-derived watches are additive and optional. The plan is valid only when
|
|
103
|
+
every baseline category is present, the entrypoint is declared, and every
|
|
104
|
+
timeout is within bounds.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
timeout = max(1, min(int(timeout_s), MAX_CHECK_TIMEOUT_S))
|
|
108
|
+
baseline = _baseline(timeout)
|
|
109
|
+
additions: list[SandboxActionInstruction] = []
|
|
110
|
+
seen: set[tuple[str, str]] = set()
|
|
111
|
+
for finding in anchors.findings:
|
|
112
|
+
category = _ANCHOR_WATCHES.get(finding.finding_type or "")
|
|
113
|
+
if category is None:
|
|
114
|
+
continue
|
|
115
|
+
key = (category.value, finding.evidence.file or "")
|
|
116
|
+
if key in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(key)
|
|
119
|
+
additions.append(
|
|
120
|
+
SandboxActionInstruction(
|
|
121
|
+
instruction_id=f"anchor-{len(additions) + 1}-{category.value.lower()}",
|
|
122
|
+
category=category,
|
|
123
|
+
reason=(finding.message or "deterministic anchor")[:500],
|
|
124
|
+
source=InstructionSource.DETERMINISTIC_ANCHOR,
|
|
125
|
+
check=category.value,
|
|
126
|
+
timeout_s=timeout,
|
|
127
|
+
required=False,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
plan = SandboxExecutionPlan(
|
|
132
|
+
instructions=baseline + additions,
|
|
133
|
+
baseline_instructions=baseline,
|
|
134
|
+
declared_entrypoint=list(package.entrypoint or []),
|
|
135
|
+
canary_context=(package.declared_intent or "")[:200],
|
|
136
|
+
)
|
|
137
|
+
problems = validate_plan(plan)
|
|
138
|
+
plan.problems = problems
|
|
139
|
+
plan.validation_status = "invalid" if problems else "valid"
|
|
140
|
+
return plan
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate_plan(plan: SandboxExecutionPlan) -> list[str]:
|
|
144
|
+
"""Return the reasons a plan must not run; an empty list means it may run."""
|
|
145
|
+
|
|
146
|
+
problems: list[str] = []
|
|
147
|
+
if not plan.declared_entrypoint:
|
|
148
|
+
problems.append(
|
|
149
|
+
"no declared entrypoint, so the scripts were not executed; declare 'entrypoint:' "
|
|
150
|
+
"in SKILL.md so they can be tested. A fallback command is never guessed"
|
|
151
|
+
)
|
|
152
|
+
present = {instruction.category for instruction in plan.baseline_instructions}
|
|
153
|
+
missing = [category.value for category in BASELINE_CATEGORIES if category not in present]
|
|
154
|
+
if missing:
|
|
155
|
+
problems.append(f"baseline checks missing: {', '.join(missing)}")
|
|
156
|
+
for instruction in plan.instructions:
|
|
157
|
+
if not 1 <= instruction.timeout_s <= MAX_CHECK_TIMEOUT_S:
|
|
158
|
+
problems.append(f"timeout out of bounds for {instruction.instruction_id}")
|
|
159
|
+
return problems
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = [
|
|
163
|
+
"BASELINE_CATEGORIES",
|
|
164
|
+
"InstructionCategory",
|
|
165
|
+
"InstructionSource",
|
|
166
|
+
"SandboxActionInstruction",
|
|
167
|
+
"SandboxExecutionPlan",
|
|
168
|
+
"build_plan",
|
|
169
|
+
"validate_plan",
|
|
170
|
+
]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Masquerade configuration, shim source, and verification helpers.
|
|
2
|
+
|
|
3
|
+
The shim hides ``/.dockerenv`` and redirects fake ``/proc`` views while logging
|
|
4
|
+
canary reads and sandbox probes. A failed verification is an **engine error**:
|
|
5
|
+
the runner must not continue and must never emit a clean verdict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
ASSET_DIR = Path(__file__).resolve().parent / "assets"
|
|
18
|
+
MASQUERADE_SOURCE_PATH = ASSET_DIR / "masquerade.c"
|
|
19
|
+
RUN_SCRIPT_PATH = ASSET_DIR / "run_skill.sh"
|
|
20
|
+
DOCKERFILE_PATH = ASSET_DIR / "Dockerfile.sandbox"
|
|
21
|
+
|
|
22
|
+
SHIM_PATH = "/opt/skillsentry/masquerade.so"
|
|
23
|
+
SHIM_LOG_PATH = "/tmp/skillsentry/events.jsonl"
|
|
24
|
+
FAKE_CGROUP_PATH = "/tmp/skillsentry/cgroup"
|
|
25
|
+
FAKE_MOUNTINFO_PATH = "/tmp/skillsentry/mountinfo"
|
|
26
|
+
DOCKERENV_PATH = "/.dockerenv"
|
|
27
|
+
CGROUP_PATH = "/proc/1/cgroup"
|
|
28
|
+
MOUNTINFO_PATH = "/proc/self/mountinfo"
|
|
29
|
+
|
|
30
|
+
FAKE_CGROUP_CONTENT = "0::/\n"
|
|
31
|
+
FAKE_MOUNTINFO_CONTENT = (
|
|
32
|
+
"1 0 0:1 / / ext4 rw,relatime 0 0\n"
|
|
33
|
+
"2 1 0:2 / /proc proc rw,nosuid,nodev,noexec,relatime 0 0\n"
|
|
34
|
+
"3 1 0:3 / /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0\n"
|
|
35
|
+
"4 1 0:4 / /tmp tmpfs rw,nosuid,nodev,noexec,relatime 0 0\n"
|
|
36
|
+
"5 1 0:5 / /home/sandbox tmpfs rw,nosuid,nodev,noexec,relatime 0 0\n"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MasqueradeUnavailable(RuntimeError):
|
|
41
|
+
"""The shim cannot be built or is absent from the image."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MasqueradeError(RuntimeError):
|
|
45
|
+
"""The shim is present but its hides/redirects did not verify."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class MasqueradeConfig:
|
|
50
|
+
shim_path: str = SHIM_PATH
|
|
51
|
+
require_shim: bool = True
|
|
52
|
+
dockerenv_source: str = "/dev/null"
|
|
53
|
+
dockerenv_target: str = DOCKERENV_PATH
|
|
54
|
+
|
|
55
|
+
def environment(self) -> dict[str, str]:
|
|
56
|
+
return {"LD_PRELOAD": self.shim_path}
|
|
57
|
+
|
|
58
|
+
def volumes(self) -> dict[str, dict[str, str]]:
|
|
59
|
+
# Docker 29+ rejects bind mounts whose target is inside /proc, so the
|
|
60
|
+
# fake cgroup/mountinfo views are served by the shim's path redirect
|
|
61
|
+
# rather than host bind mounts.
|
|
62
|
+
return {
|
|
63
|
+
self.dockerenv_source: {
|
|
64
|
+
"bind": self.dockerenv_target,
|
|
65
|
+
"mode": "ro",
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def tmpfs(self) -> dict[str, str]:
|
|
70
|
+
# Kept as an extension point for callers that need a directory tmpfs.
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class MasqueradeCheck:
|
|
76
|
+
dockerenv_hidden: bool
|
|
77
|
+
cgroup_fake: bool
|
|
78
|
+
mountinfo_clean: bool
|
|
79
|
+
shim_present: bool
|
|
80
|
+
errors: tuple[str, ...] = ()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def ok(self) -> bool:
|
|
84
|
+
return not self.errors
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, Any]:
|
|
87
|
+
return {
|
|
88
|
+
"ok": self.ok,
|
|
89
|
+
"dockerenvHidden": self.dockerenv_hidden,
|
|
90
|
+
"cgroupFake": self.cgroup_fake,
|
|
91
|
+
"mountinfoClean": self.mountinfo_clean,
|
|
92
|
+
"shimPresent": self.shim_present,
|
|
93
|
+
"errors": list(self.errors),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def source_text() -> str:
|
|
98
|
+
return MASQUERADE_SOURCE_PATH.read_text(encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_script_text() -> str:
|
|
102
|
+
return RUN_SCRIPT_PATH.read_text(encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def compile_shim(
|
|
106
|
+
*,
|
|
107
|
+
source: str | Path | None = None,
|
|
108
|
+
output: str | Path | None = None,
|
|
109
|
+
compiler: str | None = None,
|
|
110
|
+
) -> Path:
|
|
111
|
+
"""Compile the shim with a local gcc (development/CI packaging helper)."""
|
|
112
|
+
|
|
113
|
+
gcc = compiler or shutil.which("gcc")
|
|
114
|
+
if not gcc:
|
|
115
|
+
raise MasqueradeUnavailable("gcc is unavailable; use the sandbox Docker image")
|
|
116
|
+
source_path = Path(source or MASQUERADE_SOURCE_PATH)
|
|
117
|
+
output_path = Path(output or (ASSET_DIR / "masquerade.so"))
|
|
118
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
try:
|
|
120
|
+
completed = subprocess.run(
|
|
121
|
+
[
|
|
122
|
+
gcc,
|
|
123
|
+
"-shared",
|
|
124
|
+
"-fPIC",
|
|
125
|
+
"-O2",
|
|
126
|
+
"-Wall",
|
|
127
|
+
"-Wextra",
|
|
128
|
+
"-o",
|
|
129
|
+
str(output_path),
|
|
130
|
+
str(source_path),
|
|
131
|
+
"-ldl",
|
|
132
|
+
],
|
|
133
|
+
check=False,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=60,
|
|
137
|
+
)
|
|
138
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
139
|
+
raise MasqueradeUnavailable(f"could not run gcc: {exc}") from exc
|
|
140
|
+
if completed.returncode != 0:
|
|
141
|
+
raise MasqueradeUnavailable(
|
|
142
|
+
f"gcc failed ({completed.returncode}): {completed.stderr.strip()}"
|
|
143
|
+
)
|
|
144
|
+
return output_path
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _decode_b64(value: str) -> str:
|
|
148
|
+
try:
|
|
149
|
+
return base64.b64decode(value.encode("ascii"), validate=False).decode(
|
|
150
|
+
"utf-8", errors="replace"
|
|
151
|
+
)
|
|
152
|
+
except (ValueError, UnicodeError):
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def verify_outputs(
|
|
157
|
+
*,
|
|
158
|
+
dockerenv_exists: bool,
|
|
159
|
+
cgroup: str,
|
|
160
|
+
mountinfo: str,
|
|
161
|
+
shim_present: bool,
|
|
162
|
+
require_shim: bool = True,
|
|
163
|
+
) -> MasqueradeCheck:
|
|
164
|
+
errors: list[str] = []
|
|
165
|
+
dockerenv_hidden = not dockerenv_exists
|
|
166
|
+
if not dockerenv_hidden:
|
|
167
|
+
errors.append("/.dockerenv is visible to the skill")
|
|
168
|
+
|
|
169
|
+
lowered_cgroup = cgroup.lower()
|
|
170
|
+
cgroup_fake = bool(cgroup.strip()) and not any(
|
|
171
|
+
marker in lowered_cgroup for marker in ("docker", "containerd", "kubepods")
|
|
172
|
+
)
|
|
173
|
+
if not cgroup_fake:
|
|
174
|
+
errors.append("/proc/1/cgroup still exposes container markers")
|
|
175
|
+
|
|
176
|
+
lowered_mountinfo = mountinfo.lower()
|
|
177
|
+
mountinfo_clean = not any(
|
|
178
|
+
marker in lowered_mountinfo
|
|
179
|
+
for marker in ("docker", "containerd", "overlay", "kubepods", "skillsentry")
|
|
180
|
+
)
|
|
181
|
+
if not mountinfo_clean:
|
|
182
|
+
errors.append("/proc/self/mountinfo still exposes container markers")
|
|
183
|
+
|
|
184
|
+
if require_shim and not shim_present:
|
|
185
|
+
errors.append("masquerade shim is missing from the sandbox image")
|
|
186
|
+
|
|
187
|
+
return MasqueradeCheck(
|
|
188
|
+
dockerenv_hidden=dockerenv_hidden,
|
|
189
|
+
cgroup_fake=cgroup_fake,
|
|
190
|
+
mountinfo_clean=mountinfo_clean,
|
|
191
|
+
shim_present=shim_present,
|
|
192
|
+
errors=tuple(errors),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def verification_script(shim_path: str = SHIM_PATH) -> str:
|
|
197
|
+
"""POSIX shell probe used by :func:`verify_container`."""
|
|
198
|
+
|
|
199
|
+
return f"""
|
|
200
|
+
if [ -e {DOCKERENV_PATH} ]; then d=present; else d=absent; fi
|
|
201
|
+
if [ -f {shim_path} ]; then s=present; else s=missing; fi
|
|
202
|
+
c=$(base64 < {CGROUP_PATH} 2>/dev/null | tr -d '\\n')
|
|
203
|
+
m=$(base64 < {MOUNTINFO_PATH} 2>/dev/null | tr -d '\\n')
|
|
204
|
+
printf 'SKILLSENTRY_DOCKERENV=%s\\n' "$d"
|
|
205
|
+
printf 'SKILLSENTRY_SHIM=%s\\n' "$s"
|
|
206
|
+
printf 'SKILLSENTRY_CGROUP=%s\\n' "$c"
|
|
207
|
+
printf 'SKILLSENTRY_MOUNTINFO=%s\\n' "$m"
|
|
208
|
+
""".strip()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_verification_output(
|
|
212
|
+
text: str | bytes,
|
|
213
|
+
*,
|
|
214
|
+
require_shim: bool = True,
|
|
215
|
+
) -> MasqueradeCheck:
|
|
216
|
+
if isinstance(text, bytes):
|
|
217
|
+
text = text.decode("utf-8", errors="replace")
|
|
218
|
+
values: dict[str, str] = {}
|
|
219
|
+
for line in text.splitlines():
|
|
220
|
+
if "=" not in line:
|
|
221
|
+
continue
|
|
222
|
+
key, value = line.split("=", 1)
|
|
223
|
+
if key.startswith("SKILLSENTRY_"):
|
|
224
|
+
values[key] = value.strip()
|
|
225
|
+
return verify_outputs(
|
|
226
|
+
dockerenv_exists=values.get("SKILLSENTRY_DOCKERENV") != "absent",
|
|
227
|
+
cgroup=_decode_b64(values.get("SKILLSENTRY_CGROUP", "")),
|
|
228
|
+
mountinfo=_decode_b64(values.get("SKILLSENTRY_MOUNTINFO", "")),
|
|
229
|
+
shim_present=values.get("SKILLSENTRY_SHIM") == "present",
|
|
230
|
+
require_shim=require_shim,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def verify_container(
|
|
235
|
+
container: Any,
|
|
236
|
+
*,
|
|
237
|
+
config: MasqueradeConfig | None = None,
|
|
238
|
+
user: str = "65534:65534",
|
|
239
|
+
) -> MasqueradeCheck:
|
|
240
|
+
"""Run the verification probe in a started container."""
|
|
241
|
+
|
|
242
|
+
config = config or MasqueradeConfig()
|
|
243
|
+
result = container.exec_run(
|
|
244
|
+
["/bin/sh", "-c", verification_script(config.shim_path)],
|
|
245
|
+
user=user,
|
|
246
|
+
environment=config.environment(),
|
|
247
|
+
)
|
|
248
|
+
if isinstance(result.output, bytes):
|
|
249
|
+
output = result.output.decode("utf-8", errors="replace")
|
|
250
|
+
else:
|
|
251
|
+
output = str(result.output or "")
|
|
252
|
+
if result.exit_code not in (0, None):
|
|
253
|
+
return MasqueradeCheck(
|
|
254
|
+
dockerenv_hidden=False,
|
|
255
|
+
cgroup_fake=False,
|
|
256
|
+
mountinfo_clean=False,
|
|
257
|
+
shim_present=False,
|
|
258
|
+
errors=(f"masquerade probe exited {result.exit_code}: {output.strip()}",),
|
|
259
|
+
)
|
|
260
|
+
return parse_verification_output(output, require_shim=config.require_shim)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def require_masquerade(check: MasqueradeCheck) -> None:
|
|
264
|
+
if not check.ok:
|
|
265
|
+
raise MasqueradeError("; ".join(check.errors))
|