saferun-cli 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. saferun/__init__.py +3 -0
  2. saferun/__main__.py +5 -0
  3. saferun/cli.py +533 -0
  4. saferun/dockerdiag.py +65 -0
  5. saferun/doctor.py +159 -0
  6. saferun/engine/__init__.py +7 -0
  7. saferun/engine/analyzer.py +30 -0
  8. saferun/engine/context.py +27 -0
  9. saferun/engine/engine.py +100 -0
  10. saferun/engine/model.py +146 -0
  11. saferun/engine/node.py +145 -0
  12. saferun/engine/osv.py +291 -0
  13. saferun/engine/osvdb.py +367 -0
  14. saferun/engine/paths.py +41 -0
  15. saferun/engine/pysyntax.py +135 -0
  16. saferun/engine/rules/python.yml +103 -0
  17. saferun/engine/semgrep.py +173 -0
  18. saferun/engine/tools.py +167 -0
  19. saferun/engine/verdict.py +47 -0
  20. saferun/hostos/__init__.py +188 -0
  21. saferun/hostos/_win.py +316 -0
  22. saferun/hostos/acl.py +172 -0
  23. saferun/hostos/fsops.py +41 -0
  24. saferun/hostos/process.py +82 -0
  25. saferun/hostos/winfs.py +164 -0
  26. saferun/runcmd.py +139 -0
  27. saferun/sandbox/__init__.py +11 -0
  28. saferun/sandbox/analyzer.py +192 -0
  29. saferun/sandbox/attestation.py +110 -0
  30. saferun/sandbox/config.py +121 -0
  31. saferun/sandbox/controls.py +104 -0
  32. saferun/sandbox/copy.py +338 -0
  33. saferun/sandbox/copy_windows.py +281 -0
  34. saferun/sandbox/cosign_tool.py +134 -0
  35. saferun/sandbox/docker.py +167 -0
  36. saferun/sandbox/images.py +110 -0
  37. saferun/sandbox/profiles.py +40 -0
  38. saferun/sandbox/reaper.py +127 -0
  39. saferun/sandbox/runner.py +268 -0
  40. saferun/sandbox/secrets.py +91 -0
  41. saferun/sandbox/signature.py +117 -0
  42. saferun/sandbox/verify.py +75 -0
  43. saferun/sandbox/workspace.py +92 -0
  44. saferun/setup.py +559 -0
  45. saferun/ui.py +133 -0
  46. saferun_cli-0.0.2.dist-info/METADATA +136 -0
  47. saferun_cli-0.0.2.dist-info/RECORD +51 -0
  48. saferun_cli-0.0.2.dist-info/WHEEL +5 -0
  49. saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
  50. saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
  51. saferun_cli-0.0.2.dist-info/top_level.txt +1 -0
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)
@@ -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()
@@ -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