Changelog¶
All notable changes to IcebergEBS are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Note the two spellings of the same version: pyproject.toml carries the PEP 440
form (0.1.0b1) and the git tag carries the SemVer form (v0.1.0-beta.1). The
headings below use the SemVer form. See docs/RELEASING.md.
The running build also reports a build identifier — v0.1.0b1 · build 74 · 8823e7a —
where build N · sha identifies the exact commit. That is a build identifier, not a
release version; only the SemVer part appears here.
Unreleased¶
0.1.0-beta.1 — 2026-07-21¶
First beta. Everything below is the work merged to main to date; there is no earlier
release to diff against.
Added¶
- Outbound integrations: Slack, Teams, email, and Jira/ServiceNow ticketing (#37). Alert
destinations gained a
kind(webhook, slack, teams, email, jira, servicenow) and aconfigcolumn, dispatched through a newapp/senders/adapter registry that mirrors the OIDC adapter design: a purebase.py(Protocol + registry), a sharedHttpJsonSendercore (the delivery analogue ofStandardOIDCAdapter), and one small self-registering module per kind. Webhook is not privileged — Slack/Teams are specialised webhooks (same SSRF-pinned delivery, a different payload), Jira/ServiceNow add an auth header + a create-issue path, and email is the one non-HTTP transport (SMTP via stdlibsmtplib, offloaded off the event loop; it does not traverse the outbound proxy). A rule can fire to any mix of destinations and each delivery is recorded in the alert log. Secrets are env-only like the OIDC/proxy credentials: SMTP isICEBERG_EBS_SMTP_*; per-destination Jira/ServiceNow API tokens are referenced by anUPPER_SNAKE_CASEsecret_refand read at send time fromICEBERG_EBS_DEST_SECRET_<REF>, never stored on the row or returned by the API. The account page grows a kind selector with a descriptor-driven config form, and testing a destination sends a real notification through the same path real alerts use (for ticketing, one real test ticket — proving auth + field mapping). Ack_alertdestination_kindCHECK backstops the kind enum, held in lockstep with the registry by a drift test. Both deploy stacks forward the SMTP config (password as a Helm chart Secret, never ConfigMap); Compose loads per-destination secrets from an optionalintegrations.env, and Helm takes them viaextraEnv/extraEnvFrom. -
API keys now fall under the SSO revocation controls (#278). Bearer keys sat outside every session-revocation mechanism: an employee offboarded by disabling their IdP account lost their session within the hour (#221) but their API key worked forever, with no app-side signal. Two fences on the bearer path: keys minted before the user's
password_changed_atare rejected (so the IdP-driven marker bump that already revokes cookies now revokes bearer tokens too), and SSO-owned keys expire a bounded number of days after creation (ICEBERG_EBS_API_KEY_SSO_MAX_AGE_DAYS, default 30, forwarded in both deploy stacks; 0 disables). Local accounts are unaffected — their keys are still deleted outright on password change. Key creation now requires an interactive session — a bearer key can no longer mint a replacement, so an SSO key can't self-renew past its lifetime. The revocation cutoff uses a strict timestamp compare (no cookie-style 1s tolerance, sincecreated_atkeeps microsecond precision). -
Public documentation site under
website/, built with Zensical and deployed to GitHub Pages at https://icebergai.github.io/IcebergEBS/ by.github/workflows/docs.yml(pinnedzensicalversion; the job holdspages: write+id-token: write). Styled to the shared Iceberg design system to match the sibling IcebergTTX site, with pages for risk scoring, deployment, alerts/API, and security; the changelog page snippet-includes this file. Requires Settings → Pages → Source = "GitHub Actions" (one-time). - Extension tracking for the Chrome Web Store, VS Code Marketplace, and Edge Add-ons: metadata fetch, package download, and static analysis of the shipped code.
- Risk scoring (0–100) across permissions, popularity, publisher identity, staleness, code behaviour (eval/obfuscation/remote fetches), and external domains contacted.
- Multi-user watchlists with a background scheduler that re-fetches on an interval and fires webhook alerts when an extension changes.
- Pagination, filtering, search, and sorting on
GET /api/extensionsand the dashboard (#23). - Bulk import —
POST /api/extensions/bulk, plus a paste box in the UI (#24). - Export —
GET /api/extensions/export?format=csv|json(#25). - SOAR-fed org inventory and exposure —
POST /api/inventory, an install footprint per extension, and exposure ("blast radius") = risk × footprint, surfaced as a top-exposure panel and a per-department breakdown (#29). - Fetch-health surfacing on the dashboard, plus unauthenticated
/healthz(liveness) and/readyz(readiness) probes for orchestrators (#26). - Data retention pruning for
FetchLog,InstallCountHistory, andAlertLog, gated byICEBERG_EBS_RETENTION_DAYS(#22). - API keys (bearer tokens, read-only supported) for machine-to-machine access.
- Database backups — the Docker Compose stack ships a
backupservice that writes retained, atomically-writtenpg_dump -Fcdumps to./backupson a configurable cadence (BACKUP_INTERVAL_SECONDS/BACKUP_RETENTION_DAYS), plus a restore runbook, pre-upgrade dump step, Helm backup options (Bitnami CronJob / VolumeSnapshots / external managed Postgres), and an explicit RPO inDEPLOYMENT.md → Backups & disaster recovery(#86). - A browser-level UI smoke CI job (
ui) — boots the real stack (Postgres + uvicorn behind nginx withsecurity_headers.conf) and drives it with Playwright: login → dashboard renders → topbar search, asserting no CSP violation or uncaught JS error. This catches breakage the API/unit suite can't see — most importantly the hand-maintained inline-script CSP hash drifting. Playwright runs from a throwaway venv, so it needs nouv.lockentry (#100). -
Observability baseline (#89) — application logs now carry a timestamp (and can be emitted as single-line JSON via
ICEBERG_EBS_LOG_JSON=true, forwarded by both deploy stacks, covering the app + uvicorn loggers);/readyzreportslast_scheduler_run(an in-process signal — no history-table scan on the probe, scheduler-only so an API fetch can't mask a stall) so an external monitor can catch "the app is up but the scheduler has stalled" (advisory — it doesn't flip readiness); and the nginx access log gains referer, user-agent, and request/upstream timing. Error-tracking (Sentry) is a documented follow-up (it needs a runtime dependency). -
Outbound proxy support for all egress (#216) — store metadata fetching, package downloads, and webhook alert delivery route through a configurable forward proxy. Three modes (
system— honourHTTP(S)_PROXY/NO_PROXYenv;none— always direct;explicit— configured proxy URL with standard NO_PROXY semantics incl. CIDR ranges), seeded fromICEBERG_EBS_PROXY_*env into an admin-editable routing config at/admin/proxy(applies from the next request, no restart), with an SSRF-safe connectivity test over server-known egress targets only. Proxy credentials are env-only — never persisted, returned by the API, or logged. Webhook delivery keeps its IP-pinning SSRF defence through the proxy (IP-literalCONNECT). - Single sign-on via OIDC (#32) — Authorization Code + PKCE (Authlib) against
Microsoft Entra ID, Authentik, Auth0, or Okta, with just-in-time account
provisioning keyed on the immutable provider subject (a mutable email claim can never
claim an existing account — collisions are denied, not auto-linked), and IdP
group→admin mapping via a per-provider
group=admin|userallowlist (default non-admin, no self-elevation; the flag re-syncs on every SSO login and revokes older sessions on change).ICEBERG_EBS_AUTH_MODEgates the login paths (local/oidc/both) with local accounts as break-glass — OIDC-only mode is refused unless a complete provider is enabled. Non-secret provider config is env-seeded and admin-editable at/admin/oidc; client secrets are env-only, never persisted, returned, or logged. All IdP traffic routes through the #216 proxy layer. IdP group→named-role mapping (analystetc.) lands with RBAC (#33); SAML, an explicit account-linking flow, and RP-initiated logout are follow-ups.
Changed¶
- The rail is grouped by privilege, breadcrumbs come from the shell, and search has one home. Seven scoped UI fixes; no restyling and no IA change beyond the rail split.
- The single Admin rail group held per-user settings (alerts/webhooks, API keys, help)
alongside the conditionally-rendered admin-only pages, so the heading was wrong for regular
members and the two privilege levels looked identical. Split into Account (everyone) and
Administration (gated on
is_admin). iceberg.cssshipped.topbar-crumb/.crumb-*with no markup rendering them, while the extension-detail and account pages each hand-rolled a different inline breadcrumb and the dashboard had none. One breadcrumb now renders in the top bar; pages set only the leaf via{% block crumb %}.- The dashboard carried its own search form submitting to the same place as the top-bar box —
two inputs, one behaviour. The in-page form is gone; an active query shows as a clear-chip.
That form's hidden inputs were what carried
store/risk/sortthrough a search, sotopbar-search.jsnow forwards the existing query string instead of hard-coding/?q=— without it, searching would silently reset the active filters. - Store and risk filters read as one nine-pill strip once the row wrapped and the divider was lost; each axis now sits in its own labelled group.
- Table rows navigate on click but only the Refresh button said so — added a muted trailing
chevron (outside the
@click.stopgroup, so the row click still fires). - Delete on the extension-detail page sat flush with the benign actions; it is now isolated behind the same divider the watchlist switch already uses.
- The per-signal note in the score breakdown lives in a
hidden md:blockcolumn, so belowmdthe six bars carried no explanation of what each measures — now also a nativetitle. - Permission tiers now cover the surveillance/capture family (#280):
desktopCapture(arbitrary screen recording) is CRITICAL;tabCapture/audioCapture/videoCaptureandwebNavigation(full browsing-graph telemetry) are HIGH;privacy,sessions,topSites, plaindeclarativeNetRequest, andclipboardWriteare MEDIUM. Previously all of these scored zero — an extension requesting only screen/tab recording was indistinguishable from one requesting nothing. Extensions carrying these permissions re-score higher on their next refresh, which can move arisk_levelband (and may firerisk_level_changealerts). - Security headers are now owned by the app, not the edge. The FastAPI
security_headersmiddleware emits the full canonical set on every response — CSP (script-src 'self'), HSTS (max-age=63072000; includeSubDomains; preload, upgraded from the app's old 1-year no-preload value),Permissions-Policy(previously edge-only),X-Content-Type-Options,X-Frame-Options,Referrer-Policy— so the same strict policy reaches clients on every deployment path, proxied or not.caddy/headers.caddyshrinks to a minimal static set-if-absent (?) fallback covering only Caddy-generated responses (its own 502, the :80→HTTPS redirect); a hard edge SET would clobber the app's canonical values and is now test-forbidden. While relocating, the CSP was tightened:style-src-elem 'self'+style-src-attr 'unsafe-inline'(withstyle-src 'self' 'unsafe-inline'kept as the legacy-browser fallback), and the unuseddata:source dropped fromimg-src. - Two static-inspector false positives no longer inflate scores (#151): a bare
new XMLHttpRequestconstructor no longer counts as remote code (only a.open(...)to a literalhttp(s)://URL does — matching howfetch()was already scored), and a scoped wildcard-subdomain CSP source likehttps://*.googleapis.comis no longer flagged as a broad wildcard (only a whole-host wildcard — bare*,https://*,https://*:443,https://*/path— is). Benign extensions that hit either pattern re-score lower on their next refresh, which can move arisk_levelband. (The legacy duplicate_REMOTE_FETCH_RE— which drove the bare-XHR path — is removed.) - External-domain scoring now counts distinct registrable domains (eTLD+1) via
the Public Suffix List (
tldextract, pinned to its bundled offline snapshot — no network or disk access), not raw hostnames (#254): a single party spraying subdomains (api./cdn./ws.evil.com) no longer outscores genuinely unrelated sites. Existing extensions may re-score lower on their next refresh, which can move arisk_levelband and fire arisk_level_changealert once. Stored/displayed domain lists and threat-intel indicators still carry the full hostnames. - Outbound
HTTP(S)_PROXYenv vars are now honoured by default (#216): the shared httpx client previously ignored them entirely (custom transport). The new default proxy modesystemapplies them — setICEBERG_EBS_PROXY_MODE=noneto keep the old always-direct behaviour. - Legacy design tokens fully retired (#212): the ~250 inline
style="…var(--ink-N)…"attributes left from the pre-house design are swept to the house token names and theapp.cssalias bridge is deleted;tests/test_design_tokens.pyguards against legacy tokens or stray oklch severity literals reappearing in templates/JS. - Account menu moved to the rail foot;
/focuses search (#223): the user/theme menu relocated from the topbar to the bottom of the left rail, and pressing/(outside a text field) jumps to the topbar search box, with a/hint chip shown in it. - Adopted the IcebergAI house design system (#105): the shared family token sheet
(
static/css/iceberg.css— cool blue-grey oklch palette, fixed glacial-cyan accent, dark.rail/ light.workspace/.brandbarshell) replaces the bespoke "Refined Operator" theme;app.cssshrank to the app-specific layer (risk palette + components) with the old--ink-Nscale aliased onto house tokens. Fonts moved from IBM Plex to the family set — Archivo (UI), JetBrains Mono (data/labels), Spectral (prose), self-hosted. The Aperture mark is retired for the IcebergAI brand mark (rail lockup, login, favicons), and the login page became the house centered auth-card. Risk-band colours are consolidated: the 75/50/25 thresholds live only inapp/scoring.py:risk_level(the server passesrisk_bandto the UI) and the severity colours live only inapp.css's--risk-*tokens — the duplicated oklch literals in the dashboard JS, detail-page Jinja andtrend-chart.jsare deleted, and the trend chart now follows the light/dark theme. - Strict same-origin Content-Security-Policy (#106):
script-srcis now exactly'self'— no CDN hosts, nounsafe-eval, and no inline scripts anywhere. Alpine moved to the@alpinejs/cspbuild with every component registered viaAlpine.data()from same-origin files (static/js/app.js+static/js/pages/), server data delivered through<script type="application/json">islands, and the hash-pinned inline anti-flash script replaced by the externalstatic/js/theme-boot.js— deleting the hand-maintained sha256 pin entirely. Theme preference upgraded from a binary toggle to system/light/dark ('system' follows the OS viaprefers-color-scheme), cookie-backed for flicker-free server-side first paint. Guards:tests/test_csp_strict.py(no inline scripts/handlers, script-src exactly'self') and the e2e suite now fails on any CSP console error. This also fixes Alpine interactivity in production: behind Caddy the old standard build's expression eval was CSP-blocked (nounsafe-eval), so interactive components (dropdown, tabs, forms' client behaviour) silently didn't run. - The frontend is fully self-hosted — no third-party CDN at runtime (#85). Tailwind is now
a real v4 build (standalone CLI via
pytailwindcss,static/css/input.css→ a gitignoredstatic/css/output.css, built bymake css/ a Dockerfiletailwind-builderstage) instead of the browser-compiled Play CDN; Alpine.js is vendored and version-pinned (static/js/vendor/alpine-3.15.12.min.js) instead of a floating3.x.xfrom jsDelivr; the IBM Plex fonts are served fromstatic/fonts/instead of Google Fonts (also a privacy fix — visitor IPs no longer leak to Google). The Play-CDN config shimstatic/js/tailwind-config.jsis retired. Compose's Caddy now proxies/staticto the app (like the K8s sidecar) since the built CSS exists only inside the app image; the CSP dropscdn.tailwindcss.com,cdn.jsdelivr.net,fonts.googleapis.comandfonts.gstatic.com— every source directive is same-origin, enforced bytests/test_no_third_party_origins.py. - Replaced nginx with Caddy as the edge reverse proxy (#188).
The canonical security headers — CSP (with its inline-script hash), HSTS, and the rest — now
live in one place,
caddy/headers.caddy, imported by both the Compose Caddyfile and the Kubernetes sidecar config; previously the CSP was hand-duplicated acrossnginx/security_headers.confand the Helm ingress snippet, and the two copies had drifted. The Kubernetes topology is now cluster ingress → in-pod Caddy sidecar → app, so the ingress no longer carries a duplicated CSP snippet. Because Caddy has no built-in rate-limit directive, the old nginxlogin/apilimit_reqzones moved app-side: a new token-bucket API rate limiter (ICEBERG_EBS_API_RATE_LIMIT_ENABLED, on in the Compose/Helm env) keyed on the client IP, with the K8s cluster ingress still limiting at the true edge. TheX-Forwarded-Foranti-spoof (#77) is preserved — Caddy sets a single canonical{client_ip}, discarding a client-supplied XFF at the edge and honouring the ingress's value in-cluster. - Postgres pinned to
18-alpine(was16-alpine), bumped together across the Compose server, thebackup/pg_dumpservice, and the CI test service container sopg_dump's version always matches the server's. An existing PG16postgres_datavolume will not start under 18 — seeDEPLOYMENT.md → Backups & disaster recoveryfor the dump/restore upgrade path (a fresh install needs nothing). nginx bumped1.29-alpine→1.31-alpine. Supersedes the grouped Dependabot PR #114, which changed only the Compose server image. - Renamed from Marvin to IcebergEBS, ahead of the repository being made public. This
renames the product, the repository (
IcebergAI/IcebergEBS), and the internal identifiers: the config env prefix is nowICEBERG_EBS_(wasMARVIN_), the default Postgres user/database isiceberg_ebs, the session cookie isiceberg_ebs_session, API keys are prefixedebs_, and the webhook payload fieldmarvin_urlis nowiceberg_ebs_url. There was no released version and no deployment to migrate, so no compatibility shim is carried. - PostgreSQL is now the only supported database — SQLite support removed, in dev, test, and production (#27).
- Schema is managed by Alembic instead of hand-rolled migrations (#11).
- Dependencies are managed with uv against a committed
uv.lock;pyproject.tomlis the single manifest and builds are reproducible (#90). - The production image is multi-stage and runtime-only — no build tooling, no uv, and no test toolchain in the deployed container (#84).
- Dependabot watches Python packages, GitHub Actions, and container images weekly (#91).
- CPU-bound work (bcrypt, package inspection) is offloaded off the event loop, so a single worker no longer stalls on it (#4).
ApiKey.last_used_atwrites are throttled instead of committing on every bearer request (#5).- The extension list endpoint no longer builds threat-intel indicators it never renders (#12).
- Inventory scoring of unknown extensions is deferred to the scheduler, so a large SOAR batch cannot exceed the request timeout (#78).
- Outbound-fetch resilience — the shared HTTP client now retries transient store failures
(connect/timeout/429/5xx) on idempotent requests (GETs, plus the VS Code gallery query, a read
served over POST that opts in) with exponential backoff + jitter, honouring
Retry-After, and bounds its connection pool; 404 (a delisted extension) is never retried, and webhook POSTs are never retried. A per-store circuit breaker stops hammering a store that fails repeatedly in a cycle and records the skip as a store outage (newFetchLog.store_outage) so the Fetch-health tile no longer blames every extension when a store is simply down; unexpected internal errors (not store failures) stay loudly logged and never open the circuit (#108). - Each refresh now loads only the two most recent install-count readings instead of hydrating an extension's entire history — the sudden-drop scoring check only needs the previous reading, so the old full-table scan grew unboundedly (retention is disabled by default) on every refresh of every watchlist extension (#146).
- The shared
get_alert_logquery/serialization helper moved out of theroutes/alerts.pyHTTP-route module into a neutralapp/alert_queries.py, so the dashboard (routes/ui.py) no longer reaches cross-module into a route module for it. Pure code motion, no behaviour change — mirrors how the extension-query layer was pulled intoapp/extension_queries.py(#149).
Fixed¶
- The alert-destination Type dropdown is populated again, so destinations can be created from
the UI (#311).
routes/ui.pypasseddestination_kindsinto the account-page context andsenders/base.py:kind_descriptorsdocuments itself as feeding "both theGET /api/alerts/destination-kindsendpoint and the account-page JSON island" — but the island only serialiseddestinations/rules/extensions/alert_log, sodata.destination_kindswas undefined,destinationKindsfell back to[], and thex-forproduced no<option>s. The blast radius ran past the dropdown:selectedKindresolved tonull, so the kind-specific config fields never rendered, the target label degraded to a generic "Target", and the availability warning was suppressed — Slack/Teams/email/Jira/ServiceNow destinations were unreachable from the UI. The page never calls the API endpoint, so the endpoint's own tests could not catch it; the regression test asserts on the rendered island and that it matcheskind_descriptors()exactly, which is the contract that drifted. - Wrong-shape stored
host_permissions/risk_detailcan no longer mis-score or blank the UI (#291). Three consumers skipped the #150/#164/#167 defensive-parse guard: the keep-stale scoring path fed a storedhost_permissionsstring intoscore_permissions'set()(iterating it char-by-char into a silently wrong score that persisted across refreshes), and the detail page readhost_permissions/risk_detailunguarded (a non-list 500s the page; arisk_detaildict missing a key rendered blank). A singleutils.host_permissions_ofguard now backs all consumers, andRiskDetail.stored_defaults()backfills a sparse breakdown (mirroringPackageAnalysis, #164). - Workflow hygiene (#289).
build.ymlgained aconcurrencygroup so a superseded main build can't leave the moving:edgetag pointing at an older image (#88's stale-pointer class);timeout-minutesnow caps the build/release/CodeQL jobs (ci.yml already capped its own); and CodeQL no longer scans the vendored, minified Alpine bundle (static/js/vendor/, #85/#106), whose findings were being attributed to this repo. - The embedded Compose snapshot in
DEPLOYMENT.mdno longer contradicts the real stack (#290). It showed bare${VAR}credentials (the empty-string-credential trap #273 fixed) and the./staticCaddy mount retired by #85; re-synced to${VAR:?}guards with no static mount, and a doc-grep test now fails if the snapshot drifts. - Proxy credentials can no longer leak through persisted or logged error text (#228).
proxy.scrubwas applied at exactly one sink (the admin connectivity test) and only knew the explicitICEBERG_EBS_PROXY_USERNAME/_PASSWORDvalues — in the default SYSTEM mode the resolved proxy URL is the rawHTTP(S)_PROXY/ALL_PROXYenv value whose embedded userinfo passed through untouched.scrubnow also redacts env-carried credentials and generically strips anyscheme://user:pass@userinfo, and is applied at every sink that can see a proxy-layer exception: the transport retry log, the scheduler's log + persistedFetchLog.error_message(rendered to non-admin owners), andfire_alerts' persistedAlertLog.error(returned by the API and rendered in the UI). - One extension's failure can no longer abort the whole watchlist refresh cycle (#282).
The per-extension isolation held transactionally but not for control flow: the
failure-
FetchLogwrite lived inside the store-failure handler (where the siblingexcept Exceptioncannot catch it), and the post-commit refresh/alert tails were unguarded — so aDELETEracing the scheduler mid-fetch (an FKIntegrityError), or any transient DB error at those points, aborted the cycle: every remaining extension skipped, no further logs, and the/readyzscheduler heartbeat left stale. All tails are now guarded, and the driving loop carries a breaker-neutral backstop so an escaped internal error recordsERRORand the cycle continues. A failed alert delivery after a committed state change now also retries via the durable pending-alert marker instead of escaping. Pending-alert recovery (which runs at the head of each cycle, before the refresh loop) is likewise isolated per extension and wrapped in a cycle-level guard, so a concurrent delete or delivery error during recovery can't abort the refresh or leave the/readyzheartbeat stale either. - Chrome fetcher: version can no longer be hijacked by description text, and scraping is
locale-pinned (#279). The version regex ran over the whole visible page, where the
description renders before the Details section — a description saying "New in Version 9.9.9"
produced a spurious
new_versionalert and then a permanently wrong stored version. The version now comes from the Details section only: exact label adjacency for the split form, and for Chrome's inlineVersion: x.y.znode an extraction anchored to the Details labels (searched after "Updated"/"Offered by") so an earlier description node can't hijack it. The detail request also pinshl=en+Accept-Language: en-US,en: without it Google localizes by egress IP, the English label/month parsing finds nothing,last_updatedstaysNone, and staleness silently scores 10 ("unknown") for any non-US deployment. install_footprint(and therefore exposure) now decays (#287).InstallObservation. last_seenwas written on every SOAR re-push but never read, and the table was exempt from retention — so an extension removed from every endpoint kept its old footprint forever, permanently inflating exposure, the "Top exposure" ranking, andsort=exposure. Observations not re-seen withinICEBERG_EBS_INVENTORY_FRESHNESS_DAYS(default 30, forwarded in both deploy stacks; 0 disables) stop counting: the per-batch recompute and the detail-page department breakdown filter to fresh observations, a new daily scheduler job re-computes every cached footprint (covering extensions that stopped appearing in pushes entirely), and the retention prune now also expiresInstallObservationrows onlast_seen— recomputing the affected footprints in the same transaction so a cached value can't outlive its rows even when decay is disabled (the daily refresh job is only registered when decay is on).- Three unbounded-growth hot spots no longer degrade with history size (#284). The
dashboard's latest-FetchLog-per-extension lookup now uses a correlated
LATERAL … ORDER BY … LIMIT 1per extension over a new(extension_id, fetched_at DESC, id DESC)composite index (Alembic migration; siblingInstallCountHistoryalready had the analogous index) — one indexed row fetch per extension, independent of history size — instead of a group-by/max self-join (and, in review, aDISTINCT ONthat still scanned every row before deduping) that re-sorted every extension's whole history on each render.GET /api/extensions/{id}/historyis now bounded like/alerts/log(limit≤ 500 keeping the most recent points, plussincefor cheap increments) instead of returning every row ever. And/accountplus the CSV/JSON export use column-only selects instead of full ORM rows, so the multi-KB per-extension JSON blobs stay out of memory on unpaginated paths. - Detail-page permission badges no longer contradict the score (#281). The template
hand-copied the permission-tier sets and had drifted:
declarativeNetRequestWithHostAccess(CRITICAL — maxes the permissions score),pageCapture(HIGH), and the*://*/*broad-host pattern all rendered as grey "low"/wrong-tier tags. Tiers are now computed server-side fromapp/permissions.py(the same single source the scorer and inspector use), the score-breakdown bars take their band fromscoring.risk_levelinstead of re-inlined cut points, and the dashboard "High & critical" tile derives its threshold fromRISK_BANDS. - A webhook URL with an invalid port no longer 500s destination create/update (#283).
urlparsedefers port validation to.portaccess, whose bareValueErrorwas unhandled:https://hooks.example.com:99999/xcrashed the request, while the bare-IP form (http://8.8.8.8:99999/x) skipped the access entirely and was stored, turning into a permanently-failing destination that only errored at send time.validate_webhook_urlnow reads the port exactly once, up front, and rejects invalid ports as a normal 422 validation error on every URL shape. ICEBERG_EBS_OIDC_SESSION_MAX_AGEis now actually forwarded to the container (#285). The SSO session cap (#221) was documented as tunable but appeared in neither the Composeapp.environmentblock nor the Helm ConfigMap, so an operator shortening the window to speed up IdP-disable propagation got a silent no-op — sessions kept the 1h default (the #87 trap). Forwarded in both stacks and added to the deploy-env regression list.- A manifest the inspector cannot read no longer erases an extension's permissions (#274).
_read_manifest_jsonparsed with a plainjson.loads, which rejects a UTF-8 BOM and JS-style comments — both of which Chrome itself tolerates, so such packages are live in the stores. The parse failure was swallowed and analysis continued withmanifest=None, and because that analysis object is still truthy,services._effective_valuespreferred its empty permission list over the stored one: an<all_urls>+webRequestextension silently lost up to 25 permission points and fired a spuriouspermission_change"removal" alert. (The #142 keep-stale guard only coveredanalysis is None.) Manifests are now decoded withutf-8-sigand re-parsed with comments stripped by a string-aware stripper, and a manifest that genuinely cannot be read — unparsable, wrong shape, or over the size limit — raises instead, taking the same keep-stale path as a failed download. The highest-priority candidate present is authoritative, with no fallthrough: a Chrome extension with a corruptmanifest.jsonthat also ships npm metadata inpackage.jsonwould otherwise match that instead, be classified as VS Code on the filename, and have its permissions cleared anyway. Behaviour change: an over-limit manifest previously produced an analysis with no permissions; it now aborts the inspection, so scoring falls back to the unknown-midpoint rather than a falsely clean zero. - VS Code extension packs and API-only extensions are no longer reported as Chrome Manifest V2
(#274). The classifier tested
"contributes" in manifest, which misses extension packs (extensionPack) and API-only extensions (main+activationEvents). Those fell through to the Chrome path, wheremanifest_versiondefaulted to 2 and produced a "Manifest V2 extension" medium finding — a Chrome-specific claim rendered on a VSIX, worth +2 on the code-behaviour score. Packages are now classified by the file the manifest came from (package.json⇒ VS Code, information_load_manifestalready had and discarded), withengines.vscodeas a second signal. - The Helm chart's database wiring now actually resolves (#276). Three defects, none of which any gate could catch because nothing in CI rendered or installed the chart.
- The Deployment referenced its DB Secret and host as
<release>-iceberg-ebs-postgresql, but a subchart names resources with its own chart name, so Bitnami created<release>-postgresql. The app pod could only ever have died inCreateContainerConfigError, and would not have resolved the DB host either. postgresql.enabled=falsewas inert — the dependency carried nocondition:, so the subchart deployed regardless, andICEBERG_EBS_DATABASE_URLwas hardcoded from the subchart's values. The documented external-database option was impossible. There is now anexternalDatabase.url/.existingSecretknob; the DSN carries a password so it is only ever read from a Secret, and supplying neither fails the render rather than deploying a pod that cannot reach a database.- The bundled Bitnami subchart has been replaced by an in-chart StatefulSet on
docker.io/library/postgres:18-alpine. Broadcom's 2025 catalog migration moved every versionedbitnami/postgresqltag tobitnamilegacy, so the chart's~15.x.xpin no longer pulls at all — a fresh installImagePullBackOffs — and the current Bitnami chart can only reach PostgreSQL 18 through a movinglatesttag on the free tier, which could roll a running deployment onto a new major on a pod restart. The chart now runs the exact image Compose and CI use, held in lockstep bytests/test_helm_postgres.py. Upgrading an existing release needs a migration — see "Migrating from the Bitnami subchart" inDEPLOYMENT.md. Bitnami's data lives at/bitnami/postgresql/dataand the chart's atPGDATA=/var/lib/postgresql/data/pgdata, but both declare a volumeClaimTemplate nameddata— so a recreated StatefulSet binds the same PVC, finds nopgdata, andinitdbs an empty PostgreSQL 18 cluster beside the untouched PostgreSQL 16 data, leaving every probe green and the application empty. The chart refuses to render against a still-Bitnami release rather than letting that happen silently. Also:appVersionsaid1.0.0against an 0.1.0b1 app, and DEPLOYMENT.md'skubectl rollout status deployment/icebergebsnamed a Deployment that is reallyicebergebs-iceberg-ebs. A newhelmCI job renders the chart and asserts the pod's DB Secret and Service names exist and that the Service and NetworkPolicy selectors actually select the database pods. - Static analysis is no longer evadable by renaming a file (#275). The code-behaviour scan
selected files with
name.endswith(".js"), but Chrome loads whatever path the manifest points at and a VS Codemainis routinely.cjs/.mjs. A payload inbg.mjs— or in a file the manifest namescore.dat— was never read, so it reported no eval, no remote code, an obfuscation score of 0 and no external domains: lower code-behaviour and network scores than an extension whose package could not be downloaded at all, which gets the unknown-midpoint. MV2background.pageHTML with an inline<script>was invisible for the same reason. The scan now covers the.js/.mjs/.cjs/.jsxand HTML suffixes plus every path the manifest references as executable (service worker, background scripts/page, content scripts, popup/options/devtools/sandbox pages, VS Codemain/browser), whatever it is called. HTML pages have their script boundaries resolved by a real parser (BeautifulSoup) rather than a regex —</script foo>, a</script>inside a string literal and a<script>inside an HTML comment all behave in ways a pattern match gets wrong, either hiding a payload or inventing a finding for code the browser never runs — and the page is then masked down to those script bodies so line numbers stay accurate and markup never reaches the minification/obfuscation heuristics. A remote<script src>in a packaged page is flaggedremote_script_include(critical) with its host recorded as an external domain. - A missing credential now aborts
docker compose upinstead of starting a broken stack.docker-compose.ymlreferenced${POSTGRES_PASSWORD}(and the admin/secret vars) bare, and Compose resolves an unset variable to the empty string behind a warning that scrolls past inupoutput — so the stack started and failed later, inside the app, as anasyncpg.InvalidPasswordErrorthat reads like a code or networking fault rather than a missing variable. All credential references now use${VAR:?message}and name the variable on failure, matching the Helm chart's existing| required. Note onlySECRET_KEYhad an app-side validator, so an emptyICEBERG_EBS_ADMIN_PASSWORDpreviously seeded a passwordless admin without complaint. docker-compose.dev.ymlno longer hardcodes credentials, so there is a single source. It previously setPOSTGRES_PASSWORD: iceberg_ebsand a fullICEBERG_EBS_DATABASE_URL, which mademake devsucceed on a machine where a plaindocker compose upfailed with the same.env— the dev path silently masked the misconfiguration that broke every other path. Dev now inherits.envthrough the base file and overrides onlyICEBERG_EBS_SECURE_COOKIES. Consequence:make devnow requires a populated.env.make testfollows a rotated password. TheMakefilehardcodediceberg_ebs:iceberg_ebs@localhostinTEST_DATABASE_URL; it now derives the URL from the.envPOSTGRES_*values via-include .env.- Documented the two config traps that made this expensive to diagnose:
POSTGRES_PASSWORDis read only when the data volume is first created (editing.envagainst an existing volume silently keeps the old password — rotate withALTER USER, seeDEPLOYMENT.md), and host-side pytest needsICEBERG_EBS_DATABASE_URLrather thanICEBERG_EBS_TEST_DATABASE_URLin.env, becauseconftest.pyreads the latter fromos.environ, which.envnever populates. Guarded by the newtests/test_compose_secrets.py. - Frontend polish (#237): the
/search shortcut no longer fires on a modifier combo (Ctrl/Cmd/Alt+/) or mid-IME-composition; the dashboard's per-row Refresh reports a failure inline instead of an unhandled promise rejection +alert(); and the bulk-import button shows an honest "Importing N…" label instead of a fake0/Nprogress counter. - Proxy
modeis now constrained to the exact enum, case-insensitively (#230). TheProxySettings.modeCHECK only enforced the EXPLICIT⇒URL rule and was case-sensitive, andupdate_settingscompared case-sensitively too — so a writer bypassing the route validator (raw SQL, a migration backfill, or a directupdate_settings({"mode": "explicit"})) could commitmode='explicit'with an empty URL past both guards, which the resolver treats as EXPLICIT-with- no-URL → direct egress, the fail-open state the guards exist to prevent. Added aCHECK (mode IN ('NONE','SYSTEM','EXPLICIT'))constraint (new migration, with defensive normalisation of any pre-existing bad rows) and madeupdate_settingsnormalise/validate the mode case-insensitively, mirroring theOIDCSettings.auth_modeguard (#218). ICEBERG_EBS_TRUSTED_ORIGINSis now configurable in both deploy stacks (#153). The CSRF origin check's trusted-origins setting (#107) — for proxies that rewriteHost— was not forwarded by the Composeapp.environmentblock, had no Helm value/ConfigMap entry, and was absent from.env.example. Since.envis excluded from the image, an operator setting it got nothing, and a proxy that rewritesHostwould 403 every browser POST (including login) with no supported fix. Wired through docker-compose, the Helm ConfigMap +values.yaml, and.env.example(defaulting empty), guarded bytests/test_deploy_env.py.- Following
.env.exampleno longer crashes startup (#214). The shared.envthat.env.exampletells operators to fill carries the Compose stack's non-prefixedPOSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORDkeys, which pydantic-settings rejected as forbidden extras — breaking bare-uvicorn /make testlocal runs (Compose was unaffected, as it passes explicitICEBERG_EBS_*env).Settingsnow usesextra="ignore", so non-app dotenv keys are dropped instead of aborting import. - SSO no longer silently demotes IdP-managed admins in large Entra tenants (#227). When an
Entra user is in more than ~200 groups, the ID token omits the
groupsclaim and emits the distributed-claims pointers_claim_names/_claim_sourcesinstead (the "groups overage" contract). The adapter read the absent claim as "no groups", somap_is_adminreturnedFalseand the returning-user sync demoted the admin and bumpedpassword_changed_at— revoking all their sessions, silently, on an otherwise-successful login.EntraAdapternow detects the overage/distributed-groups condition and fails the login closed with a logged, operator- actionable reason (redirect to/login?error=sso) instead of syncing an empty group list, so the admin's role and sessions are left untouched. The overage indicator is always keyed ongroupsin_claim_names, so the guard covers both thegroupsclaim and theemit_as_rolesconfiguration (role_claim="roles"); an inline value is trusted over the pointer. Regression tests cover the overage payloads. - Variable fonts are shipped as one file per family+subset instead of byte-identical
per-weight copies (#236).
static/fonts/archivo-*andjetbrains-mono-*were 10 and 8 byte-identical copies of a single wght-axis variable woff2 each — every weight fetched the same bytes over a distinct URL (~130KB+ of duplicate transfer per family). Each family now ships one file per subset (archivo-normal-{latin,latin-ext}.woff2,jetbrains-mono-normal-{latin,latin-ext}.woff2) declared by a single@font-faceper subset with afont-weightrange descriptor (Archivo100 900, JetBrains Mono400 800), halving bothstatic/css/fonts.cssand the font downloads. Spectral ships as static per-weight instances and is unchanged. - The API/login rate-limit tuning knobs are now forwarded and documented (#202). The
*_RATE_LIMIT_PER_MINUTE/*_RATE_LIMIT_BURSTsettings existed inapp/config.pybut were not forwarded by the Compose stack or the Helm ConfigMap and were absent from.env.example, so an operator setting them got the defaults silently (the same.env-excluded-from-image trap as #87). All four are now wired throughdocker-compose.yml, the Helm ConfigMap +values.yaml, and.env.example, added to the DEPLOYMENT.md env table, and guarded bytests/test_deploy_env.py. The Helm Deployment also now hashes the app ConfigMap into a pod-templatechecksum/configannotation (it previously hashed only the Caddy ConfigMap), so ahelm upgradethat changes a forwarded value actually rolls the pod — otherwise the new value, loaded once viaenvFrom, would not take effect on the running pod until a manual restart. - Closed two holes in the "exactly one canonical security header" guarantee (#201). (1) The
Kubernetes ingress carried
nginx.ingress.kubernetes.io/hsts: "false", which isn't a real ingress-nginx annotation (HSTS is a controller-wide ConfigMap setting), so it was silently ignored — on a default controller its own weaker, non-preload HSTS overrode the Caddy sidecar's canonical one. The non-annotation is removed and DEPLOYMENT.md now documents disabling HSTS at the controller ConfigMap. (2)caddy/headers.caddyrelied on an implicitdefer(triggered only because the block contains the-Serverdelete op) to apply its header SETs afterreverse_proxycopies the upstream baseline headers; a future edit dropping-Serverwould have silently shipped two CSP/HSTS values. An explicitdefernow makes that robust. Neither was a security regression (all values enforce HTTPS), but both defeated the single-copy intent of #188. Guarded by newtests/test_helm_caddy.pyassertions. - The Helm Caddy sidecar pin no longer silently drifts from the Compose one (#200). The Helm
values.yamlCaddy image was pinned at2.8-alpinewhile Dependabot had already moved the Compose image to2.11-alpine, and a comment falsely claimed "Dependabot's docker ecosystem keeps it current" — but Dependabot doesn't parse Helm values, so the two production edge proxies ran different (CVE-accumulating) versions with no update path. The Helm tag is realigned to2.11-alpine, and a newtests/test_helm_caddy.py::test_helm_caddy_tag_matches_composefails whenever the Helm and Compose Caddy tags diverge, so a Dependabot Compose bump now forces the Helm bump in the same PR. - An aborted test run no longer leaves the dev database unbootable (#199). The #113 fix drops
alembic_versionin the test setup, so during a run the dev DB holds acreate_all'd head schema with no stamp. If the suite was aborted before teardown, the nextmake devboot misclassified that head schema as a pre-Alembic baseline, stamped the baseline, and re-ran the post-baseline migrations against columnscreate_allhad already made — failing withDuplicateColumn. Adoption now compares the unstamped schema against the current models: an exact match (== head) is stamped at head, making the boot a no-op; an unstamped schema that is post-baseline but does not match head (an aborted run from an older checkout, then the code updated) is refused with a clear "drop the dev database" error rather than stamped at head and silently skipping the intervening migrations. Inert in production (never runscreate_all). - The at-startup retention prune is no longer silently skipped as a misfire (#198). The prune
job fires at startup (#145) via
next_run_time=now, but relied on APScheduler's default 1-secondmisfire_grace_time: a >1s gap between the scheduler being created and the executor picking the job up — a CPU-starved container start, exactly the restart-churn #145 targets — dropped the startup prune as a misfire, so nothing pruned until +24h. Both scheduler jobs now setmisfire_grace_time=None, so a due fire runs however late the loop is (coalesced to one run) rather than being skipped. - A corrupt pending-alert marker can no longer loop forever or crash a refresh (#197). The
durable
pending_alert_eventsmarker (#109) is now decoded in one place (services._parse_pending_events), which drops both non-dict entries and malformed event dicts instead of raising. Two failure modes are closed: (1) a marker holding a valid event next to non-dict junk delivered the valid event but then compare-and-cleared against the filtered subset — which never matched the raw marker — so the alert re-fired on every scheduler cycle forever (recovery now clears against the raw marker); (2) a valid-JSON-but-wrong-shape marker (e.g."{}"or["junk"]) raisedTypeErrorinside_merge_pending_events, 500-ing the refresh and blocking every future refresh of that extension until the marker was hand-fixed. Both require an externally corrupted marker (the app only ever writes lists of dicts), but the new terminal-clear/cleanse behaviour is self-healing. - Restored the per-IP rate limit on
POST /loginthat the nginx→Caddy migration dropped (#196). The old nginxloginlimit_reqzone capped login POSTs at 5/min per IP; the Caddy migration replaced only theapizone app-side, leavingPOST /loginuncapped in the Compose deployment. BecausePOST /loginpays the ~100ms bcrypt cost even for unknown usernames, an unthrottled flood was both a CPU-DoS on the single worker and a username-spray vector that the failure-keyed(IP, username)lockout can't stop. A tighter token-bucket now caps login POSTs per IP, gated by its ownICEBERG_EBS_LOGIN_RATE_LIMIT_ENABLEDswitch (on in the Compose/Helm env) so disabling API limiting can't silently drop login protection. - Alerts no longer drop an older same-type event when several are pending (#144). The
recoverable-alert marker (#109) can hold two events of one type — e.g. a
new_version1.0→1.1 whose webhook delivery failed and was retained, then a 1.1→1.2 the next cycle.fire_alertscollapsed the event list to one per type (dict last-wins), so only the newest was POSTed and logged;fire_pending_alertsthen cleared the whole marker, losing the older transition with noAlertLogrow. It now groups rules by event type and delivers every event (oldest→newest), so a consumer learns each transition — e.g. a risk level that went low→high→low — instead of just the final one. - Running the test suite no longer leaves the dev database unbootable (#113). The suite
builds its schema with
create_alland drops it at teardown, butalembic_versionisn't a SQLModel table so it survived — stamped at head over an empty schema. The nextmake devtrusted the stamp, ran no migrations, and crashed on startup (relation "user" does not exist). Two-part fix: the test fixture now dropsalembic_versionin both setup and teardown (leaving a clean, bootable DB), andinit_db's migration runner now self-heals a stamped-but-empty database by resetting to base and rebuilding from scratch — so the app recovers regardless of how the stale stamp arose. The self-heal is inert in production, which never drops tables. CI and production were never affected (CI gets a fresh container per run). - The dashboard no longer returns a raw JSON 422 on a non-numeric
pagequery param (e.g. a mangled or hand-edited?page=abc) — like the other filter params it now tolerates junk and falls back to page 1 instead of rejecting the browser navigation (#152). - Input validation for identity-like fields (#154):
POST /api/usersnow rejects a blank / whitespace-only or multi-KBusername(422) and strips surrounding whitespace, and stores a blankemailasNULL; andPOST /api/inventoryrejects a blankasset_idper row (reportedinvalid, not failing the batch) and strips it — previously an emptyasset_idupserted a realInstallObservationand counted as a distinct asset, inflatinginstall_footprintand therefore the exposure / Top-exposure ranking. - Retention pruning now runs at startup, then daily — previously the job's first fire was
scheduled at process-start + 24h, so a deployment that restarts more often than daily
(crash / OOM / redeploy) would never prune despite
ICEBERG_EBS_RETENTION_DAYSbeing set, andFetchLog/InstallCountHistory/AlertLoggrew unboundedly. The interval job now carriesnext_run_time=now; it fires on the scheduler executor after startup, so it doesn't delay the server binding (#145). - A store becoming unreachable during an interactive add or refresh
(
POST /api/extensions,…/{id}/refresh) no longer surfaces a raw 500 with no record: a rawhttpx.TransportError(retries exhausted — connect refused / timeout) is now handled like the scheduler already does, returning 502 and writing asuccess=FalseFetchLogso the dashboard's per-extension status and Fetch-health tile see the failure. The two paths previously disagreed on what a store outage looked like (#148). - The extension detail page and JSON API no longer 500 when a stored JSON column
(
permissions,risk_detail,package_analysis) holds valid JSON of the wrong shape — e.g. an array where an object is expected, from a partial write or manual DB edit. Reads now go through typedExtensionaccessors (permissions_list()/analysis_dict()/risk_detail_dict()/pending_events()) that own one defensive parse guarding both unparsable and wrong-shape JSON, extending the earlier #17/#61 hardening that only covered unparsable JSON (#167). - The single-extension JSON API no longer 500s on a malformed
findingslist inpackage_analysis— a non-dict entry, a finding dict missing required fields, or afindingsvalue that isn't a list. Findings now deserialize tolerantly (non-dicts skipped, missing string fields defaulted) the way the detail page already did, completing the #167 wrong-shape hardening for the last stored-JSON read path (#150). - Startup no longer blocks on re-delivering recovered webhook alerts:
recover_pending_alertsno longer runs during lifespan startup at all — it's deferred to the head of each scheduler refresh cycle (where it already ran), backed by the durable pending-alert marker. Previously a backlog of undelivered alerts behind a dead/slow destination could burn one webhook timeout per pending extension before/healthzanswered — long enough to trip the liveness probe and get the pod killed mid-recovery. Recovered alerts are now re-fired on the next cycle (≤fetch_interval_minuteslater) instead of at startup; nothing is lost, and recovery runs in exactly one place so it can't race a concurrent refresh's delivery of the same events (#155). - A transient Chrome scrape mis-parse (a 200 page with a shifted layout) no longer clobbers
the stored publisher/install count/last-updated date, spiking the risk score ~+31 and firing
spurious
risk_level_changealerts — stored values are kept, matching the existing guards onversionandpermissions. The user-count/version scrapers also now only read visible page text, so a store description like "Join 1,000,000 users" can't hijack them (#142). - Adopting a pre-Alembic database now stamps it at the baseline revision and upgrades to head, instead of stamping it at head — which silently marked every post-baseline migration as applied without running it, permanently (first watchlist refresh and inventory pushes would then fail against the missing columns/tables). Databases already corrupted by that false stamp are detected at startup (baseline-era schema behind a post-baseline revision) and repaired the same way (#143).
- Broad host patterns (
*://*/*,http://*/*,https://*/*) now score as critical like<all_urls>— previously only the literal<all_urls>spelling affected the risk score, and MV2 manifests carrying*://*/*orfile:///*inpermissionswere missed by the broad-host finding entirely (#141). store_urlwas never persisted — every enrolled extension had an empty store URL (#72).- Infinite redirect loop between
/and/loginfor a stale-but-signed session cookie (#73). - Admin UI pages returned raw 401/403 JSON instead of redirecting to the login page (#7).
- The extension-detail page could 500 on malformed stored JSON (#17, #61).
- A failed first fetch left an orphaned placeholder extension row (#75).
- Check-then-insert races in enrollment and inventory upsert could surface as a 500 (#76).
- Package-download failures were swallowed by a broad
except Exception, hiding real bugs and silently scoring extensions from a midpoint fallback (#10). - Publisher-name matching produced scoring false positives (#18).
- Deleting a user destroyed alert history that should have been preserved (#28).
ICEBERG_EBS_RETENTION_DAYSandICEBERG_EBS_FETCH_INTERVAL_MINUTESwere silently ignored by the production deploy stacks — the Composeappservice didn't forward them and the Helm chart had noretentionDays, so an operator following the docs got no pruning.RETENTION_DAYSandFETCH_INTERVAL_MINUTES(plusSESSION_MAX_AGEandHTTPX_TIMEOUT, which README advertised but the stacks ignored) are now forwarded by Compose and the Helm ConfigMap, and DEPLOYMENT.md documents which env vars the stacks forward vs. which fall back toapp/config.pydefaults (#87).- The Helm chart defaulted
image.tagto the mutablelatestwithpullPolicy: IfNotPresent, sohelm upgradere-rendered an identical pod spec (no rollout) and nodes reused their cached image — deploys silently shipped stale code, andhelm rollbackcouldn't restore a known-good build.image.tagnow has no default and isrequiredat render time, forcing an explicit immutable release tag (--set image.tag=v0.1.0-beta.1) (#88). - Alerts could be silently dropped on restart — the scheduler shut down with
wait=False, abandoning an in-flight refresh; a shutdown between committing a state change and firing its alert left the change persisted but the alert never sent (and never retried, since the next cycle sees no diff). Pending change events are now persisted in the same commit as the state change (Extension.pending_alert_events) and merged across refreshes (never overwritten), so a restart re-fires anything undelivered; delivery clears the marker with compare-and-clear. Both are atomic against a concurrent refresh of the same extension (a manual API refresh racing the scheduler): the merge re-reads the marker under aSELECT … FOR UPDATErow lock before appending, and the clear is a single conditionalUPDATE … WHERE pending_alert_events = <delivered>— so neither a lost-update nor a TOCTOU clear can drop an alert. Shutdown now explicitly drains the in-flight refresh (pause - await, bounded by
ICEBERG_EBS_SHUTDOWN_DRAIN_SECONDS) — APScheduler 3.x'sshutdown(wait=True)cancels rather than awaits async jobs, so it alone doesn't drain. The container grace period (terminationGracePeriodSeconds/stop_grace_period) is raised above that window, and the HTTP client is closed on shutdown (#109). - Documentation reconciled with the as-built system. The in-app help page now documents
API keys / bearer-token auth, corrects the Chrome/Edge extension-ID format (32 characters
a–p, not "alphanumeric"/"GUID-like"), and no longer claims user deletion removes the user's
extensions (they are kept, unassigned and off the watchlist). README/CONTRIBUTING/CLAUDE.md
now describe all six CI gates (the
lint-workflowsanduijobs were missing) and the four-service Compose stack; DEPLOYMENT.md's embedded Dockerfile/compose/nginx/Helm snapshots match the shipped hardened stack (backup service, read-only rootfs,nginx:1.29-alpine,object-src 'none', NetworkPolicy/PDB templates,readOnlyRootFilesystemno longer "optional") and its env-var reference includesICEBERG_EBS_LOG_JSON+ the retry/circuit tuning knobs; SECURITY.md mentions the #107 Origin/Referer CSRF middleware; TODO.md/PLAN.md no longer list shipped features (filtering, bulk import, export, retention, inventory) as open work.
Security¶
- Webhook/destination URLs with embedded credentials are now rejected (#79). A target like
https://user:pass@host/hookvalidated at create time but the IP-pinning rebuild (send_pinned_request) replaced the whole netloc, silently dropping theuser:pass@at send time (confusing 401s from the destination). Worse,AlertDestination.targetis persisted and returned byGET /api/alerts/destinations/ rendered in the UI, so a credential in the URL was stored in plaintext and exposed.validate_webhook_urlnow refuses any userinfo with a clear 422 — at create/update and again at send time, for every sender kind — matching how the outbound-proxy URL already rejects userinfo. Credentials belong in an env-only mechanism (a sendersecret_ref), never in the target URL. - Two OIDC email-trust fail-open gaps closed (#288).
StandardOIDCAdaptercoercedemail_verifiedwithbool(), so the JSON string"false"(seen from custom Auth0 rules / claim-mapping proxies) read as truthy and JIT provisioning proceeded on an unverified email — the address-squatting vector the verified-email gate exists to block; it now requires a real booleantrue(is True), matchingEntraAdapter. Separately,_sync_email's returning-login collision check relied only on theuq_user_sso_emailpartial index (SSO rows only), so an SSO account could adopt a local account's address — including the break-glass admin's — which committed cleanly; it now runs the same_email_ownercheck JIT provisioning uses. - Database backups are no longer baked into the app image (#277).
.dockerignoreexcluded.env,tests/, andcaddy/certs/but notbackups/— the directory the Composebackupservice fills withpg_dump -Fcarchives of the live database. Since the Dockerfile doesCOPY . .and.gitignorehas no bearing on the Docker build context, the documenteddocker compose up --buildupgrade path copied every dump — user rows with bcrypt hashes, watchlist data, alert destinations and their webhook URLs — into the image layers, readable by anyone who can pull the image and shipped wholesale if it is ever pushed. It also grew the build context without bound as backups accumulated.tests/test_backup.pynow derives the host directory from the Compose mount and asserts it is excluded, so a rename can't reopen the hole. - SSO: RP-initiated logout + shorter SSO session lifetime (#221). Logging out of an SSO account
now redirects to the IdP's
end_session_endpoint(with theid_token_hint) so the provider session is terminated, not just the local cookie — falling back to a local-only logout when the provider has no such endpoint. SSO sessions also expire on a shorter, configurable lifetime (ICEBERG_EBS_OIDC_SESSION_MAX_AGE, default 1h) than local password sessions: an IdP-side disable/reset can't be pushed to us, so a short lifetime forces re-authentication through the IdP — which fails for a disabled account — bounding how long a stale session or stolen cookie survives. Continuous back-channel logout and an explicit IdP block/allowlist remain tracked follow-ups. - SSO: closed a cross-provider identity-spoofing hole (#226). OIDC provisioning matched an
account on the validated
(oidc_issuer, oidc_subject)pair globally, but Authlib only checks a token'sissagainst the originating provider's own discovery metadata — so a hostile or compromised configured provider could publish another provider's issuer, serve its own JWKS, and mint a token carrying that trust domain's(iss, sub)to log in as (or pre-squat) the other provider's account, including admin sync. The provisioning match is now scoped to the configured provider (auth_provider); the same(issuer, subject)presented under a different provider is refused as an"identity conflict"(the globaluq_user_issuer_subjectconstraint is the DB backstop). The issuer stays in the key, so a re-pointed adapter still can't inherit an account (#218). Regression test covers a second provider declaring the first's issuer with a distinct email. - CSV export is hardened against spreadsheet formula injection — a tracked extension's
attacker-controlled
name/publisher(e.g.=HYPERLINK(...),+SUM(...),@cmd|…) is no longer written verbatim into the CSV. Cells starting with a formula-trigger character (= + - @tab/CR) are prefixed with a single quote so Excel/LibreOffice treat them as text, not live formulas, when an analyst opens the export (OWASP mitigation). The JSON export is left as raw values (not opened as a spreadsheet) (#147). - Session and API-key revocation on password change — changing a password invalidates other-device sessions and deletes the user's API keys (#6).
- Application-level login rate limiting and lockout, independent of nginx (#8).
- The login rate limiter's client-IP key was spoofable via
X-Forwarded-For; the reverse proxy now overwrites rather than appends it (#77). - Webhook SSRF defence — destination URLs are validated at create/update time and again at
send time, the request is pinned to the validated IP (preserving
Hostand TLS SNI), and redirects are disabled. - The webhook-test endpoint leaked internal error strings (resolved IPs, internal hostnames) to the caller (#9).
- Host-permission changes (e.g. gaining
<all_urls>) were excluded frompermission_changealerts — a compromised update could widen host access silently (#60). - Credential verification always pays the bcrypt cost, so an unknown username cannot be distinguished by timing; the dummy hash can no longer drift from the real cost factor (#14).
- CSRF protection is a documented, deliberate decision —
SameSite=Lax+Secure, a JSON-only API, and bearer tokens as the primary M2M credential, rather than tokens (#16). - Added
LICENSE(Apache-2.0),SECURITY.md(private reporting + an explicit scope of what is and is not a trust boundary),CONTRIBUTING.md, andCODE_OF_CONDUCT.md(#92). - GitHub Actions are SHA-pinned (with the release tag in a trailing comment) and checkout
credential persistence is disabled, so a repointed action tag cannot run arbitrary code in CI —
including the GHCR-pushing build job — and the repo token is no longer left in the workspace
.git/config(OWASP CICD-SEC-¾) (#96). - A blocking
lint-workflowsCI job audits the workflows on every PR with zizmor (CI/CD security: unpinned actions, credential persistence, template injection, over-broad permissions) and actionlint (syntax + shellcheck), so the pinning/least-privilege posture above cannot silently regress (#97). - Auth hardening (#67) —
ICEBERG_EBS_SECRET_KEYshorter than 32 characters is now rejected at startup (a weak key undermines all itsdangerous cookie/flash signing), and passwords longer than bcrypt's 72-byte limit are rejected explicitly (a clean422) instead of being silently truncated — truncation previously let two distinct passwords sharing a 72-byte prefix collide. - CodeQL SAST (
codeql.yml) now runs dataflow/taint analysis over both Python and JavaScript/TypeScript on every PR, on push tomain, and on a weekly schedule (to catch new advisories against already-merged code). It is a dedicated workflow so itssecurity-events: writescope stays out of the least-privilege CI gates (#98). - App-layer security-header floor (#66) — the app now emits a conservative HSTS (over HTTPS
deployments) and a minimal CSP (
frame-ancestors/base-uri/object-src/form-action) as defence-in-depth, so a floor is present even if the reverse-proxy header config regresses. nginx now strips the upstream copies and re-adds its canonical CSP + HSTS, so exactly one value reaches the client in production (no duplicate/first-winsStrict-Transport-Security); the app CSP omitsscript-src/default-srcso it can never intersect with and break the proxy's policy. - Signed, attested release pipeline (
release.yml) — pushing av*SemVer tag verifies the tag matchespyproject.tomland that the tagged commit is onmain(so a release can only come from reviewed, merged history), then builds a release image with an SBOM and SLSA build provenance, attests the provenance to GHCR, signs it keylessly with cosign, and cuts the GitHub Release. Release images are immutable and digest-pinnable, so a consumer can verify what is in the image and that this repo's CI built it.build.ymlno longer publishes a mutable:latest— main pushes are dev-only:<sha>+:edge; deployables come only from a tagged release (#99). - CSRF origin-check middleware (#107) — a
CSRFOriginMiddlewarenow rejects cookie-authenticated, state-changing requests (POST/PUT/PATCH/DELETE) whoseOrigin/Refererdoesn't match the request host (orICEBERG_EBS_TRUSTED_ORIGINS), as defence-in-depth over the existingSameSite=Laxposture (#16). Bearer-token (M2M) requests carry no session cookie and are never checked, so the API's primary credential is unaffected. - Hardened the Docker Compose stack (#102) — the
appandnginxservices now run withno-new-privileges,cap_drop: [ALL](nginx adds back onlyNET_BIND_SERVICE+ the master's user-drop caps), a read-only root filesystem with tmpfs for the few writable paths, and healthchecks (app →/readyz, nginx → a plain-HTTP/nginx-health);postgresgetsno-new-privileges(keeping the caps its entrypoint needs). nginx now waits for the app to beservice_healthybefore starting. - Completed the Helm pod-security baseline (#104) — the deployment adds
seccompProfile: RuntimeDefaultandautomountServiceAccountToken: false(the app never calls the Kubernetes API), astrategy: Recreaterollout so a deploy never briefly runs two scheduler pods (a rolling update withreplicas=1would surge to two), and aPodDisruptionBudget(maxUnavailable: 0, toggleable) so a voluntary eviction can't take the singleton to zero.runAsNonRoot/readOnlyRootFilesystem/cap_drop: [ALL]/allowPrivilegeEscalation: falsewere already in place — the chart now meets Pod Security Standards restricted. - Kubernetes NetworkPolicies (#103) — the Helm chart adds default-deny ingress plus named hops
(ingress-controller → app:8000, app → postgres:5432), turning the previously flat namespace into
a segmented one so a single compromised pod can't reach Postgres directly. Egress is left open
(the app must reach the extension stores, webhooks, and TI feeds). The Bitnami postgresql
subchart's own (default-permissive) NetworkPolicy is disabled so it can't union back broader
ingress to Postgres. Gated behind
networkPolicy.enabled(default on); requires a CNI that enforces NetworkPolicy (Calico/Cilium).