infrawise 0.12.5 → 0.13.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
@@ -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 — all services, counts, and high-severity findings |
152
+ | `get_infra_overview` | Complete snapshot — all services, counts, high-severity findings, and a `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
+ }
@@ -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,8 +2,8 @@ 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, formatError, readCache, setCacheDir } from '../../core/index.js';
6
- import { createServer, setGraphState } from '../../server/index.js';
5
+ import { loadConfig, readCache, setCacheDir } from '../../core/index.js';
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';
9
9
  import { runStdio } from './stdio.js';
@@ -26,8 +26,10 @@ const TOOL_MAP = [
26
26
  { name: 'get_api_routes', service: 'apiGateway' },
27
27
  { name: 'get_log_errors', service: 'cloudwatchLogs' },
28
28
  ];
29
+ // With no config every registered tool is shown as active (the server exposes
30
+ // all of them); a config narrows the list to its enabled services.
29
31
  function isEnabled(cfg, service) {
30
- if (!service)
32
+ if (!service || !cfg)
31
33
  return true;
32
34
  const svc = cfg[service];
33
35
  return svc?.enabled === true;
@@ -63,16 +65,18 @@ export async function runServe(options = {}) {
63
65
  }
64
66
  const port = options.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : 3000);
65
67
  printHeader('MCP Server');
68
+ setCacheDir(path.dirname(path.resolve(options.config ?? 'infrawise.yaml')));
69
+ // A hosted MCP runtime may launch the server with no infrawise.yaml. Start
70
+ // anyway with an empty graph so the host can connect and list tools.
66
71
  let config;
67
72
  try {
68
73
  config = loadConfig(options.config);
69
- setCacheDir(path.dirname(path.resolve(options.config ?? 'infrawise.yaml')));
70
74
  log.success('Config loaded', options.config ?? 'infrawise.yaml');
71
75
  }
72
76
  catch (err) {
73
- console.error(formatError(err));
74
- process.exit(1);
77
+ log.warn(`Starting with empty graph (no config loaded): ${err instanceof Error ? err.message : String(err)}`);
75
78
  }
79
+ setConfigured(config !== undefined);
76
80
  const repoPath = process.cwd();
77
81
  // Auto-analyze if no cache
78
82
  const cachedGraph = readCache('graph');
@@ -81,7 +85,7 @@ export async function runServe(options = {}) {
81
85
  log.success('Cached analysis loaded', `${cachedGraph.nodes.length} nodes · ${cachedGraph.edges.length} edges · ${cachedFindings.length} finding(s)`);
82
86
  setGraphState(cachedGraph, cachedFindings);
83
87
  }
84
- else {
88
+ else if (config) {
85
89
  log.warn('No cache found — running analysis now...');
86
90
  console.log('');
87
91
  await runAnalyze({ repo: repoPath, config: options.config });
@@ -89,6 +93,9 @@ export async function runServe(options = {}) {
89
93
  const freshFindings = readCache('findings') ?? [];
90
94
  setGraphState(freshGraph, freshFindings);
91
95
  }
96
+ else {
97
+ setGraphState({ nodes: [], edges: [] }, []);
98
+ }
92
99
  console.log('');
93
100
  // Start server
94
101
  const spin = ora({ text: chalk.dim('Starting server...'), color: 'cyan' }).start();
@@ -127,49 +134,55 @@ export async function runServe(options = {}) {
127
134
  console.log(chalk.dim(` claude mcp add --transport http infrawise ${mcpUrl}`));
128
135
  console.log('');
129
136
  console.log(chalk.dim(' Watching for file changes... Press Ctrl+C to stop\n'));
130
- // File watch — re-run code analysis on save, skip slow AWS/DB extraction
131
- let debounceTimer = null;
132
- let refreshing = false;
133
- const configFile = path.resolve(options.config ?? 'infrawise.yaml');
134
- try {
135
- fs.watch(repoPath, { recursive: true }, (_, filename) => {
136
- if (!filename)
137
- return;
138
- const abs = path.join(repoPath, filename);
139
- if (abs === configFile) {
140
- console.log(chalk.dim('\n infrawise.yaml changed restart to apply config changes\n'));
141
- return;
142
- }
143
- const ext = path.extname(filename);
144
- if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext))
145
- return;
146
- if (filename.includes('node_modules') || filename.startsWith('.infrawise'))
147
- return;
148
- if (debounceTimer)
149
- clearTimeout(debounceTimer);
150
- debounceTimer = setTimeout(async () => {
151
- if (refreshing)
137
+ // File watch — re-run code analysis on save (needs a config to drive analyzers)
138
+ if (config) {
139
+ const cfg = config;
140
+ let debounceTimer = null;
141
+ let refreshing = false;
142
+ const configFile = path.resolve(options.config ?? 'infrawise.yaml');
143
+ try {
144
+ fs.watch(repoPath, { recursive: true }, (_, filename) => {
145
+ if (!filename)
146
+ return;
147
+ const abs = path.join(repoPath, filename);
148
+ if (abs === configFile) {
149
+ console.log(chalk.dim('\n infrawise.yaml changed — restart to apply config changes\n'));
152
150
  return;
153
- refreshing = true;
154
- const spin = ora({ text: chalk.dim('Refreshing code analysis...'), color: 'cyan' }).start();
155
- try {
156
- const { graph, findings } = await runCodeRefresh(repoPath, config);
157
- setGraphState(graph, findings);
158
- spin.succeed(chalk.green('Analysis refreshed') +
159
- chalk.dim(` ${graph.nodes.length} nodes · ${findings.length} finding(s)`));
160
- }
161
- catch (err) {
162
- spin.warn(chalk.yellow('Refresh failed') +
163
- chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
164
- }
165
- finally {
166
- refreshing = false;
167
151
  }
168
- }, 2000);
169
- });
170
- }
171
- catch {
172
- // fs.watch may not support recursive on all platforms — silently skip
152
+ const ext = path.extname(filename);
153
+ if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext))
154
+ return;
155
+ if (filename.includes('node_modules') || filename.startsWith('.infrawise'))
156
+ return;
157
+ if (debounceTimer)
158
+ clearTimeout(debounceTimer);
159
+ debounceTimer = setTimeout(async () => {
160
+ if (refreshing)
161
+ return;
162
+ refreshing = true;
163
+ const spin = ora({
164
+ text: chalk.dim('Refreshing code analysis...'),
165
+ color: 'cyan',
166
+ }).start();
167
+ try {
168
+ const { graph, findings } = await runCodeRefresh(repoPath, cfg);
169
+ setGraphState(graph, findings);
170
+ spin.succeed(chalk.green('Analysis refreshed') +
171
+ chalk.dim(` ${graph.nodes.length} nodes · ${findings.length} finding(s)`));
172
+ }
173
+ catch (err) {
174
+ spin.warn(chalk.yellow('Refresh failed') +
175
+ chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
176
+ }
177
+ finally {
178
+ refreshing = false;
179
+ }
180
+ }, 2000);
181
+ });
182
+ }
183
+ catch {
184
+ // fs.watch may not support recursive on all platforms — silently skip
185
+ }
173
186
  }
174
187
  process.on('SIGINT', () => {
175
188
  console.log(chalk.dim('\n Shutting down...\n'));
@@ -1,72 +1,81 @@
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, formatError, readCache, setCacheDir } from '../../core/index.js';
5
- import { createMcpServer, setGraphState } from '../../server/index.js';
4
+ import { loadConfig, readCache, setCacheDir } from '../../core/index.js';
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;
8
8
  export async function runStdio(configPath) {
9
+ const resolvedConfigPath = configPath ?? 'infrawise.yaml';
10
+ setCacheDir(path.dirname(path.resolve(resolvedConfigPath)));
11
+ // A hosted MCP runtime (e.g. Glama) launches the server with no infrawise.yaml.
12
+ // Start anyway with an empty graph so the host can connect and list tools;
13
+ // analysis populates once a config is present.
9
14
  let config;
10
15
  try {
11
16
  config = loadConfig(configPath);
12
17
  }
13
18
  catch (err) {
14
- process.stderr.write(formatError(err) + '\n');
15
- process.exit(1);
19
+ process.stderr.write(`infrawise: starting with empty graph (no config loaded: ${err instanceof Error ? err.message : String(err)})\n`);
16
20
  }
17
- const resolvedConfigPath = configPath ?? 'infrawise.yaml';
18
- setCacheDir(path.dirname(path.resolve(resolvedConfigPath)));
21
+ setConfigured(config !== undefined);
19
22
  const cachedGraph = readCache('graph', CACHE_TTL_MS);
20
23
  const cachedFindings = readCache('findings', CACHE_TTL_MS);
21
24
  if (cachedGraph && cachedFindings) {
22
25
  setGraphState(cachedGraph, cachedFindings);
23
26
  }
24
- else {
27
+ else if (config) {
25
28
  await runAnalyze({ config: configPath });
26
29
  const graph = readCache('graph', CACHE_TTL_MS) ?? { nodes: [], edges: [] };
27
30
  const findings = readCache('findings', CACHE_TTL_MS) ?? [];
28
31
  setGraphState(graph, findings);
29
32
  }
30
- // File watching — re-run code analysis on save (no AWS calls, instant)
31
- // stderr is safe in stdio transport; stdout is reserved for MCP JSON-RPC
32
- const repoPath = process.cwd();
33
- const configFile = path.resolve(resolvedConfigPath);
34
- let debounceTimer = null;
35
- let refreshing = false;
36
- try {
37
- fs.watch(repoPath, { recursive: true }, (_, filename) => {
38
- if (!filename)
39
- return;
40
- const abs = path.join(repoPath, filename);
41
- if (abs === configFile)
42
- return;
43
- const ext = path.extname(filename);
44
- if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext))
45
- return;
46
- if (filename.includes('node_modules') || filename.startsWith('.infrawise'))
47
- return;
48
- if (debounceTimer)
49
- clearTimeout(debounceTimer);
50
- debounceTimer = setTimeout(async () => {
51
- if (refreshing)
52
- return;
53
- refreshing = true;
54
- try {
55
- const { graph, findings } = await runCodeRefresh(repoPath, config);
56
- setGraphState(graph, findings);
57
- process.stderr.write(`infrawise: code graph refreshed (${graph.nodes.length} nodes · ${findings.length} finding(s))\n`);
58
- }
59
- catch {
60
- // don't break the MCP connection on watcher errors
61
- }
62
- finally {
63
- refreshing = false;
64
- }
65
- }, 2000);
66
- });
33
+ else {
34
+ setGraphState({ nodes: [], edges: [] }, []);
67
35
  }
68
- catch {
69
- // fs.watch recursive not supported on all platforms silently skip
36
+ // File watching drives runCodeRefresh, which needs a config — skip it without one.
37
+ // stderr is safe in stdio transport; stdout is reserved for MCP JSON-RPC.
38
+ if (config) {
39
+ const cfg = config;
40
+ const repoPath = process.cwd();
41
+ const configFile = path.resolve(resolvedConfigPath);
42
+ let debounceTimer = null;
43
+ let refreshing = false;
44
+ try {
45
+ fs.watch(repoPath, { recursive: true }, (_, filename) => {
46
+ if (!filename)
47
+ return;
48
+ const abs = path.join(repoPath, filename);
49
+ if (abs === configFile)
50
+ return;
51
+ const ext = path.extname(filename);
52
+ if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext))
53
+ return;
54
+ if (filename.includes('node_modules') || filename.startsWith('.infrawise'))
55
+ return;
56
+ if (debounceTimer)
57
+ clearTimeout(debounceTimer);
58
+ debounceTimer = setTimeout(async () => {
59
+ if (refreshing)
60
+ return;
61
+ refreshing = true;
62
+ try {
63
+ const { graph, findings } = await runCodeRefresh(repoPath, cfg);
64
+ setGraphState(graph, findings);
65
+ process.stderr.write(`infrawise: code graph refreshed (${graph.nodes.length} nodes · ${findings.length} finding(s))\n`);
66
+ }
67
+ catch {
68
+ // don't break the MCP connection on watcher errors
69
+ }
70
+ finally {
71
+ refreshing = false;
72
+ }
73
+ }, 2000);
74
+ });
75
+ }
76
+ catch {
77
+ // fs.watch recursive not supported on all platforms — silently skip
78
+ }
70
79
  }
71
80
  const mcp = createMcpServer();
72
81
  const transport = new StdioServerTransport();
@@ -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 ?? []) {
@@ -12,10 +12,17 @@ import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecre
12
12
  // ── State ────────────────────────────────────────────────────────────────────
13
13
  let currentGraph = { nodes: [], edges: [] };
14
14
  let currentFindings = [];
15
+ // False when the server booted without an infrawise.yaml (e.g. a hosted MCP
16
+ // runtime). Used to return a "run locally" hint instead of a bare empty graph.
17
+ let configured = true;
15
18
  export function setGraphState(graph, findings) {
16
19
  currentGraph = graph;
17
20
  currentFindings = findings;
18
21
  }
22
+ export function setConfigured(value) {
23
+ configured = value;
24
+ }
25
+ const NOT_CONFIGURED_HINT = 'No infrastructure loaded. infrawise reads your live infra locally — run `npx infrawise start` in your project (with AWS credentials and an infrawise.yaml) so these tools return your real DynamoDB/RDS/SQS/Lambda/etc. context. A remotely hosted instance has no access to your cloud account or code, so it returns empty results by design.';
19
26
  // ── Helpers ──────────────────────────────────────────────────────────────────
20
27
  function toText(data) {
21
28
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
@@ -31,7 +38,7 @@ function logged(name, fn) {
31
38
  export function createMcpServer() {
32
39
  const mcp = new McpServer({ name: 'infrawise', version });
33
40
  mcp.registerTool('get_infra_overview', {
34
- 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.',
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.',
35
42
  inputSchema: z.object({}),
36
43
  }, logged('get_infra_overview', async () => {
37
44
  const tables = getTableNodes(currentGraph);
@@ -44,6 +51,8 @@ export function createMcpServer() {
44
51
  const functions = getFunctionNodes(currentGraph);
45
52
  const buckets = getBucketNodes(currentGraph);
46
53
  return toText({
54
+ configured,
55
+ ...(configured ? {} : { setupHint: NOT_CONFIGURED_HINT }),
47
56
  summary: {
48
57
  tables: tables.length,
49
58
  functions: functions.length,
@@ -123,6 +132,31 @@ export function createMcpServer() {
123
132
  String(meta?.callerFunctions ?? '').includes(functionName));
124
133
  });
125
134
  const allTriggers = lambdaNode?.type === 'lambda' ? (lambdaNode.triggers ?? []) : [];
135
+ // Compute missing IAM permissions inline from graph data
136
+ const allowedServices = lambdaNode?.type === 'lambda' ? lambdaNode.allowedServices : undefined;
137
+ let missingPermissions;
138
+ if (allowedServices && !allowedServices.includes('*') && funcNode) {
139
+ const nodeMap = new Map(currentGraph.nodes.map((n) => [n.id, n]));
140
+ const needed = new Set();
141
+ for (const edge of outEdges) {
142
+ const target = nodeMap.get(edge.to);
143
+ if (!target)
144
+ continue;
145
+ if ((edge.type === 'query' || edge.type === 'scan') &&
146
+ target.type === 'table' &&
147
+ target.databaseType === 'dynamodb')
148
+ needed.add('dynamodb');
149
+ else if (edge.type === 'reads_secret')
150
+ needed.add('secretsmanager');
151
+ else if (edge.type === 'reads_parameter')
152
+ needed.add('ssm');
153
+ else if (edge.type === 'publishes_to' && target.type === 'queue')
154
+ needed.add('sqs');
155
+ else if (edge.type === 'publishes_to' && target.type === 'topic')
156
+ needed.add('sns');
157
+ }
158
+ missingPermissions = [...needed].filter((s) => !allowedServices.includes(s));
159
+ }
126
160
  return toText({
127
161
  function: functionName,
128
162
  found: true,
@@ -142,6 +176,7 @@ export function createMcpServer() {
142
176
  targetType: target?.type,
143
177
  };
144
178
  }),
179
+ ...(missingPermissions !== undefined ? { missingPermissions } : {}),
145
180
  issues: relatedFindings.map((f) => ({
146
181
  severity: f.severity,
147
182
  issue: f.issue,
@@ -334,6 +369,7 @@ export function createMcpServer() {
334
369
  timeoutSec: l.timeoutSec,
335
370
  envVarCount: l.envVarKeys?.length ?? 0,
336
371
  envVarKeys: l.envVarKeys,
372
+ roleArn: l.roleArn,
337
373
  triggers: (l.triggers ?? []).map((t) => ({
338
374
  type: t.type,
339
375
  source: t.sourceName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.12.5",
3
+ "version": "0.13.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": [
@@ -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",