diff --git a/apps/api/src/cloud-security/providers/aws-security.service.ts b/apps/api/src/cloud-security/providers/aws-security.service.ts index fa0f0a35f..de43d8955 100644 --- a/apps/api/src/cloud-security/providers/aws-security.service.ts +++ b/apps/api/src/cloud-security/providers/aws-security.service.ts @@ -40,6 +40,7 @@ import { LambdaAdapter } from './aws/lambda.adapter'; import { DynamoDbAdapter } from './aws/dynamodb.adapter'; import { SnsSqsAdapter } from './aws/sns-sqs.adapter'; import { EcrAdapter } from './aws/ecr.adapter'; +import { NeptuneAdapter } from './aws/neptune.adapter'; import { OpenSearchAdapter } from './aws/opensearch.adapter'; import { RedshiftAdapter } from './aws/redshift.adapter'; import { MacieAdapter } from './aws/macie.adapter'; @@ -110,6 +111,7 @@ export class AWSSecurityService { new SnsSqsAdapter(), new EcrAdapter(), new OpenSearchAdapter(), + new NeptuneAdapter(), new RedshiftAdapter(), new MacieAdapter(), new Route53Adapter(), @@ -711,6 +713,7 @@ const AWS_COST_SERVICE_MAPPING: Record = { 'Amazon Elastic Container Registry': ['ecr'], 'Amazon OpenSearch Service': ['opensearch'], 'Amazon Elasticsearch Service': ['opensearch'], // legacy name + 'Amazon Neptune': ['neptune'], 'Amazon Redshift': ['redshift'], 'Amazon Macie': ['macie'], 'Amazon Route 53': ['route53'], diff --git a/apps/api/src/cloud-security/providers/aws/neptune.adapter.spec.ts b/apps/api/src/cloud-security/providers/aws/neptune.adapter.spec.ts new file mode 100644 index 000000000..1620b9060 --- /dev/null +++ b/apps/api/src/cloud-security/providers/aws/neptune.adapter.spec.ts @@ -0,0 +1,139 @@ +const sendMock = jest.fn(); + +jest.mock('@aws-sdk/client-rds', () => ({ + RDSClient: jest.fn().mockImplementation(() => ({ send: sendMock })), + DescribeDBClustersCommand: jest + .fn() + .mockImplementation((input: unknown) => ({ input })), +})); + +import { NeptuneAdapter } from './neptune.adapter'; + +const creds = { + accessKeyId: 'a', + secretAccessKey: 'b', + sessionToken: 'c', +}; + +function run() { + return new NeptuneAdapter().scan({ credentials: creds, region: 'us-east-1' }); +} + +describe('NeptuneAdapter', () => { + beforeEach(() => sendMock.mockReset()); + + it('flags a non-compliant Neptune cluster on all five checks', async () => { + sendMock.mockResolvedValueOnce({ + DBClusters: [ + { + Engine: 'neptune', + DBClusterIdentifier: 'graph-1', + DBClusterArn: 'arn:aws:rds:us-east-1:123:cluster:graph-1', + StorageEncrypted: false, + DeletionProtection: false, + BackupRetentionPeriod: 1, + IAMDatabaseAuthenticationEnabled: false, + EnabledCloudwatchLogsExports: [], + }, + ], + }); + + const findings = await run(); + const failed = findings.filter((f) => f.passed === false); + expect(failed).toHaveLength(5); + + const titles = failed.map((f) => f.title); + expect(titles).toEqual( + expect.arrayContaining([ + 'Neptune cluster is not encrypted at rest', + 'Neptune cluster does not have deletion protection', + 'Neptune cluster has insufficient backup retention', + 'Neptune cluster does not enforce IAM database authentication', + 'Neptune cluster does not export audit logs to CloudWatch', + ]), + ); + + // Encryption-at-rest is not auto-fixable; the others are. + const enc = failed.find((f) => f.title.includes('not encrypted at rest')); + expect(enc?.remediation).toContain('[MANUAL]'); + const del = failed.find((f) => f.title.includes('deletion protection')); + expect(del?.remediation).toContain('rds:ModifyDBClusterCommand'); + expect(del?.remediation).toContain('DeletionProtection set to true'); + }); + + it('passes a fully-compliant Neptune cluster', async () => { + sendMock.mockResolvedValueOnce({ + DBClusters: [ + { + Engine: 'neptune', + DBClusterIdentifier: 'graph-2', + DBClusterArn: 'arn:aws:rds:us-east-1:123:cluster:graph-2', + StorageEncrypted: true, + DeletionProtection: true, + BackupRetentionPeriod: 14, + IAMDatabaseAuthenticationEnabled: true, + EnabledCloudwatchLogsExports: ['audit'], + }, + ], + }); + + const findings = await run(); + expect(findings).toHaveLength(5); + expect(findings.every((f) => f.passed === true)).toBe(true); + }); + + it('ignores non-Neptune engine clusters', async () => { + sendMock.mockResolvedValueOnce({ + DBClusters: [ + { + Engine: 'aurora-postgresql', + DBClusterIdentifier: 'pg-1', + StorageEncrypted: false, + }, + ], + }); + + expect(await run()).toEqual([]); + }); + + it('paginates through the Marker', async () => { + sendMock + .mockResolvedValueOnce({ + DBClusters: [ + { + Engine: 'neptune', + DBClusterIdentifier: 'graph-a', + StorageEncrypted: true, + DeletionProtection: true, + BackupRetentionPeriod: 7, + IAMDatabaseAuthenticationEnabled: true, + EnabledCloudwatchLogsExports: ['audit'], + }, + ], + Marker: 'page-2', + }) + .mockResolvedValueOnce({ + DBClusters: [ + { + Engine: 'neptune', + DBClusterIdentifier: 'graph-b', + StorageEncrypted: true, + DeletionProtection: true, + BackupRetentionPeriod: 7, + IAMDatabaseAuthenticationEnabled: true, + EnabledCloudwatchLogsExports: ['audit'], + }, + ], + }); + + const findings = await run(); + expect(sendMock).toHaveBeenCalledTimes(2); + // 5 checks per cluster × 2 clusters. + expect(findings).toHaveLength(10); + }); + + it('returns [] on AccessDenied', async () => { + sendMock.mockRejectedValueOnce(new Error('AccessDeniedException: nope')); + expect(await run()).toEqual([]); + }); +}); diff --git a/apps/api/src/cloud-security/providers/aws/neptune.adapter.ts b/apps/api/src/cloud-security/providers/aws/neptune.adapter.ts new file mode 100644 index 000000000..85e49f095 --- /dev/null +++ b/apps/api/src/cloud-security/providers/aws/neptune.adapter.ts @@ -0,0 +1,214 @@ +import { DescribeDBClustersCommand, RDSClient } from '@aws-sdk/client-rds'; + +import type { SecurityFinding } from '../../cloud-security.service'; +import type { AwsCredentials, AwsServiceAdapter } from './aws-service-adapter'; + +// Neptune is managed through the RDS API surface — its DB clusters are returned +// by rds:DescribeDBClusters (filtered by Engine) and modified by +// rds:ModifyDBCluster. The `neptune-db:` IAM namespace is data-plane only, so +// all management/remediation here uses the `rds:` service + IAM actions. + +/** Minimum automated-backup retention (days) we consider compliant. */ +const MIN_BACKUP_RETENTION_DAYS = 7; + +export class NeptuneAdapter implements AwsServiceAdapter { + readonly serviceId = 'neptune'; + readonly isGlobal = false; + + async scan({ + credentials, + region, + }: { + credentials: AwsCredentials; + region: string; + accountId?: string; + }): Promise { + const client = new RDSClient({ credentials, region }); + const findings: SecurityFinding[] = []; + + try { + let marker: string | undefined; + do { + const resp = await client.send( + new DescribeDBClustersCommand({ Marker: marker, MaxRecords: 100 }), + ); + + for (const cluster of resp.DBClusters ?? []) { + // DescribeDBClusters returns every DB-cluster engine (Aurora, + // Neptune, DocumentDB) — only assess Neptune clusters here. + if (cluster.Engine !== 'neptune') continue; + + const id = cluster.DBClusterIdentifier ?? 'unknown'; + const resourceId = cluster.DBClusterArn ?? id; + + // 1. Storage encryption at rest (not auto-fixable — enabling it on an + // existing cluster requires snapshot + restore into a new cluster). + if (cluster.StorageEncrypted !== true) { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster is not encrypted at rest', + `Neptune cluster "${id}" does not have storage encryption enabled`, + 'high', + { clusterId: id, storageEncrypted: false }, + false, + `[MANUAL] Cannot be auto-fixed. Enabling encryption at rest on an existing Neptune cluster requires creating an encrypted snapshot and restoring it into a new cluster.`, + ), + ); + } else { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster is encrypted at rest', + `Neptune cluster "${id}" has storage encryption enabled`, + 'info', + { clusterId: id, storageEncrypted: true }, + true, + ), + ); + } + + // 2. Deletion protection. + if (cluster.DeletionProtection !== true) { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster does not have deletion protection', + `Neptune cluster "${id}" does not have deletion protection enabled`, + 'medium', + { clusterId: id, deletionProtection: false }, + false, + `Use rds:ModifyDBClusterCommand with DBClusterIdentifier "${id}" and DeletionProtection set to true. Rollback by setting DeletionProtection to false.`, + ), + ); + } else { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster has deletion protection', + `Neptune cluster "${id}" has deletion protection enabled`, + 'info', + { clusterId: id, deletionProtection: true }, + true, + ), + ); + } + + // 3. Automated backup retention. + const retention = cluster.BackupRetentionPeriod ?? 0; + if (retention < MIN_BACKUP_RETENTION_DAYS) { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster has insufficient backup retention', + `Neptune cluster "${id}" has a backup retention period of ${retention} day(s); at least ${MIN_BACKUP_RETENTION_DAYS} is recommended`, + 'medium', + { clusterId: id, backupRetentionPeriod: retention }, + false, + `Use rds:ModifyDBClusterCommand with DBClusterIdentifier "${id}" and BackupRetentionPeriod set to ${MIN_BACKUP_RETENTION_DAYS}. Rollback by restoring the original BackupRetentionPeriod value (${retention}).`, + ), + ); + } else { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster has sufficient backup retention', + `Neptune cluster "${id}" has a backup retention period of ${retention} day(s)`, + 'info', + { clusterId: id, backupRetentionPeriod: retention }, + true, + ), + ); + } + + // 4. IAM database authentication. + if (cluster.IAMDatabaseAuthenticationEnabled !== true) { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster does not enforce IAM database authentication', + `Neptune cluster "${id}" does not have IAM database authentication enabled`, + 'medium', + { clusterId: id, iamDatabaseAuthentication: false }, + false, + `Use rds:ModifyDBClusterCommand with DBClusterIdentifier "${id}" and EnableIAMDatabaseAuthentication set to true. Rollback by setting EnableIAMDatabaseAuthentication to false.`, + ), + ); + } else { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster enforces IAM database authentication', + `Neptune cluster "${id}" has IAM database authentication enabled`, + 'info', + { clusterId: id, iamDatabaseAuthentication: true }, + true, + ), + ); + } + + // 5. Audit logs exported to CloudWatch Logs. + const auditEnabled = (cluster.EnabledCloudwatchLogsExports ?? []).includes( + 'audit', + ); + if (!auditEnabled) { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster does not export audit logs to CloudWatch', + `Neptune cluster "${id}" is not exporting audit logs to CloudWatch Logs`, + 'medium', + { clusterId: id, auditLogsToCloudWatch: false }, + false, + `Use rds:ModifyDBClusterCommand with DBClusterIdentifier "${id}" and CloudwatchLogsExportConfiguration set to { EnableLogTypes: ["audit"] }. Rollback by setting CloudwatchLogsExportConfiguration to { DisableLogTypes: ["audit"] }.`, + ), + ); + } else { + findings.push( + this.makeFinding( + resourceId, + 'Neptune cluster exports audit logs to CloudWatch', + `Neptune cluster "${id}" exports audit logs to CloudWatch Logs`, + 'info', + { clusterId: id, auditLogsToCloudWatch: true }, + true, + ), + ); + } + } + + marker = resp.Marker; + } while (marker); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes('AccessDenied')) return []; + throw error; + } + + return findings; + } + + private makeFinding( + resourceId: string, + title: string, + description: string, + severity: SecurityFinding['severity'], + evidence?: Record, + passed?: boolean, + remediation?: string, + ): SecurityFinding { + const id = `neptune-${resourceId}-${title.toLowerCase().replace(/\s+/g, '-')}`; + return { + id, + title, + description, + severity, + resourceType: 'AwsNeptuneCluster', + resourceId, + remediation, + evidence: { ...evidence, findingKey: id }, + createdAt: new Date().toISOString(), + passed, + }; + } +} diff --git a/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts b/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts index 39fcc7c65..e18d0f8be 100644 --- a/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts +++ b/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts @@ -4,7 +4,10 @@ // importing the service. jest.mock('@db', () => ({ db: {} })); -import { GCPSecurityService } from './gcp-security.service'; +import { + GCPSecurityService, + resolveGcpServiceId, +} from './gcp-security.service'; /** * Helper: build a Response-like object for a single page of the GCP @@ -639,3 +642,59 @@ describe('GCPSecurityService — project detection', () => { }); }); }); + +describe('resolveGcpServiceId — Vertex AI grouping', () => { + it('maps an aiplatform resource type to vertex-ai (regardless of category)', () => { + expect( + resolveGcpServiceId( + 'SOME_UNMAPPED_AI_CATEGORY', + 'aiplatform.googleapis.com/Dataset', + '//aiplatform.googleapis.com/projects/p/locations/l/datasets/123', + ), + ).toBe('vertex-ai'); + }); + + it('maps a Workbench (notebooks) resource type to vertex-ai', () => { + expect( + resolveGcpServiceId( + 'NOTEBOOK_PUBLIC_IP', + 'notebooks.googleapis.com/Instance', + undefined, + ), + ).toBe('vertex-ai'); + }); + + it('matches on resourceName when resource type is absent', () => { + expect( + resolveGcpServiceId( + 'X', + undefined, + '//aiplatform.googleapis.com/projects/p/locations/l/models/m', + ), + ).toBe('vertex-ai'); + }); + + it('resource type takes precedence over a category mapping', () => { + // PUBLIC_BUCKET_ACL maps to cloud-storage, but an aiplatform resource + // must still group under vertex-ai (resource type is authoritative). + expect( + resolveGcpServiceId( + 'PUBLIC_BUCKET_ACL', + 'aiplatform.googleapis.com/Endpoint', + undefined, + ), + ).toBe('vertex-ai'); + }); + + it('falls back to the category mapping for non-AI findings', () => { + expect( + resolveGcpServiceId('PUBLIC_BUCKET_ACL', 'storage.googleapis.com/Bucket', undefined), + ).toBe('cloud-storage'); + }); + + it('falls back to security-command-center for unmapped, non-AI findings', () => { + expect(resolveGcpServiceId('TOTALLY_UNKNOWN', 'compute.googleapis.com/Foo', undefined)).toBe( + 'security-command-center', + ); + }); +}); diff --git a/apps/api/src/cloud-security/providers/gcp-security.service.ts b/apps/api/src/cloud-security/providers/gcp-security.service.ts index ba2a352ac..cf78ff86b 100644 --- a/apps/api/src/cloud-security/providers/gcp-security.service.ts +++ b/apps/api/src/cloud-security/providers/gcp-security.service.ts @@ -130,6 +130,7 @@ const SERVICE_NAMES: Record = { bigquery: 'BigQuery', pubsub: 'Pub/Sub', 'cloud-armor': 'Cloud Armor', + 'vertex-ai': 'Vertex AI', 'security-command-center': 'Security Command Center', }; @@ -151,8 +152,40 @@ const GCP_API_TO_SERVICE: Record = { 'networksecurity.googleapis.com': ['cloud-armor'], 'iam.googleapis.com': ['iam'], 'iamcredentials.googleapis.com': ['iam'], + 'aiplatform.googleapis.com': ['vertex-ai'], + 'notebooks.googleapis.com': ['vertex-ai'], }; +/** + * GCP resource-type hosts that map to a Cloud Tests service, checked BEFORE the + * finding category. SCC names AI detector categories inconsistently across + * resources (Dataset/Model/Endpoint/Workbench, CMEK/access/policy, etc.), so + * grouping by the authoritative resource type is far more robust than trying to + * enumerate every category string. Any finding on an `aiplatform`/`notebooks` + * resource is grouped under "Vertex AI". + */ +const RESOURCE_TYPE_HOST_TO_SERVICE: Array<[string, string]> = [ + ['aiplatform.googleapis.com', 'vertex-ai'], + ['notebooks.googleapis.com', 'vertex-ai'], +]; + +/** + * Resolve the Cloud Tests service ID for an SCC finding. Prefer the resource + * type (authoritative) over the category, then fall back to the generic + * Security Command Center bucket so nothing is ever dropped. + */ +export function resolveGcpServiceId( + category: string, + resourceType: string | undefined, + resourceName: string | undefined, +): string { + const haystack = `${resourceType ?? ''} ${resourceName ?? ''}`; + for (const [host, service] of RESOURCE_TYPE_HOST_TO_SERVICE) { + if (haystack.includes(host)) return service; + } + return CATEGORY_TO_SERVICE[category] ?? 'security-command-center'; +} + export type GcpSetupStepId = | 'enable_security_command_center_api' | 'enable_cloud_resource_manager_api' @@ -1359,8 +1392,11 @@ export class GCPSecurityService { if (seenIds.has(f.name)) continue; seenIds.add(f.name); - const serviceId = - CATEGORY_TO_SERVICE[f.category] ?? 'security-command-center'; + const serviceId = resolveGcpServiceId( + f.category, + result.resource?.type, + f.resourceName, + ); if (enabledServiceSet && !enabledServiceSet.has(serviceId)) { continue; } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ServiceCard.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ServiceCard.tsx index b243a7f11..c629ae256 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ServiceCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ServiceCard.tsx @@ -14,6 +14,7 @@ import { ScanSearch, Server, Shield, + Sparkles, Terminal, Workflow, } from 'lucide-react'; @@ -41,6 +42,7 @@ const SERVICE_ICONS: Record = { 'sns-sqs': Workflow, 'ecr': Server, 'opensearch': Database, + 'neptune': Database, 'redshift': Database, 'macie': ScanSearch, 'route53': Globe, @@ -78,6 +80,7 @@ const SERVICE_ICONS: Record = { 'bigquery': Database, 'pubsub': Workflow, 'cloud-armor': Shield, + 'vertex-ai': Sparkles, 'security-command-center': Shield, }; diff --git a/packages/integration-platform/src/manifests/aws/index.ts b/packages/integration-platform/src/manifests/aws/index.ts index 3eacd8c4d..b84f4a84f 100644 --- a/packages/integration-platform/src/manifests/aws/index.ts +++ b/packages/integration-platform/src/manifests/aws/index.ts @@ -63,6 +63,7 @@ export const awsManifest: IntegrationManifest = { { id: 'sns-sqs', name: 'SNS & SQS', description: 'Public topic/queue policies and encryption at rest checks', enabledByDefault: false, implemented: true }, { id: 'ecr', name: 'ECR', description: 'Image scanning configuration and immutable tag enforcement', enabledByDefault: false, implemented: true }, { id: 'opensearch', name: 'OpenSearch', description: 'Domain encryption, VPC deployment, and fine-grained access control checks', enabledByDefault: false, implemented: true }, + { id: 'neptune', name: 'Neptune', description: 'Cluster encryption, deletion protection, backup retention, IAM auth, and audit log checks', enabledByDefault: false, implemented: true }, // Lower priority — specialized use cases { id: 'redshift', name: 'Redshift', description: 'Cluster encryption, public accessibility, and audit logging checks', enabledByDefault: false, implemented: true }, { id: 'macie', name: 'Macie', description: 'Sensitive data discovery and data protection monitoring', enabledByDefault: false, implemented: true }, diff --git a/packages/integration-platform/src/manifests/gcp/index.ts b/packages/integration-platform/src/manifests/gcp/index.ts index 119f9d3b8..cf594e17d 100644 --- a/packages/integration-platform/src/manifests/gcp/index.ts +++ b/packages/integration-platform/src/manifests/gcp/index.ts @@ -89,6 +89,7 @@ This is industry standard - all GCP security monitoring tools use the same scope { id: 'bigquery', name: 'BigQuery', description: 'Dataset encryption and public access checks', enabledByDefault: false, implemented: true }, { id: 'pubsub', name: 'Pub/Sub', description: 'Topic encryption configuration checks', enabledByDefault: false, implemented: true }, { id: 'cloud-armor', name: 'Cloud Armor', description: 'SSL policy strength and WAF configuration checks', enabledByDefault: false, implemented: true }, + { id: 'vertex-ai', name: 'Vertex AI', description: 'Vertex AI and Workbench checks — encryption (CMEK), public access, and notebook exposure. Requires SCC Premium or Enterprise.', enabledByDefault: false, implemented: true }, ], // Integration-level variables (used by cloud security scanning)