mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(three-stage-scan): add language recipes with inconclusive fallback
This commit is contained in:
@@ -110,7 +110,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex
|
||||
- _Boundary: RegressionRunner_
|
||||
- _Depends: 2.4_
|
||||
|
||||
- [ ] 5.2 (P) Add language recipes with inconclusive fallback
|
||||
- [x] 5.2 (P) Add language recipes with inconclusive fallback
|
||||
- Python and JavaScript recipes run a scratch snippet with the repo on the language path; C compiles and runs when the image has a compiler; a missing recipe yields `inconclusive` rather than a crash.
|
||||
- Observable completion: Python and JavaScript recipes produce a job; an unknown language returns inconclusive without calling the runner.
|
||||
- _Requirements: 5.1, 5.6_
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Isolated regression snippets and verdicts."""
|
||||
|
||||
from .candidate import INCONCLUSIVE, SAFETY_REJECTED, RegressionRunner, RegressionVerdict, select_candidates
|
||||
from .candidate import INCONCLUSIVE, MISSING_RECIPE, SAFETY_REJECTED, RegressionRunner, RegressionVerdict, select_candidates
|
||||
from .recipes import recipe_for
|
||||
from .safety import UnsafeSnippetError, check_snippet_safety
|
||||
|
||||
__all__ = [
|
||||
"INCONCLUSIVE",
|
||||
"MISSING_RECIPE",
|
||||
"SAFETY_REJECTED",
|
||||
"RegressionRunner",
|
||||
"RegressionVerdict",
|
||||
"UnsafeSnippetError",
|
||||
"check_snippet_safety",
|
||||
"recipe_for",
|
||||
"select_candidates",
|
||||
]
|
||||
|
||||
@@ -4,15 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..complexity.ledger import hotspot_key, select_forced_candidates
|
||||
from ..complexity.map import Hotspot
|
||||
from ..config import SandboxConfig
|
||||
from ..findings import StaticFinding
|
||||
from ..sandbox import SandboxJob, SandboxResult, SandboxRunner
|
||||
from .recipes import recipe_for
|
||||
from .safety import UnsafeSnippetError, check_snippet_safety
|
||||
|
||||
INCONCLUSIVE = "inconclusive"
|
||||
SAFETY_REJECTED = "safety_rejected"
|
||||
MISSING_RECIPE = "missing_recipe"
|
||||
TRIGGERABLE = "triggerable"
|
||||
NOT_TRIGGERABLE = "not_triggerable"
|
||||
|
||||
@@ -81,7 +85,23 @@ class RegressionRunner:
|
||||
def __init__(self, sandbox: SandboxRunner) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def run_snippet(self, snippet: str, job: SandboxJob) -> RegressionVerdict:
|
||||
def run_recipe(
|
||||
self,
|
||||
language: str,
|
||||
snippet: str,
|
||||
image: str,
|
||||
sandbox_limits: SandboxConfig,
|
||||
*,
|
||||
repo_root: Path,
|
||||
) -> RegressionVerdict:
|
||||
job = recipe_for(language, snippet, image, sandbox_limits, repo_root=repo_root)
|
||||
if job is None:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=MISSING_RECIPE)
|
||||
return self.run_snippet(snippet, job)
|
||||
|
||||
def run_snippet(self, snippet: str, job: SandboxJob | None) -> RegressionVerdict:
|
||||
if job is None:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=MISSING_RECIPE)
|
||||
try:
|
||||
check_snippet_safety(snippet)
|
||||
except UnsafeSnippetError:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Language command recipes for isolated regression snippets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import SandboxConfig
|
||||
from ..sandbox.runner import SCRATCH_MOUNT, WORKSPACE_MOUNT, SandboxJob
|
||||
|
||||
_PYTHON_FILE = "case.py"
|
||||
_JS_FILE = "case.js"
|
||||
_C_FILE = "case.c"
|
||||
_C_BIN = "case"
|
||||
|
||||
|
||||
def recipe_for(
|
||||
language: str,
|
||||
snippet: str,
|
||||
image: str,
|
||||
sandbox_limits: SandboxConfig,
|
||||
*,
|
||||
repo_root: Path,
|
||||
) -> SandboxJob | None:
|
||||
"""Return a sandbox job for `language`, or None when no recipe exists."""
|
||||
key = language.strip().lower()
|
||||
if key == "python":
|
||||
return _job(
|
||||
image,
|
||||
sandbox_limits,
|
||||
repo_root,
|
||||
command=("env", f"PYTHONPATH={WORKSPACE_MOUNT}", "python", f"{SCRATCH_MOUNT}/{_PYTHON_FILE}"),
|
||||
scratch_files={_PYTHON_FILE: snippet},
|
||||
)
|
||||
if key == "javascript":
|
||||
return _job(
|
||||
image,
|
||||
sandbox_limits,
|
||||
repo_root,
|
||||
command=("env", f"NODE_PATH={WORKSPACE_MOUNT}", "node", f"{SCRATCH_MOUNT}/{_JS_FILE}"),
|
||||
scratch_files={_JS_FILE: snippet},
|
||||
)
|
||||
if key == "c":
|
||||
source = f"{SCRATCH_MOUNT}/{_C_FILE}"
|
||||
binary = f"{SCRATCH_MOUNT}/{_C_BIN}"
|
||||
return _job(
|
||||
image,
|
||||
sandbox_limits,
|
||||
repo_root,
|
||||
command=("sh", "-c", f"cc {source} -o {binary} && {binary}"),
|
||||
scratch_files={_C_FILE: snippet},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _job(
|
||||
image: str,
|
||||
sandbox_limits: SandboxConfig,
|
||||
repo_root: Path,
|
||||
*,
|
||||
command: tuple[str, ...],
|
||||
scratch_files: dict[str, str],
|
||||
) -> SandboxJob:
|
||||
return SandboxJob(
|
||||
image=image,
|
||||
command=command,
|
||||
repo_root=repo_root,
|
||||
scratch_files=scratch_files,
|
||||
timeout_seconds=sandbox_limits.timeout_seconds,
|
||||
memory_mb=sandbox_limits.memory_mb,
|
||||
pids_limit=sandbox_limits.pids_limit,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Language recipes for isolated regression snippets (task 5.2)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openultrasast.config import SandboxConfig
|
||||
from openultrasast.regress import INCONCLUSIVE, RegressionRunner
|
||||
from openultrasast.regress.recipes import recipe_for
|
||||
from openultrasast.sandbox import FakeSandboxRunner, SandboxJob
|
||||
|
||||
LIMITS = SandboxConfig(memory_mb=256, timeout_seconds=30, pids_limit=128)
|
||||
PYTHON_SNIPPET = "assert 1 + 1 == 2\n"
|
||||
JS_SNIPPET = "console.log('ok')\n"
|
||||
C_SNIPPET = "int main(void) { return 0; }\n"
|
||||
JAVA_SNIPPET = "class Case { public static void main(String[] args) {} }\n"
|
||||
|
||||
|
||||
def _repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
return repo
|
||||
|
||||
|
||||
def test_python_recipe_runs_scratch_snippet_with_repo_on_pythonpath(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
job = recipe_for("python", PYTHON_SNIPPET, "python:3.12-alpine", LIMITS, repo_root=repo)
|
||||
|
||||
assert isinstance(job, SandboxJob)
|
||||
joined = " ".join(job.command)
|
||||
assert job.image == "python:3.12-alpine"
|
||||
assert job.repo_root == repo
|
||||
assert job.scratch_files == {"case.py": PYTHON_SNIPPET}
|
||||
assert "python" in job.command
|
||||
assert "/scratch/case.py" in job.command
|
||||
assert "PYTHONPATH=/workspace" in joined
|
||||
assert job.timeout_seconds == LIMITS.timeout_seconds
|
||||
assert job.memory_mb == LIMITS.memory_mb
|
||||
assert job.pids_limit == LIMITS.pids_limit
|
||||
|
||||
|
||||
def test_javascript_recipe_runs_scratch_snippet_with_repo_on_node_path(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
job = recipe_for("javascript", JS_SNIPPET, "node:22-alpine", LIMITS, repo_root=repo)
|
||||
|
||||
assert isinstance(job, SandboxJob)
|
||||
joined = " ".join(job.command)
|
||||
assert job.image == "node:22-alpine"
|
||||
assert job.repo_root == repo
|
||||
assert job.scratch_files == {"case.js": JS_SNIPPET}
|
||||
assert "node" in job.command
|
||||
assert "/scratch/case.js" in job.command
|
||||
assert "NODE_PATH=/workspace" in joined
|
||||
assert job.timeout_seconds == LIMITS.timeout_seconds
|
||||
assert job.memory_mb == LIMITS.memory_mb
|
||||
assert job.pids_limit == LIMITS.pids_limit
|
||||
|
||||
|
||||
def test_c_recipe_compiles_and_runs_when_image_has_compiler(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
job = recipe_for("c", C_SNIPPET, "gcc:13", LIMITS, repo_root=repo)
|
||||
|
||||
assert isinstance(job, SandboxJob)
|
||||
joined = " ".join(job.command)
|
||||
assert job.scratch_files == {"case.c": C_SNIPPET}
|
||||
assert "cc" in joined
|
||||
assert "/scratch/case.c" in joined
|
||||
assert "-o" in joined
|
||||
assert "/scratch/case" in joined
|
||||
|
||||
|
||||
def test_unknown_language_recipe_is_none(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
assert recipe_for("java", JAVA_SNIPPET, "eclipse-temurin:21", LIMITS, repo_root=repo) is None
|
||||
assert recipe_for("unknown", "print(1)\n", "python:3.12-alpine", LIMITS, repo_root=repo) is None
|
||||
|
||||
|
||||
def test_python_and_javascript_recipes_produce_runner_jobs(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
python_job = recipe_for("python", PYTHON_SNIPPET, "python:3.12-alpine", LIMITS, repo_root=repo)
|
||||
js_job = recipe_for("javascript", JS_SNIPPET, "node:22-alpine", LIMITS, repo_root=repo)
|
||||
sandbox = FakeSandboxRunner()
|
||||
runner = RegressionRunner(sandbox)
|
||||
|
||||
python_verdict = runner.run_recipe("python", PYTHON_SNIPPET, "python:3.12-alpine", LIMITS, repo_root=repo)
|
||||
js_verdict = runner.run_recipe("javascript", JS_SNIPPET, "node:22-alpine", LIMITS, repo_root=repo)
|
||||
|
||||
assert python_job is not None
|
||||
assert js_job is not None
|
||||
assert python_verdict.reason != "missing_recipe"
|
||||
assert js_verdict.reason != "missing_recipe"
|
||||
assert sandbox.jobs == [python_job, js_job]
|
||||
|
||||
|
||||
def test_unknown_language_returns_inconclusive_without_calling_runner(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
sandbox = FakeSandboxRunner()
|
||||
runner = RegressionRunner(sandbox)
|
||||
|
||||
assert recipe_for("java", JAVA_SNIPPET, "eclipse-temurin:21", LIMITS, repo_root=repo) is None
|
||||
verdict = runner.run_recipe("java", JAVA_SNIPPET, "eclipse-temurin:21", LIMITS, repo_root=repo)
|
||||
|
||||
assert verdict.verdict == INCONCLUSIVE
|
||||
assert verdict.reason == "missing_recipe"
|
||||
assert sandbox.jobs == []
|
||||
Reference in New Issue
Block a user