infrawise 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,8 +173,9 @@ To let Claude Code manage the server lifecycle automatically:
173
173
  | `get_topic_details` | SNS topics — subscription counts and protocols |
174
174
  | `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
175
175
  | `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
176
- | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge), env var key names |
176
+ | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), env var key names |
177
177
  | `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
178
+ | `get_s3_overview` | S3 buckets — versioning, encryption, public access, event notifications |
178
179
  | `get_log_errors` | CloudWatch error patterns and counts (no raw log messages) |
179
180
 
180
181
  ---
@@ -198,7 +199,7 @@ To let Claude Code manage the server lifecycle automatically:
198
199
  | `-r, --repo <path>` | Repository to scan (default: current directory) |
199
200
  | `--no-cache` | Skip reading/writing the cache |
200
201
  | `-o, --output <path>` | Save findings as a markdown report, e.g. `report.md` |
201
- | `--severity <level>` | Only show findings at or above this level: `high` \| `medium` \| `low` |
202
+ | `--severity <level>` | Only show findings at or above this level: `high` \| `medium` \| `low` \| `verify` |
202
203
 
203
204
  ```bash
204
205
  # Export a shareable findings report
@@ -277,6 +278,9 @@ eventbridge:
277
278
  rds:
278
279
  enabled: false
279
280
 
281
+ s3:
282
+ enabled: false
283
+
280
284
  kafka:
281
285
  enabled: false
282
286
 
@@ -343,7 +347,8 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
343
347
  | SQS | Missing DLQs, unencrypted queues, large backlogs |
344
348
  | Kafka (kafkajs) | Producer/consumer topic mapping from code |
345
349
  | Secrets Manager | Missing secret rotation |
346
- | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge), missing DLQ on trigger source |
350
+ | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), missing DLQ on trigger source |
351
+ | S3 | Public access blocking (verify), missing versioning, missing encryption |
347
352
  | EventBridge | Rules, schedules, event patterns, target Lambda functions |
348
353
  | RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
349
354
  | CloudWatch Logs | Log groups with no retention policy |
@@ -1,3 +1,4 @@
1
1
  export * from './services.js';
2
2
  export * from './dynamodb.js';
3
3
  export * from './logs.js';
4
+ export * from './s3.js';
@@ -0,0 +1,72 @@
1
+ import { S3Client, ListBucketsCommand, GetBucketNotificationConfigurationCommand, GetBucketVersioningCommand, GetBucketEncryptionCommand, GetPublicAccessBlockCommand, } from '@aws-sdk/client-s3';
2
+ import { fromIni } from '@aws-sdk/credential-providers';
3
+ import { logger } from '../../core/index.js';
4
+ function clientConfig(cfg) {
5
+ const region = cfg.region ?? 'us-east-1';
6
+ const base = { region };
7
+ if (cfg.endpoint)
8
+ base.endpoint = cfg.endpoint;
9
+ if (cfg.profile)
10
+ base.credentials = fromIni({ profile: cfg.profile });
11
+ return base;
12
+ }
13
+ export async function extractS3Metadata(cfg = {}) {
14
+ const client = new S3Client(clientConfig(cfg));
15
+ const buckets = [];
16
+ try {
17
+ const listRes = await client.send(new ListBucketsCommand({}));
18
+ const rawBuckets = (listRes.Buckets ?? []).slice(0, 200);
19
+ for (const bucket of rawBuckets) {
20
+ const name = bucket.Name ?? '';
21
+ if (!name)
22
+ continue;
23
+ const arn = `arn:aws:s3:::${name}`;
24
+ const createdAt = bucket.CreationDate?.toISOString();
25
+ const [notifResult, versionResult, encryptResult, pabResult] = await Promise.allSettled([
26
+ client.send(new GetBucketNotificationConfigurationCommand({ Bucket: name })),
27
+ client.send(new GetBucketVersioningCommand({ Bucket: name })),
28
+ client.send(new GetBucketEncryptionCommand({ Bucket: name })),
29
+ client.send(new GetPublicAccessBlockCommand({ Bucket: name })),
30
+ ]);
31
+ const notifications = [];
32
+ if (notifResult.status === 'fulfilled') {
33
+ for (const config of notifResult.value.LambdaFunctionConfigurations ?? []) {
34
+ const lambdaArn = config.LambdaFunctionArn ?? '';
35
+ const lambdaName = lambdaArn.split(':').pop() ?? lambdaArn;
36
+ const rules = config.Filter?.Key?.FilterRules ?? [];
37
+ const prefix = rules.find((r) => r.Name?.toLowerCase() === 'prefix')?.Value;
38
+ const suffix = rules.find((r) => r.Name?.toLowerCase() === 'suffix')?.Value;
39
+ const notification = {
40
+ events: config.Events ?? [],
41
+ lambdaArn,
42
+ lambdaName,
43
+ };
44
+ if (prefix !== undefined)
45
+ notification.prefix = prefix;
46
+ if (suffix !== undefined)
47
+ notification.suffix = suffix;
48
+ notifications.push(notification);
49
+ }
50
+ }
51
+ const versioned = versionResult.status === 'fulfilled'
52
+ ? versionResult.value.Status === 'Enabled'
53
+ : false;
54
+ const encrypted = encryptResult.status === 'fulfilled'
55
+ ? (encryptResult.value.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0
56
+ : false;
57
+ let publicAccessBlocked = false;
58
+ if (pabResult.status === 'fulfilled') {
59
+ const pab = pabResult.value.PublicAccessBlockConfiguration ?? {};
60
+ publicAccessBlocked = !!(pab.BlockPublicAcls && pab.IgnorePublicAcls && pab.BlockPublicPolicy && pab.RestrictPublicBuckets);
61
+ }
62
+ buckets.push({ name, arn, createdAt, versioned, encrypted, publicAccessBlocked, notifications });
63
+ }
64
+ }
65
+ catch (err) {
66
+ logger.warn(`S3 list failed: ${err instanceof Error ? err.message : String(err)}`);
67
+ }
68
+ return buckets;
69
+ }
70
+ export async function validateS3Access(cfg = {}) {
71
+ await new S3Client(clientConfig(cfg)).send(new ListBucketsCommand({}));
72
+ }
@@ -183,3 +183,64 @@ export class LambdaHighTimeoutAnalyzer {
183
183
  return findings;
184
184
  }
185
185
  }
186
+ // ─── S3 ──────────────────────────────────────────────────────────────────────
187
+ export class S3PublicAccessAnalyzer {
188
+ name = 'S3PublicAccessAnalyzer';
189
+ async analyze(graph) {
190
+ const findings = [];
191
+ for (const node of graph.nodes) {
192
+ if (node.type !== 'bucket')
193
+ continue;
194
+ if (node.publicAccessBlocked === false) {
195
+ findings.push({
196
+ severity: 'verify',
197
+ issue: `S3 bucket "${node.name}" has public access blocking disabled`,
198
+ description: `Public access blocking is disabled on "${node.name}". This is expected for static website hosting and public asset buckets. Confirm this is intentional before treating it as a security issue.`,
199
+ recommendation: `If "${node.name}" is not intentionally public, enable all four S3 Block Public Access settings: BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets.`,
200
+ metadata: { bucketName: node.name, provider: node.provider },
201
+ });
202
+ }
203
+ }
204
+ return findings;
205
+ }
206
+ }
207
+ export class S3MissingVersioningAnalyzer {
208
+ name = 'S3MissingVersioningAnalyzer';
209
+ async analyze(graph) {
210
+ const findings = [];
211
+ for (const node of graph.nodes) {
212
+ if (node.type !== 'bucket')
213
+ continue;
214
+ if (node.versioned === false) {
215
+ findings.push({
216
+ severity: 'medium',
217
+ issue: `S3 bucket "${node.name}" does not have versioning enabled`,
218
+ description: `"${node.name}" has versioning disabled. Without versioning, accidental deletes or overwrites are unrecoverable. Versioning is required for cross-region replication and Object Lock.`,
219
+ recommendation: `Enable versioning on "${node.name}" via the S3 console or IaC. Consider adding a lifecycle rule to expire old versions and manage storage costs.`,
220
+ metadata: { bucketName: node.name, provider: node.provider },
221
+ });
222
+ }
223
+ }
224
+ return findings;
225
+ }
226
+ }
227
+ export class S3UnencryptedAnalyzer {
228
+ name = 'S3UnencryptedAnalyzer';
229
+ async analyze(graph) {
230
+ const findings = [];
231
+ for (const node of graph.nodes) {
232
+ if (node.type !== 'bucket')
233
+ continue;
234
+ if (node.encrypted === false) {
235
+ findings.push({
236
+ severity: 'medium',
237
+ issue: `S3 bucket "${node.name}" does not have server-side encryption configured`,
238
+ description: `"${node.name}" has no SSE (Server-Side Encryption) configuration. Data at rest is unencrypted. AWS S3 has enabled SSE-S3 by default since January 2023 for new buckets, but older buckets or those without explicit config should be verified.`,
239
+ recommendation: `Enable SSE on "${node.name}" using SSE-S3 (AES-256) or SSE-KMS. Specify the encryption configuration in your IaC to make it explicit.`,
240
+ metadata: { bucketName: node.name, provider: node.provider },
241
+ });
242
+ }
243
+ }
244
+ return findings;
245
+ }
246
+ }
@@ -4,7 +4,7 @@ export { MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer } from './p
4
4
  export { MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer } from './mysql.js';
5
5
  export { MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer } from './mongodb.js';
6
6
  export { IaCDriftAnalyzer } from './terraform.js';
7
- export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, } from './aws-services.js';
7
+ export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
8
8
  export { RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from './rds.js';
9
9
  export async function runAllAnalyzers(graph, analyzers) {
10
10
  const allFindings = [];
@@ -19,12 +19,12 @@ export async function runAllAnalyzers(graph, analyzers) {
19
19
  logger.warn(`Analyzer "${analyzer.name}" failed: ${err instanceof Error ? err.message : String(err)}`);
20
20
  }
21
21
  }
22
- const severityOrder = { high: 0, medium: 1, low: 2 };
22
+ const severityOrder = { high: 0, medium: 1, low: 2, verify: 3 };
23
23
  allFindings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
24
24
  return allFindings;
25
25
  }
26
26
  export function summarizeFindings(findings) {
27
- const counts = { total: findings.length, high: 0, medium: 0, low: 0 };
27
+ const counts = { total: findings.length, high: 0, medium: 0, low: 0, verify: 0 };
28
28
  for (const f of findings)
29
29
  counts[f.severity]++;
30
30
  return counts;
@@ -10,16 +10,18 @@ import { extractMongoMetadata } from '../../adapters/db/mongodb.js';
10
10
  import { extractIaCSchema } from '../../adapters/iac/terraform.js';
11
11
  import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, } from '../../adapters/aws/services.js';
12
12
  import { extractLogsMetadata } from '../../adapters/aws/logs.js';
13
+ import { extractS3Metadata } from '../../adapters/aws/s3.js';
13
14
  import { scanRepository } from '../../context/index.js';
14
15
  import { buildGraph } from '../../graph/index.js';
15
- import { runAllAnalyzers, IaCDriftAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from '../../analyzers/index.js';
16
+ import { runAllAnalyzers, IaCDriftAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from '../../analyzers/index.js';
16
17
  import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
17
- const SEVERITY_ORDER = { high: 3, medium: 2, low: 1 };
18
+ const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
18
19
  function buildMarkdownReport(findings, projectName) {
19
20
  const date = new Date().toISOString().split('T')[0];
20
21
  const high = findings.filter((f) => f.severity === 'high');
21
22
  const medium = findings.filter((f) => f.severity === 'medium');
22
23
  const low = findings.filter((f) => f.severity === 'low');
24
+ const verify = findings.filter((f) => f.severity === 'verify');
23
25
  const renderGroup = (label, emoji, group) => {
24
26
  if (group.length === 0)
25
27
  return '';
@@ -30,10 +32,11 @@ function buildMarkdownReport(findings, projectName) {
30
32
  `# infrawise report — ${projectName}`,
31
33
  `_Generated: ${date}_`,
32
34
  '',
33
- `**${findings.length} finding(s)**: ${high.length} high · ${medium.length} medium · ${low.length} low`,
35
+ `**${findings.length} finding(s)**: ${high.length} high · ${medium.length} medium · ${low.length} low · ${verify.length} verify`,
34
36
  renderGroup('High severity', '🔴', high),
35
37
  renderGroup('Medium severity', '🟡', medium),
36
38
  renderGroup('Low severity', '🟢', low),
39
+ renderGroup('Verify (check intent)', '🔵', verify),
37
40
  '',
38
41
  '_Generated by [infrawise](https://github.com/Sidd27/infrawise) — MCP server for AWS infrastructure analysis_',
39
42
  ].filter((l) => l !== undefined).join('\n');
@@ -192,6 +195,18 @@ export async function runAnalyze(options = {}) {
192
195
  s.warn(chalk.yellow('RDS skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
193
196
  }
194
197
  }
198
+ // ── S3 ────────────────────────────────────────────────────────────────────────
199
+ if (config.s3?.enabled === true) {
200
+ const s = mkSpinner('Extracting S3 buckets...');
201
+ try {
202
+ const result = await extractS3Metadata(awsCfg);
203
+ servicesMeta.s3 = result;
204
+ s.succeed(chalk.green('S3') + chalk.dim(` ${result.length} bucket(s)`));
205
+ }
206
+ catch (err) {
207
+ s.warn(chalk.yellow('S3 skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
208
+ }
209
+ }
195
210
  // ── CloudWatch Logs ──────────────────────────────────────────────────────────
196
211
  if (config.cloudwatchLogs?.enabled) {
197
212
  const s = mkSpinner('Sampling CloudWatch Logs (errors only, max 50 groups)...');
@@ -293,6 +308,11 @@ export async function runAnalyze(options = {}) {
293
308
  new RDSNoDeletionProtectionAnalyzer(),
294
309
  new RDSNoMultiAZAnalyzer(),
295
310
  ] : []),
311
+ ...(config.s3?.enabled === true ? [
312
+ new S3PublicAccessAnalyzer(),
313
+ new S3MissingVersioningAnalyzer(),
314
+ new S3UnencryptedAnalyzer(),
315
+ ] : []),
296
316
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
297
317
  ];
298
318
  findings = await runAllAnalyzers(graph, analyzers);
@@ -384,6 +404,11 @@ export async function runCodeRefresh(repoPath, config) {
384
404
  new RDSPubliclyAccessibleAnalyzer(), new RDSNoBackupAnalyzer(), new RDSUnencryptedAnalyzer(),
385
405
  new RDSNoDeletionProtectionAnalyzer(), new RDSNoMultiAZAnalyzer(),
386
406
  ] : []),
407
+ ...(config.s3?.enabled === true ? [
408
+ new S3PublicAccessAnalyzer(),
409
+ new S3MissingVersioningAnalyzer(),
410
+ new S3UnencryptedAnalyzer(),
411
+ ] : []),
387
412
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
388
413
  ];
389
414
  const findings = await runAllAnalyzers(graph, analyzers);
@@ -10,6 +10,7 @@ import { validateMySQLAccess } from '../../adapters/db/mysql.js';
10
10
  import { validateMongoAccess } from '../../adapters/db/mongodb.js';
11
11
  import { validateSQSAccess, validateSNSAccess, validateSSMAccess, validateSecretsAccess, validateLambdaAccess, validateEventBridgeAccess, } from '../../adapters/aws/services.js';
12
12
  import { validateLogsAccess } from '../../adapters/aws/logs.js';
13
+ import { validateS3Access } from '../../adapters/aws/s3.js';
13
14
  import { printHeader } from '../utils.js';
14
15
  async function runCheck(label, fn) {
15
16
  const spin = ora({ text: chalk.dim(label), color: 'cyan' }).start();
@@ -189,6 +190,22 @@ export async function runDoctor(options = {}) {
189
190
  };
190
191
  }
191
192
  }));
193
+ // S3
194
+ results.push(await runCheck('Testing S3 access...', async () => {
195
+ if (config?.s3?.enabled !== true)
196
+ return { name: 'S3', status: 'skip', message: 'Disabled in config' };
197
+ try {
198
+ await validateS3Access(awsCfg);
199
+ return { name: 'S3', status: 'pass', message: 'Connected' };
200
+ }
201
+ catch (err) {
202
+ return {
203
+ name: 'S3', status: 'warn',
204
+ message: err instanceof Error ? err.message : String(err),
205
+ detail: 'Check IAM: s3:ListBuckets, s3:GetBucketNotificationConfiguration, s3:GetBucketVersioning, s3:GetBucketEncryption, s3:GetPublicAccessBlock',
206
+ };
207
+ }
208
+ }));
192
209
  // CloudWatch Logs
193
210
  results.push(await runCheck('Testing CloudWatch Logs access...', async () => {
194
211
  if (!config?.cloudwatchLogs?.enabled)
package/dist/cli/utils.js CHANGED
@@ -93,6 +93,7 @@ function severityBadge(severity) {
93
93
  case 'high': return chalk.bgRed.white.bold(` HIGH `);
94
94
  case 'medium': return chalk.bgYellow.black.bold(` MED `);
95
95
  case 'low': return chalk.bgCyan.black.bold(` LOW `);
96
+ case 'verify': return chalk.bgBlue.white.bold(` VER? `);
96
97
  }
97
98
  }
98
99
  export function printFinding(finding, index) {
@@ -106,6 +107,7 @@ export function printSummaryBox(findings) {
106
107
  const high = findings.filter((f) => f.severity === 'high').length;
107
108
  const medium = findings.filter((f) => f.severity === 'medium').length;
108
109
  const low = findings.filter((f) => f.severity === 'low').length;
110
+ const verify = findings.filter((f) => f.severity === 'verify').length;
109
111
  console.log('');
110
112
  console.log(chalk.dim(' ┌─────────────────────────────┐'));
111
113
  console.log(chalk.dim(' │') + chalk.bold(' Analysis Summary ') + chalk.dim('│'));
@@ -113,6 +115,7 @@ export function printSummaryBox(findings) {
113
115
  console.log(chalk.dim(' │') + ` ${chalk.red('●')} High ${chalk.red.bold(String(high).padStart(3))} ` + chalk.dim('│'));
114
116
  console.log(chalk.dim(' │') + ` ${chalk.yellow('●')} Medium ${chalk.yellow.bold(String(medium).padStart(3))} ` + chalk.dim('│'));
115
117
  console.log(chalk.dim(' │') + ` ${chalk.cyan('●')} Low ${chalk.cyan.bold(String(low).padStart(3))} ` + chalk.dim('│'));
118
+ console.log(chalk.dim(' │') + ` ${chalk.blue('●')} Verify ${chalk.blue.bold(String(verify).padStart(3))} ` + chalk.dim('│'));
116
119
  console.log(chalk.dim(' ├─────────────────────────────┤'));
117
120
  console.log(chalk.dim(' │') + ` Total ${chalk.bold(String(findings.length).padStart(3))} ` + chalk.dim('│'));
118
121
  console.log(chalk.dim(' └─────────────────────────────┘'));
@@ -40,6 +40,7 @@ export const InfrawiseConfigSchema = z.object({
40
40
  }).optional(),
41
41
  eventbridge: z.object({ enabled: z.boolean().optional().default(true) }).optional(),
42
42
  rds: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
43
+ s3: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
43
44
  kafka: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
44
45
  cloudwatchLogs: z.object({
45
46
  enabled: z.boolean().optional().default(false),
@@ -2,10 +2,12 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
2
2
  const nodes = [];
3
3
  const edges = [];
4
4
  const nodeIds = new Set();
5
+ const nodeMap = new Map();
5
6
  function addNode(node) {
6
7
  if (!nodeIds.has(node.id)) {
7
8
  nodes.push(node);
8
9
  nodeIds.add(node.id);
10
+ nodeMap.set(node.id, node);
9
11
  }
10
12
  }
11
13
  // ── Database tables ──────────────────────────────────────────────────────
@@ -152,7 +154,7 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
152
154
  if (!nodeIds.has(lambdaId))
153
155
  continue;
154
156
  edges.push({ from: ruleId, to: lambdaId, type: 'triggers' });
155
- const lambdaNodeRef = nodes.find((n) => n.id === lambdaId);
157
+ const lambdaNodeRef = nodeMap.get(lambdaId);
156
158
  if (lambdaNodeRef && lambdaNodeRef.type === 'lambda') {
157
159
  const trigger = {
158
160
  type: 'eventbridge',
@@ -182,13 +184,42 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
182
184
  multiAZ: db.multiAZ,
183
185
  });
184
186
  }
187
+ // ── S3 Buckets ────────────────────────────────────────────────────────────────
188
+ for (const bucket of servicesMeta.s3 ?? []) {
189
+ const bucketId = `bucket:aws:${bucket.name}`;
190
+ addNode({
191
+ id: bucketId,
192
+ type: 'bucket',
193
+ name: bucket.name,
194
+ provider: 'aws',
195
+ versioned: bucket.versioned,
196
+ encrypted: bucket.encrypted,
197
+ publicAccessBlocked: bucket.publicAccessBlocked,
198
+ });
199
+ for (const notification of bucket.notifications) {
200
+ const lambdaId = `lambda:aws:${notification.lambdaName}`;
201
+ if (!nodeMap.has(lambdaId))
202
+ continue;
203
+ edges.push({ from: bucketId, to: lambdaId, type: 'triggers' });
204
+ const lambdaNode = nodeMap.get(lambdaId);
205
+ if (lambdaNode && lambdaNode.type === 'lambda') {
206
+ lambdaNode.triggers = [
207
+ ...(lambdaNode.triggers ?? []),
208
+ {
209
+ type: 's3',
210
+ sourceArn: bucket.arn,
211
+ sourceName: bucket.name,
212
+ eventShape: 'event.Records[0].s3.object.key',
213
+ events: notification.events,
214
+ },
215
+ ];
216
+ }
217
+ }
218
+ }
185
219
  // ── Code operations (functions + edges) ───────────────────────────────────
186
220
  for (const op of operations) {
187
221
  const funcNodeId = `function:${op.filePath}:${op.functionName}`;
188
- if (!nodeIds.has(funcNodeId)) {
189
- nodes.push({ id: funcNodeId, type: 'function', name: op.functionName, file: op.filePath });
190
- nodeIds.add(funcNodeId);
191
- }
222
+ addNode({ id: funcNodeId, type: 'function', name: op.functionName, file: op.filePath });
192
223
  // AWS service operations create edges to service nodes
193
224
  if (op.serviceType === 'sqs') {
194
225
  const queueId = `queue:aws:${op.target}`;
@@ -293,6 +324,9 @@ export function getLambdaNodes(graph) {
293
324
  export function getEventBridgeRuleNodes(graph) {
294
325
  return graph.nodes.filter((n) => n.type === 'eventbridge_rule');
295
326
  }
327
+ export function getBucketNodes(graph) {
328
+ return graph.nodes.filter((n) => n.type === 'bucket');
329
+ }
296
330
  export function getEdgesForNode(graph, nodeId) {
297
331
  return graph.edges.filter((e) => e.from === nodeId || e.to === nodeId);
298
332
  }
@@ -8,7 +8,7 @@ import { z } from 'zod';
8
8
  import { logger } from '../core/index.js';
9
9
  const { version } = JSON.parse(readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'));
10
10
  import { summarizeFindings } from '../analyzers/index.js';
11
- import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
11
+ import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getBucketNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
12
12
  // ── State ────────────────────────────────────────────────────────────────────
13
13
  let currentGraph = { nodes: [], edges: [] };
14
14
  let currentFindings = [];
@@ -42,12 +42,14 @@ export function createMcpServer() {
42
42
  const logGroups = getLogGroupNodes(currentGraph);
43
43
  const lambdas = getLambdaNodes(currentGraph);
44
44
  const functions = getFunctionNodes(currentGraph);
45
+ const buckets = getBucketNodes(currentGraph);
45
46
  return toText({
46
47
  summary: {
47
48
  tables: tables.length, functions: functions.length,
48
49
  queues: queues.length, topics: topics.length,
49
50
  secrets: secrets.length, parameters: parameters.length,
50
51
  logGroups: logGroups.length, lambdas: lambdas.length,
52
+ buckets: buckets.length,
51
53
  totalNodes: currentGraph.nodes.length, totalEdges: currentGraph.edges.length,
52
54
  findings: summarizeFindings(currentFindings),
53
55
  },
@@ -58,6 +60,7 @@ export function createMcpServer() {
58
60
  parameters: parameters.map((p) => ({ name: p.name, type: p.paramType, tier: p.tier })),
59
61
  lambdas: lambdas.map((l) => ({ name: l.name, runtime: l.runtime, memoryMB: l.memoryMB })),
60
62
  logGroups: logGroups.map((lg) => ({ name: lg.name, retentionDays: lg.retentionDays ?? 'never', errorCount: lg.errorCount })),
63
+ buckets: buckets.map((b) => ({ name: b.name, versioned: b.versioned, publicAccessBlocked: b.publicAccessBlocked })),
61
64
  highFindings: currentFindings.filter((f) => f.severity === 'high').map((f) => ({ issue: f.issue, recommendation: f.recommendation })),
62
65
  });
63
66
  }));
@@ -262,6 +265,27 @@ export function createMcpServer() {
262
265
  })),
263
266
  });
264
267
  }));
268
+ mcp.registerTool('get_s3_overview', {
269
+ description: 'Returns all S3 buckets with versioning status, encryption, public access configuration, and security findings. Call this when checking which S3 buckets exist, reviewing bucket security posture, or before writing S3 upload/delete handlers to confirm the bucket name. Do NOT call when you only need a quick infrastructure count — use get_infra_overview for that. Object contents are never included.',
270
+ inputSchema: z.object({}),
271
+ }, logged('get_s3_overview', async () => {
272
+ const buckets = getBucketNodes(currentGraph);
273
+ const bucketFindings = currentFindings.filter((f) => f.metadata?.bucketName);
274
+ return toText({
275
+ total: buckets.length,
276
+ note: 'Object contents are never included.',
277
+ buckets: buckets.map((b) => ({
278
+ name: b.name,
279
+ provider: b.provider,
280
+ versioned: b.versioned,
281
+ encrypted: b.encrypted,
282
+ publicAccessBlocked: b.publicAccessBlocked,
283
+ findings: bucketFindings
284
+ .filter((f) => f.metadata.bucketName === b.name)
285
+ .map((f) => ({ severity: f.severity, issue: f.issue })),
286
+ })),
287
+ });
288
+ }));
265
289
  mcp.registerTool('get_log_errors', {
266
290
  description: 'Returns recent error pattern summaries from CloudWatch log groups: pattern counts and frequencies grouped by log group. Raw log messages are never returned. Use the optional logGroup filter to scope to one group by name substring. Call this when investigating errors or identifying log groups with no retention policy.',
267
291
  inputSchema: z.object({ logGroup: z.string().describe('Filter to a specific log group name (optional)').optional() }),
@@ -291,7 +315,7 @@ export function createServer(port = 3000) {
291
315
  name: 'io.github.Sidd27/infrawise',
292
316
  display_name: 'Infrawise',
293
317
  version,
294
- description: 'Infrastructure analysis MCP server — scans DynamoDB, PostgreSQL, MySQL, MongoDB, Lambda, SQS, SNS, EventBridge, Secrets Manager, SSM, CloudWatch, Terraform, CDK, and source code. Surfaces missing indexes, DLQ gaps, Lambda misconfig, and correct trigger event shapes.',
318
+ description: 'Infrastructure analysis MCP server — scans DynamoDB, PostgreSQL, MySQL, MongoDB, S3, Lambda, SQS, SNS, EventBridge, Secrets Manager, SSM, CloudWatch, Terraform, CDK, and source code. Surfaces missing indexes, DLQ gaps, Lambda misconfig, S3 security posture, and correct trigger event shapes.',
295
319
  homepage: 'https://github.com/Sidd27/infrawise',
296
320
  repository: 'https://github.com/Sidd27/infrawise',
297
321
  transports: [{ type: 'streamable-http', url: `http://localhost:${port}/mcp` }],
@@ -299,7 +323,7 @@ export function createServer(port = 3000) {
299
323
  'get_infra_overview', 'get_graph_summary', 'analyze_function',
300
324
  'suggest_gsi', 'postgres_index_suggestions', 'suggest_mongo_index', 'mysql_index_suggestions',
301
325
  'get_queue_details', 'get_topic_details', 'get_secrets_overview', 'get_parameter_overview',
302
- 'get_lambda_overview', 'get_eventbridge_details', 'get_log_errors',
326
+ 'get_lambda_overview', 'get_eventbridge_details', 'get_s3_overview', 'get_log_errors',
303
327
  ],
304
328
  }));
305
329
  fastify.post('/mcp', async (request, reply) => {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.Sidd27/infrawise",
5
- "description": "CLI-first infrastructure intelligence platform — analyzes DynamoDB, PostgreSQL, MySQL, MongoDB, SQS, SNS, SSM, Secrets Manager, Lambda, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
5
+ "description": "CLI-first infrastructure intelligence platform — analyzes DynamoDB, PostgreSQL, MySQL, MongoDB, SQS, SNS, SSM, Secrets Manager, Lambda, S3, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "mcp-server",
@@ -14,6 +14,7 @@
14
14
  "sqs",
15
15
  "sns",
16
16
  "lambda",
17
+ "s3",
17
18
  "eventbridge",
18
19
  "cloudwatch",
19
20
  "secrets-manager",
@@ -63,6 +64,7 @@
63
64
  },
64
65
  "dependencies": {
65
66
  "@aws-sdk/client-cloudwatch-logs": "^3.1048.0",
67
+ "@aws-sdk/client-s3": "^3.1048.0",
66
68
  "@aws-sdk/client-dynamodb": "^3.1048.0",
67
69
  "@aws-sdk/client-eventbridge": "^3.1051.0",
68
70
  "@aws-sdk/client-lambda": "^3.1048.0",
@@ -74,9 +76,9 @@
74
76
  "@aws-sdk/credential-providers": "^3.1048.0",
75
77
  "@modelcontextprotocol/sdk": "^1.29.0",
76
78
  "chalk": "^5.6.2",
77
- "commander": "^14.0.3",
79
+ "commander": "^15.0.0",
78
80
  "fastify": "^5.8.5",
79
- "inquirer": "^13.4.3",
81
+ "inquirer": "^14.0.2",
80
82
  "js-yaml": "^4.1.0",
81
83
  "mongodb": "^7.2.0",
82
84
  "mysql2": "^3.9.0",