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 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 and rotation status (values never included) |
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/ 34 rule-based 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 [GitHub Project](https://github.com/users/Sidd27/projects/1). Feature requests and upvotes welcome.
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
- return { tableName, partitionKey, sortKey, indexes };
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
+ }
@@ -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
@@ -95,29 +95,76 @@ const PRISMA_METHODS = new Set([
95
95
  'deleteMany',
96
96
  'updateMany',
97
97
  ]);
98
- function getEnclosingFunctionName(node) {
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
- if (Node.isFunctionDeclaration(current) || Node.isMethodDeclaration(current)) {
106
- return current.getName() ?? '<anonymous>';
107
- }
108
- // Arrow function or function expression — check if assigned to a variable
109
- const parent = current.getParent();
110
- if (parent && Node.isVariableDeclaration(parent)) {
111
- return parent.getName();
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 (parent && Node.isPropertyAssignment(parent)) {
114
- return parent.getName();
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 '<module>';
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('.')) {
@@ -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
@@ -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
  }
@@ -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 rotation interval. Secret values are never returned. Call this when checking which secrets exist, confirming rotation is enabled before a security review, or identifying secrets that lack rotation.',
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
- name: l.name,
393
- runtime: l.runtime,
394
- memoryMB: l.memoryMB,
395
- timeoutSec: l.timeoutSec,
396
- envVarCount: l.envVarKeys?.length ?? 0,
397
- envVarKeys: l.envVarKeys,
398
- roleArn: l.roleArn,
399
- recentThrottles: l.recentThrottles,
400
- recentErrors: l.recentErrors,
401
- triggers: (l.triggers ?? []).map((t) => ({
402
- type: t.type,
403
- source: t.sourceName,
404
- eventShape: t.eventShape,
405
- state: t.state,
406
- })),
407
- findings: lambdaFindings
408
- .filter((f) => f.metadata.functionName === l.name)
409
- .map((f) => ({ severity: f.severity, issue: f.issue })),
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
- name: t.name,
523
- databaseType: t.databaseType,
524
- ...(t.columns ? { columns: t.columns } : {}),
525
- ...(t.primaryKeys?.length ? { primaryKeys: t.primaryKeys } : {}),
526
- ...(t.foreignKeys?.length ? { foreignKeys: t.foreignKeys } : {}),
527
- ...(t.partitionKey ? { partitionKey: t.partitionKey } : {}),
528
- ...(t.sortKey ? { sortKey: t.sortKey } : {}),
529
- ...(t.estimatedCount !== undefined ? { estimatedCount: t.estimatedCount } : {}),
530
- indexes: indexNamesFor(t.id),
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
- id: c.name,
550
- engine: c.engine,
551
- engineVersion: c.engineVersion,
552
- nodeType: c.nodeType,
553
- numNodes: c.numNodes,
554
- transitEncryption: c.transitEncryption,
555
- atRestEncryption: c.atRestEncryption,
556
- replicationGroupId: c.replicationGroupId,
557
- automaticFailover: c.automaticFailover,
558
- findings: cacheFindings
559
- .filter((f) => f.metadata.cacheClusterId === c.name)
560
- .map((f) => ({ severity: f.severity, issue: f.issue })),
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.17.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": [