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.
- saferun/__init__.py +3 -0
- saferun/__main__.py +5 -0
- saferun/cli.py +533 -0
- saferun/dockerdiag.py +65 -0
- saferun/doctor.py +159 -0
- saferun/engine/__init__.py +7 -0
- saferun/engine/analyzer.py +30 -0
- saferun/engine/context.py +27 -0
- saferun/engine/engine.py +100 -0
- saferun/engine/model.py +146 -0
- saferun/engine/node.py +145 -0
- saferun/engine/osv.py +291 -0
- saferun/engine/osvdb.py +367 -0
- saferun/engine/paths.py +41 -0
- saferun/engine/pysyntax.py +135 -0
- saferun/engine/rules/python.yml +103 -0
- saferun/engine/semgrep.py +173 -0
- saferun/engine/tools.py +167 -0
- saferun/engine/verdict.py +47 -0
- saferun/hostos/__init__.py +188 -0
- saferun/hostos/_win.py +316 -0
- saferun/hostos/acl.py +172 -0
- saferun/hostos/fsops.py +41 -0
- saferun/hostos/process.py +82 -0
- saferun/hostos/winfs.py +164 -0
- saferun/runcmd.py +139 -0
- saferun/sandbox/__init__.py +11 -0
- saferun/sandbox/analyzer.py +192 -0
- saferun/sandbox/attestation.py +110 -0
- saferun/sandbox/config.py +121 -0
- saferun/sandbox/controls.py +104 -0
- saferun/sandbox/copy.py +338 -0
- saferun/sandbox/copy_windows.py +281 -0
- saferun/sandbox/cosign_tool.py +134 -0
- saferun/sandbox/docker.py +167 -0
- saferun/sandbox/images.py +110 -0
- saferun/sandbox/profiles.py +40 -0
- saferun/sandbox/reaper.py +127 -0
- saferun/sandbox/runner.py +268 -0
- saferun/sandbox/secrets.py +91 -0
- saferun/sandbox/signature.py +117 -0
- saferun/sandbox/verify.py +75 -0
- saferun/sandbox/workspace.py +92 -0
- saferun/setup.py +559 -0
- saferun/ui.py +133 -0
- saferun_cli-0.0.2.dist-info/METADATA +136 -0
- saferun_cli-0.0.2.dist-info/RECORD +51 -0
- saferun_cli-0.0.2.dist-info/WHEEL +5 -0
- saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
- saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
- saferun_cli-0.0.2.dist-info/top_level.txt +1 -0
saferun/doctor.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""`saferun doctor`: read-only diagnostics with PASS / WARNING / FAIL per check. It changes nothing and installs nothing.
|
|
2
|
+
|
|
3
|
+
Every verdict about an image, the analyzer or the OSV data comes from the same verification `saferun status` and every scan use
|
|
4
|
+
(`setup.component_status`); doctor adds the surrounding facts (Python, OS, Docker state, cosign, directories) and a fix for each problem.
|
|
5
|
+
A check that cannot be carried out is a FAIL or WARNING, never a PASS.
|
|
6
|
+
Exit status: 0 = no FAIL (warnings allowed), 1 = at least one FAIL.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import dockerdiag, hostos
|
|
19
|
+
from . import setup as st
|
|
20
|
+
from .engine import osvdb
|
|
21
|
+
from .engine.model import clean_text
|
|
22
|
+
from .sandbox import SandboxError
|
|
23
|
+
from .sandbox import cosign_tool
|
|
24
|
+
from .sandbox import images as im
|
|
25
|
+
from .sandbox import signature as sig
|
|
26
|
+
|
|
27
|
+
PASS, WARN, FAIL = "PASS", "WARNING", "FAIL"
|
|
28
|
+
MIN_PYTHON = (3, 12)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Check:
|
|
33
|
+
name: str
|
|
34
|
+
level: str
|
|
35
|
+
detail: str
|
|
36
|
+
fix: str = ""
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {"name": self.name, "level": self.level, "detail": self.detail, "fix": self.fix}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _version() -> str:
|
|
43
|
+
from . import __version__
|
|
44
|
+
try:
|
|
45
|
+
installed = metadata.version("saferun-cli")
|
|
46
|
+
except metadata.PackageNotFoundError:
|
|
47
|
+
return f"{__version__} (not installed as a package)"
|
|
48
|
+
return installed if installed == __version__ else f"{__version__} (package metadata says {installed})"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_install() -> Check:
|
|
52
|
+
p = Path(__file__).resolve().parent
|
|
53
|
+
return Check("SafeRun installation", PASS, f"version {_version()} at {p}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_python(v=None) -> Check:
|
|
57
|
+
v = v or sys.version_info
|
|
58
|
+
if tuple(v[:2]) >= MIN_PYTHON:
|
|
59
|
+
return Check("Python", PASS, f"{v[0]}.{v[1]}.{v[2]} ({platform.python_implementation()})")
|
|
60
|
+
return Check("Python", FAIL, f"{v[0]}.{v[1]} is too old", f"SafeRun needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_os(system: str | None = None, machine: str | None = None) -> Check:
|
|
64
|
+
s = (system or platform.system()).lower()
|
|
65
|
+
m = machine or platform.machine()
|
|
66
|
+
if s in ("linux", "darwin", "windows"):
|
|
67
|
+
note = "" if s == "linux" else " (native Windows and macOS support is newer than Linux; see docs/platforms.md)"
|
|
68
|
+
return Check("Operating system", PASS, f"{platform.system()} {m}{note}")
|
|
69
|
+
return Check("Operating system", FAIL, f"{platform.system()} is not supported", "SafeRun supports Linux, macOS and Windows")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_docker(d: dockerdiag.DockerDiagnosis) -> Check:
|
|
73
|
+
return Check("Docker", PASS if d.ok else FAIL, d.detail, "" if d.ok else d.guidance)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _from_status(name: str, s: st.Status, fix: str) -> Check:
|
|
77
|
+
level = {st.READY: PASS, st.OUTDATED: WARN}.get(s.state, FAIL)
|
|
78
|
+
return Check(name, level, clean_text(s.detail, 300) if s.detail else s.state, "" if level == PASS else fix)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_cosign() -> Check:
|
|
82
|
+
managed = cosign_tool.managed_cosign()
|
|
83
|
+
if managed:
|
|
84
|
+
return Check("Cosign", PASS, f"SafeRun's pinned copy {cosign_tool.COSIGN_VERSION} (SHA-256 verified) at {managed}")
|
|
85
|
+
path = sig.find_cosign()
|
|
86
|
+
if path:
|
|
87
|
+
return Check("Cosign", PASS, f"found on PATH at {path} (not SafeRun's pinned copy)")
|
|
88
|
+
return Check("Cosign", WARN, "not installed yet; only needed to install the signed analyzer image", "run: saferun init --only analyzer (fetches a verified copy)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_directories() -> list[Check]:
|
|
92
|
+
out = []
|
|
93
|
+
base = hostos.user_data_base() / "saferun"
|
|
94
|
+
probe = base
|
|
95
|
+
while not probe.exists() and probe != probe.parent:
|
|
96
|
+
probe = probe.parent
|
|
97
|
+
ok = os.access(probe, os.W_OK)
|
|
98
|
+
out.append(Check("Data directory", PASS if ok else FAIL, f"{base} ({'exists' if base.exists() else 'will be created'}; writable: {ok})",
|
|
99
|
+
"" if ok else f"make {probe} writable by your user"))
|
|
100
|
+
env = [k for k in ("SAFERUN_OSV_DB", "SAFERUN_ANALYZER_LOCK") if os.environ.get(k)]
|
|
101
|
+
if env:
|
|
102
|
+
out.append(Check("Configuration", PASS, "overridden by environment: " + ", ".join(f"{k}={os.environ[k]}" for k in env)))
|
|
103
|
+
else:
|
|
104
|
+
out.append(Check("Configuration", PASS, "defaults (no SAFERUN_* overrides)"))
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_signature_online(*, verify=sig.verify_release_signature) -> Check:
|
|
109
|
+
rel = im.ANALYZER_RELEASE
|
|
110
|
+
if rel is None:
|
|
111
|
+
return Check("Analyzer release signature", WARN, "no published analyzer release is recorded; the analyzer is built locally")
|
|
112
|
+
try:
|
|
113
|
+
n = verify(rel)
|
|
114
|
+
except SandboxError as e:
|
|
115
|
+
return Check("Analyzer release signature", FAIL, clean_text(e, 300), "run: saferun init --only analyzer")
|
|
116
|
+
return Check("Analyzer release signature", PASS, f"{n} valid signature(s) for {rel.reference}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_checks(*, online: bool = False, status=st.component_status, diagnose=dockerdiag.diagnose) -> list[Check]:
|
|
120
|
+
"""Run every check. Never raises: a broken probe becomes a FAIL."""
|
|
121
|
+
checks = [check_install(), check_python(), check_os()]
|
|
122
|
+
d = diagnose()
|
|
123
|
+
checks.append(check_docker(d))
|
|
124
|
+
for key, label in (("python", "Python sandbox image"), ("node", "Node.js sandbox image"), ("analyzer", "Analyzer image")):
|
|
125
|
+
if not d.ok:
|
|
126
|
+
checks.append(Check(label, FAIL, "cannot be verified while Docker is unusable", "fix Docker first"))
|
|
127
|
+
else:
|
|
128
|
+
checks.append(_from_status(label, status(key), f"run: saferun init --only {key}" + (" --repair" if key == "analyzer" else "")))
|
|
129
|
+
checks.append(check_cosign())
|
|
130
|
+
osv = _from_status("OSV database", status("osv-db"), "run: saferun osv-db update")
|
|
131
|
+
checks.append(osv)
|
|
132
|
+
checks.extend(check_directories())
|
|
133
|
+
if online:
|
|
134
|
+
try:
|
|
135
|
+
checks.append(check_signature_online())
|
|
136
|
+
except Exception as e: # noqa: BLE001
|
|
137
|
+
checks.append(Check("Analyzer release signature", FAIL, f"{type(e).__name__}: {clean_text(e, 200)}"))
|
|
138
|
+
return checks
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def exit_code(checks: list[Check]) -> int:
|
|
142
|
+
return 1 if any(c.level == FAIL for c in checks) else 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render_text(checks: list[Check]) -> str:
|
|
146
|
+
lines = []
|
|
147
|
+
for c in checks:
|
|
148
|
+
lines.append(f"[{c.level:<7}] {c.name}: {c.detail}")
|
|
149
|
+
if c.fix:
|
|
150
|
+
lines.append(f"{'':11}fix: {c.fix}")
|
|
151
|
+
n = {lvl: sum(1 for c in checks if c.level == lvl) for lvl in (PASS, WARN, FAIL)}
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append(f"{n[PASS]} passed, {n[WARN]} warning(s), {n[FAIL]} failed" + (" - SafeRun is ready" if not n[FAIL] and not n[WARN] else
|
|
154
|
+
" - SafeRun can run, but review the warnings" if not n[FAIL] else " - SafeRun is NOT ready; apply the fixes above"))
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_json(checks: list[Check]) -> str:
|
|
159
|
+
return json.dumps({"schema_version": 1, "ok": exit_code(checks) == 0, "checks": [c.to_dict() for c in checks]}, indent=2, sort_keys=True)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""SafeRun validation engine (Phase 2). See verdict.py for the verdict rules."""
|
|
2
|
+
from .engine import ValidationOptions, validate_project
|
|
3
|
+
from .model import (CheckResult, CheckStatus, Finding, PackageRef, Severity, ValidationReport, Verdict)
|
|
4
|
+
from .verdict import compute_verdict
|
|
5
|
+
|
|
6
|
+
__all__ = ["CheckResult", "CheckStatus", "Finding", "PackageRef", "Severity", "ValidationOptions",
|
|
7
|
+
"ValidationReport", "Verdict", "compute_verdict", "validate_project"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Run Semgrep and OSV-Scanner inside the verified analyzer container (Phase 5B). There is no host-side path.
|
|
2
|
+
|
|
3
|
+
`analyzer_runner` has the `ToolRunner` shape the checks already use, but instead of starting a host process it:
|
|
4
|
+
1. verifies the analyzer image (lock-file ID, pinned tool and rules labels) and gets its immutable ID,
|
|
5
|
+
2. runs the argv through the Phase 1 sandbox *by that ID*: no network, read-only root, non-root, all capabilities dropped,
|
|
6
|
+
seccomp/no-new-privileges, memory/swap/CPU/pids/fd/file-size limits, private tmpfs, container-side kill deadline,
|
|
7
|
+
3. mounts only the sanitized project (read-only) and, for OSV, the vulnerability database directory (read-only).
|
|
8
|
+
The host environment is deliberately not forwarded (`env` is ignored): no host credentials or proxy settings can reach a container.
|
|
9
|
+
Any failure raises (`SandboxError` and subclasses); callers turn that into a non-clean check status. Nothing falls back to the host.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..sandbox import SandboxLimits, run_sandboxed
|
|
16
|
+
from ..sandbox.analyzer import verify_analyzer_image
|
|
17
|
+
from .tools import ToolRun
|
|
18
|
+
|
|
19
|
+
# Larger than the syntax-check profile: Semgrep needs memory and a few worker processes. Still hard limits.
|
|
20
|
+
ANALYZER_LIMITS = dict(memory_mb=1536, cpus=2.0, pids=256, tmpfs_mb=256, nofile=1024, fsize_mb=64, shm_mb=64)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def analyzer_runner(argv: list[str], *, cwd: Path, env=None, timeout_s: float, max_output: int,
|
|
24
|
+
mounts: tuple[tuple[Path, str], ...] = (), sandbox=run_sandboxed, verify=verify_analyzer_image) -> ToolRun:
|
|
25
|
+
image_id = verify()
|
|
26
|
+
limits = SandboxLimits(timeout_s=timeout_s, max_output_bytes=max_output, **ANALYZER_LIMITS)
|
|
27
|
+
res = sandbox(cwd, list(argv), limits, image=image_id, readonly_mounts=mounts)
|
|
28
|
+
killed = bool(res.timed_out or res.output_truncated)
|
|
29
|
+
return ToolRun(argv=list(argv), exit_code=None if killed else res.exit_code, stdout=res.stdout, stderr=res.stderr,
|
|
30
|
+
timed_out=bool(res.timed_out), output_truncated=bool(res.output_truncated), duration_s=res.duration_s)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared inputs for checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from ..sandbox import docker as _docker
|
|
9
|
+
from ..sandbox import run_sandboxed
|
|
10
|
+
from ..sandbox.analyzer import verify_analyzer_image
|
|
11
|
+
from .analyzer import analyzer_runner
|
|
12
|
+
from .tools import ToolRunner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CheckContext:
|
|
17
|
+
project: Path # sanitized copy of the project (never the user's original directory)
|
|
18
|
+
home: Path # private scratch/HOME for analyzers
|
|
19
|
+
files: list[str] # relative posix paths of every regular file in `project`
|
|
20
|
+
analyzer: ToolRunner = analyzer_runner # Semgrep / OSV-Scanner run ONLY here (analyzer container); injectable for tests
|
|
21
|
+
analyzer_available: Callable[[], str] = verify_analyzer_image # returns the verified image ID or raises SandboxError
|
|
22
|
+
sandbox: Callable = run_sandboxed # container runner; injectable for tests
|
|
23
|
+
image_available: Callable[[str], None] = _docker.check_available # raises SandboxError if unusable
|
|
24
|
+
timeout_s: float = 120.0
|
|
25
|
+
max_output: int = 8 * 1024 * 1024
|
|
26
|
+
max_findings: int = 1000
|
|
27
|
+
options: dict = field(default_factory=dict)
|
saferun/engine/engine.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Validation engine: sanitized copy -> checks -> normalized report -> verdict."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..sandbox import SensitivePolicy, WorkspaceLimits
|
|
10
|
+
from ..sandbox.workspace import create_workspace
|
|
11
|
+
from . import node, osv, pysyntax, semgrep
|
|
12
|
+
from .context import CheckContext
|
|
13
|
+
from .model import CheckResult, CheckStatus, Finding, Severity, ValidationReport, clean_text
|
|
14
|
+
from .verdict import compute_verdict
|
|
15
|
+
|
|
16
|
+
WORKSPACE_CHECK = "workspace-sensitive-files"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ValidationOptions:
|
|
21
|
+
python_analysis: bool = True # Semgrep
|
|
22
|
+
python_syntax: bool = True # compile() in the sandbox
|
|
23
|
+
dependency_scan: bool = True
|
|
24
|
+
javascript_check: bool = True
|
|
25
|
+
tool_timeout_s: float = 120.0 # per external tool / sandbox run
|
|
26
|
+
osv_offline: bool = True # kept for compatibility: OSV-Scanner always runs offline (no network) since Phase 5B
|
|
27
|
+
osv_db_dir: Path | None = None # offline OSV database directory (default: saferun.engine.osvdb.default_dir())
|
|
28
|
+
osv_db_max_age_days: float = 30.0 # older (but valid) OSV data makes the dependency check INCOMPLETE, never clean
|
|
29
|
+
workspace_limits: WorkspaceLimits | None = None
|
|
30
|
+
sensitive_policy: SensitivePolicy | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _list_files(root: Path) -> list[str]:
|
|
34
|
+
out = []
|
|
35
|
+
for dirpath, _dirs, names in os.walk(root, followlinks=False):
|
|
36
|
+
d = Path(dirpath).relative_to(root).as_posix()
|
|
37
|
+
out += [n if d == "." else f"{d}/{n}" for n in names]
|
|
38
|
+
return sorted(out)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _workspace_check(ws) -> CheckResult:
|
|
42
|
+
"""Withheld credential-like files become findings; the content was never read by any analyzer."""
|
|
43
|
+
findings = []
|
|
44
|
+
for w in ws.withheld:
|
|
45
|
+
secret = w.reason.startswith(("private-key", "token:"))
|
|
46
|
+
findings.append(Finding(
|
|
47
|
+
check=WORKSPACE_CHECK, rule_id=f"withheld.{clean_text(w.reason, 60)}",
|
|
48
|
+
severity=Severity.HIGH if secret else Severity.LOW, category="workspace",
|
|
49
|
+
path=clean_text(w.path, 300),
|
|
50
|
+
message=("credential-like file found in the project; it was withheld from analysis and the sandbox"
|
|
51
|
+
if secret else "file not readable by other users was withheld from analysis (not analysed)")))
|
|
52
|
+
return CheckResult(WORKSPACE_CHECK, CheckStatus.FINDINGS if findings else CheckStatus.PASSED, findings=findings,
|
|
53
|
+
notes=["content of withheld files is never read by SafeRun analyzers"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _guarded(name: str, fn, ctx: CheckContext) -> CheckResult:
|
|
57
|
+
"""A crashing check is an ERROR result, never an absent (= clean) one."""
|
|
58
|
+
try:
|
|
59
|
+
return fn(ctx)
|
|
60
|
+
except Exception as e: # noqa: BLE001 - any bug must surface as an operational error
|
|
61
|
+
return CheckResult(name, CheckStatus.ERROR, detail=clean_text(f"internal error: {type(e).__name__}: {e}"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_project(project_dir, options: ValidationOptions | None = None, *, ctx_overrides: dict | None = None
|
|
65
|
+
) -> ValidationReport:
|
|
66
|
+
"""Validate the project at `project_dir`. Raises SandboxError if a safe copy cannot be made (no verdict
|
|
67
|
+
is possible then). `ctx_overrides` replaces CheckContext fields (tool runner, sandbox runner) for tests."""
|
|
68
|
+
opts = options or ValidationOptions()
|
|
69
|
+
ws = create_workspace(Path(project_dir), opts.workspace_limits, opts.sensitive_policy)
|
|
70
|
+
try:
|
|
71
|
+
home = ws.root / "home"
|
|
72
|
+
home.mkdir(mode=0o700)
|
|
73
|
+
ctx = CheckContext(project=ws.project, home=home, files=_list_files(ws.project),
|
|
74
|
+
timeout_s=opts.tool_timeout_s, options={"osv_offline": True, "osv_db_dir": opts.osv_db_dir,
|
|
75
|
+
"osv_db_max_age_days": opts.osv_db_max_age_days})
|
|
76
|
+
if ctx_overrides:
|
|
77
|
+
ctx = replace(ctx, **ctx_overrides)
|
|
78
|
+
checks = [_workspace_check(ws)]
|
|
79
|
+
checks[0].analysis = False
|
|
80
|
+
for enabled, name, mod in ((opts.python_syntax, pysyntax.NAME, pysyntax),
|
|
81
|
+
(opts.python_analysis, semgrep.NAME, semgrep),
|
|
82
|
+
(opts.dependency_scan, osv.NAME, osv),
|
|
83
|
+
(opts.javascript_check, node.NAME, node)):
|
|
84
|
+
if enabled:
|
|
85
|
+
checks.append(_guarded(name, mod.run_check, ctx))
|
|
86
|
+
else:
|
|
87
|
+
checks.append(CheckResult(name, CheckStatus.SKIPPED, detail="disabled by options"))
|
|
88
|
+
partial = []
|
|
89
|
+
if ws.skipped:
|
|
90
|
+
partial.append(f"{len(ws.skipped)} entr(y/ies) skipped (symlinks, special files or external hard links)")
|
|
91
|
+
if ws.withheld:
|
|
92
|
+
partial.append(f"{len(ws.withheld)} sensitive file(s) withheld")
|
|
93
|
+
workspace = {"entries_scanned": ws.entries_scanned, "copied_files": ws.copied_files,
|
|
94
|
+
"copied_dirs": ws.copied_dirs, "copied_bytes": ws.copied_bytes,
|
|
95
|
+
"skipped": [{"path": clean_text(s.path, 300), "reason": s.reason} for s in ws.skipped],
|
|
96
|
+
"withheld": [{"path": clean_text(w.path, 300), "reason": clean_text(w.reason, 60)} for w in ws.withheld]}
|
|
97
|
+
verdict, reasons, complete = compute_verdict(checks, "; ".join(partial))
|
|
98
|
+
return ValidationReport(verdict, reasons, complete, checks, workspace)
|
|
99
|
+
finally:
|
|
100
|
+
ws.cleanup()
|
saferun/engine/model.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Normalized result model for the validation engine.
|
|
2
|
+
|
|
3
|
+
Two things are kept strictly apart:
|
|
4
|
+
* a *finding* - something the analysis found in the project (a security signal), and
|
|
5
|
+
* a *check status* - whether the analysis itself ran to completion (an operational fact).
|
|
6
|
+
A check that errored, timed out, was unavailable or was skipped has NO findings by definition, and that
|
|
7
|
+
is never the same as a clean result: see `verdict.compute_verdict`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
MAX_TEXT = 500
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Severity(str, Enum):
|
|
20
|
+
CRITICAL = "critical"
|
|
21
|
+
HIGH = "high"
|
|
22
|
+
MEDIUM = "medium"
|
|
23
|
+
LOW = "low"
|
|
24
|
+
INFO = "info"
|
|
25
|
+
UNKNOWN = "unknown" # the tool gave no usable severity; the verdict treats it as MEDIUM
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def rank(self) -> int:
|
|
29
|
+
return _RANK[self]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_RANK = {Severity.CRITICAL: 5, Severity.HIGH: 4, Severity.MEDIUM: 3, Severity.UNKNOWN: 3,
|
|
33
|
+
Severity.LOW: 2, Severity.INFO: 1}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CheckStatus(str, Enum):
|
|
37
|
+
PASSED = "passed" # ran to completion, no findings
|
|
38
|
+
FINDINGS = "findings" # ran to completion, findings reported
|
|
39
|
+
INCOMPLETE = "incomplete" # ran, but not everything was analysed (partial results kept)
|
|
40
|
+
ERROR = "error" # tool failed / produced unusable output; nothing can be concluded
|
|
41
|
+
TIMEOUT = "timeout" # tool exceeded its time limit; nothing can be concluded
|
|
42
|
+
UNAVAILABLE = "unavailable" # tool or runtime missing; the check did not run
|
|
43
|
+
SKIPPED = "skipped" # applicable, but deliberately not run (reason given)
|
|
44
|
+
NOT_APPLICABLE = "not_applicable" # nothing for this check to look at (e.g. no JavaScript files)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def completed(self) -> bool:
|
|
48
|
+
"""The analysis ran to completion: its (lack of) findings is a real result."""
|
|
49
|
+
return self in (CheckStatus.PASSED, CheckStatus.FINDINGS)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def operational_problem(self) -> bool:
|
|
53
|
+
"""Something that should have been checked was not (fully) checked."""
|
|
54
|
+
return self in (CheckStatus.INCOMPLETE, CheckStatus.ERROR, CheckStatus.TIMEOUT,
|
|
55
|
+
CheckStatus.UNAVAILABLE, CheckStatus.SKIPPED)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Verdict(str, Enum):
|
|
59
|
+
PASS = "PASS"
|
|
60
|
+
WARNING = "WARNING"
|
|
61
|
+
FAIL = "FAIL"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_CTRL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f--]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clean_text(value, limit: int = MAX_TEXT) -> str:
|
|
68
|
+
"""Tool output and project content are untrusted: drop control/escape/bidi characters, bound length."""
|
|
69
|
+
s = _CTRL.sub("?", str(value)).replace("\r", " ")
|
|
70
|
+
return s if len(s) <= limit else s[: limit - 1] + "…"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class PackageRef:
|
|
75
|
+
name: str
|
|
76
|
+
version: str = ""
|
|
77
|
+
ecosystem: str = "" # OSV ecosystem name, e.g. "PyPI", "npm"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Finding:
|
|
82
|
+
check: str # name of the check that produced it
|
|
83
|
+
rule_id: str # rule / vulnerability id
|
|
84
|
+
severity: Severity
|
|
85
|
+
message: str
|
|
86
|
+
category: str = "code" # code | dependency | syntax | workspace
|
|
87
|
+
path: str | None = None # relative, posix, sanitized; None when not file-specific
|
|
88
|
+
line: int | None = None
|
|
89
|
+
end_line: int | None = None
|
|
90
|
+
package: PackageRef | None = None
|
|
91
|
+
vulnerability_ids: tuple[str, ...] = () # primary id first, then aliases (CVE-..., GHSA-...)
|
|
92
|
+
fixed_versions: tuple[str, ...] = ()
|
|
93
|
+
cwe: tuple[str, ...] = ()
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"check": self.check, "rule_id": self.rule_id, "severity": self.severity.value,
|
|
98
|
+
"message": self.message, "category": self.category, "path": self.path,
|
|
99
|
+
"line": self.line, "end_line": self.end_line,
|
|
100
|
+
"package": None if self.package is None else vars(self.package).copy(),
|
|
101
|
+
"vulnerability_ids": list(self.vulnerability_ids),
|
|
102
|
+
"fixed_versions": list(self.fixed_versions), "cwe": list(self.cwe),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class CheckResult:
|
|
108
|
+
name: str
|
|
109
|
+
status: CheckStatus
|
|
110
|
+
findings: list[Finding] = field(default_factory=list)
|
|
111
|
+
detail: str = "" # human-readable reason, always set for non-passing statuses
|
|
112
|
+
tool: str = "" # tool name and version when known
|
|
113
|
+
duration_s: float = 0.0
|
|
114
|
+
errors: list[str] = field(default_factory=list) # operational problems (never security findings)
|
|
115
|
+
notes: list[str] = field(default_factory=list) # coverage notes (what was / was not looked at)
|
|
116
|
+
truncated_findings: int = 0 # findings dropped because of the per-check cap
|
|
117
|
+
analysis: bool = True # False for bookkeeping checks that must not count as 'something was validated'
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict:
|
|
120
|
+
return {
|
|
121
|
+
"name": self.name, "status": self.status.value, "detail": self.detail, "tool": self.tool,
|
|
122
|
+
"duration_s": round(self.duration_s, 3), "errors": list(self.errors), "notes": list(self.notes),
|
|
123
|
+
"truncated_findings": self.truncated_findings,
|
|
124
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class ValidationReport:
|
|
130
|
+
verdict: Verdict
|
|
131
|
+
reasons: list[str] # why this verdict (one line each, ordered by importance)
|
|
132
|
+
complete: bool # every applicable check ran to completion on the whole project
|
|
133
|
+
checks: list[CheckResult]
|
|
134
|
+
workspace: dict = field(default_factory=dict) # copy accounting (entries, skipped, withheld)
|
|
135
|
+
schema_version: int = SCHEMA_VERSION
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def findings(self) -> list[Finding]:
|
|
139
|
+
return [f for c in self.checks for f in c.findings]
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict:
|
|
142
|
+
return {
|
|
143
|
+
"schema_version": self.schema_version, "verdict": self.verdict.value, "reasons": list(self.reasons),
|
|
144
|
+
"complete": self.complete, "workspace": dict(self.workspace),
|
|
145
|
+
"checks": [c.to_dict() for c in self.checks],
|
|
146
|
+
}
|
saferun/engine/node.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""JavaScript syntax validation: `node --check` for every .js/.mjs/.cjs file, INSIDE the sandbox.
|
|
2
|
+
|
|
3
|
+
`node --check` only compiles a file, but the compiler still parses untrusted input, so it is not run on the
|
|
4
|
+
host: one sandbox run (node:22-slim, same verified restrictions as everything else: no network, read-only,
|
|
5
|
+
non-root, resource limits) executes a fixed driver script that walks /workspace and runs `node --check` on each
|
|
6
|
+
file, printing one JSON line per file. Project code is never executed. node_modules/.git are not walked.
|
|
7
|
+
|
|
8
|
+
Implemented: syntax validation. NOT implemented (deferred): npm install / dependency acquisition, test runners,
|
|
9
|
+
Semgrep JavaScript rules, ESLint, TypeScript. npm *vulnerability scanning* is done from lockfiles (see osv.py).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from ..sandbox import SandboxError, SandboxLimits
|
|
18
|
+
from ..sandbox.profiles import NODE
|
|
19
|
+
from .context import CheckContext
|
|
20
|
+
from .model import CheckResult, CheckStatus, Finding, Severity, clean_text
|
|
21
|
+
|
|
22
|
+
NAME = "javascript-syntax"
|
|
23
|
+
EXTS = (".js", ".mjs", ".cjs")
|
|
24
|
+
MAX_FILES = 1000
|
|
25
|
+
SKIP_DIRS = frozenset({"node_modules", ".git"})
|
|
26
|
+
|
|
27
|
+
DRIVER = r"""
|
|
28
|
+
const fs=require('fs'),cp=require('child_process'),path=require('path');
|
|
29
|
+
const ROOT='/workspace',MAX=%d,SKIP=new Set(['node_modules','.git']);
|
|
30
|
+
const out=o=>process.stdout.write(JSON.stringify(o)+'\n');
|
|
31
|
+
const files=[];let truncated=false;
|
|
32
|
+
(function walk(d,depth){
|
|
33
|
+
if(depth>32)return;
|
|
34
|
+
let ents;try{ents=fs.readdirSync(d,{withFileTypes:true});}catch(e){out({walk_error:path.relative(ROOT,d)});return;}
|
|
35
|
+
for(const e of ents){
|
|
36
|
+
if(e.isDirectory()){if(!SKIP.has(e.name))walk(path.join(d,e.name),depth+1);}
|
|
37
|
+
else if(e.isFile()&&/\.(js|mjs|cjs)$/.test(e.name)){if(files.length>=MAX)truncated=true;else files.push(path.join(d,e.name));}
|
|
38
|
+
}
|
|
39
|
+
})(ROOT,0);
|
|
40
|
+
files.sort();
|
|
41
|
+
for(const f of files){
|
|
42
|
+
const r=cp.spawnSync(process.execPath,['--check',f],{encoding:'utf8',timeout:10000,maxBuffer:65536,env:{}});
|
|
43
|
+
out({file:path.relative(ROOT,f),status:r.status,signal:r.signal,failed:!!r.error,err:(r.stderr||'').slice(0,2000)});
|
|
44
|
+
}
|
|
45
|
+
out({done:true,count:files.length,truncated});
|
|
46
|
+
""" % MAX_FILES
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def js_files(files: list[str]) -> list[str]:
|
|
50
|
+
return [f for f in files if f.endswith(EXTS) and not any(p in SKIP_DIRS for p in f.split("/")[:-1])]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_command() -> list[str]:
|
|
54
|
+
return ["node", "-e", DRIVER]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_LINE = re.compile(r":(\d+)\s*$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _syntax_finding(file: str, err: str) -> Finding:
|
|
61
|
+
lines = [ln for ln in err.splitlines() if ln.strip()]
|
|
62
|
+
line = next((int(m.group(1)) for ln in lines[:3] if (m := _LINE.search(ln))), None)
|
|
63
|
+
msg = next((ln for ln in lines if re.match(r"^\w*Error\b", ln)), lines[0] if lines else "syntax error")
|
|
64
|
+
return Finding(check=NAME, rule_id="javascript.syntax-error", severity=Severity.MEDIUM,
|
|
65
|
+
message=clean_text(msg, 300), category="syntax", path=clean_text(file, 300), line=line)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_output(text: str, expected: list[str]) -> tuple[list[Finding], list[str], int]:
|
|
69
|
+
"""Return (findings, per-file operational problems, files checked). Raises ValueError if the driver's
|
|
70
|
+
output is malformed or incomplete (no 'done' marker), so a cut-off run cannot look clean."""
|
|
71
|
+
findings, problems, count, done = [], [], 0, None
|
|
72
|
+
known = set(expected)
|
|
73
|
+
for raw in text.splitlines():
|
|
74
|
+
if not raw.strip():
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
o = json.loads(raw)
|
|
78
|
+
except (json.JSONDecodeError, RecursionError):
|
|
79
|
+
raise ValueError("driver output is not valid JSON lines") from None
|
|
80
|
+
if not isinstance(o, dict):
|
|
81
|
+
raise ValueError("driver output line is not an object")
|
|
82
|
+
if "walk_error" in o:
|
|
83
|
+
problems.append(f"{clean_text(o['walk_error'], 200)}: directory could not be read")
|
|
84
|
+
elif o.get("done") is True:
|
|
85
|
+
done = o
|
|
86
|
+
elif isinstance(o.get("file"), str):
|
|
87
|
+
f = o["file"]
|
|
88
|
+
if f not in known:
|
|
89
|
+
raise ValueError("driver reported a file that was not in the project")
|
|
90
|
+
count += 1
|
|
91
|
+
if o.get("failed") or o.get("signal") or not isinstance(o.get("status"), int):
|
|
92
|
+
problems.append(f"{clean_text(f, 200)}: node --check could not complete")
|
|
93
|
+
elif o["status"] != 0:
|
|
94
|
+
findings.append(_syntax_finding(f, str(o.get("err", ""))))
|
|
95
|
+
else:
|
|
96
|
+
raise ValueError("unrecognised driver output line")
|
|
97
|
+
if done is None:
|
|
98
|
+
raise ValueError("driver output ended before its completion marker")
|
|
99
|
+
if done.get("count") != count:
|
|
100
|
+
raise ValueError("driver file count does not match its output")
|
|
101
|
+
if done.get("truncated"):
|
|
102
|
+
problems.append(f"more than {MAX_FILES} JavaScript files; the rest were not checked")
|
|
103
|
+
return findings, problems, count
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_check(ctx: CheckContext) -> CheckResult:
|
|
107
|
+
targets = js_files(ctx.files)
|
|
108
|
+
if not targets:
|
|
109
|
+
return CheckResult(NAME, CheckStatus.NOT_APPLICABLE, detail="no JavaScript files")
|
|
110
|
+
try:
|
|
111
|
+
ctx.image_available(NODE.image)
|
|
112
|
+
except SandboxError as e:
|
|
113
|
+
return CheckResult(NAME, CheckStatus.UNAVAILABLE, tool=f"node --check ({NODE.label})",
|
|
114
|
+
detail=clean_text(f"sandbox or image unavailable: {e}"))
|
|
115
|
+
t0 = time.monotonic()
|
|
116
|
+
res = CheckResult(NAME, CheckStatus.ERROR, tool=f"node --check ({NODE.label}, sandboxed)")
|
|
117
|
+
try:
|
|
118
|
+
r = ctx.sandbox(ctx.project, build_command(), SandboxLimits(timeout_s=ctx.timeout_s, max_output_bytes=ctx.max_output),
|
|
119
|
+
image=NODE.image)
|
|
120
|
+
except SandboxError as e:
|
|
121
|
+
res.detail = clean_text(f"sandbox refused or failed to run: {e}")
|
|
122
|
+
return res
|
|
123
|
+
finally:
|
|
124
|
+
res.duration_s = time.monotonic() - t0
|
|
125
|
+
if r.timed_out:
|
|
126
|
+
res.status, res.detail = CheckStatus.TIMEOUT, f"syntax check exceeded {ctx.timeout_s:g}s and was killed"
|
|
127
|
+
return res
|
|
128
|
+
if r.oom_killed or r.output_truncated or r.exit_code != 0:
|
|
129
|
+
res.detail = clean_text(f"sandboxed run failed (exit {r.exit_code}, oom={r.oom_killed}, "
|
|
130
|
+
f"truncated={r.output_truncated}): {r.stderr.strip()[-200:]}")
|
|
131
|
+
return res
|
|
132
|
+
try:
|
|
133
|
+
findings, problems, count = parse_output(r.stdout, targets)
|
|
134
|
+
except ValueError as e:
|
|
135
|
+
res.detail = clean_text(f"driver output unusable: {e}")
|
|
136
|
+
return res
|
|
137
|
+
res.findings = findings[: ctx.max_findings]
|
|
138
|
+
res.truncated_findings = max(0, len(findings) - ctx.max_findings)
|
|
139
|
+
res.errors = problems
|
|
140
|
+
res.notes = [f"{count} JavaScript file(s) syntax-checked with node --check; no code executed"]
|
|
141
|
+
if problems:
|
|
142
|
+
res.status, res.detail = CheckStatus.INCOMPLETE, f"{len(problems)} problem(s): " + "; ".join(problems)[:300]
|
|
143
|
+
else:
|
|
144
|
+
res.status = CheckStatus.FINDINGS if findings else CheckStatus.PASSED
|
|
145
|
+
return res
|