infrawise 0.3.0 → 0.4.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 CHANGED
@@ -160,6 +160,41 @@ To let Claude Code manage the server lifecycle automatically:
160
160
 
161
161
  ---
162
162
 
163
+ ## GitHub Action
164
+
165
+ Add infrastructure analysis to any PR workflow. No `infrawise.yaml` required — the action auto-generates one from your AWS credentials.
166
+
167
+ ```yaml
168
+ # .github/workflows/infrawise.yml
169
+ name: Infrawise
170
+ on: [pull_request]
171
+
172
+ jobs:
173
+ analyze:
174
+ runs-on: ubuntu-latest
175
+ steps:
176
+ - uses: actions/checkout@v4
177
+ - uses: Sidd27/infrawise@v1
178
+ with:
179
+ fail-on: high # fail the PR on high severity findings (default)
180
+ env:
181
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
182
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
183
+ AWS_REGION: ${{ secrets.AWS_REGION }}
184
+ ```
185
+
186
+ If `infrawise.yaml` is not present in the repo, the action generates a minimal config with DynamoDB, Lambda, SQS, SNS, SSM, Secrets Manager, and RDS enabled — using `AWS_REGION` from the environment. Add an `infrawise.yaml` to your repo to customize which services to include or to add database connections.
187
+
188
+ Findings appear as annotations on the PR diff and in the job summary.
189
+
190
+ | Input | Default | Description |
191
+ |---|---|---|
192
+ | `version` | `latest` | infrawise version to install |
193
+ | `fail-on` | `high` | Exit code 1 if findings at or above this severity (`high`, `medium`, `low`, `never`) |
194
+ | `config` | `infrawise.yaml` | Path to config file — auto-generated if not present |
195
+
196
+ ---
197
+
163
198
  ## CLI reference
164
199
 
165
200
  | Command | What it does |
@@ -176,6 +211,16 @@ To let Claude Code manage the server lifecycle automatically:
176
211
 
177
212
  `infrawise.yaml` is generated by `infrawise init` and lives in your repo root. Every service must be explicitly `enabled: true` — infrawise never connects to anything not listed in config.
178
213
 
214
+ Connection strings support `${ENV_VAR}` substitution so passwords never need to be committed:
215
+
216
+ ```yaml
217
+ postgres:
218
+ enabled: true
219
+ connectionString: postgresql://infrawise_ro:${DB_PASSWORD}@host:5432/mydb
220
+ ```
221
+
222
+ Full example:
223
+
179
224
  ```yaml
180
225
  project: payments-service
181
226
 
@@ -191,7 +236,7 @@ dynamodb:
191
236
 
192
237
  postgres:
193
238
  enabled: true
194
- connectionString: postgresql://infrawise_ro:password@host:5432/mydb
239
+ connectionString: postgresql://infrawise_ro:${DB_PASSWORD}@host:5432/mydb
195
240
 
196
241
  mysql:
197
242
  enabled: false
@@ -220,6 +265,9 @@ lambda:
220
265
  rds:
221
266
  enabled: false
222
267
 
268
+ kafka:
269
+ enabled: false
270
+
223
271
  cloudwatchLogs:
224
272
  enabled: false
225
273
  logGroupPrefixes: []
@@ -284,6 +332,7 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
284
332
  | PostgreSQL / MySQL schema | Tables, indexes, column types |
285
333
  | MongoDB schema | Collections, indexes |
286
334
  | SQS | Missing DLQs, unencrypted queues, large backlogs |
335
+ | Kafka (kafkajs) | Producer/consumer topic mapping from code |
287
336
  | Secrets Manager | Missing secret rotation |
288
337
  | Lambda | Default memory (128 MB), high timeouts |
289
338
  | RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
@@ -309,7 +358,7 @@ Uses [ts-morph](https://ts-morph.com/) AST analysis to detect which functions ca
309
358
 
310
359
  Non-TypeScript/JavaScript projects still get full value from infrastructure-level analyzers — code correlation (function-to-table mapping, N+1 patterns) is skipped.
311
360
 
312
- The scanner supports: AWS SDK v3/v2 for DynamoDB, `pg`/Prisma/Knex for PostgreSQL, `mysql2`/Knex for MySQL, driver/Mongoose for MongoDB, and AWS SDK v3 for SQS/SNS/SSM/Secrets/Lambda.
361
+ 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).
313
362
 
314
363
  ---
315
364
 
@@ -399,6 +448,7 @@ src/
399
448
  - Kubernetes workload graphing
400
449
  - VS Code extension
401
450
  - Infrastructure drift detection
451
+ - MSK (Amazon Managed Streaming for Apache Kafka) — cluster metadata + topic listing via MSK API and Kafka admin client
402
452
 
403
453
  ### Under consideration
404
454
  - OpenTelemetry integration
@@ -244,7 +244,7 @@ async function extractRDSMetadata(cfg = {}) {
244
244
  instanceClass: db.DBInstanceClass ?? '',
245
245
  publiclyAccessible: db.PubliclyAccessible ?? false,
246
246
  storageEncrypted: db.StorageEncrypted ?? false,
247
- backupRetentionPeriod: db.BackupRetentionPeriod ?? 0,
247
+ backupRetentionDays: db.BackupRetentionPeriod ?? 0,
248
248
  deletionProtection: db.DeletionProtection ?? false,
249
249
  multiAZ: db.MultiAZ ?? false,
250
250
  dbInstanceStatus: db.DBInstanceStatus ?? '',
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.extractLogsSummary = extractLogsSummary;
3
+ exports.extractLogsMetadata = extractLogsMetadata;
4
4
  exports.validateLogsAccess = validateLogsAccess;
5
5
  const client_cloudwatch_logs_1 = require("@aws-sdk/client-cloudwatch-logs");
6
6
  const credential_providers_1 = require("@aws-sdk/credential-providers");
@@ -44,7 +44,7 @@ function topPatterns(messages, limit = 5) {
44
44
  .slice(0, limit)
45
45
  .map(([pattern, count]) => ({ pattern, count }));
46
46
  }
47
- async function extractLogsSummary(cfg = {}) {
47
+ async function extractLogsMetadata(cfg = {}) {
48
48
  const client = new client_cloudwatch_logs_1.CloudWatchLogsClient(clientConfig(cfg));
49
49
  const windowMs = (cfg.windowHours ?? 24) * 60 * 60 * 1000;
50
50
  const startTime = Date.now() - windowMs;
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.runAnalyze = runAnalyze;
40
+ const fs = __importStar(require("fs"));
40
41
  const path = __importStar(require("path"));
41
42
  const chalk_1 = __importDefault(require("chalk"));
42
43
  const ora_1 = __importDefault(require("ora"));
@@ -52,16 +53,72 @@ const context_1 = require("../../context");
52
53
  const graph_1 = require("../../graph");
53
54
  const analyzers_1 = require("../../analyzers");
54
55
  const utils_1 = require("../utils");
56
+ const SEVERITY_RANK = { high: 3, medium: 2, low: 1, never: 0 };
57
+ function mkSpinner(text, ci) {
58
+ if (ci) {
59
+ return {
60
+ succeed: (msg) => console.log(` ✓ ${msg}`),
61
+ warn: (msg) => console.log(` ⚠ ${msg}`),
62
+ };
63
+ }
64
+ return (0, ora_1.default)({ text: chalk_1.default.dim(text), color: 'cyan' }).start();
65
+ }
66
+ function emitCIOutput(findings, failOn) {
67
+ for (const f of findings) {
68
+ const level = f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'notice';
69
+ console.log(`::${level} title=Infrawise [${f.severity.toUpperCase()}]::${f.issue} — ${f.description}`);
70
+ }
71
+ const summaryPath = process.env['GITHUB_STEP_SUMMARY'];
72
+ if (summaryPath) {
73
+ const high = findings.filter((f) => f.severity === 'high').length;
74
+ const medium = findings.filter((f) => f.severity === 'medium').length;
75
+ const low = findings.filter((f) => f.severity === 'low').length;
76
+ const rows = findings.map((f) => {
77
+ const icon = f.severity === 'high' ? '🔴' : f.severity === 'medium' ? '🟡' : '🔵';
78
+ return `| ${icon} ${f.severity.toUpperCase()} | ${f.issue} | ${f.recommendation} |`;
79
+ }).join('\n');
80
+ const summary = findings.length === 0
81
+ ? '## ✅ Infrawise — no issues found\n'
82
+ : [
83
+ '## Infrawise Analysis',
84
+ '',
85
+ `**${high} high · ${medium} medium · ${low} low**`,
86
+ '',
87
+ '| Severity | Issue | Recommendation |',
88
+ '|---|---|---|',
89
+ rows,
90
+ ].join('\n');
91
+ fs.appendFileSync(summaryPath, summary + '\n');
92
+ }
93
+ const threshold = SEVERITY_RANK[failOn] ?? 3;
94
+ const maxFound = Math.max(0, ...findings.map((f) => SEVERITY_RANK[f.severity] ?? 0));
95
+ if (maxFound >= threshold && threshold > 0) {
96
+ process.exit(1);
97
+ }
98
+ }
55
99
  async function runAnalyze(options = {}) {
56
- (0, utils_1.printHeader)('Running Analysis');
100
+ const ci = options.ci ?? false;
101
+ const failOn = options.failOn ?? 'high';
102
+ if (!ci)
103
+ (0, utils_1.printHeader)('Running Analysis');
57
104
  let config;
105
+ let isFallback = false;
58
106
  try {
59
107
  config = (0, core_1.loadConfig)(options.config);
60
- utils_1.log.success('Config loaded', options.config ?? 'infrawise.yaml');
108
+ if (!ci)
109
+ utils_1.log.success('Config loaded', options.config ?? 'infrawise.yaml');
61
110
  }
62
111
  catch (err) {
63
- console.error((0, core_1.formatError)(err));
64
- process.exit(1);
112
+ const isNotFound = err instanceof Error && err.message.includes('not found at');
113
+ if (ci && isNotFound) {
114
+ config = { project: path.basename(process.cwd()) };
115
+ isFallback = true;
116
+ console.log(' ✓ No infrawise.yaml — running code-only analysis (repo scan + IaC)');
117
+ }
118
+ else {
119
+ console.error((0, core_1.formatError)(err));
120
+ process.exit(1);
121
+ }
65
122
  }
66
123
  const repoPath = options.repo ?? process.cwd();
67
124
  const awsCfg = { region: config.aws?.region, profile: config.aws?.profile, endpoint: config.aws?.endpoint };
@@ -72,145 +129,145 @@ async function runAnalyze(options = {}) {
72
129
  const servicesMeta = {};
73
130
  // ── DynamoDB ────────────────────────────────────────────────────────────────
74
131
  if (config.dynamodb?.enabled === true) {
75
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting DynamoDB tables...'), color: 'cyan' }).start();
132
+ const s = mkSpinner('Extracting DynamoDB tables...', ci);
76
133
  try {
77
134
  const result = await (0, dynamodb_1.extractDynamoMetadata)(config);
78
135
  dynamoMeta.push(...result);
79
- spin.succeed(chalk_1.default.green('DynamoDB') + chalk_1.default.dim(` ${result.length} table(s)`));
136
+ s.succeed(chalk_1.default.green('DynamoDB') + chalk_1.default.dim(` ${result.length} table(s)`));
80
137
  }
81
138
  catch (err) {
82
- spin.warn(chalk_1.default.yellow('DynamoDB skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
139
+ s.warn(chalk_1.default.yellow('DynamoDB skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
83
140
  }
84
141
  }
85
142
  // ── PostgreSQL ──────────────────────────────────────────────────────────────
86
143
  if (config.postgres?.enabled && config.postgres.connectionString) {
87
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting PostgreSQL schema...'), color: 'cyan' }).start();
144
+ const s = mkSpinner('Extracting PostgreSQL schema...', ci);
88
145
  try {
89
146
  const result = await (0, postgres_1.extractPostgresMetadata)(config.postgres.connectionString);
90
147
  postgresMeta.push(...result);
91
- spin.succeed(chalk_1.default.green('PostgreSQL') + chalk_1.default.dim(` ${result.length} table(s)`));
148
+ s.succeed(chalk_1.default.green('PostgreSQL') + chalk_1.default.dim(` ${result.length} table(s)`));
92
149
  }
93
150
  catch (err) {
94
- spin.warn(chalk_1.default.yellow('PostgreSQL skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
151
+ s.warn(chalk_1.default.yellow('PostgreSQL skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
95
152
  }
96
153
  }
97
154
  // ── MySQL ───────────────────────────────────────────────────────────────────
98
155
  if (config.mysql?.enabled && config.mysql.connectionString) {
99
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting MySQL schema...'), color: 'cyan' }).start();
156
+ const s = mkSpinner('Extracting MySQL schema...', ci);
100
157
  try {
101
158
  const result = await (0, mysql_1.extractMySQLMetadata)(config.mysql.connectionString);
102
159
  mysqlMeta.push(...result);
103
- spin.succeed(chalk_1.default.green('MySQL') + chalk_1.default.dim(` ${result.length} table(s)`));
160
+ s.succeed(chalk_1.default.green('MySQL') + chalk_1.default.dim(` ${result.length} table(s)`));
104
161
  }
105
162
  catch (err) {
106
- spin.warn(chalk_1.default.yellow('MySQL skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
163
+ s.warn(chalk_1.default.yellow('MySQL skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
107
164
  }
108
165
  }
109
166
  // ── MongoDB ─────────────────────────────────────────────────────────────────
110
167
  if (config.mongodb?.enabled && config.mongodb.connectionString) {
111
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting MongoDB schema...'), color: 'cyan' }).start();
168
+ const s = mkSpinner('Extracting MongoDB schema...', ci);
112
169
  try {
113
170
  const result = await (0, mongodb_1.extractMongoMetadata)(config.mongodb.connectionString, config.mongodb.databases);
114
171
  mongoMeta.push(...result);
115
- spin.succeed(chalk_1.default.green('MongoDB') + chalk_1.default.dim(` ${result.length} collection(s)`));
172
+ s.succeed(chalk_1.default.green('MongoDB') + chalk_1.default.dim(` ${result.length} collection(s)`));
116
173
  }
117
174
  catch (err) {
118
- spin.warn(chalk_1.default.yellow('MongoDB skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
175
+ s.warn(chalk_1.default.yellow('MongoDB skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
119
176
  }
120
177
  }
121
178
  // ── SQS ─────────────────────────────────────────────────────────────────────
122
179
  if (config.sqs?.enabled === true) {
123
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting SQS queues...'), color: 'cyan' }).start();
180
+ const s = mkSpinner('Extracting SQS queues...', ci);
124
181
  try {
125
182
  const result = await (0, aws_1.extractSQSMetadata)(awsCfg);
126
183
  servicesMeta.sqs = result;
127
- spin.succeed(chalk_1.default.green('SQS') + chalk_1.default.dim(` ${result.length} queue(s)`));
184
+ s.succeed(chalk_1.default.green('SQS') + chalk_1.default.dim(` ${result.length} queue(s)`));
128
185
  }
129
186
  catch (err) {
130
- spin.warn(chalk_1.default.yellow('SQS skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
187
+ s.warn(chalk_1.default.yellow('SQS skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
131
188
  }
132
189
  }
133
190
  // ── SNS ─────────────────────────────────────────────────────────────────────
134
191
  if (config.sns?.enabled === true) {
135
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting SNS topics...'), color: 'cyan' }).start();
192
+ const s = mkSpinner('Extracting SNS topics...', ci);
136
193
  try {
137
194
  const result = await (0, aws_1.extractSNSMetadata)(awsCfg);
138
195
  servicesMeta.sns = result;
139
- spin.succeed(chalk_1.default.green('SNS') + chalk_1.default.dim(` ${result.length} topic(s)`));
196
+ s.succeed(chalk_1.default.green('SNS') + chalk_1.default.dim(` ${result.length} topic(s)`));
140
197
  }
141
198
  catch (err) {
142
- spin.warn(chalk_1.default.yellow('SNS skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
199
+ s.warn(chalk_1.default.yellow('SNS skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
143
200
  }
144
201
  }
145
202
  // ── SSM Parameter Store ──────────────────────────────────────────────────────
146
203
  if (config.ssm?.enabled === true) {
147
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting SSM parameters...'), color: 'cyan' }).start();
204
+ const s = mkSpinner('Extracting SSM parameters...', ci);
148
205
  try {
149
206
  const result = await (0, aws_1.extractSSMMetadata)({ ...awsCfg, paths: config.ssm?.paths });
150
207
  servicesMeta.ssm = result;
151
- spin.succeed(chalk_1.default.green('SSM') + chalk_1.default.dim(` ${result.length} parameter(s) `) + chalk_1.default.dim('(metadata only, no values)'));
208
+ s.succeed(chalk_1.default.green('SSM') + chalk_1.default.dim(` ${result.length} parameter(s) `) + chalk_1.default.dim('(metadata only, no values)'));
152
209
  }
153
210
  catch (err) {
154
- spin.warn(chalk_1.default.yellow('SSM skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
211
+ s.warn(chalk_1.default.yellow('SSM skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
155
212
  }
156
213
  }
157
214
  // ── Secrets Manager ──────────────────────────────────────────────────────────
158
215
  if (config.secretsManager?.enabled === true) {
159
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting Secrets Manager metadata...'), color: 'cyan' }).start();
216
+ const s = mkSpinner('Extracting Secrets Manager metadata...', ci);
160
217
  try {
161
218
  const result = await (0, aws_1.extractSecretsMetadata)(awsCfg);
162
219
  servicesMeta.secrets = result;
163
- spin.succeed(chalk_1.default.green('Secrets Manager') + chalk_1.default.dim(` ${result.length} secret(s) `) + chalk_1.default.dim('(names/rotation only, no values)'));
220
+ s.succeed(chalk_1.default.green('Secrets Manager') + chalk_1.default.dim(` ${result.length} secret(s) `) + chalk_1.default.dim('(names/rotation only, no values)'));
164
221
  }
165
222
  catch (err) {
166
- spin.warn(chalk_1.default.yellow('Secrets Manager skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
223
+ s.warn(chalk_1.default.yellow('Secrets Manager skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
167
224
  }
168
225
  }
169
226
  // ── Lambda ───────────────────────────────────────────────────────────────────
170
227
  if (config.lambda?.enabled === true) {
171
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting Lambda functions...'), color: 'cyan' }).start();
228
+ const s = mkSpinner('Extracting Lambda functions...', ci);
172
229
  try {
173
230
  const result = await (0, aws_1.extractLambdaMetadata)(awsCfg);
174
231
  servicesMeta.lambda = result;
175
- spin.succeed(chalk_1.default.green('Lambda') + chalk_1.default.dim(` ${result.length} function(s)`));
232
+ s.succeed(chalk_1.default.green('Lambda') + chalk_1.default.dim(` ${result.length} function(s)`));
176
233
  }
177
234
  catch (err) {
178
- spin.warn(chalk_1.default.yellow('Lambda skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
235
+ s.warn(chalk_1.default.yellow('Lambda skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
179
236
  }
180
237
  }
181
238
  // ── RDS ──────────────────────────────────────────────────────────────────────
182
239
  if (config.rds?.enabled === true) {
183
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting RDS instances...'), color: 'cyan' }).start();
240
+ const s = mkSpinner('Extracting RDS instances...', ci);
184
241
  try {
185
242
  const result = await (0, aws_1.extractRDSMetadata)(awsCfg);
186
243
  servicesMeta.rds = result;
187
- spin.succeed(chalk_1.default.green('RDS') + chalk_1.default.dim(` ${result.length} instance(s)`));
244
+ s.succeed(chalk_1.default.green('RDS') + chalk_1.default.dim(` ${result.length} instance(s)`));
188
245
  }
189
246
  catch (err) {
190
- spin.warn(chalk_1.default.yellow('RDS skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
247
+ s.warn(chalk_1.default.yellow('RDS skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
191
248
  }
192
249
  }
193
250
  // ── CloudWatch Logs ──────────────────────────────────────────────────────────
194
251
  if (config.cloudwatchLogs?.enabled) {
195
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Sampling CloudWatch Logs (errors only, max 50 groups)...'), color: 'cyan' }).start();
252
+ const s = mkSpinner('Sampling CloudWatch Logs (errors only, max 50 groups)...', ci);
196
253
  try {
197
- const result = await (0, logs_1.extractLogsSummary)({
254
+ const result = await (0, logs_1.extractLogsMetadata)({
198
255
  ...awsCfg,
199
256
  logGroupPrefixes: config.cloudwatchLogs.logGroupPrefixes,
200
257
  windowHours: config.cloudwatchLogs.windowHours,
201
258
  });
202
259
  servicesMeta.logs = result;
203
260
  const errorGroups = result.filter((lg) => lg.errorCount > 0).length;
204
- spin.succeed(chalk_1.default.green('CloudWatch Logs') + chalk_1.default.dim(` ${result.length} group(s), ${errorGroups} with errors`));
261
+ s.succeed(chalk_1.default.green('CloudWatch Logs') + chalk_1.default.dim(` ${result.length} group(s), ${errorGroups} with errors`));
205
262
  }
206
263
  catch (err) {
207
- spin.warn(chalk_1.default.yellow('CloudWatch Logs skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
264
+ s.warn(chalk_1.default.yellow('CloudWatch Logs skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
208
265
  }
209
266
  }
210
267
  // ── IaC schema (Terraform / CloudFormation / CDK) ────────────────────────────
211
268
  let iacDriftAnalyzer;
212
269
  {
213
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Extracting IaC schema (Terraform / CloudFormation / CDK)...'), color: 'cyan' }).start();
270
+ const s = mkSpinner('Extracting IaC schema (Terraform / CloudFormation / CDK)...', ci);
214
271
  try {
215
272
  const iacSchema = await (0, terraform_1.extractIaCSchema)(repoPath);
216
273
  const total = iacSchema.dynamoTables.length + iacSchema.rdsInstances.length +
@@ -219,52 +276,52 @@ async function runAnalyze(options = {}) {
219
276
  iacSchema.secrets.length + iacSchema.apiGateways.length;
220
277
  iacDriftAnalyzer = new analyzers_1.IaCDriftAnalyzer();
221
278
  iacDriftAnalyzer.setIaCSchema(iacSchema);
222
- spin.succeed(chalk_1.default.green('IaC schema') + chalk_1.default.dim(` ${total} resource(s) across TF/CFN/CDK`));
279
+ s.succeed(chalk_1.default.green('IaC schema') + chalk_1.default.dim(` ${total} resource(s) across TF/CFN/CDK`));
223
280
  }
224
281
  catch (err) {
225
- spin.warn(chalk_1.default.yellow('IaC scan skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
282
+ s.warn(chalk_1.default.yellow('IaC scan skipped') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
226
283
  }
227
284
  }
228
285
  // ── Repository scan ──────────────────────────────────────────────────────────
229
286
  let operations;
230
287
  {
231
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim(`Scanning ${path.basename(repoPath)} for service usage...`), color: 'cyan' }).start();
288
+ const s = mkSpinner(`Scanning ${path.basename(repoPath)} for service usage...`, ci);
232
289
  try {
233
290
  operations = await (0, context_1.scanRepository)(repoPath);
234
- spin.succeed(chalk_1.default.green('Repository scanned') + chalk_1.default.dim(` ${operations.length} service operation(s) found`));
291
+ s.succeed(chalk_1.default.green('Repository scanned') + chalk_1.default.dim(` ${operations.length} service operation(s) found`));
235
292
  }
236
293
  catch (err) {
237
- spin.warn(chalk_1.default.yellow('Repository scan failed') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
294
+ s.warn(chalk_1.default.yellow('Repository scan failed') + chalk_1.default.dim(` ${err instanceof Error ? err.message : String(err)}`));
238
295
  operations = [];
239
296
  }
240
297
  }
241
298
  // ── Build graph ──────────────────────────────────────────────────────────────
242
299
  let graph;
243
300
  {
244
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Building infrastructure graph...'), color: 'cyan' }).start();
301
+ const s = mkSpinner('Building infrastructure graph...', ci);
245
302
  graph = (0, graph_1.buildGraph)(operations, dynamoMeta, postgresMeta, mysqlMeta, mongoMeta, servicesMeta);
246
- spin.succeed(chalk_1.default.green('Graph built') + chalk_1.default.dim(` ${graph.nodes.length} nodes, ${graph.edges.length} edges`));
303
+ s.succeed(chalk_1.default.green('Graph built') + chalk_1.default.dim(` ${graph.nodes.length} nodes, ${graph.edges.length} edges`));
247
304
  }
248
305
  // ── Run analyzers ────────────────────────────────────────────────────────────
249
306
  let findings;
250
307
  {
251
- const spin = (0, ora_1.default)({ text: chalk_1.default.dim('Running analyzers...'), color: 'cyan' }).start();
308
+ const s = mkSpinner('Running analyzers...', ci);
252
309
  const analyzers = [
253
- ...(config.dynamodb?.enabled === true ? [
310
+ ...(config.dynamodb?.enabled === true || isFallback ? [
254
311
  new analyzers_1.FullTableScanAnalyzer(),
255
312
  new analyzers_1.MissingGSIAnalyzer(),
256
313
  new analyzers_1.HotPartitionAnalyzer(),
257
314
  ] : []),
258
- ...(config.postgres?.enabled ? [
315
+ ...(config.postgres?.enabled || isFallback ? [
259
316
  new analyzers_1.MissingIndexAnalyzer(),
260
317
  new analyzers_1.NplusOneAnalyzer(),
261
318
  new analyzers_1.LargeSelectAnalyzer(),
262
319
  ] : []),
263
- ...(config.mysql?.enabled ? [
320
+ ...(config.mysql?.enabled || isFallback ? [
264
321
  new analyzers_1.MissingMySQLIndexAnalyzer(),
265
322
  new analyzers_1.MySQLFullTableScanAnalyzer(),
266
323
  ] : []),
267
- ...(config.mongodb?.enabled ? [
324
+ ...(config.mongodb?.enabled || isFallback ? [
268
325
  new analyzers_1.MissingMongoIndexAnalyzer(),
269
326
  new analyzers_1.MongoCollectionScanAnalyzer(),
270
327
  ] : []),
@@ -293,13 +350,17 @@ async function runAnalyze(options = {}) {
293
350
  ...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
294
351
  ];
295
352
  findings = await (0, analyzers_1.runAllAnalyzers)(graph, analyzers);
296
- spin.succeed(chalk_1.default.green('Analysis complete') + chalk_1.default.dim(` ${findings.length} finding(s)`));
353
+ s.succeed(chalk_1.default.green('Analysis complete') + chalk_1.default.dim(` ${findings.length} finding(s)`));
297
354
  }
298
355
  // ── Cache ─────────────────────────────────────────────────────────────────────
299
356
  (0, core_1.writeCache)('graph', graph);
300
357
  (0, core_1.writeCache)('findings', findings);
301
358
  (0, core_1.writeCache)('operations', operations);
302
359
  // ── Output ────────────────────────────────────────────────────────────────────
360
+ if (ci) {
361
+ emitCIOutput(findings, failOn);
362
+ return;
363
+ }
303
364
  console.log('');
304
365
  if (findings.length === 0) {
305
366
  console.log(` ${chalk_1.default.green.bold('✓ No issues found!')} ${chalk_1.default.dim('Your infrastructure looks clean.')}`);
package/dist/cli/index.js CHANGED
@@ -37,12 +37,17 @@ program
37
37
  .option('-c, --config <path>', 'Path to infrawise.yaml', 'infrawise.yaml')
38
38
  .option('-r, --repo <path>', 'Path to repository to scan', process.cwd())
39
39
  .option('--no-cache', 'Skip reading/writing the cache')
40
+ .option('--ci', 'CI mode: GitHub annotations output, job summary, exit code on findings')
41
+ .option('--fail-on <severity>', 'Exit 1 if findings at or above this severity (high|medium|low|never)', 'high')
40
42
  .action(async (options) => {
41
- (0, utils_1.printBanner)();
43
+ if (!options.ci)
44
+ (0, utils_1.printBanner)();
42
45
  await (0, analyze_1.runAnalyze)({
43
46
  config: options.config !== 'infrawise.yaml' ? options.config : undefined,
44
47
  repo: options.repo,
45
48
  noCache: !options.cache,
49
+ ci: options.ci ?? false,
50
+ failOn: options.failOn ?? 'high',
46
51
  });
47
52
  });
48
53
  program
@@ -82,6 +82,11 @@ const MONGO_COLLECTION_METHODS = new Set(['collection']);
82
82
  // ── AWS service patterns ──────────────────────────────────────────────────────
83
83
  const SQS_COMMANDS = new Set(['SendMessageCommand', 'SendMessageBatchCommand', 'ReceiveMessageCommand', 'DeleteMessageCommand', 'sendMessage', 'sendMessageBatch', 'receiveMessage']);
84
84
  const SNS_COMMANDS = new Set(['PublishCommand', 'PublishBatchCommand', 'publish', 'publishBatch']);
85
+ // kafkajs patterns — detection relies on variable naming (producer/consumer/kafka)
86
+ const KAFKA_PRODUCER_METHODS = new Set(['send', 'sendBatch']);
87
+ const KAFKA_CONSUMER_METHODS = new Set(['subscribe']);
88
+ const KAFKA_CLIENT_PATTERNS = ['kafka', 'producer', 'consumer'];
89
+ const KAFKA_TOPIC_KEYS = ['topic'];
85
90
  const SSM_COMMANDS = new Set(['GetParameterCommand', 'GetParametersCommand', 'GetParametersByPathCommand', 'getParameter', 'getParameters', 'getParametersByPath']);
86
91
  const SECRETS_COMMANDS = new Set(['GetSecretValueCommand', 'getSecretValue']);
87
92
  const LAMBDA_COMMANDS = new Set(['InvokeCommand', 'InvokeAsyncCommand', 'invoke', 'invokeAsync']);
@@ -158,7 +163,7 @@ function detectDynamoOperations(callExpr, filePath) {
158
163
  return {
159
164
  functionName: getEnclosingFunctionName(callExpr),
160
165
  operationType: className,
161
- databaseType: 'dynamodb',
166
+ serviceType: 'dynamodb',
162
167
  target: tableName,
163
168
  filePath,
164
169
  };
@@ -173,7 +178,7 @@ function detectDynamoOperations(callExpr, filePath) {
173
178
  return {
174
179
  functionName: getEnclosingFunctionName(callExpr),
175
180
  operationType: methodName,
176
- databaseType: 'dynamodb',
181
+ serviceType: 'dynamodb',
177
182
  target: tableName,
178
183
  filePath,
179
184
  };
@@ -229,7 +234,7 @@ function detectPostgresOperations(callExpr, filePath) {
229
234
  return {
230
235
  functionName: getEnclosingFunctionName(callExpr),
231
236
  operationType: 'query',
232
- databaseType: 'postgres',
237
+ serviceType: 'postgres',
233
238
  target: tableName,
234
239
  filePath,
235
240
  };
@@ -245,7 +250,7 @@ function detectPostgresOperations(callExpr, filePath) {
245
250
  return {
246
251
  functionName: getEnclosingFunctionName(callExpr),
247
252
  operationType: methodName,
248
- databaseType: 'postgres',
253
+ serviceType: 'postgres',
249
254
  target: modelName,
250
255
  filePath,
251
256
  };
@@ -262,7 +267,7 @@ function detectPostgresOperations(callExpr, filePath) {
262
267
  return {
263
268
  functionName: getEnclosingFunctionName(callExpr),
264
269
  operationType: methodName,
265
- databaseType: 'postgres',
270
+ serviceType: 'postgres',
266
271
  target: 'unknown',
267
272
  filePath,
268
273
  };
@@ -278,7 +283,7 @@ function detectPostgresOperations(callExpr, filePath) {
278
283
  return {
279
284
  functionName: getEnclosingFunctionName(callExpr),
280
285
  operationType: methodName,
281
- databaseType: 'postgres',
286
+ serviceType: 'postgres',
282
287
  target: tableName,
283
288
  filePath,
284
289
  };
@@ -315,7 +320,7 @@ function detectMySQLOperations(callExpr, filePath) {
315
320
  return {
316
321
  functionName: getEnclosingFunctionName(callExpr),
317
322
  operationType: 'query',
318
- databaseType: 'mysql',
323
+ serviceType: 'mysql',
319
324
  target: tableName,
320
325
  filePath,
321
326
  };
@@ -334,7 +339,7 @@ function detectMySQLOperations(callExpr, filePath) {
334
339
  return {
335
340
  functionName: getEnclosingFunctionName(callExpr),
336
341
  operationType: methodName,
337
- databaseType: 'mysql',
342
+ serviceType: 'mysql',
338
343
  target: tableName,
339
344
  filePath,
340
345
  };
@@ -382,7 +387,7 @@ function detectMongoOperations(callExpr, filePath) {
382
387
  return {
383
388
  functionName: getEnclosingFunctionName(callExpr),
384
389
  operationType: opType,
385
- databaseType: 'mongodb',
390
+ serviceType: 'mongodb',
386
391
  target: collectionName,
387
392
  filePath,
388
393
  };
@@ -399,7 +404,7 @@ function detectMongoOperations(callExpr, filePath) {
399
404
  return {
400
405
  functionName: getEnclosingFunctionName(callExpr),
401
406
  operationType: 'query',
402
- databaseType: 'mongodb',
407
+ serviceType: 'mongodb',
403
408
  target: collectionName,
404
409
  filePath,
405
410
  };
@@ -422,6 +427,35 @@ function extractArgValue(arg, ...keys) {
422
427
  }
423
428
  return 'unknown';
424
429
  }
430
+ function detectKafkaOperations(callExpr, filePath) {
431
+ const expr = callExpr.getExpression();
432
+ const args = callExpr.getArguments();
433
+ if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
434
+ return null;
435
+ const methodName = expr.getName();
436
+ const objText = expr.getExpression().getText().toLowerCase();
437
+ if (!KAFKA_CLIENT_PATTERNS.some((p) => objText.includes(p)))
438
+ return null;
439
+ if (KAFKA_PRODUCER_METHODS.has(methodName)) {
440
+ return {
441
+ functionName: getEnclosingFunctionName(callExpr),
442
+ operationType: methodName,
443
+ serviceType: 'kafka',
444
+ target: args.length > 0 ? extractArgValue(args[0], ...KAFKA_TOPIC_KEYS) : 'unknown',
445
+ filePath,
446
+ };
447
+ }
448
+ if (KAFKA_CONSUMER_METHODS.has(methodName)) {
449
+ return {
450
+ functionName: getEnclosingFunctionName(callExpr),
451
+ operationType: methodName,
452
+ serviceType: 'kafka',
453
+ target: args.length > 0 ? extractArgValue(args[0], ...KAFKA_TOPIC_KEYS) : 'unknown',
454
+ filePath,
455
+ };
456
+ }
457
+ return null;
458
+ }
425
459
  function detectAWSServiceOperations(callExpr, filePath) {
426
460
  const expr = callExpr.getExpression();
427
461
  const args = callExpr.getArguments();
@@ -435,7 +469,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
435
469
  return {
436
470
  functionName: getEnclosingFunctionName(callExpr),
437
471
  operationType: className,
438
- databaseType: 'sqs',
472
+ serviceType: 'sqs',
439
473
  target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...SQS_ARG_KEYS) : 'unknown',
440
474
  filePath,
441
475
  };
@@ -444,7 +478,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
444
478
  return {
445
479
  functionName: getEnclosingFunctionName(callExpr),
446
480
  operationType: className,
447
- databaseType: 'sns',
481
+ serviceType: 'sns',
448
482
  target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...SNS_ARG_KEYS) : 'unknown',
449
483
  filePath,
450
484
  };
@@ -453,7 +487,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
453
487
  return {
454
488
  functionName: getEnclosingFunctionName(callExpr),
455
489
  operationType: className,
456
- databaseType: 'ssm',
490
+ serviceType: 'ssm',
457
491
  target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...SSM_ARG_KEYS) : 'unknown',
458
492
  filePath,
459
493
  };
@@ -462,7 +496,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
462
496
  return {
463
497
  functionName: getEnclosingFunctionName(callExpr),
464
498
  operationType: className,
465
- databaseType: 'secretsmanager',
499
+ serviceType: 'secretsmanager',
466
500
  target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...SECRETS_ARG_KEYS) : 'unknown',
467
501
  filePath,
468
502
  };
@@ -471,7 +505,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
471
505
  return {
472
506
  functionName: getEnclosingFunctionName(callExpr),
473
507
  operationType: className,
474
- databaseType: 'lambda',
508
+ serviceType: 'lambda',
475
509
  target: cmdArgs.length > 0 ? extractArgValue(cmdArgs[0], ...LAMBDA_ARG_KEYS) : 'unknown',
476
510
  filePath,
477
511
  };
@@ -486,7 +520,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
486
520
  return {
487
521
  functionName: getEnclosingFunctionName(callExpr),
488
522
  operationType: methodName,
489
- databaseType: 'sqs',
523
+ serviceType: 'sqs',
490
524
  target: args.length > 0 ? extractArgValue(args[0], ...SQS_ARG_KEYS) : 'unknown',
491
525
  filePath,
492
526
  };
@@ -495,7 +529,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
495
529
  return {
496
530
  functionName: getEnclosingFunctionName(callExpr),
497
531
  operationType: methodName,
498
- databaseType: 'sns',
532
+ serviceType: 'sns',
499
533
  target: args.length > 0 ? extractArgValue(args[0], ...SNS_ARG_KEYS) : 'unknown',
500
534
  filePath,
501
535
  };
@@ -504,7 +538,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
504
538
  return {
505
539
  functionName: getEnclosingFunctionName(callExpr),
506
540
  operationType: methodName,
507
- databaseType: 'ssm',
541
+ serviceType: 'ssm',
508
542
  target: args.length > 0 ? extractArgValue(args[0], ...SSM_ARG_KEYS) : 'unknown',
509
543
  filePath,
510
544
  };
@@ -513,7 +547,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
513
547
  return {
514
548
  functionName: getEnclosingFunctionName(callExpr),
515
549
  operationType: methodName,
516
- databaseType: 'secretsmanager',
550
+ serviceType: 'secretsmanager',
517
551
  target: args.length > 0 ? extractArgValue(args[0], ...SECRETS_ARG_KEYS) : 'unknown',
518
552
  filePath,
519
553
  };
@@ -522,7 +556,7 @@ function detectAWSServiceOperations(callExpr, filePath) {
522
556
  return {
523
557
  functionName: getEnclosingFunctionName(callExpr),
524
558
  operationType: methodName,
525
- databaseType: 'lambda',
559
+ serviceType: 'lambda',
526
560
  target: args.length > 0 ? extractArgValue(args[0], ...LAMBDA_ARG_KEYS) : 'unknown',
527
561
  filePath,
528
562
  };
@@ -586,6 +620,11 @@ async function scanRepository(repoPath) {
586
620
  operations.push(mongoOp);
587
621
  continue;
588
622
  }
623
+ const kafkaOp = detectKafkaOperations(callExpr, filePath);
624
+ if (kafkaOp) {
625
+ operations.push(kafkaOp);
626
+ continue;
627
+ }
589
628
  const awsOp = detectAWSServiceOperations(callExpr, filePath);
590
629
  if (awsOp) {
591
630
  operations.push(awsOp);
@@ -109,6 +109,11 @@ function loadConfig(configPath) {
109
109
  catch (err) {
110
110
  throw new ConfigError(`Unable to read configuration file: ${resolvedPath}`, [String(err)]);
111
111
  }
112
+ // Expand ${ENV_VAR} references — lets users store secrets in env, not in the YAML file
113
+ rawContent = rawContent.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
114
+ const val = process.env[name];
115
+ return val !== undefined ? val : match;
116
+ });
112
117
  let parsedYaml;
113
118
  try {
114
119
  parsedYaml = yaml.load(rawContent);
@@ -140,7 +140,7 @@ function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [], mongoM
140
140
  instanceClass: db.instanceClass,
141
141
  publiclyAccessible: db.publiclyAccessible,
142
142
  storageEncrypted: db.storageEncrypted,
143
- backupRetentionDays: db.backupRetentionPeriod,
143
+ backupRetentionDays: db.backupRetentionDays,
144
144
  deletionProtection: db.deletionProtection,
145
145
  multiAZ: db.multiAZ,
146
146
  });
@@ -153,31 +153,38 @@ function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [], mongoM
153
153
  nodeIds.add(funcNodeId);
154
154
  }
155
155
  // AWS service operations create edges to service nodes
156
- if (op.databaseType === 'sqs') {
156
+ if (op.serviceType === 'sqs') {
157
157
  const queueId = `queue:aws:${op.target}`;
158
158
  addNode({ id: queueId, type: 'queue', name: op.target, provider: 'aws', hasDLQ: false, encrypted: false });
159
159
  edges.push({ from: funcNodeId, to: queueId, type: 'publishes_to' });
160
160
  continue;
161
161
  }
162
- if (op.databaseType === 'sns') {
162
+ if (op.serviceType === 'sns') {
163
163
  const topicId = `topic:aws:${op.target}`;
164
164
  addNode({ id: topicId, type: 'topic', name: op.target, provider: 'aws', encrypted: false });
165
165
  edges.push({ from: funcNodeId, to: topicId, type: 'publishes_to' });
166
166
  continue;
167
167
  }
168
- if (op.databaseType === 'ssm') {
168
+ if (op.serviceType === 'kafka') {
169
+ const topicId = `topic:kafka:${op.target}`;
170
+ addNode({ id: topicId, type: 'topic', name: op.target, provider: 'kafka', encrypted: false });
171
+ const edgeType = op.operationType === 'subscribe' ? 'subscribes_to' : 'publishes_to';
172
+ edges.push({ from: funcNodeId, to: topicId, type: edgeType });
173
+ continue;
174
+ }
175
+ if (op.serviceType === 'ssm') {
169
176
  const paramId = `parameter:aws:${op.target}`;
170
177
  addNode({ id: paramId, type: 'parameter', name: op.target, provider: 'aws', paramType: 'String', tier: 'Standard' });
171
178
  edges.push({ from: funcNodeId, to: paramId, type: 'reads_parameter' });
172
179
  continue;
173
180
  }
174
- if (op.databaseType === 'secretsmanager') {
181
+ if (op.serviceType === 'secretsmanager') {
175
182
  const secretId = `secret:aws:${op.target}`;
176
183
  addNode({ id: secretId, type: 'secret', name: op.target, provider: 'aws', rotationEnabled: false });
177
184
  edges.push({ from: funcNodeId, to: secretId, type: 'reads_secret' });
178
185
  continue;
179
186
  }
180
- if (op.databaseType === 'lambda') {
187
+ if (op.serviceType === 'lambda') {
181
188
  const lambdaId = `lambda:aws:${op.target}`;
182
189
  addNode({ id: lambdaId, type: 'lambda', name: op.target });
183
190
  edges.push({ from: funcNodeId, to: lambdaId, type: 'triggers' });
@@ -185,16 +192,16 @@ function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [], mongoM
185
192
  }
186
193
  // Database operations
187
194
  let tableNodeId;
188
- if (op.databaseType === 'dynamodb') {
195
+ if (op.serviceType === 'dynamodb') {
189
196
  tableNodeId = `table:dynamo:${op.target}`;
190
197
  addNode({ id: tableNodeId, type: 'table', name: op.target, databaseType: 'dynamodb' });
191
198
  }
192
- else if (op.databaseType === 'mysql') {
199
+ else if (op.serviceType === 'mysql') {
193
200
  const q = op.target.includes('.') ? op.target : `default.${op.target}`;
194
201
  tableNodeId = `table:mysql:${q}`;
195
202
  addNode({ id: tableNodeId, type: 'table', name: q, databaseType: 'mysql' });
196
203
  }
197
- else if (op.databaseType === 'mongodb') {
204
+ else if (op.serviceType === 'mongodb') {
198
205
  const q = op.target.includes('.') ? op.target : `default.${op.target}`;
199
206
  tableNodeId = `table:mongodb:${q}`;
200
207
  addNode({ id: tableNodeId, type: 'table', name: q, databaseType: 'mongodb' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "CLI-first infrastructure intelligence platform — analyzes DynamoDB, PostgreSQL, MySQL, MongoDB, SQS, SNS, SSM, Secrets Manager, Lambda, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
5
5
  "keywords": [
6
6
  "infrastructure",