saferun-cli 0.0.2__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 (51) hide show
  1. saferun/__init__.py +3 -0
  2. saferun/__main__.py +5 -0
  3. saferun/cli.py +533 -0
  4. saferun/dockerdiag.py +65 -0
  5. saferun/doctor.py +159 -0
  6. saferun/engine/__init__.py +7 -0
  7. saferun/engine/analyzer.py +30 -0
  8. saferun/engine/context.py +27 -0
  9. saferun/engine/engine.py +100 -0
  10. saferun/engine/model.py +146 -0
  11. saferun/engine/node.py +145 -0
  12. saferun/engine/osv.py +291 -0
  13. saferun/engine/osvdb.py +367 -0
  14. saferun/engine/paths.py +41 -0
  15. saferun/engine/pysyntax.py +135 -0
  16. saferun/engine/rules/python.yml +103 -0
  17. saferun/engine/semgrep.py +173 -0
  18. saferun/engine/tools.py +167 -0
  19. saferun/engine/verdict.py +47 -0
  20. saferun/hostos/__init__.py +188 -0
  21. saferun/hostos/_win.py +316 -0
  22. saferun/hostos/acl.py +172 -0
  23. saferun/hostos/fsops.py +41 -0
  24. saferun/hostos/process.py +82 -0
  25. saferun/hostos/winfs.py +164 -0
  26. saferun/runcmd.py +139 -0
  27. saferun/sandbox/__init__.py +11 -0
  28. saferun/sandbox/analyzer.py +192 -0
  29. saferun/sandbox/attestation.py +110 -0
  30. saferun/sandbox/config.py +121 -0
  31. saferun/sandbox/controls.py +104 -0
  32. saferun/sandbox/copy.py +338 -0
  33. saferun/sandbox/copy_windows.py +281 -0
  34. saferun/sandbox/cosign_tool.py +134 -0
  35. saferun/sandbox/docker.py +167 -0
  36. saferun/sandbox/images.py +110 -0
  37. saferun/sandbox/profiles.py +40 -0
  38. saferun/sandbox/reaper.py +127 -0
  39. saferun/sandbox/runner.py +268 -0
  40. saferun/sandbox/secrets.py +91 -0
  41. saferun/sandbox/signature.py +117 -0
  42. saferun/sandbox/verify.py +75 -0
  43. saferun/sandbox/workspace.py +92 -0
  44. saferun/setup.py +559 -0
  45. saferun/ui.py +133 -0
  46. saferun_cli-0.0.2.dist-info/METADATA +136 -0
  47. saferun_cli-0.0.2.dist-info/RECORD +51 -0
  48. saferun_cli-0.0.2.dist-info/WHEEL +5 -0
  49. saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
  50. saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
  51. saferun_cli-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,117 @@
1
+ """Cosign signature check for the published analyzer image (Phase 5E). Fails closed: every problem raises `SignatureError`.
2
+
3
+ What is verified: that `<repository>@<digest>` carries a Sigstore "keyless" signature whose certificate was issued to the expected
4
+ GitHub Actions identity (`AnalyzerRelease.signer_identity_regexp`, issuer `oidc_issuer`) and that the signed payload names exactly
5
+ that repository and digest. The check is made by the `cosign` command line tool (`saferun init` fetches a pinned, hash-verified copy itself, see cosign_tool.py; a cosign 2.2+ on PATH is used if that copy is absent), which also checks the transparency log (Rekor).
6
+
7
+ How it is run: argv list (no shell), absolute path of the executable, minimal environment (no tokens or credentials), a private
8
+ HOME (so nothing is read from or written to the user's home), a hard timeout and an output cap. This runs on the HOST: cosign parses
9
+ signature data from the registry, which is why the image itself is only pulled AFTER this check succeeds and why the digest, tags and
10
+ labels are verified again afterwards. Needs internet (registry, Sigstore TUF root, Rekor); there is no offline or "skip" mode.
11
+
12
+ What it does not prove: that the signed workflow itself is free of bugs, that the repository or its GitHub account was not compromised,
13
+ or anything about cosign's own correctness. See docs/release-images.md.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from .. import hostos
25
+ from . import cosign_tool
26
+ from . import docker as dk
27
+ from .config import SandboxError
28
+ from .images import DIGEST_RE, AnalyzerRelease
29
+
30
+ COSIGN_TIMEOUT_S = 120
31
+ MAX_OUTPUT = 1024 * 1024
32
+ INSTALL_HINT = "run `saferun init --only analyzer` (it fetches a verified cosign) or install cosign 2.2+ (https://docs.sigstore.dev/cosign/system_config/installation/)"
33
+ _ENV_KEEP = ("PATH", "LANG", "LC_ALL", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
34
+ "SSL_CERT_FILE", "SSL_CERT_DIR")
35
+
36
+
37
+ class SignatureError(SandboxError):
38
+ """The release signature is missing, wrong, or could not be checked. The image must not be pulled or trusted."""
39
+
40
+
41
+ class CosignMissing(SignatureError):
42
+ """The `cosign` executable is not installed, so nothing could be verified."""
43
+
44
+
45
+ def find_cosign() -> str | None:
46
+ """The cosign to use: SafeRun's own pinned, hash-verified copy (cosign_tool.py) if it is installed, else a cosign on PATH."""
47
+ managed = cosign_tool.managed_cosign()
48
+ if managed:
49
+ return managed
50
+ path = shutil.which("cosign")
51
+ return os.path.abspath(path) if path else None
52
+
53
+
54
+ def build_command(cosign: str, rel: AnalyzerRelease) -> list[str]:
55
+ """argv for `cosign verify`. Refuses anything that is not a plain image reference and a sha256 digest."""
56
+ if not DIGEST_RE.match(rel.digest or ""):
57
+ raise SignatureError(f"release digest is not a sha256 digest: {rel.digest!r}")
58
+ if not rel.signer_identity_regexp or not rel.oidc_issuer:
59
+ raise SignatureError("the release names no signer identity or issuer, so its signature cannot be checked")
60
+ try:
61
+ dk.validate_image(rel.reference)
62
+ except SandboxError as e:
63
+ raise SignatureError(str(e)) from None
64
+ return [cosign, "verify",
65
+ "--certificate-identity-regexp", rel.signer_identity_regexp,
66
+ "--certificate-oidc-issuer", rel.oidc_issuer,
67
+ "--output", "json", rel.reference]
68
+
69
+
70
+ def parse_output(stdout: str, rel: AnalyzerRelease) -> int:
71
+ """Number of signature payloads in cosign's JSON that name exactly `rel`'s repository and digest; raises if there is none.
72
+ cosign has already verified the signatures; this makes sure what it reports is about the image we asked for."""
73
+ try:
74
+ data = json.loads(stdout)
75
+ except (ValueError, RecursionError):
76
+ raise SignatureError("cosign printed no valid JSON; the signature was NOT confirmed") from None
77
+ if not isinstance(data, list):
78
+ raise SignatureError("cosign output has an unexpected shape; the signature was NOT confirmed")
79
+ good = 0
80
+ for item in data:
81
+ try:
82
+ crit = item["critical"]
83
+ digest = crit["image"]["docker-manifest-digest"]
84
+ ref = crit["identity"]["docker-reference"]
85
+ except (KeyError, TypeError):
86
+ continue
87
+ if digest == rel.digest and ref == rel.repository:
88
+ good += 1
89
+ if not good:
90
+ raise SignatureError("cosign reported no signature for exactly this repository and digest; it was NOT confirmed")
91
+ return good
92
+
93
+
94
+ def verify_release_signature(rel: AnalyzerRelease, *, which=find_cosign, run=subprocess.run, timeout_s: float = COSIGN_TIMEOUT_S) -> int:
95
+ """Raise SignatureError / CosignMissing unless `rel` is validly signed by the expected identity. Returns the number of signatures."""
96
+ cosign = which()
97
+ if not cosign:
98
+ raise CosignMissing(f"cosign is not installed, so the signature of {rel.reference} cannot be checked: {INSTALL_HINT}")
99
+ argv = build_command(cosign, rel)
100
+ env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ and "\0" not in os.environ[k]}
101
+ with tempfile.TemporaryDirectory(prefix="saferun-cosign-") as home:
102
+ env.update(HOME=home, TMPDIR=home, XDG_CACHE_HOME=str(Path(home) / "cache"), XDG_CONFIG_HOME=str(Path(home) / "config"))
103
+ env.update({k: os.environ[k] for k in hostos.base_env_names() if k in os.environ}) # Windows: SYSTEMROOT etc. (needed to run at all)
104
+ env.update(hostos.private_home_env(home))
105
+ try:
106
+ r = run(argv, capture_output=True, text=True, errors="replace", timeout=timeout_s, stdin=subprocess.DEVNULL,
107
+ env=env, cwd=home)
108
+ except subprocess.TimeoutExpired:
109
+ raise SignatureError(f"cosign did not finish within {int(timeout_s)} s; the signature was NOT confirmed") from None
110
+ except OSError as e:
111
+ raise SignatureError(f"cosign could not be started: {e}") from None
112
+ if r.returncode != 0:
113
+ tail = " ".join((r.stderr or "").split())[-300:]
114
+ raise SignatureError(f"cosign could not verify the signature of {rel.reference} (exit {r.returncode}): {tail or 'no message'}")
115
+ if len(r.stdout or "") > MAX_OUTPUT:
116
+ raise SignatureError("cosign printed an unexpectedly large output; the signature was NOT confirmed")
117
+ return parse_output(r.stdout or "", rel)
@@ -0,0 +1,75 @@
1
+ """`saferun verify-images`: check that the local Docker images carry the pinned digests (Phase 5A).
2
+
3
+ Read-only: it inspects local images through the Docker CLI. It never pulls, never creates containers, and
4
+ never changes images. A missing image or a digest mismatch is a failure (fail closed), not a warning.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import asdict, dataclass
9
+
10
+ from . import docker as dk
11
+ from .analyzer import ANALYZER_REPO_TAG, AnalyzerIntegrityError, AnalyzerUnavailable, verify_analyzer_image
12
+ from .config import SandboxError
13
+ from .images import PINNED_IMAGES, PinnedImage, repo_digests
14
+
15
+ VERIFIED, MISSING, MISMATCH, ERROR = "verified", "missing", "mismatch", "error"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ImageCheck:
20
+ name: str
21
+ reference: str
22
+ pinned_digest: str
23
+ status: str
24
+ detail: str = ""
25
+
26
+ @property
27
+ def ok(self) -> bool:
28
+ return self.status == VERIFIED
29
+
30
+ def to_dict(self) -> dict:
31
+ return asdict(self)
32
+
33
+
34
+ def check_image(img: PinnedImage) -> ImageCheck:
35
+ base = dict(name=img.name, reference=img.reference, pinned_digest=img.digest)
36
+ try:
37
+ r = dk.docker("image", "inspect", "--format", "{{json .RepoDigests}}", dk.validate_image(img.reference))
38
+ except SandboxError as e:
39
+ return ImageCheck(**base, status=ERROR, detail=str(e))
40
+ if r.returncode != 0:
41
+ if "no such image" in r.stderr.lower():
42
+ return ImageCheck(**base, status=MISSING,
43
+ detail=f"not present locally; fetch it explicitly with: docker pull {img.pull_reference}")
44
+ return ImageCheck(**base, status=ERROR, detail=f"docker image inspect failed: {r.stderr.strip()[:200]}")
45
+ try:
46
+ have = repo_digests(r.stdout.strip())
47
+ except ValueError as e:
48
+ return ImageCheck(**base, status=ERROR, detail=f"unexpected docker output: {e}")
49
+ if img.digest in have:
50
+ return ImageCheck(**base, status=VERIFIED)
51
+ return ImageCheck(**base, status=MISMATCH,
52
+ detail=f"local image has {', '.join(have) if have else 'no registry digest'}, pinned is {img.digest}")
53
+
54
+
55
+ def check_analyzer() -> ImageCheck:
56
+ """The locally built analyzer image against its build lock and pinned tool/rules labels."""
57
+ base = dict(name="analyzer", reference=ANALYZER_REPO_TAG, pinned_digest="")
58
+ try:
59
+ image_id = verify_analyzer_image()
60
+ except AnalyzerUnavailable as e:
61
+ return ImageCheck(**base, status=MISSING, detail=str(e))
62
+ except AnalyzerIntegrityError as e:
63
+ return ImageCheck(**base, status=MISMATCH, detail=str(e))
64
+ except SandboxError as e:
65
+ return ImageCheck(**base, status=ERROR, detail=str(e))
66
+ return ImageCheck(**{**base, "pinned_digest": image_id}, status=VERIFIED)
67
+
68
+
69
+ def verify_images(images: tuple[PinnedImage, ...] = PINNED_IMAGES, *, analyzer: bool = True) -> list[ImageCheck]:
70
+ """Raises SandboxError when Docker itself is unusable (remote endpoint, daemon down)."""
71
+ dk.check_local_daemon()
72
+ checks = [check_image(i) for i in images]
73
+ if analyzer:
74
+ checks.append(check_analyzer())
75
+ return checks
@@ -0,0 +1,92 @@
1
+ """Temporary, sanitized copy of an untrusted project directory."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .. import hostos
11
+ from .config import SandboxError, SensitivePolicy, SkippedEntry, WithheldEntry, WorkspaceLimits
12
+ from .secrets import Redactor
13
+
14
+ if hostos.IS_WINDOWS:
15
+ from .copy_windows import copy_tree
16
+ else:
17
+ import fcntl
18
+
19
+ from .copy import copy_tree
20
+
21
+ WS_PREFIX = "saferun-ws-"
22
+ LOCK_NAME = ".lock" # flock held for the life of a run; the kernel drops it if we die
23
+
24
+
25
+ @dataclass
26
+ class Workspace:
27
+ root: Path
28
+ project: Path # the only thing ever mounted into a container
29
+ skipped: list[SkippedEntry] = field(default_factory=list)
30
+ withheld: list[WithheldEntry] = field(default_factory=list)
31
+ redactor: Redactor = field(default_factory=Redactor, repr=False) # holds secret values: never log
32
+ entries_scanned: int = 0
33
+ copied_files: int = 0
34
+ copied_dirs: int = 0
35
+ copied_bytes: int = 0
36
+ _lock_fd: int | None = None
37
+
38
+ def cleanup(self) -> None:
39
+ """Delete the workspace and release the lock. Raises if it is still there."""
40
+ try:
41
+ if hostos.IS_WINDOWS and self._lock_fd is not None: # an open exclusive lock file cannot be deleted on Windows
42
+ os.close(self._lock_fd)
43
+ self._lock_fd = None
44
+ shutil.rmtree(hostos.long_path(self.root) if hostos.IS_WINDOWS else self.root, ignore_errors=False,
45
+ **({"onexc": hostos.rmtree_onexc} if hostos.IS_WINDOWS else {}))
46
+ except FileNotFoundError:
47
+ pass
48
+ except OSError as e:
49
+ raise SandboxError(f"could not delete workspace {self.root}: {e}") from e
50
+ finally:
51
+ if self._lock_fd is not None:
52
+ os.close(self._lock_fd)
53
+ self._lock_fd = None
54
+ if self.root.exists():
55
+ raise SandboxError(f"workspace {self.root} still exists after cleanup")
56
+
57
+
58
+ def create_workspace(src: Path, limits: WorkspaceLimits | None = None,
59
+ policy: SensitivePolicy | None = None) -> Workspace:
60
+ """Copy `src` into a fresh temp dir. Symlinks, special files and external hard links are skipped, and
61
+ sensitive files withheld; both are recorded."""
62
+ try:
63
+ src = Path(src).resolve()
64
+ is_dir = src.is_dir()
65
+ except (TypeError, ValueError, OSError, RuntimeError) as e: # NUL byte, symlink loop, bad type...
66
+ raise SandboxError(f"invalid project path {src!r}: {e}") from e
67
+ if not is_dir:
68
+ raise SandboxError(f"not a directory: {src}")
69
+ root = Path(tempfile.mkdtemp(prefix=WS_PREFIX))
70
+ ws = Workspace(root=root, project=root / "project")
71
+ try:
72
+ if hostos.IS_WINDOWS: # POSIX: the directory is 0700 from mkdtemp; Windows: protected ACL for this user (and SYSTEM) only
73
+ hostos.restrict_to_owner(root)
74
+ if hostos.IS_WINDOWS: # held with share mode 0: the Windows counterpart of the flock below (released by the OS if we die)
75
+ ws._lock_fd = hostos.create_workspace_lock(root / LOCK_NAME)
76
+ else:
77
+ ws._lock_fd = os.open(root / LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, 0o600)
78
+ fcntl.flock(ws._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
79
+ ws.project.mkdir(mode=0o755)
80
+ r = copy_tree(str(src), str(ws.project), limits or WorkspaceLimits(), policy)
81
+ ws.skipped, ws.withheld, ws.redactor = r.skipped, r.withheld, r.redactor
82
+ ws.entries_scanned, ws.copied_files, ws.copied_dirs, ws.copied_bytes = (
83
+ r.entries_scanned, r.copied_files, r.copied_dirs, r.copied_bytes)
84
+ ws.project.chmod(0o755)
85
+ root.chmod(0o711) # traversable by the container user, not listable
86
+ except BaseException:
87
+ try:
88
+ ws.cleanup()
89
+ except SandboxError:
90
+ pass # keep the original exception; the reaper will collect the leftovers
91
+ raise
92
+ return ws