infrawise 0.10.3 → 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 +15 -4
- package/dist/adapters/aws/services.js +106 -0
- package/dist/adapters/iac/terraform.js +2 -0
- package/dist/analyzers/aws-services.js +36 -0
- package/dist/analyzers/dynamodb.js +9 -5
- package/dist/analyzers/index.js +2 -1
- package/dist/analyzers/linkers.js +106 -0
- package/dist/analyzers/pipeline.js +166 -0
- package/dist/cli/commands/analyze.js +47 -5
- package/dist/core/config.js +6 -0
- package/dist/graph/index.js +21 -0
- package/dist/server/index.js +22 -2
- package/package.json +8 -14
package/README.md
CHANGED
|
@@ -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) |
|
|
@@ -274,6 +275,9 @@ s3:
|
|
|
274
275
|
kafka:
|
|
275
276
|
enabled: false
|
|
276
277
|
|
|
278
|
+
apiGateway:
|
|
279
|
+
enabled: false
|
|
280
|
+
|
|
277
281
|
cloudwatchLogs:
|
|
278
282
|
enabled: false
|
|
279
283
|
logGroupPrefixes: []
|
|
@@ -281,6 +285,9 @@ cloudwatchLogs:
|
|
|
281
285
|
|
|
282
286
|
analysis:
|
|
283
287
|
sampleSize: 100
|
|
288
|
+
hotPartitionThreshold: 5
|
|
289
|
+
hotPartitionThresholds:
|
|
290
|
+
high-traffic-table: 12
|
|
284
291
|
```
|
|
285
292
|
|
|
286
293
|
### AWS setup
|
|
@@ -334,13 +341,14 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
|
|
|
334
341
|
| DynamoDB schema | Tables, GSIs, partition keys |
|
|
335
342
|
| PostgreSQL / MySQL schema | Tables, indexes, column types |
|
|
336
343
|
| MongoDB schema | Collections, indexes |
|
|
337
|
-
| SQS | Missing DLQs, unencrypted queues, large backlogs
|
|
344
|
+
| SQS | Missing DLQs, unencrypted queues, large backlogs, FIFO detection, visibility timeout vs Lambda timeout mismatch |
|
|
338
345
|
| SNS | Subscription filter policies — required message attributes per subscription |
|
|
339
346
|
| Kafka (kafkajs) | Producer/consumer topic mapping from code |
|
|
340
347
|
| Secrets Manager | Missing secret rotation |
|
|
341
348
|
| Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), missing DLQ on trigger source |
|
|
342
349
|
| S3 | Public access blocking (verify), missing versioning, missing encryption |
|
|
343
350
|
| EventBridge | Rules, schedules, event patterns, target Lambda functions |
|
|
351
|
+
| API Gateway | REST, HTTP, and WebSocket APIs — routes, methods, Lambda integrations |
|
|
344
352
|
| RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
|
|
345
353
|
| CloudWatch Logs | Log groups with no retention policy |
|
|
346
354
|
| Terraform / CloudFormation / CDK | IaC drift vs deployed state |
|
|
@@ -361,6 +369,9 @@ Uses [ts-morph](https://ts-morph.com/) AST analysis to detect which functions ca
|
|
|
361
369
|
| MySQL Full Table Scan | High | Full table scan patterns in MySQL queries |
|
|
362
370
|
| Missing Mongo Index | Medium | Collections queried without secondary indexes |
|
|
363
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 |
|
|
364
375
|
|
|
365
376
|
Non-TypeScript/JavaScript projects still get full value from infrastructure-level analyzers — code correlation (function-to-table mapping, N+1 patterns) is skipped.
|
|
366
377
|
|
|
@@ -409,10 +420,10 @@ src/
|
|
|
409
420
|
core/ Config (Zod + YAML), logger (Pino), local cache
|
|
410
421
|
graph/ Graph engine — nodes, edges, builder
|
|
411
422
|
adapters/
|
|
412
|
-
aws/ DynamoDB, S3, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS, CloudWatch
|
|
423
|
+
aws/ DynamoDB, S3, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS/APIGateway, CloudWatch
|
|
413
424
|
db/ PostgreSQL, MySQL, MongoDB
|
|
414
425
|
iac/ Terraform, CDK, CloudFormation (local file parsing)
|
|
415
|
-
analyzers/
|
|
426
|
+
analyzers/ 29 rule-based analyzers
|
|
416
427
|
context/ Repository scanner (ts-morph AST)
|
|
417
428
|
server/ Fastify MCP server (@modelcontextprotocol/sdk, Streamable HTTP)
|
|
418
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
});
|
package/dist/analyzers/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
? [
|
|
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
|
-
? [
|
|
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
|
-
? [
|
|
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);
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
});
|
package/dist/graph/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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,7 +32,9 @@
|
|
|
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"
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"node": ">=24.0.0",
|
|
53
55
|
"pnpm": ">=9.0.0"
|
|
54
56
|
},
|
|
55
|
-
"packageManager": "pnpm@
|
|
57
|
+
"packageManager": "pnpm@11.6.0",
|
|
56
58
|
"scripts": {
|
|
57
59
|
"build": "tsc --noEmit false --outDir dist",
|
|
58
60
|
"test": "vitest run --passWithNoTests",
|
|
@@ -71,6 +73,8 @@
|
|
|
71
73
|
"pre-commit": "pnpm format && git add src/ && pnpm lint && pnpm typecheck && pnpm test"
|
|
72
74
|
},
|
|
73
75
|
"dependencies": {
|
|
76
|
+
"@aws-sdk/client-api-gateway": "^3.1068.0",
|
|
77
|
+
"@aws-sdk/client-apigatewayv2": "^3.1068.0",
|
|
74
78
|
"@aws-sdk/client-cloudwatch-logs": "^3.1048.0",
|
|
75
79
|
"@aws-sdk/client-dynamodb": "^3.1048.0",
|
|
76
80
|
"@aws-sdk/client-eventbridge": "^3.1051.0",
|
|
@@ -121,15 +125,5 @@
|
|
|
121
125
|
],
|
|
122
126
|
"publishConfig": {
|
|
123
127
|
"access": "public"
|
|
124
|
-
},
|
|
125
|
-
"pnpm": {
|
|
126
|
-
"overrides": {
|
|
127
|
-
"qs": ">=6.15.2",
|
|
128
|
-
"vite": "^7"
|
|
129
|
-
},
|
|
130
|
-
"allowedBuildScripts": [
|
|
131
|
-
"puppeteer",
|
|
132
|
-
"simple-git-hooks"
|
|
133
|
-
]
|
|
134
128
|
}
|
|
135
129
|
}
|