infrawise 0.7.1 → 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.
@@ -1,18 +1,46 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
2
3
  import chalk from 'chalk';
3
4
  import ora from 'ora';
4
5
  import { loadConfig, formatError, writeCache, readCache } from '../../core/index.js';
5
- import { extractDynamoMetadata } from '../../adapters/dynamodb.js';
6
- import { extractPostgresMetadata } from '../../adapters/postgres.js';
7
- import { extractMySQLMetadata } from '../../adapters/mysql.js';
8
- import { extractMongoMetadata } from '../../adapters/mongodb.js';
9
- import { extractIaCSchema } from '../../adapters/terraform.js';
10
- import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, } from '../../adapters/aws.js';
11
- import { extractLogsMetadata } from '../../adapters/logs.js';
6
+ import { extractDynamoMetadata } from '../../adapters/aws/dynamodb.js';
7
+ import { extractPostgresMetadata } from '../../adapters/db/postgres.js';
8
+ import { extractMySQLMetadata } from '../../adapters/db/mysql.js';
9
+ import { extractMongoMetadata } from '../../adapters/db/mongodb.js';
10
+ import { extractIaCSchema } from '../../adapters/iac/terraform.js';
11
+ import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, } from '../../adapters/aws/services.js';
12
+ import { extractLogsMetadata } from '../../adapters/aws/logs.js';
13
+ import { extractS3Metadata } from '../../adapters/aws/s3.js';
12
14
  import { scanRepository } from '../../context/index.js';
13
15
  import { buildGraph } from '../../graph/index.js';
14
- 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';
15
17
  import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
18
+ const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
19
+ function buildMarkdownReport(findings, projectName) {
20
+ const date = new Date().toISOString().split('T')[0];
21
+ const high = findings.filter((f) => f.severity === 'high');
22
+ const medium = findings.filter((f) => f.severity === 'medium');
23
+ const low = findings.filter((f) => f.severity === 'low');
24
+ const verify = findings.filter((f) => f.severity === 'verify');
25
+ const renderGroup = (label, emoji, group) => {
26
+ if (group.length === 0)
27
+ return '';
28
+ const rows = group.map((f) => `| ${f.issue} | ${f.recommendation} |`).join('\n');
29
+ return `\n## ${emoji} ${label} (${group.length})\n\n| Issue | Recommendation |\n|---|---|\n${rows}\n`;
30
+ };
31
+ return [
32
+ `# infrawise report — ${projectName}`,
33
+ `_Generated: ${date}_`,
34
+ '',
35
+ `**${findings.length} finding(s)**: ${high.length} high · ${medium.length} medium · ${low.length} low · ${verify.length} verify`,
36
+ renderGroup('High severity', '🔴', high),
37
+ renderGroup('Medium severity', '🟡', medium),
38
+ renderGroup('Low severity', '🟢', low),
39
+ renderGroup('Verify (check intent)', '🔵', verify),
40
+ '',
41
+ '_Generated by [infrawise](https://github.com/Sidd27/infrawise) — MCP server for AWS infrastructure analysis_',
42
+ ].filter((l) => l !== undefined).join('\n');
43
+ }
16
44
  function mkSpinner(text) {
17
45
  return ora({ text: chalk.dim(text), color: 'cyan' }).start();
18
46
  }
@@ -28,6 +56,7 @@ export async function runAnalyze(options = {}) {
28
56
  process.exit(1);
29
57
  }
30
58
  const repoPath = options.repo ?? process.cwd();
59
+ const minSeverity = options.severity ? SEVERITY_ORDER[options.severity] ?? 1 : 0;
31
60
  const awsCfg = { region: config.aws?.region, profile: config.aws?.profile, endpoint: config.aws?.endpoint };
32
61
  const dynamoMeta = [];
33
62
  const postgresMeta = [];
@@ -166,6 +195,18 @@ export async function runAnalyze(options = {}) {
166
195
  s.warn(chalk.yellow('RDS skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
167
196
  }
168
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
+ }
169
210
  // ── CloudWatch Logs ──────────────────────────────────────────────────────────
170
211
  if (config.cloudwatchLogs?.enabled) {
171
212
  const s = mkSpinner('Sampling CloudWatch Logs (errors only, max 50 groups)...');
@@ -267,6 +308,11 @@ export async function runAnalyze(options = {}) {
267
308
  new RDSNoDeletionProtectionAnalyzer(),
268
309
  new RDSNoMultiAZAnalyzer(),
269
310
  ] : []),
311
+ ...(config.s3?.enabled === true ? [
312
+ new S3PublicAccessAnalyzer(),
313
+ new S3MissingVersioningAnalyzer(),
314
+ new S3UnencryptedAnalyzer(),
315
+ ] : []),
270
316
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
271
317
  ];
272
318
  findings = await runAllAnalyzers(graph, analyzers);
@@ -278,18 +324,30 @@ export async function runAnalyze(options = {}) {
278
324
  writeCache('operations', operations);
279
325
  writeCache('meta', { dynamoMeta, postgresMeta, mysqlMeta, mongoMeta, servicesMeta });
280
326
  // ── Output ────────────────────────────────────────────────────────────────────
327
+ const displayFindings = minSeverity > 0
328
+ ? findings.filter((f) => (SEVERITY_ORDER[f.severity] ?? 0) >= minSeverity)
329
+ : findings;
281
330
  console.log('');
282
- if (findings.length === 0) {
283
- console.log(` ${chalk.green.bold('✓ No issues found!')} ${chalk.dim('Your infrastructure looks clean.')}`);
331
+ if (displayFindings.length === 0) {
332
+ const msg = minSeverity > 0 ? `No ${options.severity} (or higher) severity issues found.` : 'Your infrastructure looks clean.';
333
+ console.log(` ${chalk.green.bold('✓ No issues found!')} ${chalk.dim(msg)}`);
284
334
  }
285
335
  else {
286
- console.log(chalk.bold(` Findings`) + chalk.dim(` ${findings.length} total`));
287
- findings.forEach((f, i) => printFinding(f, i));
288
- printSummaryBox(findings);
289
- if (findings.some((f) => f.severity === 'high')) {
336
+ const filterNote = minSeverity > 0 ? chalk.dim(` (${options.severity}+ only)`) : '';
337
+ console.log(chalk.bold(` Findings`) + chalk.dim(` ${displayFindings.length} total`) + filterNote);
338
+ displayFindings.forEach((f, i) => printFinding(f, i));
339
+ printSummaryBox(displayFindings);
340
+ if (displayFindings.some((f) => f.severity === 'high')) {
290
341
  console.log(`\n ${chalk.red.bold('Action required:')} ${chalk.red('High severity issues detected.')}`);
291
342
  }
292
343
  }
344
+ if (options.output) {
345
+ const report = buildMarkdownReport(displayFindings, config.project);
346
+ const outPath = path.resolve(options.output);
347
+ fs.writeFileSync(outPath, report, 'utf8');
348
+ console.log('');
349
+ log.success('Report saved', outPath);
350
+ }
293
351
  console.log('');
294
352
  log.dim(`Results cached in .infrawise/cache/`);
295
353
  log.info(`Run ${chalk.cyan('infrawise dev')} to explore via the MCP server`);
@@ -346,6 +404,11 @@ export async function runCodeRefresh(repoPath, config) {
346
404
  new RDSPubliclyAccessibleAnalyzer(), new RDSNoBackupAnalyzer(), new RDSUnencryptedAnalyzer(),
347
405
  new RDSNoDeletionProtectionAnalyzer(), new RDSNoMultiAZAnalyzer(),
348
406
  ] : []),
407
+ ...(config.s3?.enabled === true ? [
408
+ new S3PublicAccessAnalyzer(),
409
+ new S3MissingVersioningAnalyzer(),
410
+ new S3UnencryptedAnalyzer(),
411
+ ] : []),
349
412
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
350
413
  ];
351
414
  const findings = await runAllAnalyzers(graph, analyzers);
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import ora from 'ora';
4
4
  import { readAWSProfiles, log, printHeader } from '../utils.js';
5
- import { validateDynamoAccess } from '../../adapters/dynamodb.js';
5
+ import { validateDynamoAccess } from '../../adapters/aws/dynamodb.js';
6
6
  export async function runAuth() {
7
7
  printHeader('AWS Authentication');
8
8
  const profiles = readAWSProfiles();
@@ -54,7 +54,7 @@ function groupTools(tools) {
54
54
  return lines;
55
55
  }
56
56
  export async function runDev(options = {}) {
57
- const port = options.port ?? 3000;
57
+ const port = options.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : 3000);
58
58
  printHeader('MCP Server');
59
59
  let config;
60
60
  try {
@@ -4,12 +4,13 @@ import * as os from 'os';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
6
  import { loadConfig } from '../../core/index.js';
7
- import { probeDynamoAccess } from '../../adapters/dynamodb.js';
8
- import { validatePostgresAccess } from '../../adapters/postgres.js';
9
- import { validateMySQLAccess } from '../../adapters/mysql.js';
10
- import { validateMongoAccess } from '../../adapters/mongodb.js';
11
- import { validateSQSAccess, validateSNSAccess, validateSSMAccess, validateSecretsAccess, validateLambdaAccess, validateEventBridgeAccess, } from '../../adapters/aws.js';
12
- import { validateLogsAccess } from '../../adapters/logs.js';
7
+ import { probeDynamoAccess } from '../../adapters/aws/dynamodb.js';
8
+ import { validatePostgresAccess } from '../../adapters/db/postgres.js';
9
+ import { validateMySQLAccess } from '../../adapters/db/mysql.js';
10
+ import { validateMongoAccess } from '../../adapters/db/mongodb.js';
11
+ import { validateSQSAccess, validateSNSAccess, validateSSMAccess, validateSecretsAccess, validateLambdaAccess, validateEventBridgeAccess, } from '../../adapters/aws/services.js';
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)
@@ -0,0 +1,28 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import { loadConfig, formatError, readCache } from '../../core/index.js';
3
+ import { createMcpServer, setGraphState } from '../../server/index.js';
4
+ import { runAnalyze } from './analyze.js';
5
+ export async function runStdio(configPath) {
6
+ let config;
7
+ try {
8
+ config = loadConfig(configPath);
9
+ }
10
+ catch (err) {
11
+ process.stderr.write(formatError(err) + '\n');
12
+ process.exit(1);
13
+ }
14
+ const cachedGraph = readCache('graph');
15
+ const cachedFindings = readCache('findings');
16
+ if (cachedGraph && cachedFindings) {
17
+ setGraphState(cachedGraph, cachedFindings, config);
18
+ }
19
+ else {
20
+ await runAnalyze({ config: configPath });
21
+ const graph = readCache('graph') ?? { nodes: [], edges: [] };
22
+ const findings = readCache('findings') ?? [];
23
+ setGraphState(graph, findings, config);
24
+ }
25
+ const mcp = createMcpServer();
26
+ const transport = new StdioServerTransport();
27
+ await mcp.connect(transport);
28
+ }
package/dist/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { runAuth } from './commands/auth.js';
8
8
  import { runAnalyze } from './commands/analyze.js';
9
9
  import { runDev } from './commands/dev.js';
10
10
  import { runDoctor } from './commands/doctor.js';
11
+ import { runStdio } from './commands/stdio.js';
11
12
  const { version } = JSON.parse(readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'));
12
13
  const program = new Command();
13
14
  program
@@ -35,12 +36,16 @@ program
35
36
  .option('-c, --config <path>', 'Path to infrawise.yaml', 'infrawise.yaml')
36
37
  .option('-r, --repo <path>', 'Path to repository to scan', process.cwd())
37
38
  .option('--no-cache', 'Skip reading/writing the cache')
39
+ .option('-o, --output <path>', 'Save findings as a markdown report (e.g. report.md)')
40
+ .option('--severity <level>', 'Only show findings at or above this level: high | medium | low')
38
41
  .action(async (options) => {
39
42
  printBanner();
40
43
  await runAnalyze({
41
44
  config: options.config !== 'infrawise.yaml' ? options.config : undefined,
42
45
  repo: options.repo,
43
46
  noCache: !options.cache,
47
+ output: options.output,
48
+ severity: options.severity,
44
49
  });
45
50
  });
46
51
  program
@@ -55,6 +60,13 @@ program
55
60
  port: parseInt(options.port, 10),
56
61
  });
57
62
  });
63
+ program
64
+ .command('stdio')
65
+ .description('Start MCP server on stdio transport (for Claude Desktop and Glama)')
66
+ .option('-c, --config <path>', 'Path to infrawise.yaml', 'infrawise.yaml')
67
+ .action(async (options) => {
68
+ await runStdio(options.config !== 'infrawise.yaml' ? options.config : undefined);
69
+ });
58
70
  program
59
71
  .command('doctor')
60
72
  .description('Validate AWS access, postgres connectivity, config, and repo scan')
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 = [];
@@ -31,7 +31,7 @@ function logged(name, fn) {
31
31
  export function createMcpServer() {
32
32
  const mcp = new McpServer({ name: 'infrawise', version });
33
33
  mcp.registerTool('get_infra_overview', {
34
- description: 'Returns a complete snapshot of all infrastructure: databases, queues, topics, secrets, parameters, log groups, lambdas, and all findings. Start here for a full picture.',
34
+ description: 'Returns a compact infrastructure snapshot: service counts, all databases, queues, topics, secrets, lambdas, and high-severity findings. Call this first at the start of any database or infrastructure task to understand what services are in scope. Prefer this over get_graph_summary for quick orientation; use get_graph_summary only when you need every node, edge, and finding in full.',
35
35
  inputSchema: z.object({}),
36
36
  }, logged('get_infra_overview', async () => {
37
37
  const tables = getTableNodes(currentGraph);
@@ -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,11 +60,12 @@ 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
  }));
64
67
  mcp.registerTool('get_graph_summary', {
65
- description: 'Returns the full infrastructure graph (all nodes and edges) plus findings summary.',
68
+ description: 'Returns every node (tables, functions, lambdas, queues, etc.), every edge (query, scan, triggers, publishes_to), and all findings. Use this when you need to trace relationships across multiple services or require the complete finding set — not just high-severity ones. For a quick overview use get_infra_overview instead.',
66
69
  inputSchema: z.object({}),
67
70
  }, logged('get_graph_summary', async () => toText({
68
71
  nodes: currentGraph.nodes,
@@ -76,7 +79,7 @@ export function createMcpServer() {
76
79
  },
77
80
  })));
78
81
  mcp.registerTool('analyze_function', {
79
- description: 'Analyze a specific function for all infrastructure issues: DB queries, queue publishing, secret access, trigger event shapes, etc.',
82
+ description: 'Analyzes a single named function or Lambda handler for infrastructure issues: which tables it queries, how it queries them (scan vs query), queue publishing, secret access, and the correct event shape for each trigger (SQS, DynamoDB Streams, Kinesis, EventBridge). Call this before writing or reviewing a Lambda handler to get the exact trigger event shape and all findings scoped to this function. Returns found: false if the function name was not discovered during analysis.',
80
83
  inputSchema: z.object({ function: z.string().describe('Function name to analyze') }),
81
84
  }, logged('analyze_function', async ({ function: functionName }) => {
82
85
  const funcNode = currentGraph.nodes.find((n) => n.type === 'function' && n.name === functionName);
@@ -107,7 +110,7 @@ export function createMcpServer() {
107
110
  });
108
111
  }));
109
112
  mcp.registerTool('suggest_gsi', {
110
- description: 'Get GSI suggestions for a DynamoDB table and attribute',
113
+ description: 'Generates a ready-to-use DynamoDB GSI definition — index name, partition key, projection type, billing mode — for a given table and attribute. Call this when a query pattern needs an index that does not exist yet, or when the analyzer flags a missing GSI finding. Does not verify whether the GSI already exists; check the table schema in get_infra_overview first.',
111
114
  inputSchema: z.object({
112
115
  table: z.string().describe('DynamoDB table name'),
113
116
  attribute: z.string().describe('Attribute to create the GSI on'),
@@ -125,7 +128,7 @@ export function createMcpServer() {
125
128
  });
126
129
  }));
127
130
  mcp.registerTool('postgres_index_suggestions', {
128
- description: 'Get PostgreSQL index suggestions for a table column',
131
+ description: 'Generates the exact CREATE INDEX CONCURRENTLY SQL for a PostgreSQL table column, including a partial index variant and a post-creation ANALYZE reminder. Call this when the analyzer flags a missing index finding or when writing a query that filters on a column without an existing index. Does not verify whether the index already exists.',
129
132
  inputSchema: z.object({
130
133
  table: z.string().describe('PostgreSQL table name'),
131
134
  column: z.string().describe('Column name to index'),
@@ -143,7 +146,7 @@ export function createMcpServer() {
143
146
  });
144
147
  }));
145
148
  mcp.registerTool('suggest_mongo_index', {
146
- description: 'Get index suggestions for a MongoDB collection field',
149
+ description: 'Generates the exact db.collection.createIndex() command for a MongoDB field, plus compound and text index variants and an explain query to verify. Call this when a collection scan is flagged by the analyzer or when writing a query that filters on an unindexed field. Does not check whether the index already exists.',
147
150
  inputSchema: z.object({
148
151
  collection: z.string().describe('MongoDB collection name'),
149
152
  field: z.string().describe('Field name to index'),
@@ -163,7 +166,7 @@ export function createMcpServer() {
163
166
  });
164
167
  }));
165
168
  mcp.registerTool('mysql_index_suggestions', {
166
- description: 'Get MySQL index suggestions for a table column',
169
+ description: 'Generates the exact ALTER TABLE ADD INDEX SQL for a MySQL table column, including a composite variant and EXPLAIN guidance to verify the index is used. Call this when the analyzer flags a missing MySQL index or full table scan finding. Does not verify whether the index already exists.',
167
170
  inputSchema: z.object({
168
171
  table: z.string().describe('MySQL table name'),
169
172
  column: z.string().describe('Column name to index'),
@@ -181,7 +184,7 @@ export function createMcpServer() {
181
184
  });
182
185
  }));
183
186
  mcp.registerTool('get_queue_details', {
184
- description: 'Returns all SQS queues with DLQ status, encryption, message counts, and retention.',
187
+ description: 'Returns all SQS queues with DLQ presence, encryption status, approximate message count, and retention days. Call this when reviewing messaging architecture, investigating a message backlog, or checking DLQ coverage before adding a new consumer. Use get_infra_overview for a quick queue count only.',
185
188
  inputSchema: z.object({}),
186
189
  }, logged('get_queue_details', async () => {
187
190
  const queues = getQueueNodes(currentGraph);
@@ -196,14 +199,14 @@ export function createMcpServer() {
196
199
  });
197
200
  }));
198
201
  mcp.registerTool('get_topic_details', {
199
- description: 'Returns all SNS topics with subscription counts and protocols.',
202
+ description: 'Returns all SNS topics with subscription count and encryption status. Call this when reviewing event fan-out patterns or checking whether a topic has the expected number of subscribers.',
200
203
  inputSchema: z.object({}),
201
204
  }, logged('get_topic_details', async () => {
202
205
  const topics = getTopicNodes(currentGraph);
203
206
  return toText({ total: topics.length, topics: topics.map((t) => ({ name: t.name, provider: t.provider, subscriptionCount: t.subscriptionCount, encrypted: t.encrypted })) });
204
207
  }));
205
208
  mcp.registerTool('get_secrets_overview', {
206
- description: 'Returns all Secrets Manager secrets: names, rotation status. Secret VALUES are never included.',
209
+ description: 'Returns all Secrets Manager secrets with rotation status and rotation interval. Secret values are never returned. Call this when checking which secrets exist, confirming rotation is enabled before a security review, or identifying secrets that lack rotation.',
207
210
  inputSchema: z.object({}),
208
211
  }, logged('get_secrets_overview', async () => {
209
212
  const secrets = getSecretNodes(currentGraph);
@@ -217,7 +220,7 @@ export function createMcpServer() {
217
220
  });
218
221
  }));
219
222
  mcp.registerTool('get_parameter_overview', {
220
- description: 'Returns all SSM Parameter Store parameters: names, types, tiers. Parameter VALUES are never included.',
223
+ description: 'Returns all SSM Parameter Store parameters with type (String, SecureString, StringList) and tier (Standard, Advanced). Parameter values are never returned. Call this when checking which config parameters exist for a service or verifying parameter types.',
221
224
  inputSchema: z.object({}),
222
225
  }, logged('get_parameter_overview', async () => {
223
226
  const parameters = getParameterNodes(currentGraph);
@@ -227,7 +230,7 @@ export function createMcpServer() {
227
230
  });
228
231
  }));
229
232
  mcp.registerTool('get_lambda_overview', {
230
- description: 'Returns all Lambda functions: runtime, memory, timeout, env var key names (values never included), and known event source triggers with correct handler event shapes.',
233
+ description: 'Returns all Lambda functions with runtime, memory (MB), timeout (sec), environment variable key names (values never returned), and event source triggers with the correct handler event shape for each. Call this when auditing Lambda configuration for default memory (128 MB) or high timeouts, or when you need the trigger event shape for a specific function without running analyze_function.',
231
234
  inputSchema: z.object({}),
232
235
  }, logged('get_lambda_overview', async () => {
233
236
  const lambdas = getLambdaNodes(currentGraph);
@@ -243,7 +246,7 @@ export function createMcpServer() {
243
246
  });
244
247
  }));
245
248
  mcp.registerTool('get_eventbridge_details', {
246
- description: 'Returns all EventBridge rules: name, state, schedule expression or event pattern, and target Lambda functions.',
249
+ description: 'Returns all EventBridge rules with name, ENABLED/DISABLED state, schedule expression (rate/cron rules), event pattern (event-driven rules), and target Lambda function names. Call this when checking what schedule or event triggers a Lambda, or when reviewing rule coverage across the account.',
247
250
  inputSchema: z.object({}),
248
251
  }, logged('get_eventbridge_details', async () => {
249
252
  const rules = getEventBridgeRuleNodes(currentGraph);
@@ -262,8 +265,29 @@ 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
- description: 'Returns recent error patterns from CloudWatch log groups. Returns pattern counts and frequencies never raw log messages.',
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() }),
268
292
  }, logged('get_log_errors', async ({ logGroup: filterName }) => {
269
293
  const logGroups = getLogGroupNodes(currentGraph).filter((lg) => !filterName || lg.name.includes(filterName));
@@ -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.1",
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",
@@ -107,5 +109,10 @@
107
109
  ],
108
110
  "publishConfig": {
109
111
  "access": "public"
112
+ },
113
+ "pnpm": {
114
+ "overrides": {
115
+ "qs": ">=6.15.2"
116
+ }
110
117
  }
111
118
  }