infrawise 0.8.3 → 0.9.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
@@ -170,7 +170,7 @@ To let Claude Code manage the server lifecycle automatically:
170
170
  | `suggest_mongo_index` | Exact `createIndex` command for a MongoDB collection + field |
171
171
  | `mysql_index_suggestions` | Exact `ALTER TABLE ADD INDEX` SQL for your MySQL table |
172
172
  | `get_queue_details` | SQS queues — DLQ status, encryption, message counts |
173
- | `get_topic_details` | SNS topics — subscription counts and protocols |
173
+ | `get_topic_details` | SNS topics — subscription counts, protocols, and filter policies (required message attributes per subscription) |
174
174
  | `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
175
175
  | `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
176
176
  | `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), env var key names |
@@ -345,6 +345,7 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
345
345
  | PostgreSQL / MySQL schema | Tables, indexes, column types |
346
346
  | MongoDB schema | Collections, indexes |
347
347
  | SQS | Missing DLQs, unencrypted queues, large backlogs |
348
+ | SNS | Subscription filter policies — required message attributes per subscription |
348
349
  | Kafka (kafkajs) | Producer/consumer topic mapping from code |
349
350
  | Secrets Manager | Missing secret rotation |
350
351
  | Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), missing DLQ on trigger source |
@@ -1,11 +1,25 @@
1
1
  import { S3Client, ListBucketsCommand, GetBucketNotificationConfigurationCommand, GetBucketVersioningCommand, GetBucketEncryptionCommand, GetPublicAccessBlockCommand, } from '@aws-sdk/client-s3';
2
2
  import { fromIni } from '@aws-sdk/credential-providers';
3
3
  import { logger } from '../../core/index.js';
4
+ function validateEndpoint(endpoint) {
5
+ let url;
6
+ try {
7
+ url = new URL(endpoint);
8
+ }
9
+ catch {
10
+ throw new Error(`Invalid aws.endpoint URL: "${endpoint}"`);
11
+ }
12
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
13
+ throw new Error(`aws.endpoint must use http:// or https://, got "${url.protocol}//"`);
14
+ }
15
+ }
4
16
  function clientConfig(cfg) {
5
17
  const region = cfg.region ?? 'us-east-1';
6
18
  const base = { region };
7
- if (cfg.endpoint)
19
+ if (cfg.endpoint) {
20
+ validateEndpoint(cfg.endpoint);
8
21
  base.endpoint = cfg.endpoint;
22
+ }
9
23
  if (cfg.profile)
10
24
  base.credentials = fromIni({ profile: cfg.profile });
11
25
  return base;
@@ -52,7 +66,7 @@ export async function extractS3Metadata(cfg = {}) {
52
66
  const encrypted = encryptResult.status === 'fulfilled'
53
67
  ? (encryptResult.value.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0
54
68
  : false;
55
- let publicAccessBlocked = false;
69
+ let publicAccessBlocked = null;
56
70
  if (pabResult.status === 'fulfilled') {
57
71
  const pab = pabResult.value.PublicAccessBlockConfiguration ?? {};
58
72
  publicAccessBlocked = !!(pab.BlockPublicAcls &&
@@ -60,6 +74,13 @@ export async function extractS3Metadata(cfg = {}) {
60
74
  pab.BlockPublicPolicy &&
61
75
  pab.RestrictPublicBuckets);
62
76
  }
77
+ else {
78
+ const httpStatus = pabResult.reason
79
+ ?.$metadata?.httpStatusCode;
80
+ if (httpStatus !== 403)
81
+ publicAccessBlocked = false;
82
+ // 403 AccessDenied: leave as null — insufficient permissions, not a public access finding
83
+ }
63
84
  buckets.push({
64
85
  name,
65
86
  arn,
@@ -1,5 +1,5 @@
1
1
  import { SQSClient, ListQueuesCommand, GetQueueAttributesCommand } from '@aws-sdk/client-sqs';
2
- import { SNSClient, ListTopicsCommand, GetTopicAttributesCommand, ListSubscriptionsByTopicCommand, } from '@aws-sdk/client-sns';
2
+ import { SNSClient, ListTopicsCommand, GetTopicAttributesCommand, ListSubscriptionsByTopicCommand, GetSubscriptionAttributesCommand, } from '@aws-sdk/client-sns';
3
3
  import { SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm';
4
4
  import { SecretsManagerClient, ListSecretsCommand } from '@aws-sdk/client-secrets-manager';
5
5
  import { LambdaClient, ListFunctionsCommand, ListEventSourceMappingsCommand, } from '@aws-sdk/client-lambda';
@@ -7,11 +7,25 @@ import { EventBridgeClient, ListRulesCommand, ListTargetsByRuleCommand, } from '
7
7
  import { RDSClient, DescribeDBInstancesCommand } from '@aws-sdk/client-rds';
8
8
  import { fromIni } from '@aws-sdk/credential-providers';
9
9
  import { logger } from '../../core/index.js';
10
+ function validateEndpoint(endpoint) {
11
+ let url;
12
+ try {
13
+ url = new URL(endpoint);
14
+ }
15
+ catch {
16
+ throw new Error(`Invalid aws.endpoint URL: "${endpoint}"`);
17
+ }
18
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
19
+ throw new Error(`aws.endpoint must use http:// or https://, got "${url.protocol}//"`);
20
+ }
21
+ }
10
22
  function clientConfig(cfg) {
11
23
  const region = cfg.region ?? 'us-east-1';
12
24
  const base = { region };
13
- if (cfg.endpoint)
25
+ if (cfg.endpoint) {
26
+ validateEndpoint(cfg.endpoint);
14
27
  base.endpoint = cfg.endpoint;
28
+ }
15
29
  if (cfg.profile)
16
30
  base.credentials = fromIni({ profile: cfg.profile });
17
31
  return base;
@@ -98,12 +112,34 @@ export async function extractSNSMetadata(cfg = {}) {
98
112
  ]);
99
113
  const attrs = attrsRes.Attributes ?? {};
100
114
  const subs = subsRes.Subscriptions ?? [];
115
+ const filterPolicies = [];
116
+ for (const sub of subs) {
117
+ if (!sub.SubscriptionArn || sub.SubscriptionArn === 'PendingConfirmation')
118
+ continue;
119
+ try {
120
+ const subAttrs = await client.send(new GetSubscriptionAttributesCommand({ SubscriptionArn: sub.SubscriptionArn }));
121
+ const fp = subAttrs.Attributes?.['FilterPolicy'];
122
+ if (fp) {
123
+ const parsed = JSON.parse(fp);
124
+ filterPolicies.push({
125
+ subscriptionArn: sub.SubscriptionArn,
126
+ protocol: sub.Protocol ?? 'unknown',
127
+ requiredAttributes: Object.keys(parsed),
128
+ scope: subAttrs.Attributes?.['FilterPolicyScope'] ?? 'MessageAttributes',
129
+ });
130
+ }
131
+ }
132
+ catch {
133
+ // skip subscription if attributes fetch fails
134
+ }
135
+ }
101
136
  topics.push({
102
137
  name: arn.split(':').pop() ?? arn,
103
138
  arn,
104
139
  encrypted: !!attrs['KmsMasterKeyId'],
105
140
  subscriptionCount: parseInt(attrs['SubscriptionsConfirmed'] ?? '0', 10),
106
141
  subscriptionProtocols: [...new Set(subs.map((s) => s.Protocol ?? 'unknown'))],
142
+ filterPolicies,
107
143
  });
108
144
  }
109
145
  catch (err) {
@@ -298,7 +334,7 @@ export async function extractLambdaMetadata(cfg = {}, includeFunctions) {
298
334
  });
299
335
  }
300
336
  marker = res.NextMarker;
301
- } while (marker && functions.length < 200);
337
+ } while (marker);
302
338
  // Fetch all event source mappings in one paginated call and attach to functions
303
339
  const triggerMap = await fetchAllEventSourceMappings(cfg);
304
340
  for (const fn of functions) {
@@ -1,12 +1,15 @@
1
1
  import { MongoClient } from 'mongodb';
2
2
  import { InfrawiseError, logger } from '../../core/index.js';
3
3
  const SYSTEM_DATABASES = new Set(['admin', 'local', 'config']);
4
+ function sanitizeConnectionDetail(s) {
5
+ return s.replace(/\/\/[^:/@]+:[^@]+@/g, '//***:***@');
6
+ }
4
7
  export class MongoConnectionError extends InfrawiseError {
5
8
  constructor(details) {
6
9
  super('Unable to connect to MongoDB.\n\nPossible reasons:\n- invalid connection string\n- port 27017 not accessible\n- wrong credentials\n\nRun: infrawise doctor', undefined, undefined);
7
10
  this.name = 'MongoConnectionError';
8
11
  if (details) {
9
- this.message = `Unable to connect to MongoDB.\n\nPossible reasons:\n- invalid connection string\n- port 27017 not accessible\n- wrong credentials\n\nRun: infrawise doctor\n\nDetail: ${details}`;
12
+ this.message = `Unable to connect to MongoDB.\n\nPossible reasons:\n- invalid connection string\n- port 27017 not accessible\n- wrong credentials\n\nRun: infrawise doctor\n\nDetail: ${sanitizeConnectionDetail(details)}`;
10
13
  }
11
14
  }
12
15
  }
@@ -1,12 +1,15 @@
1
1
  import mysql from 'mysql2/promise';
2
2
  import { InfrawiseError, logger } from '../../core/index.js';
3
3
  const SYSTEM_SCHEMAS = new Set(['information_schema', 'performance_schema', 'mysql', 'sys']);
4
+ function sanitizeConnectionDetail(s) {
5
+ return s.replace(/\/\/[^:/@]+:[^@]+@/g, '//***:***@');
6
+ }
4
7
  export class MySQLConnectionError extends InfrawiseError {
5
8
  constructor(details) {
6
9
  super('Unable to connect to MySQL.\n\nPossible reasons:\n- invalid connection string\n- port 3306 not accessible\n- wrong credentials\n\nRun: infrawise doctor', undefined, undefined);
7
10
  this.name = 'MySQLConnectionError';
8
11
  if (details) {
9
- this.message = `Unable to connect to MySQL.\n\nPossible reasons:\n- invalid connection string\n- port 3306 not accessible\n- wrong credentials\n\nRun: infrawise doctor\n\nDetail: ${details}`;
12
+ this.message = `Unable to connect to MySQL.\n\nPossible reasons:\n- invalid connection string\n- port 3306 not accessible\n- wrong credentials\n\nRun: infrawise doctor\n\nDetail: ${sanitizeConnectionDetail(details)}`;
10
13
  }
11
14
  }
12
15
  }
@@ -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, formatError, writeCache, readCache } from '../../core/index.js';
5
+ import { loadConfig, formatError, writeCache, readCache, setCacheDir } from '../../core/index.js';
6
6
  import { extractDynamoMetadata } from '../../adapters/aws/dynamodb.js';
7
7
  import { extractPostgresMetadata } from '../../adapters/db/postgres.js';
8
8
  import { extractMySQLMetadata } from '../../adapters/db/mysql.js';
@@ -51,6 +51,7 @@ export async function runAnalyze(options = {}) {
51
51
  let config;
52
52
  try {
53
53
  config = loadConfig(options.config);
54
+ setCacheDir(path.dirname(path.resolve(options.config ?? 'infrawise.yaml')));
54
55
  log.success('Config loaded', options.config ?? 'infrawise.yaml');
55
56
  }
56
57
  catch (err) {
@@ -351,16 +352,18 @@ export async function runAnalyze(options = {}) {
351
352
  s.succeed(chalk.green('Analysis complete') + chalk.dim(` ${findings.length} finding(s)`));
352
353
  }
353
354
  // ── Cache ─────────────────────────────────────────────────────────────────────
354
- writeCache('graph', graph);
355
- writeCache('findings', findings);
356
- writeCache('operations', operations);
357
- writeCache('meta', {
358
- dynamoMeta,
359
- postgresMeta,
360
- mysqlMeta,
361
- mongoMeta,
362
- servicesMeta,
363
- });
355
+ if (!options.noCache) {
356
+ writeCache('graph', graph);
357
+ writeCache('findings', findings);
358
+ writeCache('operations', operations);
359
+ writeCache('meta', {
360
+ dynamoMeta,
361
+ postgresMeta,
362
+ mysqlMeta,
363
+ mongoMeta,
364
+ servicesMeta,
365
+ });
366
+ }
364
367
  // ── Output ────────────────────────────────────────────────────────────────────
365
368
  const displayFindings = minSeverity > 0
366
369
  ? findings.filter((f) => (SEVERITY_ORDER[f.severity] ?? 0) >= minSeverity)
@@ -394,7 +397,7 @@ export async function runAnalyze(options = {}) {
394
397
  console.log('');
395
398
  }
396
399
  export async function runCodeRefresh(repoPath, config) {
397
- const cached = readCache('meta', Infinity);
400
+ const cached = readCache('meta', 60 * 60 * 1000);
398
401
  const dynamoMeta = cached?.dynamoMeta ?? [];
399
402
  const postgresMeta = cached?.postgresMeta ?? [];
400
403
  const mysqlMeta = cached?.mysqlMeta ?? [];
@@ -438,7 +441,11 @@ export async function runCodeRefresh(repoPath, config) {
438
441
  ...(config.secretsManager?.enabled === true ? [new MissingSecretRotationAnalyzer()] : []),
439
442
  ...(config.cloudwatchLogs?.enabled ? [new MissingLogRetentionAnalyzer()] : []),
440
443
  ...(config.lambda?.enabled === true
441
- ? [new LambdaDefaultMemoryAnalyzer(), new LambdaHighTimeoutAnalyzer()]
444
+ ? [
445
+ new LambdaDefaultMemoryAnalyzer(),
446
+ new LambdaHighTimeoutAnalyzer(),
447
+ new LambdaMissingTriggerDLQAnalyzer(),
448
+ ]
442
449
  : []),
443
450
  ...(config.rds?.enabled === true
444
451
  ? [
@@ -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, formatError, readCache } from '../../core/index.js';
5
+ import { loadConfig, formatError, readCache, setCacheDir } from '../../core/index.js';
6
6
  import { createServer, setGraphState } from '../../server/index.js';
7
7
  import { log, printHeader } from '../utils.js';
8
8
  import { runAnalyze, runCodeRefresh } from './analyze.js';
@@ -59,6 +59,7 @@ export async function runDev(options = {}) {
59
59
  let config;
60
60
  try {
61
61
  config = loadConfig(options.config);
62
+ setCacheDir(path.dirname(path.resolve(options.config ?? 'infrawise.yaml')));
62
63
  log.success('Config loaded', options.config ?? 'infrawise.yaml');
63
64
  }
64
65
  catch (err) {
@@ -193,14 +193,15 @@ function resolveStringValue(node, sourceFile) {
193
193
  return result;
194
194
  }
195
195
  if (Node.isIdentifier(node)) {
196
- const name = node.getText();
197
- const decl = sourceFile
198
- .getDescendantsOfKind(SyntaxKind.VariableDeclaration)
199
- .find((d) => d.getName() === name);
200
- if (decl) {
201
- const init = decl.getInitializer();
202
- if (init)
203
- return resolveStringValue(init, sourceFile);
196
+ const symbol = node.getSymbol();
197
+ if (symbol) {
198
+ for (const decl of symbol.getDeclarations()) {
199
+ if (Node.isVariableDeclaration(decl)) {
200
+ const init = decl.getInitializer();
201
+ if (init)
202
+ return resolveStringValue(init, sourceFile);
203
+ }
204
+ }
204
205
  }
205
206
  }
206
207
  return null;
@@ -1,10 +1,13 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  const CACHE_VERSION = '1.0.0';
4
- const CACHE_DIR = path.join(process.cwd(), '.infrawise', 'cache');
4
+ let cacheDir = path.join(process.cwd(), '.infrawise', 'cache');
5
+ export function setCacheDir(dir) {
6
+ cacheDir = path.join(dir, '.infrawise', 'cache');
7
+ }
5
8
  function ensureCacheDir() {
6
- if (!fs.existsSync(CACHE_DIR)) {
7
- fs.mkdirSync(CACHE_DIR, { recursive: true });
9
+ if (!fs.existsSync(cacheDir)) {
10
+ fs.mkdirSync(cacheDir, { recursive: true });
8
11
  }
9
12
  }
10
13
  export function writeCache(key, data) {
@@ -14,11 +17,11 @@ export function writeCache(key, data) {
14
17
  data,
15
18
  version: CACHE_VERSION,
16
19
  };
17
- const filePath = path.join(CACHE_DIR, `${key}.json`);
20
+ const filePath = path.join(cacheDir, `${key}.json`);
18
21
  fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), 'utf-8');
19
22
  }
20
23
  export function readCache(key, maxAgeMs = 3600000) {
21
- const filePath = path.join(CACHE_DIR, `${key}.json`);
24
+ const filePath = path.join(cacheDir, `${key}.json`);
22
25
  if (!fs.existsSync(filePath))
23
26
  return null;
24
27
  try {
@@ -36,15 +39,15 @@ export function readCache(key, maxAgeMs = 3600000) {
36
39
  }
37
40
  export function clearCache(key) {
38
41
  if (key) {
39
- const filePath = path.join(CACHE_DIR, `${key}.json`);
42
+ const filePath = path.join(cacheDir, `${key}.json`);
40
43
  if (fs.existsSync(filePath))
41
44
  fs.unlinkSync(filePath);
42
45
  }
43
46
  else {
44
- if (fs.existsSync(CACHE_DIR)) {
45
- const files = fs.readdirSync(CACHE_DIR);
47
+ if (fs.existsSync(cacheDir)) {
48
+ const files = fs.readdirSync(cacheDir);
46
49
  for (const file of files) {
47
- fs.unlinkSync(path.join(CACHE_DIR, file));
50
+ fs.unlinkSync(path.join(cacheDir, file));
48
51
  }
49
52
  }
50
53
  }
@@ -1,4 +1,4 @@
1
1
  export { loadConfig, generateDefaultConfig, InfrawiseConfigSchema, ConfigError as ConfigValidationError, } from './config.js';
2
2
  export { logger } from './logger.js';
3
3
  export { InfrawiseError, AWSConnectionError, DynamoDBError, PostgresConnectionError, RepositoryScanError, ConfigError, formatError, } from './errors.js';
4
- export { writeCache, readCache, clearCache } from './cache.js';
4
+ export { writeCache, readCache, clearCache, setCacheDir } from './cache.js';
@@ -85,6 +85,7 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
85
85
  provider: 'aws',
86
86
  subscriptionCount: t.subscriptionCount,
87
87
  encrypted: t.encrypted,
88
+ filterPolicies: t.filterPolicies?.length > 0 ? t.filterPolicies : undefined,
88
89
  });
89
90
  }
90
91
  for (const s of servicesMeta.secrets ?? []) {
@@ -265,7 +265,7 @@ export function createMcpServer() {
265
265
  });
266
266
  }));
267
267
  mcp.registerTool('get_topic_details', {
268
- description: 'Returns all SNS topics with subscription count and encryption status. Call this when reviewing event fan-out patterns or checking whether a topic has the expected number of subscribers.',
268
+ description: 'Returns all SNS topics with subscription count, encryption status, and filter policies. Filter policies list the message attributes each subscription requires — publishers must include these attributes or messages are silently dropped. Call this before writing any SNS publish code or when reviewing event fan-out patterns.',
269
269
  inputSchema: z.object({}),
270
270
  }, logged('get_topic_details', async () => {
271
271
  const topics = getTopicNodes(currentGraph);
@@ -276,6 +276,7 @@ export function createMcpServer() {
276
276
  provider: t.provider,
277
277
  subscriptionCount: t.subscriptionCount,
278
278
  encrypted: t.encrypted,
279
+ filterPolicies: t.filterPolicies ?? [],
279
280
  })),
280
281
  });
281
282
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.8.3",
3
+ "version": "0.9.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, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
6
6
  "keywords": [