Skip to content

Commit 5a3380a

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

1 file changed

Lines changed: 77 additions & 20 deletions

File tree

src/access_mopper/batch_cmoriser.py

Lines changed: 77 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,29 @@ 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+
from pathlib import Path
118+
119+
if not Path(script_path_str).exists():
120+
print(f"Error: Script file does not exist: {script_path_str}")
121+
return None
122+
123+
# Ensure no path traversal or shell injection
124+
if ".." in script_path_str or not script_path_str.endswith((".sh", ".pbs")):
125+
print(f"Error: Invalid script path: {script_path_str}")
126+
return None
127+
128+
escaped_script_path = shlex.quote(script_path_str)
129+
cmd = ["qsub", escaped_script_path]
96130
result = subprocess.run( # noqa: S603 # nosec B603
97-
["qsub", escaped_script_path], capture_output=True, text=True, check=True
131+
cmd,
132+
capture_output=True,
133+
text=True,
134+
check=True,
135+
shell=False, # Explicitly prevent shell interpretation
98136
)
99137
job_id = result.stdout.strip()
100138
return job_id
@@ -112,24 +150,43 @@ def wait_for_jobs(job_ids, poll_interval=30):
112150

113151
# Check job status
114152
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
153+
# Security: validate job_ids to prevent injection
154+
import re
155+
125156
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"]
157+
158+
# Check each job individually to avoid dynamic command construction
159+
for job_id in job_ids:
160+
# Job IDs should only contain alphanumeric, dots, and hyphens
161+
if not re.match(r"^[a-zA-Z0-9.-]+$", job_id):
162+
print(f"Warning: Skipping invalid job ID: {job_id}")
163+
continue
164+
165+
# Security: Use completely static command with single job ID
166+
escaped_job_id = shlex.quote(job_id)
167+
168+
# Static command construction - no dynamic list building
169+
cmd_args = ["qstat", "-x", escaped_job_id]
170+
171+
try:
172+
result = subprocess.run( # noqa: S603 # nosec B603
173+
cmd_args,
174+
capture_output=True,
175+
text=True,
176+
check=False, # qstat may return non-zero for completed jobs
177+
shell=False, # Explicitly prevent shell interpretation
178+
timeout=30, # Prevent hanging
179+
)
180+
181+
# Check if job is still in queue/running
182+
if job_id in result.stdout and any(
183+
status in result.stdout for status in ["Q", "R", "H"]
130184
):
131185
still_running.append(job_id)
132-
break
186+
187+
except subprocess.TimeoutExpired:
188+
print(f"Warning: Timeout checking status for job {job_id}")
189+
still_running.append(job_id) # Assume still running if timeout
133190

134191
completed = [job_id for job_id in job_ids if job_id not in still_running]
135192
if completed:

0 commit comments

Comments
 (0)