mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
The agent passes a manifest file or project directory; pip uses -r for requirements files and the directory for pyproject/setup, npm ci/install runs in the directory, composer/maven/gradle likewise. Build and verify failures carry phase, exit code and a sanitised stderr tail. Co-Authored-By: Claude Fable 5.1 <[email protected]>
143 lines
5.6 KiB
Python
143 lines
5.6 KiB
Python
"""Disk-backed task scratch and aggregate workspace guard for kube-ax."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DEFAULT_SCRATCH_BYTES = 2 * 1024**3
|
|
WORKSPACE = Path("/workspace")
|
|
|
|
|
|
class ScratchLimit(ValueError):
|
|
"""The task workspace exceeded its configured scratch budget."""
|
|
|
|
|
|
def environment() -> dict[str, str]:
|
|
if not WORKSPACE.is_dir():
|
|
return {}
|
|
root = str(WORKSPACE)
|
|
return {
|
|
"HOME": root + "/home",
|
|
"TMPDIR": root + "/tmp",
|
|
"npm_config_cache": root + "/.npm",
|
|
"MAVEN_OPTS": "-Dmaven.repo.local=" + root + "/.m2",
|
|
"COMPOSER_HOME": root + "/.composer",
|
|
"PIP_CACHE_DIR": root + "/.pip",
|
|
"GRADLE_USER_HOME": root + "/.gradle",
|
|
}
|
|
|
|
|
|
def configure() -> int:
|
|
limit = int(os.environ.get("OUSAST_SCRATCH_BYTES", DEFAULT_SCRATCH_BYTES))
|
|
if limit < 1:
|
|
raise ValueError("scratch limit must be positive")
|
|
env = environment()
|
|
if env:
|
|
Path(env["TMPDIR"]).mkdir(parents=True, exist_ok=True)
|
|
Path(env["HOME"]).mkdir(parents=True, exist_ok=True)
|
|
os.environ.update(env)
|
|
# tempfile may have cached /tmp before this entry point ran.
|
|
tempfile.tempdir = env["TMPDIR"]
|
|
os.environ["OUSAST_SCRATCH_BYTES"] = str(limit)
|
|
return limit
|
|
|
|
|
|
def check(root: Path | None = None, limit: int | None = None, *, additional_bytes: int = 0) -> int:
|
|
root = root or WORKSPACE
|
|
limit = limit if limit is not None else int(os.environ.get("OUSAST_SCRATCH_BYTES", DEFAULT_SCRATCH_BYTES))
|
|
if not root.is_dir():
|
|
raise OSError("scratch root unavailable")
|
|
|
|
def unreadable(error: OSError) -> None:
|
|
if not isinstance(error, FileNotFoundError):
|
|
raise error
|
|
|
|
total = additional_bytes
|
|
if total > limit:
|
|
raise ScratchLimit("scratch limit")
|
|
for parent, directories, names in os.walk(root, followlinks=False, onerror=unreadable):
|
|
directories[:] = [d for d in directories if not (Path(parent) / d).is_symlink()]
|
|
for name in names:
|
|
try:
|
|
info = (Path(parent) / name).lstat()
|
|
except FileNotFoundError: # A running build may remove its temporary files.
|
|
continue
|
|
if stat.S_ISREG(info.st_mode):
|
|
total += max(info.st_size, info.st_blocks * 512)
|
|
if total > limit:
|
|
raise ScratchLimit("scratch limit")
|
|
return total
|
|
|
|
|
|
def communicate(proc: subprocess.Popen[Any], data: bytes, timeout: float, root: Path, *, files: tuple[Any, ...] = ()) -> None:
|
|
"""Poll while the child runs and after exit, catching short-lived growth too."""
|
|
|
|
def guard() -> None:
|
|
# TemporaryFile logs are unlinked, so a directory walk cannot see them.
|
|
check(WORKSPACE if WORKSPACE.is_dir() else root, additional_bytes=sum(os.fstat(f.fileno()).st_size for f in files))
|
|
|
|
end = time.monotonic() + timeout
|
|
first = True
|
|
while True:
|
|
guard()
|
|
try:
|
|
proc.communicate(input=data if first else None, timeout=min(0.05, max(0, end - time.monotonic())))
|
|
guard()
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
first = False
|
|
if time.monotonic() >= end:
|
|
raise
|
|
|
|
|
|
def diagnostic(value: object, *, maximum: int = 300, private: tuple[str, ...] = ()) -> str:
|
|
"""Sanitize before truncation so a clipped secret cannot escape redaction."""
|
|
text = str(value)
|
|
for key, secret in os.environ.items():
|
|
if len(secret) >= 4 and any(word in key.upper() for word in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL")):
|
|
text = text.replace(secret, "[redacted]")
|
|
text = re.sub(r"(?i)bearer\s+[^\s,;\"']+", "Bearer [redacted]", text)
|
|
text = re.sub(r"(?i)(?:https?|s3)://[^\s<>\"']+", "[redacted-url]", text)
|
|
text = re.sub(
|
|
r"(?i)([\w-]*(?:authorization|signature|credential|token|secret|password|key))[\"']?\s*[:=]\s*[\"']?[^\s,;&\"']+",
|
|
r"\1=[redacted]",
|
|
text,
|
|
)
|
|
text = re.sub(r"\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]+|npm_[A-Za-z0-9]+)\b", "[redacted]", text)
|
|
text = re.sub(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", "[redacted]", text)
|
|
for name in sorted(set(private), key=len, reverse=True):
|
|
if name:
|
|
text = text.replace(name, "[repository]")
|
|
return "\n".join(text.splitlines()[-20:])[-maximum:]
|
|
|
|
|
|
def exception_reason(exc: BaseException, *, private: tuple[str, ...] = ()) -> str:
|
|
prefix = type(exc).__name__ + ": "
|
|
maximum = max(0, 300 - len(prefix))
|
|
cleanup = getattr(exc, "executor_cleanup_reason", None)
|
|
if cleanup:
|
|
original = str(exc).removesuffix("; " + cleanup)
|
|
# Reserve space for both causes: a long teardown must not erase the
|
|
# original failure from the bounded public record.
|
|
half = max(0, (maximum - 2) // 2)
|
|
return prefix + diagnostic(original, maximum=half, private=private) + "; " + diagnostic(cleanup, maximum=half, private=private)
|
|
return prefix + diagnostic(exc, maximum=maximum, private=private)
|
|
|
|
|
|
class CommandFailure(ValueError):
|
|
"""Transport a bounded command diagnostic without dropping stderr."""
|
|
|
|
def __init__(self, phase: str, exit_code: int | None, stderr: str, reason: str = "") -> None:
|
|
self.phase = phase
|
|
self.exit_code = exit_code
|
|
self.stderr = diagnostic(stderr, maximum=1500)
|
|
summary = diagnostic(reason) if reason else f"{phase}: exit code {exit_code}"
|
|
super().__init__(summary + (": " + self.stderr if self.stderr else ""))
|