524 lines
11 KiB
Markdown
524 lines
11 KiB
Markdown
# Security Patterns for uv Scripts
|
|
|
|
Security best practices for Python single-file scripts following Virgo-Core patterns.
|
|
|
|
## Never Hardcode Secrets
|
|
|
|
### ❌ Anti-Pattern: Hardcoded Secrets
|
|
|
|
```python
|
|
# NEVER DO THIS
|
|
API_KEY = "sk_live_1234567890abcdef"
|
|
DATABASE_URL = "postgresql://user:password@localhost/db"
|
|
PROXMOX_PASSWORD = "admin123"
|
|
```
|
|
|
|
**Risks:**
|
|
|
|
- Secrets exposed in version control
|
|
- No audit trail
|
|
- Difficult to rotate
|
|
- Same credentials across environments
|
|
|
|
### ✅ Pattern 1: Environment Variables
|
|
|
|
```python
|
|
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = []
|
|
# ///
|
|
|
|
import os
|
|
import sys
|
|
|
|
def get_secret(name: str, required: bool = True) -> str:
|
|
"""Get secret from environment"""
|
|
value = os.getenv(name)
|
|
|
|
if required and not value:
|
|
print(f"Error: {name} environment variable not set", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return value
|
|
|
|
# Usage
|
|
PROXMOX_PASSWORD = get_secret("PROXMOX_PASSWORD")
|
|
API_URL = get_secret("PROXMOX_API_URL")
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```bash
|
|
export PROXMOX_PASSWORD="$(cat ~/.secrets/proxmox_pass)"
|
|
./script.py
|
|
```
|
|
|
|
### ✅ Pattern 2: Keyring Integration
|
|
|
|
```python
|
|
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "keyring>=24.0.0",
|
|
# ]
|
|
# ///
|
|
|
|
import keyring
|
|
import sys
|
|
|
|
def get_password(service: str, username: str) -> str:
|
|
"""Get password from system keyring"""
|
|
password = keyring.get_password(service, username)
|
|
|
|
if not password:
|
|
print(f"Error: No password found for {username}@{service}", file=sys.stderr)
|
|
print(f"Set with: keyring set {service} {username}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return password
|
|
|
|
# Usage
|
|
proxmox_password = get_password("proxmox", "terraform@pam")
|
|
```
|
|
|
|
**Setup:**
|
|
|
|
```bash
|
|
# Store password in system keyring
|
|
keyring set proxmox terraform@pam
|
|
# Prompts for password, stores securely
|
|
|
|
# Run script (no password in environment)
|
|
./script.py
|
|
```
|
|
|
|
### ✅ Pattern 3: Infisical Integration (Repository Standard)
|
|
|
|
```python
|
|
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "infisical-python>=2.3.3",
|
|
# ]
|
|
# ///
|
|
|
|
from infisical import InfisicalClient
|
|
import os
|
|
import sys
|
|
|
|
def get_infisical_secret(
|
|
secret_name: str,
|
|
project_id: str,
|
|
environment: str = "prod",
|
|
path: str = "/"
|
|
) -> str:
|
|
"""Get secret from Infisical vault"""
|
|
try:
|
|
client = InfisicalClient()
|
|
secret = client.get_secret(
|
|
secret_name=secret_name,
|
|
project_id=project_id,
|
|
environment=environment,
|
|
path=path
|
|
)
|
|
return secret.secret_value
|
|
except Exception as e:
|
|
print(f"Error retrieving secret {secret_name}: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Usage (following Virgo-Core pattern)
|
|
PROXMOX_PASSWORD = get_infisical_secret(
|
|
secret_name="PROXMOX_PASSWORD",
|
|
project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
|
|
environment="prod",
|
|
path="/matrix"
|
|
)
|
|
```
|
|
|
|
**Setup:**
|
|
|
|
```bash
|
|
# Authenticate with Infisical
|
|
infisical login
|
|
|
|
# Run script (secrets fetched automatically)
|
|
./script.py
|
|
```
|
|
|
|
## Input Validation
|
|
|
|
### ❌ Anti-Pattern: No Validation
|
|
|
|
```python
|
|
import subprocess
|
|
|
|
# DANGEROUS - Command injection risk
|
|
def ping_host(hostname: str):
|
|
subprocess.run(f"ping -c 3 {hostname}", shell=True)
|
|
|
|
# User provides: "localhost; rm -rf /"
|
|
ping_host(sys.argv[1]) # 💥 System destroyed
|
|
```
|
|
|
|
### ✅ Pattern: Validate All Inputs
|
|
|
|
```python
|
|
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = []
|
|
# ///
|
|
|
|
import re
|
|
import sys
|
|
import subprocess
|
|
|
|
def validate_hostname(hostname: str) -> bool:
|
|
"""Validate hostname format"""
|
|
# Only allow alphanumeric, dots, hyphens
|
|
pattern = r'^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$'
|
|
if not re.match(pattern, hostname):
|
|
return False
|
|
|
|
# Max length check
|
|
if len(hostname) > 253:
|
|
return False
|
|
|
|
return True
|
|
|
|
def ping_host(hostname: str):
|
|
"""Safely ping a host"""
|
|
if not validate_hostname(hostname):
|
|
print(f"Error: Invalid hostname: {hostname}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Use list form (no shell injection)
|
|
result = subprocess.run(
|
|
["ping", "-c", "3", hostname],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
return result.returncode == 0
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: ping_host.py <hostname>", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
ping_host(sys.argv[1])
|
|
```
|
|
|
|
### Input Validation Patterns
|
|
|
|
```python
|
|
import re
|
|
from ipaddress import IPv4Address, AddressValueError
|
|
|
|
def validate_ip(ip: str) -> bool:
|
|
"""Validate IPv4 address"""
|
|
try:
|
|
IPv4Address(ip)
|
|
return True
|
|
except AddressValueError:
|
|
return False
|
|
|
|
def validate_port(port: int) -> bool:
|
|
"""Validate TCP/UDP port number"""
|
|
return 1 <= port <= 65535
|
|
|
|
def validate_vmid(vmid: int) -> bool:
|
|
"""Validate Proxmox VMID (100-999999999)"""
|
|
return 100 <= vmid <= 999999999
|
|
|
|
def validate_path(path: str) -> bool:
|
|
"""Validate file path (no directory traversal)"""
|
|
# Reject paths with ../
|
|
if ".." in path:
|
|
return False
|
|
|
|
# Reject absolute paths
|
|
if path.startswith("/"):
|
|
return False
|
|
|
|
return True
|
|
```
|
|
|
|
## Dependency Security
|
|
|
|
### Pin Dependencies
|
|
|
|
```python
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "httpx>=0.27.0", # ✅ Minimum version pinned
|
|
# "rich>=13.0.0", # ✅ Known good version
|
|
# ]
|
|
# ///
|
|
```
|
|
|
|
**Why pin?**
|
|
|
|
- Prevents automatic upgrades with vulnerabilities
|
|
- Ensures reproducible execution
|
|
- Allows controlled dependency updates
|
|
|
|
### Exclude Recent Packages
|
|
|
|
```python
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = ["httpx>=0.27.0"]
|
|
#
|
|
# [tool.uv]
|
|
# exclude-newer = "2024-10-01T00:00:00Z" # Only packages before this date
|
|
# ///
|
|
```
|
|
|
|
**Use cases:**
|
|
|
|
- Prevent supply chain attacks from compromised packages
|
|
- Freeze dependencies at known-good state
|
|
- Reproducible builds in CI/CD
|
|
|
|
### Check for Vulnerabilities
|
|
|
|
```bash
|
|
# Use safety or pip-audit
|
|
uv pip install safety
|
|
safety check --json
|
|
|
|
# Or use built-in tools
|
|
uv pip list --format json | jq '.[] | select(.name == "httpx")'
|
|
```
|
|
|
|
## File Operations Security
|
|
|
|
### ❌ Anti-Pattern: Unsafe File Access
|
|
|
|
```python
|
|
# DANGEROUS - Path traversal vulnerability
|
|
def read_log(filename: str):
|
|
with open(f"/var/log/{filename}") as f:
|
|
return f.read()
|
|
|
|
# User provides: "../../../etc/passwd"
|
|
read_log(sys.argv[1]) # 💥 Reads /etc/passwd
|
|
```
|
|
|
|
### ✅ Pattern: Safe File Operations
|
|
|
|
```python
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
def safe_read_log(filename: str, log_dir: str = "/var/log") -> str:
|
|
"""Safely read log file"""
|
|
# Resolve to absolute paths
|
|
log_dir_path = Path(log_dir).resolve()
|
|
file_path = (log_dir_path / filename).resolve()
|
|
|
|
# Ensure file is within log directory
|
|
try:
|
|
file_path.relative_to(log_dir_path)
|
|
except ValueError:
|
|
print(f"Error: Path traversal detected: {filename}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Check file exists and is readable
|
|
if not file_path.exists():
|
|
print(f"Error: File not found: {filename}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not file_path.is_file():
|
|
print(f"Error: Not a file: {filename}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Read with size limit
|
|
MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
|
if file_path.stat().st_size > MAX_SIZE:
|
|
print(f"Error: File too large: {filename}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
with open(file_path) as f:
|
|
return f.read()
|
|
```
|
|
|
|
## Command Execution Security
|
|
|
|
### ❌ Anti-Pattern: Shell Injection
|
|
|
|
```python
|
|
# DANGEROUS
|
|
import subprocess
|
|
|
|
def list_directory(path: str):
|
|
subprocess.run(f"ls -la {path}", shell=True)
|
|
|
|
# User provides: "; rm -rf /"
|
|
list_directory(sys.argv[1]) # 💥 Disaster
|
|
```
|
|
|
|
### ✅ Pattern: Safe Command Execution
|
|
|
|
```python
|
|
import subprocess
|
|
import sys
|
|
|
|
def list_directory(path: str):
|
|
"""Safely list directory contents"""
|
|
# Validate path first
|
|
if not validate_path(path):
|
|
print(f"Error: Invalid path: {path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Use list form (no shell)
|
|
try:
|
|
result = subprocess.run(
|
|
["ls", "-la", path],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=5 # Prevent hanging
|
|
)
|
|
print(result.stdout)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Error: {e.stderr}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except subprocess.TimeoutExpired:
|
|
print("Error: Command timed out", file=sys.stderr)
|
|
sys.exit(1)
|
|
```
|
|
|
|
## Logging and Audit
|
|
|
|
### Secure Logging
|
|
|
|
```python
|
|
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "structlog>=24.0.0",
|
|
# ]
|
|
# ///
|
|
|
|
import structlog
|
|
import sys
|
|
|
|
# Configure structured logging
|
|
structlog.configure(
|
|
processors=[
|
|
structlog.processors.add_log_level,
|
|
structlog.processors.TimeStamper(fmt="iso"),
|
|
structlog.processors.JSONRenderer(),
|
|
],
|
|
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
)
|
|
|
|
log = structlog.get_logger()
|
|
|
|
def process_data(user_id: int, action: str):
|
|
"""Process user action with audit logging"""
|
|
log.info(
|
|
"user_action",
|
|
user_id=user_id,
|
|
action=action,
|
|
# Don't log sensitive data!
|
|
)
|
|
|
|
# ... processing logic ...
|
|
|
|
log.info(
|
|
"action_completed",
|
|
user_id=user_id,
|
|
action=action,
|
|
status="success"
|
|
)
|
|
```
|
|
|
|
### Never Log Secrets
|
|
|
|
```python
|
|
# ❌ BAD - Logs password
|
|
log.info(f"Connecting with password: {password}")
|
|
|
|
# ✅ GOOD - No sensitive data
|
|
log.info("Connecting to API", endpoint=api_url)
|
|
|
|
# ✅ GOOD - Masked credentials
|
|
log.info("Authentication successful", user=username)
|
|
```
|
|
|
|
## Network Security
|
|
|
|
### HTTPS Only
|
|
|
|
```python
|
|
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = ["httpx>=0.27.0"]
|
|
# ///
|
|
|
|
import httpx
|
|
import sys
|
|
|
|
def fetch_api(url: str) -> dict:
|
|
"""Fetch data from API (HTTPS only)"""
|
|
# Validate HTTPS
|
|
if not url.startswith("https://"):
|
|
print("Error: Only HTTPS URLs allowed", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
with httpx.Client(verify=True) as client: # Verify SSL certs
|
|
response = client.get(url, timeout=10.0)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
```
|
|
|
|
### Certificate Verification
|
|
|
|
```python
|
|
import httpx
|
|
import certifi
|
|
|
|
# Use trusted CA bundle
|
|
client = httpx.Client(verify=certifi.where())
|
|
|
|
# For internal CAs, specify cert path
|
|
client = httpx.Client(verify="/path/to/internal-ca.pem")
|
|
|
|
# ONLY disable for development (never in production)
|
|
if os.getenv("DEVELOPMENT") == "true":
|
|
client = httpx.Client(verify=False)
|
|
```
|
|
|
|
## Security Checklist
|
|
|
|
Before deploying a script:
|
|
|
|
- [ ] No hardcoded secrets
|
|
- [ ] Secrets from environment/keyring/Infisical
|
|
- [ ] All inputs validated
|
|
- [ ] No shell=True in subprocess
|
|
- [ ] File paths checked for traversal
|
|
- [ ] Dependencies pinned
|
|
- [ ] No sensitive data in logs
|
|
- [ ] HTTPS for network requests
|
|
- [ ] Certificate verification enabled
|
|
- [ ] Timeouts on external calls
|
|
- [ ] Error messages don't leak information
|
|
- [ ] Principle of least privilege applied
|
|
|
|
## References
|
|
|
|
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
|
- [Infisical Python SDK](https://infisical.com/docs/sdks/languages/python)
|
|
- [Python Security Best Practices](https://python.readthedocs.io/en/latest/library/security_warnings.html)
|
|
- Virgo-Core Infisical integration: [../../ansible/tasks/infisical-secret-lookup.yml](../../ansible/tasks/infisical-secret-lookup.yml)
|