infrawise 0.6.0 → 0.7.1

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
@@ -15,6 +15,8 @@ It statically analyzes your codebase, cloud infrastructure, and database schemas
15
15
 
16
16
  ## Why this exists
17
17
 
18
+ New software developers don't write wrong code. Claude Code writes wrong code and they ship it. Infrawise is the only thing standing between Claude Code's generated output and a production incident.
19
+
18
20
  AI coding assistants can read your source files but have no deterministic knowledge of your infrastructure. They do not know which GSIs exist, how tables are partitioned, which functions already trigger scans, or where indexes are missing. So they guess.
19
21
 
20
22
  Infrawise replaces guessing with infrastructure-aware context.
@@ -159,7 +161,7 @@ To let Claude Code manage the server lifecycle automatically:
159
161
  |---|---|
160
162
  | `get_infra_overview` | Complete snapshot — all services, counts, and high-severity findings |
161
163
  | `get_graph_summary` | Full infrastructure graph — all nodes, edges, and findings |
162
- | `analyze_function` | Issues in a specific function — scans, missing indexes, N+1 |
164
+ | `analyze_function` | Issues in a specific function — scans, missing indexes, N+1, trigger event shapes |
163
165
  | `suggest_gsi` | Exact GSI config for a DynamoDB table + attribute |
164
166
  | `postgres_index_suggestions` | Exact `CREATE INDEX` SQL for your actual table |
165
167
  | `suggest_mongo_index` | Exact `createIndex` command for a MongoDB collection + field |
@@ -168,7 +170,8 @@ To let Claude Code manage the server lifecycle automatically:
168
170
  | `get_topic_details` | SNS topics — subscription counts and protocols |
169
171
  | `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
170
172
  | `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
171
- | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, env var key names |
173
+ | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge), env var key names |
174
+ | `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
172
175
  | `get_log_errors` | CloudWatch error patterns and counts (no raw log messages) |
173
176
 
174
177
  ---
@@ -239,6 +242,12 @@ secretsManager:
239
242
 
240
243
  lambda:
241
244
  enabled: true
245
+ includeFunctions: # omit to include all functions
246
+ - myFunction
247
+ - anotherFunction
248
+
249
+ eventbridge:
250
+ enabled: true
242
251
 
243
252
  rds:
244
253
  enabled: false
@@ -312,7 +321,8 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
312
321
  | SQS | Missing DLQs, unencrypted queues, large backlogs |
313
322
  | Kafka (kafkajs) | Producer/consumer topic mapping from code |
314
323
  | Secrets Manager | Missing secret rotation |
315
- | Lambda | Default memory (128 MB), high timeouts |
324
+ | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge), missing DLQ on trigger source |
325
+ | EventBridge | Rules, schedules, event patterns, target Lambda functions |
316
326
  | RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
317
327
  | CloudWatch Logs | Log groups with no retention policy |
318
328
  | Terraform / CloudFormation / CDK | IaC drift vs deployed state |
@@ -420,6 +430,8 @@ src/
420
430
 
421
431
  ## Roadmap
422
432
 
433
+ Feature roadmap is tracked in the [GitHub Project](https://github.com/users/Sidd27/projects/1). Priorities, complexity, and virality scores are visible there. Feature requests and upvotes welcome.
434
+
423
435
  ### Planned
424
436
  - Runtime tracing integration
425
437
  - Incremental analysis for large monorepos
@@ -435,6 +447,12 @@ src/
435
447
 
436
448
  ---
437
449
 
450
+ ## Demo
451
+
452
+ The `demo/localstack/` directory runs infrawise against real AWS APIs emulated locally via [LocalStack](https://localstack.cloud) — an open-source tool that spins up a full AWS environment in Docker so you can test AWS integrations at zero cost, with no real AWS account needed. See [`demo/localstack/README.md`](demo/localstack/README.md) for setup instructions.
453
+
454
+ ---
455
+
438
456
  ## Contributing
439
457
 
440
458
  ### Prerequisites
@@ -2,7 +2,8 @@ import { SQSClient, ListQueuesCommand, GetQueueAttributesCommand, } from '@aws-s
2
2
  import { SNSClient, ListTopicsCommand, GetTopicAttributesCommand, ListSubscriptionsByTopicCommand, } from '@aws-sdk/client-sns';
3
3
  import { SSMClient, DescribeParametersCommand, } from '@aws-sdk/client-ssm';
4
4
  import { SecretsManagerClient, ListSecretsCommand, } from '@aws-sdk/client-secrets-manager';
5
- import { LambdaClient, ListFunctionsCommand, } from '@aws-sdk/client-lambda';
5
+ import { LambdaClient, ListFunctionsCommand, ListEventSourceMappingsCommand, } from '@aws-sdk/client-lambda';
6
+ import { EventBridgeClient, ListRulesCommand, ListTargetsByRuleCommand, } from '@aws-sdk/client-eventbridge';
6
7
  import { RDSClient, DescribeDBInstancesCommand, } from '@aws-sdk/client-rds';
7
8
  import { fromIni } from '@aws-sdk/credential-providers';
8
9
  import { logger } from '../core/index.js';
@@ -13,11 +14,6 @@ function clientConfig(cfg) {
13
14
  base.endpoint = cfg.endpoint;
14
15
  if (cfg.profile)
15
16
  base.credentials = fromIni({ profile: cfg.profile });
16
- else if (cfg.endpoint)
17
- base.credentials = {
18
- accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? 'test',
19
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? 'test',
20
- };
21
17
  return base;
22
18
  }
23
19
  // ─── SQS ─────────────────────────────────────────────────────────────────────
@@ -101,7 +97,7 @@ export async function extractSNSMetadata(cfg = {}) {
101
97
  name: arn.split(':').pop() ?? arn,
102
98
  arn,
103
99
  encrypted: !!attrs['KmsMasterKeyId'],
104
- subscriptionCount: subs.length,
100
+ subscriptionCount: parseInt(attrs['SubscriptionsConfirmed'] ?? '0', 10),
105
101
  subscriptionProtocols: [...new Set(subs.map((s) => s.Protocol ?? 'unknown'))],
106
102
  });
107
103
  }
@@ -128,7 +124,9 @@ export async function extractSSMMetadata(cfg = {}) {
128
124
  const res = await client.send(new DescribeParametersCommand({
129
125
  NextToken: nextToken,
130
126
  MaxResults: 50,
131
- // Only metadata — GetParameter/GetParameters would return values
127
+ ParameterFilters: cfg.paths?.length
128
+ ? [{ Key: 'Path', Values: cfg.paths, Option: 'Recursive' }]
129
+ : undefined,
132
130
  }));
133
131
  for (const p of res.Parameters ?? []) {
134
132
  parameters.push({
@@ -183,7 +181,94 @@ export async function validateSecretsAccess(cfg = {}) {
183
181
  await new SecretsManagerClient(clientConfig(cfg)).send(new ListSecretsCommand({ MaxResults: 1 }));
184
182
  }
185
183
  // ─── Lambda ───────────────────────────────────────────────────────────────────
186
- export async function extractLambdaMetadata(cfg = {}) {
184
+ const EVENT_SHAPES = {
185
+ sqs: 'event.Records[0].body',
186
+ dynamodb: 'event.Records[0].dynamodb.NewImage',
187
+ kinesis: 'event.Records[0].kinesis.data // base64',
188
+ msk: 'event.records[topic][0].value // base64',
189
+ sns: 'event.Records[0].Sns.Message',
190
+ s3: 'event.Records[0].s3.object.key',
191
+ eventbridge: 'event.detail',
192
+ unknown: 'event // unknown trigger type',
193
+ };
194
+ function triggerFromArn(arn, batchSize, state) {
195
+ let type = 'unknown';
196
+ if (arn.includes(':sqs:'))
197
+ type = 'sqs';
198
+ else if (arn.includes(':dynamodb:'))
199
+ type = 'dynamodb';
200
+ else if (arn.includes(':kinesis:'))
201
+ type = 'kinesis';
202
+ else if (arn.includes(':kafka:') || arn.toLowerCase().includes('msk'))
203
+ type = 'msk';
204
+ else if (arn.includes(':sns:'))
205
+ type = 'sns';
206
+ else if (arn.includes(':s3:'))
207
+ type = 's3';
208
+ const sourceName = arn.split(':').pop() ?? arn;
209
+ return { type, sourceArn: arn, sourceName, eventShape: EVENT_SHAPES[type], batchSize, state };
210
+ }
211
+ async function fetchAllEventSourceMappings(cfg) {
212
+ const client = new LambdaClient(clientConfig(cfg));
213
+ const triggerMap = new Map();
214
+ try {
215
+ let marker;
216
+ do {
217
+ const res = await client.send(new ListEventSourceMappingsCommand({ Marker: marker, MaxItems: 100 }));
218
+ for (const m of res.EventSourceMappings ?? []) {
219
+ if (!m.FunctionArn || !m.EventSourceArn)
220
+ continue;
221
+ const trigger = triggerFromArn(m.EventSourceArn, m.BatchSize, m.State);
222
+ const existing = triggerMap.get(m.FunctionArn) ?? [];
223
+ existing.push(trigger);
224
+ triggerMap.set(m.FunctionArn, existing);
225
+ }
226
+ marker = res.NextMarker;
227
+ } while (marker);
228
+ }
229
+ catch (err) {
230
+ logger.warn(`Event source mappings fetch failed: ${err instanceof Error ? err.message : String(err)}`);
231
+ }
232
+ return triggerMap;
233
+ }
234
+ export async function extractEventBridgeMetadata(cfg = {}) {
235
+ const client = new EventBridgeClient(clientConfig(cfg));
236
+ const rules = [];
237
+ try {
238
+ let nextToken;
239
+ do {
240
+ const res = await client.send(new ListRulesCommand({ NextToken: nextToken, Limit: 100 }));
241
+ for (const rule of res.Rules ?? []) {
242
+ if (!rule.Name)
243
+ continue;
244
+ try {
245
+ const targetsRes = await client.send(new ListTargetsByRuleCommand({ Rule: rule.Name }));
246
+ const targetArns = (targetsRes.Targets ?? []).map((t) => t.Arn ?? '').filter(Boolean);
247
+ rules.push({
248
+ name: rule.Name,
249
+ arn: rule.Arn ?? '',
250
+ state: rule.State ?? 'UNKNOWN',
251
+ scheduleExpression: rule.ScheduleExpression,
252
+ eventPattern: rule.EventPattern,
253
+ targetArns,
254
+ });
255
+ }
256
+ catch (err) {
257
+ logger.warn(`EventBridge targets fetch failed for ${rule.Name}: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
259
+ }
260
+ nextToken = res.NextToken;
261
+ } while (nextToken && rules.length < 500);
262
+ }
263
+ catch (err) {
264
+ logger.warn(`EventBridge list failed: ${err instanceof Error ? err.message : String(err)}`);
265
+ }
266
+ return rules;
267
+ }
268
+ export async function validateEventBridgeAccess(cfg = {}) {
269
+ await new EventBridgeClient(clientConfig(cfg)).send(new ListRulesCommand({ Limit: 1 }));
270
+ }
271
+ export async function extractLambdaMetadata(cfg = {}, includeFunctions) {
187
272
  const client = new LambdaClient(clientConfig(cfg));
188
273
  const functions = [];
189
274
  try {
@@ -191,8 +276,11 @@ export async function extractLambdaMetadata(cfg = {}) {
191
276
  do {
192
277
  const res = await client.send(new ListFunctionsCommand({ Marker: marker, MaxItems: 50 }));
193
278
  for (const fn of res.Functions ?? []) {
279
+ const name = fn.FunctionName ?? '';
280
+ if (includeFunctions?.length && !includeFunctions.includes(name))
281
+ continue;
194
282
  functions.push({
195
- name: fn.FunctionName ?? '',
283
+ name,
196
284
  arn: fn.FunctionArn ?? '',
197
285
  runtime: fn.Runtime,
198
286
  handler: fn.Handler,
@@ -201,10 +289,16 @@ export async function extractLambdaMetadata(cfg = {}) {
201
289
  lastModified: fn.LastModified,
202
290
  envVarKeys: Object.keys(fn.Environment?.Variables ?? {}),
203
291
  layers: (fn.Layers ?? []).map((l) => l.Arn ?? '').filter(Boolean),
292
+ triggers: [],
204
293
  });
205
294
  }
206
295
  marker = res.NextMarker;
207
296
  } while (marker && functions.length < 200);
297
+ // Fetch all event source mappings in one paginated call and attach to functions
298
+ const triggerMap = await fetchAllEventSourceMappings(cfg);
299
+ for (const fn of functions) {
300
+ fn.triggers = triggerMap.get(fn.arn) ?? [];
301
+ }
208
302
  }
209
303
  catch (err) {
210
304
  logger.warn(`Lambda list failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -10,11 +10,6 @@ function createDynamoClient(config) {
10
10
  clientConfig.endpoint = endpoint;
11
11
  if (profile)
12
12
  clientConfig.credentials = fromIni({ profile });
13
- else if (endpoint)
14
- clientConfig.credentials = {
15
- accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? 'test',
16
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? 'test',
17
- };
18
13
  return new DynamoDBClient(clientConfig);
19
14
  }
20
15
  function parseTableDescription(desc) {
@@ -11,11 +11,6 @@ function clientConfig(cfg) {
11
11
  base.endpoint = cfg.endpoint;
12
12
  if (cfg.profile)
13
13
  base.credentials = fromIni({ profile: cfg.profile });
14
- else if (cfg.endpoint)
15
- base.credentials = {
16
- accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? 'test',
17
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? 'test',
18
- };
19
14
  return base;
20
15
  }
21
16
  // Strip UUIDs, timestamps, numbers → group similar messages by pattern
@@ -136,6 +136,33 @@ export class LambdaDefaultMemoryAnalyzer {
136
136
  return findings;
137
137
  }
138
138
  }
139
+ export class LambdaMissingTriggerDLQAnalyzer {
140
+ name = 'LambdaMissingTriggerDLQAnalyzer';
141
+ async analyze(graph) {
142
+ const findings = [];
143
+ for (const node of graph.nodes) {
144
+ if (node.type !== 'lambda')
145
+ continue;
146
+ for (const trigger of node.triggers ?? []) {
147
+ if (trigger.type !== 'sqs' && trigger.type !== 'kinesis' && trigger.type !== 'dynamodb')
148
+ continue;
149
+ // Check if there's a DLQ/destination on the trigger edge — we flag if the source queue itself has no DLQ
150
+ // and the trigger is active, since failures will be silently dropped
151
+ const sourceQueue = graph.nodes.find((n) => n.type === 'queue' && n.name === trigger.sourceName);
152
+ if (sourceQueue && sourceQueue.type === 'queue' && !sourceQueue.hasDLQ) {
153
+ findings.push({
154
+ severity: 'high',
155
+ issue: `Lambda "${node.name}" is triggered by "${trigger.sourceName}" which has no DLQ`,
156
+ description: `"${node.name}" receives events from "${trigger.sourceName}" (${trigger.type.toUpperCase()}). If the Lambda handler fails, messages will be retried and eventually discarded with no failure record.`,
157
+ recommendation: `Add a DLQ to "${trigger.sourceName}" and set a destination config on the event source mapping so failed batches are captured and inspectable.`,
158
+ metadata: { functionName: node.name, triggerSource: trigger.sourceName, triggerType: trigger.type },
159
+ });
160
+ }
161
+ }
162
+ }
163
+ return findings;
164
+ }
165
+ }
139
166
  export class LambdaHighTimeoutAnalyzer {
140
167
  name = 'LambdaHighTimeoutAnalyzer';
141
168
  async analyze(graph) {
@@ -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, } from './aws-services.js';
7
+ export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, } 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 = [];
@@ -7,11 +7,11 @@ import { extractPostgresMetadata } from '../../adapters/postgres.js';
7
7
  import { extractMySQLMetadata } from '../../adapters/mysql.js';
8
8
  import { extractMongoMetadata } from '../../adapters/mongodb.js';
9
9
  import { extractIaCSchema } from '../../adapters/terraform.js';
10
- import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractRDSMetadata, } from '../../adapters/aws.js';
10
+ import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, } from '../../adapters/aws.js';
11
11
  import { extractLogsMetadata } from '../../adapters/logs.js';
12
12
  import { scanRepository } from '../../context/index.js';
13
13
  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, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from '../../analyzers/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';
15
15
  import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
16
16
  function mkSpinner(text) {
17
17
  return ora({ text: chalk.dim(text), color: 'cyan' }).start();
@@ -134,7 +134,7 @@ export async function runAnalyze(options = {}) {
134
134
  if (config.lambda?.enabled === true) {
135
135
  const s = mkSpinner('Extracting Lambda functions...');
136
136
  try {
137
- const result = await extractLambdaMetadata(awsCfg);
137
+ const result = await extractLambdaMetadata(awsCfg, config.lambda?.includeFunctions);
138
138
  servicesMeta.lambda = result;
139
139
  s.succeed(chalk.green('Lambda') + chalk.dim(` ${result.length} function(s)`));
140
140
  }
@@ -142,6 +142,18 @@ export async function runAnalyze(options = {}) {
142
142
  s.warn(chalk.yellow('Lambda skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
143
143
  }
144
144
  }
145
+ // ── EventBridge ──────────────────────────────────────────────────────────────
146
+ if (config.eventbridge?.enabled === true) {
147
+ const s = mkSpinner('Extracting EventBridge rules...');
148
+ try {
149
+ const result = await extractEventBridgeMetadata(awsCfg);
150
+ servicesMeta.eventbridge = result;
151
+ s.succeed(chalk.green('EventBridge') + chalk.dim(` ${result.length} rule(s)`));
152
+ }
153
+ catch (err) {
154
+ s.warn(chalk.yellow('EventBridge skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
155
+ }
156
+ }
145
157
  // ── RDS ──────────────────────────────────────────────────────────────────────
146
158
  if (config.rds?.enabled === true) {
147
159
  const s = mkSpinner('Extracting RDS instances...');
@@ -246,6 +258,7 @@ export async function runAnalyze(options = {}) {
246
258
  ...(config.lambda?.enabled === true ? [
247
259
  new LambdaDefaultMemoryAnalyzer(),
248
260
  new LambdaHighTimeoutAnalyzer(),
261
+ new LambdaMissingTriggerDLQAnalyzer(),
249
262
  ] : []),
250
263
  ...(config.rds?.enabled === true ? [
251
264
  new RDSPubliclyAccessibleAnalyzer(),
@@ -20,6 +20,7 @@ const TOOL_MAP = [
20
20
  { name: 'get_secrets_overview', service: 'secretsManager' },
21
21
  { name: 'get_parameter_overview', service: 'ssm' },
22
22
  { name: 'get_lambda_overview', service: 'lambda' },
23
+ { name: 'get_eventbridge_details', service: 'eventbridge' },
23
24
  { name: 'get_log_errors', service: 'cloudwatchLogs' },
24
25
  ];
25
26
  function isEnabled(cfg, service) {
@@ -70,7 +71,7 @@ export async function runDev(options = {}) {
70
71
  const cachedFindings = readCache('findings');
71
72
  if (cachedGraph && cachedFindings) {
72
73
  log.success('Cached analysis loaded', `${cachedGraph.nodes.length} nodes · ${cachedGraph.edges.length} edges · ${cachedFindings.length} finding(s)`);
73
- setGraphState(cachedGraph, cachedFindings);
74
+ setGraphState(cachedGraph, cachedFindings, config);
74
75
  }
75
76
  else {
76
77
  log.warn('No cache found — running analysis now...');
@@ -78,7 +79,7 @@ export async function runDev(options = {}) {
78
79
  await runAnalyze({ repo: repoPath, config: options.config });
79
80
  const freshGraph = readCache('graph') ?? { nodes: [], edges: [] };
80
81
  const freshFindings = readCache('findings') ?? [];
81
- setGraphState(freshGraph, freshFindings);
82
+ setGraphState(freshGraph, freshFindings, config);
82
83
  }
83
84
  console.log('');
84
85
  // Start server
@@ -145,7 +146,7 @@ export async function runDev(options = {}) {
145
146
  const spin = ora({ text: chalk.dim('Refreshing code analysis...'), color: 'cyan' }).start();
146
147
  try {
147
148
  const { graph, findings } = await runCodeRefresh(repoPath, config);
148
- setGraphState(graph, findings);
149
+ setGraphState(graph, findings, config);
149
150
  spin.succeed(chalk.green('Analysis refreshed') + chalk.dim(` ${graph.nodes.length} nodes · ${findings.length} finding(s)`));
150
151
  }
151
152
  catch (err) {
@@ -8,7 +8,7 @@ import { probeDynamoAccess } from '../../adapters/dynamodb.js';
8
8
  import { validatePostgresAccess } from '../../adapters/postgres.js';
9
9
  import { validateMySQLAccess } from '../../adapters/mysql.js';
10
10
  import { validateMongoAccess } from '../../adapters/mongodb.js';
11
- import { validateSQSAccess, validateSNSAccess, validateSSMAccess, validateSecretsAccess, validateLambdaAccess, } from '../../adapters/aws.js';
11
+ import { validateSQSAccess, validateSNSAccess, validateSSMAccess, validateSecretsAccess, validateLambdaAccess, validateEventBridgeAccess, } from '../../adapters/aws.js';
12
12
  import { validateLogsAccess } from '../../adapters/logs.js';
13
13
  import { printHeader } from '../utils.js';
14
14
  async function runCheck(label, fn) {
@@ -173,6 +173,22 @@ export async function runDoctor(options = {}) {
173
173
  };
174
174
  }
175
175
  }));
176
+ // EventBridge
177
+ results.push(await runCheck('Testing EventBridge access...', async () => {
178
+ if (config?.eventbridge?.enabled !== true)
179
+ return { name: 'EventBridge', status: 'skip', message: 'Disabled in config' };
180
+ try {
181
+ await validateEventBridgeAccess(awsCfg);
182
+ return { name: 'EventBridge', status: 'pass', message: 'Connected' };
183
+ }
184
+ catch (err) {
185
+ return {
186
+ name: 'EventBridge', status: 'warn',
187
+ message: err instanceof Error ? err.message : String(err),
188
+ detail: 'Check IAM: events:ListRules, events:ListTargetsByRule',
189
+ };
190
+ }
191
+ }));
176
192
  // CloudWatch Logs
177
193
  results.push(await runCheck('Testing CloudWatch Logs access...', async () => {
178
194
  if (!config?.cloudwatchLogs?.enabled)
@@ -29,7 +29,7 @@ export async function runInit(options = {}) {
29
29
  default: repoName,
30
30
  },
31
31
  {
32
- type: 'list',
32
+ type: 'select',
33
33
  name: 'awsProfile',
34
34
  message: 'AWS profile:',
35
35
  choices: [
@@ -155,6 +155,18 @@ export async function runInit(options = {}) {
155
155
  message: 'Introspect Lambda functions?',
156
156
  default: true,
157
157
  },
158
+ {
159
+ type: 'confirm',
160
+ name: 'eventbridgeEnabled',
161
+ message: 'Introspect EventBridge rules?',
162
+ default: true,
163
+ },
164
+ {
165
+ type: 'confirm',
166
+ name: 'rdsEnabled',
167
+ message: 'Introspect RDS instances?',
168
+ default: true,
169
+ },
158
170
  {
159
171
  type: 'confirm',
160
172
  name: 'logsEnabled',
@@ -195,6 +207,8 @@ export async function runInit(options = {}) {
195
207
  ssm: { enabled: services.ssmEnabled, paths: ssmPaths },
196
208
  secretsManager: { enabled: services.secretsEnabled },
197
209
  lambda: { enabled: services.lambdaEnabled },
210
+ eventbridge: { enabled: services.eventbridgeEnabled },
211
+ rds: { enabled: services.rdsEnabled },
198
212
  cloudwatchLogs: {
199
213
  enabled: services.logsEnabled,
200
214
  logGroupPrefixes,
@@ -6,7 +6,7 @@ export const InfrawiseConfigSchema = z.object({
6
6
  project: z.string().min(1, 'Project name is required'),
7
7
  aws: z
8
8
  .object({
9
- profile: z.string().optional().default('default'),
9
+ profile: z.string().optional().default(''),
10
10
  region: z.string().optional().default('us-east-1'),
11
11
  endpoint: z.string().optional(),
12
12
  })
@@ -34,7 +34,11 @@ export const InfrawiseConfigSchema = z.object({
34
34
  paths: z.array(z.string()).optional(),
35
35
  }).optional(),
36
36
  secretsManager: z.object({ enabled: z.boolean().optional().default(true) }).optional(),
37
- lambda: z.object({ enabled: z.boolean().optional().default(true) }).optional(),
37
+ lambda: z.object({
38
+ enabled: z.boolean().optional().default(true),
39
+ includeFunctions: z.array(z.string()).optional(),
40
+ }).optional(),
41
+ eventbridge: z.object({ enabled: z.boolean().optional().default(true) }).optional(),
38
42
  rds: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
39
43
  kafka: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
40
44
  cloudwatchLogs: z.object({
@@ -94,7 +98,7 @@ export function generateDefaultConfig(projectName, options) {
94
98
  const config = {
95
99
  project: projectName,
96
100
  aws: {
97
- profile: options?.aws?.profile ?? 'default',
101
+ profile: options?.aws?.profile ?? '',
98
102
  region: options?.aws?.region ?? 'us-east-1',
99
103
  ...(options?.aws?.endpoint ? { endpoint: options.aws.endpoint } : {}),
100
104
  },
@@ -121,6 +125,8 @@ export function generateDefaultConfig(projectName, options) {
121
125
  },
122
126
  secretsManager: { enabled: options?.secretsManager?.enabled ?? true },
123
127
  lambda: { enabled: options?.lambda?.enabled ?? true },
128
+ eventbridge: { enabled: options?.eventbridge?.enabled ?? true },
129
+ rds: { enabled: options?.rds?.enabled ?? true },
124
130
  cloudwatchLogs: {
125
131
  enabled: options?.cloudwatchLogs?.enabled ?? false,
126
132
  logGroupPrefixes: options?.cloudwatchLogs?.logGroupPrefixes ?? [],
@@ -102,15 +102,69 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
102
102
  });
103
103
  }
104
104
  for (const fn of servicesMeta.lambda ?? []) {
105
+ const lambdaId = `lambda:aws:${fn.name}`;
105
106
  addNode({
106
- id: `lambda:aws:${fn.name}`,
107
+ id: lambdaId,
107
108
  type: 'lambda',
108
109
  name: fn.name,
109
110
  runtime: fn.runtime,
110
111
  memoryMB: fn.memoryMB,
111
112
  timeoutSec: fn.timeoutSec,
112
113
  envVarKeys: fn.envVarKeys,
114
+ triggers: fn.triggers,
113
115
  });
116
+ // Add trigger edges from source → lambda (only for enabled/active mappings)
117
+ for (const trigger of fn.triggers ?? []) {
118
+ if (trigger.state && trigger.state !== 'Enabled')
119
+ continue;
120
+ let sourceId;
121
+ if (trigger.type === 'sqs') {
122
+ sourceId = `queue:aws:${trigger.sourceName}`;
123
+ addNode({ id: sourceId, type: 'queue', name: trigger.sourceName, provider: 'aws', hasDLQ: false, encrypted: false });
124
+ }
125
+ else if (trigger.type === 'dynamodb') {
126
+ sourceId = `table:dynamo:${trigger.sourceName}`;
127
+ addNode({ id: sourceId, type: 'table', name: trigger.sourceName, databaseType: 'dynamodb' });
128
+ }
129
+ else if (trigger.type === 'kinesis') {
130
+ sourceId = `queue:aws:${trigger.sourceName}`;
131
+ addNode({ id: sourceId, type: 'queue', name: trigger.sourceName, provider: 'aws', hasDLQ: false, encrypted: false });
132
+ }
133
+ else {
134
+ continue;
135
+ }
136
+ edges.push({ from: sourceId, to: lambdaId, type: 'triggers' });
137
+ }
138
+ }
139
+ for (const rule of servicesMeta.eventbridge ?? []) {
140
+ const ruleId = `eventbridge_rule:aws:${rule.name}`;
141
+ addNode({
142
+ id: ruleId,
143
+ type: 'eventbridge_rule',
144
+ name: rule.name,
145
+ state: rule.state,
146
+ scheduleExpression: rule.scheduleExpression,
147
+ eventPattern: rule.eventPattern,
148
+ });
149
+ for (const targetArn of rule.targetArns) {
150
+ const fnName = targetArn.split(':').pop() ?? '';
151
+ const lambdaId = `lambda:aws:${fnName}`;
152
+ if (!nodeIds.has(lambdaId))
153
+ continue;
154
+ edges.push({ from: ruleId, to: lambdaId, type: 'triggers' });
155
+ const lambdaNodeRef = nodes.find((n) => n.id === lambdaId);
156
+ if (lambdaNodeRef && lambdaNodeRef.type === 'lambda') {
157
+ const trigger = {
158
+ type: 'eventbridge',
159
+ sourceArn: rule.arn,
160
+ sourceName: rule.name,
161
+ eventShape: 'event.detail',
162
+ ruleName: rule.name,
163
+ eventPattern: rule.scheduleExpression ?? rule.eventPattern ?? '',
164
+ };
165
+ lambdaNodeRef.triggers = [...(lambdaNodeRef.triggers ?? []), trigger];
166
+ }
167
+ }
114
168
  }
115
169
  for (const db of servicesMeta.rds ?? []) {
116
170
  addNode({
@@ -236,6 +290,9 @@ export function getLogGroupNodes(graph) {
236
290
  export function getLambdaNodes(graph) {
237
291
  return graph.nodes.filter((n) => n.type === 'lambda');
238
292
  }
293
+ export function getEventBridgeRuleNodes(graph) {
294
+ return graph.nodes.filter((n) => n.type === 'eventbridge_rule');
295
+ }
239
296
  export function getEdgesForNode(graph, nodeId) {
240
297
  return graph.edges.filter((e) => e.from === nodeId || e.to === nodeId);
241
298
  }
@@ -8,11 +8,11 @@ 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, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
11
+ import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
12
12
  // ── State ────────────────────────────────────────────────────────────────────
13
13
  let currentGraph = { nodes: [], edges: [] };
14
14
  let currentFindings = [];
15
- export function setGraphState(graph, findings) {
15
+ export function setGraphState(graph, findings, _config) {
16
16
  currentGraph = graph;
17
17
  currentFindings = findings;
18
18
  }
@@ -76,21 +76,28 @@ export function createMcpServer() {
76
76
  },
77
77
  })));
78
78
  mcp.registerTool('analyze_function', {
79
- description: 'Analyze a specific function for all infrastructure issues: DB queries, queue publishing, secret access, etc.',
79
+ description: 'Analyze a specific function for all infrastructure issues: DB queries, queue publishing, secret access, trigger event shapes, etc.',
80
80
  inputSchema: z.object({ function: z.string().describe('Function name to analyze') }),
81
81
  }, logged('analyze_function', async ({ function: functionName }) => {
82
82
  const funcNode = currentGraph.nodes.find((n) => n.type === 'function' && n.name === functionName);
83
- if (!funcNode) {
83
+ // Also check if there's a Lambda node with this name (for AWS-deployed functions)
84
+ const lambdaNode = currentGraph.nodes.find((n) => n.type === 'lambda' && n.name === functionName);
85
+ if (!funcNode && !lambdaNode) {
84
86
  return toText({ function: functionName, found: false, issues: [], recommendations: [`Function "${functionName}" not found in the analyzed codebase.`] });
85
87
  }
86
- const outEdges = getOutgoingEdges(currentGraph, funcNode.id);
88
+ const outEdges = funcNode ? getOutgoingEdges(currentGraph, funcNode.id) : [];
87
89
  const relatedFindings = currentFindings.filter((f) => {
88
90
  const meta = f.metadata;
89
91
  return meta?.functionName === functionName || String(meta?.callerFunctions ?? '').includes(functionName);
90
92
  });
93
+ const allTriggers = lambdaNode?.type === 'lambda' ? (lambdaNode.triggers ?? []) : [];
91
94
  return toText({
92
95
  function: functionName, found: true,
93
- file: funcNode.type === 'function' ? funcNode.file : undefined,
96
+ file: funcNode?.type === 'function' ? funcNode.file : undefined,
97
+ triggers: allTriggers.map((t) => ({
98
+ type: t.type, source: t.sourceName, eventShape: t.eventShape,
99
+ ...(t.ruleName ? { ruleName: t.ruleName, eventPattern: t.eventPattern } : {}),
100
+ })),
94
101
  accesses: outEdges.map((e) => {
95
102
  const target = currentGraph.nodes.find((n) => n.id === e.to);
96
103
  return { targetId: e.to, edgeType: e.type, targetName: target && 'name' in target ? target.name : e.to, targetType: target?.type };
@@ -220,7 +227,7 @@ export function createMcpServer() {
220
227
  });
221
228
  }));
222
229
  mcp.registerTool('get_lambda_overview', {
223
- description: 'Returns all Lambda functions: runtime, memory, timeout, env var key names (values never included).',
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.',
224
231
  inputSchema: z.object({}),
225
232
  }, logged('get_lambda_overview', async () => {
226
233
  const lambdas = getLambdaNodes(currentGraph);
@@ -230,10 +237,31 @@ export function createMcpServer() {
230
237
  lambdas: lambdas.map((l) => ({
231
238
  name: l.name, runtime: l.runtime, memoryMB: l.memoryMB, timeoutSec: l.timeoutSec,
232
239
  envVarCount: l.envVarKeys?.length ?? 0, envVarKeys: l.envVarKeys,
240
+ triggers: (l.triggers ?? []).map((t) => ({ type: t.type, source: t.sourceName, eventShape: t.eventShape, state: t.state })),
233
241
  findings: lambdaFindings.filter((f) => f.metadata.functionName === l.name).map((f) => ({ severity: f.severity, issue: f.issue })),
234
242
  })),
235
243
  });
236
244
  }));
245
+ mcp.registerTool('get_eventbridge_details', {
246
+ description: 'Returns all EventBridge rules: name, state, schedule expression or event pattern, and target Lambda functions.',
247
+ inputSchema: z.object({}),
248
+ }, logged('get_eventbridge_details', async () => {
249
+ const rules = getEventBridgeRuleNodes(currentGraph);
250
+ return toText({
251
+ total: rules.length,
252
+ rules: rules.map((r) => ({
253
+ name: r.name,
254
+ state: r.state,
255
+ scheduleExpression: r.scheduleExpression,
256
+ eventPattern: r.eventPattern,
257
+ targets: currentGraph.edges
258
+ .filter((e) => e.from === r.id && e.type === 'triggers')
259
+ .map((e) => currentGraph.nodes.find((n) => n.id === e.to))
260
+ .filter(Boolean)
261
+ .map((n) => n && 'name' in n ? n.name : ''),
262
+ })),
263
+ });
264
+ }));
237
265
  mcp.registerTool('get_log_errors', {
238
266
  description: 'Returns recent error patterns from CloudWatch log groups. Returns pattern counts and frequencies — never raw log messages.',
239
267
  inputSchema: z.object({ logGroup: z.string().describe('Filter to a specific log group name (optional)').optional() }),
@@ -258,6 +286,22 @@ export function createServer(port = 3000) {
258
286
  graphEdges: currentGraph.edges.length,
259
287
  findings: currentFindings.length,
260
288
  }));
289
+ fastify.get('/.well-known/mcp/server-card.json', async () => ({
290
+ schema_version: '2026-01',
291
+ name: 'io.github.Sidd27/infrawise',
292
+ display_name: 'Infrawise',
293
+ 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.',
295
+ homepage: 'https://github.com/Sidd27/infrawise',
296
+ repository: 'https://github.com/Sidd27/infrawise',
297
+ transports: [{ type: 'streamable-http', url: `http://localhost:${port}/mcp` }],
298
+ tools: [
299
+ 'get_infra_overview', 'get_graph_summary', 'analyze_function',
300
+ 'suggest_gsi', 'postgres_index_suggestions', 'suggest_mongo_index', 'mysql_index_suggestions',
301
+ 'get_queue_details', 'get_topic_details', 'get_secrets_overview', 'get_parameter_overview',
302
+ 'get_lambda_overview', 'get_eventbridge_details', 'get_log_errors',
303
+ ],
304
+ }));
261
305
  fastify.post('/mcp', async (request, reply) => {
262
306
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
263
307
  reply.raw.on('close', () => transport.close());
package/package.json CHANGED
@@ -1,31 +1,41 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "mcpName": "io.github.Sidd27/infrawise",
5
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",
6
6
  "keywords": [
7
+ "mcp",
8
+ "mcp-server",
9
+ "model-context-protocol",
7
10
  "infrastructure",
11
+ "infrastructure-analysis",
8
12
  "aws",
9
13
  "dynamodb",
10
14
  "sqs",
11
15
  "sns",
12
16
  "lambda",
17
+ "eventbridge",
13
18
  "cloudwatch",
14
19
  "secrets-manager",
15
20
  "ssm",
16
21
  "postgresql",
17
22
  "mysql",
18
23
  "mongodb",
19
- "mcp",
24
+ "serverless",
20
25
  "claude",
21
26
  "claude-code",
22
- "analysis",
23
- "cli",
27
+ "cursor",
28
+ "ai-coding",
29
+ "ai-tools",
24
30
  "devtools",
31
+ "cli",
25
32
  "terraform",
26
33
  "cloudformation",
27
34
  "cdk"
28
35
  ],
36
+ "agentskills": {
37
+ "infrawise": "./AGENTS.md"
38
+ },
29
39
  "homepage": "https://github.com/Sidd27/infrawise#readme",
30
40
  "repository": {
31
41
  "type": "git",
@@ -54,6 +64,7 @@
54
64
  "dependencies": {
55
65
  "@aws-sdk/client-cloudwatch-logs": "^3.1048.0",
56
66
  "@aws-sdk/client-dynamodb": "^3.1048.0",
67
+ "@aws-sdk/client-eventbridge": "^3.1051.0",
57
68
  "@aws-sdk/client-lambda": "^3.1048.0",
58
69
  "@aws-sdk/client-rds": "^3.1048.0",
59
70
  "@aws-sdk/client-secrets-manager": "^3.1048.0",