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 +8 -6
- package/dist/adapters/db/mongodb.js +8 -2
- package/dist/context/index.js +110 -13
- package/dist/context/python.js +42 -0
- package/dist/context/scanner.py +380 -0
- package/dist/graph/index.js +6 -0
- package/dist/server/index.js +2 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
|
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 [
|
|
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
|
|
95
|
-
|
|
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 {
|
package/dist/context/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
113
|
-
|
|
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
|
|
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()
|
package/dist/graph/index.js
CHANGED
|
@@ -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
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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 })),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infrawise",
|
|
3
|
-
"version": "0.
|
|
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",
|