cordon-scanner 0.1.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 (98) hide show
  1. cordon_sandbox/__init__.py +29 -0
  2. cordon_sandbox/cli.py +151 -0
  3. cordon_sandbox/fetch.py +156 -0
  4. cordon_sandbox/isolation.py +228 -0
  5. cordon_sandbox/observe.py +611 -0
  6. cordon_sandbox/py.typed +0 -0
  7. cordon_scanner/__init__.py +236 -0
  8. cordon_scanner/__main__.py +15 -0
  9. cordon_scanner/archive/__init__.py +0 -0
  10. cordon_scanner/archive/safe.py +641 -0
  11. cordon_scanner/cli/__init__.py +0 -0
  12. cordon_scanner/cli/__main__.py +6 -0
  13. cordon_scanner/cli/main.py +1342 -0
  14. cordon_scanner/cli/progress.py +297 -0
  15. cordon_scanner/core/__init__.py +0 -0
  16. cordon_scanner/core/audit.py +182 -0
  17. cordon_scanner/core/bundle.py +267 -0
  18. cordon_scanner/core/cache.py +631 -0
  19. cordon_scanner/core/closure.py +137 -0
  20. cordon_scanner/core/config.py +1891 -0
  21. cordon_scanner/core/content.py +607 -0
  22. cordon_scanner/core/distribution.py +153 -0
  23. cordon_scanner/core/engine.py +2065 -0
  24. cordon_scanner/core/errors.py +123 -0
  25. cordon_scanner/core/guard.py +523 -0
  26. cordon_scanner/core/limits.py +239 -0
  27. cordon_scanner/core/models.py +1215 -0
  28. cordon_scanner/core/parallel.py +411 -0
  29. cordon_scanner/core/paths.py +54 -0
  30. cordon_scanner/core/policy.py +484 -0
  31. cordon_scanner/core/progress.py +83 -0
  32. cordon_scanner/core/prose.py +51 -0
  33. cordon_scanner/core/redact.py +295 -0
  34. cordon_scanner/core/registry.py +251 -0
  35. cordon_scanner/core/scoring.py +289 -0
  36. cordon_scanner/core/taxonomy.py +306 -0
  37. cordon_scanner/core/walker.py +845 -0
  38. cordon_scanner/detect/__init__.py +0 -0
  39. cordon_scanner/detect/advisory.py +181 -0
  40. cordon_scanner/detect/attestation.py +213 -0
  41. cordon_scanner/detect/base.py +364 -0
  42. cordon_scanner/detect/binary.py +506 -0
  43. cordon_scanner/detect/capability.py +727 -0
  44. cordon_scanner/detect/catalogue.py +81 -0
  45. cordon_scanner/detect/config_files.py +977 -0
  46. cordon_scanner/detect/dependency.py +732 -0
  47. cordon_scanner/detect/embedded.py +199 -0
  48. cordon_scanner/detect/lockfile.py +290 -0
  49. cordon_scanner/detect/manifest.py +449 -0
  50. cordon_scanner/detect/obfuscation.py +623 -0
  51. cordon_scanner/detect/pyast.py +454 -0
  52. cordon_scanner/detect/registry.py +563 -0
  53. cordon_scanner/detect/sbom.py +265 -0
  54. cordon_scanner/detect/secrets.py +1065 -0
  55. cordon_scanner/detect/vcs.py +269 -0
  56. cordon_scanner/ecosystems/base.py +402 -0
  57. cordon_scanner/ecosystems/npm.py +368 -0
  58. cordon_scanner/ecosystems/others.py +705 -0
  59. cordon_scanner/ecosystems/pypi.py +483 -0
  60. cordon_scanner/ecosystems/registry.py +201 -0
  61. cordon_scanner/intel/__init__.py +0 -0
  62. cordon_scanner/intel/advisories.py +269 -0
  63. cordon_scanner/intel/hosts.py +264 -0
  64. cordon_scanner/intel/popular.py +387 -0
  65. cordon_scanner/intel/registry_client.py +344 -0
  66. cordon_scanner/langs/__init__.py +0 -0
  67. cordon_scanner/langs/registry.py +180 -0
  68. cordon_scanner/py.typed +0 -0
  69. cordon_scanner/report/__init__.py +0 -0
  70. cordon_scanner/report/base.py +228 -0
  71. cordon_scanner/report/github.py +97 -0
  72. cordon_scanner/report/json_.py +53 -0
  73. cordon_scanner/report/junit.py +114 -0
  74. cordon_scanner/report/markdown.py +114 -0
  75. cordon_scanner/report/sarif.py +283 -0
  76. cordon_scanner/report/text.py +427 -0
  77. cordon_scanner/rules/__init__.py +0 -0
  78. cordon_scanner/rules/builtin/__init__.py +0 -0
  79. cordon_scanner/rules/builtin/capabilities-build.yaml +479 -0
  80. cordon_scanner/rules/builtin/capabilities-dotnet.yaml +228 -0
  81. cordon_scanner/rules/builtin/capabilities-javascript.yaml +270 -0
  82. cordon_scanner/rules/builtin/capabilities-jvm-go-ruby.yaml +636 -0
  83. cordon_scanner/rules/builtin/capabilities-malware-iocs.yaml +616 -0
  84. cordon_scanner/rules/builtin/capabilities-python.yaml +308 -0
  85. cordon_scanner/rules/builtin/capabilities-shell.yaml +305 -0
  86. cordon_scanner/rules/builtin/composites.yaml +701 -0
  87. cordon_scanner/rules/loader.py +1350 -0
  88. cordon_scanner/sources/__init__.py +0 -0
  89. cordon_scanner/sources/base.py +152 -0
  90. cordon_scanner/sources/git.py +700 -0
  91. cordon_scanner/version.py +54 -0
  92. cordon_scanner-0.1.0.dist-info/METADATA +657 -0
  93. cordon_scanner-0.1.0.dist-info/RECORD +98 -0
  94. cordon_scanner-0.1.0.dist-info/WHEEL +5 -0
  95. cordon_scanner-0.1.0.dist-info/entry_points.txt +26 -0
  96. cordon_scanner-0.1.0.dist-info/licenses/LICENSE +202 -0
  97. cordon_scanner-0.1.0.dist-info/licenses/NOTICE +26 -0
  98. cordon_scanner-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,29 @@
1
+ """The runtime-analysis component. Separate from the scanner on purpose.
2
+
3
+ Cordon's core promise is that it never executes the code it scans, and that
4
+ promise is a security property rather than a limitation: running attacker code
5
+ is the thing the tool exists to protect people from. This component does the
6
+ opposite, deliberately, under isolation, and only when someone asks.
7
+
8
+ So it is kept apart in every way that matters. It is not imported by the
9
+ scanner, not reachable from `cordon-scanner scan`, and not run by any default.
10
+ It has its own entry point, and that entry point refuses to do anything without
11
+ an explicit flag whose only purpose is to be hard to pass by accident.
12
+
13
+ **What it exists to see.** The static tiers resolve constant-derived
14
+ obfuscation and turn runtime-computed dispatch into a signal of its own. What
15
+ neither can decide is behaviour that only exists while the code runs -- a target
16
+ decoded from a network response, logic gated on a fetched value. That residual
17
+ is not a gap in the implementation, it is Rice's theorem, and the only tool that
18
+ observes it is one that runs the code.
19
+
20
+ **What it refuses to do.** If isolation cannot be established, it does not run
21
+ the code. Not with a warning, not with reduced isolation, not on the host: it
22
+ exits and says why. A sandbox that degrades to running untrusted code directly
23
+ when its backend is missing is worse than no sandbox, because the person who
24
+ asked for it believes they are protected.
25
+ """
26
+
27
+ from cordon_sandbox.isolation import Backend, IsolationError, available_backend
28
+
29
+ __all__ = ["Backend", "IsolationError", "available_backend"]
cordon_sandbox/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """The entry point, and the flag that has to be typed.
2
+
3
+ `cordon-sandbox` runs untrusted code. That is its purpose and it is also the
4
+ single most dangerous thing in this repository, so the interface is built to
5
+ make the decision explicit and hard to make by accident:
6
+
7
+ * Nothing runs without `--sandbox`. The flag carries no information the command
8
+ does not already imply -- naming the subcommand and the package would be
9
+ enough for an argument parser. It exists to be typed deliberately, and to make
10
+ a copied command line obviously the thing it is.
11
+ * Nothing runs without isolation. A missing container runtime is an error with
12
+ no override, because the alternative is somebody believing they are protected
13
+ while a package installs on their laptop.
14
+ * Nothing here is reachable from `cordon-scanner`. The scanner's promise is that
15
+ it never executes what it scans, and a promise with an entry point into
16
+ execution is not one.
17
+
18
+ Exit codes match the scanner's, so a pipeline can treat the two the same way:
19
+ 0 nothing observed, 1 something was, 2 the component failed, 3 bad invocation.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import sys
26
+ from collections.abc import Sequence
27
+
28
+ from cordon_sandbox.fetch import fetch
29
+ from cordon_sandbox.isolation import IsolationError, available_backend
30
+ from cordon_sandbox.observe import Run, observe
31
+
32
+ CLEAN = 0
33
+ OBSERVED = 1
34
+ FAILED = 2
35
+ BAD_INVOCATION = 3
36
+
37
+ REFUSAL = (
38
+ "cordon-sandbox installs and runs the package you name. Pass --sandbox to "
39
+ "say you meant that. It is not a switch that changes behaviour; it is there "
40
+ "so that running untrusted code is never something you did by accident."
41
+ )
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="cordon-sandbox",
47
+ description=(
48
+ "Install a package under isolation and report what it did. "
49
+ "This EXECUTES the package. The scanner does not; this is the "
50
+ "separate component that does, and only when asked."
51
+ ),
52
+ )
53
+ parser.add_argument(
54
+ "ecosystem", choices=("npm", "pypi"), help="which registry the package is from"
55
+ )
56
+ parser.add_argument("package", help="package name, optionally with a version specifier")
57
+ parser.add_argument(
58
+ "--sandbox",
59
+ action="store_true",
60
+ help="required. Confirms you intend to execute this package.",
61
+ )
62
+ parser.add_argument(
63
+ "--json",
64
+ action="store_true",
65
+ help="emit the observation record as JSON instead of prose",
66
+ )
67
+ return parser
68
+
69
+
70
+ def render(run: Run) -> str:
71
+ lines = [
72
+ f"ran: {run.command}",
73
+ f"image: {run.image}",
74
+ f"runtime: {run.backend.command} {run.backend.version}",
75
+ "",
76
+ "isolation in effect:",
77
+ *[f" - {claim}" for claim in run.guarantees],
78
+ "",
79
+ ]
80
+
81
+ if not run.observations:
82
+ traced = (
83
+ "This run recorded filesystem effects, exit status, output and the "
84
+ "install's execve and connect calls. What it does not see is "
85
+ "behaviour that needs neither: a package that read a file and held "
86
+ "it, or one that waited out the analysis window."
87
+ if run.traced
88
+ else "This run recorded filesystem effects, exit status and output "
89
+ "and produced no syscall trace, so a package that ran something or "
90
+ "tried to reach somewhere would look exactly like this."
91
+ )
92
+ lines += [
93
+ "observed: nothing worth reporting.",
94
+ "",
95
+ "That is not a clean bill of health. " + traced,
96
+ ]
97
+ else:
98
+ lines.append("observed:")
99
+ lines += [f" [{o.kind}] {o.detail}" for o in run.observations]
100
+
101
+ if run.output_tail.strip():
102
+ lines += ["", "last output from the install:", run.output_tail.rstrip()]
103
+ return "\n".join(lines)
104
+
105
+
106
+ def main(argv: Sequence[str] | None = None) -> int:
107
+ parser = build_parser()
108
+ args = parser.parse_args(argv)
109
+
110
+ if not args.sandbox:
111
+ print(REFUSAL, file=sys.stderr)
112
+ return BAD_INVOCATION
113
+
114
+ try:
115
+ # Isolation is established before anything is downloaded, so a missing
116
+ # runtime is discovered before the network is touched at all.
117
+ backend = available_backend()
118
+ artefact = fetch(args.ecosystem, args.package)
119
+ run = observe(backend, args.ecosystem, artefact)
120
+ except IsolationError as exc:
121
+ print(str(exc), file=sys.stderr)
122
+ return FAILED
123
+
124
+ if args.json:
125
+ import json
126
+
127
+ print(
128
+ json.dumps(
129
+ {
130
+ "command": run.command,
131
+ "image": run.image,
132
+ "runtime": f"{run.backend.command} {run.backend.version}",
133
+ "isolation": list(run.guarantees),
134
+ "exit_status": run.exit_status,
135
+ "timed_out": run.timed_out,
136
+ "syscalls_traced": run.traced,
137
+ "observations": [
138
+ {"kind": o.kind, "detail": o.detail} for o in run.observations
139
+ ],
140
+ },
141
+ indent=2,
142
+ )
143
+ )
144
+ else:
145
+ print(render(run))
146
+
147
+ return OBSERVED if run.observations else CLEAN
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -0,0 +1,156 @@
1
+ """Getting the artefact before the isolation goes up.
2
+
3
+ The first working version of this component installed the package inside a
4
+ container with no network, which meant the installer could not reach the
5
+ registry and every package failed at the fetch step. The observation "it failed
6
+ without a network" then fired on everything, which is a signal that means
7
+ nothing.
8
+
9
+ The fix is the structure a sandbox needs anyway: separate *getting* the code
10
+ from *running* it.
11
+
12
+ **Fetching does not execute anything.** This reads the registry's JSON metadata
13
+ and downloads the artefact over HTTP. A `.tar.gz` or a `.tgz` arriving as bytes
14
+ runs nothing; the code in it runs later, inside the container, which is the
15
+ point. Doing this with `pip download` instead would be a mistake -- pip executes
16
+ `setup.py egg_info` on a source distribution to resolve it, so the payload would
17
+ run on the host before the sandbox existed.
18
+
19
+ **The artefact goes in without a mount.** It is copied into the created
20
+ container rather than bind-mounted, so no host path is reachable from inside at
21
+ any point, and the install runs with `--no-index` against the local file.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from dataclasses import dataclass
31
+
32
+ from cordon_sandbox.isolation import IsolationError
33
+
34
+ TIMEOUT_SECONDS = 30.0
35
+ MAX_ARTEFACT_BYTES = 256 << 20
36
+ """Ceiling on what will be downloaded. A package larger than this is not
37
+ something to analyse by accident."""
38
+
39
+ USER_AGENT = "cordon-sandbox (+https://github.com/Threx-code/cordon)"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Artefact:
44
+ """A downloaded package, held in memory until it is copied in."""
45
+
46
+ filename: str
47
+ data: bytes
48
+ source_url: str
49
+
50
+
51
+ def _get(url: str, *, accept: str) -> bytes:
52
+ parsed = urllib.parse.urlsplit(url)
53
+ if parsed.scheme != "https":
54
+ raise IsolationError(f"refusing a non-HTTPS artefact URL: {url}")
55
+
56
+ request = urllib.request.Request( # noqa: S310 (scheme checked above)
57
+ url, headers={"User-Agent": USER_AGENT, "Accept": accept}, method="GET"
58
+ )
59
+ try:
60
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310
61
+ body = response.read(MAX_ARTEFACT_BYTES + 1)
62
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError) as exc:
63
+ raise IsolationError(f"could not fetch {url}: {type(exc).__name__}") from exc
64
+
65
+ if len(body) > MAX_ARTEFACT_BYTES:
66
+ raise IsolationError(f"{url} exceeded {MAX_ARTEFACT_BYTES} bytes and was not downloaded")
67
+ return bytes(body)
68
+
69
+
70
+ def _mapping(value: object) -> dict[str, object]:
71
+ """A mapping, or an empty one. Registry documents are written by whoever
72
+ published the package, so a field documented as an object arrives as
73
+ whatever they put there."""
74
+ return value if isinstance(value, dict) else {}
75
+
76
+
77
+ def fetch(ecosystem: str, package: str) -> Artefact:
78
+ """Download the artefact for a package, without running any of it."""
79
+ if ecosystem == "pypi":
80
+ return _pypi(package)
81
+ if ecosystem == "npm":
82
+ return _npm(package)
83
+ raise IsolationError(f"no fetcher is defined for {ecosystem!r}")
84
+
85
+
86
+ def _split(package: str, separator: str) -> tuple[str, str | None]:
87
+ name, found, version = package.rpartition(separator)
88
+ if not found:
89
+ return package, None
90
+ return name, version or None
91
+
92
+
93
+ def _pypi(package: str) -> Artefact:
94
+ name, version = _split(package, "==")
95
+ document = json.loads(
96
+ _get(
97
+ f"https://pypi.org/pypi/{urllib.parse.quote(name, safe='')}/json",
98
+ accept="application/json",
99
+ )
100
+ )
101
+ releases = _mapping(document.get("releases"))
102
+ info = _mapping(document.get("info"))
103
+ declared = info.get("version")
104
+ version = version or (declared if isinstance(declared, str) else None)
105
+
106
+ entries = releases.get(version) if version else None
107
+ if not isinstance(entries, list) or not entries:
108
+ raise IsolationError(f"pypi has no downloadable files for {name} {version}")
109
+
110
+ # A source distribution is preferred over a wheel, because `setup.py` is
111
+ # where install-time code lives and a wheel usually has none. Analysing the
112
+ # wheel of a package whose payload is in its sdist would observe nothing and
113
+ # report it as nothing found.
114
+ ordered = sorted(entries, key=lambda e: e.get("packagetype") != "sdist")
115
+ chosen = ordered[0]
116
+ url = chosen.get("url")
117
+ if not isinstance(url, str):
118
+ raise IsolationError(f"pypi returned no URL for {name} {version}")
119
+
120
+ return Artefact(
121
+ filename=str(chosen.get("filename") or "package.tar.gz"),
122
+ data=_get(url, accept="application/octet-stream"),
123
+ source_url=url,
124
+ )
125
+
126
+
127
+ def _npm(package: str) -> Artefact:
128
+ name, version = _split(package, "@") if not package.startswith("@") else (package, None)
129
+ if package.startswith("@") and package.count("@") > 1:
130
+ name, _, version = package.rpartition("@")
131
+
132
+ document = json.loads(
133
+ _get(
134
+ f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@/')}",
135
+ accept="application/json",
136
+ )
137
+ )
138
+ dist_tags = _mapping(document.get("dist-tags"))
139
+ versions = _mapping(document.get("versions"))
140
+ latest = dist_tags.get("latest")
141
+ version = version or (latest if isinstance(latest, str) else None)
142
+
143
+ entry = _mapping(versions.get(version)) if version else {}
144
+ dist = _mapping(entry.get("dist"))
145
+ url = dist.get("tarball")
146
+ if not isinstance(url, str):
147
+ raise IsolationError(f"npm has no tarball for {name} {version}")
148
+
149
+ return Artefact(
150
+ filename=f"{name.replace('/', '-').lstrip('@')}-{version}.tgz",
151
+ data=_get(url, accept="application/octet-stream"),
152
+ source_url=url,
153
+ )
154
+
155
+
156
+ __all__ = ["MAX_ARTEFACT_BYTES", "TIMEOUT_SECONDS", "Artefact", "fetch"]
@@ -0,0 +1,228 @@
1
+ """Establishing isolation, or refusing to proceed.
2
+
3
+ This module answers one question: is there somewhere safe to run this? Every
4
+ other part of the component depends on the answer being honest, so the failure
5
+ mode is chosen carefully -- it raises rather than returning a degraded option.
6
+
7
+ **Why refusal is the feature.** The person running this has decided to execute a
8
+ package they do not trust. If the isolation they think they have is not there,
9
+ running it anyway harms them more than not running it at all, because they will
10
+ read the result as "it did nothing" rather than "it ran on my laptop". So a
11
+ missing backend is an error, not a warning, and there is no flag to override it.
12
+
13
+ **What counts as isolation here.** A container runtime with the network removed,
14
+ no host filesystem mounted, dropped capabilities, no-new-privileges, a process
15
+ ceiling and a memory ceiling. That is weaker than a microVM and it is stated as
16
+ such: a container boundary is a kernel boundary, and a kernel exploit crosses
17
+ it. It is what is available on an ordinary developer machine and a CI runner
18
+ without privileged setup, and the alternative -- offering nothing until a
19
+ hypervisor is present -- would mean the residual never gets looked at.
20
+
21
+ **Except where a stronger boundary is already installed.** gVisor's `runsc`
22
+ intercepts the guest's syscalls in userspace and serves them from its own
23
+ kernel, so the host kernel sees a small, fixed surface rather than the whole
24
+ syscall table. Where it is configured in the runtime this uses it, because the
25
+ cost is one flag and the difference is the class of bug that gets you out. It
26
+ is not required -- demanding it would put this back to running on nothing --
27
+ and which boundary was actually used is recorded with the result, since a
28
+ reader deciding what an observation is worth needs to know which one it was.
29
+
30
+ **The container filesystem is writable, and that is deliberate.** The first
31
+ version made the root read-only, which reads as stronger and was in fact
32
+ useless: a payload that writes to `/etc/cron.d` simply failed, and the run
33
+ reported "the install failed" rather than "it tried to persist". A sandbox that
34
+ prevents the behaviour it exists to observe has been configured into
35
+ uselessness. Nothing on the host is reachable either way -- that is what the
36
+ absent mounts do -- and the container is destroyed when the run ends.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import shutil
42
+ import subprocess
43
+ from dataclasses import dataclass
44
+
45
+ RUNTIMES = ("podman", "docker")
46
+ """Runtimes tried in order.
47
+
48
+ Podman first because it runs rootless by default, so a container escape lands as
49
+ an unprivileged user rather than as root. Where both are present that is the
50
+ better default, and where only Docker is present it is still isolation."""
51
+
52
+ PROBE_TIMEOUT = 10.0
53
+ """How long to wait for a runtime to say it is working.
54
+
55
+ A runtime that is installed but whose daemon is not running would otherwise hang
56
+ the check, and a hang here is indistinguishable to the user from a sandbox that
57
+ is thinking."""
58
+
59
+
60
+ class IsolationError(RuntimeError):
61
+ """Isolation could not be established, so nothing was run.
62
+
63
+ Deliberately not recoverable by the caller into "run it anyway". There is no
64
+ degraded mode: the whole value of this component is that the code ran
65
+ somewhere it could not reach anything.
66
+ """
67
+
68
+
69
+ GVISOR_RUNTIME = "runsc"
70
+ """gVisor's OCI runtime, as `--runtime` names it."""
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Backend:
75
+ """A runtime that can host the analysis, and what it guarantees."""
76
+
77
+ command: str
78
+ version: str
79
+ rootless: bool
80
+
81
+ runtime: str | None = None
82
+ """An OCI runtime to ask for by name, when one stronger than the default is
83
+ configured. `None` means the runtime's own default, which is `runc`."""
84
+
85
+ traces_syscalls: bool = False
86
+ """Whether the run can be traced.
87
+
88
+ Recorded on the backend rather than assumed, because tracing needs
89
+ `CAP_SYS_PTRACE` and a seccomp profile that permits `ptrace`, and a host
90
+ that refuses either produces a run with no trace. The difference between
91
+ "nothing called execve" and "nothing was watching" is the difference this
92
+ whole project is built around, so it is carried rather than inferred."""
93
+
94
+ @property
95
+ def guarantees(self) -> tuple[str, ...]:
96
+ """What this backend does and does not promise, in the report's words.
97
+
98
+ Carried with the result rather than described in documentation, because
99
+ a reader deciding what an observation is worth needs to know what the
100
+ boundary was -- and the boundary differs between a rootless and a
101
+ root-owned runtime.
102
+ """
103
+ common: tuple[str, ...] = (
104
+ "no network interface",
105
+ "no host filesystem mounted",
106
+ "container filesystem is writable but discarded afterwards",
107
+ "all Linux capabilities dropped",
108
+ "no new privileges",
109
+ "process and memory ceilings",
110
+ )
111
+ if self.runtime == GVISOR_RUNTIME:
112
+ common = (
113
+ *common,
114
+ "gVisor: the guest's syscalls are served by a userspace kernel, "
115
+ "so the host kernel sees a small fixed surface rather than the "
116
+ "whole syscall table",
117
+ )
118
+ else:
119
+ common = (
120
+ *common,
121
+ "the host kernel is the boundary: a kernel exploit crosses it, "
122
+ "which a virtual machine or gVisor would not allow",
123
+ )
124
+
125
+ common = (
126
+ *common,
127
+ "syscalls traced: execve and connect are recorded"
128
+ if self.traces_syscalls
129
+ else "syscalls are NOT traced: what the install executed and what "
130
+ "it tried to reach were not observed",
131
+ )
132
+
133
+ if self.rootless:
134
+ return (*common, "runtime is rootless: an escape lands unprivileged")
135
+ return (
136
+ *common,
137
+ "runtime is root-owned: an escape lands as root, which is weaker "
138
+ "than a rootless runtime and much weaker than a virtual machine",
139
+ )
140
+
141
+
142
+ def available_backend() -> Backend:
143
+ """The first working runtime, or `IsolationError` if there is none."""
144
+ tried: list[str] = []
145
+
146
+ for runtime in RUNTIMES:
147
+ path = shutil.which(runtime)
148
+ if path is None:
149
+ tried.append(f"{runtime}: not installed")
150
+ continue
151
+ try:
152
+ probe = subprocess.run( # noqa: S603 (fixed argv, resolved path)
153
+ [path, "version", "--format", "{{.Client.Version}}"],
154
+ capture_output=True,
155
+ text=True,
156
+ timeout=PROBE_TIMEOUT,
157
+ check=False,
158
+ )
159
+ except (OSError, subprocess.SubprocessError) as exc:
160
+ tried.append(f"{runtime}: {type(exc).__name__}")
161
+ continue
162
+
163
+ if probe.returncode != 0:
164
+ detail = (probe.stderr or probe.stdout or "").strip().splitlines()
165
+ tried.append(f"{runtime}: not usable ({detail[0] if detail else 'no output'})")
166
+ continue
167
+
168
+ return Backend(
169
+ command=path,
170
+ version=(probe.stdout or "").strip() or "unknown",
171
+ rootless=runtime == "podman",
172
+ runtime=GVISOR_RUNTIME if _has_gvisor(path) else None,
173
+ )
174
+
175
+ raise IsolationError(
176
+ "no container runtime is available, so the package was not run. "
177
+ "Install podman (preferred, rootless) or docker. This does not fall "
178
+ "back to running the code on this machine: a sandbox that degrades to "
179
+ "no sandbox is worse than none, because you would read the result as "
180
+ "'it did nothing'.\n " + "\n ".join(tried)
181
+ )
182
+
183
+
184
+ def _has_gvisor(command: str) -> bool:
185
+ """Whether this runtime has gVisor configured as an OCI runtime.
186
+
187
+ Asked of the runtime rather than of `PATH`. `runsc` sitting in a directory
188
+ the daemon does not know about is not a runtime that can be selected, and
189
+ passing `--runtime runsc` on that host fails the container creation --
190
+ which would turn "a stronger boundary is available" into "nothing ran".
191
+
192
+ **Every failure is "no".** `docker info` talks to the daemon, so on a
193
+ machine where Docker is installed and not running it hangs until the
194
+ timeout and raises -- and this is called from `available_backend`, whose
195
+ entire job is to answer that situation with a sentence rather than a
196
+ traceback. It went unhandled: a user with Docker installed and stopped got
197
+ `subprocess.TimeoutExpired` out of `cordon-sandbox` instead of the refusal
198
+ that explains what to install. Windows CI, where the daemon is absent, is
199
+ what surfaced it.
200
+
201
+ Refusing to answer is also the safe direction. A runtime that cannot say
202
+ whether it has gVisor is used with its default runtime, which is what would
203
+ have happened anyway.
204
+ """
205
+ try:
206
+ probe = subprocess.run( # noqa: S603 (fixed argv, resolved path)
207
+ [command, "info", "--format", "{{.Runtimes}}"],
208
+ capture_output=True,
209
+ text=True,
210
+ timeout=PROBE_TIMEOUT,
211
+ check=False,
212
+ )
213
+ except (OSError, subprocess.SubprocessError):
214
+ return False
215
+
216
+ if probe.returncode != 0:
217
+ return False
218
+ return GVISOR_RUNTIME in (probe.stdout or "")
219
+
220
+
221
+ __all__ = [
222
+ "GVISOR_RUNTIME",
223
+ "PROBE_TIMEOUT",
224
+ "RUNTIMES",
225
+ "Backend",
226
+ "IsolationError",
227
+ "available_backend",
228
+ ]