From 777fe9d8c5d86075ee6e2505049a1dab3a38c171 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 2 Jun 2026 21:58:04 -0400 Subject: [PATCH] fix(integration-platform): preserve array credential fields so AWS evidence checks see regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual "Run" path for App Automations (task-integrations + checks controllers) ran decrypted credentials through toStringCredentials, which collapses every value via getStringValue — turning AWS `regions: string[]` into a single string. assumeAwsSession only accepts regions as an array, so it resolved regions=[] and returned null, logging "AWS IAM check: connection not configured — skipping" even though a valid AWS connection exists (and works in Cloud Tests and on the scheduled/daily path, which use ensure-valid-credentials and preserve the array). Fix: stop flattening credentials on the run paths and let array fields survive. - Widen CheckContext.credentials and CheckContextOptions.credentials to Record (the runtime already read `regions` as an array; the type now matches reality). String-only auth schemes (api key, basic) read a scalar via a local credString() coercion. - task-integrations.controller + checks.controller: pass the decrypted credentials through unchanged instead of toStringCredentials(...). Remove the now-unused toStringCredentials helper (getStringValue stays for access_token). - Extract assumeAwsSession's input resolution into a pure resolveAwsCredentialInputs() that also tolerates a single region string / legacy `region` key, and unit-test it. Scheduled/auto-run paths were already correct (they consume ensure-valid-credentials). GCP/OAuth unaffected. apps/api/src/cloud-security untouched. 211 package tests pass (5 new); package build clean; api source type-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/checks.controller.ts | 8 +-- .../task-integrations.controller.ts | 8 +-- .../utils/credential-utils.ts | 19 ------- .../aws/checks/__tests__/aws-checks.test.ts | 39 ++++++++++++++ .../src/manifests/aws/checks/shared.ts | 54 ++++++++++++++----- .../src/runtime/check-context.ts | 27 ++++++++-- packages/integration-platform/src/types.ts | 9 +++- 7 files changed, 120 insertions(+), 44 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/checks.controller.ts b/apps/api/src/integration-platform/controllers/checks.controller.ts index 5376d67d91..516b318f54 100644 --- a/apps/api/src/integration-platform/controllers/checks.controller.ts +++ b/apps/api/src/integration-platform/controllers/checks.controller.ts @@ -32,7 +32,7 @@ import { ConnectionService } from '../services/connection.service'; import { CredentialVaultService } from '../services/credential-vault.service'; import { ProviderRepository } from '../repositories/provider.repository'; import { CheckRunRepository } from '../repositories/check-run.repository'; -import { getStringValue, toStringCredentials } from '../utils/credential-utils'; +import { getStringValue } from '../utils/credential-utils'; // Class (not interface) so @nestjs/swagger emits a body schema, plus a // class-validator decorator so the ValidationPipe whitelist accepts the field. @@ -257,11 +257,13 @@ export class ChecksController { try { // Run checks const accessToken = getStringValue(credentials.access_token); - const stringCredentials = toStringCredentials(credentials); const result = await runAllChecks({ manifest, accessToken, - credentials: stringCredentials, + // Pass decrypted credentials through unchanged. Collapsing array fields + // here (e.g. AWS `regions`) made custom-auth checks see no regions and + // skip with "connection not configured". + credentials, variables, connectionId, organizationId: connection.organizationId, diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts index b1f1922042..36d153ea2d 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -35,7 +35,7 @@ import { CheckRunRepository } from '../repositories/check-run.repository'; import { CredentialVaultService } from '../services/credential-vault.service'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { TaskIntegrationChecksService } from '../services/task-integration-checks.service'; -import { getStringValue, toStringCredentials } from '../utils/credential-utils'; +import { getStringValue } from '../utils/credential-utils'; import { isCheckDisabledForTask } from '../utils/disabled-task-checks'; import { db } from '@db'; import type { Prisma } from '@db'; @@ -429,11 +429,13 @@ export class TaskIntegrationsController { try { // Run the specific check const accessToken = getStringValue(credentials.access_token); - const stringCredentials = toStringCredentials(credentials); const result = await runAllChecks({ manifest, accessToken, - credentials: stringCredentials, + // Pass decrypted credentials through unchanged. Collapsing array fields + // here (e.g. AWS `regions`) made custom-auth checks see no regions and + // skip with "connection not configured". + credentials, variables, connectionId, organizationId, diff --git a/apps/api/src/integration-platform/utils/credential-utils.ts b/apps/api/src/integration-platform/utils/credential-utils.ts index f73a0d6426..0b2027b332 100644 --- a/apps/api/src/integration-platform/utils/credential-utils.ts +++ b/apps/api/src/integration-platform/utils/credential-utils.ts @@ -14,22 +14,3 @@ export function getStringValue(value?: string | string[]): string | undefined { } return value; } - -/** - * Normalizes credentials from Record to Record - * by extracting the first value from arrays - * @param credentials - The credentials object with potential array values - * @returns A normalized credentials object with only string values - */ -export function toStringCredentials( - credentials: Record, -): Record { - const normalized: Record = {}; - for (const [key, value] of Object.entries(credentials)) { - const stringValue = getStringValue(value); - if (typeof stringValue === 'string' && stringValue.length > 0) { - normalized[key] = stringValue; - } - } - return normalized; -} diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts index 13f4f5c96d..cfa7d727ae 100644 --- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts +++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts @@ -14,9 +14,48 @@ import { evaluateRdsEncryption, } from '../rds'; import { evaluateS3Encryption, evaluateS3PublicAccess } from '../s3'; +import { resolveAwsCredentialInputs } from '../shared'; const kinds = (os: { kind: string }[]) => os.map((o) => o.kind); +describe('AWS credential resolution (regions shape)', () => { + const base = { roleArn: 'arn:aws:iam::123456789012:role/x', externalId: 'eid' }; + + it('honors a multi-element regions array (the normal stored shape)', () => { + const r = resolveAwsCredentialInputs({ + ...base, + regions: ['us-east-1', 'us-west-2'], + }); + expect(r).not.toBeNull(); + expect(r!.regions).toEqual(['us-east-1', 'us-west-2']); + }); + + it('accepts a single region string (resilient to an upstream collapse)', () => { + const r = resolveAwsCredentialInputs({ ...base, regions: 'us-east-1' }); + expect(r).not.toBeNull(); + expect(r!.regions).toEqual(['us-east-1']); + }); + + it('accepts the legacy singular `region` key', () => { + const r = resolveAwsCredentialInputs({ ...base, region: 'eu-west-1' }); + expect(r!.regions).toEqual(['eu-west-1']); + }); + + it('returns null when regions resolve to empty (not configured)', () => { + expect(resolveAwsCredentialInputs({ ...base, regions: [] })).toBeNull(); + expect(resolveAwsCredentialInputs({ ...base, regions: [' '] })).toBeNull(); + }); + + it('returns null when roleArn or externalId is missing', () => { + expect( + resolveAwsCredentialInputs({ externalId: 'eid', regions: ['us-east-1'] }), + ).toBeNull(); + expect( + resolveAwsCredentialInputs({ roleArn: base.roleArn, regions: ['us-east-1'] }), + ).toBeNull(); + }); +}); + describe('AWS IAM account evaluator', () => { it('fails on missing policy, root MFA off, and root keys present', () => { const out = evaluateIamAccount({ diff --git a/packages/integration-platform/src/manifests/aws/checks/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts index cc5edc04a3..9c0f949e9a 100644 --- a/packages/integration-platform/src/manifests/aws/checks/shared.ts +++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts @@ -10,6 +10,43 @@ export interface AwsSession { regions: string[]; } +export interface AwsCredentialInputs { + roleArn: string; + externalId: string; + regions: string[]; +} + +/** + * Resolve role ARN, external ID, and regions from raw connection credentials. + * Returns null when any is missing (treated as "connection not configured"). + * + * `regions` is normally a string[]; a single region string (or the legacy + * singular `region` key) is also accepted, so a value that was collapsed to a + * scalar upstream still yields a usable region instead of silently resolving to + * "not configured". + */ +export function resolveAwsCredentialInputs( + credentials: Record, +): AwsCredentialInputs | null { + const roleArn = + typeof credentials.roleArn === 'string' ? credentials.roleArn : ''; + const externalId = + typeof credentials.externalId === 'string' ? credentials.externalId : ''; + const rawRegions = credentials.regions; + const regions = ( + Array.isArray(rawRegions) + ? rawRegions.filter((r): r is string => typeof r === 'string') + : typeof rawRegions === 'string' + ? [rawRegions] + : typeof credentials.region === 'string' + ? [credentials.region] + : [] + ).filter((r) => r.trim().length > 0); + + if (!roleArn || !externalId || regions.length === 0) return null; + return { roleArn, externalId, regions }; +} + /** * Assume the customer's cross-account IAM role (role ARN + external ID from the * connection credentials) and return temporary credentials + the selected @@ -19,18 +56,11 @@ export interface AwsSession { export async function assumeAwsSession( ctx: CheckContext, ): Promise { - const raw = ctx.credentials as Record; - const roleArn = typeof raw.roleArn === 'string' ? raw.roleArn : ''; - const externalId = typeof raw.externalId === 'string' ? raw.externalId : ''; - const regions = ( - Array.isArray(raw.regions) - ? raw.regions.filter((r): r is string => typeof r === 'string') - : typeof raw.region === 'string' - ? [raw.region] - : [] - ).filter((r) => r.trim().length > 0); - - if (!roleArn || !externalId || regions.length === 0) return null; + const inputs = resolveAwsCredentialInputs( + ctx.credentials as Record, + ); + if (!inputs) return null; + const { roleArn, externalId, regions } = inputs; const sts = new STSClient({ region: regions[0] }); const res = await sts.send( diff --git a/packages/integration-platform/src/runtime/check-context.ts b/packages/integration-platform/src/runtime/check-context.ts index 16a773f68c..27527a67d2 100644 --- a/packages/integration-platform/src/runtime/check-context.ts +++ b/packages/integration-platform/src/runtime/check-context.ts @@ -10,7 +10,13 @@ export interface CheckContextOptions { manifest: IntegrationManifest; /** Access token for OAuth integrations. Optional for custom auth types (e.g., AWS IAM). */ accessToken?: string; - credentials: Record; + /** + * Credential values. Custom-auth providers can legitimately hold array + * fields (e.g. AWS `regions: string[]`), so values are `string | string[]`. + * Do NOT collapse arrays to a single value before passing them in — checks + * like the AWS ones read `regions` as an array. + */ + credentials: Record; variables?: CheckVariableValues; connectionId: string; organizationId: string; @@ -92,6 +98,17 @@ export function createCheckContext(options: CheckContextOptions): { } = options; let currentAccessToken = initialAccessToken ?? ''; + + // Read a credential as a single string. Custom-auth credentials can be + // arrays (e.g. AWS regions); the string-based auth schemes (api key, basic) + // need a scalar, so collapse to the first element here — never upstream, + // where it would destroy multi-value fields for the checks that need them. + const credString = (key: string): string => { + const v = credentials[key]; + if (Array.isArray(v)) return v[0] ?? ''; + return v ?? ''; + }; + const findings: CheckResult['findings'] = []; const passingResults: CheckResult['passingResults'] = []; const logs: CheckResult['logs'] = []; @@ -135,7 +152,7 @@ export function createCheckContext(options: CheckContextOptions): { // API Key: Add to header if configured if (manifest.auth.type === 'api_key' && manifest.auth.config.in === 'header') { - const apiKey = credentials[manifest.auth.config.name] || credentials.api_key || ''; + const apiKey = credString(manifest.auth.config.name) || credString('api_key'); const value = manifest.auth.config.prefix ? `${manifest.auth.config.prefix}${apiKey}` : apiKey; @@ -144,8 +161,8 @@ export function createCheckContext(options: CheckContextOptions): { // Basic Auth: Encode username:password if (manifest.auth.type === 'basic') { - const username = credentials[manifest.auth.config.usernameField || 'username'] || ''; - const password = credentials[manifest.auth.config.passwordField || 'password'] || ''; + const username = credString(manifest.auth.config.usernameField || 'username'); + const password = credString(manifest.auth.config.passwordField || 'password'); const encoded = Buffer.from(`${username}:${password}`).toString('base64'); headers['Authorization'] = `Basic ${encoded}`; } @@ -227,7 +244,7 @@ export function createCheckContext(options: CheckContextOptions): { // API Key in query param if (manifest.auth.type === 'api_key' && manifest.auth.config.in === 'query') { - const apiKey = credentials[manifest.auth.config.name] || credentials.api_key || ''; + const apiKey = credString(manifest.auth.config.name) || credString('api_key'); const value = manifest.auth.config.prefix ? `${manifest.auth.config.prefix}${apiKey}` : apiKey; diff --git a/packages/integration-platform/src/types.ts b/packages/integration-platform/src/types.ts index 9217d9d9a1..a2929b5099 100644 --- a/packages/integration-platform/src/types.ts +++ b/packages/integration-platform/src/types.ts @@ -268,8 +268,13 @@ export interface CheckContext { /** The OAuth access token (for oauth2 auth). Empty for custom auth types like AWS. */ accessToken: string; - /** All credentials as key-value pairs (form fields for custom auth, or token data for OAuth) */ - credentials: Record; + /** + * All credentials as key-value pairs (form fields for custom auth, or token + * data for OAuth). Custom-auth fields can be arrays (e.g. AWS `regions`), so + * values are `string | string[]` — read array fields directly and use a + * scalar coercion only where a single string is required. + */ + credentials: Record; /** User-configured variables for this integration */ variables: CheckVariableValues;