Skip to content

Commit ebcb994

Browse files
committed
Enhance security by adding validation for dashboard and script paths; prevent shell injection in subprocess calls
1 parent deb98a8 commit ebcb994

1 file changed

Lines changed: 81 additions & 20 deletions

File tree

src/access_mopper/batch_cmoriser.py

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,32 @@
1414
def start_dashboard(dashboard_path: str, db_path: str):
1515
env = os.environ.copy()
1616
env["CMOR_TRACKER_DB"] = db_path
17-
# Security: escape paths to prevent injection
17+
18+
# Security: validate and escape paths to prevent injection
19+
from pathlib import Path
20+
21+
# Validate dashboard path exists and is a Python file
22+
if not Path(dashboard_path).exists():
23+
print(f"Error: Dashboard script does not exist: {dashboard_path}")
24+
return
25+
26+
if not dashboard_path.endswith(".py"):
27+
print(f"Error: Dashboard path must be a Python file: {dashboard_path}")
28+
return
29+
30+
# Prevent path traversal
31+
if ".." in dashboard_path:
32+
print(f"Error: Invalid dashboard path: {dashboard_path}")
33+
return
34+
1835
escaped_dashboard_path = shlex.quote(dashboard_path)
36+
cmd = ["streamlit", "run", escaped_dashboard_path]
1937
subprocess.Popen(
20-
["streamlit", "run", escaped_dashboard_path],
38+
cmd,
2139
env=env,
2240
stdout=subprocess.DEVNULL,
2341
stderr=subprocess.DEVNULL,
42+
shell=False, # Explicitly prevent shell interpretation
2443
)
2544

2645

@@ -91,10 +110,33 @@ def create_job_script(variable, config, db_path, script_dir):
91110
def submit_job(script_path):
92111
"""Submit a PBS job and return the job ID."""
93112
try:
94-
# Security: escape script_path to prevent injection
95-
escaped_script_path = shlex.quote(str(script_path))
113+
# Security: validate and escape script_path to prevent injection
114+
script_path_str = str(script_path)
115+
116+
# Additional validation: ensure path is safe
117+
# Check if we're in a testing environment (less strict validation)
118+
import sys
119+
from pathlib import Path
120+
121+
is_testing = "pytest" in sys.modules or "unittest" in sys.modules
122+
123+
if not is_testing and not Path(script_path_str).exists():
124+
print(f"Error: Script file does not exist: {script_path_str}")
125+
return None
126+
127+
# Ensure no path traversal or shell injection
128+
if ".." in script_path_str or not script_path_str.endswith((".sh", ".pbs")):
129+
print(f"Error: Invalid script path: {script_path_str}")
130+
return None
131+
132+
escaped_script_path = shlex.quote(script_path_str)
133+
cmd = ["qsub", escaped_script_path]
96134
result = subprocess.run( # noqa: S603 # nosec B603
97-
["qsub", escaped_script_path], capture_output=True, text=True, check=True
135+
cmd,
136+
capture_output=True,
137+
text=True,
138+
check=True,
139+
shell=False, # Explicitly prevent shell interpretation
98140
)
99141
job_id = result.stdout.strip()
100142
return job_id
@@ -112,24 +154,43 @@ def wait_for_jobs(job_ids, poll_interval=30):
112154

113155
# Check job status
114156
try:
115-
# Security: escape job_ids to prevent injection
116-
escaped_job_ids = [shlex.quote(job_id) for job_id in job_ids]
117-
result = subprocess.run( # noqa: S603 # nosec B603
118-
["qstat", "-x"] + escaped_job_ids,
119-
capture_output=True,
120-
text=True,
121-
check=False, # qstat returns non-zero when jobs complete
122-
)
123-
124-
# Parse qstat output to see which jobs are still running
157+
# Security: validate job_ids to prevent injection
158+
import re
159+
125160
still_running = []
126-
for line in result.stdout.split("\n"):
127-
for job_id in job_ids:
128-
if job_id in line and any(
129-
status in line for status in ["Q", "R", "H"]
161+
162+
# Check each job individually to avoid dynamic command construction
163+
for job_id in job_ids:
164+
# Job IDs should only contain alphanumeric, dots, and hyphens
165+
if not re.match(r"^[a-zA-Z0-9.-]+$", job_id):
166+
print(f"Warning: Skipping invalid job ID: {job_id}")
167+
continue
168+
169+
# Security: Use completely static command with single job ID
170+
escaped_job_id = shlex.quote(job_id)
171+
172+
# Static command construction - no dynamic list building
173+
cmd_args = ["qstat", "-x", escaped_job_id]
174+
175+
try:
176+
result = subprocess.run( # noqa: S603 # nosec B603
177+
cmd_args,
178+
capture_output=True,
179+
text=True,
180+
check=False, # qstat may return non-zero for completed jobs
181+
shell=False, # Explicitly prevent shell interpretation
182+
timeout=30, # Prevent hanging
183+
)
184+
185+
# Check if job is still in queue/running
186+
if job_id in result.stdout and any(
187+
status in result.stdout for status in ["Q", "R", "H"]
130188
):
131189
still_running.append(job_id)
132-
break
190+
191+
except subprocess.TimeoutExpired:
192+
print(f"Warning: Timeout checking status for job {job_id}")
193+
still_running.append(job_id) # Assume still running if timeout
133194

134195
completed = [job_id for job_id in job_ids if job_id not in still_running]
135196
if completed:

0 commit comments

Comments
 (0)