Developer Security Guide 🔐
Classification: Public
Owner: Raziel (CISO)
Last updated: 2026-03-05
Audience: Contributors and developers building on or extending AnisminOS
This guide covers the security requirements for contributing code to AnisminOS. Every rule here exists because something went wrong before, or because our threat model demands it.
If you're looking for the high-level security posture overview, see Security Posture.
Before You Start
Before writing any production code:
- Is there a dispatch task for this work? If not, create one.
- Does the work touch auth, database schema, secrets, or new API routes? → A CISO review is required before merge.
- Are you working in a branch? → Always branch first, never commit directly to
main.
Authentication: Use What's There
AnisminOS provides authentication decorators. Use them. Don't roll your own.
For new API routes
Every new route must be protected with the existing auth decorators — one for agent token validation, one for browser session RBAC. Both are required.
Do NOT: - Skip auth on a route because "it's read-only" - Write custom session checks inline in route handlers - Assume a route is safe from abuse because it returns non-sensitive data
After authentication, the verified agent identity is available on the request context for audit logging.
Database: Parameterize Everything
AnisminOS uses PostgreSQL via a connection pool. Rules:
Always parameterize SQL
# ✅ Correct — placeholder, no string formatting
cur.execute('SELECT * FROM items WHERE id = %s', (item_id,))
# ❌ Wrong — SQL injection waiting to happen
cur.execute(f"SELECT * FROM items WHERE id = {item_id}")
cur.execute("SELECT * FROM items WHERE id = '{}'".format(item_id))
There are no exceptions to this rule. Even if you think the value is safe, parameterize it. A future refactor might make it unsafe.
Always return connections to the pool
conn = get_db()
try:
# ... do work ...
conn.commit()
finally:
return_db(conn) # ALWAYS in a finally block — never skip this
If return_db() is not in a finally block and an exception is raised, the connection leaks. Enough leaks exhaust the pool and take down the service.
Never run DDL from application code
CREATE TABLE, ALTER TABLE, DROP, TRUNCATE — none of these belong in route handlers or application logic. Schema changes go through migration scripts, reviewed and run by the infrastructure team.
Input Validation
Validate every field before trusting it.
# ✅ Validate against an allowlist of valid values
VALID_STATUSES = ('assigned', 'in_progress', 'blocked', 'complete')
if status not in VALID_STATUSES:
return jsonify({'error': f'status must be one of {VALID_STATUSES}'}), 400
# ✅ Strip and require non-empty strings
title = data.get('title', '').strip()
if not title:
return jsonify({'error': 'title is required'}), 400
# ✅ Type-check collections
dep_ids = data.get('depends_on', [])
if not isinstance(dep_ids, list) or not all(isinstance(x, int) for x in dep_ids):
return jsonify({'error': 'depends_on must be an array of integers'}), 400
Don't validate on the frontend only. Validate on the backend, every time, regardless of what the client says.
Secrets: Zero Tolerance for Hardcoding
Never do this
# ❌ Hardcoded credentials
PASSWORD = "hunter2"
API_KEY = "sk-..."
# ❌ Secret in a log message
logger.info(f"Connecting with password={password}")
# ❌ Secret printed to stdout
print(f"Token: {token}")
Always do this
# ✅ Load from environment at runtime
password = os.environ.get("MY_SERVICE_PASSWORD")
if not password:
raise RuntimeError("MY_SERVICE_PASSWORD not set in environment")
If you need a new secret added to the environment, ask the infrastructure team. Never add it to source code or commit it to the repository under any circumstances.
Before committing, scan your diff for anything that looks like a secret. If you're unsure, ask.
New Route Checklist
When adding a new Flask route, check every item:
- [ ] Mutates data? → Auth decorator with
'write'permission - [ ] Reads sensitive data? → Auth decorator with
'read'permission - [ ] Truly public (unauthenticated)? → Justify it explicitly. Almost nothing should be.
- [ ] Accepts user input? → Validate every field, every type
- [ ] Runs SQL? → Parameterized queries only;
return_db()infinally - [ ] Calls a subprocess? → Use a list (not a string),
shell=False - [ ] Reads or writes files? → Validate and canonicalize paths (see below)
- [ ] Logs anything? → Confirm no secrets appear in log messages
Subprocess Safety
# ✅ Safe — list form, shell=False (default)
result = subprocess.check_output(['systemctl', 'is-active', 'myservice'], text=True)
# ⚠️ Caution — shell=True is acceptable ONLY with no user input
result = subprocess.check_output("df -B1 | grep /dev", shell=True, text=True)
# ❌ Dangerous — user input in a shell string = command injection
result = subprocess.check_output(f"ls {user_path}", shell=True)
If user input must influence a subprocess call, pass it as a list element — never interpolate it into a shell string.
File Path Safety
If a route accepts a user-supplied filename or path, canonicalize and validate it before use:
import os
BASE_DIR = '/your/safe/base/directory'
def safe_path(user_input: str) -> str:
"""Resolve path and ensure it stays within BASE_DIR."""
full = os.path.realpath(os.path.join(BASE_DIR, user_input))
if not full.startswith(BASE_DIR + os.sep):
raise ValueError("Path traversal rejected")
return full
os.path.realpath resolves symlinks and ../ sequences. Always use it before checking whether a path is allowed. Never trust os.path.join alone.
CSRF for HTML Forms
If you add a new HTML form (not a JSON API endpoint), it must include a CSRF token.
In the template:
<form method="POST" action="/my-app/submit">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<!-- rest of form fields -->
</form>
In the route handler:
@bp.route('/submit', methods=['POST'])
def submit():
if not _validate_csrf_token():
return jsonify({'error': 'Invalid request'}), 403
# continue with safe input
JSON API endpoints do not need CSRF tokens — authenticated agent tokens + CORS preflight requirements provide equivalent protection.
Logging Security Events
Use structured log messages for anything security-relevant. Be consistent so logs are parseable:
import logging
logger = logging.getLogger("anismin.myapp")
# Security event format
logger.warning(
f"SECURITY EVENT | type=auth_fail user={user} ip={ip} path={request.path}"
)
Never log: - Passwords or password hashes - Session tokens or agent tokens - Encryption keys or secrets of any kind - Full credit card numbers, SSNs, or other PII
If in doubt, log the ID of the thing, not the thing itself.
When You Must Get a CISO Review
You must request a review before shipping if your change:
- Adds or modifies authentication or session logic
- Adds a new unauthenticated (public) route
- Changes database schema or connection permissions
- Introduces a new external service or third-party integration
- Handles file uploads or user-supplied file paths
- Changes how tokens or credentials are generated, stored, or validated
- Is classified HIGH or CRITICAL priority
A review means: create a dispatch task assigned to Raziel, wait for complete status before deploying to production.
Production Deployment Checklist
Before any production change:
- [ ] Syntax check passes:
python3 -m py_compile <changed files> - [ ] No secrets in the diff (scan it manually before committing)
- [ ] No
shell=Truewith user-controlled input - [ ] All new SQL uses parameterized queries
- [ ] All new routes have auth decorators
- [ ] Raziel reviewed if on the trigger list above
- [ ] Service restarts cleanly after the change
- [ ] Security headers still present after restart (verify with
curl -sI)
Common Mistakes We've Seen
These have happened. Don't repeat them.
| Mistake | Why It's Bad | Fix |
|---|---|---|
| SQL string formatting with user input | SQL injection — attacker can read/modify/delete any data | Always use %s placeholders |
Skipping return_db() on error paths |
Connection pool exhaustion → service outage | Always use try/finally |
| Hardcoded credentials in source | Credentials end up in git history permanently | Load from environment |
| Missing auth on "internal" routes | No such thing as "internal-only" if it's HTTP | Every route needs auth |
shell=True with user-supplied data |
Arbitrary command execution | Use list form, shell=False |
| Logging secrets for debugging | Secrets end up in log files, rotation doesn't help | Never log secrets |
Trusting os.path.join for path safety |
../ traversal still works |
Use os.path.realpath + prefix check |
Questions & Security Concerns
- Found a bug or vulnerability? Report to Raziel (CISO) privately — not in public issues.
- Unsure if your change needs a security review? Err on the side of asking. A 10-minute review beats a 3am incident.
- See the Security Posture doc for the full picture on what controls are active.