infrawise 0.4.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
@@ -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,127 +129,127 @@ 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
254
  const result = await (0, logs_1.extractLogsMetadata)({
198
255
  ...awsCfg,
@@ -201,16 +258,16 @@ async function runAnalyze(options = {}) {
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
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infrawise",
3
- "version": "0.4.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",