diff --git a/README.md b/README.md index 0c8600d0..d817b135 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,17 @@ Native support for industry-standard security tools including: - **Discovery**: `Subfinder`, `DNSX`, `Naabu`, `HTTPx` - **Vulnerability**: `Nuclei`, `TruffleHog` +- **CSPM**: `Prowler` for AWS cloud security posture management - **Utility**: `JSON Transform`, `Logic Scripts`, `HTTP Requests` +### Cloud & Platform Integrations + +First-class integration support with encrypted credential management: + +- **AWS**: IAM Role connections with STS AssumeRole, Organization account discovery, cross-account scanning +- **Slack**: Incoming Webhooks and OAuth App connections for notifications and alerts +- **Credential Resolution**: Workflow components that securely resolve connection credentials at runtime + ### Advanced Orchestration - **Human-in-the-Loop**: Pause workflows for approvals, form inputs, or manual validation before continuing. diff --git a/backend/.env.example b/backend/.env.example index e2c3f27b..a33f4711 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -68,3 +68,12 @@ REDIS_URL="" # Kafka / Redpanda configuration for node I/O, log, and event ingestion LOG_KAFKA_BROKERS="localhost:19092" + +# Extra CORS origins (comma-separated), e.g. for ngrok tunnels +# CORS_EXTRA_ORIGINS="https://your-ngrok-url.ngrok-free.app" + +# Slack OAuth integration (required for Slack app installation into customer workspaces) +SLACK_OAUTH_CLIENT_ID="" +SLACK_OAUTH_CLIENT_SECRET="" +# Override default scopes (comma-separated); defaults: channels:read,chat:write,chat:write.public,commands,im:write,team:read +# SLACK_OAUTH_SCOPES="" diff --git a/backend/README.md b/backend/README.md index 1b44f794..b6385498 100644 --- a/backend/README.md +++ b/backend/README.md @@ -62,9 +62,13 @@ bun run test #### Integrations Module -- **OAuth Provider**: Multi-provider OAuth orchestration -- **Token Vault**: Encrypted storage of access tokens -- **Connection Management**: OAuth lifecycle and refresh handling +- **Provider Catalog**: Static integration catalog (AWS, Slack) with auth methods, setup scenarios, and configuration +- **OAuth Provider**: Multi-provider OAuth orchestration (GitHub, Slack, Zoom) with PKCE support +- **Token Vault**: AES-256-GCM encrypted storage of access and refresh tokens +- **Connection Management**: Full lifecycle management -- create, validate, refresh, disconnect +- **AWS Service**: IAM Role connections with STS AssumeRole, Organization account discovery, trust policy generation +- **Slack Service**: Webhook connections with test messaging support +- **Credential Resolution**: Internal API for worker components to resolve typed credentials at runtime #### Logging & Events Module @@ -134,12 +138,27 @@ INTEGRATION_STORE_MASTER_KEY=your-32-character-integ-key!!!!! - `GET /api/v1/files/{id}/download` - Download file - `GET /api/v1/files/{id}/metadata` - Get file metadata -### Secrets & Integrations +### Secrets - `GET /api/v1/secrets` - List secrets - `POST /api/v1/secrets` - Create secret -- `GET /api/v1/integrations/providers` - List OAuth providers -- `POST /api/v1/integrations/{provider}/start` - Start OAuth flow + +### Integrations + +- `GET /integrations/catalog` - List integration provider catalog +- `GET /integrations/connections` - List user connections +- `GET /integrations/org/connections` - List organization connections +- `DELETE /integrations/connections/{id}` - Remove a connection +- `POST /integrations/connections/{id}/refresh` - Refresh connection tokens +- `GET /integrations/providers` - List OAuth providers +- `POST /integrations/{provider}/start` - Start OAuth flow +- `POST /integrations/{provider}/exchange` - Complete OAuth exchange +- `GET /integrations/aws/setup-info` - Get IAM trust policy and external ID +- `POST /integrations/aws/connections` - Create AWS IAM role connection +- `POST /integrations/aws/connections/{id}/validate` - Validate AWS connection +- `POST /integrations/aws/connections/{id}/discover-org` - Discover AWS Organization accounts +- `POST /integrations/slack/connections` - Create Slack webhook connection +- `POST /integrations/slack/connections/{id}/test` - Test Slack connection ## Project Structure diff --git a/backend/drizzle/0020_expand-integration-tokens.sql b/backend/drizzle/0020_expand-integration-tokens.sql new file mode 100644 index 00000000..a7c9f34c --- /dev/null +++ b/backend/drizzle/0020_expand-integration-tokens.sql @@ -0,0 +1,62 @@ +-- Step 1: Add new columns (all nullable initially for safe migration) +-- organization_id is varchar(255), NOT varchar(191), because the backfill +-- produces 'workspace-' || user_id (10 + up to 191 chars = 201), which +-- would overflow varchar(191). (R4 Finding #1) +-- This wider column is specific to integration_tokens — other tables use +-- varchar(191) for org IDs because they store real Clerk org IDs directly. +-- See D14 for rationale. +ALTER TABLE "integration_tokens" + ADD COLUMN IF NOT EXISTS "credential_type" varchar(32) NOT NULL DEFAULT 'oauth', + ADD COLUMN IF NOT EXISTS "display_name" varchar(191), + ADD COLUMN IF NOT EXISTS "organization_id" varchar(255), + ADD COLUMN IF NOT EXISTS "last_validated_at" timestamptz, + ADD COLUMN IF NOT EXISTS "last_validation_status" varchar(16), + ADD COLUMN IF NOT EXISTS "last_validation_error" text, + ADD COLUMN IF NOT EXISTS "last_used_at" timestamptz; + +-- Step 2: Backfill organization_id = 'workspace-' || user_id for ALL rows. +-- Why per-user workspace (not cross-table join): +-- - `workflows` table has NO `created_by` column (R5 Finding #1), so we can't +-- map user_id → org_id from existing DB data. +-- - Per-user workspace guarantees ZERO collisions (R5 Finding #2): each user +-- gets their own namespace, so (workspace-userA, slack, oauth, slack) and +-- (workspace-userB, slack, oauth, slack) never conflict — even if both users are +-- in the same real Clerk org. The unique index creation (Step 6) always succeeds. +-- - Clerk auth resolves personal workspace as 'workspace-' || userId +-- (backend/src/auth/providers/clerk-auth.provider.ts). +-- Tradeoff: local-dev tokens land in 'workspace-admin' instead of 'local-dev'. +-- Local dev users reconnect once after migration (acceptable for dev environments). +UPDATE "integration_tokens" + SET "organization_id" = 'workspace-' || "user_id" + WHERE "organization_id" IS NULL; + +-- Step 3: Backfill display_name = provider for existing OAuth rows +UPDATE "integration_tokens" + SET "display_name" = "provider" + WHERE "display_name" IS NULL; + +-- Step 4: Make both columns NOT NULL now that all rows are backfilled. +-- organization_id MUST be NOT NULL — PostgreSQL unique indexes treat NULLs as +-- distinct, so nullable org_id would bypass uniqueness entirely (R3 Finding #4). +ALTER TABLE "integration_tokens" ALTER COLUMN "organization_id" SET NOT NULL; +ALTER TABLE "integration_tokens" ALTER COLUMN "display_name" SET NOT NULL; + +-- Step 5: Drop old unique index on (user_id, provider) +DROP INDEX IF EXISTS "integration_tokens_user_provider_uidx"; + +-- Step 6: New unique index — org-scoped, includes credential_type (D15). +-- Prevents Slack OAuth/webhook collision: (org, slack, oauth, slack) and +-- (org, slack, webhook, slack) are distinct rows. +-- All columns are NOT NULL, so uniqueness is always enforced. +CREATE UNIQUE INDEX "integration_tokens_org_provider_type_name_uidx" + ON "integration_tokens" ("organization_id", "provider", "credential_type", "display_name"); + +-- Step 7: Add organization_id to OAuth state table (R5 Finding #3). +-- Without this, completeOAuthSession has no way to retrieve the org that was +-- active when the user initiated the OAuth flow. +ALTER TABLE "integration_oauth_states" + ADD COLUMN IF NOT EXISTS "organization_id" varchar(255); + +-- Step 8: Supporting indexes +CREATE INDEX IF NOT EXISTS "integration_tokens_org_idx" + ON "integration_tokens" ("organization_id"); diff --git a/backend/package.json b/backend/package.json index b609af47..dfb194a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,9 +15,12 @@ "migration:push": "bun x drizzle-kit push", "migration:smoke": "bun scripts/migration-smoke.ts", "delete:runs": "bun scripts/delete-all-workflow-runs.ts", - "setup:opensearch": "bun scripts/setup-opensearch.ts" + "setup:opensearch": "bun scripts/setup-opensearch.ts", + "seed:aws-workflow": "bun scripts/seed-aws-cspm-workflow.ts" }, "dependencies": { + "@aws-sdk/client-organizations": "^3.750.0", + "@aws-sdk/client-sts": "^3.750.0", "@clerk/backend": "^2.29.5", "@clerk/types": "^4.101.13", "@grpc/grpc-js": "^1.14.3", @@ -45,6 +48,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "cookie-parser": "^1.4.7", "date-fns": "^4.1.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", diff --git a/backend/scripts/seed-aws-cspm-workflow.ts b/backend/scripts/seed-aws-cspm-workflow.ts new file mode 100644 index 00000000..50cb952f --- /dev/null +++ b/backend/scripts/seed-aws-cspm-workflow.ts @@ -0,0 +1,61 @@ +/** + * Seed script: imports the AWS CSPM Org Discovery workflow via the backend API. + * + * Usage: + * bun backend/scripts/seed-aws-cspm-workflow.ts + * + * The backend must be running on BACKEND_URL (default http://localhost:3001). + * Set ADMIN_USERNAME / ADMIN_PASSWORD env vars if admin auth is required, + * or CLERK_SESSION_TOKEN for Clerk-based auth. + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:3001'; +const ADMIN_USER = process.env.ADMIN_USERNAME ?? 'admin'; +const ADMIN_PASS = process.env.ADMIN_PASSWORD ?? 'admin'; + +async function main() { + const workflowPath = resolve(import.meta.dir, '../../docs/sample/aws-cspm-org-discovery.json'); + + const workflowJson = JSON.parse(readFileSync(workflowPath, 'utf-8')); + console.log(`Importing workflow: ${workflowJson.name}`); + console.log(` Nodes: ${workflowJson.nodes.length}`); + console.log(` Edges: ${workflowJson.edges.length}`); + + // Build auth headers — try Clerk token first, then fall back to basic auth + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (process.env.CLERK_SESSION_TOKEN) { + headers['Authorization'] = `Bearer ${process.env.CLERK_SESSION_TOKEN}`; + } else { + headers['Authorization'] = `Basic ${btoa(`${ADMIN_USER}:${ADMIN_PASS}`)}`; + } + + const res = await fetch(`${BACKEND_URL}/api/v1/workflows`, { + method: 'POST', + headers, + body: JSON.stringify(workflowJson), + }); + + if (!res.ok) { + const body = await res.text(); + console.error(`Failed to create workflow (${res.status}): ${body}`); + process.exit(1); + } + + const created = await res.json(); + console.log(`\nWorkflow created successfully!`); + console.log(` ID: ${created.id}`); + console.log(` Name: ${created.name}`); + console.log(` Version: ${created.currentVersion}`); + console.log(`\nOpen in dashboard: http://localhost:5173/workflows/${created.id}`); +} + +main().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/backend/src/common/guards/internal-only.guard.ts b/backend/src/common/guards/internal-only.guard.ts new file mode 100644 index 00000000..fd7b10c6 --- /dev/null +++ b/backend/src/common/guards/internal-only.guard.ts @@ -0,0 +1,77 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, + SetMetadata, + UseGuards, + applyDecorators, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; + +const INTERNAL_ONLY_KEY = 'internal_only'; + +/** + * NestJS guard that restricts access to internal service calls only. + * + * Authentication is via the `X-Internal-Token` header matched against + * the `INTERNAL_SERVICE_TOKEN` environment variable. + * + * If `INTERNAL_SERVICE_TOKEN` is NOT set: + * - If `ALLOW_INSECURE_INTERNAL_ENDPOINTS=true` → allow with warning + * - Otherwise → reject with 403 + * + * Note: Internal endpoints have no per-org scoping. Workers authenticate + * via a shared service secret, not per-user/per-org credentials. Tenant + * isolation is enforced at the workflow layer (workflows are org-scoped). + */ +@Injectable() +export class InternalOnlyGuard implements CanActivate { + private readonly logger = new Logger(InternalOnlyGuard.name); + + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isInternalOnly = this.reflector.getAllAndOverride(INTERNAL_ONLY_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!isInternalOnly) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const providedToken = request.header('x-internal-token'); + const expectedToken = process.env.INTERNAL_SERVICE_TOKEN; + + if (!expectedToken) { + if (process.env.ALLOW_INSECURE_INTERNAL_ENDPOINTS === 'true') { + this.logger.warn( + `INTERNAL_SERVICE_TOKEN is not set. Allowing insecure access to ${request.method} ${request.path} because ALLOW_INSECURE_INTERNAL_ENDPOINTS=true.`, + ); + return true; + } + + throw new ForbiddenException( + 'INTERNAL_SERVICE_TOKEN must be configured or ALLOW_INSECURE_INTERNAL_ENDPOINTS=true must be set', + ); + } + + if (providedToken !== expectedToken) { + throw new ForbiddenException('Invalid internal access token'); + } + + return true; + } +} + +/** + * Decorator that restricts an endpoint to internal service calls only. + * Validates the `X-Internal-Token` header against `INTERNAL_SERVICE_TOKEN`. + */ +export function InternalOnly(): MethodDecorator & ClassDecorator { + return applyDecorators(SetMetadata(INTERNAL_ONLY_KEY, true), UseGuards(InternalOnlyGuard)); +} diff --git a/backend/src/components/utils/categorization.ts b/backend/src/components/utils/categorization.ts index f3081054..70b9d465 100644 --- a/backend/src/components/utils/categorization.ts +++ b/backend/src/components/utils/categorization.ts @@ -18,6 +18,9 @@ const SUPPORTED_CATEGORIES: readonly ComponentCategory[] = [ 'notification', 'manual_action', 'output', + 'process', + 'cloud', + 'core', ]; const COMPONENT_CATEGORY_CONFIG: Record = { @@ -84,6 +87,27 @@ const COMPONENT_CATEGORY_CONFIG: Record().notNull().default([]), accessToken: jsonb('access_token') .$type<{ @@ -34,15 +37,22 @@ export const integrationTokens = pgTable( .default(null), tokenType: varchar('token_type', { length: 32 }).default('Bearer'), expiresAt: timestamp('expires_at', { withTimezone: true }), + lastValidatedAt: timestamp('last_validated_at', { withTimezone: true }), + lastValidationStatus: varchar('last_validation_status', { length: 16 }), + lastValidationError: text('last_validation_error'), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), metadata: jsonb('metadata').$type>().default({}), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => ({ - userProviderIdx: index('integration_tokens_user_idx').on(table.userId), - userProviderUnique: uniqueIndex('integration_tokens_user_provider_uidx').on( - table.userId, + userIdx: index('integration_tokens_user_idx').on(table.userId), + orgIdx: index('integration_tokens_org_idx').on(table.organizationId), + orgProviderTypeNameUnique: uniqueIndex('integration_tokens_org_provider_type_name_uidx').on( + table.organizationId, table.provider, + table.credentialType, + table.displayName, ), }), ); @@ -54,6 +64,7 @@ export const integrationOAuthStates = pgTable( state: text('state').notNull(), userId: varchar('user_id', { length: 191 }).notNull(), provider: varchar('provider', { length: 64 }).notNull(), + organizationId: varchar('organization_id', { length: 255 }), codeVerifier: text('code_verifier'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, diff --git a/backend/src/integrations/aws.service.ts b/backend/src/integrations/aws.service.ts new file mode 100644 index 00000000..552bf918 --- /dev/null +++ b/backend/src/integrations/aws.service.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { STSClient, GetCallerIdentityCommand, AssumeRoleCommand } from '@aws-sdk/client-sts'; +import { OrganizationsClient, paginateListAccounts } from '@aws-sdk/client-organizations'; + +interface AwsCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + +interface OrgAccount { + id: string; + name: string; + status: string; + email: string; +} + +interface OrgAccountsResult { + accounts: OrgAccount[]; +} + +interface AssumedRoleCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; +} + +@Injectable() +export class AwsService implements OnModuleInit { + private readonly logger = new Logger(AwsService.name); + + // ── Startup self-check ── + async onModuleInit(): Promise { + const platformArn = process.env.SHIPSEC_PLATFORM_ROLE_ARN; + if (!platformArn) { + this.logger.warn('SHIPSEC_PLATFORM_ROLE_ARN not set — AWS IAM role integration disabled'); + return; + } + try { + const client = this.getPlatformStsClient(); + const identity = await client.send(new GetCallerIdentityCommand({})); + const callerArn = identity.Arn!; + const normalizedCaller = this.normalizeToRoleArn(callerArn); + if (normalizedCaller !== platformArn) { + const msg = + `Platform identity mismatch: SHIPSEC_PLATFORM_ROLE_ARN=${platformArn} ` + + `but actual caller is ${callerArn} (normalized: ${normalizedCaller})`; + if (process.env.SHIPSEC_AWS_STRICT_IDENTITY_CHECK === 'true') { + throw new Error(msg); + } + this.logger.warn( + msg + ' — continuing anyway (set SHIPSEC_AWS_STRICT_IDENTITY_CHECK=true to enforce)', + ); + } else { + this.logger.log(`Platform identity verified: ${platformArn}`); + } + } catch (error) { + this.logger.error('AWS platform identity verification failed', error); + throw error; // Prevent app from starting with invalid AWS creds + } + } + + // ── Platform STS client (env vars → default chain) ── + private getPlatformStsClient(region?: string): STSClient { + const keyId = process.env.SHIPSEC_AWS_ACCESS_KEY_ID; + const secret = process.env.SHIPSEC_AWS_SECRET_ACCESS_KEY; + if ((keyId && !secret) || (!keyId && secret)) { + throw new Error( + 'Both SHIPSEC_AWS_ACCESS_KEY_ID and SHIPSEC_AWS_SECRET_ACCESS_KEY must be set, or neither', + ); + } + const credentials = + keyId && secret + ? { + accessKeyId: keyId, + secretAccessKey: secret, + ...(process.env.SHIPSEC_AWS_SESSION_TOKEN && { + sessionToken: process.env.SHIPSEC_AWS_SESSION_TOKEN, + }), + } + : undefined; // SDK default chain + return new STSClient({ ...(credentials && { credentials }), ...(region && { region }) }); + } + + // ── Platform role ARN (from env var) ── + getPlatformRoleArn(): string { + const arn = process.env.SHIPSEC_PLATFORM_ROLE_ARN; + if (!arn) throw new Error('SHIPSEC_PLATFORM_ROLE_ARN is required'); + return arn; + } + + // ── Assume customer role via platform identity (with duration retry) ── + async assumeRoleWithPlatformIdentity( + roleArn: string, + externalId?: string, + region?: string, + ): Promise { + const configured = parseInt(process.env.SHIPSEC_AWS_STS_DURATION_SECONDS || '3600', 10); + const durations = [...new Set([Math.min(configured, 43200), 3600, 900])]; + let lastError: Error | undefined; + for (const duration of durations) { + try { + const client = this.getPlatformStsClient(region); + const response = await client.send( + new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: `shipsec-${Date.now()}`, + DurationSeconds: duration, + ...(externalId && { ExternalId: externalId }), + }), + ); + if (!response.Credentials) throw new Error('No credentials returned'); + return { + accessKeyId: response.Credentials.AccessKeyId!, + secretAccessKey: response.Credentials.SecretAccessKey!, + sessionToken: response.Credentials.SessionToken!, + }; + } catch (error: any) { + lastError = error; + if (error.name === 'ValidationError' && error.message?.includes('DurationSeconds')) { + this.logger.warn(`STS duration=${duration}s too high for ${roleArn}, retrying lower`); + continue; + } + throw error; + } + } + throw lastError ?? new Error('All STS duration attempts failed'); + } + + // ── Normalize assumed-role ARN to role ARN ── + private normalizeToRoleArn(arn: string): string { + // arn:aws:sts::123456789012:assumed-role/RoleName/session + // → arn:aws:iam::123456789012:role/RoleName + const match = arn.match(/^arn:aws:sts::(\d{12}):assumed-role\/([^/]+)/); + if (match) return `arn:aws:iam::${match[1]}:role/${match[2]}`; + return arn; // Already a role/user ARN + } + + // ── Discovers AWS Organization accounts ── + async discoverOrgAccounts(credentials: AwsCredentials): Promise { + try { + const orgClient = new OrganizationsClient({ + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + ...(credentials.sessionToken && { + sessionToken: credentials.sessionToken, + }), + }, + }); + + const accounts: OrgAccount[] = []; + + const paginator = paginateListAccounts({ client: orgClient }, {}); + + for await (const page of paginator) { + if (page.Accounts) { + for (const account of page.Accounts) { + accounts.push({ + id: account.Id || '', + name: account.Name || '', + status: account.Status || '', + email: account.Email || '', + }); + } + } + } + + this.logger.log(`Successfully discovered ${accounts.length} organization accounts`); + + return { accounts }; + } catch (error) { + this.logger.error('Failed to discover organization accounts', error); + throw new Error((error as Error).message || 'Failed to discover organization accounts'); + } + } +} diff --git a/backend/src/integrations/external-id-generator.ts b/backend/src/integrations/external-id-generator.ts new file mode 100644 index 00000000..2c7fa238 --- /dev/null +++ b/backend/src/integrations/external-id-generator.ts @@ -0,0 +1,109 @@ +import { randomUUID } from 'crypto'; + +const ADJECTIVES = [ + 'cosmic', + 'crystal', + 'golden', + 'silver', + 'blazing', + 'frozen', + 'midnight', + 'stellar', + 'radiant', + 'velvet', + 'emerald', + 'crimson', + 'silent', + 'rapid', + 'ancient', + 'bright', + 'calm', + 'deep', + 'fierce', + 'gentle', + 'hollow', + 'iron', + 'jade', + 'keen', + 'lunar', + 'mystic', + 'noble', + 'omega', + 'primal', + 'quiet', + 'royal', + 'swift', + 'tidal', + 'ultra', + 'vivid', + 'wild', + 'zen', + 'amber', + 'bold', + 'cedar', + 'dawn', + 'echo', + 'flint', + 'granite', + 'harbor', + 'ivory', +]; + +const NOUNS = [ + 'falcon', + 'phoenix', + 'summit', + 'cascade', + 'nebula', + 'voyager', + 'cipher', + 'atlas', + 'beacon', + 'comet', + 'delta', + 'ember', + 'forge', + 'glacier', + 'helix', + 'iris', + 'jaguar', + 'kestrel', + 'lancer', + 'meteor', + 'nexus', + 'orbit', + 'pulse', + 'quasar', + 'raven', + 'sentry', + 'titan', + 'umbra', + 'vertex', + 'wraith', + 'zenith', + 'anchor', + 'breeze', + 'coral', + 'drift', + 'flare', + 'grove', + 'haven', + 'inlet', + 'karma', + 'lotus', + 'marsh', + 'oasis', + 'peak', +]; + +export function generateExternalId(): string { + return randomUUID(); +} + +export function formatExternalIdForDisplay(externalId: string): string { + const hex = externalId.replace(/-/g, ''); + const adjIdx = parseInt(hex.slice(0, 2), 16) % ADJECTIVES.length; + const nounIdx = parseInt(hex.slice(2, 4), 16) % NOUNS.length; + const shortId = hex.slice(0, 8); + return `${ADJECTIVES[adjIdx]}-${NOUNS[nounIdx]}-${shortId}`; +} diff --git a/backend/src/integrations/integration-catalog.ts b/backend/src/integrations/integration-catalog.ts new file mode 100644 index 00000000..fe63ebc8 --- /dev/null +++ b/backend/src/integrations/integration-catalog.ts @@ -0,0 +1,175 @@ +import type { IntegrationProviderConfig } from './integration-providers'; + +export interface AuthMethodField { + id: string; + label: string; + type: 'text' | 'password' | 'select'; + required: boolean; + placeholder?: string; + helpText?: string; + options?: { label: string; value: string }[]; +} + +export interface AuthMethod { + type: string; + label: string; + description: string; + fields: AuthMethodField[]; +} + +export interface SetupInstructionSection { + title: string; + authMethodType: string; + scenario: string; + steps: string[]; +} + +export interface IntegrationProviderDefinition { + id: string; + name: string; + description: string; + docsUrl?: string; + iconUrl?: string; + authMethods: AuthMethod[]; + supportsMultipleConnections: boolean; + setupInstructions: { sections: SetupInstructionSection[] }; + oauthConfig?: IntegrationProviderConfig; +} + +export const AWS_PROVIDER: IntegrationProviderDefinition = { + id: 'aws', + name: 'Amazon Web Services', + description: + 'Connect AWS accounts for cloud security posture management (CSPM), compliance scanning, and resource discovery.', + docsUrl: + 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html', + iconUrl: '/icons/aws.png', + supportsMultipleConnections: true, + authMethods: [ + { + type: 'iam_role', + label: 'IAM Role', + description: + 'Create an IAM role in your AWS account (IAM → Roles → Create role → "Custom trust policy") that allows the ShipSec platform to assume it. No customer secrets are stored.', + fields: [ + { + id: 'roleArn', + label: 'Role ARN', + type: 'text', + required: true, + placeholder: 'arn:aws:iam::123456789012:role/ShipSecAuditRole', + }, + { + id: 'region', + label: 'Default Region', + type: 'text', + required: false, + placeholder: 'us-east-1', + helpText: 'Default AWS region for API calls. Can be overridden per workflow.', + }, + ], + }, + ], + setupInstructions: { + sections: [ + { + title: 'Single Account', + authMethodType: 'iam_role', + scenario: 'single-account', + steps: [ + 'Click "Add Connection" to generate a trust policy with a unique External ID.', + 'In the target AWS account, create an IAM role with `SecurityAudit` and `ViewOnlyAccess` policies.', + 'Set the trust policy to the JSON shown in the setup dialog (includes the ShipSec platform ARN and External ID).', + 'Copy the role ARN and paste it into the form, then click "Create Connection".', + ], + }, + { + title: 'Cross-Account', + authMethodType: 'iam_role', + scenario: 'cross-account', + steps: [ + 'Click "Add Connection" to generate a trust policy with a unique External ID.', + 'In each target account, create an IAM role with `SecurityAudit` and `ViewOnlyAccess` policies.', + 'Use the same trust policy JSON from the setup dialog for each role.', + 'Add one connection per target account using its role ARN.', + ], + }, + { + title: 'Organizations', + authMethodType: 'iam_role', + scenario: 'organizations', + steps: [ + 'Click "Add Connection" for your management account and apply the trust policy shown.', + 'Ensure the management role has `SecurityAudit`, `ViewOnlyAccess`, and `OrganizationsReadOnlyAccess` policies.', + 'Use "Discover Accounts" to list all member accounts.', + 'For each member account, create a role with the same trust policy and add a connection.', + ], + }, + ], + }, +}; + +export const SLACK_PROVIDER: IntegrationProviderDefinition = { + id: 'slack', + name: 'Slack', + description: 'Send workflow notifications, security alerts, and scan results to Slack channels.', + docsUrl: 'https://api.slack.com/messaging/webhooks', + iconUrl: '/icons/slack.svg', + supportsMultipleConnections: true, + authMethods: [ + { + type: 'webhook', + label: 'Incoming Webhook', + description: + 'Simple webhook URL for sending messages to a specific channel. No OAuth required.', + fields: [ + { + id: 'webhookUrl', + label: 'Webhook URL', + type: 'password', + required: true, + placeholder: 'https://hooks.slack.com/services/T.../B.../...', + helpText: 'Create an incoming webhook at https://api.slack.com/apps', + }, + ], + }, + { + type: 'oauth', + label: 'Slack App (OAuth)', + description: + 'Full Slack app with OAuth for sending messages, slash commands, and DMs. Requires a Slack app with channels:read, chat:write, chat:write.public, commands, and im:write scopes.', + fields: [], + }, + ], + setupInstructions: { + sections: [ + { + title: 'Incoming Webhook', + authMethodType: 'webhook', + scenario: 'webhook', + steps: [ + 'Go to https://api.slack.com/apps and create a new app (or select an existing one).', + 'Navigate to "Incoming Webhooks" and toggle it on.', + 'Click "Add New Webhook to Workspace" and select the target channel.', + 'Copy the generated webhook URL and paste it below.', + ], + }, + { + title: 'Slack App (OAuth)', + authMethodType: 'oauth', + scenario: 'oauth', + steps: [ + 'Go to https://api.slack.com/apps and create a new app.', + 'Under "OAuth & Permissions", add the bot token scopes: channels:read, chat:write, chat:write.public, commands, im:write.', + 'Set the redirect URL to the one shown below.', + 'Copy the Client ID and Client Secret into the provider configuration.', + 'Click "Add to Slack" to complete the OAuth flow.', + ], + }, + ], + }, +}; + +export function getCatalog(): IntegrationProviderDefinition[] { + return [AWS_PROVIDER, SLACK_PROVIDER]; +} diff --git a/backend/src/integrations/integration-providers.ts b/backend/src/integrations/integration-providers.ts index 1102dc68..af56baa7 100644 --- a/backend/src/integrations/integration-providers.ts +++ b/backend/src/integrations/integration-providers.ts @@ -41,6 +41,17 @@ export function loadIntegrationProviders(): Record scope.trim()) .filter(Boolean) ?? ['user:read:admin']; + const slackScopes = process.env.SLACK_OAUTH_SCOPES?.split(',') + .map((scope) => scope.trim()) + .filter(Boolean) ?? [ + 'channels:read', + 'chat:write', + 'chat:write.public', + 'commands', + 'im:write', + 'team:read', + ]; + return { github: { id: 'github', @@ -82,6 +93,22 @@ export function loadIntegrationProviders(): Record { + if (queryUserId) { + this.logger.warn( + 'Deprecated: userId query parameter is ignored. User is derived from auth context.', + ); + } + + const userId = auth?.userId; if (!userId) { - throw new BadRequestException('userId is required'); + throw new BadRequestException('Authentication required'); } const connections = await this.integrations.listConnections(userId); - return connections.map((connection) => ({ - ...connection, - expiresAt: connection.expiresAt ? connection.expiresAt.toISOString() : null, - createdAt: connection.createdAt.toISOString(), - updatedAt: connection.updatedAt.toISOString(), - })); + return connections.map((c) => this.toConnectionResponse(c)); + } + + /* ------------------------------------------------------------------ */ + /* Org-scoped connection listing (D6, D17) */ + /* ------------------------------------------------------------------ */ + + @Get('org/connections') + @ApiOkResponse({ type: [IntegrationConnectionResponse] }) + async listOrgConnections( + @CurrentAuth() auth: AuthContext | null, + @Query('provider') provider?: string, + ): Promise { + if (!auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); + } + + const connections = await this.integrations.listConnectionsForOrg(auth, provider); + return connections.map((c) => this.toConnectionResponse(c)); } + /* ------------------------------------------------------------------ */ + /* OAuth flow (D16: userId from auth context) */ + /* ------------------------------------------------------------------ */ + @Post(':provider/start') @ApiOkResponse({ type: OAuthStartResponseDto }) async startOAuth( @Param('provider') provider: string, + @CurrentAuth() auth: AuthContext | null, @Body() body: StartOAuthDto, ): Promise { + if (!auth?.userId) { + throw new BadRequestException('Authentication required'); + } + + const organizationId = auth.organizationId ?? `workspace-${auth.userId}`; + const response = await this.integrations.startOAuthSession(provider, { - userId: body.userId, + userId: auth.userId, + organizationId, redirectUri: body.redirectUri, scopes: body.scopes, }); @@ -122,60 +184,66 @@ export class IntegrationsController { }; } + @Public() @Post(':provider/exchange') @ApiOkResponse({ type: IntegrationConnectionResponse }) async completeOAuth( @Param('provider') provider: string, @Body() body: CompleteOAuthDto, ): Promise { + // The exchange endpoint is @Public because the OAuth callback may arrive on a + // different origin (e.g. ngrok) where the Clerk session cookie is unavailable. + // Security is enforced by the one-time state token created during startOAuth, + // which binds the exchange to a specific userId and provider. const connection = await this.integrations.completeOAuthSession(provider, { - userId: body.userId, code: body.code, state: body.state, redirectUri: body.redirectUri, scopes: body.scopes, }); - return { - ...connection, - expiresAt: connection.expiresAt ? connection.expiresAt.toISOString() : null, - createdAt: connection.createdAt.toISOString(), - updatedAt: connection.updatedAt.toISOString(), - }; + return this.toConnectionResponse(connection); } + /* ------------------------------------------------------------------ */ + /* Refresh & disconnect (D16 + D18) */ + /* ------------------------------------------------------------------ */ + @Post('connections/:id/refresh') @ApiOkResponse({ type: IntegrationConnectionResponse }) async refreshConnection( @Param('id') id: string, - @Body() body: RefreshConnectionDto, + @CurrentAuth() auth: AuthContext | null, ): Promise { - const refreshed = await this.integrations.refreshConnection(id, body.userId); - return { - ...refreshed, - expiresAt: refreshed.expiresAt ? refreshed.expiresAt.toISOString() : null, - createdAt: refreshed.createdAt.toISOString(), - updatedAt: refreshed.updatedAt.toISOString(), - }; + if (!auth?.userId) { + throw new BadRequestException('Authentication required'); + } + + const refreshed = await this.integrations.refreshConnection(id, auth); + return this.toConnectionResponse(refreshed); } @Delete('connections/:id') @ApiOkResponse({ description: 'Connection removed' }) async disconnectConnection( @Param('id') id: string, - @Body() body: DisconnectConnectionDto, + @CurrentAuth() auth: AuthContext | null, ): Promise { - await this.integrations.disconnect(id, body.userId); + if (!auth?.userId) { + throw new BadRequestException('Authentication required'); + } + + await this.integrations.disconnect(id, auth); } + /* ------------------------------------------------------------------ */ + /* Internal token endpoint (D4: @InternalOnly replaces inline check) */ + /* ------------------------------------------------------------------ */ + @Post('connections/:id/token') + @InternalOnly() @ApiOkResponse({ type: ConnectionTokenResponseDto }) - async issueConnectionToken( - @Param('id') id: string, - @Headers('x-internal-token') internalToken?: string, - ): Promise { - this.assertInternalAccess(internalToken); - + async issueConnectionToken(@Param('id') id: string): Promise { const token = await this.integrations.getConnectionToken(id); return { provider: token.provider, @@ -187,14 +255,172 @@ export class IntegrationsController { }; } - private assertInternalAccess(token?: string): void { - const expected = process.env.INTERNAL_SERVICE_TOKEN; - if (!expected) { - return; + /* ------------------------------------------------------------------ */ + /* Credential resolution — internal only (D3, D4) */ + /* ------------------------------------------------------------------ */ + + @Post('connections/:id/credentials') + @InternalOnly() + @ApiOkResponse({ type: ConnectionCredentialsResponseDto }) + async resolveCredentials(@Param('id') id: string): Promise { + const result = await this.integrations.resolveConnectionCredentials(id); + return { + credentialType: result.credentialType, + provider: result.provider, + data: result.data, + accountId: result.accountId, + region: result.region, + displayName: result.displayName, + }; + } + + /* ------------------------------------------------------------------ */ + /* AWS setup info & connections */ + /* ------------------------------------------------------------------ */ + + @Get('aws/setup-info') + @ApiOkResponse({ type: AwsSetupInfoResponseDto }) + async getAwsSetupInfo(@CurrentAuth() auth: AuthContext | null): Promise { + if (!auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); + } + return this.integrations.getAwsSetupInfo(auth.organizationId); + } + + @Post('aws/connections') + @ApiOkResponse({ type: IntegrationConnectionResponse }) + async createAwsConnection( + @CurrentAuth() auth: AuthContext | null, + @Body() body: CreateAwsConnectionDto, + ): Promise { + if (!auth?.userId || !auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); + } + + const connection = await this.integrations.createAwsConnection(auth, body); + return this.toConnectionResponse(connection); + } + + @Post('aws/connections/:id/validate') + @ApiOkResponse({ type: ValidateAwsResponseDto }) + async validateAwsConnection( + @Param('id') id: string, + @CurrentAuth() auth: AuthContext | null, + ): Promise { + if (!auth?.userId || !auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); } - if (token !== expected) { - throw new UnauthorizedException('Invalid internal access token'); + await this.integrations.assertConnectionOwnership(id, auth); + const result = await this.integrations.validateConnection(id); + + return { + valid: result.valid, + error: result.error, + }; + } + + @Post('aws/connections/:id/discover-org') + @ApiOkResponse({ type: DiscoverOrgAccountsResponseDto }) + async discoverOrgAccounts( + @Param('id') id: string, + @CurrentAuth() auth: AuthContext | null, + ): Promise { + if (!auth?.userId || !auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); } + + await this.integrations.assertConnectionOwnership(id, auth); + const result = await this.integrations.discoverOrgAccounts(id); + + return { + accounts: result.accounts.map((a) => ({ + id: a.id, + name: a.name, + status: a.status, + email: a.email, + })), + }; + } + + /* ------------------------------------------------------------------ */ + /* Slack connections (chunk 8) */ + /* ------------------------------------------------------------------ */ + + @Post('slack/connections') + @ApiOkResponse({ type: IntegrationConnectionResponse }) + async createSlackWebhookConnection( + @CurrentAuth() auth: AuthContext | null, + @Body() body: CreateSlackWebhookConnectionDto, + ): Promise { + if (!auth?.userId || !auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); + } + + const connection = await this.integrations.createSlackWebhookConnection(auth, body); + return this.toConnectionResponse(connection); + } + + @Post('slack/connections/:id/test') + @ApiOkResponse({ description: 'Test result for Slack connection' }) + async testSlackConnection( + @Param('id') id: string, + @CurrentAuth() auth: AuthContext | null, + ): Promise<{ ok: boolean; error?: string }> { + if (!auth?.userId || !auth?.organizationId) { + throw new BadRequestException('Authentication with organization context required'); + } + + await this.integrations.assertConnectionOwnership(id, auth); + const result = await this.integrations.validateConnection(id); + return { ok: result.valid, error: result.error }; + } + + /* ------------------------------------------------------------------ */ + /* Private helpers */ + /* ------------------------------------------------------------------ */ + + private toConnectionResponse(connection: { + id: string; + provider: string; + providerName: string; + userId: string; + credentialType: string; + displayName: string; + organizationId: string; + scopes: string[]; + tokenType: string; + expiresAt: Date | null; + lastValidatedAt: Date | null; + lastValidationStatus: string | null; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; + status: 'active' | 'expired'; + supportsRefresh: boolean; + hasRefreshToken: boolean; + metadata: Record; + }): IntegrationConnectionResponse { + return { + id: connection.id, + provider: connection.provider, + providerName: connection.providerName, + userId: connection.userId, + credentialType: connection.credentialType, + displayName: connection.displayName, + organizationId: connection.organizationId, + scopes: connection.scopes, + tokenType: connection.tokenType, + expiresAt: connection.expiresAt ? connection.expiresAt.toISOString() : null, + lastValidatedAt: connection.lastValidatedAt ? connection.lastValidatedAt.toISOString() : null, + lastValidationStatus: connection.lastValidationStatus ?? null, + lastUsedAt: connection.lastUsedAt ? connection.lastUsedAt.toISOString() : null, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + status: connection.status, + supportsRefresh: connection.supportsRefresh, + hasRefreshToken: connection.hasRefreshToken, + metadata: connection.metadata, + }; } } diff --git a/backend/src/integrations/integrations.dto.ts b/backend/src/integrations/integrations.dto.ts index 8a6ee425..9c573ca8 100644 --- a/backend/src/integrations/integrations.dto.ts +++ b/backend/src/integrations/integrations.dto.ts @@ -1,12 +1,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsOptional, IsString, IsUrl, MinLength } from 'class-validator'; +import { IsArray, IsOptional, IsString, IsUrl, Matches, MinLength } from 'class-validator'; -export class StartOAuthDto { - @ApiProperty({ description: 'Application user identifier to associate the connection with' }) - @IsString() - @MinLength(1) - userId!: string; +// --- OAuth DTOs (D16: userId removed, derived from auth context) --- +export class StartOAuthDto { @ApiProperty({ description: 'Frontend callback URL that receives the OAuth code' }) @IsString() @IsUrl() @@ -31,19 +28,12 @@ export class CompleteOAuthDto extends StartOAuthDto { code!: string; } -export class RefreshConnectionDto { - @ApiProperty({ description: 'Application user identifier that owns the connection' }) - @IsString() - @MinLength(1) - userId!: string; -} +// D16: userId removed from these DTOs — now derived from @CurrentAuth() +export class RefreshConnectionDto {} -export class DisconnectConnectionDto { - @ApiProperty({ description: 'Application user identifier that owns the connection' }) - @IsString() - @MinLength(1) - userId!: string; -} +export class DisconnectConnectionDto {} + +// --- Provider config DTOs --- export class UpsertProviderConfigDto { @ApiProperty({ description: 'OAuth client identifier used for this provider' }) @@ -60,6 +50,70 @@ export class UpsertProviderConfigDto { clientSecret?: string; } +// --- AWS connection DTOs --- + +export class CreateAwsConnectionDto { + @ApiProperty({ description: 'Display name for this connection' }) + @IsString() + @MinLength(1) + displayName!: string; + + @ApiProperty({ description: 'IAM role ARN for ShipSec to assume' }) + @IsString() + @Matches(/^arn:aws:iam::\d{12}:role\/.+$/, { + message: 'roleArn must be a valid IAM role ARN (arn:aws:iam:::role/)', + }) + roleArn!: string; + + @ApiPropertyOptional({ description: 'Default AWS region' }) + @IsOptional() + @IsString() + region?: string; + + @ApiProperty({ description: 'External ID from setup-info endpoint' }) + @IsString() + @MinLength(1) + externalId!: string; + + @ApiProperty({ description: 'Signed setup token from setup-info endpoint' }) + @IsString() + @MinLength(1) + setupToken!: string; +} + +export class AwsSetupInfoResponseDto { + @ApiProperty() + platformRoleArn!: string; + + @ApiProperty() + externalId!: string; + + @ApiProperty() + setupToken!: string; + + @ApiProperty() + trustPolicyTemplate!: string; + + @ApiPropertyOptional() + externalIdDisplay?: string; +} + +// --- Slack connection DTOs --- + +export class CreateSlackWebhookConnectionDto { + @ApiProperty({ description: 'Display name for this webhook connection' }) + @IsString() + @MinLength(1) + displayName!: string; + + @ApiProperty({ description: 'Slack incoming webhook URL' }) + @IsString() + @IsUrl() + webhookUrl!: string; +} + +// --- Response DTOs --- + export class ProviderConfigurationResponse { @ApiProperty() provider!: string; @@ -132,6 +186,15 @@ export class IntegrationConnectionResponse { @ApiProperty() userId!: string; + @ApiProperty() + credentialType!: string; + + @ApiProperty() + displayName!: string; + + @ApiPropertyOptional() + organizationId?: string; + @ApiProperty({ type: [String] }) scopes!: string[]; @@ -141,6 +204,15 @@ export class IntegrationConnectionResponse { @ApiPropertyOptional() expiresAt?: string | null; + @ApiPropertyOptional() + lastValidatedAt?: string | null; + + @ApiPropertyOptional() + lastValidationStatus?: string | null; + + @ApiPropertyOptional() + lastUsedAt?: string | null; + @ApiProperty() createdAt!: string; @@ -179,3 +251,58 @@ export class ConnectionTokenResponseDto { @ApiPropertyOptional() expiresAt?: string | null; } + +export class ValidateAwsResponseDto { + @ApiProperty() + valid!: boolean; + + @ApiPropertyOptional() + accountId?: string; + + @ApiPropertyOptional() + arn?: string; + + @ApiPropertyOptional() + error?: string; +} + +export class OrgAccountDto { + @ApiProperty() + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty() + status!: string; + + @ApiPropertyOptional() + email?: string; +} + +export class DiscoverOrgAccountsResponseDto { + @ApiProperty({ type: [OrgAccountDto] }) + accounts!: OrgAccountDto[]; +} + +export class ConnectionCredentialsResponseDto { + @ApiProperty({ description: 'Credential type discriminator' }) + credentialType!: string; + + @ApiProperty() + provider!: string; + + @ApiProperty({ description: 'Type-specific credential data' }) + data!: Record; + + @ApiPropertyOptional({ + description: 'Provider account identifier (e.g. AWS 12-digit account ID)', + }) + accountId?: string; + + @ApiPropertyOptional({ description: 'Default region from the connection' }) + region?: string; + + @ApiPropertyOptional({ description: 'Display name of the connection' }) + displayName?: string; +} diff --git a/backend/src/integrations/integrations.module.ts b/backend/src/integrations/integrations.module.ts index e9650554..cfa1a61b 100644 --- a/backend/src/integrations/integrations.module.ts +++ b/backend/src/integrations/integrations.module.ts @@ -1,15 +1,25 @@ import { Module } from '@nestjs/common'; import { DatabaseModule } from '../database/database.module'; +import { AwsService } from './aws.service'; import { IntegrationsController } from './integrations.controller'; import { IntegrationsRepository } from './integrations.repository'; import { IntegrationsService } from './integrations.service'; +import { SetupTokenService } from './setup-token.service'; +import { SlackService } from './slack.service'; import { TokenEncryptionService } from './token.encryption'; @Module({ imports: [DatabaseModule], controllers: [IntegrationsController], - providers: [IntegrationsService, IntegrationsRepository, TokenEncryptionService], + providers: [ + IntegrationsService, + IntegrationsRepository, + TokenEncryptionService, + AwsService, + SlackService, + SetupTokenService, + ], exports: [IntegrationsService], }) export class IntegrationsModule {} diff --git a/backend/src/integrations/integrations.repository.ts b/backend/src/integrations/integrations.repository.ts index abf05657..b85fea6c 100644 --- a/backend/src/integrations/integrations.repository.ts +++ b/backend/src/integrations/integrations.repository.ts @@ -16,6 +16,9 @@ import { interface UpsertIntegrationTokenInput { userId: string; provider: string; + organizationId: string; + credentialType: string; + displayName: string; scopes: string[]; accessToken: SecretEncryptionMaterial; refreshToken: SecretEncryptionMaterial | null; @@ -24,6 +27,23 @@ interface UpsertIntegrationTokenInput { metadata?: Record; } +interface InsertConnectionInput { + userId: string; + provider: string; + organizationId: string; + credentialType: string; + displayName: string; + scopes?: string[]; + accessToken: SecretEncryptionMaterial; + refreshToken?: SecretEncryptionMaterial | null; + tokenType?: string; + expiresAt?: Date | null; + lastValidatedAt?: Date | null; + lastValidationStatus?: string | null; + lastValidationError?: string | null; + metadata?: Record; +} + @Injectable() export class IntegrationsRepository { constructor( @@ -48,7 +68,7 @@ export class IntegrationsRepository { return record; } - async findByProvider( + async findByUserAndProvider( userId: string, provider: string, ): Promise { @@ -60,10 +80,45 @@ export class IntegrationsRepository { return record; } + async findByOrgAndProvider( + organizationId: string, + provider: string, + ): Promise { + const [record] = await this.db + .select() + .from(integrationTokens) + .where( + and( + eq(integrationTokens.organizationId, organizationId), + eq(integrationTokens.provider, provider), + ), + ) + .limit(1); + return record; + } + + async listConnectionsByOrg( + organizationId: string, + provider?: string, + ): Promise { + const conditions = [eq(integrationTokens.organizationId, organizationId)]; + if (provider) { + conditions.push(eq(integrationTokens.provider, provider)); + } + return await this.db + .select() + .from(integrationTokens) + .where(and(...conditions)) + .orderBy(integrationTokens.provider); + } + async upsertConnection(input: UpsertIntegrationTokenInput): Promise { const payload = { userId: input.userId, provider: input.provider, + organizationId: input.organizationId, + credentialType: input.credentialType, + displayName: input.displayName, scopes: input.scopes, accessToken: input.accessToken, refreshToken: input.refreshToken, @@ -80,7 +135,12 @@ export class IntegrationsRepository { createdAt: new Date(), }) .onConflictDoUpdate({ - target: [integrationTokens.userId, integrationTokens.provider], + target: [ + integrationTokens.organizationId, + integrationTokens.provider, + integrationTokens.credentialType, + integrationTokens.displayName, + ], set: payload, }) .returning(); @@ -88,10 +148,61 @@ export class IntegrationsRepository { return record; } - async deleteConnection(id: string, userId: string): Promise { - await this.db - .delete(integrationTokens) - .where(and(eq(integrationTokens.id, id), eq(integrationTokens.userId, userId))); + /** + * Insert a new connection (no upsert). For non-OAuth connection types. + * On unique constraint violation, catches the error and returns the existing connection (D12). + */ + async insertConnection(input: InsertConnectionInput): Promise { + try { + const [record] = await this.db + .insert(integrationTokens) + .values({ + userId: input.userId, + provider: input.provider, + organizationId: input.organizationId, + credentialType: input.credentialType, + displayName: input.displayName, + scopes: input.scopes ?? [], + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenType: input.tokenType ?? 'Bearer', + expiresAt: input.expiresAt ?? null, + lastValidatedAt: input.lastValidatedAt ?? null, + lastValidationStatus: input.lastValidationStatus ?? null, + lastValidationError: input.lastValidationError ?? null, + metadata: input.metadata ?? {}, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + return record; + } catch (error: any) { + // Unique constraint violation — return existing connection (D12 natural idempotency) + if (error?.code === '23505') { + const existing = await this.db + .select() + .from(integrationTokens) + .where( + and( + eq(integrationTokens.organizationId, input.organizationId), + eq(integrationTokens.provider, input.provider), + eq(integrationTokens.credentialType, input.credentialType), + eq(integrationTokens.displayName, input.displayName), + ), + ) + .limit(1); + + if (existing[0]) { + return existing[0]; + } + } + throw error; + } + } + + async deleteConnection(id: string): Promise { + await this.db.delete(integrationTokens).where(eq(integrationTokens.id, id)); } async deleteByProvider(userId: string, provider: string): Promise { @@ -100,10 +211,39 @@ export class IntegrationsRepository { .where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, provider))); } + async updateConnectionHealth( + id: string, + health: { + lastValidatedAt: Date; + lastValidationStatus: string; + lastValidationError?: string | null; + metadata?: Record; + }, + ): Promise { + const setClause: Record = { + lastValidatedAt: health.lastValidatedAt, + lastValidationStatus: health.lastValidationStatus, + lastValidationError: health.lastValidationError ?? null, + updatedAt: new Date(), + }; + if (health.metadata) { + setClause.metadata = health.metadata; + } + await this.db.update(integrationTokens).set(setClause).where(eq(integrationTokens.id, id)); + } + + async updateLastUsedAt(id: string): Promise { + await this.db + .update(integrationTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(integrationTokens.id, id)); + } + async createOAuthState(payload: { state: string; userId: string; provider: string; + organizationId?: string | null; codeVerifier?: string | null; }): Promise { const [record] = await this.db @@ -112,6 +252,7 @@ export class IntegrationsRepository { state: payload.state, userId: payload.userId, provider: payload.provider, + organizationId: payload.organizationId ?? null, codeVerifier: payload.codeVerifier ?? null, }) .onConflictDoUpdate({ @@ -119,6 +260,7 @@ export class IntegrationsRepository { set: { userId: payload.userId, provider: payload.provider, + organizationId: payload.organizationId ?? null, codeVerifier: payload.codeVerifier ?? null, createdAt: new Date(), }, diff --git a/backend/src/integrations/integrations.service.ts b/backend/src/integrations/integrations.service.ts index 5b5dcc37..be1000e9 100644 --- a/backend/src/integrations/integrations.service.ts +++ b/backend/src/integrations/integrations.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, Logger, NotFoundException, @@ -15,9 +16,16 @@ import { loadIntegrationProviders, summarizeProvider, } from './integration-providers'; +import { getCatalog, type IntegrationProviderDefinition } from './integration-catalog'; import { IntegrationsRepository } from './integrations.repository'; import { TokenEncryptionService } from './token.encryption'; +import { AwsService } from './aws.service'; +import { SlackService } from './slack.service'; +import { SetupTokenService } from './setup-token.service'; +import { generateExternalId, formatExternalIdForDisplay } from './external-id-generator'; +import type { CreateAwsConnectionDto } from './integrations.dto'; import type { IntegrationTokenRecord } from '../database/schema'; +import type { AuthContext } from '../auth/types'; export interface OAuthStartResponse { provider: string; @@ -31,9 +39,15 @@ export interface IntegrationConnection { provider: string; providerName: string; userId: string; + credentialType: string; + displayName: string; + organizationId: string; scopes: string[]; tokenType: string; expiresAt: Date | null; + lastValidatedAt: Date | null; + lastValidationStatus: string | null; + lastUsedAt: Date | null; createdAt: Date; updatedAt: Date; status: 'active' | 'expired'; @@ -84,6 +98,9 @@ export class IntegrationsService implements OnModuleInit { constructor( private readonly repository: IntegrationsRepository, private readonly encryption: TokenEncryptionService, + private readonly awsService: AwsService, + private readonly slackService: SlackService, + private readonly setupTokenService: SetupTokenService, ) { this.providers = loadIntegrationProviders(); } @@ -92,6 +109,18 @@ export class IntegrationsService implements OnModuleInit { await this.reloadProviderOverrides(); } + /* ------------------------------------------------------------------ */ + /* Catalog */ + /* ------------------------------------------------------------------ */ + + getCatalog(): IntegrationProviderDefinition[] { + return getCatalog(); + } + + /* ------------------------------------------------------------------ */ + /* Provider listing & configuration */ + /* ------------------------------------------------------------------ */ + listProviders(): IntegrationProviderSummary[] { return Object.values(this.providers).map((config) => summarizeProvider(this.mergeProviderConfig(config)), @@ -208,14 +237,30 @@ export class IntegrationsService implements OnModuleInit { this.providerOverrides.delete(providerId); } + /* ------------------------------------------------------------------ */ + /* Connection listing */ + /* ------------------------------------------------------------------ */ + async listConnections(userId: string): Promise { const records = await this.repository.listConnections(userId); return records.map((record) => this.toConnection(record)); } + async listConnectionsForOrg( + auth: AuthContext, + provider?: string, + ): Promise { + const records = await this.repository.listConnectionsByOrg(auth.organizationId!, provider); + return records.map((record) => this.toConnection(record)); + } + + /* ------------------------------------------------------------------ */ + /* OAuth flow */ + /* ------------------------------------------------------------------ */ + async startOAuthSession( providerId: string, - input: { userId: string; redirectUri: string; scopes?: string[] }, + input: { userId: string; organizationId: string; redirectUri: string; scopes?: string[] }, ): Promise { const provider = await this.resolveProviderForAuth(providerId); @@ -249,6 +294,7 @@ export class IntegrationsService implements OnModuleInit { state, userId: input.userId, provider: providerId, + organizationId: input.organizationId, codeVerifier, }); @@ -264,28 +310,32 @@ export class IntegrationsService implements OnModuleInit { async completeOAuthSession( providerId: string, input: { - userId: string; state: string; code: string; redirectUri: string; scopes?: string[]; }, ): Promise { + this.logger.log( + `[completeOAuth] Starting exchange for provider=${providerId}, redirectUri=${input.redirectUri}`, + ); const provider = await this.resolveProviderForAuth(providerId); const stateRecord = await this.repository.consumeOAuthState(input.state); if (!stateRecord) { throw new BadRequestException('OAuth state is missing or has already been used'); } - if (stateRecord.userId !== input.userId) { - throw new BadRequestException('OAuth state does not match the requesting user'); - } if (stateRecord.provider !== providerId) { throw new BadRequestException('OAuth state does not match the provider'); } + // userId derived from the state record (created during startOAuth with auth context). + // The exchange endpoint is @Public() — the one-time state token is the proof of identity. + const userId = stateRecord.userId; + const organizationId = stateRecord.organizationId ?? `workspace-${userId}`; const scopes = this.normalizeScopes(input.scopes, provider); + this.logger.log(`[completeOAuth] Requesting tokens from ${provider.tokenUrl}`); const rawResponse = await this.requestTokens(provider, { grantType: 'authorization_code', code: input.code, @@ -294,33 +344,62 @@ export class IntegrationsService implements OnModuleInit { scopes, }); + // For Slack, extract workspace name from the token response for a better display name + const displayName = rawResponse?.team?.name ?? rawResponse?.team_name ?? providerId; + + // For Slack, fetch the workspace icon via team.info (best-effort, non-blocking) + if (providerId === 'slack' && rawResponse?.access_token) { + try { + const teamInfo = await this.slackService.getTeamInfo(rawResponse.access_token); + if (teamInfo.ok && teamInfo.icon) { + rawResponse._teamIcon = teamInfo.icon; + } + } catch { + // Non-critical — icon will simply be absent + } + } + const persisted = await this.persistTokenResponse({ - userId: input.userId, + userId, + organizationId, + credentialType: 'oauth', + displayName, provider, scopes, rawResponse, - previous: await this.repository.findByProvider(input.userId, providerId), + previous: await this.repository.findByUserAndProvider(userId, providerId), }); + this.logger.log(`[completeOAuth] Connection persisted: ${persisted.id}`); return this.toConnection(persisted); } - async refreshConnection(id: string, userId: string): Promise { - const record = await this.repository.findById(id); - if (!record || record.userId !== userId) { - throw new NotFoundException(`Connection ${id} was not found for user ${userId}`); - } + /* ------------------------------------------------------------------ */ + /* Refresh & disconnect */ + /* ------------------------------------------------------------------ */ + async refreshConnection(id: string, auth: AuthContext): Promise { + const record = await this.assertConnectionOwnership(id, auth); const refreshed = await this.refreshTokenRecord(record); return this.toConnection(refreshed); } - async disconnect(id: string, userId: string): Promise { - await this.repository.deleteConnection(id, userId); + async disconnect(id: string, auth: AuthContext): Promise { + await this.assertConnectionOwnership(id, auth); + + if (!auth.roles.includes('ADMIN')) { + throw new ForbiddenException('Only admins can disconnect integrations'); + } + + await this.repository.deleteConnection(id); } + /* ------------------------------------------------------------------ */ + /* Token retrieval */ + /* ------------------------------------------------------------------ */ + async getProviderToken(providerId: string, userId: string): Promise { - const record = await this.repository.findByProvider(userId, providerId); + const record = await this.repository.findByUserAndProvider(userId, providerId); if (!record) { throw new NotFoundException(`No credentials found for provider ${providerId}`); } @@ -365,6 +444,348 @@ export class IntegrationsService implements OnModuleInit { }; } + /* ------------------------------------------------------------------ */ + /* AWS connection */ + /* ------------------------------------------------------------------ */ + + getAwsSetupInfo(orgId: string): { + platformRoleArn: string; + externalId: string; + setupToken: string; + trustPolicyTemplate: string; + externalIdDisplay: string; + } { + let platformRoleArn: string; + try { + platformRoleArn = this.awsService.getPlatformRoleArn(); + } catch { + throw new BadRequestException( + 'AWS integration is not configured. Set the SHIPSEC_PLATFORM_ROLE_ARN environment variable and restart the backend.', + ); + } + const externalId = generateExternalId(); + const setupToken = this.setupTokenService.generate(orgId, externalId); + const trustPolicyTemplate = JSON.stringify( + { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: platformRoleArn }, + Action: 'sts:AssumeRole', + Condition: { StringEquals: { 'sts:ExternalId': externalId } }, + }, + ], + }, + null, + 2, + ); + return { + platformRoleArn, + externalId, + setupToken, + trustPolicyTemplate, + externalIdDisplay: formatExternalIdForDisplay(externalId), + }; + } + + async createAwsConnection( + auth: AuthContext, + input: CreateAwsConnectionDto, + ): Promise { + const orgId = auth.organizationId!; + const userId = auth.userId!; + + // Verify setup token — cryptographically binds externalId to this org + this.setupTokenService.verify(input.setupToken, orgId, input.externalId); + + // Validate: can platform identity assume this role with this externalId? + try { + await this.awsService.assumeRoleWithPlatformIdentity( + input.roleArn, + input.externalId, + input.region, + ); + } catch (error: any) { + const platformArn = this.awsService.getPlatformRoleArn(); + throw new BadRequestException( + `Cannot assume role ${input.roleArn}. Ensure the trust policy includes: ` + + `Principal {"AWS": "${platformArn}"} and Condition sts:ExternalId: "${input.externalId}". ` + + `Error: ${error.message}`, + ); + } + + // Store: roleArn + externalId + region only — no customer secrets + const encryptedCreds = await this.encryption.encrypt( + JSON.stringify({ + roleArn: input.roleArn, + externalId: input.externalId, + region: input.region, + }), + ); + + const record = await this.repository.insertConnection({ + userId, + provider: 'aws', + organizationId: orgId, + credentialType: 'iam_role', + displayName: input.displayName, + scopes: [], + accessToken: encryptedCreds, + refreshToken: null, + tokenType: 'Bearer', + expiresAt: null, + lastValidatedAt: new Date(), + lastValidationStatus: 'valid', + metadata: { + roleArn: input.roleArn, + externalId: input.externalId, + region: input.region ?? null, + }, + }); + return this.toConnection(record); + } + + /* ------------------------------------------------------------------ */ + /* Slack webhook connection */ + /* ------------------------------------------------------------------ */ + + async createSlackWebhookConnection( + auth: AuthContext, + input: { displayName: string; webhookUrl: string }, + ): Promise { + const userId = auth.userId!; + const organizationId = auth.organizationId!; + + // Encrypt the webhook URL as a JSON payload + const encryptedCreds = await this.encryption.encrypt( + JSON.stringify({ webhookUrl: input.webhookUrl }), + ); + + const record = await this.repository.insertConnection({ + userId, + provider: 'slack', + organizationId, + credentialType: 'webhook', + displayName: input.displayName, + scopes: [], + accessToken: encryptedCreds, + refreshToken: null, + tokenType: 'Bearer', + expiresAt: null, + metadata: {}, + }); + + return this.toConnection(record); + } + + /* ------------------------------------------------------------------ */ + /* Connection validation */ + /* ------------------------------------------------------------------ */ + + async validateConnection(connectionId: string): Promise<{ valid: boolean; error?: string }> { + const record = await this.repository.findById(connectionId); + if (!record) { + throw new NotFoundException(`Connection ${connectionId} was not found`); + } + + const decrypted = await this.encryption.decrypt(record.accessToken as SecretEncryptionMaterial); + + let result: { valid: boolean; error?: string }; + + if (record.provider === 'aws' && record.credentialType === 'iam_role') { + const creds = JSON.parse(decrypted); + try { + await this.awsService.assumeRoleWithPlatformIdentity( + creds.roleArn, + creds.externalId, + creds.region, + ); + result = { valid: true }; + } catch (error: any) { + result = { valid: false, error: error.message }; + } + } else if (record.provider === 'slack' && record.credentialType === 'webhook') { + const creds = JSON.parse(decrypted); + const webhookResult = await this.slackService.testWebhook(creds.webhookUrl); + result = { valid: webhookResult.ok, error: webhookResult.error }; + } else if (record.provider === 'slack' && record.credentialType === 'oauth') { + // Validate the OAuth bot token by calling Slack's auth.test API + const authResult = await this.slackService.authTest(decrypted); + result = { valid: authResult.ok, error: authResult.error }; + + // Best-effort: enrich metadata with workspace icon if missing + if (authResult.ok) { + try { + const existingMeta = this.coerceMetadata(record.metadata); + const payload = (existingMeta.providerPayload ?? {}) as Record; + if (!payload._teamIcon) { + const teamInfo = await this.slackService.getTeamInfo(decrypted); + if (teamInfo.ok && teamInfo.icon) { + payload._teamIcon = teamInfo.icon; + const updatedMeta = { ...existingMeta, providerPayload: payload }; + await this.repository.updateConnectionHealth(connectionId, { + lastValidatedAt: new Date(), + lastValidationStatus: 'valid', + metadata: updatedMeta, + }); + return result; + } + } + } catch { + // Non-critical — icon enrichment failure doesn't affect validation + } + } + } else { + // For other types, we cannot generically validate; treat as valid. + result = { valid: true }; + } + + // Update health fields + await this.repository.updateConnectionHealth(connectionId, { + lastValidatedAt: new Date(), + lastValidationStatus: result.valid ? 'valid' : 'invalid', + lastValidationError: result.error ?? null, + }); + + return result; + } + + /* ------------------------------------------------------------------ */ + /* AWS Org discovery */ + /* ------------------------------------------------------------------ */ + + async discoverOrgAccounts( + connectionId: string, + ): Promise<{ accounts: { id: string; name: string; status: string; email: string }[] }> { + const record = await this.repository.findById(connectionId); + if (!record) { + throw new NotFoundException(`Connection ${connectionId} was not found`); + } + + const decrypted = await this.encryption.decrypt(record.accessToken as SecretEncryptionMaterial); + const creds = JSON.parse(decrypted); + + const assumed = await this.awsService.assumeRoleWithPlatformIdentity( + creds.roleArn, + creds.externalId, + creds.region, + ); + return this.awsService.discoverOrgAccounts({ + accessKeyId: assumed.accessKeyId, + secretAccessKey: assumed.secretAccessKey, + sessionToken: assumed.sessionToken, + }); + } + + /* ------------------------------------------------------------------ */ + /* Credential resolution (for worker / internal use) */ + /* ------------------------------------------------------------------ */ + + async resolveConnectionCredentials(connectionId: string): Promise<{ + credentialType: string; + provider: string; + data: Record; + accountId?: string; + region?: string; + displayName?: string; + }> { + const record = await this.repository.findById(connectionId); + if (!record) { + throw new NotFoundException(`Connection ${connectionId} was not found`); + } + + const decrypted = await this.encryption.decrypt(record.accessToken as SecretEncryptionMaterial); + + let data: Record; + let accountId: string | undefined; + let region: string | undefined; + + switch (record.credentialType) { + case 'oauth': { + // Existing OAuth flow: decrypt + optional refresh, return access token + const provider = this.providers[record.provider]; + let hydratedRecord = record; + if (provider) { + hydratedRecord = await this.ensureFreshToken(record, provider); + } + const accessToken = await this.encryption.decrypt( + hydratedRecord.accessToken as SecretEncryptionMaterial, + ); + data = { + accessToken, + tokenType: hydratedRecord.tokenType ?? 'Bearer', + }; + break; + } + case 'iam_role': { + const creds = JSON.parse(decrypted); + const assumed = await this.awsService.assumeRoleWithPlatformIdentity( + creds.roleArn, + creds.externalId, + creds.region, + ); + data = { + accessKeyId: assumed.accessKeyId, + secretAccessKey: assumed.secretAccessKey, + sessionToken: assumed.sessionToken, + region: creds.region, + }; + // Extract account ID from role ARN (arn:aws:iam:::role/...) + const arnMatch = + typeof creds.roleArn === 'string' ? creds.roleArn.match(/^arn:aws:iam::(\d{12}):/) : null; + accountId = arnMatch?.[1]; + region = creds.region ?? undefined; + break; + } + case 'webhook': { + const creds = JSON.parse(decrypted); + data = { + webhookUrl: creds.webhookUrl, + }; + break; + } + default: + throw new BadRequestException(`Unsupported credential type: ${record.credentialType}`); + } + + // Update lastUsedAt + await this.repository.updateLastUsedAt(connectionId); + + return { + credentialType: record.credentialType, + provider: record.provider, + data, + accountId, + region, + displayName: record.displayName ?? undefined, + }; + } + + /* ------------------------------------------------------------------ */ + /* Ownership assertion */ + /* ------------------------------------------------------------------ */ + + async assertConnectionOwnership( + connectionId: string, + auth: AuthContext, + ): Promise { + const record = await this.repository.findById(connectionId); + if (!record) { + throw new NotFoundException(`Connection ${connectionId} was not found`); + } + + if (record.organizationId !== auth.organizationId) { + throw new ForbiddenException('You do not have access to this connection'); + } + + return record; + } + + /* ------------------------------------------------------------------ */ + /* Private helpers: scopes */ + /* ------------------------------------------------------------------ */ + private cleanScopes(scopes: string[]): string[] { return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))).sort(); } @@ -384,6 +805,10 @@ export class IntegrationsService implements OnModuleInit { return this.cleanScopes(source); } + /* ------------------------------------------------------------------ */ + /* Private helpers: provider resolution */ + /* ------------------------------------------------------------------ */ + private async resolveProviderForAuth(providerId: string): Promise { const base = this.requireProvider(providerId); const override = this.providerOverrides.get(providerId); @@ -413,23 +838,34 @@ export class IntegrationsService implements OnModuleInit { return provider; } + /* ------------------------------------------------------------------ */ + /* Private helpers: record ↔ connection mapping */ + /* ------------------------------------------------------------------ */ + private toConnection(record: IntegrationTokenRecord): IntegrationConnection { - const provider = this.requireProvider(record.provider); + const provider = this.providers[record.provider]; + const providerName = provider?.name ?? record.provider; const expiresAt = record.expiresAt ? new Date(record.expiresAt) : null; const isExpired = expiresAt ? expiresAt.getTime() < Date.now() : false; return { id: record.id, provider: record.provider, - providerName: provider.name, + providerName, userId: record.userId, + credentialType: record.credentialType ?? 'oauth', + displayName: record.displayName ?? record.provider, + organizationId: record.organizationId, scopes: record.scopes ?? [], tokenType: record.tokenType ?? 'Bearer', expiresAt, + lastValidatedAt: record.lastValidatedAt ? new Date(record.lastValidatedAt) : null, + lastValidationStatus: record.lastValidationStatus ?? null, + lastUsedAt: record.lastUsedAt ? new Date(record.lastUsedAt) : null, createdAt: new Date(record.createdAt), updatedAt: new Date(record.updatedAt), status: isExpired ? 'expired' : 'active', - supportsRefresh: provider.supportsRefresh, + supportsRefresh: provider?.supportsRefresh ?? false, hasRefreshToken: Boolean(record.refreshToken), metadata: this.coerceMetadata(record.metadata), }; @@ -442,6 +878,10 @@ export class IntegrationsService implements OnModuleInit { return metadata as Record; } + /* ------------------------------------------------------------------ */ + /* Private helpers: PKCE */ + /* ------------------------------------------------------------------ */ + private generateCodeVerifier(): string { return randomBytes(32).toString('base64url'); } @@ -450,6 +890,10 @@ export class IntegrationsService implements OnModuleInit { return createHash('sha256').update(verifier).digest('base64url'); } + /* ------------------------------------------------------------------ */ + /* Private helpers: token exchange */ + /* ------------------------------------------------------------------ */ + private async requestTokens( provider: IntegrationProviderConfig, options: TokenRequestOptions, @@ -539,8 +983,15 @@ export class IntegrationsService implements OnModuleInit { return payload; } + /* ------------------------------------------------------------------ */ + /* Private helpers: persist & refresh token records */ + /* ------------------------------------------------------------------ */ + private async persistTokenResponse(input: { userId: string; + organizationId: string; + credentialType: string; + displayName: string; provider: IntegrationProviderConfig; scopes: string[]; rawResponse: Record; @@ -571,6 +1022,9 @@ export class IntegrationsService implements OnModuleInit { return this.repository.upsertConnection({ userId: input.userId, + organizationId: input.organizationId, + credentialType: input.credentialType, + displayName: input.displayName, provider: input.provider.id, scopes: grantedScopes, accessToken: accessMaterial, @@ -629,6 +1083,9 @@ export class IntegrationsService implements OnModuleInit { return this.repository.upsertConnection({ userId: record.userId, + organizationId: record.organizationId, + credentialType: record.credentialType ?? 'oauth', + displayName: record.displayName ?? record.provider, provider: record.provider, scopes: grantedScopes, accessToken: accessMaterial, @@ -639,6 +1096,10 @@ export class IntegrationsService implements OnModuleInit { }); } + /* ------------------------------------------------------------------ */ + /* Private helpers: token extraction & expiry */ + /* ------------------------------------------------------------------ */ + private extractToken(value: unknown, field: string): string { if (typeof value === 'string' && value.trim().length > 0) { return value; diff --git a/backend/src/integrations/setup-token.service.ts b/backend/src/integrations/setup-token.service.ts new file mode 100644 index 00000000..8b7f0282 --- /dev/null +++ b/backend/src/integrations/setup-token.service.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { createHmac } from 'crypto'; + +interface SetupTokenPayload { + orgId: string; + externalId: string; + exp: number; // Unix timestamp +} + +const SETUP_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes +const DEFAULT_DEV_SIGNING_KEY = 'fedcba9876543210fedcba9876543210'; + +@Injectable() +export class SetupTokenService { + private readonly logger = new Logger(SetupTokenService.name); + private readonly signingKey: string; + + constructor() { + const key = + process.env.INTEGRATION_STORE_MASTER_KEY ?? + process.env.SECRET_STORE_MASTER_KEY ?? + DEFAULT_DEV_SIGNING_KEY; + + if (!process.env.INTEGRATION_STORE_MASTER_KEY && !process.env.SECRET_STORE_MASTER_KEY) { + this.logger.warn( + 'INTEGRATION_STORE_MASTER_KEY is not configured. Using insecure dev key for setup tokens.', + ); + } + + this.signingKey = key; + } + + generate(orgId: string, externalId: string): string { + const payload: SetupTokenPayload = { + orgId, + externalId, + exp: Date.now() + SETUP_TOKEN_TTL_MS, + }; + const data = JSON.stringify(payload); + const sig = createHmac('sha256', this.signingKey).update(data).digest('hex'); + // base64url(payload.sig) + return Buffer.from(`${data}.${sig}`).toString('base64url'); + } + + verify(token: string, orgId: string, externalId: string): void { + let decoded: string; + try { + decoded = Buffer.from(token, 'base64url').toString('utf8'); + } catch { + throw new BadRequestException('Invalid setup token'); + } + + const dotIdx = decoded.lastIndexOf('.'); + if (dotIdx === -1) throw new BadRequestException('Invalid setup token format'); + + const data = decoded.slice(0, dotIdx); + const sig = decoded.slice(dotIdx + 1); + + const expectedSig = createHmac('sha256', this.signingKey).update(data).digest('hex'); + if (sig !== expectedSig) { + throw new BadRequestException('Setup token signature invalid'); + } + + const payload: SetupTokenPayload = JSON.parse(data); + if (Date.now() > payload.exp) { + throw new BadRequestException('Setup token expired — please restart the connection setup'); + } + if (payload.orgId !== orgId) { + throw new BadRequestException('Setup token organization mismatch'); + } + if (payload.externalId !== externalId) { + throw new BadRequestException('External ID does not match setup token'); + } + } +} diff --git a/backend/src/integrations/slack.service.ts b/backend/src/integrations/slack.service.ts new file mode 100644 index 00000000..3b0ca30b --- /dev/null +++ b/backend/src/integrations/slack.service.ts @@ -0,0 +1,191 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class SlackService { + private readonly logger = new Logger(SlackService.name); + + /** + * Test a Slack webhook URL by sending a test message + */ + async testWebhook(webhookUrl: string): Promise<{ ok: boolean; error?: string }> { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: 'ShipSec test message — your Slack webhook is configured correctly.', + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + this.logger.error(`Webhook test failed: ${response.status} ${errorText}`); + return { + ok: false, + error: `Webhook returned status ${response.status}: ${errorText}`, + }; + } + + return { ok: true }; + } catch (error) { + this.logger.error('Network error testing webhook:', error); + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown network error', + }; + } + } + + /** + * Test a Slack bot token via auth.test. Returns workspace info. + */ + async authTest( + botToken: string, + ): Promise<{ ok: boolean; error?: string; teamName?: string; teamId?: string; url?: string }> { + try { + const response = await fetch('https://slack.com/api/auth.test', { + method: 'POST', + headers: { + Authorization: `Bearer ${botToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data: any = await response.json(); + + if (!data.ok) { + this.logger.error(`Slack auth.test failed: ${data.error}`); + return { ok: false, error: data.error || 'auth.test failed' }; + } + + return { + ok: true, + teamName: data.team, + teamId: data.team_id, + url: data.url, + }; + } catch (error) { + this.logger.error('Network error calling auth.test:', error); + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown network error', + }; + } + } + + /** + * Fetch workspace info (name + icon) via team.info. Requires team:read scope. + */ + async getTeamInfo( + botToken: string, + ): Promise<{ ok: boolean; icon?: string; name?: string; id?: string }> { + try { + const response = await fetch('https://slack.com/api/team.info', { + method: 'GET', + headers: { + Authorization: `Bearer ${botToken}`, + }, + }); + + const data: any = await response.json(); + + if (!data.ok) { + this.logger.warn(`Slack team.info failed: ${data.error}`); + return { ok: false }; + } + + // Pick the largest available icon (image_230 > image_132 > image_88 > image_68) + const icons = data.team?.icon ?? {}; + const icon = + icons.image_230 || icons.image_132 || icons.image_88 || icons.image_68 || icons.image_44; + + return { + ok: true, + icon: icons.image_default ? undefined : icon, + name: data.team?.name, + id: data.team?.id, + }; + } catch (error) { + this.logger.warn('Failed to fetch team.info (non-critical):', error); + return { ok: false }; + } + } + + /** + * List Slack channels using a bot token + */ + async listChannels(botToken: string): Promise<{ channels: { id: string; name: string }[] }> { + try { + const response = await fetch('https://slack.com/api/conversations.list', { + method: 'GET', + headers: { + Authorization: `Bearer ${botToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data: any = await response.json(); + + if (!data.ok) { + this.logger.error(`Slack API error: ${data.error}`); + throw new Error(data.error || 'Failed to list channels'); + } + + const channels = (data.channels || []).map((channel: any) => ({ + id: channel.id, + name: channel.name, + })); + + return { channels }; + } catch (error) { + this.logger.error('Error listing Slack channels:', error); + throw error; + } + } + + /** + * Send a message to a Slack channel using a bot token + */ + async sendMessage( + botToken: string, + channel: string, + text: string, + ): Promise<{ ok: boolean; error?: string; ts?: string }> { + try { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${botToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel, + text, + }), + }); + + const data: any = await response.json(); + + if (!data.ok) { + this.logger.error(`Failed to send message: ${data.error}`); + return { + ok: false, + error: data.error || 'Failed to send message', + }; + } + + return { + ok: true, + ts: data.ts, + }; + } catch (error) { + this.logger.error('Network error sending message:', error); + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown network error', + }; + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 8c8c4236..233b259e 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -41,6 +41,12 @@ async function bootstrap() { instanceOrigins.push(`http://127.0.0.1:${backendPort}`); } + // Allow extra CORS origins (e.g. ngrok tunnels) via comma-separated env var + const extraOrigins = (process.env.CORS_EXTRA_ORIGINS ?? '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean); + app.enableCors({ origin: [ 'http://localhost', @@ -48,6 +54,7 @@ async function bootstrap() { 'http://localhost:8090', 'https://studio.shipsec.ai', ...instanceOrigins, + ...extraOrigins, ], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], diff --git a/bun.lock b/bun.lock index d994c1f6..12285b42 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,8 @@ "name": "@shipsec/studio-backend", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-organizations": "^3.750.0", + "@aws-sdk/client-sts": "^3.750.0", "@clerk/backend": "^2.29.5", "@clerk/types": "^4.101.13", "@grpc/grpc-js": "^1.14.3", @@ -58,6 +60,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "cookie-parser": "^1.4.7", "date-fns": "^4.1.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", @@ -254,7 +257,9 @@ "@ai-sdk/google": "^3.0.13", "@ai-sdk/mcp": "^1.0.13", "@ai-sdk/openai": "^3.0.18", + "@aws-sdk/client-organizations": "^3.987.0", "@aws-sdk/client-s3": "^3.975.0", + "@aws-sdk/client-sts": "^3.987.0", "@googleapis/admin": "^29.0.0", "@grpc/grpc-js": "^1.14.3", "@modelcontextprotocol/sdk": "^1.25.1", @@ -351,29 +356,33 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-organizations": ["@aws-sdk/client-organizations@3.987.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-node": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.987.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-fz9OOrZj+ywtcCh4rVnCd38epnRQ7xg/khPi0l4CXQX4vorrSajOEzM0G89MlP1E0g5tId8Frbe/SlRvkpsSng=="], + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.980.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/credential-provider-node": "^3.972.4", "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", "@aws-sdk/middleware-expect-continue": "^3.972.3", "@aws-sdk/middleware-flexible-checksums": "^3.972.3", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-location-constraint": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-sdk-s3": "^3.972.5", "@aws-sdk/middleware-ssec": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/signature-v4-multi-region": "3.980.0", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/eventstream-serde-config-resolver": "^4.3.8", "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-blob-browser": "^4.2.9", "@smithy/hash-node": "^4.2.8", "@smithy/hash-stream-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/md5-js": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-ch8QqKehyn1WOYbd8LyDbWjv84Z9OEj9qUxz8q3IOCU3ftAVkVR0wAuN96a1xCHnpOJcQZo3rOB08RlyKdkGxQ=="], - "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.980.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-AhNXQaJ46C1I+lQ+6Kj+L24il5K9lqqIanJd8lMszPmP7bLnmX0wTKK0dxywcvrLdij3zhWttjAKEBNgLtS8/A=="], + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.985.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ=="], + + "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.987.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-node": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.987.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zDqUCxS/6oUgQjIoDRfijHRgCkUTO3bnC+Hvi1Jh8s0Hj6cGpaUTCWYjwksV3bJGfS+HcloUtUseGO26Pcbeeg=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.2", "@smithy/core": "^3.22.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA=="], + "@aws-sdk/core": ["@aws-sdk/core@3.973.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.22.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg=="], "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-OBYNY4xQPq7Rx+oOhtyuyO0AQvdJSpXRg7JuPNBJH4a1XXIzJQl4UHQTPKZKwfJXmYLpv4+OkcFen4LYmDPd3g=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LxJ9PEO4gKPXzkufvIESUysykPIdrV7+Ocb9yAhbhJLE4TiAYqbCVUE+VuKP1leGR1bBfjWjYgSV5MxprlX3mQ=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" } }, "sha512-GpvBgEmSZPvlDekd26Zi+XsI27Qz7y0utUx0g2fSTSiDzhnd1FSa1owuodxR0BcUKNL7U2cOVhhDxgZ4iSoPVg=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.7", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.11", "tslib": "^2.6.2" } }, "sha512-L2uOGtvp2x3bTcxFTpSM+GkwFIPd8pHfGWO1764icMbo7e5xJh0nfhx1UwkXLnwvocTNEf8A7jISZLYjUSNaTg=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/credential-provider-env": "^3.972.3", "@aws-sdk/credential-provider-http": "^3.972.5", "@aws-sdk/credential-provider-login": "^3.972.3", "@aws-sdk/credential-provider-process": "^3.972.3", "@aws-sdk/credential-provider-sso": "^3.972.3", "@aws-sdk/credential-provider-web-identity": "^3.972.3", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-rMQAIxstP7cLgYfsRGrGOlpyMl0l8JL2mcke3dsIPLWke05zKOFyR7yoJzWCsI/QiIxjRbxpvPiAeKEA6CoYkg=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-env": "^3.972.5", "@aws-sdk/credential-provider-http": "^3.972.7", "@aws-sdk/credential-provider-login": "^3.972.5", "@aws-sdk/credential-provider-process": "^3.972.5", "@aws-sdk/credential-provider-sso": "^3.972.5", "@aws-sdk/credential-provider-web-identity": "^3.972.5", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-SdDTYE6jkARzOeL7+kudMIM4DaFnP5dZVeatzw849k4bSXDdErDS188bgeNzc/RA2WGrlEpsqHUKP6G7sVXhZg=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-uYq1ILyTSI6ZDCMY5+vUsRM0SOCVI7kaW4wBrehVVkhAxC6y+e9rvGtnoZqCOWL1gKjTMouvsf4Ilhc5NCg1Aw=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.4", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.3", "@aws-sdk/credential-provider-http": "^3.972.5", "@aws-sdk/credential-provider-ini": "^3.972.3", "@aws-sdk/credential-provider-process": "^3.972.3", "@aws-sdk/credential-provider-sso": "^3.972.3", "@aws-sdk/credential-provider-web-identity": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-UwerdzosMSY7V5oIZm3NsMDZPv2aSVzSkZxYxIOWHBeKTZlUqW7XpHtJMZ4PZpJ+HMRhgP+MDGQx4THndgqJfQ=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.6", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.5", "@aws-sdk/credential-provider-http": "^3.972.7", "@aws-sdk/credential-provider-ini": "^3.972.5", "@aws-sdk/credential-provider-process": "^3.972.5", "@aws-sdk/credential-provider-sso": "^3.972.5", "@aws-sdk/credential-provider-web-identity": "^3.972.5", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DZ3CnAAtSVtVz+G+ogqecaErMLgzph4JH5nYbHoBMgBkwTUV+SUcjsjOJwdBJTHu3Dm6l5LBYekZoU2nDqQk2A=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-xkSY7zjRqeVc6TXK2xr3z1bTLm0wD8cj3lAkproRGaO4Ku7dPlKy843YKnHrUOUzOnMezdZ4xtmFc0eKIDTo2w=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-HDKF3mVbLnuqGg6dMnzBf1VUOywE12/N286msI9YaK9mEIzdsGCtLTvrDhe3Up0R9/hGFbB+9l21/TwF5L1C6g=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.3", "", { "dependencies": { "@aws-sdk/client-sso": "3.980.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/token-providers": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.5", "", { "dependencies": { "@aws-sdk/client-sso": "3.985.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/token-providers": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8urj3AoeNeQisjMmMBhFeiY2gxt6/7wQQbEGun0YV/OaOOiXrIudTIEYF8ZfD+NQI6X1FY5AkRsx6O/CaGiybA=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-62VufdcH5rRfiRKZRcf1wVbbt/1jAntMj1+J0qAd+r5pQRg2t0/P9/Rz16B1o5/0Se9lVL506LRjrhIJAhYBfA=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-OK3cULuJl6c+RcDZfPpaK5o3deTOnKZbxm7pzhFNGA3fI2hF9yDih17fGRazJzGGWaDVlR9ejZrpDef4DJCEsw=="], "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg=="], @@ -393,29 +402,29 @@ "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@smithy/core": "^3.22.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.7", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@smithy/core": "^3.22.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-HUD+geASjXSCyL/DHPQc/Ua7JhldTcIglVAoCV8kiVm99IaFSlAbTvEnyhZwdE6bdFyTL+uIaWLaCFSRsglZBQ=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.980.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.985.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-TsWwKzb/2WHafAY0CE7uXgLj0FmnkBTgfioG9HO+7z/zCPcl1+YU+i7dW4o0y+aFxFgxTMG+ExBQpqT/k2ao8g=="], "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/config-resolver": "^4.4.6", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.980.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.5", "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-tO2jBj+ZIVM0nEgi1SyxWtaYGpuAJdsrugmWcI3/U2MPWCYsrvKasUo0026NvJJao38wyUq9B8XTG8Xu53j/VA=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.980.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.985.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-+hwpHZyEq8k+9JL2PkE60V93v2kNhUIv7STFt+EAez1UJsJOQDhc5LpzEX66pNjclI5OTwBROs/DhJjC/BtMjQ=="], "@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.980.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.987.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-rZnZwDq7Pn+TnL0nyS6ryAhpqTZtLtHbJaqfxuHlDX3v/bq0M7Ch/V3qF9dZWaGgsJ2H9xn7/vFOxlnL4fBMcQ=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="], "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.972.3", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.972.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-GsUDF+rXyxDZkkJxUsDxnA67FG+kc5W1dnloCFLl6fWzceevsCYzJpASBzT+BPjwUgREE6FngfJYYYMQUY5fZQ=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.2", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], @@ -921,7 +930,7 @@ "@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="], - "@smithy/core": ["@smithy/core@3.22.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA=="], + "@smithy/core": ["@smithy/core@3.22.1", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="], @@ -951,9 +960,9 @@ "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.12", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.13", "", { "dependencies": { "@smithy/core": "^3.22.1", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.29", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-bmTn75a4tmKRkC5w61yYQLb3DmxNzB8qSVu9SbTYqW6GAL0WXO2bDZuMAn/GJSbOdHEdjZvWxe+9Kk015bw6Cg=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.30", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg=="], "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="], @@ -961,7 +970,7 @@ "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.8", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.8", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.9", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w=="], "@smithy/property-provider": ["@smithy/property-provider@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w=="], @@ -977,7 +986,7 @@ "@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.11.1", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" } }, "sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ=="], + "@smithy/smithy-client": ["@smithy/smithy-client@4.11.2", "", { "dependencies": { "@smithy/core": "^3.22.1", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.11", "tslib": "^2.6.2" } }, "sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A=="], "@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], @@ -993,9 +1002,9 @@ "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.28", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-/9zcatsCao9h6g18p/9vH9NIi5PSqhCkxQ/tb7pMgRFnqYp9XUOyOlGPDMHzr8n5ih6yYgwJEY2MLEobUgi47w=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.29", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.31", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-JTvoApUXA5kbpceI2vuqQzRjeTbLpx1eoa5R/YEZbTgtxvIB7AQZxFJ0SEyfCpgPCyVV9IT7we+ytSeIB3CyWA=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.32", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q=="], "@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="], @@ -1553,6 +1562,8 @@ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie-parser": ["cookie-parser@1.4.7", "", { "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.6" } }, "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], @@ -3119,7 +3130,47 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.2", "@smithy/core": "^3.22.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.4", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.3", "@aws-sdk/credential-provider-http": "^3.972.5", "@aws-sdk/credential-provider-ini": "^3.972.3", "@aws-sdk/credential-provider-process": "^3.972.3", "@aws-sdk/credential-provider-sso": "^3.972.3", "@aws-sdk/credential-provider-web-identity": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-UwerdzosMSY7V5oIZm3NsMDZPv2aSVzSkZxYxIOWHBeKTZlUqW7XpHtJMZ4PZpJ+HMRhgP+MDGQx4THndgqJfQ=="], + + "@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@smithy/core": "^3.22.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg=="], + + "@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.980.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw=="], + + "@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.972.3", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA=="], + + "@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.22.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA=="], + + "@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.12", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q=="], + + "@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.29", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-bmTn75a4tmKRkC5w61yYQLb3DmxNzB8qSVu9SbTYqW6GAL0WXO2bDZuMAn/GJSbOdHEdjZvWxe+9Kk015bw6Cg=="], + + "@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.8", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg=="], + + "@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.11.1", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" } }, "sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ=="], + + "@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.28", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-/9zcatsCao9h6g18p/9vH9NIi5PSqhCkxQ/tb7pMgRFnqYp9XUOyOlGPDMHzr8n5ih6yYgwJEY2MLEobUgi47w=="], + + "@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.31", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-JTvoApUXA5kbpceI2vuqQzRjeTbLpx1eoa5R/YEZbTgtxvIB7AQZxFJ0SEyfCpgPCyVV9IT7we+ytSeIB3CyWA=="], + + "@aws-sdk/client-sso/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.985.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA=="], + + "@aws-sdk/credential-provider-http/@smithy/util-stream": ["@smithy/util-stream@4.5.11", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core": ["@aws-sdk/core@3.973.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.2", "@smithy/core": "^3.22.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.2", "@smithy/core": "^3.22.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA=="], + + "@aws-sdk/middleware-sdk-s3/@smithy/core": ["@smithy/core@3.22.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA=="], + + "@aws-sdk/middleware-sdk-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.11.1", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" } }, "sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ=="], + + "@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.985.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA=="], + + "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.985.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA=="], + + "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3249,6 +3300,12 @@ "@shipsec/studio-worker/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], + "@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.11", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA=="], + + "@smithy/smithy-client/@smithy/util-stream": ["@smithy/util-stream@4.5.11", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA=="], + + "@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.8", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg=="], + "@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=="], @@ -3317,6 +3374,8 @@ "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "cookie-parser/cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], + "cssstyle/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="], "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], @@ -3511,6 +3570,30 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.2", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-OBYNY4xQPq7Rx+oOhtyuyO0AQvdJSpXRg7JuPNBJH4a1XXIzJQl4UHQTPKZKwfJXmYLpv4+OkcFen4LYmDPd3g=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" } }, "sha512-GpvBgEmSZPvlDekd26Zi+XsI27Qz7y0utUx0g2fSTSiDzhnd1FSa1owuodxR0BcUKNL7U2cOVhhDxgZ4iSoPVg=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/credential-provider-env": "^3.972.3", "@aws-sdk/credential-provider-http": "^3.972.5", "@aws-sdk/credential-provider-login": "^3.972.3", "@aws-sdk/credential-provider-process": "^3.972.3", "@aws-sdk/credential-provider-sso": "^3.972.3", "@aws-sdk/credential-provider-web-identity": "^3.972.3", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-rMQAIxstP7cLgYfsRGrGOlpyMl0l8JL2mcke3dsIPLWke05zKOFyR7yoJzWCsI/QiIxjRbxpvPiAeKEA6CoYkg=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-xkSY7zjRqeVc6TXK2xr3z1bTLm0wD8cj3lAkproRGaO4Ku7dPlKy843YKnHrUOUzOnMezdZ4xtmFc0eKIDTo2w=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.3", "", { "dependencies": { "@aws-sdk/client-sso": "3.980.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/token-providers": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-62VufdcH5rRfiRKZRcf1wVbbt/1jAntMj1+J0qAd+r5pQRg2t0/P9/Rz16B1o5/0Se9lVL506LRjrhIJAhYBfA=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.2", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@smithy/core": ["@smithy/core@3.22.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@smithy/smithy-client": ["@smithy/smithy-client@4.11.1", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" } }, "sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.2", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA=="], + + "@aws-sdk/middleware-sdk-s3/@smithy/smithy-client/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.12", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q=="], + "@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -3761,6 +3844,24 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.3", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.980.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.980.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-AhNXQaJ46C1I+lQ+6Kj+L24il5K9lqqIanJd8lMszPmP7bLnmX0wTKK0dxywcvrLdij3zhWttjAKEBNgLtS8/A=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.980.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.5", "@aws-sdk/nested-clients": "3.980.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.980.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@smithy/smithy-client/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.12", "", { "dependencies": { "@smithy/core": "^3.22.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "@nestjs/platform-express/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@nestjs/platform-express/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -3779,6 +3880,14 @@ "multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.980.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.980.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + "@nestjs/platform-express/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@nestjs/platform-express/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index 48314ea0..e2e14cea 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -207,6 +207,8 @@ services: depends_on: opensearch-dashboards: condition: service_healthy + environment: + - DEFAULT_ORGANIZATION_ID=${DEFAULT_ORGANIZATION_ID:-local-dev} volumes: - ./opensearch-init.sh:/init.sh:ro entrypoint: ['/bin/sh', '/init.sh'] diff --git a/docker/docker-compose.infra.yml b/docker/docker-compose.infra.yml index 1e9e619c..c23c6751 100644 --- a/docker/docker-compose.infra.yml +++ b/docker/docker-compose.infra.yml @@ -9,12 +9,12 @@ services: POSTGRES_MULTIPLE_DATABASES: temporal # Internal only - use docker-compose.dev-ports.yml overlay for local dev access 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 @@ -35,7 +35,7 @@ services: - POSTGRES_SEEDS=postgres - AUTO_SETUP=true expose: - - "7233" + - '7233' volumes: - temporal_data:/var/lib/temporal restart: unless-stopped @@ -49,7 +49,7 @@ services: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_CORS_ORIGINS=http://localhost:5173 expose: - - "8080" + - '8080' restart: unless-stopped minio: @@ -60,13 +60,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 @@ -75,12 +75,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 @@ -90,13 +90,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 @@ -115,13 +115,13 @@ services: - --check=false - --advertise-kafka-addr=localhost: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 @@ -134,7 +134,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 @@ -145,7 +145,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: @@ -158,13 +158,13 @@ services: # Ports exposed only within Docker network (not to host) # Use docker-compose.dev-ports.yml overlay for local dev access 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 @@ -185,12 +185,12 @@ services: # Use docker-compose.dev-ports.yml overlay for local dev access # Production uses nginx reverse proxy at /analytics 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 @@ -202,10 +202,12 @@ services: depends_on: opensearch-dashboards: condition: service_healthy + environment: + - DEFAULT_ORGANIZATION_ID=${DEFAULT_ORGANIZATION_ID:-local-dev} volumes: - ./opensearch-init.sh:/init.sh:ro - entrypoint: ["/bin/sh", "/init.sh"] - restart: "no" + 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 @@ -216,14 +218,14 @@ services: opensearch-dashboards: condition: service_healthy ports: - - "80:80" + - '80:80' volumes: - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro extra_hosts: - - "host.docker.internal:host-gateway" + - 'host.docker.internal:host-gateway' restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health'] interval: 30s timeout: 10s retries: 5 diff --git a/docker/opensearch-init.sh b/docker/opensearch-init.sh index 1d9d97b1..c58d0854 100755 --- a/docker/opensearch-init.sh +++ b/docker/opensearch-init.sh @@ -61,36 +61,43 @@ if [ "$SECURITY_ENABLED" = "true" ]; then exit 0 fi -# Check if index pattern already exists (insecure mode only) -echo "[opensearch-init] Checking for existing index patterns..." -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}') +# Helper: create an index pattern if it doesn't already exist +create_index_pattern() { + local PATTERN_ID="$1" -TOTAL=$(echo "$EXISTING" | grep -o '"total":[0-9]*' | grep -o '[0-9]*' || echo "0") + EXISTING=$(auth_curl -sf "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/index-pattern/${PATTERN_ID}" \ + -H "osd-xsrf: true" 2>/dev/null || echo '') -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-*'..." + if echo "$EXISTING" | grep -q '"type":"index-pattern"'; then + echo "[opensearch-init] Index pattern '${PATTERN_ID}' already exists, skipping creation" + return + fi - # Use specific ID so dashboards can reference it consistently - RESPONSE=$(auth_curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/index-pattern/security-findings-*" \ + echo "[opensearch-init] Creating index pattern '${PATTERN_ID}'..." + RESPONSE=$(auth_curl -sf -X POST "${DASHBOARDS_URL}${DASHBOARDS_BASE_PATH}/api/saved_objects/index-pattern/${PATTERN_ID}" \ -H "Content-Type: application/json" \ -H "osd-xsrf: true" \ - -d '{ - "attributes": { - "title": "security-findings-*", - "timeFieldName": "@timestamp" + -d "{ + \"attributes\": { + \"title\": \"${PATTERN_ID}\", + \"timeFieldName\": \"@timestamp\" } - }' 2>&1) + }" 2>&1) if echo "$RESPONSE" | grep -q '"type":"index-pattern"'; then - echo "[opensearch-init] Successfully created index pattern 'security-findings-*'" + echo "[opensearch-init] Successfully created index pattern '${PATTERN_ID}'" else - echo "[opensearch-init] WARNING: Failed to create index pattern. Response: $RESPONSE" - # Don't fail - the pattern might be created later when data exists + echo "[opensearch-init] WARNING: Failed to create index pattern '${PATTERN_ID}'. Response: $RESPONSE" fi -fi +} + +# Create index patterns (insecure mode only) +# Generic pattern for all findings +create_index_pattern "security-findings-*" + +# Org-specific pattern for local dev (matches the frontend's org-scoped dashboard links) +LOCAL_DEV_ORG_ID="${DEFAULT_ORGANIZATION_ID:-local-dev}" +create_index_pattern "security-findings-${LOCAL_DEV_ORG_ID}-*" # Set as default index pattern (optional, helps UX) echo "[opensearch-init] Setting default index pattern..." diff --git a/docs/components/core.mdx b/docs/components/core.mdx index 3ad0c765..6941c97a 100644 --- a/docs/components/core.mdx +++ b/docs/components/core.mdx @@ -155,6 +155,79 @@ Outputs data to workflow logs for debugging. --- +## Integrations & Cloud + +### Integration Credential Resolver + +Resolves credentials from an integration connection at runtime. This component bridges the [Integrations](/integrations) system with workflow execution, enabling workflows to securely access cloud provider credentials without hardcoding secrets. + +| Input | Type | Description | +|-------|------|-------------| +| `connectionId` | Text | Integration connection ID. Wire from Entry Point or set as a parameter. | +| `regions` | Text | Optional comma-separated region override | + +| Output | Type | Description | +|--------|------|-------------| +| `credentialType` | String | Type of credentials (`iam_role`, `oauth`, `api_key`, `webhook`) | +| `provider` | String | Integration provider (`aws`, `slack`, etc.) | +| `accountId` | String | Provider account identifier (e.g. AWS 12-digit account ID) | +| `regions` | String | Resolved regions (input override or connection default) | +| `data` | Object | Resolved credential payload (varies by provider and credential type) | + +| Parameter | Type | Description | +|-----------|------|-------------| +| `connectionId` | Text | Static connection ID. Overridden when wired from upstream. | + +**Example use cases:** +- Resolve AWS IAM role credentials for Prowler scans +- Fetch Slack webhook URLs for notification workflows +- Multi-account workflows with per-account connection IDs + +--- + +### AWS Org Discovery + +Lists all accounts in an AWS Organization using the `organizations:ListAccounts` API. Paginates through all accounts automatically. + +| Input | Type | Description | +|-------|------|-------------| +| `credentials` | AWS Credentials | AWS credentials with `organizations:ListAccounts` permission | + +| Output | Type | Description | +|--------|------|-------------| +| `accounts` | Array | List of `{ id, name, status, email }` objects | +| `organizationId` | String | AWS Organization ID | + +**Example use cases:** +- Discover all member accounts before running multi-account CSPM scans +- Build inventory of active AWS accounts for compliance + +--- + +### AWS Assume Role + +Assumes an AWS IAM role via STS `AssumeRole` and returns temporary credentials (1 hour TTL). Essential for cross-account access patterns. + +| Input | Type | Description | +|-------|------|-------------| +| `sourceCredentials` | AWS Credentials | Credentials used to call STS | + +| Parameter | Type | Description | +|-----------|------|-------------| +| `roleArn` | String | ARN of the IAM role to assume (required) | +| `externalId` | String | Optional external ID for the trust policy | +| `sessionName` | String | STS session name (default: `shipsec-session`) | + +| Output | Type | Description | +|--------|------|-------------| +| `credentials` | AWS Credentials | Temporary assumed-role credentials | + +**Example use cases:** +- Cross-account Prowler scans using a central management account +- Assume least-privilege roles for specific scan tasks + +--- + ## Storage Destinations ### Artifact Writer diff --git a/docs/components/overview.mdx b/docs/components/overview.mdx index ba42b9d7..f11c27d6 100644 --- a/docs/components/overview.mdx +++ b/docs/components/overview.mdx @@ -9,10 +9,10 @@ Components are the building blocks of ShipSec Studio workflows. Each component p - Triggers, file handling, data transformation, and outputs + Triggers, file handling, data transformation, cloud integrations, and outputs - Subdomain discovery, port scanning, DNS resolution, secret detection + Subdomain discovery, port scanning, DNS resolution, secret detection, CSPM Provider configurations and autonomous agents @@ -69,6 +69,17 @@ Manual Trigger → TruffleHog → OpenAI Provider → AI Generate Text → Notif 4. **AI Generate Text** – Analyzes and prioritizes findings 5. **Notify** – Alerts team via Slack, Discord, or email +### AWS Cloud Security Posture (CSPM) + +``` +Entry Point → Credential Resolver → Prowler Scan → Analytics Sink +``` + +1. **Entry Point** – Collects AWS connection ID and regions +2. **Integration Credential Resolver** – Resolves AWS IAM credentials from the [Integrations](/integrations) system +3. **Prowler Scan** – Runs cloud security posture checks (severity high/critical) +4. **Analytics Sink** – Indexes findings into OpenSearch for dashboards and alerts + ## Execution Context Components receive an `ExecutionContext` with access to: diff --git a/docs/components/security.mdx b/docs/components/security.mdx index 38c39655..edd03ddd 100644 --- a/docs/components/security.mdx +++ b/docs/components/security.mdx @@ -227,20 +227,65 @@ Scans for leaked credentials across repositories, filesystems, and cloud storage ### Prowler Scan -[GitHub](https://github.com/prowler-cloud/prowler) · Docker: `ghcr.io/shipsecai/prowler` +[GitHub](https://github.com/prowler-cloud/prowler) · Docker: `ghcr.io/shipsecai/prowler:5.14.2` -Cloud (AWS, Azure, GCP) security posture management. Best practices auditing. +Cloud security posture management. Supports single-account scanning and **org-wide multi-account scanning** for AWS Organizations. + +**Component ID:** `security.prowler.scan` | Input | Type | Description | |-------|------|-------------| -| `credentials` | Object | AWS credentials | -| `checks` | Array | Specific checks to run | +| `accountId` | String (optional) | AWS account ID to tag findings with. Required when `orgScan` is false. | +| `credentials` | AWS Credentials (optional) | AWS credentials from the Credential Resolver. Required for AWS scans. | +| `regions` | String | Comma-separated AWS regions (default: `us-east-1`). | + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `scanMode` | Select | `aws` | `aws` for single/org account scan, `cloud` for multi-cloud overview. | +| `recommendedFlags` | Multi-Select | severity-high-critical, ignore-exit-code, no-banner | Pre-selected CLI flags. | +| `customFlags` | Textarea | — | Additional CLI flags appended verbatim. | +| `orgScan` | Toggle | `false` | Enable org-wide scanning (discovers and scans all member accounts). | +| `memberRoleName` | String | `OrganizationAccountAccessRole` | IAM role to assume in each member account. Visible when `orgScan=true`. | +| `externalId` | String | — | Optional external ID for cross-account trust policy. Visible when `orgScan=true`. | +| `continueOnError` | Toggle | `true` | Continue scanning if a member account fails. Visible when `orgScan=true`. | +| `skipManagementAccount` | Toggle | `false` | Exclude the management account from scanning. Visible when `orgScan=true`. | +| `maxConcurrency` | Number (1-5) | `1` | Number of accounts to scan in parallel. Visible when `orgScan=true`. | -| Parameter | Type | Description | -|-----------|------|-------------| -| `severity` | Array | Filter by severity | -| `services` | Array | AWS services to audit | +| Output | Type | Description | +|--------|------|-------------| +| `scanId` | String | Deterministic scan run identifier. | +| `findings` | Array | Normalized findings (severity, resource, remediation). | +| `results` | Array | Analytics-ready results. Connect to **Analytics Sink**. | +| `rawOutput` | String | Raw Prowler output for debugging. | +| `summary` | Object | Aggregate counts, regions, and per-account summaries (org mode). | +| `command` | Array | CLI arguments used during the run. | +| `stderr` | String | Standard error output from Prowler. | +| `errors` | Array | Errors encountered during scanning. | + +#### Org-Wide Scanning + +When `orgScan` is enabled, the component: + +1. Discovers all active accounts via AWS Organizations (using the management account credentials). +2. For each member account, assumes the configured IAM role via STS. +3. Runs a full Prowler scan per account. +4. Aggregates findings across all accounts with per-account status summaries. + +Failed accounts are recorded with their error message and scanning continues to the next account (when `continueOnError=true`). + +``` +Management Account Credentials + ↓ +Org Discovery (ListAccounts) + ↓ +For each member account: + AssumeRole → Prowler Scan → Collect Findings + ↓ +Aggregate Results → Analytics Sink +``` + +**Sample workflow:** [`docs/sample/aws-cspm-org-prowler-to-analytics.json`](/docs/sample/aws-cspm-org-prowler-to-analytics.json) ### Supabase Scanner diff --git a/docs/docs.json b/docs/docs.json index 046e4014..c7cce05a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -15,18 +15,15 @@ "groups": [ { "group": "Getting Started", - "pages": [ - "index", - "quickstart", - "installation", - "command-reference" - ] + "pages": ["index", "quickstart", "installation", "command-reference"] }, { "group": "Architecture", - "pages": [ - "architecture" - ] + "pages": ["architecture"] + }, + { + "group": "Integrations", + "pages": ["integrations"] }, { "group": "Components", diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 00000000..ebdb0d09 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,319 @@ +# Integrations + +ShipSec Studio provides first-class integrations with cloud providers and communication platforms. Integrations allow workflows to securely resolve credentials at runtime without embedding secrets directly into workflow definitions. + +## Overview + +The integrations system consists of three layers: + +1. **Backend** -- Manages connection lifecycle, encrypted token storage, and credential resolution via REST API. +2. **Frontend** -- Settings UI for connecting providers, managing connections, and viewing setup instructions. +3. **Worker Components** -- Workflow nodes that resolve credentials from connections at execution time. + +--- + +## Supported Providers + +### Amazon Web Services (AWS) + +Connect AWS accounts for cloud security posture management (CSPM), compliance scanning, and resource discovery. + +**Auth Method:** IAM Role with External ID (cross-account trust policy) + +**Supported Scenarios:** + +- Single AWS Account +- Cross-Account via STS AssumeRole +- AWS Organizations (multi-account discovery) + +### Slack + +Send workflow notifications, security alerts, and scan results to Slack channels. + +**Auth Methods:** + +- **Incoming Webhook** -- Simple webhook URL for posting messages to a channel +- **OAuth App** -- Full Slack App integration with scopes: `channels:read`, `chat:write`, `chat:write.public`, `commands`, `im:write`, `team:read` + +--- + +## Setting Up AWS + +### Step 1: Navigate to Integrations + +Open **Settings > Integrations** in the ShipSec Studio dashboard. Click the **AWS** card. + +### Step 2: Create an IAM Role + +The setup page displays a pre-generated **External ID** and an **IAM Trust Policy** JSON document. Use these to create an IAM role in your AWS account: + +1. Go to the AWS IAM Console > **Roles** > **Create Role**. +2. Choose **Another AWS account** as the trusted entity. +3. Paste the trust policy JSON from the ShipSec setup page. +4. Attach your desired permissions policy (e.g., `ReadOnlyAccess`, `SecurityAudit`, or a custom policy). +5. Name the role (e.g., `ShipSecStudioRole`) and create it. +6. Copy the **Role ARN** (e.g., `arn:aws:iam::123456789012:role/ShipSecStudioRole`). + +### Step 3: Add the Connection + +Back in the ShipSec UI: + +1. Paste the **Role ARN** into the connection form. +2. Optionally set a default **AWS Region** (defaults to `us-east-1`). +3. Click **Connect**. + +ShipSec will validate the credentials by calling `sts:GetCallerIdentity`. If successful, the connection appears in your connections list. + +### Step 4: Discover Organization Accounts (Optional) + +If the IAM role has `organizations:ListAccounts` permission, click **Discover Accounts** on the connection detail page to list all member accounts in your AWS Organization. + +### Using AWS Connections in Workflows + +Wire the **Connection ID** (visible on the connection card) into a workflow's **Entry Point** runtime input, or configure it directly in the **Integration Credential Resolver** node parameter. + +The resolver will call the backend's internal credentials endpoint, assume the IAM role via STS, and output temporary credentials for downstream components like **Prowler Scan** or **AWS Org Discovery**. + +--- + +## Setting Up Slack + +### Incoming Webhook + +1. Go to [Slack Incoming Webhooks](https://api.slack.com/messaging/webhooks) and create a webhook URL for your channel. +2. In **Settings > Integrations > Slack**, select **Incoming Webhook** as the auth method. +3. Paste the webhook URL and click **Connect**. +4. Click **Test** to verify the connection sends a message to your channel. + +### OAuth App + +1. In **Settings > Integrations > Slack**, select **Slack App (OAuth)**. +2. You'll be redirected to Slack's authorization page. +3. Authorize the ShipSec app for your workspace. +4. The connection is created automatically on successful authorization. + +> **Note:** OAuth requires the Slack provider to be configured by an admin (client ID and secret via `SLACK_OAUTH_CLIENT_ID` / `SLACK_OAUTH_CLIENT_SECRET` environment variables or the provider config API). + +--- + +## API Reference + +### Provider Catalog + +| Method | Endpoint | Description | +| ------ | ----------------------- | -------------------------------------------------------------------------- | +| GET | `/integrations/catalog` | List all integration providers with their auth methods and setup scenarios | + +### Connection Management + +| Method | Endpoint | Description | +| ------ | --------------------------------------- | ---------------------------------------------------------------------- | +| GET | `/integrations/connections` | List connections for the authenticated user | +| GET | `/integrations/org/connections` | List organization-scoped connections (optional `?provider=aws` filter) | +| DELETE | `/integrations/connections/:id` | Remove a connection | +| POST | `/integrations/connections/:id/refresh` | Refresh connection tokens | + +### AWS Endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------------------------ | --------------------------------------------------- | +| GET | `/integrations/aws/setup-info` | Get External ID and IAM trust policy for role setup | +| POST | `/integrations/aws/connections` | Create an AWS IAM role connection | +| POST | `/integrations/aws/connections/:id/validate` | Validate an AWS connection's credentials | +| POST | `/integrations/aws/connections/:id/discover-org` | Discover AWS Organization member accounts | + +### Slack Endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------------------ | -------------------------------------------- | +| POST | `/integrations/slack/connections` | Create a Slack webhook connection | +| POST | `/integrations/slack/connections/:id/test` | Send a test message via the Slack connection | + +### OAuth Flow + +| Method | Endpoint | Description | +| ------ | ---------------------------------- | --------------------------------------------------- | +| POST | `/integrations/:provider/start` | Initiate OAuth session (returns `authorizationUrl`) | +| POST | `/integrations/:provider/exchange` | Complete OAuth token exchange | + +### Internal Endpoints (Worker-to-Backend) + +These endpoints are called by worker components at runtime and are protected by the `X-Internal-Token` header: + +| Method | Endpoint | Description | +| ------ | ------------------------------------------- | ------------------------------------------------------------------- | +| POST | `/integrations/connections/:id/token` | Issue raw connection token | +| POST | `/integrations/connections/:id/credentials` | Resolve typed credentials (provider, type, data, accountId, region) | + +--- + +## Workflow Components + +Three new core components support integrations in workflows: + +### Integration Credential Resolver + +**Component ID:** `core.integration.resolve-credentials` + +Resolves credentials from an integration connection at runtime. This is the bridge between the integrations system and workflow execution. + +| Input | Type | Description | +| -------------- | ---- | --------------------------------------------------------------------- | +| `connectionId` | Text | Integration connection ID (wire from Entry Point or set as parameter) | +| `regions` | Text | Optional region override (comma-separated) | + +| Output | Type | Description | +| ---------------- | ------ | ----------------------------------------------------------- | +| `credentialType` | String | Credential type (e.g., `iam_role`, `oauth`, `webhook`) | +| `provider` | String | Provider name (e.g., `aws`, `slack`) | +| `accountId` | String | Provider account identifier (e.g., AWS 12-digit account ID) | +| `regions` | String | Resolved regions (input override or connection default) | +| `data` | Object | Raw credential payload (varies by provider) | + +### AWS Org Discovery + +**Component ID:** `core.aws.org-discovery` + +Lists all accounts in an AWS Organization using `organizations:ListAccounts`. Paginates automatically. + +| Input | Type | Description | +| ------------- | --------------- | ---------------------------------------------- | +| `credentials` | AWS Credentials | AWS credentials with Organizations permissions | + +| Output | Type | Description | +| ---------------- | ------ | --------------------------------------------- | +| `accounts` | Array | List of `{ id, name, status, email }` objects | +| `organizationId` | String | AWS Organization ID | + +### AWS Assume Role + +**Component ID:** `core.aws.assume-role` + +Assumes an IAM role via STS and returns temporary credentials. Useful for cross-account access patterns. + +| Input | Type | Description | +| ------------------- | --------------- | ----------------------------------- | +| `sourceCredentials` | AWS Credentials | Credentials to use when calling STS | + +| Parameter | Type | Description | +| ------------- | ------ | --------------------------------------------- | +| `roleArn` | String | ARN of the IAM role to assume | +| `externalId` | String | Optional external ID for the trust policy | +| `sessionName` | String | STS session name (default: `shipsec-session`) | + +| Output | Type | Description | +| ------------- | --------------- | ----------------------------------------------- | +| `credentials` | AWS Credentials | Temporary assumed-role credentials (1 hour TTL) | + +--- + +## Sample Workflows + +Four pre-built workflow templates are available in `docs/sample/`: + +### AWS CSPM -- Org Account Discovery + +**File:** `docs/sample/aws-cspm-org-discovery.json` + +``` +Entry Point → Resolve Credentials → Org Discovery + → Assume Role +``` + +Resolves AWS credentials from an integration connection, discovers all member accounts in the AWS Organization, and optionally assumes a cross-account role. + +**Runtime Inputs:** + +- `connectionId` (required) -- AWS integration connection ID +- `targetRoleArn` (optional) -- Cross-account role to assume +- `externalId` (optional) -- External ID for the trust policy + +### AWS CSPM -- Prowler Scan to Analytics + +**File:** `docs/sample/aws-cspm-prowler-to-analytics.json` + +``` +Entry Point → Resolve Credentials → Prowler Scan → Analytics Sink +``` + +End-to-end CSPM workflow: resolves AWS credentials, runs a Prowler security scan (severity high/critical), and indexes the results into the Analytics dashboard via OpenSearch. + +**Runtime Inputs:** + +- `connectionId` (required) -- AWS integration connection ID +- `regions` (optional) -- Comma-separated AWS regions (default: `us-east-1`) + +### AWS CSPM -- Org-Wide Prowler Scan to Analytics + +**File:** `docs/sample/aws-cspm-org-prowler-to-analytics.json` + +``` +Entry Point → Resolve Credentials → Prowler Scan (orgScan=true) → Analytics Sink +``` + +Resolves AWS credentials for the organization management account, discovers all member accounts via AWS Organizations, assumes a cross-account role in each, runs Prowler per-account, and indexes aggregated findings into the Analytics dashboard. Failed accounts are recorded and scanning continues. + +**Runtime Inputs:** + +- `connectionId` (required) -- AWS integration connection ID for the management account +- `regions` (optional) -- Comma-separated AWS regions (default: `us-east-1`) + +**Key Parameters:** + +- `orgScan: true` -- Enables organization-wide scanning +- `memberRoleName` -- IAM role to assume in each member account (default: `OrganizationAccountAccessRole`) +- `continueOnError: true` -- Record errors per account and continue scanning + +### AWS CSPM -- Prowler Scan to Slack Summary + +**File:** `docs/sample/aws-cspm-prowler-slack-summary.json` + +``` +Entry Point → Resolve AWS Credentials → Prowler Scan ─┐ + └→ Resolve Slack Credentials ───────────────┴→ Slack Message +``` + +Resolves AWS credentials, runs a Prowler security scan (severity high/critical), resolves Slack credentials from an integration connection (OAuth or webhook), and sends a formatted summary of findings to a Slack channel. + +**Runtime Inputs:** + +- `connectionId` (required) -- AWS integration connection ID +- `regions` (optional) -- Comma-separated AWS regions (default: `us-east-1`) +- `slackConnectionId` (required) -- Slack integration connection ID (OAuth or webhook) +- `slackChannel` (optional) -- Slack channel to post to (required for OAuth, ignored for webhook; default: `#general`) + +### Importing Sample Workflows + +**Via the UI:** Go to **Workflows** > **Import** and upload the JSON file. + +**Via the API:** + +```bash +curl -X POST http://localhost:3211/api/v1/workflows \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic $(echo -n admin:admin | base64)" \ + -d @docs/sample/aws-cspm-prowler-to-analytics.json +``` + +**Via the seed script:** + +```bash +cd backend +bun run seed:aws-workflow +``` + +--- + +## Environment Variables + +| Variable | Description | Required | +| ------------------------------- | --------------------------------------------- | ---------------- | +| `INTEGRATION_STORE_MASTER_KEY` | 32-character encryption key for token storage | Yes (production) | +| `SHIPSEC_PLATFORM_ROLE_ARN` | Platform IAM role ARN for AWS STS operations | Yes (AWS) | +| `SHIPSEC_AWS_ACCESS_KEY_ID` | Platform AWS access key for STS calls | Yes (AWS) | +| `SHIPSEC_AWS_SECRET_ACCESS_KEY` | Platform AWS secret key for STS calls | Yes (AWS) | +| `SLACK_OAUTH_CLIENT_ID` | Slack OAuth app client ID | For Slack OAuth | +| `SLACK_OAUTH_CLIENT_SECRET` | Slack OAuth app client secret | For Slack OAuth | +| `INTERNAL_SERVICE_TOKEN` | Shared token for worker-to-backend auth | Recommended | + +> **Dev Fallback:** In development, `INTEGRATION_STORE_MASTER_KEY` falls back to a hardcoded key with a console warning. Do not use the fallback in production. diff --git a/docs/sample/aws-cspm-org-discovery.json b/docs/sample/aws-cspm-org-discovery.json new file mode 100644 index 00000000..1688aeeb --- /dev/null +++ b/docs/sample/aws-cspm-org-discovery.json @@ -0,0 +1,108 @@ +{ + "name": "AWS CSPM – Org Account Discovery", + "description": "Resolves AWS credentials from an integration connection, discovers all AWS Organization member accounts, and optionally assumes a cross-account role for scanning.", + "nodes": [ + { + "id": "entry-1", + "type": "core.workflow.entrypoint", + "position": { "x": 250, "y": 50 }, + "data": { + "label": "Entry Point", + "config": { + "params": { + "runtimeInputs": [ + { + "id": "connectionId", + "label": "AWS Integration Connection ID", + "type": "text", + "required": true, + "description": "The ID of the AWS integration connection to resolve credentials from. Find this in Settings > Integrations > AWS." + }, + { + "id": "targetRoleArn", + "label": "Target Role ARN (optional)", + "type": "text", + "required": false, + "description": "If provided, the workflow will assume this cross-account role after discovering accounts." + }, + { + "id": "externalId", + "label": "External ID (optional)", + "type": "text", + "required": false, + "description": "External ID for the STS AssumeRole call, if required by the trust policy." + } + ] + }, + "inputOverrides": {} + } + } + }, + { + "id": "resolve-creds-1", + "type": "core.integration.resolve-credentials", + "position": { "x": 250, "y": 280 }, + "data": { + "label": "Resolve AWS Credentials", + "config": { + "params": { "provider": "aws" }, + "inputOverrides": { + "connectionId": "{{entry-1.connectionId}}" + } + } + } + }, + { + "id": "org-discovery-1", + "type": "core.aws.org-discovery", + "position": { "x": 100, "y": 510 }, + "data": { + "label": "Discover Org Accounts", + "config": { + "params": {}, + "inputOverrides": {} + } + } + }, + { + "id": "assume-role-1", + "type": "core.aws.assume-role", + "position": { "x": 400, "y": 510 }, + "data": { + "label": "Assume Cross-Account Role", + "config": { + "params": { + "roleArn": "{{entry-1.targetRoleArn}}", + "externalId": "{{entry-1.externalId}}", + "sessionName": "shipsec-cspm-scan" + }, + "inputOverrides": {} + } + } + } + ], + "edges": [ + { + "id": "e-entry-to-resolve", + "source": "entry-1", + "target": "resolve-creds-1", + "sourceHandle": "connectionId", + "targetHandle": "connectionId" + }, + { + "id": "e-resolve-to-discovery", + "source": "resolve-creds-1", + "target": "org-discovery-1", + "sourceHandle": "data", + "targetHandle": "credentials" + }, + { + "id": "e-resolve-to-assume", + "source": "resolve-creds-1", + "target": "assume-role-1", + "sourceHandle": "data", + "targetHandle": "sourceCredentials" + } + ], + "viewport": { "x": 0, "y": 0, "zoom": 1 } +} diff --git a/docs/sample/aws-cspm-org-prowler-to-analytics.json b/docs/sample/aws-cspm-org-prowler-to-analytics.json new file mode 100644 index 00000000..2963e20f --- /dev/null +++ b/docs/sample/aws-cspm-org-prowler-to-analytics.json @@ -0,0 +1,125 @@ +{ + "name": "AWS CSPM \u2013 Org-Wide Prowler Scan to Analytics", + "description": "Resolves AWS org credentials, scans all member accounts with Prowler, and indexes aggregated findings into Analytics.", + "nodes": [ + { + "id": "entry-1", + "type": "core.workflow.entrypoint", + "position": { "x": 300, "y": 50 }, + "data": { + "label": "Entry Point", + "config": { + "params": { + "runtimeInputs": [ + { + "id": "connectionId", + "label": "AWS Org Connection ID", + "type": "text", + "required": true, + "description": "The ID of the AWS integration connection for the organization management account." + }, + { + "id": "regions", + "label": "AWS Regions", + "type": "text", + "required": false, + "description": "Comma-separated AWS regions to scan. Defaults to us-east-1.", + "default": "us-east-1" + } + ] + }, + "inputOverrides": {} + } + } + }, + { + "id": "resolve-creds-1", + "type": "core.integration.resolve-credentials", + "position": { "x": 300, "y": 280 }, + "data": { + "label": "Resolve Org Credentials", + "config": { + "params": { "provider": "aws" }, + "inputOverrides": {} + } + } + }, + { + "id": "prowler-1", + "type": "security.prowler.scan", + "position": { "x": 300, "y": 510 }, + "data": { + "label": "Org-Wide Prowler Scan", + "config": { + "params": { + "orgScan": true, + "memberRoleName": "OrganizationAccountAccessRole", + "continueOnError": true, + "scanMode": "aws", + "recommendedFlags": ["severity-high-critical", "ignore-exit-code", "no-banner"] + }, + "inputOverrides": {} + } + } + }, + { + "id": "analytics-sink-1", + "type": "core.analytics.sink", + "position": { "x": 300, "y": 770 }, + "data": { + "label": "Analytics Sink", + "config": { + "params": { + "dataInputs": [ + { + "id": "input1", + "label": "Org Prowler Results", + "sourceTag": "prowler-org" + } + ], + "failOnError": false + }, + "inputOverrides": {} + } + } + } + ], + "edges": [ + { + "id": "e-entry-to-resolve", + "source": "entry-1", + "target": "resolve-creds-1", + "sourceHandle": "connectionId", + "targetHandle": "connectionId" + }, + { + "id": "e-resolve-to-prowler-creds", + "source": "resolve-creds-1", + "target": "prowler-1", + "sourceHandle": "data", + "targetHandle": "credentials" + }, + { + "id": "e-resolve-to-prowler-account", + "source": "resolve-creds-1", + "target": "prowler-1", + "sourceHandle": "accountId", + "targetHandle": "accountId" + }, + { + "id": "e-entry-to-prowler-regions", + "source": "entry-1", + "target": "prowler-1", + "sourceHandle": "regions", + "targetHandle": "regions" + }, + { + "id": "e-prowler-to-analytics", + "source": "prowler-1", + "target": "analytics-sink-1", + "sourceHandle": "results", + "targetHandle": "input1" + } + ], + "viewport": { "x": 0, "y": 0, "zoom": 1 } +} diff --git a/docs/sample/aws-cspm-prowler-slack-summary.json b/docs/sample/aws-cspm-prowler-slack-summary.json new file mode 100644 index 00000000..17726896 --- /dev/null +++ b/docs/sample/aws-cspm-prowler-slack-summary.json @@ -0,0 +1,191 @@ +{ + "name": "AWS CSPM – Prowler Scan to Slack Summary", + "description": "Resolves AWS credentials from an integration connection, runs a Prowler security scan (high+critical severity), and sends a summary of findings to a Slack channel via an integration connection (OAuth or webhook).", + "nodes": [ + { + "id": "entry-1", + "type": "core.workflow.entrypoint", + "position": { "x": 300, "y": 50 }, + "data": { + "label": "Entry Point", + "config": { + "params": { + "runtimeInputs": [ + { + "id": "connectionId", + "label": "AWS Integration Connection ID", + "type": "text", + "required": true, + "description": "The ID of the AWS integration connection. Find this in Settings > Integrations > AWS." + }, + { + "id": "regions", + "label": "AWS Regions", + "type": "text", + "required": false, + "description": "Comma-separated AWS regions to scan. Defaults to us-east-1.", + "default": "us-east-1" + }, + { + "id": "slackConnectionId", + "label": "Slack Integration Connection ID", + "type": "text", + "required": true, + "description": "The ID of the Slack integration connection (OAuth or webhook). Find this in Settings > Integrations > Slack." + }, + { + "id": "slackChannel", + "label": "Slack Channel", + "type": "text", + "required": false, + "description": "Slack channel to post to (e.g. #security-alerts). Required for OAuth connections, ignored for webhook connections.", + "default": "#general" + }, + { + "id": "dashboardBaseUrl", + "label": "Dashboard Base URL", + "type": "text", + "required": false, + "description": "Base URL of your deployment (e.g. https://your-domain). Used to build the analytics dashboard link in the Slack notification.", + "default": "http://localhost" + } + ] + }, + "inputOverrides": {} + } + } + }, + { + "id": "resolve-creds-1", + "type": "core.integration.resolve-credentials", + "position": { "x": 450, "y": 280 }, + "data": { + "label": "Resolve AWS Credentials", + "config": { + "params": { "provider": "aws" }, + "inputOverrides": {} + } + } + }, + { + "id": "resolve-slack-creds-1", + "type": "core.integration.resolve-credentials", + "position": { "x": 100, "y": 550 }, + "data": { + "label": "Resolve Slack Credentials", + "config": { + "params": { "provider": "slack" }, + "inputOverrides": {} + } + } + }, + { + "id": "prowler-1", + "type": "security.prowler.scan", + "position": { "x": 450, "y": 510 }, + "data": { + "label": "Prowler Security Scan", + "config": { + "params": { + "scanMode": "aws", + "recommendedFlags": ["severity-high-critical", "ignore-exit-code", "no-banner"] + }, + "inputOverrides": {} + } + } + }, + { + "id": "slack-1", + "type": "core.notification.slack", + "position": { "x": 300, "y": 770 }, + "data": { + "label": "Slack Scan Summary", + "config": { + "params": { + "authType": "credentials", + "variables": [ + { "name": "scanSummary", "type": "json" }, + { "name": "dashboardBaseUrl", "type": "string" } + ] + }, + "inputOverrides": { + "text": "🔒 *Prowler Security Scan Complete*\n\n📊 *Summary*\n• Total findings: {{totalFindings}}\n• Failed: {{failed}} | Passed: {{passed}}\n• Regions: {{regions}}\n\n🔴 Critical: {{critical}} | 🟠 High: {{high}} | 🟡 Medium: {{medium}} | 🟢 Low: {{low}}\n\n📈 <{{dashboardBaseUrl}}/analytics/app/data-explorer/discover/#?_a=(discover%3A(columns%3A!(_source)%2Cinterval%3Aauto%2Csort%3A!())%2Cmetadata%3A(indexPattern%3A'security-findings-*'%2Cview%3Adiscover))&_q=(query%3A(language%3Akuery%2Cquery%3A'shipsec.run_id.keyword%3A%22{{runId}}%22'))&_g=(filters%3A!()%2CrefreshInterval%3A(pause%3A!t%2Cvalue%3A0)%2Ctime%3A(from%3Anow-1y%2Cto%3Anow))|View Findings in Dashboard>\n\nGenerated at {{generatedAt}}" + } + } + } + } + ], + "edges": [ + { + "id": "e-entry-to-resolve", + "source": "entry-1", + "target": "resolve-creds-1", + "sourceHandle": "connectionId", + "targetHandle": "connectionId" + }, + { + "id": "e-entry-to-resolve-regions", + "source": "entry-1", + "target": "resolve-creds-1", + "sourceHandle": "regions", + "targetHandle": "regions" + }, + { + "id": "e-entry-to-slack-resolver", + "source": "entry-1", + "target": "resolve-slack-creds-1", + "sourceHandle": "slackConnectionId", + "targetHandle": "connectionId" + }, + { + "id": "e-resolve-to-prowler-creds", + "source": "resolve-creds-1", + "target": "prowler-1", + "sourceHandle": "data", + "targetHandle": "credentials" + }, + { + "id": "e-resolve-to-prowler-account", + "source": "resolve-creds-1", + "target": "prowler-1", + "sourceHandle": "accountId", + "targetHandle": "accountId" + }, + { + "id": "e-entry-to-prowler-regions", + "source": "entry-1", + "target": "prowler-1", + "sourceHandle": "regions", + "targetHandle": "regions" + }, + { + "id": "e-slack-resolver-to-slack", + "source": "resolve-slack-creds-1", + "target": "slack-1", + "sourceHandle": "data", + "targetHandle": "credentials" + }, + { + "id": "e-entry-to-slack-channel", + "source": "entry-1", + "target": "slack-1", + "sourceHandle": "slackChannel", + "targetHandle": "channel" + }, + { + "id": "e-prowler-to-slack-summary", + "source": "prowler-1", + "target": "slack-1", + "sourceHandle": "summary", + "targetHandle": "scanSummary" + }, + { + "id": "e-entry-to-slack-dashboard-url", + "source": "entry-1", + "target": "slack-1", + "sourceHandle": "dashboardBaseUrl", + "targetHandle": "dashboardBaseUrl" + } + ], + "viewport": { "x": 0, "y": 0, "zoom": 1 } +} diff --git a/docs/sample/aws-cspm-prowler-to-analytics.json b/docs/sample/aws-cspm-prowler-to-analytics.json new file mode 100644 index 00000000..4ff7b85e --- /dev/null +++ b/docs/sample/aws-cspm-prowler-to-analytics.json @@ -0,0 +1,122 @@ +{ + "name": "AWS CSPM – Prowler Scan to Analytics", + "description": "End-to-end AWS CSPM workflow: resolves credentials from an integration connection, runs a Prowler security scan, and indexes the results into the Analytics dashboard via OpenSearch.", + "nodes": [ + { + "id": "entry-1", + "type": "core.workflow.entrypoint", + "position": { "x": 300, "y": 50 }, + "data": { + "label": "Entry Point", + "config": { + "params": { + "runtimeInputs": [ + { + "id": "connectionId", + "label": "AWS Integration Connection ID", + "type": "text", + "required": true, + "description": "The ID of the AWS integration connection. Find this in Settings > Integrations > AWS." + }, + { + "id": "regions", + "label": "AWS Regions", + "type": "text", + "required": false, + "description": "Comma-separated AWS regions to scan. Defaults to us-east-1.", + "default": "us-east-1" + } + ] + }, + "inputOverrides": {} + } + } + }, + { + "id": "resolve-creds-1", + "type": "core.integration.resolve-credentials", + "position": { "x": 300, "y": 280 }, + "data": { + "label": "Resolve AWS Credentials", + "config": { + "params": { "provider": "aws" }, + "inputOverrides": {} + } + } + }, + { + "id": "prowler-1", + "type": "security.prowler.scan", + "position": { "x": 300, "y": 510 }, + "data": { + "label": "Prowler Security Scan", + "config": { + "params": { + "scanMode": "aws", + "recommendedFlags": ["severity-high-critical", "ignore-exit-code", "no-banner"] + }, + "inputOverrides": {} + } + } + }, + { + "id": "analytics-sink-1", + "type": "core.analytics.sink", + "position": { "x": 300, "y": 770 }, + "data": { + "label": "Analytics Sink", + "config": { + "params": { + "dataInputs": [ + { + "id": "input1", + "label": "Prowler Results", + "sourceTag": "prowler" + } + ], + "failOnError": false + }, + "inputOverrides": {} + } + } + } + ], + "edges": [ + { + "id": "e-entry-to-resolve", + "source": "entry-1", + "target": "resolve-creds-1", + "sourceHandle": "connectionId", + "targetHandle": "connectionId" + }, + { + "id": "e-resolve-to-prowler-creds", + "source": "resolve-creds-1", + "target": "prowler-1", + "sourceHandle": "data", + "targetHandle": "credentials" + }, + { + "id": "e-resolve-to-prowler-account", + "source": "resolve-creds-1", + "target": "prowler-1", + "sourceHandle": "accountId", + "targetHandle": "accountId" + }, + { + "id": "e-entry-to-prowler-regions", + "source": "entry-1", + "target": "prowler-1", + "sourceHandle": "regions", + "targetHandle": "regions" + }, + { + "id": "e-prowler-to-analytics", + "source": "prowler-1", + "target": "analytics-sink-1", + "sourceHandle": "results", + "targetHandle": "input1" + } + ], + "viewport": { "x": 0, "y": 0, "zoom": 1 } +} diff --git a/docs/user-guide.md b/docs/user-guide.md index 752e3e61..ae54bbb2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -190,6 +190,21 @@ Once complete, visit **http://localhost** to access ShipSec Studio. - Output formats - **Use**: Web application vulnerability scanning +#### Prowler Scan + +- **Purpose**: AWS cloud security posture management +- **Features**: + - Single-account AWS security scanning + - Org-wide multi-account scanning (discovers and scans all member accounts) + - Normalized ASFF findings with severity, resource ID, and remediation + - Analytics-ready output for dashboards +- **Configuration**: + - Scan mode (AWS account or multi-cloud overview) + - Org scan toggle with member role name, concurrency, and error handling + - Recommended flag presets (severity filter, ignore exit codes) + - Custom CLI flags +- **Use**: AWS CSPM, compliance scanning, org-wide security posture assessment + #### TruffleHog - **Purpose**: Secret detection in code diff --git a/frontend/.env.example b/frontend/.env.example index 8089b93e..88f10263 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,6 +5,11 @@ VITE_API_URL=http://localhost VITE_APP_NAME=Security Workflow Builder VITE_APP_VERSION=1.0.0 +# Public-facing app URL (used for OAuth redirect URIs, etc.) +# Falls back to window.location.origin if not set. +# Set this when using ngrok or a custom domain. +# VITE_APP_URL=https://your-ngrok-url.ngrok-free.app + # Git commit information (injected at build time) # Example: VITE_GIT_SHA=$(git rev-parse HEAD) VITE_GIT_SHA= diff --git a/frontend/public/icons/aws.png b/frontend/public/icons/aws.png new file mode 100644 index 00000000..8c6b81cb Binary files /dev/null and b/frontend/public/icons/aws.png differ diff --git a/frontend/public/icons/github.svg b/frontend/public/icons/github.svg new file mode 100644 index 00000000..1c233af3 --- /dev/null +++ b/frontend/public/icons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/jira.svg b/frontend/public/icons/jira.svg new file mode 100644 index 00000000..7757cd70 --- /dev/null +++ b/frontend/public/icons/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/slack.svg b/frontend/public/icons/slack.svg new file mode 100644 index 00000000..75b14013 --- /dev/null +++ b/frontend/public/icons/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 87fea899..57fa2491 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { WorkflowBuilder } from '@/features/workflow-builder/WorkflowBuilder'; import { SecretsManager } from '@/pages/SecretsManager'; import { ApiKeysManager } from '@/pages/ApiKeysManager'; import { IntegrationsManager } from '@/pages/IntegrationsManager'; +import { IntegrationDetailPage } from '@/pages/IntegrationDetailPage'; import { ArtifactLibrary } from '@/pages/ArtifactLibrary'; import { McpLibraryPage } from '@/pages/McpLibraryPage'; import { IntegrationCallback } from '@/pages/IntegrationCallback'; @@ -79,6 +80,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/workflow/ConfigPanel.tsx b/frontend/src/components/workflow/ConfigPanel.tsx index 2278fedb..c6fa5f3c 100644 --- a/frontend/src/components/workflow/ConfigPanel.tsx +++ b/frontend/src/components/workflow/ConfigPanel.tsx @@ -884,6 +884,7 @@ export function ConfigPanel({ parameters={manualParameters} onUpdateParameter={handleParamValueChange} allComponentParameters={componentParameters} + nodeLabel={nodeData.label} /> ); diff --git a/frontend/src/components/workflow/ParameterField.tsx b/frontend/src/components/workflow/ParameterField.tsx index 44e4c7ea..2baf2ed8 100644 --- a/frontend/src/components/workflow/ParameterField.tsx +++ b/frontend/src/components/workflow/ParameterField.tsx @@ -18,10 +18,11 @@ import type { Parameter } from '@/schemas/component'; import type { InputMapping } from '@/schemas/node'; import { useSecretStore } from '@/store/secretStore'; import { useIntegrationStore } from '@/store/integrationStore'; -import { getCurrentUserId } from '@/lib/currentUser'; +// D17: getCurrentUserId no longer needed for connection fetching import { useArtifactStore } from '@/store/artifactStore'; import { env } from '@/config/env'; import { api } from '@/services/api'; +import type { IntegrationConnection } from '@/services/api'; import { useWorkflowStore } from '@/store/workflowStore'; import { Dialog, @@ -41,6 +42,7 @@ interface ParameterFieldProps { componentId?: string; parameters?: Record | undefined; onUpdateParameter?: (paramId: string, value: any) => void; + nodeLabel?: string; } /** @@ -54,6 +56,7 @@ export function ParameterField({ componentId, parameters, onUpdateParameter, + nodeLabel, }: ParameterFieldProps) { const currentValue = value !== undefined ? value : parameter.default; const [jsonError, setJsonError] = useState(null); @@ -65,12 +68,12 @@ export function ParameterField({ const fetchSecrets = useSecretStore((state) => state.fetchSecrets); const refreshSecrets = useSecretStore((state) => state.refresh); - const integrationConnections = useIntegrationStore((state) => state.connections); - const fetchIntegrationConnections = useIntegrationStore((state) => state.fetchConnections); + const fetchMergedConnections = useIntegrationStore((state) => state.fetchMergedConnections); const integrationLoading = useIntegrationStore((state) => state.loadingConnections); const integrationError = useIntegrationStore((state) => state.error); - const currentUserId = useMemo(() => getCurrentUserId(), []); + // D17: merged connections from both user-scoped and org-scoped endpoints + const [mergedConnections, setMergedConnections] = useState([]); const hasFetchedConnectionsRef = useRef(false); const autoSelectedConnectionRef = useRef(false); @@ -89,18 +92,45 @@ export function ParameterField({ const isRemoveGithubComponent = componentId === 'github.org.membership.remove'; const isProviderGithubComponent = componentId === 'github.connection.provider'; const isGitHubConnectionComponent = isRemoveGithubComponent || isProviderGithubComponent; - const isConnectionSelector = isGitHubConnectionComponent && parameter.id === 'connectionId'; + const isGenericCredentialResolver = componentId === 'core.integration.resolve-credentials'; + const isConnectionSelector = + (isGitHubConnectionComponent || isGenericCredentialResolver) && parameter.id === 'connectionId'; const isGithubConnectionMode = isRemoveGithubComponent && authModeFromParameters === 'connection'; const currentBuilderWorkflowId = useWorkflowStore((state) => state.metadata.id); const isWorkflowCallComponent = componentId === 'core.workflow.call'; const isWorkflowSelector = isWorkflowCallComponent && parameter.id === 'workflowId'; + // D17: use merged connections (combines user-scoped and org-scoped) const githubConnections = useMemo( - () => integrationConnections.filter((connection) => connection.provider === 'github'), - [integrationConnections], + () => mergedConnections.filter((connection) => connection.provider === 'github'), + [mergedConnections], ); + // For the generic credential resolver, filter by explicit `provider` param or infer from node label + const credResolverProviderFilter = useMemo(() => { + if (!isGenericCredentialResolver) return undefined; + // 1. Explicit provider parameter takes priority + const map = (parameters ?? {}) as Record; + const explicit = typeof map?.provider === 'string' ? map.provider.trim().toLowerCase() : ''; + if (explicit) return explicit; + // 2. Infer from node label (e.g. "Resolve AWS Credentials" → "aws") + const KNOWN_PROVIDERS = ['aws', 'slack', 'github']; + const label = (nodeLabel ?? '').toLowerCase(); + return KNOWN_PROVIDERS.find((p) => label.includes(p)); + }, [isGenericCredentialResolver, parameters, nodeLabel]); + + const filteredConnections = useMemo( + () => + credResolverProviderFilter + ? mergedConnections.filter((c) => c.provider === credResolverProviderFilter) + : mergedConnections, + [mergedConnections, credResolverProviderFilter], + ); + + // Pick the right list depending on the component + const connectionOptions = isGenericCredentialResolver ? filteredConnections : githubConnections; + const [workflowOptions, setWorkflowOptions] = useState<{ id: string; name: string }[]>([]); const [workflowOptionsLoading, setWorkflowOptionsLoading] = useState(false); const [workflowOptionsError, setWorkflowOptionsError] = useState(null); @@ -182,10 +212,13 @@ export function ParameterField({ return; } hasFetchedConnectionsRef.current = true; - fetchIntegrationConnections(currentUserId).catch((error) => { - console.error('Failed to load integration connections', error); - }); - }, [isConnectionSelector, fetchIntegrationConnections, currentUserId]); + // D17: fetch merged connections (user-scoped + org-scoped, deduped) + fetchMergedConnections() + .then((merged) => setMergedConnections(merged)) + .catch((error) => { + console.error('Failed to load integration connections', error); + }); + }, [isConnectionSelector, fetchMergedConnections]); useEffect(() => { if (!isConnectionSelector || integrationLoading) { @@ -200,8 +233,8 @@ export function ParameterField({ return; } - if (githubConnections.length === 1 && !autoSelectedConnectionRef.current) { - const [firstConnection] = githubConnections; + if (connectionOptions.length === 1 && !autoSelectedConnectionRef.current) { + const [firstConnection] = connectionOptions; if (firstConnection) { autoSelectedConnectionRef.current = true; onChange(firstConnection.id); @@ -214,7 +247,7 @@ export function ParameterField({ } }, [ isConnectionSelector, - githubConnections, + connectionOptions, integrationLoading, currentValue, onChange, @@ -224,7 +257,8 @@ export function ParameterField({ const handleRefreshConnections = async () => { try { - await fetchIntegrationConnections(currentUserId, true); + const merged = await fetchMergedConnections(); + setMergedConnections(merged); } catch (error) { console.error('Failed to refresh integration connections', error); } @@ -361,7 +395,6 @@ export function ParameterField({ onChange={(event) => { autoSelectedConnectionRef.current = true; const nextValue = event.target.value; - console.log('Selected GitHub connection ID:', nextValue); if (nextValue === '') { onChange(undefined); if (isRemoveGithubComponent) { @@ -379,10 +412,12 @@ export function ParameterField({ className="w-full px-3 py-2 text-sm border rounded-md bg-background" disabled={disabled} > - - {githubConnections.map((connection) => ( + + {connectionOptions.map((connection) => ( ))} @@ -393,9 +428,11 @@ export function ParameterField({ {integrationError &&

{integrationError}

} - {!integrationLoading && githubConnections.length === 0 && ( + {!integrationLoading && connectionOptions.length === 0 && (

- No active GitHub connections yet. Connect GitHub from the Connections manager. + {isGenericCredentialResolver + ? 'No active connections yet. Add a connection from the Integrations page.' + : 'No active GitHub connections yet. Connect GitHub from the Connections manager.'}

)} @@ -550,6 +587,7 @@ export function ParameterField({ ); case 'boolean': + case 'toggle': return (