infrawise 0.10.3 → 0.12.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 +62 -28
- package/dist/adapters/aws/dynamodb.js +0 -3
- package/dist/adapters/aws/logs.js +0 -2
- package/dist/adapters/aws/s3.js +0 -16
- package/dist/adapters/aws/services.js +106 -16
- package/dist/adapters/iac/terraform.js +2 -0
- package/dist/analyzers/aws-services.js +36 -0
- package/dist/analyzers/dynamodb.js +9 -5
- package/dist/analyzers/index.js +2 -1
- package/dist/analyzers/linkers.js +106 -0
- package/dist/analyzers/pipeline.js +166 -0
- package/dist/cli/commands/analyze.js +47 -6
- package/dist/cli/commands/check.js +28 -0
- package/dist/cli/commands/discover.js +201 -0
- package/dist/cli/commands/doctor.js +1 -2
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/start.js +24 -12
- package/dist/cli/index.js +32 -26
- package/dist/cli/{commands/init.js → interactive-setup.js} +6 -27
- package/dist/cli/probe.js +44 -0
- package/dist/cli/utils.js +23 -32
- package/dist/core/config.js +39 -5
- package/dist/core/errors.js +1 -1
- package/dist/graph/index.js +21 -0
- package/dist/server/index.js +22 -2
- package/package.json +10 -20
- package/dist/cli/commands/auth.js +0 -54
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { HeuristicLinker, IaCHandlerLinker, CompositeLinker, } from './linkers.js';
|
|
2
|
+
const TRANSPORT_EDGES = new Set(['publishes_to', 'triggers']);
|
|
3
|
+
export class PipelineAnalyzer {
|
|
4
|
+
name = 'PipelineAnalyzer';
|
|
5
|
+
iacLambdas = [];
|
|
6
|
+
setIaCLambdas(lambdas) {
|
|
7
|
+
this.iacLambdas = lambdas;
|
|
8
|
+
}
|
|
9
|
+
async analyze(graph) {
|
|
10
|
+
const links = new CompositeLinker(new IaCHandlerLinker(this.iacLambdas), new HeuristicLinker()).link(graph);
|
|
11
|
+
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
12
|
+
return [
|
|
13
|
+
...detectMissingDlqHop(graph),
|
|
14
|
+
...detectScanInPipeline(graph, links, nodeById),
|
|
15
|
+
...detectRepeatedTableAccess(graph, links, nodeById),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
class UnionFind {
|
|
20
|
+
parent = new Map();
|
|
21
|
+
find(x) {
|
|
22
|
+
const p = this.parent.get(x);
|
|
23
|
+
if (p === undefined) {
|
|
24
|
+
this.parent.set(x, x);
|
|
25
|
+
return x;
|
|
26
|
+
}
|
|
27
|
+
if (p === x)
|
|
28
|
+
return x;
|
|
29
|
+
const root = this.find(p);
|
|
30
|
+
this.parent.set(x, root);
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
union(a, b) {
|
|
34
|
+
const ra = this.find(a);
|
|
35
|
+
const rb = this.find(b);
|
|
36
|
+
if (ra !== rb)
|
|
37
|
+
this.parent.set(ra, rb);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function buildComponents(graph, links) {
|
|
41
|
+
const uf = new UnionFind();
|
|
42
|
+
for (const e of graph.edges) {
|
|
43
|
+
if (TRANSPORT_EDGES.has(e.type))
|
|
44
|
+
uf.union(e.from, e.to);
|
|
45
|
+
}
|
|
46
|
+
for (const l of links)
|
|
47
|
+
uf.union(l.lambdaId, l.functionId);
|
|
48
|
+
const inferredRoots = new Set();
|
|
49
|
+
for (const l of links) {
|
|
50
|
+
if (l.confidence === 'inferred')
|
|
51
|
+
inferredRoots.add(uf.find(l.lambdaId));
|
|
52
|
+
}
|
|
53
|
+
return { uf, inferredRoots };
|
|
54
|
+
}
|
|
55
|
+
function detectMissingDlqHop(graph) {
|
|
56
|
+
const hasProducer = new Set();
|
|
57
|
+
const hasConsumer = new Set();
|
|
58
|
+
for (const e of graph.edges) {
|
|
59
|
+
if (e.type === 'publishes_to')
|
|
60
|
+
hasProducer.add(e.to);
|
|
61
|
+
if (e.type === 'triggers')
|
|
62
|
+
hasConsumer.add(e.from);
|
|
63
|
+
}
|
|
64
|
+
const findings = [];
|
|
65
|
+
for (const node of graph.nodes) {
|
|
66
|
+
if (node.type !== 'queue' || node.hasDLQ)
|
|
67
|
+
continue;
|
|
68
|
+
if (!hasProducer.has(node.id) || !hasConsumer.has(node.id))
|
|
69
|
+
continue;
|
|
70
|
+
findings.push({
|
|
71
|
+
severity: 'medium',
|
|
72
|
+
issue: `Queue "${node.name}" sits mid-pipeline without a Dead Letter Queue`,
|
|
73
|
+
description: `Queue "${node.name}" has both a producer and a downstream consumer but no DLQ. A failure in the consumer drops the message silently, breaking the pipeline with no recovery path.`,
|
|
74
|
+
recommendation: `Add a Dead Letter Queue to "${node.name}" with maxReceiveCount 3-5, and alert on DLQ depth so mid-pipeline failures are visible.`,
|
|
75
|
+
metadata: { queueName: node.name, pipelineHop: true },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
function detectScanInPipeline(graph, links, nodeById) {
|
|
81
|
+
const triggeredLambdas = new Set();
|
|
82
|
+
for (const e of graph.edges) {
|
|
83
|
+
if (e.type === 'triggers')
|
|
84
|
+
triggeredLambdas.add(e.to);
|
|
85
|
+
}
|
|
86
|
+
const triggeredFunctionLinks = new Map();
|
|
87
|
+
for (const l of links) {
|
|
88
|
+
if (triggeredLambdas.has(l.lambdaId))
|
|
89
|
+
triggeredFunctionLinks.set(l.functionId, l);
|
|
90
|
+
}
|
|
91
|
+
const findings = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const e of graph.edges) {
|
|
94
|
+
if (e.type !== 'scan')
|
|
95
|
+
continue;
|
|
96
|
+
const link = triggeredFunctionLinks.get(e.from);
|
|
97
|
+
if (!link)
|
|
98
|
+
continue;
|
|
99
|
+
const fn = nodeById.get(e.from);
|
|
100
|
+
const table = nodeById.get(e.to);
|
|
101
|
+
const lambda = nodeById.get(link.lambdaId);
|
|
102
|
+
if (!fn || fn.type !== 'function' || !table || table.type !== 'table')
|
|
103
|
+
continue;
|
|
104
|
+
const dedupe = `${e.from}\0${e.to}`;
|
|
105
|
+
if (seen.has(dedupe))
|
|
106
|
+
continue;
|
|
107
|
+
seen.add(dedupe);
|
|
108
|
+
const inferred = link.confidence === 'inferred';
|
|
109
|
+
const lambdaName = lambda && lambda.type === 'lambda' ? lambda.name : link.lambdaId;
|
|
110
|
+
findings.push({
|
|
111
|
+
severity: inferred ? 'verify' : 'high',
|
|
112
|
+
issue: `Full scan runs inside event-triggered Lambda "${lambdaName}"`,
|
|
113
|
+
description: `Function "${fn.name}" (${fn.file}) performs a full scan on table "${table.name}" and runs as the handler for event-triggered Lambda "${lambdaName}". A scan inside an event-driven consumer executes on every invocation, multiplying read cost with traffic.` +
|
|
114
|
+
(inferred
|
|
115
|
+
? ` The Lambda-to-code link is inferred by name match (not proven from IaC), so verify it before acting.`
|
|
116
|
+
: ` The Lambda-to-code link is proven from IaC handler configuration.`),
|
|
117
|
+
recommendation: `Replace the scan with a Query using a partition key, or add a GSI for the access pattern. Scans in per-event handlers are the highest-leverage place to fix.`,
|
|
118
|
+
metadata: { function: fn.name, table: table.name, lambda: lambdaName, inferred },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
function detectRepeatedTableAccess(graph, links, nodeById) {
|
|
124
|
+
const { uf, inferredRoots } = buildComponents(graph, links);
|
|
125
|
+
const access = new Map();
|
|
126
|
+
for (const e of graph.edges) {
|
|
127
|
+
if (e.type !== 'query' && e.type !== 'scan')
|
|
128
|
+
continue;
|
|
129
|
+
const fromNode = nodeById.get(e.from);
|
|
130
|
+
const toNode = nodeById.get(e.to);
|
|
131
|
+
if (!fromNode || fromNode.type !== 'function')
|
|
132
|
+
continue;
|
|
133
|
+
if (!toNode || toNode.type !== 'table')
|
|
134
|
+
continue;
|
|
135
|
+
const key = `${uf.find(e.from)}\0${e.to}`;
|
|
136
|
+
const set = access.get(key) ?? new Set();
|
|
137
|
+
set.add(e.from);
|
|
138
|
+
access.set(key, set);
|
|
139
|
+
}
|
|
140
|
+
const findings = [];
|
|
141
|
+
for (const [key, fns] of access) {
|
|
142
|
+
if (fns.size < 2)
|
|
143
|
+
continue;
|
|
144
|
+
const [root, tableId] = key.split('\0');
|
|
145
|
+
const table = nodeById.get(tableId);
|
|
146
|
+
if (!table || table.type !== 'table')
|
|
147
|
+
continue;
|
|
148
|
+
const fnNames = [...fns]
|
|
149
|
+
.map((id) => nodeById.get(id))
|
|
150
|
+
.filter((n) => !!n && n.type === 'function')
|
|
151
|
+
.map((n) => `${n.name} (${n.file})`)
|
|
152
|
+
.sort();
|
|
153
|
+
const inferred = inferredRoots.has(root);
|
|
154
|
+
findings.push({
|
|
155
|
+
severity: inferred ? 'verify' : 'medium',
|
|
156
|
+
issue: `Table "${table.name}" is accessed at multiple stages of one pipeline`,
|
|
157
|
+
description: `Table "${table.name}" is read by ${fns.size} functions linked in the same service pipeline: ${fnNames.join(', ')}. Re-reading the same table across pipeline stages often signals redundant work that could be passed forward in the message payload.` +
|
|
158
|
+
(inferred
|
|
159
|
+
? ` One or more Lambda-to-code links on this pipeline are inferred by name match (not proven from IaC), so verify the chain before acting.`
|
|
160
|
+
: ''),
|
|
161
|
+
recommendation: `Confirm each access is necessary. Consider carrying the needed fields in the event payload, caching the first read, or consolidating the reads. If the duplication is intentional, no action needed.`,
|
|
162
|
+
metadata: { tableName: table.name, functions: fnNames, inferred },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return findings;
|
|
166
|
+
}
|
|
@@ -8,12 +8,12 @@ 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, } from '../../adapters/aws/services.js';
|
|
11
|
+
import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, extractAPIGatewayMetadata, } from '../../adapters/aws/services.js';
|
|
12
12
|
import { extractLogsMetadata } from '../../adapters/aws/logs.js';
|
|
13
13
|
import { extractS3Metadata } from '../../adapters/aws/s3.js';
|
|
14
14
|
import { scanRepository } from '../../context/index.js';
|
|
15
15
|
import { buildGraph } from '../../graph/index.js';
|
|
16
|
-
import { runAllAnalyzers, IaCDriftAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from '../../analyzers/index.js';
|
|
16
|
+
import { runAllAnalyzers, IaCDriftAnalyzer, PipelineAnalyzer, FullTableScanAnalyzer, MissingGSIAnalyzer, HotPartitionAnalyzer, MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer, MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer, MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer, MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, VisibilityTimeoutMismatchAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from '../../analyzers/index.js';
|
|
17
17
|
import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
|
|
18
18
|
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
|
|
19
19
|
function buildMarkdownReport(findings, projectName) {
|
|
@@ -63,7 +63,6 @@ export async function runAnalyze(options = {}) {
|
|
|
63
63
|
const awsCfg = {
|
|
64
64
|
region: config.aws?.region,
|
|
65
65
|
profile: config.aws?.profile,
|
|
66
|
-
endpoint: config.aws?.endpoint,
|
|
67
66
|
};
|
|
68
67
|
const dynamoMeta = [];
|
|
69
68
|
const postgresMeta = [];
|
|
@@ -230,6 +229,20 @@ export async function runAnalyze(options = {}) {
|
|
|
230
229
|
chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
|
|
231
230
|
}
|
|
232
231
|
}
|
|
232
|
+
// ── API Gateway ───────────────────────────────────────────────────────────────
|
|
233
|
+
if (config.apiGateway?.enabled === true) {
|
|
234
|
+
const s = mkSpinner('Extracting API Gateway routes...');
|
|
235
|
+
try {
|
|
236
|
+
const result = await extractAPIGatewayMetadata(awsCfg);
|
|
237
|
+
servicesMeta.apiGateway = result;
|
|
238
|
+
const routeCount = result.reduce((sum, api) => sum + api.routes.length, 0);
|
|
239
|
+
s.succeed(chalk.green('API Gateway') + chalk.dim(` ${result.length} API(s), ${routeCount} route(s)`));
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
s.warn(chalk.yellow('API Gateway skipped') +
|
|
243
|
+
chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
233
246
|
// ── CloudWatch Logs ──────────────────────────────────────────────────────────
|
|
234
247
|
if (config.cloudwatchLogs?.enabled) {
|
|
235
248
|
const s = mkSpinner('Sampling CloudWatch Logs (errors only, max 50 groups)...');
|
|
@@ -251,6 +264,7 @@ export async function runAnalyze(options = {}) {
|
|
|
251
264
|
}
|
|
252
265
|
// ── IaC schema (Terraform / CloudFormation / CDK) ────────────────────────────
|
|
253
266
|
let iacDriftAnalyzer;
|
|
267
|
+
let iacLambdas = [];
|
|
254
268
|
{
|
|
255
269
|
const s = mkSpinner('Extracting IaC schema (Terraform / CloudFormation / CDK)...');
|
|
256
270
|
try {
|
|
@@ -267,6 +281,7 @@ export async function runAnalyze(options = {}) {
|
|
|
267
281
|
iacSchema.apiGateways.length;
|
|
268
282
|
iacDriftAnalyzer = new IaCDriftAnalyzer();
|
|
269
283
|
iacDriftAnalyzer.setIaCSchema(iacSchema);
|
|
284
|
+
iacLambdas = iacSchema.lambdas;
|
|
270
285
|
s.succeed(chalk.green('IaC schema') + chalk.dim(` ${total} resource(s) across TF/CFN/CDK`));
|
|
271
286
|
}
|
|
272
287
|
catch (err) {
|
|
@@ -301,9 +316,17 @@ export async function runAnalyze(options = {}) {
|
|
|
301
316
|
let findings;
|
|
302
317
|
{
|
|
303
318
|
const s = mkSpinner('Running analyzers...');
|
|
319
|
+
const hotPartitionThreshold = config.analysis?.hotPartitionThreshold;
|
|
320
|
+
const hotPartitionThresholds = config.analysis?.hotPartitionThresholds;
|
|
321
|
+
const pipelineAnalyzer = new PipelineAnalyzer();
|
|
322
|
+
pipelineAnalyzer.setIaCLambdas(iacLambdas);
|
|
304
323
|
const analyzers = [
|
|
305
324
|
...(config.dynamodb?.enabled === true
|
|
306
|
-
? [
|
|
325
|
+
? [
|
|
326
|
+
new FullTableScanAnalyzer(),
|
|
327
|
+
new MissingGSIAnalyzer(),
|
|
328
|
+
new HotPartitionAnalyzer(hotPartitionThreshold, hotPartitionThresholds),
|
|
329
|
+
]
|
|
307
330
|
: []),
|
|
308
331
|
...(config.postgres?.enabled
|
|
309
332
|
? [new MissingIndexAnalyzer(), new NplusOneAnalyzer(), new LargeSelectAnalyzer()]
|
|
@@ -319,6 +342,7 @@ export async function runAnalyze(options = {}) {
|
|
|
319
342
|
new MissingDLQAnalyzer(),
|
|
320
343
|
new UnencryptedQueueAnalyzer(),
|
|
321
344
|
new LargeQueueBacklogAnalyzer(),
|
|
345
|
+
new VisibilityTimeoutMismatchAnalyzer(),
|
|
322
346
|
]
|
|
323
347
|
: []),
|
|
324
348
|
...(config.secretsManager?.enabled === true ? [new MissingSecretRotationAnalyzer()] : []),
|
|
@@ -347,6 +371,7 @@ export async function runAnalyze(options = {}) {
|
|
|
347
371
|
]
|
|
348
372
|
: []),
|
|
349
373
|
...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
|
|
374
|
+
pipelineAnalyzer,
|
|
350
375
|
];
|
|
351
376
|
findings = await runAllAnalyzers(graph, analyzers);
|
|
352
377
|
s.succeed(chalk.green('Analysis complete') + chalk.dim(` ${findings.length} finding(s)`));
|
|
@@ -406,10 +431,12 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
406
431
|
const servicesMeta = cached?.servicesMeta ?? {};
|
|
407
432
|
// Re-run IaC schema (pure file scan, no AWS calls)
|
|
408
433
|
let iacDriftAnalyzer;
|
|
434
|
+
let iacLambdas = [];
|
|
409
435
|
try {
|
|
410
436
|
const iacSchema = await extractIaCSchema(repoPath);
|
|
411
437
|
iacDriftAnalyzer = new IaCDriftAnalyzer();
|
|
412
438
|
iacDriftAnalyzer.setIaCSchema(iacSchema);
|
|
439
|
+
iacLambdas = iacSchema.lambdas;
|
|
413
440
|
}
|
|
414
441
|
catch {
|
|
415
442
|
// IaC scan is best-effort
|
|
@@ -423,9 +450,17 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
423
450
|
operations = [];
|
|
424
451
|
}
|
|
425
452
|
const graph = buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta, mongoMeta, servicesMeta);
|
|
453
|
+
const hotPartitionThreshold = config.analysis?.hotPartitionThreshold;
|
|
454
|
+
const hotPartitionThresholds = config.analysis?.hotPartitionThresholds;
|
|
455
|
+
const pipelineAnalyzer = new PipelineAnalyzer();
|
|
456
|
+
pipelineAnalyzer.setIaCLambdas(iacLambdas);
|
|
426
457
|
const analyzers = [
|
|
427
458
|
...(config.dynamodb?.enabled === true
|
|
428
|
-
? [
|
|
459
|
+
? [
|
|
460
|
+
new FullTableScanAnalyzer(),
|
|
461
|
+
new MissingGSIAnalyzer(),
|
|
462
|
+
new HotPartitionAnalyzer(hotPartitionThreshold, hotPartitionThresholds),
|
|
463
|
+
]
|
|
429
464
|
: []),
|
|
430
465
|
...(config.postgres?.enabled
|
|
431
466
|
? [new MissingIndexAnalyzer(), new NplusOneAnalyzer(), new LargeSelectAnalyzer()]
|
|
@@ -437,7 +472,12 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
437
472
|
? [new MissingMongoIndexAnalyzer(), new MongoCollectionScanAnalyzer()]
|
|
438
473
|
: []),
|
|
439
474
|
...(config.sqs?.enabled === true
|
|
440
|
-
? [
|
|
475
|
+
? [
|
|
476
|
+
new MissingDLQAnalyzer(),
|
|
477
|
+
new UnencryptedQueueAnalyzer(),
|
|
478
|
+
new LargeQueueBacklogAnalyzer(),
|
|
479
|
+
new VisibilityTimeoutMismatchAnalyzer(),
|
|
480
|
+
]
|
|
441
481
|
: []),
|
|
442
482
|
...(config.secretsManager?.enabled === true ? [new MissingSecretRotationAnalyzer()] : []),
|
|
443
483
|
...(config.cloudwatchLogs?.enabled ? [new MissingLogRetentionAnalyzer()] : []),
|
|
@@ -465,6 +505,7 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
465
505
|
]
|
|
466
506
|
: []),
|
|
467
507
|
...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
|
|
508
|
+
pipelineAnalyzer,
|
|
468
509
|
];
|
|
469
510
|
const findings = await runAllAnalyzers(graph, analyzers);
|
|
470
511
|
writeCache('graph', graph);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readCache, setCacheDir } from '../../core/index.js';
|
|
4
|
+
import { printFinding, log, printHeader } from '../utils.js';
|
|
5
|
+
import { runAnalyze } from './analyze.js';
|
|
6
|
+
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
|
|
7
|
+
export async function runCheck(options = {}) {
|
|
8
|
+
const failOn = options.failOn ?? 'high';
|
|
9
|
+
const threshold = SEVERITY_ORDER[failOn] ?? 3;
|
|
10
|
+
printHeader('Infrawise Check');
|
|
11
|
+
// Always extract fresh — CI must not gate on a stale graph.
|
|
12
|
+
await runAnalyze({ config: options.config, repo: options.repo, silent: true });
|
|
13
|
+
setCacheDir(path.dirname(path.resolve(options.config ?? 'infrawise.yaml')));
|
|
14
|
+
const findings = readCache('findings') ?? [];
|
|
15
|
+
const violations = findings.filter((f) => (SEVERITY_ORDER[f.severity] ?? 0) >= threshold);
|
|
16
|
+
console.log('');
|
|
17
|
+
if (violations.length === 0) {
|
|
18
|
+
log.success('Check passed', `no ${failOn}+ findings (${findings.length} total below threshold)`);
|
|
19
|
+
console.log('');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
console.log(chalk.bold(' Blocking findings') + chalk.dim(` ${violations.length} at or above ${failOn}`));
|
|
23
|
+
violations.forEach((f, i) => printFinding(f, i));
|
|
24
|
+
console.log('');
|
|
25
|
+
log.fail(`Check failed`, `${violations.length} ${failOn}+ finding(s) must be resolved before deploy`);
|
|
26
|
+
console.log('');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as yaml from 'js-yaml';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import inquirer from 'inquirer';
|
|
7
|
+
import { probePort, scanDotEnv } from '../probe.js';
|
|
8
|
+
import { readAWSProfiles, detectAWSRegion, log } from '../utils.js';
|
|
9
|
+
import { runInit } from '../interactive-setup.js';
|
|
10
|
+
const DB_PORTS = { postgres: 5432, mysql: 3306, mongodb: 27017 };
|
|
11
|
+
const ENV_KEYS = {
|
|
12
|
+
postgres: ['DATABASE_URL', 'POSTGRES_URL', 'POSTGRESQL_URL', 'POSTGRES_CONNECTION_STRING'],
|
|
13
|
+
mysql: ['MYSQL_URL', 'MYSQL_CONNECTION_STRING'],
|
|
14
|
+
mongodb: ['MONGO_URI', 'MONGODB_URI', 'MONGO_URL', 'MONGODB_URL'],
|
|
15
|
+
};
|
|
16
|
+
export async function runDiscover(options = {}) {
|
|
17
|
+
if (options.interactive) {
|
|
18
|
+
await runInit();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
log.dim('Probing environment...');
|
|
23
|
+
const [pgDetected, mysqlDetected, mongoDetected] = await Promise.all([
|
|
24
|
+
probePort('localhost', DB_PORTS.postgres, 300),
|
|
25
|
+
probePort('localhost', DB_PORTS.mysql, 300),
|
|
26
|
+
probePort('localhost', DB_PORTS.mongodb, 300),
|
|
27
|
+
]);
|
|
28
|
+
if (pgDetected)
|
|
29
|
+
log.success('Postgres detected', `port ${DB_PORTS.postgres}`);
|
|
30
|
+
if (mysqlDetected)
|
|
31
|
+
log.success('MySQL detected', `port ${DB_PORTS.mysql}`);
|
|
32
|
+
if (mongoDetected)
|
|
33
|
+
log.success('MongoDB detected', `port ${DB_PORTS.mongodb}`);
|
|
34
|
+
const envVars = scanDotEnv(cwd);
|
|
35
|
+
const secrets = extractDbSecrets(envVars);
|
|
36
|
+
const iacDetected = detectIaC(cwd);
|
|
37
|
+
const hasAWSConfig = process.env.AWS_ACCESS_KEY_ID !== undefined ||
|
|
38
|
+
fs.existsSync(path.join(os.homedir(), '.aws', 'credentials')) ||
|
|
39
|
+
fs.existsSync(path.join(os.homedir(), '.aws', 'config'));
|
|
40
|
+
if (!hasAWSConfig && !pgDetected && !mysqlDetected && !mongoDetected) {
|
|
41
|
+
console.error(chalk.red('\n Nothing detected.'));
|
|
42
|
+
console.error(chalk.dim(' Run `aws configure` to set up AWS credentials, or start a local database.\n'));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
let selectedProfile = '';
|
|
46
|
+
if (process.env.AWS_PROFILE) {
|
|
47
|
+
selectedProfile = process.env.AWS_PROFILE;
|
|
48
|
+
log.success('AWS profile', `${selectedProfile} (from AWS_PROFILE)`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const profiles = readAWSProfiles();
|
|
52
|
+
if (profiles.length === 1) {
|
|
53
|
+
selectedProfile = profiles[0];
|
|
54
|
+
log.success('AWS profile', profiles[0]);
|
|
55
|
+
}
|
|
56
|
+
else if (profiles.length > 1) {
|
|
57
|
+
console.log('');
|
|
58
|
+
const answer = await inquirer.prompt([
|
|
59
|
+
{
|
|
60
|
+
type: 'select',
|
|
61
|
+
name: 'profile',
|
|
62
|
+
message: 'Select AWS profile:',
|
|
63
|
+
choices: profiles,
|
|
64
|
+
default: profiles[0],
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
selectedProfile = answer.profile;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const region = detectAWSRegion(selectedProfile);
|
|
71
|
+
ensureInfrawiseDir(cwd);
|
|
72
|
+
writeYaml(cwd, {
|
|
73
|
+
profile: selectedProfile,
|
|
74
|
+
region,
|
|
75
|
+
pgDetected,
|
|
76
|
+
mysqlDetected,
|
|
77
|
+
mongoDetected,
|
|
78
|
+
iacDetected,
|
|
79
|
+
});
|
|
80
|
+
const anyDbDetected = pgDetected || mysqlDetected || mongoDetected;
|
|
81
|
+
if (anyDbDetected) {
|
|
82
|
+
writeSecrets(cwd, secrets);
|
|
83
|
+
}
|
|
84
|
+
console.log('');
|
|
85
|
+
log.success('Generated infrawise.yaml');
|
|
86
|
+
if (pgDetected && !secrets.postgres) {
|
|
87
|
+
log.warn('Postgres detected — add connection string to .infrawise/secrets.yaml');
|
|
88
|
+
}
|
|
89
|
+
if (mysqlDetected && !secrets.mysql) {
|
|
90
|
+
log.warn('MySQL detected — add connection string to .infrawise/secrets.yaml');
|
|
91
|
+
}
|
|
92
|
+
if (mongoDetected && !secrets.mongodb) {
|
|
93
|
+
log.warn('MongoDB detected — add connection string to .infrawise/secrets.yaml');
|
|
94
|
+
}
|
|
95
|
+
console.log('');
|
|
96
|
+
}
|
|
97
|
+
function extractDbSecrets(env) {
|
|
98
|
+
const secrets = {};
|
|
99
|
+
for (const key of ENV_KEYS.postgres) {
|
|
100
|
+
if (env[key]) {
|
|
101
|
+
secrets.postgres = env[key];
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const key of ENV_KEYS.mysql) {
|
|
106
|
+
if (env[key]) {
|
|
107
|
+
secrets.mysql = env[key];
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const key of ENV_KEYS.mongodb) {
|
|
112
|
+
if (env[key]) {
|
|
113
|
+
secrets.mongodb = env[key];
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return secrets;
|
|
118
|
+
}
|
|
119
|
+
function detectIaC(cwd) {
|
|
120
|
+
return scanDirForIaC(cwd, 3);
|
|
121
|
+
}
|
|
122
|
+
function scanDirForIaC(dir, depth) {
|
|
123
|
+
if (depth < 0)
|
|
124
|
+
return false;
|
|
125
|
+
try {
|
|
126
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
129
|
+
continue;
|
|
130
|
+
if (entry.isFile()) {
|
|
131
|
+
const n = entry.name;
|
|
132
|
+
if (n.endsWith('.tf') ||
|
|
133
|
+
n === 'cdk.json' ||
|
|
134
|
+
n === 'template.yaml' ||
|
|
135
|
+
n === 'template.yml') {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (entry.isDirectory() && depth > 0) {
|
|
140
|
+
if (scanDirForIaC(path.join(dir, entry.name), depth - 1))
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// ignore permission errors
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
function ensureInfrawiseDir(cwd) {
|
|
151
|
+
const infrawiseDir = path.join(cwd, '.infrawise');
|
|
152
|
+
if (!fs.existsSync(infrawiseDir)) {
|
|
153
|
+
fs.mkdirSync(infrawiseDir, { recursive: true });
|
|
154
|
+
}
|
|
155
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
156
|
+
const entry = '.infrawise/';
|
|
157
|
+
if (fs.existsSync(gitignorePath)) {
|
|
158
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
159
|
+
if (!content.includes(entry)) {
|
|
160
|
+
const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
161
|
+
fs.appendFileSync(gitignorePath, `${prefix}${entry}\n`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
fs.writeFileSync(gitignorePath, `${entry}\n`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function writeYaml(cwd, opts) {
|
|
169
|
+
const config = {
|
|
170
|
+
project: path.basename(cwd),
|
|
171
|
+
aws: {
|
|
172
|
+
profile: opts.profile,
|
|
173
|
+
region: opts.region,
|
|
174
|
+
},
|
|
175
|
+
dynamodb: { enabled: true, includeTables: [] },
|
|
176
|
+
postgres: { enabled: opts.pgDetected, connectionString: '' },
|
|
177
|
+
mysql: { enabled: opts.mysqlDetected, connectionString: '' },
|
|
178
|
+
mongodb: { enabled: opts.mongoDetected, connectionString: '', databases: [] },
|
|
179
|
+
terraform: { enabled: opts.iacDetected },
|
|
180
|
+
sqs: { enabled: true },
|
|
181
|
+
sns: { enabled: true },
|
|
182
|
+
ssm: { enabled: true, paths: [] },
|
|
183
|
+
secretsManager: { enabled: true },
|
|
184
|
+
lambda: { enabled: true },
|
|
185
|
+
eventbridge: { enabled: true },
|
|
186
|
+
rds: { enabled: false },
|
|
187
|
+
s3: { enabled: true },
|
|
188
|
+
apiGateway: { enabled: true },
|
|
189
|
+
cloudwatchLogs: { enabled: false, logGroupPrefixes: [], windowHours: 24 },
|
|
190
|
+
analysis: { sampleSize: 100 },
|
|
191
|
+
};
|
|
192
|
+
fs.writeFileSync(path.join(cwd, 'infrawise.yaml'), yaml.dump(config, { lineWidth: 120 }), 'utf-8');
|
|
193
|
+
}
|
|
194
|
+
function writeSecrets(cwd, secrets) {
|
|
195
|
+
const data = {
|
|
196
|
+
postgres: { connectionString: secrets.postgres ?? '' },
|
|
197
|
+
mysql: { connectionString: secrets.mysql ?? '' },
|
|
198
|
+
mongodb: { connectionString: secrets.mongodb ?? '' },
|
|
199
|
+
};
|
|
200
|
+
fs.writeFileSync(path.join(cwd, '.infrawise', 'secrets.yaml'), yaml.dump(data, { lineWidth: 120 }), 'utf-8');
|
|
201
|
+
}
|
|
@@ -44,7 +44,7 @@ export async function runDoctor(options = {}) {
|
|
|
44
44
|
name: 'Config file',
|
|
45
45
|
status: exists ? 'pass' : 'fail',
|
|
46
46
|
message: exists ? configPath : `Not found at ${configPath}`,
|
|
47
|
-
detail: exists ? undefined : 'Run: infrawise
|
|
47
|
+
detail: exists ? undefined : 'Run: infrawise start',
|
|
48
48
|
};
|
|
49
49
|
}));
|
|
50
50
|
// Config valid
|
|
@@ -80,7 +80,6 @@ export async function runDoctor(options = {}) {
|
|
|
80
80
|
const awsCfg = {
|
|
81
81
|
region: config?.aws?.region,
|
|
82
82
|
profile: config?.aws?.profile,
|
|
83
|
-
endpoint: config?.aws?.endpoint,
|
|
84
83
|
};
|
|
85
84
|
// DynamoDB
|
|
86
85
|
results.push(await runCheck('Testing DynamoDB access...', async () => {
|
|
@@ -4,7 +4,7 @@ import { spawn } from 'child_process';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { readCache, setCacheDir, formatError, loadConfig } from '../../core/index.js';
|
|
6
6
|
import { log, printHeader } from '../utils.js';
|
|
7
|
-
import {
|
|
7
|
+
import { runDiscover } from './discover.js';
|
|
8
8
|
import { runAnalyze } from './analyze.js';
|
|
9
9
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
10
|
function writeMcpJson(configAbsPath) {
|
|
@@ -12,7 +12,7 @@ function writeMcpJson(configAbsPath) {
|
|
|
12
12
|
mcpServers: {
|
|
13
13
|
infrawise: {
|
|
14
14
|
command: 'infrawise',
|
|
15
|
-
args: ['stdio', '--config', configAbsPath],
|
|
15
|
+
args: ['serve', '--stdio', '--config', configAbsPath],
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
};
|
|
@@ -27,7 +27,7 @@ function writeCursorMcp(configAbsPath) {
|
|
|
27
27
|
mcpServers: {
|
|
28
28
|
infrawise: {
|
|
29
29
|
command: 'infrawise',
|
|
30
|
-
args: ['stdio', '--config', configAbsPath],
|
|
30
|
+
args: ['serve', '--stdio', '--config', configAbsPath],
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
33
|
};
|
|
@@ -54,7 +54,7 @@ function launchEditor(editor) {
|
|
|
54
54
|
const cmd = editor;
|
|
55
55
|
const args = ['.'];
|
|
56
56
|
console.log('');
|
|
57
|
-
console.log(chalk.dim(
|
|
57
|
+
console.log(chalk.dim(` Opening ${cmd}...`));
|
|
58
58
|
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
59
59
|
child.on('error', (err) => {
|
|
60
60
|
if (err.code === 'ENOENT') {
|
|
@@ -71,23 +71,35 @@ export async function runStart(options = {}) {
|
|
|
71
71
|
const cwd = process.cwd();
|
|
72
72
|
const configPath = options.config ?? 'infrawise.yaml';
|
|
73
73
|
const configAbsPath = path.resolve(cwd, configPath);
|
|
74
|
-
//
|
|
74
|
+
// --rediscover: wipe yaml + entire .infrawise dir, then re-run discovery and analysis
|
|
75
|
+
if (options.rediscover) {
|
|
76
|
+
if (fs.existsSync(configAbsPath)) {
|
|
77
|
+
fs.unlinkSync(configAbsPath);
|
|
78
|
+
}
|
|
79
|
+
const infrawiseDir = path.join(path.dirname(configAbsPath), '.infrawise');
|
|
80
|
+
if (fs.existsSync(infrawiseDir)) {
|
|
81
|
+
fs.rmSync(infrawiseDir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
log.warn('Cleared config and .infrawise — rediscovering...');
|
|
84
|
+
console.log('');
|
|
85
|
+
}
|
|
86
|
+
// Generate config if missing
|
|
75
87
|
if (!fs.existsSync(configAbsPath)) {
|
|
76
|
-
log.warn('No infrawise.yaml found —
|
|
88
|
+
log.warn('No infrawise.yaml found — probing environment...');
|
|
77
89
|
console.log('');
|
|
78
|
-
await
|
|
90
|
+
await runDiscover({ interactive: options.interactive });
|
|
79
91
|
console.log('');
|
|
80
92
|
}
|
|
81
|
-
// Validate config
|
|
93
|
+
// Validate config
|
|
82
94
|
try {
|
|
83
|
-
loadConfig(
|
|
95
|
+
loadConfig(configAbsPath);
|
|
84
96
|
}
|
|
85
97
|
catch (err) {
|
|
86
98
|
console.error(formatError(err));
|
|
87
99
|
process.exit(1);
|
|
88
100
|
}
|
|
89
101
|
setCacheDir(path.dirname(configAbsPath));
|
|
90
|
-
//
|
|
102
|
+
// Check cache, re-analyze if stale
|
|
91
103
|
const cachedGraph = readCache('graph', CACHE_TTL_MS);
|
|
92
104
|
const cachedFindings = readCache('findings', CACHE_TTL_MS);
|
|
93
105
|
if (cachedGraph && cachedFindings) {
|
|
@@ -100,12 +112,12 @@ export async function runStart(options = {}) {
|
|
|
100
112
|
await runAnalyze({ config: options.config, silent: true });
|
|
101
113
|
console.log('');
|
|
102
114
|
}
|
|
103
|
-
//
|
|
115
|
+
// Write editor MCP config files
|
|
104
116
|
console.log('');
|
|
105
117
|
writeMcpJson(configAbsPath);
|
|
106
118
|
if (options.cursor)
|
|
107
119
|
writeCursorMcp(configAbsPath);
|
|
108
|
-
//
|
|
120
|
+
// Launch editor or print instructions
|
|
109
121
|
const editor = options.claude ? 'claude' : options.cursor ? 'cursor' : null;
|
|
110
122
|
if (!editor) {
|
|
111
123
|
console.log('');
|