This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
AdvisoryHub is a private Django application for authoring, reviewing, publishing, and auditing security advisories for Eclipse Foundation projects. Published advisories are exported to OSV+CSAF JSON and pushed to a separate publication Git repo whose own CI/CD renders the public website. There is no public anonymous surface in this codebase.
Stack: Python 3.14, Django 5.2 LTS, PostgreSQL (required in prod — append-only audit triggers and JSON queries are Postgres-specific), Celery + Valkey (Redis-wire compatible — redis:// URLs work unchanged), mozilla-django-oidc, server-rendered templates with HTMX.
The specification set in docs/specification/ is the single source of truth for what this system is and does (verified against the code and aligned on 2026-06-11). All development MUST conform to it:
-
Read the relevant file before making non-trivial changes; cite
INV-*IDs in commits and PRs. -
Any deviation from the spec requires explicit maintainer confirmation before implementation — never silently implement behavior the spec contradicts or forbids.
-
Every behavior change must update the affected spec file(s) in the same commit/PR; a code/spec mismatch is a defect in whichever side drifted.
-
docs/specification/invariant.md— load-bearing rules with stableINV-*IDs, severity tiers, enforcement file paths, and test pointers. -
docs/specification/architecture.md— tech stack, full app layout, architectural patterns, publication & GHSA pipelines, Celery beat schedule, env-var inventory, operations, testing strategy. -
docs/specification/permissions.md— authorization model: actors, roles, capability matrix, state-conditioned overrides, enforcement surfaces. -
docs/specification/advisory-lifecycle.md— four lifecycle states plus three orthogonal sub-machines (review, CVE-request, publication-task), with transition tables and a sequence diagram. -
docs/specification/requirements.md— top-down functional spec: actors, domain objects, functional & non-functional requirements, use cases. -
docs/specification/openapi.yaml— OpenAPI 3.0 contract for the machine-consumable endpoints (/api/, GHSA webhook, intake project picker, health probes), rendered on the docs site bydocs/specification/api.md; drift-guarded against the URLconf byapi/tests/test_openapi_spec.py,info.versionrelease-lockstepped bydev/release.sh/dev/check_release_versions.sh.
Dev environment is docker-compose driven and self-contained — no .env editing required.
# One time: the app images base on Docker Hardened Images (free Docker account)
docker login dhi.io
# First run (mints OIDC client secret)
docker compose up -d kanidm
bash dev/kanidm/setup.sh
docker compose up
# Schema + demo data (in another terminal)
docker compose exec web python manage.py migrate
docker compose exec web python manage.py seed_demo --with-publish-repo /tmp/advisoryhub-pub.git
# Reset everything (down -v drops volumes but keeps cached images — rebuild
# so a changed Dockerfile / uv.lock, e.g. a Python bump, is actually picked up)
docker compose down -v && docker compose build && docker compose up -d kanidm && bash dev/kanidm/setup.sh && docker compose upDemo login: alice@example.org / correcthorsebatterystaple (created by dev/kanidm/setup.sh to match seed_demo).
mise wraps every command in this section. mise trust && mise run setup installs the bootstrap toolchain (uv + prek), syncs the locked dev env, and wires the git hooks; mise tasks lists them all. Each task is a thin 1:1 wrapper over the documented uv run … / docker compose … command, with DJANGO_SETTINGS_MODULE set per task:
mise run up/down/build/reset— docker dev stack (resetwipes volumes, rebuilds images, and re-bootstraps kanidm;buildrebuilds the web/worker images alone — needed after a Dockerfile/dependency change, sincedown -vkeeps cached images)mise run kanidm-up/kanidm-setup— first-run OIDC bootstrapmise run migrate/seed— schema + demo datamise run test— pytest against the compose Postgres (needsmise run up); args pass through:mise run test -- -k name path/mise run lint/fix/typecheck/ty— ruff + mypy + advisory tymise run check/makemigrations-check/audit— Django checks + pip-auditmise run verify-vendor/update-vendor/check-templates— vendored-asset checksum guard / re-vendor to the pinned.VERSION(the script Renovate runs as a postUpgradeTask) / template-comment guard (verify + template also run by prek/CI)mise run helm-lint/helm-template/helm-validate/verify-chart-assets— Helm chart gates forcharts/advisoryhub(lint + kubeconform render validation + observability-asset sync; also run by CI)mise run zizmor— security-audit the GitHub Actions workflows (also run by prek + CI's workflow-security job; config in.github/zizmor.yml)mise run release/release-check/changelog/sbom— cut a release / version-consistency gate / git-cliff notes / CycloneDX SBOM (see Releases)mise run docs-build/docs-serve/docs-deploy— strict docs build / live preview on :8001 / mike deploy into the LOCAL gh-pages branch (CI:.github/workflows/docs.ymlpublishes versioned docs to GitHub Pages)
mise pins only the bootstrap uv + prek (in mise.toml) plus the chart/release binaries (helm, kubeconform, git-cliff, trivy); all dev tool versions stay in uv.lock, the Python version in .python-version. CI runs these same tasks. Raw uv/docker compose commands remain canonical.
Tests run against PostgreSQL (the same engine as prod), exercising the append-only triggers and JSONB queries. Start Postgres first (docker compose up -d postgres or mise run up). config.settings.test defaults to the local compose Postgres; TEST_DATABASE_URL overrides the host/port. --reuse-db (in pytest addopts) keeps the test DB between runs — pass --create-db after a migration change.
DJANGO_SETTINGS_MODULE=config.settings.test pytest # all tests
DJANGO_SETTINGS_MODULE=config.settings.test pytest path/to/test_file.py::TestClass::test_name
TEST_DATABASE_URL=postgres://user:pass@host:5432/db \
DJANGO_SETTINGS_MODULE=config.settings.test pytest # custom Postgresconfig.settings.test strips OIDC middleware (so force_login works), force-disables rate limiting and step-up, and sets CELERY_TASK_ALWAYS_EAGER=True. The dedicated ratelimit_* and step-up tests re-enable each via @override_settings.
ruff check .
ruff format --check .Ruff config is in pyproject.toml (E,F,W,I,B,UP,DJ, line length 100, E501 ignored). Migrations and tests have relaxed rules; advisories/models.py ignores DJ012.
python manage.py migrate
python manage.py makemigrations --check --dry-run # CI also runs this
python manage.py check --deploy --fail-level WARNING
python manage.py seed_demo --with-publish-repo /tmp/pub.git
python manage.py prune_audit / forget_user # in audit/management/commands
celery -A config worker -l info.pre-commit-config.yaml runs the lint/format/type/Django gates above via prek (the Rust pre-commit drop-in). Hooks shell out through uv run --no-sync --python .venv …, so they use the exact uv.lock-pinned tools CI runs — no second place to bump versions.
mise run setup # one-shot: installs uv+prek, syncs .venv, wires hooks
# …or by hand:
uv sync --extra dev # provides the ruff / mypy the hooks call
uv tool install prek # or: pipx install prek / cargo install prek / mise install
prek install # installs BOTH the pre-commit and pre-push hooks
prek run --all-files # commit stage: hygiene + ruff
prek run --all-files --hook-stage pre-push # + mypy, Django checks & pip-audit
prek run --all-files --hook-stage manual # advisory ty (mirrors CI's ty job)Commit stage = file hygiene + ruff check --fix + ruff format, plus three repo guards: dev/check_vendored_assets.sh (htmx, Inter, ALTCHA + the OSV/CSAF/CVE/CVSS JSON schemas — sha256 vs their *.VERSION / SCHEMAS.VERSION files), dev/check_template_comments.py (rejects multi-line {# #} — Django's single-line comment renders those as literal text), and zizmor (workflow security audit, fires when .github/ workflow files change). Push stage adds mypy, makemigrations --check, manage.py check, and pip-audit (dependency audit, mirrors CI's security job; needs network). ty is manual + advisory (no Django plugin yet), matching CI's continue-on-error ty job. Vendored/verbatim assets (static/htmx.*, static/fonts/*.woff2, static/fonts/Inter.VERSION, static/altcha/*, publication/schemas/*.upstream.json, publication/schemas/cvss/*.json, publication/schemas/SCHEMAS.VERSION, docs/assets/css/neoteroi-mkdocs.*) are excluded from the hygiene hooks.
Vendored-asset updates are automated by a scoped, self-hosted Renovate workflow (.github/workflows/renovate.yml + .github/renovate.json) that tracks ONLY those .VERSION/SCHEMAS.VERSION files (Dependabot still owns Python/uv, Actions, Docker — disjoint scopes). On a new upstream release Renovate bumps the pinned version, then runs dev/update_vendored_assets.py (mise run update-vendor) as a tightly allow-listed postUpgradeTask to re-download, rehash, and re-apply the OSV ECL- prefix patch. Auto-merge policy: the JSON schemas are payload-validated by publication/tests (+ the OSV ecosystem drift guard in publication/tests/test_osv.py + publication/tests/test_schemas_valid.py), so their PRs auto-merge on green CI; the frontend assets have no behavioral tests, so their PRs are review-only (manual smoke). Schemas are tag-pinned (osv-schema/cve-schema tags; OSV carries the local ECL- patch — see publication/schemas/SCHEMAS.md).
Full catalog with stable IDs, severity tiers, and enforcement file paths in docs/specification/invariant.md. Cite INV-* IDs in commits, PRs, and code comments. The rules that shape almost every change in this codebase:
INV-LIFECYCLE-1— four advisory states only:triage,draft,published,dismissed. Review is orthogonal (review_status), not a fifth state.INV-LIFECYCLE-3—stateflips topublishedonly after a successful Git push. Any failure keeps the prior state and marks thePublicationTaskfailedwith a redactedlast_error.INV-VERSION-1/-2—AdvisoryVersionis append-only; content edits append v(n+1), state-only flips do not. Workflow rows (ReviewTask,PublicationTask)PROTECT-FK the pinned version. Adding a field toAdvisory.to_payload()makes it versioned automatically.INV-VERSION-3— OSV/CSAF are built from the immutableAdvisoryVersionpayload pinned on the task, never from live form data.INV-AUTH-1— authorization is server-side (views, APIs, Celery tasks); templates only display. Notification recipient lists are re-checked at send time.INV-AUTH-3— owner is derived, never assigned.access.models.Permission.choicesexcludesowner; services and APIs rejectpermission="owner"at the boundary.INV-AUDIT-1— audit log is append-only at both the application layer and a Postgres trigger.INV-AUDIT-2/INV-SECRET-1..3— funnel all user/CI-supplied strings throughaudit.services.redact_secrets; secrets never reach logs, audit metadata, task errors, artifact rows, or notification bodies. The publication git layer addspublication.git_service._redactfor token-rewritten URLs.INV-OIDC-1/-2— OIDC groups are DB-mirrored on every login (accounts.auth.AdvisoryHubOIDCBackend); authorization always re-reads the DB mirror, never client-submitted group data. "Mature publisher" lives on theProjectrow, not on group membership.INV-OIDC-5/INV-ROSTER-1— the security-team roster sync (projects.services.sync_security_team_roster, off unlessPMI_ROSTER_SYNC_ENABLED) pre-provisions notification-only shadow users (User.is_provisioned=True) so@teammentions reach members who've never logged in. Shadows are in no group, hold no authorization, and are cleared to real users on first login; the roster sync never writesuser.groups.INV-INTAKE-1/-2— honeypot trips createHoneypotSubmission, never anAdvisory. The public intake form has noreporter_emailfield; anonymous reports cannot be re-associated later.INV-MAINT-1— while maintenance mode is on, only global admins may mutate state; every other user's writes are paused server-side (common.middleware.MaintenanceModeMiddleware), toggled from the Admin Console's Maintenance page.
Implemented in advisories/permissions.py. Three roles only, ranked owner > collaborator > viewer. Resolution:
- Admin group (
OIDC_ADMIN_GROUP) →ownereverywhere. - Project security team membership →
owneron that project's advisories. - Per-advisory user/group grants →
collaboratororviewer(max rank across direct + group grants wins). - Otherwise → no access.
Publication state grants no implicit read access inside AdvisoryHub — published advisories are visible only to owners and explicit grantees (the public surface lives in the consumer Git repo's website). Editing a published advisory appends an AdvisoryVersion and sets republish_required=True.
Capability matrix per role, state-conditioned overrides (triage, admin-routing-flagged, review_status=submitted, published, dismissed), step-up authentication, mature-publisher gating, and enforcement surfaces are in docs/specification/permissions.md.
Fourteen Django apps under the project root (plus the config project package and the common helper module, which are not installed apps). Full per-app inventory in docs/specification/architecture.md §2. The apps you'll touch most:
advisories/—Advisory(incl.triagestate) + append-onlyAdvisoryVersion+AdvisoryIntakeMetadatasidecar; permissions, services (promote_triage_to_draft,record_advisory_version), forms, HTMX views.access/—AdvisoryAccessGrant,PendingInvitation, grant services.audit/— append-onlyAuditLogEntry, Postgres triggers,redact_secrets.publication/— OSV+CSAF builders, vendored JSON schemas inpublication/schemas/, Git push service, Celery task.workflows/—CveRequestTask+ReviewTaskstate machines.admin_console/— sidebar shell at/admin/(Inbox, Projects, Users, Groups, Invitations, CVE Assignment, Publication, GHSA, Audit logs, Access log, Maintenance — review decisions happen on the advisory page, surfaced via the Inbox); views split intoadmin_console/views/<section>.py. The Invitations section lists outstandingPendingInvitationrows across all advisories with per-row re-send (refreshes the expiry window) and cancel. The GHSA section (feature-gated onGHSA_FEATURE_ENABLED) is a GHSA operations + observability dashboard whose action buttons POST to theghsa:endpoints. Django's built-in admin is not mounted — it was removed (nodjango.contrib.admin, no/django-admin/) so every mutation goes through the audited service layer.intake/— publicPOST /report/+HoneypotSubmissiontable. Triage UI lives inadvisories.views(promote / dismiss / flag / reassign).similarity/— LLM-assisted duplicate detection:SimilarityCheck/SimilarityCandidatetask rows +AdvisoryFingerprintcache, Postgres prefilter + provider-agnostic LLM judge (similarity/llm/— Anthropic or any OpenAI-compatible endpoint, rawrequests), owner-only HTMX panel on the advisory page,backfill_fingerprintscommand. Dormant unlessSIMILARITY_CHECK_ENABLED(INV-SIM-2: enabling it is the consent for advisory content to reach the LLM provider).
Untrusted public submissions land in Advisory(state=triage) via advisories.services.submit_triage_report, with intake fingerprints on the AdvisoryIntakeMetadata sidecar. Owner-only until promoted via promote_triage_to_draft or dismissed via dismiss_triage; misrouted reports get flag_for_admin_routing (admin-only thereafter). Full transition table and edit side-effects in docs/specification/advisory-lifecycle.md §10.
Entry: publication.services.publish(advisory, by=user) — pins the latest AdvisoryVersion on a new PublicationTask and enqueues publication.tasks.run_publication via transaction.on_commit. Full pipeline (build OSV/CSAF, validate against vendored schemas, persist PublicationArtifact, clone into a fresh tempdir, write, commit, push, atomic finalisation under select_for_update, failure handling) in docs/specification/architecture.md §4. Failed exports surface in the Admin Console's Publication page (/admin/publications/).
Auth modes for the publication repo:
PUB_REPO_AUTH=ssh— a per-callGIT_SSHwrapper execs ssh withIdentitiesOnly=yes,BatchMode=yes,StrictHostKeyChecking=accept-new(GIT_SSH_COMMANDwould need the shell the production image doesn't have). Use a pre-populated known_hosts image in prod for strict checking.PUB_REPO_AUTH=token— rewrites HTTPS URL withhttps://x-access-token:$PUB_REPO_TOKEN@…; token stripped from every error/audit/artifact/notification surface.
Server-rendered Django templates + HTMX, one stylesheet (static/advisoryhub.css), hand-written vanilla JS. A nonce-based script-src 'strict-dynamic' CSP (via django-csp; enforced by default, set CSP_REPORT_ONLY=True to fall back to Report-Only) forbids inline script and — via style-src 'self' with no 'unsafe-inline' — inline styles, so when touching templates/JS:
- No inline
on*=handlers, nohx-on::…, no per-elementhx-headers. Add interactive behaviour through the global delegated controllers instatic/advisoryhub-{dialogs,htmx,forms}.js(open/close native<dialog>viadata-dialog-open/-close/-host; CSRF is injected on every htmx request from a single<meta name="csrf-token">viahtmx:configRequest). htmxallowEval/allowScriptTagsare off. - Any genuinely-inline
<script>(e.g. the pre-paint theme bootstrap) must carrynonce="{{ request.csp_nonce }}". - No inline
style="…"/<style>(style-src 'self'blocks them) — put rules inadvisoryhub.css. htmx's own indicator-<style>injection is disabled (htmx.config.includeIndicatorStyles = falseinadvisoryhub-htmx.js); the.htmx-indicatorrules live in the stylesheet instead. - Reference assets only with
{% static %}— prod serves content-hashed files via WhiteNoiseCompressedManifestStaticFilesStorage. Inter is self-hosted (static/fonts/); there is no font CDN. - Multi-line
{# #}comments are forbidden (Django renders them literally —dev/check_template_comments.pyguards this). - Browser-support policy: the frontend targets Baseline Newly Available features and adopts them natively without polyfills when support is broad across current Chrome/Edge/Firefox/Safari — e.g. CSS
@layer,light-dark(), and thepopoverattribute (the account menu inbase.htmlis a native[popover]toggled bypopovertarget, giving click/Escape/light-dismiss with no JS). Prefer a native feature with graceful degradation over a vendored polyfill.
docker-compose.yml's x-django-env anchor is the canonical dev configuration (reused by web and worker); don't edit env files for dev. .env.example documents every prod knob and is reference-only — it is not loaded by docker-compose. dev/kanidm/.env.kanidm is the only file compose actually loads at runtime (for the OIDC client secret minted by the bootstrap script).
Notable knobs: OIDC_GROUP_CLAIM, OIDC_ADMIN_GROUP, OIDC_REQUIRE_EMAIL_VERIFIED (reject an absent email_verified claim too — an explicit falsey always blocks account create/link; default False for Kanidm; INV-OIDC-6), STEP_UP_REQUIRED (re-auth gate before publish), CSP_REPORT_ONLY (CSP is enforced by default; set True for Report-Only) + CSP_REPORT_URI, READYZ_INCLUDE_PUB_REPO / READYZ_INCLUDE_BROKER (extra /readyz probes), RATELIMIT_ENABLE, PMI_ROSTER_SYNC_ENABLED (+ ECLIPSE_API_* OAuth2 creds — security-team roster sync, off by default), SIMILARITY_CHECK_ENABLED (+ SIMILARITY_LLM_* provider/model/key/base-url — LLM duplicate detection, off by default; enabling sends advisory content to the configured provider, INV-SIM-2).
Tag-driven; full runbook in docs/contributing/releasing.md. mise run release -- X.Y.Z bumps every recorded version in lockstep (pyproject.toml + uv.lock via uv version, Chart.yaml version/appVersion) and creates the signed chore(release) commit + signed vX.Y.Z tag; pushing the tag triggers release-image.yml (container image → ghcr.io, signed, SBOM + provenance) and release.yml (version gate → git-cliff notes → wait-for-image → Helm chart → oci://ghcr.io/<owner>/charts, cosign-signed → GitHub release with chart/SBOM/checksums attached), and docs.yml (versioned documentation → GitHub Pages: X.Y.Z + latest via mike on the gh-pages branch, dev tracks main). mise run release-check asserts the version lockstep anytime.
Workflow conventions (gated by zizmor — mise run zizmor, prek hook, and the workflow-security.yml job): every uses: is pinned to a full commit SHA with a trailing # vX.Y.Z comment that Dependabot maintains — keep both when editing workflows; every job starts with step-security/harden-runner (audit); checkouts set persist-credentials: false; step outputs are expanded via env: indirection, never inline in run:. Suppressions live in .github/zizmor.yml with rationale.
- No public anonymous website (lives in the consumer git repo's CI output).
- No real MITRE CVE integration —
workflows.CveRequestTaskis an internal queue. - Tests do not require a real OIDC provider, real email, or a real Git remote (Phase D tests use a temporary local bare repo and skip if
gitisn't on PATH).
When creating commits in this repository, every rule below must be respected:
- Every commit must be signed (
-S) —preferably with ssh keys in sandboxes (viaSSH_AUTH_SOCK) — and signed-off (-s). - Every commit message must follow the Conventional Commits specification.
- Never add a
Co-Authored-ByGit trailer to the commit message footer (this intentionally overrides the default Co-Authored-By behaviour). - Every AI-generated commit MUST include this Git trailer in the commit message footer:
Assisted-by: <AGENT_NAME>:<MODEL_VERSION>
Examples:
Assisted-by: Codex:gpt-5.4-mini
Assisted-by: Claude:claude-sonnet-4-6