Athena — interimplaza-audit-2026-07-24.md

InterimPlaza — Standalone Audit

Date: 2026-07-24 Auditor: Claude Fable 5 (read only, independent of the running swarm) Baseline (pinned): origin/develop @ 872b8170de6c4504b327c4250dbf73521baf2306 · origin/main @ fd625d7c4d16e6d40d7472bf97639b838ffa96be Live surfaces checked: interimplaza.nl (prod marketing), app.interimplaza.nl (prod app), interimplaza.staging.glorylabs.nl (staging), cluster context leaseweb namespaces recruitment (prod) + interimplaza-staging (staging)

Scope and method

Strictly read only. Code was read from a detached worktree at the pinned develop SHA so the moving develop branch could not shift results. Live surfaces were browsed and probed with GET requests only (no form submissions on prod, no cluster mutations, no 1Password access, nothing posted to the swarm bus). develop is a moving target: the swarm merges rolling batches roughly every 30 minutes, so some findings below may already be in flight.

Every finding is tagged. [NEW] was not found in an open PR, issue, or the swarm queue at audit time. [tracked #N] is already known. Severity reflects live production impact first.

Headline

Production is stable and the security engineering is genuinely strong (auth, SSRF, secrets hygiene all handled well). The real problems are on the live production surface and the prod deployment lagging develop, not in the code on develop:

  1. The prod job-count endpoint is broken (500), the prod site carries only ~9% of the jobs staging has, and the prod homepage still shows a source count and fabricated figures that violate standing owner rules.
  2. Production error monitoring (Sentry) is silently disabled.
  3. The public frontends ship almost no security headers.

None of the findings is a live-exploitable breach. The highest-severity items are operational/quality defects visible to real users right now.


Findings by severity

HIGH

H1 — Prod /api/jobs/count returns HTTP 500 on every call [NEW] GET https://app.interimplaza.nl/api/jobs/count500 Internal Server Error on 3/3 attempts. The same endpoint on staging returns 200 {"activeJobs":1878}. Meanwhile prod /api/jobs?page=0&size=1 works and reports 168 active jobs, so the failure is isolated to the count query (JobService.getActiveJobCount()), not the datasource. This is the endpoint the marketing homepage calls for its live vacancy stat, so the failure is what forces the fake "150+" fallback (H3/M2). Because staging works and prod does not, the cause is prod-specific: prod backend image sha-d454533 lags develop and/or a prod-data condition triggers the error. Action: inspect the prod backend logs for the stack trace on /api/jobs/count, then promote the develop fix to prod (this is gated on the develop→main merge).

H2 — Prod job corpus is a fraction of staging (168 vs 1878 active) [NEW] Prod /api/jobs totalElements = 168; staging = 1878. The live site is serving roughly 9% of the aggregated corpus that staging has. For a job-aggregation platform this is the core product being under-delivered on production. Likely causes: prod scrapers/schedulers not running or not configured, connector secrets missing in the prod namespace, or mass expiry on prod. Data-safe but materially degrades the live product. Action: verify the scraper schedulers and connector configuration in the recruitment namespace; compare active-vs-expired counts; confirm the ingestion cron is actually firing in prod.

H3 — Prod homepage displays a source count ("87 Aangesloten bronnen") [tracked-rule / regression] interimplaza.nl renders a hero stat "87 — Aangesloten bronnen". This directly violates the owner's permanent standing rule: never display or mention the number of sources anywhere user-facing. Prior work removed this from the app surface but the prod marketing surface still shows it live. This matches the earlier "removed on the wrong surface" pattern. Action: remove the bronnen stat from the marketing homepage hero (the SSR interimplaza-web home component), do not wire it to a live count.

H4 — Production error monitoring is disabled [NEW] The prod app console throws Invalid Sentry Dsn: ${SENTRY_DSN} — the deploy shipped the unsubstituted literal placeholder instead of the real DSN, so Sentry never initializes. Production runs blind: no client error capture. (Same class of defect as the "150+" literal and the count 500 — the prod build/deploy is not fully wired.) Action: inject the real SENTRY_DSN at build/deploy time for the prod frontend and confirm events arrive.

MEDIUM

M1 — Em-dashes in live copy and i18n [standing-rule violation] The owner's standing rule forbids any dash (- – —) in user-facing InterimPlaza copy. Live on prod: the page <title> "InterimPlaza — Interim & Recruitment Platform", footer "© 2026 Mahmoud Consultancy B.V. — gebouwd door GloryLabs", beta banners "Nu gratis — doe mee aan de bèta" and "We zijn in bèta — uw feedback helpt ons verbeteren". In the repo, 17 em/en-dash occurrences remain across frontend/recruitment-portal/src/assets/i18n/nl.json and en.json (e.g. "Beta — in ontwikkeling", "Verse opdrachten — vandaag"). The rule is baked into the worker base prompt but existing strings were never scrubbed. Action: scrub dashes from the SSR marketing strings and both i18n files; rephrase rather than substituting punctuation.

M2 — Fabricated hero figures on prod [tracked #972] interimplaza-web home.component.html:25 renders activeJobs() === null ? '150+' : activeJobs(). With H1 failing, the live homepage shows the fabricated "150+". The hero also shows "<48u Gemiddelde reactietijd" — an unverifiable claim. Both conflict with the no-fabricated-figures rule. Action: once H1 is fixed, show the real count; if it cannot load, hide the stat rather than inventing one. Remove or substantiate "<48u".

M3 — Public frontends ship almost no security headers [NEW] Prod marketing (interimplaza.nl, Express/Angular SSR): no CSP, no X-Frame-Options, no X-Content-Type-Options, only HSTS is present, and it leaks X-Powered-By: Express. Prod app (app.interimplaza.nl) sets XFO=SAMEORIGIN and nosniff, but its CSP is effectively a no-op: default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'. The backend API sets a real CSP; the user-facing SSR sites do not. Action: add CSP (without unsafe-eval; scope unsafe-inline down), X-Frame-Options/frame-ancestors, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy to the Express SSR server; disable x-powered-by.

M4 — No real legal/privacy pages despite GDPR data-controller claim [NEW] The prod footer links Privacybeleid, Algemene voorwaarden and Cookie-instellingen all point to /contact. The site's own privacy copy names Mahmoud Consultancy B.V. as GDPR data controller, yet there is no privacy policy, no terms, and no cookie/consent mechanism. Sector footer links also all point to /. Action: publish a real privacy policy and terms; wire a cookie/consent banner if any non-essential cookies are set; fix the placeholder sector links.

M5 — OnPush change detection on 8 of 56 portal components [tracked #913] frontend/recruitment-portal: 56 @Component files, 8 use ChangeDetectionStrategy.OnPush. Performance/consistency debt on the Angular app.

M6 — Dead nested workflows imply security scanning that never runs [NEW] backend/.github/workflows/ contains backend-ci.yml, frontend-ci.yml, sbom-scan.yml, security-scan.yml. GitHub only executes workflows in the repo-root .github/workflows/, so these never run. Their names ("sbom-scan", "security-scan") create a false impression of coverage, and they reference stale Node 18.x/20.x matrices. Action: delete the nested backend/.github/workflows/ directory (root workflows are the live set), or move any unique-and-wanted checks up to root.

M7 — Performance indexes live outside Flyway [NEW] backend/src/main/resources/db-indexes.sql (created Oct 2025) is referenced by no code and no Flyway migration. Those indexes are therefore not guaranteed to exist on the prod database — a silent drift risk against every query they were meant to optimize. Action: fold the indexes into a versioned Flyway migration (CREATE INDEX IF NOT EXISTS ... CONCURRENTLY where appropriate) and delete the loose SQL file.

M8 — Production is a single-replica single-Postgres availability SPOF [infra] values-production.yaml sets replicaCount: 1 for backend/frontend/marketing with a Recreate strategy, no HPA and no PodDisruptionBudget on the app deployments (only postgres/redis have PDBs). Prod Postgres is a single in-cluster pod. Every deploy causes a downtime window; any node/pod failure takes the service down. Data is safe (db-backup CronJob completing on schedule). This is a deliberate cost tradeoff per the values comments, but it is the biggest availability risk. Action: at minimum move to RollingUpdate with maxUnavailable: 0 and 2 replicas for the stateless services to remove deploy downtime; consider managed/HA Postgres.

M9 — Backend CSP allows script-src 'unsafe-inline' [SEC] SecurityConfig.java:127-132 sets a CSP with script-src 'self' 'unsafe-inline'. The backend renders HTML (Thymeleaf email/PDF templates, error pages), so inline-script XSS defense is weakened there. Action: drop 'unsafe-inline' for scripts; use nonces/hashes if inline scripts are genuinely needed.

M10 — Historical git-history secret exposure remains [tracked #277] The working tree is clean — .env.claude now holds a sk-ant-REDACTED placeholder, and .gitleaksignore correctly reports no baselined literals. However, real secrets were committed historically (Anthropic key, Flextender/Firecrawl credentials) and remain in git history. History scrub + rotation is owner-gated (#277). The overdue rotations #500–#503 (JWT_SECRET, OP_SERVICE_ACCOUNT_TOKEN, Firecrawl key, GHCR_TOKEN, due 2026-07-15) are part of this.

LOW

L1 — Hardcoded admin credential committed [NEW]website/website/src/pages/admin/login.astro:206 checks username === 'admin' && password === 'mahmoud2024' (client side only, guard is a localStorage flag), and website/website/PROJECT_STATUS.md:57 documents the same credential. Verified not live: interimplaza.nl/admin/login redirects to home, and this legacy Sept-2024 Astro prototype is not deployed to the k3s cluster (prod marketing is the Angular interimplaza-web). Still, remove the credential from code and docs; treat mahmoud2024 as burned.

L2 — Prod app title is the unbranded default [NEW]app.interimplaza.nl <title> is "RecruitmentPortal". Set a branded title.

L3 — SSRF validator does not re-check on redirects [SEC-low]UrlSecurityValidator validates the initial URL only; the direct-fetch OkHttp clients follow redirects by default. Residual redirect/DNS-rebind SSRF risk is low because user-supplied crawl URLs are handed to the Firecrawl API (not fetched by the server) and the direct clients target fixed allowlisted hosts. Defense-in-depth: disable auto-redirect or re-validate the redirect target.

L4 — Public info endpoints by design [info] — Swagger/OpenAPI (/v3/api-docs, /swagger-ui) and /api/actuator/prometheus are public. Intentional (developer portal + alert-routing scrape) and PII-free, but they do expose the full API surface and operational metrics unauthenticated.

L5 — Branch sprawl and develop/main divergence [hygiene] — 824 remote branches (mostly adhoc/* and laptop1-rescue/* automation debris). develop is +36/−6 vs main: 6 go-live hotfixes landed on main were never back-merged to develop, and 36 develop commits (including the H1 count fix and server-side search) have not reached prod. This divergence is the root of several HIGH findings.


What is solid (verified positives)

  • Auth hardening is excellent: MFA-pending tokens cannot authenticate a session (#290), per-user revocation epoch on password reset (#326), cross-pod Redis token blacklist (#325), X-Forwarded-For spoofing defeated for rate limiting (#319), cluster-wide Redis rate limits with sensible per-endpoint tiers (#328), and dual-key JWT rotation support.
  • SSRF guard is well built: scheme + allowlist + internal/private/loopback/link-local/IPv6-ULA blocking, applied at every user-URL entry point.
  • Prod backend config is hardened: no H2, show-sql:false, ddl-auto: validate, actuator limited to health/info/prometheus, health details only when authorized. DoD #937 (Hibernate DEBUG SQL in prod) appears already fixed in the production profile.
  • Flyway migrations are disciplined: additive/idempotent enum-constraint changes and one intentional dead-table drop; no dangerous data-loss DDL.
  • Server-side search and pagination work on prod: query "a" returns totalElements 168 with correct paging, so DoD #1005 (search stuck at top-100) does not reproduce at the prod API layer — appears resolved server-side.
  • TLS valid (prod cert to 2026-09-08, marketing to 2026-09-30), backups green (db-backup CronJob completing), prod pods 0 restarts.

DoD spot-checks

| Item | Status at audit | |---|---| | #937 Hibernate DEBUG SQL in prod | Appears fixed (prod profile show-sql:false) | | #1005 search beyond top-100 | Not reproduced on prod API (paging works) | | #913 OnPush coverage | Still low: 8/56 portal components | | #938 Astro forms discard submissions | Fix in flight (PR #996) | | #882 ZAP non-gate / #885 backup-verify | ZAP schedule-only; backups are completing in-cluster |

Recommended priority order

  1. H1 + L5 — get the develop→main promotion out so the count fix (and 36 other commits) reach prod; inspect the prod count-endpoint stack trace.
  2. H2 — investigate why prod has 168 jobs vs staging's 1878 (scrapers/connector config in the recruitment namespace).
  3. H4 — wire the real SENTRY_DSN into the prod frontend build.
  4. H3 + M1 + M2 — scrub the bronnen count, dashes, and fabricated figures from the live marketing surface.
  5. M3 + M4 — add security headers to the SSR sites; publish real privacy/terms pages.
  6. M6 + M7 — remove dead nested workflows; move db-indexes into Flyway.
  7. M8 — remove deploy-time downtime (RollingUpdate + 2 stateless replicas).

Note on independence: findings overlapping active swarm work — the count/hero display, Astro forms (#996), the develop→main sync — are tagged so they can be reconciled with in-flight PRs rather than duplicated.

Reacties

Nog geen reacties