infrawise 0.12.6 → 0.13.1
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 +3 -3
- package/dist/adapters/aws/services.js +83 -0
- package/dist/analyzers/aws-services.js +64 -0
- package/dist/analyzers/index.js +1 -1
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/serve.js +4 -4
- package/dist/cli/commands/stdio.js +4 -4
- package/dist/core/cache.js +14 -0
- package/dist/core/index.js +1 -1
- package/dist/graph/index.js +2 -0
- package/dist/server/index.js +50 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -149,9 +149,9 @@ Add to your editor's MCP config:
|
|
|
149
149
|
|
|
150
150
|
| Tool | What it provides |
|
|
151
151
|
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
152
|
-
| `get_infra_overview` | Complete snapshot —
|
|
152
|
+
| `get_infra_overview` | Complete snapshot — services, counts, high-severity findings, analysis `freshness` (age + stale flag), `configured` flag |
|
|
153
153
|
| `get_graph_summary` | Full infrastructure graph — all nodes, edges, and findings |
|
|
154
|
-
| `analyze_function` | Issues in a specific function — scans, missing indexes, N+1, trigger event shapes
|
|
154
|
+
| `analyze_function` | Issues in a specific function — scans, missing indexes, N+1, trigger event shapes, missing IAM permissions |
|
|
155
155
|
| `suggest_gsi` | Exact GSI config for a DynamoDB table + attribute |
|
|
156
156
|
| `postgres_index_suggestions` | Exact `CREATE INDEX` SQL for your actual table |
|
|
157
157
|
| `suggest_mongo_index` | Exact `createIndex` command for a MongoDB collection + field |
|
|
@@ -161,7 +161,7 @@ Add to your editor's MCP config:
|
|
|
161
161
|
| `get_topic_details` | SNS topics — subscription counts, protocols, and filter policies (required message attributes per subscription) |
|
|
162
162
|
| `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
|
|
163
163
|
| `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
|
|
164
|
-
| `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), env var key names |
|
|
164
|
+
| `get_lambda_overview` | Lambda functions — runtime, memory, timeout, execution role ARN, triggers (SQS/SNS/DynamoDB/Kinesis/MSK/EventBridge/S3), env var key names |
|
|
165
165
|
| `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
|
|
166
166
|
| `get_s3_overview` | S3 buckets — versioning, encryption, public access, event notifications |
|
|
167
167
|
| `get_log_errors` | CloudWatch error patterns and counts (no raw log messages) |
|
|
@@ -7,6 +7,7 @@ import { SecretsManagerClient, ListSecretsCommand } from '@aws-sdk/client-secret
|
|
|
7
7
|
import { LambdaClient, ListFunctionsCommand, ListEventSourceMappingsCommand, } from '@aws-sdk/client-lambda';
|
|
8
8
|
import { EventBridgeClient, ListRulesCommand, ListTargetsByRuleCommand, } from '@aws-sdk/client-eventbridge';
|
|
9
9
|
import { RDSClient, DescribeDBInstancesCommand } from '@aws-sdk/client-rds';
|
|
10
|
+
import { IAMClient, ListAttachedRolePoliciesCommand, GetPolicyCommand, GetPolicyVersionCommand, ListRolePoliciesCommand, GetRolePolicyCommand, } from '@aws-sdk/client-iam';
|
|
10
11
|
import { fromIni } from '@aws-sdk/credential-providers';
|
|
11
12
|
import { logger } from '../../core/index.js';
|
|
12
13
|
function clientConfig(cfg) {
|
|
@@ -209,6 +210,77 @@ export async function extractSecretsMetadata(cfg = {}) {
|
|
|
209
210
|
export async function validateSecretsAccess(cfg = {}) {
|
|
210
211
|
await new SecretsManagerClient(clientConfig(cfg)).send(new ListSecretsCommand({ MaxResults: 1 }));
|
|
211
212
|
}
|
|
213
|
+
function servicesFromDoc(doc) {
|
|
214
|
+
const out = new Set();
|
|
215
|
+
for (const stmt of doc.Statement ?? []) {
|
|
216
|
+
if (stmt.Effect !== 'Allow')
|
|
217
|
+
continue;
|
|
218
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : stmt.Action ? [stmt.Action] : [];
|
|
219
|
+
for (const a of actions) {
|
|
220
|
+
if (a === '*') {
|
|
221
|
+
out.add('*');
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const prefix = a.split(':')[0].toLowerCase();
|
|
225
|
+
if (prefix)
|
|
226
|
+
out.add(prefix);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return [...out];
|
|
230
|
+
}
|
|
231
|
+
async function extractAllowedServices(roleArn, cfg) {
|
|
232
|
+
const client = new IAMClient(clientConfig(cfg));
|
|
233
|
+
const roleName = roleArn.split('/').pop();
|
|
234
|
+
const services = new Set();
|
|
235
|
+
try {
|
|
236
|
+
let marker;
|
|
237
|
+
do {
|
|
238
|
+
const res = await client.send(new ListAttachedRolePoliciesCommand({ RoleName: roleName, Marker: marker }));
|
|
239
|
+
for (const policy of res.AttachedPolicies ?? []) {
|
|
240
|
+
try {
|
|
241
|
+
const meta = await client.send(new GetPolicyCommand({ PolicyArn: policy.PolicyArn }));
|
|
242
|
+
const versionId = meta.Policy?.DefaultVersionId;
|
|
243
|
+
if (!versionId)
|
|
244
|
+
continue;
|
|
245
|
+
const ver = await client.send(new GetPolicyVersionCommand({ PolicyArn: policy.PolicyArn, VersionId: versionId }));
|
|
246
|
+
const doc = ver.PolicyVersion?.Document;
|
|
247
|
+
if (doc) {
|
|
248
|
+
const parsed = JSON.parse(decodeURIComponent(doc));
|
|
249
|
+
for (const s of servicesFromDoc(parsed))
|
|
250
|
+
services.add(s);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* skip unparseable policy */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
marker = res.Marker;
|
|
258
|
+
} while (marker);
|
|
259
|
+
let iMarker;
|
|
260
|
+
do {
|
|
261
|
+
const res = await client.send(new ListRolePoliciesCommand({ RoleName: roleName, Marker: iMarker }));
|
|
262
|
+
for (const name of res.PolicyNames ?? []) {
|
|
263
|
+
try {
|
|
264
|
+
const inline = await client.send(new GetRolePolicyCommand({ RoleName: roleName, PolicyName: name }));
|
|
265
|
+
if (inline.PolicyDocument) {
|
|
266
|
+
const parsed = JSON.parse(decodeURIComponent(inline.PolicyDocument));
|
|
267
|
+
for (const s of servicesFromDoc(parsed))
|
|
268
|
+
services.add(s);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* skip */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
iMarker = res.Marker;
|
|
276
|
+
} while (iMarker);
|
|
277
|
+
return [...services];
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
logger.debug(`IAM fetch skipped for ${roleName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
212
284
|
// ─── Lambda ───────────────────────────────────────────────────────────────────
|
|
213
285
|
const EVENT_SHAPES = {
|
|
214
286
|
sqs: 'event.Records[0].body',
|
|
@@ -319,6 +391,7 @@ export async function extractLambdaMetadata(cfg = {}, includeFunctions) {
|
|
|
319
391
|
envVarKeys: Object.keys(fn.Environment?.Variables ?? {}),
|
|
320
392
|
layers: (fn.Layers ?? []).map((l) => l.Arn ?? '').filter(Boolean),
|
|
321
393
|
triggers: [],
|
|
394
|
+
roleArn: fn.Role,
|
|
322
395
|
});
|
|
323
396
|
}
|
|
324
397
|
marker = res.NextMarker;
|
|
@@ -328,6 +401,16 @@ export async function extractLambdaMetadata(cfg = {}, includeFunctions) {
|
|
|
328
401
|
for (const fn of functions) {
|
|
329
402
|
fn.triggers = triggerMap.get(fn.arn) ?? [];
|
|
330
403
|
}
|
|
404
|
+
// Batch IAM policy fetch per unique role ARN
|
|
405
|
+
const uniqueRoles = [...new Set(functions.map((f) => f.roleArn).filter(Boolean))];
|
|
406
|
+
const roleServices = new Map();
|
|
407
|
+
await Promise.all(uniqueRoles.map(async (arn) => {
|
|
408
|
+
roleServices.set(arn, await extractAllowedServices(arn, cfg));
|
|
409
|
+
}));
|
|
410
|
+
for (const fn of functions) {
|
|
411
|
+
if (fn.roleArn)
|
|
412
|
+
fn.allowedServices = roleServices.get(fn.roleArn);
|
|
413
|
+
}
|
|
331
414
|
}
|
|
332
415
|
catch (err) {
|
|
333
416
|
logger.warn(`Lambda list failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -284,3 +284,67 @@ export class S3UnencryptedAnalyzer {
|
|
|
284
284
|
return findings;
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
|
+
// ─── IAM ─────────────────────────────────────────────────────────────────────
|
|
288
|
+
const MINIMAL_ACTIONS = {
|
|
289
|
+
dynamodb: 'dynamodb:GetItem, dynamodb:PutItem, dynamodb:Query, dynamodb:UpdateItem, dynamodb:DeleteItem',
|
|
290
|
+
secretsmanager: 'secretsmanager:GetSecretValue',
|
|
291
|
+
ssm: 'ssm:GetParameter, ssm:GetParameters, ssm:GetParametersByPath',
|
|
292
|
+
sqs: 'sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes',
|
|
293
|
+
sns: 'sns:Publish',
|
|
294
|
+
s3: 's3:GetObject, s3:PutObject, s3:DeleteObject',
|
|
295
|
+
};
|
|
296
|
+
export class LambdaMissingIAMPermissionsAnalyzer {
|
|
297
|
+
name = 'LambdaMissingIAMPermissionsAnalyzer';
|
|
298
|
+
async analyze(graph) {
|
|
299
|
+
const findings = [];
|
|
300
|
+
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
301
|
+
for (const lambda of graph.nodes) {
|
|
302
|
+
if (lambda.type !== 'lambda')
|
|
303
|
+
continue;
|
|
304
|
+
if (!lambda.allowedServices)
|
|
305
|
+
continue; // IAM data unavailable — skip
|
|
306
|
+
if (lambda.allowedServices.includes('*'))
|
|
307
|
+
continue; // AdministratorAccess
|
|
308
|
+
const funcNode = graph.nodes.find((n) => n.type === 'function' && n.name === lambda.name);
|
|
309
|
+
if (!funcNode)
|
|
310
|
+
continue;
|
|
311
|
+
const needed = new Set();
|
|
312
|
+
for (const edge of graph.edges) {
|
|
313
|
+
if (edge.from !== funcNode.id)
|
|
314
|
+
continue;
|
|
315
|
+
const target = nodeMap.get(edge.to);
|
|
316
|
+
if (!target)
|
|
317
|
+
continue;
|
|
318
|
+
if ((edge.type === 'query' || edge.type === 'scan') &&
|
|
319
|
+
target.type === 'table' &&
|
|
320
|
+
target.databaseType === 'dynamodb') {
|
|
321
|
+
needed.add('dynamodb');
|
|
322
|
+
}
|
|
323
|
+
else if (edge.type === 'reads_secret') {
|
|
324
|
+
needed.add('secretsmanager');
|
|
325
|
+
}
|
|
326
|
+
else if (edge.type === 'reads_parameter') {
|
|
327
|
+
needed.add('ssm');
|
|
328
|
+
}
|
|
329
|
+
else if (edge.type === 'publishes_to' && target.type === 'queue') {
|
|
330
|
+
needed.add('sqs');
|
|
331
|
+
}
|
|
332
|
+
else if (edge.type === 'publishes_to' && target.type === 'topic') {
|
|
333
|
+
needed.add('sns');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const service of needed) {
|
|
337
|
+
if (lambda.allowedServices.includes(service))
|
|
338
|
+
continue;
|
|
339
|
+
findings.push({
|
|
340
|
+
severity: 'high',
|
|
341
|
+
issue: `Lambda "${lambda.name}" accesses ${service} but execution role has no ${service} permissions`,
|
|
342
|
+
description: `"${lambda.name}" calls ${service} in code but its IAM execution role (${lambda.roleArn ?? 'unknown'}) has no ${service}:* permissions. This will cause AccessDeniedException at runtime — code passes tests but fails in AWS.`,
|
|
343
|
+
recommendation: `Add ${service} permissions to the execution role for "${lambda.name}". Minimum required: ${MINIMAL_ACTIONS[service] ?? `${service}:*`}.`,
|
|
344
|
+
metadata: { functionName: lambda.name, missingService: service, roleArn: lambda.roleArn },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return findings;
|
|
349
|
+
}
|
|
350
|
+
}
|
package/dist/analyzers/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer } from './mysql.j
|
|
|
5
5
|
export { MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer } from './mongodb.js';
|
|
6
6
|
export { IaCDriftAnalyzer } from './terraform.js';
|
|
7
7
|
export { PipelineAnalyzer } from './pipeline.js';
|
|
8
|
-
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
|
|
8
|
+
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, LambdaMissingIAMPermissionsAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
|
|
9
9
|
export { RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from './rds.js';
|
|
10
10
|
export async function runAllAnalyzers(graph, analyzers) {
|
|
11
11
|
const allFindings = [];
|
|
@@ -13,7 +13,7 @@ import { extractLogsMetadata } from '../../adapters/aws/logs.js';
|
|
|
13
13
|
import { extractS3Metadata } from '../../adapters/aws/s3.js';
|
|
14
14
|
import { scanRepository } from '../../context/index.js';
|
|
15
15
|
import { buildGraph } from '../../graph/index.js';
|
|
16
|
-
import { runAllAnalyzers, IaCDriftAnalyzer, PipelineAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from '../../analyzers/index.js';
|
|
16
|
+
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, } from '../../analyzers/index.js';
|
|
17
17
|
import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
|
|
18
18
|
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
|
|
19
19
|
function buildMarkdownReport(findings, projectName) {
|
|
@@ -100,6 +100,7 @@ function buildAnalyzers(config, iacDriftAnalyzer, iacLambdas) {
|
|
|
100
100
|
new LambdaDefaultMemoryAnalyzer(),
|
|
101
101
|
new LambdaHighTimeoutAnalyzer(),
|
|
102
102
|
new LambdaMissingTriggerDLQAnalyzer(),
|
|
103
|
+
new LambdaMissingIAMPermissionsAnalyzer(),
|
|
103
104
|
]
|
|
104
105
|
: []),
|
|
105
106
|
...(config.rds?.enabled === true
|
|
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { loadConfig, readCache, setCacheDir } from '../../core/index.js';
|
|
5
|
+
import { loadConfig, readCache, readCacheTimestamp, setCacheDir } from '../../core/index.js';
|
|
6
6
|
import { createServer, setGraphState, setConfigured } from '../../server/index.js';
|
|
7
7
|
import { log, printHeader } from '../utils.js';
|
|
8
8
|
import { runAnalyze, runCodeRefresh } from './analyze.js';
|
|
@@ -83,7 +83,7 @@ export async function runServe(options = {}) {
|
|
|
83
83
|
const cachedFindings = readCache('findings');
|
|
84
84
|
if (cachedGraph && cachedFindings) {
|
|
85
85
|
log.success('Cached analysis loaded', `${cachedGraph.nodes.length} nodes · ${cachedGraph.edges.length} edges · ${cachedFindings.length} finding(s)`);
|
|
86
|
-
setGraphState(cachedGraph, cachedFindings);
|
|
86
|
+
setGraphState(cachedGraph, cachedFindings, readCacheTimestamp('graph'));
|
|
87
87
|
}
|
|
88
88
|
else if (config) {
|
|
89
89
|
log.warn('No cache found — running analysis now...');
|
|
@@ -91,10 +91,10 @@ export async function runServe(options = {}) {
|
|
|
91
91
|
await runAnalyze({ repo: repoPath, config: options.config });
|
|
92
92
|
const freshGraph = readCache('graph') ?? { nodes: [], edges: [] };
|
|
93
93
|
const freshFindings = readCache('findings') ?? [];
|
|
94
|
-
setGraphState(freshGraph, freshFindings);
|
|
94
|
+
setGraphState(freshGraph, freshFindings, readCacheTimestamp('graph'));
|
|
95
95
|
}
|
|
96
96
|
else {
|
|
97
|
-
setGraphState({ nodes: [], edges: [] }, []);
|
|
97
|
+
setGraphState({ nodes: [], edges: [] }, [], null);
|
|
98
98
|
}
|
|
99
99
|
console.log('');
|
|
100
100
|
// Start server
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import { loadConfig, readCache, setCacheDir } from '../../core/index.js';
|
|
4
|
+
import { loadConfig, readCache, readCacheTimestamp, setCacheDir } from '../../core/index.js';
|
|
5
5
|
import { createMcpServer, setGraphState, setConfigured } from '../../server/index.js';
|
|
6
6
|
import { runAnalyze, runCodeRefresh } from './analyze.js';
|
|
7
7
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -22,16 +22,16 @@ export async function runStdio(configPath) {
|
|
|
22
22
|
const cachedGraph = readCache('graph', CACHE_TTL_MS);
|
|
23
23
|
const cachedFindings = readCache('findings', CACHE_TTL_MS);
|
|
24
24
|
if (cachedGraph && cachedFindings) {
|
|
25
|
-
setGraphState(cachedGraph, cachedFindings);
|
|
25
|
+
setGraphState(cachedGraph, cachedFindings, readCacheTimestamp('graph'));
|
|
26
26
|
}
|
|
27
27
|
else if (config) {
|
|
28
28
|
await runAnalyze({ config: configPath });
|
|
29
29
|
const graph = readCache('graph', CACHE_TTL_MS) ?? { nodes: [], edges: [] };
|
|
30
30
|
const findings = readCache('findings', CACHE_TTL_MS) ?? [];
|
|
31
|
-
setGraphState(graph, findings);
|
|
31
|
+
setGraphState(graph, findings, readCacheTimestamp('graph'));
|
|
32
32
|
}
|
|
33
33
|
else {
|
|
34
|
-
setGraphState({ nodes: [], edges: [] }, []);
|
|
34
|
+
setGraphState({ nodes: [], edges: [] }, [], null);
|
|
35
35
|
}
|
|
36
36
|
// File watching drives runCodeRefresh, which needs a config — skip it without one.
|
|
37
37
|
// stderr is safe in stdio transport; stdout is reserved for MCP JSON-RPC.
|
package/dist/core/cache.js
CHANGED
|
@@ -37,6 +37,20 @@ export function readCache(key, maxAgeMs = 3600000) {
|
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
// Returns when the entry was written (ms epoch), ignoring TTL — used to surface
|
|
41
|
+
// analysis freshness. null if the entry is missing or unreadable.
|
|
42
|
+
export function readCacheTimestamp(key) {
|
|
43
|
+
const filePath = path.join(cacheDir, `${key}.json`);
|
|
44
|
+
if (!fs.existsSync(filePath))
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const entry = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
48
|
+
return typeof entry.timestamp === 'number' ? entry.timestamp : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
40
54
|
export function clearCache(key) {
|
|
41
55
|
if (key) {
|
|
42
56
|
const filePath = path.join(cacheDir, `${key}.json`);
|
package/dist/core/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { loadConfig, generateDefaultConfig, InfrawiseConfigSchema, ConfigError } from './config.js';
|
|
2
2
|
export { logger } from './logger.js';
|
|
3
3
|
export { InfrawiseError, DynamoDBError, PostgresConnectionError, RepositoryScanError, formatError, } from './errors.js';
|
|
4
|
-
export { writeCache, readCache, clearCache, setCacheDir } from './cache.js';
|
|
4
|
+
export { writeCache, readCache, readCacheTimestamp, clearCache, setCacheDir } from './cache.js';
|
package/dist/graph/index.js
CHANGED
|
@@ -148,6 +148,8 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
148
148
|
timeoutSec: fn.timeoutSec,
|
|
149
149
|
envVarKeys: fn.envVarKeys,
|
|
150
150
|
triggers: fn.triggers,
|
|
151
|
+
roleArn: fn.roleArn,
|
|
152
|
+
allowedServices: fn.allowedServices,
|
|
151
153
|
});
|
|
152
154
|
// Add trigger edges from source → lambda (only for enabled/active mappings)
|
|
153
155
|
for (const trigger of fn.triggers ?? []) {
|
package/dist/server/index.js
CHANGED
|
@@ -12,12 +12,32 @@ import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecre
|
|
|
12
12
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
13
13
|
let currentGraph = { nodes: [], edges: [] };
|
|
14
14
|
let currentFindings = [];
|
|
15
|
+
// When the loaded analysis was produced (ms epoch). null when serving an empty
|
|
16
|
+
// graph with no analysis. Surfaced via get_infra_overview so assistants can
|
|
17
|
+
// judge how stale the facts are and decide to refresh.
|
|
18
|
+
let analyzedAt = null;
|
|
19
|
+
// Analysis is considered stale past this age — matches the 24h cache TTL that
|
|
20
|
+
// drives auto-refresh in stdio/start.
|
|
21
|
+
const STALE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
15
22
|
// False when the server booted without an infrawise.yaml (e.g. a hosted MCP
|
|
16
23
|
// runtime). Used to return a "run locally" hint instead of a bare empty graph.
|
|
17
24
|
let configured = true;
|
|
18
|
-
export function setGraphState(graph, findings) {
|
|
25
|
+
export function setGraphState(graph, findings, analyzedAtMs = Date.now()) {
|
|
19
26
|
currentGraph = graph;
|
|
20
27
|
currentFindings = findings;
|
|
28
|
+
analyzedAt = analyzedAtMs;
|
|
29
|
+
}
|
|
30
|
+
function freshness() {
|
|
31
|
+
if (analyzedAt === null)
|
|
32
|
+
return { analyzedAt: null, ageSeconds: null, stale: false };
|
|
33
|
+
const ageMs = Date.now() - analyzedAt;
|
|
34
|
+
const stale = ageMs > STALE_AGE_MS;
|
|
35
|
+
return {
|
|
36
|
+
analyzedAt: new Date(analyzedAt).toISOString(),
|
|
37
|
+
ageSeconds: Math.round(ageMs / 1000),
|
|
38
|
+
stale,
|
|
39
|
+
...(stale ? { hint: 'Analysis is stale — run `infrawise analyze` to refresh.' } : {}),
|
|
40
|
+
};
|
|
21
41
|
}
|
|
22
42
|
export function setConfigured(value) {
|
|
23
43
|
configured = value;
|
|
@@ -38,7 +58,7 @@ function logged(name, fn) {
|
|
|
38
58
|
export function createMcpServer() {
|
|
39
59
|
const mcp = new McpServer({ name: 'infrawise', version });
|
|
40
60
|
mcp.registerTool('get_infra_overview', {
|
|
41
|
-
description: 'Returns a compact infrastructure snapshot: service counts, all databases, queues, topics, secrets, lambdas, and high-severity findings. Call this first at the start of any database or infrastructure task to understand what services are in scope. Prefer this over get_graph_summary for quick orientation; use get_graph_summary only when you need every node, edge, and finding in full. Also returns a `configured` flag — when false, the server has no infrawise.yaml loaded (e.g. a remotely hosted instance) and all tools return empty results; a `setupHint` explains how to run infrawise locally.',
|
|
61
|
+
description: 'Returns a compact infrastructure snapshot: service counts, all databases, queues, topics, secrets, lambdas, and high-severity findings. Call this first at the start of any database or infrastructure task to understand what services are in scope. Prefer this over get_graph_summary for quick orientation; use get_graph_summary only when you need every node, edge, and finding in full. Also returns a `configured` flag — when false, the server has no infrawise.yaml loaded (e.g. a remotely hosted instance) and all tools return empty results; a `setupHint` explains how to run infrawise locally. The `freshness` field reports when the analysis ran (`analyzedAt`, `ageSeconds`) and a `stale` flag with a refresh hint once the data is older than 24h.',
|
|
42
62
|
inputSchema: z.object({}),
|
|
43
63
|
}, logged('get_infra_overview', async () => {
|
|
44
64
|
const tables = getTableNodes(currentGraph);
|
|
@@ -53,6 +73,7 @@ export function createMcpServer() {
|
|
|
53
73
|
return toText({
|
|
54
74
|
configured,
|
|
55
75
|
...(configured ? {} : { setupHint: NOT_CONFIGURED_HINT }),
|
|
76
|
+
freshness: freshness(),
|
|
56
77
|
summary: {
|
|
57
78
|
tables: tables.length,
|
|
58
79
|
functions: functions.length,
|
|
@@ -132,6 +153,31 @@ export function createMcpServer() {
|
|
|
132
153
|
String(meta?.callerFunctions ?? '').includes(functionName));
|
|
133
154
|
});
|
|
134
155
|
const allTriggers = lambdaNode?.type === 'lambda' ? (lambdaNode.triggers ?? []) : [];
|
|
156
|
+
// Compute missing IAM permissions inline from graph data
|
|
157
|
+
const allowedServices = lambdaNode?.type === 'lambda' ? lambdaNode.allowedServices : undefined;
|
|
158
|
+
let missingPermissions;
|
|
159
|
+
if (allowedServices && !allowedServices.includes('*') && funcNode) {
|
|
160
|
+
const nodeMap = new Map(currentGraph.nodes.map((n) => [n.id, n]));
|
|
161
|
+
const needed = new Set();
|
|
162
|
+
for (const edge of outEdges) {
|
|
163
|
+
const target = nodeMap.get(edge.to);
|
|
164
|
+
if (!target)
|
|
165
|
+
continue;
|
|
166
|
+
if ((edge.type === 'query' || edge.type === 'scan') &&
|
|
167
|
+
target.type === 'table' &&
|
|
168
|
+
target.databaseType === 'dynamodb')
|
|
169
|
+
needed.add('dynamodb');
|
|
170
|
+
else if (edge.type === 'reads_secret')
|
|
171
|
+
needed.add('secretsmanager');
|
|
172
|
+
else if (edge.type === 'reads_parameter')
|
|
173
|
+
needed.add('ssm');
|
|
174
|
+
else if (edge.type === 'publishes_to' && target.type === 'queue')
|
|
175
|
+
needed.add('sqs');
|
|
176
|
+
else if (edge.type === 'publishes_to' && target.type === 'topic')
|
|
177
|
+
needed.add('sns');
|
|
178
|
+
}
|
|
179
|
+
missingPermissions = [...needed].filter((s) => !allowedServices.includes(s));
|
|
180
|
+
}
|
|
135
181
|
return toText({
|
|
136
182
|
function: functionName,
|
|
137
183
|
found: true,
|
|
@@ -151,6 +197,7 @@ export function createMcpServer() {
|
|
|
151
197
|
targetType: target?.type,
|
|
152
198
|
};
|
|
153
199
|
}),
|
|
200
|
+
...(missingPermissions !== undefined ? { missingPermissions } : {}),
|
|
154
201
|
issues: relatedFindings.map((f) => ({
|
|
155
202
|
severity: f.severity,
|
|
156
203
|
issue: f.issue,
|
|
@@ -343,6 +390,7 @@ export function createMcpServer() {
|
|
|
343
390
|
timeoutSec: l.timeoutSec,
|
|
344
391
|
envVarCount: l.envVarKeys?.length ?? 0,
|
|
345
392
|
envVarKeys: l.envVarKeys,
|
|
393
|
+
roleArn: l.roleArn,
|
|
346
394
|
triggers: (l.triggers ?? []).map((t) => ({
|
|
347
395
|
type: t.type,
|
|
348
396
|
source: t.sourceName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infrawise",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
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": [
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"@aws-sdk/client-cloudwatch-logs": "^3.1048.0",
|
|
79
79
|
"@aws-sdk/client-dynamodb": "^3.1048.0",
|
|
80
80
|
"@aws-sdk/client-eventbridge": "^3.1051.0",
|
|
81
|
+
"@aws-sdk/client-iam": "^3.1073.0",
|
|
81
82
|
"@aws-sdk/client-lambda": "^3.1048.0",
|
|
82
83
|
"@aws-sdk/client-rds": "^3.1048.0",
|
|
83
84
|
"@aws-sdk/client-s3": "^3.1048.0",
|