infrawise 0.16.0 → 0.17.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
@@ -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
@@ -234,13 +234,15 @@ function servicesFromDoc(doc) {
234
234
  }
235
235
  async function extractAllowedServices(roleArn, cfg) {
236
236
  const client = new IAMClient(clientConfig(cfg));
237
- const roleName = roleArn.split('/').pop();
237
+ const roleName = roleArn.split('/').pop() ?? roleArn;
238
238
  const services = new Set();
239
239
  try {
240
240
  let marker;
241
241
  do {
242
242
  const res = await client.send(new ListAttachedRolePoliciesCommand({ RoleName: roleName, Marker: marker }));
243
243
  for (const policy of res.AttachedPolicies ?? []) {
244
+ if (!policy.PolicyArn)
245
+ continue;
244
246
  try {
245
247
  const meta = await client.send(new GetPolicyCommand({ PolicyArn: policy.PolicyArn }));
246
248
  const versionId = meta.Policy?.DefaultVersionId;
@@ -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',
@@ -571,11 +572,56 @@ function detectAWSServiceOperations(callExpr, filePath) {
571
572
  }
572
573
  return null;
573
574
  }
575
+ const PROBE_EXCLUDED_DIRS = new Set([
576
+ 'node_modules',
577
+ 'venv',
578
+ '.venv',
579
+ 'site-packages',
580
+ '__pycache__',
581
+ 'dist',
582
+ ]);
583
+ function detectLanguages(root) {
584
+ let hasTypeScript = fs.existsSync(path.join(root, 'tsconfig.json'));
585
+ let hasPython = false;
586
+ const stack = [root];
587
+ while (stack.length > 0 && !(hasTypeScript && hasPython)) {
588
+ const dir = stack.pop();
589
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
590
+ if (entry.isDirectory()) {
591
+ if (!PROBE_EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
592
+ stack.push(path.join(dir, entry.name));
593
+ }
594
+ }
595
+ else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
596
+ hasTypeScript = true;
597
+ }
598
+ else if (entry.name.endsWith('.py')) {
599
+ hasPython = true;
600
+ }
601
+ if (hasTypeScript && hasPython)
602
+ break;
603
+ }
604
+ }
605
+ return { hasTypeScript, hasPython };
606
+ }
574
607
  export async function scanRepository(repoPath) {
575
608
  const resolvedPath = path.resolve(repoPath);
576
609
  if (!fs.existsSync(resolvedPath)) {
577
610
  throw new RepositoryScanError(`Path does not exist: ${resolvedPath}`);
578
611
  }
612
+ const { hasTypeScript, hasPython } = detectLanguages(resolvedPath);
613
+ const detected = [hasTypeScript && 'TypeScript', hasPython && 'Python'].filter(Boolean);
614
+ logger.info(`Detected languages: ${detected.length > 0 ? detected.join(', ') : 'none'}`);
615
+ const operations = [];
616
+ if (hasTypeScript) {
617
+ operations.push(...scanTypeScriptRepository(resolvedPath));
618
+ }
619
+ if (hasPython) {
620
+ operations.push(...(await scanPythonRepository(resolvedPath)));
621
+ }
622
+ return operations;
623
+ }
624
+ function scanTypeScriptRepository(resolvedPath) {
579
625
  const tsconfigPath = path.join(resolvedPath, 'tsconfig.json');
580
626
  const hasTsConfig = fs.existsSync(tsconfigPath);
581
627
  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,310 @@
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
+ class Visitor(ast.NodeVisitor):
73
+ def __init__(self, file_path):
74
+ self.file_path = file_path
75
+ self.ops = []
76
+ self.stack = []
77
+ self.strings = {}
78
+ self.boto_clients = {}
79
+ self.dynamo_tables = {}
80
+ self.collections = {}
81
+
82
+ def function_name(self):
83
+ return self.stack[-1] if self.stack else '<module>'
84
+
85
+ def add(self, op_type, service, target):
86
+ self.ops.append({
87
+ 'functionName': self.function_name(),
88
+ 'operationType': op_type,
89
+ 'serviceType': service,
90
+ 'target': target,
91
+ 'filePath': self.file_path,
92
+ })
93
+
94
+ def visit_FunctionDef(self, node):
95
+ self.stack.append(node.name)
96
+ self.generic_visit(node)
97
+ self.stack.pop()
98
+
99
+ visit_AsyncFunctionDef = visit_FunctionDef
100
+
101
+ def resolve_string(self, node):
102
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
103
+ return node.value
104
+ if isinstance(node, ast.Name):
105
+ return self.strings.get(node.id)
106
+ if isinstance(node, ast.JoinedStr):
107
+ parts = []
108
+ for value in node.values:
109
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
110
+ parts.append(value.value)
111
+ elif isinstance(value, ast.FormattedValue):
112
+ resolved = self.resolve_string(value.value)
113
+ if resolved is None:
114
+ return None
115
+ parts.append(resolved)
116
+ else:
117
+ return None
118
+ return ''.join(parts)
119
+ return None
120
+
121
+ def kwarg(self, node, keys):
122
+ for kw in node.keywords:
123
+ if kw.arg in keys:
124
+ return self.resolve_string(kw.value)
125
+ return None
126
+
127
+ def boto3_service(self, node):
128
+ if (
129
+ isinstance(node, ast.Call)
130
+ and isinstance(node.func, ast.Attribute)
131
+ and node.func.attr in ('client', 'resource')
132
+ and expr_text(node.func.value) == 'boto3'
133
+ and node.args
134
+ ):
135
+ return self.resolve_string(node.args[0])
136
+ return None
137
+
138
+ def table_name(self, node):
139
+ if (
140
+ isinstance(node, ast.Call)
141
+ and isinstance(node.func, ast.Attribute)
142
+ and node.func.attr == 'Table'
143
+ and node.args
144
+ ):
145
+ return self.resolve_string(node.args[0])
146
+ return None
147
+
148
+ def collection_name(self, node):
149
+ if isinstance(node, ast.Subscript):
150
+ base = expr_text(node.value).lower()
151
+ if any(h in base for h in MONGO_BASE_HINTS):
152
+ return self.resolve_string(node.slice)
153
+ if (
154
+ isinstance(node, ast.Call)
155
+ and isinstance(node.func, ast.Attribute)
156
+ and node.func.attr == 'get_collection'
157
+ and node.args
158
+ ):
159
+ return self.resolve_string(node.args[0])
160
+ return None
161
+
162
+ def visit_Assign(self, node):
163
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
164
+ name = node.targets[0].id
165
+ resolved = self.resolve_string(node.value)
166
+ if resolved is not None:
167
+ self.strings[name] = resolved
168
+ else:
169
+ service = self.boto3_service(node.value)
170
+ table = self.table_name(node.value)
171
+ collection = self.collection_name(node.value)
172
+ if service is not None:
173
+ self.boto_clients[name] = service
174
+ elif table is not None:
175
+ self.dynamo_tables[name] = table
176
+ elif collection is not None:
177
+ self.collections[name] = collection
178
+ self.generic_visit(node)
179
+
180
+ def visit_Call(self, node):
181
+ if isinstance(node.func, ast.Attribute):
182
+ method = node.func.attr
183
+ recv = node.func.value
184
+ recv_text = expr_text(recv).lower()
185
+ _ = (
186
+ self.detect_dynamo(node, method, recv, recv_text)
187
+ or self.detect_sql(node, method, recv_text)
188
+ or self.detect_mongo(node, method, recv, recv_text)
189
+ or self.detect_kafka(node, method, recv_text)
190
+ or self.detect_aws_client(node, method, recv, recv_text)
191
+ )
192
+ self.generic_visit(node)
193
+
194
+ def detect_dynamo(self, node, method, recv, recv_text):
195
+ if method not in DYNAMO_METHODS:
196
+ return False
197
+ if isinstance(recv, ast.Name) and recv.id in self.dynamo_tables:
198
+ self.add(method, 'dynamodb', self.dynamo_tables[recv.id])
199
+ return True
200
+ inline = self.table_name(recv)
201
+ if inline is not None:
202
+ self.add(method, 'dynamodb', inline)
203
+ return True
204
+ kw = self.kwarg(node, ('TableName',))
205
+ tracked = isinstance(recv, ast.Name) and self.boto_clients.get(recv.id) == 'dynamodb'
206
+ if kw is not None or tracked or 'dynamo' in recv_text or 'ddb' in recv_text:
207
+ self.add(method, 'dynamodb', kw if kw else 'unknown')
208
+ return True
209
+ return False
210
+
211
+ def detect_sql(self, node, method, recv_text):
212
+ if method not in SQL_METHODS:
213
+ return False
214
+ if not any(h in recv_text for h in SQL_RECEIVER_HINTS):
215
+ return False
216
+ sql = None
217
+ if node.args:
218
+ arg = node.args[0]
219
+ if (
220
+ isinstance(arg, ast.Call)
221
+ and isinstance(arg.func, ast.Name)
222
+ and arg.func.id == 'text'
223
+ and arg.args
224
+ ):
225
+ arg = arg.args[0]
226
+ sql = self.resolve_string(arg)
227
+ service = 'mysql' if any(h in recv_text for h in MYSQL_HINTS) else 'postgres'
228
+ self.add('query', service, sql_table(sql) if sql else 'unknown')
229
+ return True
230
+
231
+ def detect_mongo(self, node, method, recv, recv_text):
232
+ if method not in MONGO_METHODS:
233
+ return False
234
+ collection = None
235
+ if isinstance(recv, ast.Name) and recv.id in self.collections:
236
+ collection = self.collections[recv.id]
237
+ elif isinstance(recv, ast.Attribute):
238
+ base = expr_text(recv.value).lower()
239
+ if any(h in base for h in MONGO_BASE_HINTS):
240
+ collection = recv.attr
241
+ else:
242
+ collection = self.collection_name(recv)
243
+ if collection is None and ('collection' in recv_text or 'mongo' in recv_text):
244
+ collection = 'unknown'
245
+ if collection is None:
246
+ return False
247
+ op_type = 'scan' if method in MONGO_SCAN_METHODS else 'query'
248
+ self.add(op_type, 'mongodb', collection)
249
+ return True
250
+
251
+ def detect_kafka(self, node, method, recv_text):
252
+ if not any(h in recv_text for h in KAFKA_HINTS):
253
+ return False
254
+ if method in KAFKA_PRODUCER_METHODS:
255
+ target = self.resolve_string(node.args[0]) if node.args else None
256
+ self.add(method, 'kafka', target if target else 'unknown')
257
+ return True
258
+ if method == 'subscribe':
259
+ topics = []
260
+ if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
261
+ for element in node.args[0].elts:
262
+ resolved = self.resolve_string(element)
263
+ if resolved is not None:
264
+ topics.append(resolved)
265
+ elif node.args:
266
+ resolved = self.resolve_string(node.args[0])
267
+ if resolved is not None:
268
+ topics.append(resolved)
269
+ for topic in topics or ['unknown']:
270
+ self.add('subscribe', 'kafka', topic)
271
+ return True
272
+ return False
273
+
274
+ def detect_aws_client(self, node, method, recv, recv_text):
275
+ for service, methods, keys, hints in SERVICE_RULES:
276
+ if method not in methods:
277
+ continue
278
+ tracked = isinstance(recv, ast.Name) and self.boto_clients.get(recv.id) == service
279
+ target = self.kwarg(node, keys)
280
+ if tracked or target is not None or any(h in recv_text for h in hints):
281
+ self.add(method, service, short_name(target) if target else 'unknown')
282
+ return True
283
+ return False
284
+
285
+
286
+ def find_py_files(root):
287
+ for dirpath, dirnames, filenames in os.walk(root):
288
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith('.')]
289
+ for f in filenames:
290
+ if f.endswith('.py'):
291
+ yield os.path.join(dirpath, f)
292
+
293
+
294
+ def main():
295
+ root = os.path.abspath(sys.argv[1])
296
+ ops = []
297
+ for file_path in find_py_files(root):
298
+ try:
299
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
300
+ tree = ast.parse(fh.read())
301
+ except SyntaxError:
302
+ continue
303
+ visitor = Visitor(file_path)
304
+ visitor.visit(tree)
305
+ ops.extend(visitor.ops)
306
+ print(json.dumps(ops))
307
+
308
+
309
+ if __name__ == '__main__':
310
+ main()
@@ -501,8 +501,7 @@ export function createMcpServer() {
501
501
  const indexNamesFor = (nodeId) => currentGraph.edges
502
502
  .filter((e) => e.from === nodeId && e.type === 'uses_index')
503
503
  .map((e) => currentGraph.nodes.find((n) => n.id === e.to))
504
- .filter((n) => n?.type === 'index')
505
- .map((n) => n.name);
504
+ .flatMap((n) => (n?.type === 'index' ? [n.name] : []));
506
505
  const results = tables.map((requested) => {
507
506
  const lower = requested.toLowerCase();
508
507
  const matches = tableNodes.filter((t) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",