infrawise 0.13.2 → 0.15.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 +33 -4
- package/dist/adapters/aws/index.js +1 -0
- package/dist/adapters/aws/metrics.js +94 -0
- package/dist/adapters/aws/services.js +197 -1
- package/dist/adapters/db/mysql.js +37 -1
- package/dist/adapters/db/postgres.js +49 -2
- package/dist/adapters/iac/terraform.js +66 -1
- package/dist/analyzers/aws-services.js +87 -0
- package/dist/analyzers/index.js +1 -1
- package/dist/cli/commands/analyze.js +46 -4
- package/dist/cli/commands/discover.js +5 -0
- package/dist/cli/commands/serve.js +5 -0
- package/dist/core/config.js +18 -0
- package/dist/graph/index.js +96 -10
- package/dist/server/index.js +149 -3
- package/package.json +6 -1
|
@@ -14,6 +14,7 @@ function emptySchema() {
|
|
|
14
14
|
parameters: [],
|
|
15
15
|
secrets: [],
|
|
16
16
|
apiGateways: [],
|
|
17
|
+
outputs: [],
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
// ─── File discovery ───────────────────────────────────────────────────────────
|
|
@@ -76,6 +77,25 @@ function tfGSINames(body) {
|
|
|
76
77
|
}
|
|
77
78
|
return names;
|
|
78
79
|
}
|
|
80
|
+
function extractTerraformOutputBlocks(content) {
|
|
81
|
+
const results = [];
|
|
82
|
+
const pat = /output\s+"([^"]+)"\s*\{/g;
|
|
83
|
+
let match;
|
|
84
|
+
while ((match = pat.exec(content)) !== null) {
|
|
85
|
+
const startBrace = match.index + match[0].length - 1;
|
|
86
|
+
let depth = 1;
|
|
87
|
+
let i = startBrace + 1;
|
|
88
|
+
while (i < content.length && depth > 0) {
|
|
89
|
+
if (content[i] === '{')
|
|
90
|
+
depth++;
|
|
91
|
+
else if (content[i] === '}')
|
|
92
|
+
depth--;
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
results.push({ name: match[1] ?? '', body: content.slice(startBrace + 1, i - 1) });
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
79
99
|
export async function extractTerraformSchema(repoPath) {
|
|
80
100
|
const schema = emptySchema();
|
|
81
101
|
const tfFiles = findFilesRecursively(repoPath, ['.tf']);
|
|
@@ -173,6 +193,16 @@ export async function extractTerraformSchema(repoPath) {
|
|
|
173
193
|
break;
|
|
174
194
|
}
|
|
175
195
|
}
|
|
196
|
+
for (const { name, body } of extractTerraformOutputBlocks(content)) {
|
|
197
|
+
const valueMatch = body.match(/value\s*=\s*(.+)/);
|
|
198
|
+
schema.outputs.push({
|
|
199
|
+
name,
|
|
200
|
+
description: tfStr(body, 'description'),
|
|
201
|
+
value: valueMatch?.[1]?.trim(),
|
|
202
|
+
source: 'terraform',
|
|
203
|
+
filePath,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
176
206
|
}
|
|
177
207
|
return schema;
|
|
178
208
|
}
|
|
@@ -341,6 +371,30 @@ function processCFNResources(resources, schema, filePath, source) {
|
|
|
341
371
|
}
|
|
342
372
|
}
|
|
343
373
|
}
|
|
374
|
+
function processCFNOutputs(template, schema, filePath, source) {
|
|
375
|
+
const outputs = template['Outputs'];
|
|
376
|
+
if (typeof outputs !== 'object' || outputs === null)
|
|
377
|
+
return;
|
|
378
|
+
for (const [name, raw] of Object.entries(outputs)) {
|
|
379
|
+
if (typeof raw !== 'object' || raw === null)
|
|
380
|
+
continue;
|
|
381
|
+
const out = raw;
|
|
382
|
+
const exp = out['Export'];
|
|
383
|
+
const expName = exp?.['Name'];
|
|
384
|
+
schema.outputs.push({
|
|
385
|
+
name,
|
|
386
|
+
description: typeof out['Description'] === 'string' ? out['Description'] : undefined,
|
|
387
|
+
exportName: expName === undefined
|
|
388
|
+
? undefined
|
|
389
|
+
: typeof expName === 'string'
|
|
390
|
+
? expName
|
|
391
|
+
: JSON.stringify(expName),
|
|
392
|
+
value: typeof out['Value'] === 'string' ? out['Value'] : JSON.stringify(out['Value']),
|
|
393
|
+
source,
|
|
394
|
+
filePath,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
344
398
|
export async function extractCloudFormationSchema(repoPath) {
|
|
345
399
|
const schema = emptySchema();
|
|
346
400
|
const cfnFiles = findFilesRecursively(repoPath, ['.yaml', '.yml', '.json']);
|
|
@@ -353,6 +407,7 @@ export async function extractCloudFormationSchema(repoPath) {
|
|
|
353
407
|
if (!resources)
|
|
354
408
|
continue;
|
|
355
409
|
processCFNResources(resources, schema, filePath, 'cloudformation');
|
|
410
|
+
processCFNOutputs(parsed, schema, filePath, 'cloudformation');
|
|
356
411
|
}
|
|
357
412
|
return schema;
|
|
358
413
|
}
|
|
@@ -375,6 +430,7 @@ export async function extractCDKSchema(repoPath) {
|
|
|
375
430
|
if (!resources)
|
|
376
431
|
continue;
|
|
377
432
|
processCFNResources(resources, schema, filePath, 'cdk');
|
|
433
|
+
processCFNOutputs(parsed, schema, filePath, 'cdk');
|
|
378
434
|
}
|
|
379
435
|
}
|
|
380
436
|
// Strategy 2: Detect CDK TypeScript construct patterns if no cdk.out exists
|
|
@@ -400,6 +456,7 @@ function mergeSchemas(...schemas) {
|
|
|
400
456
|
param: new Set(),
|
|
401
457
|
secret: new Set(),
|
|
402
458
|
api: new Set(),
|
|
459
|
+
output: new Set(),
|
|
403
460
|
};
|
|
404
461
|
for (const schema of schemas) {
|
|
405
462
|
for (const t of schema.dynamoTables) {
|
|
@@ -472,6 +529,13 @@ function mergeSchemas(...schemas) {
|
|
|
472
529
|
merged.apiGateways.push(a);
|
|
473
530
|
}
|
|
474
531
|
}
|
|
532
|
+
for (const o of schema.outputs) {
|
|
533
|
+
const k = `${o.source}::${o.filePath}::${o.name}`;
|
|
534
|
+
if (!seen.output.has(k)) {
|
|
535
|
+
seen.output.add(k);
|
|
536
|
+
merged.outputs.push(o);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
475
539
|
}
|
|
476
540
|
return merged;
|
|
477
541
|
}
|
|
@@ -491,7 +555,8 @@ export async function extractIaCSchema(repoPath) {
|
|
|
491
555
|
merged.buckets.length +
|
|
492
556
|
merged.parameters.length +
|
|
493
557
|
merged.secrets.length +
|
|
494
|
-
merged.apiGateways.length
|
|
558
|
+
merged.apiGateways.length +
|
|
559
|
+
merged.outputs.length;
|
|
495
560
|
logger.debug(`IaC schema total: ${total} resource(s) across TF/CFN/CDK`);
|
|
496
561
|
return merged;
|
|
497
562
|
}
|
|
@@ -284,6 +284,93 @@ export class S3UnencryptedAnalyzer {
|
|
|
284
284
|
return findings;
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
|
+
// ─── ElastiCache ─────────────────────────────────────────────────────────────
|
|
288
|
+
export class CacheTransitEncryptionAnalyzer {
|
|
289
|
+
name = 'CacheTransitEncryptionAnalyzer';
|
|
290
|
+
async analyze(graph) {
|
|
291
|
+
const findings = [];
|
|
292
|
+
for (const node of graph.nodes) {
|
|
293
|
+
if (node.type !== 'cache_cluster')
|
|
294
|
+
continue;
|
|
295
|
+
if (node.transitEncryption === false) {
|
|
296
|
+
findings.push({
|
|
297
|
+
severity: 'medium',
|
|
298
|
+
issue: `Cache cluster "${node.name}" has no in-transit encryption`,
|
|
299
|
+
description: `ElastiCache cluster "${node.name}" (${node.engine}) does not have TLS in-transit encryption enabled. Credentials and cached data cross the network in plaintext.`,
|
|
300
|
+
recommendation: `Enable transit encryption on "${node.name}". Note this requires clients to connect with TLS (rediss:// for Redis).`,
|
|
301
|
+
metadata: { cacheClusterId: node.name, provider: node.provider },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return findings;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export class CacheSingleNodeAnalyzer {
|
|
309
|
+
name = 'CacheSingleNodeAnalyzer';
|
|
310
|
+
async analyze(graph) {
|
|
311
|
+
const findings = [];
|
|
312
|
+
for (const node of graph.nodes) {
|
|
313
|
+
if (node.type !== 'cache_cluster')
|
|
314
|
+
continue;
|
|
315
|
+
if (node.numNodes === 1 && !node.replicationGroupId) {
|
|
316
|
+
findings.push({
|
|
317
|
+
severity: 'low',
|
|
318
|
+
issue: `Cache cluster "${node.name}" is a single node with no replication`,
|
|
319
|
+
description: `"${node.name}" runs one cache node outside a replication group. A node failure loses all cached data and takes the cache offline until replaced.`,
|
|
320
|
+
recommendation: `Move "${node.name}" into a replication group with at least one replica and automatic failover if cache availability matters for this workload.`,
|
|
321
|
+
metadata: { cacheClusterId: node.name },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return findings;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ─── Runtime signals ─────────────────────────────────────────────────────────
|
|
329
|
+
export class LambdaThrottlingAnalyzer {
|
|
330
|
+
name = 'LambdaThrottlingAnalyzer';
|
|
331
|
+
async analyze(graph) {
|
|
332
|
+
const findings = [];
|
|
333
|
+
for (const node of graph.nodes) {
|
|
334
|
+
if (node.type !== 'lambda')
|
|
335
|
+
continue;
|
|
336
|
+
if ((node.recentThrottles ?? 0) > 0) {
|
|
337
|
+
findings.push({
|
|
338
|
+
severity: 'high',
|
|
339
|
+
issue: `Lambda "${node.name}" was throttled ${node.recentThrottles} time(s) recently`,
|
|
340
|
+
description: `"${node.name}" hit concurrency throttling in the analysis window. Throttled invocations are rejected or delayed; for sync callers this surfaces as errors, for event sources as retries and growing backlogs.`,
|
|
341
|
+
recommendation: `Check reserved/account concurrency for "${node.name}". Raise reserved concurrency, request an account limit increase, or smooth the invoke rate (SQS between producer and Lambda).`,
|
|
342
|
+
metadata: { functionName: node.name, recentThrottles: node.recentThrottles },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return findings;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
export class StaleQueueMessagesAnalyzer {
|
|
350
|
+
name = 'StaleQueueMessagesAnalyzer';
|
|
351
|
+
thresholdSec;
|
|
352
|
+
constructor(thresholdSec = 3600) {
|
|
353
|
+
this.thresholdSec = thresholdSec;
|
|
354
|
+
}
|
|
355
|
+
async analyze(graph) {
|
|
356
|
+
const findings = [];
|
|
357
|
+
for (const node of graph.nodes) {
|
|
358
|
+
if (node.type !== 'queue')
|
|
359
|
+
continue;
|
|
360
|
+
const age = node.oldestMessageAgeSec ?? 0;
|
|
361
|
+
if (age > this.thresholdSec) {
|
|
362
|
+
findings.push({
|
|
363
|
+
severity: 'medium',
|
|
364
|
+
issue: `Queue "${node.name}" has messages older than ${Math.round(age / 3600)} hour(s)`,
|
|
365
|
+
description: `The oldest message in "${node.name}" is ${Math.round(age / 60)} minutes old. Consumers are not keeping up, are failing, or are not running.`,
|
|
366
|
+
recommendation: `Check consumer health for "${node.name}". If a Lambda consumes it, look at its error and throttle counts; if messages are near the retention limit they will be silently dropped.`,
|
|
367
|
+
metadata: { queueName: node.name, oldestMessageAgeSec: age },
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return findings;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
287
374
|
// ─── IAM ─────────────────────────────────────────────────────────────────────
|
|
288
375
|
const MINIMAL_ACTIONS = {
|
|
289
376
|
dynamodb: 'dynamodb:GetItem, dynamodb:PutItem, dynamodb:Query, dynamodb:UpdateItem, dynamodb:DeleteItem',
|
package/dist/analyzers/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer } from './mysql.j
|
|
|
5
5
|
export { MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer } from './mongodb.js';
|
|
6
6
|
export { IaCDriftAnalyzer } from './terraform.js';
|
|
7
7
|
export { PipelineAnalyzer } from './pipeline.js';
|
|
8
|
-
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, LambdaMissingIAMPermissionsAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
|
|
8
|
+
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, LambdaMissingIAMPermissionsAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, CacheTransitEncryptionAnalyzer, CacheSingleNodeAnalyzer, LambdaThrottlingAnalyzer, StaleQueueMessagesAnalyzer, } 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 = [];
|
|
@@ -8,12 +8,13 @@ import { extractPostgresMetadata } from '../../adapters/db/postgres.js';
|
|
|
8
8
|
import { extractMySQLMetadata } from '../../adapters/db/mysql.js';
|
|
9
9
|
import { extractMongoMetadata } from '../../adapters/db/mongodb.js';
|
|
10
10
|
import { extractIaCSchema } from '../../adapters/iac/terraform.js';
|
|
11
|
-
import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, extractAPIGatewayMetadata, } from '../../adapters/aws/services.js';
|
|
11
|
+
import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, extractAPIGatewayMetadata, extractCognitoMetadata, extractKinesisMetadata, extractMSKMetadata, extractElastiCacheMetadata, } from '../../adapters/aws/services.js';
|
|
12
12
|
import { extractLogsMetadata } from '../../adapters/aws/logs.js';
|
|
13
|
+
import { extractRuntimeSignals } from '../../adapters/aws/metrics.js';
|
|
13
14
|
import { extractS3Metadata } from '../../adapters/aws/s3.js';
|
|
14
15
|
import { scanRepository } from '../../context/index.js';
|
|
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, LambdaMissingIAMPermissionsAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from '../../analyzers/index.js';
|
|
16
|
+
import { buildGraph, addStackOutputNodes } from '../../graph/index.js';
|
|
17
|
+
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, CacheTransitEncryptionAnalyzer, CacheSingleNodeAnalyzer, LambdaThrottlingAnalyzer, StaleQueueMessagesAnalyzer, } from '../../analyzers/index.js';
|
|
17
18
|
import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
|
|
18
19
|
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
|
|
19
20
|
function buildMarkdownReport(findings, projectName) {
|
|
@@ -119,6 +120,12 @@ function buildAnalyzers(config, iacDriftAnalyzer, iacLambdas) {
|
|
|
119
120
|
new S3UnencryptedAnalyzer(),
|
|
120
121
|
]
|
|
121
122
|
: []),
|
|
123
|
+
...(config.elasticache?.enabled === true
|
|
124
|
+
? [new CacheTransitEncryptionAnalyzer(), new CacheSingleNodeAnalyzer()]
|
|
125
|
+
: []),
|
|
126
|
+
...(config.runtimeSignals?.enabled === true
|
|
127
|
+
? [new LambdaThrottlingAnalyzer(), new StaleQueueMessagesAnalyzer()]
|
|
128
|
+
: []),
|
|
122
129
|
...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
|
|
123
130
|
pipelineAnalyzer,
|
|
124
131
|
];
|
|
@@ -158,15 +165,44 @@ export async function runAnalyze(options = {}) {
|
|
|
158
165
|
servicesMeta.rds = await extract(config.rds?.enabled === true, 'Extracting RDS instances...', 'RDS', () => extractRDSMetadata(awsCfg), (r) => `${r.length} instance(s)`);
|
|
159
166
|
servicesMeta.s3 = await extract(config.s3?.enabled === true, 'Extracting S3 buckets...', 'S3', () => extractS3Metadata(awsCfg), (r) => `${r.length} bucket(s)`);
|
|
160
167
|
servicesMeta.apiGateway = await extract(config.apiGateway?.enabled === true, 'Extracting API Gateway routes...', 'API Gateway', () => extractAPIGatewayMetadata(awsCfg), (r) => `${r.length} API(s), ${r.reduce((sum, api) => sum + api.routes.length, 0)} route(s)`);
|
|
168
|
+
servicesMeta.cognito = await extract(config.cognito?.enabled === true, 'Extracting Cognito user pools...', 'Cognito', () => extractCognitoMetadata(awsCfg), (r) => `${r.length} user pool(s)`);
|
|
169
|
+
servicesMeta.kinesis = await extract(config.kinesis?.enabled === true, 'Extracting Kinesis streams...', 'Kinesis', () => extractKinesisMetadata(awsCfg), (r) => `${r.length} stream(s)`);
|
|
170
|
+
servicesMeta.msk = await extract(config.msk?.enabled === true, 'Extracting MSK clusters...', 'MSK', () => extractMSKMetadata(awsCfg), (r) => `${r.length} cluster(s)`);
|
|
171
|
+
servicesMeta.elasticache = await extract(config.elasticache?.enabled === true, 'Extracting ElastiCache clusters...', 'ElastiCache', () => extractElastiCacheMetadata(awsCfg), (r) => `${r.length} cluster(s)`);
|
|
161
172
|
const cwLogs = config.cloudwatchLogs;
|
|
162
173
|
servicesMeta.logs = await extract(cwLogs?.enabled, 'Sampling CloudWatch Logs (errors only, max 50 groups)...', 'CloudWatch Logs', () => extractLogsMetadata({
|
|
163
174
|
...awsCfg,
|
|
164
175
|
logGroupPrefixes: cwLogs?.logGroupPrefixes,
|
|
165
176
|
windowHours: cwLogs?.windowHours,
|
|
166
177
|
}), (r) => `${r.length} group(s), ${r.filter((lg) => lg.errorCount > 0).length} with errors`);
|
|
178
|
+
if (config.runtimeSignals?.enabled && (servicesMeta.lambda?.length || servicesMeta.sqs?.length)) {
|
|
179
|
+
const s = mkSpinner('Fetching runtime signals (CloudWatch metrics)...');
|
|
180
|
+
try {
|
|
181
|
+
const signals = await extractRuntimeSignals(awsCfg, (servicesMeta.lambda ?? []).map((f) => f.name), (servicesMeta.sqs ?? []).map((q) => q.name), config.runtimeSignals.windowHours);
|
|
182
|
+
for (const fn of servicesMeta.lambda ?? []) {
|
|
183
|
+
const sig = signals.lambdas.get(fn.name);
|
|
184
|
+
if (sig) {
|
|
185
|
+
fn.recentThrottles = sig.throttles;
|
|
186
|
+
fn.recentErrors = sig.errors;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const q of servicesMeta.sqs ?? []) {
|
|
190
|
+
const sig = signals.queues.get(q.name);
|
|
191
|
+
if (sig)
|
|
192
|
+
q.oldestMessageAgeSec = sig.oldestMessageAgeSec;
|
|
193
|
+
}
|
|
194
|
+
s.succeed(chalk.green('Runtime signals') +
|
|
195
|
+
chalk.dim(` ${signals.lambdas.size} function(s), ${signals.queues.size} queue(s)`));
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
s.warn(chalk.yellow('Runtime signals skipped') +
|
|
199
|
+
chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
167
202
|
// ── IaC schema (Terraform / CloudFormation / CDK) ────────────────────────────
|
|
168
203
|
let iacDriftAnalyzer;
|
|
169
204
|
let iacLambdas = [];
|
|
205
|
+
let iacOutputs = [];
|
|
170
206
|
{
|
|
171
207
|
const s = mkSpinner('Extracting IaC schema (Terraform / CloudFormation / CDK)...');
|
|
172
208
|
try {
|
|
@@ -180,10 +216,12 @@ export async function runAnalyze(options = {}) {
|
|
|
180
216
|
iacSchema.buckets.length +
|
|
181
217
|
iacSchema.parameters.length +
|
|
182
218
|
iacSchema.secrets.length +
|
|
183
|
-
iacSchema.apiGateways.length
|
|
219
|
+
iacSchema.apiGateways.length +
|
|
220
|
+
iacSchema.outputs.length;
|
|
184
221
|
iacDriftAnalyzer = new IaCDriftAnalyzer();
|
|
185
222
|
iacDriftAnalyzer.setIaCSchema(iacSchema);
|
|
186
223
|
iacLambdas = iacSchema.lambdas;
|
|
224
|
+
iacOutputs = iacSchema.outputs;
|
|
187
225
|
s.succeed(chalk.green('IaC schema') + chalk.dim(` ${total} resource(s) across TF/CFN/CDK`));
|
|
188
226
|
}
|
|
189
227
|
catch (err) {
|
|
@@ -211,6 +249,7 @@ export async function runAnalyze(options = {}) {
|
|
|
211
249
|
{
|
|
212
250
|
const s = mkSpinner('Building infrastructure graph...');
|
|
213
251
|
graph = buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta, mongoMeta, servicesMeta);
|
|
252
|
+
addStackOutputNodes(graph, iacOutputs);
|
|
214
253
|
s.succeed(chalk.green('Graph built') +
|
|
215
254
|
chalk.dim(` ${graph.nodes.length} nodes, ${graph.edges.length} edges`));
|
|
216
255
|
}
|
|
@@ -278,11 +317,13 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
278
317
|
// Re-run IaC schema (pure file scan, no AWS calls)
|
|
279
318
|
let iacDriftAnalyzer;
|
|
280
319
|
let iacLambdas = [];
|
|
320
|
+
let iacOutputs = [];
|
|
281
321
|
try {
|
|
282
322
|
const iacSchema = await extractIaCSchema(repoPath);
|
|
283
323
|
iacDriftAnalyzer = new IaCDriftAnalyzer();
|
|
284
324
|
iacDriftAnalyzer.setIaCSchema(iacSchema);
|
|
285
325
|
iacLambdas = iacSchema.lambdas;
|
|
326
|
+
iacOutputs = iacSchema.outputs;
|
|
286
327
|
}
|
|
287
328
|
catch {
|
|
288
329
|
// IaC scan is best-effort
|
|
@@ -296,6 +337,7 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
296
337
|
operations = [];
|
|
297
338
|
}
|
|
298
339
|
const graph = buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta, mongoMeta, servicesMeta);
|
|
340
|
+
addStackOutputNodes(graph, iacOutputs);
|
|
299
341
|
const analyzers = buildAnalyzers(config, iacDriftAnalyzer, iacLambdas);
|
|
300
342
|
const findings = await runAllAnalyzers(graph, analyzers);
|
|
301
343
|
writeCache('graph', graph);
|
|
@@ -186,6 +186,11 @@ function writeYaml(cwd, opts) {
|
|
|
186
186
|
rds: { enabled: false },
|
|
187
187
|
s3: { enabled: true },
|
|
188
188
|
apiGateway: { enabled: true },
|
|
189
|
+
cognito: { enabled: false },
|
|
190
|
+
kinesis: { enabled: false },
|
|
191
|
+
msk: { enabled: false },
|
|
192
|
+
elasticache: { enabled: false },
|
|
193
|
+
runtimeSignals: { enabled: false, windowHours: 24 },
|
|
189
194
|
cloudwatchLogs: { enabled: false, logGroupPrefixes: [], windowHours: 24 },
|
|
190
195
|
analysis: { sampleSize: 100 },
|
|
191
196
|
};
|
|
@@ -11,6 +11,7 @@ const BOX_W = 52;
|
|
|
11
11
|
const TOOL_MAP = [
|
|
12
12
|
{ name: 'get_infra_overview' },
|
|
13
13
|
{ name: 'get_graph_summary' },
|
|
14
|
+
{ name: 'get_table_schema' },
|
|
14
15
|
{ name: 'analyze_function' },
|
|
15
16
|
{ name: 'suggest_gsi', service: 'dynamodb' },
|
|
16
17
|
{ name: 'postgres_index_suggestions', service: 'postgres' },
|
|
@@ -25,6 +26,10 @@ const TOOL_MAP = [
|
|
|
25
26
|
{ name: 'get_s3_overview', service: 's3' },
|
|
26
27
|
{ name: 'get_api_routes', service: 'apiGateway' },
|
|
27
28
|
{ name: 'get_log_errors', service: 'cloudwatchLogs' },
|
|
29
|
+
{ name: 'get_stack_outputs', service: 'terraform' },
|
|
30
|
+
{ name: 'get_cognito_overview', service: 'cognito' },
|
|
31
|
+
{ name: 'get_stream_details', service: 'kinesis' },
|
|
32
|
+
{ name: 'get_cache_overview', service: 'elasticache' },
|
|
28
33
|
];
|
|
29
34
|
// With no config every registered tool is shown as active (the server exposes
|
|
30
35
|
// all of them); a config narrows the list to its enabled services.
|
package/dist/core/config.js
CHANGED
|
@@ -57,6 +57,16 @@ export const InfrawiseConfigSchema = z.object({
|
|
|
57
57
|
rds: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
58
58
|
s3: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
59
59
|
apiGateway: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
60
|
+
cognito: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
61
|
+
kinesis: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
62
|
+
msk: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
63
|
+
elasticache: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
64
|
+
runtimeSignals: z
|
|
65
|
+
.object({
|
|
66
|
+
enabled: z.boolean().optional().default(false),
|
|
67
|
+
windowHours: z.number().int().positive().optional().default(24),
|
|
68
|
+
})
|
|
69
|
+
.optional(),
|
|
60
70
|
cloudwatchLogs: z
|
|
61
71
|
.object({
|
|
62
72
|
enabled: z.boolean().optional().default(false),
|
|
@@ -185,6 +195,14 @@ export function generateDefaultConfig(projectName, options) {
|
|
|
185
195
|
rds: { enabled: options?.rds?.enabled ?? false },
|
|
186
196
|
s3: { enabled: options?.s3?.enabled ?? false },
|
|
187
197
|
apiGateway: { enabled: options?.apiGateway?.enabled ?? false },
|
|
198
|
+
cognito: { enabled: options?.cognito?.enabled ?? false },
|
|
199
|
+
kinesis: { enabled: options?.kinesis?.enabled ?? false },
|
|
200
|
+
msk: { enabled: options?.msk?.enabled ?? false },
|
|
201
|
+
elasticache: { enabled: options?.elasticache?.enabled ?? false },
|
|
202
|
+
runtimeSignals: {
|
|
203
|
+
enabled: options?.runtimeSignals?.enabled ?? false,
|
|
204
|
+
windowHours: options?.runtimeSignals?.windowHours ?? 24,
|
|
205
|
+
},
|
|
188
206
|
cloudwatchLogs: {
|
|
189
207
|
enabled: options?.cloudwatchLogs?.enabled ?? false,
|
|
190
208
|
logGroupPrefixes: options?.cloudwatchLogs?.logGroupPrefixes ?? [],
|
package/dist/graph/index.js
CHANGED
|
@@ -13,7 +13,14 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
13
13
|
// ── Database tables ──────────────────────────────────────────────────────
|
|
14
14
|
for (const table of dynamoMeta) {
|
|
15
15
|
const nodeId = `table:dynamo:${table.tableName}`;
|
|
16
|
-
addNode({
|
|
16
|
+
addNode({
|
|
17
|
+
id: nodeId,
|
|
18
|
+
type: 'table',
|
|
19
|
+
name: table.tableName,
|
|
20
|
+
databaseType: 'dynamodb',
|
|
21
|
+
partitionKey: table.partitionKey,
|
|
22
|
+
sortKey: table.sortKey,
|
|
23
|
+
});
|
|
17
24
|
for (const indexName of table.indexes) {
|
|
18
25
|
const indexNodeId = `index:${table.tableName}:${indexName}`;
|
|
19
26
|
addNode({ id: indexNodeId, type: 'index', name: indexName });
|
|
@@ -27,6 +34,9 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
27
34
|
type: 'table',
|
|
28
35
|
name: `${table.schema}.${table.table}`,
|
|
29
36
|
databaseType: 'postgres',
|
|
37
|
+
columns: table.columnDetails,
|
|
38
|
+
primaryKeys: table.primaryKeys,
|
|
39
|
+
foreignKeys: table.foreignKeys,
|
|
30
40
|
});
|
|
31
41
|
for (const indexName of table.indexes) {
|
|
32
42
|
const indexNodeId = `index:${table.schema}.${table.table}:${indexName}`;
|
|
@@ -41,6 +51,9 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
41
51
|
type: 'table',
|
|
42
52
|
name: `${table.schema}.${table.table}`,
|
|
43
53
|
databaseType: 'mysql',
|
|
54
|
+
columns: table.columnDetails,
|
|
55
|
+
primaryKeys: table.primaryKeys,
|
|
56
|
+
foreignKeys: table.foreignKeys,
|
|
44
57
|
});
|
|
45
58
|
for (const indexName of table.indexes) {
|
|
46
59
|
const indexNodeId = `index:${table.schema}.${table.table}:${indexName}`;
|
|
@@ -55,6 +68,7 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
55
68
|
type: 'table',
|
|
56
69
|
name: `${coll.database}.${coll.collection}`,
|
|
57
70
|
databaseType: 'mongodb',
|
|
71
|
+
estimatedCount: coll.estimatedCount,
|
|
58
72
|
});
|
|
59
73
|
for (const idx of coll.indexes) {
|
|
60
74
|
if (idx.name === '_id_')
|
|
@@ -77,6 +91,32 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
77
91
|
visibilityTimeoutSec: q.visibilityTimeoutSec,
|
|
78
92
|
approximateMessages: q.approximateMessages,
|
|
79
93
|
retentionDays: q.retentionDays,
|
|
94
|
+
oldestMessageAgeSec: q.oldestMessageAgeSec,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const s of servicesMeta.kinesis ?? []) {
|
|
98
|
+
addNode({
|
|
99
|
+
id: `stream:aws:${s.name}`,
|
|
100
|
+
type: 'stream',
|
|
101
|
+
name: s.name,
|
|
102
|
+
provider: 'aws',
|
|
103
|
+
status: s.status,
|
|
104
|
+
shardCount: s.shardCount,
|
|
105
|
+
retentionHours: s.retentionHours,
|
|
106
|
+
encrypted: s.encrypted,
|
|
107
|
+
mode: s.mode,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const c of servicesMeta.msk ?? []) {
|
|
111
|
+
addNode({
|
|
112
|
+
id: `kafka_cluster:aws:${c.name}`,
|
|
113
|
+
type: 'kafka_cluster',
|
|
114
|
+
name: c.name,
|
|
115
|
+
provider: 'aws',
|
|
116
|
+
state: c.state,
|
|
117
|
+
clusterType: c.clusterType,
|
|
118
|
+
kafkaVersion: c.kafkaVersion,
|
|
119
|
+
brokerNodes: c.brokerNodes,
|
|
80
120
|
});
|
|
81
121
|
}
|
|
82
122
|
for (const api of servicesMeta.apiGateway ?? []) {
|
|
@@ -150,6 +190,8 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
150
190
|
triggers: fn.triggers,
|
|
151
191
|
roleArn: fn.roleArn,
|
|
152
192
|
allowedServices: fn.allowedServices,
|
|
193
|
+
recentThrottles: fn.recentThrottles,
|
|
194
|
+
recentErrors: fn.recentErrors,
|
|
153
195
|
});
|
|
154
196
|
// Add trigger edges from source → lambda (only for enabled/active mappings)
|
|
155
197
|
for (const trigger of fn.triggers ?? []) {
|
|
@@ -177,15 +219,8 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
177
219
|
});
|
|
178
220
|
}
|
|
179
221
|
else if (trigger.type === 'kinesis') {
|
|
180
|
-
sourceId = `
|
|
181
|
-
addNode({
|
|
182
|
-
id: sourceId,
|
|
183
|
-
type: 'queue',
|
|
184
|
-
name: trigger.sourceName,
|
|
185
|
-
provider: 'aws',
|
|
186
|
-
hasDLQ: false,
|
|
187
|
-
encrypted: false,
|
|
188
|
-
});
|
|
222
|
+
sourceId = `stream:aws:${trigger.sourceName}`;
|
|
223
|
+
addNode({ id: sourceId, type: 'stream', name: trigger.sourceName, provider: 'aws' });
|
|
189
224
|
}
|
|
190
225
|
else {
|
|
191
226
|
continue;
|
|
@@ -271,6 +306,33 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
271
306
|
}
|
|
272
307
|
}
|
|
273
308
|
}
|
|
309
|
+
for (const c of servicesMeta.elasticache ?? []) {
|
|
310
|
+
addNode({
|
|
311
|
+
id: `cache_cluster:aws:${c.id}`,
|
|
312
|
+
type: 'cache_cluster',
|
|
313
|
+
name: c.id,
|
|
314
|
+
provider: 'aws',
|
|
315
|
+
engine: c.engine,
|
|
316
|
+
engineVersion: c.engineVersion,
|
|
317
|
+
nodeType: c.nodeType,
|
|
318
|
+
numNodes: c.numNodes,
|
|
319
|
+
transitEncryption: c.transitEncryption,
|
|
320
|
+
atRestEncryption: c.atRestEncryption,
|
|
321
|
+
replicationGroupId: c.replicationGroupId,
|
|
322
|
+
automaticFailover: c.automaticFailover,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
for (const pool of servicesMeta.cognito ?? []) {
|
|
326
|
+
addNode({
|
|
327
|
+
id: `user_pool:aws:${pool.id}`,
|
|
328
|
+
type: 'user_pool',
|
|
329
|
+
name: pool.name,
|
|
330
|
+
provider: 'aws',
|
|
331
|
+
poolId: pool.id,
|
|
332
|
+
mfaConfiguration: pool.mfaConfiguration,
|
|
333
|
+
clients: pool.clients,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
274
336
|
// ── Code operations (functions + edges) ───────────────────────────────────
|
|
275
337
|
for (const op of operations) {
|
|
276
338
|
const funcNodeId = `function:${op.filePath}:${op.functionName}`;
|
|
@@ -360,6 +422,25 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
360
422
|
}
|
|
361
423
|
return { nodes, edges };
|
|
362
424
|
}
|
|
425
|
+
export function addStackOutputNodes(graph, outputs) {
|
|
426
|
+
const ids = new Set(graph.nodes.map((n) => n.id));
|
|
427
|
+
for (const o of outputs) {
|
|
428
|
+
const id = `stack_output:${o.source}:${o.filePath}:${o.name}`;
|
|
429
|
+
if (ids.has(id))
|
|
430
|
+
continue;
|
|
431
|
+
ids.add(id);
|
|
432
|
+
graph.nodes.push({
|
|
433
|
+
id,
|
|
434
|
+
type: 'stack_output',
|
|
435
|
+
name: o.name,
|
|
436
|
+
description: o.description,
|
|
437
|
+
exportName: o.exportName,
|
|
438
|
+
value: o.value,
|
|
439
|
+
iacSource: o.source,
|
|
440
|
+
file: o.filePath,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
363
444
|
function resolveEdgeType(operationType) {
|
|
364
445
|
const op = operationType.toLowerCase();
|
|
365
446
|
if (op === 'scan' || op === 'scancommand')
|
|
@@ -385,6 +466,11 @@ export const getLambdaNodes = (g) => getNodes(g, 'lambda');
|
|
|
385
466
|
export const getEventBridgeRuleNodes = (g) => getNodes(g, 'eventbridge_rule');
|
|
386
467
|
export const getBucketNodes = (g) => getNodes(g, 'bucket');
|
|
387
468
|
export const getAPINodes = (g) => getNodes(g, 'api');
|
|
469
|
+
export const getStackOutputNodes = (g) => getNodes(g, 'stack_output');
|
|
470
|
+
export const getUserPoolNodes = (g) => getNodes(g, 'user_pool');
|
|
471
|
+
export const getStreamNodes = (g) => getNodes(g, 'stream');
|
|
472
|
+
export const getKafkaClusterNodes = (g) => getNodes(g, 'kafka_cluster');
|
|
473
|
+
export const getCacheClusterNodes = (g) => getNodes(g, 'cache_cluster');
|
|
388
474
|
export function getEdgesForNode(graph, nodeId) {
|
|
389
475
|
return graph.edges.filter((e) => e.from === nodeId || e.to === nodeId);
|
|
390
476
|
}
|