Property PrismDev Hub

Project Prism -- Security & SOC 2 Compliance Audit

Updated Apr 3, 2026

Date: 2026-04-01 Auditor: Claude Opus 4.6 (automated static analysis) Scope: Go backend, React/TypeScript frontend, database schema & migrations, infrastructure/ops


Executive Summary

The application demonstrates a security-conscious architecture overall: strong Row Level Security enforcement (72 policies), parameterized SQL throughout, proper org-isolation, distroless Docker containers, append-only audit logging, and zero XSS vectors in the frontend.

However, there are 2 critical, 4 high, 12 medium, and 10 low/informational findings that require attention before SOC 2 readiness can be claimed.

Key positive note: .env files are confirmed not tracked in git history (git ls-files returned no matches for any .env file). Secrets have not been committed to version control.


Findings Summary

SeverityCount
Critical2
High4
Medium12
Low / Info10

Critical Findings

CRIT-1: Live Production Credentials in .env Files on Disk

  • Files: go-backend/.env, .env.local, go-backend/ops/.env, frontend/.env
  • SOC 2 Controls: CC6.1 (Logical Access), CC6.7 (Data Transmission)
  • OWASP: A02:2021 Cryptographic Failures

Multiple .env files contain live credentials in plaintext:

  • Supabase PostgreSQL connection string with embedded password
  • Redis connection URL with embedded password
  • Two different Clerk secret keys (sk_test_)
  • Azure Maps API key
  • Supabase service role key -- this key bypasses ALL Row Level Security policies and is the single most dangerous credential in the project
  • Slack webhook URLs (capable of posting messages to team channels)

While these files are properly .gitignore'd and confirmed not in git history, they sit unencrypted on every developer machine with repo access.

Recommended Fix:

  1. Rotate ALL credentials immediately, prioritising the Supabase service role key
  2. Migrate secrets to a secrets manager (HashiCorp Vault, AWS Secrets Manager, Doppler, or 1Password CLI)
  3. Add a pre-commit hook that blocks commits containing .env files
  4. Consider direnv with encrypted backends for local development

Status (2026-04-01): REMEDIATED. All production secrets migrated to Infisical. Infisical syncs to Render (backend) and Cloudflare Pages (frontend) via native integrations. Platform dashboards are no longer manually edited. Local .env files on disk remain for local dev until the Infisical dev environment is set up. See docs/security/secrets-management.md for the updated workflow.


CRIT-2: Development Database Uses postgres Superuser Role

  • File: go-backend/.env (line 8)
  • SOC 2 Controls: CC6.1 (Logical Access)
  • OWASP: A01:2021 Broken Access Control

The development DATABASE_URL connects as the postgres superuser, which bypasses all RLS policies. This means org-isolation bugs cannot be caught during development and only surface in production where a least-privilege role is used.

The deploy .env.example already recommends a prism_app role -- but the active development configuration does not follow this guidance.

Recommended Fix:

  1. Create a dedicated prism_app database role without SUPERUSER or BYPASSRLS privileges
  2. Update go-backend/.env and all development configurations to use this role
  3. Reserve the postgres role exclusively for migration scripts
  4. Note: the backend already validates against superuser connections in production (cmd/api/main.go:352-378), which is excellent -- extend this discipline to development

High Findings

HIGH-1: Admin /tables Endpoint Exposes Cross-Organisation Data

  • File: go-backend/internal/handler/admin_handler.go (line 36), go-backend/internal/repository/postgres/admin_repo.go (lines 25-55)
  • SOC 2 Controls: CC6.1 (Logical Access), CC6.3 (Role-Based Access)
  • OWASP: A01:2021 Broken Access Control

The GET /admin/tables endpoint queries pg_stat_user_tables and returns table names and row counts for ALL tables in the public schema. This is not scoped to the requesting organisation. Any user with admin:read scope in any organisation can see global row counts, leaking information about the platform's total data volume and table structure.

Recommended Fix: Restrict this endpoint to a platform-level super-admin role (not org-level admin), or return only organisation-scoped counts.


HIGH-2: biSchemaID Used in DDL Without UUID Format Validation

  • File: go-backend/internal/repository/postgres/export_repo.go (lines 526-533)
  • SOC 2 Controls: CC6.1 (Logical Access)
  • OWASP: A03:2021 Injection

The biSchemaID value from the organisations table is used to construct PostgreSQL role names and schema names via fmt.Sprintf. While pgQuoteIdent() provides proper escaping, the biSchemaID is not validated as a UUID before use in DDL operations. If this value were ever manipulated (via SQL injection elsewhere or a compromised admin), unexpected schema/role names could be created.

Recommended Fix: Validate that biSchemaID matches ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ before using it in CreateBIUser and RotateBIPassword.


HIGH-3: Wildcard Scope (*) Bypasses All Authorisation Checks

  • File: go-backend/internal/domain/auth.go (line 29)
  • SOC 2 Controls: CC6.1 (Logical Access), CC6.3 (Role-Based Access)
  • OWASP: A01:2021 Broken Access Control

The HasScope function accepts * as a wildcard that matches any scope. While * is not in the ValidAPIKeyScopes allowlist (so API keys cannot be created with it), the ValidateDelegableAPIKeyScopes function short-circuits when the caller has ScopeAll. If a Clerk JWT user were to receive a * scope through misconfigured Clerk permissions, they would bypass all scope checks.

Recommended Fix: Add explicit logging/alerting if a * scope is encountered. Consider removing the wildcard entirely in favour of explicit scope enumeration.


HIGH-4: Slack Webhook URLs Exposed in Ops Environment File

  • File: go-backend/ops/.env (lines 1-4)
  • SOC 2 Controls: CC6.1 (Logical Access)

Two live Slack webhook URLs and channel IDs are stored in the ops .env file. These URLs can be used to send arbitrary messages to team Slack channels if leaked, enabling phishing or social engineering attacks.

Recommended Fix: Regenerate both webhook URLs in Slack admin. Move webhook configuration to a secrets manager.


Medium Findings

MED-1: No Client-Side Session Idle Timeout

  • File: frontend/src/app/providers.tsx (AuthTokenBridge component)
  • SOC 2 Controls: CC6.1 (Logical Access), CC6.3 (Session Management)

There is no client-side idle timeout. The application only detects session expiry when the API returns 401. If a user walks away from their workstation, the session remains active indefinitely until the Clerk token expires server-side. SOC 2 auditors typically require idle timeouts of 15-30 minutes for sensitive applications.

Recommended Fix: Implement a client-side idle timer that detects mouse/keyboard inactivity and either locks the screen or triggers sign-out after a configured period.


MED-2: CSP connect-src Allows Wildcard https:

  • File: frontend/public/_headers (line 9)
  • SOC 2 Controls: CC6.6 (System Boundaries), CC6.7 (Data Transmission)

The Content Security Policy sets connect-src 'self' https: wss: which allows the application to connect to any HTTPS or WSS endpoint. This effectively permits data exfiltration via XSS to any attacker-controlled HTTPS endpoint. Similarly, frame-src 'self' https: allows embedding any HTTPS page.

Recommended Fix: Restrict connect-src to specific API domains: 'self' https://api.property-prism.com https://*.clerk.accounts.dev https://*.sentry.io. Restrict frame-src similarly.


MED-3: Admin Route Access Control is UI-Only

  • Files: frontend/src/modules/auth/components/AdminRouteGate.tsx, frontend/src/shared/ui/navigationConfig.ts
  • SOC 2 Controls: CC6.1 (Logical Access), CC6.3 (Principle of Least Privilege)

The AdminRouteGate component and getNavigationGroups function check orgRole client-side to hide admin navigation and block admin page rendering. This is appropriate for UX but must be backed by server-side authorisation. If the backend does not enforce the same role check on admin API endpoints (/admin/*, /import-jobs/*, /export/*), a non-admin user could call admin APIs directly.

Recommended Fix: Verify that all admin API endpoints on the Go backend enforce role-based authorisation server-side. The frontend gate is necessary but not sufficient.


MED-4: normalizeWebsite() Permits Arbitrary URLs

  • File: frontend/src/modules/data/pages/OwnersManagePage.tsx (lines 130-135)
  • SOC 2 Controls: CC6.1 (Logical Access)

The normalizeWebsite function accepts any user-provided URL and prepends https:// if no scheme is present. The resulting URL is rendered as a clickable <a> tag. While javascript: scheme is blocked, the displayed text (owner.website_url) could differ from the normalised href, potentially deceiving users.

Recommended Fix: Validate URLs with new URL(). Display the actual normalised URL as the link text.


MED-5: No HSTS Header on API Responses

  • File: go-backend/internal/middleware/security_headers.go
  • SOC 2 Controls: CC6.7 (Data Transmission)
  • OWASP: A05:2021 Security Misconfiguration

The security headers middleware sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Cache-Control, but does not set Strict-Transport-Security. While TLS termination may happen at a reverse proxy, the API should still set HSTS to prevent downgrade attacks.

Recommended Fix: Add Strict-Transport-Security: max-age=31536000; includeSubDomains to the security headers middleware in production mode.


MED-6: pprof Debug Endpoints Exposed Without Auth in Non-Production

  • File: go-backend/cmd/api/main.go (lines 469-471)
  • SOC 2 Controls: CC6.1 (Logical Access)
  • OWASP: A05:2021 Security Misconfiguration

The pprof profiler is mounted without authentication when ENV is not production/prod/prd. A staging server with ENV=staging would expose pprof, allowing an attacker to dump goroutine stacks (which may contain auth tokens), heap dumps (which may contain credentials), and CPU profiles.

Recommended Fix: Gate pprof behind authentication even in non-production, or restrict to ENV=development only.


MED-7: Rate Limiter is In-Memory Only

  • File: go-backend/internal/middleware/rate_limit.go
  • SOC 2 Controls: CC7.2 (System Monitoring)
  • OWASP: A04:2021 Insecure Design

The rate limiter uses an in-memory map[string]*tokenBucket. In a multi-instance deployment behind a load balancer, each instance maintains its own state, effectively multiplying the allowed rate by the instance count. Redis is already in the stack.

Recommended Fix: Implement Redis-backed rate limiting (sliding window with INCRBY + EXPIRE) for production deployments.


MED-8: Audit Log Body Sanitisation Too Narrow

  • File: go-backend/internal/middleware/audit.go (lines 133-156)
  • SOC 2 Controls: CC6.1 (Logical Access), CC7.2 (System Monitoring)
  • OWASP: A09:2021 Logging and Monitoring Failures

The audit middleware sanitises known sensitive field names (password, secret, token, etc.) using exact key matches. Field names like user_password, auth_token, ssn_last_four, or bank_account would pass through unsanitised.

Recommended Fix: Use substring matching for sensitive field detection (any field containing "password", "secret", "token", "ssn"). Consider an allowlist approach per resource type.


MED-9: Contact PII Stored Without Column-Level Encryption

  • File: database/PRISM_vNext_FINAL.sql (lines 938-965)
  • SOC 2 Controls: CC6.1 (Logical Access), CC6.7 (Data Transmission)

The contacts table stores PII in plaintext: email, phone_office, phone_mobile, phone_direct, linkedin_url, first_name, last_name. The broker_details table stores license_number. While RLS provides access control, a database compromise (backup theft, SQL injection bypassing RLS) would expose PII immediately.

Recommended Fix: Evaluate pgcrypto or application-layer encryption for sensitive PII fields. Confirm Supabase encryption at rest is enabled (it is by default). Document as accepted risk or implement.


MED-10: No Data Retention Policy for Audit and Error Logs

  • File: All database files (no retention mechanisms found)
  • SOC 2 Controls: CC7.1 (Monitoring), CC8.1 (Backup)

There are no data retention policies, partition strategies, or automated purge mechanisms for audit_log (append-only, grows indefinitely), error_log, contact_activities, or import_jobs. Without retention policies, storage costs increase, query performance degrades, and GDPR right-to-erasure compliance becomes harder.

Recommended Fix: Implement table partitioning by month for audit_log and error_log using pg_partman. Define retention periods (e.g., audit_log: 7 years for SOC 2, error_log: 90 days). Create a scheduled job for archival/purge.


MED-11: No Migration Version Tracking Framework

  • File: database/migrations/
  • SOC 2 Controls: CC7.2 (Change Management)

Migrations are plain SQL files with no version numbering, no migration tracking table (schema_migrations), and no rollback scripts. There is no way to determine which migrations have been applied to a given database instance without manual inspection.

Recommended Fix: Adopt a migration framework such as golang-migrate, goose, or atlas.


MED-12: Default Grafana Credentials in Docker Compose

  • File: go-backend/ops/docker-compose.yml (lines 128-129)
  • SOC 2 Controls: CC6.1 (Logical Access)

Grafana defaults to admin/admin credentials via fallback values. If the Grafana port is accidentally exposed, this provides unauthenticated access to all monitoring data.

Recommended Fix: Remove the hardcoded fallback and require explicit configuration. Add a startup check that rejects admin as a password.


Low / Informational Findings

  • File: frontend/src/modules/data/pages/OwnersManagePage.tsx (line 444) vs ContactProfilePage.tsx (line 156)
  • Description: Some external links use rel="noreferrer" while others use rel="noopener noreferrer". Modern browsers treat noreferrer as implying noopener, so the practical risk is negligible, but inconsistency suggests a missing standard pattern.
  • Recommended Fix: Standardise all external links to rel="noopener noreferrer".

LOW-2: Source Map Deletion Depends on Sentry Plugin

  • File: frontend/config/vite.config.ts (lines 22-27, 38)
  • Description: Source maps are generated (sourcemap: true) and deleted by the Sentry plugin after upload. If SENTRY_AUTH_TOKEN is unset or the plugin fails, .map files remain in the deployed bundle.
  • Recommended Fix: Add a CI step that verifies no .map files remain: test -z "$(find dist -name '*.map')".

LOW-3: window.__prismPerf Exposed in Production

  • File: frontend/src/shared/perf/metrics.ts (lines 102-109)
  • Description: The performance metrics API is registered on window unconditionally. While the dev panel UI is gated behind import.meta.env.DEV, the underlying API exposes internal API surface area in production.
  • Recommended Fix: Gate the window.__prismPerf assignment behind import.meta.env.DEV.

LOW-4: Sentry Session Replay May Capture Sensitive Data

  • File: frontend/src/main.tsx (lines 16-21)
  • Description: Sentry replay captures DOM snapshots at 10% sample rate (100% on error). Displayed business data (lease terms, comp details, contact info) may be included.
  • Recommended Fix: Configure maskAllText: true for production or apply data-sentry-mask to sensitive components.

LOW-5: PII Sent to Sentry

  • File: frontend/src/app/providers.tsx (lines 38-42)
  • Description: Sentry.setUser() sends user email and full name to Sentry. This should be covered by a Data Processing Agreement.
  • Recommended Fix: Consider using only user.id for Sentry identification. Ensure a DPA with Sentry covers PII transmission.

LOW-6: LocalStorage Drafts May Persist Business Data

  • File: frontend/src/shared/storage/localDraft.ts, frontend/src/modules/map/savedViews.ts
  • Description: Form drafts and saved map views may contain business-sensitive data and persist beyond session end.
  • Recommended Fix: Use sessionStorage for drafts containing business data. The existing maxAgeMs expiry in readLocalDraft is good -- ensure calling code uses reasonable values.

LOW-7: JWKS Refresh Uses fmt.Printf Instead of Structured Logger

  • File: go-backend/internal/auth/clerk.go (lines 444, 457)
  • Description: JWKS refresh failures log via fmt.Printf and will not appear in structured logs or log aggregation.
  • Recommended Fix: Pass a *zap.Logger to ClerkValidator.

LOW-8: Missing created_by Audit Column on Several Tables

  • File: database/PRISM_vNext_FINAL.sql
  • Description: Tables including building_addresses, building_electrical, building_metrics_current, building_distances, and building_drive_catchment lack a created_by column, making forensic investigation harder.
  • Recommended Fix: Add created_by text to tables where human-initiated writes are possible.

LOW-9: Lookup Tables Lack Write-Deny RLS Policies

  • File: database/PRISM_vNext_FINAL.sql (lines 2351-2401)
  • Description: Reference tables have SELECT USING (true) but no explicit INSERT/UPDATE/DELETE deny policies. While GRANT statements revoke write privileges, explicit RLS deny policies provide defense-in-depth.
  • Recommended Fix: Add FOR INSERT WITH CHECK (false), FOR UPDATE USING (false), and FOR DELETE USING (false) policies on all lookup tables.

LOW-10: Local Redis Without Authentication

  • File: go-backend/ops/docker-compose.yml (lines 21-30)
  • Description: The local Redis container has no requirepass and binds to all interfaces. On a shared network, Redis is accessible without authentication.
  • Recommended Fix: Add --requirepass to the Redis command or bind to 127.0.0.1.

SOC 2 Control Mapping

ControlStatusKey Gaps
CC6.1 Logical AccessPARTIALSuperuser dev DB, secrets on disk, wildcard scope, pprof
CC6.3 Role-Based AccessPASSThree-tier roles, scope delegation, API key restrictions
CC6.6 System BoundariesPARTIALBroad CSP connect-src, no Docker network segmentation
CC6.7 Encryption in TransitPARTIALMissing HSTS, PII unencrypted at rest
CC7.1 MonitoringPASSSentry (error tracking + performance), structured logging, Render metrics. Prometheus/Grafana/Alertmanager available locally but not deployed in production
CC7.2 Change ManagementPARTIALNo migration framework, audit sanitisation gaps
CC8.1 Backup/RecoveryPARTIALSupabase auto-backup; no retention policy, no restore testing
Privacy (GDPR)PARTIALNo documented PII deletion procedure, no audit log anonymisation

OWASP Top 10 Summary

CategoryStatusNotes
A01: Broken Access ControlPASSConsistent org-scoping, scope enforcement
A02: Cryptographic FailuresFAILCredentials on disk, PII unencrypted
A03: InjectionPASSParameterised queries, LIKE escaping, sort column whitelists
A04: Insecure DesignPASSMulti-layer auth, defence-in-depth
A05: Security MisconfigurationPARTIALMissing HSTS, pprof exposed, broad CSP
A06: Vulnerable ComponentsPASSAll dependencies at current versions
A07: Auth FailuresPASSJWT validation, API key hashing, JWKS SSRF protection
A08: Data IntegrityPASSSigned JWTs, append-only audit log
A09: Logging FailuresPARTIALGood audit logging; sanitisation could be broader
A10: SSRFPASSJWKS URL allowlisting, IP rejection, redirect protection

Strengths

  1. 72 RLS policies with org-scoped enforcement on every private table
  2. Zero SQL injection vectors -- parameterised queries throughout all repositories
  3. Zero XSS vectors -- no dangerouslySetInnerHTML, no eval(), no console.log in frontend source
  4. Append-only audit log with field-level change tracking and RLS deny on UPDATE/DELETE
  5. Distroless container running as non-root (UID 65532) with multi-stage build
  6. SSRF protection on JWKS endpoint with hostname allowlisting and IP rejection
  7. API key hash-only storage with immutability guards and safe view pattern
  8. Production DB role validation -- rejects superuser/BYPASSRLS connections at startup
  9. Comprehensive security headers on frontend (HSTS, X-Frame-Options DENY, CSP, COOP, Permissions-Policy)
  10. Cross-org consistency triggers on join tables preventing data leaks between organisations
  11. Security documentation covering incident response, access control, secrets management, vulnerability management, and backup/DR

Remediation Roadmap

Immediate (This Week)

PriorityActionFindingStatus
P0Rotate all exposed credentials (Supabase service role key first)CRIT-1IN PROGRESS — Infisical set up as secrets manager; prod secrets synced to Render/Cloudflare Pages. Credential rotation and dev .env elimination pending.
P0Switch dev DATABASE_URL to non-superuser prism_app roleCRIT-2DONE (2026-04-01)
P1Restrict /admin/tables to platform-level super-admin Endpoint removedHIGH-1DONE (2026-04-01)
P1Add UUID validation for biSchemaID in export DDLHIGH-2DONE (2026-04-01)
P1Regenerate Slack webhook URLsHIGH-4DONE (2026-04-02) — URLs rotated, sourced from Infisical via env vars

Short-Term (30 Days)

PriorityActionFindingStatus
P2Implement client-side idle timeout (15-30 min)MED-1DONE (2026-04-01) — 20min idle lock screen with 60s warning banner
P2Tighten CSP connect-src to specific API domainsMED-2DONE (2026-04-01)
P2Add HSTS header to backend security middlewareMED-5DONE (2026-04-01)
P2Gate pprof to ENV=development onlyMED-6DONE (2026-04-01)
P2Adopt a migration framework (golang-migrate, goose, or atlas)MED-11DONE (2026-04-01) — golang-migrate integrated, 16 existing migrations versioned
P2Add pre-commit hook blocking .env file commitsCRIT-1DONE (2026-04-01) — .githooks/pre-commit rejects staged .env files
P3Add logging/alerting for wildcard scope usageHIGH-3DONE (2026-04-01)
P3Verify backend enforces admin role on all /admin/* endpointsMED-3VERIFIED — backend enforces admin:read/admin:write scopes (2026-04-01)

Medium-Term (90 Days)

PriorityActionFindingStatus
P3Implement data retention policies (partition audit/error logs)MED-10DONE (2026-04-01) — pg_cron daily purge: audit_log 7yr, error_log 90d, import_jobs 90d, contact_activities 2yr
P3Document and test PII deletion procedurePrivacyDONE (2026-04-01) — docs/security/data-deletion-policy.md
P3Implement Redis-backed distributed rate limitingMED-7DONE (2026-04-01)
P3Expand audit body sanitisation to substring matchingMED-8DONE (2026-04-01)
P4Evaluate column-level encryption for contact PIIMED-9ACCEPTED RISK — RLS org-isolation + Supabase encryption-at-rest provide sufficient protection; column-level encryption would break contact search/sort functionality
P4Configure Sentry replay privacy (maskAllText: true)LOW-4DONE (2026-04-01)
P4Add write-deny RLS policies to lookup tablesLOW-9DONE (2026-04-01)
P4Gate window.__prismPerf behind dev modeLOW-3DONE (2026-04-01)
P4Prevent .map files in production buildsLOW-2DONE (2026-04-01) — sourcemap generation gated on SENTRY_AUTH_TOKEN

Additional Remediations (2026-04-01)

FindingAction Taken
MED-4normalizeWebsite() validates via new URL(), displays normalised URL as link text
MED-12Removed default admin Grafana password fallback in docker-compose
LOW-1Standardised all external links to rel="noopener noreferrer"
LOW-5Sentry.setUser() now sends only user.id — no email/name PII
LOW-7JWKS refresh warnings use structured zap logger via SetLogger()
LOW-8Added created_by column to building_addresses, building_electrical, building_metrics_current, building_distances, building_drive_catchment; wired through service/repo layer for user-initiated writes
LOW-10Local Redis requires password (--requirepass) and binds to 127.0.0.1

Methodology

This audit was conducted through automated static analysis of:

  • All Go source files in go-backend/ (handlers, middleware, services, repositories, configuration)
  • All TypeScript/React source files in frontend/src/
  • Database schema (database/PRISM_vNext_FINAL.sql) and all 13 migration files
  • Docker and Docker Compose configurations
  • Environment files and .gitignore patterns
  • Prometheus, Alertmanager, and Grafana configurations
  • Security documentation in docs/security/

No dynamic testing, penetration testing, or runtime analysis was performed. Findings should be validated with runtime testing where applicable.