Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/src/cloud-security/providers/aws-security.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +111,7 @@ export class AWSSecurityService {
new SnsSqsAdapter(),
new EcrAdapter(),
new OpenSearchAdapter(),
new NeptuneAdapter(),
new RedshiftAdapter(),
new MacieAdapter(),
new Route53Adapter(),
Expand Down Expand Up @@ -711,6 +713,7 @@ const AWS_COST_SERVICE_MAPPING: Record<string, string[]> = {
'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'],
Expand Down
139 changes: 139 additions & 0 deletions apps/api/src/cloud-security/providers/aws/neptune.adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
214 changes: 214 additions & 0 deletions apps/api/src/cloud-security/providers/aws/neptune.adapter.ts
Original file line number Diff line number Diff line change
@@ -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<SecurityFinding[]> {
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<string, unknown>,
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,
};
}
}
Loading
Loading