-
Notifications
You must be signed in to change notification settings - Fork 0
fix(ops): replay bounded subprocess integrity on current review workflow #1015
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
f05a8c4
🛡️ Sentinel: [MEDIUM] subprocess 호출에 대한 타임아웃 추가를 통한 DoS 취약점 해결
seonghobae 90ee6bc
docs(security): preserve Sentinel history for subprocess timeout
seonghobae 9bf3a07
test(security): prove GitHub CLI timeout boundaries
seonghobae 470b597
🛡️ Sentinel: [MEDIUM] subprocess 호출에 대한 타임아웃 추가를 통한 DoS 취약점 해결
seonghobae c0f68e7
Fix unbounded JSON loading in automation scripts
seonghobae 2528b0c
fix(ops): preserve sentinel history while recording subprocess timeout
seonghobae 27f7d2c
test(ops): lock GitHub command timeout fail-closed behavior
seonghobae 95b0223
test(ops): expose subprocess output-bound regressions
seonghobae bd6c90c
fix(ops): add byte-bounded subprocess capture
seonghobae acf21ec
chore(ops): reconcile bounded capture onto timeout parent
seonghobae ecc9335
fix(ops): bound PR queue gh JSON capture
seonghobae e1b0401
fix(ops): bound procurement gh JSON capture
seonghobae bbd5243
test(ops): cover bounded gh caller integration
seonghobae 760e47a
test(ops): expose bounded subprocess cleanup and decode defects
seonghobae b38ee0b
fix(ops): bound subprocess tree cleanup and strict stdout decode
seonghobae 91714ad
fix(ops): normalize invalid stdout as data-error result
seonghobae 07e3d26
test(ops): assert fail-closed decode status and process-tree deadline
seonghobae fbf2061
Fix unbounded JSON loading in automation scripts
seonghobae 7f1cf40
test(ops): restore subprocess process-tree and UTF-8 regressions
seonghobae d29f7f3
fix(ops): restore bounded process-tree and UTF-8 handling
seonghobae c4de2a4
test(ops): mock bounded GH runner after transport hardening
seonghobae ed0cf09
test(ops): target bounded runner in timeout regression
seonghobae 916696c
test(ops): mock bounded GitHub capture in governance tests
seonghobae 1bb4567
fix(ops): keep capture pipe validation active
seonghobae d734e16
fix(ops): reap bounded subprocess children
seonghobae e47b28b
test(ops): forbid re-signalling reaped process groups
seonghobae 0a348b4
fix(ops): avoid re-signalling reaped process groups
seonghobae 4bf401c
test(ops): require capture pipe cleanup on success
seonghobae 27d753a
fix(ops): close bounded capture pipes after success
seonghobae 050c01f
docs(changelog): record bounded subprocess cleanup
seonghobae 5a855b7
fix(ops): terminate pipe-owning subprocess descendants
seonghobae 7f71090
fix(ops): harden bounded capture edge cases
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Harden bounded subprocess cleanup | ||
|
|
||
| ## Fixed | ||
|
|
||
| - Keep governance and procurement subprocess capture bounded across stdout, stderr, execution time, decoding, and JSON parsing. POSIX cleanup now avoids re-signalling an already reaped process group, successful capture closes parent-side pipe descriptors deterministically, and timeout/overflow paths retain fail-closed evidence without weakening repository gates. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| """Bound subprocess stdout/stderr in memory while preserving a hard deadline.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import signal | ||
| import subprocess | ||
| import threading | ||
| import time | ||
| from collections.abc import Mapping, Sequence | ||
| from pathlib import Path | ||
|
|
||
| _READ_CHUNK_BYTES = 64 * 1024 | ||
| _DATA_ERROR_RETURN_CODE = 65 | ||
| _PROCESS_REAP_TIMEOUT_SECONDS = 5.0 | ||
|
|
||
|
|
||
| class BoundedSubprocessOutputError(RuntimeError): | ||
| """Raised when a captured subprocess stream exceeds its configured limit.""" | ||
|
|
||
| def __init__(self, stream: str, limit_bytes: int) -> None: | ||
| self.stream = stream | ||
| self.limit_bytes = limit_bytes | ||
| super().__init__(f"{stream} exceeded bounded capture limit of {limit_bytes} bytes") | ||
|
|
||
|
|
||
| class BoundedSubprocessDecodeError(UnicodeError): | ||
| """Describe machine-readable subprocess stdout that is not valid UTF-8.""" | ||
|
|
||
| def __init__(self, stream: str) -> None: | ||
| self.stream = stream | ||
| super().__init__(f"{stream} was not valid UTF-8") | ||
|
|
||
|
|
||
| def _drain_bounded( | ||
| stream: object, | ||
| *, | ||
| limit_bytes: int, | ||
| buffer: bytearray, | ||
| overflow: threading.Event, | ||
| ) -> None: | ||
| """Drain one binary pipe without retaining more than ``limit_bytes + 1`` bytes. | ||
|
|
||
| ``BufferedReader.read`` may wait for the requested chunk size even when a | ||
| smaller payload is already available. ``read1`` observes the pipe promptly, | ||
| which keeps overflow detection independent from a descendant holding the | ||
| write end open. | ||
| """ | ||
| if hasattr(stream, "read1"): | ||
| read = stream.read1 # type: ignore[attr-defined] | ||
| else: | ||
| read = stream.read # type: ignore[attr-defined] | ||
| while True: | ||
| try: | ||
| chunk = read(_READ_CHUNK_BYTES) | ||
| except (OSError, ValueError): | ||
| return | ||
| if not chunk: | ||
| return | ||
| remaining = (limit_bytes + 1) - len(buffer) | ||
| if remaining > 0: | ||
| buffer.extend(chunk[:remaining]) | ||
| if len(buffer) > limit_bytes: | ||
| overflow.set() | ||
|
|
||
|
|
||
| def _terminate_process_tree( | ||
| process: subprocess.Popen[bytes], | ||
| *, | ||
| terminate_descendants: bool = False, | ||
| ) -> None: | ||
| """Terminate and bounded-reap the owned process tree. | ||
|
|
||
| A live process group is terminated after the direct child exits only when | ||
| a capture reader still proves that a descendant owns the pipe. The default | ||
| keeps repeated cleanup from signalling a reaped process group. | ||
| """ | ||
| if process.poll() is None or (terminate_descendants and os.name == "posix"): | ||
| if os.name == "posix": | ||
| try: | ||
| os.killpg(process.pid, signal.SIGKILL) | ||
| except OSError: | ||
| pass | ||
| else: | ||
| try: | ||
| process.kill() | ||
| except OSError: | ||
| pass | ||
|
seonghobae marked this conversation as resolved.
|
||
| try: | ||
| process.wait(timeout=_PROCESS_REAP_TIMEOUT_SECONDS) | ||
| except subprocess.TimeoutExpired: | ||
| # The caller still has a hard deadline; leave pipe cleanup to the | ||
| # existing close path if a hostile child ignores the bounded reap. | ||
| pass | ||
|
|
||
|
|
||
| def _close_capture_pipes(process: subprocess.Popen[bytes]) -> None: | ||
| """Close parent-side capture pipes to unblock any remaining daemon reader.""" | ||
| for stream in (process.stdout, process.stderr): | ||
| if stream is not None: | ||
| try: | ||
| stream.close() | ||
| except (OSError, ValueError): | ||
| pass | ||
|
|
||
|
|
||
| def _remaining(deadline: float) -> float: | ||
| """Return non-negative seconds remaining before one absolute deadline.""" | ||
| return max(0.0, deadline - time.monotonic()) | ||
|
|
||
|
|
||
| def run_bounded_capture( | ||
| command: Sequence[str], | ||
| *, | ||
| timeout_seconds: float, | ||
| max_stdout_bytes: int, | ||
| max_stderr_bytes: int, | ||
| cwd: str | Path | None = None, | ||
| env: Mapping[str, str] | None = None, | ||
| ) -> subprocess.CompletedProcess[str]: | ||
| """Run ``command`` with one hard deadline and bounded output capture. | ||
|
|
||
| Stdout and stderr are drained concurrently so neither pipe can deadlock the | ||
| child. POSIX commands run in a dedicated session so timeout/overflow cleanup | ||
| can terminate descendants that inherited a capture pipe. Process reaping | ||
| and reader joins share the original deadline rather than extending it. | ||
| Machine-readable stdout is decoded strictly as UTF-8. Malformed stdout | ||
| becomes a stable data-error result rather than replacement-decoded content; | ||
| diagnostic stderr alone uses replacement decoding. | ||
| """ | ||
| if timeout_seconds <= 0: | ||
| raise ValueError("timeout_seconds must be positive") | ||
| if max_stdout_bytes < 0 or max_stderr_bytes < 0: | ||
| raise ValueError("output limits must be non-negative") | ||
| if not command: | ||
| raise ValueError("command must not be empty") | ||
|
|
||
| process = subprocess.Popen( | ||
| list(command), | ||
| cwd=cwd, | ||
| env=dict(env) if env is not None else None, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| text=False, | ||
| start_new_session=os.name == "posix", | ||
| ) | ||
| if process.stdout is None or process.stderr is None: | ||
| process.kill() | ||
| process.wait() | ||
| raise RuntimeError("bounded capture requires stdout and stderr pipes") | ||
|
|
||
| stdout = bytearray() | ||
| stderr = bytearray() | ||
| stdout_overflow = threading.Event() | ||
| stderr_overflow = threading.Event() | ||
| readers = [ | ||
| threading.Thread( | ||
| target=_drain_bounded, | ||
| kwargs={ | ||
| "stream": process.stdout, | ||
| "limit_bytes": max_stdout_bytes, | ||
| "buffer": stdout, | ||
| "overflow": stdout_overflow, | ||
| }, | ||
| daemon=True, | ||
| ), | ||
| threading.Thread( | ||
| target=_drain_bounded, | ||
| kwargs={ | ||
| "stream": process.stderr, | ||
| "limit_bytes": max_stderr_bytes, | ||
| "buffer": stderr, | ||
| "overflow": stderr_overflow, | ||
| }, | ||
| daemon=True, | ||
| ), | ||
| ] | ||
| for reader in readers: | ||
| reader.start() | ||
|
|
||
| deadline = time.monotonic() + timeout_seconds | ||
| timed_out = False | ||
| overflowed = False | ||
| while process.poll() is None: | ||
| if stdout_overflow.is_set() or stderr_overflow.is_set(): | ||
| overflowed = True | ||
| _terminate_process_tree(process) | ||
| break | ||
| remaining = _remaining(deadline) | ||
| if remaining <= 0.0: | ||
| timed_out = True | ||
| _terminate_process_tree(process) | ||
| break | ||
| time.sleep(min(0.01, remaining)) | ||
|
|
||
| if process.poll() is None: | ||
| try: | ||
| process.wait(timeout=_remaining(deadline)) | ||
| except subprocess.TimeoutExpired: | ||
| timed_out = True | ||
| _terminate_process_tree(process) | ||
|
|
||
| for reader in readers: | ||
| reader.join(timeout=_remaining(deadline)) | ||
| if reader.is_alive(): | ||
| overflowed = overflowed or stdout_overflow.is_set() or stderr_overflow.is_set() | ||
| if not overflowed: | ||
| timed_out = True | ||
| _terminate_process_tree(process, terminate_descendants=True) | ||
| _close_capture_pipes(process) | ||
| break | ||
|
seonghobae marked this conversation as resolved.
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| if timed_out: | ||
| _terminate_process_tree(process) | ||
| _close_capture_pipes(process) | ||
| raise subprocess.TimeoutExpired(list(command), timeout_seconds) | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| if stdout_overflow.is_set(): | ||
| _terminate_process_tree(process) | ||
| _close_capture_pipes(process) | ||
| raise BoundedSubprocessOutputError("stdout", max_stdout_bytes) | ||
| if stderr_overflow.is_set(): | ||
| _terminate_process_tree(process) | ||
| _close_capture_pipes(process) | ||
| raise BoundedSubprocessOutputError("stderr", max_stderr_bytes) | ||
|
|
||
| _close_capture_pipes(process) | ||
| stderr_text = stderr.decode("utf-8", errors="replace") | ||
| try: | ||
| stdout_text = stdout.decode("utf-8", errors="strict") | ||
| except UnicodeDecodeError: | ||
| decode_error = BoundedSubprocessDecodeError("stdout") | ||
| diagnostic = stderr_text.strip() | ||
| if diagnostic: | ||
| diagnostic = f"{diagnostic}\n{decode_error}" | ||
| else: | ||
| diagnostic = str(decode_error) | ||
| return subprocess.CompletedProcess( | ||
| list(command), | ||
| _DATA_ERROR_RETURN_CODE, | ||
| "", | ||
| diagnostic, | ||
| ) | ||
| return subprocess.CompletedProcess( | ||
| list(command), | ||
| process.returncode, | ||
| stdout_text, | ||
| stderr_text, | ||
| ) | ||
|
seonghobae marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.