CLAUDE.md
Repository orientation for AI coding assistants and human contributors. The notes below capture non-obvious context that's hard to reverse-engineer from the source alone — startup ordering, tenancy invariants, and footguns that have actually caught people. If you're using a tool that prefers AGENTS.md, symlink it to this file.
What this is
SOCFortress CoPilot — a "single pane of glass" SOC platform that wraps open-source security tooling (Wazuh, Graylog, Velociraptor, Cortex, Grafana, InfluxDB, Shuffle, Sublime, …) plus many commercial integrations (CrowdStrike, Carbon Black, Defender, Mimecast, Huntress, Bitdefender, Darktrace, Duo, …).
Three independent apps in one repo:
backend/— Python 3.11 FastAPI (port 5000) + APSchedulerfrontend/— Vue 3 / Vite SOC-analyst UI (Naive UI, Pinia, Tailwind, pnpm)customer-portal/— Vue 3 / Vite end-customer UI (port 3001 in dev, separate build)
Plus tools/remotion-* (video generators) and docs/ (mkdocs site, published by docs-pages.yml).
Common commands
Stack
docker compose pull && docker compose up -d # backend, frontend, mysql, minio, nuclei, mcp
./build-dockers.sh [version] # local image rebuild (frontend only; backend stanza commented)
# Local backend rebuild + hot-swap into the running stack (~5–8 min build):
cd backend && docker buildx build --load -t ghcr.io/socfortress/copilot-backend:latest .
docker compose up -d --force-recreate copilot-backend
# Boot signal: "Application startup complete" + "Uvicorn running on 0.0.0.0:5000"
.env at repo root feeds compose; .env.example documents every var.
Backend (cd backend, Python 3.11)
uvicorn copilot:app --reload --port=5000 # dev
python copilot.py # prod-style (matches Dockerfile CMD)
pytest # tests
alembic upgrade head # auto-runs at startup; manual when needed
alembic revision --autogenerate -m "msg"
JWT_SECRET is a hard startup gate (backend/app/auth/utils.py) — the app refuses to boot if unset or set to the GHSA-4gxj-hw3c-3x2x default.
Frontend / Customer Portal (both pnpm, Node ≥ 18)
cd frontend # or cd customer-portal — both have these scripts
pnpm dev # vite (frontend uses Vite default port; portal forces 3001)
pnpm start # concurrently runs backend uvicorn + this app's vite
pnpm build # type-check + vite build
pnpm lint # eslint --fix
pnpm type-check # vue-tsc --build --force
pnpm format # prettier --write src/
pnpm lint-type-format # combined preflight
pnpm test:unit (vitest) exists only in frontend/; customer-portal has no test setup. Single file: pnpm test:unit path/to/file.spec.ts.
Pre-commit
pre-commit run --all-files
Hooks: black (py3.11, line-length 140 per pyproject.toml), isort (force_single_line = true, black profile), flake8 (.flake8, max-line-length 400, many E* ignored), add-trailing-comma, setup-cfg-fmt, eslint via frontend/eslint.config.mjs. The whole repo's hooks skip backend/app/routers/__init__.py and backend/app/db/all_models.py.
Architecture
backend/copilot.py startup
One FastAPI app mounts ~50 routers under /api. The @app.on_event("startup") hook runs in order:
- (PRODUCTION env only)
create_database_if_not_exists+create_copilot_user_if_not_exists— bootstraps MySQL via root creds apply_migrations()— Alembicupgrade headcreate_buckets()— MinIO bucketsadd_connectors/delete_connectors— sync supported connectors into theconnectorstablecreate_roles,create_available_integrations,create_available_network_connectors— seed enumsensure_admin_user,ensure_scheduler_user— idempotent system usersinit_scheduler()— APScheduler, started here, removed cleanly on shutdown
CORS is wide open (allow_origins=["*"]). Auth is per-route via app.auth.utils.AuthHandler (JWT). app/middleware/exception_handlers.py converts HTTPException, RequestValidationError, and ValueError into structured responses.
Connectors vs. Integrations vs. Routers
Three sibling namespaces with distinct roles — pick the right one:
app/connectors/<tool>/— first-party deeply-integrated tools (Wazuh indexer/manager, Graylog, Velociraptor, Cortex, Grafana, InfluxDB, Shuffle, Sublime, Portainer, Talon, event_shipper). Credentials live in theconnectorstable. The Talon connector is special — see "AI analyst pipeline" below.app/integrations/<tool>/— pluggable 3rd-party services (CrowdStrike, Carbon Black, Defender, Bitdefender, Mimecast, Huntress, Darktrace, Duo, Cato, Office 365, GitHub Audit, …) plus higher-level integration features (alert_creation_settings,alert_escalation,copilot_action,copilot_mcp,copilot_searches,monitoring_alert,modules). Auth keys are persisted per-customer (integration_auth_keys).app/routers/<feature>.py— top-level FastAPI routers that compose connectors + integrations into product features. Every router must be imported andinclude_router-ed incopilot.py.
Module layout: routes / services / schema
Every backend module — connectors, integrations, and feature modules (app/auth/, app/customers/, app/incidents/, app/network_connectors/, …) — follows the same modular split. When adding code, keep each layer responsible for only its concern:
routes/— FastAPI endpoints. Thin: parse/validate input, call services, shape the response. No business logic, no DB queries, no third-party SDK calls.services/— business logic. Talks to the DB, calls connector/integration SDKs, orchestrates work. Imported by routes and by other services.schema/— Pydantic / SQLModel request and response shapes. Shared between routes and services so signatures stay typed end-to-end.models/(when present) — SQLModel ORM tables for that module.utils/orutils.py(when present) — pure helpers with no FastAPI or DB dependency.
New modules should mirror this structure exactly — it's what the rest of the backend assumes when importing across modules and what keeps Alembic / OpenAPI generation predictable.
Secrets that can't be rotated
TOTP_ENCRYPTION_KEY (Fernet, in app/auth/services/totp.py) — once 2FA is enrolled, rotating this key makes stored TOTP secrets unreadable. Falls back to a key derived from JWT_SECRET. SSO_STATE_SECRET similarly falls back to JWT_SECRET.
AI analyst pipeline (CoPilot ↔ Talon / NanoClaw)
The "AI Analyst" feature is a separate service: Talon (also referred to as NanoClaw in its docs) — a Node.js process running an MCP-driven Claude agent that investigates SOC alerts. CoPilot is the database of record; Talon is the AI brain. Full integration spec: https://raw.githubusercontent.com/taylorwalton/talon/refs/heads/main/docs/COPILOT_INTEGRATION.md.
Data flow (both directions matter):
- CoPilot → Talon (outbound) —
app/connectors/talon/is a normal CoPilot connector. The HTTP endpoint + API key live in theconnectorstable (TALON_URL=http://127.1.1.1:3100,TALON_API_KEYper.env.example).app/connectors/talon/utils/universal.pyexposes async GET/POST helpers and an SSE streaming POST. Triggers: a real-timePOST /investigatewhen an alert lands, an analyst-initiatedPOST /message, plus status/job lookups (GET /status,GET /jobs/:alertId). - Talon → CoPilot (inbound write-back) — Talon's agent has read-only MySQL access; all writes go through CoPilot REST endpoints (mounted under
/api/ai_analyst, lives inapp/ai_analyst/routes/). These power the agent's MCP tools (CreateAiAnalystJobTool,SubmitAiAnalystReportTool,SubmitAiAnalystIocsTool,ListAiAnalystJobsByAlertTool, …) defined in thecopilot-mcp-serverrepo. - Scheduled fallback — every 15 min Talon queries
incident_management_alertJOINincident_management_assetLEFT JOINai_analyst_jobfor OPEN alerts with no job row, and runs the same investigation workflow as the real-time path.
Persisted state on the CoPilot side lives in app/db/universal_models.py as the AiAnalyst* block — ai_analyst_job → ai_analyst_report → ai_analyst_ioc, plus the human-feedback tables ai_analyst_review, ai_analyst_ioc_review, and ai_analyst_palace_lesson. The palace-lesson table is a queue: a CoPilot async drainer POSTs queued lessons to NanoClaw's POST /palace/lesson (which wraps a MemPalace MCP write), then flips the row from pending → ingested and stores the returned drawer_id for later expiry. CoPilot never speaks to MemPalace directly — every palace interaction is proxied via NanoClaw HTTP. See "Database structure" below for the table-level shape.
Per-customer auto-trigger is gated by incident_management_ai_analyst_trigger_enabled (one row per customer; default off). Don't fire investigations for a customer whose row is missing or enabled=false.
Database structure
MySQL is the primary store via async SQLAlchemy/SQLModel (app/db/db_session.py); SQLite fallback in backend/settings.py. MinIO handles object storage (app/data_store/). Roughly 80 SQLModel tables spread across the codebase — the map below is the orientation aid.
Tenancy keystone — customers.customer_code. A varchar(50) PK on the customers table that is the universal tenant key. Every per-tenant feature carries it. Two enforcement levels exist in the wild and you must read carefully which one a model uses:
- Hard FK (
foreign_key="customers.customer_code") — agents, agent_vulnerabilities, vulnerability_reports, sca_reports, event_sources, enabled_dashboards, AI analyst tables, customer_notification_route, customer_shuffle_integration, github_audit_config, github_audit_report, customers_meta. Deletes/renames cascade where set up. - String only, no FK —
incident_management_alert.customer_code,incident_management_asset.customer_code,incident_management_case.customer_code,monitoring_alerts.customer_code,customer_integrations.customer_code,customer_network_connectors.customer_code,custom_alert_creation_settings.customer_code. The ingest pipeline can land alerts for codes that don't exist yet — orphaned rows are tolerated by design. Don't add a new join expecting referential integrity.
Permissions / RBAC (app/auth/models/):
role— four enumerated rows:admin=1, analyst=2, scheduler=3, customer_user=4(seeRoleEnum).user— bcrypt password, FK to role.user_customer_access— M2M scoping which customers a user can see.user_tag_access+role_tag_access— parallel allow-list ACLs against alert tags (incident_management_alerttag.id). Toggled globally by the singletonincident_management_tag_access_settings(enabledflag,untagged_alert_behavior∈ admin_only|visible_to_all|default_tag).user_totp— Fernet-encrypted secret + bcrypt-hashed backup codes;last_used_atprevents replay.sso_config(singleton id=1) — Azure / Google / Cloudflare Access JWT settings.sso_allowed_emailis the email allowlist; new SSO users default to role 2 (analyst).
Major domain blocks — module → core tables → tenancy enforcement:
| Domain | Where | Headline tables | Tenant FK? |
|---|---|---|---|
| Customers / agents | app/db/universal_models.py |
customers, customers_meta, agents, agent_datastore, agent_vulnerabilities, event_sources, enabled_dashboards |
✅ FK |
| Auth / RBAC / 2FA / SSO | app/auth/models/ |
user, role, user_customer_access, user_tag_access, role_tag_access, user_totp, sso_config, sso_allowed_email |
n/a |
| Connector credentials | app/connectors/models.py |
connectors, connectorhistory |
n/a (deployment-wide) |
| Incident management (~28 tables) | app/incidents/models.py |
incident_management_alert, _asset, _case, _ioc, _alerttag, _case_template/_case_task/_case_event, _threshold_alert_metadata, _velo_sigma_exclusion, _tag_access_settings, plus _*fieldname ingest dictionaries |
⚠️ STRING |
| AI analyst | app/db/universal_models.py (AiAnalyst* block) + incident_management_ai_analyst_trigger_enabled |
ai_analyst_job → ai_analyst_report → ai_analyst_ioc; review chain ai_analyst_review → ai_analyst_ioc_review; queue ai_analyst_palace_lesson |
✅ FK |
| Notification routing | app/db/universal_models.py |
customer_notification_route, notification_dispatch_log, customer_shuffle_integration |
✅ FK |
| Generic integrations system | app/integrations/models/customer_integration_settings.py |
catalog (available_integrations, _auth_keys) → activation (customer_integrations, integration_subscriptions, integration_auth_keys) → service def (integration_services, integration_configs) → wiring (customer_integrations_meta) |
⚠️ STRING |
| Network connectors | app/network_connectors/models/network_connectors.py |
mirrors the integrations shape with network_connectors_* prefix |
⚠️ STRING |
| Alert creation settings | app/integrations/alert_creation_settings/ |
custom_alert_creation_settings → _event_order → _condition, _event_config |
⚠️ STRING |
| GitHub audit | app/integrations/github_audit/model.py |
github_audit_config → _report, _check_exclusion, _baseline |
indexed string |
| Sublime alerts | app/connectors/sublime/models/alerts.py |
sublimealerts + child rows (flaggedrule, mailbox, triggeredaction, sender, recipient) |
n/a |
| Wazuh ecosystem | app/connectors/wazuh_indexer/models/, app/connectors/wazuh_manager/models/ |
sigma_queries, index_snapshot_schedules, disabledrule |
n/a |
| Schedulers | app/schedulers/models/, app/db/universal_models.py |
scheduled_job_metadata (CoPilot sidecar) + schedulerjob (APScheduler state blob) |
n/a |
| Reports / branding / misc | app/db/universal_models.py + module-local |
vulnerability_reports, sca_reports, customer_portal_settings, monitoring_alerts, sap_siem_multiple_logins, license, license_cache, log_entries, customer_provisioning_default_settings |
mixed |
Conventions worth knowing before writing migrations or queries:
- MinIO blob pointer pattern — every MinIO-backed row carries
bucket_name,object_key,file_name,file_size,file_hash,content_type. Instances:agent_datastore(velociraptor-artifacts),vulnerability_reports,sca_reports,incident_management_case_datastore,incident_management_case_report_template_datastore. The DB row is the manifest; the actual bytes are in MinIO. - M2M join-table naming —
<entity>_to_<entity>(incident_management_alert_to_ioc,incident_management_alert_to_tag). One exception:incident_management_casealertlinkcollapses the underscores and uses an explicitPrimaryKeyConstraint. - Snapshot vs reference —
incident_management_case_taskrows are snapshot-copied from_case_template_taskat apply time.template_task_idis documented as a soft link, informational only — editing the source template does not mutate existing case tasks. Same convention applies anywhere the table comments call something "snapshot at … time." - Idempotency via unique constraints —
notification_dispatch_logusesUniqueConstraint(customer_code, alert_id, route_id, trigger)and the dispatch service does INSERT…ON CONFLICT DO NOTHING.ai_analyst_reviewis unique on(report_id, reviewer_user_id).enabled_dashboardson(customer_code, event_source_id, library_card, template_id). Honor these — don't write upserts that conflict with their semantics. - Source-mapped ingest dictionaries — the
incident_management_*fieldnamefamily (fieldname,assetfieldname,timestampfieldname,alerttitlefieldname,iocfieldname,customercodefieldname) maps per-source (wazuh,velociraptor,office365, …) field names to canonical ingest fields. New SIEM source = new rows in all six tables. - Connector credentials are global, integration auth keys are per-customer. Connectors (
app/connectors/models.py:Connectors) hold one row per first-party tool for the whole deployment. Integrations (integration_auth_keysvia the subscription chain) hold one row per (customer, integration). The third axis — Shuffle — usescustomer_shuffle_integration.shuffle_org_idas the per-customer differentiator while the deployment-wideSHUFFLER_API_KEYlives in theconnectorstable.
Detections Catalog (view layer over rules_cache + Wazuh)
The Detections Catalog (top-level nav, /detection-catalog — note URL path stays singular for backward-compat; user-facing label is plural since the Wazuh tab was added) is a discovery surface — not its own data store. It is the single feature in CoPilot that deliberately introduces no new tables, no new columns, no Alembic migrations, no schema of any kind. Everything is in-memory caches loading from sources that already exist.
Source caches (all in app/integrations/copilot_searches/services/):
rules_cache— CoPilot Searches rule corpus, loaded from GitHub. Each rule carriesanalytic_story,product,data_source,mitre_attack_id,tactics,type,references, …wazuh_rules_cache— full Wazuh Manager ruleset, loaded via oneget_wazuh_rules(limit=100000)call. 1-hour TTL (rules change only on operator action). Graceful degradation: when Wazuh is unreachable the cache holdsunavailable_reasonandis_available=Falseso the UI can render an inline warning instead of erroring.wazuh_firing_stats_cache— per-rule hit counts from the Wazuh indexer (not manager). 15-min TTL. One ES terms aggregation per refresh buckets the last 30d of alerts byrule.idwith three sub-aggs:last_7d(filter to 7d),last_seen(max oftimestamp— gives the most recent firing per rule, rendered as relative time in the modal), and the doc_count itself (30d). ~50–100 KB of buckets, sub-second response even on 100M-doc indices. Uses an ES-native wildcard+exclusion pattern rather than hardcodingwazuh-alerts-*, because real SOCFortress deployments spread alerts across vendor-/customer-prefixed indices (office365-<code>,crowdstrike-<code>,carbonblack-<code>,huntress_<code>, ad-hoc test indices — easily 1500+ on lab clusters). The pattern is*,-.*,-_*,-wazuh-monitoring-*,-wazuh-statistics-*,-wazuh-states-*,-wazuh-vulnerabilities-*,-security-auditlog-*— exclusions remove Wazuh internals + Kibana/OpenSearch system indices; the*includes everything else. A separate_cat/indicescall runs alongside but only to populateresolved_indicesfor debug logging — the search itself uses the pattern string. Field-name fallback chainrule.id → rule_id → idbecause the index mapping varies across versions; the winning field is sticky between refreshes. Search usesignore_unavailable=Trueandallow_no_indices=Trueso one inaccessible index doesn't fail the whole aggregation.Per-customer firing slice is NOT cached —
fetch_firing_stats_for_customer(customer_code)runs a fresh ES aggregation per request with an extratermfilter onagent_labels_customer(oragent.labels.customer— both shapes tried in fallback). Reason: caching every customer × rule combo would balloon memory for customers nobody is looking at. On-demand keeps the global cache lean and the per-customer query is still sub-second. The customer field uses Graylog's flat convention (agent_labels_customer) by default since that's what SOCFortress's pipelines produce; falls back to the nested Wazuh-vanilla shape (agent.labels.customer) for stock installs.mitre_matrix— STIX bundle file. Resolvesmitre_attack_id→ tactic display names and powers the Coverage Gaps tab.
Consequence: refreshing CoPilot Searches refreshes the Stories surface; Wazuh rules + firing stats have their own TTL'd refresh paths. There is no migration, no new table. Aggregations (group by story, project to row shape, compute coverage gaps, format synthesized XML, …) happen fresh on each request inside services/detection_catalog.py. If you find yourself adding a database column, schema, or migration for the catalog, stop — that's a design violation. The catalog must remain a thin projection.
Surfaces (browse modes):
- CoPilot Searches tab (URL token still
?tab=storiesfor back-compat) — CoPilot Searches rules grouped byanalytic_storytag. Click a row → story detail page (description, member detections table, deduplicated data sources / references). The story detail is its own URL state (?story=Foo), not a tab. - Wazuh Rules tab — flat searchable grid over the entire Wazuh ruleset (~3–5k rows). Columns: ID · Level · Description · Groups · MITRE · File · Hits 30d (with 7d sub-count) when firing stats available. Quick-filter chips All / Top noisy (50) / Dead (level ≥7, 0 hits 30d) for tuning workflows. Customer-scope dropdown to slice firing stats by
agent_labels_customer(oragent.labels.customer— both shapes tried). Row click → modal with full meta + reconstructed<rule>XML + "Last fired" relative timestamp. - Coverage Gaps tab — MITRE techniques no rule in either corpus addresses. Sub-techniques collapsed into parents (a hit on T1059.001 counts as coverage for T1059) — listing every sub-technique would flood the gap report.
- Compliance tab — Wazuh rules grouped by control IDs for a selected compliance framework (PCI DSS / HIPAA / NIST 800-53 / GDPR / TSC / GPG13). Each row shows rule count + total firing hits per control. Frameworks are statically declared in
services/detection_catalog.py:COMPLIANCE_FRAMEWORKS— adding a new one is one row in that dict + the field already being on the cached Wazuh rules. - Logtest panel (collapsible, above the Wazuh table, not its own tab) — paste a raw log line, pick a
log_format, see which Wazuh rule fires. Wraps Wazuh'sPUT /logtestso we get real Wazuh decoder/rule-chain behavior rather than re-implementing the engine. The panel persists the last 5 tests to localStorage (detectionCatalog.logtest.history) so analysts can click any prior paste to restore the input + format without retyping.
Rule source XML synthesis: The Wazuh rule detail modal renders a reconstructed <rule id="…" level="…">…</rule> block in the "Rule Source" section. We don't fetch the original .xml file via Wazuh's GET /rules/files/{filename} because (a) that endpoint returns the whole file (dozens of rules per file), requiring XML parsing to extract one, and (b) it's admin-scoped while the catalog is analyst-accessible. Synthesis from cached fields (id, level, description, groups, mitre, compliance arrays, the free-form details dict) is functionally identical and avoids both problems. See _synthesize_rule_xml in services/detection_catalog.py if you need to evolve the format.
Auth-scope sidestep pattern (used throughout the catalog): when a catalog route needs data from an admin-only connector route, call the underlying service function directly rather than proxying through the connector route. Scope checks live on the route handler (the Security(...require_any_scope("admin")) dependency), not on the service function. The catalog's wazuh_rules_cache.refresh() calls get_wazuh_rules() directly; the catalog's logtest calls run_logtest() directly; both let the catalog routes stay admin|analyst|customer_user while the connector routes (used for management actions like enable/disable) stay locked down. If you find yourself wanting to widen the scope on an existing connector route to feed the catalog, don't — sidestep instead.
Wiring layout (everything stays inside the copilot_searches namespace on the backend and a detectionCatalog/ namespace on the frontend):
- Backend services:
services/detection_catalog.py(aggregators),services/wazuh_rules_cache.py,services/wazuh_firing_stats_cache.py - Backend connector wrapper:
app/connectors/wazuh_manager/services/logtest.py(thinPUT /logtestwrapper — seesend_put_requestfootgun below) - Backend routes (appended to
routes/copilot_searches.py):GET /catalog/stats— counts for the header strip (includes Wazuh-side counts mirrored from the cache)GET /catalog/stories+GET /catalog/stories/{story_name:path}(the:pathconverter is required — story names contain spaces and punctuation)GET /catalog/wazuh-rules(optional?customer_code=Xfor per-customer firing scope) +GET /catalog/wazuh-rules/{rule_id}(rule_id isint; route ordering matters — see route-ordering footgun)GET /catalog/coverage-gapsGET /catalog/compliance/frameworks(static, before wildcard) +GET /catalog/compliance/{framework}(framework key fromCOMPLIANCE_FRAMEWORKSdict; 400 on unknown)POST /catalog/wazuh-rules/test— logtest
- Frontend view:
src/views/DetectionCatalog.vue(thin wrapper) →src/components/detectionCatalog/directory:DetectionCatalogShell.vue— header strip + tab switcher (URL-synced via?tab=stories|wazuh|gaps)StoriesIndex.vue+StoryDetail.vue— the original Stories surfaceWazuhRulesIndex.vue— Wazuh tab grid + quick-filter chips + customer-scope dropdown, mountsWazuhLogTest.vueabove the tableWazuhRuleDetail.vue— Wazuh rule detail modal contents (incl. Last fired relative-time badge)WazuhLogTest.vue— collapsible logtest panel, persists last 5 tests to localStorageCoverageGapsIndex.vue— Coverage Gaps tabComplianceIndex.vue— Compliance tab (framework selector + grouped table + drill-down modal)
- Frontend supporting files:
src/types/detectionCatalog.d.ts,src/api/endpoints/detectionCatalog.ts - Three authorized cross-cutting edits and only these three: nav entry in
app-layouts/common/Navbar/items.tsx, route inrouter/index.ts, barrel registration inapi/index.ts. Nothing else outside the catalog namespace should change for this feature.
Frontend essentials
src/api/httpClient.ts(axios) andsrc/api/sseClient.ts(@microsoft/fetch-event-source) — backend traffic. Wrappers insrc/api/endpoints/.src/stores/— Pinia withpinia-plugin-persistedstate, encrypted viasecure-ls.- Tailwind v4 + Naive UI; design tokens flow from
figma-tokens.jsonviapnpm design-tokens. @shuffleio/shuffle-mcpsis embedded as a frontend dependency for per-org Shuffle app management.
The customer-portal/ mirrors this structure but is a leaner standalone app, served separately (its own nginx.conf; port 3001 dev, 8443 in compose when uncommented).
CI (.github/workflows/)
docker.yml— multi-arch builds →ghcr.io/socfortress/copilot-{backend,frontend,customer-portal,mcp,nuclei-module,minio}pre-commit.yml— runs the hooks above on PRsdocs-pages.yml— mkdocs → GitHub Pages fromdocs/(mkdocs.yml)
Things that bite
- Don't edit
backend/app/routers/__init__.pyorbackend/app/db/all_models.py— auto-managed import aggregators, excluded from pre-commit for that reason. - A new router takes two edits: the file in
app/routers/, plus the import +api_router.include_router(...)inbackend/copilot.py. Miss the second and the route silently doesn't exist. - Alembic autogenerate's model registry is
backend/alembic/env.py, notapp/db/all_models.py—all_models.pyhas many imports commented out and is not what env.py loads (env.py imports models directly). Adding a model to an existing model file is fine (Python imports the whole module, registering every SQLModel in it). Adding a model in a brand-new file requires an explicit import inenv.py— otherwise the file is never loaded and autogenerate silently skips it. incident_management_*.customer_codeis a STRING, not a foreign key. Same forcustomer_integrations,customer_network_connectors,monitoring_alerts,custom_alert_creation_settings. Renaming or deleting a customer does not cascade. Don't write joins that assume referential integrity on these columns.- AsyncSession +
back_populates=MissingGreenletin some cases.CustomerNotificationRouteandNotificationDispatchLogdeliberately omitback_populateson their reverse relationships (with code comments explaining why) — bidirectional relationships fire implicit synchronous loads onflush()that fail underAsyncSession. When adding relationships in async-write paths, prefer one-way FKs and explicitsession.get(...)overback_populates. .envis shared across compose services. Backend andcopilot-mcpboth read it; many MCP vars fall back toWAZUH_*defaults — renaming without fixing fallbacks breaks MCP wiring.- Black line length is 140, not 88. isort
force_single_line = true— combined imports get split. - Pydantic 2 strictness regressions are still surfacing across the codebase. The repo migrated from v1 to v2; v1 was lenient where v2 errors. Three patterns to watch for:
- Leading-underscore field annotations get silently dropped as
PrivateAttr. A field like_source: GenericSourceModelparses as nothing — Pydantic 2 never populates it. Usesource: GenericSourceModel = Field(alias="_source")plusmodel_config = ConfigDict(populate_by_name=True). We've hit this on_source(GenericAlertModel) and_client_config(VelociraptorOrganization). - Pydantic schemas fed SQLAlchemy/SQLModel rows need
model_config = ConfigDict(from_attributes=True)(the rename of v1'sorm_mode). Symptom is a 400/500 withInput should be a valid dictionary or instance of <ClassName>even though the input is an instance — Pydantic 2 enforces exact class match, and ORM-row → Pydantic-schema extraction requires this config explicitly. Audit signal: any Pydantic class that shares a name with aSQLModel(table=True)is suspicious. Whole chains of nested response models (CustomerIntegrations→IntegrationSubscription→IntegrationService→IntegrationAuthKeys) need it on every level when joinedload feeds them. - v2 stops coercing
bool→str,int→str, etc. A schema typedsuccess: strwhose callers all passsuccess=Trueworked in v1 (silently coerced to"True"); v2 errors withInput should be a valid string [type=string_type, input_value=True, input_type=bool]. Fix the schema's type to match what callers actually pass, not the other way around.
- Leading-underscore field annotations get silently dropped as
regex=→pattern=is FastAPI-only. FastAPI 0.116 deprecatedregex=onQuery/Path/Header/Bodyin favor ofpattern=.sqlmodel.Fieldstill usesregex=and rejectspattern=— blindly running a global rename will crashloop the backend at import time withTypeError: Field() got an unexpected keyword argument 'pattern'. Pydantic v2 nativeFieldusespattern=. Three different surfaces, three different rules; check the import before renaming.pyvelociraptoris pure gRPC transport. Itjson.loadswhatever the Velociraptor server emits inResponsechunks; it does no semantic deserialization. The shape is whatever the server version picks, and can change between minor versions. 0.75.6 changed_client_configfrom a structured object to a YAML string. Schemas for VQL outputs should default to tolerant — drop unused fields, structure only what we actually read, lean onextra='ignore'.elasticsearch7.exceptions.RequestErrormessage lives on.info, notstr(err).str(err)returns only the error code (search_phase_execution_exception); the human-readable cause is onerr.info._is_text_field_agg_errorinapp/siem/services/dashboards.pyis the canonical example — match againsterr.errorfor the type andstr(err.info)for the message.- FastAPI route ordering inside a single router: static paths must come before wildcards. A
@router.get("/{id}")declared above@router.get("/library")will swallow/libraryand try to parse"library"as theidparam, returning a 422 withInput is not a valid integer. We've hit this on both the Case Templates Library (/libraryvs/{template_id}) and the Detection Catalog (/catalog/stats,/catalog/storiesvs/catalog/stories/{story_name:path}). When appending new routes to an existing router, scan for wildcard routes already declared and insert above them. - Vue JSX (
<script setup lang="tsx">) does not use the:v-bind prefix. Inside a.vue<template>you write<NTag :bordered="false">; inside a JSXrender: row => (...)you write<NTag bordered={false}>. Mixing them up — easy when copying from one to the other — produces a Vite parser error (Unexpected token). The catalog'sStoriesIndex.vue/StoryDetail.vueare pure-JSX render columns; the surrounding<template>is Vue syntax. Don't conflate. - SPA sub-views driven by URL query params:
pushto open,replaceto close. When a parent component swaps between an index view and a detail view based on a query param (?story=Foo), entering the detail must userouter.pushso the browser-back button lands on the index, not on whatever was open before this page. Exiting via an in-app button should userouter.replaceto avoid piling up duplicate index entries in history. Detection Catalog'sDetectionCatalogShell.vueopenStory/closeStoryis the canonical pair — getting this wrong is invisible until a user complains that browser-back skips the index entirely. - Iconify icon collections loaded in the frontend:
carbon:,mdi:,logos:.simple-icons:and other unbundled sets render as an empty box with no console error. If you reach forsimple-icons:wazuhor similar, either switch to a loaded set or fall back to a plain text label. app/connectors/wazuh_manager/utils/universal.py:send_put_requestform-encodes dicts even withContent-Type: application/json. The helper sets the JSON content-type header but then callsrequests.put(data=<dict>), whichrequestsURL-form-encodes (key=value&key=value). Wazuh sees the JSON header but a form-encoded body, tries to parse it as JSON, and 400s withExpecting value: line 1 column 1 (char 0). Fix at the call site by pre-serializing:send_put_request(endpoint="/logtest", data=json.dumps(payload))—requestssends string bodies as-is, no encoding. The wazuh-managerlogtest.pyconnector has the canonical example. Don't try to "fix" it by passing a dict and assuming JSON encoding; the helper is used by the rule-file-upload flow which expects raw bytes/XML, and changing it globally would break that path. (Cleaner long-term fix would be ajson_data=Trueflag on the helper, but it touches every Wazuh integration that PUTs.)- Cross-branch local-DB alembic divergence is invisible until startup. If you run a migration from a feature branch against your local DB and then switch to a different branch that lacks the migration file,
apply_migrations()at startup crashes withCan't locate revision identified by <hash>— the DB'salembic_versiontable records the missing revision, alembic has no file to walk. Safe recovery:git checkout <other-branch-commit> -- backend/alembic/versions/<missing-revision>_*.py(plus any predecessors in the chain). That brings the migration file into the working tree without changing the DB — the schema the file describes is already applied, alembic just needs the file to recognize the state. Don'tUPDATE alembic_versionmanually unless you're prepared to fight it later; don'talembic downgradeunless you genuinely want to drop the columns. - Don't assume Wazuh alerts live only in
wazuh-alerts-*. Real SOCFortress deployments spread alerts across vendor- and customer-prefixed indices:office365-<customer_code>,crowdstrike-<customer_code>,carbonblack-<customer_code>,huntress_<customer_code>, ad-hocnewest-*test indices, and so on — easily 1500+ on lab clusters. A query targeting onlywazuh-alerts-*will miss the bulk of real traffic. Equally,wazuh-*is too broad and includes Wazuh's own internal indices (wazuh-monitoring-*,wazuh-statistics-*,wazuh-states-*,wazuh-vulnerabilities-*) — those carryrule.idfor system/control events with Wazuh's parent-template rule IDs (2 = firewall template, 3 = ids template, 4 = web-log template) that never fire on real analyst alerts but produce massive fake hit counts. Use ES's native wildcard+exclusion pattern, not client-side index enumeration:*,-.*,-_*,-wazuh-monitoring-*,-wazuh-statistics-*,-wazuh-states-*,-wazuh-vulnerabilities-*,-security-auditlog-*passes as one short string and ES resolves it server-side.wazuh_firing_stats_cache.py:ALERT_INDEX_PATTERNis the canonical implementation. Integer coercion on the bucket keys naturally drops vendor-native rule IDs that aren't Wazuh-compatible, so over-including is safe. - Don't pass a long index list directly to
client.search(index=[...]). The elasticsearch7 client serializes the list as a comma-joined URL path segment. At ~1500 indices that crosses the 4096-byte HTTP line limit and you gettoo_long_http_line_exceptionfrom the server. The fix is server-side pattern resolution — pass a short string like*,-foo-*,-bar-*toclient.search(index=pattern)and let ES expand it. Combine withignore_unavailable=True, allow_no_indices=Trueso per-index permission errors and empty matches don't fail the whole call. - Graylog 7.0 wraps entity-creation POSTs in a
CreateEntityRequest— CoPilot must support both 6.x and 7.x. SOCFortress still has clients on Graylog 6. In 7.0 the create-entity endpoints changed their request body from a flat object ({"title": ..., ...}) to{"entity": {...}, "share_request": null}; neither version accepts the other's shape, and 7.0 strictly rejects unknown properties, so you cannot just always-send the wrapper. The symptom on a flat body against 7.x is a 400RequestErrorwith"entity cannot be null"andreference_path: org.graylog.security.shares.CreateEntityRequest. Useapp/connectors/graylog/utils/universal.py:send_post_request_create_entity(endpoint, entity=...)for any entity-creation POST — it probesGET /api/systemfor the server's major version (cached 5 min, defaults to 6 on failure so 6.x stays byte-for-byte unchanged) and wraps only when major ≥ 7. Exactly four endpoints CoPilot uses are wrapped (verified against the live 7.1.2 server's OpenAPI spec):POST /api/streams,POST /api/events/definitions,POST /api/events/notifications, and content-pack install (POST /api/system/content_packs/{id}/{rev}/installations). Everything else is FLAT and must keep using plainsend_post_request—POST /api/system/indices/index_sets(index-set create), content-pack upload (POST /api/system/content_packs), all/api/system/pipelines/*(rule, pipeline, connections/to_stream), and/api/events/search. No PUT endpoint is wrapped (UpdateStreamRequest,IndexSetUpdateRequest,PipelineSourceare all flat), so don't touch PUTs. Wrapping a flat endpoint breaks it under 7.x's strict-property rejection just as surely as not-wrapping a wrapped one — the four-vs-rest split is load-bearing, not cosmetic. Separately, 7.0 droppedGET /api/system/urlwhitelistentirely (404, not aliased) in favor ofGET /api/system/urlallowlist;collector.py:get_url_whitelist_entriestries the 7.x path first and falls back to the legacy path for 6.x.