From bf40687d8adb153aa92ad219e115436ad67b2b26 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Wed, 11 Feb 2026 20:53:44 -0500 Subject: [PATCH 1/5] feat(integrations): add first-class AWS and Slack integrations with redesigned UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive integrations module with AWS and Slack as first-class providers, including a redesigned frontend, new backend services, worker components, and sample CSPM workflows. Backend: - Expand integration_tokens schema with org-scoped fields, health tracking, credential types (api_key, iam_role, webhook, oauth), and display names - Add @InternalOnly() guard for internal-only endpoints (X-Internal-Token) - Add AwsService (STS AssumeRole, Organizations discovery) and SlackService (webhook messaging, OAuth token exchange) - Add integration catalog with static provider definitions and setup instructions - Add SetupTokenService and ExternalIdGenerator for secure onboarding flows - Expand controller with catalog, org-connections, AWS/Slack creation, validation, credential resolution, and org-discovery endpoints - Enforce @CurrentAuth() on all user-facing routes (D16) - Add assertConnectionOwnership for org-bound authorization (D18) Frontend: - Redesign IntegrationsManager as provider card grid with connection counts - Add IntegrationDetailPage with setup instructions, connection table, and provider-specific forms (AWS access key/IAM role, Slack webhook/OAuth) - Update integrationStore with org-scoped connections, merge-and-dedup (D17), catalog fetching, and provider-specific create/validate/test actions - Update API service with direct fetch calls for new endpoints - Update ParameterField to use merged connections for workflow selectors Worker: - Add integration-credential-resolver component (resolves creds via internal API) - Add aws-org-discovery component (lists AWS Organization accounts) - Add aws-assume-role component (STS AssumeRole for cross-account scanning) Sample workflows: - AWS CSPM Org Account Discovery (resolve creds → discover accounts) - AWS CSPM Prowler to Analytics (resolve creds → Prowler scan → Analytics Sink) Signed-off-by: Aseem Shrey --- backend/.env.example | 9 + .../0020_expand-integration-tokens.sql | 62 + backend/package.json | 5 +- backend/scripts/seed-aws-cspm-workflow.ts | 61 + .../src/common/guards/internal-only.guard.ts | 77 + backend/src/database/schema/integrations.ts | 19 +- backend/src/integrations/aws.service.ts | 177 +++ .../src/integrations/external-id-generator.ts | 109 ++ .../src/integrations/integration-catalog.ts | 175 +++ .../src/integrations/integration-providers.ts | 26 + .../integrations/integrations.controller.ts | 311 +++- backend/src/integrations/integrations.dto.ts | 163 ++- .../src/integrations/integrations.module.ts | 12 +- .../integrations/integrations.repository.ts | 152 +- .../src/integrations/integrations.service.ts | 451 +++++- .../src/integrations/setup-token.service.ts | 75 + backend/src/integrations/slack.service.ts | 116 ++ backend/src/main.ts | 7 + bun.lock | 160 +- docs/sample/aws-cspm-org-discovery.json | 108 ++ .../sample/aws-cspm-prowler-to-analytics.json | 122 ++ frontend/.env.example | 5 + frontend/public/icons/aws.png | Bin 0 -> 123445 bytes frontend/public/icons/github.svg | 3 + frontend/public/icons/jira.svg | 15 + frontend/public/icons/slack.svg | 6 + frontend/src/App.tsx | 2 + .../components/workflow/ParameterField.tsx | 59 +- frontend/src/config/env.ts | 2 + frontend/src/pages/IntegrationCallback.tsx | 10 +- frontend/src/pages/IntegrationDetailPage.tsx | 1283 +++++++++++++++++ frontend/src/pages/IntegrationsManager.tsx | 1048 +++----------- frontend/src/services/api.ts | 229 ++- frontend/src/store/integrationStore.ts | 215 ++- worker/package.json | 2 + worker/src/components/core/aws-assume-role.ts | 133 ++ .../src/components/core/aws-org-discovery.ts | 106 ++ .../core/integration-credential-resolver.ts | 222 +++ worker/src/components/index.ts | 3 + .../src/components/security/prowler-scan.ts | 13 +- worker/src/temporal/utils/component-output.ts | 3 + 41 files changed, 4704 insertions(+), 1052 deletions(-) create mode 100644 backend/drizzle/0020_expand-integration-tokens.sql create mode 100644 backend/scripts/seed-aws-cspm-workflow.ts create mode 100644 backend/src/common/guards/internal-only.guard.ts create mode 100644 backend/src/integrations/aws.service.ts create mode 100644 backend/src/integrations/external-id-generator.ts create mode 100644 backend/src/integrations/integration-catalog.ts create mode 100644 backend/src/integrations/setup-token.service.ts create mode 100644 backend/src/integrations/slack.service.ts create mode 100644 docs/sample/aws-cspm-org-discovery.json create mode 100644 docs/sample/aws-cspm-prowler-to-analytics.json create mode 100644 frontend/public/icons/aws.png create mode 100644 frontend/public/icons/github.svg create mode 100644 frontend/public/icons/jira.svg create mode 100644 frontend/public/icons/slack.svg create mode 100644 frontend/src/pages/IntegrationDetailPage.tsx create mode 100644 worker/src/components/core/aws-assume-role.ts create mode 100644 worker/src/components/core/aws-org-discovery.ts create mode 100644 worker/src/components/core/integration-credential-resolver.ts diff --git a/backend/.env.example b/backend/.env.example index e2c3f27b..c4a7b2fb 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 +# SLACK_OAUTH_SCOPES="" 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..c8e9b629 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,8 @@ "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": { "@clerk/backend": "^2.29.5", @@ -29,6 +30,8 @@ "@nestjs/platform-express": "^10.4.22", "@nestjs/swagger": "^11.2.5", "@nestjs/throttler": "^6.5.0", + "@aws-sdk/client-organizations": "^3.750.0", + "@aws-sdk/client-sts": "^3.750.0", "@opensearch-project/opensearch": "^3.5.1", "@shipsec/backend-client": "workspace:*", "@shipsec/component-sdk": "workspace:*", 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/database/schema/integrations.ts b/backend/src/database/schema/integrations.ts index c10e89f1..67e00eeb 100644 --- a/backend/src/database/schema/integrations.ts +++ b/backend/src/database/schema/integrations.ts @@ -2,11 +2,11 @@ import { index, jsonb, pgTable, + text, timestamp, uniqueIndex, uuid, varchar, - text, } from 'drizzle-orm/pg-core'; export const integrationTokens = pgTable( @@ -15,6 +15,9 @@ export const integrationTokens = pgTable( id: uuid('id').primaryKey().defaultRandom(), userId: varchar('user_id', { length: 191 }).notNull(), provider: varchar('provider', { length: 64 }).notNull(), + credentialType: varchar('credential_type', { length: 32 }).notNull().default('oauth'), + displayName: varchar('display_name', { length: 191 }).notNull(), + organizationId: varchar('organization_id', { length: 255 }).notNull(), scopes: jsonb('scopes').$type().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..351f1a24 --- /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.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..929a1846 --- /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 with a 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..eca03797 100644 --- a/backend/src/integrations/integration-providers.ts +++ b/backend/src/integrations/integration-providers.ts @@ -41,6 +41,16 @@ 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', + ]; + return { github: { id: 'github', @@ -82,6 +92,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,171 @@ 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); + return this.integrations.validateConnection(id); + } + + /* ------------------------------------------------------------------ */ + /* 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..80aebdae 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,37 @@ 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; + }, + ): Promise { + await this.db + .update(integrationTokens) + .set({ + lastValidatedAt: health.lastValidatedAt, + lastValidationStatus: health.lastValidationStatus, + lastValidationError: health.lastValidationError ?? null, + updatedAt: new Date(), + }) + .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 +250,7 @@ export class IntegrationsRepository { state: payload.state, userId: payload.userId, provider: payload.provider, + organizationId: payload.organizationId ?? null, codeVerifier: payload.codeVerifier ?? null, }) .onConflictDoUpdate({ @@ -119,6 +258,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..80a2797f 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, }); @@ -271,8 +317,13 @@ export class IntegrationsService implements OnModuleInit { scopes?: string[]; }, ): Promise { + this.logger.log( + `[completeOAuth] Starting exchange for provider=${providerId}, redirectUri=${input.redirectUri}`, + ); const provider = await this.resolveProviderForAuth(providerId); + this.logger.log(`[completeOAuth] Provider resolved: ${provider.id}`); const stateRecord = await this.repository.consumeOAuthState(input.state); + this.logger.log(`[completeOAuth] State consumed: ${!!stateRecord}`); if (!stateRecord) { throw new BadRequestException('OAuth state is missing or has already been used'); @@ -284,8 +335,10 @@ export class IntegrationsService implements OnModuleInit { throw new BadRequestException('OAuth state does not match the provider'); } + const organizationId = stateRecord.organizationId ?? `workspace-${input.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, @@ -293,34 +346,51 @@ export class IntegrationsService implements OnModuleInit { codeVerifier: stateRecord.codeVerifier, scopes, }); + this.logger.log( + `[completeOAuth] Token response received, keys: ${Object.keys(rawResponse).join(', ')}`, + ); const persisted = await this.persistTokenResponse({ userId: input.userId, + organizationId, + credentialType: 'oauth', + displayName: providerId, provider, scopes, rawResponse, - previous: await this.repository.findByProvider(input.userId, providerId), + previous: await this.repository.findByUserAndProvider(input.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 +435,321 @@ 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 { + // For OAuth or 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 +769,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 +802,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 +842,10 @@ export class IntegrationsService implements OnModuleInit { return metadata as Record; } + /* ------------------------------------------------------------------ */ + /* Private helpers: PKCE */ + /* ------------------------------------------------------------------ */ + private generateCodeVerifier(): string { return randomBytes(32).toString('base64url'); } @@ -450,6 +854,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 +947,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 +986,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 +1047,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 +1060,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..e2ade5fe --- /dev/null +++ b/backend/src/integrations/slack.service.ts @@ -0,0 +1,116 @@ +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', + }; + } + } + + /** + * 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 = 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 = 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..bd31553e 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", @@ -254,7 +256,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 +355,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.986.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.986.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-t3uDpFtbUm4gW92EUJd0eEJW2K5FAeePBgbiAHg88mNTaKzLvHj3C96Kxc3yUg1xHH6XNKjEm+8yCuQ7LDrdPw=="], + "@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.986.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.986.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-MKL3OSaUXJ4Xftl+sm7n7o8pJmX5FQgIFc/suWPoNvKEdMJOC+/+2DYjjpASw5X89LL4+9xL2BHhz0y32m5FYw=="], - "@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 +401,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.986.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-Mqi79L38qi1gCG3adlVdbNrSxvcm1IPDLiJPA3OBypY5ewxUyWbaA3DD4goG+EwET6LSFgZJcRSIh6KBNpP5pA=="], "@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 +929,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 +959,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 +969,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 +985,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 +1001,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=="], @@ -3119,7 +3127,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=="], @@ -3245,10 +3293,20 @@ "@shipsec/studio-frontend/ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="], + "@shipsec/studio-worker/@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=="], + + "@shipsec/studio-worker/@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=="], + "@shipsec/studio-worker/@googleapis/admin": ["@googleapis/admin@29.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-lujnbfmDn1aetoJBDeExH4IDKniMZs7Ga8hGagN/lecO8hd0fy9hu+osROp0HFk6u2wCAiA89oXi5qHVXupbOQ=="], "@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=="], @@ -3511,6 +3569,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=="], @@ -3621,6 +3703,10 @@ "@shipsec/studio-frontend/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + "@shipsec/studio-worker/@aws-sdk/client-organizations/@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=="], + + "@shipsec/studio-worker/@aws-sdk/client-sts/@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=="], + "@types/express/@types/express-serve-static-core/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -3761,6 +3847,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 +3883,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/docs/sample/aws-cspm-org-discovery.json b/docs/sample/aws-cspm-org-discovery.json new file mode 100644 index 00000000..227ebb4f --- /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": {}, + "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-prowler-to-analytics.json b/docs/sample/aws-cspm-prowler-to-analytics.json new file mode 100644 index 00000000..69a4cb9f --- /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": {}, + "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/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 0000000000000000000000000000000000000000..8c6b81cbd733984df8ce1d7dd96690e6acefb4c2 GIT binary patch literal 123445 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

N?z5?Fv#3Eh8c- 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 literal 0 HcmV?d00001 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/ParameterField.tsx b/frontend/src/components/workflow/ParameterField.tsx index 44e4c7ea..f1b31ec1 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, @@ -65,12 +66,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 +90,27 @@ 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], ); + // All connections for the generic credential resolver (no provider filter) + const allConnections = useMemo(() => mergedConnections, [mergedConnections]); + + // Pick the right list depending on the component + const connectionOptions = isGenericCredentialResolver ? allConnections : githubConnections; + const [workflowOptions, setWorkflowOptions] = useState<{ id: string; name: string }[]>([]); const [workflowOptionsLoading, setWorkflowOptionsLoading] = useState(false); const [workflowOptionsError, setWorkflowOptionsError] = useState(null); @@ -182,10 +192,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 +213,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 +227,7 @@ export function ParameterField({ } }, [ isConnectionSelector, - githubConnections, + connectionOptions, integrationLoading, currentValue, onChange, @@ -224,7 +237,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 +375,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 +392,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 +408,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.'}

)} diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index a9eb9fa5..7c167b37 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -9,6 +9,7 @@ interface FrontendEnv { VITE_ENABLE_CONNECTIONS: boolean; VITE_ENABLE_IT_OPS: boolean; VITE_API_URL: string; + VITE_APP_URL: string; VITE_OPENSEARCH_DASHBOARDS_URL: string; } @@ -20,6 +21,7 @@ export const env: FrontendEnv = { VITE_ENABLE_CONNECTIONS: import.meta.env.VITE_ENABLE_CONNECTIONS === 'true', VITE_ENABLE_IT_OPS: import.meta.env.VITE_ENABLE_IT_OPS === 'true', VITE_API_URL: (import.meta.env.VITE_API_URL as string | undefined) ?? '', + VITE_APP_URL: (import.meta.env.VITE_APP_URL as string | undefined) ?? window.location.origin, VITE_OPENSEARCH_DASHBOARDS_URL: (import.meta.env.VITE_OPENSEARCH_DASHBOARDS_URL as string | undefined) ?? '', }; diff --git a/frontend/src/pages/IntegrationCallback.tsx b/frontend/src/pages/IntegrationCallback.tsx index 18ba1574..018dde5e 100644 --- a/frontend/src/pages/IntegrationCallback.tsx +++ b/frontend/src/pages/IntegrationCallback.tsx @@ -55,7 +55,7 @@ export function IntegrationCallback() { const authCode = code; const authState = state; - const redirectUri = `${window.location.origin}/integrations/callback/${providerId}`; + const redirectUri = `${env.VITE_APP_URL}/integrations/callback/${providerId}`; let cancelled = false; async function exchangeCode() { @@ -75,9 +75,7 @@ export function IntegrationCallback() { setStatus('success'); setMessage(`Connected to ${connection.providerName}. Redirecting…`); setTimeout(() => { - const target = env.VITE_ENABLE_CONNECTIONS - ? `/integrations?connected=${connection.provider}` - : '/'; + const target = env.VITE_ENABLE_CONNECTIONS ? `/integrations/${connection.provider}` : '/'; navigate(target, { replace: true }); }, 1200); } catch (error) { @@ -114,7 +112,9 @@ export function IntegrationCallback() { {status !== 'pending' && ( diff --git a/frontend/src/pages/IntegrationDetailPage.tsx b/frontend/src/pages/IntegrationDetailPage.tsx new file mode 100644 index 00000000..91f6f008 --- /dev/null +++ b/frontend/src/pages/IntegrationDetailPage.tsx @@ -0,0 +1,1283 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + ArrowLeft, + Plus, + Trash2, + RefreshCcw, + CheckCircle, + XCircle, + ExternalLink, + Loader2, + Shield, + MessageSquare, + Copy, + Check, + ChevronDown, + Info, + ChevronRight, + Hash, + Terminal, + Send, + Eye, +} from 'lucide-react'; + +import { env } from '@/config/env'; +import { useIntegrationStore } from '@/store/integrationStore'; +import { api } from '@/services/api'; +import type { IntegrationCatalogEntry, IntegrationConnection } from '@/services/api'; +import { useToast } from '@/components/ui/use-toast'; +import { cn } from '@/lib/utils'; + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Skeleton } from '@/components/ui/skeleton'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatTimestamp(iso: string | null | undefined): string { + if (!iso) return '\u2014'; + try { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(iso)); + } catch { + return iso; + } +} + +const PROVIDER_VISUALS: Record = { + aws: { + logo: '/icons/aws.png', + gradient: '', + borderAccent: 'border-orange-200 dark:border-orange-800', + }, + slack: { + logo: '/icons/slack.svg', + gradient: 'from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20', + borderAccent: 'border-purple-200 dark:border-purple-800', + }, +}; + +function credentialTypeBadge(type: string) { + const labels: Record = { + api_key: 'Access Key', + iam_role: 'IAM Role', + webhook: 'Webhook', + oauth: 'OAuth', + }; + return ( + + {labels[type] ?? type} + + ); +} + +function healthBadge(connection: IntegrationConnection) { + const status = connection.lastValidationStatus ?? connection.status; + if (status === 'active' || status === 'valid' || status === 'ok') { + return ( + + + Healthy + + ); + } + if (status === 'expired' || status === 'invalid' || status === 'error') { + return ( + + + {status === 'expired' ? 'Expired' : 'Unhealthy'} + + ); + } + return ( + + {connection.status} + + ); +} + +/** Renders step text with inline `code` segments styled as elements. */ +function renderStepText(text: string): React.ReactNode { + const parts = text.split(/(`[^`]+`)/g); + if (parts.length === 1) return text; + return parts.map((part, i) => { + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ); + } + return {part}; + }); +} + +// --------------------------------------------------------------------------- +// AWS Connection Form +// --------------------------------------------------------------------------- + +interface AwsFormProps { + onCreated: () => void; + onCancel: () => void; +} + +function AwsConnectionForm({ onCreated, onCancel }: AwsFormProps) { + const store = useIntegrationStore(); + const { toast } = useToast(); + const [step, setStep] = useState<1 | 2>(1); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + const [loadingSetup, setLoadingSetup] = useState(true); + const [setupInfo, setSetupInfo] = useState<{ + platformRoleArn: string; + externalId: string; + setupToken: string; + trustPolicyTemplate: string; + externalIdDisplay?: string; + } | null>(null); + const [copied, setCopied] = useState(false); + const [whyOpen, setWhyOpen] = useState(false); + + // Form fields + const [displayName, setDisplayName] = useState(''); + const [roleArn, setRoleArn] = useState(''); + const [region, setRegion] = useState(''); + + useEffect(() => { + store + .getAwsSetupInfo() + .then(setSetupInfo) + .catch((err) => + setResult({ + ok: false, + message: + err instanceof Error ? err.message : 'Failed to load setup info. Please try again.', + }), + ) + .finally(() => setLoadingSetup(false)); + }, []); + + const handleCopyPolicy = async () => { + if (!setupInfo) return; + try { + await navigator.clipboard.writeText(setupInfo.trustPolicyTemplate); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ + title: 'Copy failed', + description: 'Please select and copy the policy manually.', + variant: 'destructive', + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!setupInfo) return; + if (!displayName.trim() || !roleArn.trim()) { + setResult({ ok: false, message: 'Display Name and Role ARN are required.' }); + return; + } + setSubmitting(true); + setResult(null); + try { + await store.createAwsConnection({ + displayName: displayName.trim(), + roleArn: roleArn.trim(), + region: region.trim() || undefined, + externalId: setupInfo.externalId, + setupToken: setupInfo.setupToken, + }); + toast({ title: 'AWS connection created' }); + onCreated(); + } catch (err) { + setResult({ + ok: false, + message: err instanceof Error ? err.message : 'Failed to create connection.', + }); + } finally { + setSubmitting(false); + } + }; + + if (loadingSetup) { + return ( +
+ + Loading setup info... +
+ ); + } + + // Setup info failed — show error + cancel only + if (!setupInfo) { + return ( +
+ {result && ( +
+ + {result.message} +
+ )} + + + +
+ ); + } + + return ( +
+ {/* Step indicator */} +
+ + + +
+ + {/* ── Step 1: Trust Policy ── */} + {step === 1 && ( +
+
+ +

+ Create an IAM role in your AWS account and set its trust policy to the JSON below. + This allows ShipSec to securely access your account using a unique External ID — + without you sharing any access keys. +

+
+
+                {setupInfo.trustPolicyTemplate}
+              
+ +
+
+ + {/* Collapsible: Why IAM Role Assumption? */} +
+ + {whyOpen && ( +
+

+ IAM Role assumption with an External ID is the AWS-recommended + approach for granting cross-account access. Here is why it is safer than sharing + access keys: +

+
    +
  • + No long-lived credentials. Access keys are permanent secrets + that can be leaked or stolen. Role assumption uses temporary security tokens + (STS) that expire automatically. +
  • +
  • + Scoped permissions. The IAM role you create defines exactly + what ShipSec can do. You control the permission boundary, and can revoke access + at any time by deleting the role. +
  • +
  • + External ID prevents confused-deputy attacks. The unique + External ID in the trust policy ensures that only ShipSec — and + specifically your organization — can assume this role. No other party can + reuse this trust relationship. +
  • +
  • + Full audit trail. Every role assumption is logged in AWS + CloudTrail, giving you complete visibility into when and how ShipSec accesses + your account. +
  • +
+

+ For more details, see the{' '} + + AWS documentation on External IDs + + . +

+
+ )} +
+ + + + + +
+ )} + + {/* ── Step 2: Connection Details ── */} + {step === 2 && ( +
+
+

+ Enter the details of the IAM role you created in Step 1. +

+
+ + setDisplayName(e.target.value)} + placeholder="e.g. Production AWS Account" + disabled={submitting} + /> +
+
+ + setRoleArn(e.target.value)} + placeholder="arn:aws:iam::123456789012:role/ShipSecAuditRole" + disabled={submitting} + /> +
+
+ + setRegion(e.target.value)} + placeholder="e.g. us-east-1" + disabled={submitting} + /> +
+
+ + {result && ( +
+ {result.ok ? ( + + ) : ( + + )} + {result.message} +
+ )} + + + + + +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Slack Connection Form +// --------------------------------------------------------------------------- + +interface SlackFormProps { + onCreated: () => void; + onCancel: () => void; +} + +function SlackConnectionForm({ onCreated, onCancel }: SlackFormProps) { + const store = useIntegrationStore(); + const { toast } = useToast(); + const [tab, setTab] = useState('webhook'); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + + // Webhook fields + const [whDisplayName, setWhDisplayName] = useState(''); + const [whWebhookUrl, setWhWebhookUrl] = useState(''); + + const resetForm = () => { + setWhDisplayName(''); + setWhWebhookUrl(''); + setResult(null); + }; + + const handleWebhookSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setResult(null); + + try { + if (!whDisplayName.trim() || !whWebhookUrl.trim()) { + setResult({ ok: false, message: 'Display name and Webhook URL are required.' }); + setSubmitting(false); + return; + } + + const conn = await store.createSlackWebhookConnection({ + displayName: whDisplayName.trim(), + webhookUrl: whWebhookUrl.trim(), + }); + + // Auto-test + try { + const testResult = await store.testSlackConnection(conn.id); + if (testResult.ok) { + setResult({ + ok: true, + message: 'Webhook connection created and test message sent successfully.', + }); + } else { + setResult({ + ok: false, + message: `Connection created but test failed: ${testResult.error ?? 'Unknown error'}`, + }); + } + } catch { + setResult({ + ok: true, + message: 'Connection created. Test message could not be sent automatically.', + }); + } + + toast({ + title: 'Slack webhook connection created', + description: 'The webhook has been stored.', + }); + resetForm(); + onCreated(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create Slack connection.'; + setResult({ ok: false, message }); + } finally { + setSubmitting(false); + } + }; + + const handleOAuthConnect = async () => { + setSubmitting(true); + setResult(null); + try { + const redirectUri = `${env.VITE_APP_URL}/integrations/callback/slack`; + const response = await api.integrations.startOAuth('slack', { redirectUri }); + window.location.href = response.authorizationUrl; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to start Slack OAuth flow.'; + setResult({ ok: false, message }); + setSubmitting(false); + } + }; + + return ( +
+ + + + Webhook + + + OAuth + + + + +
+
+ + setWhDisplayName(e.target.value)} + placeholder="e.g. Security Alerts Channel" + disabled={submitting} + /> +
+
+ + setWhWebhookUrl(e.target.value)} + placeholder="https://hooks.slack.com/services/T.../B.../..." + autoComplete="off" + disabled={submitting} + /> +

+ Create an incoming webhook in your Slack workspace settings. +

+
+ + {result && tab === 'webhook' && ( +
+ {result.ok ? ( + + ) : ( + + )} + {result.message} +
+ )} + + + + + +
+
+ + + {/* Main CTA */} +
+
+ Slack +
+
+

Install ShipSec into your Slack workspace

+

+ You'll be redirected to Slack to authorize the app. +

+
+ +
+ + {/* Permissions breakdown */} +
+

+ Permissions requested +

+
+ {[ + { + icon: Eye, + scope: 'channels:read', + label: 'View channels', + desc: 'List public channels in your workspace', + }, + { + icon: MessageSquare, + scope: 'chat:write', + label: 'Send messages', + desc: 'Post messages to channels the bot is in', + }, + { + icon: Hash, + scope: 'chat:write.public', + label: 'Send to any channel', + desc: 'Post to channels without being a member', + }, + { + icon: Terminal, + scope: 'commands', + label: 'Slash commands', + desc: 'Respond to /shipsec commands', + }, + { + icon: Send, + scope: 'im:write', + label: 'Direct messages', + desc: 'Send DMs to users for alerts and notifications', + }, + ].map(({ icon: Icon, scope, label, desc }) => ( +
+ +
+
+ {label} + + {scope} + +
+

{desc}

+
+
+ ))} +
+
+ + {result && tab === 'oauth' && ( +
+ {result.ok ? ( + + ) : ( + + )} + {result.message} +
+ )} + + + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + +export function IntegrationDetailPage() { + const { provider } = useParams<{ provider: string }>(); + const navigate = useNavigate(); + const { toast } = useToast(); + + const store = useIntegrationStore(); + const { + catalog, + orgConnections, + loadingOrgConnections, + loadingCatalog, + fetchOrgConnections, + fetchCatalog, + validateAwsConnection, + testSlackConnection, + disconnect, + error, + resetError, + } = store; + + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [validatingId, setValidatingId] = useState(null); + + // Fetch catalog and connections on mount + useEffect(() => { + fetchCatalog(); + }, [fetchCatalog]); + + useEffect(() => { + if (provider) { + fetchOrgConnections(provider, true); + } + }, [provider, fetchOrgConnections]); + + // Show store errors as toasts + useEffect(() => { + if (error) { + toast({ title: 'Error', description: error, variant: 'destructive' }); + resetError(); + } + }, [error, toast, resetError]); + + const catalogEntry: IntegrationCatalogEntry | undefined = useMemo(() => { + return catalog.find((c) => c.id === provider); + }, [catalog, provider]); + + const connections = useMemo(() => { + if (!provider) return []; + return orgConnections.filter((c) => c.provider === provider); + }, [orgConnections, provider]); + + const visuals = PROVIDER_VISUALS[provider ?? '']; + + const healthyCount = useMemo(() => { + return connections.filter((c) => { + const s = c.lastValidationStatus ?? c.status; + return s === 'active' || s === 'valid' || s === 'ok'; + }).length; + }, [connections]); + + // ---- Actions ---- + + const handleValidate = async (connection: IntegrationConnection) => { + setValidatingId(connection.id); + try { + if (provider === 'aws') { + const result = await validateAwsConnection(connection.id); + if (result.valid) { + toast({ + title: 'Validation passed', + description: `${connection.displayName} credentials are valid.`, + }); + } else { + toast({ + title: 'Validation failed', + description: result.error ?? 'Credentials are invalid.', + variant: 'destructive', + }); + } + } else if (provider === 'slack') { + const result = await testSlackConnection(connection.id); + if (result.ok) { + toast({ + title: 'Test passed', + description: `Test message sent via ${connection.displayName}.`, + }); + } else { + toast({ + title: 'Test failed', + description: result.error ?? 'Could not deliver test message.', + variant: 'destructive', + }); + } + } + // Refresh the connections list to pick up updated lastValidationStatus + fetchOrgConnections(provider, true); + } catch (err) { + const message = err instanceof Error ? err.message : 'Validation request failed.'; + toast({ title: 'Error', description: message, variant: 'destructive' }); + } finally { + setValidatingId(null); + } + }; + + const confirmDelete = (connection: IntegrationConnection) => { + setDeleteTarget(connection); + setDeleteDialogOpen(true); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await disconnect(deleteTarget.id); + toast({ + title: 'Connection removed', + description: `${deleteTarget.displayName} has been deleted.`, + }); + setDeleteDialogOpen(false); + setDeleteTarget(null); + fetchOrgConnections(provider, true); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete connection.'; + toast({ title: 'Delete failed', description: message, variant: 'destructive' }); + } finally { + setDeleting(false); + } + }; + + const handleConnectionCreated = () => { + setDialogOpen(false); + fetchOrgConnections(provider, true); + }; + + // ---- Loading state ---- + + if (loadingCatalog && !catalogEntry) { + return ( +
+
+ + + + +
+
+ ); + } + + // ---- Unknown provider ---- + + if (!loadingCatalog && !catalogEntry) { + return ( +
+
+ + + + +

Provider not found

+

+ The integration provider "{provider}" is not available in the catalog. +

+
+
+
+
+ ); + } + + // ---- Render ---- + + const hasSetupSections = + catalogEntry?.setupInstructions?.sections && catalogEntry.setupInstructions.sections.length > 0; + + return ( +
+
+ {/* Navigation */} + + + {/* ── Provider Header ── */} +
+
+ {visuals ? ( + visuals.gradient ? ( +
+ {catalogEntry?.name +
+ ) : ( + {catalogEntry?.name + ) + ) : ( +
+ +
+ )} +
+

+ {catalogEntry?.name ?? provider} +

+

+ {catalogEntry?.description} +

+
+
+ +
+ {catalogEntry?.docsUrl && ( + + )} + + + + + + + + Add {catalogEntry?.name ?? provider} Connection + + Configure a new connection to {catalogEntry?.name ?? provider}. + + + + {provider === 'aws' && ( + setDialogOpen(false)} + /> + )} + {provider === 'slack' && ( + setDialogOpen(false)} + /> + )} + {provider !== 'aws' && provider !== 'slack' && ( +
+ No connection form is available for this provider yet. +
+ )} +
+
+
+
+ + {/* ── Connection Cards ── */} +
+

+ Connections + {connections.length > 0 && ( + + ({connections.length}) + + )} +

+ + {loadingOrgConnections ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ) : connections.length === 0 ? ( +
setDialogOpen(true)} + > +
+ +
+

No connections yet

+

+ Click to add your first {catalogEntry?.name ?? provider} connection. +

+
+ ) : ( +
+ {connections.map((conn) => { + const isValidating = validatingId === conn.id; + return ( + + +
+

+ {conn.displayName || '\u2014'} +

+ {healthBadge(conn)} +
+
+ {credentialTypeBadge(conn.credentialType)} +
+

+ Created {formatTimestamp(conn.createdAt)} +

+
+ + +
+
+
+ ); + })} +
+ )} +
+ + {/* ── Two-column bottom: Setup Instructions + Info Sidebar ── */} +
+ {/* Left column: Setup Instructions */} + {hasSetupSections && ( +
+ + + Setup + + Choose a scenario that matches your environment. + + + + + + {catalogEntry!.setupInstructions.sections.map((section) => ( + + {section.title} + + ))} + + + {catalogEntry!.setupInstructions.sections.map((section) => ( + +
    + {section.steps.map((step, stepIdx) => ( +
  1. + + {stepIdx + 1} + + {renderStepText(step)} +
  2. + ))} +
+
+ ))} +
+
+
+
+ )} + + {/* Right column: Info Sidebar */} +
+
+ {/* Documentation link */} + {catalogEntry?.docsUrl && ( + + + + + View {catalogEntry.name} Documentation + + + + )} + + {/* Connection stats */} + + +

Connection Summary

+
+
+

{connections.length}

+

Total

+
+
+

+ {healthyCount} +

+

Healthy

+
+
+
+
+ + {/* Auth methods */} + {catalogEntry?.authMethods && catalogEntry.authMethods.length > 0 && ( + + +

Supported Auth Methods

+
+ {catalogEntry.authMethods.map((method) => ( + + + {method.label} + + ))} +
+
+
+ )} +
+
+
+
+ + {/* Delete confirmation dialog */} + + + + Delete Connection + + Are you sure you want to delete "{deleteTarget?.displayName}"? This action + cannot be undone. Any workflows using this connection will stop working. + + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/IntegrationsManager.tsx b/frontend/src/pages/IntegrationsManager.tsx index b7d2c6ae..3ca79ca8 100644 --- a/frontend/src/pages/IntegrationsManager.tsx +++ b/frontend/src/pages/IntegrationsManager.tsx @@ -1,887 +1,249 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowRight, Plug, CheckCircle2, Circle } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useToast } from '@/components/ui/use-toast'; -import { - AlertCircle, - Check, - Copy, - KeyRound, - Loader2, - ExternalLink, - Plug, - RefreshCcw, - Trash2, -} from 'lucide-react'; - -import type { components } from '@shipsec/backend-client'; +import { Skeleton } from '@/components/ui/skeleton'; import { useIntegrationStore } from '@/store/integrationStore'; -import { getCurrentUserId } from '@/lib/currentUser'; -import { api } from '@/services/api'; -import { env } from '@/config/env'; - -type IntegrationProvider = components['schemas']['IntegrationProviderResponse']; -type IntegrationConnection = components['schemas']['IntegrationConnectionResponse']; - -function formatTimestamp(iso: string | null | undefined): string { - if (!iso) { - return '—'; - } - - try { - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(iso)); - } catch (error) { - console.error('Failed to format timestamp', error); - return iso; +import { cn } from '@/lib/utils'; + +/** Static metadata for the two D5 providers (AWS + Slack). */ +const PROVIDER_META: Record< + string, + { + logo: string; + route: string; + gradient: string; + borderAccent: string; + badgeClass: string; + category: string; } -} - -function getProviderConnection( - providerId: string, - connectionMap: Map, -): IntegrationConnection | undefined { - return connectionMap.get(providerId); -} +> = { + aws: { + logo: '/icons/aws.png', + route: '/integrations/aws', + gradient: '', + borderAccent: 'hover:border-orange-300 dark:hover:border-orange-700', + badgeClass: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + category: 'Cloud Security', + }, + slack: { + logo: '/icons/slack.svg', + route: '/integrations/slack', + gradient: 'from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20', + borderAccent: 'hover:border-purple-300 dark:hover:border-purple-700', + badgeClass: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + category: 'Notifications', + }, +}; + +/** IDs of the providers shown on this page, in display order. */ +const VISIBLE_PROVIDERS = ['aws', 'slack'] as const; export function IntegrationsManager() { const navigate = useNavigate(); - const location = useLocation(); - const userId = getCurrentUserId(); + const { - providers, - connections, - fetchProviders, - fetchConnections, - refreshConnection, - disconnect, - upsertConnection, - error, - resetError, - loadingProviders, - loadingConnections, + catalog, + orgConnections, + fetchCatalog, + fetchOrgConnections, + loadingCatalog, + loadingOrgConnections, } = useIntegrationStore(); - const { toast } = useToast(); - - const [configProvider, setConfigProvider] = useState(null); - const [connectingProvider, setConnectingProvider] = useState(null); - const [refreshingConnectionId, setRefreshingConnectionId] = useState(null); - const [deletingConnectionId, setDeletingConnectionId] = useState(null); - const [customScopes, setCustomScopes] = useState>({}); useEffect(() => { - fetchProviders().catch((err) => { - console.error('Failed to load providers', err); - }); - }, [fetchProviders]); + fetchCatalog(); + fetchOrgConnections(); + }, [fetchCatalog, fetchOrgConnections]); - useEffect(() => { - fetchConnections(userId).catch((err) => { - console.error('Failed to load integrations', err); - }); - }, [fetchConnections, userId]); - - useEffect(() => { - if (!error) { - return; + /** Map provider id -> number of org-scoped connections. */ + const connectionCounts = useMemo(() => { + const counts = new Map(); + for (const conn of orgConnections) { + counts.set(conn.provider, (counts.get(conn.provider) ?? 0) + 1); } + return counts; + }, [orgConnections]); - toast({ - title: 'Integration error', - description: error, - variant: 'destructive', - }); - }, [error, toast]); - - useEffect(() => { - const params = new URLSearchParams(location.search); - const connectedProvider = params.get('connected'); - if (connectedProvider) { - toast({ - title: 'Connection established', - description: `Successfully connected to ${connectedProvider}.`, - }); - params.delete('connected'); - navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); - } - }, [location.pathname, location.search, navigate, toast]); - - const connectionByProvider = useMemo(() => { - return new Map(connections.map((connection) => [connection.provider, connection])); - }, [connections]); - - const parseAdditionalScopes = (input: string | undefined): string[] => { - if (!input) { - return []; - } - return Array.from( - new Set( - input - .split(/[\s,]+/) - .map((scope) => scope.trim()) - .filter(Boolean), - ), - ); - }; + /** Catalog entries keyed by id for quick lookup. */ + const catalogById = useMemo(() => { + return new Map(catalog.map((entry) => [entry.id, entry])); + }, [catalog]); - const buildRequestedScopes = (provider: IntegrationProvider): string[] => { - const baseScopes = provider.defaultScopes ?? []; - const extraScopes = parseAdditionalScopes(customScopes[provider.id]); - return Array.from(new Set([...baseScopes, ...extraScopes])); - }; - - const handleConnect = async (provider: IntegrationProvider) => { - resetError(); - - if (!provider.isConfigured) { - setConfigProvider(provider); - return; - } - - setConnectingProvider(provider.id); - try { - const redirectUri = `${window.location.origin}/integrations/callback/${provider.id}`; - const response = await api.integrations.startOAuth(provider.id, { - userId, - redirectUri, - scopes: buildRequestedScopes(provider), - }); - window.location.href = response.authorizationUrl; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to start OAuth session'; - toast({ - title: 'Could not start OAuth flow', - description: message, - variant: 'destructive', - }); - } finally { - setConnectingProvider(null); - } - }; - - const handleConfigureProvider = (provider: IntegrationProvider) => { - resetError(); - setConfigProvider(provider); - }; - - const handleRefresh = async (connection: IntegrationConnection) => { - resetError(); - setRefreshingConnectionId(connection.id); - try { - await refreshConnection(connection.id, userId); - toast({ - title: 'Token refreshed', - description: `${connection.providerName} token has been refreshed.`, - }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to refresh token'; - toast({ - title: 'Refresh failed', - description: message, - variant: 'destructive', - }); - } finally { - setRefreshingConnectionId(null); - } - }; - - const handleDisconnect = async (connection: IntegrationConnection) => { - resetError(); - setDeletingConnectionId(connection.id); - try { - await disconnect(connection.id, userId); - toast({ - title: 'Connection removed', - description: `${connection.providerName} credentials have been deleted.`, - }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to disconnect'; - toast({ - title: 'Disconnect failed', - description: message, - variant: 'destructive', - }); - } finally { - setDeletingConnectionId(null); - } - }; - - const handleManualReconnect = (connection: IntegrationConnection) => { - const provider = providers.find((item) => item.id === connection.provider); - if (!provider) { - toast({ - title: 'Provider unavailable', - description: 'The provider is no longer configured.', - variant: 'destructive', - }); - return; - } - handleConnect(provider); - }; - - const handleProviderConfigCompleted = ( - action: 'saved' | 'deleted', - provider: IntegrationProvider, - ) => { - setConfigProvider(null); - - fetchProviders().catch((err) => { - console.error('Failed to refresh providers', err); - }); - - const title = - action === 'saved' - ? `${provider.name} credentials saved` - : `${provider.name} credentials removed`; - const description = - action === 'saved' - ? 'You can now start a new OAuth flow for this provider.' - : 'The provider will now rely on environment credentials if available.'; - - toast({ - title, - description, - }); - }; - - const handleConnectionComplete = (connection: IntegrationConnection) => { - upsertConnection(connection); - toast({ - title: `${connection.providerName} connected`, - description: 'Credentials stored successfully.', - }); - }; + const isLoading = loadingCatalog || loadingOrgConnections; return (
-
-
-

- Manage OAuth tokens for external providers. Connections are encrypted and can be - refreshed or revoked at any time. -

-
- -
-
-
-

Active connections

-

- Tokens are refreshed automatically when possible. You can also refresh or disconnect - manually. -

-
-
- - {connections.length === 0 ? ( -
- -

No active connections yet

-

- Connect a provider below to start using OAuth-protected APIs in your workflows. -

+
+ {/* Page header */} +
+
+
+
- ) : ( -
- - - - - - - - - - - - {connections.map((connection) => { - const isRefreshing = refreshingConnectionId === connection.id; - const isDeleting = deletingConnectionId === connection.id; - const provider = providers.find((item) => item.id === connection.provider); - const canRefresh = connection.supportsRefresh && connection.hasRefreshToken; - - return ( - - - - - - - - ); - })} - -
- Provider - - Status - - Scopes - - Expires - - Actions -
-
{connection.providerName}
-
{connection.userId}
-
- - {connection.status} - - -
- {connection.scopes.map((scope) => ( - - {scope} - - ))} - {connection.scopes.length === 0 && ( - (none) - )} -
-
- {formatTimestamp(connection.expiresAt ?? null)} - -
- - - -
-
-
- )} -
- -
-
-

Available providers

-

- Connect a provider to issue OAuth tokens and reuse them across your workflows. +

Integrations

+

+ Connect external services to enable cloud security scanning, notifications, and + more.

+
-
- {providers.map((provider) => { - const connection = getProviderConnection(provider.id, connectionByProvider); - const isConnecting = connectingProvider === provider.id; - const requestedScopes = buildRequestedScopes(provider); - const additionalScopesValue = customScopes[provider.id] ?? ''; - const configuredBadge = provider.isConfigured ? ( - Configured - ) : ( - Setup required - ); - - // Get colored logo URL using Logo.dev (recommended alternative to Clearbit Logo API) - // Reference: https://clearbit.com/blog/the-future-of-clearbits-free-tools - const getLogoUrl = (providerId: string) => { - // Map provider IDs to domain names for Logo.dev - const domainMap: Record = { - github: 'github.com', - zoom: 'zoom.us', - // Add more providers as needed: 'provider-id': 'domain.com' - }; - - const domain = domainMap[providerId.toLowerCase()]; - if (!domain) return null; - - // Logo.dev provides colored brand logos via CDN (free alternative to Clearbit) - // Read public key from environment variable - return `https://img.logo.dev/${domain}?token=${env.VITE_LOGO_DEV_PUBLIC_KEY}`; - }; + {/* Loading skeleton */} + {isLoading && ( +
+ {VISIBLE_PROVIDERS.map((id) => ( +
+
+ +
+ + + +
+
+
+ + +
+
+ ))} +
+ )} - const logoUrl = getLogoUrl(provider.id); + {/* Provider cards */} + {!isLoading && ( +
+ {VISIBLE_PROVIDERS.map((providerId) => { + const meta = PROVIDER_META[providerId]; + const entry = catalogById.get(providerId); + const count = connectionCounts.get(providerId) ?? 0; + const isConnected = count > 0; return (
navigate(meta.route)} > -
-
- {logoUrl && ( -
+ {/* Gradient accent top bar */} +
+ +
+ {/* Logo + Info */} +
+ {meta.gradient ? ( +
{`${provider.name} { - // Hide logo container if image fails to load - e.currentTarget.parentElement!.style.display = 'none'; - }} + src={meta.logo} + alt={entry?.name ?? providerId} + className="h-full w-full object-contain" />
+ ) : ( + {entry?.name )} -
-

{provider.name}

-

{provider.description}

+
+
+

+ {entry?.name ?? providerId.toUpperCase()} +

+
+

+ {entry?.description ?? 'Integration provider'} +

- {configuredBadge} -
- -
- {requestedScopes.map((scope) => ( - - {scope} - - ))} -
-
- - { - if (event.key === 'Enter') { - event.preventDefault(); - const normalized = parseAdditionalScopes(additionalScopesValue); - if (normalized.length > 0) { - setCustomScopes((prev) => ({ - ...prev, - [provider.id]: normalized.join(' '), - })); - toast({ - title: 'Scopes added', - description: `Queued ${normalized.join(', ')} for next connection attempt.`, - }); - } - } - }} - onChange={(event) => - setCustomScopes((prev) => ({ - ...prev, - [provider.id]: event.target.value, - })) - } - /> -

- Separate scopes with spaces or commas. Press Enter to normalize them. Default - scopes stay included automatically. -

-
- - {connection ? ( -

- Last updated {formatTimestamp(connection.updatedAt)} -

- ) : ( -

- No active token stored for this provider. -

- )} - -
- - + {/* Status + Action */} +
+
+ {isConnected ? ( + <> + + + {count} {count === 1 ? 'connection' : 'connections'} + + + ) : ( + <> + + Not configured + + )} +
+
+ Configure + +
+
- - {/* Docs button - absolute bottom right */} - {provider.docsUrl && ( - - )}
); })}
- - {(loadingProviders || loadingConnections) && ( -

Loading provider catalog…

- )} - -
- - setConfigProvider(null)} - onCompleted={handleProviderConfigCompleted} - /> - - {/* Hidden portal slot to inject new connections from callback */} - -
- ); -} - -interface ProviderConfigDialogProps { - provider: IntegrationProvider | null; - open: boolean; - onClose: () => void; - onCompleted: (action: 'saved' | 'deleted', provider: IntegrationProvider) => void; -} - -function ProviderConfigDialog({ provider, open, onClose, onCompleted }: ProviderConfigDialogProps) { - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [clientId, setClientId] = useState(''); - const [clientSecret, setClientSecret] = useState(''); - const [hasStoredSecret, setHasStoredSecret] = useState(false); - const [configuredBy, setConfiguredBy] = useState<'environment' | 'user'>('user'); - const [updatedAt, setUpdatedAt] = useState(null); - const [error, setError] = useState(null); - const [copiedRedirect, setCopiedRedirect] = useState(false); - - const callbackUrl = provider - ? `${window.location.origin}/integrations/callback/${provider.id}` - : ''; - - useEffect(() => { - if (!open) { - setClientSecret(''); - setError(null); - setCopiedRedirect(false); - return; - } - - if (!provider) { - return; - } - - let cancelled = false; - setLoading(true); - setError(null); - setClientId(''); - setClientSecret(''); - setHasStoredSecret(false); - setConfiguredBy('user'); - setUpdatedAt(null); - - api.integrations - .getProviderConfig(provider.id) - .then((config) => { - if (cancelled) { - return; - } - setClientId(config.clientId ?? ''); - setHasStoredSecret(config.hasClientSecret); - setConfiguredBy(config.configuredBy); - setUpdatedAt(config.updatedAt ?? null); - }) - .catch((err) => { - if (cancelled) { - return; - } - const message = - err instanceof Error ? err.message : 'Failed to load provider configuration.'; - setError(message); - }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [open, provider?.id]); - - useEffect(() => { - setCopiedRedirect(false); - }, [callbackUrl]); - - const handleDialogOpenChange = (nextOpen: boolean) => { - if (!nextOpen) { - onClose(); - } - }; - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!provider) { - return; - } - - const trimmedClientId = clientId.trim(); - const trimmedSecret = clientSecret.trim(); - - if (trimmedClientId.length === 0) { - setError('Client ID is required.'); - return; - } - - if (!hasStoredSecret && trimmedSecret.length === 0) { - setError('Client secret is required.'); - return; - } - - setSaving(true); - setError(null); - try { - await api.integrations.upsertProviderConfig(provider.id, { - clientId: trimmedClientId, - ...(trimmedSecret.length > 0 ? { clientSecret: trimmedSecret } : {}), - }); - onCompleted('saved', provider); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to save provider credentials.'; - setError(message); - } finally { - setSaving(false); - } - }; - - const handleRemove = async () => { - if (!provider) { - return; - } - - setSaving(true); - setError(null); - try { - await api.integrations.deleteProviderConfig(provider.id); - onCompleted('deleted', provider); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to remove stored credentials.'; - setError(message); - setSaving(false); - } - }; - - const canRemove = configuredBy === 'user' && hasStoredSecret; - const isBusy = loading || saving; - - const handleCopyRedirect = async () => { - if (!callbackUrl) { - return; - } - - try { - await navigator.clipboard.writeText(callbackUrl); - setCopiedRedirect(true); - setTimeout(() => setCopiedRedirect(false), 2000); - } catch (err) { - console.error('Failed to copy redirect URL', err); - setError('Unable to copy redirect URL. Please copy it manually.'); - } - }; - - return ( - - - - - {provider ? `Configure ${provider.name}` : 'Configure provider'} - - - Provide the OAuth client credentials required to start authorization flows for this - provider. - - - -
- {configuredBy === 'environment' && ( -

- This provider currently uses credentials from the server environment. Saving values - here will override them for ShipSec Studio. -

- )} - - {loading && ( -

- - Loading current configuration… -

- )} - - {updatedAt && ( -

- Last updated {formatTimestamp(updatedAt)} -

- )} - -
- - setClientId(event.target.value)} - placeholder="e.g. github-client-id" - autoComplete="off" - disabled={isBusy} - /> -
- -
- - setClientSecret(event.target.value)} - placeholder={ - hasStoredSecret ? 'Leave blank to keep existing secret' : 'Enter new client secret' - } - type="password" - autoComplete="off" - disabled={isBusy} - /> -
- - {callbackUrl && ( -
- -
- - -
-

- Add this exact URL to the provider's allowed redirect list to avoid callback - warnings. -

-
- )} - - {error &&

{error}

} - - -
- {canRemove && ( - - )} -
-
- - +
+ {item.name} +
+ + {item.name} + +
+ ))}
- - - - +
+ )} +
+
); } - -interface IntegrationCallbackBridgeProps { - onConnected: (connection: IntegrationConnection) => void; -} - -function IntegrationCallbackBridge({ onConnected }: IntegrationCallbackBridgeProps) { - useEffect(() => { - const listener = (event: Event) => { - const detail = (event as CustomEvent).detail; - if (detail) { - onConnected(detail); - } - }; - - window.addEventListener('integration:connected', listener); - return () => { - window.removeEventListener('integration:connected', listener); - }; - }, [onConnected]); - - return null; -} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 31f063a2..e41b5f86 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -23,10 +23,6 @@ type IntegrationProviderResponse = components['schemas']['IntegrationProviderRes type IntegrationConnectionResponse = components['schemas']['IntegrationConnectionResponse']; type ProviderConfigurationResponse = components['schemas']['ProviderConfigurationResponse']; type OAuthStartResponseDto = components['schemas']['OAuthStartResponseDto']; -type StartOAuthRequest = components['schemas']['StartOAuthDto']; -type CompleteOAuthRequest = components['schemas']['CompleteOAuthDto']; -type RefreshConnectionRequest = components['schemas']['RefreshConnectionDto']; -type DisconnectConnectionRequest = components['schemas']['DisconnectConnectionDto']; type UpsertProviderConfigRequest = components['schemas']['UpsertProviderConfigDto']; type WorkflowVersionResponse = components['schemas']['WorkflowVersionResponseDto']; type CreateScheduleRequestDto = components['schemas']['CreateScheduleRequestDto']; @@ -55,6 +51,38 @@ export type IntegrationProvider = IntegrationProviderResponse; export type IntegrationConnection = IntegrationConnectionResponse; export type IntegrationProviderConfiguration = ProviderConfigurationResponse; export type OAuthStartResponse = OAuthStartResponseDto; + +// Catalog types (from backend integration-catalog.ts) +export interface IntegrationCatalogEntry { + id: string; + name: string; + description: string; + docsUrl?: string; + iconUrl?: string; + authMethods: { + type: string; + label: string; + description: string; + fields: { + id: string; + label: string; + type: 'text' | 'password' | 'select'; + required: boolean; + placeholder?: string; + helpText?: string; + options?: { label: string; value: string }[]; + }[]; + }[]; + supportsMultipleConnections: boolean; + setupInstructions: { + sections: { + title: string; + authMethodType: string; + scenario: string; + steps: string[]; + }[]; + }; +} export interface ArtifactListFilters { workflowId?: string; componentId?: string; @@ -83,7 +111,10 @@ function resolveApiBaseUrl() { } } - return 'http://localhost:3211'; + // Default to empty string (relative URLs) so API calls go through the same + // origin that served the page. This avoids CORS/mixed-content issues when + // accessed via ngrok or custom domains (nginx proxies /api/ to the backend). + return ''; } export const API_BASE_URL = resolveApiBaseUrl(); @@ -343,43 +374,187 @@ export const api = { return (response.data ?? []) as IntegrationProvider[]; }, - listConnections: async (userId: string): Promise => { - const response = await apiClient.listIntegrationConnections(userId); - if (response.error) throw new Error('Failed to load integrations'); - return (response.data ?? []) as IntegrationConnection[]; + // D16: userId is now derived from auth context server-side + listConnections: async (): Promise => { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_V1_URL}/integrations/connections`, { headers }); + if (!res.ok) throw new Error('Failed to load integrations'); + return res.json(); + }, + + // D6/D17: org-scoped connection listing + listOrgConnections: async (provider?: string): Promise => { + const headers = await getAuthHeaders(); + const params = provider ? `?provider=${encodeURIComponent(provider)}` : ''; + const res = await fetch(`${API_V1_URL}/integrations/org/connections${params}`, { headers }); + if (!res.ok) throw new Error('Failed to load org connections'); + return res.json(); }, + // D5: Provider catalog (AWS + Slack definitions) + getCatalog: async (): Promise => { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_V1_URL}/integrations/catalog`, { headers }); + if (!res.ok) throw new Error('Failed to load integration catalog'); + return res.json(); + }, + + // D16: userId removed from OAuth payloads startOAuth: async ( providerId: string, - payload: StartOAuthRequest, + payload: { redirectUri: string; scopes?: string[] }, ): Promise => { - const response = await apiClient.startIntegrationOAuth(providerId, payload); - if (response.error || !response.data) throw new Error('Failed to start OAuth flow'); - return response.data; + const headers = await getAuthHeaders(); + const res = await fetch( + `${API_V1_URL}/integrations/${encodeURIComponent(providerId)}/start`, + { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) throw new Error('Failed to start OAuth flow'); + return res.json(); }, completeOAuth: async ( providerId: string, - payload: CompleteOAuthRequest, + payload: { redirectUri: string; state: string; code: string; scopes?: string[] }, ): Promise => { - const response = await apiClient.completeIntegrationOAuth(providerId, payload); - if (response.error || !response.data) throw new Error('Failed to complete OAuth exchange'); - return response.data; + const headers = await getAuthHeaders(); + const res = await fetch( + `${API_V1_URL}/integrations/${encodeURIComponent(providerId)}/exchange`, + { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) throw new Error('Failed to complete OAuth exchange'); + return res.json(); }, - refreshConnection: async (id: string, userId: string): Promise => { - const payload: RefreshConnectionRequest = { userId }; - const response = await apiClient.refreshIntegrationConnection(id, payload); - if (response.error || !response.data) { - throw new Error('Failed to refresh integration connection'); + // D16: userId removed, derived from auth context + refreshConnection: async (id: string): Promise => { + const headers = await getAuthHeaders(); + const res = await fetch( + `${API_V1_URL}/integrations/connections/${encodeURIComponent(id)}/refresh`, + { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + }, + ); + if (!res.ok) throw new Error('Failed to refresh integration connection'); + return res.json(); + }, + + // D16: userId removed, derived from auth context + disconnect: async (id: string): Promise => { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_V1_URL}/integrations/connections/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers, + }); + if (!res.ok) throw new Error('Failed to disconnect integration'); + }, + + // AWS setup info (generates trust policy + external ID + setup token) + getAwsSetupInfo: async (): Promise<{ + platformRoleArn: string; + externalId: string; + setupToken: string; + trustPolicyTemplate: string; + externalIdDisplay?: string; + }> => { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_V1_URL}/integrations/aws/setup-info`, { headers }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to load AWS setup info'); } - return response.data; + return res.json(); }, - disconnect: async (id: string, userId: string): Promise => { - const payload: DisconnectConnectionRequest = { userId }; - const response = await apiClient.disconnectIntegrationConnection(id, payload); - if (response.error) throw new Error('Failed to disconnect integration'); + // AWS connection management (IAM role only) + createAwsConnection: async (payload: { + displayName: string; + roleArn: string; + region?: string; + externalId: string; + setupToken: string; + }): Promise => { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_V1_URL}/integrations/aws/connections`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || 'Failed to create AWS connection'); + } + return res.json(); + }, + + validateAwsConnection: async (id: string): Promise<{ valid: boolean; error?: string }> => { + const headers = await getAuthHeaders(); + const res = await fetch( + `${API_V1_URL}/integrations/aws/connections/${encodeURIComponent(id)}/validate`, + { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + }, + ); + if (!res.ok) throw new Error('Failed to validate AWS connection'); + return res.json(); + }, + + discoverOrgAccounts: async ( + id: string, + ): Promise<{ + accounts: { id: string; name: string; status: string; email?: string }[]; + }> => { + const headers = await getAuthHeaders(); + const res = await fetch( + `${API_V1_URL}/integrations/aws/connections/${encodeURIComponent(id)}/discover-org`, + { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + }, + ); + if (!res.ok) throw new Error('Failed to discover AWS organization accounts'); + return res.json(); + }, + + // Slack connection management + createSlackWebhookConnection: async (payload: { + displayName: string; + webhookUrl: string; + }): Promise => { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_V1_URL}/integrations/slack/connections`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || 'Failed to create Slack connection'); + } + return res.json(); + }, + + testSlackConnection: async (id: string): Promise<{ ok: boolean; error?: string }> => { + const headers = await getAuthHeaders(); + const res = await fetch( + `${API_V1_URL}/integrations/slack/connections/${encodeURIComponent(id)}/test`, + { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + }, + ); + if (!res.ok) throw new Error('Failed to test Slack connection'); + return res.json(); }, getProviderConfig: async (providerId: string): Promise => { diff --git a/frontend/src/store/integrationStore.ts b/frontend/src/store/integrationStore.ts index 83e363f5..0c082721 100644 --- a/frontend/src/store/integrationStore.ts +++ b/frontend/src/store/integrationStore.ts @@ -1,88 +1,160 @@ import { create } from 'zustand'; -import type { components } from '@shipsec/backend-client'; import { api } from '@/services/api'; - -type IntegrationProvider = components['schemas']['IntegrationProviderResponse']; -type IntegrationConnection = components['schemas']['IntegrationConnectionResponse']; +import type { IntegrationConnection, IntegrationCatalogEntry } from '@/services/api'; interface IntegrationStoreState { - providers: IntegrationProvider[]; connections: IntegrationConnection[]; - loadingProviders: boolean; + orgConnections: IntegrationConnection[]; + catalog: IntegrationCatalogEntry[]; loadingConnections: boolean; + loadingOrgConnections: boolean; + loadingCatalog: boolean; error: string | null; initialized: boolean; + orgInitialized: boolean; } interface IntegrationStoreActions { - fetchProviders: () => Promise; - fetchConnections: (userId: string, force?: boolean) => Promise; - refreshConnection: (id: string, userId: string) => Promise; - disconnect: (id: string, userId: string) => Promise; + // D16: userId removed — derived from auth context server-side + fetchConnections: (force?: boolean) => Promise; + // D6: org-scoped connection listing + fetchOrgConnections: (provider?: string, force?: boolean) => Promise; + // D17: merge-and-dedup from both endpoints + fetchMergedConnections: () => Promise; + refreshConnection: (id: string) => Promise; + disconnect: (id: string) => Promise; upsertConnection: (connection: IntegrationConnection) => void; + // AWS setup info + getAwsSetupInfo: () => Promise<{ + platformRoleArn: string; + externalId: string; + setupToken: string; + trustPolicyTemplate: string; + externalIdDisplay?: string; + }>; + // New connection creation (IAM role only) + createAwsConnection: (payload: { + displayName: string; + roleArn: string; + region?: string; + externalId: string; + setupToken: string; + }) => Promise; + createSlackWebhookConnection: (payload: { + displayName: string; + webhookUrl: string; + }) => Promise; + validateAwsConnection: (id: string) => Promise<{ valid: boolean; error?: string }>; + testSlackConnection: (id: string) => Promise<{ ok: boolean; error?: string }>; + discoverOrgAccounts: ( + id: string, + ) => Promise<{ accounts: { id: string; name: string; status: string; email?: string }[] }>; + fetchCatalog: () => Promise; resetError: () => void; } type IntegrationStore = IntegrationStoreState & IntegrationStoreActions; -function sortProviders(providers: IntegrationProvider[]) { - return [...providers].sort((a, b) => a.name.localeCompare(b.name)); +function sortConnections(connections: IntegrationConnection[]) { + return [...connections].sort((a, b) => { + const providerCmp = (a.providerName ?? a.provider).localeCompare(b.providerName ?? b.provider); + if (providerCmp !== 0) return providerCmp; + return (a.displayName ?? '').localeCompare(b.displayName ?? ''); + }); } -function sortConnections(connections: IntegrationConnection[]) { - return [...connections].sort((a, b) => a.providerName.localeCompare(b.providerName)); +/** + * D17: Merge connections from user-scoped and org-scoped endpoints, dedup by id. + * Org-scoped takes precedence if the same id appears in both (shouldn't happen). + */ +function mergeAndDedup( + userConnections: IntegrationConnection[], + orgConnections: IntegrationConnection[], +): IntegrationConnection[] { + const byId = new Map(); + for (const c of userConnections) { + byId.set(c.id, c); + } + for (const c of orgConnections) { + byId.set(c.id, c); // org-scoped takes precedence + } + return sortConnections(Array.from(byId.values())); } export const useIntegrationStore = create((set, get) => ({ - providers: [], connections: [], - loadingProviders: false, + orgConnections: [], + catalog: [], loadingConnections: false, + loadingOrgConnections: false, + loadingCatalog: false, error: null, initialized: false, + orgInitialized: false, - fetchProviders: async () => { - if (get().loadingProviders) { + fetchConnections: async (force = false) => { + const { loadingConnections, initialized } = get(); + if (loadingConnections || (!force && initialized)) { return; } - set({ loadingProviders: true, error: null }); + set({ loadingConnections: true, error: null }); try { - const providers = await api.integrations.listProviders(); + const connections = await api.integrations.listConnections(); set({ - providers: sortProviders(providers), - loadingProviders: false, + connections: sortConnections(connections), + loadingConnections: false, + initialized: true, }); } catch (error) { set({ - loadingProviders: false, - error: error instanceof Error ? error.message : 'Failed to load providers', + loadingConnections: false, + error: error instanceof Error ? error.message : 'Failed to load integrations', }); } }, - fetchConnections: async (userId: string, force = false) => { - const { loadingConnections, initialized } = get(); - if (loadingConnections || (!force && initialized)) { + fetchOrgConnections: async (provider?: string, force = false) => { + const { loadingOrgConnections, orgInitialized } = get(); + if (loadingOrgConnections || (!force && orgInitialized)) { return; } - set({ loadingConnections: true, error: null }); + set({ loadingOrgConnections: true, error: null }); try { - const connections = await api.integrations.listConnections(userId); + const orgConnections = await api.integrations.listOrgConnections(provider); set({ - connections: sortConnections(connections), - loadingConnections: false, - initialized: true, + orgConnections: sortConnections(orgConnections), + loadingOrgConnections: false, + orgInitialized: true, }); } catch (error) { set({ - loadingConnections: false, - error: error instanceof Error ? error.message : 'Failed to load integrations', + loadingOrgConnections: false, + error: error instanceof Error ? error.message : 'Failed to load org connections', }); } }, + fetchMergedConnections: async () => { + // D17: call both endpoints, merge, dedup + const results = await Promise.allSettled([ + api.integrations.listConnections(), + api.integrations.listOrgConnections(), + ]); + + const userConns = results[0].status === 'fulfilled' ? results[0].value : []; + const orgConns = results[1].status === 'fulfilled' ? results[1].value : []; + + if (results[0].status === 'rejected' && results[1].status === 'rejected') { + throw new Error('Failed to load connections from both endpoints'); + } + + const merged = mergeAndDedup(userConns, orgConns); + set({ connections: userConns, orgConnections: orgConns }); + return merged; + }, + upsertConnection: (connection: IntegrationConnection) => { set((state) => ({ connections: sortConnections( @@ -90,15 +162,21 @@ export const useIntegrationStore = create((set, get) => ({ ? state.connections.map((item) => (item.id === connection.id ? connection : item)) : [...state.connections, connection], ), + orgConnections: sortConnections( + state.orgConnections.some((item) => item.id === connection.id) + ? state.orgConnections.map((item) => (item.id === connection.id ? connection : item)) + : [...state.orgConnections, connection], + ), })); }, - refreshConnection: async (id: string, userId: string) => { + refreshConnection: async (id: string) => { try { - const refreshed = await api.integrations.refreshConnection(id, userId); + const refreshed = await api.integrations.refreshConnection(id); set((state) => ({ - connections: sortConnections( - state.connections.map((connection) => (connection.id === id ? refreshed : connection)), + connections: sortConnections(state.connections.map((c) => (c.id === id ? refreshed : c))), + orgConnections: sortConnections( + state.orgConnections.map((c) => (c.id === id ? refreshed : c)), ), })); return refreshed; @@ -110,11 +188,12 @@ export const useIntegrationStore = create((set, get) => ({ } }, - disconnect: async (id: string, userId: string) => { + disconnect: async (id: string) => { try { - await api.integrations.disconnect(id, userId); + await api.integrations.disconnect(id); set((state) => ({ - connections: state.connections.filter((connection) => connection.id !== id), + connections: state.connections.filter((c) => c.id !== id), + orgConnections: state.orgConnections.filter((c) => c.id !== id), })); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to disconnect integration'; @@ -123,6 +202,60 @@ export const useIntegrationStore = create((set, get) => ({ } }, + getAwsSetupInfo: async () => { + return api.integrations.getAwsSetupInfo(); + }, + + createAwsConnection: async (payload) => { + try { + const connection = await api.integrations.createAwsConnection(payload); + get().upsertConnection(connection); + return connection; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create AWS connection'; + set({ error: message }); + throw error instanceof Error ? error : new Error(message); + } + }, + + createSlackWebhookConnection: async (payload) => { + try { + const connection = await api.integrations.createSlackWebhookConnection(payload); + get().upsertConnection(connection); + return connection; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create Slack connection'; + set({ error: message }); + throw error instanceof Error ? error : new Error(message); + } + }, + + validateAwsConnection: async (id: string) => { + return api.integrations.validateAwsConnection(id); + }, + + testSlackConnection: async (id: string) => { + return api.integrations.testSlackConnection(id); + }, + + discoverOrgAccounts: async (id: string) => { + return api.integrations.discoverOrgAccounts(id); + }, + + fetchCatalog: async () => { + if (get().loadingCatalog) return; + set({ loadingCatalog: true, error: null }); + try { + const catalog = await api.integrations.getCatalog(); + set({ catalog, loadingCatalog: false }); + } catch (error) { + set({ + loadingCatalog: false, + error: error instanceof Error ? error.message : 'Failed to load catalog', + }); + } + }, + resetError: () => { set({ error: null }); }, diff --git a/worker/package.json b/worker/package.json index bc75fb3d..e829e7dc 100644 --- a/worker/package.json +++ b/worker/package.json @@ -22,7 +22,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", diff --git a/worker/src/components/core/aws-assume-role.ts b/worker/src/components/core/aws-assume-role.ts new file mode 100644 index 00000000..802db33f --- /dev/null +++ b/worker/src/components/core/aws-assume-role.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; +import { + componentRegistry, + ConfigurationError, + defineComponent, + inputs, + outputs, + parameters, + port, + param, +} from '@shipsec/component-sdk'; +import { awsCredentialSchema } from '@shipsec/contracts'; +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; + +const inputSchema = inputs({ + sourceCredentials: port(awsCredentialSchema(), { + label: 'Source Credentials', + description: 'AWS credentials to use when assuming the target role.', + connectionType: { kind: 'contract', name: 'core.credential.aws', credential: true }, + }), +}); + +const parameterSchema = parameters({ + roleArn: param( + z.string().min(1, 'Role ARN is required').describe('ARN of the IAM role to assume.'), + { + label: 'Role ARN', + editor: 'text', + description: + 'The ARN of the IAM role to assume (e.g. arn:aws:iam::123456789012:role/MyRole).', + }, + ), + externalId: param( + z.string().optional().describe('Optional external ID for cross-account role assumption.'), + { + label: 'External ID', + editor: 'text', + description: 'Optional external ID required by the trust policy of the target role.', + }, + ), + sessionName: param( + z.string().default('shipsec-session').describe('Session name for the assumed role session.'), + { + label: 'Session Name', + editor: 'text', + description: 'Name for the assumed role session. Defaults to shipsec-session.', + }, + ), +}); + +const outputSchema = outputs({ + credentials: port(awsCredentialSchema(), { + label: 'Assumed Role Credentials', + description: 'Temporary assumed role credentials', + connectionType: { kind: 'contract', name: 'core.credential.aws', credential: true }, + }), +}); + +const definition = defineComponent({ + id: 'core.aws.assume-role', + label: 'AWS Assume Role', + category: 'process', + runner: { kind: 'inline' }, + inputs: inputSchema, + outputs: outputSchema, + parameters: parameterSchema, + docs: 'Assume an AWS IAM role using STS and return temporary credentials. Useful for cross-account access and least-privilege workflows.', + ui: { + slug: 'aws-assume-role', + version: '1.0.0', + type: 'process', + category: 'cloud', + description: + 'Assume an AWS IAM role via STS to obtain temporary credentials for cross-account or scoped access.', + icon: 'Shield', + }, + async execute({ inputs, params }, context) { + const sourceCreds = inputSchema.parse(inputs).sourceCredentials; + + if (!sourceCreds.accessKeyId || !sourceCreds.secretAccessKey) { + throw new ConfigurationError( + 'Source AWS credentials (accessKeyId and secretAccessKey) are required.', + { configKey: 'sourceCredentials' }, + ); + } + + const roleArn = params.roleArn; + const sessionName = params.sessionName ?? 'shipsec-session'; + + context.emitProgress(`Assuming role ${roleArn}...`); + + const stsClient = new STSClient({ + credentials: { + accessKeyId: sourceCreds.accessKeyId, + secretAccessKey: sourceCreds.secretAccessKey, + sessionToken: sourceCreds.sessionToken, + }, + region: sourceCreds.region ?? 'us-east-1', + }); + + const command = new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: sessionName, + DurationSeconds: 3600, + ...(params.externalId ? { ExternalId: params.externalId } : {}), + }); + + const response = await stsClient.send(command); + + if (!response.Credentials) { + throw new ConfigurationError( + `STS AssumeRole did not return credentials for role ${roleArn}.`, + { configKey: 'roleArn' }, + ); + } + + context.logger.info( + `[AWSAssumeRole] Successfully assumed role ${roleArn} (session: ${sessionName}).`, + ); + context.emitProgress(`Assumed role ${roleArn} successfully.`); + + return outputSchema.parse({ + credentials: { + accessKeyId: response.Credentials.AccessKeyId ?? '', + secretAccessKey: response.Credentials.SecretAccessKey ?? '', + sessionToken: response.Credentials.SessionToken, + region: sourceCreds.region, + }, + }); + }, +}); + +componentRegistry.register(definition); diff --git a/worker/src/components/core/aws-org-discovery.ts b/worker/src/components/core/aws-org-discovery.ts new file mode 100644 index 00000000..12dfa131 --- /dev/null +++ b/worker/src/components/core/aws-org-discovery.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { + componentRegistry, + ConfigurationError, + defineComponent, + inputs, + outputs, + port, +} from '@shipsec/component-sdk'; +import { awsCredentialSchema } from '@shipsec/contracts'; +import { OrganizationsClient, paginateListAccounts } from '@aws-sdk/client-organizations'; + +const inputSchema = inputs({ + credentials: port(awsCredentialSchema(), { + label: 'AWS Credentials', + description: 'AWS credentials with permissions to list organization accounts.', + connectionType: { kind: 'contract', name: 'core.credential.aws', credential: true }, + }), +}); + +const accountSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.string(), + email: z.string(), +}); + +const outputSchema = outputs({ + accounts: port(z.array(accountSchema), { + label: 'Accounts', + description: 'List of AWS Organization accounts', + connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } }, + }), + organizationId: port(z.string().optional(), { + label: 'Organization ID', + description: 'AWS Organization ID', + }), +}); + +const definition = defineComponent({ + id: 'core.aws.org-discovery', + label: 'AWS Org Discovery', + category: 'process', + runner: { kind: 'inline' }, + inputs: inputSchema, + outputs: outputSchema, + docs: 'Discover all accounts in an AWS Organization using the provided credentials. Paginates through all accounts automatically.', + ui: { + slug: 'aws-org-discovery', + version: '1.0.0', + type: 'process', + category: 'cloud', + description: 'List all AWS Organization accounts to enable multi-account workflows.', + icon: 'Cloud', + }, + async execute({ inputs }, context) { + const creds = inputSchema.parse(inputs).credentials; + + if (!creds.accessKeyId || !creds.secretAccessKey) { + throw new ConfigurationError( + 'AWS credentials (accessKeyId and secretAccessKey) are required.', + { configKey: 'credentials' }, + ); + } + + context.emitProgress('Discovering AWS Organization accounts...'); + + const client = new OrganizationsClient({ + credentials: { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }, + region: creds.region ?? 'us-east-1', + }); + + const accounts: { id: string; name: string; status: string; email: string }[] = []; + + const paginator = paginateListAccounts({ client }, {}); + + 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 ?? 'UNKNOWN', + email: account.Email ?? '', + }); + } + } + } + + context.logger.info( + `[AWSOrGDiscovery] Discovered ${accounts.length} accounts in the organization.`, + ); + context.emitProgress(`Found ${accounts.length} AWS Organization accounts.`); + + return outputSchema.parse({ + accounts, + organizationId: undefined, + }); + }, +}); + +componentRegistry.register(definition); diff --git a/worker/src/components/core/integration-credential-resolver.ts b/worker/src/components/core/integration-credential-resolver.ts new file mode 100644 index 00000000..f7c08a14 --- /dev/null +++ b/worker/src/components/core/integration-credential-resolver.ts @@ -0,0 +1,222 @@ +import { z } from 'zod'; +import { + componentRegistry, + ConfigurationError, + defineComponent, + inputs, + outputs, + parameters, + port, + param, + DEFAULT_SENSITIVE_HEADERS, + fromHttpResponse, + type ExecutionContext, +} from '@shipsec/component-sdk'; + +const inputSchema = inputs({ + connectionId: port( + z.string().trim().optional().describe('Integration connection ID to resolve credentials from.'), + { + label: 'Connection ID', + description: 'Integration connection ID. Wire from Entry Point or set as a parameter.', + connectionType: { kind: 'primitive', name: 'text' }, + }, + ), + regions: port( + z + .string() + .trim() + .optional() + .describe('Comma-separated regions to scan. Falls back to the connection default region.'), + { + label: 'Regions', + description: 'Optional region override. If not wired, uses the connection default region.', + connectionType: { kind: 'primitive', name: 'text' }, + }, + ), +}); + +const parameterSchema = parameters({ + connectionId: param( + z.string().trim().optional().describe('Integration connection ID to resolve credentials from.'), + { + label: 'Connection ID', + editor: 'text', + description: + 'Select an integration connection to resolve credentials from. Overridden when wired from an upstream node.', + }, + ), +}); + +const outputSchema = outputs({ + credentialType: port(z.string(), { + label: 'Credential Type', + description: 'The type of credentials (oauth, api_key, iam_role, webhook)', + }), + provider: port(z.string(), { + label: 'Provider', + description: 'The integration provider (aws, slack, github, etc.)', + }), + accountId: port(z.string().default(''), { + label: 'Account ID', + description: + 'Provider account identifier (e.g. AWS 12-digit account ID). Empty when not applicable.', + connectionType: { kind: 'primitive', name: 'text' }, + }), + regions: port(z.string().default(''), { + label: 'Regions', + description: + 'Regions to scan. Uses the input override if provided, otherwise falls back to the connection default region.', + connectionType: { kind: 'primitive', name: 'text' }, + }), + data: port(z.record(z.string(), z.unknown()), { + label: 'Credentials Data', + description: 'Resolved credentials data', + allowAny: true, + reason: 'Credential payloads vary by provider and credential type.', + editor: 'secret', + connectionType: { kind: 'any' }, + }), +}); + +const definition = defineComponent({ + id: 'core.integration.resolve-credentials', + label: 'Integration Credential Resolver', + category: 'input', + runner: { kind: 'inline' }, + inputs: inputSchema, + outputs: outputSchema, + parameters: parameterSchema, + docs: 'Resolve credentials from an integration connection. Calls the internal credentials endpoint and returns the provider, type, and credential data.', + ui: { + slug: 'integration-credential-resolver', + version: '1.0.0', + type: 'input', + category: 'core', + description: + 'Resolve credentials from an integration connection for use by downstream components.', + icon: 'KeySquare', + }, + async execute({ inputs, params }, context) { + // Prefer input port value (wired from upstream), fall back to parameter (static config) + const connectionId = (inputs.connectionId || params.connectionId || '').trim(); + + if (connectionId.length === 0) { + throw new ConfigurationError('Connection ID is required.', { + configKey: 'connectionId', + }); + } + + context.emitProgress(`Resolving credentials for connection ${connectionId}...`); + + const payload = await fetchConnectionCredentials(connectionId, context); + + context.logger.info( + `[IntegrationCredentialResolver] Resolved credentials for connection ${connectionId} (provider=${payload.provider}, type=${payload.credentialType}).`, + ); + + // Regions: prefer explicit input, fall back to the connection's default region + const resolvedRegions = (inputs.regions || payload.region || '').trim(); + + return outputSchema.parse({ + credentialType: payload.credentialType, + provider: payload.provider, + accountId: payload.accountId ?? '', + regions: resolvedRegions, + data: payload.data, + }); + }, +}); + +async function fetchConnectionCredentials( + connectionId: string, + context: ExecutionContext, +): Promise<{ + credentialType: string; + provider: string; + data: Record; + accountId?: string; + region?: string; +}> { + const internalToken = process.env.INTERNAL_SERVICE_TOKEN; + + const baseUrl = + process.env.STUDIO_API_BASE_URL ?? + process.env.SHIPSEC_API_BASE_URL ?? + process.env.API_BASE_URL ?? + 'http://localhost:3211'; + + const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + + if (!internalToken) { + context.emitProgress({ + level: 'warn', + message: + 'INTERNAL_SERVICE_TOKEN env var not set; requesting credentials without internal auth header.', + }); + } + + const sensitiveHeaders = internalToken + ? Array.from(new Set([...DEFAULT_SENSITIVE_HEADERS, 'x-internal-token'])) + : DEFAULT_SENSITIVE_HEADERS; + + const response = await context.http.fetch( + `${normalizedBase}/integrations/connections/${encodeURIComponent(connectionId)}/credentials`, + { + method: 'POST', + headers: internalToken + ? { + 'Content-Type': 'application/json', + 'X-Internal-Token': internalToken, + } + : { + 'Content-Type': 'application/json', + }, + }, + { sensitiveHeaders }, + ); + + if (!response.ok) { + const raw = await safeReadText(response); + throw fromHttpResponse( + response, + `Failed to resolve credentials for connection ${connectionId}: ${raw}`, + ); + } + + const payload = (await response.json()) as { + credentialType?: string; + provider?: string; + data?: Record; + accountId?: string; + region?: string; + }; + + if (!payload.credentialType || !payload.provider) { + throw new ConfigurationError( + `Connection ${connectionId} returned an incomplete credential response.`, + { + configKey: 'connectionId', + details: { connectionId }, + }, + ); + } + + return { + credentialType: payload.credentialType, + provider: payload.provider, + data: payload.data ?? {}, + accountId: payload.accountId, + region: payload.region, + }; +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch (error) { + return `<>`; + } +} + +componentRegistry.register(definition); diff --git a/worker/src/components/index.ts b/worker/src/components/index.ts index 714c92e2..3c0cea3c 100644 --- a/worker/src/components/index.ts +++ b/worker/src/components/index.ts @@ -22,6 +22,9 @@ import './core/array-pack'; import './core/artifact-writer'; import './core/file-writer'; import './core/credentials-aws'; +import './core/integration-credential-resolver'; +import './core/aws-org-discovery'; +import './core/aws-assume-role'; import './core/destination-artifact'; import './core/destination-s3'; import './core/text-block'; diff --git a/worker/src/components/security/prowler-scan.ts b/worker/src/components/security/prowler-scan.ts index 78077771..207bdf77 100644 --- a/worker/src/components/security/prowler-scan.ts +++ b/worker/src/components/security/prowler-scan.ts @@ -705,14 +705,17 @@ const definition = defineComponent({ } } + const { findings, errors } = + rawSegments.length > 0 + ? normaliseFindings(rawSegments, context.runId) + : { findings: [] as NormalisedFinding[], errors: [] as string[] }; + if (rawSegments.length === 0) { - throw new ServiceError('Prowler did not produce any ASFF output files.', { - details: { volumeName: outputVolume.getVolumeName() }, - }); + context.logger.info( + '[ProwlerScan] Prowler produced no ASFF output — likely 0 findings for the selected severity/region.', + ); } - const { findings, errors } = normaliseFindings(rawSegments, context.runId); - const generatedAt = new Date().toISOString(); const severityCounts: Record = { critical: 0, diff --git a/worker/src/temporal/utils/component-output.ts b/worker/src/temporal/utils/component-output.ts index 6601b97d..91aef639 100644 --- a/worker/src/temporal/utils/component-output.ts +++ b/worker/src/temporal/utils/component-output.ts @@ -37,6 +37,9 @@ function maskSecretPorts(secretPorts: { id: string }[], data: unknown): unknown */ function getSecretPorts(ports: ComponentPortMetadata[]): { id: string }[] { return ports.filter((port) => { + if (port.editor === 'secret') { + return true; + } const connType = port.connectionType; if (!connType) { return false; From ab9863ef136b682752b003bcce67d55fa6597b39 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Wed, 11 Feb 2026 23:09:53 -0500 Subject: [PATCH 2/5] feat(integrations): improve Slack OAuth flow, validation, and connection cards - Fix OAuth callback spinner stuck in React 18 strict mode by removing the cancelled cleanup pattern (exchangeStartedRef is sufficient) - Remove userId from completeOAuthSession input; derive from state record since the exchange endpoint is @Public() - Add Slack auth.test validation for OAuth connections and fix valid->ok field mapping in testSlackConnection controller - Add team:read scope and team.info API to fetch workspace icons - Enrich connection metadata with workspace icon during OAuth and on test/validate (best-effort backfill for existing connections) - Redesign connection cards with workspace icon/initials, green health badge, scope count, and hover effects - Add dashed "Add Connection" card to the connections grid - Fix toast text: "connection is healthy" instead of "message sent" Signed-off-by: Aseem Shrey --- .../src/integrations/integration-providers.ts | 1 + .../integrations/integrations.controller.ts | 3 +- .../integrations/integrations.repository.ts | 20 +-- .../src/integrations/integrations.service.ts | 64 +++++++--- backend/src/integrations/slack.service.ts | 75 +++++++++++ frontend/src/pages/IntegrationCallback.tsx | 29 ++--- frontend/src/pages/IntegrationDetailPage.tsx | 118 ++++++++++++++---- 7 files changed, 241 insertions(+), 69 deletions(-) diff --git a/backend/src/integrations/integration-providers.ts b/backend/src/integrations/integration-providers.ts index eca03797..af56baa7 100644 --- a/backend/src/integrations/integration-providers.ts +++ b/backend/src/integrations/integration-providers.ts @@ -49,6 +49,7 @@ export function loadIntegrationProviders(): Record; }, ): Promise { - await this.db - .update(integrationTokens) - .set({ - lastValidatedAt: health.lastValidatedAt, - lastValidationStatus: health.lastValidationStatus, - lastValidationError: health.lastValidationError ?? null, - updatedAt: new Date(), - }) - .where(eq(integrationTokens.id, id)); + 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 { diff --git a/backend/src/integrations/integrations.service.ts b/backend/src/integrations/integrations.service.ts index 80a2797f..be1000e9 100644 --- a/backend/src/integrations/integrations.service.ts +++ b/backend/src/integrations/integrations.service.ts @@ -310,7 +310,6 @@ export class IntegrationsService implements OnModuleInit { async completeOAuthSession( providerId: string, input: { - userId: string; state: string; code: string; redirectUri: string; @@ -321,21 +320,19 @@ export class IntegrationsService implements OnModuleInit { `[completeOAuth] Starting exchange for provider=${providerId}, redirectUri=${input.redirectUri}`, ); const provider = await this.resolveProviderForAuth(providerId); - this.logger.log(`[completeOAuth] Provider resolved: ${provider.id}`); const stateRecord = await this.repository.consumeOAuthState(input.state); - this.logger.log(`[completeOAuth] State consumed: ${!!stateRecord}`); 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'); } - const organizationId = stateRecord.organizationId ?? `workspace-${input.userId}`; + // 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}`); @@ -346,19 +343,31 @@ export class IntegrationsService implements OnModuleInit { codeVerifier: stateRecord.codeVerifier, scopes, }); - this.logger.log( - `[completeOAuth] Token response received, keys: ${Object.keys(rawResponse).join(', ')}`, - ); + + // 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: providerId, + displayName, provider, scopes, rawResponse, - previous: await this.repository.findByUserAndProvider(input.userId, providerId), + previous: await this.repository.findByUserAndProvider(userId, providerId), }); this.logger.log(`[completeOAuth] Connection persisted: ${persisted.id}`); @@ -600,8 +609,35 @@ export class IntegrationsService implements OnModuleInit { 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 OAuth or other types, we cannot generically validate; treat as valid. + // For other types, we cannot generically validate; treat as valid. result = { valid: true }; } diff --git a/backend/src/integrations/slack.service.ts b/backend/src/integrations/slack.service.ts index e2ade5fe..beab2949 100644 --- a/backend/src/integrations/slack.service.ts +++ b/backend/src/integrations/slack.service.ts @@ -38,6 +38,81 @@ export class SlackService { } } + /** + * 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 = 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 = 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 */ diff --git a/frontend/src/pages/IntegrationCallback.tsx b/frontend/src/pages/IntegrationCallback.tsx index 018dde5e..63f4c3c6 100644 --- a/frontend/src/pages/IntegrationCallback.tsx +++ b/frontend/src/pages/IntegrationCallback.tsx @@ -1,11 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Loader2, CheckCircle, XCircle } from 'lucide-react'; import type { components } from '@shipsec/backend-client'; import { api } from '@/services/api'; -import { getCurrentUserId } from '@/lib/currentUser'; import { env } from '@/config/env'; type IntegrationConnection = components['schemas']['IntegrationConnectionResponse']; @@ -16,10 +15,12 @@ export function IntegrationCallback() { const { provider } = useParams<{ provider: string }>(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const userId = useMemo(() => getCurrentUserId(), []); const [status, setStatus] = useState('pending'); const [message, setMessage] = useState('Exchanging authorization code…'); + // Ref guard prevents double-execution in React 18 strict mode. + // Do NOT combine with a `cancelled` cleanup — strict mode unmounts between + // the two dev-only mounts, which would cancel the in-flight fetch. const exchangeStartedRef = useRef(false); useEffect(() => { @@ -52,25 +53,16 @@ export function IntegrationCallback() { } exchangeStartedRef.current = true; - const authCode = code; - const authState = state; - const redirectUri = `${env.VITE_APP_URL}/integrations/callback/${providerId}`; - let cancelled = false; async function exchangeCode() { try { const connection = await api.integrations.completeOAuth(providerId, { - userId, - code: authCode, - state: authState, + code, + state, redirectUri, }); - if (cancelled) { - return; - } - broadcastConnection(connection); setStatus('success'); setMessage(`Connected to ${connection.providerName}. Redirecting…`); @@ -79,9 +71,6 @@ export function IntegrationCallback() { navigate(target, { replace: true }); }, 1200); } catch (error) { - if (cancelled) { - return; - } const description = error instanceof Error ? error.message : 'Failed to exchange authorization code.'; setStatus('error'); @@ -90,11 +79,7 @@ export function IntegrationCallback() { } exchangeCode(); - - return () => { - cancelled = true; - }; - }, [navigate, provider, searchParams, userId]); + }, [navigate, provider, searchParams]); return (
diff --git a/frontend/src/pages/IntegrationDetailPage.tsx b/frontend/src/pages/IntegrationDetailPage.tsx index 91f6f008..3ffdf632 100644 --- a/frontend/src/pages/IntegrationDetailPage.tsx +++ b/frontend/src/pages/IntegrationDetailPage.tsx @@ -96,7 +96,7 @@ function healthBadge(connection: IntegrationConnection) { const status = connection.lastValidationStatus ?? connection.status; if (status === 'active' || status === 'valid' || status === 'ok') { return ( - + Healthy @@ -495,7 +495,7 @@ interface SlackFormProps { function SlackConnectionForm({ onCreated, onCancel }: SlackFormProps) { const store = useIntegrationStore(); const { toast } = useToast(); - const [tab, setTab] = useState('webhook'); + const [tab, setTab] = useState('oauth'); const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); @@ -579,15 +579,28 @@ function SlackConnectionForm({ onCreated, onCancel }: SlackFormProps) {
- - Webhook - OAuth + + Webhook + - + + {/* Main CTA */} +
+
+ Slack +
+
+

Send alerts to a Slack channel via Webhook

+

+ Paste an incoming webhook URL to post messages directly to a channel. +

+
+
+
@@ -631,20 +644,30 @@ function SlackConnectionForm({ onCreated, onCancel }: SlackFormProps) {
)} - - - +
+ + + @@ -853,12 +876,12 @@ export function IntegrationDetailPage() { if (result.ok) { toast({ title: 'Test passed', - description: `Test message sent via ${connection.displayName}.`, + description: `${connection.displayName} connection is healthy.`, }); } else { toast({ title: 'Test failed', - description: result.error ?? 'Could not deliver test message.', + description: result.error ?? 'Connection test failed.', variant: 'destructive', }); } @@ -1087,20 +1110,56 @@ export function IntegrationDetailPage() {
{connections.map((conn) => { const isValidating = validatingId === conn.id; + const meta = (conn.metadata ?? {}) as Record; + const providerPayload = meta.providerPayload ?? {}; + const teamName = providerPayload?.team?.name ?? conn.displayName; + const teamId = providerPayload?.team?.id; + const teamIcon: string | undefined = providerPayload?._teamIcon; + const connVisuals = PROVIDER_VISUALS[conn.provider]; + return ( - + -
-

- {conn.displayName || '\u2014'} -

- {healthBadge(conn)} +
+ {teamIcon ? ( + {teamName} + ) : ( +
+ {(teamName ?? '?').slice(0, 2).toUpperCase()} +
+ )} +
+
+

{teamName}

+ {healthBadge(conn)} +
+ {teamId && ( +

+ {teamId} +

+ )} +
-
+
{credentialTypeBadge(conn.credentialType)} + {conn.scopes && conn.scopes.length > 0 && ( + + {conn.scopes.length} scope{conn.scopes.length !== 1 ? 's' : ''} + + )}

- Created {formatTimestamp(conn.createdAt)} + Connected {formatTimestamp(conn.createdAt)}

)}
From fcfc1de6e9f6b94106b092722a16a1b504205801 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Wed, 11 Feb 2026 23:11:25 -0500 Subject: [PATCH 3/5] docs: update Slack OAuth scopes and add integration documentation - Add team:read to default Slack OAuth scopes in .env.example and integrations.md - Include integration docs, core component docs, and README updates Signed-off-by: Aseem Shrey --- README.md | 9 ++ backend/.env.example | 2 +- backend/README.md | 31 +++- docs/components/core.mdx | 73 +++++++++ docs/components/overview.mdx | 15 +- docs/docs.json | 15 +- docs/integrations.md | 280 +++++++++++++++++++++++++++++++++++ 7 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 docs/integrations.md 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 c4a7b2fb..a33f4711 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -75,5 +75,5 @@ LOG_KAFKA_BROKERS="localhost:19092" # 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 +# 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/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/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..e95738b1 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,280 @@ +# 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 + +Two 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`) + +### 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. From 0ea37c0ccc3efebc7a561b3a9ada91f0a26c6e79 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Wed, 11 Feb 2026 23:15:59 -0500 Subject: [PATCH 4/5] docs: add AWS Prowler to Slack summary sample workflow Add a new sample workflow JSON that chains AWS credential resolution, Prowler security scan, and Slack notification via webhook. The Slack message includes a formatted summary with finding counts by severity. Signed-off-by: Aseem Shrey --- docs/integrations.md | 18 ++- .../aws-cspm-prowler-slack-summary.json | 139 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 docs/sample/aws-cspm-prowler-slack-summary.json diff --git a/docs/integrations.md b/docs/integrations.md index e95738b1..5c6c9418 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -209,7 +209,7 @@ Assumes an IAM role via STS and returns temporary credentials. Useful for cross- ## Sample Workflows -Two pre-built workflow templates are available in `docs/sample/`: +Three pre-built workflow templates are available in `docs/sample/`: ### AWS CSPM -- Org Account Discovery @@ -243,6 +243,22 @@ End-to-end CSPM workflow: resolves AWS credentials, runs a Prowler security scan - `connectionId` (required) -- AWS integration connection ID - `regions` (optional) -- Comma-separated AWS regions (default: `us-east-1`) +### AWS CSPM -- Prowler Scan to Slack Summary + +**File:** `docs/sample/aws-cspm-prowler-slack-summary.json` + +``` +Entry Point → Resolve Credentials → Prowler Scan → Slack Message +``` + +Resolves AWS credentials, runs a Prowler security scan (severity high/critical), and sends a formatted summary of findings to a Slack channel via an Incoming Webhook. + +**Runtime Inputs:** + +- `connectionId` (required) -- AWS integration connection ID +- `regions` (optional) -- Comma-separated AWS regions (default: `us-east-1`) +- `slackWebhookUrl` (required) -- Slack Incoming Webhook URL for the target channel + ### Importing Sample Workflows **Via the UI:** Go to **Workflows** > **Import** and upload the JSON file. 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..5d1aa772 --- /dev/null +++ b/docs/sample/aws-cspm-prowler-slack-summary.json @@ -0,0 +1,139 @@ +{ + "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 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": "slackWebhookUrl", + "label": "Slack Webhook URL", + "type": "text", + "required": true, + "description": "Slack Incoming Webhook URL to send the scan summary to." + } + ] + }, + "inputOverrides": {} + } + } + }, + { + "id": "resolve-creds-1", + "type": "core.integration.resolve-credentials", + "position": { "x": 300, "y": 280 }, + "data": { + "label": "Resolve AWS Credentials", + "config": { + "params": {}, + "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": "slack-1", + "type": "core.notification.slack", + "position": { "x": 300, "y": 770 }, + "data": { + "label": "Slack Scan Summary", + "config": { + "params": { + "authType": "webhook", + "variables": [{ "name": "scanSummary", "type": "json" }] + }, + "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\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-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-entry-to-slack-webhook", + "source": "entry-1", + "target": "slack-1", + "sourceHandle": "slackWebhookUrl", + "targetHandle": "webhookUrl" + }, + { + "id": "e-prowler-to-slack-summary", + "source": "prowler-1", + "target": "slack-1", + "sourceHandle": "summary", + "targetHandle": "scanSummary" + } + ], + "viewport": { "x": 0, "y": 0, "zoom": 1 } +} From 95d961d9b6f3b2754832bfec157f875e5561769f Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Thu, 12 Feb 2026 16:22:36 -0500 Subject: [PATCH 5/5] feat: enhance prowler scanning, slack notifications, and AWS components Refactor prowler-scan with shared utilities, improve slack notification formatting, enhance AWS assume-role and org-discovery with integration credential resolver support, update opensearch indexer, and improve frontend workflow parameter handling and integration detail page. Signed-off-by: Aseem Shrey --- backend/package.json | 5 +- .../src/components/utils/categorization.ts | 24 + backend/src/integrations/aws.service.ts | 2 +- .../src/integrations/integration-catalog.ts | 2 +- backend/src/integrations/slack.service.ts | 8 +- bun.lock | 19 +- docker/docker-compose.full.yml | 2 + docker/docker-compose.infra.yml | 54 +- docker/opensearch-init.sh | 47 +- docs/components/security.mdx | 61 +- docs/integrations.md | 31 +- docs/sample/aws-cspm-org-discovery.json | 2 +- .../aws-cspm-org-prowler-to-analytics.json | 125 ++ .../aws-cspm-prowler-slack-summary.json | 78 +- .../sample/aws-cspm-prowler-to-analytics.json | 2 +- docs/user-guide.md | 15 + .../src/components/workflow/ConfigPanel.tsx | 1 + .../components/workflow/ParameterField.tsx | 35 +- frontend/src/pages/IntegrationCallback.tsx | 4 +- frontend/src/pages/IntegrationDetailPage.tsx | 136 +- frontend/src/schemas/component.ts | 1 + frontend/src/store/integrationStore.ts | 24 +- packages/backend-client/src/client.ts | 10 +- packages/component-sdk/src/types.ts | 6 +- worker/src/components/core/aws-assume-role.ts | 88 +- .../src/components/core/aws-org-discovery.ts | 67 +- worker/src/components/core/entry-point.ts | 12 +- .../core/integration-credential-resolver.ts | 9 + worker/src/components/notification/slack.ts | 218 ++- .../src/components/security/prowler-scan.ts | 1602 +++++++++-------- .../src/components/security/prowler-shared.ts | 506 ++++++ .../activities/run-component.activity.ts | 15 +- worker/src/temporal/utils/component-output.ts | 20 +- worker/src/temporal/workflow-runner.ts | 3 + worker/src/utils/opensearch-indexer.ts | 64 +- 35 files changed, 2295 insertions(+), 1003 deletions(-) create mode 100644 docs/sample/aws-cspm-org-prowler-to-analytics.json create mode 100644 worker/src/components/security/prowler-shared.ts diff --git a/backend/package.json b/backend/package.json index c8e9b629..dfb194a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,8 @@ "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", @@ -30,8 +32,6 @@ "@nestjs/platform-express": "^10.4.22", "@nestjs/swagger": "^11.2.5", "@nestjs/throttler": "^6.5.0", - "@aws-sdk/client-organizations": "^3.750.0", - "@aws-sdk/client-sts": "^3.750.0", "@opensearch-project/opensearch": "^3.5.1", "@shipsec/backend-client": "workspace:*", "@shipsec/component-sdk": "workspace:*", @@ -48,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/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/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/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/integrations.md b/docs/integrations.md index 5c6c9418..ebdb0d09 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -209,7 +209,7 @@ Assumes an IAM role via STS and returns temporary credentials. Useful for cross- ## Sample Workflows -Three pre-built workflow templates are available in `docs/sample/`: +Four pre-built workflow templates are available in `docs/sample/`: ### AWS CSPM -- Org Account Discovery @@ -243,21 +243,44 @@ End-to-end CSPM workflow: resolves AWS credentials, runs a Prowler security scan - `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 Credentials → Prowler Scan → Slack Message +Entry Point → Resolve AWS Credentials → Prowler Scan ─┐ + └→ Resolve Slack Credentials ───────────────┴→ Slack Message ``` -Resolves AWS credentials, runs a Prowler security scan (severity high/critical), and sends a formatted summary of findings to a Slack channel via an Incoming Webhook. +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`) -- `slackWebhookUrl` (required) -- Slack Incoming Webhook URL for the target channel +- `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 diff --git a/docs/sample/aws-cspm-org-discovery.json b/docs/sample/aws-cspm-org-discovery.json index 227ebb4f..1688aeeb 100644 --- a/docs/sample/aws-cspm-org-discovery.json +++ b/docs/sample/aws-cspm-org-discovery.json @@ -45,7 +45,7 @@ "data": { "label": "Resolve AWS Credentials", "config": { - "params": {}, + "params": { "provider": "aws" }, "inputOverrides": { "connectionId": "{{entry-1.connectionId}}" } 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 index 5d1aa772..17726896 100644 --- a/docs/sample/aws-cspm-prowler-slack-summary.json +++ b/docs/sample/aws-cspm-prowler-slack-summary.json @@ -1,6 +1,6 @@ { "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 webhook.", + "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", @@ -27,11 +27,27 @@ "default": "us-east-1" }, { - "id": "slackWebhookUrl", - "label": "Slack Webhook URL", + "id": "slackConnectionId", + "label": "Slack Integration Connection ID", "type": "text", "required": true, - "description": "Slack Incoming Webhook URL to send the scan summary to." + "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" } ] }, @@ -42,11 +58,23 @@ { "id": "resolve-creds-1", "type": "core.integration.resolve-credentials", - "position": { "x": 300, "y": 280 }, + "position": { "x": 450, "y": 280 }, "data": { "label": "Resolve AWS Credentials", "config": { - "params": {}, + "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": {} } } @@ -54,7 +82,7 @@ { "id": "prowler-1", "type": "security.prowler.scan", - "position": { "x": 300, "y": 510 }, + "position": { "x": 450, "y": 510 }, "data": { "label": "Prowler Security Scan", "config": { @@ -74,11 +102,14 @@ "label": "Slack Scan Summary", "config": { "params": { - "authType": "webhook", - "variables": [{ "name": "scanSummary", "type": "json" }] + "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\nGenerated at {{generatedAt}}" + "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}}" } } } @@ -99,6 +130,13 @@ "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", @@ -121,11 +159,18 @@ "targetHandle": "regions" }, { - "id": "e-entry-to-slack-webhook", + "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": "slackWebhookUrl", - "targetHandle": "webhookUrl" + "sourceHandle": "slackChannel", + "targetHandle": "channel" }, { "id": "e-prowler-to-slack-summary", @@ -133,6 +178,13 @@ "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 index 69a4cb9f..4ff7b85e 100644 --- a/docs/sample/aws-cspm-prowler-to-analytics.json +++ b/docs/sample/aws-cspm-prowler-to-analytics.json @@ -39,7 +39,7 @@ "data": { "label": "Resolve AWS Credentials", "config": { - "params": {}, + "params": { "provider": "aws" }, "inputOverrides": {} } } 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/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 f1b31ec1..2baf2ed8 100644 --- a/frontend/src/components/workflow/ParameterField.tsx +++ b/frontend/src/components/workflow/ParameterField.tsx @@ -42,6 +42,7 @@ interface ParameterFieldProps { componentId?: string; parameters?: Record | undefined; onUpdateParameter?: (paramId: string, value: any) => void; + nodeLabel?: string; } /** @@ -55,6 +56,7 @@ export function ParameterField({ componentId, parameters, onUpdateParameter, + nodeLabel, }: ParameterFieldProps) { const currentValue = value !== undefined ? value : parameter.default; const [jsonError, setJsonError] = useState(null); @@ -105,11 +107,29 @@ export function ParameterField({ [mergedConnections], ); - // All connections for the generic credential resolver (no provider filter) - const allConnections = useMemo(() => mergedConnections, [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 ? allConnections : githubConnections; + const connectionOptions = isGenericCredentialResolver ? filteredConnections : githubConnections; const [workflowOptions, setWorkflowOptions] = useState<{ id: string; name: string }[]>([]); const [workflowOptionsLoading, setWorkflowOptionsLoading] = useState(false); @@ -567,6 +587,7 @@ export function ParameterField({ ); case 'boolean': + case 'toggle': return (