infrawise 0.17.0 → 0.19.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 +10 -10
- package/dist/adapters/aws/dynamodb.js +9 -1
- package/dist/analyzers/cost-signals.js +70 -0
- package/dist/analyzers/index.js +1 -0
- package/dist/cli/commands/analyze.js +3 -1
- package/dist/context/index.js +64 -13
- package/dist/context/scanner.py +70 -0
- package/dist/graph/index.js +8 -0
- package/dist/server/index.js +67 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ Add to your editor's MCP config:
|
|
|
176
176
|
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
177
177
|
| `get_infra_overview` | Complete snapshot — services, counts, high-severity findings, analysis `freshness` (age + stale flag), `configured` flag |
|
|
178
178
|
| `get_graph_summary` | Full infrastructure graph — all nodes, edges, and findings |
|
|
179
|
-
| `get_table_schema` | Column-level schema for named tables/collections — types, PKs, FKs, indexes, DynamoDB keys (no row data)
|
|
179
|
+
| `get_table_schema` | Column-level schema for named tables/collections — types, PKs, FKs, indexes, DynamoDB keys/billing mode, cost signal (no row data) |
|
|
180
180
|
| `analyze_function` | Issues in a specific function — scans, missing indexes, N+1, trigger event shapes, missing IAM permissions |
|
|
181
181
|
| `suggest_gsi` | Exact GSI config for a DynamoDB table + attribute |
|
|
182
182
|
| `postgres_index_suggestions` | Exact `CREATE INDEX` SQL for your actual table |
|
|
@@ -185,16 +185,16 @@ Add to your editor's MCP config:
|
|
|
185
185
|
| `get_queue_details` | SQS queues — DLQ status, encryption, FIFO type, visibility timeout, message counts |
|
|
186
186
|
| `get_api_routes` | API Gateway APIs (REST, HTTP, WebSocket) — routes, HTTP methods, paths, and Lambda integrations |
|
|
187
187
|
| `get_topic_details` | SNS topics — subscription counts, protocols, and filter policies (required message attributes per subscription) |
|
|
188
|
-
| `get_secrets_overview` | Secrets Manager — names
|
|
188
|
+
| `get_secrets_overview` | Secrets Manager — names, rotation status, and key names inferred from code (values never included) |
|
|
189
189
|
| `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
|
|
190
|
-
| `get_lambda_overview` | Lambda functions — runtime, memory, timeout, execution role ARN, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), env var key names |
|
|
190
|
+
| `get_lambda_overview` | Lambda functions — runtime, memory, timeout, execution role ARN, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), env var key names, cost signal |
|
|
191
191
|
| `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
|
|
192
192
|
| `get_s3_overview` | S3 buckets — versioning, encryption, public access, event notifications |
|
|
193
193
|
| `get_log_errors` | CloudWatch error patterns and counts (no raw log messages) |
|
|
194
194
|
| `get_stack_outputs` | Stack outputs and cross-stack exports parsed from local IaC files (Terraform outputs, CFN/CDK Outputs) |
|
|
195
195
|
| `get_cognito_overview` | Cognito user pools — MFA config, app client auth flows, OAuth settings, token validity (secrets never included) |
|
|
196
196
|
| `get_stream_details` | Kinesis streams (shards, retention, capacity mode) and MSK clusters (state, Kafka version, brokers) |
|
|
197
|
-
| `get_cache_overview` | ElastiCache clusters — engine, encryption in transit/at rest, replication group, failover (data never read)
|
|
197
|
+
| `get_cache_overview` | ElastiCache clusters — engine, encryption in transit/at rest, replication group, failover, cost signal (data never read) |
|
|
198
198
|
|
|
199
199
|
---
|
|
200
200
|
|
|
@@ -408,22 +408,22 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
|
|
|
408
408
|
|
|
409
409
|
| Service | What it checks |
|
|
410
410
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
411
|
-
| DynamoDB schema | Tables, GSIs, partition keys
|
|
411
|
+
| DynamoDB schema | Tables, GSIs, partition keys, billing mode, cost signal (provisioned capacity) |
|
|
412
412
|
| PostgreSQL / MySQL schema | Tables, indexes, column types |
|
|
413
413
|
| MongoDB schema | Collections, indexes |
|
|
414
414
|
| SQS | Missing DLQs, unencrypted queues, large backlogs, FIFO detection, visibility timeout vs Lambda timeout mismatch |
|
|
415
415
|
| SNS | Subscription filter policies — required message attributes per subscription |
|
|
416
416
|
| Apache Kafka (kafkajs) | Producer/consumer topic mapping from code — any broker (self-hosted, Confluent, Redpanda, MSK); distinct from the MSK Lambda trigger |
|
|
417
417
|
| Secrets Manager | Missing secret rotation |
|
|
418
|
-
| Lambda | Default memory (128 MB), high timeouts, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), missing DLQ on trigger source |
|
|
418
|
+
| Lambda | Default memory (128 MB), high timeouts, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), missing DLQ on trigger source, cost signal (high memory with no throttling evidence) |
|
|
419
419
|
| S3 | Public access blocking (verify), missing versioning, missing encryption |
|
|
420
420
|
| EventBridge | Rules, schedules, event patterns, target Lambda functions |
|
|
421
421
|
| API Gateway | REST, HTTP, and WebSocket APIs — routes, methods, Lambda integrations |
|
|
422
|
-
| RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ
|
|
422
|
+
| RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ, cost signal (Multi-AZ on a non-production-looking instance) |
|
|
423
423
|
| CloudWatch Logs | Log groups with no retention policy |
|
|
424
424
|
| Cognito | User pools and app client config — auth flows, OAuth settings, token validity, client secret presence |
|
|
425
425
|
| Kinesis / MSK | Streams (shards, retention, capacity mode) and MSK clusters (state, Kafka version, brokers) |
|
|
426
|
-
| ElastiCache | Missing in-transit encryption, single-node clusters with no replication
|
|
426
|
+
| ElastiCache | Missing in-transit encryption, single-node clusters with no replication, cost signal (more than 3 nodes) |
|
|
427
427
|
| Runtime signals (opt-in) | Lambda throttling/errors and stale queue messages from CloudWatch metrics |
|
|
428
428
|
| Terraform / CloudFormation / CDK | IaC drift vs deployed state; stack outputs and cross-stack exports |
|
|
429
429
|
|
|
@@ -499,7 +499,7 @@ src/
|
|
|
499
499
|
aws/ DynamoDB, S3, Lambda, SQS/SNS/SSM/Secrets/EventBridge/RDS/APIGateway, CloudWatch
|
|
500
500
|
db/ PostgreSQL, MySQL, MongoDB
|
|
501
501
|
iac/ Terraform, CDK, CloudFormation (local file parsing)
|
|
502
|
-
analyzers/
|
|
502
|
+
analyzers/ 36 rule-based analyzers
|
|
503
503
|
context/ Repository scanner (ts-morph AST + Python stdlib-ast subprocess)
|
|
504
504
|
server/ Fastify MCP server (@modelcontextprotocol/sdk, Streamable HTTP)
|
|
505
505
|
cli/ CLI commands (Commander.js)
|
|
@@ -518,7 +518,7 @@ src/
|
|
|
518
518
|
|
|
519
519
|
## Roadmap
|
|
520
520
|
|
|
521
|
-
Feature roadmap is tracked in the [
|
|
521
|
+
Feature roadmap is tracked in the [Infrawise v1](https://github.com/users/Sidd27/projects/1) project board. Feature requests and upvotes welcome.
|
|
522
522
|
|
|
523
523
|
---
|
|
524
524
|
|
|
@@ -28,7 +28,15 @@ function parseTableDescription(desc) {
|
|
|
28
28
|
indexes.push(lsi.IndexName);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
const billingMode = desc.BillingModeSummary?.BillingMode;
|
|
32
|
+
const provisionedThroughput = desc.ProvisionedThroughput?.ReadCapacityUnits !== undefined &&
|
|
33
|
+
desc.ProvisionedThroughput?.WriteCapacityUnits !== undefined
|
|
34
|
+
? {
|
|
35
|
+
readCapacityUnits: desc.ProvisionedThroughput.ReadCapacityUnits,
|
|
36
|
+
writeCapacityUnits: desc.ProvisionedThroughput.WriteCapacityUnits,
|
|
37
|
+
}
|
|
38
|
+
: undefined;
|
|
39
|
+
return { tableName, partitionKey, sortKey, indexes, billingMode, provisionedThroughput };
|
|
32
40
|
}
|
|
33
41
|
async function listAllTables(client) {
|
|
34
42
|
const tableNames = [];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ─── Cost signals ────────────────────────────────────────────────────────────
|
|
2
|
+
// No billing API involved — every signal here is derived from resource specs
|
|
3
|
+
// already extracted (config-level heuristics, not real utilization data).
|
|
4
|
+
const HIGH_MEMORY_MB = 3008;
|
|
5
|
+
const NON_PROD_NAME = /dev|staging|test|sandbox/i;
|
|
6
|
+
const MANY_CACHE_NODES = 3;
|
|
7
|
+
export class LambdaHighMemoryAnalyzer {
|
|
8
|
+
name = 'LambdaHighMemoryAnalyzer';
|
|
9
|
+
async analyze(graph) {
|
|
10
|
+
const findings = [];
|
|
11
|
+
for (const node of graph.nodes) {
|
|
12
|
+
if (node.type !== 'lambda')
|
|
13
|
+
continue;
|
|
14
|
+
if ((node.memoryMB ?? 0) < HIGH_MEMORY_MB)
|
|
15
|
+
continue;
|
|
16
|
+
if (node.recentThrottles !== 0)
|
|
17
|
+
continue;
|
|
18
|
+
findings.push({
|
|
19
|
+
severity: 'low',
|
|
20
|
+
issue: `Lambda "${node.name}" has high memory (${node.memoryMB} MB) with zero recent throttles`,
|
|
21
|
+
description: `"${node.name}" is allocated ${node.memoryMB} MB with no recent throttling, which is one signal (not proof) that it may be over-provisioned. AWS Lambda pricing is duration × memory.`,
|
|
22
|
+
recommendation: `Run Lambda Power Tuning on "${node.name}" to find the cost-optimal memory size for its actual workload.`,
|
|
23
|
+
metadata: { functionName: node.name, memoryMB: node.memoryMB },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return findings;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class RDSMultiAZNonProdAnalyzer {
|
|
30
|
+
name = 'RDSMultiAZNonProdAnalyzer';
|
|
31
|
+
async analyze(graph) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
for (const node of graph.nodes) {
|
|
34
|
+
if (node.type !== 'database_instance')
|
|
35
|
+
continue;
|
|
36
|
+
if (!node.multiAZ)
|
|
37
|
+
continue;
|
|
38
|
+
if (!NON_PROD_NAME.test(node.name))
|
|
39
|
+
continue;
|
|
40
|
+
findings.push({
|
|
41
|
+
severity: 'low',
|
|
42
|
+
issue: `RDS instance "${node.name}" has Multi-AZ enabled on what looks like a non-production instance`,
|
|
43
|
+
description: `"${node.name}" has Multi-AZ enabled, which roughly doubles RDS cost by running a standby replica. The name suggests a non-production instance, where Multi-AZ is often unnecessary.`,
|
|
44
|
+
recommendation: `If "${node.name}" is not production, disable Multi-AZ to cut its cost roughly in half.`,
|
|
45
|
+
metadata: { dbInstanceIdentifier: node.name, engine: node.engine },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return findings;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Advisory only — no Finding, since there's no runtime-signal evidence either way. */
|
|
52
|
+
export function lambdaCostSignal(node) {
|
|
53
|
+
if ((node.memoryMB ?? 0) < HIGH_MEMORY_MB)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (node.recentThrottles !== undefined)
|
|
56
|
+
return undefined; // covered by LambdaHighMemoryAnalyzer's Finding when signals are on
|
|
57
|
+
return `${node.memoryMB} MB memory allocated; verify this matches actual workload needs — Lambda Power Tuning can find the cost-optimal size.`;
|
|
58
|
+
}
|
|
59
|
+
export function dynamoCostSignal(node) {
|
|
60
|
+
if (node.databaseType !== 'dynamodb')
|
|
61
|
+
return undefined;
|
|
62
|
+
if (node.billingMode !== 'PROVISIONED')
|
|
63
|
+
return undefined;
|
|
64
|
+
return 'Provisioned capacity; on-demand may cost less under spiky traffic, provisioned is usually cheaper under steady traffic.';
|
|
65
|
+
}
|
|
66
|
+
export function cacheCostSignal(node) {
|
|
67
|
+
if ((node.numNodes ?? 0) <= MANY_CACHE_NODES)
|
|
68
|
+
return undefined;
|
|
69
|
+
return `${node.numNodes} nodes; verify traffic justifies ${node.numNodes}× the per-node cost.`;
|
|
70
|
+
}
|
package/dist/analyzers/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { IaCDriftAnalyzer } from './terraform.js';
|
|
|
7
7
|
export { PipelineAnalyzer } from './pipeline.js';
|
|
8
8
|
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, LambdaMissingIAMPermissionsAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, CacheTransitEncryptionAnalyzer, CacheSingleNodeAnalyzer, LambdaThrottlingAnalyzer, StaleQueueMessagesAnalyzer, } from './aws-services.js';
|
|
9
9
|
export { RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from './rds.js';
|
|
10
|
+
export { LambdaHighMemoryAnalyzer, RDSMultiAZNonProdAnalyzer, lambdaCostSignal, dynamoCostSignal, cacheCostSignal, } from './cost-signals.js';
|
|
10
11
|
export async function runAllAnalyzers(graph, analyzers) {
|
|
11
12
|
const allFindings = [];
|
|
12
13
|
for (const analyzer of analyzers) {
|
|
@@ -14,7 +14,7 @@ import { extractRuntimeSignals } from '../../adapters/aws/metrics.js';
|
|
|
14
14
|
import { extractS3Metadata } from '../../adapters/aws/s3.js';
|
|
15
15
|
import { scanRepository } from '../../context/index.js';
|
|
16
16
|
import { buildGraph, addStackOutputNodes } from '../../graph/index.js';
|
|
17
|
-
import { runAllAnalyzers, IaCDriftAnalyzer, PipelineAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, LambdaMissingIAMPermissionsAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, CacheTransitEncryptionAnalyzer, CacheSingleNodeAnalyzer, LambdaThrottlingAnalyzer, StaleQueueMessagesAnalyzer, } from '../../analyzers/index.js';
|
|
17
|
+
import { runAllAnalyzers, IaCDriftAnalyzer, PipelineAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, LambdaMissingIAMPermissionsAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, CacheTransitEncryptionAnalyzer, CacheSingleNodeAnalyzer, LambdaThrottlingAnalyzer, StaleQueueMessagesAnalyzer, LambdaHighMemoryAnalyzer, RDSMultiAZNonProdAnalyzer, } from '../../analyzers/index.js';
|
|
18
18
|
import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
|
|
19
19
|
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
|
|
20
20
|
function buildMarkdownReport(findings, projectName) {
|
|
@@ -102,6 +102,7 @@ function buildAnalyzers(config, iacDriftAnalyzer, iacLambdas) {
|
|
|
102
102
|
new LambdaHighTimeoutAnalyzer(),
|
|
103
103
|
new LambdaMissingTriggerDLQAnalyzer(),
|
|
104
104
|
new LambdaMissingIAMPermissionsAnalyzer(),
|
|
105
|
+
new LambdaHighMemoryAnalyzer(),
|
|
105
106
|
]
|
|
106
107
|
: []),
|
|
107
108
|
...(config.rds?.enabled === true
|
|
@@ -111,6 +112,7 @@ function buildAnalyzers(config, iacDriftAnalyzer, iacLambdas) {
|
|
|
111
112
|
new RDSUnencryptedAnalyzer(),
|
|
112
113
|
new RDSNoDeletionProtectionAnalyzer(),
|
|
113
114
|
new RDSNoMultiAZAnalyzer(),
|
|
115
|
+
new RDSMultiAZNonProdAnalyzer(),
|
|
114
116
|
]
|
|
115
117
|
: []),
|
|
116
118
|
...(config.s3?.enabled === true
|
package/dist/context/index.js
CHANGED
|
@@ -95,29 +95,76 @@ const PRISMA_METHODS = new Set([
|
|
|
95
95
|
'deleteMany',
|
|
96
96
|
'updateMany',
|
|
97
97
|
]);
|
|
98
|
-
function
|
|
98
|
+
function getEnclosingFunction(node) {
|
|
99
99
|
let current = node.getParent();
|
|
100
100
|
while (current) {
|
|
101
101
|
if (Node.isFunctionDeclaration(current) ||
|
|
102
102
|
Node.isFunctionExpression(current) ||
|
|
103
103
|
Node.isArrowFunction(current) ||
|
|
104
104
|
Node.isMethodDeclaration(current)) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
return current;
|
|
106
|
+
}
|
|
107
|
+
current = current.getParent();
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
function getEnclosingFunctionName(node) {
|
|
112
|
+
const current = getEnclosingFunction(node);
|
|
113
|
+
if (!current)
|
|
114
|
+
return '<module>';
|
|
115
|
+
if (Node.isFunctionDeclaration(current) || Node.isMethodDeclaration(current)) {
|
|
116
|
+
return current.getName() ?? '<anonymous>';
|
|
117
|
+
}
|
|
118
|
+
// Arrow function or function expression — check if assigned to a variable
|
|
119
|
+
const parent = current.getParent();
|
|
120
|
+
if (parent && Node.isVariableDeclaration(parent)) {
|
|
121
|
+
return parent.getName();
|
|
122
|
+
}
|
|
123
|
+
if (parent && Node.isPropertyAssignment(parent)) {
|
|
124
|
+
return parent.getName();
|
|
125
|
+
}
|
|
126
|
+
return '<anonymous>';
|
|
127
|
+
}
|
|
128
|
+
// Scans the enclosing function for JSON.parse(x.SecretString) usage and collects the property
|
|
129
|
+
// names accessed on the parsed result — never the secret values themselves.
|
|
130
|
+
function extractSecretKeys(func) {
|
|
131
|
+
if (!func)
|
|
132
|
+
return undefined;
|
|
133
|
+
const keys = new Set();
|
|
134
|
+
for (const call of func.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
135
|
+
const expr = call.getExpression();
|
|
136
|
+
if (!Node.isPropertyAccessExpression(expr) || expr.getName() !== 'parse')
|
|
137
|
+
continue;
|
|
138
|
+
if (expr.getExpression().getText() !== 'JSON')
|
|
139
|
+
continue;
|
|
140
|
+
const arg = call.getArguments()[0];
|
|
141
|
+
if (!arg || !arg.getText().includes('SecretString'))
|
|
142
|
+
continue;
|
|
143
|
+
const parent = call.getParent();
|
|
144
|
+
if (Node.isPropertyAccessExpression(parent) && parent.getExpression() === call) {
|
|
145
|
+
keys.add(parent.getName());
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (Node.isVariableDeclaration(parent)) {
|
|
149
|
+
const nameNode = parent.getNameNode();
|
|
150
|
+
if (Node.isObjectBindingPattern(nameNode)) {
|
|
151
|
+
for (const el of nameNode.getElements()) {
|
|
152
|
+
const propName = el.getPropertyNameNode();
|
|
153
|
+
keys.add(propName ? propName.getText() : el.getName());
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
112
156
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
157
|
+
if (Node.isIdentifier(nameNode)) {
|
|
158
|
+
const varName = nameNode.getText();
|
|
159
|
+
for (const access of func.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
160
|
+
if (access.getExpression().getText() === varName) {
|
|
161
|
+
keys.add(access.getName());
|
|
162
|
+
}
|
|
163
|
+
}
|
|
115
164
|
}
|
|
116
|
-
return '<anonymous>';
|
|
117
165
|
}
|
|
118
|
-
current = current.getParent();
|
|
119
166
|
}
|
|
120
|
-
return
|
|
167
|
+
return keys.size > 0 ? Array.from(keys).sort() : undefined;
|
|
121
168
|
}
|
|
122
169
|
function extractTableNameFromArg(arg) {
|
|
123
170
|
if (Node.isStringLiteral(arg)) {
|
|
@@ -505,6 +552,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
|
|
|
505
552
|
serviceType: 'secretsmanager',
|
|
506
553
|
target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...SECRETS_ARG_KEYS) : 'unknown',
|
|
507
554
|
filePath,
|
|
555
|
+
keys: extractSecretKeys(getEnclosingFunction(callExpr)),
|
|
508
556
|
};
|
|
509
557
|
}
|
|
510
558
|
if (LAMBDA_COMMANDS.has(className)) {
|
|
@@ -558,6 +606,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
|
|
|
558
606
|
serviceType: 'secretsmanager',
|
|
559
607
|
target: args.length > 0 ? extractArgValue(args[0], ...SECRETS_ARG_KEYS) : 'unknown',
|
|
560
608
|
filePath,
|
|
609
|
+
keys: extractSecretKeys(getEnclosingFunction(callExpr)),
|
|
561
610
|
};
|
|
562
611
|
}
|
|
563
612
|
if (LAMBDA_COMMANDS.has(methodName) && (objText.includes('lambda') || objText.includes('fn'))) {
|
|
@@ -586,6 +635,8 @@ function detectLanguages(root) {
|
|
|
586
635
|
const stack = [root];
|
|
587
636
|
while (stack.length > 0 && !(hasTypeScript && hasPython)) {
|
|
588
637
|
const dir = stack.pop();
|
|
638
|
+
if (dir === undefined)
|
|
639
|
+
break;
|
|
589
640
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
590
641
|
if (entry.isDirectory()) {
|
|
591
642
|
if (!PROBE_EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
package/dist/context/scanner.py
CHANGED
|
@@ -69,6 +69,33 @@ def sql_table(sql):
|
|
|
69
69
|
return 'unknown'
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def const_str(node):
|
|
73
|
+
if isinstance(node, ast.Index): # py3.8 compat
|
|
74
|
+
node = node.value
|
|
75
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
76
|
+
return node.value
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_secret_string_ref(node):
|
|
81
|
+
if isinstance(node, ast.Attribute) and node.attr == 'SecretString':
|
|
82
|
+
return True
|
|
83
|
+
if isinstance(node, ast.Subscript):
|
|
84
|
+
return const_str(node.slice) == 'SecretString'
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_json_loads_secret(node):
|
|
89
|
+
return (
|
|
90
|
+
isinstance(node, ast.Call)
|
|
91
|
+
and isinstance(node.func, ast.Attribute)
|
|
92
|
+
and node.func.attr == 'loads'
|
|
93
|
+
and expr_text(node.func.value) == 'json'
|
|
94
|
+
and node.args
|
|
95
|
+
and is_secret_string_ref(node.args[0])
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
72
99
|
class Visitor(ast.NodeVisitor):
|
|
73
100
|
def __init__(self, file_path):
|
|
74
101
|
self.file_path = file_path
|
|
@@ -93,11 +120,54 @@ class Visitor(ast.NodeVisitor):
|
|
|
93
120
|
|
|
94
121
|
def visit_FunctionDef(self, node):
|
|
95
122
|
self.stack.append(node.name)
|
|
123
|
+
start = len(self.ops)
|
|
96
124
|
self.generic_visit(node)
|
|
125
|
+
keys = self.find_secret_keys(node)
|
|
126
|
+
if keys:
|
|
127
|
+
for op in self.ops[start:]:
|
|
128
|
+
if op['serviceType'] == 'secretsmanager':
|
|
129
|
+
op['keys'] = keys
|
|
97
130
|
self.stack.pop()
|
|
98
131
|
|
|
99
132
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
100
133
|
|
|
134
|
+
# Finds json.loads(x['SecretString']) usage in this function and collects the key names
|
|
135
|
+
# accessed on the parsed result (via subscript or .get) — never the secret values.
|
|
136
|
+
def find_secret_keys(self, func_node):
|
|
137
|
+
keys = set()
|
|
138
|
+
tracked_vars = set()
|
|
139
|
+
|
|
140
|
+
for n in ast.walk(func_node):
|
|
141
|
+
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name):
|
|
142
|
+
if is_json_loads_secret(n.value):
|
|
143
|
+
tracked_vars.add(n.targets[0].id)
|
|
144
|
+
elif isinstance(n.value, ast.Subscript) and is_json_loads_secret(n.value.value):
|
|
145
|
+
key = const_str(n.value.slice)
|
|
146
|
+
if key:
|
|
147
|
+
keys.add(key)
|
|
148
|
+
|
|
149
|
+
if not tracked_vars:
|
|
150
|
+
return sorted(keys)
|
|
151
|
+
|
|
152
|
+
for n in ast.walk(func_node):
|
|
153
|
+
if isinstance(n, ast.Subscript) and isinstance(n.value, ast.Name) and n.value.id in tracked_vars:
|
|
154
|
+
key = const_str(n.slice)
|
|
155
|
+
if key:
|
|
156
|
+
keys.add(key)
|
|
157
|
+
elif (
|
|
158
|
+
isinstance(n, ast.Call)
|
|
159
|
+
and isinstance(n.func, ast.Attribute)
|
|
160
|
+
and n.func.attr == 'get'
|
|
161
|
+
and isinstance(n.func.value, ast.Name)
|
|
162
|
+
and n.func.value.id in tracked_vars
|
|
163
|
+
and n.args
|
|
164
|
+
):
|
|
165
|
+
key = const_str(n.args[0])
|
|
166
|
+
if key:
|
|
167
|
+
keys.add(key)
|
|
168
|
+
|
|
169
|
+
return sorted(keys)
|
|
170
|
+
|
|
101
171
|
def resolve_string(self, node):
|
|
102
172
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
103
173
|
return node.value
|
package/dist/graph/index.js
CHANGED
|
@@ -20,6 +20,8 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
20
20
|
databaseType: 'dynamodb',
|
|
21
21
|
partitionKey: table.partitionKey,
|
|
22
22
|
sortKey: table.sortKey,
|
|
23
|
+
billingMode: table.billingMode,
|
|
24
|
+
provisionedThroughput: table.provisionedThroughput,
|
|
23
25
|
});
|
|
24
26
|
for (const indexName of table.indexes) {
|
|
25
27
|
const indexNodeId = `index:${table.tableName}:${indexName}`;
|
|
@@ -386,6 +388,12 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
386
388
|
provider: 'aws',
|
|
387
389
|
rotationEnabled: false,
|
|
388
390
|
});
|
|
391
|
+
if (op.keys && op.keys.length > 0) {
|
|
392
|
+
const node = nodeMap.get(secretId);
|
|
393
|
+
if (node && node.type === 'secret') {
|
|
394
|
+
node.referencedKeys = Array.from(new Set([...(node.referencedKeys ?? []), ...op.keys])).sort();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
389
397
|
edges.push({ from: funcNodeId, to: secretId, type: 'reads_secret' });
|
|
390
398
|
continue;
|
|
391
399
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
7
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
|
-
import { summarizeFindings } from '../analyzers/index.js';
|
|
10
|
+
import { summarizeFindings, lambdaCostSignal, dynamoCostSignal, cacheCostSignal, } from '../analyzers/index.js';
|
|
11
11
|
import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getBucketNodes, getAPINodes, getStackOutputNodes, getUserPoolNodes, getStreamNodes, getKafkaClusterNodes, getCacheClusterNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
|
|
12
12
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
13
13
|
let currentGraph = { nodes: [], edges: [] };
|
|
@@ -344,7 +344,7 @@ export function createMcpServer() {
|
|
|
344
344
|
});
|
|
345
345
|
}));
|
|
346
346
|
mcp.registerTool('get_secrets_overview', {
|
|
347
|
-
description: 'Returns all Secrets Manager secrets with rotation status and
|
|
347
|
+
description: 'Returns all Secrets Manager secrets with rotation status, rotation interval, and referencedKeys — key names (e.g. "password", "apiKey") inferred from application code that parses the secret, never the values. Call this when checking which secrets exist, confirming rotation is enabled before a security review, or before writing code that reads a secret so you use the correct key name instead of guessing.',
|
|
348
348
|
inputSchema: z.object({}),
|
|
349
349
|
}, logged('get_secrets_overview', async () => {
|
|
350
350
|
const secrets = getSecretNodes(currentGraph);
|
|
@@ -357,6 +357,7 @@ export function createMcpServer() {
|
|
|
357
357
|
provider: s.provider,
|
|
358
358
|
rotationEnabled: s.rotationEnabled,
|
|
359
359
|
rotationDays: s.rotationDays,
|
|
360
|
+
referencedKeys: s.referencedKeys ?? [],
|
|
360
361
|
findings: secretFindings
|
|
361
362
|
.filter((f) => f.metadata.secretName === s.name)
|
|
362
363
|
.map((f) => ({ severity: f.severity, issue: f.issue })),
|
|
@@ -380,7 +381,7 @@ export function createMcpServer() {
|
|
|
380
381
|
});
|
|
381
382
|
}));
|
|
382
383
|
mcp.registerTool('get_lambda_overview', {
|
|
383
|
-
description: 'Returns all Lambda functions with runtime, memory (MB), timeout (sec), environment variable key names (values never returned), and event source triggers with the correct handler event shape for each. Call this when auditing Lambda configuration for default memory (128 MB) or high timeouts, or when you need the trigger event shape for a specific function without running analyze_function. When runtime signals are enabled, recentThrottles and recentErrors report CloudWatch counts for the analysis window.',
|
|
384
|
+
description: 'Returns all Lambda functions with runtime, memory (MB), timeout (sec), environment variable key names (values never returned), and event source triggers with the correct handler event shape for each. Call this when auditing Lambda configuration for default memory (128 MB) or high timeouts, or when you need the trigger event shape for a specific function without running analyze_function. When runtime signals are enabled, recentThrottles and recentErrors report CloudWatch counts for the analysis window. A costSignal note appears when memory is 3008 MB+ and there is no throttling evidence to justify it — no billing API involved, this is a config-level heuristic.',
|
|
384
385
|
inputSchema: z.object({}),
|
|
385
386
|
}, logged('get_lambda_overview', async () => {
|
|
386
387
|
const lambdas = getLambdaNodes(currentGraph);
|
|
@@ -388,26 +389,30 @@ export function createMcpServer() {
|
|
|
388
389
|
return toText({
|
|
389
390
|
total: lambdas.length,
|
|
390
391
|
note: 'Environment variable values are never included.',
|
|
391
|
-
lambdas: lambdas.map((l) =>
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
392
|
+
lambdas: lambdas.map((l) => {
|
|
393
|
+
const costSignal = lambdaCostSignal(l);
|
|
394
|
+
return {
|
|
395
|
+
name: l.name,
|
|
396
|
+
runtime: l.runtime,
|
|
397
|
+
memoryMB: l.memoryMB,
|
|
398
|
+
timeoutSec: l.timeoutSec,
|
|
399
|
+
envVarCount: l.envVarKeys?.length ?? 0,
|
|
400
|
+
envVarKeys: l.envVarKeys,
|
|
401
|
+
roleArn: l.roleArn,
|
|
402
|
+
recentThrottles: l.recentThrottles,
|
|
403
|
+
recentErrors: l.recentErrors,
|
|
404
|
+
...(costSignal ? { costSignal } : {}),
|
|
405
|
+
triggers: (l.triggers ?? []).map((t) => ({
|
|
406
|
+
type: t.type,
|
|
407
|
+
source: t.sourceName,
|
|
408
|
+
eventShape: t.eventShape,
|
|
409
|
+
state: t.state,
|
|
410
|
+
})),
|
|
411
|
+
findings: lambdaFindings
|
|
412
|
+
.filter((f) => f.metadata.functionName === l.name)
|
|
413
|
+
.map((f) => ({ severity: f.severity, issue: f.issue })),
|
|
414
|
+
};
|
|
415
|
+
}),
|
|
411
416
|
});
|
|
412
417
|
}));
|
|
413
418
|
mcp.registerTool('get_eventbridge_details', {
|
|
@@ -488,7 +493,7 @@ export function createMcpServer() {
|
|
|
488
493
|
});
|
|
489
494
|
}));
|
|
490
495
|
mcp.registerTool('get_table_schema', {
|
|
491
|
-
description: 'Returns the full schema for specific tables or collections by name: columns with data types and nullability, primary keys, foreign keys (join paths), indexes, DynamoDB partition/sort keys, and MongoDB estimated document counts. Accepts short names ("orders" matches "public.orders") and is case-insensitive. Call this after get_infra_overview when you need column-level detail to write a SQL query, DynamoDB expression, or MongoDB filter for specific tables — instead of pulling every schema with get_graph_summary. Do NOT call for a table inventory; use get_infra_overview for that. Row data is never included.',
|
|
496
|
+
description: 'Returns the full schema for specific tables or collections by name: columns with data types and nullability, primary keys, foreign keys (join paths), indexes, DynamoDB partition/sort keys and billing mode, and MongoDB estimated document counts. Accepts short names ("orders" matches "public.orders") and is case-insensitive. Call this after get_infra_overview when you need column-level detail to write a SQL query, DynamoDB expression, or MongoDB filter for specific tables — instead of pulling every schema with get_graph_summary. Do NOT call for a table inventory; use get_infra_overview for that. Row data is never included. DynamoDB matches include a costSignal note for provisioned-capacity tables.',
|
|
492
497
|
inputSchema: z.object({
|
|
493
498
|
tables: z
|
|
494
499
|
.array(z.string())
|
|
@@ -518,17 +523,25 @@ export function createMcpServer() {
|
|
|
518
523
|
return {
|
|
519
524
|
requested,
|
|
520
525
|
found: true,
|
|
521
|
-
matches: matches.map((t) =>
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
526
|
+
matches: matches.map((t) => {
|
|
527
|
+
const costSignal = t.databaseType === 'dynamodb' ? dynamoCostSignal(t) : undefined;
|
|
528
|
+
return {
|
|
529
|
+
name: t.name,
|
|
530
|
+
databaseType: t.databaseType,
|
|
531
|
+
...(t.columns ? { columns: t.columns } : {}),
|
|
532
|
+
...(t.primaryKeys?.length ? { primaryKeys: t.primaryKeys } : {}),
|
|
533
|
+
...(t.foreignKeys?.length ? { foreignKeys: t.foreignKeys } : {}),
|
|
534
|
+
...(t.partitionKey ? { partitionKey: t.partitionKey } : {}),
|
|
535
|
+
...(t.sortKey ? { sortKey: t.sortKey } : {}),
|
|
536
|
+
...(t.estimatedCount !== undefined ? { estimatedCount: t.estimatedCount } : {}),
|
|
537
|
+
...(t.billingMode ? { billingMode: t.billingMode } : {}),
|
|
538
|
+
...(t.provisionedThroughput
|
|
539
|
+
? { provisionedThroughput: t.provisionedThroughput }
|
|
540
|
+
: {}),
|
|
541
|
+
...(costSignal ? { costSignal } : {}),
|
|
542
|
+
indexes: indexNamesFor(t.id),
|
|
543
|
+
};
|
|
544
|
+
}),
|
|
532
545
|
};
|
|
533
546
|
});
|
|
534
547
|
return toText({
|
|
@@ -537,7 +550,7 @@ export function createMcpServer() {
|
|
|
537
550
|
});
|
|
538
551
|
}));
|
|
539
552
|
mcp.registerTool('get_cache_overview', {
|
|
540
|
-
description: 'Returns all ElastiCache clusters with engine, version, node type, node count, in-transit and at-rest encryption status, replication group, and automatic failover state. Call this before writing cache client code (TLS is required when transit encryption is on — rediss:// for Redis) or when reviewing cache availability and security posture. Cached data is never read or included.',
|
|
553
|
+
description: 'Returns all ElastiCache clusters with engine, version, node type, node count, in-transit and at-rest encryption status, replication group, and automatic failover state. Call this before writing cache client code (TLS is required when transit encryption is on — rediss:// for Redis) or when reviewing cache availability and security posture. Cached data is never read or included. A costSignal note appears on clusters with more than 3 nodes.',
|
|
541
554
|
inputSchema: z.object({}),
|
|
542
555
|
}, logged('get_cache_overview', async () => {
|
|
543
556
|
const caches = getCacheClusterNodes(currentGraph);
|
|
@@ -545,20 +558,24 @@ export function createMcpServer() {
|
|
|
545
558
|
return toText({
|
|
546
559
|
total: caches.length,
|
|
547
560
|
note: 'Cached data is never included.',
|
|
548
|
-
caches: caches.map((c) =>
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
561
|
+
caches: caches.map((c) => {
|
|
562
|
+
const costSignal = cacheCostSignal(c);
|
|
563
|
+
return {
|
|
564
|
+
id: c.name,
|
|
565
|
+
engine: c.engine,
|
|
566
|
+
engineVersion: c.engineVersion,
|
|
567
|
+
nodeType: c.nodeType,
|
|
568
|
+
numNodes: c.numNodes,
|
|
569
|
+
transitEncryption: c.transitEncryption,
|
|
570
|
+
atRestEncryption: c.atRestEncryption,
|
|
571
|
+
replicationGroupId: c.replicationGroupId,
|
|
572
|
+
automaticFailover: c.automaticFailover,
|
|
573
|
+
...(costSignal ? { costSignal } : {}),
|
|
574
|
+
findings: cacheFindings
|
|
575
|
+
.filter((f) => f.metadata.cacheClusterId === c.name)
|
|
576
|
+
.map((f) => ({ severity: f.severity, issue: f.issue })),
|
|
577
|
+
};
|
|
578
|
+
}),
|
|
562
579
|
});
|
|
563
580
|
}));
|
|
564
581
|
mcp.registerTool('get_stream_details', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infrawise",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"mcpName": "io.github.Sidd27/infrawise",
|
|
5
5
|
"description": "CLI-first infrastructure intelligence platform — analyzes DynamoDB, PostgreSQL, MySQL, MongoDB, SQS, SNS, SSM, Secrets Manager, Lambda, S3, API Gateway, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
|
|
6
6
|
"keywords": [
|