7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
329 lines
12 KiB
Python
Executable File
329 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Simple pre-commit hook to check for direct os.environ usage.
|
|
This is a lightweight check - comprehensive validation happens in CI.
|
|
"""
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
# Allowlist entries, partitioned by matching strategy.
|
|
#
|
|
# Previously this was a single ALLOWED_PATTERNS set checked with
|
|
# `pattern in self.filename` — a bare substring match. That incorrectly
|
|
# exempted production files whose path happened to contain an allowlist
|
|
# substring: e.g. "test_" matched protest_handler.py, "settings/" matched
|
|
# foo_settings_override.py, and so on. Partition the list instead so each
|
|
# entry matches at the right granularity.
|
|
|
|
# Directory subtrees where env-var access is allowed (settings / tests /
|
|
# scripts / examples / migrations / the hooks themselves).
|
|
ALLOWED_PATH_SEGMENTS = {
|
|
"settings",
|
|
"config",
|
|
"tests",
|
|
"scripts",
|
|
"examples",
|
|
"migrations",
|
|
".pre-commit-hooks",
|
|
}
|
|
|
|
# Filename prefixes for test files.
|
|
ALLOWED_NAME_PREFIXES = ("test_",)
|
|
|
|
# Filename suffixes for Go-style *_test.py convention.
|
|
ALLOWED_NAME_SUFFIXES = ("_test.py",)
|
|
|
|
# Exact basenames for bootstrap / infrastructure modules that run before
|
|
# SettingsManager is initialized.
|
|
ALLOWED_NAMES = {
|
|
"log_utils.py", # Logger init before DB/SettingsManager
|
|
"server_config.py", # Fail-closed security validation for LDR_APP_ALLOW_REGISTRATIONS
|
|
"sqlcipher_utils.py", # Encryption init needs LDR_TEST_MODE before SettingsManager
|
|
}
|
|
|
|
# Path-anchored entries where the basename alone is too generic to match
|
|
# reliably.
|
|
# Leading "/" anchors the path segment so endswith() cannot match lookalike
|
|
# paths (e.g. "notsecurity/secure_logging.py").
|
|
ALLOWED_PATH_ENDINGS = (
|
|
"/security/rate_limiter.py", # Module-level RATE_LIMIT_FAIL_CLOSED at decorator time
|
|
"/security/secure_logging.py", # Diagnose-mode gate must work before/without SettingsManager (same rationale as log_utils.py)
|
|
)
|
|
|
|
# System environment variables that are always allowed
|
|
SYSTEM_VARS = {
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"PYTHONPATH",
|
|
"TMPDIR",
|
|
"TEMP",
|
|
"TZ", # Standard POSIX timezone variable
|
|
"CI",
|
|
"GITHUB_ACTIONS",
|
|
"TESTING", # External testing flag
|
|
"PYTEST_CURRENT_TEST", # Pytest test detection
|
|
"WERKZEUG_RUN_MAIN", # Flask/Werkzeug debug reloader detection
|
|
}
|
|
|
|
|
|
class EnvVarChecker(ast.NodeVisitor):
|
|
def __init__(self, filename: str):
|
|
self.filename = filename
|
|
self.errors = []
|
|
|
|
def visit_ImportFrom(self, node):
|
|
"""Check for 'from os import environ/getenv' direct imports."""
|
|
if not self._is_file_allowed() and node.module == "os" and node.names:
|
|
for alias in node.names:
|
|
if alias.name in ("environ", "getenv"):
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
f"Direct import 'from os import {alias.name}' — use SettingsManager instead of direct env var access",
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def visit_Assign(self, node):
|
|
"""Check for aliasing: env = os.environ."""
|
|
if not self._is_file_allowed():
|
|
# Check the right-hand side for os.environ attribute access
|
|
if (
|
|
isinstance(node.value, ast.Attribute)
|
|
and node.value.attr == "environ"
|
|
and isinstance(node.value.value, ast.Name)
|
|
and node.value.value.id == "os"
|
|
):
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
"Aliasing os.environ to a local variable — use SettingsManager instead",
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def visit_Call(self, node):
|
|
# Check for os.environ.get() or os.getenv()
|
|
is_environ_get = False
|
|
env_var_name = None
|
|
|
|
# Pattern 1: os.environ.get("VAR_NAME") or os.environ.get(variable)
|
|
if (
|
|
isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "get"
|
|
and isinstance(node.func.value, ast.Attribute)
|
|
and node.func.value.attr == "environ"
|
|
and isinstance(node.func.value.value, ast.Name)
|
|
and node.func.value.value.id == "os"
|
|
):
|
|
is_environ_get = True
|
|
if node.args and isinstance(node.args[0], ast.Constant):
|
|
env_var_name = node.args[0].value
|
|
|
|
# Pattern 2: os.getenv("VAR_NAME") or os.getenv(variable)
|
|
elif (
|
|
isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "getenv"
|
|
and isinstance(node.func.value, ast.Name)
|
|
and node.func.value.id == "os"
|
|
):
|
|
is_environ_get = True
|
|
if node.args and isinstance(node.args[0], ast.Constant):
|
|
env_var_name = node.args[0].value
|
|
|
|
# Pattern 3: os.environ.pop() / .setdefault() / .update()
|
|
if (
|
|
isinstance(node.func, ast.Attribute)
|
|
and node.func.attr in ("pop", "setdefault", "update")
|
|
and isinstance(node.func.value, ast.Attribute)
|
|
and node.func.value.attr == "environ"
|
|
and isinstance(node.func.value.value, ast.Name)
|
|
and node.func.value.value.id == "os"
|
|
):
|
|
if not self._is_file_allowed():
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
f"Direct os.environ.{node.func.attr}() call — use SettingsManager instead",
|
|
)
|
|
)
|
|
|
|
if is_environ_get:
|
|
if env_var_name:
|
|
# Known constant key — allow system vars
|
|
if env_var_name in SYSTEM_VARS:
|
|
return self.generic_visit(node)
|
|
|
|
if not self._is_file_allowed():
|
|
if env_var_name.startswith("LDR_"):
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ",
|
|
)
|
|
)
|
|
else:
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager",
|
|
)
|
|
)
|
|
else:
|
|
# Dynamic key (variable, not a string literal)
|
|
if not self._is_file_allowed():
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
"Dynamic environment variable access (variable key) — use SettingsManager instead",
|
|
)
|
|
)
|
|
|
|
self.generic_visit(node)
|
|
|
|
def visit_Subscript(self, node):
|
|
# Check for os.environ["VAR_NAME"] pattern
|
|
if (
|
|
isinstance(node.value, ast.Attribute)
|
|
and node.value.attr == "environ"
|
|
and isinstance(node.value.value, ast.Name)
|
|
and node.value.value.id == "os"
|
|
):
|
|
if isinstance(node.slice, ast.Constant):
|
|
env_var_name = node.slice.value
|
|
|
|
# Allow system vars
|
|
if env_var_name in SYSTEM_VARS:
|
|
return self.generic_visit(node)
|
|
|
|
if not self._is_file_allowed():
|
|
if env_var_name.startswith("LDR_"):
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ",
|
|
)
|
|
)
|
|
else:
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager",
|
|
)
|
|
)
|
|
else:
|
|
# Dynamic subscript: os.environ[variable]
|
|
if not self._is_file_allowed():
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
"Dynamic os.environ[variable] access — use SettingsManager instead",
|
|
)
|
|
)
|
|
|
|
self.generic_visit(node)
|
|
|
|
def visit_Compare(self, node):
|
|
"""Check for 'KEY in os.environ' containment checks."""
|
|
if not self._is_file_allowed():
|
|
for comparator in node.comparators:
|
|
if (
|
|
isinstance(comparator, ast.Attribute)
|
|
and comparator.attr == "environ"
|
|
and isinstance(comparator.value, ast.Name)
|
|
and comparator.value.id == "os"
|
|
):
|
|
# Check if the left side is a system var
|
|
if (
|
|
isinstance(node.left, ast.Constant)
|
|
and node.left.value in SYSTEM_VARS
|
|
):
|
|
continue
|
|
self.errors.append(
|
|
(
|
|
node.lineno,
|
|
"'... in os.environ' check — use SettingsManager instead of direct env var access",
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def _is_file_allowed(self) -> bool:
|
|
"""Check if this file is allowed to use os.environ directly."""
|
|
p = Path(self.filename)
|
|
if p.name in ALLOWED_NAMES:
|
|
return True
|
|
if p.name.startswith(ALLOWED_NAME_PREFIXES):
|
|
return True
|
|
if p.name.endswith(ALLOWED_NAME_SUFFIXES):
|
|
return True
|
|
if ALLOWED_PATH_SEGMENTS.intersection(p.parts):
|
|
return True
|
|
for ending in ALLOWED_PATH_ENDINGS:
|
|
if self.filename.endswith(ending):
|
|
return True
|
|
return False
|
|
|
|
|
|
def check_file(filename: str) -> bool:
|
|
"""Check a single Python file for direct env var access."""
|
|
if not filename.endswith(".py"):
|
|
return True
|
|
|
|
try:
|
|
with open(filename, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
except Exception as e:
|
|
print(f"Error reading {filename}: {e}")
|
|
return False
|
|
|
|
try:
|
|
tree = ast.parse(content, filename=filename)
|
|
checker = EnvVarChecker(filename)
|
|
checker.visit(tree)
|
|
|
|
if checker.errors:
|
|
print(f"\n{filename}:")
|
|
for line_num, error in checker.errors:
|
|
print(f" Line {line_num}: {error}")
|
|
return False
|
|
|
|
except SyntaxError:
|
|
# Skip files with syntax errors
|
|
pass
|
|
except Exception as e:
|
|
print(f"Error parsing {filename}: {e}")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def main():
|
|
"""Main function to check all staged Python files."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: check-env-vars.py <file1> <file2> ...")
|
|
sys.exit(1)
|
|
|
|
files_to_check = sys.argv[1:]
|
|
has_errors = False
|
|
|
|
for filename in files_to_check:
|
|
if not check_file(filename):
|
|
has_errors = True
|
|
|
|
if has_errors:
|
|
print("\n⚠️ Direct environment variable access detected!")
|
|
print("\nFor LDR_ variables, use SettingsManager instead of os.environ")
|
|
print("See issue #598 for migration details")
|
|
print("\nNote: Full validation runs in CI")
|
|
sys.exit(1)
|
|
else:
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|