From 843627bf08108b103d5ad43aa3a0a0f32aca3575 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Mon, 26 Jan 2026 23:27:59 -0500 Subject: [PATCH 01/13] feat(infra): add nginx reverse proxy and production security - Add nginx reverse proxy for unified entry point at http://localhost - Routes: / (frontend), /api (backend), /analytics (OpenSearch Dashboards) - Configure OpenSearch Dashboards with /analytics base path - Add production deployment with TLS and security plugin - SaaS multitenancy with per-customer tenant isolation - Certificate generation script (just generate-certs) - New commands: just dev, just prod-secure Signed-off-by: Aseem Shrey --- Dockerfile | 4 + docker/PRODUCTION.md | 223 ++++++++++++++++++ docker/README.md | 166 +++++++++---- docker/certs/.gitignore | 7 + docker/docker-compose.full.yml | 122 ++++++++-- docker/docker-compose.infra.yml | 88 +++++++ docker/docker-compose.prod.yml | 89 +++++++ docker/nginx/nginx.dev.conf | 189 +++++++++++++++ docker/nginx/nginx.full.conf | 185 +++++++++++++++ docker/nginx/nginx.prod.conf | 182 ++++++++++++++ docker/opensearch-dashboards.prod.yml | 57 +++++ docker/opensearch-dashboards.yml | 30 +++ docker/opensearch-init.sh | 69 ++++++ docker/opensearch-security/internal_users.yml | 61 +++++ docker/opensearch-security/roles.yml | 140 +++++++++++ docker/opensearch-security/roles_mapping.yml | 64 +++++ docker/opensearch-security/tenants.yml | 28 +++ docker/scripts/generate-certs.sh | 91 +++++++ justfile | 96 +++++++- pm2.config.cjs | 37 +++ 20 files changed, 1862 insertions(+), 66 deletions(-) create mode 100644 docker/PRODUCTION.md create mode 100644 docker/certs/.gitignore create mode 100644 docker/docker-compose.prod.yml create mode 100644 docker/nginx/nginx.dev.conf create mode 100644 docker/nginx/nginx.full.conf create mode 100644 docker/nginx/nginx.prod.conf create mode 100644 docker/opensearch-dashboards.prod.yml create mode 100644 docker/opensearch-dashboards.yml create mode 100755 docker/opensearch-init.sh create mode 100644 docker/opensearch-security/internal_users.yml create mode 100644 docker/opensearch-security/roles.yml create mode 100644 docker/opensearch-security/roles_mapping.yml create mode 100644 docker/opensearch-security/tenants.yml create mode 100755 docker/scripts/generate-certs.sh diff --git a/Dockerfile b/Dockerfile index ecbca77a..c2f28972 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,6 +89,7 @@ ARG VITE_DEFAULT_ORG_ID=local-dev ARG VITE_GIT_SHA=unknown ARG VITE_PUBLIC_POSTHOG_KEY="" ARG VITE_PUBLIC_POSTHOG_HOST="" +ARG VITE_OPENSEARCH_DASHBOARDS_URL="" ENV VITE_AUTH_PROVIDER=${VITE_AUTH_PROVIDER} ENV VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY} @@ -98,6 +99,7 @@ ENV VITE_DEFAULT_ORG_ID=${VITE_DEFAULT_ORG_ID} ENV VITE_GIT_SHA=${VITE_GIT_SHA} ENV VITE_PUBLIC_POSTHOG_KEY=${VITE_PUBLIC_POSTHOG_KEY} ENV VITE_PUBLIC_POSTHOG_HOST=${VITE_PUBLIC_POSTHOG_HOST} +ENV VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL} # Set working directory for frontend USER shipsec @@ -129,6 +131,7 @@ ARG VITE_DEFAULT_ORG_ID=local-dev ARG VITE_GIT_SHA=unknown ARG VITE_PUBLIC_POSTHOG_KEY="" ARG VITE_PUBLIC_POSTHOG_HOST="" +ARG VITE_OPENSEARCH_DASHBOARDS_URL="" ENV VITE_AUTH_PROVIDER=${VITE_AUTH_PROVIDER} ENV VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY} @@ -138,6 +141,7 @@ ENV VITE_DEFAULT_ORG_ID=${VITE_DEFAULT_ORG_ID} ENV VITE_GIT_SHA=${VITE_GIT_SHA} ENV VITE_PUBLIC_POSTHOG_KEY=${VITE_PUBLIC_POSTHOG_KEY} ENV VITE_PUBLIC_POSTHOG_HOST=${VITE_PUBLIC_POSTHOG_HOST} +ENV VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL} # Set working directory for frontend USER shipsec diff --git a/docker/PRODUCTION.md b/docker/PRODUCTION.md new file mode 100644 index 00000000..dd5908d0 --- /dev/null +++ b/docker/PRODUCTION.md @@ -0,0 +1,223 @@ +# Production Deployment Guide + +This guide covers deploying the analytics infrastructure with security and SaaS multitenancy enabled. + +## Overview + +| Environment | Security | Multitenancy | Use Case | +|-------------|----------|--------------|----------| +| Development | Disabled | No | Local development, fast iteration | +| Production | Enabled | Yes (Strict) | Multi-tenant SaaS deployment | + +## SaaS Multitenancy Model + +**Key Principles:** +- Each customer gets complete data isolation by default +- No shared dashboards - sharing is explicitly opt-in +- Each customer has their own index pattern (`{customer_id}-*`) +- Tenants, roles, and users are created dynamically via backend + +**Index Naming Convention:** +``` +{customer_id}-analytics-* # Analytics data +{customer_id}-workflows-* # Workflow results +{customer_id}-scans-* # Scan results +``` + +## Quick Start (Production) + +```bash +# 1. Generate TLS certificates +./scripts/generate-certs.sh + +# 2. Set required environment variables +export OPENSEARCH_ADMIN_PASSWORD="your-secure-admin-password" +export OPENSEARCH_DASHBOARDS_PASSWORD="your-secure-dashboards-password" + +# 3. Start with production configuration +docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml up -d +``` + +## Files Overview + +| File | Purpose | +|------|---------| +| `docker-compose.infra.yml` | Base infrastructure (dev mode, PM2 on host) | +| `docker-compose.full.yml` | Full stack containerized (simple prod, no security) | +| `docker-compose.prod.yml` | Security overlay (combines with infra.yml for SaaS) | +| `nginx/nginx.dev.conf` | Nginx routing to host (PM2 services) | +| `nginx/nginx.prod.conf` | Nginx routing to containers | +| `opensearch-dashboards.yml` | Dashboards config (dev) | +| `opensearch-dashboards.prod.yml` | Dashboards config (prod with multitenancy) | +| `scripts/generate-certs.sh` | TLS certificate generator | +| `opensearch-security/` | Security plugin configuration | +| `certs/` | Generated certificates (gitignored) | + +See [README.md](README.md) for detailed usage of each compose file. + +## Customer Provisioning (Backend Integration) + +When a new customer is onboarded, the backend must create: + +### 1. Create Customer Tenant +```bash +PUT /_plugins/_security/api/tenants/{customer_id} +{ + "description": "Tenant for customer {customer_id}" +} +``` + +### 2. Create Customer Role (with Index Isolation) +```bash +PUT /_plugins/_security/api/roles/customer_{customer_id}_rw +{ + "cluster_permissions": ["cluster_composite_ops_ro"], + "index_permissions": [{ + "index_patterns": ["{customer_id}-*"], + "allowed_actions": ["read", "write", "create_index", "indices:data/read/*", "indices:data/write/*"] + }], + "tenant_permissions": [{ + "tenant_patterns": ["{customer_id}"], + "allowed_actions": ["kibana_all_write"] + }] +} +``` + +### 3. Create Customer User +```bash +PUT /_plugins/_security/api/internalusers/{user_email} +{ + "password": "hashed_password", + "backend_roles": ["customer_{customer_id}"], + "attributes": { + "customer_id": "{customer_id}", + "email": "{user_email}" + } +} +``` + +### 4. Map User to Role +```bash +PUT /_plugins/_security/api/rolesmapping/customer_{customer_id}_rw +{ + "users": ["{user_email}"], + "backend_roles": ["customer_{customer_id}"] +} +``` + +## Security Configuration + +### TLS Certificates + +The `scripts/generate-certs.sh` script generates: + +- **root-ca.pem** - Root certificate authority +- **node.pem / node-key.pem** - OpenSearch node certificate +- **admin.pem / admin-key.pem** - Admin certificate for cluster management + +For production: +- Use a proper CA (Let's Encrypt, internal PKI) +- Store private keys in a secrets manager (Vault, AWS Secrets Manager) +- Set up certificate rotation before expiration + +### System Users + +Only two system users are defined (in `internal_users.yml`): + +| User | Purpose | +|------|---------| +| `admin` | Platform operations - DO NOT give to customers | +| `kibanaserver` | Dashboards backend communication | + +Customer users are created dynamically via the Security REST API. + +### Password Hashing + +Generate password hashes for users: +```bash +docker run -it opensearchproject/opensearch:2.11.1 \ + /usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p YOUR_PASSWORD +``` + +## Data Isolation Verification + +After setting up a customer, verify isolation: + +```bash +# As customer user - should only see their data +curl -u user@customer.com:password \ + "https://localhost:9200/{customer_id}-*/_search" + +# Should NOT be able to access other customer's data (403 Forbidden) +curl -u user@customer.com:password \ + "https://localhost:9200/other_customer-*/_search" +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `OPENSEARCH_ADMIN_PASSWORD` | Yes | Admin user password | +| `OPENSEARCH_DASHBOARDS_PASSWORD` | Yes | kibanaserver user password | + +## Updating Security Configuration + +After modifying security files, apply changes: + +```bash +docker exec -it shipsec-opensearch \ + /usr/share/opensearch/plugins/opensearch-security/tools/securityadmin.sh \ + -cd /usr/share/opensearch/config/opensearch-security \ + -icl -nhnv \ + -cacert /usr/share/opensearch/config/certs/root-ca.pem \ + -cert /usr/share/opensearch/config/certs/admin.pem \ + -key /usr/share/opensearch/config/certs/admin-key.pem +``` + +## Troubleshooting + +### Container fails to start + +Check logs: +```bash +docker logs shipsec-opensearch +docker logs shipsec-opensearch-dashboards +``` + +Common issues: +- Certificate permissions (should be 600 for keys, 644 for certs) +- Missing environment variables +- Incorrect certificate paths + +### Cannot connect to secured cluster + +```bash +# Test with curl +curl -k -u admin:PASSWORD https://localhost:9200/_cluster/health +``` + +### Customer cannot see their dashboards + +1. Verify tenant was created for customer +2. Check user has correct backend_roles +3. Verify role has correct tenant_permissions +4. Check index pattern matches customer's indices + +### Cross-tenant data leak + +If a customer can see another customer's data: +1. Verify index_patterns in role are correctly scoped to `{customer_id}-*` +2. Check role mapping is correct +3. Ensure user's backend_roles match their customer ID + +## Switching Between Environments + +**Development (no security):** +```bash +docker compose -f docker-compose.infra.yml up -d +``` + +**Production (with security):** +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml up -d +``` diff --git a/docker/README.md b/docker/README.md index a0b7bce2..6b1d9a5c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,73 +1,155 @@ -# MCP Docker Images +# Docker Configuration -This directory contains Docker images for the MCP (Model Context Protocol) Library implementation. +This directory contains Docker Compose configurations for running ShipSec Studio in different environments. -## Overview +## Docker Compose Files -The MCP Docker images provide a standardized way to deploy MCP servers with a built-in stdio proxy. Each image includes: +| File | Purpose | When to Use | +|------|---------|-------------| +| `docker-compose.infra.yml` | Infrastructure services only | Development with PM2 (frontend/backend on host) | +| `docker-compose.full.yml` | Full stack in containers | Self-hosted deployment, all services containerized | +| `docker-compose.prod.yml` | Security overlay | Production SaaS with multitenancy (overlays infra.yml) | -1. **Base stdio proxy** (`mcp-stdio-proxy`): A Node.js HTTP server that acts as a bridge between MCP stdio servers and HTTP clients -2. **Provider suites** (`mcp-aws-suite`): Bundled MCP servers for specific cloud providers +## Environment Modes -## Quick Start with Docker Compose - -Start all services: +### Development Mode (`just dev`) ```bash -cd docker -docker-compose up -d +just dev ``` -Check health of services: +- **Compose file**: `docker-compose.infra.yml` +- **Frontend/Backend**: Run via PM2 on host machine +- **Infrastructure**: Runs in Docker (Postgres, Redis, Temporal, OpenSearch, etc.) +- **Nginx**: Uses `nginx.dev.conf` pointing to `host.docker.internal` +- **Security**: Disabled for fast iteration + +**Access:** +- Frontend: http://localhost:5173 +- Backend: http://localhost:3211 +- Analytics: http://localhost:5601/analytics/ + +### Production Mode (`just prod`) ```bash -curl http://localhost:8080/health # stdio proxy -curl http://localhost:8081/health # AWS suite +just prod ``` -## Individual Services +- **Compose file**: `docker-compose.full.yml` +- **All services**: Run as Docker containers +- **Nginx**: Unified entry point on port 80 +- **Security**: Disabled (simple deployment) -### stdio proxy +**Access (all via port 80):** +- Frontend: http://localhost/ +- Backend API: http://localhost/api/ +- Analytics: http://localhost/analytics/ -- Port: 8080 -- Purpose: Base HTTP to stdio proxy for MCP servers +**Nginx Routing (nginx.full.conf):** -### AWS Suite +| Path | Target Container | Port | +|------|------------------|------| +| `/analytics/*` | opensearch-dashboards | 5601 | +| `/api/*` | backend | 3211 | +| `/*` | frontend | 8080 | -- Port: 8081 -- MCP servers: CloudTrail, CloudWatch, EC2, S3 -- Environment: `MCP_COMMAND`, `MCP_ARGS` +> **Note:** Frontend and backend containers only expose ports internally. All external traffic flows through nginx on port 80. -## Customization - -You can override the default MCP server using environment variables: +### Production Secure Mode (`just prod-secure`) ```bash -# Using docker-compose -docker-compose up -e MCP_COMMAND=awslabs.cloudwatch-mcp-server +just generate-certs +export OPENSEARCH_ADMIN_PASSWORD='secure-password' +export OPENSEARCH_DASHBOARDS_PASSWORD='secure-password' +just prod-secure +``` + +- **Compose files**: `docker-compose.infra.yml` + `docker-compose.prod.yml` (overlay) +- **Security**: TLS enabled, authentication required +- **Multitenancy**: Strict SaaS isolation per customer +- **Nginx**: Uses `nginx.prod.conf` with container networking + +**Access:** +- Analytics: https://localhost/analytics (auth required) +- OpenSearch: https://localhost:9200 (TLS) + +## Nginx Configuration + +| File | Target Services | Use Case | +|------|-----------------|----------| +| `nginx/nginx.dev.conf` | `host.docker.internal:5173/3211` | Dev (PM2 on host) | +| `nginx/nginx.full.conf` | `frontend:8080`, `backend:3211`, `opensearch-dashboards:5601` | Full stack (all containerized) | +| `nginx/nginx.prod.conf` | Same as full + TLS | Prod with security | + +### Routing Architecture -# Or with docker run -docker run -e MCP_COMMAND=github.github-issue-mcp-server shipsec/mcp-aws-suite:latest +All modes use nginx as a reverse proxy with unified routing: + +``` +┌─────────────────────────────────────────────────┐ +│ Nginx (port 80/443) │ +├─────────────────────────────────────────────────┤ +│ /analytics/* → OpenSearch Dashboards:5601 │ +│ /api/* → Backend:3211 │ +│ /* → Frontend:8080 │ +└─────────────────────────────────────────────────┘ ``` -## Build Instructions +### OpenSearch Dashboards BasePath -Build individual images: +OpenSearch Dashboards is configured with `server.basePath: "/analytics"` to work behind nginx: +- Incoming requests: `/analytics/app/discover` → internally processed as `/app/discover` +- Outgoing URLs: Automatically prefixed with `/analytics` -```bash -# Build stdio proxy -cd mcp-stdio-proxy -docker build -t shipsec/mcp-stdio-proxy:latest . +## Analytics Pipeline -# Build AWS suite -cd mcp-aws-suite -docker build -t shipsec/mcp-aws-suite:latest . +The worker service writes analytics data to OpenSearch via the Analytics Sink component. +**Required Environment Variable:** +```yaml +OPENSEARCH_URL=http://opensearch:9200 ``` -## Authentication +This is pre-configured in `docker-compose.full.yml`. For detailed analytics documentation, see [docs/analytics.md](../docs/analytics.md). + +## Directory Structure + +``` +docker/ +├── docker-compose.infra.yml # Infrastructure (dev base) +├── docker-compose.full.yml # Full stack containerized +├── docker-compose.prod.yml # Security overlay for prod +├── nginx/ +│ ├── nginx.dev.conf # Routes to host (PM2) +│ └── nginx.prod.conf # Routes to containers +├── opensearch-dashboards.yml # Dev dashboards config +├── opensearch-dashboards.prod.yml # Prod dashboards config +├── opensearch-security/ # Security plugin configs +│ ├── internal_users.yml +│ ├── roles.yml +│ ├── roles_mapping.yml +│ └── tenants.yml +├── scripts/ +│ └── generate-certs.sh # TLS certificate generator +├── certs/ # Generated certs (gitignored) +├── PRODUCTION.md # Production deployment guide +└── README.md # This file +``` + +## Quick Reference + +| Command | Description | +|---------|-------------| +| `just dev` | Start dev environment (PM2 + Docker infra) | +| `just dev stop` | Stop dev environment | +| `just prod` | Start full stack in Docker | +| `just prod stop` | Stop production | +| `just prod-secure` | Start with security & multitenancy | +| `just generate-certs` | Generate TLS certificates | +| `just infra up` | Start infrastructure only | +| `just help` | Show all available commands | -Each suite requires specific authentication: +## See Also -- **AWS Suite**: AWS credentials (access key, secret key, or credentials file) - See individual suite README for detailed authentication instructions. +- [PRODUCTION.md](PRODUCTION.md) - Detailed production deployment and customer provisioning guide +- [docs/analytics.md](../docs/analytics.md) - Analytics pipeline and OpenSearch configuration diff --git a/docker/certs/.gitignore b/docker/certs/.gitignore new file mode 100644 index 00000000..5ae618b7 --- /dev/null +++ b/docker/certs/.gitignore @@ -0,0 +1,7 @@ +# Ignore generated certificates and private keys +# These should NEVER be committed to version control +*.pem +*.key +*.crt +*.csr +*.srl diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index 26c5b584..0a1e2979 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -62,7 +62,7 @@ services: - temporal_data:/var/lib/temporal restart: unless-stopped healthcheck: - test: ['CMD', 'tctl', '--address', 'localhost:7233', 'cluster', 'health'] + test: ["CMD-SHELL", "tctl --address $(hostname -i):7233 cluster health"] interval: 30s timeout: 10s retries: 5 @@ -79,7 +79,7 @@ services: - temporal restart: unless-stopped healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:8080'] + test: ["CMD", "curl", "-sf", "http://localhost:8080"] interval: 30s timeout: 10s retries: 5 @@ -171,6 +171,65 @@ services: - ./redpanda-console-config.yaml:/etc/redpanda/console-config.yaml:ro restart: unless-stopped + opensearch: + image: opensearchproject/opensearch:2.11.1 + container_name: shipsec-opensearch + environment: + - discovery.type=single-node + - bootstrap.memory_lock=true + - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" + - DISABLE_SECURITY_PLUGIN=true + - DISABLE_INSTALL_DEMO_CONFIG=true + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + ports: + - "9200:9200" + - "9600:9600" + volumes: + - opensearch_data:/usr/share/opensearch/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:2.11.1 + container_name: shipsec-opensearch-dashboards + depends_on: + opensearch: + condition: service_healthy + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + expose: + - "5601" + volumes: + - ./opensearch-dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5601/analytics/api/status || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + + opensearch-init: + image: curlimages/curl:8.5.0 + container_name: shipsec-opensearch-init + depends_on: + opensearch-dashboards: + condition: service_healthy + volumes: + - ./opensearch-init.sh:/init.sh:ro + entrypoint: ["/bin/sh", "/init.sh"] + restart: "no" + # Applications dind: image: docker:27-dind @@ -226,8 +285,9 @@ services: - SECRET_STORE_MASTER_KEY=${SECRET_STORE_MASTER_KEY:-CHANGE_ME_32_CHAR_SECRET_KEY!!!!} # Internal service-to-service auth token (must match worker) - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-internal-service-token} - ports: - - '3211:3211' + # Internal only - accessed via nginx at /api/ + expose: + - "3211" depends_on: postgres: condition: service_healthy @@ -246,30 +306,33 @@ services: dockerfile: Dockerfile target: frontend args: - VITE_API_URL: ${VITE_API_URL:-http://localhost:3211} - VITE_BACKEND_URL: ${VITE_BACKEND_URL:-http://localhost:3211} - VITE_AUTH_PROVIDER: ${VITE_AUTH_PROVIDER:-clerk} - VITE_DEFAULT_ORG_ID: ${VITE_DEFAULT_ORG_ID:-} + VITE_API_URL: ${VITE_API_URL:-http://localhost} + VITE_BACKEND_URL: ${VITE_BACKEND_URL:-http://localhost} + VITE_AUTH_PROVIDER: ${VITE_AUTH_PROVIDER:-local} + VITE_DEFAULT_ORG_ID: ${VITE_DEFAULT_ORG_ID:-local-dev} VITE_CLERK_PUBLISHABLE_KEY: ${VITE_CLERK_PUBLISHABLE_KEY:-} VITE_GIT_SHA: ${GIT_SHA:-unknown} VITE_PUBLIC_POSTHOG_KEY: ${VITE_PUBLIC_POSTHOG_KEY:-} VITE_PUBLIC_POSTHOG_HOST: ${VITE_PUBLIC_POSTHOG_HOST:-} + VITE_OPENSEARCH_DASHBOARDS_URL: ${VITE_OPENSEARCH_DASHBOARDS_URL:-/analytics} container_name: shipsec-frontend # NOTE: Auth defaults to Clerk intentionally - production requires Clerk authentication. # Set VITE_AUTH_PROVIDER=local in .env only for local development without Clerk. environment: - - VITE_API_URL=${VITE_API_URL:-http://localhost:3211} - - VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost:3211} - - VITE_AUTH_PROVIDER=${VITE_AUTH_PROVIDER:-clerk} - - VITE_DEFAULT_ORG_ID=${VITE_DEFAULT_ORG_ID:-} - - VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY:-} - ports: - - '8090:8080' + - VITE_API_URL=${VITE_API_URL:-http://localhost} + - VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost} + - VITE_AUTH_PROVIDER=clerk + - VITE_DEFAULT_ORG_ID=local-dev + - VITE_CLERK_PUBLISHABLE_KEY= + - VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL:-/analytics} + # Internal only - accessed via nginx at / + expose: + - "8080" depends_on: - backend restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:8080'] + test: ["CMD", "curl", "-sf", "http://localhost:8080"] interval: 30s timeout: 10s retries: 5 @@ -307,6 +370,8 @@ services: - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-internal-service-token} # Backend URL for internal API calls - STUDIO_API_BASE_URL=http://backend:3211/api/v1 + # OpenSearch for Analytics Sink + - OPENSEARCH_URL=http://opensearch:9200 depends_on: postgres: condition: service_healthy @@ -320,6 +385,8 @@ services: condition: service_healthy redpanda: condition: service_healthy + opensearch: + condition: service_healthy restart: unless-stopped healthcheck: test: ['CMD', 'node', '-e', 'process.exit(0)'] @@ -327,6 +394,28 @@ services: timeout: 10s retries: 5 + # Nginx reverse proxy - unified entry point + nginx: + image: nginx:1.25-alpine + container_name: shipsec-nginx + depends_on: + frontend: + condition: service_healthy + backend: + condition: service_started + opensearch-dashboards: + condition: service_healthy + ports: + - "80:80" + volumes: + - ./nginx/nginx.full.conf:/etc/nginx/nginx.conf:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 5 + volumes: postgres_data: minio_data: @@ -335,6 +424,7 @@ volumes: docker_data: redis_data: redpanda_data: + opensearch_data: networks: default: diff --git a/docker/docker-compose.infra.yml b/docker/docker-compose.infra.yml index 1022273f..5e822d17 100644 --- a/docker/docker-compose.infra.yml +++ b/docker/docker-compose.infra.yml @@ -134,6 +134,89 @@ services: - ./redpanda-console-config.yaml:/etc/redpanda/console-config.yaml:ro restart: unless-stopped + opensearch: + image: opensearchproject/opensearch:2.11.1 + container_name: shipsec-opensearch + environment: + - discovery.type=single-node + - bootstrap.memory_lock=true + - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" + - DISABLE_SECURITY_PLUGIN=true + - DISABLE_INSTALL_DEMO_CONFIG=true + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + ports: + - "9200:9200" + - "9600:9600" + volumes: + - opensearch_data:/usr/share/opensearch/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:2.11.1 + container_name: shipsec-opensearch-dashboards + depends_on: + opensearch: + condition: service_healthy + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + # Port exposed for development access + # Production uses nginx reverse proxy at /analytics + ports: + - "5601:5601" + volumes: + - ./opensearch-dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5601/analytics/api/status || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + + # Initialize OpenSearch Dashboards with default index patterns + opensearch-init: + image: curlimages/curl:8.5.0 + container_name: shipsec-opensearch-init + depends_on: + opensearch-dashboards: + condition: service_healthy + volumes: + - ./opensearch-init.sh:/init.sh:ro + entrypoint: ["/bin/sh", "/init.sh"] + restart: "no" + + # Nginx reverse proxy - unified entry point + # DEV MODE: Uses nginx.dev.conf which points to host.docker.internal for PM2 services + nginx: + image: nginx:1.25-alpine + container_name: shipsec-nginx + depends_on: + opensearch-dashboards: + condition: service_healthy + ports: + - "80:80" + volumes: + - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 5 + volumes: postgres_data: minio_data: @@ -141,3 +224,8 @@ volumes: temporal_data: redis_data: redpanda_data: + opensearch_data: + +networks: + default: + name: shipsec-network diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 00000000..cca75fcb --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,89 @@ +# Production Docker Compose - OpenSearch with Security & Multitenancy +# +# Usage: +# docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml up -d +# +# Prerequisites: +# 1. Generate TLS certificates: ./scripts/generate-certs.sh +# 2. Set environment variables in .env.prod or export them: +# - OPENSEARCH_ADMIN_PASSWORD (required) +# - OPENSEARCH_DASHBOARDS_PASSWORD (required) +# +# This file overrides the development infrastructure with: +# - Security plugin enabled +# - TLS encryption for transport and HTTP +# - Multitenancy support in OpenSearch Dashboards + +services: + opensearch: + environment: + # Remove security disable flags (override dev settings) + - DISABLE_SECURITY_PLUGIN=false + - DISABLE_INSTALL_DEMO_CONFIG=false + # Security configuration + - plugins.security.ssl.transport.pemcert_filepath=config/certs/node.pem + - plugins.security.ssl.transport.pemkey_filepath=config/certs/node-key.pem + - plugins.security.ssl.transport.pemtrustedcas_filepath=config/certs/root-ca.pem + - plugins.security.ssl.transport.enforce_hostname_verification=false + - plugins.security.ssl.http.enabled=true + - plugins.security.ssl.http.pemcert_filepath=config/certs/node.pem + - plugins.security.ssl.http.pemkey_filepath=config/certs/node-key.pem + - plugins.security.ssl.http.pemtrustedcas_filepath=config/certs/root-ca.pem + - plugins.security.allow_unsafe_democertificates=false + - plugins.security.allow_default_init_securityindex=true + - plugins.security.authcz.admin_dn=CN=admin,OU=ShipSec,O=ShipSecAI,L=SF,ST=CA,C=US + - plugins.security.audit.type=internal_opensearch + - plugins.security.enable_snapshot_restore_privilege=true + - plugins.security.check_snapshot_restore_write_privileges=true + - plugins.security.restapi.roles_enabled=["all_access", "security_rest_api_access"] + - cluster.name=shipsec-prod + - node.name=opensearch-node1 + volumes: + - opensearch_data:/usr/share/opensearch/data + - ./certs:/usr/share/opensearch/config/certs:ro + - ./opensearch-security:/usr/share/opensearch/config/opensearch-security:ro + healthcheck: + test: ["CMD-SHELL", "curl -sf --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + + opensearch-dashboards: + environment: + # Remove security disable flag (override dev settings) + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=false + - OPENSEARCH_HOSTS=["https://opensearch:9200"] + volumes: + - ./opensearch-dashboards.prod.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro + - ./certs:/usr/share/opensearch-dashboards/config/certs:ro + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:5601/analytics/api/status || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + # Override init script to work with secured cluster + opensearch-init: + environment: + - OPENSEARCH_SECURITY_ENABLED=true + - OPENSEARCH_CA_CERT=/certs/root-ca.pem + volumes: + - ./opensearch-init.sh:/init.sh:ro + - ./certs:/certs:ro + + # Nginx with production config (container service names) + nginx: + volumes: + - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ./certs:/etc/nginx/certs:ro + ports: + - "80:80" + - "443:443" + +volumes: + opensearch_data: + +networks: + default: + name: shipsec-network diff --git a/docker/nginx/nginx.dev.conf b/docker/nginx/nginx.dev.conf new file mode 100644 index 00000000..8f4b1f3d --- /dev/null +++ b/docker/nginx/nginx.dev.conf @@ -0,0 +1,189 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript + application/rss+xml application/atom+xml image/svg+xml; + + # ================================================================= + # DEVELOPMENT MODE - Frontend & Backend run on host via PM2 + # Uses host.docker.internal to reach host machine from container + # ================================================================= + + # Upstream definitions - pointing to host machine (PM2 services) + upstream frontend { + # Vite dev server on host + server host.docker.internal:5173; + keepalive 32; + } + + upstream backend { + # NestJS backend on host + server host.docker.internal:3211; + keepalive 32; + } + + # OpenSearch Dashboards runs in Docker + upstream opensearch-dashboards { + server opensearch-dashboards:5601; + keepalive 32; + } + + # WebSocket connection upgrade map + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 80; + server_name _; + + # Client request body size (for file uploads) + client_max_body_size 100M; + client_body_buffer_size 10M; + + # Proxy buffer settings + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + # Common proxy headers + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # ================================================================= + # Auth validation endpoint (public, proxied to backend) + # ================================================================= + location = /auth/validate { + proxy_pass http://backend/api/v1/auth/validate; + proxy_set_header Cookie $http_cookie; + proxy_set_header Authorization $http_authorization; + } + + # ================================================================= + # Internal auth validation endpoint for auth_request + # ================================================================= + location = /_auth { + internal; + proxy_pass http://backend/api/v1/auth/validate; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + # Pass cookies for session auth + proxy_set_header Cookie $http_cookie; + # Pass Authorization header for API key/token auth + proxy_set_header Authorization $http_authorization; + } + + # ================================================================= + # OpenSearch Dashboards - /analytics/* (PROTECTED) + # ================================================================= + location /analytics/ { + # Require authentication before proxying + auth_request /_auth; + # On auth failure, redirect to login page + error_page 401 = @auth_redirect; + + proxy_pass http://opensearch-dashboards; + + # WebSocket support for dashboards real-time features + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Timeouts for dashboards (can be slow for large queries) + proxy_connect_timeout 60s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + + # Dashboards-specific headers + proxy_set_header osd-xsrf "true"; + + # Preserve cookies + proxy_cookie_path /analytics/ /analytics/; + + # No redirect rewriting needed - we preserve the path + proxy_redirect off; + } + + # Auth redirect handler - redirect to home with return URL + location @auth_redirect { + return 302 /?returnTo=$request_uri; + } + + # Exact match for /analytics without trailing slash + location = /analytics { + return 301 /analytics/; + } + + # ================================================================= + # Backend API - /api/* + # ================================================================= + location /api/ { + proxy_pass http://backend/api/; + + # WebSocket support for terminal/streaming endpoints + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # API timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Don't buffer API responses (important for streaming) + proxy_buffering off; + } + + # ================================================================= + # Frontend (SPA) - /* (catch-all) + # ================================================================= + location / { + proxy_pass http://frontend/; + + # WebSocket support for Vite HMR in development + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Frontend timeouts - longer read timeout for HMR WebSocket + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 86400s; # 24 hours - keep HMR WebSocket alive + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/docker/nginx/nginx.full.conf b/docker/nginx/nginx.full.conf new file mode 100644 index 00000000..f8805296 --- /dev/null +++ b/docker/nginx/nginx.full.conf @@ -0,0 +1,185 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript + application/rss+xml application/atom+xml image/svg+xml; + + # ================================================================= + # FULL DOCKER MODE - All services run in Docker containers + # ================================================================= + + # Upstream definitions - Docker container names + upstream frontend { + server frontend:8080; + keepalive 32; + } + + upstream backend { + server backend:3211; + keepalive 32; + } + + upstream opensearch-dashboards { + server opensearch-dashboards:5601; + keepalive 32; + } + + # WebSocket connection upgrade map + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 80; + server_name _; + + # Client request body size (for file uploads) + client_max_body_size 100M; + client_body_buffer_size 10M; + + # Proxy buffer settings + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + # Common proxy headers + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # ================================================================= + # Auth validation endpoint (public, proxied to backend) + # ================================================================= + location = /auth/validate { + proxy_pass http://backend/api/v1/auth/validate; + proxy_set_header Cookie $http_cookie; + proxy_set_header Authorization $http_authorization; + } + + # ================================================================= + # Internal auth validation endpoint for auth_request + # ================================================================= + location = /_auth { + internal; + proxy_pass http://backend/api/v1/auth/validate; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + # Pass cookies for session auth + proxy_set_header Cookie $http_cookie; + # Pass Authorization header for API key/token auth + proxy_set_header Authorization $http_authorization; + } + + # ================================================================= + # OpenSearch Dashboards - /analytics/* (PROTECTED) + # ================================================================= + location /analytics/ { + # Require authentication before proxying + auth_request /_auth; + # On auth failure, redirect to login page + error_page 401 = @auth_redirect; + + proxy_pass http://opensearch-dashboards; + + # WebSocket support for dashboards real-time features + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Timeouts for dashboards (can be slow for large queries) + proxy_connect_timeout 60s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + + # Dashboards-specific headers + proxy_set_header osd-xsrf "true"; + + # Preserve cookies + proxy_cookie_path /analytics/ /analytics/; + + # No redirect rewriting needed - we preserve the path + proxy_redirect off; + } + + # Auth redirect handler - redirect to home with return URL + location @auth_redirect { + return 302 /?returnTo=$request_uri; + } + + # Exact match for /analytics without trailing slash + location = /analytics { + return 301 /analytics/; + } + + # ================================================================= + # Backend API - /api/* + # ================================================================= + location /api/ { + proxy_pass http://backend/api/; + + # WebSocket support for terminal/streaming endpoints + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # API timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Don't buffer API responses (important for streaming) + proxy_buffering off; + } + + # ================================================================= + # Frontend (SPA) - /* (catch-all) + # ================================================================= + location / { + proxy_pass http://frontend/; + + # WebSocket support for HMR (if running dev build) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Frontend timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/docker/nginx/nginx.prod.conf b/docker/nginx/nginx.prod.conf new file mode 100644 index 00000000..36ea580c --- /dev/null +++ b/docker/nginx/nginx.prod.conf @@ -0,0 +1,182 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript + application/rss+xml application/atom+xml image/svg+xml; + + # Upstream definitions + upstream frontend { + server frontend:8080; + keepalive 32; + } + + upstream backend { + server backend:3211; + keepalive 32; + } + + upstream opensearch-dashboards { + server opensearch-dashboards:5601; + keepalive 32; + } + + # WebSocket connection upgrade map + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 80; + server_name _; + + # Client request body size (for file uploads) + client_max_body_size 100M; + client_body_buffer_size 10M; + + # Proxy buffer settings + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + # Common proxy headers + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # ================================================================= + # Auth validation endpoint (public, proxied to backend) + # ================================================================= + location = /auth/validate { + proxy_pass http://backend/api/v1/auth/validate; + proxy_set_header Cookie $http_cookie; + proxy_set_header Authorization $http_authorization; + } + + # ================================================================= + # Internal auth validation endpoint for auth_request + # ================================================================= + location = /_auth { + internal; + proxy_pass http://backend/api/v1/auth/validate; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + # Pass cookies for session auth + proxy_set_header Cookie $http_cookie; + # Pass Authorization header for API key/token auth + proxy_set_header Authorization $http_authorization; + } + + # ================================================================= + # OpenSearch Dashboards - /analytics/* (PROTECTED) + # ================================================================= + location /analytics/ { + # Require authentication before proxying + auth_request /_auth; + # On auth failure, redirect to login page + error_page 401 = @auth_redirect; + + proxy_pass http://opensearch-dashboards/; + + # WebSocket support for dashboards real-time features + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Timeouts for dashboards (can be slow for large queries) + proxy_connect_timeout 60s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + + # Dashboards-specific headers + proxy_set_header osd-xsrf "true"; + + # Preserve cookies + proxy_cookie_path / /analytics/; + + # Handle redirects from dashboards + proxy_redirect / /analytics/; + proxy_redirect http://opensearch-dashboards:5601/ /analytics/; + } + + # Auth redirect handler - redirect to home with return URL + location @auth_redirect { + return 302 /?returnTo=$request_uri; + } + + # Exact match for /analytics without trailing slash + location = /analytics { + return 301 /analytics/; + } + + # ================================================================= + # Backend API - /api/* + # ================================================================= + location /api/ { + proxy_pass http://backend/api/; + + # WebSocket support for terminal/streaming endpoints + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # API timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Don't buffer API responses (important for streaming) + proxy_buffering off; + } + + # ================================================================= + # Frontend (SPA) - /* (catch-all) + # ================================================================= + location / { + proxy_pass http://frontend/; + + # WebSocket support for Vite HMR in development + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Frontend timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/docker/opensearch-dashboards.prod.yml b/docker/opensearch-dashboards.prod.yml new file mode 100644 index 00000000..c53263f3 --- /dev/null +++ b/docker/opensearch-dashboards.prod.yml @@ -0,0 +1,57 @@ +# OpenSearch Dashboards Production Configuration +# Mount this file to /usr/share/opensearch-dashboards/config/opensearch_dashboards.yml +# +# This configuration enables: +# - Security plugin with authentication +# - Multitenancy for tenant isolation +# - TLS for secure communication with OpenSearch + +server.host: "0.0.0.0" +server.port: 5601 + +# Base path configuration for reverse proxy +server.basePath: "/analytics" +server.rewriteBasePath: true + +# OpenSearch connection (HTTPS for production) +opensearch.hosts: ["https://opensearch:9200"] + +# TLS Configuration - trust the CA certificate +opensearch.ssl.verificationMode: certificate +opensearch.ssl.certificateAuthorities: ["/usr/share/opensearch-dashboards/config/certs/root-ca.pem"] + +# Authentication - use OpenSearch Security plugin +opensearch.username: "kibanaserver" +opensearch.password: "${OPENSEARCH_DASHBOARDS_PASSWORD}" +opensearch.requestHeadersWhitelist: ["securitytenant", "Authorization"] + +# Security Plugin Configuration - SaaS Multitenancy +# Each customer gets their own isolated tenant - no shared data by default +opensearch_security.multitenancy.enabled: true +opensearch_security.multitenancy.tenants.enable_global: false +opensearch_security.multitenancy.tenants.enable_private: true +opensearch_security.multitenancy.tenants.preferred: ["Private"] +opensearch_security.readonly_mode.roles: ["kibana_read_only"] +opensearch_security.cookie.secure: true +opensearch_security.cookie.isSameSite: "Strict" + +# Tenant isolation - users only see their tenant's dashboards +# Backend creates tenant dynamically per customer (tenant name = customer ID) + +# Session configuration +opensearch_security.session.ttl: 3600000 +opensearch_security.session.keepalive: true + +# Logging +logging.dest: stdout +logging.silent: false +logging.quiet: false +logging.verbose: false + +# Telemetry (disable for production privacy) +telemetry.enabled: false +telemetry.allowChangingOptInStatus: false + +# CSP headers for security +csp.strict: true +csp.warnLegacyBrowsers: true diff --git a/docker/opensearch-dashboards.yml b/docker/opensearch-dashboards.yml new file mode 100644 index 00000000..cc9dbc6a --- /dev/null +++ b/docker/opensearch-dashboards.yml @@ -0,0 +1,30 @@ +# OpenSearch Dashboards configuration +# Mount this file to /usr/share/opensearch-dashboards/config/opensearch_dashboards.yml +# +# SECURITY NOTE: +# - Local development: Security plugin is disabled (DISABLE_SECURITY_DASHBOARDS_PLUGIN=true in docker-compose) +# - Production: Enable security plugin and configure multitenancy: +# 1. Remove DISABLE_SECURITY_PLUGIN=true from OpenSearch +# 2. Remove DISABLE_SECURITY_DASHBOARDS_PLUGIN=true from Dashboards +# 3. Configure TLS certificates and authentication +# 4. Add: opensearch_security.multitenancy.enabled: true +# 5. Add: opensearch_security.multitenancy.tenants.preferred: ["Private", "Global"] + +server.host: "0.0.0.0" +server.port: 5601 + +# Base path configuration for reverse proxy +server.basePath: "/analytics" +server.rewriteBasePath: true + +# OpenSearch connection +opensearch.hosts: ["http://opensearch:9200"] + +# Logging +logging.dest: stdout +logging.silent: false +logging.quiet: false +logging.verbose: false + +# CSP - relaxed for development (inline scripts needed by dashboards) +csp.strict: false diff --git a/docker/opensearch-init.sh b/docker/opensearch-init.sh new file mode 100755 index 00000000..9ff64282 --- /dev/null +++ b/docker/opensearch-init.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# OpenSearch Dashboards initialization script +# Creates default index patterns and saved objects + +set -e + +# Note: Use /analytics prefix since dashboards is configured with server.basePath=/analytics +DASHBOARDS_URL="${OPENSEARCH_DASHBOARDS_URL:-http://opensearch-dashboards:5601}" +DASHBOARDS_BASE_PATH="/analytics" +MAX_RETRIES=30 +RETRY_INTERVAL=5 + +echo "[opensearch-init] Waiting for OpenSearch Dashboards to be ready..." + +# Wait for Dashboards to be healthy (use basePath) +for i in $(seq 1 $MAX_RETRIES); do + if curl -sf "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/status" > /dev/null 2>&1; then + echo "[opensearch-init] OpenSearch Dashboards is ready!" + break + fi + + if [ $i -eq $MAX_RETRIES ]; then + echo "[opensearch-init] ERROR: OpenSearch Dashboards not ready after $((MAX_RETRIES * RETRY_INTERVAL)) seconds" + exit 1 + fi + + echo "[opensearch-init] Waiting for Dashboards... (attempt $i/$MAX_RETRIES)" + sleep $RETRY_INTERVAL +done + +# Check if index pattern already exists +echo "[opensearch-init] Checking for existing index patterns..." +EXISTING=$(curl -sf "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/_find?type=index-pattern&search_fields=title&search=security-findings-*" \ + -H "osd-xsrf: true" 2>/dev/null || echo '{"total":0}') + +TOTAL=$(echo "$EXISTING" | grep -o '"total":[0-9]*' | grep -o '[0-9]*' || echo "0") + +if [ "$TOTAL" -gt 0 ]; then + echo "[opensearch-init] Index pattern 'security-findings-*' already exists, skipping creation" +else + echo "[opensearch-init] Creating index pattern 'security-findings-*'..." + + # Use specific ID so dashboards can reference it consistently + RESPONSE=$(curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/index-pattern/security-findings-*" \ + -H "Content-Type: application/json" \ + -H "osd-xsrf: true" \ + -d '{ + "attributes": { + "title": "security-findings-*", + "timeFieldName": "@timestamp" + } + }' 2>&1) + + if echo "$RESPONSE" | grep -q '"type":"index-pattern"'; then + echo "[opensearch-init] Successfully created index pattern 'security-findings-*'" + else + echo "[opensearch-init] WARNING: Failed to create index pattern. Response: $RESPONSE" + # Don't fail - the pattern might be created later when data exists + fi +fi + +# Set as default index pattern (optional, helps UX) +echo "[opensearch-init] Setting default index pattern..." +curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/opensearch-dashboards/settings" \ + -H "Content-Type: application/json" \ + -H "osd-xsrf: true" \ + -d '{"changes":{"defaultIndex":"security-findings-*"}}' > /dev/null 2>&1 || true + +echo "[opensearch-init] Initialization complete!" diff --git a/docker/opensearch-security/internal_users.yml b/docker/opensearch-security/internal_users.yml new file mode 100644 index 00000000..313b7d50 --- /dev/null +++ b/docker/opensearch-security/internal_users.yml @@ -0,0 +1,61 @@ +# OpenSearch Security - Internal Users (SaaS Model) +# +# USER PROVISIONING STRATEGY: +# Customer users are created dynamically via the Security REST API +# when users are added to the platform. This file only contains +# system users required for platform operations. +# +# Customer user creation example (via backend): +# PUT /_plugins/_security/api/internalusers/{user_email} +# { +# "password": "hashed_password", +# "backend_roles": ["customer_{customer_id}"], +# "attributes": { +# "customer_id": "{customer_id}", +# "email": "{user_email}" +# } +# } +# +# Password hashing: +# docker run -it opensearchproject/opensearch:2.11.1 \ +# /usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p + +--- +_meta: + type: "internalusers" + config_version: 2 + +# ============================================================================= +# SYSTEM USERS (Platform Operations) +# ============================================================================= + +# Platform admin - for internal operations only +admin: + # CHANGE THIS IN PRODUCTION - hash for "admin" + hash: "$2y$12$QJMOhaNM2dVJQGOVIBJOqOHQhQqq2v7rnE3iyNWMWvqjvjnvZe/Aq" + reserved: true + backend_roles: + - "platform_admin" + attributes: + role: "system" + description: "Platform administrator - internal use only" + +# Dashboards server user - used by OpenSearch Dashboards +kibanaserver: + # CHANGE THIS IN PRODUCTION - hash for "kibanaserver" + hash: "$2y$12$r2uo1.C/6oXP1NnMgQzNxO3LnKCJR2I3ymvY9rUYLQq9cYEITCwfO" + reserved: true + attributes: + role: "system" + description: "Dashboards backend communication user" + +# ============================================================================= +# CUSTOMER USERS +# Note: Customer users are created dynamically by the backend when users +# register or are invited to the platform. +# +# Each customer user will have: +# - backend_roles: ["customer_{customer_id}"] +# - attributes.customer_id: their customer ID +# - Mapped to customer-specific role for index isolation +# ============================================================================= diff --git a/docker/opensearch-security/roles.yml b/docker/opensearch-security/roles.yml new file mode 100644 index 00000000..5d5b5558 --- /dev/null +++ b/docker/opensearch-security/roles.yml @@ -0,0 +1,140 @@ +# OpenSearch Security - Roles Configuration (SaaS Model) +# +# INDEX ISOLATION STRATEGY: +# Each customer's data is stored in indices prefixed with their customer ID: +# {customer_id}-analytics-* +# {customer_id}-workflows-* +# {customer_id}-scans-* +# +# Roles are created dynamically per customer with index patterns that +# restrict access to only their data. This file defines role templates +# and system roles. +# +# Dynamic role creation example (via backend): +# PUT /_plugins/_security/api/roles/customer_{customer_id} +# { +# "cluster_permissions": ["cluster_composite_ops_ro"], +# "index_permissions": [{ +# "index_patterns": ["{customer_id}-*"], +# "allowed_actions": ["read", "indices:data/read/*"] +# }], +# "tenant_permissions": [{ +# "tenant_patterns": ["{customer_id}"], +# "allowed_actions": ["kibana_all_write"] +# }] +# } + +--- +_meta: + type: "roles" + config_version: 2 + +# ============================================================================= +# SYSTEM ROLES (Platform Operations) +# ============================================================================= + +# Platform admin - full access for operators +platform_admin: + reserved: true + cluster_permissions: + - "*" + index_permissions: + - index_patterns: + - "*" + allowed_actions: + - "*" + tenant_permissions: + - tenant_patterns: + - "__platform_admin" + allowed_actions: + - "kibana_all_write" + +# ============================================================================= +# CUSTOMER ROLE TEMPLATE +# These are templates - actual roles are created dynamically per customer +# ============================================================================= + +# Template: Customer read-write access (for active users) +# Actual role name: customer_{customer_id}_rw +# Index pattern: {customer_id}-* +customer_template_rw: + reserved: false + description: "Template for customer read-write roles - DO NOT USE DIRECTLY" + cluster_permissions: + - "cluster_composite_ops_ro" + - "indices:data/read/scroll*" + index_permissions: + - index_patterns: + - "CUSTOMER_ID_PLACEHOLDER-*" + allowed_actions: + - "read" + - "write" + - "create_index" + - "indices:data/read/*" + - "indices:data/write/*" + - "indices:admin/mapping/put" + tenant_permissions: + - tenant_patterns: + - "CUSTOMER_ID_PLACEHOLDER" + allowed_actions: + - "kibana_all_write" + +# Template: Customer read-only access (for viewers) +# Actual role name: customer_{customer_id}_ro +# Index pattern: {customer_id}-* +customer_template_ro: + reserved: false + description: "Template for customer read-only roles - DO NOT USE DIRECTLY" + cluster_permissions: + - "cluster_composite_ops_ro" + index_permissions: + - index_patterns: + - "CUSTOMER_ID_PLACEHOLDER-*" + allowed_actions: + - "read" + - "indices:data/read/*" + tenant_permissions: + - tenant_patterns: + - "CUSTOMER_ID_PLACEHOLDER" + allowed_actions: + - "kibana_all_read" + +# ============================================================================= +# DASHBOARDS INTERNAL ROLES +# ============================================================================= + +# Dashboards server role - for backend communication +kibana_server: + reserved: true + cluster_permissions: + - "cluster_monitor" + - "cluster_composite_ops" + - "indices:admin/template/*" + - "indices:data/read/scroll*" + index_permissions: + - index_patterns: + - ".kibana" + - ".kibana_*" + - ".opensearch_dashboards" + - ".opensearch_dashboards_*" + allowed_actions: + - "indices_all" + - index_patterns: + - "*" + allowed_actions: + - "indices:admin/aliases/get" + - "indices:admin/mappings/get" + +# Read-only dashboard user (for embedding/sharing) +kibana_read_only: + reserved: true + cluster_permissions: + - "cluster_composite_ops_ro" + index_permissions: + - index_patterns: + - ".kibana" + - ".kibana_*" + - ".opensearch_dashboards" + - ".opensearch_dashboards_*" + allowed_actions: + - "read" diff --git a/docker/opensearch-security/roles_mapping.yml b/docker/opensearch-security/roles_mapping.yml new file mode 100644 index 00000000..ff4c8065 --- /dev/null +++ b/docker/opensearch-security/roles_mapping.yml @@ -0,0 +1,64 @@ +# OpenSearch Security - Roles Mapping (SaaS Model) +# +# DYNAMIC ROLE MAPPING: +# Customer role mappings are created dynamically when users are provisioned. +# Each customer user is mapped to their customer-specific role. +# +# Example dynamic mapping creation (via backend): +# PUT /_plugins/_security/api/rolesmapping/customer_{customer_id}_rw +# { +# "users": ["user@customer.com"], +# "backend_roles": ["customer_{customer_id}"] +# } +# +# The backend should: +# 1. Create customer tenant when customer onboards +# 2. Create customer role (customer_{id}_rw or customer_{id}_ro) +# 3. Map user to customer role when user is added + +--- +_meta: + type: "rolesmapping" + config_version: 2 + +# ============================================================================= +# SYSTEM ROLE MAPPINGS +# ============================================================================= + +# Platform admin mapping - internal operators only +platform_admin: + reserved: true + users: + - "admin" + backend_roles: + - "platform_admin" + description: "Platform administrators with full system access" + +# Dashboards server mapping +kibana_server: + reserved: true + users: + - "kibanaserver" + description: "OpenSearch Dashboards server user" + +# Security REST API access - for admin operations +security_rest_api_access: + reserved: true + users: + - "admin" + backend_roles: + - "platform_admin" + description: "Access to Security REST API for tenant/role management" + +# ============================================================================= +# CUSTOMER ROLE MAPPINGS +# Note: Customer-specific mappings are created dynamically by the backend +# when customers and users are provisioned. +# +# Pattern for dynamic mappings: +# Role: customer_{customer_id}_rw +# Users: [list of customer's users with write access] +# +# Role: customer_{customer_id}_ro +# Users: [list of customer's users with read-only access] +# ============================================================================= diff --git a/docker/opensearch-security/tenants.yml b/docker/opensearch-security/tenants.yml new file mode 100644 index 00000000..ae9d0393 --- /dev/null +++ b/docker/opensearch-security/tenants.yml @@ -0,0 +1,28 @@ +# OpenSearch Security - Tenants Configuration (SaaS Model) +# +# TENANT ISOLATION STRATEGY: +# Each customer gets their own isolated tenant and index pattern. +# No shared/global dashboards - sharing is explicitly opt-in. +# +# Tenants are created dynamically via the Security REST API when +# a new customer is onboarded. Tenant name = customer ID. +# +# Index naming convention: {customer_id}-analytics-* +# Each customer's role restricts access to only their indices. +# +# Example dynamic tenant creation (via backend): +# POST /_plugins/_security/api/tenants/{customer_id} +# { "description": "Tenant for customer {customer_id}" } + +--- +_meta: + type: "tenants" + config_version: 2 + +# NOTE: Customer tenants are created dynamically by the application backend +# when customers are onboarded. This file only contains system tenants. + +# Admin tenant - for platform operators only (not customers) +__platform_admin: + reserved: true + description: "Platform administration - internal use only" diff --git a/docker/scripts/generate-certs.sh b/docker/scripts/generate-certs.sh new file mode 100755 index 00000000..4a04c2fd --- /dev/null +++ b/docker/scripts/generate-certs.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Generate TLS certificates for OpenSearch production deployment +# +# This script creates: +# - Root CA certificate and key +# - Node certificate for OpenSearch (server) +# - Admin certificate for cluster management +# +# Usage: ./generate-certs.sh [output-dir] +# +# Requirements: openssl + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIR="${1:-$SCRIPT_DIR/../certs}" +DAYS_VALID=365 + +# Certificate Subject fields +COUNTRY="US" +STATE="CA" +LOCALITY="SF" +ORGANIZATION="ShipSecAI" +ORG_UNIT="ShipSec" + +echo "=== OpenSearch Certificate Generator ===" +echo "Output directory: $OUTPUT_DIR" +echo "" + +# Create output directory +mkdir -p "$OUTPUT_DIR" +cd "$OUTPUT_DIR" + +# Check if certificates already exist +if [[ -f "root-ca.pem" ]]; then + echo "WARNING: Certificates already exist in $OUTPUT_DIR" + read -p "Overwrite existing certificates? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +fi + +echo "1. Generating Root CA..." +openssl genrsa -out root-ca-key.pem 2048 +openssl req -new -x509 -sha256 -key root-ca-key.pem -out root-ca.pem -days $DAYS_VALID \ + -subj "/C=$COUNTRY/ST=$STATE/L=$LOCALITY/O=$ORGANIZATION/OU=$ORG_UNIT/CN=Root CA" + +echo "2. Generating Admin Certificate..." +openssl genrsa -out admin-key-temp.pem 2048 +openssl pkcs8 -inform PEM -outform PEM -in admin-key-temp.pem -topk8 -nocrypt -out admin-key.pem +openssl req -new -key admin-key.pem -out admin.csr \ + -subj "/C=$COUNTRY/ST=$STATE/L=$LOCALITY/O=$ORGANIZATION/OU=$ORG_UNIT/CN=admin" +openssl x509 -req -in admin.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \ + -sha256 -out admin.pem -days $DAYS_VALID +rm admin-key-temp.pem admin.csr + +echo "3. Generating Node Certificate..." +# Create extension file for SAN (Subject Alternative Names) +cat > node-ext.cnf << EOF +subjectAltName = DNS:localhost, DNS:opensearch, DNS:opensearch-node1, IP:127.0.0.1 +EOF + +openssl genrsa -out node-key-temp.pem 2048 +openssl pkcs8 -inform PEM -outform PEM -in node-key-temp.pem -topk8 -nocrypt -out node-key.pem +openssl req -new -key node-key.pem -out node.csr \ + -subj "/C=$COUNTRY/ST=$STATE/L=$LOCALITY/O=$ORGANIZATION/OU=$ORG_UNIT/CN=opensearch-node1" +openssl x509 -req -in node.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \ + -sha256 -out node.pem -days $DAYS_VALID -extfile node-ext.cnf +rm node-key-temp.pem node.csr node-ext.cnf + +echo "4. Setting permissions..." +chmod 600 *-key.pem +chmod 644 *.pem + +echo "" +echo "=== Certificates Generated Successfully ===" +echo "" +echo "Files created in $OUTPUT_DIR:" +ls -la "$OUTPUT_DIR" +echo "" +echo "Next steps:" +echo " 1. Review the certificates" +echo " 2. Set OPENSEARCH_ADMIN_PASSWORD and OPENSEARCH_DASHBOARDS_PASSWORD environment variables" +echo " 3. Run: docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml up -d" +echo "" +echo "For production deployments:" +echo " - Use proper certificate authority (e.g., Let's Encrypt, internal CA)" +echo " - Store private keys securely (e.g., HashiCorp Vault, AWS Secrets Manager)" +echo " - Rotate certificates before expiration ($DAYS_VALID days)" diff --git a/justfile b/justfile index 145cf510..7c9272bf 100644 --- a/justfile +++ b/justfile @@ -337,8 +337,9 @@ prod action="start": docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d echo "" echo "✅ Production environment ready" - echo " Frontend: http://localhost:8090" - echo " Backend: http://localhost:3211" + echo " App: http://localhost" + echo " API: http://localhost/api" + echo " Analytics: http://localhost/analytics" echo " Temporal UI: http://localhost:8081" echo "" @@ -367,8 +368,9 @@ prod action="start": [ -f "docker/.env" ] && ENV_FLAG="--env-file docker/.env" docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d --build echo "✅ Production built and started" - echo " Frontend: http://localhost:8090" - echo " Backend: http://localhost:3211" + echo " App: http://localhost" + echo " API: http://localhost/api" + echo " Analytics: http://localhost/analytics" echo "" # Version check @@ -424,8 +426,9 @@ prod action="start": echo "" echo "✅ ShipSec Studio $LATEST_TAG ready" - echo " Frontend: http://localhost:8090" - echo " Backend: http://localhost:3211" + echo " App: http://localhost" + echo " API: http://localhost/api" + echo " Analytics: http://localhost/analytics" echo " Temporal UI: http://localhost:8081" echo "" echo "💡 Note: Using images tagged as $LATEST_TAG" @@ -477,8 +480,9 @@ prod-images action="start": DOCKER_BUILDKIT=1 docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d echo "" echo "✅ Production environment ready" - echo " Frontend: http://localhost:8090" - echo " Backend: http://localhost:3211" + echo " App: http://localhost" + echo " API: http://localhost/api" + echo " Analytics: http://localhost/analytics" echo " Temporal UI: http://localhost:8081" ;; stop) @@ -533,6 +537,75 @@ prod-images action="start": ;; esac +# === Production Secure (with Security & Multitenancy) === + +# Run production with OpenSearch security and SaaS multitenancy +prod-secure action="start": + #!/usr/bin/env bash + set -euo pipefail + case "{{action}}" in + start) + echo "🔐 Starting secure production environment..." + + # Check for certificates + if [ ! -f "docker/certs/root-ca.pem" ]; then + echo "❌ TLS certificates not found!" + echo "" + echo " Run: just generate-certs" + exit 1 + fi + + # Check for required env vars + if [ -z "${OPENSEARCH_ADMIN_PASSWORD:-}" ] || [ -z "${OPENSEARCH_DASHBOARDS_PASSWORD:-}" ]; then + echo "❌ Required environment variables not set!" + echo "" + echo " export OPENSEARCH_ADMIN_PASSWORD='your-secure-password'" + echo " export OPENSEARCH_DASHBOARDS_PASSWORD='your-secure-password'" + exit 1 + fi + + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml up -d + echo "" + echo "✅ Secure production environment ready" + echo " Analytics: https://localhost/analytics (requires auth)" + echo " OpenSearch: https://localhost:9200 (TLS enabled)" + echo "" + echo "💡 See docker/PRODUCTION.md for customer provisioning" + ;; + stop) + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml down + echo "✅ Secure production stopped" + ;; + logs) + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml logs -f + ;; + status) + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml ps + ;; + clean) + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml down -v + echo "✅ Secure production cleaned" + ;; + *) + echo "Usage: just prod-secure [start|stop|logs|status|clean]" + ;; + esac + +# Generate TLS certificates for production +generate-certs: + #!/usr/bin/env bash + set -euo pipefail + echo "🔐 Generating TLS certificates..." + chmod +x docker/scripts/generate-certs.sh + docker/scripts/generate-certs.sh + echo "" + echo "✅ Certificates generated in docker/certs/" + echo "" + echo "Next steps:" + echo " 1. export OPENSEARCH_ADMIN_PASSWORD='your-secure-password'" + echo " 2. export OPENSEARCH_DASHBOARDS_PASSWORD='your-secure-password'" + echo " 3. just prod-secure" + # === Infrastructure Only === # Manage infrastructure containers separately @@ -635,6 +708,13 @@ help: @echo " just prod clean Remove all data" @echo " just prod-images Start with GHCR images (uses cache)" @echo "" + @echo "Production Secure (SaaS with multitenancy):" + @echo " just generate-certs Generate TLS certificates" + @echo " just prod-secure Start with security & multitenancy" + @echo " just prod-secure stop Stop secure production" + @echo " just prod-secure logs View logs" + @echo " just prod-secure clean Remove all data" + @echo "" @echo "Infrastructure:" @echo " just infra up Start infrastructure only" @echo " just infra down Stop infrastructure" diff --git a/pm2.config.cjs b/pm2.config.cjs index 71452c39..235219c1 100644 --- a/pm2.config.cjs +++ b/pm2.config.cjs @@ -196,6 +196,41 @@ function loadFrontendEnv() { const frontendEnv = loadFrontendEnv(); +// Load worker .env file for OpenSearch and other worker-specific variables +function loadWorkerEnv() { + const envPath = path.join(__dirname, 'worker', '.env'); + const env = {}; + + try { + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + envContent.split('\n').forEach((line) => { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) { + return; + } + const match = trimmed.match(/^([^=]+)=(.*)$/); + if (match) { + const key = match[1].trim(); + let value = match[2].trim(); + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + }); + } + } catch (err) { + console.warn('Failed to load worker .env file:', err.message); + } + + return env; +} + +const workerEnv = loadWorkerEnv(); + // Determine environment from NODE_ENV or SHIPSEC_ENV const environment = process.env.SHIPSEC_ENV || process.env.NODE_ENV || 'development'; const isProduction = environment === 'production'; @@ -305,6 +340,7 @@ module.exports = { env_file: resolveEnvFile('worker', instanceNum), env: Object.assign( { + ...workerEnv, // Load worker .env file (includes OPENSEARCH_URL, etc.) ...currentEnvConfig, NAPI_RS_FORCE_WASI: '1', INTERNAL_SERVICE_TOKEN: process.env.INTERNAL_SERVICE_TOKEN || 'local-internal-token', @@ -335,6 +371,7 @@ module.exports = { env_file: __dirname + '/worker/.env', env: Object.assign( { + ...workerEnv, // Load worker .env file (includes OPENSEARCH_URL, etc.) TEMPORAL_TASK_QUEUE: 'test-worker-integration', TEMPORAL_NAMESPACE: 'shipsec-dev', NODE_ENV: 'development', From 912f1020da5e350da2a1d42e3c9f408d3e8a8c93 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Mon, 26 Jan 2026 23:28:06 -0500 Subject: [PATCH 02/13] feat(workflows): add STALE status and workflow improvements - Add STALE status for orphaned run records (DB/Temporal mismatch) - Improve status inference from trace events when Temporal not found - Use correct TraceEventType values for status detection - Add amber badge color for STALE status - Extract WorkflowNode into modular directory structure - Document all execution statuses with transition diagram Signed-off-by: Aseem Shrey --- backend/src/workflows/workflows.service.ts | 154 ++++++++++++++++----- docs/workflows/execution-status.md | 101 ++++++++++++++ frontend/src/utils/statusBadgeStyles.ts | 1 + packages/shared/src/execution.ts | 18 ++- 4 files changed, 241 insertions(+), 33 deletions(-) create mode 100644 docs/workflows/execution-status.md diff --git a/backend/src/workflows/workflows.service.ts b/backend/src/workflows/workflows.service.ts index 39768c82..bf44fe5d 100644 --- a/backend/src/workflows/workflows.service.ts +++ b/backend/src/workflows/workflows.service.ts @@ -8,6 +8,7 @@ import { BadRequestException, } from '@nestjs/common'; import { status as grpcStatus, type ServiceError } from '@grpc/grpc-js'; +import { WorkflowNotFoundError } from '@temporalio/client'; import { z } from 'zod'; import { compileWorkflowGraph } from '../dsl/compiler'; @@ -562,11 +563,12 @@ export class WorkflowsService { const graph = (version?.graph ?? workflow?.graph) as { nodes?: unknown[] } | undefined; const nodeCount = graph?.nodes && Array.isArray(graph.nodes) ? graph.nodes.length : 0; - const eventCount = await this.traceRepository.countByType( - run.runId, - 'NODE_STARTED', - organizationId, - ); + // Get trace event counts for status inference + const [startedActions, completedActions, failedActions] = await Promise.all([ + this.traceRepository.countByType(run.runId, 'NODE_STARTED', organizationId), + this.traceRepository.countByType(run.runId, 'NODE_COMPLETED', organizationId), + this.traceRepository.countByType(run.runId, 'NODE_FAILED', organizationId), + ]); // Calculate duration from events (more accurate than createdAt/updatedAt) const eventTimeRange = await this.traceRepository.getEventTimeRange(run.runId, organizationId); @@ -583,22 +585,19 @@ export class WorkflowsService { }); currentStatus = this.normalizeStatus(status.status); } catch (error) { - // If Temporal can't find the workflow (NOT_FOUND), check if events have stopped - // If events stopped more than 5 minutes ago, assume the workflow completed - const isNotFound = this.isNotFoundError(error); - if (isNotFound && eventTimeRange.lastTimestamp) { - const lastEventTime = new Date(eventTimeRange.lastTimestamp); - const minutesSinceLastEvent = (Date.now() - lastEventTime.getTime()) / (1000 * 60); - if (minutesSinceLastEvent > 5) { - // Events stopped more than 5 minutes ago and Temporal can't find it - // Assume the workflow completed successfully - currentStatus = 'COMPLETED'; - this.logger.log( - `Run ${run.runId} not found in Temporal but last event was ${minutesSinceLastEvent.toFixed(1)} minutes ago, assuming COMPLETED`, - ); - } else { - this.logger.warn(`Failed to get status for run ${run.runId}: ${error}`); - } + // If Temporal can't find the workflow, infer status from trace events + if (this.isNotFoundError(error)) { + currentStatus = this.inferStatusFromTraceEvents({ + runId: run.runId, + totalActions: run.totalActions ?? nodeCount, + completedActions, + failedActions, + startedActions, + }); + this.logger.log( + `Run ${run.runId} not found in Temporal, inferred status: ${currentStatus} ` + + `(started=${startedActions}, completed=${completedActions}, failed=${failedActions})`, + ); } else { this.logger.warn(`Failed to get status for run ${run.runId}: ${error}`); } @@ -622,7 +621,7 @@ export class WorkflowsService { endTime: run.updatedAt ?? null, temporalRunId: run.temporalRunId ?? undefined, workflowName, - eventCount, + eventCount: startedActions, nodeCount, duration, triggerType, @@ -1068,19 +1067,59 @@ export class WorkflowsService { this.logger.log( `Fetching status for workflow run ${runId} (temporalRunId=${temporalRunId ?? 'latest'})`, ); - const temporalStatus = await this.temporalService.describeWorkflow({ - workflowId: runId, - runId: temporalRunId, - }); const { organizationId, run } = await this.requireRunAccess(runId, auth); + let temporalStatus: Awaited>; let completedActions = 0; + let failedActions = 0; + let startedActions = 0; + + // Pre-fetch trace event counts for status inference if (run.totalActions && run.totalActions > 0) { - completedActions = await this.traceRepository.countByType( - runId, - 'NODE_COMPLETED', - organizationId, - ); + [completedActions, failedActions, startedActions] = await Promise.all([ + this.traceRepository.countByType(runId, 'NODE_COMPLETED', organizationId), + this.traceRepository.countByType(runId, 'NODE_FAILED', organizationId), + this.traceRepository.countByType(runId, 'NODE_STARTED', organizationId), + ]); + } + + try { + temporalStatus = await this.temporalService.describeWorkflow({ + workflowId: runId, + runId: temporalRunId, + }); + } catch (error) { + // If Temporal can't find the workflow, infer status from trace events + if (this.isNotFoundError(error)) { + const inferredStatus = this.inferStatusFromTraceEvents({ + runId, + totalActions: run.totalActions ?? 0, + completedActions, + failedActions, + startedActions, + }); + + this.logger.log( + `Workflow ${runId} not found in Temporal, inferred status: ${inferredStatus} ` + + `(started=${startedActions}, completed=${completedActions}, failed=${failedActions}, total=${run.totalActions})`, + ); + + temporalStatus = { + workflowId: runId, + runId: temporalRunId ?? runId, + // Cast to WorkflowExecutionStatusName - normalizeStatus handles mapping + status: inferredStatus as unknown as typeof temporalStatus.status, + startTime: run.createdAt.toISOString(), + // Only set closeTime for terminal states that actually ran + closeTime: ['COMPLETED', 'FAILED'].includes(inferredStatus) + ? new Date().toISOString() + : undefined, + historyLength: 0, + taskQueue: '', + }; + } else { + throw error; + } } const statusPayload = this.mapTemporalStatus(runId, temporalStatus, run, completedActions); @@ -1535,15 +1574,66 @@ export class WorkflowsService { } } - private isNotFoundError(error: unknown): error is ServiceError { + private isNotFoundError(error: unknown): boolean { if (!error || typeof error !== 'object') { return false; } + // Check for Temporal WorkflowNotFoundError + if (error instanceof WorkflowNotFoundError) { + return true; + } + + // Check for gRPC NOT_FOUND error const serviceError = error as ServiceError; return serviceError.code === grpcStatus.NOT_FOUND; } + /** + * Infer workflow status from trace events when Temporal workflow is not found. + * + * Cases: + * - No started events → STALE (orphaned record - run exists but never executed) + * - All nodes completed → COMPLETED + * - Any node failed → FAILED + * - Partial completion (some started, not all finished) → FAILED (crashed/lost) + */ + private inferStatusFromTraceEvents(params: { + runId: string; + totalActions: number; + completedActions: number; + failedActions: number; + startedActions: number; + }): ExecutionStatus { + const { totalActions, completedActions, failedActions, startedActions } = params; + + // Case 1: No events at all - orphaned record (DB/Temporal mismatch) + // This indicates data inconsistency - run record exists but workflow never executed + if (startedActions === 0) { + return 'STALE'; + } + + // Case 2: Any node failed explicitly + if (failedActions > 0) { + return 'FAILED'; + } + + // Case 3: All nodes completed successfully + if (totalActions > 0 && completedActions >= totalActions) { + return 'COMPLETED'; + } + + // Case 4: Some nodes started but not all completed and no failures + // This means the workflow crashed or was lost - treat as FAILED + if (startedActions > 0 && completedActions < totalActions) { + return 'FAILED'; + } + + // Fallback: we have events but can't determine status + // This shouldn't happen normally, but default to FAILED for safety + return 'FAILED'; + } + private buildFailure(status: ExecutionStatus, failure?: unknown): FailureSummary | undefined { if (!['FAILED', 'TERMINATED', 'TIMED_OUT'].includes(status)) { return undefined; diff --git a/docs/workflows/execution-status.md b/docs/workflows/execution-status.md new file mode 100644 index 00000000..1ac61aba --- /dev/null +++ b/docs/workflows/execution-status.md @@ -0,0 +1,101 @@ +# Workflow Execution Status + +This document describes the different execution statuses a workflow run can have and when each status applies. + +## Status Overview + +| Status | Color | Description | +|--------|-------|-------------| +| `QUEUED` | Blue | Workflow is waiting to be executed | +| `RUNNING` | Blue | Workflow is actively executing | +| `COMPLETED` | Green | Workflow finished successfully - all nodes completed | +| `FAILED` | Red | Workflow failed - at least one node failed or workflow crashed | +| `CANCELLED` | Gray | Workflow was cancelled by user | +| `TERMINATED` | Gray | Workflow was forcefully terminated | +| `TIMED_OUT` | Amber | Workflow exceeded maximum execution time | +| `AWAITING_INPUT` | Purple | Workflow is paused waiting for human input | +| `STALE` | Amber | Orphaned record - data inconsistency (see below) | + +## Status Transitions + +``` +QUEUED → RUNNING → COMPLETED + → FAILED + → CANCELLED + → TERMINATED + → TIMED_OUT + → AWAITING_INPUT → RUNNING (when input provided) +``` + +## Detailed Status Descriptions + +### QUEUED +The workflow run has been created and is waiting to start execution. This is the initial state before the Temporal worker picks up the workflow. + +### RUNNING +The workflow is actively executing. At least one node has started processing. + +### COMPLETED +All nodes in the workflow have finished successfully. This is a terminal state. + +**Conditions:** +- All expected nodes have `COMPLETED` trace events +- No `FAILED` trace events + +### FAILED +The workflow encountered an error during execution. This is a terminal state. + +**Conditions:** +- At least one node has a `FAILED` trace event, OR +- Some nodes started but not all completed (workflow crashed/lost) + +### CANCELLED +The user manually cancelled the workflow execution. This is a terminal state. + +### TERMINATED +The workflow was forcefully terminated (e.g., via Temporal API). This is a terminal state. + +### TIMED_OUT +The workflow exceeded its maximum allowed execution time. This is a terminal state. + +### AWAITING_INPUT +The workflow has reached a human input node and is waiting for user interaction. The workflow will resume to `RUNNING` when input is provided. + +### STALE +**Special Status - Data Inconsistency Warning** + +The run record exists in the database but there's no evidence it ever executed: +- No trace events in the database +- Temporal has no record of this workflow + +**Common Causes:** +1. **Fresh Temporal instance with old database** - The Temporal server was reset/reinstalled but the application database retained old run records +2. **Failed workflow start** - The backend created a run record but the Temporal workflow failed to start (network error, Temporal unavailable, etc.) +3. **Data migration issues** - Database was migrated without corresponding Temporal data + +**Recommended Action:** +- Review these records and delete them if they represent stale data +- Investigate why the data inconsistency occurred to prevent future occurrences + +## Status Determination Logic + +When querying run status, the system follows this logic: + +1. **Query Temporal** - Get the workflow status from Temporal server +2. **If Temporal returns status** - Use the normalized Temporal status +3. **If Temporal returns NOT_FOUND** - Infer status from trace events: + - No `STARTED` events → `STALE` (orphaned record) + - Any `FAILED` events → `FAILED` + - All nodes have `COMPLETED` events → `COMPLETED` + - Some `STARTED` but incomplete → `FAILED` (crashed) + +## Frontend Badge Colors + +Status badges use these colors for visual distinction: + +- **Blue** (active): `QUEUED`, `RUNNING` +- **Green** (success): `COMPLETED` +- **Red** (error): `FAILED` +- **Amber** (warning): `TIMED_OUT`, `STALE` +- **Gray** (neutral): `CANCELLED`, `TERMINATED` +- **Purple** (attention): `AWAITING_INPUT` diff --git a/frontend/src/utils/statusBadgeStyles.ts b/frontend/src/utils/statusBadgeStyles.ts index ada7699a..6036f4c7 100644 --- a/frontend/src/utils/statusBadgeStyles.ts +++ b/frontend/src/utils/statusBadgeStyles.ts @@ -17,6 +17,7 @@ export const STATUS_COLOR_MAP: Record = { TERMINATED: 'gray', TIMED_OUT: 'amber', AWAITING_INPUT: 'purple', + STALE: 'amber', // Orphaned record - data inconsistency warning }; /** diff --git a/packages/shared/src/execution.ts b/packages/shared/src/execution.ts index 59b7e7ff..83b8e6b9 100644 --- a/packages/shared/src/execution.ts +++ b/packages/shared/src/execution.ts @@ -1,5 +1,20 @@ import { z } from 'zod'; +/** + * Workflow execution status values. + * + * @see docs/workflows/execution-status.md for detailed documentation + * + * - QUEUED: Waiting to execute + * - RUNNING: Actively executing + * - COMPLETED: All nodes finished successfully + * - FAILED: Execution failed (node failure or crash) + * - CANCELLED: User cancelled + * - TERMINATED: Forcefully terminated + * - TIMED_OUT: Exceeded max execution time + * - AWAITING_INPUT: Paused for human input + * - STALE: Orphaned record (data inconsistency) + */ export const EXECUTION_STATUS = [ 'QUEUED', 'RUNNING', @@ -8,7 +23,8 @@ export const EXECUTION_STATUS = [ 'CANCELLED', 'TERMINATED', 'TIMED_OUT', - 'AWAITING_INPUT' + 'AWAITING_INPUT', + 'STALE', ] as const; export type ExecutionStatus = (typeof EXECUTION_STATUS)[number]; From 663d71d98daef784e7ad14a6f0407b045303468d Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Mon, 26 Jan 2026 23:28:21 -0500 Subject: [PATCH 03/13] feat(analytics): add Security Analytics platform with OpenSearch integration Analytics Sink Component (core.analytics.sink): - Index output data from any upstream node to OpenSearch - Auto-detect asset correlation keys (host, domain, url, ip, etc.) - Fire-and-forget with retry logic (3 attempts, exponential backoff) - Configurable index suffix and fail-on-error modes OpenSearch Integration: - Daily index rotation: security-findings-{orgId}-{YYYY.MM.DD} - Index template with standard metadata fields - Multi-tenant data isolation per organization Analytics API: - POST /api/v1/analytics/query with OpenSearch DSL support - Auto-scope queries to organization's index pattern - Rate limiting: 100 req/min per user - Protected routes require authentication - Session cookie support for analytics route auth UI Integration: - Analytics Settings page with tier-based retention - Dashboards link in sidebar (opens in new tab) - View Analytics button uses Discover app with proper URL state - Uses .keyword fields for exact match filtering Component SDK Extensions: - generateFindingHash() for deduplication - Workflow context (workflowId, workflowName, organizationId) - Results output port on nuclei, trufflehog, supabase-scanner - Support for optional inputs in components Bug fixes: - Fix webhook URLs to include global API prefix (ENG-115) - Add proper connectionType for list variable types - Handle invalid_value errors for placeholder fields Signed-off-by: Aseem Shrey --- .ai/analytics-output-port-design.md | 190 ++++++++ backend/.env.example | 21 +- backend/package.json | 7 +- backend/scripts/setup-opensearch.ts | 93 ++++ backend/src/analytics/analytics.controller.ts | 223 +++++++++ backend/src/analytics/analytics.module.ts | 8 +- .../src/analytics/dto/analytics-query.dto.ts | 66 +++ .../analytics/dto/analytics-settings.dto.ts | 75 +++ .../organization-settings.service.ts | 94 ++++ .../analytics/security-analytics.service.ts | 239 +++++++++ backend/src/app.controller.ts | 110 ++++- backend/src/app.module.ts | 28 +- .../src/auth/providers/local-auth.provider.ts | 28 +- backend/src/auth/session.utils.ts | 66 +++ backend/src/config/opensearch.client.ts | 53 ++ backend/src/config/opensearch.config.ts | 13 + backend/src/config/opensearch.module.ts | 9 + backend/src/database/migration.guard.ts | 1 + backend/src/database/schema/index.ts | 1 + .../database/schema/organization-settings.ts | 17 + backend/src/dsl/validator.ts | 4 + backend/src/main.ts | 8 + docker/docker-compose.full.yml | 1 + docs/analytics.md | 218 +++++++++ docs/components/core.mdx | 41 ++ docs/development/analytics.mdx | 2 +- docs/development/component-development.mdx | 159 ++++++ docs/development/workflow-analytics.mdx | 373 ++++++++++++++ docs/docs.json | 1 + docs/installation.mdx | 62 +++ e2e-tests/analytics.test.ts | 274 +++++++++++ frontend/.env.example | 9 +- frontend/src/App.tsx | 2 + frontend/src/auth/AuthProvider.tsx | 12 +- .../src/components/auth/AdminLoginForm.tsx | 37 +- frontend/src/components/layout/AppLayout.tsx | 60 +++ frontend/src/components/layout/AppTopBar.tsx | 8 + frontend/src/components/layout/TopBar.tsx | 43 ++ .../workflow/AnalyticsInputsEditor.tsx | 195 ++++++++ frontend/src/config/env.ts | 3 + .../workflow-builder/WorkflowBuilder.tsx | 1 + frontend/src/pages/AnalyticsSettingsPage.tsx | 258 ++++++++++ frontend/vite.config.ts | 16 +- packages/component-sdk/src/analytics.ts | 66 +++ packages/component-sdk/src/context.ts | 8 +- packages/component-sdk/src/index.ts | 3 + packages/component-sdk/src/types.ts | 8 +- worker/.env.example | 11 + worker/package.json | 1 + worker/src/components/core/analytics-sink.ts | 378 +++++++++++++++ worker/src/components/index.ts | 2 + .../security/__tests__/dnsx.test.ts | 8 +- .../security/__tests__/httpx.test.ts | 3 +- .../security/__tests__/nuclei.test.ts | 3 + .../security/__tests__/subfinder.test.ts | 8 +- .../security/__tests__/trufflehog.test.ts | 38 ++ worker/src/components/security/abuseipdb.ts | 45 +- worker/src/components/security/amass.ts | 31 +- worker/src/components/security/dnsx.ts | 80 ++- worker/src/components/security/httpx.ts | 32 +- worker/src/components/security/naabu.ts | 23 + worker/src/components/security/nuclei.ts | 17 + .../src/components/security/prowler-scan.ts | 53 ++ .../components/security/shuffledns-massdns.ts | 37 +- worker/src/components/security/subfinder.ts | 20 + .../components/security/supabase-scanner.ts | 62 ++- worker/src/components/security/trufflehog.ts | 28 +- worker/src/components/security/virustotal.ts | 41 ++ .../test/__tests__/analytics-fixture.test.ts | 36 ++ .../src/components/test/analytics-fixture.ts | 79 +++ .../__tests__/optional-input-handling.test.ts | 279 +++++++++++ .../activities/run-component.activity.ts | 43 +- worker/src/temporal/types.ts | 1 + worker/src/temporal/workflow-runner.ts | 3 + worker/src/temporal/workflows/index.ts | 1 + worker/src/utils/opensearch-indexer.ts | 459 ++++++++++++++++++ 76 files changed, 4975 insertions(+), 61 deletions(-) create mode 100644 .ai/analytics-output-port-design.md create mode 100644 backend/scripts/setup-opensearch.ts create mode 100644 backend/src/analytics/analytics.controller.ts create mode 100644 backend/src/analytics/dto/analytics-query.dto.ts create mode 100644 backend/src/analytics/dto/analytics-settings.dto.ts create mode 100644 backend/src/analytics/organization-settings.service.ts create mode 100644 backend/src/analytics/security-analytics.service.ts create mode 100644 backend/src/auth/session.utils.ts create mode 100644 backend/src/config/opensearch.client.ts create mode 100644 backend/src/config/opensearch.config.ts create mode 100644 backend/src/config/opensearch.module.ts create mode 100644 backend/src/database/schema/organization-settings.ts create mode 100644 docs/analytics.md create mode 100644 docs/development/workflow-analytics.mdx create mode 100644 e2e-tests/analytics.test.ts create mode 100644 frontend/src/components/workflow/AnalyticsInputsEditor.tsx create mode 100644 frontend/src/pages/AnalyticsSettingsPage.tsx create mode 100644 packages/component-sdk/src/analytics.ts create mode 100644 worker/src/components/core/analytics-sink.ts create mode 100644 worker/src/components/test/__tests__/analytics-fixture.test.ts create mode 100644 worker/src/components/test/analytics-fixture.ts create mode 100644 worker/src/temporal/__tests__/optional-input-handling.test.ts create mode 100644 worker/src/utils/opensearch-indexer.ts diff --git a/.ai/analytics-output-port-design.md b/.ai/analytics-output-port-design.md new file mode 100644 index 00000000..41ce63cc --- /dev/null +++ b/.ai/analytics-output-port-design.md @@ -0,0 +1,190 @@ +# Analytics Output Port Design + +## Status: Approved +## Date: 2025-01-21 + +## Problem Statement + +When connecting a component's `rawOutput` (which contains complex nested JSON) to the Analytics Sink, OpenSearch hits the default field limit of 1000 fields. This is because: + +1. **Dynamic mapping explosion**: Elasticsearch/OpenSearch creates a field for every unique JSON path +2. **Nested structures**: Arrays with objects like `issues[0].metadata.schema` create many paths +3. **Varying schemas**: Different scanner outputs accumulate unique field paths over time + +Example error: +``` +illegal_argument_exception: Limit of total fields [1000] has been exceeded +``` + +## Solution + +### Design Decisions + +1. **Each component owns its analytics schema** + - Components output structured `list` through dedicated ports (`findings`, `results`, `secrets`, `issues`) + - Component authors define the structure appropriate for their tool + - No generic "one schema fits all" approach + +2. **Analytics Sink accepts `list`** + - Input type: `z.array(z.record(z.string(), z.unknown()))` + - Each item in the array is indexed as a separate document + - Rejects arbitrary nested objects (must be an array) + +3. **Same timestamp for all findings in a batch** + - All findings from one component execution share the same `@timestamp` + - Captured once at the start of indexing, applied to all documents + +4. **Nested `shipsec` context** + - Workflow context stored under `shipsec.*` namespace + - Prevents field name collision with component data + - Clear separation: component fields at root, system fields under `shipsec` + +5. **Nested objects serialized before indexing** + - Any nested object or array within a finding is JSON-stringified + - Prevents field explosion from dynamic mapping + - Trade-off: Can't query inside serialized fields directly, but prevents index corruption + +6. **No `data` wrapper** + - Original PRD design wrapped component output in a `data` field + - New design: finding fields are at the top level for easier querying + +### Document Structure + +**Before (PRD design):** +```json +{ + "workflow_id": "...", + "workflow_name": "...", + "run_id": "...", + "node_ref": "...", + "component_id": "...", + "@timestamp": "...", + "asset_key": "...", + "data": { + "check_id": "DB_RLS_DISABLED", + "severity": "CRITICAL", + "metadata": { "schema": "public", "table": "users" } + } +} +``` + +**After (new design):** +```json +{ + "check_id": "DB_RLS_DISABLED", + "severity": "CRITICAL", + "title": "RLS Disabled on Table: users", + "resource": "public.users", + "metadata": "{\"schema\":\"public\",\"table\":\"users\"}", + "scanner": "supabase-scanner", + "asset_key": "abcdefghij1234567890", + "finding_hash": "a1b2c3d4e5f67890", + + "shipsec": { + "organization_id": "org_123", + "run_id": "shipsec-run-xxx", + "workflow_id": "d1d33161-929f-4af4-9a64-xxx", + "workflow_name": "Supabase Security Audit", + "component_id": "core.analytics.sink", + "node_ref": "analytics-sink-1" + }, + + "@timestamp": "2025-01-21T10:30:00.000Z" +} +``` + +### Component Output Ports + +Components should use their existing structured list outputs: + +| Component | Port | Type | Notes | +|-----------|------|------|-------| +| Nuclei | `results` | `z.array(z.record(z.string(), z.unknown()))` | Scanner + asset_key added | +| TruffleHog | `results` | `z.array(z.record(z.string(), z.unknown()))` | Scanner + asset_key added | +| Supabase Scanner | `results` | `z.array(z.record(z.string(), z.unknown()))` | Scanner + asset_key added | + +All `results` ports include: +- `scanner`: Scanner identifier (e.g., `'nuclei'`, `'trufflehog'`, `'supabase-scanner'`) +- `asset_key`: Primary asset identifier from the finding +- `finding_hash`: Stable hash for deduplication (16-char hex from SHA-256) + +### Finding Hash for Deduplication + +The `finding_hash` enables tracking findings across workflow runs: + +**Generation:** +```typescript +import { createHash } from 'crypto'; + +function generateFindingHash(...fields: (string | undefined | null)[]): string { + const normalized = fields.map((f) => (f ?? '').toLowerCase().trim()).join('|'); + return createHash('sha256').update(normalized).digest('hex').slice(0, 16); +} +``` + +**Key fields per scanner:** +| Scanner | Hash Fields | +|---------|-------------| +| Nuclei | `templateId + host + matchedAt` | +| TruffleHog | `DetectorType + Redacted + filePath` | +| Supabase Scanner | `check_id + projectRef + resource` | + +**Use cases:** +- **New vs recurring**: Is this finding appearing for the first time? +- **First-seen / last-seen**: When did we first detect this? Is it still present? +- **Resolution tracking**: Findings that stop appearing may be resolved +- **Deduplication**: Remove duplicates in dashboards across runs + +### `shipsec` Context Fields + +The indexer automatically adds these fields under `shipsec`: + +| Field | Description | +|-------|-------------| +| `organization_id` | Organization that owns the workflow | +| `run_id` | Unique identifier for this workflow execution | +| `workflow_id` | ID of the workflow definition | +| `workflow_name` | Human-readable workflow name | +| `component_id` | Component type (e.g., `core.analytics.sink`) | +| `node_ref` | Node reference in the workflow graph | +| `asset_key` | Auto-detected or specified asset identifier | + +### Querying in OpenSearch + +With this structure, users can: +- Filter by organization: `shipsec.organization_id: "org_123"` +- Filter by workflow: `shipsec.workflow_id: "xxx"` +- Filter by run: `shipsec.run_id: "xxx"` +- Filter by asset: `asset_key: "api.example.com"` +- Filter by scanner: `scanner: "nuclei"` +- Filter by component-specific fields: `severity: "CRITICAL"` +- Aggregate by severity: `terms` aggregation on `severity` field +- Track finding history: `finding_hash: "a1b2c3d4" | sort @timestamp` +- Find recurring findings: Group by `finding_hash`, count occurrences + +### Trade-offs + +| Decision | Pro | Con | +|----------|-----|-----| +| Serialize nested objects | Prevents field explosion | Can't query inside serialized fields | +| `shipsec` namespace | No field collision | Slightly more verbose queries | +| No generic schema | Better fit per component | Less consistency across components | +| Same timestamp per batch | Accurate (same scan time) | Can't distinguish individual finding times | + +### Implementation Files + +1. `/worker/src/utils/opensearch-indexer.ts` - Add `shipsec` context, serialize nested objects +2. `/worker/src/components/core/analytics-sink.ts` - Accept `list`, consistent timestamp +3. Component files - Ensure structured output, add `results` port where missing + +### Backward Compatibility + +- Existing workflows connecting `rawOutput` to Analytics Sink will still work +- Analytics Sink continues to accept any data type for backward compatibility +- New `list` processing only triggers when input is an array + +### Future Considerations + +1. **Index templates**: Create OpenSearch index template with explicit mappings for `shipsec.*` fields +2. **Field discovery**: Build UI to show available fields from indexed data +3. **Schema validation**: Optional strict mode to validate findings against expected schema diff --git a/backend/.env.example b/backend/.env.example index 1964e7dc..fc68355d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,6 +32,8 @@ AUTH_PROVIDER="local" # If AUTH_LOCAL_ALLOW_UNAUTHENTICATED=false, clients must present AUTH_LOCAL_API_KEY in the Authorization header. AUTH_LOCAL_ALLOW_UNAUTHENTICATED="true" AUTH_LOCAL_API_KEY="" +# Required in production for session auth cookie signing +SESSION_SECRET="" # Clerk provider options # Required when AUTH_PROVIDER="clerk" @@ -44,15 +46,24 @@ PLATFORM_SERVICE_TOKEN="" # Optional: override request timeout in milliseconds (default 5000) PLATFORM_API_TIMEOUT_MS="" -# OpenSearch configuration -OPENSEARCH_URL="http://localhost:9200" -OPENSEARCH_INDEX_PREFIX="logs-tenant" -# OPENSEARCH_USERNAME="" -# OPENSEARCH_PASSWORD="" +# OpenSearch configuration for security analytics indexing +# Optional: if not set, security analytics indexing will be disabled +OPENSEARCH_URL="" +OPENSEARCH_USERNAME="" +OPENSEARCH_PASSWORD="" + +# OpenSearch Dashboards configuration for analytics visualization +# Optional: if not set, Dashboards link will not appear in frontend sidebar +# Example: "http://localhost:5601" or "https://dashboards.example.com" +OPENSEARCH_DASHBOARDS_URL="" # Secret encryption key (must be exactly 32 characters, NOT hex-encoded) # Generate with: openssl rand -base64 24 | head -c 32 SECRET_STORE_MASTER_KEY="CHANGE_ME_32_CHAR_SECRET_KEY!!!!" +# Redis configuration for rate limiting and caching +# Optional: if not set, rate limiting will use in-memory storage (not recommended for production) +REDIS_URL="" + # Kafka / Redpanda configuration for node I/O, log, and event ingestion LOG_KAFKA_BROKERS="localhost:19092" diff --git a/backend/package.json b/backend/package.json index b4064185..676958ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,18 +14,22 @@ "generate:openapi": "bun scripts/generate-openapi.ts", "migration:push": "bun x drizzle-kit push", "migration:smoke": "bun scripts/migration-smoke.ts", - "delete:runs": "bun scripts/delete-all-workflow-runs.ts" + "delete:runs": "bun scripts/delete-all-workflow-runs.ts", + "setup:opensearch": "bun scripts/setup-opensearch.ts" }, "dependencies": { "@clerk/backend": "^2.29.5", "@clerk/types": "^4.101.13", "@grpc/grpc-js": "^1.14.3", + "@nest-lab/throttler-storage-redis": "^1.1.0", "@nestjs/common": "^10.4.22", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.22", "@nestjs/microservices": "^11.1.13", "@nestjs/platform-express": "^10.4.22", "@nestjs/swagger": "^11.2.5", + "@nestjs/throttler": "^6.5.0", + "@opensearch-project/opensearch": "^3.5.1", "@shipsec/backend-client": "workspace:*", "@shipsec/component-sdk": "workspace:*", "@shipsec/shared": "workspace:*", @@ -62,6 +66,7 @@ "@eslint/js": "^9.39.2", "@nestjs/testing": "^10.4.22", "@types/bcryptjs": "^3.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express-serve-static-core": "^4.19.8", "@types/har-format": "^1.2.16", "@types/multer": "^2.0.0", diff --git a/backend/scripts/setup-opensearch.ts b/backend/scripts/setup-opensearch.ts new file mode 100644 index 00000000..bb4646e0 --- /dev/null +++ b/backend/scripts/setup-opensearch.ts @@ -0,0 +1,93 @@ +import { Client } from '@opensearch-project/opensearch'; +import { config } from 'dotenv'; + +// Load environment variables +config(); + +async function main() { + const url = process.env.OPENSEARCH_URL; + const username = process.env.OPENSEARCH_USERNAME; + const password = process.env.OPENSEARCH_PASSWORD; + + if (!url) { + console.error('❌ OPENSEARCH_URL environment variable is required'); + process.exit(1); + } + + console.log('🔍 Connecting to OpenSearch...'); + + const client = new Client({ + node: url, + auth: username && password ? { username, password } : undefined, + ssl: { + rejectUnauthorized: process.env.NODE_ENV === 'production', + }, + }); + + try { + // Test connection + const healthCheck = await client.cluster.health(); + console.log(`✅ Connected to OpenSearch cluster (status: ${healthCheck.body.status})`); + + // Create index template for security-findings-* + const templateName = 'security-findings-template'; + console.log(`\n📋 Creating index template: ${templateName}`); + + await client.indices.putIndexTemplate({ + name: templateName, + body: { + index_patterns: ['security-findings-*'], + template: { + settings: { + number_of_shards: 1, + number_of_replicas: 1, + }, + mappings: { + properties: { + '@timestamp': { type: 'date' }, + // Root-level analytics fields + scanner: { type: 'keyword' }, + severity: { type: 'keyword' }, + finding_hash: { type: 'keyword' }, + asset_key: { type: 'keyword' }, + // Workflow context under shipsec namespace + shipsec: { + type: 'object', + dynamic: true, + properties: { + organization_id: { type: 'keyword' }, + run_id: { type: 'keyword' }, + workflow_id: { type: 'keyword' }, + workflow_name: { type: 'keyword' }, + component_id: { type: 'keyword' }, + node_ref: { type: 'keyword' }, + asset_key: { type: 'keyword' }, + }, + }, + }, + }, + }, + }, + }); + + console.log(`✅ Index template '${templateName}' created successfully`); + console.log('\n📊 Template configuration:'); + console.log(' - Index pattern: security-findings-*'); + console.log(' - Shards: 1, Replicas: 1'); + console.log(' - Mappings: @timestamp (date)'); + console.log(' root: scanner, severity, finding_hash, asset_key (keyword)'); + console.log(' shipsec.*: organization_id, run_id, workflow_id, workflow_name,'); + console.log(' component_id, node_ref, asset_key (keyword)'); + console.log('\n🎉 OpenSearch setup completed successfully!'); + } catch (error) { + console.error('❌ OpenSearch setup failed'); + console.error(error); + process.exit(1); + } +} + +main().catch((error) => { + console.error('❌ Unexpected error during OpenSearch setup'); + console.error(error); + process.exit(1); +}); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts new file mode 100644 index 00000000..781c20a4 --- /dev/null +++ b/backend/src/analytics/analytics.controller.ts @@ -0,0 +1,223 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + Post, + Put, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiOkResponse, ApiTags, ApiHeader } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; + +import { SecurityAnalyticsService } from './security-analytics.service'; +import { OrganizationSettingsService } from './organization-settings.service'; +import { AnalyticsQueryRequestDto, AnalyticsQueryResponseDto } from './dto/analytics-query.dto'; +import { + AnalyticsSettingsResponseDto, + UpdateAnalyticsSettingsDto, + TIER_LIMITS, +} from './dto/analytics-settings.dto'; +import { CurrentAuth } from '../auth/auth-context.decorator'; +import type { AuthContext } from '../auth/types'; + +const MAX_QUERY_SIZE = 1000; +const MAX_QUERY_FROM = 10000; + +function isValidNonNegativeInt(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +@ApiTags('analytics') +@Controller('analytics') +export class AnalyticsController { + constructor( + private readonly securityAnalyticsService: SecurityAnalyticsService, + private readonly organizationSettingsService: OrganizationSettingsService, + ) {} + + @Post('query') + @Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute per user + @ApiOkResponse({ + description: 'Query analytics data for the authenticated organization', + type: AnalyticsQueryResponseDto, + }) + @ApiHeader({ + name: 'X-RateLimit-Limit', + description: 'Maximum number of requests allowed per minute', + schema: { type: 'integer', example: 100 }, + }) + @ApiHeader({ + name: 'X-RateLimit-Remaining', + description: 'Number of requests remaining in the current time window', + schema: { type: 'integer', example: 99 }, + }) + async queryAnalytics( + @CurrentAuth() auth: AuthContext | null, + @Body() queryDto: AnalyticsQueryRequestDto, + ): Promise { + // Require authentication + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + + // Require organization context + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + + // Validate query syntax + if (queryDto.query && typeof queryDto.query !== 'object') { + throw new BadRequestException('Invalid query syntax: query must be an object'); + } + + if (queryDto.aggs && typeof queryDto.aggs !== 'object') { + throw new BadRequestException('Invalid query syntax: aggs must be an object'); + } + + // Set defaults + const size = queryDto.size ?? 10; + const from = queryDto.from ?? 0; + + if (!isValidNonNegativeInt(size)) { + throw new BadRequestException('Invalid size: must be a non-negative integer'); + } + + if (!isValidNonNegativeInt(from)) { + throw new BadRequestException('Invalid from: must be a non-negative integer'); + } + + if (size > MAX_QUERY_SIZE) { + throw new BadRequestException(`Invalid size: maximum is ${MAX_QUERY_SIZE}`); + } + + if (from > MAX_QUERY_FROM) { + throw new BadRequestException(`Invalid from: maximum is ${MAX_QUERY_FROM}`); + } + + // Call the service to execute the query + return this.securityAnalyticsService.query(auth.organizationId, { + query: queryDto.query, + size, + from, + aggs: queryDto.aggs, + }); + } + + @Get('settings') + @ApiOkResponse({ + description: 'Get analytics settings for the authenticated organization', + type: AnalyticsSettingsResponseDto, + }) + async getAnalyticsSettings( + @CurrentAuth() auth: AuthContext | null, + ): Promise { + // Require authentication + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + + // Require organization context + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + + // Get or create organization settings + const settings = await this.organizationSettingsService.getOrganizationSettings( + auth.organizationId, + ); + + // Get max retention days for tier + const maxRetentionDays = this.organizationSettingsService.getMaxRetentionDays( + settings.subscriptionTier, + ); + + return { + organizationId: settings.organizationId, + subscriptionTier: settings.subscriptionTier, + analyticsRetentionDays: settings.analyticsRetentionDays, + maxRetentionDays, + createdAt: settings.createdAt, + updatedAt: settings.updatedAt, + }; + } + + @Put('settings') + @ApiOkResponse({ + description: 'Update analytics settings for the authenticated organization', + type: AnalyticsSettingsResponseDto, + }) + async updateAnalyticsSettings( + @CurrentAuth() auth: AuthContext | null, + @Body() updateDto: UpdateAnalyticsSettingsDto, + ): Promise { + // Require authentication + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + + // Require organization context + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + + // Only org admins can update settings + if (!auth.roles.includes('ADMIN')) { + throw new ForbiddenException('Only organization admins can update analytics settings'); + } + + // Get current settings to validate against tier + const currentSettings = await this.organizationSettingsService.getOrganizationSettings( + auth.organizationId, + ); + + // Determine the tier to validate against (use new tier if provided, otherwise current) + const tierToValidate = updateDto.subscriptionTier ?? currentSettings.subscriptionTier; + + // Validate retention period is within tier limits + if (updateDto.analyticsRetentionDays !== undefined) { + if ( + typeof updateDto.analyticsRetentionDays !== 'number' || + !Number.isInteger(updateDto.analyticsRetentionDays) + ) { + throw new BadRequestException('Retention period must be an integer number of days'); + } + + const isValid = this.organizationSettingsService.validateRetentionPeriod( + tierToValidate, + updateDto.analyticsRetentionDays, + ); + + if (!isValid) { + const maxDays = TIER_LIMITS[tierToValidate].maxRetentionDays; + throw new BadRequestException( + `Retention period of ${updateDto.analyticsRetentionDays} days exceeds the limit for ${TIER_LIMITS[tierToValidate].name} tier (${maxDays} days)`, + ); + } + } + + // Update settings + const updated = await this.organizationSettingsService.updateOrganizationSettings( + auth.organizationId, + { + analyticsRetentionDays: updateDto.analyticsRetentionDays, + subscriptionTier: updateDto.subscriptionTier, + }, + ); + + // Get max retention days for updated tier + const maxRetentionDays = this.organizationSettingsService.getMaxRetentionDays( + updated.subscriptionTier, + ); + + return { + organizationId: updated.organizationId, + subscriptionTier: updated.subscriptionTier, + analyticsRetentionDays: updated.analyticsRetentionDays, + maxRetentionDays, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }; + } +} diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index ee64fd92..df287cc4 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; import { AnalyticsService } from './analytics.service'; +import { SecurityAnalyticsService } from './security-analytics.service'; +import { OrganizationSettingsService } from './organization-settings.service'; +import { AnalyticsController } from './analytics.controller'; @Module({ - providers: [AnalyticsService], - exports: [AnalyticsService], + controllers: [AnalyticsController], + providers: [AnalyticsService, SecurityAnalyticsService, OrganizationSettingsService], + exports: [AnalyticsService, SecurityAnalyticsService, OrganizationSettingsService], }) export class AnalyticsModule {} diff --git a/backend/src/analytics/dto/analytics-query.dto.ts b/backend/src/analytics/dto/analytics-query.dto.ts new file mode 100644 index 00000000..969939bd --- /dev/null +++ b/backend/src/analytics/dto/analytics-query.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AnalyticsQueryRequestDto { + @ApiProperty({ + description: 'OpenSearch DSL query object', + example: { match_all: {} }, + required: false, + }) + query?: Record; + + @ApiProperty({ + description: 'Number of results to return', + example: 10, + default: 10, + minimum: 0, + maximum: 1000, + required: false, + }) + size?: number; + + @ApiProperty({ + description: 'Offset for pagination', + example: 0, + default: 0, + minimum: 0, + maximum: 10000, + required: false, + }) + from?: number; + + @ApiProperty({ + description: 'OpenSearch aggregations object', + example: { + components: { + terms: { field: 'component_id' }, + }, + }, + required: false, + }) + aggs?: Record; +} + +export class AnalyticsQueryResponseDto { + @ApiProperty({ + description: 'Total number of matching documents', + example: 100, + }) + total!: number; + + @ApiProperty({ + description: 'Search hits', + type: 'array', + items: { type: 'object' }, + }) + hits!: { + _id: string; + _source: Record; + _score?: number; + }[]; + + @ApiProperty({ + description: 'Aggregation results', + required: false, + }) + aggregations?: Record; +} diff --git a/backend/src/analytics/dto/analytics-settings.dto.ts b/backend/src/analytics/dto/analytics-settings.dto.ts new file mode 100644 index 00000000..ce34c4d3 --- /dev/null +++ b/backend/src/analytics/dto/analytics-settings.dto.ts @@ -0,0 +1,75 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsInt, Min, Max, IsOptional } from 'class-validator'; +import type { SubscriptionTier } from '../../database/schema/organization-settings'; + +export type { SubscriptionTier }; + +export const TIER_LIMITS: Record = { + free: { name: 'Free', maxRetentionDays: 30 }, + pro: { name: 'Pro', maxRetentionDays: 90 }, + enterprise: { name: 'Enterprise', maxRetentionDays: 365 }, +}; + +export class AnalyticsSettingsResponseDto { + @ApiProperty({ + description: 'Organization ID', + example: 'org_abc123', + }) + organizationId!: string; + + @ApiProperty({ + description: 'Subscription tier', + enum: ['free', 'pro', 'enterprise'], + example: 'free', + }) + subscriptionTier!: SubscriptionTier; + + @ApiProperty({ + description: 'Data retention period in days', + example: 30, + }) + analyticsRetentionDays!: number; + + @ApiProperty({ + description: 'Maximum retention days allowed for this tier', + example: 30, + }) + maxRetentionDays!: number; + + @ApiProperty({ + description: 'Timestamp when settings were created', + example: '2026-01-20T00:00:00.000Z', + }) + createdAt!: Date; + + @ApiProperty({ + description: 'Timestamp when settings were last updated', + example: '2026-01-20T00:00:00.000Z', + }) + updatedAt!: Date; +} + +export class UpdateAnalyticsSettingsDto { + @ApiProperty({ + description: 'Data retention period in days (must be within tier limits)', + example: 30, + minimum: 1, + maximum: 365, + required: false, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(365) + analyticsRetentionDays?: number; + + // Optional: allow updating subscription tier (if needed in the future) + @ApiProperty({ + description: 'Subscription tier (optional - usually set by billing system)', + enum: ['free', 'pro', 'enterprise'], + required: false, + }) + @IsOptional() + @IsEnum(['free', 'pro', 'enterprise']) + subscriptionTier?: SubscriptionTier; +} diff --git a/backend/src/analytics/organization-settings.service.ts b/backend/src/analytics/organization-settings.service.ts new file mode 100644 index 00000000..b26a612c --- /dev/null +++ b/backend/src/analytics/organization-settings.service.ts @@ -0,0 +1,94 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DRIZZLE_TOKEN } from '../database/database.module'; +import { + organizationSettingsTable, + OrganizationSettings, + SubscriptionTier, +} from '../database/schema/organization-settings'; +import { TIER_LIMITS } from './dto/analytics-settings.dto'; + +@Injectable() +export class OrganizationSettingsService { + private readonly logger = new Logger(OrganizationSettingsService.name); + + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + /** + * Get or create organization settings + */ + async getOrganizationSettings(organizationId: string): Promise { + // Try to get existing settings + const [existing] = await this.db + .select() + .from(organizationSettingsTable) + .where(eq(organizationSettingsTable.organizationId, organizationId)); + + if (existing) { + return existing; + } + + // Create default settings if they don't exist + this.logger.log(`Creating default settings for organization: ${organizationId}`); + const [created] = await this.db + .insert(organizationSettingsTable) + .values({ + organizationId, + subscriptionTier: 'free', + analyticsRetentionDays: 30, + }) + .returning(); + + return created; + } + + /** + * Update organization settings + */ + async updateOrganizationSettings( + organizationId: string, + updates: { + analyticsRetentionDays?: number; + subscriptionTier?: SubscriptionTier; + }, + ): Promise { + // Ensure settings exist + await this.getOrganizationSettings(organizationId); + + // Update settings + const [updated] = await this.db + .update(organizationSettingsTable) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(organizationSettingsTable.organizationId, organizationId)) + .returning(); + + this.logger.log( + `Updated settings for organization ${organizationId}: ${JSON.stringify(updates)}`, + ); + + return updated; + } + + /** + * Validate retention period is within tier limits + */ + validateRetentionPeriod(tier: SubscriptionTier, retentionDays: number): boolean { + const limit = TIER_LIMITS[tier]; + return retentionDays <= limit.maxRetentionDays && retentionDays > 0; + } + + /** + * Get max retention days for a tier + */ + getMaxRetentionDays(tier: SubscriptionTier): number { + return TIER_LIMITS[tier].maxRetentionDays; + } +} diff --git a/backend/src/analytics/security-analytics.service.ts b/backend/src/analytics/security-analytics.service.ts new file mode 100644 index 00000000..ce53a645 --- /dev/null +++ b/backend/src/analytics/security-analytics.service.ts @@ -0,0 +1,239 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OpenSearchClient } from '../config/opensearch.client'; + +interface IndexDocumentOptions { + workflowId: string; + workflowName: string; + runId: string; + nodeRef: string; + componentId: string; + assetKeyField?: string; + indexSuffix?: string; +} + +type BulkIndexOptions = IndexDocumentOptions; + +@Injectable() +export class SecurityAnalyticsService { + private readonly logger = new Logger(SecurityAnalyticsService.name); + + constructor(private readonly openSearchClient: OpenSearchClient) {} + + /** + * Index a single document to OpenSearch with metadata + */ + async indexDocument( + orgId: string, + document: Record, + options: IndexDocumentOptions, + ): Promise { + if (!this.openSearchClient.isClientEnabled()) { + this.logger.debug('OpenSearch client not enabled, skipping indexing'); + return; + } + + const client = this.openSearchClient.getClient(); + if (!client) { + this.logger.warn('OpenSearch client is null, skipping indexing'); + return; + } + + try { + const indexName = this.buildIndexName(orgId, options.indexSuffix); + const assetKey = this.detectAssetKey(document, options.assetKeyField); + + const enrichedDocument = { + ...document, + '@timestamp': new Date().toISOString(), + workflow_id: options.workflowId, + workflow_name: options.workflowName, + run_id: options.runId, + node_ref: options.nodeRef, + component_id: options.componentId, + ...(assetKey && { asset_key: assetKey }), + }; + + await client.index({ + index: indexName, + body: enrichedDocument, + }); + + this.logger.debug(`Indexed document to ${indexName} for workflow ${options.workflowId}`); + } catch (error) { + this.logger.error(`Failed to index document: ${error}`); + throw error; + } + } + + /** + * Bulk index multiple documents to OpenSearch + */ + async bulkIndex( + orgId: string, + documents: Record[], + options: BulkIndexOptions, + ): Promise { + if (!this.openSearchClient.isClientEnabled()) { + this.logger.debug('OpenSearch client not enabled, skipping bulk indexing'); + return; + } + + const client = this.openSearchClient.getClient(); + if (!client) { + this.logger.warn('OpenSearch client is null, skipping bulk indexing'); + return; + } + + if (documents.length === 0) { + this.logger.debug('No documents to index, skipping bulk indexing'); + return; + } + + try { + const indexName = this.buildIndexName(orgId, options.indexSuffix); + + // Build bulk operations array + const bulkOps: any[] = []; + for (const document of documents) { + const assetKey = this.detectAssetKey(document, options.assetKeyField); + + const enrichedDocument = { + ...document, + '@timestamp': new Date().toISOString(), + workflow_id: options.workflowId, + workflow_name: options.workflowName, + run_id: options.runId, + node_ref: options.nodeRef, + component_id: options.componentId, + ...(assetKey && { asset_key: assetKey }), + }; + + bulkOps.push({ index: { _index: indexName } }); + bulkOps.push(enrichedDocument); + } + + const response = await client.bulk({ + body: bulkOps, + }); + + if (response.body.errors) { + const errorCount = response.body.items.filter((item: any) => item.index?.error).length; + this.logger.warn( + `Bulk indexing completed with ${errorCount} errors out of ${documents.length} documents`, + ); + } else { + this.logger.debug( + `Bulk indexed ${documents.length} documents to ${indexName} for workflow ${options.workflowId}`, + ); + } + } catch (error) { + this.logger.error(`Failed to bulk index documents: ${error}`); + throw error; + } + } + + /** + * Build the index name with org scoping and date-based rotation + * Format: security-findings-{orgId}-{YYYY.MM.DD} + */ + private buildIndexName(orgId: string, indexSuffix?: string): string { + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + const suffix = indexSuffix || `${year}.${month}.${day}`; + return `security-findings-${orgId}-${suffix}`; + } + + /** + * Query analytics data for an organization + */ + async query( + orgId: string, + options: { + query?: Record; + size?: number; + from?: number; + aggs?: Record; + }, + ): Promise<{ + total: number; + hits: { _id: string; _source: Record; _score?: number }[]; + aggregations?: Record; + }> { + if (!this.openSearchClient.isClientEnabled()) { + this.logger.warn('OpenSearch client not enabled, returning empty results'); + return { total: 0, hits: [], aggregations: undefined }; + } + + const client = this.openSearchClient.getClient(); + if (!client) { + this.logger.warn('OpenSearch client is null, returning empty results'); + return { total: 0, hits: [], aggregations: undefined }; + } + + try { + // Build index pattern for org: security-findings-{orgId}-* + const indexPattern = `security-findings-${orgId}-*`; + + // Execute the search + const response = await client.search({ + index: indexPattern, + body: { + query: options.query || { match_all: {} }, + size: options.size ?? 10, + from: options.from ?? 0, + ...(options.aggs && { aggs: options.aggs }), + }, + }); + + // Extract results from OpenSearch response + const total: number = + typeof response.body.hits.total === 'object' + ? (response.body.hits.total.value ?? 0) + : (response.body.hits.total ?? 0); + + const hits = response.body.hits.hits.map((hit: any) => ({ + _id: hit._id, + _source: hit._source, + ...(hit._score !== undefined && { _score: hit._score }), + })); + + return { + total, + hits, + aggregations: response.body.aggregations, + }; + } catch (error) { + this.logger.error(`Failed to query analytics data: ${error}`); + throw error; + } + } + + /** + * Auto-detect asset key from common fields + * Priority: host > domain > subdomain > url > ip > asset > target + */ + private detectAssetKey(document: Record, explicitField?: string): string | null { + // If explicit field is provided, use it + if (explicitField && document[explicitField]) { + return String(document[explicitField]); + } + + if (document.asset_key) { + return String(document.asset_key); + } + + // Auto-detect from common fields + const assetFields = ['host', 'domain', 'subdomain', 'url', 'ip', 'asset', 'target']; + + for (const field of assetFields) { + if (document[field]) { + return String(document[field]); + } + } + + return null; + } +} diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 681adbac..379c7e50 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,13 +1,119 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Post, Res, UnauthorizedException, Headers } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SkipThrottle } from '@nestjs/throttler'; +import type { Response } from 'express'; import { AppService } from './app.service'; +import { CurrentAuth } from './auth/auth-context.decorator'; +import type { AuthContext } from './auth/types'; +import { Public } from './auth/public.decorator'; +import type { AuthConfig } from './config/auth.config'; +import { + SESSION_COOKIE_NAME, + SESSION_COOKIE_MAX_AGE, + createSessionToken, +} from './auth/session.utils'; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + private readonly authCfg: AuthConfig; + constructor( + private readonly appService: AppService, + private readonly configService: ConfigService, + ) { + this.authCfg = this.configService.get('auth')!; + } + + @SkipThrottle() @Get('/health') health() { return this.appService.getHealth(); } + + /** + * Auth validation endpoint for nginx auth_request. + * Returns 200 if authenticated, 401 otherwise. + * Used by nginx to protect /analytics/* routes. + * + * Note: SkipThrottle is required because nginx sends an auth_request + * for every resource loaded from /analytics/*, which can quickly + * exceed rate limits and cause 500 errors. + */ + @SkipThrottle() + @Get('/auth/validate') + validateAuth(@CurrentAuth() auth: AuthContext | null) { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException(); + } + return { valid: true }; + } + + /** + * Login endpoint for local auth. + * Validates Basic auth credentials and sets a session cookie. + */ + @Public() + @Post('/auth/login') + login( + @Headers('authorization') authHeader: string | undefined, + @Res({ passthrough: true }) res: Response, + ) { + // Only for local auth provider + if (this.authCfg.provider !== 'local') { + throw new UnauthorizedException('Login endpoint only available for local auth'); + } + + // Validate Basic auth header + if (!authHeader || !authHeader.startsWith('Basic ')) { + throw new UnauthorizedException('Missing Basic Auth credentials'); + } + + const base64Credentials = authHeader.slice(6); + let username: string; + let password: string; + + try { + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + [username, password] = credentials.split(':'); + } catch { + throw new UnauthorizedException('Invalid Basic Auth format'); + } + + if (!username || !password) { + throw new UnauthorizedException('Invalid Basic Auth format'); + } + + // Validate credentials + if ( + username !== this.authCfg.local.adminUsername || + password !== this.authCfg.local.adminPassword + ) { + throw new UnauthorizedException('Invalid admin credentials'); + } + + // Create session token and set cookie + const sessionToken = createSessionToken(username); + + res.cookie(SESSION_COOKIE_NAME, sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: SESSION_COOKIE_MAX_AGE, + path: '/', + }); + + return { success: true, message: 'Logged in successfully' }; + } + + /** + * Logout endpoint for local auth. + * Clears the session cookie. + */ + @Public() + @Post('/auth/logout') + logout(@Res({ passthrough: true }) res: Response) { + res.clearCookie(SESSION_COOKIE_NAME, { path: '/' }); + return { success: true, message: 'Logged out successfully' }; + } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 81e3e26b..c11cda7e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,10 +2,15 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { join } from 'node:path'; +import { ThrottlerModule, ThrottlerGuard, seconds } from '@nestjs/throttler'; +import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; +import Redis from 'ioredis'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { authConfig } from './config/auth.config'; +import { opensearchConfig } from './config/opensearch.config'; +import { OpenSearchModule } from './config/opensearch.module'; import { AgentsModule } from './agents/agents.module'; import { AuthModule } from './auth/auth.module'; import { AuthGuard } from './auth/auth.guard'; @@ -68,8 +73,25 @@ function getEnvFilePaths(): string[] { ConfigModule.forRoot({ isGlobal: true, envFilePath: getEnvFilePaths(), - load: [authConfig], + load: [authConfig, opensearchConfig], }), + ThrottlerModule.forRootAsync({ + useFactory: () => { + const redisUrl = process.env.REDIS_URL; + + return { + throttlers: [ + { + name: 'default', + ttl: seconds(60), // 60 seconds + limit: 100, // 100 requests per minute + }, + ], + storage: redisUrl ? new ThrottlerStorageRedisService(new Redis(redisUrl)) : undefined, // Falls back to in-memory storage if Redis not configured + }; + }, + }), + OpenSearchModule, ...coreModules, ...testingModules, ], @@ -84,6 +106,10 @@ function getEnvFilePaths(): string[] { provide: APP_GUARD, useClass: RolesGuard, }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/backend/src/auth/providers/local-auth.provider.ts b/backend/src/auth/providers/local-auth.provider.ts index f4adadea..2e2e14d4 100644 --- a/backend/src/auth/providers/local-auth.provider.ts +++ b/backend/src/auth/providers/local-auth.provider.ts @@ -1,10 +1,11 @@ import type { Request } from 'express'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, Logger } from '@nestjs/common'; import type { LocalAuthConfig } from '../../config/auth.config'; import { DEFAULT_ROLES, type AuthContext } from '../types'; import type { AuthProviderStrategy } from './auth-provider.interface'; import { DEFAULT_ORGANIZATION_ID } from '../constants'; +import { verifySessionToken, SESSION_COOKIE_NAME } from '../session.utils'; function extractBasicAuth( headerValue: string | undefined, @@ -28,6 +29,7 @@ function extractBasicAuth( @Injectable() export class LocalAuthProvider implements AuthProviderStrategy { readonly name = 'local'; + private readonly logger = new Logger(LocalAuthProvider.name); constructor(private readonly config: LocalAuthConfig) {} @@ -35,16 +37,36 @@ export class LocalAuthProvider implements AuthProviderStrategy { // Always use local-dev org ID for local auth const orgId = DEFAULT_ORGANIZATION_ID; - // Require Basic Auth (admin credentials) + // Check config if (!this.config.adminUsername || !this.config.adminPassword) { throw new UnauthorizedException('Local auth not configured - admin credentials required'); } + // Try session cookie first (for browser navigation requests like /analytics/) + const sessionCookie = request.cookies?.[SESSION_COOKIE_NAME]; + if (sessionCookie) { + const session = verifySessionToken(sessionCookie); + if (session && session.username === this.config.adminUsername) { + this.logger.debug(`Session cookie auth successful for user: ${session.username}`); + return { + userId: 'admin', + organizationId: orgId, + roles: DEFAULT_ROLES, + isAuthenticated: true, + provider: this.name, + }; + } + this.logger.debug('Session cookie invalid or username mismatch'); + } + + // Fall back to Basic Auth (for API requests) const authHeader = request.headers.authorization; const basicAuth = extractBasicAuth(authHeader); if (!basicAuth) { - throw new UnauthorizedException('Missing Basic Auth credentials'); + throw new UnauthorizedException( + 'Missing authentication - provide session cookie or Basic Auth', + ); } if ( diff --git a/backend/src/auth/session.utils.ts b/backend/src/auth/session.utils.ts new file mode 100644 index 00000000..d7b54319 --- /dev/null +++ b/backend/src/auth/session.utils.ts @@ -0,0 +1,66 @@ +import * as crypto from 'crypto'; + +// Session cookie configuration +export const SESSION_COOKIE_NAME = 'shipsec_session'; +export const SESSION_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days + +function getSessionSecret(): string { + const secret = process.env.SESSION_SECRET; + if (!secret) { + if (process.env.NODE_ENV === 'production') { + throw new Error('SESSION_SECRET is required in production for session authentication'); + } + return 'local-dev-session-secret'; + } + return secret; +} + +export interface SessionPayload { + username: string; + ts: number; +} + +/** + * Create a signed session token for local auth. + */ +export function createSessionToken(username: string): string { + const secret = getSessionSecret(); + const payload = JSON.stringify({ username, ts: Date.now() }); + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + const signature = hmac.digest('hex'); + return Buffer.from(`${payload}.${signature}`).toString('base64'); +} + +/** + * Verify and decode a session token. + */ +export function verifySessionToken(token: string): SessionPayload | null { + try { + const secret = getSessionSecret(); + const decoded = Buffer.from(token, 'base64').toString('utf-8'); + const lastDot = decoded.lastIndexOf('.'); + if (lastDot === -1) return null; + + const payload = decoded.slice(0, lastDot); + const signature = decoded.slice(lastDot + 1); + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + const expectedSignature = hmac.digest('hex'); + + if (signature.length !== expectedSignature.length) return null; + const signatureMatch = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ); + if (!signatureMatch) return null; + + const parsed = JSON.parse(payload) as SessionPayload; + if (typeof parsed.ts !== 'number') return null; + if (Date.now() - parsed.ts > SESSION_COOKIE_MAX_AGE) return null; + return parsed; + } catch { + return null; + } +} diff --git a/backend/src/config/opensearch.client.ts b/backend/src/config/opensearch.client.ts new file mode 100644 index 00000000..bed76f8f --- /dev/null +++ b/backend/src/config/opensearch.client.ts @@ -0,0 +1,53 @@ +import { Client } from '@opensearch-project/opensearch'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class OpenSearchClient implements OnModuleInit { + private readonly logger = new Logger(OpenSearchClient.name); + private client: Client | null = null; + private isEnabled = false; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + this.initializeClient(); + } + + private initializeClient() { + const url = this.configService.get('opensearch.url'); + const username = this.configService.get('opensearch.username'); + const password = this.configService.get('opensearch.password'); + + if (!url) { + this.logger.warn( + '🔍 OpenSearch client not configured - OPENSEARCH_URL not set. Security analytics indexing disabled.', + ); + return; + } + + try { + this.client = new Client({ + node: url, + auth: username && password ? { username, password } : undefined, + ssl: { + rejectUnauthorized: process.env.NODE_ENV === 'production', + }, + }); + + this.isEnabled = true; + this.logger.log(`🔍 OpenSearch client initialized - Connected to ${url}`); + } catch (error) { + this.logger.error(`Failed to initialize OpenSearch client: ${error}`); + this.isEnabled = false; + } + } + + getClient(): Client | null { + return this.client; + } + + isClientEnabled(): boolean { + return this.isEnabled && this.client !== null; + } +} diff --git a/backend/src/config/opensearch.config.ts b/backend/src/config/opensearch.config.ts new file mode 100644 index 00000000..b031ad3d --- /dev/null +++ b/backend/src/config/opensearch.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from '@nestjs/config'; + +export interface OpenSearchConfig { + url: string | null; + username: string | null; + password: string | null; +} + +export const opensearchConfig = registerAs('opensearch', () => ({ + url: process.env.OPENSEARCH_URL ?? null, + username: process.env.OPENSEARCH_USERNAME ?? null, + password: process.env.OPENSEARCH_PASSWORD ?? null, +})); diff --git a/backend/src/config/opensearch.module.ts b/backend/src/config/opensearch.module.ts new file mode 100644 index 00000000..b4db4b84 --- /dev/null +++ b/backend/src/config/opensearch.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { OpenSearchClient } from './opensearch.client'; + +@Global() +@Module({ + providers: [OpenSearchClient], + exports: [OpenSearchClient], +}) +export class OpenSearchModule {} diff --git a/backend/src/database/migration.guard.ts b/backend/src/database/migration.guard.ts index e321a2db..773260b1 100644 --- a/backend/src/database/migration.guard.ts +++ b/backend/src/database/migration.guard.ts @@ -8,6 +8,7 @@ const REQUIRED_TABLES = [ 'artifacts', 'workflow_log_streams', 'workflow_traces', + 'organization_settings', ]; @Injectable() diff --git a/backend/src/database/schema/index.ts b/backend/src/database/schema/index.ts index 32f11f65..6d985721 100644 --- a/backend/src/database/schema/index.ts +++ b/backend/src/database/schema/index.ts @@ -19,3 +19,4 @@ export * from './agent-trace-events'; export * from './mcp-servers'; export * from './node-io'; +export * from './organization-settings'; diff --git a/backend/src/database/schema/organization-settings.ts b/backend/src/database/schema/organization-settings.ts new file mode 100644 index 00000000..b6dd7f14 --- /dev/null +++ b/backend/src/database/schema/organization-settings.ts @@ -0,0 +1,17 @@ +import { integer, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'; + +export type SubscriptionTier = 'free' | 'pro' | 'enterprise'; + +export const organizationSettingsTable = pgTable('organization_settings', { + organizationId: varchar('organization_id', { length: 191 }).primaryKey(), + subscriptionTier: varchar('subscription_tier', { length: 50 }) + .$type() + .notNull() + .default('free'), + analyticsRetentionDays: integer('analytics_retention_days').notNull().default(30), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type OrganizationSettings = typeof organizationSettingsTable.$inferSelect; +export type NewOrganizationSettings = typeof organizationSettingsTable.$inferInsert; diff --git a/backend/src/dsl/validator.ts b/backend/src/dsl/validator.ts index 6ec112a9..5df2437c 100644 --- a/backend/src/dsl/validator.ts +++ b/backend/src/dsl/validator.ts @@ -170,6 +170,10 @@ function isPlaceholderIssue(issue: ZodIssue, placeholderFields: Set): bo return true; case 'too_big': return true; + case 'invalid_value': + // Enum/literal validation fails on placeholder objects with missing fields + // The actual value from upstream will have the correct enum value at runtime + return true; case 'custom': // Custom validations (from .refine()) fail on placeholders but will pass at runtime // when the actual value comes from the connected edge diff --git a/backend/src/main.ts b/backend/src/main.ts index 9347d0a8..8c8c4236 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,6 +3,7 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { cleanupOpenApiDoc } from 'nestjs-zod'; +import cookieParser from 'cookie-parser'; import { isVersionCheckDisabled, performVersionCheck } from './version-check'; @@ -14,6 +15,9 @@ async function bootstrap() { logger: ['log', 'error', 'warn'], }); + // Enable cookie parsing for session auth + app.use(cookieParser()); + // Set global prefix for all routes app.setGlobalPrefix('api/v1'); @@ -40,6 +44,7 @@ async function bootstrap() { app.enableCors({ origin: [ 'http://localhost', + 'http://localhost:80', 'http://localhost:8090', 'https://studio.shipsec.ai', ...instanceOrigins, @@ -52,6 +57,9 @@ async function bootstrap() { 'Accept', 'Cache-Control', 'x-organization-id', + 'X-Real-IP', + 'X-Forwarded-For', + 'X-Forwarded-Proto', ], }); const port = Number(process.env.PORT ?? 3211); diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index 0a1e2979..bf4713e6 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -372,6 +372,7 @@ services: - STUDIO_API_BASE_URL=http://backend:3211/api/v1 # OpenSearch for Analytics Sink - OPENSEARCH_URL=http://opensearch:9200 + - OPENSEARCH_DASHBOARDS_URL=http://opensearch-dashboards:5601/analytics depends_on: postgres: condition: service_healthy diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 00000000..1006e50e --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,218 @@ +# Analytics Pipeline + +This document describes the analytics infrastructure for ShipSec Studio, including OpenSearch for data storage, OpenSearch Dashboards for visualization, and the routing architecture. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Nginx (port 80) │ +│ │ +│ /analytics/* ──────► OpenSearch Dashboards (5601) │ +│ /api/* ──────► Backend API (3211) │ +│ /* ──────► Frontend SPA (8080) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Worker Service │ +│ │ +│ Analytics Sink Component ──────► OpenSearch (9200) │ +│ (OPENSEARCH_URL env var) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Components + +### OpenSearch (Port 9200) + +Time-series database for storing security findings and workflow analytics. + +**Configuration:** +- Single-node deployment (dev/simple prod) +- Security plugin disabled for development +- Index pattern: `security-findings-{org-id}-{date}` + +### OpenSearch Dashboards (Port 5601) + +Web UI for exploring and visualizing analytics data. + +**Configuration (`opensearch-dashboards.yml`):** +```yaml +server.basePath: "/analytics" +server.rewriteBasePath: true +opensearch.hosts: ["http://opensearch:9200"] +``` + +**Key Settings:** +- `basePath: "/analytics"` - All URLs are prefixed with `/analytics` +- `rewriteBasePath: true` - Strips `/analytics` from incoming requests, adds it back to responses + +### Analytics Sink (Worker Component) + +The `core.analytics.sink` component writes workflow results to OpenSearch. + +**Environment Variable:** +```yaml +OPENSEARCH_URL=http://opensearch:9200 +``` + +**Document Structure:** +```json +{ + "@timestamp": "2026-01-25T01:22:43.783Z", + "title": "Finding title", + "severity": "high", + "description": "...", + "shipsec": { + "organization_id": "local-dev", + "run_id": "shipsec-run-xxx", + "workflow_id": "workflow-xxx", + "workflow_name": "My Workflow", + "component_id": "core.analytics.sink", + "node_ref": "analytics-sink-123" + } +} +``` + +## Nginx Routing + +All traffic flows through Nginx on port 80: + +| Path | Target | Description | +|------|--------|-------------| +| `/analytics/*` | `opensearch-dashboards:5601` | Analytics dashboard UI | +| `/api/*` | `backend:3211` | Backend REST API | +| `/*` | `frontend:8080` | Frontend SPA (catch-all) | + +### OpenSearch Dashboards Routing Details + +The `/analytics` route requires special handling: + +1. **Authentication**: Routes are protected - users must be logged in to access +2. **Session Cookies**: Backend validates session cookies for analytics route auth +3. **BasePath Configuration**: OpenSearch Dashboards is configured with `server.basePath: "/analytics"` +4. **Proxy Pass**: Nginx forwards requests to OpenSearch Dashboards without path rewriting +5. **rewriteBasePath**: OpenSearch Dashboards strips `/analytics` internally and adds it back to URLs + +```nginx +location /analytics/ { + proxy_pass http://opensearch-dashboards; + proxy_set_header osd-xsrf "true"; + proxy_cookie_path /analytics/ /analytics/; +} +``` + +## Frontend Integration + +The frontend links to OpenSearch Dashboards Discover app with pre-filtered queries: + +```typescript +const baseUrl = '/analytics'; +// Use .keyword fields for exact match filtering +const filterQuery = `shipsec.run_id.keyword:"${runId}"`; + +// Build Discover URL with proper state format +const gParam = encodeURIComponent('(time:(from:now-7d,to:now))'); +const aParam = encodeURIComponent( + `(columns:!(_source),index:'security-findings-*',interval:auto,query:(language:kuery,query:'${filterQuery}'),sort:!('@timestamp',desc))` +); +const url = `${baseUrl}/app/discover#/?_g=${gParam}&_a=${aParam}`; + +// Open in new tab +window.open(url, '_blank', 'noopener,noreferrer'); +``` + +**Key points:** +- Use `.keyword` fields (e.g., `shipsec.run_id.keyword`) for exact match filtering +- Use Discover app (`/app/discover`) for viewing raw data without saved views +- Include `index`, `columns`, `interval`, and `sort` in the `_a` param + +**Environment Variable:** +``` +VITE_OPENSEARCH_DASHBOARDS_URL=/analytics +``` + +## Data Flow + +1. **Workflow Execution**: Worker runs workflow with Analytics Sink component +2. **Data Enrichment**: Analytics Sink adds `shipsec.*` metadata fields +3. **Indexing**: Documents bulk-indexed to OpenSearch via `OPENSEARCH_URL` +4. **Visualization**: Users explore data in OpenSearch Dashboards at `/analytics` + +## Analytics API Limits + +To protect OpenSearch and keep queries responsive: + +- `size` must be a non-negative integer and is capped at **1000** +- `from` must be a non-negative integer and is capped at **10000** + +Requests exceeding these limits return `400 Bad Request`. + +## Analytics Settings Updates + +The analytics settings update API supports **partial updates**: + +- `analyticsRetentionDays` is optional +- `subscriptionTier` is optional + +Omit fields you don’t want to change. The backend validates the retention days only when provided. + +## Troubleshooting + +### Analytics Sink Not Writing Data + +**Symptom:** New workflow runs don't appear in OpenSearch + +**Check:** +```bash +# Verify worker has OPENSEARCH_URL set +docker exec shipsec-worker env | grep OPENSEARCH + +# Check worker logs for indexing errors +docker logs shipsec-worker 2>&1 | grep -i "analytics\|indexing" +``` + +**Solution:** Ensure `OPENSEARCH_URL=http://opensearch:9200` is set in worker environment. + +### OpenSearch Dashboards Shows Blank Page + +**Symptom:** Page loads but content area is empty + +**Check:** +1. Browser console for JavaScript errors +2. Time range filter (data might be outside selected range) +3. Index pattern selection + +**Solution:** +- Set time range to "Last 30 days" or wider +- Ensure `security-findings-*` index pattern is selected + +### Query Returns No Results + +**Check if data exists:** +```bash +# Count documents +curl -s "http://localhost:9200/security-findings-*/_count" | jq '.count' + +# List run_ids with data +curl -s "http://localhost:9200/security-findings-*/_search" \ + -H "Content-Type: application/json" \ + -d '{"size":0,"aggs":{"run_ids":{"terms":{"field":"shipsec.run_id.keyword"}}}}' \ + | jq '.aggregations.run_ids.buckets' +``` + +## Environment Variables + +| Variable | Service | Description | +|----------|---------|-------------| +| `OPENSEARCH_URL` | Worker | OpenSearch connection URL | +| `OPENSEARCH_USERNAME` | Worker | Optional: OpenSearch username | +| `OPENSEARCH_PASSWORD` | Worker | Optional: OpenSearch password | +| `VITE_OPENSEARCH_DASHBOARDS_URL` | Frontend | Dashboard URL for links | + +## See Also + +- [Docker README](../docker/README.md) - Docker deployment configurations +- [nginx.full.conf](../docker/nginx/nginx.full.conf) - Full stack nginx routing +- [opensearch-dashboards.yml](../docker/opensearch-dashboards.yml) - Dashboard configuration diff --git a/docs/components/core.mdx b/docs/components/core.mdx index 101e79e0..3ad0c765 100644 --- a/docs/components/core.mdx +++ b/docs/components/core.mdx @@ -205,3 +205,44 @@ Provides AWS credentials for S3 operations. | Output | Type | Description | |--------|------|-------------| | `credentials` | Object | Credential object for S3 components | + +--- + +## Analytics + +### Analytics Sink + +Indexes workflow output data into OpenSearch for analytics dashboards, queries, and alerts. Connect the `results` port from upstream security scanners. + +| Input | Type | Description | +|-------|------|-------------| +| `data` | Any | Data to index. Works best with `list` from scanner `results` ports. | + +| Output | Type | Description | +|--------|------|-------------| +| `indexed` | Boolean | Whether data was successfully indexed | +| `documentCount` | Number | Number of documents indexed | +| `indexName` | String | Name of the OpenSearch index used | + +| Parameter | Type | Description | +|-----------|------|-------------| +| `indexSuffix` | String | Custom suffix for the index name. Defaults to slugified workflow name. | +| `assetKeyField` | Select | Field to use as asset identifier. Options: auto, asset_key, host, domain, subdomain, url, ip, asset, target, custom | +| `customAssetKeyField` | String | Custom field name when assetKeyField is "custom" | +| `failOnError` | Boolean | When enabled, workflow stops if indexing fails. Default: false (fire-and-forget) | + +**How it works:** + +1. Each item in the input array becomes a separate document +2. Workflow context is added under `shipsec.*` namespace +3. Nested objects are serialized to JSON strings (prevents field explosion) +4. All documents get the same `@timestamp` + +**Example use cases:** +- Index Nuclei scan results for trend analysis +- Store TruffleHog secrets for tracking over time +- Aggregate vulnerability data across workflows + + + See [Workflow Analytics](/development/workflow-analytics) for detailed setup and querying guide. + diff --git a/docs/development/analytics.mdx b/docs/development/analytics.mdx index 69ee9956..a4d91a3f 100644 --- a/docs/development/analytics.mdx +++ b/docs/development/analytics.mdx @@ -1,5 +1,5 @@ --- -title: "Analytics" +title: "Product Analytics (PostHog)" description: "PostHog integration for product analytics and session recording" --- diff --git a/docs/development/component-development.mdx b/docs/development/component-development.mdx index 853151f1..77151fdb 100644 --- a/docs/development/component-development.mdx +++ b/docs/development/component-development.mdx @@ -405,7 +405,166 @@ async execute({ inputs }, context) { --- +## Analytics Output Port (Results) +Security components should include a `results` output port for analytics integration. This port outputs structured findings that can be indexed into OpenSearch via the Analytics Sink. + +### Schema Requirements + +The `results` port must output `list` (array of records): + +```typescript +outputs: outputs({ + // ... other outputs ... + + results: port(z.array(z.record(z.string(), z.unknown())), { + label: 'Results', + description: + 'Analytics-ready findings array. Each item includes scanner name and asset key. Connect to Analytics Sink.', + connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } }, + }), +}), +``` + +### Required Fields + +Each finding in the results array **must** include: + +| Field | Type | Description | +|-------|------|-------------| +| `scanner` | string | Scanner identifier (e.g., `'nuclei'`, `'trufflehog'`, `'supabase-scanner'`) | +| `asset_key` | string | Primary asset identifier (host, domain, target, etc.) | +| `finding_hash` | string | Stable hash for deduplication (16-char hex from SHA-256) | + +Additional fields from the scanner output should be spread into the finding object. + +### Finding Hash + +The `finding_hash` is a stable identifier that enables deduplication across workflow runs. It should be generated from the key identifying fields of each finding. + +**Purpose:** +- Track if a finding is **new** or **recurring** across scans +- Deduplicate findings in dashboards +- Calculate **first-seen** and **last-seen** timestamps +- Identify which findings have been **resolved** (no longer appearing) + +**How to generate:** + +Import from the component SDK: + +```typescript +import { generateFindingHash } from '@shipsec/component-sdk'; + +// Usage +const hash = generateFindingHash(finding.templateId, finding.host, finding.matchedAt); +``` + +**Key fields per scanner:** + +| Scanner | Fields Used | +|---------|-------------| +| Nuclei | `templateId + host + matchedAt` | +| TruffleHog | `DetectorType + Redacted + filePath` | +| Supabase Scanner | `check_id + projectRef + resource` | + +Choose fields that uniquely identify a finding but remain stable across runs (avoid timestamps, random IDs, etc.). + +### Example Implementation + +```typescript +import { generateFindingHash } from '@shipsec/component-sdk'; + +async execute({ inputs, params }, context) { + // ... run scanner and get findings ... + + // Build analytics-ready results with scanner metadata + const results: Record[] = findings.map((finding) => ({ + ...finding, // Spread all finding fields + scanner: 'my-scanner', // Scanner identifier + asset_key: finding.host ?? inputs.target, // Primary asset + finding_hash: generateFindingHash( // Stable deduplication hash + finding.ruleId, + finding.host, + finding.matchedAt + ), + })); + + return { + findings, // Original findings array + results, // Analytics-ready array for Analytics Sink + rawOutput, // Raw output for debugging + }; +} +``` + +### How It Works + +1. **Component outputs `results`**: Each scanner outputs its findings with `scanner` and `asset_key` fields +2. **Connect to Analytics Sink**: In the workflow canvas, connect the `results` port to Analytics Sink's `data` input +3. **Indexed to OpenSearch**: Each item in the array becomes a separate document with: + - Finding data at root level (nested objects serialized to JSON strings) + - Workflow context under `shipsec.*` namespace + - Consistent `@timestamp` for all findings in the batch + +### Document Structure in OpenSearch + +```json +{ + "check_id": "DB_RLS_DISABLED", + "severity": "CRITICAL", + "title": "RLS Disabled", + "metadata": "{\"table\":\"users\"}", + "scanner": "supabase-scanner", + "asset_key": "abc123xyz", + "finding_hash": "a1b2c3d4e5f67890", + "shipsec": { + "organization_id": "org_123", + "run_id": "run_abc123", + "workflow_id": "wf_xyz789", + "workflow_name": "Supabase Security Audit", + "component_id": "core.analytics.sink", + "node_ref": "analytics-sink-1" + }, + "@timestamp": "2024-01-21T10:30:00Z" +} +``` + +### `shipsec` Context Fields + +The Analytics Sink automatically adds workflow context under the `shipsec` namespace: + +| Field | Description | +|-------|-------------| +| `organization_id` | Organization that owns the workflow | +| `run_id` | Unique identifier for this workflow execution | +| `workflow_id` | ID of the workflow definition | +| `workflow_name` | Human-readable workflow name | +| `component_id` | Component type (e.g., `core.analytics.sink`) | +| `node_ref` | Node reference in the workflow graph | +| `asset_key` | Auto-detected or specified asset identifier | + +### Example Queries + +``` +# Find all findings for an asset +asset_key: "api.example.com" + +# Find new findings (first seen today) +finding_hash: X AND @timestamp: [now-1d TO now] AND NOT (finding_hash: X AND @timestamp: [* TO now-1d]) + +# All findings from a specific workflow run +shipsec.run_id: "run_abc123" + +# Aggregate findings by scanner +scanner: * | stats count() by scanner + +# Track recurring findings +finding_hash: "a1b2c3d4" | sort @timestamp +``` + + + Nested objects in findings are automatically serialized to JSON strings to prevent OpenSearch field explosion (1000 field limit). + --- diff --git a/docs/development/workflow-analytics.mdx b/docs/development/workflow-analytics.mdx new file mode 100644 index 00000000..52b612e5 --- /dev/null +++ b/docs/development/workflow-analytics.mdx @@ -0,0 +1,373 @@ +--- +title: "Workflow Analytics" +description: "Index security findings into OpenSearch for dashboards, queries, and alerting" +--- + +ShipSec Studio includes a workflow analytics system that indexes security findings into OpenSearch. This enables real-time dashboards, historical trend analysis, and alerting on security data. + +--- + +## Overview + +The analytics system consists of: + +1. **Analytics Sink component** - Indexes workflow output data into OpenSearch +2. **Results output port** - Structured findings from security scanners +3. **OpenSearch storage** - Time-series index for querying and visualization +4. **View Analytics button** - Quick access to filtered dashboards + +--- + +## Architecture + +```mermaid +flowchart LR + subgraph Scanners + N[Nuclei Scan] + T[TruffleHog] + S[Supabase Scanner] + end + + AS[Analytics Sink] + OS[(OpenSearch)] + + subgraph Dashboard[OpenSearch Dashboards] + V[Visualizations] + A[Alerts] + Q[Queries] + end + + N -->|results port| AS + T -->|results port| AS + S -->|results port| AS + AS --> OS + OS --> Dashboard +``` + +Each scanner outputs findings through its `results` port, which connects to the Analytics Sink. The sink indexes each finding as a separate document with workflow metadata. + +--- + +## Document Structure + +Indexed documents follow this structure: + +```json +{ + "check_id": "DB_RLS_DISABLED", + "severity": "CRITICAL", + "title": "RLS Disabled on Table: users", + "resource": "public.users", + "metadata": "{\"schema\":\"public\",\"table\":\"users\"}", + "scanner": "supabase-scanner", + "asset_key": "abcdefghij1234567890", + "finding_hash": "a1b2c3d4e5f67890", + "shipsec": { + "organization_id": "org_123", + "run_id": "shipsec-run-xxx", + "workflow_id": "d1d33161-929f-4af4-9a64-xxx", + "workflow_name": "Supabase Security Audit", + "component_id": "core.analytics.sink", + "node_ref": "analytics-sink-1" + }, + "@timestamp": "2025-01-21T10:30:00.000Z" +} +``` + +### Field Categories + +| Category | Fields | Description | +|----------|--------|-------------| +| Finding data | `check_id`, `severity`, `title`, etc. | Scanner-specific fields at root level | +| Asset tracking | `scanner`, `asset_key`, `finding_hash` | Required fields for analytics | +| Workflow context | `shipsec.*` | Automatic metadata from the workflow | +| Timestamp | `@timestamp` | Indexing timestamp | + + + Nested objects in findings are automatically serialized to JSON strings to prevent OpenSearch field explosion (1000 field limit). + + +--- + +## `shipsec` context fields + +The Analytics Sink automatically adds workflow context under the `shipsec` namespace: + +| Field | Description | +|-------|-------------| +| `organization_id` | Organization that owns the workflow | +| `run_id` | Unique identifier for this workflow execution | +| `workflow_id` | ID of the workflow definition | +| `workflow_name` | Human-readable workflow name | +| `component_id` | Component type (always `core.analytics.sink`) | +| `node_ref` | Node reference in the workflow graph | +| `asset_key` | Auto-detected or specified asset identifier | + +--- + +## Finding Hash for Deduplication + +The `finding_hash` is a stable 16-character identifier that enables tracking findings across workflow runs. + +### Purpose + +- **New vs recurring**: Determine if a finding appeared before +- **First-seen / last-seen**: Track when findings were first and last detected +- **Resolution tracking**: Findings that stop appearing may be resolved +- **Deduplication**: Remove duplicates in dashboards across runs + +### Generation + +Each scanner generates the hash from key identifying fields: + +| Scanner | Hash Fields | +|---------|-------------| +| Nuclei | `templateId + host + matchedAt` | +| TruffleHog | `DetectorType + Redacted + filePath` | +| Supabase Scanner | `check_id + projectRef + resource` | + +Fields are normalized (lowercase, trimmed) and hashed with SHA-256, truncated to 16 hex characters. + +--- + +## Querying Data + +### Basic Queries (KQL) + +``` +# Find all findings for an asset +asset_key: "api.example.com" + +# Filter by severity +severity: "CRITICAL" OR severity: "HIGH" + +# Filter by scanner +scanner: "nuclei" + +# All findings from a specific workflow run +shipsec.run_id: "shipsec-run-abc123" + +# Filter by organization +shipsec.organization_id: "org_123" + +# Filter by workflow +shipsec.workflow_id: "d1d33161-929f-4af4-9a64-xxx" +``` + +### Tracking Findings Over Time + +``` +# Track a specific finding across runs +finding_hash: "a1b2c3d4e5f67890" + +# Find recurring findings (multiple runs) +# Use aggregation: group by finding_hash, count occurrences +``` + +### Common Aggregations + +| Aggregation | Use Case | +|-------------|----------| +| `terms` on `severity` | Count findings by severity | +| `terms` on `scanner` | Count findings by scanner | +| `terms` on `asset_key` | Most vulnerable assets | +| `date_histogram` on `@timestamp` | Findings over time | +| `cardinality` on `finding_hash` | Unique findings count | + +--- + +## Setting Up OpenSearch + +### Environment Variables + +Set these in your `worker/.env`: + +```bash +OPENSEARCH_URL=http://localhost:9200 +OPENSEARCH_USERNAME=admin +OPENSEARCH_PASSWORD=admin +``` + +Set this in your `frontend/.env`: + +```bash +VITE_OPENSEARCH_DASHBOARDS_URL=http://localhost:5601 +``` + +### Docker Compose + +The infrastructure stack includes OpenSearch and OpenSearch Dashboards: + +```bash +docker compose -f docker/docker-compose.infra.yml up -d opensearch opensearch-dashboards +``` + +### Index Pattern + +After indexing data, create an index pattern in OpenSearch Dashboards: + +1. Go to **Dashboards Management** > **Index Patterns** +2. Create pattern: `security-findings-*` +3. Select `@timestamp` as the time field +4. Click **Create index pattern** + + + If you don't see `shipsec.*` fields in Available Fields after indexing, refresh the index pattern field list in Dashboards Management. + + +--- + +## Analytics API Limits + +The analytics query API enforces sane bounds to protect OpenSearch: + +- `size` must be a non-negative integer and is capped at **1000** +- `from` must be a non-negative integer and is capped at **10000** + +Requests above these limits return `400 Bad Request`. + +## Analytics Settings Updates + +The analytics settings API supports partial updates: + +- `analyticsRetentionDays` is optional +- `subscriptionTier` is optional + +Omit fields you don’t want to change. The backend validates retention days only when provided. + +--- + +## Using Analytics Sink + +### Basic Workflow + +1. Add a security scanner to your workflow (Nuclei, TruffleHog, etc.) +2. Add an Analytics Sink component +3. Connect the scanner's `results` port to the Analytics Sink's `data` input +4. Run the workflow + +### Component Parameters + +| Parameter | Description | +|-----------|-------------| +| **Index Suffix** | Custom suffix for the index name. Defaults to slugified workflow name. | +| **Asset Key Field** | Field to use as asset identifier. Auto-detect checks: asset_key > host > domain > subdomain > url > ip > asset > target | +| **Custom Field Name** | Custom field when Asset Key Field is "custom" | +| **Fail on Error** | When enabled, workflow stops if indexing fails. Default: fire-and-forget. | + +### Fire-and-Forget Mode + +By default, Analytics Sink operates in fire-and-forget mode: +- Indexing errors are logged but don't stop the workflow +- Useful for non-critical analytics that shouldn't block security scans +- Enable "Fail on Error" for strict indexing requirements + +--- + +## View Analytics Button + +The workflow builder includes a "View Analytics" button that opens OpenSearch Dashboards with pre-filtered data: + +- **When a run is selected**: Filters by `shipsec.run_id` +- **When no run is selected**: Filters by `shipsec.workflow_id` +- **Time range**: Last 7 days + +The button only appears when `VITE_OPENSEARCH_DASHBOARDS_URL` is configured. + +--- + +## Index Naming + +Indexes follow the pattern: `security-findings-{orgId}-{suffix}` + +| Component | Value | +|-----------|-------| +| `orgId` | Organization ID from workflow context | +| `suffix` | Custom suffix parameter, or date (`YYYY.MM.DD`) | + +Example: `security-findings-org_abc123-2025.01.21` + +--- + +## Building Dashboards + +### Recommended Visualizations + +| Visualization | Description | +|---------------|-------------| +| **Findings Over Time** | Line chart with `@timestamp` on X-axis, count on Y-axis | +| **Severity Distribution** | Pie chart with `terms` on `severity` | +| **Top Vulnerable Assets** | Bar chart with `terms` on `asset_key` | +| **Findings by Scanner** | Bar chart with `terms` on `scanner` | +| **New vs Recurring** | Use `finding_hash` cardinality vs total count | + +### Alert Examples + +| Alert | Query | +|-------|-------| +| Critical finding detected | `severity: "CRITICAL"` | +| New secrets exposed | `scanner: "trufflehog"` | +| RLS disabled | `check_id: "DB_RLS_DISABLED"` | + +--- + +## Troubleshooting + +### Data not appearing in OpenSearch + +1. Check worker logs for `[OpenSearchIndexer]` messages +2. Verify `OPENSEARCH_URL` is set in worker environment +3. Ensure Analytics Sink is connected to a `results` port +4. Check if OpenSearch is running: `curl http://localhost:9200/_cluster/health` + +### Field mapping errors + +If you see "Limit of total fields [1000] has been exceeded": +1. Delete the problematic index: `curl -X DELETE "http://localhost:9200/security-findings-*"` +2. Re-run the workflow (new index will use correct schema) + +### shipsec fields not visible + +1. Fields starting with `_` are hidden in OpenSearch UI +2. Ensure you're using `shipsec.*` (no underscore prefix) +3. Refresh the index pattern in Dashboards Management + +### pm2 not loading environment variables + +pm2's `env_file` doesn't auto-inject variables. The worker uses a custom `loadWorkerEnv()` function in `pm2.config.cjs`. After changing `worker/.env`: + +```bash +pm2 delete shipsec-worker +pm2 start pm2.config.cjs --only shipsec-worker +``` + +--- + +## Best Practices + +### Do + +- Connect `results` ports (not `rawOutput`) to Analytics Sink +- Use meaningful index suffixes for organization +- Monitor index size and implement retention policies +- Create saved searches for common queries + +### Don't + +- Don't connect deeply nested JSON (causes field explosion) +- Don't rely on analytics for critical workflow logic +- Don't store PII or secrets in indexed findings + +--- + +## Component Author Guidelines + +If you're building a security scanner component, see [Analytics Output Port](/development/component-development#analytics-output-port-results) for implementation details on adding the `results` output port. + +--- + +## Related + +- [Component Development](/development/component-development) - Building scanner components +- [Core Components](/components/core) - Analytics Sink reference +- [Analytics (PostHog)](/development/analytics) - Product analytics (different system) diff --git a/docs/docs.json b/docs/docs.json index d5d0f744..046e4014 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -42,6 +42,7 @@ "pages": [ "development/component-development", "development/isolated-volumes", + "development/workflow-analytics", "development/analytics", "development/release-process" ] diff --git a/docs/installation.mdx b/docs/installation.mdx index c99d868f..2e56a34d 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -74,6 +74,68 @@ The `just dev` command automatically: --- +## Analytics Stack (Optional) + +ShipSec Studio includes an optional analytics stack powered by OpenSearch for indexing and visualizing workflow execution data. + +### Starting the Analytics Stack + +The analytics services are included in the infrastructure docker-compose file: + +```bash +# Start infrastructure including OpenSearch +just infra up +``` + +This will start: +- **OpenSearch** on port `9200` - Search and analytics engine +- **OpenSearch Dashboards** on port `5601` - Visualization and query UI + +### Configuring Analytics + +Add these environment variables to your backend and worker `.env` files: + +```bash +# Backend (.env) +OPENSEARCH_URL=http://localhost:9200 +OPENSEARCH_USERNAME=admin +OPENSEARCH_PASSWORD=admin +OPENSEARCH_DASHBOARDS_URL=http://localhost:5601 + +# Frontend (.env) +VITE_OPENSEARCH_DASHBOARDS_URL=http://localhost:5601 +``` + +### Setting Up the Index Template + +After starting OpenSearch, create the security findings index template: + +```bash +cd backend +bun run setup:opensearch +``` + +This creates the `security-findings-*` index template with proper mappings for workflow execution data. + +### Using Analytics + +1. **Analytics Sink Component**: Add the "Analytics Sink" component to your workflows to index output data +2. **Dashboards Link**: Access OpenSearch Dashboards from the Studio sidebar +3. **Query API**: Use the `/api/analytics/query` endpoint to query indexed data programmatically + +### Analytics Service Endpoints + +| Service | URL | Notes | +|---------|-----|-------| +| OpenSearch | http://localhost:9200 | Search engine API | +| OpenSearch Dashboards | http://localhost:5601 | Visualization UI | + + + The analytics stack is optional. If OpenSearch is not configured, the Analytics Sink component will gracefully skip indexing and log a warning. + + +--- + ## Production Deployment For production, use the Docker-based deployment: diff --git a/e2e-tests/analytics.test.ts b/e2e-tests/analytics.test.ts new file mode 100644 index 00000000..eed2911d --- /dev/null +++ b/e2e-tests/analytics.test.ts @@ -0,0 +1,274 @@ +/** + * E2E Tests - Workflow Analytics + * + * Validates analytics sink ingestion into OpenSearch and analytics query API. + * + * Requirements: + * - Backend API running on http://localhost:3211 + * - Worker running and component registry loaded + * - OpenSearch running on http://localhost:9200 + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; + +const API_BASE = 'http://localhost:3211/api/v1'; +const OPENSEARCH_URL = process.env.OPENSEARCH_URL ?? 'http://localhost:9200'; +const HEADERS = { + 'Content-Type': 'application/json', + 'x-internal-token': 'local-internal-token', +}; + +const runE2E = process.env.RUN_E2E === 'true'; + +const servicesAvailableSync = (() => { + if (!runE2E) return false; + try { + const backend = Bun.spawnSync( + [ + 'curl', + '-sf', + '--max-time', + '1', + '-H', + `x-internal-token: ${HEADERS['x-internal-token']}`, + `${API_BASE}/health`, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ); + if (backend.exitCode !== 0) return false; + + const opensearch = Bun.spawnSync( + ['curl', '-sf', '--max-time', '1', `${OPENSEARCH_URL}/_cluster/health`], + { stdout: 'pipe', stderr: 'pipe' }, + ); + return opensearch.exitCode === 0; + } catch { + return false; + } +})(); + +async function checkServicesAvailable(): Promise { + if (!runE2E) return false; + try { + const healthRes = await fetch(`${API_BASE}/health`, { + headers: HEADERS, + signal: AbortSignal.timeout(2000), + }); + if (!healthRes.ok) return false; + + const osRes = await fetch(`${OPENSEARCH_URL}/_cluster/health`, { + signal: AbortSignal.timeout(2000), + }); + return osRes.ok; + } catch { + return false; + } +} + +const e2eDescribe = runE2E && servicesAvailableSync ? describe : describe.skip; + +function e2eTest( + name: string, + optionsOrFn: { timeout?: number } | (() => void | Promise), + fn?: () => void | Promise, +): void { + if (runE2E && servicesAvailableSync) { + if (typeof optionsOrFn === 'function') { + test(name, optionsOrFn); + } else if (fn) { + (test as any)(name, optionsOrFn, fn); + } + } else { + const actualFn = typeof optionsOrFn === 'function' ? optionsOrFn : fn!; + test.skip(name, actualFn); + } +} + +async function pollRunStatus(runId: string, timeoutMs = 180000): Promise<{ status: string }> { + const startTime = Date.now(); + const pollInterval = 1000; + + while (Date.now() - startTime < timeoutMs) { + const res = await fetch(`${API_BASE}/workflows/runs/${runId}/status`, { headers: HEADERS }); + const s = await res.json(); + if (['COMPLETED', 'FAILED', 'CANCELLED'].includes(s.status)) { + return s; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error(`Workflow run ${runId} did not complete within ${timeoutMs}ms`); +} + +async function createWorkflow(workflow: any): Promise { + const res = await fetch(`${API_BASE}/workflows`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(workflow), + }); + if (!res.ok) { + const error = await res.text(); + throw new Error(`Workflow creation failed: ${res.status} - ${error}`); + } + const { id } = await res.json(); + return id; +} + +async function runWorkflow(workflowId: string, inputs: Record = {}): Promise { + const res = await fetch(`${API_BASE}/workflows/${workflowId}/run`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ inputs }), + }); + if (!res.ok) { + const error = await res.text(); + throw new Error(`Workflow run failed: ${res.status} - ${error}`); + } + const { runId } = await res.json(); + return runId; +} + +async function pollOpenSearch(runId: string, timeoutMs = 60000): Promise { + const startTime = Date.now(); + const pollInterval = 2000; + + const query = { + size: 1, + query: { + term: { + 'shipsec.run_id': runId, + }, + }, + }; + + while (Date.now() - startTime < timeoutMs) { + const res = await fetch(`${OPENSEARCH_URL}/security-findings-*/_search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(query), + }); + + if (res.ok) { + const body = await res.json(); + const total = + typeof body?.hits?.total === 'object' + ? body.hits.total.value ?? 0 + : body?.hits?.total ?? 0; + + if (total > 0) { + return total; + } + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error(`OpenSearch documents not indexed for runId ${runId} within ${timeoutMs}ms`); +} + +let servicesAvailable = false; + +beforeAll(async () => { + if (!runE2E) { + console.log('\n Analytics E2E: Skipping (RUN_E2E not set)'); + return; + } + + console.log('\n Analytics E2E: Verifying services...'); + servicesAvailable = await checkServicesAvailable(); + if (!servicesAvailable) { + console.log(' Required services are not available. Tests will be skipped.'); + return; + } + console.log(' Backend API and OpenSearch are running'); +}); + +afterAll(async () => { + console.log('\n Cleanup: Run "bun e2e-tests/cleanup.ts" to remove test workflows'); +}); + +e2eDescribe('Workflow Analytics E2E Tests', () => { + e2eTest('Analytics Sink indexes results into OpenSearch', { timeout: 180000 }, async () => { + console.log('\n Test: Analytics Sink indexing'); + + const workflow = { + name: 'Test: Analytics Sink E2E', + nodes: [ + { + id: 'start', + type: 'core.workflow.entrypoint', + position: { x: 0, y: 0 }, + data: { + label: 'Start', + config: { params: { runtimeInputs: [] } }, + }, + }, + { + id: 'fixture', + type: 'test.analytics.fixture', + position: { x: 200, y: 0 }, + data: { + label: 'Analytics Fixture', + config: { + params: {}, + }, + }, + }, + { + id: 'sink', + type: 'core.analytics.sink', + position: { x: 400, y: 0 }, + data: { + label: 'Analytics Sink', + config: { + params: { + dataInputs: [ + { id: 'results', label: 'Results', sourceTag: 'fixture' }, + ], + assetKeyField: 'auto', + failOnError: true, + }, + }, + }, + }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'fixture' }, + { id: 'e2', source: 'fixture', target: 'sink' }, + { + id: 'e3', + source: 'fixture', + target: 'sink', + sourceHandle: 'results', + targetHandle: 'results', + }, + ], + }; + + const workflowId = await createWorkflow(workflow); + const runId = await runWorkflow(workflowId); + + const status = await pollRunStatus(runId); + expect(status.status).toBe('COMPLETED'); + + const total = await pollOpenSearch(runId); + expect(total).toBeGreaterThan(0); + + const analyticsRes = await fetch(`${API_BASE}/analytics/query`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + query: { + term: { + 'shipsec.run_id': runId, + }, + }, + size: 5, + }), + }); + + expect(analyticsRes.ok).toBe(true); + const analyticsBody = await analyticsRes.json(); + expect(analyticsBody.total).toBeGreaterThan(0); + }); +}); diff --git a/frontend/.env.example b/frontend/.env.example index 7bc89a98..8089b93e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,5 @@ -# API Configuration -VITE_API_URL=http://localhost:3211 +# API Configuration (nginx /api) +VITE_API_URL=http://localhost # Application Configuration VITE_APP_NAME=Security Workflow Builder @@ -21,3 +21,8 @@ VITE_PUBLIC_POSTHOG_HOST= # Logo.dev public key for brand logos VITE_LOGO_DEV_PUBLIC_KEY= + +# OpenSearch Dashboards (Optional - for Analytics features) +# Leave empty to hide Dashboards navigation link +# For dev/prod: http://localhost/analytics (nginx in dev/prod) +VITE_OPENSEARCH_DASHBOARDS_URL=http://localhost/analytics diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54d9a741..87fea899 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { WebhookEditorPage } from '@/pages/WebhookEditorPage'; import { SchedulesPage } from '@/pages/SchedulesPage'; import { ActionCenterPage } from '@/pages/ActionCenterPage'; import { RunRedirect } from '@/pages/RunRedirect'; +import { AnalyticsSettingsPage } from '@/pages/AnalyticsSettingsPage'; import { ToastProvider } from '@/components/ui/toast-provider'; import { AppLayout } from '@/components/layout/AppLayout'; import { AuthProvider } from '@/auth/auth-context'; @@ -85,6 +86,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index 062f7932..1fc0207b 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -93,7 +93,17 @@ const LocalAuthProvider: FrontendAuthProviderComponent = ({ signUp: () => { console.warn('Local auth: signUp not implemented'); }, - signOut: () => { + signOut: async () => { + // Clear session cookie via backend logout endpoint + // Use relative path to ensure we hit the same origin as login + try { + await fetch('/api/v1/auth/logout', { + method: 'POST', + credentials: 'include', + }); + } catch (error) { + console.warn('Failed to clear session cookie:', error); + } // Clear admin credentials from store useAuthStore.getState().clear(); }, diff --git a/frontend/src/components/auth/AdminLoginForm.tsx b/frontend/src/components/auth/AdminLoginForm.tsx index 3840dff6..094e3311 100644 --- a/frontend/src/components/auth/AdminLoginForm.tsx +++ b/frontend/src/components/auth/AdminLoginForm.tsx @@ -3,9 +3,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useAuthStore } from '@/store/authStore'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { LogIn } from 'lucide-react'; -import { API_V1_URL } from '@/services/api'; export function AdminLoginForm() { const [username, setUsername] = useState(''); @@ -14,6 +13,8 @@ export function AdminLoginForm() { const [isLoading, setIsLoading] = useState(false); const setAdminCredentials = useAuthStore((state) => state.setAdminCredentials); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get('returnTo'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -31,28 +32,42 @@ export function AdminLoginForm() { setIsLoading(true); try { - // Test the credentials by making a simple API call - // If it fails, the credentials are invalid + // Validate credentials and set session cookie via /auth/login endpoint + // This sets an httpOnly cookie for browser navigation to protected routes (e.g., /analytics/) + // Use relative path to ensure cookie is set via nginx (same origin as /analytics/* routes) const credentials = btoa(`${trimmedUsername}:${trimmedPassword}`); - const response = await fetch(`${API_V1_URL}/workflows`, { + const loginResponse = await fetch('/api/v1/auth/login', { + method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/json', }, + credentials: 'include', // Important: include cookies in the response }); - if (!response.ok) { - if (response.status === 401) { + if (!loginResponse.ok) { + if (loginResponse.status === 401) { throw new Error('Invalid username or password'); } - throw new Error(`Authentication failed: ${response.status} ${response.statusText}`); + throw new Error( + `Authentication failed: ${loginResponse.status} ${loginResponse.statusText}`, + ); } - // Store credentials only after verification succeeds + // Store credentials for API requests (Basic auth header) setAdminCredentials(trimmedUsername, trimmedPassword); - // Success - navigate to home - navigate('/'); + // Success - redirect to returnTo URL or home + if (returnTo) { + // For paths like /analytics/*, use full page navigation since they're served by nginx + if (returnTo.startsWith('/analytics')) { + window.location.href = returnTo; + } else { + navigate(returnTo); + } + } else { + navigate('/'); + } } catch (err) { // Clear credentials on error useAuthStore.getState().clear(); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 1204679e..df723111 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -24,6 +24,7 @@ import { Zap, Webhook, ServerCog, + BarChart3, Settings, ChevronDown, } from 'lucide-react'; @@ -293,6 +294,21 @@ export function AppLayout({ children }: AppLayoutProps) { href: '/artifacts', icon: Archive, }, + { + name: 'Analytics Settings', + href: '/analytics-settings', + icon: Settings, + }, + ...(env.VITE_OPENSEARCH_DASHBOARDS_URL + ? [ + { + name: 'Dashboards', + href: env.VITE_OPENSEARCH_DASHBOARDS_URL, + icon: BarChart3, + external: true, + }, + ] + : []), ]; const settingsItems = [ @@ -411,6 +427,50 @@ export function AppLayout({ children }: AppLayoutProps) { {navigationItems.map((item) => { const Icon = item.icon; const active = isActive(item.href); + const isExternal = 'external' in item && item.external; + const openInNewTab = isExternal && 'newTab' in item ? item.newTab !== false : true; + + // Render external link + if (isExternal) { + return ( + { + // Close sidebar on mobile after clicking + if (isMobile) { + setSidebarOpen(false); + } + }} + > + + + + {item.name} + + + + ); + } + + // Render internal link (React Router) return ( void; onSave: () => Promise | void; @@ -47,6 +50,7 @@ const DEFAULT_WORKFLOW_NAME = 'Untitled Workflow'; export function TopBar({ workflowId, selectedRunId, + selectedRunStatus, onRun, onSave, onImport, @@ -454,6 +458,45 @@ export function TopBar({ )} + {env.VITE_OPENSEARCH_DASHBOARDS_URL && + workflowId && + (!selectedRunId || (selectedRunStatus && selectedRunStatus !== 'RUNNING')) && ( + + )} + + + + {inputs.length === 0 ? ( +
+

No data inputs configured

+

+ Configure input ports to receive analytics results from different scanner components. + Each input creates a corresponding input port on this node. +

+ +
+ ) : ( +
+ {inputs.map((input, index) => ( +
+ {/* Header with drag handle and delete */} +
+ + Input {index + 1} + +
+ + {/* ID Field */} +
+ + updateInput(index, 'id', e.target.value)} + placeholder="e.g., nucleiResults" + className="h-8 text-xs font-mono" + /> +

+ Unique identifier (becomes input port ID) +

+
+ + {/* Label Field */} +
+ + updateInput(index, 'label', e.target.value)} + placeholder="e.g., Nuclei Results" + className="h-8 text-xs" + /> +

Display name in workflow editor

+
+ + {/* Source Tag Field */} +
+ + updateInput(index, 'sourceTag', e.target.value)} + placeholder="e.g., nuclei-scan" + className="h-8 text-xs font-mono" + /> +

+ Added to indexed documents as 'source_input' for filtering in dashboards + (optional) +

+
+ + {/* Input Port Preview */} +
+
+
+ + Input port: {input.id} + +
+
+
+ ))} +
+ )} + + {/* Summary */} + {inputs.length > 0 && ( +
+

+ {inputs.length} data input + {inputs.length !== 1 ? 's' : ''} configured +

+

+ {inputs.length} input port{inputs.length !== 1 ? 's' : ''} will be created on this node +

+
+ )} + + ); +} diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index b39537fc..a9eb9fa5 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -9,6 +9,7 @@ interface FrontendEnv { VITE_ENABLE_CONNECTIONS: boolean; VITE_ENABLE_IT_OPS: boolean; VITE_API_URL: string; + VITE_OPENSEARCH_DASHBOARDS_URL: string; } export const env: FrontendEnv = { @@ -19,4 +20,6 @@ export const env: FrontendEnv = { VITE_ENABLE_CONNECTIONS: import.meta.env.VITE_ENABLE_CONNECTIONS === 'true', VITE_ENABLE_IT_OPS: import.meta.env.VITE_ENABLE_IT_OPS === 'true', VITE_API_URL: (import.meta.env.VITE_API_URL as string | undefined) ?? '', + VITE_OPENSEARCH_DASHBOARDS_URL: + (import.meta.env.VITE_OPENSEARCH_DASHBOARDS_URL as string | undefined) ?? '', }; diff --git a/frontend/src/features/workflow-builder/WorkflowBuilder.tsx b/frontend/src/features/workflow-builder/WorkflowBuilder.tsx index 53632e79..c2fdb0bc 100644 --- a/frontend/src/features/workflow-builder/WorkflowBuilder.tsx +++ b/frontend/src/features/workflow-builder/WorkflowBuilder.tsx @@ -905,6 +905,7 @@ function WorkflowBuilderContent() { state.roles); + const canManageSettings = hasAdminRole(roles); + const isReadOnly = !canManageSettings; + + // Mock data - will be replaced with actual API calls in US-013 + const [currentTier] = useState('free'); + const [retentionPeriod, setRetentionPeriod] = useState('30'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [storageUsage] = useState<{ used: string; total: string } | null>(null); + + const tierInfo = TIER_LIMITS[currentTier]; + const maxAllowedRetention = tierInfo.maxRetentionDays; + + // Filter retention periods based on tier limit + const availableRetentionPeriods = RETENTION_PERIODS.filter( + (period) => period.days <= maxAllowedRetention, + ); + + useEffect(() => { + // TODO: US-013 will implement API call to fetch current settings + // fetchSettings().catch(console.error); + }, []); + + const handleSave = async () => { + if (isReadOnly) return; + + setError(null); + setSuccessMessage(null); + setIsSubmitting(true); + + try { + // TODO: US-013 will implement API call to save settings + // await api.analytics.updateSettings({ retentionDays: parseInt(retentionPeriod) }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 500)); + + setSuccessMessage('Analytics settings updated successfully'); + + // Clear success message after 5 seconds + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update analytics settings'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+

Analytics Settings

+

+ Configure data retention and storage settings for workflow analytics +

+
+
+ + {/* Read-only warning */} + {isReadOnly && ( +
+
+ +
+

+ Read-Only Access +

+

+ You need admin privileges to modify analytics settings. +

+
+
+
+ )} + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Success Message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Settings Cards */} +
+ {/* Subscription Tier Card */} +
+
+
+ +
+
+

Subscription Tier

+

+ Your current subscription tier and analytics limits +

+
+ + {tierInfo.name} + + + Maximum retention: {maxAllowedRetention} days + +
+
+
+
+ + {/* Data Retention Card */} +
+
+
+ +
+
+

Data Retention Period

+

+ How long to keep analytics data before automatic deletion +

+ +
+ + +

+ Analytics data older than {retentionPeriod} days will be automatically deleted. +

+
+ +
+ +
+
+
+
+ + {/* Storage Usage Card */} +
+
+
+ +
+
+

Storage Usage

+

+ Current storage consumption for analytics data +

+ +
+ {storageUsage ? ( +
+
+ Used + {storageUsage.used} +
+
+ Total Available + {storageUsage.total} +
+ {/* Progress bar could be added here */} +
+ ) : ( +
+ Storage usage information will be available once analytics data is collected. +
+ )} +
+
+
+
+
+ + {/* Info Box */} +
+

About Analytics Data

+
    +
  • + • Analytics data is indexed from workflow executions using the Analytics Sink + component +
  • +
  • • Data includes security findings, scan results, and other workflow outputs
  • +
  • • You can query this data via the API or view it in OpenSearch Dashboards
  • +
  • • Retention settings apply organization-wide and cannot exceed your tier limit
  • +
+
+
+
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e8818ab3..00d75468 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -23,9 +23,21 @@ export default defineConfig({ host: '0.0.0.0', port: 5173, open: false, - allowedHosts: ['studio.shipsec.ai'], + allowedHosts: ['studio.shipsec.ai', 'frontend'], + proxy: { + '/api': { + target: 'http://localhost:3211', + changeOrigin: true, + secure: false, + }, + '/analytics': { + target: 'http://localhost:5601', + changeOrigin: true, + secure: false, + }, + }, }, preview: { - allowedHosts: ['studio.shipsec.ai'], + allowedHosts: ['studio.shipsec.ai', 'frontend'], }, }) diff --git a/packages/component-sdk/src/analytics.ts b/packages/component-sdk/src/analytics.ts new file mode 100644 index 00000000..2035eb2a --- /dev/null +++ b/packages/component-sdk/src/analytics.ts @@ -0,0 +1,66 @@ +/** + * Analytics helpers for component authors. + * + * These utilities help components output structured findings + * that can be indexed into OpenSearch via the Analytics Sink. + */ + +import { createHash } from 'crypto'; +import { z } from 'zod'; +import { withPortMeta } from './port-meta'; + +// Analytics Results Contract +export const analyticsResultContractName = 'core.analytics.result.v1'; + +export const severitySchema = z.enum(['critical', 'high', 'medium', 'low', 'info', 'none']); + +export const analyticsResultSchema = () => + withPortMeta( + z + .object({ + scanner: z.string().describe('Scanner/component that produced this result'), + finding_hash: z.string().describe('Stable 16-char hash for deduplication'), + severity: severitySchema.describe('Finding severity level, use "none" if not applicable'), + asset_key: z + .string() + .optional() + .describe('Primary asset identifier (auto-detected if missing)'), + }) + .passthrough(), // Allow scanner-specific fields + { schemaName: analyticsResultContractName } + ); + +export type AnalyticsResult = z.infer>; +export type Severity = z.infer; + +/** + * Generate a stable hash for finding deduplication. + * + * The hash is used to track findings across workflow runs: + * - Identify new vs recurring findings + * - Calculate first-seen / last-seen timestamps + * - Deduplicate findings in dashboards + * + * @param fields - Key identifying fields of the finding (e.g., templateId, host, matchedAt) + * @returns 16-character hex string (SHA-256 truncated) + * + * @example + * ```typescript + * // Nuclei scanner + * const hash = generateFindingHash(finding.templateId, finding.host, finding.matchedAt); + * + * // TruffleHog scanner + * const hash = generateFindingHash(secret.DetectorType, secret.Redacted, filePath); + * + * // Supabase scanner + * const hash = generateFindingHash(check.check_id, projectRef, check.resource); + * ``` + */ +export function generateFindingHash( + ...fields: (string | undefined | null)[] +): string { + const normalized = fields + .map((f) => (f ?? '').toLowerCase().trim()) + .join('|'); + return createHash('sha256').update(normalized).digest('hex').slice(0, 16); +} diff --git a/packages/component-sdk/src/context.ts b/packages/component-sdk/src/context.ts index 63185b3a..e267f869 100644 --- a/packages/component-sdk/src/context.ts +++ b/packages/component-sdk/src/context.ts @@ -45,10 +45,13 @@ export interface CreateContextOptions { logCollector?: (entry: LogEventInput) => void; terminalCollector?: (chunk: TerminalChunkInput) => void; agentTracePublisher?: AgentTracePublisher; + workflowId?: string; + workflowName?: string; + organizationId?: string | null; } export function createExecutionContext(options: CreateContextOptions): ExecutionContext { - const { runId, componentRef, metadata: metadataInput, storage, secrets, artifacts, trace, logCollector, terminalCollector, agentTracePublisher } = + const { runId, componentRef, metadata: metadataInput, storage, secrets, artifacts, trace, logCollector, terminalCollector, agentTracePublisher, workflowId, workflowName, organizationId } = options; const metadata = createMetadata(runId, componentRef, metadataInput); const scopedTrace = trace ? createScopedTrace(trace, metadata) : undefined; @@ -145,6 +148,9 @@ export function createExecutionContext(options: CreateContextOptions): Execution terminalCollector, metadata, agentTracePublisher, + workflowId, + workflowName, + organizationId, http: undefined as unknown as ExecutionContext['http'], }; diff --git a/packages/component-sdk/src/index.ts b/packages/component-sdk/src/index.ts index 7e914b2a..edd46ce6 100644 --- a/packages/component-sdk/src/index.ts +++ b/packages/component-sdk/src/index.ts @@ -36,3 +36,6 @@ export * from './zod-parameters'; export * from './json-schema'; export * from './schema-validation'; export * from './zod-coerce'; + +// Analytics helpers for component authors +export * from './analytics'; diff --git a/packages/component-sdk/src/types.ts b/packages/component-sdk/src/types.ts index 19ae0c78..c9cd317b 100644 --- a/packages/component-sdk/src/types.ts +++ b/packages/component-sdk/src/types.ts @@ -271,7 +271,8 @@ export type ComponentParameterType = | 'artifact' | 'variable-list' | 'form-fields' - | 'selection-options'; + | 'selection-options' + | 'analytics-inputs'; export interface ComponentParameterOption { label: string; @@ -377,6 +378,11 @@ export interface ExecutionContext { metadata: ExecutionContextMetadata; agentTracePublisher?: AgentTracePublisher; + // Workflow context (optional, available when running in workflow) + workflowId?: string; + workflowName?: string; + organizationId?: string | null; + // Service interfaces - implemented by adapters storage?: IFileStorageService; secrets?: ISecretsService; diff --git a/worker/.env.example b/worker/.env.example index 65c71cc9..a1c6b7f9 100644 --- a/worker/.env.example +++ b/worker/.env.example @@ -24,5 +24,16 @@ LOKI_PASSWORD= # Generate with: openssl rand -base64 24 | head -c 32 SECRET_STORE_MASTER_KEY=CHANGE_ME_32_CHAR_SECRET_KEY!!!! +# OpenSearch Configuration (Optional - for Analytics Sink component) +# Leave empty to disable analytics indexing +OPENSEARCH_URL=http://localhost:9200 +OPENSEARCH_USERNAME= +OPENSEARCH_PASSWORD= +# OpenSearch Dashboards URL for auto-refreshing index patterns after indexing +# - Local dev (worker outside Docker): http://localhost:5601/analytics +# - Docker (worker inside Docker): http://opensearch-dashboards:5601/analytics (set in docker-compose) +# Note: Include the basePath (/analytics) as configured in opensearch_dashboards.yml +OPENSEARCH_DASHBOARDS_URL=http://localhost:5601/analytics + # Kafka / Redpanda configuration for log and event ingestion LOG_KAFKA_BROKERS=localhost:9092 diff --git a/worker/package.json b/worker/package.json index 191d1361..632d0914 100644 --- a/worker/package.json +++ b/worker/package.json @@ -27,6 +27,7 @@ "@grpc/grpc-js": "^1.14.3", "@modelcontextprotocol/sdk": "^1.25.1", "@okta/okta-sdk-nodejs": "^7.3.0", + "@opensearch-project/opensearch": "^3.5.1", "@shipsec/component-sdk": "*", "@shipsec/contracts": "*", "@shipsec/shared": "*", diff --git a/worker/src/components/core/analytics-sink.ts b/worker/src/components/core/analytics-sink.ts new file mode 100644 index 00000000..113e25c2 --- /dev/null +++ b/worker/src/components/core/analytics-sink.ts @@ -0,0 +1,378 @@ +import { z } from 'zod'; +import { + componentRegistry, + defineComponent, + inputs, + outputs, + parameters, + port, + param, + analyticsResultSchema, + withPortMeta, + ValidationError, +} from '@shipsec/component-sdk'; + +// Schema for defining a data input port +const dataInputDefinitionSchema = z.object({ + id: z.string().describe('Unique identifier for this input (becomes input port ID)'), + label: z.string().describe('Display label for the input in the UI'), + sourceTag: z + .string() + .optional() + .describe('Tag added to indexed documents for filtering by source in dashboards'), +}); + +type DataInputDefinition = z.infer; + +function toWorkflowSlug(value?: string | null): string | undefined { + if (!value) return undefined; + const slug = value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug.length > 0 ? slug : undefined; +} + +// Base input schema - will be extended by resolvePorts +const baseInputSchema = inputs({}); + +const outputSchema = outputs({ + indexed: port(z.boolean(), { + label: 'Indexed', + description: 'Indicates whether the data was successfully indexed to OpenSearch.', + }), + documentCount: port(z.number(), { + label: 'Document Count', + description: 'Number of documents indexed (1 for objects, array length for arrays).', + }), + indexName: port(z.string(), { + label: 'Index Name', + description: 'Name of the OpenSearch index where data was stored.', + }), +}); + +const parameterSchema = parameters({ + dataInputs: param( + z + .array(dataInputDefinitionSchema) + .default([{ id: 'input1', label: 'Input 1', sourceTag: 'input_1' }]) + .describe('Define multiple data inputs from different scanner components'), + { + label: 'Data Inputs', + editor: 'analytics-inputs', + description: + 'Configure input ports for different scanner results. Each input creates a corresponding input port.', + helpText: + 'Each input accepts AnalyticsResult[] and can be tagged for filtering in dashboards.', + }, + ), + indexSuffix: param( + z + .string() + .optional() + .describe( + 'Optional suffix to append to the index name. Defaults to slugified workflow name if not provided.', + ), + { + label: 'Index Suffix', + editor: 'text', + placeholder: 'workflow-slug (default)', + description: + 'Custom suffix for the index name (e.g., "subdomain-enum"). Defaults to slugified workflow name if not provided.', + }, + ), + assetKeyField: param( + z + .enum([ + 'auto', + 'asset_key', + 'host', + 'domain', + 'subdomain', + 'url', + 'ip', + 'asset', + 'target', + 'custom', + ]) + .default('auto') + .describe( + 'Field name to use as the asset_key. Auto-detect checks common fields (asset_key, host, domain, subdomain, url, ip, asset, target) in priority order.', + ), + { + label: 'Asset Key Field', + editor: 'select', + options: [ + { label: 'Auto-detect', value: 'auto' }, + { label: 'asset_key', value: 'asset_key' }, + { label: 'host', value: 'host' }, + { label: 'domain', value: 'domain' }, + { label: 'subdomain', value: 'subdomain' }, + { label: 'url', value: 'url' }, + { label: 'ip', value: 'ip' }, + { label: 'asset', value: 'asset' }, + { label: 'target', value: 'target' }, + { label: 'Custom field name', value: 'custom' }, + ], + description: + 'Specify which field to use as the asset identifier. Auto-detect uses priority: asset_key > host > domain > subdomain > url > ip > asset > target.', + }, + ), + customAssetKeyField: param( + z + .string() + .optional() + .describe('Custom field name to use as asset_key when assetKeyField is set to "custom".'), + { + label: 'Custom Field Name', + editor: 'text', + placeholder: 'e.g., hostname, endpoint, etc.', + description: 'Enter the custom field name to use as the asset identifier.', + visibleWhen: { assetKeyField: 'custom' }, + }, + ), + failOnError: param( + z + .boolean() + .default(false) + .describe( + 'Strict mode: requires all configured inputs to have data and validates all documents before indexing. Default is lenient (fire-and-forget).', + ), + { + label: 'Strict Mode (Fail on Error)', + editor: 'boolean', + description: + 'When enabled: requires ALL configured inputs to have data, validates ALL documents before indexing, and fails the workflow if any check fails. When disabled: skips missing inputs and logs errors without failing.', + }, + ), +}); + +const definition = defineComponent({ + id: 'core.analytics.sink', + label: 'Analytics Sink', + category: 'output', + runner: { kind: 'inline' }, + inputs: baseInputSchema, + outputs: outputSchema, + parameters: parameterSchema, + docs: 'Indexes structured analytics results into OpenSearch for dashboards, queries, and alerts. Configure multiple data inputs to aggregate results from different scanner components. Each input can be tagged with a sourceTag for filtering in dashboards. Supports lenient (fire-and-forget) and strict (all-or-nothing) modes via the failOnError parameter.', + ui: { + slug: 'analytics-sink', + version: '2.0.0', + type: 'output', + category: 'output', + description: + 'Index security findings from multiple scanners into OpenSearch for analytics, dashboards, and alerting.', + icon: 'BarChart3', + author: { + name: 'ShipSecAI', + type: 'shipsecai', + }, + isLatest: true, + deprecated: false, + examples: [ + 'Aggregate findings from Nuclei, Subfinder, and Prowler into a unified security dashboard.', + 'Index subdomain enumeration results for tracking asset discovery over time.', + 'Store vulnerability scan findings for correlation and trend analysis.', + ], + }, + resolvePorts(params: z.infer) { + const dataInputs = Array.isArray(params.dataInputs) ? params.dataInputs : []; + + const inputShape: Record = {}; + + // Create dynamic input ports from dataInputs parameter + for (const input of dataInputs) { + const id = typeof input?.id === 'string' ? input.id.trim() : ''; + if (!id) { + continue; + } + + const label = typeof input?.label === 'string' ? input.label : id; + const sourceTag = typeof input?.sourceTag === 'string' ? input.sourceTag : undefined; + + const description = sourceTag + ? `Analytics results tagged with '${sourceTag}' in indexed documents.` + : `Analytics results from ${label}.`; + + // Each input port accepts an optional array of analytics results + inputShape[id] = withPortMeta(z.array(analyticsResultSchema()).optional(), { + label, + description, + }); + } + + return { + inputs: inputs(inputShape), + outputs: outputSchema, + }; + }, + async execute({ inputs, params }, context) { + const { getOpenSearchIndexer } = await import('../../utils/opensearch-indexer'); + const indexer = getOpenSearchIndexer(); + + const dataInputsMap = new Map( + (params.dataInputs ?? []).map((d) => [d.id, d]), + ); + + // Check if indexing is enabled + if (!indexer.isEnabled()) { + context.logger.debug( + '[Analytics Sink] OpenSearch not configured, skipping indexing (fire-and-forget)', + ); + return { + indexed: false, + documentCount: 0, + indexName: '', + }; + } + + // Validate required workflow context + if (!context.workflowId || !context.workflowName || !context.organizationId) { + const error = new Error( + 'Analytics Sink requires workflow context (workflowId, workflowName, organizationId)', + ); + context.logger.error(`[Analytics Sink] ${error.message}`); + if (params.failOnError) { + throw error; + } + return { + indexed: false, + documentCount: 0, + indexName: '', + }; + } + + // STRICT MODE: Require all configured inputs to be present + if (params.failOnError) { + for (const inputDef of params.dataInputs ?? []) { + const inputData = (inputs as Record)[inputDef.id]; + if (!inputData || !Array.isArray(inputData) || inputData.length === 0) { + throw new ValidationError( + `Required input '${inputDef.label}' (${inputDef.id}) is missing or empty. ` + + `All configured inputs must provide data when strict mode is enabled.`, + { + fieldErrors: { [inputDef.id]: ['This input is required but has no data'] }, + }, + ); + } + } + } + + // Aggregate all documents from all inputs + const allDocuments: Record[] = []; + const inputsRecord = inputs as Record; + + for (const [inputId, inputData] of Object.entries(inputsRecord)) { + if (!inputData || !Array.isArray(inputData)) { + if (!params.failOnError) { + context.logger.warn( + `[Analytics Sink] Input '${inputId}' is empty or undefined, skipping`, + ); + } + continue; + } + + const inputDef = dataInputsMap.get(inputId); + const sourceTag = inputDef?.sourceTag; + + for (const doc of inputData) { + // STRICT MODE: Validate each document against analytics schema + if (params.failOnError) { + const validated = analyticsResultSchema().safeParse(doc); + if (!validated.success) { + throw new ValidationError( + `Document from input '${inputDef?.label ?? inputId}' failed validation: ${validated.error.message}`, + { + fieldErrors: { [inputId]: [validated.error.message] }, + }, + ); + } + } + + // Add source_input field if sourceTag is defined + const enrichedDoc = sourceTag ? { ...doc, source_input: sourceTag } : { ...doc }; + allDocuments.push(enrichedDoc); + } + } + + const documentCount = allDocuments.length; + + if (documentCount === 0) { + context.logger.info('[Analytics Sink] No documents to index from any input'); + return { + indexed: false, + documentCount: 0, + indexName: '', + }; + } + + // LENIENT MODE: Validate all documents (but don't fail, just log warnings) + if (!params.failOnError) { + const validated = z.array(analyticsResultSchema()).safeParse(allDocuments); + if (!validated.success) { + context.logger.warn( + `[Analytics Sink] Some documents have validation issues: ${validated.error.message}`, + ); + // Continue anyway in lenient mode + } + } + + try { + // Determine the actual asset key field to use + let assetKeyField: string | undefined; + if (params.assetKeyField === 'auto') { + assetKeyField = undefined; + } else if (params.assetKeyField === 'custom') { + assetKeyField = params.customAssetKeyField; + } else { + assetKeyField = params.assetKeyField; + } + + const fallbackIndexSuffix = + params.indexSuffix ?? toWorkflowSlug(context.workflowName ?? undefined); + + const indexOptions = { + workflowId: context.workflowId, + workflowName: context.workflowName, + runId: context.runId, + nodeRef: context.componentRef, + componentId: 'core.analytics.sink', + assetKeyField, + indexSuffix: fallbackIndexSuffix, + trace: context.trace, + }; + + context.logger.info( + `[Analytics Sink] Bulk indexing ${documentCount} documents from ${dataInputsMap.size} input(s)`, + ); + const result = await indexer.bulkIndex(context.organizationId, allDocuments, indexOptions); + + context.logger.info( + `[Analytics Sink] Successfully indexed ${result.documentCount} document(s) to ${result.indexName}`, + ); + return { + indexed: true, + documentCount: result.documentCount, + indexName: result.indexName, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error during indexing'; + context.logger.error(`[Analytics Sink] Indexing failed: ${errorMessage}`); + + if (params.failOnError) { + throw error; + } + + // Fire-and-forget mode: log error but don't fail workflow + return { + indexed: false, + documentCount, + indexName: '', + }; + } + }, +}); + +componentRegistry.register(definition); diff --git a/worker/src/components/index.ts b/worker/src/components/index.ts index e2d4d533..a1d2f1e1 100644 --- a/worker/src/components/index.ts +++ b/worker/src/components/index.ts @@ -28,6 +28,7 @@ import './core/destination-s3'; import './core/text-block'; import './core/workflow-call'; import './core/mcp-library'; +import './core/analytics-sink'; // Manual Action components import './manual-action/manual-approval'; import './manual-action/manual-selection'; @@ -69,6 +70,7 @@ import './it-automation/okta-user-offboard'; import './test/sleep-parallel'; import './test/live-event-heartbeat'; import './test/simple-http-mcp'; +import './test/analytics-fixture'; // Export registry for external use export { componentRegistry } from '@shipsec/component-sdk'; diff --git a/worker/src/components/security/__tests__/dnsx.test.ts b/worker/src/components/security/__tests__/dnsx.test.ts index 5fad599c..2674493b 100644 --- a/worker/src/components/security/__tests__/dnsx.test.ts +++ b/worker/src/components/security/__tests__/dnsx.test.ts @@ -83,9 +83,11 @@ describe.skip('dnsx component', () => { expect(result.domainCount).toBe(1); expect(result.recordCount).toBe(2); - expect(result.results).toHaveLength(2); - expect(result.results[0].host).toBe('example.com'); - const aggregatedAnswers = result.results.flatMap((entry) => entry.answers.a ?? []); + expect(result.dnsRecords).toHaveLength(2); + expect((result.dnsRecords[0] as { host: string }).host).toBe('example.com'); + const aggregatedAnswers = result.dnsRecords.flatMap( + (entry) => (entry as { answers: { a?: string[] } }).answers.a ?? [], + ); expect(aggregatedAnswers).toEqual(['23.215.0.138', '23.215.0.136']); expect(result.recordTypes).toEqual(['A']); expect(result.resolvedHosts).toEqual(['example.com']); diff --git a/worker/src/components/security/__tests__/httpx.test.ts b/worker/src/components/security/__tests__/httpx.test.ts index 83282c84..8f7f45f8 100644 --- a/worker/src/components/security/__tests__/httpx.test.ts +++ b/worker/src/components/security/__tests__/httpx.test.ts @@ -85,7 +85,7 @@ describeHttpx('httpx component', () => { }); const payload: HttpxOutput = { - results: [ + responses: [ { url: 'https://example.com', host: 'example.com', @@ -105,6 +105,7 @@ describeHttpx('httpx component', () => { timestamp: '2023-01-01T00:00:00Z', }, ], + results: [], rawOutput: '{"url":"https://example.com","host":"example.com","status-code":200,"title":"Example Domain","tech":["HTTP","CDN"]}', targetCount: 1, diff --git a/worker/src/components/security/__tests__/nuclei.test.ts b/worker/src/components/security/__tests__/nuclei.test.ts index 9bd6ee20..4208cb42 100644 --- a/worker/src/components/security/__tests__/nuclei.test.ts +++ b/worker/src/components/security/__tests__/nuclei.test.ts @@ -147,6 +147,7 @@ describe('Nuclei Component', () => { timestamp: '2024-12-04T10:00:00Z', }, ], + results: [], rawOutput: '{"template-id":"CVE-2024-1234"}', targetCount: 1, findingCount: 1, @@ -174,6 +175,7 @@ describe('Nuclei Component', () => { timestamp: '2024-12-04T10:00:00Z', }, ], + results: [], rawOutput: '', targetCount: 1, findingCount: 1, @@ -200,6 +202,7 @@ describe('Nuclei Component', () => { ip: '1.2.3.4', }, ], + results: [], rawOutput: '', targetCount: 1, findingCount: 1, diff --git a/worker/src/components/security/__tests__/subfinder.test.ts b/worker/src/components/security/__tests__/subfinder.test.ts index 4e590dee..6f6b07d1 100644 --- a/worker/src/components/security/__tests__/subfinder.test.ts +++ b/worker/src/components/security/__tests__/subfinder.test.ts @@ -73,13 +73,19 @@ describe.skip('subfinder component', () => { rawOutput: 'api.example.com', domainCount: 1, subdomainCount: 1, + results: [], }; vi.spyOn(sdk, 'runComponentWithRunner').mockResolvedValue(payload); const result = component.outputs.parse(await component.execute(executePayload, context)); - expect(result).toEqual(component.outputs.parse(payload)); + expect(result.subdomains).toEqual(['api.example.com']); + expect(result.domainCount).toBe(1); + expect(result.subdomainCount).toBe(1); + expect(result.results).toHaveLength(1); + expect(result.results[0].scanner).toBe('subfinder'); + expect(result.results[0].severity).toBe('info'); }); it('should accept a single domain string and normalise to array', () => { diff --git a/worker/src/components/security/__tests__/trufflehog.test.ts b/worker/src/components/security/__tests__/trufflehog.test.ts index d3c4e13a..e9bfff11 100644 --- a/worker/src/components/security/__tests__/trufflehog.test.ts +++ b/worker/src/components/security/__tests__/trufflehog.test.ts @@ -97,6 +97,18 @@ describe('trufflehog component', () => { secretCount: 1, verifiedCount: 1, hasVerifiedSecrets: true, + results: [ + { + DetectorType: 'AWS', + DetectorName: 'AWS', + Verified: true, + Raw: 'AKIAIOSFODNN7EXAMPLE', + scanner: 'trufflehog', + severity: 'high', + finding_hash: 'abc123def456abcd', + asset_key: 'https://github.com/test/repo', + }, + ], }; vi.spyOn(sdk, 'runComponentWithRunner').mockResolvedValue(JSON.stringify(mockOutput)); @@ -107,6 +119,9 @@ describe('trufflehog component', () => { expect(result.verifiedCount).toBe(1); expect(result.hasVerifiedSecrets).toBe(true); expect(result.secrets).toHaveLength(1); + expect(result.results).toHaveLength(1); + expect(result.results[0].scanner).toBe('trufflehog'); + expect(result.results[0].severity).toBe('high'); }); it('should handle no secrets found', async () => { @@ -135,6 +150,7 @@ describe('trufflehog component', () => { secretCount: 0, verifiedCount: 0, hasVerifiedSecrets: false, + results: [], }; vi.spyOn(sdk, 'runComponentWithRunner').mockResolvedValue(JSON.stringify(mockOutput)); @@ -145,6 +161,7 @@ describe('trufflehog component', () => { expect(result.verifiedCount).toBe(0); expect(result.hasVerifiedSecrets).toBe(false); expect(result.secrets).toHaveLength(0); + expect(result.results).toHaveLength(0); }); it('should support different scan types', () => { @@ -234,6 +251,26 @@ describe('trufflehog component', () => { secretCount: 2, verifiedCount: 1, hasVerifiedSecrets: true, + results: [ + { + DetectorType: 'Generic', + Verified: false, + Raw: 'potential_secret_123', + scanner: 'trufflehog', + severity: 'high', + finding_hash: 'def456abc789def0', + asset_key: 'https://github.com/test/repo', + }, + { + DetectorType: 'AWS', + Verified: true, + Raw: 'AKIAIOSFODNN7EXAMPLE', + scanner: 'trufflehog', + severity: 'high', + finding_hash: 'abc123def456abcd', + asset_key: 'https://github.com/test/repo', + }, + ], }; vi.spyOn(sdk, 'runComponentWithRunner').mockResolvedValue(JSON.stringify(mockOutput)); @@ -243,6 +280,7 @@ describe('trufflehog component', () => { expect(result.secretCount).toBe(2); expect(result.verifiedCount).toBe(1); expect(result.hasVerifiedSecrets).toBe(true); + expect(result.results).toHaveLength(2); }); it('should handle parse errors gracefully', async () => { diff --git a/worker/src/components/security/abuseipdb.ts b/worker/src/components/security/abuseipdb.ts index c9a5a044..6dec02eb 100644 --- a/worker/src/components/security/abuseipdb.ts +++ b/worker/src/components/security/abuseipdb.ts @@ -13,6 +13,9 @@ import { param, coerceBooleanFromText, coerceNumberFromText, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; const inputSchema = inputs({ @@ -102,6 +105,11 @@ const outputSchema = outputs({ reason: 'Full AbuseIPDB response payload varies by plan and API version.', connectionType: { kind: 'primitive', name: 'json' }, }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); const abuseIPDBRetryPolicy: ComponentRetryPolicy = { @@ -177,6 +185,7 @@ const definition = defineComponent({ context.logger.warn(`[AbuseIPDB] IP not found: ${ipAddress}`); return { ipAddress, + results: [], abuseConfidenceScore: 0, full_report: { error: 'Not Found' }, }; @@ -190,14 +199,46 @@ const definition = defineComponent({ const data = (await response.json()) as Record; const info = (data.data || {}) as Record; - context.logger.info(`[AbuseIPDB] Score for ${ipAddress}: ${info.abuseConfidenceScore}`); + const abuseConfidenceScore = info.abuseConfidenceScore as number; + + context.logger.info(`[AbuseIPDB] Score for ${ipAddress}: ${abuseConfidenceScore}`); + + // Determine severity based on abuse confidence score + let severity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'none' = 'none'; + if (abuseConfidenceScore >= 90) { + severity = 'critical'; + } else if (abuseConfidenceScore >= 70) { + severity = 'high'; + } else if (abuseConfidenceScore >= 50) { + severity = 'medium'; + } else if (abuseConfidenceScore >= 25) { + severity = 'low'; + } else if (abuseConfidenceScore > 0) { + severity = 'info'; + } + + // Build analytics-ready results + const analyticsResults: AnalyticsResult[] = [ + { + scanner: 'abuseipdb', + finding_hash: generateFindingHash('ip-reputation', ipAddress, String(abuseConfidenceScore)), + severity, + asset_key: ipAddress, + ip_address: ipAddress, + abuse_confidence_score: abuseConfidenceScore, + country_code: info.countryCode as string | undefined, + isp: info.isp as string | undefined, + total_reports: info.totalReports as number | undefined, + }, + ]; return { ipAddress: info.ipAddress as string, + results: analyticsResults, isPublic: info.isPublic as boolean | undefined, ipVersion: info.ipVersion as number | undefined, isWhitelisted: info.isWhitelisted as boolean | undefined, - abuseConfidenceScore: info.abuseConfidenceScore as number, + abuseConfidenceScore, countryCode: info.countryCode as string | undefined, usageType: info.usageType as string | undefined, isp: info.isp as string | undefined, diff --git a/worker/src/components/security/amass.ts b/worker/src/components/security/amass.ts index 16386347..16fe1094 100644 --- a/worker/src/components/security/amass.ts +++ b/worker/src/components/security/amass.ts @@ -11,6 +11,11 @@ import { port, param, type DockerRunnerConfig, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, + type ExecutionContext, + type ExecutionPayload, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; @@ -278,6 +283,11 @@ const outputSchema = outputs({ connectionType: { kind: 'primitive', name: 'json' }, }, ), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); // Split custom CLI flags into an array of arguments @@ -452,7 +462,7 @@ const amassRetryPolicy: ComponentRetryPolicy = { nonRetryableErrorTypes: ['ContainerError', 'ValidationError', 'ConfigurationError'], }; -const definition = defineComponent({ +const definition = (defineComponent as any)({ id: 'shipsec.amass.enum', label: 'Amass Enumeration', category: 'security', @@ -506,7 +516,13 @@ const definition = defineComponent({ 'Perform quick passive reconnaissance using custom CLI flags like --passive.', ], }, - async execute({ inputs, params }, context) { + async execute( + { + inputs, + params, + }: ExecutionPayload, z.infer>, + context: ExecutionContext, + ) { const parsedParams = parameterSchema.parse(params); const { passive, @@ -718,12 +734,23 @@ const definition = defineComponent({ }); } + // Build analytics-ready results with scanner metadata + const analyticsResults: AnalyticsResult[] = subdomains.map((subdomain) => ({ + scanner: 'amass', + finding_hash: generateFindingHash('subdomain-discovery', subdomain, inputs.domains.join(',')), + severity: 'info' as const, + asset_key: subdomain, + subdomain, + parent_domains: inputs.domains, + })); + return { subdomains, rawOutput, domainCount, subdomainCount, options: optionsSummary, + results: analyticsResults, }; }, }); diff --git a/worker/src/components/security/dnsx.ts b/worker/src/components/security/dnsx.ts index 2dcf2f12..11c690ba 100644 --- a/worker/src/components/security/dnsx.ts +++ b/worker/src/components/security/dnsx.ts @@ -11,6 +11,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; @@ -235,8 +238,8 @@ const dnsxLineSchema = z .passthrough(); const outputSchema = outputs({ - results: port(z.array(z.any()), { - label: 'Results', + dnsRecords: port(z.array(z.any()), { + label: 'DNS Records', description: 'DNS resolution results returned by dnsx.', allowAny: true, reason: 'dnsx returns heterogeneous record payloads.', @@ -270,6 +273,11 @@ const outputSchema = outputs({ label: 'Errors', description: 'Errors encountered during dnsx execution.', }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); const splitCliArgs = (input: string): string[] => { @@ -566,6 +574,7 @@ const definition = defineComponent({ if (domainCount === 0) { context.logger.info('[DNSX] Skipping dnsx execution because no domains were provided.'); return outputSchema.parse({ + dnsRecords: [], results: [], rawOutput: '', domainCount: 0, @@ -770,8 +779,24 @@ const definition = defineComponent({ .filter((host): host is string => typeof host === 'string' && host.length > 0), ); + // Build analytics-ready results with scanner metadata + const analyticsResults: AnalyticsResult[] = normalisedRecords.map((record) => ({ + scanner: 'dnsx', + finding_hash: generateFindingHash( + 'dns-resolution', + record.host, + JSON.stringify(record.answers), + ), + severity: 'info' as const, + asset_key: record.host, + host: record.host, + record_types: Object.keys(record.answers), + answers: record.answers, + })); + return { - results: normalisedRecords, + dnsRecords: normalisedRecords, + results: analyticsResults, rawOutput: params.rawOutput, domainCount: params.domainCount, recordCount: params.recordCount, @@ -810,6 +835,7 @@ const definition = defineComponent({ if (trimmed.length === 0) { return { + dnsRecords: [], results: [], rawOutput, domainCount: domainCount, @@ -834,6 +860,7 @@ const definition = defineComponent({ ? (record.domainCount as number) : domainCount; return { + dnsRecords: [], results: [], rawOutput: trimmed, domainCount: errorDomainCount, @@ -848,10 +875,10 @@ const definition = defineComponent({ const validated = outputSchema.safeParse(record); if (validated.success) { return buildOutput({ - records: validated.data.results as z.infer[], + records: validated.data.dnsRecords as z.infer[], rawOutput: validated.data.rawOutput ?? rawOutput, domainCount: validated.data.domainCount ?? domainCount, - recordCount: validated.data.recordCount ?? validated.data.results.length, + recordCount: validated.data.recordCount ?? validated.data.dnsRecords.length, recordTypes: validated.data.recordTypes ?? recordTypes, resolvers: validated.data.resolvers ?? resolverList, errors: validated.data.errors, @@ -869,6 +896,7 @@ const definition = defineComponent({ if (lines.length === 0) { return { + dnsRecords: [], results: [], rawOutput, domainCount: domainCount, @@ -895,8 +923,24 @@ const definition = defineComponent({ }; }); + // Build analytics-ready results + const analyticsResults: AnalyticsResult[] = silentRecords.map((record) => ({ + scanner: 'dnsx', + finding_hash: generateFindingHash( + 'dns-resolution', + record.host, + JSON.stringify(record.answers), + ), + severity: 'info' as const, + asset_key: record.host, + host: record.host, + record_types: Object.keys(record.answers), + answers: record.answers, + })); + return { - results: silentRecords, + dnsRecords: silentRecords, + results: analyticsResults, rawOutput, domainCount: domainCount, recordCount: silentRecords.length, @@ -919,6 +963,7 @@ const definition = defineComponent({ if (trimmed.length === 0) { return { + dnsRecords: [], results: [], rawOutput, domainCount: domainCount, @@ -975,8 +1020,24 @@ const definition = defineComponent({ }; }); + // Build analytics-ready results + const analyticsResults: AnalyticsResult[] = fallbackResults.map((record) => ({ + scanner: 'dnsx', + finding_hash: generateFindingHash( + 'dns-resolution', + record.host, + JSON.stringify(record.answers), + ), + severity: 'info' as const, + asset_key: record.host, + host: record.host, + record_types: Object.keys(record.answers), + answers: record.answers, + })); + return { - results: fallbackResults, + dnsRecords: fallbackResults, + results: analyticsResults, rawOutput, domainCount: domainCount, recordCount: fallbackResults.length, @@ -1016,6 +1077,7 @@ const definition = defineComponent({ : JSON.stringify(rawPayload, null, 2).slice(0, 5000); return { + dnsRecords: [], results: [], rawOutput, domainCount: domainCount, @@ -1028,10 +1090,10 @@ const definition = defineComponent({ } return buildOutput({ - records: safeResult.data.results as z.infer[], + records: safeResult.data.dnsRecords as z.infer[], rawOutput: safeResult.data.rawOutput, domainCount: safeResult.data.domainCount ?? domainCount, - recordCount: safeResult.data.recordCount ?? safeResult.data.results.length, + recordCount: safeResult.data.recordCount ?? safeResult.data.dnsRecords.length, recordTypes: safeResult.data.recordTypes, resolvers: safeResult.data.resolvers, errors: safeResult.data.errors, diff --git a/worker/src/components/security/httpx.ts b/worker/src/components/security/httpx.ts index 84198ea5..f885d6ab 100644 --- a/worker/src/components/security/httpx.ts +++ b/worker/src/components/security/httpx.ts @@ -10,6 +10,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; @@ -145,7 +148,7 @@ const findingSchema = z.object({ type Finding = z.infer; const outputSchema = outputs({ - results: port(z.array(findingSchema), { + responses: port(z.array(findingSchema), { label: 'HTTP Responses', description: 'Structured metadata for each responsive endpoint.', connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } }, @@ -178,6 +181,11 @@ const outputSchema = outputs({ connectionType: { kind: 'primitive', name: 'json' }, }, ), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); const httpxRunnerOutputSchema = z.object({ @@ -272,6 +280,7 @@ const definition = defineComponent({ if (runnerParams.targets.length === 0) { context.logger.info('[httpx] Skipping httpx probe because no targets were provided.'); const emptyOutput: Output = { + responses: [], results: [], rawOutput: '', targetCount: 0, @@ -395,8 +404,27 @@ const definition = defineComponent({ `[httpx] Completed probe with ${findings.length} result(s) from ${runnerParams.targets.length} target(s)`, ); + // Build analytics-ready results with scanner metadata + const analyticsResults: AnalyticsResult[] = findings.map((finding) => ({ + scanner: 'httpx', + finding_hash: generateFindingHash( + 'http-endpoint', + finding.url, + String(finding.statusCode ?? 0), + ), + severity: 'info' as const, + asset_key: finding.url, + url: finding.url, + host: finding.host, + status_code: finding.statusCode, + title: finding.title, + webserver: finding.webserver, + technologies: finding.technologies, + })); + const output: Output = { - results: findings, + responses: findings, + results: analyticsResults, rawOutput: runnerOutput, targetCount: runnerParams.targets.length, resultCount: findings.length, diff --git a/worker/src/components/security/naabu.ts b/worker/src/components/security/naabu.ts index 052aa16a..8f5de0bf 100644 --- a/worker/src/components/security/naabu.ts +++ b/worker/src/components/security/naabu.ts @@ -8,6 +8,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; const inputSchema = inputs({ @@ -160,6 +163,11 @@ const outputSchema = outputs({ connectionType: { kind: 'primitive', name: 'json' }, }, ), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); type Finding = z.infer; @@ -338,8 +346,22 @@ eval "$CMD" if (typeof result === 'string') { const findings = parseNaabuOutput(result); + + // Build analytics-ready results with scanner metadata + const analyticsResults: AnalyticsResult[] = findings.map((finding) => ({ + scanner: 'naabu', + finding_hash: generateFindingHash('open-port', finding.host, String(finding.port)), + severity: 'info' as const, + asset_key: `${finding.host}:${finding.port}`, + host: finding.host, + port: finding.port, + protocol: finding.protocol, + ip: finding.ip, + })); + const output: Output = { findings, + results: analyticsResults, rawOutput: result, targetCount: runnerParams.targets.length, openPortCount: findings.length, @@ -365,6 +387,7 @@ eval "$CMD" return { findings: [], + results: [], rawOutput: typeof result === 'string' ? result : '', targetCount: runnerParams.targets.length, openPortCount: 0, diff --git a/worker/src/components/security/nuclei.ts b/worker/src/components/security/nuclei.ts index c6bbab5c..84e51e70 100644 --- a/worker/src/components/security/nuclei.ts +++ b/worker/src/components/security/nuclei.ts @@ -12,6 +12,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; import * as yaml from 'js-yaml'; @@ -203,6 +206,11 @@ const outputSchema = outputs({ description: 'Array of detected vulnerabilities with severity, tags, and matched URLs.', connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } }, }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), rawOutput: port(z.string(), { label: 'Raw Output', description: 'Complete JSONL output from nuclei for downstream processing.', @@ -549,8 +557,17 @@ const definition = defineComponent({ `[Nuclei] Scan complete: ${findings.length} finding(s) from ${parsedInputs.targets.length} target(s)`, ); + // Build analytics-ready results with scanner metadata (follows core.analytics.result.v1 contract) + const results: AnalyticsResult[] = findings.map((finding) => ({ + ...finding, + scanner: 'nuclei', + asset_key: finding.host ?? finding.matchedAt, + finding_hash: generateFindingHash(finding.templateId, finding.host, finding.matchedAt), + })); + const output = { findings, + results, rawOutput: stdout, targetCount: parsedInputs.targets.length, findingCount: findings.length, diff --git a/worker/src/components/security/prowler-scan.ts b/worker/src/components/security/prowler-scan.ts index bc299fd1..589f9d22 100644 --- a/worker/src/components/security/prowler-scan.ts +++ b/worker/src/components/security/prowler-scan.ts @@ -14,6 +14,9 @@ import { parameters, port, param, + analyticsResultSchema, + generateFindingHash, + type AnalyticsResult, } from '@shipsec/component-sdk'; import type { DockerRunnerConfig } from '@shipsec/component-sdk'; @@ -247,6 +250,11 @@ const outputSchema = outputs({ 'Array of normalized findings derived from Prowler ASFF output (includes severity, resource id, remediation).', connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } }, }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), rawOutput: port(z.string(), { label: 'Raw Output', description: 'Raw Prowler output for debugging.', @@ -734,9 +742,29 @@ const definition = defineComponent({ const scanId = buildScanId(parsedInputs.accountId, parsedParams.scanMode); + // Build analytics-ready results (follows core.analytics.result.v1 contract) + const results: AnalyticsResult[] = findings.map((finding) => ({ + scanner: 'prowler', + finding_hash: generateFindingHash( + finding.id, + finding.resourceId ?? finding.accountId ?? '', + finding.title ?? '', + ), + severity: mapToAnalyticsSeverity(finding.severity), + asset_key: finding.resourceId ?? finding.accountId ?? undefined, + // Include additional context for analytics + title: finding.title, + description: finding.description, + region: finding.region, + status: finding.status, + remediationText: finding.remediationText, + recommendationUrl: finding.recommendationUrl, + })); + const output: Output = { scanId, findings, + results, rawOutput: rawSegments.join('\n'), summary: { totalFindings: findings.length, @@ -943,6 +971,31 @@ function extractRegionFromArn(resourceId?: string): string | null { return null; } +/** + * Maps Prowler severity levels to analytics severity enum. + * Prowler: critical, high, medium, low, informational, unknown + * Analytics: critical, high, medium, low, info, none + */ +function mapToAnalyticsSeverity( + prowlerSeverity: NormalisedSeverity, +): 'critical' | 'high' | 'medium' | 'low' | 'info' | 'none' { + switch (prowlerSeverity) { + case 'critical': + return 'critical'; + case 'high': + return 'high'; + case 'medium': + return 'medium'; + case 'low': + return 'low'; + case 'informational': + return 'info'; + case 'unknown': + default: + return 'none'; + } +} + componentRegistry.register(definition); // Create local type aliases for backward compatibility diff --git a/worker/src/components/security/shuffledns-massdns.ts b/worker/src/components/security/shuffledns-massdns.ts index fff8e396..d3690bea 100644 --- a/worker/src/components/security/shuffledns-massdns.ts +++ b/worker/src/components/security/shuffledns-massdns.ts @@ -12,6 +12,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; @@ -162,6 +165,11 @@ const outputSchema = outputs({ label: 'Subdomain Count', description: 'Number of unique subdomains discovered.', }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); const definition = defineComponent({ @@ -371,8 +379,19 @@ const definition = defineComponent({ const deduped = Array.from(new Set(subdomains)); + // Build analytics-ready results with scanner metadata + const analyticsResults: AnalyticsResult[] = deduped.map((subdomain) => ({ + scanner: 'shuffledns', + finding_hash: generateFindingHash('subdomain-discovery', subdomain, domains.join(',')), + severity: 'info' as const, + asset_key: subdomain, + subdomain, + parent_domains: domains, + })); + return outputSchema.parse({ subdomains: deduped, + results: analyticsResults, rawOutput, domainCount: domains.length, subdomainCount: deduped.length, @@ -397,17 +416,31 @@ const definition = defineComponent({ .map((line) => line.trim()) .filter((line) => line.length > 0); + const deduped = Array.from(new Set(subdomainsValue)); + + // Build analytics-ready results + const analyticsResults: AnalyticsResult[] = deduped.map((subdomain) => ({ + scanner: 'shuffledns', + finding_hash: generateFindingHash('subdomain-discovery', subdomain, domains.join(',')), + severity: 'info' as const, + asset_key: subdomain, + subdomain, + parent_domains: domains, + })); + return outputSchema.parse({ - subdomains: Array.from(new Set(subdomainsValue)), + subdomains: deduped, + results: analyticsResults, rawOutput: maybeRaw || subdomainsValue.join('\n'), domainCount: domains.length, - subdomainCount: subdomainsValue.length, + subdomainCount: deduped.length, }); } // Fallback – empty return outputSchema.parse({ subdomains: [], + results: [], rawOutput: '', domainCount: domains.length, subdomainCount: 0, diff --git a/worker/src/components/security/subfinder.ts b/worker/src/components/security/subfinder.ts index 0dce2f4a..ddfbf12b 100644 --- a/worker/src/components/security/subfinder.ts +++ b/worker/src/components/security/subfinder.ts @@ -11,6 +11,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; @@ -123,6 +126,11 @@ const outputSchema = outputs({ label: 'Subdomain Count', description: 'Number of subdomains discovered.', }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); // Split custom CLI flags into an array of arguments @@ -360,6 +368,7 @@ const definition = defineComponent({ context.logger.info('[Subfinder] Skipping execution because no domains were provided.'); return { subdomains: [], + results: [], rawOutput: '', domainCount: 0, subdomainCount: 0, @@ -511,11 +520,22 @@ const definition = defineComponent({ }); } + // Build analytics-ready results with scanner metadata + const analyticsResults: AnalyticsResult[] = subdomains.map((subdomain) => ({ + scanner: 'subfinder', + finding_hash: generateFindingHash('subdomain-discovery', subdomain, domains.join(',')), + severity: 'info' as const, + asset_key: subdomain, + subdomain, + parent_domains: domains, + })); + return { subdomains, rawOutput, domainCount, subdomainCount, + results: analyticsResults, }; }, }); diff --git a/worker/src/components/security/supabase-scanner.ts b/worker/src/components/security/supabase-scanner.ts index 4bd0d167..5b1d3c96 100644 --- a/worker/src/components/security/supabase-scanner.ts +++ b/worker/src/components/security/supabase-scanner.ts @@ -10,8 +10,11 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, + type DockerRunnerConfig, } from '@shipsec/component-sdk'; -import type { DockerRunnerConfig } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; // Extract Supabase project ref from a standard URL like https://.supabase.co @@ -150,6 +153,11 @@ const outputSchema = outputs({ reason: 'Scanner issue payloads can vary by Supabase project configuration.', connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } }, }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), report: port(z.unknown(), { label: 'Scanner Report', description: 'Full JSON report produced by the scanner.', @@ -329,6 +337,19 @@ const definition = defineComponent({ } catch (err) { const msg = (err as Error)?.message ?? 'Unknown error'; context.logger.error(`[SupabaseScanner] Scanner failed: ${msg}`); + + // Check if this is a fatal Docker error (image pull failure, container start failure) + // These should fail hard, not gracefully degrade + if ( + msg.includes('exit code 125') || + msg.includes('Unable to find image') || + msg.includes('permission denied') || + msg.includes('authentication required') + ) { + throw err; + } + + // For other errors (scanner runtime errors), allow graceful degradation errors.push(msg); } @@ -357,6 +378,22 @@ const definition = defineComponent({ } catch (err) { const msg = (err as Error)?.message ?? 'Unknown error'; context.logger.error(`[SupabaseScanner] Scanner failed: ${msg}`); + + // Check if this is a fatal Docker error that should fail the workflow + if ( + msg.includes('exit code 125') || + msg.includes('Unable to find image') || + msg.includes('permission denied') || + msg.includes('authentication required') + ) { + // Cleanup volume before throwing + if (volumeInitialized) { + await volume.cleanup(); + context.logger.info('[SupabaseScanner] Cleaned up isolated volume'); + } + throw err; + } + errors.push(msg); } finally { if (volumeInitialized) { @@ -365,11 +402,34 @@ const definition = defineComponent({ } } + // Build analytics-ready results with scanner metadata (follows core.analytics.result.v1 contract) + const results: AnalyticsResult[] = (issues ?? []).map((issue) => { + const issueObj = typeof issue === 'object' && issue !== null ? issue : { raw: issue }; + const issueRecord = issueObj as Record; + // Extract check_id and resource for deduplication hash + const checkId = issueRecord.check_id as string | undefined; + const resource = issueRecord.resource as string | undefined; + // Map severity from scanner output or default to 'medium' for security issues + const rawSeverity = (issueRecord.severity as string | undefined)?.toLowerCase(); + const validSeverities = ['critical', 'high', 'medium', 'low', 'info', 'none'] as const; + const severity = validSeverities.includes(rawSeverity as (typeof validSeverities)[number]) + ? (rawSeverity as (typeof validSeverities)[number]) + : 'medium'; + return { + ...issueObj, + scanner: 'supabase-scanner', + severity, + asset_key: projectRef ?? undefined, + finding_hash: generateFindingHash(checkId, projectRef, resource), + }; + }); + const output: Output = { projectRef: projectRef ?? null, score, summary, issues, + results, report, rawOutput: stdoutCombined ?? '', errors: errors.length > 0 ? errors : undefined, diff --git a/worker/src/components/security/trufflehog.ts b/worker/src/components/security/trufflehog.ts index 3ed88eef..3388ad4a 100644 --- a/worker/src/components/security/trufflehog.ts +++ b/worker/src/components/security/trufflehog.ts @@ -12,6 +12,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; @@ -186,6 +189,11 @@ const outputSchema = outputs({ label: 'Has Verified Secrets', description: 'True when any verified secrets are detected.', }), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); // Helper function to build TruffleHog command arguments @@ -256,6 +264,7 @@ function parseRawOutput(rawOutput: string): Output { secretCount: 0, verifiedCount: 0, hasVerifiedSecrets: false, + results: [], }; } @@ -294,6 +303,7 @@ function parseRawOutput(rawOutput: string): Output { secretCount: secrets.length, verifiedCount, hasVerifiedSecrets: verifiedCount > 0, + results: [], // Populated in execute() with scanner metadata }; } @@ -493,7 +503,23 @@ const definition = defineComponent({ }); } - return output; + // Build analytics-ready results with scanner metadata (follows core.analytics.result.v1 contract) + const results: AnalyticsResult[] = output.secrets.map((secret: Secret) => { + // Extract file path from source metadata for hashing + const filePath = + secret.SourceMetadata?.Data?.Git?.file ?? + secret.SourceMetadata?.Data?.Filesystem?.file ?? + ''; + return { + ...secret, + scanner: 'trufflehog', + severity: 'high' as const, // Secrets are always high severity + asset_key: runnerPayload.scanTarget, + finding_hash: generateFindingHash(secret.DetectorType, secret.Redacted, filePath), + }; + }); + + return { ...output, results }; } finally { // Always cleanup volume if it was created if (volume) { diff --git a/worker/src/components/security/virustotal.ts b/worker/src/components/security/virustotal.ts index 76bcad0d..f9699ea2 100644 --- a/worker/src/components/security/virustotal.ts +++ b/worker/src/components/security/virustotal.ts @@ -11,6 +11,9 @@ import { parameters, port, param, + generateFindingHash, + analyticsResultSchema, + type AnalyticsResult, } from '@shipsec/component-sdk'; const inputSchema = inputs({ @@ -64,6 +67,11 @@ const outputSchema = outputs({ connectionType: { kind: 'primitive', name: 'json' }, }, ), + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: + 'Analytics-ready findings with scanner, finding_hash, and severity. Connect to Analytics Sink.', + }), }); // Retry policy for VirusTotal API - handles rate limits and transient failures @@ -167,6 +175,7 @@ const definition = defineComponent({ suspicious: 0, harmless: 0, tags: [], + results: [], full_report: { error: 'Not Found in VirusTotal' }, }; } @@ -190,12 +199,44 @@ const definition = defineComponent({ `[VirusTotal] Results for ${indicator}: ${malicious} malicious, ${suspicious} suspicious.`, ); + // Determine severity based on malicious/suspicious counts + let severity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'none' = 'none'; + if (malicious >= 10) { + severity = 'critical'; + } else if (malicious >= 5) { + severity = 'high'; + } else if (malicious >= 1 || suspicious >= 5) { + severity = 'medium'; + } else if (suspicious >= 1) { + severity = 'low'; + } else { + severity = 'info'; + } + + // Build analytics-ready results + const analyticsResults: AnalyticsResult[] = [ + { + scanner: 'virustotal', + finding_hash: generateFindingHash('threat-intelligence', indicator, type), + severity, + asset_key: indicator, + indicator, + indicator_type: type, + malicious_count: malicious, + suspicious_count: suspicious, + harmless_count: harmless, + reputation, + tags, + }, + ]; + return { malicious, suspicious, harmless, tags, reputation, + results: analyticsResults, full_report: data, }; }, diff --git a/worker/src/components/test/__tests__/analytics-fixture.test.ts b/worker/src/components/test/__tests__/analytics-fixture.test.ts new file mode 100644 index 00000000..30d7ff33 --- /dev/null +++ b/worker/src/components/test/__tests__/analytics-fixture.test.ts @@ -0,0 +1,36 @@ +import { beforeAll, describe, expect, it } from 'bun:test'; +import { componentRegistry, createExecutionContext } from '@shipsec/component-sdk'; +import type { AnalyticsFixtureInput, AnalyticsFixtureOutput } from '../analytics-fixture'; + +describe('test.analytics.fixture component', () => { + beforeAll(async () => { + await import('../../index'); + }); + + it('should be registered', () => { + const component = componentRegistry.get( + 'test.analytics.fixture', + ); + expect(component).toBeDefined(); + expect(component!.label).toBe('Analytics Fixture (Test)'); + }); + + it('should emit deterministic analytics results', async () => { + const component = componentRegistry.get( + 'test.analytics.fixture', + ); + if (!component) { + throw new Error('test.analytics.fixture not registered'); + } + + const context = createExecutionContext({ + runId: 'analytics-fixture-test', + componentRef: 'fixture-node', + }); + + const result = await component.execute({ inputs: {}, params: {} }, context); + expect(Array.isArray(result.results)).toBe(true); + expect(result.results.length).toBeGreaterThanOrEqual(2); + expect(result.results[0].scanner).toBe('analytics-fixture'); + }); +}); diff --git a/worker/src/components/test/analytics-fixture.ts b/worker/src/components/test/analytics-fixture.ts new file mode 100644 index 00000000..f50e482e --- /dev/null +++ b/worker/src/components/test/analytics-fixture.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; +import { + componentRegistry, + defineComponent, + inputs, + outputs, + port, + analyticsResultSchema, + generateFindingHash, + type ExecutionContext, +} from '@shipsec/component-sdk'; + +const inputSchema = inputs({ + trigger: port(z.any().optional(), { + label: 'Trigger', + description: 'Optional trigger input to allow wiring from entrypoint.', + allowAny: true, + reason: 'Test fixture accepts any trigger input for E2E testing flexibility.', + }), +}); + +const outputSchema = outputs({ + results: port(z.array(analyticsResultSchema()), { + label: 'Results', + description: 'Deterministic analytics results for E2E testing.', + }), +}); + +const definition = (defineComponent as any)({ + id: 'test.analytics.fixture', + label: 'Analytics Fixture (Test)', + category: 'transform', + runner: { kind: 'inline' }, + inputs: inputSchema, + outputs: outputSchema, + docs: 'Emits deterministic analytics results for end-to-end tests.', + ui: { + slug: 'analytics-fixture', + version: '1.0.0', + type: 'process', + category: 'transform', + description: 'Test-only component that emits deterministic analytics results.', + author: { + name: 'ShipSecAI', + type: 'shipsecai', + }, + }, + async execute(_payload: z.infer, _context: ExecutionContext) { + const results = [ + { + scanner: 'analytics-fixture', + severity: 'high', + title: 'Fixture Finding 1', + asset_key: 'fixture.local', + host: 'fixture.local', + finding_hash: generateFindingHash('fixture', 'finding-1'), + }, + { + scanner: 'analytics-fixture', + severity: 'low', + title: 'Fixture Finding 2', + asset_key: 'fixture.local', + host: 'fixture.local', + finding_hash: generateFindingHash('fixture', 'finding-2'), + }, + ]; + + return { results }; + }, +}); + +if (!componentRegistry.has(definition.id)) { + componentRegistry.register(definition); +} + +type Input = typeof inputSchema; +type Output = typeof outputSchema; + +export type { Input as AnalyticsFixtureInput, Output as AnalyticsFixtureOutput }; diff --git a/worker/src/temporal/__tests__/optional-input-handling.test.ts b/worker/src/temporal/__tests__/optional-input-handling.test.ts new file mode 100644 index 00000000..98d20a65 --- /dev/null +++ b/worker/src/temporal/__tests__/optional-input-handling.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for optional input handling in component execution + * + * Validates that components with optional inputs (required: false or connectionType.kind === 'any') + * can proceed when upstream components return undefined values, instead of failing with + * a ValidationError. + * + * This tests the fix for workflows getting stuck in infinite retry loops when an upstream + * component fails gracefully and returns undefined for some outputs. + */ + +import { describe, expect, it, beforeAll } from 'bun:test'; +import { z } from 'zod'; +import { + componentRegistry, + defineComponent, + inputs, + outputs, + port, + createExecutionContext, + extractPorts, + type ComponentPortMetadata, +} from '@shipsec/component-sdk'; + +describe('Optional Input Handling', () => { + beforeAll(() => { + // Register test component with optional input (required: false) + if (!componentRegistry.has('test.optional.required-false')) { + const component = defineComponent({ + id: 'test.optional.required-false', + label: 'Optional Input (required: false)', + category: 'transform', + runner: { kind: 'inline' }, + inputs: inputs({ + requiredInput: port(z.string(), { + label: 'Required Input', + description: 'This input is required', + }), + optionalInput: port(z.string().optional(), { + label: 'Optional Input', + description: 'This input is optional', + }), + }), + outputs: outputs({ + result: port(z.string(), { label: 'Result' }), + }), + async execute({ inputs }) { + return { + result: `required: ${inputs.requiredInput}, optional: ${inputs.optionalInput ?? 'undefined'}`, + }; + }, + }); + componentRegistry.register(component); + } + + // Register test component with allowAny input (connectionType.kind === 'any') + if (!componentRegistry.has('test.optional.allow-any')) { + const component = defineComponent({ + id: 'test.optional.allow-any', + label: 'Optional Input (allowAny)', + category: 'transform', + runner: { kind: 'inline' }, + inputs: inputs({ + requiredInput: port(z.string(), { + label: 'Required Input', + description: 'This input is required', + }), + anyInput: port(z.any(), { + label: 'Any Input', + description: 'This input accepts any type including undefined', + allowAny: true, + reason: 'Accepts arbitrary data for testing', + connectionType: { kind: 'any' }, + }), + }), + outputs: outputs({ + result: port(z.string(), { label: 'Result' }), + }), + async execute({ inputs }) { + return { + result: `required: ${inputs.requiredInput}, any: ${inputs.anyInput ?? 'undefined'}`, + }; + }, + }); + componentRegistry.register(component); + } + + // Register test component with all required inputs + if (!componentRegistry.has('test.all-required')) { + const component = defineComponent({ + id: 'test.all-required', + label: 'All Required Inputs', + category: 'transform', + runner: { kind: 'inline' }, + inputs: inputs({ + input1: port(z.string(), { + label: 'Input 1', + description: 'Required input 1', + }), + input2: port(z.string(), { + label: 'Input 2', + description: 'Required input 2', + }), + }), + outputs: outputs({ + result: port(z.string(), { label: 'Result' }), + }), + async execute({ inputs }) { + return { result: `${inputs.input1} + ${inputs.input2}` }; + }, + }); + componentRegistry.register(component); + } + }); + + describe('extractPorts identifies optional inputs correctly', () => { + it('identifies required: false as optional', () => { + const component = componentRegistry.get('test.optional.required-false'); + expect(component).toBeDefined(); + + const ports = extractPorts(component!.inputs); + const optionalPort = ports.find((p: ComponentPortMetadata) => p.id === 'optionalInput'); + + expect(optionalPort).toBeDefined(); + expect(optionalPort!.required).toBe(false); + }); + + it('identifies connectionType.kind === "any" as optional', () => { + const component = componentRegistry.get('test.optional.allow-any'); + expect(component).toBeDefined(); + + const ports = extractPorts(component!.inputs); + const anyPort = ports.find((p: ComponentPortMetadata) => p.id === 'anyInput'); + + expect(anyPort).toBeDefined(); + expect(anyPort!.connectionType?.kind).toBe('any'); + }); + + it('identifies regular inputs as required', () => { + const component = componentRegistry.get('test.all-required'); + expect(component).toBeDefined(); + + const ports = extractPorts(component!.inputs); + + for (const port of ports) { + // Required is either undefined (defaults to true) or explicitly true + expect(port.required).not.toBe(false); + expect(port.connectionType?.kind).not.toBe('any'); + } + }); + }); + + describe('filterRequiredMissingInputs logic', () => { + /** + * This test validates the core logic used in run-component.activity.ts + * to filter out optional inputs from the missing inputs list. + */ + it('filters out optional inputs from missing list', () => { + const component = componentRegistry.get('test.optional.required-false'); + expect(component).toBeDefined(); + + const inputPorts = extractPorts(component!.inputs); + + // Simulate warnings for both inputs being undefined + const warningsToReport = [ + { target: 'requiredInput', sourceRef: 'upstream', sourceHandle: 'output' }, + { target: 'optionalInput', sourceRef: 'upstream', sourceHandle: 'output' }, + ]; + + // Apply the filtering logic from run-component.activity.ts + const requiredMissingInputs = warningsToReport.filter((warning) => { + const portMeta = inputPorts.find((p: ComponentPortMetadata) => p.id === warning.target); + if (!portMeta) return true; + if (portMeta.required === false) return false; + if (portMeta.connectionType?.kind === 'any') return false; + return true; + }); + + // Only requiredInput should be in the filtered list + expect(requiredMissingInputs).toHaveLength(1); + expect(requiredMissingInputs[0].target).toBe('requiredInput'); + }); + + it('filters out allowAny inputs from missing list', () => { + const component = componentRegistry.get('test.optional.allow-any'); + expect(component).toBeDefined(); + + const inputPorts = extractPorts(component!.inputs); + + // Simulate warnings for both inputs being undefined + const warningsToReport = [ + { target: 'requiredInput', sourceRef: 'upstream', sourceHandle: 'output' }, + { target: 'anyInput', sourceRef: 'upstream', sourceHandle: 'output' }, + ]; + + // Apply the filtering logic from run-component.activity.ts + const requiredMissingInputs = warningsToReport.filter((warning) => { + const portMeta = inputPorts.find((p: ComponentPortMetadata) => p.id === warning.target); + if (!portMeta) return true; + if (portMeta.required === false) return false; + if (portMeta.connectionType?.kind === 'any') return false; + return true; + }); + + // Only requiredInput should be in the filtered list + expect(requiredMissingInputs).toHaveLength(1); + expect(requiredMissingInputs[0].target).toBe('requiredInput'); + }); + + it('keeps all required inputs in missing list', () => { + const component = componentRegistry.get('test.all-required'); + expect(component).toBeDefined(); + + const inputPorts = extractPorts(component!.inputs); + + // Simulate warnings for both inputs being undefined + const warningsToReport = [ + { target: 'input1', sourceRef: 'upstream', sourceHandle: 'output' }, + { target: 'input2', sourceRef: 'upstream', sourceHandle: 'output' }, + ]; + + // Apply the filtering logic from run-component.activity.ts + const requiredMissingInputs = warningsToReport.filter((warning) => { + const portMeta = inputPorts.find((p: ComponentPortMetadata) => p.id === warning.target); + if (!portMeta) return true; + if (portMeta.required === false) return false; + if (portMeta.connectionType?.kind === 'any') return false; + return true; + }); + + // Both inputs should be in the filtered list + expect(requiredMissingInputs).toHaveLength(2); + }); + }); + + describe('component execution with optional inputs', () => { + it('executes component with undefined optional input (required: false)', async () => { + const component = componentRegistry.get('test.optional.required-false'); + expect(component).toBeDefined(); + + const context = createExecutionContext({ + runId: 'test-run', + componentRef: 'test-node', + }); + + // Execute with only the required input + const result = await component!.execute!( + { + inputs: { requiredInput: 'hello', optionalInput: undefined }, + params: {}, + }, + context, + ); + + expect(result).toEqual({ result: 'required: hello, optional: undefined' }); + }); + + it('executes component with undefined allowAny input', async () => { + const component = componentRegistry.get('test.optional.allow-any'); + expect(component).toBeDefined(); + + const context = createExecutionContext({ + runId: 'test-run', + componentRef: 'test-node', + }); + + // Execute with only the required input + const result = await component!.execute!( + { + inputs: { requiredInput: 'hello', anyInput: undefined }, + params: {}, + }, + context, + ); + + expect(result).toEqual({ result: 'required: hello, any: undefined' }); + }); + }); +}); diff --git a/worker/src/temporal/activities/run-component.activity.ts b/worker/src/temporal/activities/run-component.activity.ts index 803594cc..94c19658 100644 --- a/worker/src/temporal/activities/run-component.activity.ts +++ b/worker/src/temporal/activities/run-component.activity.ts @@ -154,6 +154,9 @@ export async function runComponentActivity( const context = createExecutionContext({ runId: input.runId, componentRef: action.ref, + workflowId: input.workflowId, + workflowName: input.workflowName, + organizationId: input.organizationId ?? null, metadata: { activityId: activityInfo.activityId, attempt: activityInfo.attempt, @@ -383,21 +386,53 @@ export async function runComponentActivity( await resolveSecretParams(resolvedParams, input.rawParams ?? {}); + // Get input port metadata to check which inputs are truly required + let inputsSchemaForValidation = component.inputs; + if (typeof component.resolvePorts === 'function') { + try { + const resolved = component.resolvePorts(resolvedParams); + if (resolved?.inputs) { + inputsSchemaForValidation = resolved.inputs; + } + } catch { + // If port resolution fails, use the base schema + } + } + const inputPorts = inputsSchemaForValidation ? extractPorts(inputsSchemaForValidation) : []; + + // Filter warnings to only those for truly required inputs + // An input is NOT required if: + // - Its schema allows undefined/null (required: false) + // - It accepts any type (connectionType.kind === 'any') which includes undefined + const requiredMissingInputs = warningsToReport.filter((warning) => { + const portMeta = inputPorts.find((p: ComponentPortMetadata) => p.id === warning.target); + // If we can't find the port metadata, assume it's required to be safe + if (!portMeta) return true; + // If marked as not required, it's optional + if (portMeta.required === false) return false; + // If connectionType is 'any', it accepts undefined + if (portMeta.connectionType?.kind === 'any') return false; + return true; + }); + + // Log warnings for all undefined inputs (even optional ones) for (const warning of warningsToReport) { + const isRequired = requiredMissingInputs.some((r) => r.target === warning.target); context.trace?.record({ type: 'NODE_PROGRESS', timestamp: new Date().toISOString(), message: `Input '${warning.target}' mapped from ${warning.sourceRef}.${warning.sourceHandle} was undefined`, - level: 'warn', + level: isRequired ? 'error' : 'warn', data: warning, }); } - if (warningsToReport.length > 0) { - const missing = warningsToReport.map((warning) => `'${warning.target}'`).join(', '); + // Only throw if there are truly missing required inputs + if (requiredMissingInputs.length > 0) { + const missing = requiredMissingInputs.map((warning) => `'${warning.target}'`).join(', '); throw new ValidationError(`Missing required inputs for ${action.ref}: ${missing}`, { fieldErrors: Object.fromEntries( - warningsToReport.map((w) => [ + requiredMissingInputs.map((w) => [ w.target, [`mapped from ${w.sourceRef}.${w.sourceHandle} was undefined`], ]), diff --git a/worker/src/temporal/types.ts b/worker/src/temporal/types.ts index 591ca991..e75daef3 100644 --- a/worker/src/temporal/types.ts +++ b/worker/src/temporal/types.ts @@ -74,6 +74,7 @@ export interface WorkflowDefinition { export interface RunComponentActivityInput { runId: string; workflowId: string; + workflowName?: string; workflowVersionId?: string | null; organizationId?: string | null; action: { diff --git a/worker/src/temporal/workflow-runner.ts b/worker/src/temporal/workflow-runner.ts index ab5cef3e..166e99fc 100644 --- a/worker/src/temporal/workflow-runner.ts +++ b/worker/src/temporal/workflow-runner.ts @@ -304,6 +304,9 @@ export async function executeWorkflow( artifacts: scopedArtifacts, trace: options.trace, logCollector: forwardLog, + workflowId: options.workflowId, + workflowName: definition.title, + organizationId: options.organizationId, }); try { diff --git a/worker/src/temporal/workflows/index.ts b/worker/src/temporal/workflows/index.ts index ad3e97f6..8b041b07 100644 --- a/worker/src/temporal/workflows/index.ts +++ b/worker/src/temporal/workflows/index.ts @@ -642,6 +642,7 @@ export async function shipsecWorkflowRun( const activityInput: RunComponentActivityInput = { runId: input.runId, workflowId: input.workflowId, + workflowName: input.definition.title, workflowVersionId: input.workflowVersionId ?? null, organizationId: input.organizationId ?? null, action: { diff --git a/worker/src/utils/opensearch-indexer.ts b/worker/src/utils/opensearch-indexer.ts new file mode 100644 index 00000000..88cec47d --- /dev/null +++ b/worker/src/utils/opensearch-indexer.ts @@ -0,0 +1,459 @@ +import { Client } from '@opensearch-project/opensearch'; +import type { IScopedTraceService } from '@shipsec/component-sdk'; + +interface IndexOptions { + workflowId: string; + workflowName: string; + runId: string; + nodeRef: string; + componentId: string; + assetKeyField?: string; + indexSuffix?: string; + trace?: IScopedTraceService; +} + +/** + * Retry helper with exponential backoff + * Attempts: 3, delays: 1s, 2s, 4s + */ +async function retryWithBackoff(operation: () => Promise, operationName: string): Promise { + const maxAttempts = 3; + const delays = [1000, 2000, 4000]; // milliseconds + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + const isLastAttempt = attempt === maxAttempts - 1; + + if (isLastAttempt) { + throw error; // Re-throw on last attempt + } + + const delay = delays[attempt]; + console.warn( + `[OpenSearchIndexer] ${operationName} failed (attempt ${attempt + 1}/${maxAttempts}), ` + + `retrying in ${delay}ms...`, + error, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // This should never be reached, but TypeScript requires it + throw new Error(`${operationName} failed after ${maxAttempts} attempts`); +} + +export class OpenSearchIndexer { + private client: Client | null = null; + private enabled = false; + private dashboardsUrl: string | null = null; + private dashboardsAuth: { username: string; password: string } | null = null; + + constructor() { + const url = process.env.OPENSEARCH_URL; + const username = process.env.OPENSEARCH_USERNAME; + const password = process.env.OPENSEARCH_PASSWORD; + + // OpenSearch Dashboards URL for index pattern management + this.dashboardsUrl = process.env.OPENSEARCH_DASHBOARDS_URL || null; + if (username && password) { + this.dashboardsAuth = { username, password }; + } + + if (url) { + try { + this.client = new Client({ + node: url, + ...(username && + password && { + auth: { + username, + password, + }, + }), + ssl: { + rejectUnauthorized: process.env.NODE_ENV === 'production', + }, + }); + this.enabled = true; + console.log('[OpenSearchIndexer] Client initialized'); + } catch (error) { + console.warn('[OpenSearchIndexer] Failed to initialize client:', error); + } + } else { + console.debug('[OpenSearchIndexer] OpenSearch URL not configured, indexing disabled'); + } + } + + isEnabled(): boolean { + return this.enabled && this.client !== null; + } + + /** + * Serialize nested objects and arrays to JSON strings to prevent field explosion. + * Preserves primitive values (string, number, boolean, null) as-is. + */ + private serializeNestedFields(document: Record): Record { + // Pass through as-is - let OpenSearch handle dynamic mapping + return { ...document }; + } + + /** + * Build the enriched document structure with _shipsec context. + * - Component data fields at root level (nested objects serialized) + * - Workflow context under _shipsec namespace (prevents field collision) + */ + private buildEnrichedDocument( + document: Record, + options: IndexOptions, + orgId: string, + timestamp: string, + assetKey: string | null, + ): Record { + // Serialize nested objects in the document to prevent field explosion + const serializedDocument = this.serializeNestedFields(document); + + return { + // Component data at root level (serialized) + ...serializedDocument, + + // Workflow context under shipsec namespace (no underscore prefix for UI visibility) + shipsec: { + organization_id: orgId, + run_id: options.runId, + workflow_id: options.workflowId, + workflow_name: options.workflowName, + component_id: options.componentId, + node_ref: options.nodeRef, + ...(assetKey && { asset_key: assetKey }), + }, + + // Standard timestamp + '@timestamp': timestamp, + }; + } + + async indexDocument( + orgId: string, + document: Record, + options: IndexOptions, + ): Promise { + if (!this.isEnabled() || !this.client) { + console.debug('[OpenSearchIndexer] Indexing skipped, client not enabled'); + throw new Error('OpenSearch client not enabled'); + } + + const indexName = this.buildIndexName(orgId, options.indexSuffix); + const assetKey = this.detectAssetKey(document, options.assetKeyField); + const timestamp = new Date().toISOString(); + + const enrichedDocument = this.buildEnrichedDocument( + document, + options, + orgId, + timestamp, + assetKey, + ); + + try { + await retryWithBackoff(async () => { + await this.client!.index({ + index: indexName, + body: enrichedDocument, + }); + }, `Index document to ${indexName}`); + + console.debug(`[OpenSearchIndexer] Indexed document to ${indexName}`); + + // Log successful indexing to trace + if (options.trace) { + options.trace.record({ + type: 'NODE_PROGRESS', + level: 'info', + message: `Successfully indexed 1 document to ${indexName}`, + data: { + indexName, + documentCount: 1, + assetKey: assetKey ?? undefined, + }, + }); + } + + return indexName; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[OpenSearchIndexer] Failed to index document after retries:`, error); + + // Log indexing error to trace + if (options.trace) { + options.trace.record({ + type: 'NODE_PROGRESS', + level: 'error', + message: `Failed to index document to ${indexName}`, + error: errorMessage, + data: { + indexName, + documentCount: 1, + }, + }); + } + + throw error; + } + } + + async bulkIndex( + orgId: string, + documents: Record[], + options: IndexOptions, + ): Promise<{ indexName: string; documentCount: number }> { + if (!this.isEnabled() || !this.client) { + console.debug('[OpenSearchIndexer] Bulk indexing skipped, client not enabled'); + throw new Error('OpenSearch client not enabled'); + } + + if (documents.length === 0) { + console.debug('[OpenSearchIndexer] No documents to index'); + return { indexName: '', documentCount: 0 }; + } + + const indexName = this.buildIndexName(orgId, options.indexSuffix); + + // Use same timestamp for all documents in this batch + // (they all came from the same component execution) + const timestamp = new Date().toISOString(); + + // Build bulk operations array + const bulkOps: any[] = []; + for (const document of documents) { + const assetKey = this.detectAssetKey(document, options.assetKeyField); + + const enrichedDocument = this.buildEnrichedDocument( + document, + options, + orgId, + timestamp, + assetKey, + ); + + bulkOps.push({ index: { _index: indexName } }); + bulkOps.push(enrichedDocument); + } + + try { + const response = await retryWithBackoff(async () => { + return await this.client!.bulk({ + body: bulkOps, + }); + }, `Bulk index ${documents.length} documents to ${indexName}`); + + if (response.body.errors) { + const failedItems = response.body.items.filter((item: any) => item.index?.error); + const errorCount = failedItems.length; + + // Log first 3 error details for debugging + const errorSamples = failedItems.slice(0, 3).map((item: any) => ({ + type: item.index?.error?.type, + reason: item.index?.error?.reason, + })); + + console.warn( + `[OpenSearchIndexer] Bulk indexing completed with ${errorCount} errors out of ${documents.length} documents`, + ); + console.warn(`[OpenSearchIndexer] Error samples:`, JSON.stringify(errorSamples, null, 2)); + + // Log partial failure to trace + if (options.trace) { + options.trace.record({ + type: 'NODE_PROGRESS', + level: 'warn', + message: `Bulk indexed with ${errorCount} errors out of ${documents.length} documents to ${indexName}`, + data: { + indexName, + documentCount: documents.length, + errorCount, + errorSamples, + }, + }); + } + } else { + console.debug( + `[OpenSearchIndexer] Bulk indexed ${documents.length} documents to ${indexName}`, + ); + + // Log successful bulk indexing to trace + if (options.trace) { + options.trace.record({ + type: 'NODE_PROGRESS', + level: 'info', + message: `Successfully bulk indexed ${documents.length} documents to ${indexName}`, + data: { + indexName, + documentCount: documents.length, + }, + }); + } + } + + // Refresh index pattern in OpenSearch Dashboards to make new fields visible + await this.refreshIndexPattern(); + + return { indexName, documentCount: documents.length }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[OpenSearchIndexer] Failed to bulk index after retries:`, error); + + // Log bulk indexing error to trace + if (options.trace) { + options.trace.record({ + type: 'NODE_PROGRESS', + level: 'error', + message: `Failed to bulk index ${documents.length} documents to ${indexName}`, + error: errorMessage, + data: { + indexName, + documentCount: documents.length, + }, + }); + } + + throw error; + } + } + + /** + * Refresh the index pattern in OpenSearch Dashboards to make new fields visible. + * Two-step process: + * 1. Get fresh field mappings from OpenSearch via _fields_for_wildcard API + * 2. Update the saved index pattern object with the new fields + * Fails silently if Dashboards URL is not configured or refresh fails. + */ + private async refreshIndexPattern(): Promise { + if (!this.dashboardsUrl) { + console.debug( + '[OpenSearchIndexer] Dashboards URL not configured, skipping index pattern refresh', + ); + return; + } + + const indexPatternId = 'security-findings-*'; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'osd-xsrf': 'true', // Required by OpenSearch Dashboards + }; + + // Add basic auth if credentials are available + if (this.dashboardsAuth) { + const authString = Buffer.from( + `${this.dashboardsAuth.username}:${this.dashboardsAuth.password}`, + ).toString('base64'); + headers['Authorization'] = `Basic ${authString}`; + } + + // Step 1: Get fresh fields from OpenSearch via Dashboards API + const fieldsUrl = `${this.dashboardsUrl}/api/index_patterns/_fields_for_wildcard?pattern=${encodeURIComponent(indexPatternId)}&meta_fields=_source&meta_fields=_id&meta_fields=_type&meta_fields=_index&meta_fields=_score`; + const fieldsResponse = await fetch(fieldsUrl, { method: 'GET', headers }); + + if (!fieldsResponse.ok) { + console.warn(`[OpenSearchIndexer] Failed to get fresh fields: ${fieldsResponse.status}`); + return; + } + + const fieldsData = (await fieldsResponse.json()) as { fields?: unknown[] }; + const freshFields = fieldsData.fields || []; + + // Step 2: Get current index pattern to preserve other attributes + const patternUrl = `${this.dashboardsUrl}/api/saved_objects/index-pattern/${encodeURIComponent(indexPatternId)}`; + const patternResponse = await fetch(patternUrl, { method: 'GET', headers }); + + if (!patternResponse.ok) { + console.warn(`[OpenSearchIndexer] Index pattern not found: ${patternResponse.status}`); + return; + } + + const patternData = (await patternResponse.json()) as { + attributes: { title: string; timeFieldName: string }; + version: string; + }; + + // Step 3: Update the index pattern with fresh fields + // Include version for optimistic concurrency control (matches UI behavior) + const updateResponse = await fetch(patternUrl, { + method: 'PUT', + headers, + body: JSON.stringify({ + attributes: { + title: patternData.attributes.title, + timeFieldName: patternData.attributes.timeFieldName, + fields: JSON.stringify(freshFields), + }, + version: patternData.version, + }), + }); + + if (updateResponse.ok) { + console.debug( + `[OpenSearchIndexer] Index pattern fields refreshed (${freshFields.length} fields)`, + ); + } else { + console.warn( + `[OpenSearchIndexer] Failed to update index pattern: ${updateResponse.status}`, + ); + } + } catch (error) { + // Non-critical failure - log but don't throw + console.warn('[OpenSearchIndexer] Failed to refresh index pattern:', error); + } + } + + private buildIndexName(orgId: string, indexSuffix?: string): string { + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + const suffix = indexSuffix || `${year}.${month}.${day}`; + return `security-findings-${orgId}-${suffix}`; + } + + private detectAssetKey(document: Record, explicitField?: string): string | null { + // If explicit field is provided, use it + if (explicitField && document[explicitField]) { + return String(document[explicitField]); + } + + // Auto-detect from common fields + const assetFields = [ + 'asset_key', + 'host', + 'domain', + 'subdomain', + 'url', + 'ip', + 'asset', + 'target', + ]; + + for (const field of assetFields) { + if (document[field]) { + return String(document[field]); + } + } + + return null; + } +} + +// Singleton instance +let indexerInstance: OpenSearchIndexer | null = null; + +export function getOpenSearchIndexer(): OpenSearchIndexer { + if (!indexerInstance) { + indexerInstance = new OpenSearchIndexer(); + } + return indexerInstance; +} From 82ea105fd8b1aad56adb3bfeb0bf90a152c24341 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Thu, 5 Feb 2026 18:00:41 -0500 Subject: [PATCH 04/13] feat(analytics): add multi-tenant OpenSearch Security with dynamic provisioning Signed-off-by: Aseem Shrey --- backend/src/analytics/analytics.controller.ts | 78 +++- backend/src/analytics/analytics.module.ts | 15 +- .../analytics/opensearch-tenant.service.ts | 387 ++++++++++++++++++ .../organization-settings.service.ts | 9 + backend/src/app.controller.ts | 47 ++- .../src/auth/providers/clerk-auth.provider.ts | 85 +++- backend/src/workflows/workflows.controller.ts | 2 + backend/src/workflows/workflows.service.ts | 2 + docker/SECURE-DEV-MODE.md | 126 ++++++ docker/docker-compose.dev-ports.yml | 20 + docker/docker-compose.dev-secure.yml | 75 ++++ docker/docker-compose.full.yml | 17 +- docker/docker-compose.infra.yml | 15 +- docker/docker-compose.prod.yml | 28 +- docker/nginx/nginx.dev.conf | 34 +- docker/nginx/nginx.full.conf | 18 +- docker/nginx/nginx.prod.conf | 18 +- docker/opensearch-dashboards.prod.yml | 30 +- docker/opensearch-init.sh | 49 ++- docker/opensearch-security/action_groups.yml | 61 +++ docker/opensearch-security/allowlist.yml | 13 + docker/opensearch-security/audit.yml | 30 ++ docker/opensearch-security/config.yml | 47 +++ .../docker-entrypoint-security.sh | 108 +++++ docker/opensearch-security/internal_users.yml | 21 +- docker/opensearch-security/nodes_dn.yml | 12 + docker/opensearch-security/roles.yml | 65 ++- docker/opensearch-security/roles_mapping.yml | 9 + docker/opensearch-security/whitelist.yml | 12 + docker/opensearch.dev-secure.yml | 35 ++ docker/scripts/hash-password.sh | 37 ++ docker/scripts/security-init.sh | 157 +++++++ docs/analytics.md | 9 + frontend/src/auth/AuthProvider.tsx | 6 +- frontend/src/components/layout/TopBar.tsx | 24 +- .../workflow-builder/WorkflowBuilder.tsx | 1 + .../hooks/useWorkflowImportExport.ts | 37 +- frontend/src/store/runStore.ts | 2 + justfile | 152 ++++++- worker/src/components/core/analytics-sink.ts | 19 +- worker/src/utils/opensearch-indexer.ts | 89 +++- 41 files changed, 1858 insertions(+), 143 deletions(-) create mode 100644 backend/src/analytics/opensearch-tenant.service.ts create mode 100644 docker/SECURE-DEV-MODE.md create mode 100644 docker/docker-compose.dev-ports.yml create mode 100644 docker/docker-compose.dev-secure.yml create mode 100644 docker/opensearch-security/action_groups.yml create mode 100644 docker/opensearch-security/allowlist.yml create mode 100644 docker/opensearch-security/audit.yml create mode 100644 docker/opensearch-security/config.yml create mode 100755 docker/opensearch-security/docker-entrypoint-security.sh create mode 100644 docker/opensearch-security/nodes_dn.yml create mode 100644 docker/opensearch-security/whitelist.yml create mode 100644 docker/opensearch.dev-secure.yml create mode 100755 docker/scripts/hash-password.sh create mode 100755 docker/scripts/security-init.sh diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index 781c20a4..a8cef78a 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -4,15 +4,18 @@ import { Controller, ForbiddenException, Get, + Headers, Post, Put, UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ApiOkResponse, ApiTags, ApiHeader } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { SecurityAnalyticsService } from './security-analytics.service'; import { OrganizationSettingsService } from './organization-settings.service'; +import { OpenSearchTenantService } from './opensearch-tenant.service'; import { AnalyticsQueryRequestDto, AnalyticsQueryResponseDto } from './dto/analytics-query.dto'; import { AnalyticsSettingsResponseDto, @@ -20,6 +23,7 @@ import { TIER_LIMITS, } from './dto/analytics-settings.dto'; import { CurrentAuth } from '../auth/auth-context.decorator'; +import { Public } from '../auth/public.decorator'; import type { AuthContext } from '../auth/types'; const MAX_QUERY_SIZE = 1000; @@ -32,10 +36,16 @@ function isValidNonNegativeInt(value: unknown): value is number { @ApiTags('analytics') @Controller('analytics') export class AnalyticsController { + private readonly internalServiceToken: string; + constructor( private readonly securityAnalyticsService: SecurityAnalyticsService, private readonly organizationSettingsService: OrganizationSettingsService, - ) {} + private readonly openSearchTenantService: OpenSearchTenantService, + private readonly configService: ConfigService, + ) { + this.internalServiceToken = this.configService.get('INTERNAL_SERVICE_TOKEN') || ''; + } @Post('query') @Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute per user @@ -220,4 +230,68 @@ export class AnalyticsController { updatedAt: updated.updatedAt, }; } + + /** + * Ensure tenant resources exist for an organization. + * Called by worker before indexing to ensure tenant isolation is set up. + * + * Requires X-Internal-Token header for authentication (internal service-to-service). + * This endpoint is idempotent - safe to call multiple times. + */ + @Public() + @SkipThrottle() + @Post('ensure-tenant') + @ApiOkResponse({ + description: 'Ensure tenant resources exist for organization', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + securityEnabled: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }) + async ensureTenant( + @Headers('x-internal-token') internalToken: string | undefined, + @Body() body: { organizationId: string }, + ): Promise<{ success: boolean; securityEnabled: boolean; message: string }> { + // Validate internal service token + if (!this.internalServiceToken) { + // Token not configured - allow in dev mode but log warning + console.warn('[ensureTenant] INTERNAL_SERVICE_TOKEN not configured'); + } else if (internalToken !== this.internalServiceToken) { + throw new UnauthorizedException('Invalid internal service token'); + } + + // Validate request body + if (!body.organizationId || typeof body.organizationId !== 'string') { + throw new BadRequestException('organizationId is required'); + } + + const orgId = body.organizationId.trim(); + if (!orgId) { + throw new BadRequestException('organizationId cannot be empty'); + } + + // Check if security mode is enabled + if (!this.openSearchTenantService.isSecurityEnabled()) { + return { + success: true, + securityEnabled: false, + message: 'Security mode disabled, tenant provisioning skipped', + }; + } + + // Provision tenant resources + const success = await this.openSearchTenantService.ensureTenantExists(orgId); + + return { + success, + securityEnabled: true, + message: success + ? `Tenant provisioned for ${orgId}` + : `Failed to provision tenant for ${orgId}`, + }; + } } diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index df287cc4..33815f69 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -2,11 +2,22 @@ import { Module } from '@nestjs/common'; import { AnalyticsService } from './analytics.service'; import { SecurityAnalyticsService } from './security-analytics.service'; import { OrganizationSettingsService } from './organization-settings.service'; +import { OpenSearchTenantService } from './opensearch-tenant.service'; import { AnalyticsController } from './analytics.controller'; @Module({ controllers: [AnalyticsController], - providers: [AnalyticsService, SecurityAnalyticsService, OrganizationSettingsService], - exports: [AnalyticsService, SecurityAnalyticsService, OrganizationSettingsService], + providers: [ + AnalyticsService, + SecurityAnalyticsService, + OrganizationSettingsService, + OpenSearchTenantService, + ], + exports: [ + AnalyticsService, + SecurityAnalyticsService, + OrganizationSettingsService, + OpenSearchTenantService, + ], }) export class AnalyticsModule {} diff --git a/backend/src/analytics/opensearch-tenant.service.ts b/backend/src/analytics/opensearch-tenant.service.ts new file mode 100644 index 00000000..b12c63f7 --- /dev/null +++ b/backend/src/analytics/opensearch-tenant.service.ts @@ -0,0 +1,387 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 1000; + +/** + * OpenSearch Tenant Service + * + * Handles dynamic tenant provisioning for multi-tenant analytics isolation. + * Creates OpenSearch Security tenants, roles, role mappings, index templates, + * seed indices, and index patterns for new organizations. + * + * This service is idempotent - safe to call multiple times for the same org. + * Guarded by OPENSEARCH_SECURITY_ENABLED - no-op when security is disabled. + */ +@Injectable() +export class OpenSearchTenantService { + private readonly logger = new Logger(OpenSearchTenantService.name); + private readonly securityEnabled: boolean; + private readonly opensearchUrl: string; + private readonly dashboardsUrl: string; + private readonly adminUsername: string; + private readonly adminPassword: string; + + constructor(private readonly configService: ConfigService) { + this.securityEnabled = + this.configService.get('OPENSEARCH_SECURITY_ENABLED') === 'true'; + this.opensearchUrl = this.configService.get('OPENSEARCH_URL') || 'http://opensearch:9200'; + this.dashboardsUrl = + this.configService.get('OPENSEARCH_DASHBOARDS_URL') || 'http://opensearch-dashboards:5601'; + this.adminUsername = this.configService.get('OPENSEARCH_ADMIN_USERNAME') || 'admin'; + this.adminPassword = this.configService.get('OPENSEARCH_ADMIN_PASSWORD') || ''; + + this.logger.log( + `OpenSearch tenant service initialized (security: ${this.securityEnabled}, url: ${this.opensearchUrl})`, + ); + } + + /** + * Validates organization ID format. + * Must be lowercase alphanumeric with hyphens/underscores, starting with alphanumeric. + */ + private validateOrgId(orgId: string): boolean { + return /^[a-z0-9][a-z0-9_-]*$/.test(orgId); + } + + /** + * Creates Basic Auth header for OpenSearch API calls. + */ + private getAuthHeader(): string { + return `Basic ${Buffer.from(`${this.adminUsername}:${this.adminPassword}`).toString('base64')}`; + } + + /** + * Fetch wrapper with retry logic for transient connection errors. + * Bun's fetch can fail with various messages (ConnectionRefused, "typo in url", + * "Unable to connect") during concurrent request bursts. Retry all fetch-level + * errors (not HTTP errors) with exponential backoff. + */ + private async fetchWithRetry( + url: string, + options: RequestInit, + label: string, + ): Promise { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return await fetch(url, options); + } catch (error: any) { + if (attempt === MAX_RETRIES) { + throw error; + } + + const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + this.logger.warn( + `${label}: fetch failed (attempt ${attempt}/${MAX_RETRIES}): ${error?.message}. Retrying in ${delay}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + // Unreachable, but TypeScript needs it + throw new Error(`${label}: exhausted retries`); + } + + /** + * Ensures all tenant resources exist for the given organization. + * Creates: tenant, role, role mapping, index template, seed index, index pattern. + * + * This method is idempotent - safe to call multiple times. + * Returns true if all resources were created/verified successfully. + */ + async ensureTenantExists(orgId: string): Promise { + // No-op when security is disabled (dev mode) + if (!this.securityEnabled) { + this.logger.debug(`Tenant provisioning skipped (security disabled): ${orgId}`); + return true; + } + + // Normalize to lowercase for consistent tenant naming + const normalizedOrgId = orgId.toLowerCase(); + + // Validate format + if (!this.validateOrgId(normalizedOrgId)) { + this.logger.warn(`Invalid org ID format: ${orgId}`); + return false; + } + + this.logger.log(`Provisioning tenant for org: ${normalizedOrgId}`); + + try { + // Brief delay to let the nginx auth_request burst settle before + // making outbound connections (Bun's fetch can fail during bursts) + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Step 1: Create tenant + await this.createTenant(normalizedOrgId); + + // Step 2: Create read-only role for this customer + await this.createCustomerRole(normalizedOrgId); + + // Step 3: Create role mapping + await this.createRoleMapping(normalizedOrgId); + + // Step 4: Create index template with field mappings + await this.createIndexTemplate(normalizedOrgId); + + // Step 5: Create seed index so the index pattern can resolve fields + await this.createSeedIndex(normalizedOrgId); + + // Step 6: Create index pattern in Dashboards + await this.createIndexPattern(normalizedOrgId); + + this.logger.log(`Tenant provisioned successfully: ${normalizedOrgId}`); + return true; + } catch (error: any) { + this.logger.error( + `Failed to provision tenant ${normalizedOrgId}: ${error?.message || error}`, + ); + return false; + } + } + + /** + * Creates a tenant in OpenSearch Security. + */ + private async createTenant(orgId: string): Promise { + const url = `${this.opensearchUrl}/_plugins/_security/api/tenants/${orgId}`; + + const response = await this.fetchWithRetry( + url, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.getAuthHeader(), + }, + body: JSON.stringify({ + description: `Tenant for organization ${orgId}`, + }), + }, + `createTenant(${orgId})`, + ); + + // 200 = created, 409 = already exists (both are OK) + if (!response.ok && response.status !== 409) { + throw new Error(`Failed to create tenant: ${response.status} ${response.statusText}`); + } + + this.logger.debug(`Tenant created/verified: ${orgId}`); + } + + /** + * Creates a read-only customer role for the organization. + * Grants read access to security-findings-{orgId}-* indices. + */ + private async createCustomerRole(orgId: string): Promise { + const roleName = `customer_${orgId}_ro`; + const url = `${this.opensearchUrl}/_plugins/_security/api/roles/${roleName}`; + + const roleDefinition = { + cluster_permissions: [ + 'cluster_composite_ops_ro', + 'cluster:admin/opendistro/ism/policy/get', + 'cluster:admin/opendistro/ism/policy/search', + 'cluster:admin/opendistro/ism/managedindex/explain', + ], + index_permissions: [ + { + index_patterns: [`security-findings-${orgId}-*`], + allowed_actions: ['read', 'indices:data/read/*'], + }, + ], + tenant_permissions: [ + { + tenant_patterns: [orgId], + allowed_actions: ['kibana_all_write'], + }, + ], + }; + + const response = await this.fetchWithRetry( + url, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.getAuthHeader(), + }, + body: JSON.stringify(roleDefinition), + }, + `createCustomerRole(${orgId})`, + ); + + if (!response.ok && response.status !== 409) { + throw new Error(`Failed to create role: ${response.status} ${response.statusText}`); + } + + this.logger.debug(`Role created/verified: ${roleName}`); + } + + /** + * Creates a role mapping for the customer role. + * Maps the role name to backend_roles so nginx proxy auth works. + */ + private async createRoleMapping(orgId: string): Promise { + const roleName = `customer_${orgId}_ro`; + const url = `${this.opensearchUrl}/_plugins/_security/api/rolesmapping/${roleName}`; + + const mappingDefinition = { + backend_roles: [roleName], + description: `Role mapping for ${orgId} read-only access`, + }; + + const response = await this.fetchWithRetry( + url, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.getAuthHeader(), + }, + body: JSON.stringify(mappingDefinition), + }, + `createRoleMapping(${orgId})`, + ); + + if (!response.ok && response.status !== 409) { + throw new Error(`Failed to create role mapping: ${response.status} ${response.statusText}`); + } + + this.logger.debug(`Role mapping created/verified: ${roleName}`); + } + + /** + * Creates an index template so all future security-findings-{orgId}-* indices + * get proper field mappings automatically. + */ + private async createIndexTemplate(orgId: string): Promise { + const templateName = `security-findings-${orgId}`; + const url = `${this.opensearchUrl}/_index_template/${templateName}`; + + const templateDefinition = { + index_patterns: [`security-findings-${orgId}-*`], + template: { + mappings: { + properties: { + '@timestamp': { type: 'date' }, + workflow_id: { type: 'keyword' }, + workflow_name: { type: 'keyword' }, + run_id: { type: 'keyword' }, + node_ref: { type: 'keyword' }, + component_id: { type: 'keyword' }, + asset_key: { type: 'keyword' }, + }, + }, + }, + priority: 100, + }; + + const response = await this.fetchWithRetry( + url, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.getAuthHeader(), + }, + body: JSON.stringify(templateDefinition), + }, + `createIndexTemplate(${orgId})`, + ); + + if (!response.ok) { + throw new Error(`Failed to create index template: ${response.status} ${response.statusText}`); + } + + this.logger.debug(`Index template created/verified: ${templateName}`); + } + + /** + * Creates a seed index with explicit mappings so the Dashboards index pattern + * can resolve fields (especially @timestamp) before any real data is ingested. + */ + private async createSeedIndex(orgId: string): Promise { + const indexName = `security-findings-${orgId}-seed`; + const url = `${this.opensearchUrl}/${indexName}`; + + const response = await this.fetchWithRetry( + url, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.getAuthHeader(), + }, + body: JSON.stringify({ + mappings: { + properties: { + '@timestamp': { type: 'date' }, + workflow_id: { type: 'keyword' }, + workflow_name: { type: 'keyword' }, + run_id: { type: 'keyword' }, + node_ref: { type: 'keyword' }, + component_id: { type: 'keyword' }, + asset_key: { type: 'keyword' }, + }, + }, + }), + }, + `createSeedIndex(${orgId})`, + ); + + // 200 = created, 400 with "already exists" = OK + if (!response.ok && response.status !== 400) { + throw new Error(`Failed to create seed index: ${response.status} ${response.statusText}`); + } + + this.logger.debug(`Seed index created/verified: ${indexName}`); + } + + /** + * Creates an index pattern in OpenSearch Dashboards for this tenant. + */ + private async createIndexPattern(orgId: string): Promise { + const patternId = `security-findings-${orgId}-*`; + const url = `${this.dashboardsUrl}/analytics/api/saved_objects/index-pattern/${encodeURIComponent(patternId)}`; + + const response = await this.fetchWithRetry( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'osd-xsrf': 'true', + securitytenant: orgId, // Create in tenant's namespace + 'x-proxy-user': this.adminUsername, // Required for Dashboards proxy auth mode + 'x-proxy-roles': 'platform_admin', + 'x-forwarded-for': '127.0.0.1', // Required for proxy auth trust chain + }, + body: JSON.stringify({ + attributes: { + title: patternId, + timeFieldName: '@timestamp', + }, + }), + }, + `createIndexPattern(${orgId})`, + ); + + // 200 = created, 409 = already exists (both are OK) + if (!response.ok && response.status !== 409) { + const body = await response.text().catch(() => ''); + throw new Error( + `Failed to create index pattern: ${response.status} ${response.statusText} - ${body}`, + ); + } + + this.logger.debug(`Index pattern created/verified: ${patternId}`); + } + + /** + * Check if security mode is enabled. + */ + isSecurityEnabled(): boolean { + return this.securityEnabled; + } +} diff --git a/backend/src/analytics/organization-settings.service.ts b/backend/src/analytics/organization-settings.service.ts index b26a612c..a1ef7437 100644 --- a/backend/src/analytics/organization-settings.service.ts +++ b/backend/src/analytics/organization-settings.service.ts @@ -9,6 +9,7 @@ import { SubscriptionTier, } from '../database/schema/organization-settings'; import { TIER_LIMITS } from './dto/analytics-settings.dto'; +import { OpenSearchTenantService } from './opensearch-tenant.service'; @Injectable() export class OrganizationSettingsService { @@ -17,6 +18,7 @@ export class OrganizationSettingsService { constructor( @Inject(DRIZZLE_TOKEN) private readonly db: NodePgDatabase, + private readonly tenantService: OpenSearchTenantService, ) {} /** @@ -44,6 +46,13 @@ export class OrganizationSettingsService { }) .returning(); + // Provision OpenSearch tenant for the new organization (fire-and-forget) + this.tenantService.ensureTenantExists(organizationId).catch((err) => { + this.logger.error( + `Failed to provision OpenSearch tenant for ${organizationId}: ${err}`, + ); + }); + return created; } diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 379c7e50..3983f6c1 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Res, UnauthorizedException, Headers } from '@nestjs/common'; +import { Controller, Get, Logger, Post, Res, UnauthorizedException, Headers } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SkipThrottle } from '@nestjs/throttler'; import type { Response } from 'express'; @@ -13,14 +13,19 @@ import { SESSION_COOKIE_MAX_AGE, createSessionToken, } from './auth/session.utils'; +import { OpenSearchTenantService } from './analytics/opensearch-tenant.service'; @Controller() export class AppController { + private readonly logger = new Logger(AppController.name); private readonly authCfg: AuthConfig; + /** Track org provisioning state: resolved promises for completed orgs, pending promises for in-flight */ + private readonly provisioningOrgs = new Map>(); constructor( private readonly appService: AppService, private readonly configService: ConfigService, + private readonly tenantService: OpenSearchTenantService, ) { this.authCfg = this.configService.get('auth')!; } @@ -36,16 +41,54 @@ export class AppController { * Returns 200 if authenticated, 401 otherwise. * Used by nginx to protect /analytics/* routes. * + * Response headers (for nginx tenant isolation): + * - X-Auth-Organization-Id: lowercase normalized org ID (empty if no org context) + * - X-Auth-User-Id: user identifier + * * Note: SkipThrottle is required because nginx sends an auth_request * for every resource loaded from /analytics/*, which can quickly * exceed rate limits and cause 500 errors. */ @SkipThrottle() @Get('/auth/validate') - validateAuth(@CurrentAuth() auth: AuthContext | null) { + validateAuth( + @CurrentAuth() auth: AuthContext | null, + @Res({ passthrough: true }) res: Response, + ) { if (!auth || !auth.isAuthenticated) { throw new UnauthorizedException(); } + + // Set headers for nginx tenant isolation + // Canonicalize orgId to lowercase for consistent tenant naming + const normalizedOrgId = auth.organizationId?.toLowerCase() || ''; + res.setHeader('X-Auth-Organization-Id', normalizedOrgId); + res.setHeader('X-Auth-User-Id', auth.userId || ''); + + // Ensure OpenSearch tenant exists for this org (fire-and-forget, cached) + // Uses a Map of promises so: (1) concurrent requests share the same in-flight provisioning, + // (2) failures are removed from cache to allow retry on next auth request. + if (normalizedOrgId && !this.provisioningOrgs.has(normalizedOrgId)) { + const promise = this.tenantService.ensureTenantExists(normalizedOrgId).then( + (success) => { + if (!success) { + // Provisioning returned false (validation error, etc.) — allow retry + this.provisioningOrgs.delete(normalizedOrgId); + } + return success; + }, + (err) => { + // Remove from cache so it retries next request + this.provisioningOrgs.delete(normalizedOrgId); + this.logger.error( + `Failed to provision OpenSearch tenant for ${normalizedOrgId}: ${err}`, + ); + return false; + }, + ); + this.provisioningOrgs.set(normalizedOrgId, promise); + } + return { valid: true }; } diff --git a/backend/src/auth/providers/clerk-auth.provider.ts b/backend/src/auth/providers/clerk-auth.provider.ts index 5cb42458..d51af777 100644 --- a/backend/src/auth/providers/clerk-auth.provider.ts +++ b/backend/src/auth/providers/clerk-auth.provider.ts @@ -95,45 +95,90 @@ export class ClerkAuthProvider implements AuthProviderStrategy { } private extractBearerToken(request: Request): string | null { + // Priority 1: Authorization header (API calls from frontend) const header = request.headers.authorization ?? (request.headers.Authorization as string | undefined); - if (!header) { - return null; + if (header) { + const [scheme, token] = header.split(' '); + if (scheme && token && scheme.toLowerCase() === 'bearer') { + return token.trim(); + } } - const [scheme, token] = header.split(' '); - if (!scheme || !token) { - return null; + + // Priority 2: Clerk's __session cookie (browser navigations like /analytics/) + // Clerk's JS SDK sets this cookie on the app's domain; it contains a verifiable JWT + const sessionCookie = request.cookies?.['__session']; + if (sessionCookie) { + this.logger.log(`[AUTH] No Authorization header, falling back to Clerk __session cookie`); + return sessionCookie; } - return scheme.toLowerCase() === 'bearer' ? token.trim() : null; + + return null; } /** - * Resolves organization ID with priority: - * 1. X-Organization-Id header (user's selected org) - * 2. JWT payload org_id/organization_id - * 3. Default to "user's workspace" format: workspace-{userId} + * Resolves organization ID with validation: + * 1. If X-Organization-Id header matches JWT's org_id → trust it + * 2. If header is workspace-{userId} → trust it (personal workspace) + * 3. If header specifies different org than JWT → IGNORE it, log security warning + * 4. Fall through to JWT org or workspace default + * + * Security: This prevents spoofed X-Organization-Id headers from accessing + * other organizations' data. The JWT's org_id is the source of truth. */ private resolveOrganizationId(request: Request, payload: ClerkJwt, userId: string): string { - // Priority 1: Header (user's selected org from frontend) const headerOrg = request.headers['x-organization-id'] as string | undefined; - this.logger.log(`[AUTH] X-Organization-Id header: ${headerOrg || 'not present'}`); + const jwtOrg = payload.o?.id || payload.org_id || payload.organization_id; + const userWorkspace = `workspace-${userId}`; + this.logger.log( + `[AUTH] Resolving org - Header: ${headerOrg || 'not present'}, JWT org: ${jwtOrg || 'none'}, User: ${userId}`, + ); + + // If header is provided, validate it if (headerOrg && headerOrg.trim().length > 0) { - this.logger.log(`[AUTH] Using org from header: ${headerOrg.trim()}`); - return headerOrg.trim(); + const trimmedHeader = headerOrg.trim(); + + // Case 1: Header matches JWT org → trust it + if (jwtOrg && trimmedHeader === jwtOrg) { + this.logger.log(`[AUTH] Header matches JWT org, using: ${trimmedHeader}`); + return trimmedHeader; + } + + // Case 2: Header is user's personal workspace → trust it + if (trimmedHeader === userWorkspace) { + this.logger.log(`[AUTH] Header is user's workspace, using: ${trimmedHeader}`); + return trimmedHeader; + } + + // Case 3: Header specifies a DIFFERENT org than JWT → IGNORE and log security warning + if (jwtOrg && trimmedHeader !== jwtOrg) { + this.logger.warn( + `[AUTH] SECURITY: X-Organization-Id header "${trimmedHeader}" does not match JWT org "${jwtOrg}". ` + + `User ${userId} may be attempting cross-tenant access. Ignoring header.`, + ); + // Fall through to use JWT org + } + + // Case 4: No JWT org but header is not user's workspace → potential spoofing + if (!jwtOrg && trimmedHeader !== userWorkspace) { + this.logger.warn( + `[AUTH] SECURITY: X-Organization-Id header "${trimmedHeader}" provided without JWT org context. ` + + `User ${userId} does not have active org session. Ignoring header.`, + ); + // Fall through to workspace default + } } - // Priority 2: JWT payload org - const jwtOrg = payload.o?.id || payload.org_id || payload.organization_id; + // Use JWT org if available if (jwtOrg) { this.logger.log(`[AUTH] Using org from JWT payload: ${jwtOrg}`); return jwtOrg; } - // Priority 3: Default to user's workspace - const workspace = `workspace-${userId}`; - this.logger.log(`[AUTH] No org found, using workspace: ${workspace}`); - return workspace; + // Default to user's workspace + this.logger.log(`[AUTH] No org found, using workspace: ${userWorkspace}`); + return userWorkspace; } /** diff --git a/backend/src/workflows/workflows.controller.ts b/backend/src/workflows/workflows.controller.ts index f0f9c41d..b6e31f86 100644 --- a/backend/src/workflows/workflows.controller.ts +++ b/backend/src/workflows/workflows.controller.ts @@ -303,6 +303,7 @@ export class WorkflowsController { properties: { id: { type: 'string' }, workflowId: { type: 'string' }, + organizationId: { type: 'string' }, status: { type: 'string', enum: [ @@ -372,6 +373,7 @@ export class WorkflowsController { properties: { id: { type: 'string' }, workflowId: { type: 'string' }, + organizationId: { type: 'string' }, status: { type: 'string', enum: [ diff --git a/backend/src/workflows/workflows.service.ts b/backend/src/workflows/workflows.service.ts index bf44fe5d..0ac9191f 100644 --- a/backend/src/workflows/workflows.service.ts +++ b/backend/src/workflows/workflows.service.ts @@ -66,6 +66,7 @@ export interface WorkflowRunHandle { export interface WorkflowRunSummary { id: string; workflowId: string; + organizationId: string; workflowVersionId: string | null; workflowVersion: number | null; status: ExecutionStatus; @@ -614,6 +615,7 @@ export class WorkflowsService { return { id: run.runId, workflowId: run.workflowId, + organizationId, workflowVersionId: run.workflowVersionId ?? null, workflowVersion: run.workflowVersion ?? null, status: currentStatus, diff --git a/docker/SECURE-DEV-MODE.md b/docker/SECURE-DEV-MODE.md new file mode 100644 index 00000000..f20acccb --- /dev/null +++ b/docker/SECURE-DEV-MODE.md @@ -0,0 +1,126 @@ +# Secure Development Mode + +This document describes the secure development environment setup with OpenSearch Security enabled for multi-tenant isolation. + +## Overview + +The `just dev` command now starts the development environment with full OpenSearch Security enabled, matching the production security model. This provides: + +- **TLS encryption** for all OpenSearch communication +- **Multi-tenant isolation** - each organization's data is isolated +- **Authentication required** - no anonymous access +- **Same security model as production** - test security features locally + +## Quick Start + +```bash +# Start secure dev environment (recommended) +just dev + +# Start without security (faster, for quick iteration) +just dev-insecure +``` + +## Architecture + +### Docker Compose Files + +| File | Purpose | +|------|---------| +| `docker-compose.infra.yml` | Base infrastructure (Postgres, Redis, Temporal, etc.) | +| `docker-compose.dev-secure.yml` | Security overlay for development | +| `docker-compose.prod.yml` | Production security configuration | + +### Security Configuration Files + +Located in `docker/opensearch-security/`: + +| File | Purpose | +|------|---------| +| `config.yml` | Authentication/authorization backends (proxy auth) | +| `internal_users.yml` | System users (admin, kibanaserver, worker) | +| `roles.yml` | Role definitions with index permissions | +| `roles_mapping.yml` | User-to-role mappings | +| `action_groups.yml` | Permission groups for roles | +| `tenants.yml` | Tenant definitions | +| `audit.yml` | Audit logging configuration | + +### TLS Certificates + +Certificates are auto-generated on first run and stored in `docker/certs/`: + +- `root-ca.pem` / `root-ca-key.pem` - Certificate Authority +- `admin.pem` / `admin-key.pem` - Admin certificate for securityadmin tool +- `node.pem` / `node-key.pem` - OpenSearch node certificate + +## Default Credentials + +For development convenience, default passwords are set: + +| User | Password | Purpose | +|------|----------|---------| +| `admin` | `admin` | Platform administrator | +| `kibanaserver` | `admin` | Dashboards backend communication | +| `worker` | `admin` | Worker service for indexing | + +**Important**: Change these in production via environment variables: +- `OPENSEARCH_ADMIN_PASSWORD` +- `OPENSEARCH_DASHBOARDS_PASSWORD` + +## Multi-Tenant Isolation + +### How It Works + +1. **Index Pattern**: Each organization's data is stored in indices prefixed with their org ID: + - `security-findings-{org_id}-*` + +2. **Tenant Isolation**: OpenSearch Dashboards uses tenants to isolate saved objects (dashboards, visualizations) + +3. **Role-Based Access**: Dynamic roles are created per customer restricting access to their indices only + +### Dynamic Provisioning + +When a new customer is onboarded, the backend creates: +1. A tenant for their organization +2. A role with permissions scoped to their indices +3. User-to-role mappings + +## Troubleshooting + +### Check Container Health + +```bash +just dev status +docker logs shipsec-opensearch +docker logs shipsec-opensearch-dashboards +``` + +### Reset Security Configuration + +```bash +# Clean everything and restart +just dev clean && just dev + +# Or manually run securityadmin +docker exec shipsec-opensearch /usr/share/opensearch/plugins/opensearch-security/tools/securityadmin.sh \ + -cd /usr/share/opensearch/config/opensearch-security \ + -icl -nhnv \ + -cacert /usr/share/opensearch/config/certs/root-ca.pem \ + -cert /usr/share/opensearch/config/certs/admin.pem \ + -key /usr/share/opensearch/config/certs/admin-key.pem +``` + +### Regenerate Certificates + +```bash +rm -rf docker/certs +just generate-certs +just dev clean && just dev +``` + +## Changes from Previous Setup + +1. **`just dev`** now runs with security enabled (was insecure) +2. **`just dev-insecure`** is the new command for fast, insecure development +3. Certificates are auto-generated if missing +4. Environment variable `OPENSEARCH_SECURITY_ENABLED=true` is set for backend/worker diff --git a/docker/docker-compose.dev-ports.yml b/docker/docker-compose.dev-ports.yml new file mode 100644 index 00000000..f52add21 --- /dev/null +++ b/docker/docker-compose.dev-ports.yml @@ -0,0 +1,20 @@ +# Development Ports Overlay +# +# WARNING: These ports bypass ALL nginx authentication! +# Use ONLY for local development where direct OpenSearch/Dashboards access is needed. +# +# Usage: +# docker compose -f docker-compose.infra.yml -f docker-compose.dev-ports.yml up -d +# +# This overlay exposes OpenSearch and Dashboards ports on loopback (127.0.0.1) only, +# preventing external network access while allowing local development tools to connect. + +services: + opensearch: + ports: + - "127.0.0.1:9200:9200" + - "127.0.0.1:9600:9600" + + opensearch-dashboards: + ports: + - "127.0.0.1:5601:5601" diff --git a/docker/docker-compose.dev-secure.yml b/docker/docker-compose.dev-secure.yml new file mode 100644 index 00000000..1411e382 --- /dev/null +++ b/docker/docker-compose.dev-secure.yml @@ -0,0 +1,75 @@ +# Development Docker Compose with Security & Multitenancy +# +# Usage: +# docker compose -f docker-compose.infra.yml -f docker-compose.dev-secure.yml up -d +# +# This overlay enables OpenSearch Security plugin for development: +# - TLS encryption +# - Multi-tenant isolation +# - Same security model as production +# +# Requires: +# 1. TLS certificates in docker/certs/ (run: just generate-certs) +# 2. Environment variables (auto-set by just dev for convenience): +# - OPENSEARCH_ADMIN_PASSWORD +# - OPENSEARCH_DASHBOARDS_PASSWORD + +services: + opensearch: + # Custom entrypoint for proxy auth config templating + entrypoint: ["/usr/share/opensearch/config/opensearch-security/docker-entrypoint-security.sh"] + environment: + # Enable security plugin (override infra.yml settings) + - DISABLE_SECURITY_PLUGIN=false + - DISABLE_INSTALL_DEMO_CONFIG=true + # Admin password for healthcheck (default: admin) + - OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-admin} + # Proxy auth - trusted proxy IP regex (Docker networks: 172.x, 192.168.x, 10.x) + # NOTE: Double backslashes required - sed consumes one level during config templating + - OPENSEARCH_INTERNAL_PROXIES=(172|192|10)\\.\\d+\\.\\d+\\.\\d+ + volumes: + - opensearch_data:/usr/share/opensearch/data + - ./certs:/usr/share/opensearch/config/certs:ro + - ./opensearch-security:/usr/share/opensearch/config/opensearch-security:ro + # Custom config file with admin_dn as YAML array (env vars don't support arrays) + - ./opensearch.dev-secure.yml:/usr/share/opensearch/config/opensearch.yml:ro + healthcheck: + test: ["CMD-SHELL", "curl -sf -u admin:$${OPENSEARCH_ADMIN_PASSWORD:-admin} --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + opensearch-dashboards: + environment: + # Enable security plugin (override infra.yml settings) + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=false + - OPENSEARCH_HOSTS=["https://opensearch:9200"] + - OPENSEARCH_DASHBOARDS_PASSWORD=${OPENSEARCH_DASHBOARDS_PASSWORD:-admin} + volumes: + - ./opensearch-dashboards.prod.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro + - ./certs:/usr/share/opensearch-dashboards/config/certs:ro + healthcheck: + # Check if server responds (401 is fine - means server is up, security just requires auth) + test: ["CMD-SHELL", "curl -sf -o /dev/null -w '%{http_code}' http://localhost:5601/analytics/api/status | grep -qE '(200|401)' || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + # Override init script to work with secured cluster + opensearch-init: + environment: + - OPENSEARCH_SECURITY_ENABLED=true + - OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-admin} + - OPENSEARCH_CA_CERT=/certs/root-ca.pem + volumes: + - ./opensearch-init.sh:/init.sh:ro + - ./certs:/certs:ro + +volumes: + opensearch_data: + +networks: + default: + name: shipsec-network diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index bf4713e6..fe397305 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -279,12 +279,19 @@ services: - AUTH_PROVIDER=${AUTH_PROVIDER:-local} - CLERK_PUBLISHABLE_KEY=${CLERK_PUBLISHABLE_KEY:-} - CLERK_SECRET_KEY=${CLERK_SECRET_KEY:-} + - SESSION_SECRET=${SESSION_SECRET:-} # Set to 'true' to disable analytics - DISABLE_ANALYTICS=${DISABLE_ANALYTICS:-false} # Secret encryption key (must be exactly 32 characters, NOT hex-encoded) - SECRET_STORE_MASTER_KEY=${SECRET_STORE_MASTER_KEY:-CHANGE_ME_32_CHAR_SECRET_KEY!!!!} # Internal service-to-service auth token (must match worker) - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-internal-service-token} + # OpenSearch tenant provisioning + - OPENSEARCH_SECURITY_ENABLED=${OPENSEARCH_SECURITY_ENABLED:-false} + - OPENSEARCH_URL=http://opensearch:9200 + - OPENSEARCH_DASHBOARDS_URL=http://opensearch-dashboards:5601/analytics + - OPENSEARCH_ADMIN_USERNAME=${OPENSEARCH_ADMIN_USERNAME:-admin} + - OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-} # Internal only - accessed via nginx at /api/ expose: - "3211" @@ -321,9 +328,9 @@ services: environment: - VITE_API_URL=${VITE_API_URL:-http://localhost} - VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost} - - VITE_AUTH_PROVIDER=clerk - - VITE_DEFAULT_ORG_ID=local-dev - - VITE_CLERK_PUBLISHABLE_KEY= + - VITE_AUTH_PROVIDER=${VITE_AUTH_PROVIDER:-local} + - VITE_DEFAULT_ORG_ID=${VITE_DEFAULT_ORG_ID:-local-dev} + - VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY:-} - VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL:-/analytics} # Internal only - accessed via nginx at / expose: @@ -373,6 +380,10 @@ services: # OpenSearch for Analytics Sink - OPENSEARCH_URL=http://opensearch:9200 - OPENSEARCH_DASHBOARDS_URL=http://opensearch-dashboards:5601/analytics + # Tenant provisioning (for multi-tenant security mode) + - BACKEND_URL=http://backend:3211 + - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-} + - OPENSEARCH_SECURITY_ENABLED=${OPENSEARCH_SECURITY_ENABLED:-false} depends_on: postgres: condition: service_healthy diff --git a/docker/docker-compose.infra.yml b/docker/docker-compose.infra.yml index 5e822d17..04145ad1 100644 --- a/docker/docker-compose.infra.yml +++ b/docker/docker-compose.infra.yml @@ -150,9 +150,11 @@ services: nofile: soft: 65536 hard: 65536 - ports: - - "9200:9200" - - "9600:9600" + # Ports exposed only within Docker network (not to host) + # Use docker-compose.dev-ports.yml overlay for local dev access + expose: + - "9200" + - "9600" volumes: - opensearch_data:/usr/share/opensearch/data restart: unless-stopped @@ -171,10 +173,11 @@ services: environment: - OPENSEARCH_HOSTS=["http://opensearch:9200"] - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true - # Port exposed for development access + # Ports exposed only within Docker network (not to host) + # Use docker-compose.dev-ports.yml overlay for local dev access # Production uses nginx reverse proxy at /analytics - ports: - - "5601:5601" + expose: + - "5601" volumes: - ./opensearch-dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro restart: unless-stopped diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index cca75fcb..fe81b009 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -16,19 +16,25 @@ services: opensearch: + # Custom entrypoint for proxy auth config templating + entrypoint: ["/usr/share/opensearch/config/opensearch-security/docker-entrypoint-security.sh"] environment: # Remove security disable flags (override dev settings) - DISABLE_SECURITY_PLUGIN=false - - DISABLE_INSTALL_DEMO_CONFIG=false + - DISABLE_INSTALL_DEMO_CONFIG=true + # Admin password for healthcheck + - OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-admin} + # Proxy auth - trusted proxy IP regex (Docker bridge network) + - OPENSEARCH_INTERNAL_PROXIES=172\.\d+\.\d+\.\d+ # Security configuration - - plugins.security.ssl.transport.pemcert_filepath=config/certs/node.pem - - plugins.security.ssl.transport.pemkey_filepath=config/certs/node-key.pem - - plugins.security.ssl.transport.pemtrustedcas_filepath=config/certs/root-ca.pem + - plugins.security.ssl.transport.pemcert_filepath=certs/node.pem + - plugins.security.ssl.transport.pemkey_filepath=certs/node-key.pem + - plugins.security.ssl.transport.pemtrustedcas_filepath=certs/root-ca.pem - plugins.security.ssl.transport.enforce_hostname_verification=false - plugins.security.ssl.http.enabled=true - - plugins.security.ssl.http.pemcert_filepath=config/certs/node.pem - - plugins.security.ssl.http.pemkey_filepath=config/certs/node-key.pem - - plugins.security.ssl.http.pemtrustedcas_filepath=config/certs/root-ca.pem + - plugins.security.ssl.http.pemcert_filepath=certs/node.pem + - plugins.security.ssl.http.pemkey_filepath=certs/node-key.pem + - plugins.security.ssl.http.pemtrustedcas_filepath=certs/root-ca.pem - plugins.security.allow_unsafe_democertificates=false - plugins.security.allow_default_init_securityindex=true - plugins.security.authcz.admin_dn=CN=admin,OU=ShipSec,O=ShipSecAI,L=SF,ST=CA,C=US @@ -43,10 +49,11 @@ services: - ./certs:/usr/share/opensearch/config/certs:ro - ./opensearch-security:/usr/share/opensearch/config/opensearch-security:ro healthcheck: - test: ["CMD-SHELL", "curl -sf --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health || exit 1"] + test: ["CMD-SHELL", "curl -sf -u admin:$${OPENSEARCH_ADMIN_PASSWORD:-admin} --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health || exit 1"] interval: 30s timeout: 10s - retries: 5 + retries: 10 + start_period: 60s opensearch-dashboards: environment: @@ -57,7 +64,8 @@ services: - ./opensearch-dashboards.prod.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro - ./certs:/usr/share/opensearch-dashboards/config/certs:ro healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:5601/analytics/api/status || exit 1"] + # Check if server responds (401 is fine - means server is up, security just requires auth) + test: ["CMD-SHELL", "curl -sf -o /dev/null -w '%{http_code}' http://localhost:5601/analytics/api/status | grep -qE '(200|401)' || exit 1"] interval: 30s timeout: 10s retries: 10 diff --git a/docker/nginx/nginx.dev.conf b/docker/nginx/nginx.dev.conf index 8f4b1f3d..119b5967 100644 --- a/docker/nginx/nginx.dev.conf +++ b/docker/nginx/nginx.dev.conf @@ -14,6 +14,10 @@ http { '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; + # Debug log format for analytics auth issues + log_format auth_debug '$remote_addr [$time_local] "$request" $status ' + 'auth_org_id="$auth_org_id" auth_user="$auth_user_id"'; + access_log /var/log/nginx/access.log main; sendfile on; @@ -94,6 +98,9 @@ http { # ================================================================= location = /_auth { internal; + # Debug: log internal auth requests + access_log /var/log/nginx/auth_internal.log main; + proxy_pass http://backend/api/v1/auth/validate; proxy_pass_request_body off; proxy_set_header Content-Length ""; @@ -105,16 +112,35 @@ http { } # ================================================================= - # OpenSearch Dashboards - /analytics/* (PROTECTED) + # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) # ================================================================= location /analytics/ { + # Debug logging for auth issues + access_log /var/log/nginx/analytics_auth.log auth_debug; + # Require authentication before proxying auth_request /_auth; # On auth failure, redirect to login page error_page 401 = @auth_redirect; + # Capture org/user context from auth response headers + auth_request_set $auth_org_id $upstream_http_x_auth_organization_id; + auth_request_set $auth_user_id $upstream_http_x_auth_user_id; + + # NOTE: Cannot use 'if ($auth_org_id = "")' here because nginx's 'if' runs + # in the rewrite phase BEFORE auth_request completes in the access phase. + # OpenSearch Security will reject requests with invalid/missing proxy auth headers. + # For fail-closed behavior, the auth backend should return 401 if org context is missing. + proxy_pass http://opensearch-dashboards; + # Standard forwarding headers (must be repeated here because nginx + # proxy_set_header in a location block overrides ALL parent-level directives) + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # WebSocket support for dashboards real-time features proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; @@ -127,6 +153,12 @@ http { # Dashboards-specific headers proxy_set_header osd-xsrf "true"; + # OpenSearch Security proxy auth headers + # proxy_set_header REPLACES any client-supplied headers (prevents spoofing) + proxy_set_header x-proxy-user $auth_org_id; + proxy_set_header x-proxy-roles "customer_${auth_org_id}_ro"; + proxy_set_header securitytenant $auth_org_id; + # Preserve cookies proxy_cookie_path /analytics/ /analytics/; diff --git a/docker/nginx/nginx.full.conf b/docker/nginx/nginx.full.conf index f8805296..0b3e03f1 100644 --- a/docker/nginx/nginx.full.conf +++ b/docker/nginx/nginx.full.conf @@ -101,7 +101,7 @@ http { } # ================================================================= - # OpenSearch Dashboards - /analytics/* (PROTECTED) + # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) # ================================================================= location /analytics/ { # Require authentication before proxying @@ -109,6 +109,16 @@ http { # On auth failure, redirect to login page error_page 401 = @auth_redirect; + # Capture org/user context from auth response headers + auth_request_set $auth_org_id $upstream_http_x_auth_organization_id; + auth_request_set $auth_user_id $upstream_http_x_auth_user_id; + + # FAIL-CLOSED: Reject if org context is missing + # This prevents unauthenticated or org-less sessions from reaching Dashboards + if ($auth_org_id = "") { + return 403; + } + proxy_pass http://opensearch-dashboards; # WebSocket support for dashboards real-time features @@ -123,6 +133,12 @@ http { # Dashboards-specific headers proxy_set_header osd-xsrf "true"; + # OpenSearch Security proxy auth headers + # proxy_set_header REPLACES any client-supplied headers (prevents spoofing) + proxy_set_header x-proxy-user $auth_org_id; + proxy_set_header x-proxy-roles "customer_${auth_org_id}_ro"; + proxy_set_header securitytenant $auth_org_id; + # Preserve cookies proxy_cookie_path /analytics/ /analytics/; diff --git a/docker/nginx/nginx.prod.conf b/docker/nginx/nginx.prod.conf index 36ea580c..5d4dd024 100644 --- a/docker/nginx/nginx.prod.conf +++ b/docker/nginx/nginx.prod.conf @@ -97,7 +97,7 @@ http { } # ================================================================= - # OpenSearch Dashboards - /analytics/* (PROTECTED) + # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) # ================================================================= location /analytics/ { # Require authentication before proxying @@ -105,6 +105,16 @@ http { # On auth failure, redirect to login page error_page 401 = @auth_redirect; + # Capture org/user context from auth response headers + auth_request_set $auth_org_id $upstream_http_x_auth_organization_id; + auth_request_set $auth_user_id $upstream_http_x_auth_user_id; + + # FAIL-CLOSED: Reject if org context is missing + # This prevents unauthenticated or org-less sessions from reaching Dashboards + if ($auth_org_id = "") { + return 403; + } + proxy_pass http://opensearch-dashboards/; # WebSocket support for dashboards real-time features @@ -119,6 +129,12 @@ http { # Dashboards-specific headers proxy_set_header osd-xsrf "true"; + # OpenSearch Security proxy auth headers + # proxy_set_header REPLACES any client-supplied headers (prevents spoofing) + proxy_set_header x-proxy-user $auth_org_id; + proxy_set_header x-proxy-roles "customer_${auth_org_id}_ro"; + proxy_set_header securitytenant $auth_org_id; + # Preserve cookies proxy_cookie_path / /analytics/; diff --git a/docker/opensearch-dashboards.prod.yml b/docker/opensearch-dashboards.prod.yml index c53263f3..323dc339 100644 --- a/docker/opensearch-dashboards.prod.yml +++ b/docker/opensearch-dashboards.prod.yml @@ -20,24 +20,34 @@ opensearch.hosts: ["https://opensearch:9200"] opensearch.ssl.verificationMode: certificate opensearch.ssl.certificateAuthorities: ["/usr/share/opensearch-dashboards/config/certs/root-ca.pem"] -# Authentication - use OpenSearch Security plugin +# Authentication - proxy auth from nginx (primary) + basic auth for kibanaserver +# Note: OpenSearch Dashboards doesn't support env var interpolation in YAML +# In production, use a secrets manager or pre-process this file opensearch.username: "kibanaserver" -opensearch.password: "${OPENSEARCH_DASHBOARDS_PASSWORD}" -opensearch.requestHeadersWhitelist: ["securitytenant", "Authorization"] +opensearch.password: "admin" +opensearch.requestHeadersAllowlist: ["securitytenant", "Authorization", "x-forwarded-for", "x-proxy-user", "x-proxy-roles"] + +# Proxy Authentication Configuration +# Nginx sets x-proxy-user/x-proxy-roles headers after validating user session via auth_request. +# Dashboards trusts these headers (no login form). Users must log in via the main app first. +# The kibanaserver user (above) is still used for Dashboards' own backend connection to OpenSearch. +opensearch_security.auth.type: "proxy" +opensearch_security.proxycache.user_header: "x-proxy-user" +opensearch_security.proxycache.roles_header: "x-proxy-roles" # Security Plugin Configuration - SaaS Multitenancy # Each customer gets their own isolated tenant - no shared data by default +# Tenant is forced via nginx securitytenant header (per-org), no tenant picker shown opensearch_security.multitenancy.enabled: true opensearch_security.multitenancy.tenants.enable_global: false -opensearch_security.multitenancy.tenants.enable_private: true -opensearch_security.multitenancy.tenants.preferred: ["Private"] +opensearch_security.multitenancy.tenants.enable_private: false +opensearch_security.multitenancy.tenants.preferred: ["Custom"] +opensearch_security.multitenancy.show_roles: false +opensearch_security.multitenancy.enable_filter: false opensearch_security.readonly_mode.roles: ["kibana_read_only"] opensearch_security.cookie.secure: true opensearch_security.cookie.isSameSite: "Strict" -# Tenant isolation - users only see their tenant's dashboards -# Backend creates tenant dynamically per customer (tenant name = customer ID) - # Session configuration opensearch_security.session.ttl: 3600000 opensearch_security.session.keepalive: true @@ -48,10 +58,6 @@ logging.silent: false logging.quiet: false logging.verbose: false -# Telemetry (disable for production privacy) -telemetry.enabled: false -telemetry.allowChangingOptInStatus: false - # CSP headers for security csp.strict: true csp.warnLegacyBrowsers: true diff --git a/docker/opensearch-init.sh b/docker/opensearch-init.sh index 9ff64282..1d9d97b1 100755 --- a/docker/opensearch-init.sh +++ b/docker/opensearch-init.sh @@ -1,6 +1,12 @@ #!/bin/bash # OpenSearch Dashboards initialization script # Creates default index patterns and saved objects +# +# Environment variables: +# OPENSEARCH_DASHBOARDS_URL - Dashboards URL (default: http://opensearch-dashboards:5601) +# OPENSEARCH_SECURITY_ENABLED - Enable security mode (default: false) +# OPENSEARCH_ADMIN_PASSWORD - Admin password (not used with proxy auth, kept for reference) +# OPENSEARCH_CA_CERT - Path to CA cert for TLS (optional, for https) set -e @@ -9,28 +15,55 @@ DASHBOARDS_URL="${OPENSEARCH_DASHBOARDS_URL:-http://opensearch-dashboards:5601}" DASHBOARDS_BASE_PATH="/analytics" MAX_RETRIES=30 RETRY_INTERVAL=5 +SECURITY_ENABLED="${OPENSEARCH_SECURITY_ENABLED:-false}" +# Wrapper function for authenticated curl requests +# When security is enabled, Dashboards uses proxy auth (not basic auth) +# We send x-proxy-user and x-proxy-roles headers to authenticate +auth_curl() { + if [ "$SECURITY_ENABLED" = "true" ]; then + curl -H "x-proxy-user: admin" -H "x-proxy-roles: admin,all_access" "$@" + else + curl "$@" + fi +} + +echo "[opensearch-init] Security mode: ${SECURITY_ENABLED}" echo "[opensearch-init] Waiting for OpenSearch Dashboards to be ready..." # Wait for Dashboards to be healthy (use basePath) +# Accept 200 or 401 as "ready" - 401 means server is up but requires auth +# Note: Don't use -f flag as we want to capture 4xx status codes without curl failing for i in $(seq 1 $MAX_RETRIES); do - if curl -sf "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/status" > /dev/null 2>&1; then - echo "[opensearch-init] OpenSearch Dashboards is ready!" + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/status" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "401" ]; then + echo "[opensearch-init] OpenSearch Dashboards is ready! (HTTP $HTTP_CODE)" break fi if [ $i -eq $MAX_RETRIES ]; then - echo "[opensearch-init] ERROR: OpenSearch Dashboards not ready after $((MAX_RETRIES * RETRY_INTERVAL)) seconds" + echo "[opensearch-init] ERROR: OpenSearch Dashboards not ready after $((MAX_RETRIES * RETRY_INTERVAL)) seconds (last HTTP code: $HTTP_CODE)" exit 1 fi - echo "[opensearch-init] Waiting for Dashboards... (attempt $i/$MAX_RETRIES)" + echo "[opensearch-init] Waiting for Dashboards... (attempt $i/$MAX_RETRIES, HTTP $HTTP_CODE)" sleep $RETRY_INTERVAL done -# Check if index pattern already exists +# In secure mode, skip index pattern creation via Dashboards API +# Reason: Dashboards uses proxy auth which requires requests to come through nginx +# Index patterns will be created when users first access Dashboards through the normal flow +if [ "$SECURITY_ENABLED" = "true" ]; then + echo "[opensearch-init] Security mode enabled - skipping index pattern creation" + echo "[opensearch-init] Index patterns will be created on first user access via nginx" + echo "[opensearch-init] Initialization complete!" + exit 0 +fi + +# Check if index pattern already exists (insecure mode only) echo "[opensearch-init] Checking for existing index patterns..." -EXISTING=$(curl -sf "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/_find?type=index-pattern&search_fields=title&search=security-findings-*" \ +EXISTING=$(auth_curl -sf "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/_find?type=index-pattern&search_fields=title&search=security-findings-*" \ -H "osd-xsrf: true" 2>/dev/null || echo '{"total":0}') TOTAL=$(echo "$EXISTING" | grep -o '"total":[0-9]*' | grep -o '[0-9]*' || echo "0") @@ -41,7 +74,7 @@ else echo "[opensearch-init] Creating index pattern 'security-findings-*'..." # Use specific ID so dashboards can reference it consistently - RESPONSE=$(curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/index-pattern/security-findings-*" \ + RESPONSE=$(auth_curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/index-pattern/security-findings-*" \ -H "Content-Type: application/json" \ -H "osd-xsrf: true" \ -d '{ @@ -61,7 +94,7 @@ fi # Set as default index pattern (optional, helps UX) echo "[opensearch-init] Setting default index pattern..." -curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/opensearch-dashboards/settings" \ +auth_curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/opensearch-dashboards/settings" \ -H "Content-Type: application/json" \ -H "osd-xsrf: true" \ -d '{"changes":{"defaultIndex":"security-findings-*"}}' > /dev/null 2>&1 || true diff --git a/docker/opensearch-security/action_groups.yml b/docker/opensearch-security/action_groups.yml new file mode 100644 index 00000000..1b4a5179 --- /dev/null +++ b/docker/opensearch-security/action_groups.yml @@ -0,0 +1,61 @@ +# OpenSearch Security - Action Groups +# +# Action groups bundle permissions together for easier role assignment. +# Most common groups are built-in, but custom ones can be defined here. +# +# Built-in action groups (no need to redefine): +# - cluster_all, cluster_monitor, cluster_composite_ops, cluster_composite_ops_ro +# - indices_all, indices_monitor, read, write, delete, create_index, manage +# - kibana_all_read, kibana_all_write +# +# Reference: https://opensearch.org/docs/latest/security/access-control/default-action-groups/ + +--- +_meta: + type: "actiongroups" + config_version: 2 + +# ============================================================================= +# CUSTOM ACTION GROUPS +# ============================================================================= + +# Index management for security findings +security_findings_write: + reserved: false + static: false + allowed_actions: + - "indices:data/write/index" + - "indices:data/write/bulk*" + - "indices:data/write/update" + - "indices:data/write/delete" + - "indices:admin/create" + - "indices:admin/mapping/put" + description: "Write access to security findings indices" + +security_findings_read: + reserved: false + static: false + allowed_actions: + - "indices:data/read/search*" + - "indices:data/read/get*" + - "indices:data/read/mget*" + - "indices:data/read/msearch*" + - "indices:data/read/scroll*" + - "indices:admin/mappings/get" + - "indices:admin/resolve/index" + description: "Read access to security findings indices" + +# Dashboard access for customers +dashboards_read: + reserved: false + static: false + allowed_actions: + - "kibana_all_read" + description: "Read-only access to dashboards" + +dashboards_write: + reserved: false + static: false + allowed_actions: + - "kibana_all_write" + description: "Write access to dashboards" diff --git a/docker/opensearch-security/allowlist.yml b/docker/opensearch-security/allowlist.yml new file mode 100644 index 00000000..cd934c4a --- /dev/null +++ b/docker/opensearch-security/allowlist.yml @@ -0,0 +1,13 @@ +# OpenSearch Security - API Allowlist +# +# Controls which REST APIs can be accessed. +# Disabled by default (all APIs allowed based on role permissions). + +--- +_meta: + type: "allowlist" + config_version: 2 + +config: + enabled: false + requests: {} diff --git a/docker/opensearch-security/audit.yml b/docker/opensearch-security/audit.yml new file mode 100644 index 00000000..f8fae321 --- /dev/null +++ b/docker/opensearch-security/audit.yml @@ -0,0 +1,30 @@ +# OpenSearch Security - Audit Configuration +# +# Audit logging configuration for security events. + +--- +_meta: + type: "audit" + config_version: 2 + +config: + # Enable audit logging + enabled: true + audit: + # Log successful authentication + enable_rest: true + # Log transport layer (disabled for dev to reduce noise) + enable_transport: false + # What to log + resolve_bulk_requests: false + log_request_body: false + resolve_indices: true + exclude_sensitive_headers: true + # Ignore system indices + ignore_users: + - "kibanaserver" + ignore_requests: + - "SearchRequest" + - "indices:data/read/*" + compliance: + enabled: false diff --git a/docker/opensearch-security/config.yml b/docker/opensearch-security/config.yml new file mode 100644 index 00000000..7af3c434 --- /dev/null +++ b/docker/opensearch-security/config.yml @@ -0,0 +1,47 @@ +# OpenSearch Security Configuration +# +# This file configures authentication domains for the security plugin. +# Proxy auth is used for nginx-authenticated requests (Dashboards access). +# Basic auth is used for direct API access (admin, worker). + +--- +_meta: + type: "config" + config_version: 2 + +config: + dynamic: + http: + xff: + enabled: true + # Trusted proxy IPs - templated at container start by docker-entrypoint-security.sh + # Default matches Docker bridge network (172.x.x.x) + internalProxies: '__INTERNAL_PROXIES__' + remoteIpHeader: 'X-Forwarded-For' + + authc: + # Proxy authentication for nginx-authenticated requests + # Nginx sets x-proxy-user and x-proxy-roles headers after auth validation + proxy_auth_domain: + http_enabled: true + transport_enabled: true + order: 0 + http_authenticator: + type: proxy + challenge: false + config: + user_header: "x-proxy-user" + roles_header: "x-proxy-roles" + authentication_backend: + type: noop + + # Basic auth fallback for direct API access (admin, worker) + basic_internal_auth_domain: + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern diff --git a/docker/opensearch-security/docker-entrypoint-security.sh b/docker/opensearch-security/docker-entrypoint-security.sh new file mode 100755 index 00000000..83314aab --- /dev/null +++ b/docker/opensearch-security/docker-entrypoint-security.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# OpenSearch Security Entrypoint (Production-Ready) +# +# This entrypoint: +# 1. Templates the internalProxies regex in config.yml +# 2. Launches a background process to initialize security after OpenSearch starts +# 3. Uses a marker file to avoid re-initializing on every restart +# +# Environment variables: +# OPENSEARCH_INTERNAL_PROXIES - Trusted proxy IP regex (default: Docker bridge) +# SECURITY_AUTO_INIT - Auto-initialize security index (default: true) + +set -e + +# Configuration +INTERNAL_PROXIES="${OPENSEARCH_INTERNAL_PROXIES:-(172|192|10)\\.\\d+\\.\\d+\\.\\d+}" +SECURITY_AUTO_INIT="${SECURITY_AUTO_INIT:-true}" +SECURITY_INIT_MARKER="/usr/share/opensearch/data/.security_initialized" + +SRC_CONFIG="/usr/share/opensearch/config/opensearch-security/config.yml" +DEST_DIR="/usr/share/opensearch/config/opensearch-security-templated" +DEST_CONFIG="${DEST_DIR}/config.yml" + +echo "[opensearch-security] Templating internalProxies: ${INTERNAL_PROXIES}" + +if [ -f "${SRC_CONFIG}" ]; then + # Create destination directory if needed + mkdir -p "${DEST_DIR}" + + # Copy and template the config file + sed "s/__INTERNAL_PROXIES__/${INTERNAL_PROXIES}/g" "${SRC_CONFIG}" > "${DEST_CONFIG}" + + # Copy other security config files to the templated directory + for file in /usr/share/opensearch/config/opensearch-security/*.yml; do + filename=$(basename "$file") + if [ "$filename" != "config.yml" ]; then + cp "$file" "${DEST_DIR}/${filename}" + fi + done + + echo "[opensearch-security] Config templating complete" +else + echo "[opensearch-security] WARNING: Config file not found at ${SRC_CONFIG}" +fi + +# Background security initialization function +security_init_background() { + # Wait for OpenSearch to be ready + echo "[opensearch-security] Waiting for OpenSearch to be ready..." + ADMIN_PASSWORD="${OPENSEARCH_ADMIN_PASSWORD:-admin}" + MAX_RETRIES=60 + RETRY_COUNT=0 + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + # Use admin credentials - OpenSearch rejects unauthenticated requests + # even before security is fully initialized + if curl -sf -u "admin:${ADMIN_PASSWORD}" \ + --cacert /usr/share/opensearch/config/certs/root-ca.pem \ + https://localhost:9200/_cluster/health > /dev/null 2>&1; then + echo "[opensearch-security] OpenSearch is ready" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + sleep 2 + done + + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "[opensearch-security] ERROR: OpenSearch not ready after $MAX_RETRIES attempts" + return 1 + fi + + # Always run securityadmin.sh to apply our templated config. + # OpenSearch may auto-init security from the raw config dir (with __INTERNAL_PROXIES__ + # placeholder), so we must overwrite it with the properly templated version. + # The marker file (checked at the outer level) prevents re-runs on subsequent restarts. + echo "[opensearch-security] Applying templated security config with securityadmin.sh..." + /usr/share/opensearch/plugins/opensearch-security/tools/securityadmin.sh \ + -cd "${DEST_DIR}" \ + -icl \ + -nhnv \ + -cacert /usr/share/opensearch/config/certs/root-ca.pem \ + -cert /usr/share/opensearch/config/certs/admin.pem \ + -key /usr/share/opensearch/config/certs/admin-key.pem + + if [ $? -eq 0 ]; then + echo "[opensearch-security] Security initialization complete" + touch "$SECURITY_INIT_MARKER" + else + echo "[opensearch-security] ERROR: Security initialization failed" + return 1 + fi +} + +# Launch background security initialization if enabled and not already done +if [ "${SECURITY_AUTO_INIT}" = "true" ]; then + if [ -f "$SECURITY_INIT_MARKER" ]; then + echo "[opensearch-security] Security previously initialized (marker exists)" + else + echo "[opensearch-security] Will initialize security after OpenSearch starts..." + # Run in background so OpenSearch can start + security_init_background & + fi +else + echo "[opensearch-security] Auto-init disabled (SECURITY_AUTO_INIT=${SECURITY_AUTO_INIT})" +fi + +# Execute the original OpenSearch entrypoint +exec /usr/share/opensearch/opensearch-docker-entrypoint.sh "$@" diff --git a/docker/opensearch-security/internal_users.yml b/docker/opensearch-security/internal_users.yml index 313b7d50..fdb93d83 100644 --- a/docker/opensearch-security/internal_users.yml +++ b/docker/opensearch-security/internal_users.yml @@ -31,24 +31,35 @@ _meta: # Platform admin - for internal operations only admin: - # CHANGE THIS IN PRODUCTION - hash for "admin" - hash: "$2y$12$QJMOhaNM2dVJQGOVIBJOqOHQhQqq2v7rnE3iyNWMWvqjvjnvZe/Aq" + # Default password: admin (CHANGE IN PRODUCTION!) + hash: "$2a$12$VcCDgh2NDk07JGN0rjGbM.Ad41qVR/YFJcgHp0UGns5JDymv..TOG" reserved: true backend_roles: - - "platform_admin" + - "admin" attributes: role: "system" description: "Platform administrator - internal use only" # Dashboards server user - used by OpenSearch Dashboards kibanaserver: - # CHANGE THIS IN PRODUCTION - hash for "kibanaserver" - hash: "$2y$12$r2uo1.C/6oXP1NnMgQzNxO3LnKCJR2I3ymvY9rUYLQq9cYEITCwfO" + # Default password: admin (matches OPENSEARCH_DASHBOARDS_PASSWORD default) + hash: "$2a$12$VcCDgh2NDk07JGN0rjGbM.Ad41qVR/YFJcgHp0UGns5JDymv..TOG" reserved: true attributes: role: "system" description: "Dashboards backend communication user" +# Worker service user - for indexing security findings from worker processes +worker: + # Default password: worker (CHANGE IN PRODUCTION!) + hash: "$2a$12$VcCDgh2NDk07JGN0rjGbM.Ad41qVR/YFJcgHp0UGns5JDymv..TOG" + reserved: false + backend_roles: + - "worker_write" + attributes: + role: "system" + description: "Worker service for indexing security findings" + # ============================================================================= # CUSTOMER USERS # Note: Customer users are created dynamically by the backend when users diff --git a/docker/opensearch-security/nodes_dn.yml b/docker/opensearch-security/nodes_dn.yml new file mode 100644 index 00000000..566555f1 --- /dev/null +++ b/docker/opensearch-security/nodes_dn.yml @@ -0,0 +1,12 @@ +# OpenSearch Security - Node Distinguished Names +# +# For single-node development, this file is empty. +# In production multi-node clusters, list the DNs of all nodes. + +--- +_meta: + type: "nodesdn" + config_version: 2 + +# Allow all nodes with certificates signed by our CA +# (In production, specify exact node DNs for tighter security) diff --git a/docker/opensearch-security/roles.yml b/docker/opensearch-security/roles.yml index 5d5b5558..04f5b3bc 100644 --- a/docker/opensearch-security/roles.yml +++ b/docker/opensearch-security/roles.yml @@ -45,10 +45,27 @@ platform_admin: - "*" tenant_permissions: - tenant_patterns: - - "__platform_admin" + - "*" allowed_actions: - "kibana_all_write" +# Worker write role - for indexing security findings from worker processes +# Write-only access to security-findings-* indices (no read of other orgs' data) +worker_write: + reserved: false + description: "Worker service role for indexing security findings" + cluster_permissions: + - "cluster_composite_ops_ro" + - "indices:data/write/*" + index_permissions: + - index_patterns: + - "security-findings-*" + allowed_actions: + - "write" + - "create_index" + - "indices:data/write/*" + - "indices:admin/mapping/put" + # ============================================================================= # CUSTOMER ROLE TEMPLATE # These are templates - actual roles are created dynamically per customer @@ -63,6 +80,9 @@ customer_template_rw: cluster_permissions: - "cluster_composite_ops_ro" - "indices:data/read/scroll*" + - "cluster:admin/opendistro/ism/policy/get" + - "cluster:admin/opendistro/ism/policy/search" + - "cluster:admin/opendistro/ism/managedindex/explain" index_permissions: - index_patterns: - "CUSTOMER_ID_PLACEHOLDER-*" @@ -87,6 +107,9 @@ customer_template_ro: description: "Template for customer read-only roles - DO NOT USE DIRECTLY" cluster_permissions: - "cluster_composite_ops_ro" + - "cluster:admin/opendistro/ism/policy/get" + - "cluster:admin/opendistro/ism/policy/search" + - "cluster:admin/opendistro/ism/managedindex/explain" index_permissions: - index_patterns: - "CUSTOMER_ID_PLACEHOLDER-*" @@ -97,44 +120,10 @@ customer_template_ro: - tenant_patterns: - "CUSTOMER_ID_PLACEHOLDER" allowed_actions: - - "kibana_all_read" + - "kibana_all_write" # ============================================================================= # DASHBOARDS INTERNAL ROLES +# Note: kibana_server and kibana_read_only are built-in static roles +# Do NOT redefine them here - they are managed by OpenSearch Security plugin # ============================================================================= - -# Dashboards server role - for backend communication -kibana_server: - reserved: true - cluster_permissions: - - "cluster_monitor" - - "cluster_composite_ops" - - "indices:admin/template/*" - - "indices:data/read/scroll*" - index_permissions: - - index_patterns: - - ".kibana" - - ".kibana_*" - - ".opensearch_dashboards" - - ".opensearch_dashboards_*" - allowed_actions: - - "indices_all" - - index_patterns: - - "*" - allowed_actions: - - "indices:admin/aliases/get" - - "indices:admin/mappings/get" - -# Read-only dashboard user (for embedding/sharing) -kibana_read_only: - reserved: true - cluster_permissions: - - "cluster_composite_ops_ro" - index_permissions: - - index_patterns: - - ".kibana" - - ".kibana_*" - - ".opensearch_dashboards" - - ".opensearch_dashboards_*" - allowed_actions: - - "read" diff --git a/docker/opensearch-security/roles_mapping.yml b/docker/opensearch-security/roles_mapping.yml index ff4c8065..69636259 100644 --- a/docker/opensearch-security/roles_mapping.yml +++ b/docker/opensearch-security/roles_mapping.yml @@ -50,6 +50,15 @@ security_rest_api_access: - "platform_admin" description: "Access to Security REST API for tenant/role management" +# Worker service mapping - for indexing security findings +worker_write: + reserved: false + users: + - "worker" + backend_roles: + - "worker_write" + description: "Worker service for indexing security findings" + # ============================================================================= # CUSTOMER ROLE MAPPINGS # Note: Customer-specific mappings are created dynamically by the backend diff --git a/docker/opensearch-security/whitelist.yml b/docker/opensearch-security/whitelist.yml new file mode 100644 index 00000000..a72da3fe --- /dev/null +++ b/docker/opensearch-security/whitelist.yml @@ -0,0 +1,12 @@ +# OpenSearch Security - Whitelist (deprecated, use allowlist.yml) +# +# This file exists for backwards compatibility. + +--- +_meta: + type: "whitelist" + config_version: 2 + +config: + enabled: false + requests: {} diff --git a/docker/opensearch.dev-secure.yml b/docker/opensearch.dev-secure.yml new file mode 100644 index 00000000..f9f0494a --- /dev/null +++ b/docker/opensearch.dev-secure.yml @@ -0,0 +1,35 @@ +# OpenSearch Development Configuration with Security +# Mount to: /usr/share/opensearch/config/opensearch.yml + +cluster.name: shipsec-dev-secure +node.name: opensearch-node1 +network.host: 0.0.0.0 + +# Single-node mode +discovery.type: single-node +bootstrap.memory_lock: true + +# Security Plugin Configuration +plugins.security.ssl.transport.pemcert_filepath: certs/node.pem +plugins.security.ssl.transport.pemkey_filepath: certs/node-key.pem +plugins.security.ssl.transport.pemtrustedcas_filepath: certs/root-ca.pem +plugins.security.ssl.transport.enforce_hostname_verification: false + +plugins.security.ssl.http.enabled: true +plugins.security.ssl.http.pemcert_filepath: certs/node.pem +plugins.security.ssl.http.pemkey_filepath: certs/node-key.pem +plugins.security.ssl.http.pemtrustedcas_filepath: certs/root-ca.pem + +plugins.security.allow_unsafe_democertificates: false +plugins.security.allow_default_init_securityindex: true + +# Admin DN - Required for securityadmin.sh and REST API access +plugins.security.authcz.admin_dn: + - "CN=admin,OU=ShipSec,O=ShipSecAI,L=SF,ST=CA,C=US" + +plugins.security.audit.type: internal_opensearch +plugins.security.enable_snapshot_restore_privilege: true +plugins.security.check_snapshot_restore_write_privileges: true +plugins.security.restapi.roles_enabled: + - "all_access" + - "security_rest_api_access" diff --git a/docker/scripts/hash-password.sh b/docker/scripts/hash-password.sh new file mode 100755 index 00000000..22ba22ce --- /dev/null +++ b/docker/scripts/hash-password.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Generate BCrypt password hash for OpenSearch Security internal users +# +# Usage: ./hash-password.sh [password] +# +# If password is not provided, it will be read from stdin (useful for piping) +# The hash can be used in opensearch-security/internal_users.yml +# +# Example: +# ./hash-password.sh mySecurePassword123 +# echo "myPassword" | ./hash-password.sh + +set -euo pipefail + +OPENSEARCH_IMAGE="${OPENSEARCH_IMAGE:-opensearchproject/opensearch:2.11.1}" + +if [ $# -ge 1 ]; then + PASSWORD="$1" +elif [ ! -t 0 ]; then + # Read from stdin if piped + read -r PASSWORD +else + # Interactive prompt + echo -n "Enter password to hash: " >&2 + read -rs PASSWORD + echo >&2 +fi + +if [ -z "$PASSWORD" ]; then + echo "Error: Password cannot be empty" >&2 + exit 1 +fi + +# Use OpenSearch's built-in hash.sh tool to generate BCrypt hash +docker run --rm -i "$OPENSEARCH_IMAGE" \ + /usr/share/opensearch/plugins/opensearch-security/tools/hash.sh \ + -p "$PASSWORD" 2>/dev/null | tail -1 diff --git a/docker/scripts/security-init.sh b/docker/scripts/security-init.sh new file mode 100755 index 00000000..0e8cd3e0 --- /dev/null +++ b/docker/scripts/security-init.sh @@ -0,0 +1,157 @@ +#!/bin/bash +# Initialize OpenSearch Security index using securityadmin.sh +# +# This script properly initializes the security configuration without using +# the deprecated demo installer. It should be run: +# - After first-time OpenSearch startup +# - After modifying security configuration files +# - When migrating from demo to production security +# +# Prerequisites: +# - OpenSearch must be running with TLS enabled +# - Admin certificates must exist in docker/certs/ +# - Security config files in docker/opensearch-security/ +# +# Usage: +# ./security-init.sh # Use defaults +# ./security-init.sh --force # Force reinitialize (overwrites existing) +# OPENSEARCH_HOST=my-host ./security-init.sh # Custom host + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOCKER_DIR="$SCRIPT_DIR/.." + +# Configuration +OPENSEARCH_HOST="${OPENSEARCH_HOST:-opensearch}" +OPENSEARCH_PORT="${OPENSEARCH_PORT:-9200}" +CERTS_DIR="${CERTS_DIR:-$DOCKER_DIR/certs}" +SECURITY_CONFIG_DIR="${SECURITY_CONFIG_DIR:-$DOCKER_DIR/opensearch-security}" +CONTAINER_NAME="${OPENSEARCH_CONTAINER:-shipsec-opensearch}" + +# Parse arguments +FORCE_INIT=false +while [[ $# -gt 0 ]]; do + case $1 in + --force|-f) + FORCE_INIT=true + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +echo "=== OpenSearch Security Initialization ===" +echo "" +echo "Configuration:" +echo " Container: $CONTAINER_NAME" +echo " Certs dir: $CERTS_DIR" +echo " Security dir: $SECURITY_CONFIG_DIR" +echo " Force init: $FORCE_INIT" +echo "" + +# Verify prerequisites +if [ ! -f "$CERTS_DIR/admin.pem" ] || [ ! -f "$CERTS_DIR/admin-key.pem" ]; then + echo "Error: Admin certificates not found in $CERTS_DIR" + echo "Run: just generate-certs" + exit 1 +fi + +if [ ! -f "$CERTS_DIR/root-ca.pem" ]; then + echo "Error: Root CA certificate not found in $CERTS_DIR" + exit 1 +fi + +if [ ! -d "$SECURITY_CONFIG_DIR" ]; then + echo "Error: Security config directory not found: $SECURITY_CONFIG_DIR" + exit 1 +fi + +# Check if OpenSearch container is running +if ! docker ps --filter "name=$CONTAINER_NAME" --format "{{.Names}}" | grep -q "$CONTAINER_NAME"; then + echo "Error: OpenSearch container '$CONTAINER_NAME' is not running" + echo "Start it first with: just dev or just prod-secure" + exit 1 +fi + +# Wait for OpenSearch to be ready +echo "Waiting for OpenSearch to be ready..." +MAX_RETRIES=30 +for i in $(seq 1 $MAX_RETRIES); do + if docker exec "$CONTAINER_NAME" curl -sf \ + --cacert /usr/share/opensearch/config/certs/root-ca.pem \ + https://localhost:9200/_cluster/health > /dev/null 2>&1; then + echo "OpenSearch is ready!" + break + fi + + if [ $i -eq $MAX_RETRIES ]; then + echo "Error: OpenSearch not ready after $MAX_RETRIES attempts" + exit 1 + fi + + echo " Waiting... (attempt $i/$MAX_RETRIES)" + sleep 2 +done + +# Check if security index already exists +echo "" +echo "Checking security index status..." +SECURITY_STATUS=$(docker exec "$CONTAINER_NAME" curl -sf \ + --cacert /usr/share/opensearch/config/certs/root-ca.pem \ + https://localhost:9200/_plugins/_security/health 2>/dev/null || echo "not_initialized") + +if echo "$SECURITY_STATUS" | grep -q '"status":"UP"'; then + if [ "$FORCE_INIT" != "true" ]; then + echo "Security index already initialized." + echo "Use --force to reinitialize (this will overwrite existing configuration)" + exit 0 + fi + echo "Security index exists, but --force specified. Reinitializing..." +else + echo "Security index not initialized. Proceeding with initialization..." +fi + +# Copy security config files to container (in case they've been updated) +echo "" +echo "Copying security configuration to container..." +docker cp "$SECURITY_CONFIG_DIR/." "$CONTAINER_NAME:/usr/share/opensearch/config/opensearch-security-init/" + +# Run securityadmin.sh +echo "" +echo "Running securityadmin.sh to initialize security index..." +docker exec "$CONTAINER_NAME" /usr/share/opensearch/plugins/opensearch-security/tools/securityadmin.sh \ + -cd /usr/share/opensearch/config/opensearch-security-init \ + -icl \ + -nhnv \ + -cacert /usr/share/opensearch/config/certs/root-ca.pem \ + -cert /usr/share/opensearch/config/certs/admin.pem \ + -key /usr/share/opensearch/config/certs/admin-key.pem + +# Verify initialization +echo "" +echo "Verifying security initialization..." +sleep 2 +FINAL_STATUS=$(docker exec "$CONTAINER_NAME" curl -sf \ + --cacert /usr/share/opensearch/config/certs/root-ca.pem \ + https://localhost:9200/_plugins/_security/health 2>/dev/null || echo "{}") + +if echo "$FINAL_STATUS" | grep -q '"status":"UP"'; then + echo "" + echo "=== Security Initialization Complete ===" + echo "" + echo "Security plugin status: UP" + echo "" + echo "Next steps:" + echo " - Test authentication: curl -u admin:PASSWORD --cacert docker/certs/root-ca.pem https://localhost:9200" + echo " - Update internal_users.yml with production password hashes" + echo " - Re-run this script with --force after updating passwords" +else + echo "" + echo "Warning: Security initialization may have failed" + echo "Check OpenSearch logs: docker logs $CONTAINER_NAME" + exit 1 +fi diff --git a/docs/analytics.md b/docs/analytics.md index 1006e50e..3944691f 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -52,6 +52,15 @@ opensearch.hosts: ["http://opensearch:9200"] The `core.analytics.sink` component writes workflow results to OpenSearch. +**Input Ports:** +- Ships with a default `input1` port so at least one connector is always available. +- Users can configure additional input ports via the **Data Inputs** parameter + (e.g., to aggregate results from multiple scanners into one index). +- Extra ports are resolved dynamically through the `resolvePorts` mechanism. When + loading a saved workflow the backend calls `resolveGraphPorts()` server-side; + when importing from a JSON file the frontend calls `resolvePorts` per-node to + ensure all dynamic handles are present before rendering. + **Environment Variable:** ```yaml OPENSEARCH_URL=http://opensearch:9200 diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index 1fc0207b..44a9ac32 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -7,14 +7,14 @@ import { GlobalAuthContext } from './auth-context-def'; // Auth provider registry - easy to add new providers // Determine which provider to use based on environment function getAuthProviderName(): string { - // Priority: explicit 'local' > dev mode (always local) > environment variable + // Priority: explicit env var > dev mode default (local) > auto-detect const envProvider = import.meta.env.VITE_AUTH_PROVIDER; const hasClerkKey = typeof import.meta.env.VITE_CLERK_PUBLISHABLE_KEY === 'string' && import.meta.env.VITE_CLERK_PUBLISHABLE_KEY.trim().length > 0; - // In dev mode, always use local auth for testing (ignore Clerk settings) - if (import.meta.env.DEV) { + // In dev mode, default to local auth unless VITE_AUTH_PROVIDER is explicitly set + if (import.meta.env.DEV && !envProvider) { return 'local'; } diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 28d4cd16..2eb03ea3 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -26,6 +26,7 @@ import { } from '@/components/ui/dropdown-menu'; import { useWorkflowStore } from '@/store/workflowStore'; import { useWorkflowUiStore } from '@/store/workflowUiStore'; +import { useAuthStore, DEFAULT_ORG_ID } from '@/store/authStore'; import { cn } from '@/lib/utils'; import { env } from '@/config/env'; @@ -33,6 +34,7 @@ interface TopBarProps { workflowId?: string; selectedRunId?: string | null; selectedRunStatus?: string | null; + selectedRunOrgId?: string | null; isNew?: boolean; onRun?: () => void; onSave: () => Promise | void; @@ -51,6 +53,7 @@ export function TopBar({ workflowId, selectedRunId, selectedRunStatus, + selectedRunOrgId, onRun, onSave, onImport, @@ -72,6 +75,11 @@ export function TopBar({ const { metadata, isDirty, setWorkflowName } = useWorkflowStore(); const mode = useWorkflowUiStore((state) => state.mode); + const organizationId = useAuthStore((s) => s.organizationId); + const authProvider = useAuthStore((s) => s.provider); + // For Clerk auth, org context is ready when organizationId is set to a real value (not default) + // For other providers (local, custom), org context is always ready + const isOrgReady = authProvider !== 'clerk' || organizationId !== DEFAULT_ORG_ID; const canEdit = Boolean(canManageWorkflows); const handleChangeWorkflowName = () => { @@ -465,17 +473,23 @@ export function TopBar({ variant="outline" size="sm" className="gap-1.5 md:gap-2 min-w-0" + disabled={!isOrgReady} onClick={() => { + if (!isOrgReady) return; const baseUrl = env.VITE_OPENSEARCH_DASHBOARDS_URL.replace(/\/+$/, ''); // Filter by run_id if a specific run is selected, otherwise by workflow_id const filterQuery = selectedRunId ? `shipsec.run_id.keyword:"${selectedRunId}"` : `shipsec.workflow_id.keyword:"${workflowId}"`; + // Use the run's backend-resolved org ID when available (matches indexed data), + // fall back to auth store org ID for workflow-level queries + const effectiveOrgId = (selectedRunOrgId || organizationId).toLowerCase(); + const orgScopedPattern = `security-findings-${effectiveOrgId}-*`; // OpenSearch Data Explorer URL format // Use .keyword fields for exact match filtering // Use 'all time' range (1 year) since run_id is unique - no need to filter by time const aParam = encodeURIComponent( - "(discover:(columns:!(_source),interval:auto,sort:!()),metadata:(indexPattern:'security-findings-*',view:discover))", + `(discover:(columns:!(_source),interval:auto,sort:!()),metadata:(indexPattern:'${orgScopedPattern}',view:discover))`, ); const qParam = encodeURIComponent( `(query:(language:kuery,query:'${filterQuery}'))`, @@ -487,9 +501,11 @@ export function TopBar({ window.open(url, '_blank', 'noopener,noreferrer'); }} title={ - selectedRunId - ? 'View analytics for this run in OpenSearch Dashboards' - : 'View analytics for this workflow in OpenSearch Dashboards' + !isOrgReady + ? 'Loading organization context...' + : selectedRunId + ? 'View analytics for this run in OpenSearch Dashboards' + : 'View analytics for this workflow in OpenSearch Dashboards' } > diff --git a/frontend/src/features/workflow-builder/WorkflowBuilder.tsx b/frontend/src/features/workflow-builder/WorkflowBuilder.tsx index c2fdb0bc..c40140fd 100644 --- a/frontend/src/features/workflow-builder/WorkflowBuilder.tsx +++ b/frontend/src/features/workflow-builder/WorkflowBuilder.tsx @@ -906,6 +906,7 @@ function WorkflowBuilderContent() { workflowId={id} selectedRunId={selectedRunId} selectedRunStatus={selectedRun?.status ?? null} + selectedRunOrgId={selectedRun?.organizationId ?? null} isNew={isNewWorkflow} onRun={handleRun} onSave={handleSave} diff --git a/frontend/src/features/workflow-builder/hooks/useWorkflowImportExport.ts b/frontend/src/features/workflow-builder/hooks/useWorkflowImportExport.ts index fa9a2828..a37ecd32 100644 --- a/frontend/src/features/workflow-builder/hooks/useWorkflowImportExport.ts +++ b/frontend/src/features/workflow-builder/hooks/useWorkflowImportExport.ts @@ -12,6 +12,7 @@ import { } from '@/features/workflow-builder/hooks/useWorkflowGraphControllers'; import type { FrontendNodeData } from '@/schemas/node'; import type { Node as ReactFlowNode, Edge as ReactFlowEdge } from 'reactflow'; +import { api } from '@/services/api'; interface WorkflowMetadataShape { id: string | null; name: string; @@ -98,10 +99,42 @@ export function useWorkflowImportExport({ const normalizedNodes = deserializeNodes(workflowGraph); const normalizedEdges = deserializeEdges(workflowGraph); + // Resolve dynamic ports for all nodes (mirrors backend resolveGraphPorts). + // Components like Analytics Sink have empty base inputs and rely on + // resolvePorts to create their input handles from config params. + const resolvedNodes = await Promise.all( + normalizedNodes.map(async (node) => { + const componentId = + (node.data as FrontendNodeData).componentId ?? + (node.data as FrontendNodeData).componentSlug; + if (!componentId) return node; + + try { + const params = node.data.config?.params ?? {}; + const inputOverrides = node.data.config?.inputOverrides ?? {}; + const result = await api.components.resolvePorts(componentId, { + ...params, + ...inputOverrides, + }); + if (!result) return node; + return { + ...node, + data: { + ...node.data, + ...(result.inputs ? { dynamicInputs: result.inputs } : {}), + ...(result.outputs ? { dynamicOutputs: result.outputs } : {}), + }, + }; + } catch { + return node; + } + }), + ); + resetWorkflow(); - setDesignNodes(normalizedNodes); + setDesignNodes(resolvedNodes as ReactFlowNode[]); setDesignEdges(normalizedEdges); - setExecutionNodes(cloneNodes(normalizedNodes)); + setExecutionNodes(cloneNodes(resolvedNodes as ReactFlowNode[])); setExecutionEdges(cloneEdges(normalizedEdges)); setMetadata({ id: null, diff --git a/frontend/src/store/runStore.ts b/frontend/src/store/runStore.ts index 4cc61a55..cd3141de 100644 --- a/frontend/src/store/runStore.ts +++ b/frontend/src/store/runStore.ts @@ -7,6 +7,7 @@ import type { ExecutionTriggerType, ExecutionInputPreview } from '@shipsec/share export interface ExecutionRun { id: string; workflowId: string; + organizationId?: string; workflowName: string; status: ExecutionStatus; startTime: string; @@ -107,6 +108,7 @@ const normalizeRun = (run: any): ExecutionRun => { return { id: String(run.id ?? ''), workflowId: String(run.workflowId ?? ''), + organizationId: typeof run.organizationId === 'string' ? run.organizationId : undefined, workflowName: String(run.workflowName ?? 'Untitled workflow'), status, startTime, diff --git a/justfile b/justfile index 7c9272bf..a0a2d585 100644 --- a/justfile +++ b/justfile @@ -26,6 +26,10 @@ instance action="show" value="": # === Development (recommended for contributors) === +# Default dev passwords for convenience (override with env vars for real security) +export OPENSEARCH_ADMIN_PASSWORD := env_var_or_default("OPENSEARCH_ADMIN_PASSWORD", "admin") +export OPENSEARCH_DASHBOARDS_PASSWORD := env_var_or_default("OPENSEARCH_DASHBOARDS_PASSWORD", "admin") + # Initialize environment files from examples init: #!/usr/bin/env bash @@ -51,18 +55,18 @@ init: echo " Edit the .env files to configure your environment" echo " Then run: just dev" -# Start development environment with hot-reload +# Start development environment with hot-reload and OpenSearch Security # Usage: just dev [instance] [action] # Examples: just dev, just dev 1, just dev 2 start, just dev 1 logs, just dev stop all dev *args: #!/usr/bin/env bash set -euo pipefail - + # Parse arguments: instance can be 0-9, action is start/stop/logs/status/clean INSTANCE="$(./scripts/active-instance.sh get)" ACTION="start" INFRA_PROJECT_NAME="shipsec-infra" - + # Process arguments for arg in {{args}}; do case "$arg" in @@ -85,12 +89,12 @@ dev *args: ;; esac done - + # Handle special case: dev stop all if [ "$ACTION" = "all" ]; then ACTION="stop" fi - + # Handle "just dev stop" as "just dev 0 stop" if [ "$ACTION" = "stop" ] && [ "$INSTANCE" = "0" ] && [ -z "{{args}}" ]; then true # Keep defaults @@ -135,13 +139,23 @@ dev *args: echo " This will create .env files from the example templates." exit 1 fi - - # Start shared infrastructure (one stack for all instances) - echo "⏳ Starting shared infrastructure..." + + # Auto-generate certificates if they don't exist + if [ ! -f "docker/certs/root-ca.pem" ]; then + echo "🔐 Generating TLS certificates..." + chmod +x docker/scripts/generate-certs.sh + docker/scripts/generate-certs.sh + echo "✅ Certificates generated" + fi + + # Start shared infrastructure with OpenSearch Security (one stack for all instances) + echo "⏳ Starting shared infrastructure (with OpenSearch Security)..." docker compose -f docker/docker-compose.infra.yml \ + -f docker/docker-compose.dev-secure.yml \ + -f docker/docker-compose.dev-ports.yml \ --project-name="$INFRA_PROJECT_NAME" \ up -d - + # Wait for Postgres echo "⏳ Waiting for infrastructure..." POSTGRES_CONTAINER="$(docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" ps -q postgres)" @@ -149,6 +163,10 @@ dev *args: timeout 30s bash -c "until docker exec $POSTGRES_CONTAINER pg_isready -U shipsec >/dev/null 2>&1; do sleep 1; done" || true fi + # Wait for OpenSearch to be healthy (security init takes longer) + echo "⏳ Waiting for OpenSearch security initialization..." + timeout 120s bash -c 'until docker exec shipsec-opensearch curl -sf -u admin:${OPENSEARCH_ADMIN_PASSWORD:-admin} --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health >/dev/null 2>&1; do sleep 2; done' || true + # Ensure instance-specific DB/namespace exists and migrations are applied. ./scripts/instance-bootstrap.sh "$INSTANCE" @@ -162,15 +180,22 @@ dev *args: # Update git SHA and start PM2 with instance-specific config ./scripts/set-git-sha.sh || true - + + # Enable OpenSearch Security for PM2 services + export OPENSEARCH_SECURITY_ENABLED=true + export NODE_TLS_REJECT_UNAUTHORIZED=0 + pm2 startOrReload pm2.config.cjs \ --only "shipsec-frontend-$INSTANCE,shipsec-backend-$INSTANCE,shipsec-worker-$INSTANCE" \ --update-env - + echo "" echo "✅ Development environment ready (instance $INSTANCE)" ./scripts/dev-instance-manager.sh info "$INSTANCE" echo "" + echo "🔐 OpenSearch Security: ENABLED (multi-tenant isolation active)" + echo " OpenSearch admin: admin / ${OPENSEARCH_ADMIN_PASSWORD:-admin}" + echo "" echo "💡 just dev $INSTANCE logs - View application logs" echo "💡 just dev $INSTANCE stop - Stop this instance" echo "" @@ -266,6 +291,78 @@ dev *args: ;; esac +# Start development environment WITHOUT security (faster, simpler) +dev-insecure action="start": + #!/usr/bin/env bash + set -euo pipefail + case "{{action}}" in + start) + echo "🚀 Starting development environment (insecure mode)..." + echo "⚠️ OpenSearch Security DISABLED - no multi-tenant isolation" + echo "" + + # Check for required env files + if [ ! -f "backend/.env" ] || [ ! -f "worker/.env" ] || [ ! -f "frontend/.env" ]; then + echo "❌ Environment files not found!" + echo "" + echo " Run this first: just init" + echo "" + echo " This will create .env files from the example templates." + exit 1 + fi + + # Start infrastructure (no security) + docker compose -f docker/docker-compose.infra.yml up -d + + # Wait for Postgres + echo "⏳ Waiting for infrastructure..." + timeout 30s bash -c 'until docker exec shipsec-postgres pg_isready -U shipsec >/dev/null 2>&1; do sleep 1; done' || true + + # Start PM2 without security + ./scripts/set-git-sha.sh || true + SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=false \ + pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env + + echo "" + echo "✅ Development environment ready (INSECURE MODE)" + echo " Frontend: http://localhost:5173" + echo " Backend: http://localhost:3211" + echo " Temporal UI: http://localhost:8081" + echo "" + echo "⚠️ OpenSearch Security: DISABLED" + echo " Use 'just dev' for full multi-tenant security" + echo "" + echo "💡 just dev-insecure logs - View application logs" + echo "💡 just dev-insecure stop - Stop everything" + echo "" + + # Version check + bun backend/scripts/version-check-summary.ts 2>/dev/null || true + ;; + stop) + echo "🛑 Stopping development environment..." + pm2 delete shipsec-frontend shipsec-backend shipsec-worker shipsec-test-worker 2>/dev/null || true + docker compose -f docker/docker-compose.infra.yml down + echo "✅ Stopped" + ;; + logs) + pm2 logs + ;; + status) + pm2 status + docker compose -f docker/docker-compose.infra.yml ps + ;; + clean) + echo "🧹 Cleaning development environment..." + pm2 delete shipsec-frontend shipsec-backend shipsec-worker shipsec-test-worker 2>/dev/null || true + docker compose -f docker/docker-compose.infra.yml down -v + echo "✅ Development environment cleaned (PM2 stopped, infrastructure volumes removed)" + ;; + *) + echo "Usage: just dev-insecure [start|stop|logs|status|clean]" + ;; + esac + # === Production (Docker-based) === # Initialize production environment with secure secrets @@ -606,6 +703,25 @@ generate-certs: echo " 2. export OPENSEARCH_DASHBOARDS_PASSWORD='your-secure-password'" echo " 3. just prod-secure" +# Initialize or reinitialize OpenSearch security index +security-init *args: + #!/usr/bin/env bash + set -euo pipefail + echo "🔐 Initializing OpenSearch Security..." + chmod +x docker/scripts/security-init.sh + docker/scripts/security-init.sh {{args}} + +# Generate BCrypt password hash for OpenSearch internal users +hash-password password="": + #!/usr/bin/env bash + set -euo pipefail + chmod +x docker/scripts/hash-password.sh + if [ -n "{{password}}" ]; then + docker/scripts/hash-password.sh "{{password}}" + else + docker/scripts/hash-password.sh + fi + # === Infrastructure Only === # Manage infrastructure containers separately @@ -680,8 +796,8 @@ help: @echo "Getting Started:" @echo " just init Set up dependencies and environment files" @echo "" - @echo "Development (hot-reload, multi-instance support):" - @echo " just dev Start the active instance (default: 0)" + @echo "Development (hot-reload, multi-instance with OpenSearch Security):" + @echo " just dev Start the active instance (default: 0) with security" @echo " just instance show Show active instance" @echo " just instance use 5 Set active instance to 5 for this workspace" @echo " just dev 1 Start instance 1" @@ -696,6 +812,11 @@ help: @echo " Note: Instances share one Docker infra stack (Postgres/Temporal/Redpanda/Redis/etc)" @echo " Isolation comes from per-instance DB + Temporal namespace/task-queue + Kafka topic suffix" @echo " Instance N uses base_port + N*100 (e.g., instance 0 uses 5173, instance 1 uses 5273)" + @echo " OpenSearch Security provides multi-tenant data isolation per organization" + @echo "" + @echo "Development (insecure, faster startup):" + @echo " just dev-insecure Start WITHOUT security (no tenant isolation)" + @echo " just dev-insecure stop Stop everything" @echo "" @echo "Production (Docker):" @echo " just prod-init Generate secrets in docker/.env (run once)" @@ -715,6 +836,11 @@ help: @echo " just prod-secure logs View logs" @echo " just prod-secure clean Remove all data" @echo "" + @echo "Security Management:" + @echo " just security-init Initialize OpenSearch security index" + @echo " just security-init --force Reinitialize (update config)" + @echo " just hash-password Generate BCrypt hash for passwords" + @echo "" @echo "Infrastructure:" @echo " just infra up Start infrastructure only" @echo " just infra down Stop infrastructure" diff --git a/worker/src/components/core/analytics-sink.ts b/worker/src/components/core/analytics-sink.ts index 113e25c2..884a4e8a 100644 --- a/worker/src/components/core/analytics-sink.ts +++ b/worker/src/components/core/analytics-sink.ts @@ -34,8 +34,14 @@ function toWorkflowSlug(value?: string | null): string | undefined { return slug.length > 0 ? slug : undefined; } -// Base input schema - will be extended by resolvePorts -const baseInputSchema = inputs({}); +// Base input schema with a default input port. +// resolvePorts adds extra ports when users configure multiple data inputs. +const baseInputSchema = inputs({ + input1: port(z.array(analyticsResultSchema()).optional(), { + label: 'Input 1', + description: 'Analytics results to index.', + }), +}); const outputSchema = outputs({ indexed: port(z.boolean(), { @@ -72,14 +78,14 @@ const parameterSchema = parameters({ .string() .optional() .describe( - 'Optional suffix to append to the index name. Defaults to slugified workflow name if not provided.', + 'Optional suffix to append to the index name. Defaults to date (YYYY.MM.DD) if not provided.', ), { label: 'Index Suffix', editor: 'text', - placeholder: 'workflow-slug (default)', + placeholder: 'YYYY.MM.DD (default)', description: - 'Custom suffix for the index name (e.g., "subdomain-enum"). Defaults to slugified workflow name if not provided.', + 'Custom suffix for the index name (e.g., "subdomain-enum"). Defaults to date-based sharding (YYYY.MM.DD) if not provided.', }, ), assetKeyField: param( @@ -330,8 +336,7 @@ const definition = defineComponent({ assetKeyField = params.assetKeyField; } - const fallbackIndexSuffix = - params.indexSuffix ?? toWorkflowSlug(context.workflowName ?? undefined); + const fallbackIndexSuffix = params.indexSuffix || undefined; const indexOptions = { workflowId: context.workflowId, diff --git a/worker/src/utils/opensearch-indexer.ts b/worker/src/utils/opensearch-indexer.ts index 88cec47d..02392592 100644 --- a/worker/src/utils/opensearch-indexer.ts +++ b/worker/src/utils/opensearch-indexer.ts @@ -45,11 +45,20 @@ async function retryWithBackoff(operation: () => Promise, operationName: s throw new Error(`${operationName} failed after ${maxAttempts} attempts`); } +// TTL for tenant provisioning cache (1 hour in milliseconds) +const TENANT_CACHE_TTL_MS = 60 * 60 * 1000; + export class OpenSearchIndexer { private client: Client | null = null; private enabled = false; private dashboardsUrl: string | null = null; private dashboardsAuth: { username: string; password: string } | null = null; + private securityEnabled = false; + private backendUrl: string | null = null; + private internalServiceToken: string | null = null; + + // Cache of provisioned org IDs with timestamp + private provisionedOrgs: Map = new Map(); constructor() { const url = process.env.OPENSEARCH_URL; @@ -62,6 +71,11 @@ export class OpenSearchIndexer { this.dashboardsAuth = { username, password }; } + // Security mode configuration + this.securityEnabled = process.env.OPENSEARCH_SECURITY_ENABLED === 'true'; + this.backendUrl = process.env.BACKEND_URL || 'http://localhost:3211'; + this.internalServiceToken = process.env.INTERNAL_SERVICE_TOKEN || null; + if (url) { try { this.client = new Client({ @@ -78,7 +92,9 @@ export class OpenSearchIndexer { }, }); this.enabled = true; - console.log('[OpenSearchIndexer] Client initialized'); + console.log( + `[OpenSearchIndexer] Client initialized (security enabled: ${this.securityEnabled})`, + ); } catch (error) { console.warn('[OpenSearchIndexer] Failed to initialize client:', error); } @@ -91,6 +107,67 @@ export class OpenSearchIndexer { return this.enabled && this.client !== null; } + /** + * Ensure tenant is provisioned in OpenSearch Security. + * Caches provisioned orgs with 1-hour TTL to avoid redundant calls. + * On failure: logs error but returns true to allow indexing to continue. + */ + private async ensureTenantProvisioned(orgId: string): Promise { + // Skip if security is disabled + if (!this.securityEnabled) { + return true; + } + + // Check cache + const cachedTimestamp = this.provisionedOrgs.get(orgId); + if (cachedTimestamp && Date.now() - cachedTimestamp < TENANT_CACHE_TTL_MS) { + console.debug(`[OpenSearchIndexer] Tenant already provisioned (cached): ${orgId}`); + return true; + } + + // Call backend to provision tenant + try { + const url = `${this.backendUrl}/api/v1/analytics/ensure-tenant`; + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.internalServiceToken) { + headers['X-Internal-Token'] = this.internalServiceToken; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ organizationId: orgId }), + }); + + if (!response.ok) { + console.error( + `[OpenSearchIndexer] Failed to provision tenant ${orgId}: ${response.status} ${response.statusText}`, + ); + // Continue with indexing anyway - tenant might already exist + return true; + } + + const result = (await response.json()) as { success: boolean; message: string }; + if (result.success) { + // Cache the successful provisioning + this.provisionedOrgs.set(orgId, Date.now()); + console.log(`[OpenSearchIndexer] Tenant provisioned: ${orgId}`); + } else { + console.warn(`[OpenSearchIndexer] Tenant provisioning returned failure: ${result.message}`); + } + + return true; + } catch (error) { + // Log error but don't block indexing + const message = error instanceof Error ? error.message : String(error); + console.error(`[OpenSearchIndexer] Error provisioning tenant ${orgId}: ${message}`); + return true; // Continue with indexing + } + } + /** * Serialize nested objects and arrays to JSON strings to prevent field explosion. * Preserves primitive values (string, number, boolean, null) as-is. @@ -219,6 +296,9 @@ export class OpenSearchIndexer { return { indexName: '', documentCount: 0 }; } + // Ensure tenant is provisioned before indexing (for multi-tenant security) + await this.ensureTenantProvisioned(orgId); + const indexName = this.buildIndexName(orgId, options.indexSuffix); // Use same timestamp for all documents in this batch @@ -298,7 +378,10 @@ export class OpenSearchIndexer { } // Refresh index pattern in OpenSearch Dashboards to make new fields visible - await this.refreshIndexPattern(); + // Skip when security is enabled - patterns are created per-tenant by the provisioning service + if (!this.securityEnabled) { + await this.refreshIndexPattern(); + } return { indexName, documentCount: documents.length }; } catch (error) { @@ -417,7 +500,7 @@ export class OpenSearchIndexer { const day = String(date.getDate()).padStart(2, '0'); const suffix = indexSuffix || `${year}.${month}.${day}`; - return `security-findings-${orgId}-${suffix}`; + return `security-findings-${orgId}-${suffix}`.toLowerCase(); } private detectAssetKey(document: Record, explicitField?: string): string | null { From 960b645e6d761c09de65ea56ac848289cc3ae1f4 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 00:58:12 -0500 Subject: [PATCH 05/13] docs(analytics): add multi-tenant architecture and troubleshooting guide Document the OpenSearch tenant identity resolution flow, Clerk active org session vs membership distinction, tenant provisioning details, and security guarantees. Add troubleshooting entry for workspace-user fallback with screenshots and diagnostic commands. Signed-off-by: Aseem Shrey --- docs/analytics.md | 131 ++++++++++++++++++ docs/media/clerk-user-local-org.png | Bin 0 -> 77621 bytes docs/media/clerk-user-test-org.png | Bin 0 -> 74050 bytes docs/media/opensearch-tenant-org-id.png | Bin 0 -> 46750 bytes .../opensearch-tenant-workspace-fallback.png | Bin 0 -> 64481 bytes 5 files changed, 131 insertions(+) create mode 100644 docs/media/clerk-user-local-org.png create mode 100644 docs/media/clerk-user-test-org.png create mode 100644 docs/media/opensearch-tenant-org-id.png create mode 100644 docs/media/opensearch-tenant-workspace-fallback.png diff --git a/docs/analytics.md b/docs/analytics.md index 3944691f..d7805a0d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -167,8 +167,139 @@ The analytics settings update API supports **partial updates**: Omit fields you don’t want to change. The backend validates the retention days only when provided. +## Multi-Tenant Architecture + +When the OpenSearch Security plugin is enabled, each organization gets an isolated tenant with its own dashboards, index patterns, and saved objects. + +### How Tenant Identity Is Resolved + +The tenant identity for OpenSearch Dashboards is determined through a proxy auth flow: + +1. **Browser navigation** to `/analytics/` sends the Clerk `__session` cookie +2. **nginx** sends an `auth_request` to the backend (`/api/v1/auth/validate`) +3. **Backend** decodes the JWT from the cookie and resolves the organization: + - If the JWT contains `org_id` (active Clerk organization session) → uses `org_id` as tenant + - If the JWT has no `org_id` → falls back to `workspace-{userId}` (personal workspace) +4. **nginx** forwards the resolved identity via `x-proxy-user`, `x-proxy-roles`, and `securitytenant` headers + +### Important: Clerk Active Organization Session + +The Clerk JWT `__session` cookie only contains `org_id` when the user has an **active organization session**. This is different from organization membership: + +| Concept | Source | Contains org info? | +|---------|--------|-------------------| +| `organizationMemberships` | Clerk User object (frontend SDK) | Lists ALL orgs the user belongs to | +| JWT `org_id` | `__session` cookie (cryptographically signed) | Only the ACTIVE org, if any | + +If a user is a member of an organization but hasn't activated it (via Clerk's `OrganizationSwitcher` or `setActive()`), their JWT won't contain `org_id`, and they'll land in a personal workspace tenant instead of their organization's tenant. + +### Tenant Provisioning + +When a new `org_id` is seen during auth validation, the backend automatically provisions: +- An OpenSearch **tenant** named after the org ID +- A **role** (`customer_{orgId}_ro`) with read access to `security-findings-{orgId}-*` indices and `kibana_all_write` tenant permissions +- A **role mapping** linking the role to the proxy auth backend role +- An **index template** and **seed index** with field mappings so index patterns resolve correctly +- A default **index pattern** (`security-findings-{orgId}-*`) in the tenant's saved objects + +### Security Guarantees + +- The JWT is cryptographically signed by Clerk — `org_id` cannot be forged +- The backend validates `X-Organization-Id` headers against the JWT's `org_id` — cross-tenant header spoofing is rejected +- Each tenant has isolated roles, index patterns, and saved objects +- The `workspace-{userId}` fallback creates an isolated personal sandbox — no data leaks between tenants + ## Troubleshooting +### OpenSearch Dashboards Shows `workspace-user_...` Instead of Organization Name + +**Symptom:** The user profile dropdown in OpenSearch Dashboards shows `workspace-user_{clerkUserId}` instead of the organization ID (e.g., `org_...`). The dashboard appears empty because all indexed data is under the organization's tenant, not the personal workspace. + +**Expected (org tenant active):** + +![OpenSearch showing org ID as tenant](media/opensearch-tenant-org-id.png) + +**Broken (workspace fallback):** + +![OpenSearch showing workspace-user fallback](media/opensearch-tenant-workspace-fallback.png) + +**Root Cause:** The user's Clerk session does not have an active organization. The Clerk JWT (`__session` cookie) only includes `org_id` when the organization is explicitly activated via `OrganizationSwitcher` or `clerk.setActive({ organization: orgId })`. Without an active org, the backend falls back to `workspace-{userId}`. + +This can happen when: +- The user signed up and was added to an org but never selected it in the UI +- The user's Clerk session expired and was recreated without org context +- The frontend didn't call `setActive()` after login + +**Clerk Dashboard — user in "local" org (but no active session, causing fallback):** + +![Clerk user in local org](media/clerk-user-local-org.png) + +**Clerk Dashboard — user in "Test Organization" (active session, working correctly):** + +![Clerk user in test org](media/clerk-user-test-org.png) + +**Diagnosis:** +```bash +# Check backend logs for the auth resolution path +docker logs shipsec-backend 2>&1 | grep -E "\[AUTH\].*Resolving org|No org found|Using org" + +# Example log when org is missing from JWT: +# [AUTH] Resolving org - Header: not present, JWT org: none, User: user_39ey3oxc0... +# [AUTH] No org found, using workspace: workspace-user_39ey3oxc0... + +# Example log when org is correctly resolved: +# [AUTH] Resolving org - Header: not present, JWT org: org_30cuor7xe..., User: user_abc... +# [AUTH] Using org from JWT payload: org_30cuor7xe... +``` + +**Solution:** +1. Have the user switch to their organization using the Organization Switcher in the app UI +2. Ensure the frontend calls `clerk.setActive({ organization: orgId })` after login when the user belongs to an organization +3. After switching, refresh the `/analytics/` page — the tenant should now show the org ID + +**Security Note:** This is a UX issue, not a security vulnerability. The `workspace-user_...` fallback creates an isolated empty sandbox. No data leaks between tenants. See the [Security Guarantees](#security-guarantees) section above. + +### Accessing OpenSearch Dashboards as Admin (Maintenance) + +For maintenance tasks (managing indices, debugging tenant provisioning, viewing all tenants, etc.), you need admin-level access to OpenSearch Dashboards. + +**Why normal `/analytics/` access won't work as admin:** +The nginx `/analytics/` route always injects org-scoped proxy headers (`x-proxy-user: org__user`). Since proxy auth (order 0) takes priority over basic auth (order 1), OpenSearch ignores any admin credentials and authenticates you as the org-scoped user instead. + +**How to access as admin:** + +Access Dashboards directly on port 5601, bypassing nginx entirely. Without proxy headers, the basic auth fallback activates. + +**Development (port already exposed):** +``` +http://localhost:5601 +``` +Log in with the admin credentials defined in `docker/opensearch-security/internal_users.yml` (default: `admin` / `admin`). + +**Production (port not publicly exposed):** + +Use SSH port forwarding to tunnel to the server's Dashboards port: +```bash +ssh -L 5601:localhost:5601 user@your-production-server +``` +Then open `http://localhost:5601` locally. + +If the Dashboards container doesn't bind to the host network, find its Docker IP first: +```bash +# On the production server +docker inspect opensearch-dashboards | grep IPAddress + +# Then tunnel to that IP +ssh -L 5601::5601 user@your-production-server +``` + +**Admin capabilities:** +- View and manage all tenants +- Inspect index mappings and document counts +- Debug role mappings and security configuration +- Manage ISM policies and index lifecycle +- Access the Security plugin UI at `/app/security-dashboards-plugin` + ### Analytics Sink Not Writing Data **Symptom:** New workflow runs don't appear in OpenSearch diff --git a/docs/media/clerk-user-local-org.png b/docs/media/clerk-user-local-org.png new file mode 100644 index 0000000000000000000000000000000000000000..381734f1217d176306f183c5b4ed8b4b6f6071f6 GIT binary patch literal 77621 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 Kze=7N1^ho$D}Rvy literal 0 HcmV?d00001 diff --git a/docs/media/clerk-user-test-org.png b/docs/media/clerk-user-test-org.png new file mode 100644 index 0000000000000000000000000000000000000000..686eb959fa964c4c167fc424bad67e3de96035fe GIT binary patch literal 74050 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%0DX~ZB2$UqOpF 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

fRF=y1SYBpY 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 a(@U~6vY#rW2=dMVFC_)_2PJamFaHOoxT+@r literal 0 HcmV?d00001 diff --git a/docs/media/opensearch-tenant-org-id.png b/docs/media/opensearch-tenant-org-id.png new file mode 100644 index 0000000000000000000000000000000000000000..58499a32d3cd6693bd0942f453903c3254c619ee GIT binary patch literal 46750 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 M)b-VBRqSH^2ca{=z5oCK literal 0 HcmV?d00001 diff --git a/docs/media/opensearch-tenant-workspace-fallback.png b/docs/media/opensearch-tenant-workspace-fallback.png new file mode 100644 index 0000000000000000000000000000000000000000..fc422bb500379884b58c95575c0528711277ed09 GIT binary patch literal 64481 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 Date: Fri, 6 Feb 2026 02:10:59 -0500 Subject: [PATCH 06/13] feat(analytics): lock down Dashboards for SaaS tenants and fix saved objects Two-layer SaaS lockdown for OpenSearch Dashboards: 1. nginx whitelist: PCRE negative lookahead blocks non-whitelisted /analytics/app/* routes (returns 403). Allowed: Discover, Visualize, Dashboards, Alerting, Dev Tools, Data Explorer, Home. Blocked: ISM, Security, Management, Anomaly Detection, Maps, etc. Admin retains full access via direct Dashboards port (5601). 2. Role permissions: Replace ISM cluster permissions with Alerting permissions (monitor CRUD, alerts, destinations) for tenant roles. Add indices:data/write/bulk cluster permission required for Dashboards saved objects (visualizations, dashboards, saved searches). Without this, multitenancy's kibana_all_write grant is never reached. 3. Default landing page set to Discover instead of Home (which exposes all plugin links including blocked ones). Signed-off-by: Aseem Shrey --- .../analytics/opensearch-tenant.service.ts | 15 ++++++++-- docker/nginx/nginx.dev.conf | 9 ++++++ docker/nginx/nginx.full.conf | 9 ++++++ docker/nginx/nginx.prod.conf | 9 ++++++ docker/opensearch-dashboards.prod.yml | 3 ++ docker/opensearch-dashboards.yml | 3 ++ docker/opensearch-security/roles.yml | 30 +++++++++++++++---- 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/backend/src/analytics/opensearch-tenant.service.ts b/backend/src/analytics/opensearch-tenant.service.ts index b12c63f7..3663ece6 100644 --- a/backend/src/analytics/opensearch-tenant.service.ts +++ b/backend/src/analytics/opensearch-tenant.service.ts @@ -180,9 +180,18 @@ export class OpenSearchTenantService { const roleDefinition = { cluster_permissions: [ 'cluster_composite_ops_ro', - 'cluster:admin/opendistro/ism/policy/get', - 'cluster:admin/opendistro/ism/policy/search', - 'cluster:admin/opendistro/ism/managedindex/explain', + // Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) + 'indices:data/write/bulk', + // Alerting: monitor CRUD, execution, alerts, and destinations + 'cluster:admin/opendistro/alerting/monitor/get', + 'cluster:admin/opendistro/alerting/monitor/search', + 'cluster:admin/opendistro/alerting/monitor/write', + 'cluster:admin/opendistro/alerting/monitor/execute', + 'cluster:admin/opendistro/alerting/alerts/get', + 'cluster:admin/opendistro/alerting/alerts/ack', + 'cluster:admin/opendistro/alerting/destination/get', + 'cluster:admin/opendistro/alerting/destination/write', + 'cluster:admin/opendistro/alerting/destination/delete', ], index_permissions: [ { diff --git a/docker/nginx/nginx.dev.conf b/docker/nginx/nginx.dev.conf index 119b5967..2070af92 100644 --- a/docker/nginx/nginx.dev.conf +++ b/docker/nginx/nginx.dev.conf @@ -111,6 +111,15 @@ http { proxy_set_header Authorization $http_authorization; } + # ================================================================= + # Dashboards SaaS Lockdown - Whitelist allowed app pages + # Tenants: Discover, Visualize, Dashboards, Alerting, Dev Tools + # Admin: use direct Dashboards port (5601) bypassing nginx + # ================================================================= + location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|data-explorer|home)($|/|\?|#)) { + return 403; + } + # ================================================================= # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) # ================================================================= diff --git a/docker/nginx/nginx.full.conf b/docker/nginx/nginx.full.conf index 0b3e03f1..8146cfda 100644 --- a/docker/nginx/nginx.full.conf +++ b/docker/nginx/nginx.full.conf @@ -100,6 +100,15 @@ http { proxy_set_header Authorization $http_authorization; } + # ================================================================= + # Dashboards SaaS Lockdown - Whitelist allowed app pages + # Tenants: Discover, Visualize, Dashboards, Alerting, Dev Tools + # Admin: use direct Dashboards port (5601) bypassing nginx + # ================================================================= + location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|data-explorer|home)($|/|\?|#)) { + return 403; + } + # ================================================================= # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) # ================================================================= diff --git a/docker/nginx/nginx.prod.conf b/docker/nginx/nginx.prod.conf index 5d4dd024..635453bd 100644 --- a/docker/nginx/nginx.prod.conf +++ b/docker/nginx/nginx.prod.conf @@ -96,6 +96,15 @@ http { proxy_set_header Authorization $http_authorization; } + # ================================================================= + # Dashboards SaaS Lockdown - Whitelist allowed app pages + # Tenants: Discover, Visualize, Dashboards, Alerting, Dev Tools + # Admin: use direct Dashboards port (5601) bypassing nginx + # ================================================================= + location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|data-explorer|home)($|/|\?|#)) { + return 403; + } + # ================================================================= # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) # ================================================================= diff --git a/docker/opensearch-dashboards.prod.yml b/docker/opensearch-dashboards.prod.yml index 323dc339..c9007b13 100644 --- a/docker/opensearch-dashboards.prod.yml +++ b/docker/opensearch-dashboards.prod.yml @@ -52,6 +52,9 @@ opensearch_security.cookie.isSameSite: "Strict" opensearch_security.session.ttl: 3600000 opensearch_security.session.keepalive: true +# Default landing page - Discover instead of Home (which shows all plugin links) +uiSettings.overrides.defaultRoute: "/app/discover" + # Logging logging.dest: stdout logging.silent: false diff --git a/docker/opensearch-dashboards.yml b/docker/opensearch-dashboards.yml index cc9dbc6a..7c24007a 100644 --- a/docker/opensearch-dashboards.yml +++ b/docker/opensearch-dashboards.yml @@ -26,5 +26,8 @@ logging.silent: false logging.quiet: false logging.verbose: false +# Default landing page - Discover instead of Home (which shows all plugin links) +uiSettings.overrides.defaultRoute: "/app/discover" + # CSP - relaxed for development (inline scripts needed by dashboards) csp.strict: false diff --git a/docker/opensearch-security/roles.yml b/docker/opensearch-security/roles.yml index 04f5b3bc..f6dba333 100644 --- a/docker/opensearch-security/roles.yml +++ b/docker/opensearch-security/roles.yml @@ -80,9 +80,18 @@ customer_template_rw: cluster_permissions: - "cluster_composite_ops_ro" - "indices:data/read/scroll*" - - "cluster:admin/opendistro/ism/policy/get" - - "cluster:admin/opendistro/ism/policy/search" - - "cluster:admin/opendistro/ism/managedindex/explain" + # Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) + - "indices:data/write/bulk" + # Alerting: monitor CRUD, execution, alerts, and destinations + - "cluster:admin/opendistro/alerting/monitor/get" + - "cluster:admin/opendistro/alerting/monitor/search" + - "cluster:admin/opendistro/alerting/monitor/write" + - "cluster:admin/opendistro/alerting/monitor/execute" + - "cluster:admin/opendistro/alerting/alerts/get" + - "cluster:admin/opendistro/alerting/alerts/ack" + - "cluster:admin/opendistro/alerting/destination/get" + - "cluster:admin/opendistro/alerting/destination/write" + - "cluster:admin/opendistro/alerting/destination/delete" index_permissions: - index_patterns: - "CUSTOMER_ID_PLACEHOLDER-*" @@ -107,9 +116,18 @@ customer_template_ro: description: "Template for customer read-only roles - DO NOT USE DIRECTLY" cluster_permissions: - "cluster_composite_ops_ro" - - "cluster:admin/opendistro/ism/policy/get" - - "cluster:admin/opendistro/ism/policy/search" - - "cluster:admin/opendistro/ism/managedindex/explain" + # Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) + - "indices:data/write/bulk" + # Alerting: monitor CRUD, execution, alerts, and destinations + - "cluster:admin/opendistro/alerting/monitor/get" + - "cluster:admin/opendistro/alerting/monitor/search" + - "cluster:admin/opendistro/alerting/monitor/write" + - "cluster:admin/opendistro/alerting/monitor/execute" + - "cluster:admin/opendistro/alerting/alerts/get" + - "cluster:admin/opendistro/alerting/alerts/ack" + - "cluster:admin/opendistro/alerting/destination/get" + - "cluster:admin/opendistro/alerting/destination/write" + - "cluster:admin/opendistro/alerting/destination/delete" index_permissions: - index_patterns: - "CUSTOMER_ID_PLACEHOLDER-*" From 3378d225d1448b8f96b7dada9b10edef4ba5b2f3 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 02:25:54 -0500 Subject: [PATCH 07/13] refactor(dev): unify justfile commands and harden Dashboards lockdown Signed-off-by: Aseem Shrey --- .../analytics/opensearch-tenant.service.ts | 9 +- .../organization-settings.service.ts | 4 +- backend/src/app.controller.ts | 9 +- docker/docker-compose.full.yml | 5 +- docker/docker-compose.infra.yml | 5 +- docker/nginx/nginx.dev.conf | 5 +- docker/nginx/nginx.full.conf | 5 +- docker/nginx/nginx.prod.conf | 5 +- docker/opensearch-dashboards.Dockerfile | 16 + justfile | 524 ++++++------------ worker/src/components/core/analytics-sink.ts | 10 - worker/src/utils/opensearch-indexer.ts | 2 +- 12 files changed, 197 insertions(+), 402 deletions(-) create mode 100644 docker/opensearch-dashboards.Dockerfile diff --git a/backend/src/analytics/opensearch-tenant.service.ts b/backend/src/analytics/opensearch-tenant.service.ts index 3663ece6..1a1d3227 100644 --- a/backend/src/analytics/opensearch-tenant.service.ts +++ b/backend/src/analytics/opensearch-tenant.service.ts @@ -24,11 +24,12 @@ export class OpenSearchTenantService { private readonly adminPassword: string; constructor(private readonly configService: ConfigService) { - this.securityEnabled = - this.configService.get('OPENSEARCH_SECURITY_ENABLED') === 'true'; - this.opensearchUrl = this.configService.get('OPENSEARCH_URL') || 'http://opensearch:9200'; + this.securityEnabled = this.configService.get('OPENSEARCH_SECURITY_ENABLED') === 'true'; + this.opensearchUrl = + this.configService.get('OPENSEARCH_URL') || 'http://opensearch:9200'; this.dashboardsUrl = - this.configService.get('OPENSEARCH_DASHBOARDS_URL') || 'http://opensearch-dashboards:5601'; + this.configService.get('OPENSEARCH_DASHBOARDS_URL') || + 'http://opensearch-dashboards:5601'; this.adminUsername = this.configService.get('OPENSEARCH_ADMIN_USERNAME') || 'admin'; this.adminPassword = this.configService.get('OPENSEARCH_ADMIN_PASSWORD') || ''; diff --git a/backend/src/analytics/organization-settings.service.ts b/backend/src/analytics/organization-settings.service.ts index a1ef7437..c33adf69 100644 --- a/backend/src/analytics/organization-settings.service.ts +++ b/backend/src/analytics/organization-settings.service.ts @@ -48,9 +48,7 @@ export class OrganizationSettingsService { // Provision OpenSearch tenant for the new organization (fire-and-forget) this.tenantService.ensureTenantExists(organizationId).catch((err) => { - this.logger.error( - `Failed to provision OpenSearch tenant for ${organizationId}: ${err}`, - ); + this.logger.error(`Failed to provision OpenSearch tenant for ${organizationId}: ${err}`); }); return created; diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 3983f6c1..952f87ee 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -51,10 +51,7 @@ export class AppController { */ @SkipThrottle() @Get('/auth/validate') - validateAuth( - @CurrentAuth() auth: AuthContext | null, - @Res({ passthrough: true }) res: Response, - ) { + validateAuth(@CurrentAuth() auth: AuthContext | null, @Res({ passthrough: true }) res: Response) { if (!auth || !auth.isAuthenticated) { throw new UnauthorizedException(); } @@ -80,9 +77,7 @@ export class AppController { (err) => { // Remove from cache so it retries next request this.provisioningOrgs.delete(normalizedOrgId); - this.logger.error( - `Failed to provision OpenSearch tenant for ${normalizedOrgId}: ${err}`, - ); + this.logger.error(`Failed to provision OpenSearch tenant for ${normalizedOrgId}: ${err}`); return false; }, ); diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index fe397305..ff1cb69e 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -200,7 +200,10 @@ services: retries: 5 opensearch-dashboards: - image: opensearchproject/opensearch-dashboards:2.11.1 + build: + context: . + dockerfile: opensearch-dashboards.Dockerfile + image: shipsec-opensearch-dashboards:2.11.1 container_name: shipsec-opensearch-dashboards depends_on: opensearch: diff --git a/docker/docker-compose.infra.yml b/docker/docker-compose.infra.yml index 04145ad1..67f6e357 100644 --- a/docker/docker-compose.infra.yml +++ b/docker/docker-compose.infra.yml @@ -165,7 +165,10 @@ services: retries: 5 opensearch-dashboards: - image: opensearchproject/opensearch-dashboards:2.11.1 + build: + context: . + dockerfile: opensearch-dashboards.Dockerfile + image: shipsec-opensearch-dashboards:2.11.1 container_name: shipsec-opensearch-dashboards depends_on: opensearch: diff --git a/docker/nginx/nginx.dev.conf b/docker/nginx/nginx.dev.conf index 2070af92..bceaf670 100644 --- a/docker/nginx/nginx.dev.conf +++ b/docker/nginx/nginx.dev.conf @@ -113,10 +113,11 @@ http { # ================================================================= # Dashboards SaaS Lockdown - Whitelist allowed app pages - # Tenants: Discover, Visualize, Dashboards, Alerting, Dev Tools + # Unwanted plugins are removed from the image (opensearch-dashboards.Dockerfile) + # This regex is defense-in-depth against any remaining/future plugins # Admin: use direct Dashboards port (5601) bypassing nginx # ================================================================= - location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|data-explorer|home)($|/|\?|#)) { + location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|notifications|management|data-explorer|home)($|/|\?|#)) { return 403; } diff --git a/docker/nginx/nginx.full.conf b/docker/nginx/nginx.full.conf index 8146cfda..8b3b2f7d 100644 --- a/docker/nginx/nginx.full.conf +++ b/docker/nginx/nginx.full.conf @@ -102,10 +102,11 @@ http { # ================================================================= # Dashboards SaaS Lockdown - Whitelist allowed app pages - # Tenants: Discover, Visualize, Dashboards, Alerting, Dev Tools + # Unwanted plugins are removed from the image (opensearch-dashboards.Dockerfile) + # This regex is defense-in-depth against any remaining/future plugins # Admin: use direct Dashboards port (5601) bypassing nginx # ================================================================= - location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|data-explorer|home)($|/|\?|#)) { + location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|notifications|management|data-explorer|home)($|/|\?|#)) { return 403; } diff --git a/docker/nginx/nginx.prod.conf b/docker/nginx/nginx.prod.conf index 635453bd..3677280a 100644 --- a/docker/nginx/nginx.prod.conf +++ b/docker/nginx/nginx.prod.conf @@ -98,10 +98,11 @@ http { # ================================================================= # Dashboards SaaS Lockdown - Whitelist allowed app pages - # Tenants: Discover, Visualize, Dashboards, Alerting, Dev Tools + # Unwanted plugins are removed from the image (opensearch-dashboards.Dockerfile) + # This regex is defense-in-depth against any remaining/future plugins # Admin: use direct Dashboards port (5601) bypassing nginx # ================================================================= - location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|data-explorer|home)($|/|\?|#)) { + location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|notifications|management|data-explorer|home)($|/|\?|#)) { return 403; } diff --git a/docker/opensearch-dashboards.Dockerfile b/docker/opensearch-dashboards.Dockerfile new file mode 100644 index 00000000..28d10fb2 --- /dev/null +++ b/docker/opensearch-dashboards.Dockerfile @@ -0,0 +1,16 @@ +FROM opensearchproject/opensearch-dashboards:2.11.1 + +# SaaS Tenant Lockdown - Remove plugins that tenants should not access +# Allowed: Discover, Dashboards, Visualize, Alerting, Dev Tools, Home +# Keeping: alertingDashboards, notificationsDashboards (alerting dependency), +# securityDashboards (proxy auth/multitenancy), ganttChartDashboards (viz type) + +RUN /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove queryWorkbenchDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove reportsDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove anomalyDetectionDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove customImportMapDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove securityAnalyticsDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove searchRelevanceDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove mlCommonsDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove indexManagementDashboards && \ + /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove observabilityDashboards diff --git a/justfile b/justfile index a0a2d585..fe97f7df 100644 --- a/justfile +++ b/justfile @@ -55,83 +55,29 @@ init: echo " Edit the .env files to configure your environment" echo " Then run: just dev" -# Start development environment with hot-reload and OpenSearch Security -# Usage: just dev [instance] [action] -# Examples: just dev, just dev 1, just dev 2 start, just dev 1 logs, just dev stop all -dev *args: +# Start development environment with hot-reload +# Auto-detects auth mode: if CLERK_SECRET_KEY is set in backend/.env → secure mode (Clerk + OpenSearch Security) +# Otherwise → local auth mode (faster startup, no multi-tenant isolation) +dev action="start": #!/usr/bin/env bash set -euo pipefail - # Parse arguments: instance can be 0-9, action is start/stop/logs/status/clean - INSTANCE="$(./scripts/active-instance.sh get)" - ACTION="start" - INFRA_PROJECT_NAME="shipsec-infra" - - # Process arguments - for arg in {{args}}; do - case "$arg" in - [0-9]) - INSTANCE="$arg" - ;; - all) - # Special instance selector for bulk operations (e.g. `just dev stop all`) - INSTANCE="all" - ;; - start|stop|logs|status|clean|all) - ACTION="$arg" - ;; - *) - echo "❌ Unknown argument: $arg" - echo "Usage: just dev [instance] [action]" - echo " instance: 0-9 (default: 0)" - echo " action: start|stop|logs|status|clean" - exit 1 - ;; - esac - done - - # Handle special case: dev stop all - if [ "$ACTION" = "all" ]; then - ACTION="stop" + # Auto-detect auth mode from backend/.env + CLERK_KEY="" + if [ -f "backend/.env" ]; then + CLERK_KEY=$(grep -E '^CLERK_SECRET_KEY=' backend/.env | cut -d= -f2- | tr -d '"' | tr -d "'" | xargs) fi - # Handle "just dev stop" as "just dev 0 stop" - if [ "$ACTION" = "stop" ] && [ "$INSTANCE" = "0" ] && [ -z "{{args}}" ]; then - true # Keep defaults + if [ -n "$CLERK_KEY" ]; then + SECURE_MODE=true + else + SECURE_MODE=false fi - # Validate "all" usage - if [ "$INSTANCE" = "all" ] && [ "$ACTION" != "stop" ] && [ "$ACTION" != "status" ] && [ "$ACTION" != "logs" ] && [ "$ACTION" != "clean" ]; then - echo "❌ Instance 'all' is only supported for: stop|status|logs|clean" - exit 1 - fi - - # Get ports for this instance (skip for "all") - if [ "$INSTANCE" != "all" ]; then - eval "$(./scripts/dev-instance-manager.sh ports "$INSTANCE")" - INSTANCE_DIR=".instances/instance-$INSTANCE" - export FRONTEND BACKEND - fi - - case "$ACTION" in + case "{{action}}" in start) - echo "🚀 Starting development environment (instance $INSTANCE)..." - - # Initialize instance if needed - if [ ! -d "$INSTANCE_DIR" ]; then - ./scripts/dev-instance-manager.sh init "$INSTANCE" - fi - # Check for required env files - if [ ! -f "$INSTANCE_DIR/backend.env" ] || [ ! -f "$INSTANCE_DIR/worker.env" ] || [ ! -f "$INSTANCE_DIR/frontend.env" ]; then - echo "❌ Environment files not found in $INSTANCE_DIR!" - echo "" - echo " Attempting to initialize instance $INSTANCE..." - ./scripts/dev-instance-manager.sh init "$INSTANCE" - fi - - # Check for original env files if instance is 0 - if [ "$INSTANCE" = "0" ] && { [ ! -f "backend/.env" ] || [ ! -f "worker/.env" ] || [ ! -f "frontend/.env" ]; }; then + if [ ! -f "backend/.env" ] || [ ! -f "worker/.env" ] || [ ! -f "frontend/.env" ]; then echo "❌ Environment files not found!" echo "" echo " Run this first: just init" @@ -140,200 +86,76 @@ dev *args: exit 1 fi - # Auto-generate certificates if they don't exist - if [ ! -f "docker/certs/root-ca.pem" ]; then - echo "🔐 Generating TLS certificates..." - chmod +x docker/scripts/generate-certs.sh - docker/scripts/generate-certs.sh - echo "✅ Certificates generated" - fi + if [ "$SECURE_MODE" = "true" ]; then + echo "🔐 Starting development environment (Clerk auth detected)..." - # Start shared infrastructure with OpenSearch Security (one stack for all instances) - echo "⏳ Starting shared infrastructure (with OpenSearch Security)..." - docker compose -f docker/docker-compose.infra.yml \ - -f docker/docker-compose.dev-secure.yml \ - -f docker/docker-compose.dev-ports.yml \ - --project-name="$INFRA_PROJECT_NAME" \ - up -d - - # Wait for Postgres - echo "⏳ Waiting for infrastructure..." - POSTGRES_CONTAINER="$(docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" ps -q postgres)" - if [ -n "$POSTGRES_CONTAINER" ]; then - timeout 30s bash -c "until docker exec $POSTGRES_CONTAINER pg_isready -U shipsec >/dev/null 2>&1; do sleep 1; done" || true - fi + # Auto-generate certificates if they don't exist + if [ ! -f "docker/certs/root-ca.pem" ]; then + echo "🔐 Generating TLS certificates..." + chmod +x docker/scripts/generate-certs.sh + docker/scripts/generate-certs.sh + echo "✅ Certificates generated" + fi - # Wait for OpenSearch to be healthy (security init takes longer) - echo "⏳ Waiting for OpenSearch security initialization..." - timeout 120s bash -c 'until docker exec shipsec-opensearch curl -sf -u admin:${OPENSEARCH_ADMIN_PASSWORD:-admin} --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health >/dev/null 2>&1; do sleep 2; done' || true - - # Ensure instance-specific DB/namespace exists and migrations are applied. - ./scripts/instance-bootstrap.sh "$INSTANCE" - - # Prepare PM2 environment variables - export SHIPSEC_INSTANCE="$INSTANCE" - export SHIPSEC_ENV=development - export NODE_ENV=development - export TERMINAL_REDIS_URL="redis://localhost:6379" - export LOG_KAFKA_BROKERS="localhost:19092" - export EVENT_KAFKA_BROKERS="localhost:19092" - - # Update git SHA and start PM2 with instance-specific config - ./scripts/set-git-sha.sh || true - - # Enable OpenSearch Security for PM2 services - export OPENSEARCH_SECURITY_ENABLED=true - export NODE_TLS_REJECT_UNAUTHORIZED=0 - - pm2 startOrReload pm2.config.cjs \ - --only "shipsec-frontend-$INSTANCE,shipsec-backend-$INSTANCE,shipsec-worker-$INSTANCE" \ - --update-env + # Start infrastructure with security enabled + # Note: dev-ports.yml exposes OpenSearch on localhost for backend tenant provisioning + echo "🚀 Starting infrastructure with OpenSearch Security..." + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml up -d + + # Wait for Postgres + echo "⏳ Waiting for infrastructure..." + timeout 30s bash -c 'until docker exec shipsec-postgres pg_isready -U shipsec >/dev/null 2>&1; do sleep 1; done' || true + + # Wait for OpenSearch to be healthy (security init takes longer) + echo "⏳ Waiting for OpenSearch security initialization..." + timeout 120s bash -c 'until docker exec shipsec-opensearch curl -sf -u admin:${OPENSEARCH_ADMIN_PASSWORD:-admin} --cacert /usr/share/opensearch/config/certs/root-ca.pem https://localhost:9200/_cluster/health >/dev/null 2>&1; do sleep 2; done' || true + + # Update git SHA and start PM2 with security enabled + ./scripts/set-git-sha.sh || true + SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=true NODE_TLS_REJECT_UNAUTHORIZED=0 \ + pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env - echo "" - echo "✅ Development environment ready (instance $INSTANCE)" - ./scripts/dev-instance-manager.sh info "$INSTANCE" - echo "" - echo "🔐 OpenSearch Security: ENABLED (multi-tenant isolation active)" - echo " OpenSearch admin: admin / ${OPENSEARCH_ADMIN_PASSWORD:-admin}" - echo "" - echo "💡 just dev $INSTANCE logs - View application logs" - echo "💡 just dev $INSTANCE stop - Stop this instance" - echo "" - - # Version check - bun backend/scripts/version-check-summary.ts 2>/dev/null || true - ;; - stop) - if [ "$INSTANCE" = "all" ]; then - echo "🛑 Stopping all development environments..." - - # Stop all PM2 apps - pm2 delete shipsec-{frontend,backend,worker}-{0..9} 2>/dev/null || true - pm2 delete shipsec-test-worker 2>/dev/null || true - - # Stop shared infrastructure - just infra down - - echo "✅ All development environments stopped" - else - echo "🛑 Stopping development environment (instance $INSTANCE)..." - - # Stop PM2 apps for this instance - pm2 delete shipsec-{frontend,backend,worker}-"$INSTANCE" 2>/dev/null || true - - echo "✅ Instance $INSTANCE stopped" - fi - ;; - logs) - if [ "$INSTANCE" = "all" ]; then - echo "📋 Viewing logs for all instances..." - pm2 logs - else - echo "📋 Viewing logs for instance $INSTANCE..." - pm2 logs "shipsec-frontend-$INSTANCE|shipsec-backend-$INSTANCE|shipsec-worker-$INSTANCE" - fi - ;; - status) - if [ "$INSTANCE" = "all" ]; then - just status - else - echo "📊 Status of instance $INSTANCE:" echo "" - pm2 status 2>/dev/null | grep -E "shipsec-(frontend|backend|worker)-$INSTANCE|error" || echo "(Instance $INSTANCE not running in PM2)" + echo "✅ Development environment ready (secure mode)" + echo " App: http://localhost (via nginx)" + echo " API: http://localhost/api" + echo " Analytics: http://localhost/analytics (requires login)" + echo " Temporal UI: http://localhost:8081" echo "" - just status - fi - ;; - clean) - if [ "$INSTANCE" = "all" ]; then - echo "🧹 Cleaning all instances (0-9)..." - - # Stop all instance-specific PM2 apps - pm2 delete shipsec-{frontend,backend,worker}-{0..9} 2>/dev/null || true - - # Clean infra state for each instance - for i in {0..9}; do - if [ -d ".instances/instance-$i" ] || [ "$i" = "0" ]; then - echo " - Instance $i..." - ./scripts/instance-clean.sh "$i" >/dev/null 2>&1 || true - rm -rf ".instances/instance-$i" - fi - done - - # Cleanup root level instance marker if it exists - rm -f .shipsec-instance - - # Also clean global infra if requested? - # User usually runs `just infra clean` for that, but let's remind them. + echo "🔐 OpenSearch Security: ENABLED (multi-tenant isolation active)" + echo " OpenSearch admin: admin / ${OPENSEARCH_ADMIN_PASSWORD:-admin}" echo "" - echo "💡 To also wipe all Docker volumes (PSQL, Kafka, etc.), run: just infra clean" - echo "✅ All instance-specific state cleaned" + echo "💡 Direct ports (debugging): Frontend :5173, Backend :3211" else - echo "🧹 Cleaning instance $INSTANCE..." - - # Stop PM2 apps - pm2 delete shipsec-{frontend,backend,worker}-"$INSTANCE" 2>/dev/null || true - - # Remove instance-specific infra state (DB + Temporal namespace + topics, etc.) - ./scripts/instance-clean.sh "$INSTANCE" || true - - # Remove instance directory - rm -rf "$INSTANCE_DIR" - - echo "✅ Instance $INSTANCE cleaned" - fi - ;; - *) - echo "Usage: just dev [instance] [action]" - echo " instance: 0-9 (default: 0)" - echo " action: start|stop|logs|status|clean" - exit 1 - ;; - esac + echo "🚀 Starting development environment (local auth)..." -# Start development environment WITHOUT security (faster, simpler) -dev-insecure action="start": - #!/usr/bin/env bash - set -euo pipefail - case "{{action}}" in - start) - echo "🚀 Starting development environment (insecure mode)..." - echo "⚠️ OpenSearch Security DISABLED - no multi-tenant isolation" - echo "" + # Start infrastructure (no security) + docker compose -f docker/docker-compose.infra.yml up -d + + # Wait for Postgres + echo "⏳ Waiting for infrastructure..." + timeout 30s bash -c 'until docker exec shipsec-postgres pg_isready -U shipsec >/dev/null 2>&1; do sleep 1; done' || true + + # Update git SHA and start PM2 + ./scripts/set-git-sha.sh || true + SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=false \ + pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env - # Check for required env files - if [ ! -f "backend/.env" ] || [ ! -f "worker/.env" ] || [ ! -f "frontend/.env" ]; then - echo "❌ Environment files not found!" echo "" - echo " Run this first: just init" + echo "✅ Development environment ready (local auth)" + echo " Frontend: http://localhost:5173" + echo " Backend: http://localhost:3211" + echo " Temporal UI: http://localhost:8081" echo "" - echo " This will create .env files from the example templates." - exit 1 + echo "💡 To enable Clerk auth + OpenSearch Security:" + echo " Set CLERK_SECRET_KEY in backend/.env, then restart" fi - # Start infrastructure (no security) - docker compose -f docker/docker-compose.infra.yml up -d - - # Wait for Postgres - echo "⏳ Waiting for infrastructure..." - timeout 30s bash -c 'until docker exec shipsec-postgres pg_isready -U shipsec >/dev/null 2>&1; do sleep 1; done' || true - # Start PM2 without security - ./scripts/set-git-sha.sh || true - SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=false \ - pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env - - echo "" - echo "✅ Development environment ready (INSECURE MODE)" - echo " Frontend: http://localhost:5173" - echo " Backend: http://localhost:3211" - echo " Temporal UI: http://localhost:8081" - echo "" - echo "⚠️ OpenSearch Security: DISABLED" - echo " Use 'just dev' for full multi-tenant security" echo "" - echo "💡 just dev-insecure logs - View application logs" - echo "💡 just dev-insecure stop - Stop everything" + echo "💡 just dev logs - View application logs" + echo "💡 just dev stop - Stop everything" + echo "💡 just dev clean - Stop and remove all data" echo "" # Version check @@ -342,7 +164,11 @@ dev-insecure action="start": stop) echo "🛑 Stopping development environment..." pm2 delete shipsec-frontend shipsec-backend shipsec-worker shipsec-test-worker 2>/dev/null || true - docker compose -f docker/docker-compose.infra.yml down + if [ "$SECURE_MODE" = "true" ]; then + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml down + else + docker compose -f docker/docker-compose.infra.yml down + fi echo "✅ Stopped" ;; logs) @@ -350,16 +176,24 @@ dev-insecure action="start": ;; status) pm2 status - docker compose -f docker/docker-compose.infra.yml ps + if [ "$SECURE_MODE" = "true" ]; then + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml ps + else + docker compose -f docker/docker-compose.infra.yml ps + fi ;; clean) echo "🧹 Cleaning development environment..." pm2 delete shipsec-frontend shipsec-backend shipsec-worker shipsec-test-worker 2>/dev/null || true - docker compose -f docker/docker-compose.infra.yml down -v + if [ "$SECURE_MODE" = "true" ]; then + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml down -v + else + docker compose -f docker/docker-compose.infra.yml down -v + fi echo "✅ Development environment cleaned (PM2 stopped, infrastructure volumes removed)" ;; *) - echo "Usage: just dev-insecure [start|stop|logs|status|clean]" + echo "Usage: just dev [start|stop|logs|status|clean]" ;; esac @@ -422,29 +256,66 @@ prod-init: echo " 2. Run 'just prod start-latest' to start with latest release" # Run production environment in Docker +# Auto-detects security mode: if TLS certs exist (docker/certs/root-ca.pem) → secure mode with multitenancy +# Otherwise → standard mode without OpenSearch Security prod action="start": #!/usr/bin/env bash set -euo pipefail + + # Auto-detect security mode from TLS certificates + if [ -f "docker/certs/root-ca.pem" ]; then + SECURE_MODE=true + else + SECURE_MODE=false + fi + + # Compose file selection based on mode + if [ "$SECURE_MODE" = "true" ]; then + COMPOSE_CMD="docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml" + else + COMPOSE_CMD="docker compose -f docker/docker-compose.full.yml" + fi + case "{{action}}" in start) - echo "🚀 Starting production environment..." - # Use --env-file if docker/.env exists - ENV_FLAG="" - [ -f "docker/.env" ] && ENV_FLAG="--env-file docker/.env" - docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d - echo "" - echo "✅ Production environment ready" - echo " App: http://localhost" - echo " API: http://localhost/api" - echo " Analytics: http://localhost/analytics" - echo " Temporal UI: http://localhost:8081" - echo "" + if [ "$SECURE_MODE" = "true" ]; then + echo "🔐 Starting production environment (secure mode)..." + + # Check for required env vars in secure mode + if [ -z "${OPENSEARCH_ADMIN_PASSWORD:-}" ] || [ -z "${OPENSEARCH_DASHBOARDS_PASSWORD:-}" ]; then + echo "❌ Required environment variables not set!" + echo "" + echo " export OPENSEARCH_ADMIN_PASSWORD='your-secure-password'" + echo " export OPENSEARCH_DASHBOARDS_PASSWORD='your-secure-password'" + exit 1 + fi + + $COMPOSE_CMD up -d + echo "" + echo "✅ Production environment ready (secure mode)" + echo " Analytics: https://localhost/analytics (requires auth)" + echo " OpenSearch: https://localhost:9200 (TLS enabled)" + echo "" + echo "💡 See docker/PRODUCTION.md for customer provisioning" + else + echo "🚀 Starting production environment..." + $COMPOSE_CMD up -d + echo "" + echo "✅ Production environment ready" + echo " App: http://localhost" + echo " API: http://localhost/api" + echo " Analytics: http://localhost/analytics" + echo " Temporal UI: http://localhost:8081" + echo "" + echo "💡 To enable security + multitenancy:" + echo " Run: just generate-certs" + fi # Version check bun backend/scripts/version-check-summary.ts 2>/dev/null || true ;; stop) - docker compose -f docker/docker-compose.full.yml down + $COMPOSE_CMD down echo "✅ Production stopped" ;; build) @@ -460,27 +331,21 @@ prod action="start": echo "📌 Building with commit: $GIT_SHA" fi - # Use --env-file if docker/.env exists - ENV_FLAG="" - [ -f "docker/.env" ] && ENV_FLAG="--env-file docker/.env" - docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d --build + $COMPOSE_CMD up -d --build echo "✅ Production built and started" - echo " App: http://localhost" - echo " API: http://localhost/api" - echo " Analytics: http://localhost/analytics" echo "" # Version check bun backend/scripts/version-check-summary.ts 2>/dev/null || true ;; logs) - docker compose -f docker/docker-compose.full.yml logs -f + $COMPOSE_CMD logs -f ;; status) - docker compose -f docker/docker-compose.full.yml ps + $COMPOSE_CMD ps ;; clean) - docker compose -f docker/docker-compose.full.yml down -v + $COMPOSE_CMD down -v docker system prune -f echo "✅ Production cleaned" ;; @@ -496,30 +361,27 @@ prod action="start": echo "❌ curl or jq is not installed. Please install them first." exit 1 fi - + LATEST_TAG=$(curl -s https://api.github.com/repos/ShipSecAI/studio/releases | jq -r '.[0].tag_name') - + # Strip leading 'v' if present (v0.1-rc2 -> 0.1-rc2) LATEST_TAG="${LATEST_TAG#v}" - + if [ "$LATEST_TAG" == "null" ] || [ -z "$LATEST_TAG" ]; then echo "❌ Could not find any releases. Please check the repository at https://github.com/ShipSecAI/studio/releases" exit 1 fi - + echo "📦 Found latest release: $LATEST_TAG" - + echo "📥 Pulling matching images from GHCR..." docker pull ghcr.io/shipsecai/studio-backend:$LATEST_TAG docker pull ghcr.io/shipsecai/studio-frontend:$LATEST_TAG docker pull ghcr.io/shipsecai/studio-worker:$LATEST_TAG - + echo "🚀 Starting production environment with version $LATEST_TAG..." export SHIPSEC_TAG=$LATEST_TAG - # Use --env-file if docker/.env exists - ENV_FLAG="" - [ -f "docker/.env" ] && ENV_FLAG="--env-file docker/.env" - docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d + $COMPOSE_CMD up -d echo "" echo "✅ ShipSec Studio $LATEST_TAG ready" @@ -634,60 +496,6 @@ prod-images action="start": ;; esac -# === Production Secure (with Security & Multitenancy) === - -# Run production with OpenSearch security and SaaS multitenancy -prod-secure action="start": - #!/usr/bin/env bash - set -euo pipefail - case "{{action}}" in - start) - echo "🔐 Starting secure production environment..." - - # Check for certificates - if [ ! -f "docker/certs/root-ca.pem" ]; then - echo "❌ TLS certificates not found!" - echo "" - echo " Run: just generate-certs" - exit 1 - fi - - # Check for required env vars - if [ -z "${OPENSEARCH_ADMIN_PASSWORD:-}" ] || [ -z "${OPENSEARCH_DASHBOARDS_PASSWORD:-}" ]; then - echo "❌ Required environment variables not set!" - echo "" - echo " export OPENSEARCH_ADMIN_PASSWORD='your-secure-password'" - echo " export OPENSEARCH_DASHBOARDS_PASSWORD='your-secure-password'" - exit 1 - fi - - docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml up -d - echo "" - echo "✅ Secure production environment ready" - echo " Analytics: https://localhost/analytics (requires auth)" - echo " OpenSearch: https://localhost:9200 (TLS enabled)" - echo "" - echo "💡 See docker/PRODUCTION.md for customer provisioning" - ;; - stop) - docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml down - echo "✅ Secure production stopped" - ;; - logs) - docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml logs -f - ;; - status) - docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml ps - ;; - clean) - docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.prod.yml down -v - echo "✅ Secure production cleaned" - ;; - *) - echo "Usage: just prod-secure [start|stop|logs|status|clean]" - ;; - esac - # Generate TLS certificates for production generate-certs: #!/usr/bin/env bash @@ -701,7 +509,7 @@ generate-certs: echo "Next steps:" echo " 1. export OPENSEARCH_ADMIN_PASSWORD='your-secure-password'" echo " 2. export OPENSEARCH_DASHBOARDS_PASSWORD='your-secure-password'" - echo " 3. just prod-secure" + echo " 3. just prod" # Initialize or reinitialize OpenSearch security index security-init *args: @@ -796,31 +604,16 @@ help: @echo "Getting Started:" @echo " just init Set up dependencies and environment files" @echo "" - @echo "Development (hot-reload, multi-instance with OpenSearch Security):" - @echo " just dev Start the active instance (default: 0) with security" - @echo " just instance show Show active instance" - @echo " just instance use 5 Set active instance to 5 for this workspace" - @echo " just dev 1 Start instance 1" - @echo " just dev 2 start Explicitly start instance 2" - @echo " just dev 1 stop Stop instance 1" - @echo " just dev 2 logs View instance 2 logs" - @echo " just dev 0 status Check instance 0 status" - @echo " just dev 1 clean Stop and remove instance 1 data" - @echo " just dev stop all Stop all instances at once" - @echo " just dev status all Check status of all instances" - @echo "" - @echo " Note: Instances share one Docker infra stack (Postgres/Temporal/Redpanda/Redis/etc)" - @echo " Isolation comes from per-instance DB + Temporal namespace/task-queue + Kafka topic suffix" - @echo " Instance N uses base_port + N*100 (e.g., instance 0 uses 5173, instance 1 uses 5273)" - @echo " OpenSearch Security provides multi-tenant data isolation per organization" + @echo "Development (hot-reload, auto-detects auth mode):" + @echo " just dev Start dev (Clerk creds in .env → secure mode, otherwise local auth)" + @echo " just dev stop Stop everything" + @echo " just dev logs View application logs" + @echo " just dev status Check service status" + @echo " just dev clean Stop and remove all data" @echo "" - @echo "Development (insecure, faster startup):" - @echo " just dev-insecure Start WITHOUT security (no tenant isolation)" - @echo " just dev-insecure stop Stop everything" - @echo "" - @echo "Production (Docker):" + @echo "Production (Docker, auto-detects security mode):" @echo " just prod-init Generate secrets in docker/.env (run once)" - @echo " just prod Start with cached images" + @echo " just prod Start prod (TLS certs present → secure mode, otherwise standard)" @echo " just prod build Rebuild and start" @echo " just prod start-latest Download latest release and start" @echo " just prod stop Stop production" @@ -829,13 +622,6 @@ help: @echo " just prod clean Remove all data" @echo " just prod-images Start with GHCR images (uses cache)" @echo "" - @echo "Production Secure (SaaS with multitenancy):" - @echo " just generate-certs Generate TLS certificates" - @echo " just prod-secure Start with security & multitenancy" - @echo " just prod-secure stop Stop secure production" - @echo " just prod-secure logs View logs" - @echo " just prod-secure clean Remove all data" - @echo "" @echo "Security Management:" @echo " just security-init Initialize OpenSearch security index" @echo " just security-init --force Reinitialize (update config)" diff --git a/worker/src/components/core/analytics-sink.ts b/worker/src/components/core/analytics-sink.ts index 884a4e8a..3a173034 100644 --- a/worker/src/components/core/analytics-sink.ts +++ b/worker/src/components/core/analytics-sink.ts @@ -24,16 +24,6 @@ const dataInputDefinitionSchema = z.object({ type DataInputDefinition = z.infer; -function toWorkflowSlug(value?: string | null): string | undefined { - if (!value) return undefined; - const slug = value - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return slug.length > 0 ? slug : undefined; -} - // Base input schema with a default input port. // resolvePorts adds extra ports when users configure multiple data inputs. const baseInputSchema = inputs({ diff --git a/worker/src/utils/opensearch-indexer.ts b/worker/src/utils/opensearch-indexer.ts index 02392592..ad033875 100644 --- a/worker/src/utils/opensearch-indexer.ts +++ b/worker/src/utils/opensearch-indexer.ts @@ -58,7 +58,7 @@ export class OpenSearchIndexer { private internalServiceToken: string | null = null; // Cache of provisioned org IDs with timestamp - private provisionedOrgs: Map = new Map(); + private provisionedOrgs = new Map(); constructor() { const url = process.env.OPENSEARCH_URL; From b2e63bf016b04c9d6c732a8c20e3f5f99ff8a723 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 12:06:25 -0500 Subject: [PATCH 08/13] feat(infra): lock down service ports to localhost-only in dev, disable in prod Base compose configs (infra.yml, full.yml) now use `expose` instead of `ports` for all internal services. Dev-ports overlay binds everything to 127.0.0.1. Only nginx port 80 remains publicly accessible. Signed-off-by: Aseem Shrey --- docker/docker-compose.dev-ports.yml | 38 ++++++- docker/docker-compose.full.yml | 95 ++++++----------- docker/docker-compose.infra.yml | 63 ++++++----- justfile | 159 ++++++---------------------- 4 files changed, 135 insertions(+), 220 deletions(-) diff --git a/docker/docker-compose.dev-ports.yml b/docker/docker-compose.dev-ports.yml index f52add21..0da3bd05 100644 --- a/docker/docker-compose.dev-ports.yml +++ b/docker/docker-compose.dev-ports.yml @@ -1,15 +1,49 @@ # Development Ports Overlay # # WARNING: These ports bypass ALL nginx authentication! -# Use ONLY for local development where direct OpenSearch/Dashboards access is needed. +# Use ONLY for local development where direct service access is needed. # # Usage: # docker compose -f docker-compose.infra.yml -f docker-compose.dev-ports.yml up -d # -# This overlay exposes OpenSearch and Dashboards ports on loopback (127.0.0.1) only, +# This overlay exposes all service ports on loopback (127.0.0.1) only, # preventing external network access while allowing local development tools to connect. services: + postgres: + ports: + - "127.0.0.1:5433:5432" + + temporal: + ports: + - "127.0.0.1:7233:7233" + + temporal-ui: + ports: + - "127.0.0.1:8081:8080" + + minio: + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + + redis: + ports: + - "127.0.0.1:6379:6379" + + loki: + ports: + - "127.0.0.1:3100:3100" + + redpanda: + ports: + - "127.0.0.1:9092:9092" + - "127.0.0.1:9644:9644" + + redpanda-console: + ports: + - "127.0.0.1:8082:8080" + opensearch: ports: - "127.0.0.1:9200:9200" diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index ff1cb69e..3de30c5e 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -1,25 +1,3 @@ -# ShipSec Studio - Production Docker Compose -# -# Required Setup: -# Run 'just prod-init' to generate required secrets in docker/.env -# -# Required Environment Variables (set in docker/.env): -# - INTERNAL_SERVICE_TOKEN: Service-to-service auth token (auto-generated by prod-init) -# - SECRET_STORE_MASTER_KEY: Encryption key for secrets, must be exactly 32 characters (auto-generated by prod-init) -# -# Optional Environment Variables: -# - AUTH_PROVIDER: Authentication provider (default: clerk) -# - CLERK_PUBLISHABLE_KEY: Clerk public key (required for Clerk auth) -# - CLERK_SECRET_KEY: Clerk secret key (required for Clerk auth) -# - VITE_API_URL: Frontend API URL (default: http://localhost:3211) -# - VITE_BACKEND_URL: Frontend backend URL (default: http://localhost:3211) -# - SHIPSEC_TAG: Docker image tag (default: latest) -# -# Usage: -# just prod-init # Initialize secrets (first time only) -# just prod start-latest # Pull and run latest release -# just prod start # Start with current images - services: # Infrastructure postgres: @@ -30,13 +8,14 @@ services: POSTGRES_PASSWORD: shipsec POSTGRES_DB: shipsec POSTGRES_MULTIPLE_DATABASES: temporal - ports: - - '5433:5432' + # Internal only - no direct port access in production + expose: + - "5432" volumes: - postgres_data:/var/lib/postgresql/data - ./init-db:/docker-entrypoint-initdb.d healthcheck: - test: ['CMD-SHELL', 'pg_isready -U shipsec'] + test: ["CMD-SHELL", "pg_isready -U shipsec"] interval: 5s timeout: 3s retries: 10 @@ -56,8 +35,8 @@ services: - POSTGRES_PWD=shipsec - POSTGRES_SEEDS=postgres - AUTO_SETUP=true - ports: - - '7233:7233' + expose: + - "7233" volumes: - temporal_data:/var/lib/temporal restart: unless-stopped @@ -73,8 +52,8 @@ services: environment: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_NAMESPACE=default - ports: - - '8081:8080' + expose: + - "8080" depends_on: - temporal restart: unless-stopped @@ -91,14 +70,14 @@ services: environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin - ports: - - '9000:9000' - - '9001:9001' + expose: + - "9000" + - "9001" volumes: - minio_data:/data restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 10s retries: 5 @@ -107,14 +86,14 @@ services: image: grafana/loki:3.2.1 container_name: shipsec-loki command: -config.file=/etc/loki/local-config.yaml - ports: - - '3100:3100' + expose: + - "3100" volumes: - ./loki/loki-config.yaml:/etc/loki/local-config.yaml - loki_data:/loki restart: unless-stopped healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3100/ready'] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/ready"] interval: 30s timeout: 10s retries: 5 @@ -122,13 +101,13 @@ services: redis: image: redis:latest container_name: shipsec-redis - ports: - - '6379:6379' + expose: + - "6379" volumes: - redis_data:/data restart: unless-stopped healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: ["CMD", "redis-cli", "ping"] interval: 30s timeout: 10s retries: 5 @@ -146,14 +125,14 @@ services: - --node-id=0 - --check=false - --advertise-kafka-addr=redpanda:9092 - ports: - - '9092:9092' - - '9644:9644' + expose: + - "9092" + - "9644" volumes: - redpanda_data:/var/lib/redpanda/data restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9644/v1/status/ready'] + test: ["CMD", "curl", "-f", "http://localhost:9644/v1/status/ready"] interval: 30s timeout: 10s retries: 5 @@ -165,8 +144,8 @@ services: - redpanda environment: CONFIG_FILEPATH: /etc/redpanda/console-config.yaml - ports: - - '8082:8080' + expose: + - "8080" volumes: - ./redpanda-console-config.yaml:/etc/redpanda/console-config.yaml:ro restart: unless-stopped @@ -187,9 +166,9 @@ services: nofile: soft: 65536 hard: 65536 - ports: - - "9200:9200" - - "9600:9600" + expose: + - "9200" + - "9600" volumes: - opensearch_data:/usr/share/opensearch/data restart: unless-stopped @@ -238,13 +217,13 @@ services: image: docker:27-dind container_name: shipsec-dind privileged: true - command: ['--host=tcp://0.0.0.0:2375', '--storage-driver=overlay2'] + command: ["--host=tcp://0.0.0.0:2375", "--storage-driver=overlay2"] environment: - DOCKER_TLS_CERTDIR= volumes: - docker_data:/var/lib/docker healthcheck: - test: ['CMD', 'docker', 'info'] + test: ["CMD", "docker", "info"] interval: 30s timeout: 10s retries: 5 @@ -285,10 +264,8 @@ services: - SESSION_SECRET=${SESSION_SECRET:-} # Set to 'true' to disable analytics - DISABLE_ANALYTICS=${DISABLE_ANALYTICS:-false} - # Secret encryption key (must be exactly 32 characters, NOT hex-encoded) - - SECRET_STORE_MASTER_KEY=${SECRET_STORE_MASTER_KEY:-CHANGE_ME_32_CHAR_SECRET_KEY!!!!} - # Internal service-to-service auth token (must match worker) - - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-internal-service-token} + # Internal service token for worker->backend auth + - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-} # OpenSearch tenant provisioning - OPENSEARCH_SECURITY_ENABLED=${OPENSEARCH_SECURITY_ENABLED:-false} - OPENSEARCH_URL=http://opensearch:9200 @@ -326,8 +303,6 @@ services: VITE_PUBLIC_POSTHOG_HOST: ${VITE_PUBLIC_POSTHOG_HOST:-} VITE_OPENSEARCH_DASHBOARDS_URL: ${VITE_OPENSEARCH_DASHBOARDS_URL:-/analytics} container_name: shipsec-frontend - # NOTE: Auth defaults to Clerk intentionally - production requires Clerk authentication. - # Set VITE_AUTH_PROVIDER=local in .env only for local development without Clerk. environment: - VITE_API_URL=${VITE_API_URL:-http://localhost} - VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost} @@ -374,12 +349,6 @@ services: - LOG_KAFKA_CLIENT_ID=shipsec-worker - EVENT_KAFKA_TOPIC=telemetry.events - EVENT_KAFKA_CLIENT_ID=shipsec-worker-events - # Secret encryption key (must be exactly 32 characters, NOT hex-encoded) - - SECRET_STORE_MASTER_KEY=${SECRET_STORE_MASTER_KEY:-CHANGE_ME_32_CHAR_SECRET_KEY!!!!} - # Internal service-to-service auth token (must match backend) - - INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:-internal-service-token} - # Backend URL for internal API calls - - STUDIO_API_BASE_URL=http://backend:3211/api/v1 # OpenSearch for Analytics Sink - OPENSEARCH_URL=http://opensearch:9200 - OPENSEARCH_DASHBOARDS_URL=http://opensearch-dashboards:5601/analytics @@ -404,7 +373,7 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ['CMD', 'node', '-e', 'process.exit(0)'] + test: ["CMD", "node", "-e", "process.exit(0)"] interval: 30s timeout: 10s retries: 5 diff --git a/docker/docker-compose.infra.yml b/docker/docker-compose.infra.yml index 67f6e357..1e9e619c 100644 --- a/docker/docker-compose.infra.yml +++ b/docker/docker-compose.infra.yml @@ -1,18 +1,20 @@ services: postgres: image: postgres:16-alpine + container_name: shipsec-postgres environment: POSTGRES_USER: shipsec POSTGRES_PASSWORD: shipsec POSTGRES_DB: shipsec POSTGRES_MULTIPLE_DATABASES: temporal - ports: - - '5433:5432' + # Internal only - use docker-compose.dev-ports.yml overlay for local dev access + expose: + - "5432" volumes: - postgres_data:/var/lib/postgresql/data - ./init-db:/docker-entrypoint-initdb.d healthcheck: - test: ['CMD-SHELL', 'pg_isready -U shipsec'] + test: ["CMD-SHELL", "pg_isready -U shipsec"] interval: 5s timeout: 3s retries: 10 @@ -20,6 +22,7 @@ services: temporal: image: temporalio/auto-setup:latest + container_name: shipsec-temporal depends_on: postgres: condition: service_healthy @@ -31,72 +34,76 @@ services: - POSTGRES_PWD=shipsec - POSTGRES_SEEDS=postgres - AUTO_SETUP=true - ports: - - '7233:7233' + expose: + - "7233" volumes: - temporal_data:/var/lib/temporal restart: unless-stopped temporal-ui: image: temporalio/ui:latest + container_name: shipsec-temporal-ui depends_on: - temporal environment: - TEMPORAL_ADDRESS=temporal:7233 - # Include several common dev frontend ports. - - TEMPORAL_CORS_ORIGINS=http://localhost:5173,http://localhost:5273,http://localhost:5373 - ports: - - '8081:8080' + - TEMPORAL_CORS_ORIGINS=http://localhost:5173 + expose: + - "8080" restart: unless-stopped minio: image: minio/minio:RELEASE.2024-10-02T17-50-41Z + container_name: shipsec-minio command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin - ports: - - '9000:9000' - - '9001:9001' + expose: + - "9000" + - "9001" volumes: - minio_data:/data restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 10s retries: 5 redis: image: redis:latest - ports: - - '6379:6379' + container_name: shipsec-redis + expose: + - "6379" volumes: - redis_data:/data restart: unless-stopped healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: ["CMD", "redis-cli", "ping"] interval: 30s timeout: 10s retries: 5 loki: image: grafana/loki:3.2.1 + container_name: shipsec-loki command: -config.file=/etc/loki/local-config.yaml - ports: - - '3100:3100' + expose: + - "3100" volumes: - ./loki/loki-config.yaml:/etc/loki/local-config.yaml - loki_data:/loki restart: unless-stopped healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3100/ready'] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/ready"] interval: 30s timeout: 10s retries: 5 redpanda: image: redpandadata/redpanda:v24.2.5 + container_name: shipsec-redpanda command: - redpanda - start @@ -106,30 +113,28 @@ services: - --overprovisioned - --node-id=0 - --check=false - # Internal listener for Docker network containers (redpanda-console, etc.) - - --kafka-addr=internal://0.0.0.0:9092,external://0.0.0.0:19092 - - --advertise-kafka-addr=internal://redpanda:9092,external://localhost:19092 - ports: - - '19092:19092' # External Kafka port for host apps - - '9092:9092' # Internal port (for Docker-to-Docker only, maps for debugging) - - '9644:9644' + - --advertise-kafka-addr=localhost:9092 + expose: + - "9092" + - "9644" volumes: - redpanda_data:/var/lib/redpanda/data restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9644/v1/status/ready'] + test: ["CMD", "curl", "-f", "http://localhost:9644/v1/status/ready"] interval: 30s timeout: 10s retries: 5 redpanda-console: image: redpandadata/console:v2.7.2 + container_name: shipsec-redpanda-console depends_on: - redpanda environment: CONFIG_FILEPATH: /etc/redpanda/console-config.yaml - ports: - - '8082:8080' + expose: + - "8080" volumes: - ./redpanda-console-config.yaml:/etc/redpanda/console-config.yaml:ro restart: unless-stopped diff --git a/justfile b/justfile index fe97f7df..af23097f 100644 --- a/justfile +++ b/justfile @@ -6,24 +6,6 @@ default: @just help -# Set/show the workspace "active" instance used when you run `just dev` without an explicit instance. -# This is stored in `.shipsec-instance` (gitignored). -instance action="show" value="": - #!/usr/bin/env bash - set -euo pipefail - case "{{action}}" in - show) - ./scripts/active-instance.sh get - ;; - use|set) - ./scripts/active-instance.sh set "{{value}}" - ;; - *) - echo "Usage: just instance [show|use] [0-9]" - exit 1 - ;; - esac - # === Development (recommended for contributors) === # Default dev passwords for convenience (override with env vars for real security) @@ -65,7 +47,7 @@ dev action="start": # Auto-detect auth mode from backend/.env CLERK_KEY="" if [ -f "backend/.env" ]; then - CLERK_KEY=$(grep -E '^CLERK_SECRET_KEY=' backend/.env | cut -d= -f2- | tr -d '"' | tr -d "'" | xargs) + CLERK_KEY=$(grep -E '^CLERK_SECRET_KEY=' backend/.env | cut -d= -f2- | tr -d '"' | tr -d "'" | xargs || true) fi if [ -n "$CLERK_KEY" ]; then @@ -129,8 +111,8 @@ dev action="start": else echo "🚀 Starting development environment (local auth)..." - # Start infrastructure (no security) - docker compose -f docker/docker-compose.infra.yml up -d + # Start infrastructure (no security, with dev ports for analytics) + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml up -d # Wait for Postgres echo "⏳ Waiting for infrastructure..." @@ -139,19 +121,21 @@ dev action="start": # Update git SHA and start PM2 ./scripts/set-git-sha.sh || true SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=false \ + OPENSEARCH_URL=http://localhost:9200 \ pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env echo "" echo "✅ Development environment ready (local auth)" - echo " Frontend: http://localhost:5173" - echo " Backend: http://localhost:3211" + echo " App: http://localhost (via nginx)" + echo " Analytics: http://localhost/analytics" echo " Temporal UI: http://localhost:8081" echo "" + echo "💡 Direct ports (debugging): Frontend :5173, Backend :3211, OpenSearch :9200, Dashboards :5601" + echo "" echo "💡 To enable Clerk auth + OpenSearch Security:" echo " Set CLERK_SECRET_KEY in backend/.env, then restart" fi - echo "" echo "💡 just dev logs - View application logs" echo "💡 just dev stop - Stop everything" @@ -167,7 +151,7 @@ dev action="start": if [ "$SECURE_MODE" = "true" ]; then docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml down else - docker compose -f docker/docker-compose.infra.yml down + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml down fi echo "✅ Stopped" ;; @@ -179,7 +163,7 @@ dev action="start": if [ "$SECURE_MODE" = "true" ]; then docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml ps else - docker compose -f docker/docker-compose.infra.yml ps + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml ps fi ;; clean) @@ -188,7 +172,7 @@ dev action="start": if [ "$SECURE_MODE" = "true" ]; then docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml down -v else - docker compose -f docker/docker-compose.infra.yml down -v + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml down -v fi echo "✅ Development environment cleaned (PM2 stopped, infrastructure volumes removed)" ;; @@ -199,62 +183,6 @@ dev action="start": # === Production (Docker-based) === -# Initialize production environment with secure secrets -# Creates docker/.env with auto-generated secrets if not present -prod-init: - #!/usr/bin/env bash - set -euo pipefail - ENV_FILE="docker/.env" - - echo "🔧 Initializing production environment..." - - # Create docker/.env if it doesn't exist - if [ ! -f "$ENV_FILE" ]; then - echo "📝 Creating $ENV_FILE..." - touch "$ENV_FILE" - fi - - # Source existing env file to check for existing values - set -a - [ -f "$ENV_FILE" ] && source "$ENV_FILE" - set +a - - UPDATED=false - - # Generate INTERNAL_SERVICE_TOKEN if not set - if [ -z "${INTERNAL_SERVICE_TOKEN:-}" ]; then - TOKEN=$(openssl rand -hex 32) - echo "INTERNAL_SERVICE_TOKEN=$TOKEN" >> "$ENV_FILE" - echo "🔑 Generated INTERNAL_SERVICE_TOKEN" - UPDATED=true - else - echo "✅ INTERNAL_SERVICE_TOKEN already set" - fi - - # Generate SECRET_STORE_MASTER_KEY if not set (exactly 32 characters, raw string) - if [ -z "${SECRET_STORE_MASTER_KEY:-}" ]; then - KEY=$(openssl rand -base64 24 | head -c 32) - echo "SECRET_STORE_MASTER_KEY=$KEY" >> "$ENV_FILE" - echo "🔑 Generated SECRET_STORE_MASTER_KEY" - UPDATED=true - else - echo "✅ SECRET_STORE_MASTER_KEY already set" - fi - - if [ "$UPDATED" = true ]; then - echo "" - echo "✅ Secrets generated and saved to $ENV_FILE" - echo "⚠️ Keep this file secure and never commit it to git!" - fi - - echo "" - echo "📋 Current configuration in $ENV_FILE:" - echo " Run 'cat $ENV_FILE' to view" - echo "" - echo "💡 Next steps:" - echo " 1. Edit $ENV_FILE to add other required variables (CLERK keys, etc.)" - echo " 2. Run 'just prod start-latest' to start with latest release" - # Run production environment in Docker # Auto-detects security mode: if TLS certs exist (docker/certs/root-ca.pem) → secure mode with multitenancy # Otherwise → standard mode without OpenSearch Security @@ -305,7 +233,8 @@ prod action="start": echo " App: http://localhost" echo " API: http://localhost/api" echo " Analytics: http://localhost/analytics" - echo " Temporal UI: http://localhost:8081" + echo "" + echo "🔒 All internal service ports are disabled (no direct access)" echo "" echo "💡 To enable security + multitenancy:" echo " Run: just generate-certs" @@ -350,12 +279,6 @@ prod action="start": echo "✅ Production cleaned" ;; start-latest) - # Auto-initialize secrets if docker/.env doesn't exist - if [ ! -f "docker/.env" ]; then - echo "⚠️ docker/.env not found, running prod-init..." - just prod-init - fi - echo "🔍 Fetching latest release information from GitHub API..." if ! command -v curl &> /dev/null || ! command -v jq &> /dev/null; then echo "❌ curl or jq is not installed. Please install them first." @@ -388,8 +311,8 @@ prod action="start": echo " App: http://localhost" echo " API: http://localhost/api" echo " Analytics: http://localhost/analytics" - echo " Temporal UI: http://localhost:8081" echo "" + echo "🔒 All internal service ports are disabled (no direct access)" echo "💡 Note: Using images tagged as $LATEST_TAG" ;; *) @@ -405,12 +328,6 @@ prod-images action="start": set -euo pipefail case "{{action}}" in start) - # Auto-initialize secrets if docker/.env doesn't exist - if [ ! -f "docker/.env" ]; then - echo "⚠️ docker/.env not found, running prod-init..." - just prod-init - fi - echo "🚀 Starting production environment with GHCR images..." # Check if images exist locally, pull if needed @@ -433,16 +350,14 @@ prod-images action="start": fi # Start with GHCR images, fallback to local build - # Use --env-file if docker/.env exists - ENV_FLAG="" - [ -f "docker/.env" ] && ENV_FLAG="--env-file docker/.env" - DOCKER_BUILDKIT=1 docker compose $ENV_FLAG -f docker/docker-compose.full.yml up -d + DOCKER_BUILDKIT=1 docker compose -f docker/docker-compose.full.yml up -d echo "" echo "✅ Production environment ready" echo " App: http://localhost" echo " API: http://localhost/api" echo " Analytics: http://localhost/analytics" - echo " Temporal UI: http://localhost:8081" + echo "" + echo "🔒 All internal service ports are disabled (no direct access)" ;; stop) docker compose -f docker/docker-compose.full.yml down @@ -536,21 +451,21 @@ hash-password password="": infra action="up": #!/usr/bin/env bash set -euo pipefail - INFRA_PROJECT_NAME="shipsec-infra" case "{{action}}" in up) - docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" up -d + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml up -d echo "✅ Infrastructure started (Postgres, Temporal, MinIO, Redis)" + echo " All ports bound to 127.0.0.1 (localhost only)" ;; down) - docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" down + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml down echo "✅ Infrastructure stopped" ;; logs) - docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" logs -f + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml logs -f ;; clean) - docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" down -v + docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-ports.yml down -v echo "✅ Infrastructure cleaned" ;; *) @@ -575,21 +490,17 @@ status: echo "=== Production Containers ===" docker compose -f docker/docker-compose.full.yml ps 2>/dev/null || echo " (Production not running)" -# Reset database for specific instance or all instances -# Usage: just db-reset [instance] -db-reset instance="0": +# Reset database (drops all data) +db-reset: #!/usr/bin/env bash set -euo pipefail - - if [ "{{instance}}" = "all" ]; then - echo "🗑️ Resetting all instance databases..." - for i in {0..9}; do - ./scripts/db-reset-instance.sh "$i" 2>/dev/null || true - done - echo "✅ All instance databases reset" - else - ./scripts/db-reset-instance.sh "{{instance}}" + if ! docker ps --filter "name=shipsec-postgres" --format "{{{{.Names}}}}" | grep -q "shipsec-postgres"; then + echo "❌ PostgreSQL not running. Run: just dev" && exit 1 fi + docker exec shipsec-postgres psql -U shipsec -d postgres -c "DROP DATABASE IF EXISTS shipsec;" + docker exec shipsec-postgres psql -U shipsec -d postgres -c "CREATE DATABASE shipsec;" + bun --cwd=backend run migration:push + echo "✅ Database reset" # Build production images without starting build: @@ -612,7 +523,6 @@ help: @echo " just dev clean Stop and remove all data" @echo "" @echo "Production (Docker, auto-detects security mode):" - @echo " just prod-init Generate secrets in docker/.env (run once)" @echo " just prod Start prod (TLS certs present → secure mode, otherwise standard)" @echo " just prod build Rebuild and start" @echo " just prod start-latest Download latest release and start" @@ -620,7 +530,6 @@ help: @echo " just prod logs View production logs" @echo " just prod status Check production status" @echo " just prod clean Remove all data" - @echo " just prod-images Start with GHCR images (uses cache)" @echo "" @echo "Security Management:" @echo " just security-init Initialize OpenSearch security index" @@ -634,8 +543,6 @@ help: @echo " just infra clean Remove infrastructure data" @echo "" @echo "Utilities:" - @echo " just status Show status of all services" - @echo " just db-reset Reset instance 0 database" - @echo " just db-reset 1 Reset instance 1 database" - @echo " just db-reset all Reset all instance databases" - @echo " just build Build images only" + @echo " just status Show status of all services" + @echo " just db-reset Reset database" + @echo " just build Build images only" From 875231fd737cb00c0cb68cb64d3cde660713c09e Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 12:07:27 -0500 Subject: [PATCH 09/13] fix(analytics): harden tenant security roles and restore bulk write permission Signed-off-by: Aseem Shrey --- .../analytics/opensearch-tenant.service.ts | 24 +++++++++++-- docker/opensearch-dashboards.Dockerfile | 12 ++++--- docker/opensearch-security/roles.yml | 34 +++++++++++++++++-- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/backend/src/analytics/opensearch-tenant.service.ts b/backend/src/analytics/opensearch-tenant.service.ts index 1a1d3227..062a6dd2 100644 --- a/backend/src/analytics/opensearch-tenant.service.ts +++ b/backend/src/analytics/opensearch-tenant.service.ts @@ -172,18 +172,20 @@ export class OpenSearchTenantService { /** * Creates a read-only customer role for the organization. - * Grants read access to security-findings-{orgId}-* indices. + * Grants read-only access to security findings indices, plus the minimum + * Dashboards/Notifications permissions required for tenant-scoped UI usage. */ private async createCustomerRole(orgId: string): Promise { const roleName = `customer_${orgId}_ro`; const url = `${this.opensearchUrl}/_plugins/_security/api/roles/${roleName}`; + const tenantSavedObjectsPattern = `.kibana_*_${orgId.replace(/[^a-z0-9]/g, '')}*`; const roleDefinition = { cluster_permissions: [ 'cluster_composite_ops_ro', // Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) 'indices:data/write/bulk', - // Alerting: monitor CRUD, execution, alerts, and destinations + // Alerting: monitor CRUD, execution, alerts, and destinations (legacy endpoints) 'cluster:admin/opendistro/alerting/monitor/get', 'cluster:admin/opendistro/alerting/monitor/search', 'cluster:admin/opendistro/alerting/monitor/write', @@ -193,12 +195,30 @@ export class OpenSearchTenantService { 'cluster:admin/opendistro/alerting/destination/get', 'cluster:admin/opendistro/alerting/destination/write', 'cluster:admin/opendistro/alerting/destination/delete', + // Notifications plugin (OpenSearch 2.x): channel features + config CRUD + 'cluster:admin/opensearch/notifications/features', + 'cluster:admin/opensearch/notifications/configs/get', + 'cluster:admin/opensearch/notifications/configs/create', + 'cluster:admin/opensearch/notifications/configs/update', + 'cluster:admin/opensearch/notifications/configs/delete', ], index_permissions: [ { index_patterns: [`security-findings-${orgId}-*`], allowed_actions: ['read', 'indices:data/read/*'], }, + { + // Tenant-scoped Dashboards saved objects index alias/index + index_patterns: [tenantSavedObjectsPattern], + allowed_actions: [ + 'read', + 'write', + 'create_index', + 'indices:data/read/*', + 'indices:data/write/*', + 'indices:admin/mapping/put', + ], + }, ], tenant_permissions: [ { diff --git a/docker/opensearch-dashboards.Dockerfile b/docker/opensearch-dashboards.Dockerfile index 28d10fb2..65613ffb 100644 --- a/docker/opensearch-dashboards.Dockerfile +++ b/docker/opensearch-dashboards.Dockerfile @@ -1,9 +1,11 @@ -FROM opensearchproject/opensearch-dashboards:2.11.1 +# Custom OpenSearch Dashboards image for SaaS tenant lockdown +# Source: https://github.com/ShipSecAI/tools/tree/main/misc/opensearch-dashboards-saas +# +# Removes unwanted plugins from sidebar. Config-level disabling is NOT possible +# because OSD 2.x plugins don't register an "enabled" config key (fatal error). +# See the tools repo README for full documentation. -# SaaS Tenant Lockdown - Remove plugins that tenants should not access -# Allowed: Discover, Dashboards, Visualize, Alerting, Dev Tools, Home -# Keeping: alertingDashboards, notificationsDashboards (alerting dependency), -# securityDashboards (proxy auth/multitenancy), ganttChartDashboards (viz type) +FROM opensearchproject/opensearch-dashboards:2.11.1 RUN /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove queryWorkbenchDashboards && \ /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove reportsDashboards && \ diff --git a/docker/opensearch-security/roles.yml b/docker/opensearch-security/roles.yml index f6dba333..f2d44c4d 100644 --- a/docker/opensearch-security/roles.yml +++ b/docker/opensearch-security/roles.yml @@ -82,7 +82,7 @@ customer_template_rw: - "indices:data/read/scroll*" # Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) - "indices:data/write/bulk" - # Alerting: monitor CRUD, execution, alerts, and destinations + # Alerting: monitor CRUD, execution, alerts, and destinations (legacy endpoints) - "cluster:admin/opendistro/alerting/monitor/get" - "cluster:admin/opendistro/alerting/monitor/search" - "cluster:admin/opendistro/alerting/monitor/write" @@ -92,6 +92,12 @@ customer_template_rw: - "cluster:admin/opendistro/alerting/destination/get" - "cluster:admin/opendistro/alerting/destination/write" - "cluster:admin/opendistro/alerting/destination/delete" + # Notifications plugin (OpenSearch 2.x): channel features + config CRUD + - "cluster:admin/opensearch/notifications/features" + - "cluster:admin/opensearch/notifications/configs/get" + - "cluster:admin/opensearch/notifications/configs/create" + - "cluster:admin/opensearch/notifications/configs/update" + - "cluster:admin/opensearch/notifications/configs/delete" index_permissions: - index_patterns: - "CUSTOMER_ID_PLACEHOLDER-*" @@ -102,6 +108,15 @@ customer_template_rw: - "indices:data/read/*" - "indices:data/write/*" - "indices:admin/mapping/put" + - index_patterns: + - ".kibana*" + allowed_actions: + - "read" + - "write" + - "create_index" + - "indices:data/read/*" + - "indices:data/write/*" + - "indices:admin/mapping/put" tenant_permissions: - tenant_patterns: - "CUSTOMER_ID_PLACEHOLDER" @@ -118,7 +133,7 @@ customer_template_ro: - "cluster_composite_ops_ro" # Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) - "indices:data/write/bulk" - # Alerting: monitor CRUD, execution, alerts, and destinations + # Alerting: monitor CRUD, execution, alerts, and destinations (legacy endpoints) - "cluster:admin/opendistro/alerting/monitor/get" - "cluster:admin/opendistro/alerting/monitor/search" - "cluster:admin/opendistro/alerting/monitor/write" @@ -128,12 +143,27 @@ customer_template_ro: - "cluster:admin/opendistro/alerting/destination/get" - "cluster:admin/opendistro/alerting/destination/write" - "cluster:admin/opendistro/alerting/destination/delete" + # Notifications plugin (OpenSearch 2.x): channel features + config CRUD + - "cluster:admin/opensearch/notifications/features" + - "cluster:admin/opensearch/notifications/configs/get" + - "cluster:admin/opensearch/notifications/configs/create" + - "cluster:admin/opensearch/notifications/configs/update" + - "cluster:admin/opensearch/notifications/configs/delete" index_permissions: - index_patterns: - "CUSTOMER_ID_PLACEHOLDER-*" allowed_actions: - "read" - "indices:data/read/*" + - index_patterns: + - ".kibana*" + allowed_actions: + - "read" + - "write" + - "create_index" + - "indices:data/read/*" + - "indices:data/write/*" + - "indices:admin/mapping/put" tenant_permissions: - tenant_patterns: - "CUSTOMER_ID_PLACEHOLDER" From 283d37a1be6afc758a9a706c19329d0e4de93559 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 15:52:51 -0500 Subject: [PATCH 10/13] chore(infra): consolidate duplicate configs and remove orphaned files - Merge nginx.full.conf into nginx.prod.conf (95% identical, prod has better proxy_redirect) - Consolidate DB init scripts: merge temporal DB creation into 01-create-instance-databases.sh - Remove orphaned scripts: dev-instance-manager.sh, instance-bootstrap.sh (unreferenced) - Remove deprecated opensearch-security/whitelist.yml (superseded by allowlist.yml) - Update docker-compose.full.yml and docs to reference nginx.prod.conf Signed-off-by: Aseem Shrey --- docker/README.md | 54 ++--- docker/docker-compose.full.yml | 68 +++--- .../init-db/01-create-instance-databases.sh | 29 ++- .../create-multiple-postgresql-databases.sh | 7 - docker/nginx/nginx.full.conf | 211 ----------------- docker/opensearch-security/whitelist.yml | 12 - docs/analytics.md | 60 +++-- scripts/dev-instance-manager.sh | 212 ------------------ scripts/instance-bootstrap.sh | 98 -------- 9 files changed, 124 insertions(+), 627 deletions(-) delete mode 100755 docker/init-db/create-multiple-postgresql-databases.sh delete mode 100644 docker/nginx/nginx.full.conf delete mode 100644 docker/opensearch-security/whitelist.yml delete mode 100755 scripts/dev-instance-manager.sh delete mode 100755 scripts/instance-bootstrap.sh diff --git a/docker/README.md b/docker/README.md index 6b1d9a5c..126191f8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -4,11 +4,11 @@ This directory contains Docker Compose configurations for running ShipSec Studio ## Docker Compose Files -| File | Purpose | When to Use | -|------|---------|-------------| -| `docker-compose.infra.yml` | Infrastructure services only | Development with PM2 (frontend/backend on host) | -| `docker-compose.full.yml` | Full stack in containers | Self-hosted deployment, all services containerized | -| `docker-compose.prod.yml` | Security overlay | Production SaaS with multitenancy (overlays infra.yml) | +| File | Purpose | When to Use | +| -------------------------- | ---------------------------- | ------------------------------------------------------ | +| `docker-compose.infra.yml` | Infrastructure services only | Development with PM2 (frontend/backend on host) | +| `docker-compose.full.yml` | Full stack in containers | Self-hosted deployment, all services containerized | +| `docker-compose.prod.yml` | Security overlay | Production SaaS with multitenancy (overlays infra.yml) | ## Environment Modes @@ -25,6 +25,7 @@ just dev - **Security**: Disabled for fast iteration **Access:** + - Frontend: http://localhost:5173 - Backend: http://localhost:3211 - Analytics: http://localhost:5601/analytics/ @@ -41,17 +42,18 @@ just prod - **Security**: Disabled (simple deployment) **Access (all via port 80):** + - Frontend: http://localhost/ - Backend API: http://localhost/api/ - Analytics: http://localhost/analytics/ -**Nginx Routing (nginx.full.conf):** +**Nginx Routing (nginx.prod.conf):** -| Path | Target Container | Port | -|------|------------------|------| +| Path | Target Container | Port | +| -------------- | --------------------- | ---- | | `/analytics/*` | opensearch-dashboards | 5601 | -| `/api/*` | backend | 3211 | -| `/*` | frontend | 8080 | +| `/api/*` | backend | 3211 | +| `/*` | frontend | 8080 | > **Note:** Frontend and backend containers only expose ports internally. All external traffic flows through nginx on port 80. @@ -70,16 +72,16 @@ just prod-secure - **Nginx**: Uses `nginx.prod.conf` with container networking **Access:** + - Analytics: https://localhost/analytics (auth required) - OpenSearch: https://localhost:9200 (TLS) ## Nginx Configuration -| File | Target Services | Use Case | -|------|-----------------|----------| -| `nginx/nginx.dev.conf` | `host.docker.internal:5173/3211` | Dev (PM2 on host) | -| `nginx/nginx.full.conf` | `frontend:8080`, `backend:3211`, `opensearch-dashboards:5601` | Full stack (all containerized) | -| `nginx/nginx.prod.conf` | Same as full + TLS | Prod with security | +| File | Target Services | Use Case | +| ----------------------- | ------------------------------------------------------------- | ---------------------------------------- | +| `nginx/nginx.dev.conf` | `host.docker.internal:5173/3211` | Dev (PM2 on host) | +| `nginx/nginx.prod.conf` | `frontend:8080`, `backend:3211`, `opensearch-dashboards:5601` | Container mode (full stack + production) | ### Routing Architecture @@ -98,6 +100,7 @@ All modes use nginx as a reverse proxy with unified routing: ### OpenSearch Dashboards BasePath OpenSearch Dashboards is configured with `server.basePath: "/analytics"` to work behind nginx: + - Incoming requests: `/analytics/app/discover` → internally processed as `/app/discover` - Outgoing URLs: Automatically prefixed with `/analytics` @@ -106,6 +109,7 @@ OpenSearch Dashboards is configured with `server.basePath: "/analytics"` to work The worker service writes analytics data to OpenSearch via the Analytics Sink component. **Required Environment Variable:** + ```yaml OPENSEARCH_URL=http://opensearch:9200 ``` @@ -138,16 +142,16 @@ docker/ ## Quick Reference -| Command | Description | -|---------|-------------| -| `just dev` | Start dev environment (PM2 + Docker infra) | -| `just dev stop` | Stop dev environment | -| `just prod` | Start full stack in Docker | -| `just prod stop` | Stop production | -| `just prod-secure` | Start with security & multitenancy | -| `just generate-certs` | Generate TLS certificates | -| `just infra up` | Start infrastructure only | -| `just help` | Show all available commands | +| Command | Description | +| --------------------- | ------------------------------------------ | +| `just dev` | Start dev environment (PM2 + Docker infra) | +| `just dev stop` | Stop dev environment | +| `just prod` | Start full stack in Docker | +| `just prod stop` | Stop production | +| `just prod-secure` | Start with security & multitenancy | +| `just generate-certs` | Generate TLS certificates | +| `just infra up` | Start infrastructure only | +| `just help` | Show all available commands | ## See Also diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index 3de30c5e..48314ea0 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -10,12 +10,12 @@ services: POSTGRES_MULTIPLE_DATABASES: temporal # Internal only - no direct port access in production expose: - - "5432" + - '5432' volumes: - postgres_data:/var/lib/postgresql/data - ./init-db:/docker-entrypoint-initdb.d healthcheck: - test: ["CMD-SHELL", "pg_isready -U shipsec"] + test: ['CMD-SHELL', 'pg_isready -U shipsec'] interval: 5s timeout: 3s retries: 10 @@ -36,12 +36,12 @@ services: - POSTGRES_SEEDS=postgres - AUTO_SETUP=true expose: - - "7233" + - '7233' volumes: - temporal_data:/var/lib/temporal restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "tctl --address $(hostname -i):7233 cluster health"] + test: ['CMD-SHELL', 'tctl --address $(hostname -i):7233 cluster health'] interval: 30s timeout: 10s retries: 5 @@ -53,12 +53,12 @@ services: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_NAMESPACE=default expose: - - "8080" + - '8080' depends_on: - temporal restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:8080"] + test: ['CMD', 'curl', '-sf', 'http://localhost:8080'] interval: 30s timeout: 10s retries: 5 @@ -71,13 +71,13 @@ services: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin expose: - - "9000" - - "9001" + - '9000' + - '9001' volumes: - minio_data:/data restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] interval: 30s timeout: 10s retries: 5 @@ -87,13 +87,13 @@ services: container_name: shipsec-loki command: -config.file=/etc/loki/local-config.yaml expose: - - "3100" + - '3100' volumes: - ./loki/loki-config.yaml:/etc/loki/local-config.yaml - loki_data:/loki restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/ready"] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3100/ready'] interval: 30s timeout: 10s retries: 5 @@ -102,12 +102,12 @@ services: image: redis:latest container_name: shipsec-redis expose: - - "6379" + - '6379' volumes: - redis_data:/data restart: unless-stopped healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 5 @@ -126,13 +126,13 @@ services: - --check=false - --advertise-kafka-addr=redpanda:9092 expose: - - "9092" - - "9644" + - '9092' + - '9644' volumes: - redpanda_data:/var/lib/redpanda/data restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9644/v1/status/ready"] + test: ['CMD', 'curl', '-f', 'http://localhost:9644/v1/status/ready'] interval: 30s timeout: 10s retries: 5 @@ -145,7 +145,7 @@ services: environment: CONFIG_FILEPATH: /etc/redpanda/console-config.yaml expose: - - "8080" + - '8080' volumes: - ./redpanda-console-config.yaml:/etc/redpanda/console-config.yaml:ro restart: unless-stopped @@ -156,7 +156,7 @@ services: environment: - discovery.type=single-node - bootstrap.memory_lock=true - - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" + - 'OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m' - DISABLE_SECURITY_PLUGIN=true - DISABLE_INSTALL_DEMO_CONFIG=true ulimits: @@ -167,13 +167,13 @@ services: soft: 65536 hard: 65536 expose: - - "9200" - - "9600" + - '9200' + - '9600' volumes: - opensearch_data:/usr/share/opensearch/data restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + test: ['CMD-SHELL', 'curl -f http://localhost:9200/_cluster/health || exit 1'] interval: 30s timeout: 10s retries: 5 @@ -191,12 +191,12 @@ services: - OPENSEARCH_HOSTS=["http://opensearch:9200"] - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true expose: - - "5601" + - '5601' volumes: - ./opensearch-dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:5601/analytics/api/status || exit 1"] + test: ['CMD-SHELL', 'curl -f http://localhost:5601/analytics/api/status || exit 1'] interval: 30s timeout: 10s retries: 5 @@ -209,21 +209,21 @@ services: condition: service_healthy volumes: - ./opensearch-init.sh:/init.sh:ro - entrypoint: ["/bin/sh", "/init.sh"] - restart: "no" + entrypoint: ['/bin/sh', '/init.sh'] + restart: 'no' # Applications dind: image: docker:27-dind container_name: shipsec-dind privileged: true - command: ["--host=tcp://0.0.0.0:2375", "--storage-driver=overlay2"] + command: ['--host=tcp://0.0.0.0:2375', '--storage-driver=overlay2'] environment: - DOCKER_TLS_CERTDIR= volumes: - docker_data:/var/lib/docker healthcheck: - test: ["CMD", "docker", "info"] + test: ['CMD', 'docker', 'info'] interval: 30s timeout: 10s retries: 5 @@ -274,7 +274,7 @@ services: - OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-} # Internal only - accessed via nginx at /api/ expose: - - "3211" + - '3211' depends_on: postgres: condition: service_healthy @@ -312,12 +312,12 @@ services: - VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL:-/analytics} # Internal only - accessed via nginx at / expose: - - "8080" + - '8080' depends_on: - backend restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:8080"] + test: ['CMD', 'curl', '-sf', 'http://localhost:8080'] interval: 30s timeout: 10s retries: 5 @@ -373,7 +373,7 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "node", "-e", "process.exit(0)"] + test: ['CMD', 'node', '-e', 'process.exit(0)'] interval: 30s timeout: 10s retries: 5 @@ -390,12 +390,12 @@ services: opensearch-dashboards: condition: service_healthy ports: - - "80:80" + - '80:80' volumes: - - ./nginx/nginx.full.conf:/etc/nginx/nginx.conf:ro + - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost/health"] + test: ['CMD', 'curl', '-sf', 'http://localhost/health'] interval: 30s timeout: 10s retries: 5 diff --git a/docker/init-db/01-create-instance-databases.sh b/docker/init-db/01-create-instance-databases.sh index f31e3b5a..4540fa63 100755 --- a/docker/init-db/01-create-instance-databases.sh +++ b/docker/init-db/01-create-instance-databases.sh @@ -1,16 +1,30 @@ #!/bin/bash -# Create instance-specific PostgreSQL databases +# Create additional PostgreSQL databases required by ShipSec # This script is run automatically by PostgreSQL init-entrypoint +# +# Creates: +# - temporal: Required by Temporal workflow engine +# - shipsec_instance_0..9: Multi-instance dev databases set -e -echo "🗄️ Creating instance-specific databases..." +# --- Temporal database (required for workflow engine) --- +echo "🗄️ Creating Temporal database..." +if psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d postgres -lqt | cut -d \| -f 1 | grep -qw "temporal"; then + echo " Database temporal already exists, skipping..." +else + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d postgres <<-EOSQL + CREATE DATABASE temporal OWNER "$POSTGRES_USER"; + GRANT ALL PRIVILEGES ON DATABASE temporal TO "$POSTGRES_USER"; +EOSQL + echo " ✅ temporal created" +fi -# Create databases for instances 0-9 +# --- Instance-specific databases (for multi-instance dev) --- +echo "🗄️ Creating instance-specific databases..." for i in {0..9}; do DB_NAME="shipsec_instance_$i" - - # Check if database already exists + if psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d postgres -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then echo " Database $DB_NAME already exists, skipping..." else @@ -22,7 +36,4 @@ EOSQL fi done -echo "✅ Instance-specific databases created successfully" -echo "" -echo "Available databases:" -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d postgres -c "\\l" | grep shipsec_instance +echo "✅ All databases created successfully" diff --git a/docker/init-db/create-multiple-postgresql-databases.sh b/docker/init-db/create-multiple-postgresql-databases.sh deleted file mode 100755 index 34368865..00000000 --- a/docker/init-db/create-multiple-postgresql-databases.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -e - -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE DATABASE temporal; - GRANT ALL PRIVILEGES ON DATABASE temporal TO $POSTGRES_USER; -EOSQL \ No newline at end of file diff --git a/docker/nginx/nginx.full.conf b/docker/nginx/nginx.full.conf deleted file mode 100644 index 8b3b2f7d..00000000 --- a/docker/nginx/nginx.full.conf +++ /dev/null @@ -1,211 +0,0 @@ -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_types text/plain text/css text/xml application/json application/javascript - application/rss+xml application/atom+xml image/svg+xml; - - # ================================================================= - # FULL DOCKER MODE - All services run in Docker containers - # ================================================================= - - # Upstream definitions - Docker container names - upstream frontend { - server frontend:8080; - keepalive 32; - } - - upstream backend { - server backend:3211; - keepalive 32; - } - - upstream opensearch-dashboards { - server opensearch-dashboards:5601; - keepalive 32; - } - - # WebSocket connection upgrade map - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - server { - listen 80; - server_name _; - - # Client request body size (for file uploads) - client_max_body_size 100M; - client_body_buffer_size 10M; - - # Proxy buffer settings - proxy_buffer_size 128k; - proxy_buffers 4 256k; - proxy_busy_buffers_size 256k; - - # Common proxy headers - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - - # ================================================================= - # Auth validation endpoint (public, proxied to backend) - # ================================================================= - location = /auth/validate { - proxy_pass http://backend/api/v1/auth/validate; - proxy_set_header Cookie $http_cookie; - proxy_set_header Authorization $http_authorization; - } - - # ================================================================= - # Internal auth validation endpoint for auth_request - # ================================================================= - location = /_auth { - internal; - proxy_pass http://backend/api/v1/auth/validate; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-URI $request_uri; - # Pass cookies for session auth - proxy_set_header Cookie $http_cookie; - # Pass Authorization header for API key/token auth - proxy_set_header Authorization $http_authorization; - } - - # ================================================================= - # Dashboards SaaS Lockdown - Whitelist allowed app pages - # Unwanted plugins are removed from the image (opensearch-dashboards.Dockerfile) - # This regex is defense-in-depth against any remaining/future plugins - # Admin: use direct Dashboards port (5601) bypassing nginx - # ================================================================= - location ~ ^/analytics/app/(?!(discover|visualize|dashboards?|dev_tools|alerting|notifications|management|data-explorer|home)($|/|\?|#)) { - return 403; - } - - # ================================================================= - # OpenSearch Dashboards - /analytics/* (PROTECTED + TENANT ISOLATED) - # ================================================================= - location /analytics/ { - # Require authentication before proxying - auth_request /_auth; - # On auth failure, redirect to login page - error_page 401 = @auth_redirect; - - # Capture org/user context from auth response headers - auth_request_set $auth_org_id $upstream_http_x_auth_organization_id; - auth_request_set $auth_user_id $upstream_http_x_auth_user_id; - - # FAIL-CLOSED: Reject if org context is missing - # This prevents unauthenticated or org-less sessions from reaching Dashboards - if ($auth_org_id = "") { - return 403; - } - - proxy_pass http://opensearch-dashboards; - - # WebSocket support for dashboards real-time features - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - # Timeouts for dashboards (can be slow for large queries) - proxy_connect_timeout 60s; - proxy_send_timeout 120s; - proxy_read_timeout 120s; - - # Dashboards-specific headers - proxy_set_header osd-xsrf "true"; - - # OpenSearch Security proxy auth headers - # proxy_set_header REPLACES any client-supplied headers (prevents spoofing) - proxy_set_header x-proxy-user $auth_org_id; - proxy_set_header x-proxy-roles "customer_${auth_org_id}_ro"; - proxy_set_header securitytenant $auth_org_id; - - # Preserve cookies - proxy_cookie_path /analytics/ /analytics/; - - # No redirect rewriting needed - we preserve the path - proxy_redirect off; - } - - # Auth redirect handler - redirect to home with return URL - location @auth_redirect { - return 302 /?returnTo=$request_uri; - } - - # Exact match for /analytics without trailing slash - location = /analytics { - return 301 /analytics/; - } - - # ================================================================= - # Backend API - /api/* - # ================================================================= - location /api/ { - proxy_pass http://backend/api/; - - # WebSocket support for terminal/streaming endpoints - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - # API timeouts - proxy_connect_timeout 30s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - - # Don't buffer API responses (important for streaming) - proxy_buffering off; - } - - # ================================================================= - # Frontend (SPA) - /* (catch-all) - # ================================================================= - location / { - proxy_pass http://frontend/; - - # WebSocket support for HMR (if running dev build) - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - # Frontend timeouts - proxy_connect_timeout 30s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - - # Health check endpoint - location /health { - access_log off; - return 200 "healthy\n"; - add_header Content-Type text/plain; - } - } -} diff --git a/docker/opensearch-security/whitelist.yml b/docker/opensearch-security/whitelist.yml deleted file mode 100644 index a72da3fe..00000000 --- a/docker/opensearch-security/whitelist.yml +++ /dev/null @@ -1,12 +0,0 @@ -# OpenSearch Security - Whitelist (deprecated, use allowlist.yml) -# -# This file exists for backwards compatibility. - ---- -_meta: - type: "whitelist" - config_version: 2 - -config: - enabled: false - requests: {} diff --git a/docs/analytics.md b/docs/analytics.md index d7805a0d..2deb769e 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -29,6 +29,7 @@ This document describes the analytics infrastructure for ShipSec Studio, includi Time-series database for storing security findings and workflow analytics. **Configuration:** + - Single-node deployment (dev/simple prod) - Security plugin disabled for development - Index pattern: `security-findings-{org-id}-{date}` @@ -38,13 +39,15 @@ Time-series database for storing security findings and workflow analytics. Web UI for exploring and visualizing analytics data. **Configuration (`opensearch-dashboards.yml`):** + ```yaml -server.basePath: "/analytics" +server.basePath: '/analytics' server.rewriteBasePath: true -opensearch.hosts: ["http://opensearch:9200"] +opensearch.hosts: ['http://opensearch:9200'] ``` **Key Settings:** + - `basePath: "/analytics"` - All URLs are prefixed with `/analytics` - `rewriteBasePath: true` - Strips `/analytics` from incoming requests, adds it back to responses @@ -53,6 +56,7 @@ opensearch.hosts: ["http://opensearch:9200"] The `core.analytics.sink` component writes workflow results to OpenSearch. **Input Ports:** + - Ships with a default `input1` port so at least one connector is always available. - Users can configure additional input ports via the **Data Inputs** parameter (e.g., to aggregate results from multiple scanners into one index). @@ -62,11 +66,13 @@ The `core.analytics.sink` component writes workflow results to OpenSearch. ensure all dynamic handles are present before rendering. **Environment Variable:** + ```yaml OPENSEARCH_URL=http://opensearch:9200 ``` **Document Structure:** + ```json { "@timestamp": "2026-01-25T01:22:43.783Z", @@ -88,11 +94,11 @@ OPENSEARCH_URL=http://opensearch:9200 All traffic flows through Nginx on port 80: -| Path | Target | Description | -|------|--------|-------------| -| `/analytics/*` | `opensearch-dashboards:5601` | Analytics dashboard UI | -| `/api/*` | `backend:3211` | Backend REST API | -| `/*` | `frontend:8080` | Frontend SPA (catch-all) | +| Path | Target | Description | +| -------------- | ---------------------------- | ------------------------ | +| `/analytics/*` | `opensearch-dashboards:5601` | Analytics dashboard UI | +| `/api/*` | `backend:3211` | Backend REST API | +| `/*` | `frontend:8080` | Frontend SPA (catch-all) | ### OpenSearch Dashboards Routing Details @@ -124,7 +130,7 @@ const filterQuery = `shipsec.run_id.keyword:"${runId}"`; // Build Discover URL with proper state format const gParam = encodeURIComponent('(time:(from:now-7d,to:now))'); const aParam = encodeURIComponent( - `(columns:!(_source),index:'security-findings-*',interval:auto,query:(language:kuery,query:'${filterQuery}'),sort:!('@timestamp',desc))` + `(columns:!(_source),index:'security-findings-*',interval:auto,query:(language:kuery,query:'${filterQuery}'),sort:!('@timestamp',desc))`, ); const url = `${baseUrl}/app/discover#/?_g=${gParam}&_a=${aParam}`; @@ -133,11 +139,13 @@ window.open(url, '_blank', 'noopener,noreferrer'); ``` **Key points:** + - Use `.keyword` fields (e.g., `shipsec.run_id.keyword`) for exact match filtering - Use Discover app (`/app/discover`) for viewing raw data without saved views - Include `index`, `columns`, `interval`, and `sort` in the `_a` param **Environment Variable:** + ``` VITE_OPENSEARCH_DASHBOARDS_URL=/analytics ``` @@ -186,16 +194,17 @@ The tenant identity for OpenSearch Dashboards is determined through a proxy auth The Clerk JWT `__session` cookie only contains `org_id` when the user has an **active organization session**. This is different from organization membership: -| Concept | Source | Contains org info? | -|---------|--------|-------------------| -| `organizationMemberships` | Clerk User object (frontend SDK) | Lists ALL orgs the user belongs to | -| JWT `org_id` | `__session` cookie (cryptographically signed) | Only the ACTIVE org, if any | +| Concept | Source | Contains org info? | +| ------------------------- | --------------------------------------------- | ---------------------------------- | +| `organizationMemberships` | Clerk User object (frontend SDK) | Lists ALL orgs the user belongs to | +| JWT `org_id` | `__session` cookie (cryptographically signed) | Only the ACTIVE org, if any | If a user is a member of an organization but hasn't activated it (via Clerk's `OrganizationSwitcher` or `setActive()`), their JWT won't contain `org_id`, and they'll land in a personal workspace tenant instead of their organization's tenant. ### Tenant Provisioning When a new `org_id` is seen during auth validation, the backend automatically provisions: + - An OpenSearch **tenant** named after the org ID - A **role** (`customer_{orgId}_ro`) with read access to `security-findings-{orgId}-*` indices and `kibana_all_write` tenant permissions - A **role mapping** linking the role to the proxy auth backend role @@ -226,6 +235,7 @@ When a new `org_id` is seen during auth validation, the backend automatically pr **Root Cause:** The user's Clerk session does not have an active organization. The Clerk JWT (`__session` cookie) only includes `org_id` when the organization is explicitly activated via `OrganizationSwitcher` or `clerk.setActive({ organization: orgId })`. Without an active org, the backend falls back to `workspace-{userId}`. This can happen when: + - The user signed up and was added to an org but never selected it in the UI - The user's Clerk session expired and was recreated without org context - The frontend didn't call `setActive()` after login @@ -239,6 +249,7 @@ This can happen when: ![Clerk user in test org](media/clerk-user-test-org.png) **Diagnosis:** + ```bash # Check backend logs for the auth resolution path docker logs shipsec-backend 2>&1 | grep -E "\[AUTH\].*Resolving org|No org found|Using org" @@ -253,6 +264,7 @@ docker logs shipsec-backend 2>&1 | grep -E "\[AUTH\].*Resolving org|No org found ``` **Solution:** + 1. Have the user switch to their organization using the Organization Switcher in the app UI 2. Ensure the frontend calls `clerk.setActive({ organization: orgId })` after login when the user belongs to an organization 3. After switching, refresh the `/analytics/` page — the tenant should now show the org ID @@ -271,20 +283,25 @@ The nginx `/analytics/` route always injects org-scoped proxy headers (`x-proxy- Access Dashboards directly on port 5601, bypassing nginx entirely. Without proxy headers, the basic auth fallback activates. **Development (port already exposed):** + ``` http://localhost:5601 ``` + Log in with the admin credentials defined in `docker/opensearch-security/internal_users.yml` (default: `admin` / `admin`). **Production (port not publicly exposed):** Use SSH port forwarding to tunnel to the server's Dashboards port: + ```bash ssh -L 5601:localhost:5601 user@your-production-server ``` + Then open `http://localhost:5601` locally. If the Dashboards container doesn't bind to the host network, find its Docker IP first: + ```bash # On the production server docker inspect opensearch-dashboards | grep IPAddress @@ -294,6 +311,7 @@ ssh -L 5601::5601 user@your-production-server ``` **Admin capabilities:** + - View and manage all tenants - Inspect index mappings and document counts - Debug role mappings and security configuration @@ -305,6 +323,7 @@ ssh -L 5601::5601 user@your-production-server **Symptom:** New workflow runs don't appear in OpenSearch **Check:** + ```bash # Verify worker has OPENSEARCH_URL set docker exec shipsec-worker env | grep OPENSEARCH @@ -320,17 +339,20 @@ docker logs shipsec-worker 2>&1 | grep -i "analytics\|indexing" **Symptom:** Page loads but content area is empty **Check:** + 1. Browser console for JavaScript errors 2. Time range filter (data might be outside selected range) 3. Index pattern selection **Solution:** + - Set time range to "Last 30 days" or wider - Ensure `security-findings-*` index pattern is selected ### Query Returns No Results **Check if data exists:** + ```bash # Count documents curl -s "http://localhost:9200/security-findings-*/_count" | jq '.count' @@ -344,15 +366,15 @@ curl -s "http://localhost:9200/security-findings-*/_search" \ ## Environment Variables -| Variable | Service | Description | -|----------|---------|-------------| -| `OPENSEARCH_URL` | Worker | OpenSearch connection URL | -| `OPENSEARCH_USERNAME` | Worker | Optional: OpenSearch username | -| `OPENSEARCH_PASSWORD` | Worker | Optional: OpenSearch password | -| `VITE_OPENSEARCH_DASHBOARDS_URL` | Frontend | Dashboard URL for links | +| Variable | Service | Description | +| -------------------------------- | -------- | ----------------------------- | +| `OPENSEARCH_URL` | Worker | OpenSearch connection URL | +| `OPENSEARCH_USERNAME` | Worker | Optional: OpenSearch username | +| `OPENSEARCH_PASSWORD` | Worker | Optional: OpenSearch password | +| `VITE_OPENSEARCH_DASHBOARDS_URL` | Frontend | Dashboard URL for links | ## See Also - [Docker README](../docker/README.md) - Docker deployment configurations -- [nginx.full.conf](../docker/nginx/nginx.full.conf) - Full stack nginx routing +- [nginx.prod.conf](../docker/nginx/nginx.prod.conf) - Container-mode nginx routing (full stack + production) - [opensearch-dashboards.yml](../docker/opensearch-dashboards.yml) - Dashboard configuration diff --git a/scripts/dev-instance-manager.sh b/scripts/dev-instance-manager.sh deleted file mode 100755 index 7e1cf001..00000000 --- a/scripts/dev-instance-manager.sh +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env bash -# Multi-instance dev stack manager for ShipSec Studio -# Handles isolated Docker containers and PM2 processes per instance - -set -euo pipefail - -# Configuration -INSTANCES_DIR=".instances" - -# Base port mappings -declare -A BASE_PORTS=( - [FRONTEND]=5173 - [BACKEND]=3211 -) - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Helper functions -log_info() { - echo -e "${BLUE}ℹ${NC} $*" -} - -log_success() { - echo -e "${GREEN}✅${NC} $*" -} - -log_warn() { - echo -e "${YELLOW}⚠️${NC} $*" -} - -log_error() { - echo -e "${RED}❌${NC} $*" -} - -get_instance_dir() { - local instance=$1 - echo "$INSTANCES_DIR/instance-$instance" -} - -get_port() { - local port_name=$1 - local instance=$2 - local base_port="${BASE_PORTS[$port_name]}" - - if [[ -z "$base_port" ]]; then - log_error "Unknown port: $port_name" - return 1 - fi - - # Port offset: instance N uses base_port + N*100 - echo $((base_port + instance * 100)) -} - -ensure_instance_dir() { - local instance=$1 - local inst_dir=$(get_instance_dir "$instance") - - if [ ! -d "$inst_dir" ]; then - mkdir -p "$inst_dir" - log_info "Created instance directory: $inst_dir" - fi -} - -copy_env_files() { - local instance=$1 - local inst_dir=$(get_instance_dir "$instance") - local root_env=".env" - - # Read KEY from repo root .env (first match). Prints empty string if missing. - get_root_env_value() { - local key=$1 - if [ ! -f "$root_env" ]; then - echo "" - return 0 - fi - # Keep everything after the first '=' (values can contain '=') - rg -m1 "^${key}=" "$root_env" 2>/dev/null | sed -E "s/^${key}=//" || true - } - - # Append KEY=VALUE to dest if KEY is not already present. - ensure_env_key() { - local dest=$1 - local key=$2 - local value=$3 - if [ -z "$value" ]; then - return 0 - fi - if rg -q "^${key}=" "$dest" 2>/dev/null; then - return 0 - fi - echo "${key}=${value}" >> "$dest" - } - - # Copy and modify .env files for this instance - for app_dir in backend worker frontend; do - local src_file="$app_dir/.env" - if [ -f "$src_file" ]; then - local dest="$inst_dir/${app_dir}.env" - cp "$src_file" "$dest" - - # Ensure each instance points at its own Postgres database. - # Backend uses quoted DATABASE_URL, worker typically does not. - if [ "$app_dir" = "backend" ] || [ "$app_dir" = "worker" ]; then - sed -i.bak -E \ - -e "s|(DATABASE_URL=.*\\/)(shipsec)(\"?)$|\\1shipsec_instance_${instance}\\3|" \ - "$dest" - fi - - # Ensure secrets/internal auth keys exist for dev processes. These live in repo root `.env`. - # Keep instance env self-contained so backend/worker don't need to load root `.env` (which - # contains a default DATABASE_URL and would break isolation). - if [ "$app_dir" = "backend" ] || [ "$app_dir" = "worker" ]; then - ensure_env_key "$dest" "INTERNAL_SERVICE_TOKEN" "$(get_root_env_value INTERNAL_SERVICE_TOKEN)" - ensure_env_key "$dest" "SECRET_STORE_MASTER_KEY" "$(get_root_env_value SECRET_STORE_MASTER_KEY)" - fi - - rm -f "$dest.bak" - log_success "Created $dest" - fi - done -} - -get_docker_compose_project_name() { - local instance=$1 - echo "shipsec-dev-$instance" -} - -validate_instance_setup() { - local instance=$1 - local inst_dir=$(get_instance_dir "$instance") - - # Check that all required env files exist - for env_file in backend worker frontend; do - if [ ! -f "$inst_dir/${env_file}.env" ]; then - log_error "Missing $inst_dir/${env_file}.env" - return 1 - fi - done - - log_success "Instance $instance configuration validated" - return 0 -} - -show_instance_info() { - local instance=$1 - - echo "" - echo -e "${BLUE}=== Instance $instance ===${NC}" - echo "Directory: $(get_instance_dir "$instance")" - echo "" - echo "Ports:" - echo " Frontend: http://localhost:$(get_port FRONTEND $instance)" - echo " Backend: http://localhost:$(get_port BACKEND $instance)" - echo " Temporal UI: http://localhost:8081" - echo "" - echo "Database: postgresql://shipsec:shipsec@localhost:5433/shipsec_instance_$instance" - echo "MinIO API: http://localhost:9000" - echo "MinIO UI: http://localhost:9001" - echo "Redis: redis://localhost:6379" - echo "" -} - -initialize_instance() { - local instance=$1 - - log_info "Initializing instance $instance..." - ensure_instance_dir "$instance" - copy_env_files "$instance" - - if validate_instance_setup "$instance"; then - show_instance_info "$instance" - log_success "Instance $instance initialized successfully" - return 0 - else - log_error "Instance $instance initialization failed" - return 1 - fi -} - -# Main command handler -main() { - local command=${1:-help} - local instance=${2:-0} - - case "$command" in - init) - initialize_instance "$instance" - ;; - info) - show_instance_info "$instance" - ;; - ports) - echo "FRONTEND=$(get_port FRONTEND $instance)" - echo "BACKEND=$(get_port BACKEND $instance)" - ;; - project-name) - get_docker_compose_project_name "$instance" - ;; - *) - log_error "Unknown command: $command" - echo "Usage: $0 {init|info|ports|project-name} [instance]" - exit 1 - ;; - esac -} - -main "$@" diff --git a/scripts/instance-bootstrap.sh b/scripts/instance-bootstrap.sh deleted file mode 100755 index 845c88ce..00000000 --- a/scripts/instance-bootstrap.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -# Bootstrap shared infra resources for a specific instance. -# - Ensure instance DB exists -# - Run migrations against that DB -# - Ensure Temporal namespace exists -# - Ensure Kafka topics exist (best-effort) -# -# Usage: ./scripts/instance-bootstrap.sh [instance_number] - -set -euo pipefail - -INSTANCE="${1:-0}" -INFRA_PROJECT_NAME="shipsec-infra" -DB_NAME="shipsec_instance_${INSTANCE}" -NAMESPACE="shipsec-dev-${INSTANCE}" -TEMPORAL_ADDRESS="127.0.0.1:7233" - -BLUE='\033[0;34m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -log_info() { echo -e "${BLUE}ℹ${NC} $*"; } -log_success() { echo -e "${GREEN}✅${NC} $*"; } -log_warn() { echo -e "${YELLOW}⚠️${NC} $*"; } -log_error() { echo -e "${RED}❌${NC} $*"; } - -POSTGRES_CONTAINER="$( - docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" ps -q postgres 2>/dev/null || true -)" - -if [ -z "$POSTGRES_CONTAINER" ]; then - log_error "Postgres container not found (infra project: $INFRA_PROJECT_NAME). Is infra running?" - exit 1 -fi - -log_info "Ensuring database exists: $DB_NAME" -docker exec "$POSTGRES_CONTAINER" \ - psql -v ON_ERROR_STOP=1 -U shipsec -d postgres </dev/null 2>&1; then - log_success "Migrations completed" -else - log_error "Migrations failed" - exit 1 -fi - -if ! command -v temporal >/dev/null 2>&1; then - log_info "temporal CLI not found; skipping Temporal namespace bootstrap" -else - log_info "Ensuring Temporal namespace exists: $NAMESPACE" - # Temporal can take a few seconds to accept CLI requests after the container is "Started". - for _ in {1..30}; do - if temporal operator namespace list --address "$TEMPORAL_ADDRESS" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - if temporal operator namespace describe --address "$TEMPORAL_ADDRESS" --namespace "$NAMESPACE" >/dev/null 2>&1; then - log_success "Temporal namespace exists" - else - # Create is not idempotent; it errors if the namespace already exists. Treat that as success. - if temporal operator namespace create --address "$TEMPORAL_ADDRESS" --namespace "$NAMESPACE" --retention 72h >/dev/null 2>&1; then - log_success "Temporal namespace created" - elif temporal operator namespace describe --address "$TEMPORAL_ADDRESS" --namespace "$NAMESPACE" >/dev/null 2>&1; then - log_success "Temporal namespace exists" - else - log_warn "Unable to ensure Temporal namespace (will likely break worker); continuing anyway" - fi - fi -fi - -# Best-effort Kafka topic creation in shared Redpanda. -REDPANDA_CONTAINER="$( - docker compose -f docker/docker-compose.infra.yml --project-name="$INFRA_PROJECT_NAME" ps -q redpanda 2>/dev/null || true -)" -if [ -n "$REDPANDA_CONTAINER" ]; then - log_info "Ensuring Kafka topics exist for instance $INSTANCE (best-effort)..." - for base in telemetry.logs telemetry.events telemetry.agent-trace telemetry.node-io; do - topic="${base}.instance-${INSTANCE}" - docker exec "$REDPANDA_CONTAINER" rpk topic create "$topic" --brokers redpanda:9092 >/dev/null 2>&1 || true - done - log_success "Kafka topics ensured" -fi From 29189580ce2ba4c284126313e0dcca100e13b10b Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 16:05:35 -0500 Subject: [PATCH 11/13] fix(test): mock AnalyticsModule in MCP integration test The AnalyticsModule's controller and services depend on ConfigService and OpenSearchClient which aren't available in the MCP test module. Use overrideModule to replace the entire AnalyticsModule with mocks. Also add explicit ConfigModule import to AnalyticsModule. Signed-off-by: Aseem Shrey --- backend/src/analytics/analytics.module.ts | 2 + .../mcp-internal.integration.spec.ts | 46 +++++++++++++++---- bun.lock | 39 ++++++++++------ 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index 33815f69..e77f2062 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { AnalyticsService } from './analytics.service'; import { SecurityAnalyticsService } from './security-analytics.service'; import { OrganizationSettingsService } from './organization-settings.service'; @@ -6,6 +7,7 @@ import { OpenSearchTenantService } from './opensearch-tenant.service'; import { AnalyticsController } from './analytics.controller'; @Module({ + imports: [ConfigModule], controllers: [AnalyticsController], providers: [ AnalyticsService, diff --git a/backend/src/mcp/__tests__/mcp-internal.integration.spec.ts b/backend/src/mcp/__tests__/mcp-internal.integration.spec.ts index 8540c6b5..7967479f 100644 --- a/backend/src/mcp/__tests__/mcp-internal.integration.spec.ts +++ b/backend/src/mcp/__tests__/mcp-internal.integration.spec.ts @@ -8,6 +8,10 @@ import { AuthService } from '../../auth/auth.service'; import { AuthGuard } from '../../auth/auth.guard'; import { ApiKeysService } from '../../api-keys/api-keys.service'; import { AnalyticsService } from '../../analytics/analytics.service'; +import { OpenSearchTenantService } from '../../analytics/opensearch-tenant.service'; +import { SecurityAnalyticsService } from '../../analytics/security-analytics.service'; +import { OrganizationSettingsService } from '../../analytics/organization-settings.service'; +import { AnalyticsModule } from '../../analytics/analytics.module'; import { AgentTraceIngestService } from '../../agent-trace/agent-trace-ingest.service'; import { EventIngestService } from '../../events/event-ingest.service'; import { LogIngestService } from '../../logging/log-ingest.service'; @@ -68,6 +72,39 @@ describe('MCP Internal API (Integration)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), McpModule], }) + .overrideModule(AnalyticsModule) + .useModule( + class MockAnalyticsModule { + static providers = [ + { + provide: AnalyticsService, + useValue: { + isEnabled: () => false, + track: () => {}, + trackWorkflowStarted: () => {}, + trackWorkflowCompleted: () => {}, + trackApiCall: () => {}, + trackComponentExecuted: () => {}, + }, + }, + { + provide: OpenSearchTenantService, + useValue: { provisionTenant: async () => true }, + }, + { + provide: SecurityAnalyticsService, + useValue: { indexDocument: async () => {}, bulkIndexDocuments: async () => {} }, + }, + { + provide: OrganizationSettingsService, + useValue: { + getOrganizationSettings: async () => ({}), + updateOrganizationSettings: async () => ({}), + }, + }, + ]; + }, + ) .overrideProvider(NodeIOIngestService) .useValue({ onModuleInit: async () => {}, @@ -96,15 +133,6 @@ describe('MCP Internal API (Integration)', () => { }) .overrideProvider(McpGatewayService) .useValue(mockGatewayService) - .overrideProvider(AnalyticsService) - .useValue({ - isEnabled: () => false, - track: () => {}, - trackWorkflowStarted: () => {}, - trackWorkflowCompleted: () => {}, - trackApiCall: () => {}, - trackComponentExecuted: () => {}, - }) .overrideProvider(AuthService) .useValue({ authenticate: async () => { diff --git a/bun.lock b/bun.lock index 4538ff20..368f5dfe 100644 --- a/bun.lock +++ b/bun.lock @@ -33,12 +33,15 @@ "@clerk/backend": "^2.29.5", "@clerk/types": "^4.101.13", "@grpc/grpc-js": "^1.14.3", + "@nest-lab/throttler-storage-redis": "^1.1.0", "@nestjs/common": "^10.4.22", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.22", "@nestjs/microservices": "^11.1.13", "@nestjs/platform-express": "^10.4.22", "@nestjs/swagger": "^11.2.5", + "@nestjs/throttler": "^6.5.0", + "@opensearch-project/opensearch": "^3.5.1", "@shipsec/backend-client": "workspace:*", "@shipsec/component-sdk": "workspace:*", "@shipsec/shared": "workspace:*", @@ -75,6 +78,7 @@ "@eslint/js": "^9.39.2", "@nestjs/testing": "^10.4.22", "@types/bcryptjs": "^3.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express-serve-static-core": "^4.19.8", "@types/har-format": "^1.2.16", "@types/multer": "^2.0.0", @@ -254,6 +258,7 @@ "@grpc/grpc-js": "^1.14.3", "@modelcontextprotocol/sdk": "^1.25.1", "@okta/okta-sdk-nodejs": "^7.3.0", + "@opensearch-project/opensearch": "^3.5.1", "@shipsec/component-sdk": "*", "@shipsec/contracts": "*", "@shipsec/shared": "*", @@ -641,6 +646,8 @@ "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@nest-lab/throttler-storage-redis": ["@nest-lab/throttler-storage-redis@1.2.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/throttler": ">=6.0.0", "ioredis": ">=5.0.0", "reflect-metadata": "^0.2.1" } }, "sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw=="], + "@nestjs/common": ["@nestjs/common@10.4.22", "", { "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw=="], "@nestjs/config": ["@nestjs/config@3.3.0", "", { "dependencies": { "dotenv": "16.4.5", "dotenv-expand": "10.0.0", "lodash": "4.17.21" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "rxjs": "^7.1.0" } }, "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA=="], @@ -657,6 +664,8 @@ "@nestjs/testing": ["@nestjs/testing@10.4.22", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0", "@nestjs/platform-express": "^10.0.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express"] }, "sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA=="], + "@nestjs/throttler": ["@nestjs/throttler@6.5.0", "", { "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "reflect-metadata": "^0.1.13 || ^0.2.0" } }, "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ=="], + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -669,6 +678,8 @@ "@okta/okta-sdk-nodejs": ["@okta/okta-sdk-nodejs@7.3.0", "", { "dependencies": { "@types/node-forge": "^1.3.1", "deep-copy": "^1.4.2", "eckles": "^1.4.1", "form-data": "^4.0.4", "https-proxy-agent": "^5.0.0", "js-yaml": "^4.1.0", "lodash": "^4.17.20", "njwt": "^2.0.1", "node-fetch": "^2.6.7", "node-jose": "^2.2.0", "parse-link-header": "^2.0.0", "rasha": "^1.2.5", "safe-flat": "^2.0.2", "url-parse": "^1.5.10", "uuid": "^11.1.0" } }, "sha512-6J3VV+8fBOqIXDqb3t2sBeXj1WOEZL6wP2AcGRzvMRMb2WL7JKR6ZDrt/1Kk7j4seXCKMpZrHsPYYdfRXwkSKQ=="], + "@opensearch-project/opensearch": ["@opensearch-project/opensearch@3.5.1", "", { "dependencies": { "aws4": "^1.11.0", "debug": "^4.3.1", "hpagent": "^1.2.0", "json11": "^2.0.0", "ms": "^2.1.3", "secure-json-parse": "^2.4.0" } }, "sha512-6bf+HcuERzAtHZxrm6phjref54ABse39BpkDie/YO3AUFMCBrb3SK5okKSdT5n3+nDRuEEQLhQCl0RQV3s1qpA=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -1085,6 +1096,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cookie-parser": ["@types/cookie-parser@1.4.10", "", { "peerDependencies": { "@types/express": "*" } }, "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg=="], + "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -1389,6 +1402,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -1949,6 +1964,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "hpagent": ["hpagent@1.2.0", "", {}, "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -2113,6 +2130,8 @@ "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json11": ["json11@2.0.2", "", { "bin": { "json11": "dist/cli.mjs" } }, "sha512-HIrd50UPYmP6sqLuLbFVm75g16o0oZrVfxrsY0EEys22klz8mRoWlX9KAEDOSOR9Q34rcxsyC8oDveGrCz5uLQ=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -2325,7 +2344,7 @@ "mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="], - "ms": ["ms@3.0.0-canary.1", "", {}, "sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "multer": ["multer@2.0.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw=="], @@ -2697,6 +2716,8 @@ "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -3227,6 +3248,8 @@ "@shipsec/studio-worker/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], + "@temporalio/common/ms": ["ms@3.0.0-canary.1", "", {}, "sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g=="], + "@temporalio/worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], @@ -3297,8 +3320,6 @@ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], - "debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "decamelize-keys/decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], "decamelize-keys/map-obj": ["map-obj@1.0.1", "", {}, "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg=="], @@ -3433,8 +3454,6 @@ "schema-utils/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], - "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "source-map-loader/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -3581,20 +3600,14 @@ "@pm2/agent/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@pm2/agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "@pm2/agent/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "@pm2/io/async/lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - "@pm2/io/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "@pm2/io/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "@pm2/js-api/async/lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - "@pm2/js-api/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@shipsec/component-sdk/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -3717,8 +3730,6 @@ "multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "needle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -3757,8 +3768,6 @@ "@nestjs/platform-express/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "@nestjs/platform-express/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "@nestjs/platform-express/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], "@nestjs/platform-express/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], From c6ea8aacc512c02771b67e406557d5fceb47e4dd Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 17:16:25 -0500 Subject: [PATCH 12/13] fix(infra): correct PM2 process names, Kafka port, and security init - Fix PM2 --only filter to use instance-suffixed names (shipsec-backend-0) - Fix Kafka broker port from 19092 to 9092 (matches single-listener Redpanda) - Add whitelist.yml required by securityadmin.sh alongside allowlist.yml Signed-off-by: Aseem Shrey --- docker/opensearch-security/whitelist.yml | 13 +++++++++++++ justfile | 8 ++++---- pm2.config.cjs | 4 ++-- 3 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 docker/opensearch-security/whitelist.yml diff --git a/docker/opensearch-security/whitelist.yml b/docker/opensearch-security/whitelist.yml new file mode 100644 index 00000000..cb55f2a9 --- /dev/null +++ b/docker/opensearch-security/whitelist.yml @@ -0,0 +1,13 @@ +# OpenSearch Security - API Whitelist (legacy name for allowlist) +# +# This file is required by securityadmin.sh even in OpenSearch 2.x. +# Actual configuration is in allowlist.yml. + +--- +_meta: + type: 'whitelist' + config_version: 2 + +config: + enabled: false + requests: {} diff --git a/justfile b/justfile index af23097f..37c64463 100644 --- a/justfile +++ b/justfile @@ -95,7 +95,7 @@ dev action="start": # Update git SHA and start PM2 with security enabled ./scripts/set-git-sha.sh || true SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=true NODE_TLS_REJECT_UNAUTHORIZED=0 \ - pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env + pm2 startOrReload pm2.config.cjs --only shipsec-frontend-0,shipsec-backend-0,shipsec-worker-0 --update-env echo "" echo "✅ Development environment ready (secure mode)" @@ -122,7 +122,7 @@ dev action="start": ./scripts/set-git-sha.sh || true SHIPSEC_ENV=development NODE_ENV=development OPENSEARCH_SECURITY_ENABLED=false \ OPENSEARCH_URL=http://localhost:9200 \ - pm2 startOrReload pm2.config.cjs --only shipsec-frontend,shipsec-backend,shipsec-worker --update-env + pm2 startOrReload pm2.config.cjs --only shipsec-frontend-0,shipsec-backend-0,shipsec-worker-0 --update-env echo "" echo "✅ Development environment ready (local auth)" @@ -147,7 +147,7 @@ dev action="start": ;; stop) echo "🛑 Stopping development environment..." - pm2 delete shipsec-frontend shipsec-backend shipsec-worker shipsec-test-worker 2>/dev/null || true + pm2 delete shipsec-frontend-0 shipsec-backend-0 shipsec-worker-0 shipsec-test-worker 2>/dev/null || true if [ "$SECURE_MODE" = "true" ]; then docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml down else @@ -168,7 +168,7 @@ dev action="start": ;; clean) echo "🧹 Cleaning development environment..." - pm2 delete shipsec-frontend shipsec-backend shipsec-worker shipsec-test-worker 2>/dev/null || true + pm2 delete shipsec-frontend-0 shipsec-backend-0 shipsec-worker-0 shipsec-test-worker 2>/dev/null || true if [ "$SECURE_MODE" = "true" ]; then docker compose -f docker/docker-compose.infra.yml -f docker/docker-compose.dev-secure.yml -f docker/docker-compose.dev-ports.yml down -v else diff --git a/pm2.config.cjs b/pm2.config.cjs index 235219c1..b06c78a8 100644 --- a/pm2.config.cjs +++ b/pm2.config.cjs @@ -299,7 +299,7 @@ module.exports = { // Ensure instance DB isolation even if dotenv auto-loads a workspace/default `.env`. ...devInstanceEnv, TERMINAL_REDIS_URL: process.env.TERMINAL_REDIS_URL || 'redis://localhost:6379', - LOG_KAFKA_BROKERS: process.env.LOG_KAFKA_BROKERS || 'localhost:19092', + LOG_KAFKA_BROKERS: process.env.LOG_KAFKA_BROKERS || 'localhost:9092', LOG_KAFKA_TOPIC: process.env.LOG_KAFKA_TOPIC || 'telemetry.logs', LOG_KAFKA_CLIENT_ID: process.env.LOG_KAFKA_CLIENT_ID || `shipsec-backend-${instanceNum}`, LOG_KAFKA_GROUP_ID: process.env.LOG_KAFKA_GROUP_ID || `shipsec-backend-log-consumer-${instanceNum}`, @@ -347,7 +347,7 @@ module.exports = { STUDIO_API_BASE_URL: process.env.STUDIO_API_BASE_URL || `http://localhost:${getInstancePort(3211, instanceNum)}/api/v1`, ...devInstanceEnv, TERMINAL_REDIS_URL: process.env.TERMINAL_REDIS_URL || 'redis://localhost:6379', - LOG_KAFKA_BROKERS: process.env.LOG_KAFKA_BROKERS || 'localhost:19092', + LOG_KAFKA_BROKERS: process.env.LOG_KAFKA_BROKERS || 'localhost:9092', LOG_KAFKA_TOPIC: process.env.LOG_KAFKA_TOPIC || 'telemetry.logs', LOG_KAFKA_CLIENT_ID: process.env.LOG_KAFKA_CLIENT_ID || `shipsec-worker-${instanceNum}`, EVENT_KAFKA_TOPIC: process.env.EVENT_KAFKA_TOPIC || 'telemetry.events', From ff8e459b61680f529b8e27d39802545c98777e66 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Fri, 6 Feb 2026 17:16:31 -0500 Subject: [PATCH 13/13] refactor(ui): move Analytics Settings under Manage sidebar section Signed-off-by: Aseem Shrey --- frontend/src/components/layout/AppLayout.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index df723111..564c4d25 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -294,11 +294,6 @@ export function AppLayout({ children }: AppLayoutProps) { href: '/artifacts', icon: Archive, }, - { - name: 'Analytics Settings', - href: '/analytics-settings', - icon: Settings, - }, ...(env.VITE_OPENSEARCH_DASHBOARDS_URL ? [ { @@ -327,6 +322,15 @@ export function AppLayout({ children }: AppLayoutProps) { href: '/mcp-library', icon: ServerCog, }, + ...(env.VITE_OPENSEARCH_DASHBOARDS_URL + ? [ + { + name: 'Analytics Settings', + href: '/analytics-settings', + icon: Settings, + }, + ] + : []), ]; const isActive = (path: string) => {