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.
- cordon_sandbox/__init__.py +29 -0
- cordon_sandbox/cli.py +151 -0
- cordon_sandbox/fetch.py +156 -0
- cordon_sandbox/isolation.py +228 -0
- cordon_sandbox/observe.py +611 -0
- cordon_sandbox/py.typed +0 -0
- cordon_scanner/__init__.py +236 -0
- cordon_scanner/__main__.py +15 -0
- cordon_scanner/archive/__init__.py +0 -0
- cordon_scanner/archive/safe.py +641 -0
- cordon_scanner/cli/__init__.py +0 -0
- cordon_scanner/cli/__main__.py +6 -0
- cordon_scanner/cli/main.py +1342 -0
- cordon_scanner/cli/progress.py +297 -0
- cordon_scanner/core/__init__.py +0 -0
- cordon_scanner/core/audit.py +182 -0
- cordon_scanner/core/bundle.py +267 -0
- cordon_scanner/core/cache.py +631 -0
- cordon_scanner/core/closure.py +137 -0
- cordon_scanner/core/config.py +1891 -0
- cordon_scanner/core/content.py +607 -0
- cordon_scanner/core/distribution.py +153 -0
- cordon_scanner/core/engine.py +2065 -0
- cordon_scanner/core/errors.py +123 -0
- cordon_scanner/core/guard.py +523 -0
- cordon_scanner/core/limits.py +239 -0
- cordon_scanner/core/models.py +1215 -0
- cordon_scanner/core/parallel.py +411 -0
- cordon_scanner/core/paths.py +54 -0
- cordon_scanner/core/policy.py +484 -0
- cordon_scanner/core/progress.py +83 -0
- cordon_scanner/core/prose.py +51 -0
- cordon_scanner/core/redact.py +295 -0
- cordon_scanner/core/registry.py +251 -0
- cordon_scanner/core/scoring.py +289 -0
- cordon_scanner/core/taxonomy.py +306 -0
- cordon_scanner/core/walker.py +845 -0
- cordon_scanner/detect/__init__.py +0 -0
- cordon_scanner/detect/advisory.py +181 -0
- cordon_scanner/detect/attestation.py +213 -0
- cordon_scanner/detect/base.py +364 -0
- cordon_scanner/detect/binary.py +506 -0
- cordon_scanner/detect/capability.py +727 -0
- cordon_scanner/detect/catalogue.py +81 -0
- cordon_scanner/detect/config_files.py +977 -0
- cordon_scanner/detect/dependency.py +732 -0
- cordon_scanner/detect/embedded.py +199 -0
- cordon_scanner/detect/lockfile.py +290 -0
- cordon_scanner/detect/manifest.py +449 -0
- cordon_scanner/detect/obfuscation.py +623 -0
- cordon_scanner/detect/pyast.py +454 -0
- cordon_scanner/detect/registry.py +563 -0
- cordon_scanner/detect/sbom.py +265 -0
- cordon_scanner/detect/secrets.py +1065 -0
- cordon_scanner/detect/vcs.py +269 -0
- cordon_scanner/ecosystems/base.py +402 -0
- cordon_scanner/ecosystems/npm.py +368 -0
- cordon_scanner/ecosystems/others.py +705 -0
- cordon_scanner/ecosystems/pypi.py +483 -0
- cordon_scanner/ecosystems/registry.py +201 -0
- cordon_scanner/intel/__init__.py +0 -0
- cordon_scanner/intel/advisories.py +269 -0
- cordon_scanner/intel/hosts.py +264 -0
- cordon_scanner/intel/popular.py +387 -0
- cordon_scanner/intel/registry_client.py +344 -0
- cordon_scanner/langs/__init__.py +0 -0
- cordon_scanner/langs/registry.py +180 -0
- cordon_scanner/py.typed +0 -0
- cordon_scanner/report/__init__.py +0 -0
- cordon_scanner/report/base.py +228 -0
- cordon_scanner/report/github.py +97 -0
- cordon_scanner/report/json_.py +53 -0
- cordon_scanner/report/junit.py +114 -0
- cordon_scanner/report/markdown.py +114 -0
- cordon_scanner/report/sarif.py +283 -0
- cordon_scanner/report/text.py +427 -0
- cordon_scanner/rules/__init__.py +0 -0
- cordon_scanner/rules/builtin/__init__.py +0 -0
- cordon_scanner/rules/builtin/capabilities-build.yaml +479 -0
- cordon_scanner/rules/builtin/capabilities-dotnet.yaml +228 -0
- cordon_scanner/rules/builtin/capabilities-javascript.yaml +270 -0
- cordon_scanner/rules/builtin/capabilities-jvm-go-ruby.yaml +636 -0
- cordon_scanner/rules/builtin/capabilities-malware-iocs.yaml +616 -0
- cordon_scanner/rules/builtin/capabilities-python.yaml +308 -0
- cordon_scanner/rules/builtin/capabilities-shell.yaml +305 -0
- cordon_scanner/rules/builtin/composites.yaml +701 -0
- cordon_scanner/rules/loader.py +1350 -0
- cordon_scanner/sources/__init__.py +0 -0
- cordon_scanner/sources/base.py +152 -0
- cordon_scanner/sources/git.py +700 -0
- cordon_scanner/version.py +54 -0
- cordon_scanner-0.1.0.dist-info/METADATA +657 -0
- cordon_scanner-0.1.0.dist-info/RECORD +98 -0
- cordon_scanner-0.1.0.dist-info/WHEEL +5 -0
- cordon_scanner-0.1.0.dist-info/entry_points.txt +26 -0
- cordon_scanner-0.1.0.dist-info/licenses/LICENSE +202 -0
- cordon_scanner-0.1.0.dist-info/licenses/NOTICE +26 -0
- 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())
|
cordon_sandbox/fetch.py
ADDED
|
@@ -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
|
+
]
|