Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions backend/secuscan/parser_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
import re
import sys
import subprocess
import string
import logging
from pathlib import Path
from typing import Any, Dict
Expand Down Expand Up @@ -79,22 +78,35 @@ def __init__(self, plugin_id: str, reason: str, stderr: str = "") -> None:
self.reason = reason
# Keep stderr private; callers must not surface this to API consumers.
self._stderr_diagnostic: str = stderr
# User-facing message: reason only — no stderr content.
super().__init__(f"Parser sandbox failed for '{plugin_id}' ({reason})")

@property
def stderr_excerpt(self) -> str:
"""Public read-only access to a truncated stderr excerpt (max 2000 chars)."""
return self._stderr_diagnostic[:2000]

def __str__(self) -> str:
base = f"Parser sandbox failed for '{self.plugin_id}' ({self.reason})"
if self._stderr_diagnostic:
excerpt = self._stderr_diagnostic[:500]
return f"{base}\nstderr: {excerpt}"
return base


# ---------------------------------------------------------------------------
# Bootstrap script injected into the child process via -c
# ---------------------------------------------------------------------------
# Uses string.Template (${var} syntax) instead of str.format() so that
# user-controlled values in parser_path (e.g. braces) cannot cause
# KeyError/ValueError via format-string injection.
# Uses str.format() with {var} placeholders. The path is repr()'d via the
# {parser_path!r} conversion so it becomes a valid Python string literal even
# on Windows or when the path contains special characters. Any literal curly
# braces that appear *inside* the generated Python code are double-escaped
# (e.g. {{exc}}) so they survive .format() without being treated as slots.

_BOOTSTRAP_TEMPLATE = string.Template(
_BOOTSTRAP_TEMPLATE = (
"import sys, json, os\n"
"\n"
"# Hard limit: refuse to read more than ${max_input_bytes} bytes from stdin.\n"
"MAX_INPUT = ${max_input_bytes}\n"
"# Hard limit: refuse to read more than {max_input_bytes} bytes from stdin.\n"
"MAX_INPUT = {max_input_bytes}\n"
"raw = sys.stdin.buffer.read(MAX_INPUT + 1)\n"
"if len(raw) > MAX_INPUT:\n"
" sys.stderr.write('Parser input exceeded size limit\\n')\n"
Expand All @@ -104,12 +116,12 @@ def __init__(self, plugin_id: str, reason: str, stderr: str = "") -> None:
" envelope = json.loads(raw.decode('utf-8', errors='replace'))\n"
" parser_input = envelope['input']\n"
"except Exception as exc:\n"
" sys.stderr.write(f'Failed to decode envelope: {exc}\\n')\n"
" sys.stderr.write(f'Failed to decode envelope: {{exc}}\\n')\n"
" sys.exit(3)\n"
"\n"
"# Load the plugin's parser module from an absolute path.\n"
"import importlib.util\n"
"parser_path = ${parser_path_repr}\n"
"parser_path = {parser_path!r}\n"
"spec = importlib.util.spec_from_file_location('_plugin_parser', parser_path)\n"
"if spec is None or spec.loader is None:\n"
" sys.stderr.write(f'Cannot load parser\\n')\n"
Expand Down Expand Up @@ -168,10 +180,10 @@ def run_parser_in_sandbox(

max_input_bytes = max(len(parser_input.encode("utf-8")) + 128, 64 * 1024)

# Use Template.safe_substitute so that any stray $ in parser_path does not
# raise; repr() ensures the path is a valid Python string literal.
bootstrap = _BOOTSTRAP_TEMPLATE.safe_substitute(
parser_path_repr=repr(str(parser_path)),
# {parser_path!r} in the template applies repr() automatically, producing a
# valid Python string literal even on Windows paths with backslashes.
bootstrap = _BOOTSTRAP_TEMPLATE.format(
parser_path=str(parser_path),
max_input_bytes=max_input_bytes,
)

Expand Down Expand Up @@ -256,21 +268,21 @@ def _read_stderr() -> None:
timeout_seconds,
plugin_id,
)
# Log sanitized stderr for internal diagnostics; do NOT pass to exception.
logger.debug(
"Parser sandbox stderr (plugin '%s', timed out): %s",
plugin_id,
_sanitize_stderr(stderr_text),
)
raise ParserSandboxError(plugin_id, f"timed out after {timeout_seconds}s")
raise ParserSandboxError(
plugin_id, f"timed out after {timeout_seconds}s", stderr=stderr_text
)

if proc.returncode != 0:
logger.error(
"Parser sandbox exited with code %d for plugin '%s'",
proc.returncode,
plugin_id,
)
# Log sanitized stderr for internal diagnostics; do NOT pass to exception.
logger.debug(
"Parser sandbox stderr (plugin '%s', exit %d): %s",
plugin_id,
Expand All @@ -280,6 +292,7 @@ def _read_stderr() -> None:
raise ParserSandboxError(
plugin_id,
f"subprocess exited with code {proc.returncode}",
stderr=stderr_text,
)

stdout_bytes = b"".join(stdout_chunks)
Expand Down
Loading
Loading