infrawise 0.16.1 → 0.18.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
@@ -185,7 +185,7 @@ 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
190
  | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, execution role ARN, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), env var key names |
191
191
  | `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
@@ -427,10 +427,12 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
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
 
430
- ### Code correlation analysis (TypeScript / JavaScript)
430
+ ### Code correlation analysis (TypeScript / JavaScript / Python)
431
431
 
432
432
  Uses [ts-morph](https://ts-morph.com/) AST analysis to detect which functions call which tables and how:
433
433
 
434
+ Python repositories are scanned with a bundled stdlib-`ast` scanner (requires `python3` on PATH; skipped with a warning otherwise): boto3 clients and `dynamodb.Table()` resources, `cursor.execute` SQL, pymongo collections, and kafka-python/confluent-kafka producers and consumers. Language detection is automatic — TypeScript and Python scans each run only when matching files exist.
435
+
434
436
  | Analyzer | Severity | What it detects |
435
437
  | -------------------------- | -------- | --------------------------------------------- |
436
438
  | Full Table Scan (DynamoDB) | High | `.scan()` calls without filters |
@@ -447,7 +449,7 @@ Uses [ts-morph](https://ts-morph.com/) AST analysis to detect which functions ca
447
449
  | Pipeline: repeated table access | Medium / Verify | Same table read by 2+ functions in one service pipeline |
448
450
  | Pipeline: missing DLQ hop | Medium | Mid-pipeline queue (has producer and consumer) with no Dead Letter Queue |
449
451
 
450
- Non-TypeScript/JavaScript projects still get full value from infrastructure-level analyzers — code correlation (function-to-table mapping, N+1 patterns) is skipped.
452
+ Projects in other languages still get full value from infrastructure-level analyzers — code correlation (function-to-table mapping, N+1 patterns) currently supports TypeScript, JavaScript, and Python.
451
453
 
452
454
  The scanner supports: AWS SDK v3/v2 for DynamoDB, `pg`/Prisma/Knex for PostgreSQL, `mysql2`/Knex for MySQL, driver/Mongoose for MongoDB, AWS SDK v3 for SQS/SNS/SSM/Secrets/Lambda, and `kafkajs` for Kafka topics (producer/consumer).
453
455
 
@@ -498,7 +500,7 @@ src/
498
500
  db/ PostgreSQL, MySQL, MongoDB
499
501
  iac/ Terraform, CDK, CloudFormation (local file parsing)
500
502
  analyzers/ 34 rule-based analyzers
501
- context/ Repository scanner (ts-morph AST)
503
+ context/ Repository scanner (ts-morph AST + Python stdlib-ast subprocess)
502
504
  server/ Fastify MCP server (@modelcontextprotocol/sdk, Streamable HTTP)
503
505
  cli/ CLI commands (Commander.js)
504
506
  ```
@@ -507,7 +509,7 @@ src/
507
509
 
508
510
  ## Current limitations
509
511
 
510
- - Code-level correlation supports TypeScript and JavaScript only
512
+ - Code-level correlation supports TypeScript, JavaScript, and Python (Python requires python3 on PATH)
511
513
  - Dynamically constructed queries may not always be resolved statically
512
514
  - Runtime tracing is not yet implemented
513
515
  - Large monorepos may require future incremental analysis optimization
@@ -516,7 +518,7 @@ src/
516
518
 
517
519
  ## Roadmap
518
520
 
519
- 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.
520
522
 
521
523
  ---
522
524
 
@@ -90,9 +90,15 @@ export async function validateMongoAccess(connectionString) {
90
90
  serverSelectionTimeoutMS: 5000,
91
91
  connectTimeoutMS: 5000,
92
92
  });
93
+ // driver can exceed serverSelectionTimeoutMS when SYNs are dropped; hard-cap the probe
94
+ const deadline = new Promise((_, reject) => {
95
+ setTimeout(() => reject(new Error('validation timeout')), 6000).unref();
96
+ });
93
97
  try {
94
- await client.connect();
95
- await client.db('admin').command({ ping: 1 });
98
+ await Promise.race([
99
+ client.connect().then(() => client.db('admin').command({ ping: 1 })),
100
+ deadline,
101
+ ]);
96
102
  return true;
97
103
  }
98
104
  catch {
@@ -2,6 +2,7 @@ import * as path from 'path';
2
2
  import * as fs from 'fs';
3
3
  import { Project, SyntaxKind, Node } from 'ts-morph';
4
4
  import { RepositoryScanError, logger } from '../core/index.js';
5
+ import { scanPythonRepository } from './python.js';
5
6
  const DYNAMO_OPERATIONS = new Set([
6
7
  'query',
7
8
  'scan',
@@ -94,29 +95,76 @@ const PRISMA_METHODS = new Set([
94
95
  'deleteMany',
95
96
  'updateMany',
96
97
  ]);
97
- function getEnclosingFunctionName(node) {
98
+ function getEnclosingFunction(node) {
98
99
  let current = node.getParent();
99
100
  while (current) {
100
101
  if (Node.isFunctionDeclaration(current) ||
101
102
  Node.isFunctionExpression(current) ||
102
103
  Node.isArrowFunction(current) ||
103
104
  Node.isMethodDeclaration(current)) {
104
- if (Node.isFunctionDeclaration(current) || Node.isMethodDeclaration(current)) {
105
- return current.getName() ?? '<anonymous>';
106
- }
107
- // Arrow function or function expression — check if assigned to a variable
108
- const parent = current.getParent();
109
- if (parent && Node.isVariableDeclaration(parent)) {
110
- 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;
111
156
  }
112
- if (parent && Node.isPropertyAssignment(parent)) {
113
- 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
+ }
114
164
  }
115
- return '<anonymous>';
116
165
  }
117
- current = current.getParent();
118
166
  }
119
- return '<module>';
167
+ return keys.size > 0 ? Array.from(keys).sort() : undefined;
120
168
  }
121
169
  function extractTableNameFromArg(arg) {
122
170
  if (Node.isStringLiteral(arg)) {
@@ -504,6 +552,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
504
552
  serviceType: 'secretsmanager',
505
553
  target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...SECRETS_ARG_KEYS) : 'unknown',
506
554
  filePath,
555
+ keys: extractSecretKeys(getEnclosingFunction(callExpr)),
507
556
  };
508
557
  }
509
558
  if (LAMBDA_COMMANDS.has(className)) {
@@ -557,6 +606,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
557
606
  serviceType: 'secretsmanager',
558
607
  target: args.length > 0 ? extractArgValue(args[0], ...SECRETS_ARG_KEYS) : 'unknown',
559
608
  filePath,
609
+ keys: extractSecretKeys(getEnclosingFunction(callExpr)),
560
610
  };
561
611
  }
562
612
  if (LAMBDA_COMMANDS.has(methodName) && (objText.includes('lambda') || objText.includes('fn'))) {
@@ -571,11 +621,58 @@ function detectAWSServiceOperations(callExpr, filePath) {
571
621
  }
572
622
  return null;
573
623
  }
624
+ const PROBE_EXCLUDED_DIRS = new Set([
625
+ 'node_modules',
626
+ 'venv',
627
+ '.venv',
628
+ 'site-packages',
629
+ '__pycache__',
630
+ 'dist',
631
+ ]);
632
+ function detectLanguages(root) {
633
+ let hasTypeScript = fs.existsSync(path.join(root, 'tsconfig.json'));
634
+ let hasPython = false;
635
+ const stack = [root];
636
+ while (stack.length > 0 && !(hasTypeScript && hasPython)) {
637
+ const dir = stack.pop();
638
+ if (dir === undefined)
639
+ break;
640
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
641
+ if (entry.isDirectory()) {
642
+ if (!PROBE_EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
643
+ stack.push(path.join(dir, entry.name));
644
+ }
645
+ }
646
+ else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
647
+ hasTypeScript = true;
648
+ }
649
+ else if (entry.name.endsWith('.py')) {
650
+ hasPython = true;
651
+ }
652
+ if (hasTypeScript && hasPython)
653
+ break;
654
+ }
655
+ }
656
+ return { hasTypeScript, hasPython };
657
+ }
574
658
  export async function scanRepository(repoPath) {
575
659
  const resolvedPath = path.resolve(repoPath);
576
660
  if (!fs.existsSync(resolvedPath)) {
577
661
  throw new RepositoryScanError(`Path does not exist: ${resolvedPath}`);
578
662
  }
663
+ const { hasTypeScript, hasPython } = detectLanguages(resolvedPath);
664
+ const detected = [hasTypeScript && 'TypeScript', hasPython && 'Python'].filter(Boolean);
665
+ logger.info(`Detected languages: ${detected.length > 0 ? detected.join(', ') : 'none'}`);
666
+ const operations = [];
667
+ if (hasTypeScript) {
668
+ operations.push(...scanTypeScriptRepository(resolvedPath));
669
+ }
670
+ if (hasPython) {
671
+ operations.push(...(await scanPythonRepository(resolvedPath)));
672
+ }
673
+ return operations;
674
+ }
675
+ function scanTypeScriptRepository(resolvedPath) {
579
676
  const tsconfigPath = path.join(resolvedPath, 'tsconfig.json');
580
677
  const hasTsConfig = fs.existsSync(tsconfigPath);
581
678
  const project = new Project({
@@ -0,0 +1,42 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import * as path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { logger } from '../core/index.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const SCANNER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'scanner.py');
8
+ let cachedInterpreter;
9
+ async function findPythonInterpreter() {
10
+ if (cachedInterpreter !== undefined)
11
+ return cachedInterpreter;
12
+ for (const cmd of ['python3', 'python', 'py']) {
13
+ try {
14
+ await execFileAsync(cmd, ['--version']);
15
+ cachedInterpreter = cmd;
16
+ return cmd;
17
+ }
18
+ catch {
19
+ // try next candidate
20
+ }
21
+ }
22
+ cachedInterpreter = null;
23
+ return null;
24
+ }
25
+ export async function scanPythonRepository(repoPath) {
26
+ const interpreter = await findPythonInterpreter();
27
+ if (interpreter === null) {
28
+ logger.warn('Python files found but no python3 on PATH — skipping Python scan');
29
+ return [];
30
+ }
31
+ try {
32
+ const { stdout } = await execFileAsync(interpreter, [SCANNER_PATH, repoPath], {
33
+ maxBuffer: 64 * 1024 * 1024,
34
+ });
35
+ return JSON.parse(stdout);
36
+ }
37
+ catch (err) {
38
+ const message = err instanceof Error ? err.message.slice(0, 200) : String(err);
39
+ logger.warn(`Python scan failed — skipping (${message})`);
40
+ return [];
41
+ }
42
+ }
@@ -0,0 +1,380 @@
1
+ import ast
2
+ import json
3
+ import os
4
+ import re
5
+ import sys
6
+
7
+ EXCLUDED_DIRS = {'node_modules', 'venv', '.venv', 'site-packages', '__pycache__', 'dist'}
8
+
9
+ SQS_METHODS = {'send_message', 'send_message_batch', 'receive_message', 'delete_message'}
10
+ SNS_METHODS = {'publish', 'publish_batch'}
11
+ SSM_METHODS = {'get_parameter', 'get_parameters', 'get_parameters_by_path'}
12
+ SECRETS_METHODS = {'get_secret_value'}
13
+ LAMBDA_METHODS = {'invoke', 'invoke_async'}
14
+ DYNAMO_METHODS = {'query', 'scan', 'get_item', 'put_item', 'update_item', 'delete_item',
15
+ 'batch_get_item', 'batch_write_item'}
16
+ SQL_METHODS = {'execute', 'executemany'}
17
+ SQL_RECEIVER_HINTS = ('cursor', 'cur', 'conn', 'connection', 'session', 'db', 'pool',
18
+ 'pg', 'psycopg', 'asyncpg', 'engine', 'mysql', 'pymysql', 'maria')
19
+ MYSQL_HINTS = ('mysql', 'pymysql', 'maria')
20
+ SQL_TABLE_PATTERNS = [
21
+ re.compile(r'FROM\s+["\']?(\w+)["\']?', re.IGNORECASE),
22
+ re.compile(r'INTO\s+["\']?(\w+)["\']?', re.IGNORECASE),
23
+ re.compile(r'UPDATE\s+["\']?(\w+)["\']?', re.IGNORECASE),
24
+ re.compile(r'JOIN\s+["\']?(\w+)["\']?', re.IGNORECASE),
25
+ ]
26
+
27
+ MONGO_METHODS = {'find', 'find_one', 'insert_one', 'insert_many', 'update_one', 'update_many',
28
+ 'delete_one', 'delete_many', 'aggregate', 'count_documents',
29
+ 'estimated_document_count'}
30
+ MONGO_SCAN_METHODS = {'find', 'aggregate'}
31
+ MONGO_BASE_HINTS = ('db', 'mongo')
32
+ KAFKA_PRODUCER_METHODS = {'send', 'produce'}
33
+ KAFKA_HINTS = ('kafka', 'producer', 'consumer')
34
+
35
+ SERVICE_RULES = [
36
+ ('sqs', SQS_METHODS, ('QueueUrl', 'QueueName'), ('sqs', 'queue')),
37
+ ('sns', SNS_METHODS, ('TopicArn', 'TargetArn'), ('sns', 'topic')),
38
+ ('ssm', SSM_METHODS, ('Name', 'Path'), ('ssm', 'parameter')),
39
+ ('secretsmanager', SECRETS_METHODS, ('SecretId',), ('secret',)),
40
+ ('lambda', LAMBDA_METHODS, ('FunctionName',), ('lambda', 'fn')),
41
+ ]
42
+
43
+
44
+ def expr_text(node):
45
+ if isinstance(node, ast.Name):
46
+ return node.id
47
+ if isinstance(node, ast.Attribute):
48
+ return expr_text(node.value) + '.' + node.attr
49
+ if isinstance(node, ast.Call):
50
+ return expr_text(node.func) + '()'
51
+ if isinstance(node, ast.Subscript):
52
+ return expr_text(node.value) + '[]'
53
+ return ''
54
+
55
+
56
+ def short_name(val):
57
+ if val.startswith('http://') or val.startswith('https://'):
58
+ return val.rstrip('/').rsplit('/', 1)[-1]
59
+ if ':' in val:
60
+ return val.rsplit(':', 1)[-1]
61
+ return val
62
+
63
+
64
+ def sql_table(sql):
65
+ for pattern in SQL_TABLE_PATTERNS:
66
+ match = pattern.search(sql)
67
+ if match:
68
+ return match.group(1)
69
+ return 'unknown'
70
+
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
+
99
+ class Visitor(ast.NodeVisitor):
100
+ def __init__(self, file_path):
101
+ self.file_path = file_path
102
+ self.ops = []
103
+ self.stack = []
104
+ self.strings = {}
105
+ self.boto_clients = {}
106
+ self.dynamo_tables = {}
107
+ self.collections = {}
108
+
109
+ def function_name(self):
110
+ return self.stack[-1] if self.stack else '<module>'
111
+
112
+ def add(self, op_type, service, target):
113
+ self.ops.append({
114
+ 'functionName': self.function_name(),
115
+ 'operationType': op_type,
116
+ 'serviceType': service,
117
+ 'target': target,
118
+ 'filePath': self.file_path,
119
+ })
120
+
121
+ def visit_FunctionDef(self, node):
122
+ self.stack.append(node.name)
123
+ start = len(self.ops)
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
130
+ self.stack.pop()
131
+
132
+ visit_AsyncFunctionDef = visit_FunctionDef
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
+
171
+ def resolve_string(self, node):
172
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
173
+ return node.value
174
+ if isinstance(node, ast.Name):
175
+ return self.strings.get(node.id)
176
+ if isinstance(node, ast.JoinedStr):
177
+ parts = []
178
+ for value in node.values:
179
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
180
+ parts.append(value.value)
181
+ elif isinstance(value, ast.FormattedValue):
182
+ resolved = self.resolve_string(value.value)
183
+ if resolved is None:
184
+ return None
185
+ parts.append(resolved)
186
+ else:
187
+ return None
188
+ return ''.join(parts)
189
+ return None
190
+
191
+ def kwarg(self, node, keys):
192
+ for kw in node.keywords:
193
+ if kw.arg in keys:
194
+ return self.resolve_string(kw.value)
195
+ return None
196
+
197
+ def boto3_service(self, node):
198
+ if (
199
+ isinstance(node, ast.Call)
200
+ and isinstance(node.func, ast.Attribute)
201
+ and node.func.attr in ('client', 'resource')
202
+ and expr_text(node.func.value) == 'boto3'
203
+ and node.args
204
+ ):
205
+ return self.resolve_string(node.args[0])
206
+ return None
207
+
208
+ def table_name(self, node):
209
+ if (
210
+ isinstance(node, ast.Call)
211
+ and isinstance(node.func, ast.Attribute)
212
+ and node.func.attr == 'Table'
213
+ and node.args
214
+ ):
215
+ return self.resolve_string(node.args[0])
216
+ return None
217
+
218
+ def collection_name(self, node):
219
+ if isinstance(node, ast.Subscript):
220
+ base = expr_text(node.value).lower()
221
+ if any(h in base for h in MONGO_BASE_HINTS):
222
+ return self.resolve_string(node.slice)
223
+ if (
224
+ isinstance(node, ast.Call)
225
+ and isinstance(node.func, ast.Attribute)
226
+ and node.func.attr == 'get_collection'
227
+ and node.args
228
+ ):
229
+ return self.resolve_string(node.args[0])
230
+ return None
231
+
232
+ def visit_Assign(self, node):
233
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
234
+ name = node.targets[0].id
235
+ resolved = self.resolve_string(node.value)
236
+ if resolved is not None:
237
+ self.strings[name] = resolved
238
+ else:
239
+ service = self.boto3_service(node.value)
240
+ table = self.table_name(node.value)
241
+ collection = self.collection_name(node.value)
242
+ if service is not None:
243
+ self.boto_clients[name] = service
244
+ elif table is not None:
245
+ self.dynamo_tables[name] = table
246
+ elif collection is not None:
247
+ self.collections[name] = collection
248
+ self.generic_visit(node)
249
+
250
+ def visit_Call(self, node):
251
+ if isinstance(node.func, ast.Attribute):
252
+ method = node.func.attr
253
+ recv = node.func.value
254
+ recv_text = expr_text(recv).lower()
255
+ _ = (
256
+ self.detect_dynamo(node, method, recv, recv_text)
257
+ or self.detect_sql(node, method, recv_text)
258
+ or self.detect_mongo(node, method, recv, recv_text)
259
+ or self.detect_kafka(node, method, recv_text)
260
+ or self.detect_aws_client(node, method, recv, recv_text)
261
+ )
262
+ self.generic_visit(node)
263
+
264
+ def detect_dynamo(self, node, method, recv, recv_text):
265
+ if method not in DYNAMO_METHODS:
266
+ return False
267
+ if isinstance(recv, ast.Name) and recv.id in self.dynamo_tables:
268
+ self.add(method, 'dynamodb', self.dynamo_tables[recv.id])
269
+ return True
270
+ inline = self.table_name(recv)
271
+ if inline is not None:
272
+ self.add(method, 'dynamodb', inline)
273
+ return True
274
+ kw = self.kwarg(node, ('TableName',))
275
+ tracked = isinstance(recv, ast.Name) and self.boto_clients.get(recv.id) == 'dynamodb'
276
+ if kw is not None or tracked or 'dynamo' in recv_text or 'ddb' in recv_text:
277
+ self.add(method, 'dynamodb', kw if kw else 'unknown')
278
+ return True
279
+ return False
280
+
281
+ def detect_sql(self, node, method, recv_text):
282
+ if method not in SQL_METHODS:
283
+ return False
284
+ if not any(h in recv_text for h in SQL_RECEIVER_HINTS):
285
+ return False
286
+ sql = None
287
+ if node.args:
288
+ arg = node.args[0]
289
+ if (
290
+ isinstance(arg, ast.Call)
291
+ and isinstance(arg.func, ast.Name)
292
+ and arg.func.id == 'text'
293
+ and arg.args
294
+ ):
295
+ arg = arg.args[0]
296
+ sql = self.resolve_string(arg)
297
+ service = 'mysql' if any(h in recv_text for h in MYSQL_HINTS) else 'postgres'
298
+ self.add('query', service, sql_table(sql) if sql else 'unknown')
299
+ return True
300
+
301
+ def detect_mongo(self, node, method, recv, recv_text):
302
+ if method not in MONGO_METHODS:
303
+ return False
304
+ collection = None
305
+ if isinstance(recv, ast.Name) and recv.id in self.collections:
306
+ collection = self.collections[recv.id]
307
+ elif isinstance(recv, ast.Attribute):
308
+ base = expr_text(recv.value).lower()
309
+ if any(h in base for h in MONGO_BASE_HINTS):
310
+ collection = recv.attr
311
+ else:
312
+ collection = self.collection_name(recv)
313
+ if collection is None and ('collection' in recv_text or 'mongo' in recv_text):
314
+ collection = 'unknown'
315
+ if collection is None:
316
+ return False
317
+ op_type = 'scan' if method in MONGO_SCAN_METHODS else 'query'
318
+ self.add(op_type, 'mongodb', collection)
319
+ return True
320
+
321
+ def detect_kafka(self, node, method, recv_text):
322
+ if not any(h in recv_text for h in KAFKA_HINTS):
323
+ return False
324
+ if method in KAFKA_PRODUCER_METHODS:
325
+ target = self.resolve_string(node.args[0]) if node.args else None
326
+ self.add(method, 'kafka', target if target else 'unknown')
327
+ return True
328
+ if method == 'subscribe':
329
+ topics = []
330
+ if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
331
+ for element in node.args[0].elts:
332
+ resolved = self.resolve_string(element)
333
+ if resolved is not None:
334
+ topics.append(resolved)
335
+ elif node.args:
336
+ resolved = self.resolve_string(node.args[0])
337
+ if resolved is not None:
338
+ topics.append(resolved)
339
+ for topic in topics or ['unknown']:
340
+ self.add('subscribe', 'kafka', topic)
341
+ return True
342
+ return False
343
+
344
+ def detect_aws_client(self, node, method, recv, recv_text):
345
+ for service, methods, keys, hints in SERVICE_RULES:
346
+ if method not in methods:
347
+ continue
348
+ tracked = isinstance(recv, ast.Name) and self.boto_clients.get(recv.id) == service
349
+ target = self.kwarg(node, keys)
350
+ if tracked or target is not None or any(h in recv_text for h in hints):
351
+ self.add(method, service, short_name(target) if target else 'unknown')
352
+ return True
353
+ return False
354
+
355
+
356
+ def find_py_files(root):
357
+ for dirpath, dirnames, filenames in os.walk(root):
358
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith('.')]
359
+ for f in filenames:
360
+ if f.endswith('.py'):
361
+ yield os.path.join(dirpath, f)
362
+
363
+
364
+ def main():
365
+ root = os.path.abspath(sys.argv[1])
366
+ ops = []
367
+ for file_path in find_py_files(root):
368
+ try:
369
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
370
+ tree = ast.parse(fh.read())
371
+ except SyntaxError:
372
+ continue
373
+ visitor = Visitor(file_path)
374
+ visitor.visit(tree)
375
+ ops.extend(visitor.ops)
376
+ print(json.dumps(ops))
377
+
378
+
379
+ if __name__ == '__main__':
380
+ main()
@@ -386,6 +386,12 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
386
386
  provider: 'aws',
387
387
  rotationEnabled: false,
388
388
  });
389
+ if (op.keys && op.keys.length > 0) {
390
+ const node = nodeMap.get(secretId);
391
+ if (node && node.type === 'secret') {
392
+ node.referencedKeys = Array.from(new Set([...(node.referencedKeys ?? []), ...op.keys])).sort();
393
+ }
394
+ }
389
395
  edges.push({ from: funcNodeId, to: secretId, type: 'reads_secret' });
390
396
  continue;
391
397
  }
@@ -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 })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.16.1",
3
+ "version": "0.18.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": [
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "packageManager": "pnpm@10.14.0",
58
58
  "scripts": {
59
- "build": "tsc --noEmit false --outDir dist",
59
+ "build": "tsc --noEmit false --outDir dist && cp src/context/scanner.py dist/context/scanner.py",
60
60
  "test": "vitest run --passWithNoTests",
61
61
  "coverage": "vitest run --coverage",
62
62
  "lint": "eslint src",