coldrun 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.
- coldrun/__init__.py +12 -0
- coldrun/cli.py +53 -0
- coldrun/config.py +29 -0
- coldrun/detect.py +223 -0
- coldrun/doctor.py +208 -0
- coldrun/fetch/__init__.py +82 -0
- coldrun/fetch/base.py +137 -0
- coldrun/fetch/local.py +46 -0
- coldrun/fetch/url.py +136 -0
- coldrun/pipeline.py +1 -0
- coldrun/py.typed +0 -0
- coldrun/report.py +195 -0
- coldrun/runners/__init__.py +15 -0
- coldrun/runners/base.py +97 -0
- coldrun/runners/registry.py +125 -0
- coldrun/sandbox/__init__.py +29 -0
- coldrun/sandbox/base.py +219 -0
- coldrun/sandbox/bwrap.py +226 -0
- coldrun/vet/__init__.py +1 -0
- coldrun/vet/findings.py +66 -0
- coldrun-0.1.0.dist-info/METADATA +53 -0
- coldrun-0.1.0.dist-info/RECORD +25 -0
- coldrun-0.1.0.dist-info/WHEEL +4 -0
- coldrun-0.1.0.dist-info/entry_points.txt +2 -0
- coldrun-0.1.0.dist-info/licenses/LICENSE +21 -0
coldrun/fetch/base.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Shared pieces of the fetch step: errors, the fetched-source record and the quarantine.
|
|
2
|
+
|
|
3
|
+
A quarantine directory holds exactly one fetched source plus a ``source.json``
|
|
4
|
+
describing where it came from. It is created mode 0700 and nothing in it is
|
|
5
|
+
ever imported or executed by the host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
#: Largest script or archive coldrun will fetch, in bytes.
|
|
21
|
+
MAX_SOURCE_BYTES = 64 * 1024 * 1024
|
|
22
|
+
_FILENAME_OK = re.compile(r"[^A-Za-z0-9._+-]+")
|
|
23
|
+
_HASH_PREFIX = 16
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FetchError(Exception):
|
|
27
|
+
"""A source could not be fetched.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
what: What failed, in one sentence.
|
|
31
|
+
hint: The next command or action the user should try.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, what: str, hint: str) -> None:
|
|
35
|
+
"""Keep both halves so the report can show them separately."""
|
|
36
|
+
super().__init__(f"{what} {hint}".strip())
|
|
37
|
+
self.what = what
|
|
38
|
+
self.hint = hint
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Fetched:
|
|
43
|
+
"""A source that now sits in quarantine.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
kind: Fetcher that produced it, such as ``file`` or ``url``.
|
|
47
|
+
origin: The spec as the user gave it.
|
|
48
|
+
path: The quarantined file.
|
|
49
|
+
sha256: Hex digest of the file contents.
|
|
50
|
+
size: Size in bytes.
|
|
51
|
+
fetched_at: UTC timestamp in ISO 8601.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
kind: str
|
|
55
|
+
origin: str
|
|
56
|
+
path: Path
|
|
57
|
+
sha256: str
|
|
58
|
+
size: int
|
|
59
|
+
fetched_at: str
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def quarantine(self) -> Path:
|
|
63
|
+
"""The directory holding the file and its ``source.json``."""
|
|
64
|
+
return self.path.parent
|
|
65
|
+
|
|
66
|
+
def to_json(self) -> str:
|
|
67
|
+
"""Serialise for ``source.json``."""
|
|
68
|
+
data = asdict(self)
|
|
69
|
+
data["path"] = str(self.path)
|
|
70
|
+
return json.dumps(data, indent=2, sort_keys=True) + "\n"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Fetcher(Protocol):
|
|
74
|
+
"""One way of obtaining a source."""
|
|
75
|
+
|
|
76
|
+
kind: str
|
|
77
|
+
|
|
78
|
+
def matches(self, spec: str) -> bool:
|
|
79
|
+
"""True when this fetcher understands ``spec``."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def fetch(self, spec: str, dest: Path) -> Path:
|
|
83
|
+
"""Place the source inside ``dest`` and return the file path.
|
|
84
|
+
|
|
85
|
+
``dest`` already exists, is empty and is private to this fetch.
|
|
86
|
+
Raise :class:`FetchError` on any failure.
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def safe_filename(name: str, default: str = "script") -> str:
|
|
92
|
+
"""Reduce an arbitrary name to something safe to create in quarantine."""
|
|
93
|
+
base = Path(name).name
|
|
94
|
+
cleaned = _FILENAME_OK.sub("_", base).strip("._")
|
|
95
|
+
if not cleaned or cleaned in {".", ".."}:
|
|
96
|
+
return default
|
|
97
|
+
return cleaned[:128]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sha256_of(path: Path) -> str:
|
|
101
|
+
"""Hex SHA-256 of a file, streamed."""
|
|
102
|
+
digest = hashlib.sha256()
|
|
103
|
+
with path.open("rb") as fh:
|
|
104
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
105
|
+
digest.update(chunk)
|
|
106
|
+
return digest.hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def new_quarantine(root: Path) -> Path:
|
|
110
|
+
"""Create a fresh private directory under ``root`` and return it."""
|
|
111
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
root.chmod(stat.S_IRWXU)
|
|
113
|
+
path = Path(tempfile.mkdtemp(prefix="q-", dir=root))
|
|
114
|
+
path.chmod(stat.S_IRWXU)
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def seal(kind: str, origin: str, file: Path) -> Fetched:
|
|
119
|
+
"""Hash the fetched file, make it read-only and write ``source.json``."""
|
|
120
|
+
size = file.stat().st_size
|
|
121
|
+
if size > MAX_SOURCE_BYTES:
|
|
122
|
+
file.unlink()
|
|
123
|
+
raise FetchError(
|
|
124
|
+
f"{origin} is {size} bytes, above the {MAX_SOURCE_BYTES} byte limit.",
|
|
125
|
+
"Fetch a smaller artefact or extract the one script you need.",
|
|
126
|
+
)
|
|
127
|
+
file.chmod(stat.S_IRUSR)
|
|
128
|
+
record = Fetched(
|
|
129
|
+
kind=kind,
|
|
130
|
+
origin=origin,
|
|
131
|
+
path=file,
|
|
132
|
+
sha256=sha256_of(file),
|
|
133
|
+
size=size,
|
|
134
|
+
fetched_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
135
|
+
)
|
|
136
|
+
(file.parent / "source.json").write_text(record.to_json())
|
|
137
|
+
return record
|
coldrun/fetch/local.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Fetch a script that is already on disk by copying it into quarantine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from coldrun.fetch.base import FetchError, safe_filename
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LocalFetcher:
|
|
12
|
+
"""Copies a regular file. Directories and archives come later."""
|
|
13
|
+
|
|
14
|
+
kind = "file"
|
|
15
|
+
|
|
16
|
+
def matches(self, spec: str) -> bool:
|
|
17
|
+
"""Anything that is not a URL scheme is treated as a path."""
|
|
18
|
+
return "://" not in spec
|
|
19
|
+
|
|
20
|
+
def fetch(self, spec: str, dest: Path) -> Path:
|
|
21
|
+
"""Copy ``spec`` into ``dest`` without following it anywhere surprising."""
|
|
22
|
+
src = Path(spec).expanduser()
|
|
23
|
+
if not src.exists():
|
|
24
|
+
raise FetchError(
|
|
25
|
+
f"{spec} does not exist.",
|
|
26
|
+
"Check the path, or pass a URL such as https://… to fetch it.",
|
|
27
|
+
)
|
|
28
|
+
if src.is_dir():
|
|
29
|
+
raise FetchError(
|
|
30
|
+
f"{spec} is a directory.",
|
|
31
|
+
"Point coldrun at the script inside it; repos arrive with the GitHub fetcher.",
|
|
32
|
+
)
|
|
33
|
+
resolved = src.resolve()
|
|
34
|
+
if not resolved.is_file():
|
|
35
|
+
raise FetchError(
|
|
36
|
+
f"{spec} is not a regular file.",
|
|
37
|
+
"coldrun fetches scripts and archives only.",
|
|
38
|
+
)
|
|
39
|
+
target = dest / safe_filename(src.name)
|
|
40
|
+
try:
|
|
41
|
+
shutil.copyfile(resolved, target, follow_symlinks=False)
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
raise FetchError(
|
|
44
|
+
f"Could not read {spec}: {exc.strerror}.", "Check permissions."
|
|
45
|
+
) from exc
|
|
46
|
+
return target
|
coldrun/fetch/url.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Fetch a script from a plain http or https URL.
|
|
2
|
+
|
|
3
|
+
Downloads stream to disk with a size cap. Redirects are followed only to
|
|
4
|
+
http or https, never to ``file://`` or anything else that could reach the
|
|
5
|
+
host, and the GitHub ``blob`` and gist page forms are rewritten to their raw
|
|
6
|
+
equivalents so the user can paste what the browser shows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from coldrun import __version__
|
|
18
|
+
from coldrun.fetch.base import MAX_SOURCE_BYTES, FetchError, safe_filename
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
TIMEOUT_SECONDS = 30
|
|
24
|
+
MAX_REDIRECTS = 5
|
|
25
|
+
_CHUNK = 256 * 1024
|
|
26
|
+
_GITHUB_BLOB = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$")
|
|
27
|
+
_GITHUB_RAW = re.compile(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _SafeRedirects(urllib.request.HTTPRedirectHandler):
|
|
31
|
+
"""Refuse redirects that leave http or https, and cap how many we follow.
|
|
32
|
+
|
|
33
|
+
The check runs before urllib's own handling so the user sees why the
|
|
34
|
+
fetch stopped instead of a bare ``HTTP 302``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def http_error_302(
|
|
38
|
+
self,
|
|
39
|
+
req: urllib.request.Request,
|
|
40
|
+
fp: Any, # noqa: ANN401 - urllib's own signature
|
|
41
|
+
code: int,
|
|
42
|
+
msg: str,
|
|
43
|
+
headers: Any, # noqa: ANN401
|
|
44
|
+
) -> Any: # noqa: ANN401
|
|
45
|
+
location = headers.get("location") or headers.get("uri") or ""
|
|
46
|
+
newurl = urllib.parse.urljoin(req.full_url, location)
|
|
47
|
+
scheme = urllib.parse.urlsplit(newurl).scheme
|
|
48
|
+
if scheme not in {"http", "https"}:
|
|
49
|
+
raise FetchError(
|
|
50
|
+
f"Redirect to {scheme}:// refused.",
|
|
51
|
+
"Only http and https destinations are followed.",
|
|
52
|
+
)
|
|
53
|
+
hops = sum(getattr(req, "redirect_dict", {}).values())
|
|
54
|
+
if hops >= MAX_REDIRECTS:
|
|
55
|
+
raise FetchError("Too many redirects.", "Fetch the final URL directly.")
|
|
56
|
+
return super().http_error_302(req, fp, code, msg, headers)
|
|
57
|
+
|
|
58
|
+
http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_raw(url: str) -> str:
|
|
62
|
+
"""Turn a GitHub ``blob`` page URL into its raw download URL."""
|
|
63
|
+
if m := _GITHUB_BLOB.match(url):
|
|
64
|
+
owner, repo, ref, path = m.groups()
|
|
65
|
+
return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}"
|
|
66
|
+
return url
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def filename_for(url: str) -> str:
|
|
70
|
+
"""Pick the quarantine file name from the URL path."""
|
|
71
|
+
return safe_filename(urllib.parse.unquote(urllib.parse.urlsplit(url).path))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UrlFetcher:
|
|
75
|
+
"""Plain downloads. Repos, gists and exploit-db have their own fetchers."""
|
|
76
|
+
|
|
77
|
+
kind = "url"
|
|
78
|
+
|
|
79
|
+
def __init__(self, opener: urllib.request.OpenerDirector | None = None) -> None:
|
|
80
|
+
"""Use ``opener`` for requests; tests inject one, callers leave it."""
|
|
81
|
+
self._opener = opener or urllib.request.build_opener(_SafeRedirects())
|
|
82
|
+
|
|
83
|
+
def matches(self, spec: str) -> bool:
|
|
84
|
+
"""Accept http and https only; other schemes are refused by :func:`fetch`."""
|
|
85
|
+
return spec.startswith(("http://", "https://"))
|
|
86
|
+
|
|
87
|
+
def fetch(self, spec: str, dest: Path) -> Path:
|
|
88
|
+
"""Download ``spec`` into ``dest`` with a size cap and a timeout."""
|
|
89
|
+
url = to_raw(spec)
|
|
90
|
+
target = dest / filename_for(url)
|
|
91
|
+
req = urllib.request.Request( # noqa: S310 - scheme checked by matches()
|
|
92
|
+
url, headers={"User-Agent": f"coldrun/{__version__}"}
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
with self._opener.open(req, timeout=TIMEOUT_SECONDS) as resp:
|
|
96
|
+
declared = resp.headers.get("Content-Length")
|
|
97
|
+
if declared and int(declared) > MAX_SOURCE_BYTES:
|
|
98
|
+
raise FetchError(
|
|
99
|
+
f"{url} is {declared} bytes, above the {MAX_SOURCE_BYTES} byte limit.",
|
|
100
|
+
"Fetch a smaller artefact.",
|
|
101
|
+
)
|
|
102
|
+
self._stream(resp, target, url)
|
|
103
|
+
except urllib.error.HTTPError as exc:
|
|
104
|
+
target.unlink(missing_ok=True)
|
|
105
|
+
if 300 <= exc.code < 400: # noqa: PLR2004 - HTTP redirect class
|
|
106
|
+
# urllib gives up on loops and repeated hops with a 3xx error.
|
|
107
|
+
raise FetchError("Too many redirects.", "Fetch the final URL directly.") from exc
|
|
108
|
+
raise FetchError(
|
|
109
|
+
f"{url} returned HTTP {exc.code}.",
|
|
110
|
+
"Check the URL in a browser; for private repos use a local clone.",
|
|
111
|
+
) from exc
|
|
112
|
+
except urllib.error.URLError as exc:
|
|
113
|
+
target.unlink(missing_ok=True)
|
|
114
|
+
raise FetchError(
|
|
115
|
+
f"Could not reach {url}: {exc.reason}.",
|
|
116
|
+
"Check the host name and your network, then retry.",
|
|
117
|
+
) from exc
|
|
118
|
+
except TimeoutError as exc:
|
|
119
|
+
target.unlink(missing_ok=True)
|
|
120
|
+
raise FetchError(f"{url} timed out after {TIMEOUT_SECONDS}s.", "Retry later.") from exc
|
|
121
|
+
return target
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _stream(resp: Any, target: Path, url: str) -> None: # noqa: ANN401 - http.client response
|
|
125
|
+
written = 0
|
|
126
|
+
with target.open("wb") as out:
|
|
127
|
+
while chunk := resp.read(_CHUNK):
|
|
128
|
+
written += len(chunk)
|
|
129
|
+
if written > MAX_SOURCE_BYTES:
|
|
130
|
+
out.close()
|
|
131
|
+
target.unlink(missing_ok=True)
|
|
132
|
+
raise FetchError(
|
|
133
|
+
f"{url} exceeded the {MAX_SOURCE_BYTES} byte limit while downloading.",
|
|
134
|
+
"Fetch a smaller artefact.",
|
|
135
|
+
)
|
|
136
|
+
out.write(chunk)
|
coldrun/pipeline.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The eight pipeline steps: fetch, detect, resolve, vet, decide, build, run, report."""
|
coldrun/py.typed
ADDED
|
File without changes
|
coldrun/report.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Render what ran, what was blocked and the exit code.
|
|
2
|
+
|
|
3
|
+
Human output goes through Rich to stderr-safe consoles; the JSON form
|
|
4
|
+
arrives with the vetting milestone. Errors from any pipeline step are shown
|
|
5
|
+
the same way: what failed, then the next command to try.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import IntEnum
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from coldrun.vet.findings import Severity
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
from coldrun.detect import Detection
|
|
24
|
+
from coldrun.fetch.base import Fetched
|
|
25
|
+
from coldrun.runners.base import DepSet
|
|
26
|
+
from coldrun.vet.findings import Finding
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExitCode(IntEnum):
|
|
30
|
+
"""Exit codes coldrun uses for its own failures.
|
|
31
|
+
|
|
32
|
+
Anything else is the script's own exit code, passed through.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
OK = 0
|
|
36
|
+
REFUSED = 100
|
|
37
|
+
SANDBOX_FAILED = 101
|
|
38
|
+
DEPS_FAILED = 102
|
|
39
|
+
USAGE = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class UserFacingError(Protocol):
|
|
44
|
+
"""Errors that carry a one-sentence cause and a next step."""
|
|
45
|
+
|
|
46
|
+
what: str
|
|
47
|
+
hint: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class Report:
|
|
52
|
+
"""Everything the user should know about one run.
|
|
53
|
+
|
|
54
|
+
Fields are filled in as the pipeline advances, so a report rendered after
|
|
55
|
+
a failure shows how far it got.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
source: Fetched | None = None
|
|
59
|
+
detection: Detection | None = None
|
|
60
|
+
deps: DepSet | None = None
|
|
61
|
+
findings: list[Finding] = field(default_factory=list)
|
|
62
|
+
verdict: str | None = None
|
|
63
|
+
backend: str | None = None
|
|
64
|
+
net: str = "none"
|
|
65
|
+
blocked: list[str] = field(default_factory=list)
|
|
66
|
+
exit_code: int | None = None
|
|
67
|
+
out_dir: str | None = None
|
|
68
|
+
wrote: list[str] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def max_severity(self) -> Severity | None:
|
|
72
|
+
"""Highest severity among the findings, or ``None``."""
|
|
73
|
+
if not self.findings:
|
|
74
|
+
return None
|
|
75
|
+
return max((f.severity for f in self.findings), key=lambda s: s.rank)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_SEVERITY_STYLE = {
|
|
79
|
+
Severity.INFO: "dim",
|
|
80
|
+
Severity.LOW: "green",
|
|
81
|
+
Severity.MEDIUM: "yellow",
|
|
82
|
+
Severity.HIGH: "bold red",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_blocked(net: str) -> list[str]:
|
|
87
|
+
"""The protections every run gets, phrased for the report."""
|
|
88
|
+
blocked = [
|
|
89
|
+
"home directory, SSH keys, browser profiles, cloud credentials",
|
|
90
|
+
"Docker socket and D-Bus session",
|
|
91
|
+
"writes outside /work and /tmp",
|
|
92
|
+
"capabilities and privilege escalation",
|
|
93
|
+
]
|
|
94
|
+
if net == "none":
|
|
95
|
+
blocked.insert(0, "all network access")
|
|
96
|
+
elif net == "allow":
|
|
97
|
+
blocked.insert(0, "network except --allow-host destinations")
|
|
98
|
+
return blocked
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def make_console(*, stderr: bool = False, force_plain: bool = False) -> Console:
|
|
102
|
+
"""A console for reports; ``force_plain`` disables colour and width tricks for tests."""
|
|
103
|
+
if force_plain:
|
|
104
|
+
return Console(stderr=stderr, color_system=None, width=100, highlight=False)
|
|
105
|
+
return Console(stderr=stderr, highlight=False)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render(report: Report, console: Console) -> None:
|
|
109
|
+
"""Print the run summary."""
|
|
110
|
+
table = Table(show_header=False, box=None, pad_edge=False, padding=(0, 1))
|
|
111
|
+
table.add_column("key", style="bold", no_wrap=True)
|
|
112
|
+
table.add_column("value")
|
|
113
|
+
|
|
114
|
+
if report.source is not None:
|
|
115
|
+
table.add_row("source", f"{report.source.origin} ({report.source.kind})")
|
|
116
|
+
table.add_row("sha256", report.source.sha256)
|
|
117
|
+
if report.detection is not None:
|
|
118
|
+
det = report.detection
|
|
119
|
+
why = ", ".join(det.reasons) or "runner score"
|
|
120
|
+
table.add_row("runner", f"{det.runner.name} ({det.confidence:.0%}: {why})")
|
|
121
|
+
if report.deps is not None:
|
|
122
|
+
table.add_row("dependencies", _format_deps(report.deps))
|
|
123
|
+
if report.findings:
|
|
124
|
+
table.add_row("findings", _format_findings(report.findings))
|
|
125
|
+
if report.verdict is not None:
|
|
126
|
+
table.add_row("verdict", report.verdict)
|
|
127
|
+
if report.backend is not None:
|
|
128
|
+
table.add_row("sandbox", f"{report.backend}, network {report.net}")
|
|
129
|
+
if report.blocked:
|
|
130
|
+
table.add_row("blocked", "\n".join(report.blocked))
|
|
131
|
+
if report.wrote:
|
|
132
|
+
where = f" -> {report.out_dir}" if report.out_dir else ""
|
|
133
|
+
table.add_row("wrote", "\n".join(report.wrote) + where)
|
|
134
|
+
if report.exit_code is not None:
|
|
135
|
+
table.add_row("exit code", _format_exit(report.exit_code))
|
|
136
|
+
console.print(table)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def render_error(err: BaseException, console: Console) -> None:
|
|
140
|
+
"""Print a failure as *what failed* then *next: what to try*."""
|
|
141
|
+
if isinstance(err, UserFacingError):
|
|
142
|
+
console.print(Text(err.what, style="bold red"))
|
|
143
|
+
console.print(Text(f"next: {err.hint}", style="yellow"))
|
|
144
|
+
else:
|
|
145
|
+
console.print(Text(f"{type(err).__name__}: {err}", style="bold red"))
|
|
146
|
+
console.print(Text("next: rerun with COLDRUN_DEBUG=1 and open an issue.", style="yellow"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def exit_code_for(err: BaseException) -> int:
|
|
150
|
+
"""Map a pipeline error to the exit code the CLI should use."""
|
|
151
|
+
code = getattr(err, "exit_code", None)
|
|
152
|
+
if isinstance(code, int):
|
|
153
|
+
return code
|
|
154
|
+
by_name = {
|
|
155
|
+
"FetchError": ExitCode.USAGE,
|
|
156
|
+
"DetectError": ExitCode.USAGE,
|
|
157
|
+
"SandboxError": ExitCode.SANDBOX_FAILED,
|
|
158
|
+
"DepsError": ExitCode.DEPS_FAILED,
|
|
159
|
+
"RefusedError": ExitCode.REFUSED,
|
|
160
|
+
}
|
|
161
|
+
return int(by_name.get(type(err).__name__, 1))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _format_deps(deps: DepSet) -> str:
|
|
165
|
+
parts: list[str] = []
|
|
166
|
+
if deps.interpreter:
|
|
167
|
+
parts.append(deps.interpreter)
|
|
168
|
+
if deps.packages:
|
|
169
|
+
eco = f"{deps.ecosystem}: " if deps.ecosystem else ""
|
|
170
|
+
parts.append(eco + ", ".join(deps.packages))
|
|
171
|
+
if deps.system_binaries:
|
|
172
|
+
parts.append("binaries: " + ", ".join(deps.system_binaries))
|
|
173
|
+
if not parts:
|
|
174
|
+
return "none"
|
|
175
|
+
return "; ".join(parts) + f" (from {deps.source})"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _format_findings(findings: Sequence[Finding]) -> Text:
|
|
179
|
+
text = Text()
|
|
180
|
+
for i, f in enumerate(sorted(findings, key=lambda x: -x.severity.rank)):
|
|
181
|
+
if i:
|
|
182
|
+
text.append("\n")
|
|
183
|
+
text.append(f"{f.severity.value:<6}", style=_SEVERITY_STYLE[f.severity])
|
|
184
|
+
text.append(f" {f.rule_id} {f.location()}: {f.message}")
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _format_exit(code: int) -> Text:
|
|
189
|
+
if code == 0:
|
|
190
|
+
return Text("0", style="green")
|
|
191
|
+
try:
|
|
192
|
+
label = ExitCode(code).name.lower().replace("_", " ")
|
|
193
|
+
except ValueError:
|
|
194
|
+
return Text(str(code), style="red")
|
|
195
|
+
return Text(f"{code} ({label})", style="red")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Language runners: one module per language plus the shared base contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from coldrun.runners.base import DepSet, Runner, RunnerInfo
|
|
6
|
+
from coldrun.runners.registry import Registry, RunnerNotFoundError, default_registry
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DepSet",
|
|
10
|
+
"Registry",
|
|
11
|
+
"Runner",
|
|
12
|
+
"RunnerInfo",
|
|
13
|
+
"RunnerNotFoundError",
|
|
14
|
+
"default_registry",
|
|
15
|
+
]
|
coldrun/runners/base.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The contract every language runner implements.
|
|
2
|
+
|
|
3
|
+
A runner knows how to recognise a script, work out what it needs, and produce
|
|
4
|
+
the commands that install those needs and run the script. The commands are
|
|
5
|
+
executed by the sandbox, never by the runner itself, and never on the host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from coldrun.vet.findings import Finding
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class DepSet:
|
|
21
|
+
"""Everything a script needs before it can run.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
ecosystem: Package registry the packages come from, such as ``pypi``
|
|
25
|
+
or ``npm``. Empty when there are no packages.
|
|
26
|
+
packages: Package requirement strings as the ecosystem spells them,
|
|
27
|
+
for example ``requests>=2.31``. Sorted and de-duplicated.
|
|
28
|
+
system_binaries: Command names the script expects on ``PATH``.
|
|
29
|
+
interpreter: Interpreter requirement, such as ``python>=3.10`` or
|
|
30
|
+
``bash``. ``None`` for prebuilt binaries.
|
|
31
|
+
source: Where the requirements came from, for the report: a manifest
|
|
32
|
+
file name or ``inferred``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
ecosystem: str = ""
|
|
36
|
+
packages: tuple[str, ...] = ()
|
|
37
|
+
system_binaries: tuple[str, ...] = ()
|
|
38
|
+
interpreter: str | None = None
|
|
39
|
+
source: str = "inferred"
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
"""Normalise ordering so equal needs compare and hash equal."""
|
|
43
|
+
object.__setattr__(self, "packages", tuple(sorted(set(self.packages))))
|
|
44
|
+
object.__setattr__(self, "system_binaries", tuple(sorted(set(self.system_binaries))))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_empty(self) -> bool:
|
|
48
|
+
"""True when nothing has to be installed."""
|
|
49
|
+
return not self.packages and not self.system_binaries
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class Runner(Protocol):
|
|
54
|
+
"""What a language plugin provides.
|
|
55
|
+
|
|
56
|
+
Runners are discovered through the ``coldrun.runners`` entry-point group.
|
|
57
|
+
Each entry point names a class with a no-argument constructor, or a
|
|
58
|
+
zero-argument factory, that yields an object satisfying this protocol.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
|
|
63
|
+
def detect(self, path: Path) -> float:
|
|
64
|
+
"""Return confidence from 0.0 to 1.0 that this runner owns ``path``.
|
|
65
|
+
|
|
66
|
+
Only inspect the bytes; never execute or import the file.
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def resolve_deps(self, path: Path) -> DepSet:
|
|
71
|
+
"""Work out what ``path`` needs, from a manifest or from the code."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
def install_cmd(self, deps: DepSet) -> list[str]:
|
|
75
|
+
"""Argument list that installs ``deps``. Runs inside the sandbox.
|
|
76
|
+
|
|
77
|
+
Return an empty list when nothing has to be installed.
|
|
78
|
+
"""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
def run_cmd(self, path: Path, args: list[str]) -> list[str]:
|
|
82
|
+
"""Argument list that runs ``path`` with ``args``. Runs inside the sandbox."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def vet(self, path: Path, deps: DepSet) -> list[Finding]:
|
|
86
|
+
"""Language-specific checks on the script and its dependencies."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class RunnerInfo:
|
|
92
|
+
"""A loaded runner together with where it came from."""
|
|
93
|
+
|
|
94
|
+
runner: Runner
|
|
95
|
+
entry_point: str
|
|
96
|
+
distribution: str | None = None
|
|
97
|
+
load_error: str | None = field(default=None, compare=False)
|