Project Prism -- Security & SOC 2 Compliance Audit
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
| Severity | Count |
|---|---|
| Critical | 2 |
| High | 4 |
| Medium | 12 |
| Low / Info | 10 |
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:
- Rotate ALL credentials immediately, prioritising the Supabase service role key
- Migrate secrets to a secrets manager (HashiCorp Vault, AWS Secrets Manager, Doppler, or 1Password CLI)
- Add a pre-commit hook that blocks commits containing
.envfiles - Consider
direnvwith 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
.envfiles on disk remain for local dev until the Infisicaldevenvironment is set up. Seedocs/security/secrets-management.mdfor 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:
- Create a dedicated
prism_appdatabase role without SUPERUSER or BYPASSRLS privileges - Update
go-backend/.envand all development configurations to use this role - Reserve the
postgresrole exclusively for migration scripts - 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
LOW-1: Inconsistent rel Attributes on External Links
- File:
frontend/src/modules/data/pages/OwnersManagePage.tsx(line 444) vsContactProfilePage.tsx(line 156) - Description: Some external links use
rel="noreferrer"while others userel="noopener noreferrer". Modern browsers treatnoreferreras implyingnoopener, 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. IfSENTRY_AUTH_TOKENis unset or the plugin fails,.mapfiles remain in the deployed bundle. - Recommended Fix: Add a CI step that verifies no
.mapfiles 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
windowunconditionally. While the dev panel UI is gated behindimport.meta.env.DEV, the underlying API exposes internal API surface area in production. - Recommended Fix: Gate the
window.__prismPerfassignment behindimport.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: truefor production or applydata-sentry-maskto 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.idfor 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
sessionStoragefor drafts containing business data. The existingmaxAgeMsexpiry inreadLocalDraftis 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.Printfand will not appear in structured logs or log aggregation. - Recommended Fix: Pass a
*zap.LoggertoClerkValidator.
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, andbuilding_drive_catchmentlack acreated_bycolumn, making forensic investigation harder. - Recommended Fix: Add
created_by textto 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), andFOR 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
requirepassand binds to all interfaces. On a shared network, Redis is accessible without authentication. - Recommended Fix: Add
--requirepassto the Redis command or bind to127.0.0.1.
SOC 2 Control Mapping
| Control | Status | Key Gaps |
|---|---|---|
| CC6.1 Logical Access | PARTIAL | Superuser dev DB, secrets on disk, wildcard scope, pprof |
| CC6.3 Role-Based Access | PASS | Three-tier roles, scope delegation, API key restrictions |
| CC6.6 System Boundaries | PARTIAL | Broad CSP connect-src, no Docker network segmentation |
| CC6.7 Encryption in Transit | PARTIAL | Missing HSTS, PII unencrypted at rest |
| CC7.1 Monitoring | PASS | Sentry (error tracking + performance), structured logging, Render metrics. Prometheus/Grafana/Alertmanager available locally but not deployed in production |
| CC7.2 Change Management | PARTIAL | No migration framework, audit sanitisation gaps |
| CC8.1 Backup/Recovery | PARTIAL | Supabase auto-backup; no retention policy, no restore testing |
| Privacy (GDPR) | PARTIAL | No documented PII deletion procedure, no audit log anonymisation |
OWASP Top 10 Summary
| Category | Status | Notes |
|---|---|---|
| A01: Broken Access Control | PASS | Consistent org-scoping, scope enforcement |
| A02: Cryptographic Failures | FAIL | Credentials on disk, PII unencrypted |
| A03: Injection | PASS | Parameterised queries, LIKE escaping, sort column whitelists |
| A04: Insecure Design | PASS | Multi-layer auth, defence-in-depth |
| A05: Security Misconfiguration | PARTIAL | Missing HSTS, pprof exposed, broad CSP |
| A06: Vulnerable Components | PASS | All dependencies at current versions |
| A07: Auth Failures | PASS | JWT validation, API key hashing, JWKS SSRF protection |
| A08: Data Integrity | PASS | Signed JWTs, append-only audit log |
| A09: Logging Failures | PARTIAL | Good audit logging; sanitisation could be broader |
| A10: SSRF | PASS | JWKS URL allowlisting, IP rejection, redirect protection |
Strengths
- 72 RLS policies with org-scoped enforcement on every private table
- Zero SQL injection vectors -- parameterised queries throughout all repositories
- Zero XSS vectors -- no
dangerouslySetInnerHTML, noeval(), noconsole.login frontend source - Append-only audit log with field-level change tracking and RLS deny on UPDATE/DELETE
- Distroless container running as non-root (UID 65532) with multi-stage build
- SSRF protection on JWKS endpoint with hostname allowlisting and IP rejection
- API key hash-only storage with immutability guards and safe view pattern
- Production DB role validation -- rejects superuser/BYPASSRLS connections at startup
- Comprehensive security headers on frontend (HSTS, X-Frame-Options DENY, CSP, COOP, Permissions-Policy)
- Cross-org consistency triggers on join tables preventing data leaks between organisations
- Security documentation covering incident response, access control, secrets management, vulnerability management, and backup/DR
Remediation Roadmap
Immediate (This Week)
| Priority | Action | Finding | Status |
|---|---|---|---|
| P0 | Rotate all exposed credentials (Supabase service role key first) | CRIT-1 | IN PROGRESS — Infisical set up as secrets manager; prod secrets synced to Render/Cloudflare Pages. Credential rotation and dev .env elimination pending. |
| P0 | Switch dev DATABASE_URL to non-superuser prism_app role | CRIT-2 | DONE (2026-04-01) |
| P1 | /admin/tables to platform-level super-admin | HIGH-1 | DONE (2026-04-01) |
| P1 | Add UUID validation for biSchemaID in export DDL | HIGH-2 | DONE (2026-04-01) |
| P1 | Regenerate Slack webhook URLs | HIGH-4 | DONE (2026-04-02) — URLs rotated, sourced from Infisical via env vars |
Short-Term (30 Days)
| Priority | Action | Finding | Status |
|---|---|---|---|
| P2 | Implement client-side idle timeout (15-30 min) | MED-1 | DONE (2026-04-01) — 20min idle lock screen with 60s warning banner |
| P2 | Tighten CSP connect-src to specific API domains | MED-2 | DONE (2026-04-01) |
| P2 | Add HSTS header to backend security middleware | MED-5 | DONE (2026-04-01) |
| P2 | Gate pprof to ENV=development only | MED-6 | DONE (2026-04-01) |
| P2 | Adopt a migration framework (golang-migrate, goose, or atlas) | MED-11 | DONE (2026-04-01) — golang-migrate integrated, 16 existing migrations versioned |
| P2 | Add pre-commit hook blocking .env file commits | CRIT-1 | DONE (2026-04-01) — .githooks/pre-commit rejects staged .env files |
| P3 | Add logging/alerting for wildcard scope usage | HIGH-3 | DONE (2026-04-01) |
| P3 | Verify backend enforces admin role on all /admin/* endpoints | MED-3 | VERIFIED — backend enforces admin:read/admin:write scopes (2026-04-01) |
Medium-Term (90 Days)
| Priority | Action | Finding | Status |
|---|---|---|---|
| P3 | Implement data retention policies (partition audit/error logs) | MED-10 | DONE (2026-04-01) — pg_cron daily purge: audit_log 7yr, error_log 90d, import_jobs 90d, contact_activities 2yr |
| P3 | Document and test PII deletion procedure | Privacy | DONE (2026-04-01) — docs/security/data-deletion-policy.md |
| P3 | Implement Redis-backed distributed rate limiting | MED-7 | DONE (2026-04-01) |
| P3 | Expand audit body sanitisation to substring matching | MED-8 | DONE (2026-04-01) |
| P4 | Evaluate column-level encryption for contact PII | MED-9 | ACCEPTED RISK — RLS org-isolation + Supabase encryption-at-rest provide sufficient protection; column-level encryption would break contact search/sort functionality |
| P4 | Configure Sentry replay privacy (maskAllText: true) | LOW-4 | DONE (2026-04-01) |
| P4 | Add write-deny RLS policies to lookup tables | LOW-9 | DONE (2026-04-01) |
| P4 | Gate window.__prismPerf behind dev mode | LOW-3 | DONE (2026-04-01) |
| P4 | Prevent .map files in production builds | LOW-2 | DONE (2026-04-01) — sourcemap generation gated on SENTRY_AUTH_TOKEN |
Additional Remediations (2026-04-01)
| Finding | Action Taken |
|---|---|
| MED-4 | normalizeWebsite() validates via new URL(), displays normalised URL as link text |
| MED-12 | Removed default admin Grafana password fallback in docker-compose |
| LOW-1 | Standardised all external links to rel="noopener noreferrer" |
| LOW-5 | Sentry.setUser() now sends only user.id — no email/name PII |
| LOW-7 | JWKS refresh warnings use structured zap logger via SetLogger() |
| LOW-8 | Added 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-10 | Local 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
.gitignorepatterns - 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.