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 +21 -15
- 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/cli/commands/start.js +2 -34
- package/dist/cli/index.js +0 -2
- package/dist/core/config.js +6 -0
- package/dist/graph/index.js +21 -0
- package/dist/server/index.js +22 -2
- package/package.json +12 -15
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](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/
|
|
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
|
-
|
|
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);
|
|
@@ -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(
|
|
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
|
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,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.
|
|
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@
|
|
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
|
-
"
|
|
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
|
}
|