diff --git a/CLAUDE.md b/CLAUDE.md index 086ff07..2a9238f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,12 @@ # corvid +> **⚠ DEPRECATED — 2026-06-16.** Ticket management has moved to **Domus** +> (`domus.welvaert.org`, repo `domus`). The Domus tickets app reached corvid +> feature parity and now backs the `homelab-tickets` MCP, served over the +> network at **`domus-mcp.welvaert.org`** (authenticated with a Domus API key). +> corvid is retained read-only for historical data and reference — **do not +> build new work on it**. See `domus/docs/rewrite/mcp.md`. + Standalone ticket-management service for AI agents. FastAPI + Postgres + MCP server. Extracted from `homelab-dashboard` — the `homelab-dashboard` app continues to use @@ -71,6 +78,14 @@ TICKETS_API_KEY="" DATABASE_URL="sqlite+aiosqlite:///dev.db" \ uvicorn corvid.main:app --reload --port 8090 ``` +## IaC sync + +Corvid runs embedded inside the homelab-dashboard Docker image — it is not deployed +as a standalone service via Ansible. However, its config surface (env vars, ports, +API key) is reflected in `pve/ansible/roles/homedash/templates/env.j2` and +`pve/ansible/host_vars/hetzner.yml`. If corvid's env requirements change (new vars, +renamed keys), update those files to prevent drift flagged by the compliance runner. + ## DB migrations Alembic runs at startup (`entrypoint.sh`). Add new migrations in diff --git a/Dockerfile b/Dockerfile index 290a985..b98eb56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM python:3.12-slim -RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/* - WORKDIR /app COPY pyproject.toml . RUN pip install --no-cache-dir -e ".[mcp]" diff --git a/SECURITY_REVIEW.md b/SECURITY_REVIEW.md new file mode 100644 index 0000000..7bb13a3 --- /dev/null +++ b/SECURITY_REVIEW.md @@ -0,0 +1,66 @@ +# Security Review — corvid + +Generated: 2026-07-13 (automated authorized audit) + +Supersedes the prior manual review dated 2026-06-02. + +## Scope + +All tracked files (`git ls-files`) of corvid — a FastAPI ticketing service with an +MCP server (`corvid/main.py`, `corvid/router.py`, `corvid/models.py`, +`corvid/database.py`, `mcp_server.py`), Alembic migrations, Jinja templates, and the +container/compose definitions. + +## Methodology + +Manual source inspection of the auth layer, all query construction, template rendering, +and config; plus greps for committed secrets and unsafe sinks. No automated scanners +were available. + +## Summary + +| Severity | Count | +|----------|-------| +| Critical | 0 | +| High | 0 | +| Medium | 1 | +| Low | 1 | +| Info | 1 | + +## Findings + +### M1 — Auth fails open when `TICKETS_API_KEY` is unset (Medium) + +- **Location:** `corvid/router.py:164-165`, `corvid/config.py:4` +- **Description:** `TICKETS_API_KEY` defaults to an empty string (`config.py:4`). In + `require_ticket_auth`, when no API key is configured the handler returns a synthetic + `{"username": "local", "email": "local@localhost"}` identity instead of raising 401 + (`router.py:164-165`) — i.e. the service runs fully open. The intent is a "local mode", + but it is the **default** state and there is no startup warning or refuse-to-serve guard. +- **Impact:** A deployment that forgets to set `TICKETS_API_KEY` exposes all ticket + read/write endpoints unauthenticated to anything that can reach the port. The web UI + never sends `X-Api-Key`, so it relies on this open mode unless fronted by an auth proxy. +- **Recommendation:** Fail closed — refuse to start (or 401 all requests) when both + `TICKETS_API_KEY` is empty and no `require_user` dependency is wired, unless an explicit + `CORVID_ALLOW_ANONYMOUS=1` opt-in is set. + +### L1 — No rate limiting / lockout on the API-key check (Low) + +- **Location:** `corvid/router.py:151-154` +- **Description:** The key comparison itself is constant-time (`hmac.compare_digest` — + good), but there is no throttling on repeated failed attempts. +- **Recommendation:** Add basic rate limiting at the proxy or app layer. + +### Info — Internal DB DSN via environment + +`corvid/config.py`/`database.py` read the database URL from the environment; `.env` is +gitignored and `.env.example` holds only placeholders. No secrets committed. + +## Clean (verified, no findings) + +- **Secrets:** none committed — `.env` is gitignored; `.env.example` is placeholders only. +- **SQL injection:** all DB access is via SQLAlchemy ORM / bound parameters; no string-built SQL. +- **XSS:** Jinja templates auto-escape; no `| safe` on user-controlled data observed. +- **Command injection / unsafe deserialization:** no `subprocess`, `os.system`, `eval`, + `exec`, `pickle`, or `yaml.load` in tracked code. +- **Auth comparison:** timing-safe via `hmac.compare_digest`. diff --git a/corvid/main.py b/corvid/main.py index 8d95dbb..4a76446 100644 --- a/corvid/main.py +++ b/corvid/main.py @@ -34,7 +34,7 @@ async def root(): @app.get("/tickets", response_class=HTMLResponse, include_in_schema=False) async def tickets_page(request: Request): return templates.TemplateResponse( - request, "tickets.html", {"active_page": "tickets"} + "tickets.html", {"request": request, "active_page": "tickets"} ) diff --git a/corvid/router.py b/corvid/router.py index f63331f..40c7f35 100644 --- a/corvid/router.py +++ b/corvid/router.py @@ -1,5 +1,7 @@ import hmac +import re import uuid +from datetime import datetime, timezone from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from pydantic import BaseModel @@ -125,7 +127,7 @@ async def _get_env_or_404(db: AsyncSession, env_id: int) -> Environment: async def _default_env_id(db: AsyncSession) -> int | None: - e = (await db.execute(select(Environment).order_by(Environment.id))).scalar_one_or_none() + e = (await db.execute(select(Environment).order_by(Environment.id))).scalars().first() return e.id if e else None @@ -159,7 +161,9 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: return {"username": "claude", "email": "claude@internal"} if require_user is not None: return require_user(request) - return {"username": "local", "email": "local@localhost"} + if not api_key: + return {"username": "local", "email": "local@localhost"} + raise HTTPException(401, "unauthorized") # ── Environment endpoints ───────────────────────────────────────────────── @@ -180,6 +184,8 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: prefix = body.prefix.strip().upper() if not prefix or len(prefix) > 16: raise HTTPException(400, "prefix must be 1–16 characters") + if not re.match(r'^[A-Z0-9_-]+$', prefix): + raise HTTPException(400, "prefix must contain only letters, digits, underscores, and hyphens") existing = (await db.execute( select(Environment).where(Environment.prefix == prefix) )).scalar_one_or_none() @@ -294,6 +300,126 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: await db.commit() return await _ticket_dict(await _get_ticket_or_404(db, t.id), db) + @router.get("/api/tickets/export") + async def export_tickets( + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + """Export all environments, tickets, and attachments as portable JSON.""" + env_rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all() + env_by_id = {e.id: e.prefix for e in env_rows} + + ticket_rows = (await db.execute( + select(Ticket).order_by(Ticket.id) + )).scalars().all() + + att_by_ticket: dict[int, list] = {} + for t in ticket_rows: + att_by_ticket[t.id] = [ + { + 'title': a.title, + 'content': a.content, + 'created_by': a.created_by, + } + for a in t.attachments + ] + + return { + 'version': '1', + 'source': 'corvid', + 'exported_at': datetime.now(timezone.utc).isoformat(), + 'environments': [ + { + 'prefix': e.prefix, + 'name': e.name, + 'description': e.description, + } + for e in env_rows + ], + 'tickets': [ + { + '_id': t.id, + 'title': t.title, + 'description': t.description, + 'status': t.status, + 'type': t.type, + 'user': t.user, + 'effort': t.effort, + '_parent_id': t.parent_id, + 'environment_prefix': env_by_id.get(t.environment_id) if t.environment_id else None, + 'attachments': att_by_ticket.get(t.id, []), + } + for t in ticket_rows + ], + } + + @router.post("/api/tickets/import", status_code=201) + async def import_tickets( + body: dict, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + """Import from a portable JSON export (additive — never overwrites existing records).""" + if body.get('version') != '1': + raise HTTPException(400, "Unsupported export version") + + env_prefix_to_id: dict[str, int] = {} + for e_data in body.get('environments', []): + prefix = (e_data.get('prefix') or '').strip().upper() + if not prefix: + continue + existing = (await db.execute( + select(Environment).where(Environment.prefix == prefix) + )).scalar_one_or_none() + if existing: + env_prefix_to_id[prefix] = existing.id + else: + new_env = Environment( + prefix=prefix, + name=e_data.get('name') or prefix, + description=e_data.get('description'), + ) + db.add(new_env) + await db.flush() + env_prefix_to_id[prefix] = new_env.id + + old_to_new: dict[int, int] = {} + for t_data in body.get('tickets', []): + old_id = t_data.get('_id') + env_prefix = t_data.get('environment_prefix') + env_id = env_prefix_to_id.get(env_prefix) if env_prefix else None + + old_parent = t_data.get('_parent_id') + parent_id = old_to_new.get(int(old_parent)) if old_parent else None + + t = Ticket( + guid=str(uuid.uuid4()), + title=t_data.get('title') or 'Imported Ticket', + description=t_data.get('description'), + status=t_data.get('status') or 'open', + type=t_data.get('type') or 'feature', + user=t_data.get('user') or 'kevin', + effort=t_data.get('effort'), + parent_id=parent_id, + environment_id=env_id, + ) + db.add(t) + await db.flush() + + if old_id is not None: + old_to_new[int(old_id)] = t.id + + for att in t_data.get('attachments', []): + db.add(TicketAttachment( + ticket_id=t.id, + title=att.get('title') or 'Attachment', + content=att.get('content') or '', + created_by=att.get('created_by') or 'kevin', + )) + + await db.commit() + return {'ok': True, 'imported': len(old_to_new)} + @router.get("/api/tickets/{ticket_id}") async def get_ticket( ticket_id: int, @@ -331,6 +457,19 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: if body.parent_id == ticket_id: raise HTTPException(400, "ticket cannot be its own parent") await _get_ticket_or_404(db, body.parent_id) + # Detect cycles of any depth before setting parent + ancestor_id = body.parent_id + visited: set[int] = set() + while ancestor_id is not None: + if ancestor_id == ticket_id: + raise HTTPException(400, "setting this parent would create a cycle") + if ancestor_id in visited: + break + visited.add(ancestor_id) + ancestor = (await db.execute( + select(Ticket).where(Ticket.id == ancestor_id) + )).scalar_one_or_none() + ancestor_id = ancestor.parent_id if ancestor else None t.parent_id = body.parent_id if "effort" in body.model_fields_set: if body.effort is not None and not (1 <= body.effort <= 10): diff --git a/corvid/templates/tickets_base.html b/corvid/templates/tickets_base.html index c86d6f1..653027d 100644 --- a/corvid/templates/tickets_base.html +++ b/corvid/templates/tickets_base.html @@ -282,6 +282,11 @@ textarea.desc-input {