diff --git a/.gitleaks.toml b/.gitleaks.toml index 3d6d031..b708a63 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -39,3 +39,18 @@ ever existed. Matched by `generic-api-key` because the fixtures are named `secre Keyed on the literal and nothing else — the file is still scanned for everything else. """ regexes = ['''AKIAABCDEFGHIJKLMNOP'''] + +[[allowlists]] +description = """ +A high-entropy nonsense string in the keyword-proximity tests +(packages/detect/tests/test_signals.py, introduced in e6b1317). It is the *subject* +of those tests rather than a credential: they assert that `oauth` clipped by a window +edge does not count as the keyword `auth`, which needs a token entropic enough for +`generic-api-key` to match — so the fixture and the finding have the same cause. + +Allowlisted here rather than with an inline `# gitleaks:allow` because e6b1317 is +already in history and the CI job scans all of it; editing the file today would not +change what that commit contains. Keyed on the literal, so the file is still scanned +for everything else. +""" +regexes = ['''8f3Kd0aQ2mVx7Lp9Zr4Ts6'''] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6022e63..040bc7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,11 @@ +# Vendored frontend assets and the brand marks are byte-pinned: static/assets.lock.json +# records a sha384 of each, base.html emits it as an `integrity=` attribute, and +# apps/api/tests/test_web_invariants.py checks the two agree. A whitespace hook that +# "fixes" a minified bundle changes those bytes, so the browser blocks the script and +# the page silently loses its behaviour — with a diff that looks like nothing happened. +# Leave them exactly as web/vendor_assets.py wrote them. +exclude: ^apps/api/src/iceberg_api/web/static/(js/vendor|css/vendor|fonts|img)/ + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2bec33f..ab867b6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -160,8 +160,9 @@ connector credentials, full audit trail on finding state changes, and RBAC on ev - **M1 — Control plane MVP:** auth, sources/schedules, scan orchestration, findings/triage. - **M2 — Detection + Confluence:** detection engine, connector framework, Confluence connector, engine worker. -- **M3 — Web UI:** HTMX/Alpine screens. -- **M4 — Notifications & prod deploy:** notifications, Helm, hardening. +- **M3 — Web UI:** HTMX/Alpine screens, served by the API at the application root and driving the + same route handlers the JSON API exposes ([`docs/web.md`](./docs/web.md)). +- **M4 — Notifications & prod deploy:** notification dispatch, Helm, hardening. Non-goals for MVP: Jira/SMB connectors, incremental/delta scanning, image OCR, external ticket creation, multi-tenancy. diff --git a/CLAUDE.md b/CLAUDE.md index 5e0228a..0e753a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,13 @@ IcebergSST — a secret-scanning platform for non-git enterprise sources (Conflu shares). See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the design spec and [`docs/adr/`](./docs/adr/) for decision rationale. -**Where the code is:** M0–M2 are built. `packages/core` (config, DB session, secret store, +**Where the code is:** M0–M3 are built. `packages/core` (config, DB session, secret store, redaction, fingerprinting, the SQLModel schema), `packages/detect` (rule packs, the detection engine), `packages/connectors` (Confluence, extraction, the sandbox), `apps/api` (auth, sources, -scans, findings, engine-facing routes, scheduler/maintenance), `apps/engine` (the Dramatiq -worker), Alembic, and the docker-compose stack all exist and are tested. The HTMX/Alpine web UI -(M3) and notifications/Helm (M4) are still to come — `docs/backlog.md` tracks those. +scans, findings, notification channels, engine-facing routes, scheduler/maintenance, and the +server-rendered web console under `apps/api/src/iceberg_api/web/`), `apps/engine` (the Dramatiq +worker), Alembic, and the docker-compose stack all exist and are tested. Notification *dispatch* +and Helm (M4) are still to come — `docs/backlog.md` tracks those. ## Non-negotiable invariants @@ -28,8 +29,13 @@ change the corresponding ADR: 3. **Detection logic in code, tuning in data.** Rules live in versioned rule packs (`packages/detect`). Suppressions/allowlists live in the DB and are analyst-editable. 4. **API-first.** The FastAPI OpenAPI schema is the contract; the HTMX/Alpine UI is just another - client of the same routes. + client of the same routes. Concretely: a web route resolves the RBAC dependency its API + counterpart declares and then calls that handler as a function. Nothing under + `iceberg_api.web` queries the database (see `docs/web.md`). 5. **Single-org.** No `tenant_id`, no row-level tenancy. +6. **No inline script or style in a template.** The console runs under + `script-src 'self'` with no `'unsafe-inline'`/`'unsafe-eval'`; Alpine components go in + `static/js/tags.js` and server data travels through JSON islands. ## Stack @@ -53,6 +59,11 @@ Prefer extending these over adding prose: configuration, no master key, and stay scalable; `.env.example` documents every variable compose interpolates. - `apps/api/tests/test_migrations.py` — migrations apply, match the metadata, and reverse. +- `apps/api/tests/test_web_invariants.py` — no `iceberg_api.web` module imports the ORM or builds a + query, every mutating browser route is CSRF-protected, no HTML endpoint appears in the OpenAPI + document, and every vendored asset matches its `assets.lock.json` integrity. +- `apps/api/tests/test_web_shell.py` — no template carries an inline ` +
+ + + +| Name | Type | Destination | Filter | Secret | State | Actions |
|---|---|---|---|---|---|---|
| {{ channel.name }} | +{{ channel.type.value }} | ++ {% if channel.type.value == 'webhook' %} + {{ channel.config.get('url', '—') }} + {% else %} + {{ channel.config.get('recipients', []) | join(', ') or '—' }} + {% endif %} + | ++ {{ (channel.event_filter.min_severity.value ~ '+') if channel.event_filter.min_severity else 'all' }} + | ++ {% if channel.has_secret %} + sealed + {% else %} + none + {% endif %} + | ++ {{ 'enabled' if channel.enabled else 'disabled' }} + | ++ + + + + + | +
Nothing leaves this deployment. Findings are visible in the console and through the API only.
++ Engines hold no database credentials. They lease tasks over the API, receive the source credential + and the fingerprint pepper for the duration of one task, and post redacted results back. An engine + that stops heartbeating loses its leases, and the API redispatches its work. +
+ +{% if error %} +{{ credential.token }}
+
+ Set ICEBERG_ENGINE_TOKEN and ICEBERG_ENGINE_ID on the engine. Only its
+ SHA-256 hash is stored here; re-enrolling the same name rotates the token and the old one stops
+ working immediately.
+
| Name | Status | Version | Last heartbeat | Enrolled |
|---|---|---|---|---|
| {{ engine.name }} | ++ {{ engine.status.value }} + | +{{ engine.version or '—' }} | ++ {{ engine.last_heartbeat_at | ago }} + · {{ engine.last_heartbeat_at | dt }} + | +{{ engine.created_at | dt }} | +
Nothing will scan until an engine has a token. Enrol one below, or mint the first from the
+ CLI with python -m iceberg_api mint-engine-token --name engine-1.
+ An engine cannot enrol itself — otherwise anyone who could reach the API could join the fleet + and start leasing tasks, credentials included. Re-using an existing name rotates that engine's + token, which is how you replace one you think has leaked. +
+ ++ Not reporting a pack: {{ rules.engines_without_a_rulepack | join(', ') }} +
+ {% endif %} + + All rules + + ++ What the live fleet is actually detecting, reported by the engines themselves rather than declared + by the API. Rule packs ship inside engine images, so a list published here would describe what the + fleet is supposed to be running — which is the thing this page exists to check. + Rules are metadata only: never a pattern. +
+ +{% if not rules.fleet_consistent %} +| Version | Engines | Rules | Running it |
|---|---|---|---|
| {{ version.version }} | +{{ version.engine_count }} | +{{ version.rule_count }} | +{{ version.engines | join(', ') }} | +
| Rule | Severity | Description | Defined in | Findings |
|---|---|---|---|---|
| {{ rule.id }} | +{{ rule.severity.value }} | +{{ rule.description }} | +{{ rule.rulepack_versions | join(', ') }} | ++ Open + | +
Enrol an engine and let it heartbeat once; the pack it loaded shows up here.
++ Accounts are created by the identity provider on first sign-in and land as viewer + — somebody has an account because the IdP knows them, not because anyone decided what they may do. + Roles are assigned here and never taken from a token claim, so no IdP change can promote anyone. + Every change below writes an audit event. +
+ +{% if error %} +| Person | Subject | Role | State | Last sign-in | Change |
|---|---|---|---|---|---|
|
+ {{ account.display_name }}
+ {{ account.email }}
+ |
+ {{ account.oidc_subject }} | +{{ account.role.value }} | ++ {% if account.disabled %} + disabled + {% else %} + active + {% endif %} + | +{{ account.last_login_at | dt }} | ++ {% if account.id == user.id %} + that is you + {% else %} + + {% endif %} + | +
The first person to sign in through the identity provider gets one.
+