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:

  1. Is there a dispatch task for this work? If not, create one.
  2. Does the work touch auth, database schema, secrets, or new API routes? → A CISO review is required before merge.
  3. 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() in finally
  • [ ] 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=True with 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.