fix(security): replace relative_to() with realpath+startswith for CodeQL sanitization

Path injection (#22, #28, #29, #30):
- Switch from Path.relative_to() to os.path.realpath() + str.startswith()
  in all four path-validation sites across finetune and rl UI data_loader.py
  and finetune app.py. CodeQL recognizes realpath+startswith as a path-
  traversal sanitizer and clears taint on the resulting Path object.
- Also simplify finetune/app.py: replace try/except relative_to block with
  the same realpath+startswith guard.

Missing workflow permissions (#32, #33, #34, #35):
- Add top-level permissions: contents: read to ci.yml, docs.yml, lint.yml,
  and security.yml. The docs deploy job already had pages: write and
  id-token: write set correctly on the job level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TPTBusiness
2026-04-17 21:59:03 +02:00
parent 5f735adcb1
commit d8ab86d6cf
7 changed files with 39 additions and 32 deletions
+3
View File
@@ -17,6 +17,9 @@ on:
env:
PYTHONUNBUFFERED: "1"
permissions:
contents: read
jobs:
test:
name: Test (Python ${{ matrix.python-version }}, ${{ matrix.os }})
+3
View File
@@ -15,6 +15,9 @@ on:
- 'README.md'
- '**/*.rst'
permissions:
contents: read
jobs:
docs:
name: Build Documentation
+3
View File
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
lint:
name: Lint & Format
+3
View File
@@ -9,6 +9,9 @@ on:
# Weekly on Monday at 6:00 UTC
- cron: '0 6 * * 1'
permissions:
contents: read
jobs:
security:
name: Security Analysis
+7 -6
View File
@@ -142,13 +142,14 @@ def main():
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
# Normalize and validate the base folder against the configured log root
safe_root = Path(default_log).expanduser().resolve()
try:
base_path = Path(base_folder).expanduser().resolve()
# Ensure the user-selected base path is within the safe root
base_path.relative_to(safe_root)
except (OSError, ValueError):
root_real = os.path.realpath(str(Path(default_log).expanduser()))
folder_real = os.path.realpath(str(Path(base_folder).expanduser()))
if folder_real == root_real or folder_real.startswith(root_real + os.sep):
base_path = Path(folder_real)
safe_root = Path(root_real)
else:
st.error("Invalid base folder: must be within the configured log directory.")
safe_root = Path(root_real)
base_path = safe_root
# base_path is validated against safe_root nosec B614
+10 -13
View File
@@ -3,6 +3,7 @@ FT UI Data Loader
Load pkl logs and convert to hierarchical timeline structure
"""
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
@@ -88,13 +89,11 @@ def extract_stage(tag: str) -> str:
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
"""Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None:
try:
resolved_root = safe_root.expanduser().resolve()
resolved_folder = log_folder.expanduser().resolve()
# Reconstruct from the trusted root so file ops use a root-derived path.
log_folder = resolved_root / resolved_folder.relative_to(resolved_root)
except ValueError:
root_real = os.path.realpath(str(safe_root.expanduser()))
folder_real = os.path.realpath(str(log_folder.expanduser()))
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
return []
log_folder = Path(folder_real)
if not log_folder.exists():
return []
@@ -376,13 +375,11 @@ def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root."""
# Validate path is within safe_root if provided
if safe_root is not None:
try:
resolved_root = safe_root.expanduser().resolve()
resolved_path = log_path.expanduser().resolve()
# Reconstruct from the trusted root so file ops use a root-derived path.
log_path = resolved_root / resolved_path.relative_to(resolved_root)
except ValueError:
return Session() # Return empty session if path is outside allowed root
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(log_path.expanduser()))
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
return Session()
log_path = Path(path_real)
session = Session()
storage = FileStorage(log_path)
+10 -13
View File
@@ -4,6 +4,7 @@ Load pkl logs and convert to hierarchical timeline structure
Simplified version: no EvoLoop (RL doesn't have evolution loops)
"""
import os
import pickle
import re
from dataclasses import dataclass, field
@@ -75,13 +76,11 @@ def extract_stage(tag: str) -> str:
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
"""Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None:
try:
resolved_root = safe_root.expanduser().resolve()
resolved_folder = log_folder.expanduser().resolve()
# Reconstruct from the trusted root so file ops use a root-derived path.
log_folder = resolved_root / resolved_folder.relative_to(resolved_root)
except ValueError:
root_real = os.path.realpath(str(safe_root.expanduser()))
folder_real = os.path.realpath(str(log_folder.expanduser()))
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
return []
log_folder = Path(folder_real)
if not log_folder.exists():
return []
@@ -248,13 +247,11 @@ def load_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root."""
# Validate path is within safe_root if provided
if safe_root is not None:
try:
resolved_root = safe_root.expanduser().resolve()
resolved_path = log_path.expanduser().resolve()
# Reconstruct from the trusted root so file ops use a root-derived path.
log_path = resolved_root / resolved_path.relative_to(resolved_root)
except ValueError:
return Session() # Return empty session if path is outside allowed root
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(log_path.expanduser()))
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
return Session()
log_path = Path(path_real)
session = Session()