infrawise 0.10.3 → 0.12.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
@@ -69,10 +69,10 @@ infrawise start --claude
69
69
 
70
70
  That's it. Infrawise will:
71
71
 
72
- 1. Ask a few questions and generate `infrawise.yaml` (first time only)
72
+ 1. Probe your environment and generate `infrawise.yaml` (first time only — asks which AWS profile to use only if you have several)
73
73
  2. Scan your AWS services, databases, and codebase
74
74
  3. Write `.mcp.json` so your editor auto-connects on every future launch
75
- 4. Open Claude Code with all 15 MCP tools ready
75
+ 4. Open Claude Code with all 16 MCP tools ready
76
76
 
77
77
  **Every time after:**
78
78
 
@@ -80,7 +80,7 @@ That's it. Infrawise will:
80
80
  claude # no infrawise command needed — editor manages the connection
81
81
  ```
82
82
 
83
- Analysis is cached for 24 hours. When the cache is stale, `infrawise stdio` (spawned automatically by your editor) refreshes it at session start. File changes are detected within the session and the code graph is updated automatically.
83
+ Analysis is cached for 24 hours. When the cache is stale, `infrawise serve --stdio` (spawned automatically by your editor) refreshes it at session start. File changes are detected within the session and the code graph is updated automatically.
84
84
 
85
85
  ```
86
86
  Findings (3 total)
@@ -107,7 +107,7 @@ Findings (3 total)
107
107
  infrawise start --claude
108
108
  ```
109
109
 
110
- Writes `.mcp.json` to your project root and opens Claude Code. Claude Code reads `.mcp.json` automatically on every launch and manages the `infrawise stdio` process — no server to start, no ports to configure.
110
+ Writes `.mcp.json` to your project root and opens Claude Code. Claude Code reads `.mcp.json` automatically on every launch and manages the `infrawise serve --stdio` process — no server to start, no ports to configure.
111
111
 
112
112
  ### Cursor
113
113
 
@@ -115,7 +115,7 @@ Writes `.mcp.json` to your project root and opens Claude Code. Claude Code reads
115
115
  infrawise start --cursor
116
116
  ```
117
117
 
118
- Writes `.cursor/mcp.json` and opens Cursor. All 15 infrawise tools are available in Cursor's MCP panel.
118
+ Writes `.cursor/mcp.json` and opens Cursor. All 16 infrawise tools are available in Cursor's MCP panel.
119
119
 
120
120
  ### Any editor (no flag)
121
121
 
@@ -123,14 +123,14 @@ Writes `.cursor/mcp.json` and opens Cursor. All 15 infrawise tools are available
123
123
  infrawise start
124
124
  ```
125
125
 
126
- Writes `.mcp.json` and exits. Open whichever editor you prefer — point it at `infrawise stdio --config /path/to/infrawise.yaml` as an MCP server command.
126
+ Writes `.mcp.json` and exits. Open whichever editor you prefer — point it at `infrawise serve --stdio --config /path/to/infrawise.yaml` as an MCP server command.
127
127
 
128
128
  ### HTTP transport (alternative)
129
129
 
130
130
  If your editor or workflow requires an HTTP MCP endpoint instead of stdio:
131
131
 
132
132
  ```bash
133
- infrawise dev # starts server at http://localhost:3000/mcp
133
+ infrawise serve # starts server at http://localhost:3000/mcp
134
134
  ```
135
135
 
136
136
  Add to your editor's MCP config:
@@ -156,7 +156,8 @@ Add to your editor's MCP config:
156
156
  | `postgres_index_suggestions` | Exact `CREATE INDEX` SQL for your actual table |
157
157
  | `suggest_mongo_index` | Exact `createIndex` command for a MongoDB collection + field |
158
158
  | `mysql_index_suggestions` | Exact `ALTER TABLE ADD INDEX` SQL for your MySQL table |
159
- | `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 |
160
161
  | `get_topic_details` | SNS topics — subscription counts, protocols, and filter policies (required message attributes per subscription) |
161
162
  | `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
162
163
  | `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
@@ -169,17 +170,17 @@ Add to your editor's MCP config:
169
170
 
170
171
  ## CLI reference
171
172
 
172
- | Command | What it does |
173
- | ---------------------------- | --------------------------------------------------------------------------------- |
174
- | `infrawise start` | **Primary command** — init + analyze + write editor MCP config, then exit |
175
- | `infrawise start --claude` | Same as above, then opens Claude Code |
176
- | `infrawise start --cursor` | Same as above, then opens Cursor |
177
- | `infrawise init` | Generate `infrawise.yaml` only (no analysis, no editor config) |
178
- | `infrawise auth` | Select or switch AWS profile |
179
- | `infrawise analyze` | Force a full re-scan — useful after major infrastructure changes |
180
- | `infrawise dev` | HTTP transport alternative starts server at `localhost:3000/mcp` |
181
- | `infrawise stdio` | Stdio transportauto-managed by editors via `.mcp.json` (rarely run directly) |
182
- | `infrawise doctor` | Validate AWS access, DB connectivity, and config |
173
+ | Command | What it does |
174
+ | ----------------------------- | --------------------------------------------------------------------------------- |
175
+ | `infrawise start` | **Primary command** — probe env, generate config, analyze, write editor MCP config |
176
+ | `infrawise start --claude` | Same as above, then opens Claude Code |
177
+ | `infrawise start --cursor` | Same as above, then opens Cursor |
178
+ | `infrawise start --interactive` | Run the guided setup wizard instead of auto-discovery |
179
+ | `infrawise start --rediscover` | Delete `infrawise.yaml` + `.infrawise/`, then re-probe and re-analyze |
180
+ | `infrawise analyze` | Force a full re-scan — useful after major infrastructure changes |
181
+ | `infrawise check` | CI gateanalyze and exit non-zero when findings reach the threshold severity |
182
+ | `infrawise serve` | Start the MCP server HTTP by default, or `--stdio` for editor integration |
183
+ | `infrawise doctor` | Diagnostic escape hatch — validate AWS/DB access, config, and repo scan |
183
184
 
184
185
  ### `infrawise analyze` options
185
186
 
@@ -189,7 +190,7 @@ Add to your editor's MCP config:
189
190
  | `-r, --repo <path>` | Repository to scan (default: current directory) |
190
191
  | `--no-cache` | Skip reading/writing the cache |
191
192
  | `-o, --output <path>` | Save findings as a markdown report, e.g. `report.md` |
192
- | `--severity <level>` | Only show findings at or above this level: `high` \| `medium` \| `low` \| `verify` |
193
+ | `--severity <level>` | Only show findings at or above this level: `high` \| `medium` \| `low` |
193
194
 
194
195
  ```bash
195
196
  # Export a shareable findings report
@@ -202,11 +203,37 @@ infrawise analyze --severity high
202
203
  infrawise analyze --severity high --output report.md
203
204
  ```
204
205
 
206
+ ### `infrawise check` options (CI/CD)
207
+
208
+ `check` runs a fresh analysis and sets a non-zero exit code when blocking findings exist, so it can gate a pipeline without an AI editor.
209
+
210
+ | Flag | Description |
211
+ | --------------------- | ---------------------------------------------------------------------- |
212
+ | `-c, --config <path>` | Path to `infrawise.yaml` (default: `infrawise.yaml`) |
213
+ | `-r, --repo <path>` | Repository to scan (default: current directory) |
214
+ | `--fail-on <level>` | Severity that fails the build: `high` (default) \| `medium` \| `low` |
215
+
216
+ ```bash
217
+ # Block a deploy if any high-severity finding exists (exit 1)
218
+ infrawise check
219
+
220
+ # Stricter gate — fail on medium and above
221
+ infrawise check --fail-on medium
222
+ ```
223
+
224
+ ### `infrawise serve` options
225
+
226
+ | Flag | Description |
227
+ | --------------------- | ---------------------------------------------------------------------- |
228
+ | `-c, --config <path>` | Path to `infrawise.yaml` (default: `infrawise.yaml`) |
229
+ | `--stdio` | Use stdio transport (for editors via `.mcp.json`) instead of HTTP |
230
+ | `-p, --port <number>` | Port to listen on, HTTP only (default: `3000`) |
231
+
205
232
  ---
206
233
 
207
234
  ## Configuration
208
235
 
209
- `infrawise.yaml` is generated by `infrawise start` (or `infrawise init` to create the file only) and lives in your repo root. Every service must be explicitly `enabled: true` — infrawise never connects to anything not listed in config.
236
+ `infrawise.yaml` is generated by `infrawise start` (or `infrawise start --interactive` for the guided wizard) and lives in your repo root. Every service must be explicitly `enabled: true` — infrawise never connects to anything not listed in config.
210
237
 
211
238
  Connection strings support `${ENV_VAR}` substitution so passwords never need to be committed:
212
239
 
@@ -271,7 +298,7 @@ rds:
271
298
  s3:
272
299
  enabled: false
273
300
 
274
- kafka:
301
+ apiGateway:
275
302
  enabled: false
276
303
 
277
304
  cloudwatchLogs:
@@ -281,6 +308,9 @@ cloudwatchLogs:
281
308
 
282
309
  analysis:
283
310
  sampleSize: 100
311
+ hotPartitionThreshold: 5
312
+ hotPartitionThresholds:
313
+ high-traffic-table: 12
284
314
  ```
285
315
 
286
316
  ### AWS setup
@@ -334,13 +364,14 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
334
364
  | DynamoDB schema | Tables, GSIs, partition keys |
335
365
  | PostgreSQL / MySQL schema | Tables, indexes, column types |
336
366
  | MongoDB schema | Collections, indexes |
337
- | SQS | Missing DLQs, unencrypted queues, large backlogs |
367
+ | SQS | Missing DLQs, unencrypted queues, large backlogs, FIFO detection, visibility timeout vs Lambda timeout mismatch |
338
368
  | SNS | Subscription filter policies — required message attributes per subscription |
339
- | Kafka (kafkajs) | Producer/consumer topic mapping from code |
369
+ | Apache Kafka (kafkajs) | Producer/consumer topic mapping from code — any broker (self-hosted, Confluent, Redpanda, MSK); distinct from the MSK Lambda trigger |
340
370
  | Secrets Manager | Missing secret rotation |
341
- | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), missing DLQ on trigger source |
371
+ | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), missing DLQ on trigger source |
342
372
  | S3 | Public access blocking (verify), missing versioning, missing encryption |
343
373
  | EventBridge | Rules, schedules, event patterns, target Lambda functions |
374
+ | API Gateway | REST, HTTP, and WebSocket APIs — routes, methods, Lambda integrations |
344
375
  | RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
345
376
  | CloudWatch Logs | Log groups with no retention policy |
346
377
  | Terraform / CloudFormation / CDK | IaC drift vs deployed state |
@@ -355,12 +386,15 @@ Uses [ts-morph](https://ts-morph.com/) AST analysis to detect which functions ca
355
386
  | Missing GSI | Medium | Queries on attributes without a matching GSI |
356
387
  | Hot Partition | Medium | 5+ distinct code paths hitting the same table |
357
388
  | Missing Index (PostgreSQL) | Medium | Tables queried without indexes |
358
- | N+1 Query | Medium | Repeated query patterns from ORM loops |
389
+ | N+1 Query | High | Repeated query patterns from ORM loops |
359
390
  | Large SELECT | Low | `SELECT *` usage |
360
391
  | Missing MySQL Index | Medium | MySQL tables queried without indexes |
361
392
  | MySQL Full Table Scan | High | Full table scan patterns in MySQL queries |
362
393
  | Missing Mongo Index | Medium | Collections queried without secondary indexes |
363
394
  | Collection Scan | High | `find()` calls without filter predicates |
395
+ | 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) |
396
+ | Pipeline: repeated table access | Medium / Verify | Same table read by 2+ functions in one service pipeline |
397
+ | Pipeline: missing DLQ hop | Medium | Mid-pipeline queue (has producer and consumer) with no Dead Letter Queue |
364
398
 
365
399
  Non-TypeScript/JavaScript projects still get full value from infrastructure-level analyzers — code correlation (function-to-table mapping, N+1 patterns) is skipped.
366
400
 
@@ -409,10 +443,10 @@ src/
409
443
  core/ Config (Zod + YAML), logger (Pino), local cache
410
444
  graph/ Graph engine — nodes, edges, builder
411
445
  adapters/
412
- aws/ DynamoDB, S3, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS, CloudWatch
446
+ aws/ DynamoDB, S3, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS/APIGateway, CloudWatch
413
447
  db/ PostgreSQL, MySQL, MongoDB
414
448
  iac/ Terraform, CDK, CloudFormation (local file parsing)
415
- analyzers/ 27 rule-based analyzers
449
+ analyzers/ 29 rule-based analyzers
416
450
  context/ Repository scanner (ts-morph AST)
417
451
  server/ Fastify MCP server (@modelcontextprotocol/sdk, Streamable HTTP)
418
452
  cli/ CLI commands (Commander.js)
@@ -4,10 +4,7 @@ import { DynamoDBError, logger } from '../../core/index.js';
4
4
  function createDynamoClient(config) {
5
5
  const region = config.aws?.region ?? 'us-east-1';
6
6
  const profile = config.aws?.profile;
7
- const endpoint = config.aws?.endpoint;
8
7
  const clientConfig = { region };
9
- if (endpoint)
10
- clientConfig.endpoint = endpoint;
11
8
  if (profile)
12
9
  clientConfig.credentials = fromIni({ profile });
13
10
  return new DynamoDBClient(clientConfig);
@@ -7,8 +7,6 @@ const MAX_EVENTS_PER_GROUP = 50;
7
7
  function clientConfig(cfg) {
8
8
  const region = cfg.region ?? 'us-east-1';
9
9
  const base = { region };
10
- if (cfg.endpoint)
11
- base.endpoint = cfg.endpoint;
12
10
  if (cfg.profile)
13
11
  base.credentials = fromIni({ profile: cfg.profile });
14
12
  return base;
@@ -1,25 +1,9 @@
1
1
  import { S3Client, ListBucketsCommand, GetBucketNotificationConfigurationCommand, GetBucketVersioningCommand, GetBucketEncryptionCommand, GetPublicAccessBlockCommand, } from '@aws-sdk/client-s3';
2
2
  import { fromIni } from '@aws-sdk/credential-providers';
3
3
  import { logger } from '../../core/index.js';
4
- function validateEndpoint(endpoint) {
5
- let url;
6
- try {
7
- url = new URL(endpoint);
8
- }
9
- catch {
10
- throw new Error(`Invalid aws.endpoint URL: "${endpoint}"`);
11
- }
12
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
13
- throw new Error(`aws.endpoint must use http:// or https://, got "${url.protocol}//"`);
14
- }
15
- }
16
4
  function clientConfig(cfg) {
17
5
  const region = cfg.region ?? 'us-east-1';
18
6
  const base = { region };
19
- if (cfg.endpoint) {
20
- validateEndpoint(cfg.endpoint);
21
- base.endpoint = cfg.endpoint;
22
- }
23
7
  if (cfg.profile)
24
8
  base.credentials = fromIni({ profile: cfg.profile });
25
9
  return base;
@@ -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';
@@ -7,25 +9,9 @@ import { EventBridgeClient, ListRulesCommand, ListTargetsByRuleCommand, } from '
7
9
  import { RDSClient, DescribeDBInstancesCommand } from '@aws-sdk/client-rds';
8
10
  import { fromIni } from '@aws-sdk/credential-providers';
9
11
  import { logger } from '../../core/index.js';
10
- function validateEndpoint(endpoint) {
11
- let url;
12
- try {
13
- url = new URL(endpoint);
14
- }
15
- catch {
16
- throw new Error(`Invalid aws.endpoint URL: "${endpoint}"`);
17
- }
18
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
19
- throw new Error(`aws.endpoint must use http:// or https://, got "${url.protocol}//"`);
20
- }
21
- }
22
12
  function clientConfig(cfg) {
23
13
  const region = cfg.region ?? 'us-east-1';
24
14
  const base = { region };
25
- if (cfg.endpoint) {
26
- validateEndpoint(cfg.endpoint);
27
- base.endpoint = cfg.endpoint;
28
- }
29
15
  if (cfg.profile)
30
16
  base.credentials = fromIni({ profile: cfg.profile });
31
17
  return base;
@@ -66,6 +52,7 @@ export async function extractSQSMetadata(cfg = {}) {
66
52
  : undefined;
67
53
  const encrypted = !!(a['KmsMasterKeyId'] || a['SqsManagedSseEnabled'] === 'true');
68
54
  const retentionSeconds = parseInt(a['MessageRetentionPeriod'] ?? '345600', 10);
55
+ const isFifo = name.endsWith('.fifo') || a['FifoQueue'] === 'true';
69
56
  queues.push({
70
57
  name,
71
58
  url,
@@ -73,6 +60,7 @@ export async function extractSQSMetadata(cfg = {}) {
73
60
  hasDLQ: !!dlqArn,
74
61
  dlqArn,
75
62
  encrypted,
63
+ isFifo,
76
64
  visibilityTimeoutSec: parseInt(a['VisibilityTimeout'] ?? '30', 10),
77
65
  retentionDays: Math.round(retentionSeconds / 86400),
78
66
  approximateMessages: parseInt(a['ApproximateNumberOfMessages'] ?? '0', 10),
@@ -382,3 +370,105 @@ export async function extractRDSMetadata(cfg = {}) {
382
370
  export async function validateRDSAccess(cfg = {}) {
383
371
  await new RDSClient(clientConfig(cfg)).send(new DescribeDBInstancesCommand({ MaxRecords: 20 }));
384
372
  }
373
+ // ─── API Gateway ──────────────────────────────────────────────────────────────
374
+ function lambdaNameFromArn(arn) {
375
+ if (!arn)
376
+ return undefined;
377
+ const parts = arn.split(':');
378
+ return parts[parts.length - 1] || undefined;
379
+ }
380
+ export async function extractAPIGatewayMetadata(cfg = {}) {
381
+ const results = [];
382
+ const ccfg = clientConfig(cfg);
383
+ // REST APIs (v1)
384
+ try {
385
+ const restClient = new APIGatewayClient(ccfg);
386
+ let position;
387
+ const restApis = [];
388
+ do {
389
+ const res = await restClient.send(new GetRestApisCommand({ position, limit: 500 }));
390
+ for (const api of res.items ?? []) {
391
+ if (api.id && api.name)
392
+ restApis.push({ id: api.id, name: api.name });
393
+ }
394
+ position = res.position;
395
+ } while (position);
396
+ for (const api of restApis) {
397
+ const routes = [];
398
+ try {
399
+ const resourcesRes = await restClient.send(new GetResourcesCommand({ restApiId: api.id, embed: ['methods'], limit: 500 }));
400
+ for (const resource of resourcesRes.items ?? []) {
401
+ const resourcePath = resource.path ?? '/';
402
+ for (const [method, methodItem] of Object.entries(resource.resourceMethods ?? {})) {
403
+ if (method === 'OPTIONS')
404
+ continue;
405
+ const integration = methodItem
406
+ ?.methodIntegration;
407
+ const lambdaArn = typeof integration?.uri === 'string' ? integration.uri : undefined;
408
+ const lambdaName = lambdaArn
409
+ ? lambdaNameFromArn(lambdaArn.split('/functions/')[1]?.split('/')[0])
410
+ : undefined;
411
+ routes.push({ method, path: resourcePath, lambdaArn, lambdaName });
412
+ }
413
+ }
414
+ }
415
+ catch (err) {
416
+ logger.warn(`API Gateway REST resources failed for ${api.name}: ${err instanceof Error ? err.message : String(err)}`);
417
+ }
418
+ results.push({ name: api.name, id: api.id, type: 'REST', routes });
419
+ }
420
+ }
421
+ catch (err) {
422
+ logger.warn(`API Gateway REST list failed: ${err instanceof Error ? err.message : String(err)}`);
423
+ }
424
+ // HTTP + WebSocket APIs (v2)
425
+ try {
426
+ const v2Client = new ApiGatewayV2Client(ccfg);
427
+ let nextToken;
428
+ const v2Apis = [];
429
+ do {
430
+ const res = await v2Client.send(new GetApisCommand({ NextToken: nextToken, MaxResults: '500' }));
431
+ for (const api of res.Items ?? []) {
432
+ if (api.ApiId && api.Name) {
433
+ v2Apis.push({ id: api.ApiId, name: api.Name, protocolType: api.ProtocolType ?? 'HTTP' });
434
+ }
435
+ }
436
+ nextToken = res.NextToken;
437
+ } while (nextToken);
438
+ for (const api of v2Apis) {
439
+ const apiType = api.protocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP';
440
+ const routes = [];
441
+ try {
442
+ const [routesRes, integrationsRes] = await Promise.all([
443
+ v2Client.send(new GetRoutesCommand({ ApiId: api.id, MaxResults: '500' })),
444
+ v2Client.send(new GetIntegrationsCommand({ ApiId: api.id, MaxResults: '500' })),
445
+ ]);
446
+ const integrationMap = new Map();
447
+ for (const integ of integrationsRes.Items ?? []) {
448
+ if (integ.IntegrationId && integ.IntegrationUri) {
449
+ integrationMap.set(integ.IntegrationId, integ.IntegrationUri);
450
+ }
451
+ }
452
+ for (const route of routesRes.Items ?? []) {
453
+ const routeKey = route.RouteKey ?? '';
454
+ const [method, ...pathParts] = routeKey.split(' ');
455
+ const routePath = pathParts.join(' ') || '/';
456
+ const integrationId = route.Target?.replace('integrations/', '');
457
+ const lambdaArn = integrationId ? integrationMap.get(integrationId) : undefined;
458
+ const lambdaName = lambdaArn
459
+ ? lambdaNameFromArn(lambdaArn.split('/functions/')[1]?.split('/')[0])
460
+ : undefined;
461
+ routes.push({ method: method ?? routeKey, path: routePath, lambdaArn, lambdaName });
462
+ }
463
+ }
464
+ catch (err) {
465
+ logger.warn(`API Gateway v2 routes failed for ${api.name}: ${err instanceof Error ? err.message : String(err)}`);
466
+ }
467
+ results.push({ name: api.name, id: api.id, type: apiType, routes });
468
+ }
469
+ }
470
+ catch (err) {
471
+ logger.debug(`API Gateway v2 list failed: ${err instanceof Error ? err.message : String(err)}`);
472
+ }
473
+ return results;
474
+ }
@@ -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
+ }