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
@@ -0,0 +1,41 @@
1
+ """Path normalisation for untrusted tool output."""
2
+ from __future__ import annotations
3
+
4
+ import ntpath
5
+ import posixpath
6
+ from pathlib import Path, PureWindowsPath
7
+
8
+ from .. import hostos
9
+ from .model import clean_text
10
+
11
+
12
+ CONTAINER_ROOT = "/workspace" # where the analyzer container sees the project; tools running there report paths under it
13
+
14
+
15
+ def rel_path(value, root: Path) -> str | None:
16
+ """A tool-reported path as a clean, relative, posix path inside `root` (or the container's /workspace); None if it is not one."""
17
+ if not isinstance(value, str) or not value or "\0" in value:
18
+ return None
19
+ p = value
20
+ if hostos.IS_WINDOWS and ntpath.splitdrive(p)[0]:
21
+ # A drive-letter path can only be a host path (the analyzer container reports /workspace/...). Strip the project root, compare
22
+ # case-insensitively like NTFS, and refuse every other drive path.
23
+ for r in (root.resolve(), root):
24
+ try:
25
+ p = PureWindowsPath(p).relative_to(PureWindowsPath(r)).as_posix()
26
+ break
27
+ except ValueError:
28
+ continue
29
+ else:
30
+ return None
31
+ elif posixpath.isabs(p):
32
+ for r in (str(root.resolve()) + "/", str(root) + "/", CONTAINER_ROOT + "/"):
33
+ if p.startswith(r):
34
+ p = p[len(r):]
35
+ break
36
+ else:
37
+ return None
38
+ p = posixpath.normpath(p)
39
+ if p in (".", "") or p == ".." or p.startswith("../") or posixpath.isabs(p):
40
+ return None
41
+ return clean_text(p, 300)
@@ -0,0 +1,135 @@
1
+ """Python syntax validation INSIDE the sandbox (python:3.12-slim): `compile()` of every .py/.pyi file.
2
+
3
+ Semgrep recovers from syntax errors without reporting them (observed with Semgrep 1.182: a file with `def f(:` is
4
+ "scanned" with no error), so a separate check is needed or a broken file would look clean. One sandbox run executes
5
+ a fixed driver that only *compiles* each file (never imports or runs it) and prints one JSON line per file.
6
+ Syntax is that of the sandbox image's Python (3.12), not necessarily the project's target version.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+
13
+ from ..sandbox import SandboxError, SandboxLimits
14
+ from ..sandbox.profiles import PYTHON
15
+ from .context import CheckContext
16
+ from .model import CheckResult, CheckStatus, Finding, Severity, clean_text
17
+ from .semgrep import python_files
18
+
19
+ NAME = "python-syntax"
20
+ MAX_FILES = 1000
21
+
22
+ DRIVER = """
23
+ import json, os
24
+ ROOT, MAX = '/workspace', %d
25
+ SKIP = {'node_modules', '.git', '.venv', 'venv', 'site-packages'}
26
+ files, truncated = [], False
27
+ for d, dirs, names in os.walk(ROOT):
28
+ dirs[:] = sorted(x for x in dirs if x not in SKIP)
29
+ for n in sorted(names):
30
+ if n.endswith(('.py', '.pyi')):
31
+ if len(files) >= MAX:
32
+ truncated = True
33
+ else:
34
+ files.append(os.path.join(d, n))
35
+ def out(o):
36
+ print(json.dumps(o), flush=True)
37
+ for f in files:
38
+ rel = os.path.relpath(f, ROOT)
39
+ try:
40
+ with open(f, 'rb') as fh:
41
+ src = fh.read()
42
+ compile(src, rel, 'exec', dont_inherit=True)
43
+ out({'file': rel, 'ok': True})
44
+ except SyntaxError as e:
45
+ out({'file': rel, 'ok': False, 'line': e.lineno, 'msg': str(e.msg)[:300]})
46
+ except (ValueError, RecursionError, MemoryError, OSError) as e:
47
+ out({'file': rel, 'failed': type(e).__name__})
48
+ out({'done': True, 'count': len(files), 'truncated': truncated})
49
+ """ % MAX_FILES
50
+
51
+
52
+ def build_command() -> list[str]:
53
+ return ["python", "-I", "-c", DRIVER]
54
+
55
+
56
+ def parse_output(text: str, expected: list[str]) -> tuple[list[Finding], list[str], int]:
57
+ """(findings, per-file problems, files checked). ValueError on malformed/incomplete output."""
58
+ findings, problems, count, done, known = [], [], 0, None, set(expected)
59
+ for raw in text.splitlines():
60
+ if not raw.strip():
61
+ continue
62
+ try:
63
+ o = json.loads(raw)
64
+ except (json.JSONDecodeError, RecursionError):
65
+ raise ValueError("driver output is not valid JSON lines") from None
66
+ if not isinstance(o, dict):
67
+ raise ValueError("driver output line is not an object")
68
+ if o.get("done") is True:
69
+ done = o
70
+ elif isinstance(o.get("file"), str):
71
+ f = o["file"]
72
+ if f not in known:
73
+ raise ValueError("driver reported a file that was not in the project")
74
+ count += 1
75
+ if "failed" in o:
76
+ problems.append(f"{clean_text(f, 200)}: could not be compiled ({clean_text(o['failed'], 40)})")
77
+ elif o.get("ok") is False:
78
+ line = o.get("line")
79
+ findings.append(Finding(
80
+ check=NAME, rule_id="python.syntax-error", severity=Severity.MEDIUM, category="syntax",
81
+ message=clean_text(f"SyntaxError: {o.get('msg', '')}", 300), path=clean_text(f, 300),
82
+ line=line if isinstance(line, int) and not isinstance(line, bool) and line > 0 else None))
83
+ elif o.get("ok") is not True:
84
+ raise ValueError("unrecognised driver output line")
85
+ else:
86
+ raise ValueError("unrecognised driver output line")
87
+ if done is None:
88
+ raise ValueError("driver output ended before its completion marker")
89
+ if done.get("count") != count:
90
+ raise ValueError("driver file count does not match its output")
91
+ if done.get("truncated"):
92
+ problems.append(f"more than {MAX_FILES} Python files; the rest were not checked")
93
+ return findings, problems, count
94
+
95
+
96
+ def run_check(ctx: CheckContext) -> CheckResult:
97
+ targets = python_files(ctx.files)
98
+ if not targets:
99
+ return CheckResult(NAME, CheckStatus.NOT_APPLICABLE, detail="no Python files")
100
+ try:
101
+ ctx.image_available(PYTHON.image)
102
+ except SandboxError as e:
103
+ return CheckResult(NAME, CheckStatus.UNAVAILABLE, tool=f"compile() ({PYTHON.label})",
104
+ detail=clean_text(f"sandbox or image unavailable: {e}"))
105
+ t0 = time.monotonic()
106
+ res = CheckResult(NAME, CheckStatus.ERROR, tool=f"compile() ({PYTHON.label}, sandboxed)")
107
+ try:
108
+ r = ctx.sandbox(ctx.project, build_command(), SandboxLimits(timeout_s=ctx.timeout_s, max_output_bytes=ctx.max_output),
109
+ image=PYTHON.image)
110
+ except SandboxError as e:
111
+ res.detail = clean_text(f"sandbox refused or failed to run: {e}")
112
+ return res
113
+ finally:
114
+ res.duration_s = time.monotonic() - t0
115
+ if r.timed_out:
116
+ res.status, res.detail = CheckStatus.TIMEOUT, f"syntax check exceeded {ctx.timeout_s:g}s and was killed"
117
+ return res
118
+ if r.oom_killed or r.output_truncated or r.exit_code != 0:
119
+ res.detail = clean_text(f"sandboxed run failed (exit {r.exit_code}, oom={r.oom_killed}, "
120
+ f"truncated={r.output_truncated}): {r.stderr.strip()[-200:]}")
121
+ return res
122
+ try:
123
+ findings, problems, count = parse_output(r.stdout, targets)
124
+ except ValueError as e:
125
+ res.detail = clean_text(f"driver output unusable: {e}")
126
+ return res
127
+ res.findings = findings[: ctx.max_findings]
128
+ res.truncated_findings = max(0, len(findings) - ctx.max_findings)
129
+ res.errors = problems
130
+ res.notes = [f"{count} Python file(s) compiled (not executed) with the sandbox image's Python 3.12"]
131
+ if problems:
132
+ res.status, res.detail = CheckStatus.INCOMPLETE, f"{len(problems)} problem(s): " + "; ".join(problems)[:300]
133
+ else:
134
+ res.status = CheckStatus.FINDINGS if findings else CheckStatus.PASSED
135
+ return res
@@ -0,0 +1,103 @@
1
+ # SafeRun's own, small, local Python rule set. Passed to Semgrep with --config <this file>; no registry,
2
+ # no network. It is a starting point, NOT a complete security ruleset: absence of findings proves nothing.
3
+ # metadata.saferun_severity (critical|high|medium|low) is what SafeRun uses; `severity` is Semgrep's own.
4
+ rules:
5
+ - id: saferun.python.dynamic-code-execution
6
+ languages: [python]
7
+ severity: ERROR
8
+ message: eval()/exec() runs dynamically built code; never pass untrusted data to it.
9
+ pattern-either:
10
+ - pattern: eval(...)
11
+ - pattern: exec(...)
12
+ metadata: {saferun_severity: high, cwe: ["CWE-95"]}
13
+
14
+ - id: saferun.python.subprocess-shell-true
15
+ languages: [python]
16
+ severity: ERROR
17
+ message: subprocess call with shell=True is vulnerable to command injection; pass an argument list instead.
18
+ pattern-either:
19
+ - pattern: subprocess.$FUNC(..., shell=True, ...)
20
+ metadata: {saferun_severity: high, cwe: ["CWE-78"]}
21
+
22
+ - id: saferun.python.os-command-execution
23
+ languages: [python]
24
+ severity: ERROR
25
+ message: os.system()/os.popen() run a shell command line; use subprocess with an argument list.
26
+ pattern-either:
27
+ - pattern: os.system(...)
28
+ - pattern: os.popen(...)
29
+ metadata: {saferun_severity: high, cwe: ["CWE-78"]}
30
+
31
+ - id: saferun.python.unsafe-deserialization
32
+ languages: [python]
33
+ severity: ERROR
34
+ message: pickle/marshal/shelve deserialization can execute arbitrary code; never load untrusted data with it.
35
+ pattern-either:
36
+ - pattern: pickle.load(...)
37
+ - pattern: pickle.loads(...)
38
+ - pattern: marshal.loads(...)
39
+ - pattern: shelve.open(...)
40
+ metadata: {saferun_severity: high, cwe: ["CWE-502"]}
41
+
42
+ - id: saferun.python.yaml-unsafe-load
43
+ languages: [python]
44
+ severity: ERROR
45
+ message: yaml.load()/unsafe_load() can construct arbitrary objects; use yaml.safe_load().
46
+ patterns:
47
+ - pattern-either:
48
+ - pattern: yaml.load(...)
49
+ - pattern: yaml.unsafe_load(...)
50
+ - pattern-not: yaml.load(..., Loader=yaml.SafeLoader, ...)
51
+ - pattern-not: yaml.load(..., Loader=SafeLoader, ...)
52
+ metadata: {saferun_severity: high, cwe: ["CWE-502"]}
53
+
54
+ - id: saferun.python.tls-verification-disabled
55
+ languages: [python]
56
+ severity: ERROR
57
+ message: TLS certificate verification is disabled (verify=False).
58
+ pattern-either:
59
+ - pattern: requests.$FUNC(..., verify=False, ...)
60
+ - pattern: httpx.$FUNC(..., verify=False, ...)
61
+ metadata: {saferun_severity: high, cwe: ["CWE-295"]}
62
+
63
+ - id: saferun.python.weak-hash
64
+ languages: [python]
65
+ severity: WARNING
66
+ message: MD5/SHA-1 are broken for security purposes.
67
+ pattern-either:
68
+ - pattern: hashlib.md5(...)
69
+ - pattern: hashlib.sha1(...)
70
+ metadata: {saferun_severity: medium, cwe: ["CWE-327"]}
71
+
72
+ - id: saferun.python.insecure-tempfile
73
+ languages: [python]
74
+ severity: WARNING
75
+ message: tempfile.mktemp() is racy; use tempfile.mkstemp() or NamedTemporaryFile().
76
+ pattern: tempfile.mktemp(...)
77
+ metadata: {saferun_severity: medium, cwe: ["CWE-377"]}
78
+
79
+ - id: saferun.python.flask-debug
80
+ languages: [python]
81
+ severity: WARNING
82
+ message: Flask debug mode exposes an interactive debugger; do not enable it outside local development.
83
+ pattern: $APP.run(..., debug=True, ...)
84
+ metadata: {saferun_severity: medium, cwe: ["CWE-489"]}
85
+
86
+ - id: saferun.python.hardcoded-secret
87
+ languages: [python]
88
+ severity: WARNING
89
+ message: A credential-like variable is assigned a string literal; keep secrets out of source.
90
+ patterns:
91
+ - pattern: $NAME = "..."
92
+ - metavariable-regex:
93
+ metavariable: $NAME
94
+ regex: (?i)^(.*_)?(password|passwd|secret|api_?key|token|private_key)$
95
+ - pattern-not: $NAME = ""
96
+ metadata: {saferun_severity: medium, cwe: ["CWE-798"]}
97
+
98
+ - id: saferun.python.assert-for-validation
99
+ languages: [python]
100
+ severity: INFO
101
+ message: assert statements are removed under python -O; do not rely on them for security checks.
102
+ pattern: assert $X
103
+ metadata: {saferun_severity: low, cwe: ["CWE-617"]}
@@ -0,0 +1,173 @@
1
+ """Python static security analysis with Semgrep, run INSIDE the analyzer container (Phase 5B). Never on the host.
2
+
3
+ Invocation (argv, no shell), run on the sanitized project copy mounted read-only at /workspace:
4
+ /usr/local/bin/semgrep scan --json --quiet --metrics=off --disable-version-check --no-git-ignore --disable-nosem --no-rewrite-rule-ids
5
+ --config /opt/saferun/rules/python.yml --timeout 10 --max-target-bytes 1000000
6
+ --exclude node_modules --exclude .venv --exclude venv --exclude site-packages .
7
+ Only the rule file baked into the verified image is used (no registry, no network, no auto config); project-supplied Semgrep
8
+ configuration is not read. If the analyzer image is missing, locked-but-different, or isolation cannot be set up, the check is
9
+ UNAVAILABLE/ERROR (never clean); there is no host-side fallback. `--disable-nosem` stops `# nosem` comments in untrusted code from hiding findings.
10
+ Exit codes: 0 = scan done (findings do not change it); anything else = operational error.
11
+
12
+ Coverage is verified, not assumed: Semgrep silently skips files (over --max-target-bytes, or matched by a
13
+ project-supplied .semgrepignore) without reporting an error, so every targeted Python file must appear in the
14
+ `paths.scanned` list of its JSON output; otherwise the check is INCOMPLETE. `.semgrepignore` files are also removed
15
+ from SafeRun's private copy before the scan (Semgrep has no flag to disable them), so an untrusted project cannot
16
+ use one to hide code.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import time
23
+ from pathlib import Path
24
+
25
+ from .context import CheckContext
26
+ from .model import CheckResult, CheckStatus, Finding, Severity, clean_text
27
+ from .paths import rel_path
28
+ from ..sandbox import SandboxError
29
+ from ..sandbox.analyzer import RULES_PATH, SEMGREP_EXE, AnalyzerIntegrityError
30
+ from .tools import ToolUnavailable
31
+
32
+ NAME = "python-static-analysis"
33
+ RULES_FILE = Path(__file__).parent / "rules" / "python.yml"
34
+ EXCLUDES = ("node_modules", ".venv", "venv", "site-packages")
35
+ _SEV = {"critical": Severity.CRITICAL, "high": Severity.HIGH, "medium": Severity.MEDIUM, "low": Severity.LOW,
36
+ "info": Severity.INFO}
37
+ _SEMGREP_SEV = {"ERROR": Severity.HIGH, "WARNING": Severity.MEDIUM, "INFO": Severity.LOW}
38
+
39
+
40
+ def python_files(files: list[str]) -> list[str]:
41
+ return [f for f in files if f.endswith((".py", ".pyi")) and not _vendored(f)]
42
+
43
+
44
+ def _vendored(rel: str) -> bool:
45
+ return any(p in EXCLUDES for p in rel.split("/")[:-1])
46
+
47
+
48
+ def build_argv(exe: str = SEMGREP_EXE, rules: str = RULES_PATH) -> list[str]:
49
+ argv = [exe, "scan", "--json", "--quiet", "--metrics=off", "--disable-version-check", "--no-git-ignore",
50
+ "--disable-nosem", "--no-rewrite-rule-ids", "--config", rules, "--timeout", "10", "--max-target-bytes", "1000000"]
51
+ for e in EXCLUDES:
52
+ argv += ["--exclude", e]
53
+ return argv + ["."]
54
+
55
+
56
+ def _as_int(v) -> int | None:
57
+ return v if isinstance(v, int) and not isinstance(v, bool) and v >= 0 else None
58
+
59
+
60
+ def parse_output(text: str, root: Path) -> tuple[list[Finding], list[str], set[str] | None]:
61
+ """Return (findings, tool-reported errors, scanned paths or None if not reported).
62
+ Raises ValueError on anything that is not Semgrep JSON."""
63
+ try:
64
+ data = json.loads(text)
65
+ except (json.JSONDecodeError, RecursionError) as e:
66
+ raise ValueError(f"output is not valid JSON: {e}") from None
67
+ if not isinstance(data, dict) or not isinstance(data.get("results"), list):
68
+ raise ValueError("JSON has no 'results' list")
69
+ findings: list[Finding] = []
70
+ for r in data["results"]:
71
+ if not isinstance(r, dict) or not isinstance(r.get("extra"), dict):
72
+ raise ValueError("malformed entry in 'results'")
73
+ extra = r["extra"]
74
+ meta = extra.get("metadata") if isinstance(extra.get("metadata"), dict) else {}
75
+ sev = _SEV.get(str(meta.get("saferun_severity", "")).lower()) \
76
+ or _SEMGREP_SEV.get(str(extra.get("severity", "")).upper(), Severity.UNKNOWN)
77
+ cwe = meta.get("cwe", [])
78
+ cwe = [cwe] if isinstance(cwe, str) else cwe if isinstance(cwe, list) else []
79
+ start, end = r.get("start"), r.get("end")
80
+ findings.append(Finding(
81
+ check=NAME, rule_id=clean_text(r.get("check_id", "unknown"), 200), severity=sev,
82
+ message=clean_text(extra.get("message", "")), category="code",
83
+ path=rel_path(r.get("path"), root),
84
+ line=_as_int(start.get("line")) if isinstance(start, dict) else None,
85
+ end_line=_as_int(end.get("line")) if isinstance(end, dict) else None,
86
+ cwe=tuple(clean_text(c, 40) for c in cwe if isinstance(c, str)),
87
+ ))
88
+ errs = []
89
+ for e in data.get("errors") or []:
90
+ if isinstance(e, dict):
91
+ where = rel_path(e.get("path"), root) or ""
92
+ errs.append(clean_text(f"{e.get('type', 'error')} {where}: {e.get('message') or e.get('long_msg') or ''}".strip(), 300))
93
+ else:
94
+ errs.append(clean_text(e, 300))
95
+ paths = data.get("paths")
96
+ raw = paths.get("scanned") if isinstance(paths, dict) else None
97
+ scanned = {p for p in (rel_path(x, root) for x in raw) if p} if isinstance(raw, list) else None
98
+ return findings, errs, scanned
99
+
100
+
101
+ def remove_ignore_files(ctx: CheckContext) -> int:
102
+ """Delete .semgrepignore files from the private analysis copy. Returns how many were removed."""
103
+ n = 0
104
+ for f in ctx.files:
105
+ if f.rsplit("/", 1)[-1] == ".semgrepignore":
106
+ try:
107
+ os.unlink(ctx.project / f)
108
+ n += 1
109
+ except OSError:
110
+ pass # the coverage cross-check below still catches anything Semgrep then skips
111
+ return n
112
+
113
+
114
+ def run_check(ctx: CheckContext) -> CheckResult:
115
+ targets = python_files(ctx.files)
116
+ if not targets:
117
+ return CheckResult(NAME, CheckStatus.NOT_APPLICABLE, detail="no Python files")
118
+ if not RULES_FILE.is_file():
119
+ return CheckResult(NAME, CheckStatus.ERROR, detail=f"bundled rule file missing: {RULES_FILE.name}", tool="semgrep")
120
+ try:
121
+ ctx.analyzer_available()
122
+ except AnalyzerIntegrityError as e:
123
+ return CheckResult(NAME, CheckStatus.ERROR, tool="semgrep",
124
+ detail=clean_text(f"analyzer image failed verification; Semgrep was not run (no host fallback): {e}"))
125
+ except SandboxError as e:
126
+ return CheckResult(NAME, CheckStatus.UNAVAILABLE, tool="semgrep",
127
+ detail=clean_text(f"analyzer image unavailable; Semgrep was not run (no host fallback): {e}"))
128
+ removed = remove_ignore_files(ctx)
129
+ t0 = time.monotonic()
130
+ try:
131
+ run = ctx.analyzer(build_argv(), cwd=ctx.project, env=None, timeout_s=ctx.timeout_s, max_output=ctx.max_output)
132
+ except ToolUnavailable as e:
133
+ return CheckResult(NAME, CheckStatus.UNAVAILABLE, detail=clean_text(e), tool="semgrep")
134
+ except SandboxError as e:
135
+ return CheckResult(NAME, CheckStatus.ERROR, tool="semgrep", duration_s=time.monotonic() - t0,
136
+ detail=clean_text(f"isolated analyzer run failed; Semgrep results are not available (no host fallback): {e}"))
137
+ res = CheckResult(NAME, CheckStatus.ERROR, tool="semgrep", duration_s=time.monotonic() - t0)
138
+ if run.timed_out:
139
+ res.status, res.detail = CheckStatus.TIMEOUT, f"semgrep exceeded {ctx.timeout_s:g}s and was killed"
140
+ return res
141
+ if run.output_truncated:
142
+ res.detail = "semgrep output exceeded the size limit and the run was killed"
143
+ return res
144
+ if run.exit_code != 0:
145
+ res.detail = clean_text(f"semgrep exited with code {run.exit_code}: {run.stderr.strip()[:300]}")
146
+ return res
147
+ try:
148
+ findings, errs, scanned = parse_output(run.stdout, ctx.project)
149
+ except ValueError as e:
150
+ res.detail = clean_text(f"semgrep output unusable: {e}")
151
+ return res
152
+ res.findings = findings[: ctx.max_findings]
153
+ res.truncated_findings = max(0, len(findings) - ctx.max_findings)
154
+ res.errors = errs
155
+ res.notes = [f"{len(targets)} Python file(s) targeted", "rules: bundled local set (saferun/engine/rules/python.yml)"]
156
+ if removed:
157
+ res.notes.append(f"{removed} .semgrepignore file(s) removed from the analysis copy")
158
+ problems = []
159
+ if errs:
160
+ problems.append(f"semgrep reported {len(errs)} error(s); some files were not fully analysed")
161
+ if scanned is None:
162
+ problems.append("semgrep did not report which files it scanned; coverage cannot be verified")
163
+ else:
164
+ missing = sorted(set(targets) - scanned)
165
+ if missing:
166
+ shown = ", ".join(clean_text(m, 80) for m in missing[:5]) + (f" and {len(missing) - 5} more" if len(missing) > 5 else "")
167
+ problems.append(f"{len(missing)} targeted Python file(s) were not scanned by semgrep (e.g. too large): {shown}")
168
+ res.errors = errs + [f"not scanned: {clean_text(m, 200)}" for m in missing[:50]]
169
+ if problems:
170
+ res.status, res.detail = CheckStatus.INCOMPLETE, "; ".join(problems)
171
+ else:
172
+ res.status = CheckStatus.FINDINGS if res.findings else CheckStatus.PASSED
173
+ return res
@@ -0,0 +1,167 @@
1
+ """Controlled execution of external analysis tools (Semgrep, OSV-Scanner).
2
+
3
+ These tools are *analyzers*: they read a sanitized copy of the project, they never run project code. Even so
4
+ they parse untrusted input, so they get: argv only (no shell), an absolute executable path, a minimal
5
+ environment, a private HOME, a new process group killed as a whole on timeout, and a hard output cap.
6
+ Failure to run is reported as ToolUnavailable / a ToolRun flag - never as an empty "clean" output.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Callable
19
+
20
+ from .. import hostos
21
+ from ..hostos import process as hostproc
22
+
23
+ MAX_ARGS, MAX_ARG_LEN = 256, 4096
24
+ DEFAULT_MAX_OUTPUT = 8 * 1024 * 1024
25
+ _BASE_ENV = ("PATH", "LANG", "LC_ALL", "TZ")
26
+ _NET_ENV = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
27
+ "SSL_CERT_FILE", "SSL_CERT_DIR")
28
+
29
+
30
+ class ToolUnavailable(RuntimeError):
31
+ """The executable is missing or cannot be started."""
32
+
33
+
34
+ @dataclass
35
+ class ToolRun:
36
+ argv: list[str]
37
+ exit_code: int | None # None when killed (timeout / output cap)
38
+ stdout: str
39
+ stderr: str
40
+ timed_out: bool = False
41
+ output_truncated: bool = False # output cap hit: the process was killed, output is incomplete
42
+ duration_s: float = 0.0
43
+ cancelled: bool = False # the caller's cancel event fired (server shutdown): the process was interrupted/killed
44
+
45
+
46
+ # Signature of the injectable runner; checks take one so tests can substitute canned results.
47
+ ToolRunner = Callable[..., ToolRun]
48
+
49
+
50
+ def find_tool(name: str) -> str | None:
51
+ """Absolute path of `name` on PATH, or None."""
52
+ p = shutil.which(name)
53
+ return os.path.abspath(p) if p else None
54
+
55
+
56
+ def validate_argv(argv) -> list[str]:
57
+ if (not isinstance(argv, (list, tuple)) or not argv or len(argv) > MAX_ARGS
58
+ or not all(isinstance(a, str) and len(a) <= MAX_ARG_LEN and "\0" not in a for a in argv)
59
+ or not argv[0] or not os.path.isabs(argv[0])):
60
+ raise ValueError("argv must be a list of strings (NUL-free) starting with an absolute executable path")
61
+ return list(argv)
62
+
63
+
64
+ def build_env(home: Path, network: bool = False, extra: dict[str, str] | None = None) -> dict[str, str]:
65
+ """Minimal environment: no inherited credentials/tokens. Proxy/cert variables only if `network`."""
66
+ env = {k: os.environ[k] for k in _BASE_ENV if k in os.environ}
67
+ if network:
68
+ env.update({k: os.environ[k] for k in _NET_ENV if k in os.environ})
69
+ env.update({k: os.environ[k] for k in hostos.base_env_names() if k in os.environ})
70
+ env.update(HOME=str(home), XDG_CACHE_HOME=str(home / "cache"), XDG_CONFIG_HOME=str(home / "config"),
71
+ TMPDIR=str(home))
72
+ env.update(hostos.private_home_env(str(home)))
73
+ env.update(extra or {})
74
+ return env
75
+
76
+
77
+ def _drain(stream, cap: int, buf: bytearray, over: threading.Event) -> None:
78
+ while chunk := stream.read(65536):
79
+ room = cap - len(buf)
80
+ if room > 0:
81
+ buf += chunk[:room]
82
+ if len(chunk) > room:
83
+ over.set()
84
+
85
+
86
+ def _kill_group(proc: subprocess.Popen) -> None:
87
+ if hostos.IS_WINDOWS: # job object: ends the whole tree
88
+ return hostproc.kill_tree(proc)
89
+ try:
90
+ os.killpg(proc.pid, signal.SIGKILL)
91
+ except (ProcessLookupError, PermissionError):
92
+ pass
93
+ try:
94
+ proc.kill()
95
+ except OSError:
96
+ pass
97
+
98
+
99
+ def _interrupt_group(proc: subprocess.Popen) -> None:
100
+ """SIGINT to the process group: lets a SafeRun CLI worker run its own cleanup (container, workspace) before it exits."""
101
+ if hostos.IS_WINDOWS:
102
+ return hostproc.interrupt_tree(proc)
103
+ try:
104
+ os.killpg(proc.pid, signal.SIGINT)
105
+ except (ProcessLookupError, PermissionError):
106
+ pass
107
+
108
+
109
+ def run_tool(argv: list[str], *, cwd: Path, env: dict[str, str], timeout_s: float,
110
+ max_output: int = DEFAULT_MAX_OUTPUT, cancel: threading.Event | None = None,
111
+ cancel_grace_s: float = 15.0) -> ToolRun:
112
+ """Run `argv` (absolute executable, no shell). Raises ToolUnavailable if it cannot be started.
113
+
114
+ `cancel` (optional): when set, the process group gets SIGINT, then SIGKILL after `cancel_grace_s`; the result
115
+ has `cancelled=True` and no exit code (the run is never usable as a verdict)."""
116
+ argv = validate_argv(argv)
117
+ if not (timeout_s > 0):
118
+ raise ValueError("timeout_s must be positive")
119
+ t0 = time.monotonic()
120
+ try:
121
+ proc = subprocess.Popen(argv, cwd=str(cwd), env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
122
+ stderr=subprocess.PIPE, **hostproc.popen_kwargs())
123
+ hostproc.after_spawn(proc) # Windows: confine to a job object, then let it run; POSIX: nothing
124
+ except OSError as e:
125
+ raise ToolUnavailable(f"cannot start {argv[0]}: {e}") from e
126
+ out, err, over = bytearray(), bytearray(), threading.Event()
127
+ readers = [threading.Thread(target=_drain, args=(s, max_output, b, over), daemon=True)
128
+ for s, b in ((proc.stdout, out), (proc.stderr, err))]
129
+ for t in readers:
130
+ t.start()
131
+ timed_out = cancelled = False
132
+ deadline = t0 + timeout_s
133
+ try:
134
+ while proc.poll() is None:
135
+ if cancel is not None and cancel.is_set():
136
+ cancelled = True
137
+ _interrupt_group(proc)
138
+ grace_end = time.monotonic() + cancel_grace_s
139
+ while proc.poll() is None and time.monotonic() < grace_end:
140
+ time.sleep(0.02)
141
+ _kill_group(proc) # also reaps anything the interrupted process left in its group
142
+ break
143
+ if over.is_set():
144
+ _kill_group(proc)
145
+ break
146
+ if time.monotonic() >= deadline:
147
+ timed_out = True
148
+ _kill_group(proc)
149
+ break
150
+ time.sleep(0.02)
151
+ try:
152
+ proc.wait(timeout=10)
153
+ except subprocess.TimeoutExpired:
154
+ _kill_group(proc)
155
+ proc.wait(timeout=10)
156
+ finally:
157
+ for t in readers:
158
+ t.join(timeout=5)
159
+ for s in (proc.stdout, proc.stderr):
160
+ s.close()
161
+ hostproc.release(proc) # Windows: closing the job also ends anything the tool left behind
162
+ return ToolRun(
163
+ argv=argv, exit_code=None if (timed_out or cancelled or over.is_set()) else proc.returncode,
164
+ stdout=out.decode("utf-8", "replace"), stderr=err.decode("utf-8", "replace"),
165
+ timed_out=timed_out, output_truncated=over.is_set(), duration_s=time.monotonic() - t0,
166
+ cancelled=cancelled,
167
+ )
@@ -0,0 +1,47 @@
1
+ """Verdict rules. Deterministic and documented (README/PROJECT.md mirror this).
2
+
3
+ FAIL any finding of severity CRITICAL or HIGH.
4
+ WARNING not FAIL, and any of: a finding of severity MEDIUM, LOW or UNKNOWN (UNKNOWN is treated as MEDIUM);
5
+ a check that is INCOMPLETE, ERROR, TIMEOUT, UNAVAILABLE or SKIPPED (the project was not fully
6
+ checked); a partial workspace copy (skipped/withheld entries); no check completed at all.
7
+ PASS at least one check ran to completion, none of the above applies. INFO findings do not matter.
8
+
9
+ PASS means "the checks that exist found nothing", not "the code is safe". An operational problem can never
10
+ produce PASS, and can never be hidden by a clean result from another check; it also does not by itself
11
+ produce FAIL, because FAIL is reserved for demonstrated findings.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from .model import CheckResult, CheckStatus, Severity, Verdict
16
+
17
+ _FAIL_AT = Severity.HIGH.rank
18
+
19
+
20
+ def compute_verdict(checks: list[CheckResult], partial_workspace: str = "") -> tuple[Verdict, list[str], bool]:
21
+ """Return (verdict, reasons, complete). `partial_workspace` is a non-empty description when the
22
+ sandbox copy skipped or withheld entries."""
23
+ fail, warn = [], []
24
+ complete = True
25
+ for c in checks:
26
+ n_fail = sum(1 for f in c.findings if f.severity.rank >= _FAIL_AT)
27
+ n_warn = sum(1 for f in c.findings if 2 <= f.severity.rank < _FAIL_AT)
28
+ if n_fail:
29
+ fail.append(f"{c.name}: {n_fail} finding(s) of severity high or critical")
30
+ if n_warn:
31
+ warn.append(f"{c.name}: {n_warn} finding(s) of severity medium, low or unknown")
32
+ if c.truncated_findings:
33
+ warn.append(f"{c.name}: {c.truncated_findings} further finding(s) not listed (cap reached)")
34
+ if c.status.operational_problem:
35
+ complete = False
36
+ warn.append(f"{c.name}: {c.status.value}" + (f" - {c.detail}" if c.detail else ""))
37
+ if partial_workspace:
38
+ complete = False
39
+ warn.append(f"partial scan: {partial_workspace}")
40
+ if not any(c.status.completed and c.analysis for c in checks):
41
+ complete = False
42
+ warn.append("no check ran to completion; nothing was validated")
43
+ if fail:
44
+ return Verdict.FAIL, fail + warn, complete
45
+ if warn:
46
+ return Verdict.WARNING, warn, complete
47
+ return Verdict.PASS, ["all applicable checks completed with no findings above informational"], complete