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/engine/paths.py
ADDED
|
@@ -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
|
saferun/engine/tools.py
ADDED
|
@@ -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
|