File size: 9,238 Bytes
d242052
 
 
 
 
 
 
 
 
 
 
 
 
 
ad2ec27
a5d33a6
d242052
 
 
 
 
 
 
 
 
 
ad2ec27
 
 
d242052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad2ec27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d242052
 
 
 
 
 
 
 
 
 
 
ad2ec27
d242052
ad2ec27
d242052
 
 
 
 
 
 
 
ad2ec27
d242052
 
ad2ec27
d242052
 
 
 
 
 
 
 
a5d33a6
 
 
d242052
 
 
 
 
 
 
 
 
ad2ec27
 
 
 
 
d242052
ad2ec27
d242052
 
 
ad2ec27
 
 
d242052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad2ec27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d242052
 
ad2ec27
 
 
 
 
 
d242052
 
ad2ec27
d242052
 
 
ad2ec27
 
 
 
 
 
 
d242052
 
 
 
 
 
a5d33a6
 
 
 
 
 
d242052
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""Safe subprocess execution utility.

Provides robust, secure, and standardized process execution across the platform.
Features:
- Mandatory non-shell execution (`shell=False` enforced)
- Enforced operation-specific timeouts
- Automated secret redaction in logs, outputs, and exceptions
- Non-interactive environment hardening for Git and external tools
- Structured error handling via SafeSubprocessError
"""

import logging
import os
import subprocess
import threading
from typing import Dict, Optional, Sequence, Union

logger = logging.getLogger(__name__)

# Standard timeout categories (in seconds)
SHORT_GIT_TIMEOUT = 15.0
INSPECTION_TIMEOUT = 30.0
HISTORY_ANALYSIS_TIMEOUT = 120.0
CLONE_TIMEOUT = 300.0
DEFAULT_TIMEOUT = 30.0

# Bounded output capture limit (16 KiB per stream to keep memory low on 512MB instances)
DEFAULT_MAX_CAPTURE_BYTES = 16384

# Non-interactive Git environment overrides
SAFE_GIT_ENV: Dict[str, str] = {
    "GIT_TERMINAL_PROMPT": "0",
    "GIT_ASKPASS": "",
    "GCM_INTERACTIVE": "never",
    "GIT_CONFIG_NOSYSTEM": "1",
    "LC_ALL": "C",
}


class SafeSubprocessError(RuntimeError):
    """Raised when a safe subprocess execution fails or times out."""

    def __init__(
        self,
        cmd: Sequence[str],
        returncode: int,
        stdout: str = "",
        stderr: str = "",
        timed_out: bool = False,
        message: Optional[str] = None,
    ) -> None:
        self.cmd = list(cmd)
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr
        self.timed_out = timed_out

        if message is None:
            if timed_out:
                message = f"Command '{' '.join(self.cmd)}' timed out."
            else:
                message = f"Command '{' '.join(self.cmd)}' failed with exit code {returncode}: {stderr.strip()}"
        super().__init__(message)


def redact_text(text: str, secrets: Sequence[str]) -> str:
    """Redacts all specified secrets from text."""
    if not text or not secrets:
        return text
    redacted = text
    for secret in secrets:
        if secret and len(secret) > 0:
            redacted = redacted.replace(secret, "[REDACTED]")
    return redacted


def _drain_pipe_bounded(pipe, max_bytes: int = DEFAULT_MAX_CAPTURE_BYTES) -> bytes:
    """Drain an OS pipe to completion while retaining at most max_bytes in memory.

    Discards any trailing data once max_bytes is reached to prevent high heap usage,
    while still reading to EOF to prevent the child process from blocking on full OS pipe buffers.
    """
    if pipe is None:
        return b""
    chunks = []
    total_len = 0
    try:
        while True:
            chunk = pipe.read(4096)
            if not chunk:
                break
            if total_len < max_bytes:
                to_take = min(len(chunk), max_bytes - total_len)
                chunks.append(chunk[:to_take])
                total_len += to_take
    except Exception:
        pass
    finally:
        try:
            pipe.close()
        except Exception:
            pass
    return b"".join(chunks)


def run_safe_command(
    cmd: Sequence[str],
    *,
    cwd: Optional[Union[str, os.PathLike]] = None,
    timeout: float = DEFAULT_TIMEOUT,
    env: Optional[Dict[str, str]] = None,
    secrets: Sequence[str] = (),
    check: bool = False,
    capture_output: bool = True,
    text: bool = True,
    max_output_length: int = 10000,
    max_capture_bytes: int = DEFAULT_MAX_CAPTURE_BYTES,
) -> subprocess.CompletedProcess:
    """Executes a subprocess safely with mandatory timeout, non-shell execution, and bounded output memory.

    Args:
        cmd: Command and arguments list. Must be a sequence of strings.
        cwd: Directory in which to execute the command.
        timeout: Execution timeout in seconds.
        env: Additional environment variables to set.
        secrets: List of sensitive strings to redact from logs, errors, and output.
        check: If True, raises SafeSubprocessError on non-zero exit code.
        capture_output: If True, capture stdout and stderr up to max_capture_bytes.
        text: If True, decode output as utf-8 text.
        max_output_length: Maximum length of stdout/stderr kept in exceptions.
        max_capture_bytes: Maximum bytes captured per stream (default 16 KiB).

    Returns:
        subprocess.CompletedProcess instance with sanitized stdout/stderr.

    Raises:
        SafeSubprocessError: If execution times out, executable is missing, or check=True fails.
    """
    if not isinstance(cmd, (list, tuple)):
        raise TypeError(
            "Command must be a list or tuple of strings (non-shell execution)."
        )

    cmd_list = [str(arg) for arg in cmd]

    # Build hardened environment
    merged_env = dict(os.environ)
    merged_env.update(SAFE_GIT_ENV)
    if env:
        merged_env.update(env)

    stdout_buf = []
    stderr_buf = []
    out_thread = None
    err_thread = None

    try:
        proc = subprocess.Popen(
            cmd_list,
            cwd=cwd,
            shell=False,  # Strictly non-shell execution
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE if capture_output else None,
            stderr=subprocess.PIPE if capture_output else None,
            env=merged_env,
        )
    except FileNotFoundError as exc:
        safe_cmd = [redact_text(arg, secrets) for arg in cmd_list]
        raise SafeSubprocessError(
            cmd=safe_cmd,
            returncode=-1,
            stdout="",
            stderr=f"Executable not found: {exc.filename}",
            timed_out=False,
            message=f"Executable not found for command: {' '.join(safe_cmd)}",
        ) from exc
    except Exception as exc:
        safe_cmd = [redact_text(arg, secrets) for arg in cmd_list]
        raise SafeSubprocessError(
            cmd=safe_cmd,
            returncode=-1,
            stdout="",
            stderr=str(exc),
            timed_out=False,
            message=f"Failed to execute command '{' '.join(safe_cmd)}': {exc}",
        ) from exc

    if capture_output:
        out_thread = threading.Thread(
            target=lambda: stdout_buf.append(
                _drain_pipe_bounded(proc.stdout, max_capture_bytes)
            ),
            daemon=True,
        )
        err_thread = threading.Thread(
            target=lambda: stderr_buf.append(
                _drain_pipe_bounded(proc.stderr, max_capture_bytes)
            ),
            daemon=True,
        )
        out_thread.start()
        err_thread.start()

    try:
        returncode = proc.wait(timeout=timeout)
    except subprocess.TimeoutExpired as exc:
        try:
            proc.kill()
        except Exception:
            pass
        try:
            proc.wait(timeout=2.0)
        except Exception:
            pass

        if out_thread:
            out_thread.join(timeout=1.0)
        if err_thread:
            err_thread.join(timeout=1.0)

        stdout_raw = stdout_buf[0] if stdout_buf else b""
        stderr_raw = stderr_buf[0] if stderr_buf else b""
        stdout_str = (
            redact_text(stdout_raw.decode("utf-8", errors="replace"), secrets)
            if text
            else ""
        )
        stderr_str = (
            redact_text(stderr_raw.decode("utf-8", errors="replace"), secrets)
            if text
            else ""
        )
        safe_cmd = [redact_text(arg, secrets) for arg in cmd_list]
        raise SafeSubprocessError(
            cmd=safe_cmd,
            returncode=-1,
            stdout=stdout_str[:max_output_length],
            stderr=stderr_str[:max_output_length],
            timed_out=True,
            message=f"Command '{' '.join(safe_cmd)}' timed out after {timeout}s",
        ) from exc

    if out_thread:
        out_thread.join(timeout=1.0)
    if err_thread:
        err_thread.join(timeout=1.0)

    stdout_raw = stdout_buf[0] if stdout_buf else b""
    stderr_raw = stderr_buf[0] if stderr_buf else b""

    # Redact output if text
    if text:
        stdout_clean = redact_text(
            stdout_raw.decode("utf-8", errors="replace"), secrets
        )
        stderr_clean = redact_text(
            stderr_raw.decode("utf-8", errors="replace"), secrets
        )
        completed = subprocess.CompletedProcess(
            args=[redact_text(arg, secrets) for arg in cmd_list],
            returncode=returncode,
            stdout=stdout_clean,
            stderr=stderr_clean,
        )
    else:
        completed = subprocess.CompletedProcess(
            args=[redact_text(arg, secrets) for arg in cmd_list],
            returncode=returncode,
            stdout=stdout_raw,
            stderr=stderr_raw,
        )

    if check and completed.returncode != 0:
        safe_cmd = [redact_text(arg, secrets) for arg in cmd_list]
        raise SafeSubprocessError(
            cmd=safe_cmd,
            returncode=completed.returncode,
            stdout=completed.stdout[:max_output_length]
            if isinstance(completed.stdout, str)
            else "",
            stderr=completed.stderr[:max_output_length]
            if isinstance(completed.stderr, str)
            else "",
            timed_out=False,
        )

    return completed