infrawise 0.10.2 → 0.11.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
@@ -6,7 +6,7 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![infrawise MCP server](https://glama.ai/mcp/servers/Sidd27/infrawise/badges/score.svg)](https://glama.ai/mcp/servers/Sidd27/infrawise)
8
8
 
9
- **Understand your infrastructure, not just your code.**
9
+ **[sidd27.github.io/infrawise](https://sidd27.github.io/infrawise/) — Understand your infrastructure, not just your code.**
10
10
 
11
11
  Infrawise gives AI coding assistants deterministic infrastructure awareness.
12
12
 
@@ -117,14 +117,6 @@ infrawise start --cursor
117
117
 
118
118
  Writes `.cursor/mcp.json` and opens Cursor. All 15 infrawise tools are available in Cursor's MCP panel.
119
119
 
120
- ### Windsurf
121
-
122
- ```bash
123
- infrawise start --windsurf
124
- ```
125
-
126
- Writes to `~/.codeium/windsurf/mcp_config.json` and opens Windsurf.
127
-
128
120
  ### Any editor (no flag)
129
121
 
130
122
  ```bash
@@ -164,11 +156,12 @@ Add to your editor's MCP config:
164
156
  | `postgres_index_suggestions` | Exact `CREATE INDEX` SQL for your actual table |
165
157
  | `suggest_mongo_index` | Exact `createIndex` command for a MongoDB collection + field |
166
158
  | `mysql_index_suggestions` | Exact `ALTER TABLE ADD INDEX` SQL for your MySQL table |
167
- | `get_queue_details` | SQS queues — DLQ status, encryption, message counts |
159
+ | `get_queue_details` | SQS queues — DLQ status, encryption, FIFO type, visibility timeout, message counts |
160
+ | `get_api_routes` | API Gateway APIs (REST, HTTP, WebSocket) — routes, HTTP methods, paths, and Lambda integrations |
168
161
  | `get_topic_details` | SNS topics — subscription counts, protocols, and filter policies (required message attributes per subscription) |
169
162
  | `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
170
163
  | `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
171
- | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), env var key names |
164
+ | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), env var key names |
172
165
  | `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
173
166
  | `get_s3_overview` | S3 buckets — versioning, encryption, public access, event notifications |
174
167
  | `get_log_errors` | CloudWatch error patterns and counts (no raw log messages) |
@@ -182,7 +175,6 @@ Add to your editor's MCP config:
182
175
  | `infrawise start` | **Primary command** — init + analyze + write editor MCP config, then exit |
183
176
  | `infrawise start --claude` | Same as above, then opens Claude Code |
184
177
  | `infrawise start --cursor` | Same as above, then opens Cursor |
185
- | `infrawise start --windsurf` | Same as above, then opens Windsurf |
186
178
  | `infrawise init` | Generate `infrawise.yaml` only (no analysis, no editor config) |
187
179
  | `infrawise auth` | Select or switch AWS profile |
188
180
  | `infrawise analyze` | Force a full re-scan — useful after major infrastructure changes |
@@ -283,6 +275,9 @@ s3:
283
275
  kafka:
284
276
  enabled: false
285
277
 
278
+ apiGateway:
279
+ enabled: false
280
+
286
281
  cloudwatchLogs:
287
282
  enabled: false
288
283
  logGroupPrefixes: []
@@ -290,6 +285,9 @@ cloudwatchLogs:
290
285
 
291
286
  analysis:
292
287
  sampleSize: 100
288
+ hotPartitionThreshold: 5
289
+ hotPartitionThresholds:
290
+ high-traffic-table: 12
293
291
  ```
294
292
 
295
293
  ### AWS setup
@@ -343,13 +341,14 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
343
341
  | DynamoDB schema | Tables, GSIs, partition keys |
344
342
  | PostgreSQL / MySQL schema | Tables, indexes, column types |
345
343
  | MongoDB schema | Collections, indexes |
346
- | SQS | Missing DLQs, unencrypted queues, large backlogs |
344
+ | SQS | Missing DLQs, unencrypted queues, large backlogs, FIFO detection, visibility timeout vs Lambda timeout mismatch |
347
345
  | SNS | Subscription filter policies — required message attributes per subscription |
348
346
  | Kafka (kafkajs) | Producer/consumer topic mapping from code |
349
347
  | Secrets Manager | Missing secret rotation |
350
348
  | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), missing DLQ on trigger source |
351
349
  | S3 | Public access blocking (verify), missing versioning, missing encryption |
352
350
  | EventBridge | Rules, schedules, event patterns, target Lambda functions |
351
+ | API Gateway | REST, HTTP, and WebSocket APIs — routes, methods, Lambda integrations |
353
352
  | RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
354
353
  | CloudWatch Logs | Log groups with no retention policy |
355
354
  | Terraform / CloudFormation / CDK | IaC drift vs deployed state |
@@ -370,6 +369,9 @@ Uses [ts-morph](https://ts-morph.com/) AST analysis to detect which functions ca
370
369
  | MySQL Full Table Scan | High | Full table scan patterns in MySQL queries |
371
370
  | Missing Mongo Index | Medium | Collections queried without secondary indexes |
372
371
  | Collection Scan | High | `find()` calls without filter predicates |
372
+ | Pipeline: scan in consumer | High / Verify | Full scan inside an event-triggered Lambda handler (High when the lambda-to-code link is IaC-proven, Verify when name-matched) |
373
+ | Pipeline: repeated table access | Medium / Verify | Same table read by 2+ functions in one service pipeline |
374
+ | Pipeline: missing DLQ hop | Medium | Mid-pipeline queue (has producer and consumer) with no Dead Letter Queue |
373
375
 
374
376
  Non-TypeScript/JavaScript projects still get full value from infrastructure-level analyzers — code correlation (function-to-table mapping, N+1 patterns) is skipped.
375
377
 
@@ -400,6 +402,10 @@ Infrawise does not use an LLM to analyze your infrastructure. All extraction and
400
402
  - **No telemetry** — zero data collection
401
403
  - **Credentials** — uses your existing AWS credential chain, never stored by infrawise
402
404
 
405
+ ### 🔒 Security & Project Naming Note
406
+
407
+ You might see this package flagged on certain supply-chain security scanners under "deceptive naming." This is a false positive triggered by automated tools because of the prefix "infra." This project is completely safe, independent, and unaffiliated with any commercial trademarks.
408
+
403
409
  ---
404
410
 
405
411
  ## Architecture overview
@@ -414,10 +420,10 @@ src/
414
420
  core/ Config (Zod + YAML), logger (Pino), local cache
415
421
  graph/ Graph engine — nodes, edges, builder
416
422
  adapters/
417
- aws/ DynamoDB, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS, CloudWatch
423
+ aws/ DynamoDB, S3, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS/APIGateway, CloudWatch
418
424
  db/ PostgreSQL, MySQL, MongoDB
419
425
  iac/ Terraform, CDK, CloudFormation (local file parsing)
420
- analyzers/ 23 rule-based analyzers
426
+ analyzers/ 29 rule-based analyzers
421
427
  context/ Repository scanner (ts-morph AST)
422
428
  server/ Fastify MCP server (@modelcontextprotocol/sdk, Streamable HTTP)
423
429
  cli/ CLI commands (Commander.js)
@@ -1,4 +1,6 @@
1
1
  import { SQSClient, ListQueuesCommand, GetQueueAttributesCommand } from '@aws-sdk/client-sqs';
2
+ import { APIGatewayClient, GetRestApisCommand, GetResourcesCommand, } from '@aws-sdk/client-api-gateway';
3
+ import { ApiGatewayV2Client, GetApisCommand, GetRoutesCommand, GetIntegrationsCommand, } from '@aws-sdk/client-apigatewayv2';
2
4
  import { SNSClient, ListTopicsCommand, GetTopicAttributesCommand, ListSubscriptionsByTopicCommand, GetSubscriptionAttributesCommand, } from '@aws-sdk/client-sns';
3
5
  import { SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm';
4
6
  import { SecretsManagerClient, ListSecretsCommand } from '@aws-sdk/client-secrets-manager';
@@ -66,6 +68,7 @@ export async function extractSQSMetadata(cfg = {}) {
66
68
  : undefined;
67
69
  const encrypted = !!(a['KmsMasterKeyId'] || a['SqsManagedSseEnabled'] === 'true');
68
70
  const retentionSeconds = parseInt(a['MessageRetentionPeriod'] ?? '345600', 10);
71
+ const isFifo = name.endsWith('.fifo') || a['FifoQueue'] === 'true';
69
72
  queues.push({
70
73
  name,
71
74
  url,
@@ -73,6 +76,7 @@ export async function extractSQSMetadata(cfg = {}) {
73
76
  hasDLQ: !!dlqArn,
74
77
  dlqArn,
75
78
  encrypted,
79
+ isFifo,
76
80
  visibilityTimeoutSec: parseInt(a['VisibilityTimeout'] ?? '30', 10),
77
81
  retentionDays: Math.round(retentionSeconds / 86400),
78
82
  approximateMessages: parseInt(a['ApproximateNumberOfMessages'] ?? '0', 10),
@@ -382,3 +386,105 @@ export async function extractRDSMetadata(cfg = {}) {
382
386
  export async function validateRDSAccess(cfg = {}) {
383
387
  await new RDSClient(clientConfig(cfg)).send(new DescribeDBInstancesCommand({ MaxRecords: 20 }));
384
388
  }
389
+ // ─── API Gateway ──────────────────────────────────────────────────────────────
390
+ function lambdaNameFromArn(arn) {
391
+ if (!arn)
392
+ return undefined;
393
+ const parts = arn.split(':');
394
+ return parts[parts.length - 1] || undefined;
395
+ }
396
+ export async function extractAPIGatewayMetadata(cfg = {}) {
397
+ const results = [];
398
+ const ccfg = clientConfig(cfg);
399
+ // REST APIs (v1)
400
+ try {
401
+ const restClient = new APIGatewayClient(ccfg);
402
+ let position;
403
+ const restApis = [];
404
+ do {
405
+ const res = await restClient.send(new GetRestApisCommand({ position, limit: 500 }));
406
+ for (const api of res.items ?? []) {
407
+ if (api.id && api.name)
408
+ restApis.push({ id: api.id, name: api.name });
409
+ }
410
+ position = res.position;
411
+ } while (position);
412
+ for (const api of restApis) {
413
+ const routes = [];
414
+ try {
415
+ const resourcesRes = await restClient.send(new GetResourcesCommand({ restApiId: api.id, embed: ['methods'], limit: 500 }));
416
+ for (const resource of resourcesRes.items ?? []) {
417
+ const resourcePath = resource.path ?? '/';
418
+ for (const [method, methodItem] of Object.entries(resource.resourceMethods ?? {})) {
419
+ if (method === 'OPTIONS')
420
+ continue;
421
+ const integration = methodItem
422
+ ?.methodIntegration;
423
+ const lambdaArn = typeof integration?.uri === 'string' ? integration.uri : undefined;
424
+ const lambdaName = lambdaArn
425
+ ? lambdaNameFromArn(lambdaArn.split('/functions/')[1]?.split('/')[0])
426
+ : undefined;
427
+ routes.push({ method, path: resourcePath, lambdaArn, lambdaName });
428
+ }
429
+ }
430
+ }
431
+ catch (err) {
432
+ logger.warn(`API Gateway REST resources failed for ${api.name}: ${err instanceof Error ? err.message : String(err)}`);
433
+ }
434
+ results.push({ name: api.name, id: api.id, type: 'REST', routes });
435
+ }
436
+ }
437
+ catch (err) {
438
+ logger.warn(`API Gateway REST list failed: ${err instanceof Error ? err.message : String(err)}`);
439
+ }
440
+ // HTTP + WebSocket APIs (v2)
441
+ try {
442
+ const v2Client = new ApiGatewayV2Client(ccfg);
443
+ let nextToken;
444
+ const v2Apis = [];
445
+ do {
446
+ const res = await v2Client.send(new GetApisCommand({ NextToken: nextToken, MaxResults: '500' }));
447
+ for (const api of res.Items ?? []) {
448
+ if (api.ApiId && api.Name) {
449
+ v2Apis.push({ id: api.ApiId, name: api.Name, protocolType: api.ProtocolType ?? 'HTTP' });
450
+ }
451
+ }
452
+ nextToken = res.NextToken;
453
+ } while (nextToken);
454
+ for (const api of v2Apis) {
455
+ const apiType = api.protocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP';
456
+ const routes = [];
457
+ try {
458
+ const [routesRes, integrationsRes] = await Promise.all([
459
+ v2Client.send(new GetRoutesCommand({ ApiId: api.id, MaxResults: '500' })),
460
+ v2Client.send(new GetIntegrationsCommand({ ApiId: api.id, MaxResults: '500' })),
461
+ ]);
462
+ const integrationMap = new Map();
463
+ for (const integ of integrationsRes.Items ?? []) {
464
+ if (integ.IntegrationId && integ.IntegrationUri) {
465
+ integrationMap.set(integ.IntegrationId, integ.IntegrationUri);
466
+ }
467
+ }
468
+ for (const route of routesRes.Items ?? []) {
469
+ const routeKey = route.RouteKey ?? '';
470
+ const [method, ...pathParts] = routeKey.split(' ');
471
+ const routePath = pathParts.join(' ') || '/';
472
+ const integrationId = route.Target?.replace('integrations/', '');
473
+ const lambdaArn = integrationId ? integrationMap.get(integrationId) : undefined;
474
+ const lambdaName = lambdaArn
475
+ ? lambdaNameFromArn(lambdaArn.split('/functions/')[1]?.split('/')[0])
476
+ : undefined;
477
+ routes.push({ method: method ?? routeKey, path: routePath, lambdaArn, lambdaName });
478
+ }
479
+ }
480
+ catch (err) {
481
+ logger.warn(`API Gateway v2 routes failed for ${api.name}: ${err instanceof Error ? err.message : String(err)}`);
482
+ }
483
+ results.push({ name: api.name, id: api.id, type: apiType, routes });
484
+ }
485
+ }
486
+ catch (err) {
487
+ logger.debug(`API Gateway v2 list failed: ${err instanceof Error ? err.message : String(err)}`);
488
+ }
489
+ return results;
490
+ }
@@ -135,6 +135,7 @@ export async function extractTerraformSchema(repoPath) {
135
135
  schema.lambdas.push({
136
136
  name: tfStr(body, 'function_name') ?? resourceName,
137
137
  runtime: tfStr(body, 'runtime'),
138
+ handler: tfStr(body, 'handler'),
138
139
  source: 'terraform',
139
140
  filePath,
140
141
  });
@@ -299,6 +300,7 @@ function processCFNResources(resources, schema, filePath, source) {
299
300
  schema.lambdas.push({
300
301
  name: cfnStr(props, 'FunctionName') ?? logicalId,
301
302
  runtime: cfnStr(props, 'Runtime'),
303
+ handler: cfnStr(props, 'Handler'),
302
304
  source,
303
305
  filePath,
304
306
  });
@@ -64,6 +64,42 @@ export class LargeQueueBacklogAnalyzer {
64
64
  return findings;
65
65
  }
66
66
  }
67
+ export class VisibilityTimeoutMismatchAnalyzer {
68
+ name = 'VisibilityTimeoutMismatchAnalyzer';
69
+ async analyze(graph) {
70
+ const findings = [];
71
+ const lambdaTimeouts = new Map();
72
+ for (const node of graph.nodes) {
73
+ if (node.type === 'lambda' && node.timeoutSec) {
74
+ lambdaTimeouts.set(node.name, node.timeoutSec);
75
+ }
76
+ }
77
+ for (const node of graph.nodes) {
78
+ if (node.type !== 'queue' || node.visibilityTimeoutSec === undefined)
79
+ continue;
80
+ const triggeringEdge = graph.edges.find((e) => e.type === 'triggers' && e.from === `queue:aws:${node.name}`);
81
+ if (!triggeringEdge)
82
+ continue;
83
+ const lambdaName = triggeringEdge.to.replace('lambda:aws:', '');
84
+ const lambdaTimeout = lambdaTimeouts.get(lambdaName);
85
+ if (lambdaTimeout && node.visibilityTimeoutSec < lambdaTimeout) {
86
+ findings.push({
87
+ severity: 'high',
88
+ issue: `Queue "${node.name}" visibility timeout (${node.visibilityTimeoutSec}s) is less than Lambda "${lambdaName}" timeout (${lambdaTimeout}s)`,
89
+ description: `If the Lambda takes longer than the visibility timeout, SQS will re-deliver the message to another consumer while the original invocation is still running, causing duplicate processing.`,
90
+ recommendation: `Set the visibility timeout for "${node.name}" to at least ${lambdaTimeout * 6}s (6× the Lambda timeout of ${lambdaTimeout}s), per AWS best practice.`,
91
+ metadata: {
92
+ queueName: node.name,
93
+ visibilityTimeoutSec: node.visibilityTimeoutSec,
94
+ lambdaName,
95
+ lambdaTimeoutSec: lambdaTimeout,
96
+ },
97
+ });
98
+ }
99
+ }
100
+ return findings;
101
+ }
102
+ }
67
103
  // ─── Secrets Manager ─────────────────────────────────────────────────────────
68
104
  export class MissingSecretRotationAnalyzer {
69
105
  name = 'MissingSecretRotationAnalyzer';
@@ -90,8 +90,10 @@ export class MissingGSIAnalyzer {
90
90
  export class HotPartitionAnalyzer {
91
91
  name = 'HotPartitionAnalyzer';
92
92
  hotThreshold;
93
- constructor(hotThreshold = 5) {
93
+ tableThresholds;
94
+ constructor(hotThreshold = 5, tableThresholds = {}) {
94
95
  this.hotThreshold = hotThreshold;
96
+ this.tableThresholds = tableThresholds;
95
97
  }
96
98
  async analyze(graph) {
97
99
  const findings = [];
@@ -110,10 +112,11 @@ export class HotPartitionAnalyzer {
110
112
  accessors.add(edge.from);
111
113
  }
112
114
  for (const [tableId, accessors] of tableAccessCount) {
113
- if (accessors.size >= this.hotThreshold) {
114
- const tableNode = graph.nodes.find((n) => n.id === tableId);
115
- if (!tableNode)
116
- continue;
115
+ const tableNode = graph.nodes.find((n) => n.id === tableId);
116
+ if (!tableNode)
117
+ continue;
118
+ const threshold = this.tableThresholds[tableNode.name] ?? this.hotThreshold;
119
+ if (accessors.size >= threshold) {
117
120
  // Also check edge frequency for repeated access patterns
118
121
  let maxFreq = 0;
119
122
  for (const [key, freq] of edgeFrequency) {
@@ -128,6 +131,7 @@ export class HotPartitionAnalyzer {
128
131
  metadata: {
129
132
  tableName: tableNode.name,
130
133
  accessorCount: accessors.size,
134
+ threshold,
131
135
  maxEdgeFrequency: maxFreq,
132
136
  },
133
137
  });
@@ -4,7 +4,8 @@ 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, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
7
+ export { PipelineAnalyzer } from './pipeline.js';
8
+ export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
8
9
  export { RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from './rds.js';
9
10
  export async function runAllAnalyzers(graph, analyzers) {
10
11
  const allFindings = [];
@@ -0,0 +1,106 @@
1
+ const STAGE_TOKENS = new Set([
2
+ 'prod',
3
+ 'production',
4
+ 'dev',
5
+ 'development',
6
+ 'staging',
7
+ 'stage',
8
+ 'test',
9
+ 'qa',
10
+ ]);
11
+ const NOISE_TOKENS = new Set(['handler', 'fn', 'func', 'function', 'lambda']);
12
+ export function normalizeName(raw) {
13
+ let s = raw.toLowerCase().trim();
14
+ s = s.split('/').pop() ?? s;
15
+ s = s.replace(/\.(ts|js|mjs|cjs)$/, '');
16
+ let segs = s.split(/[-_.\s]+/).filter(Boolean);
17
+ if (segs.length > 1 && STAGE_TOKENS.has(segs[segs.length - 1] ?? ''))
18
+ segs.pop();
19
+ segs = segs.filter((seg) => !NOISE_TOKENS.has(seg));
20
+ return segs.join('');
21
+ }
22
+ function lambdaNodes(graph) {
23
+ return graph.nodes.filter((n) => n.type === 'lambda');
24
+ }
25
+ function functionNodes(graph) {
26
+ return graph.nodes.filter((n) => n.type === 'function');
27
+ }
28
+ export class HeuristicLinker {
29
+ link(graph) {
30
+ const fns = functionNodes(graph);
31
+ const links = [];
32
+ for (const lam of lambdaNodes(graph)) {
33
+ const target = normalizeName(lam.name);
34
+ if (!target)
35
+ continue;
36
+ const matches = fns.filter((f) => {
37
+ const byName = normalizeName(f.name);
38
+ const byFile = normalizeName(f.file);
39
+ return (byName !== '' && byName === target) || (byFile !== '' && byFile === target);
40
+ });
41
+ if (matches.length !== 1)
42
+ continue;
43
+ const [only] = matches;
44
+ if (!only)
45
+ continue;
46
+ links.push({ lambdaId: lam.id, functionId: only.id, confidence: 'inferred' });
47
+ }
48
+ return links;
49
+ }
50
+ }
51
+ function parseHandler(handler) {
52
+ const lastDot = handler.lastIndexOf('.');
53
+ if (lastDot < 0)
54
+ return { fileBase: '', exportName: '' };
55
+ const exportName = handler.slice(lastDot + 1);
56
+ const filePart = handler.slice(0, lastDot);
57
+ const fileBase = filePart.split('/').pop() ?? filePart;
58
+ return { fileBase, exportName };
59
+ }
60
+ function fileBaseNoExt(file) {
61
+ const base = file.split('/').pop() ?? file;
62
+ return base.replace(/\.(ts|js|mjs|cjs)$/, '');
63
+ }
64
+ export class IaCHandlerLinker {
65
+ iacLambdas;
66
+ constructor(iacLambdas) {
67
+ this.iacLambdas = iacLambdas;
68
+ }
69
+ link(graph) {
70
+ const lambdaIds = new Set(lambdaNodes(graph).map((n) => n.id));
71
+ const fns = functionNodes(graph);
72
+ const links = [];
73
+ for (const il of this.iacLambdas) {
74
+ if (!il.handler)
75
+ continue;
76
+ const lambdaId = `lambda:aws:${il.name}`;
77
+ if (!lambdaIds.has(lambdaId))
78
+ continue;
79
+ const { fileBase, exportName } = parseHandler(il.handler);
80
+ if (!exportName || !fileBase)
81
+ continue;
82
+ const matches = fns.filter((f) => f.name === exportName && fileBaseNoExt(f.file) === fileBase);
83
+ if (matches.length !== 1)
84
+ continue;
85
+ const [only] = matches;
86
+ if (!only)
87
+ continue;
88
+ links.push({ lambdaId, functionId: only.id, confidence: 'proven' });
89
+ }
90
+ return links;
91
+ }
92
+ }
93
+ export class CompositeLinker {
94
+ proven;
95
+ heuristic;
96
+ constructor(proven, heuristic) {
97
+ this.proven = proven;
98
+ this.heuristic = heuristic;
99
+ }
100
+ link(graph) {
101
+ const provenLinks = this.proven.link(graph);
102
+ const covered = new Set(provenLinks.map((l) => l.lambdaId));
103
+ const heur = this.heuristic.link(graph).filter((l) => !covered.has(l.lambdaId));
104
+ return [...provenLinks, ...heur];
105
+ }
106
+ }
@@ -0,0 +1,166 @@
1
+ import { HeuristicLinker, IaCHandlerLinker, CompositeLinker, } from './linkers.js';
2
+ const TRANSPORT_EDGES = new Set(['publishes_to', 'triggers']);
3
+ export class PipelineAnalyzer {
4
+ name = 'PipelineAnalyzer';
5
+ iacLambdas = [];
6
+ setIaCLambdas(lambdas) {
7
+ this.iacLambdas = lambdas;
8
+ }
9
+ async analyze(graph) {
10
+ const links = new CompositeLinker(new IaCHandlerLinker(this.iacLambdas), new HeuristicLinker()).link(graph);
11
+ const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
12
+ return [
13
+ ...detectMissingDlqHop(graph),
14
+ ...detectScanInPipeline(graph, links, nodeById),
15
+ ...detectRepeatedTableAccess(graph, links, nodeById),
16
+ ];
17
+ }
18
+ }
19
+ class UnionFind {
20
+ parent = new Map();
21
+ find(x) {
22
+ const p = this.parent.get(x);
23
+ if (p === undefined) {
24
+ this.parent.set(x, x);
25
+ return x;
26
+ }
27
+ if (p === x)
28
+ return x;
29
+ const root = this.find(p);
30
+ this.parent.set(x, root);
31
+ return root;
32
+ }
33
+ union(a, b) {
34
+ const ra = this.find(a);
35
+ const rb = this.find(b);
36
+ if (ra !== rb)
37
+ this.parent.set(ra, rb);
38
+ }
39
+ }
40
+ function buildComponents(graph, links) {
41
+ const uf = new UnionFind();
42
+ for (const e of graph.edges) {
43
+ if (TRANSPORT_EDGES.has(e.type))
44
+ uf.union(e.from, e.to);
45
+ }
46
+ for (const l of links)
47
+ uf.union(l.lambdaId, l.functionId);
48
+ const inferredRoots = new Set();
49
+ for (const l of links) {
50
+ if (l.confidence === 'inferred')
51
+ inferredRoots.add(uf.find(l.lambdaId));
52
+ }
53
+ return { uf, inferredRoots };
54
+ }
55
+ function detectMissingDlqHop(graph) {
56
+ const hasProducer = new Set();
57
+ const hasConsumer = new Set();
58
+ for (const e of graph.edges) {
59
+ if (e.type === 'publishes_to')
60
+ hasProducer.add(e.to);
61
+ if (e.type === 'triggers')
62
+ hasConsumer.add(e.from);
63
+ }
64
+ const findings = [];
65
+ for (const node of graph.nodes) {
66
+ if (node.type !== 'queue' || node.hasDLQ)
67
+ continue;
68
+ if (!hasProducer.has(node.id) || !hasConsumer.has(node.id))
69
+ continue;
70
+ findings.push({
71
+ severity: 'medium',
72
+ issue: `Queue "${node.name}" sits mid-pipeline without a Dead Letter Queue`,
73
+ description: `Queue "${node.name}" has both a producer and a downstream consumer but no DLQ. A failure in the consumer drops the message silently, breaking the pipeline with no recovery path.`,
74
+ recommendation: `Add a Dead Letter Queue to "${node.name}" with maxReceiveCount 3-5, and alert on DLQ depth so mid-pipeline failures are visible.`,
75
+ metadata: { queueName: node.name, pipelineHop: true },
76
+ });
77
+ }
78
+ return findings;
79
+ }
80
+ function detectScanInPipeline(graph, links, nodeById) {
81
+ const triggeredLambdas = new Set();
82
+ for (const e of graph.edges) {
83
+ if (e.type === 'triggers')
84
+ triggeredLambdas.add(e.to);
85
+ }
86
+ const triggeredFunctionLinks = new Map();
87
+ for (const l of links) {
88
+ if (triggeredLambdas.has(l.lambdaId))
89
+ triggeredFunctionLinks.set(l.functionId, l);
90
+ }
91
+ const findings = [];
92
+ const seen = new Set();
93
+ for (const e of graph.edges) {
94
+ if (e.type !== 'scan')
95
+ continue;
96
+ const link = triggeredFunctionLinks.get(e.from);
97
+ if (!link)
98
+ continue;
99
+ const fn = nodeById.get(e.from);
100
+ const table = nodeById.get(e.to);
101
+ const lambda = nodeById.get(link.lambdaId);
102
+ if (!fn || fn.type !== 'function' || !table || table.type !== 'table')
103
+ continue;
104
+ const dedupe = `${e.from}\0${e.to}`;
105
+ if (seen.has(dedupe))
106
+ continue;
107
+ seen.add(dedupe);
108
+ const inferred = link.confidence === 'inferred';
109
+ const lambdaName = lambda && lambda.type === 'lambda' ? lambda.name : link.lambdaId;
110
+ findings.push({
111
+ severity: inferred ? 'verify' : 'high',
112
+ issue: `Full scan runs inside event-triggered Lambda "${lambdaName}"`,
113
+ description: `Function "${fn.name}" (${fn.file}) performs a full scan on table "${table.name}" and runs as the handler for event-triggered Lambda "${lambdaName}". A scan inside an event-driven consumer executes on every invocation, multiplying read cost with traffic.` +
114
+ (inferred
115
+ ? ` The Lambda-to-code link is inferred by name match (not proven from IaC), so verify it before acting.`
116
+ : ` The Lambda-to-code link is proven from IaC handler configuration.`),
117
+ recommendation: `Replace the scan with a Query using a partition key, or add a GSI for the access pattern. Scans in per-event handlers are the highest-leverage place to fix.`,
118
+ metadata: { function: fn.name, table: table.name, lambda: lambdaName, inferred },
119
+ });
120
+ }
121
+ return findings;
122
+ }
123
+ function detectRepeatedTableAccess(graph, links, nodeById) {
124
+ const { uf, inferredRoots } = buildComponents(graph, links);
125
+ const access = new Map();
126
+ for (const e of graph.edges) {
127
+ if (e.type !== 'query' && e.type !== 'scan')
128
+ continue;
129
+ const fromNode = nodeById.get(e.from);
130
+ const toNode = nodeById.get(e.to);
131
+ if (!fromNode || fromNode.type !== 'function')
132
+ continue;
133
+ if (!toNode || toNode.type !== 'table')
134
+ continue;
135
+ const key = `${uf.find(e.from)}\0${e.to}`;
136
+ const set = access.get(key) ?? new Set();
137
+ set.add(e.from);
138
+ access.set(key, set);
139
+ }
140
+ const findings = [];
141
+ for (const [key, fns] of access) {
142
+ if (fns.size < 2)
143
+ continue;
144
+ const [root, tableId] = key.split('\0');
145
+ const table = nodeById.get(tableId);
146
+ if (!table || table.type !== 'table')
147
+ continue;
148
+ const fnNames = [...fns]
149
+ .map((id) => nodeById.get(id))
150
+ .filter((n) => !!n && n.type === 'function')
151
+ .map((n) => `${n.name} (${n.file})`)
152
+ .sort();
153
+ const inferred = inferredRoots.has(root);
154
+ findings.push({
155
+ severity: inferred ? 'verify' : 'medium',
156
+ issue: `Table "${table.name}" is accessed at multiple stages of one pipeline`,
157
+ description: `Table "${table.name}" is read by ${fns.size} functions linked in the same service pipeline: ${fnNames.join(', ')}. Re-reading the same table across pipeline stages often signals redundant work that could be passed forward in the message payload.` +
158
+ (inferred
159
+ ? ` One or more Lambda-to-code links on this pipeline are inferred by name match (not proven from IaC), so verify the chain before acting.`
160
+ : ''),
161
+ recommendation: `Confirm each access is necessary. Consider carrying the needed fields in the event payload, caching the first read, or consolidating the reads. If the duplication is intentional, no action needed.`,
162
+ metadata: { tableName: table.name, functions: fnNames, inferred },
163
+ });
164
+ }
165
+ return findings;
166
+ }
@@ -8,12 +8,12 @@ import { extractPostgresMetadata } from '../../adapters/db/postgres.js';
8
8
  import { extractMySQLMetadata } from '../../adapters/db/mysql.js';
9
9
  import { extractMongoMetadata } from '../../adapters/db/mongodb.js';
10
10
  import { extractIaCSchema } from '../../adapters/iac/terraform.js';
11
- import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, } from '../../adapters/aws/services.js';
11
+ import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, extractAPIGatewayMetadata, } from '../../adapters/aws/services.js';
12
12
  import { extractLogsMetadata } from '../../adapters/aws/logs.js';
13
13
  import { extractS3Metadata } from '../../adapters/aws/s3.js';
14
14
  import { scanRepository } from '../../context/index.js';
15
15
  import { buildGraph } from '../../graph/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
+ import { runAllAnalyzers, IaCDriftAnalyzer, PipelineAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from '../../analyzers/index.js';
17
17
  import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
18
18
  const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
19
19
  function buildMarkdownReport(findings, projectName) {
@@ -230,6 +230,20 @@ export async function runAnalyze(options = {}) {
230
230
  chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
231
231
  }
232
232
  }
233
+ // ── API Gateway ───────────────────────────────────────────────────────────────
234
+ if (config.apiGateway?.enabled === true) {
235
+ const s = mkSpinner('Extracting API Gateway routes...');
236
+ try {
237
+ const result = await extractAPIGatewayMetadata(awsCfg);
238
+ servicesMeta.apiGateway = result;
239
+ const routeCount = result.reduce((sum, api) => sum + api.routes.length, 0);
240
+ s.succeed(chalk.green('API Gateway') + chalk.dim(` ${result.length} API(s), ${routeCount} route(s)`));
241
+ }
242
+ catch (err) {
243
+ s.warn(chalk.yellow('API Gateway skipped') +
244
+ chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
245
+ }
246
+ }
233
247
  // ── CloudWatch Logs ──────────────────────────────────────────────────────────
234
248
  if (config.cloudwatchLogs?.enabled) {
235
249
  const s = mkSpinner('Sampling CloudWatch Logs (errors only, max 50 groups)...');
@@ -251,6 +265,7 @@ export async function runAnalyze(options = {}) {
251
265
  }
252
266
  // ── IaC schema (Terraform / CloudFormation / CDK) ────────────────────────────
253
267
  let iacDriftAnalyzer;
268
+ let iacLambdas = [];
254
269
  {
255
270
  const s = mkSpinner('Extracting IaC schema (Terraform / CloudFormation / CDK)...');
256
271
  try {
@@ -267,6 +282,7 @@ export async function runAnalyze(options = {}) {
267
282
  iacSchema.apiGateways.length;
268
283
  iacDriftAnalyzer = new IaCDriftAnalyzer();
269
284
  iacDriftAnalyzer.setIaCSchema(iacSchema);
285
+ iacLambdas = iacSchema.lambdas;
270
286
  s.succeed(chalk.green('IaC schema') + chalk.dim(` ${total} resource(s) across TF/CFN/CDK`));
271
287
  }
272
288
  catch (err) {
@@ -301,9 +317,17 @@ export async function runAnalyze(options = {}) {
301
317
  let findings;
302
318
  {
303
319
  const s = mkSpinner('Running analyzers...');
320
+ const hotPartitionThreshold = config.analysis?.hotPartitionThreshold;
321
+ const hotPartitionThresholds = config.analysis?.hotPartitionThresholds;
322
+ const pipelineAnalyzer = new PipelineAnalyzer();
323
+ pipelineAnalyzer.setIaCLambdas(iacLambdas);
304
324
  const analyzers = [
305
325
  ...(config.dynamodb?.enabled === true
306
- ? [new FullTableScanAnalyzer(), new MissingGSIAnalyzer(), new HotPartitionAnalyzer()]
326
+ ? [
327
+ new FullTableScanAnalyzer(),
328
+ new MissingGSIAnalyzer(),
329
+ new HotPartitionAnalyzer(hotPartitionThreshold, hotPartitionThresholds),
330
+ ]
307
331
  : []),
308
332
  ...(config.postgres?.enabled
309
333
  ? [new MissingIndexAnalyzer(), new NplusOneAnalyzer(), new LargeSelectAnalyzer()]
@@ -319,6 +343,7 @@ export async function runAnalyze(options = {}) {
319
343
  new MissingDLQAnalyzer(),
320
344
  new UnencryptedQueueAnalyzer(),
321
345
  new LargeQueueBacklogAnalyzer(),
346
+ new VisibilityTimeoutMismatchAnalyzer(),
322
347
  ]
323
348
  : []),
324
349
  ...(config.secretsManager?.enabled === true ? [new MissingSecretRotationAnalyzer()] : []),
@@ -347,6 +372,7 @@ export async function runAnalyze(options = {}) {
347
372
  ]
348
373
  : []),
349
374
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
375
+ pipelineAnalyzer,
350
376
  ];
351
377
  findings = await runAllAnalyzers(graph, analyzers);
352
378
  s.succeed(chalk.green('Analysis complete') + chalk.dim(` ${findings.length} finding(s)`));
@@ -406,10 +432,12 @@ export async function runCodeRefresh(repoPath, config) {
406
432
  const servicesMeta = cached?.servicesMeta ?? {};
407
433
  // Re-run IaC schema (pure file scan, no AWS calls)
408
434
  let iacDriftAnalyzer;
435
+ let iacLambdas = [];
409
436
  try {
410
437
  const iacSchema = await extractIaCSchema(repoPath);
411
438
  iacDriftAnalyzer = new IaCDriftAnalyzer();
412
439
  iacDriftAnalyzer.setIaCSchema(iacSchema);
440
+ iacLambdas = iacSchema.lambdas;
413
441
  }
414
442
  catch {
415
443
  // IaC scan is best-effort
@@ -423,9 +451,17 @@ export async function runCodeRefresh(repoPath, config) {
423
451
  operations = [];
424
452
  }
425
453
  const graph = buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta, mongoMeta, servicesMeta);
454
+ const hotPartitionThreshold = config.analysis?.hotPartitionThreshold;
455
+ const hotPartitionThresholds = config.analysis?.hotPartitionThresholds;
456
+ const pipelineAnalyzer = new PipelineAnalyzer();
457
+ pipelineAnalyzer.setIaCLambdas(iacLambdas);
426
458
  const analyzers = [
427
459
  ...(config.dynamodb?.enabled === true
428
- ? [new FullTableScanAnalyzer(), new MissingGSIAnalyzer(), new HotPartitionAnalyzer()]
460
+ ? [
461
+ new FullTableScanAnalyzer(),
462
+ new MissingGSIAnalyzer(),
463
+ new HotPartitionAnalyzer(hotPartitionThreshold, hotPartitionThresholds),
464
+ ]
429
465
  : []),
430
466
  ...(config.postgres?.enabled
431
467
  ? [new MissingIndexAnalyzer(), new NplusOneAnalyzer(), new LargeSelectAnalyzer()]
@@ -437,7 +473,12 @@ export async function runCodeRefresh(repoPath, config) {
437
473
  ? [new MissingMongoIndexAnalyzer(), new MongoCollectionScanAnalyzer()]
438
474
  : []),
439
475
  ...(config.sqs?.enabled === true
440
- ? [new MissingDLQAnalyzer(), new UnencryptedQueueAnalyzer(), new LargeQueueBacklogAnalyzer()]
476
+ ? [
477
+ new MissingDLQAnalyzer(),
478
+ new UnencryptedQueueAnalyzer(),
479
+ new LargeQueueBacklogAnalyzer(),
480
+ new VisibilityTimeoutMismatchAnalyzer(),
481
+ ]
441
482
  : []),
442
483
  ...(config.secretsManager?.enabled === true ? [new MissingSecretRotationAnalyzer()] : []),
443
484
  ...(config.cloudwatchLogs?.enabled ? [new MissingLogRetentionAnalyzer()] : []),
@@ -465,6 +506,7 @@ export async function runCodeRefresh(repoPath, config) {
465
506
  ]
466
507
  : []),
467
508
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
509
+ pipelineAnalyzer,
468
510
  ];
469
511
  const findings = await runAllAnalyzers(graph, analyzers);
470
512
  writeCache('graph', graph);
@@ -34,29 +34,6 @@ function writeCursorMcp(configAbsPath) {
34
34
  fs.writeFileSync(path.join(dir, 'mcp.json'), JSON.stringify(entry, null, 2), 'utf-8');
35
35
  log.success('Cursor config written', '.cursor/mcp.json');
36
36
  }
37
- function writeWindsurfMcp(configAbsPath) {
38
- const windsurfDir = path.join(process.env.HOME ?? '', '.codeium', 'windsurf');
39
- if (!fs.existsSync(windsurfDir))
40
- fs.mkdirSync(windsurfDir, { recursive: true });
41
- const mcpConfigPath = path.join(windsurfDir, 'mcp_config.json');
42
- let existing = {};
43
- if (fs.existsSync(mcpConfigPath)) {
44
- try {
45
- existing = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
46
- }
47
- catch {
48
- // corrupt file — overwrite cleanly
49
- }
50
- }
51
- const servers = existing.mcpServers ?? {};
52
- servers['infrawise'] = {
53
- command: 'infrawise',
54
- args: ['stdio', '--config', configAbsPath],
55
- };
56
- existing.mcpServers = servers;
57
- fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 2), 'utf-8');
58
- log.success('Windsurf config written', mcpConfigPath);
59
- }
60
37
  function launchEditor(editor) {
61
38
  return new Promise((resolve) => {
62
39
  if (editor === 'claude') {
@@ -77,7 +54,7 @@ function launchEditor(editor) {
77
54
  const cmd = editor;
78
55
  const args = ['.'];
79
56
  console.log('');
80
- console.log(chalk.dim(` Opening ${editor === 'cursor' ? 'Cursor' : 'Windsurf'}...`));
57
+ console.log(chalk.dim(' Opening Cursor...'));
81
58
  const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
82
59
  child.on('error', (err) => {
83
60
  if (err.code === 'ENOENT') {
@@ -128,23 +105,14 @@ export async function runStart(options = {}) {
128
105
  writeMcpJson(configAbsPath);
129
106
  if (options.cursor)
130
107
  writeCursorMcp(configAbsPath);
131
- if (options.windsurf)
132
- writeWindsurfMcp(configAbsPath);
133
108
  // Step 4 — launch editor or print instructions
134
- const editor = options.claude
135
- ? 'claude'
136
- : options.cursor
137
- ? 'cursor'
138
- : options.windsurf
139
- ? 'windsurf'
140
- : null;
109
+ const editor = options.claude ? 'claude' : options.cursor ? 'cursor' : null;
141
110
  if (!editor) {
142
111
  console.log('');
143
112
  console.log(chalk.bold(' Setup complete — open your editor to start.'));
144
113
  console.log('');
145
114
  console.log(chalk.dim(' Claude Code: claude'));
146
115
  console.log(chalk.dim(' Cursor: cursor .'));
147
- console.log(chalk.dim(' Windsurf: windsurf .'));
148
116
  console.log('');
149
117
  console.log(chalk.dim(' Next time just open your editor — no infrawise command needed.'));
150
118
  console.log('');
package/dist/cli/index.js CHANGED
@@ -22,14 +22,12 @@ program
22
22
  .option('-c, --config <path>', 'Path to infrawise.yaml', 'infrawise.yaml')
23
23
  .option('--claude', 'Write .mcp.json and open Claude Code')
24
24
  .option('--cursor', 'Write .cursor/mcp.json and open Cursor')
25
- .option('--windsurf', 'Write Windsurf MCP config and open Windsurf')
26
25
  .action(async (options) => {
27
26
  printBanner();
28
27
  await runStart({
29
28
  config: options.config !== 'infrawise.yaml' ? options.config : undefined,
30
29
  claude: options.claude,
31
30
  cursor: options.cursor,
32
- windsurf: options.windsurf,
33
31
  });
34
32
  });
35
33
  program
@@ -57,6 +57,7 @@ export const InfrawiseConfigSchema = z.object({
57
57
  rds: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
58
58
  s3: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
59
59
  kafka: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
60
+ apiGateway: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
60
61
  cloudwatchLogs: z
61
62
  .object({
62
63
  enabled: z.boolean().optional().default(false),
@@ -67,6 +68,11 @@ export const InfrawiseConfigSchema = z.object({
67
68
  analysis: z
68
69
  .object({
69
70
  sampleSize: z.number().int().positive().optional().default(100),
71
+ hotPartitionThreshold: z.number().int().positive().optional().default(5),
72
+ hotPartitionThresholds: z
73
+ .record(z.string(), z.number().int().positive())
74
+ .optional()
75
+ .default({}),
70
76
  })
71
77
  .optional(),
72
78
  });
@@ -73,10 +73,28 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
73
73
  provider: 'aws',
74
74
  hasDLQ: q.hasDLQ,
75
75
  encrypted: q.encrypted,
76
+ isFifo: q.isFifo,
77
+ visibilityTimeoutSec: q.visibilityTimeoutSec,
76
78
  approximateMessages: q.approximateMessages,
77
79
  retentionDays: q.retentionDays,
78
80
  });
79
81
  }
82
+ for (const api of servicesMeta.apiGateway ?? []) {
83
+ addNode({
84
+ id: `api:aws:${api.id}`,
85
+ type: 'api',
86
+ name: api.name,
87
+ provider: 'aws',
88
+ apiType: api.type,
89
+ routes: api.routes,
90
+ });
91
+ for (const route of api.routes) {
92
+ if (route.lambdaName) {
93
+ const lambdaId = `lambda:aws:${route.lambdaName}`;
94
+ edges.push({ from: `api:aws:${api.id}`, to: lambdaId, type: 'triggers' });
95
+ }
96
+ }
97
+ }
80
98
  for (const t of servicesMeta.sns ?? []) {
81
99
  addNode({
82
100
  id: `topic:aws:${t.name}`,
@@ -402,3 +420,6 @@ export function getEdgeFrequency(graph) {
402
420
  }
403
421
  return freq;
404
422
  }
423
+ export function getAPINodes(graph) {
424
+ return graph.nodes.filter((n) => n.type === 'api');
425
+ }
@@ -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, getBucketNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
11
+ import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getBucketNodes, getAPINodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
12
12
  // ── State ────────────────────────────────────────────────────────────────────
13
13
  let currentGraph = { nodes: [], edges: [] };
14
14
  let currentFindings = [];
@@ -244,7 +244,7 @@ export function createMcpServer() {
244
244
  });
245
245
  }));
246
246
  mcp.registerTool('get_queue_details', {
247
- 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.',
247
+ description: 'Returns all SQS queues with DLQ presence, encryption status, FIFO type (isFifo), visibility timeout, approximate message count, and retention days. When isFifo is true, all SendMessage calls must include a MessageGroupId. Call this when reviewing messaging architecture, investigating a message backlog, checking DLQ coverage, or verifying visibility timeout is set correctly relative to Lambda timeout (should be 6× the Lambda timeout). Use get_infra_overview for a quick queue count only.',
248
248
  inputSchema: z.object({}),
249
249
  }, logged('get_queue_details', async () => {
250
250
  const queues = getQueueNodes(currentGraph);
@@ -256,6 +256,8 @@ export function createMcpServer() {
256
256
  provider: q.provider,
257
257
  hasDLQ: q.hasDLQ,
258
258
  encrypted: q.encrypted,
259
+ isFifo: q.isFifo ?? false,
260
+ visibilityTimeoutSec: q.visibilityTimeoutSec,
259
261
  approximateMessages: q.approximateMessages,
260
262
  retentionDays: q.retentionDays,
261
263
  findings: queueFindings
@@ -385,6 +387,24 @@ export function createMcpServer() {
385
387
  })),
386
388
  });
387
389
  }));
390
+ mcp.registerTool('get_api_routes', {
391
+ description: 'Returns all API Gateway APIs (REST, HTTP, WebSocket) with their routes, HTTP methods, paths, and the Lambda function each route invokes. Call this before writing any API handler to understand which Lambda handles a route, or when reviewing API surface area and Lambda integration coverage.',
392
+ inputSchema: z.object({}),
393
+ }, logged('get_api_routes', async () => {
394
+ const apis = getAPINodes(currentGraph);
395
+ return toText({
396
+ total: apis.length,
397
+ apis: apis.map((api) => ({
398
+ name: api.name,
399
+ type: api.apiType,
400
+ routes: (api.routes ?? []).map((r) => ({
401
+ method: r.method,
402
+ path: r.path,
403
+ lambda: r.lambdaName ?? null,
404
+ })),
405
+ })),
406
+ });
407
+ }));
388
408
  mcp.registerTool('get_log_errors', {
389
409
  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.',
390
410
  inputSchema: z.object({
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.10.2",
3
+ "version": "0.11.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, S3, 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, API Gateway, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "mcp-server",
@@ -32,12 +32,14 @@
32
32
  "cli",
33
33
  "terraform",
34
34
  "cloudformation",
35
- "cdk"
35
+ "cdk",
36
+ "api-gateway",
37
+ "sqs-fifo"
36
38
  ],
37
39
  "agentskills": {
38
40
  "infrawise": "./AGENTS.md"
39
41
  },
40
- "homepage": "https://github.com/Sidd27/infrawise#readme",
42
+ "homepage": "https://sidd27.github.io/infrawise/",
41
43
  "repository": {
42
44
  "type": "git",
43
45
  "url": "git+https://github.com/Sidd27/infrawise.git"
@@ -52,7 +54,7 @@
52
54
  "node": ">=24.0.0",
53
55
  "pnpm": ">=9.0.0"
54
56
  },
55
- "packageManager": "pnpm@10.11.0",
57
+ "packageManager": "pnpm@11.6.0",
56
58
  "scripts": {
57
59
  "build": "tsc --noEmit false --outDir dist",
58
60
  "test": "vitest run --passWithNoTests",
@@ -60,7 +62,8 @@
60
62
  "lint": "eslint src",
61
63
  "typecheck": "tsc",
62
64
  "format": "prettier --write src",
63
- "generate-diagrams": "mmdc -i docs/architecture.mmd -o docs/architecture.svg --backgroundColor transparent",
65
+ "build-arch": "node scripts/build-arch.mjs",
66
+ "generate-diagrams": "node scripts/build-arch.mjs",
64
67
  "record": "bash docs/demo/record.sh",
65
68
  "dev": "node dist/cli/index.js dev",
66
69
  "release": "node scripts/release.js",
@@ -70,6 +73,8 @@
70
73
  "pre-commit": "pnpm format && git add src/ && pnpm lint && pnpm typecheck && pnpm test"
71
74
  },
72
75
  "dependencies": {
76
+ "@aws-sdk/client-api-gateway": "^3.1068.0",
77
+ "@aws-sdk/client-apigatewayv2": "^3.1068.0",
73
78
  "@aws-sdk/client-cloudwatch-logs": "^3.1048.0",
74
79
  "@aws-sdk/client-dynamodb": "^3.1048.0",
75
80
  "@aws-sdk/client-eventbridge": "^3.1051.0",
@@ -106,6 +111,7 @@
106
111
  "@typescript-eslint/eslint-plugin": "^8.59.3",
107
112
  "@typescript-eslint/parser": "^8.59.3",
108
113
  "@vitest/coverage-v8": "^4.1.6",
114
+ "@xmldom/xmldom": "^0.9.10",
109
115
  "eslint": "^10.4.0",
110
116
  "prettier": "^3.8.3",
111
117
  "simple-git-hooks": "^2.13.1",
@@ -119,14 +125,5 @@
119
125
  ],
120
126
  "publishConfig": {
121
127
  "access": "public"
122
- },
123
- "pnpm": {
124
- "overrides": {
125
- "qs": ">=6.15.2"
126
- },
127
- "allowedBuildScripts": [
128
- "puppeteer",
129
- "simple-git-hooks"
130
- ]
131
128
  }
132
129
  }