Skip to content

Commit 66b7f5e

Browse files
committed
fix: add process-based timeout for Terraform file parsing
Signed-off-by: Mohamed Medhat Mohamed Ibrahim Shalaby <[email protected]>
1 parent 91b57c3 commit 66b7f5e

File tree

3 files changed

+132
-6
lines changed

3 files changed

+132
-6
lines changed

checkov/common/util/stopit/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
from .utils import TimeoutException
1616
from .threadstop import ThreadingTimeout, async_raise, threading_timeoutable
1717
from .signalstop import SignalTimeout, signal_timeoutable
18+
from .processstop import ProcessTimeout, process_timeoutable
1819

1920

2021
__all__ = (
2122
'ThreadingTimeout', 'async_raise', 'threading_timeoutable',
22-
'SignalTimeout', 'signal_timeoutable', 'TimeoutException'
23+
'SignalTimeout', 'signal_timeoutable', 'TimeoutException',
24+
'ProcessTimeout', 'process_timeoutable'
2325
)
+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
=================
4+
stopit.processstop
5+
=================
6+
7+
Control the timeout of blocks or callables with a context manager or a
8+
decorator. Based on the use of multiprocessing for enforcing timeouts.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import multiprocessing
14+
from typing import Callable, Any
15+
16+
from .utils import TimeoutException, BaseTimeout, base_timeoutable
17+
18+
19+
def process_target(block: Callable, args: tuple, kwargs: dict, return_dict: dict) -> None:
20+
"""Run the block of code in a subprocess.
21+
22+
:param block: The function to execute in the subprocess.
23+
:param args: Positional arguments for the block function.
24+
:param kwargs: Keyword arguments for the block function.
25+
:param return_dict: Shared dictionary to store the result or error.
26+
"""
27+
try:
28+
# Call the block function with provided arguments and store the result
29+
result = block(*args, **kwargs)
30+
return_dict['result'] = result
31+
except Exception as e:
32+
# Store the error in return_dict
33+
return_dict['error'] = str(e)
34+
35+
36+
class ProcessTimeout(BaseTimeout):
37+
"""Context manager for enforcing timeouts using multiprocessing.
38+
39+
See :class:`stopit.utils.BaseTimeout` for more information
40+
"""
41+
def __init__(self, seconds: int, swallow_exc: bool = True) -> None:
42+
super().__init__(seconds, swallow_exc)
43+
self.process: multiprocessing.Process | None = None
44+
self.manager: multiprocessing.Manager | None = None
45+
self.return_dict: multiprocessing.Dict | None = None
46+
self.block: Callable | None = None
47+
self.args: tuple = ()
48+
self.kwargs: dict = {}
49+
50+
def set_block(self, block: Callable, *args: Any, **kwargs: Any) -> None:
51+
"""Set the block of code to execute
52+
"""
53+
if not callable(block):
54+
raise ValueError("Block function must be callable.")
55+
self.block = block
56+
self.args = args
57+
self.kwargs = kwargs
58+
59+
def setup_interrupt(self) -> None:
60+
"""Setting up the resource that interrupts the block
61+
"""
62+
if not self.block:
63+
raise ValueError("No block function provided for execution.")
64+
65+
self.manager = multiprocessing.Manager()
66+
self.return_dict = self.manager.dict()
67+
68+
# Start the subprocess
69+
self.process = multiprocessing.Process(
70+
target=process_target, args=(self.block, self.args, self.kwargs, self.return_dict)
71+
)
72+
self.process.start()
73+
74+
# Wait for the process to complete or timeout
75+
self.process.join(self.seconds)
76+
if self.process.is_alive():
77+
# If still alive after timeout, terminate and raise TimeoutException
78+
self.process.terminate()
79+
self.state = self.TIMED_OUT
80+
raise TimeoutException(f"Block exceeded maximum timeout value ({self.seconds} seconds).")
81+
82+
def suppress_interrupt(self) -> None:
83+
"""Removing the resource that interrupts the block
84+
"""
85+
if self.process and self.process.is_alive():
86+
self.process.terminate() # Ensure the process is terminated
87+
if 'error' in self.return_dict:
88+
raise Exception(f"Error during execution: {self.return_dict['error']}")
89+
if self.manager:
90+
self.manager.shutdown()
91+
92+
def get_result(self) -> Any:
93+
"""Retrieve the result of the block execution
94+
"""
95+
if self.return_dict and 'result' in self.return_dict:
96+
return self.return_dict['result']
97+
return None
98+
99+
100+
class process_timeoutable(base_timeoutable): # noqa: B903
101+
"""A function or method decorator that raises a ``TimeoutException`` for
102+
decorated functions that exceed a certain amount of time. This uses the
103+
``ProcessTimeout`` context manager.
104+
105+
See :class:`.utils.base_timeoutable`` for further comments.
106+
"""
107+
def __init__(self) -> None:
108+
super().__init__()
109+
self.to_ctx_mgr = ProcessTimeout

checkov/terraform/tf_parser.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from checkov.common.util.data_structures_utils import pickle_deepcopy
1818
from checkov.common.util.deep_merge import pickle_deep_merge
1919
from checkov.common.util.env_vars_config import env_vars_config
20-
from checkov.common.util.stopit import ThreadingTimeout, SignalTimeout
20+
from checkov.common.util.stopit import ThreadingTimeout, SignalTimeout, ProcessTimeout
2121
from checkov.common.util.stopit.utils import BaseTimeout
2222
from checkov.common.util.type_forcers import force_list
2323
from checkov.common.variables.context import EvaluationContext
@@ -742,20 +742,35 @@ def load_or_die_quietly(
742742

743743
# if we are not running in a thread, run the hcl2.load function with a timeout, to prevent from getting stuck in parsing.
744744
def __parse_with_timeout(f: TextIO) -> dict[str, list[dict[str, Any]]]:
745+
"""Parse files with a timeout mechanism.
746+
747+
Attempts to use SignalTimeout for Unix systems on the main thread,
748+
ThreadingTimeout for Windows, and ProcessTimeout
749+
as a fallback for non-main threads and blocking operations.
750+
"""
745751
# setting up timeout class
746752
timeout_class: Optional[Type[BaseTimeout]] = None
747753
if platform.system() == 'Windows':
748754
timeout_class = ThreadingTimeout
749755
elif threading.current_thread() is threading.main_thread():
750756
timeout_class = SignalTimeout
757+
else:
758+
timeout_class = ProcessTimeout
751759

752-
# if we're not running on the main thread, don't use timeout
753760
parsing_timeout = env_vars_config.HCL_PARSE_TIMEOUT_SEC or 0
754-
if not timeout_class or not parsing_timeout:
761+
if not parsing_timeout:
755762
return hcl2.load(f)
756763

757-
with timeout_class(parsing_timeout) as to_ctx_mgr:
758-
raw_data = hcl2.load(f)
764+
to_ctx_mgr = timeout_class(parsing_timeout)
765+
if isinstance(to_ctx_mgr, ProcessTimeout):
766+
to_ctx_mgr.set_block(hcl2.loads, f.read())
767+
768+
with to_ctx_mgr:
769+
if isinstance(to_ctx_mgr, ProcessTimeout):
770+
raw_data = to_ctx_mgr.get_result()
771+
else:
772+
raw_data = hcl2.load(f)
773+
759774
if to_ctx_mgr.state == to_ctx_mgr.TIMED_OUT:
760775
logging.debug(f"reached timeout when parsing file {f} using hcl2")
761776
raise Exception(f"file took more than {parsing_timeout} seconds to parse")

0 commit comments

Comments
 (0)