infrawise 0.7.2 → 0.8.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 +8 -3
- package/dist/adapters/aws/index.js +1 -0
- package/dist/adapters/aws/s3.js +72 -0
- package/dist/analyzers/aws-services.js +61 -0
- package/dist/analyzers/index.js +3 -3
- package/dist/cli/commands/analyze.js +28 -3
- package/dist/cli/commands/doctor.js +17 -0
- package/dist/cli/utils.js +3 -0
- package/dist/core/config.js +1 -0
- package/dist/graph/index.js +39 -5
- package/dist/server/index.js +27 -3
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -173,8 +173,9 @@ To let Claude Code manage the server lifecycle automatically:
|
|
|
173
173
|
| `get_topic_details` | SNS topics — subscription counts and protocols |
|
|
174
174
|
| `get_secrets_overview` | Secrets Manager — names and rotation status (values never included) |
|
|
175
175
|
| `get_parameter_overview` | SSM Parameter Store — names, types, tiers (values never included) |
|
|
176
|
-
| `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge), env var key names |
|
|
176
|
+
| `get_lambda_overview` | Lambda functions — runtime, memory, timeout, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), env var key names |
|
|
177
177
|
| `get_eventbridge_details` | EventBridge rules — name, state, schedule/event pattern, target functions |
|
|
178
|
+
| `get_s3_overview` | S3 buckets — versioning, encryption, public access, event notifications |
|
|
178
179
|
| `get_log_errors` | CloudWatch error patterns and counts (no raw log messages) |
|
|
179
180
|
|
|
180
181
|
---
|
|
@@ -198,7 +199,7 @@ To let Claude Code manage the server lifecycle automatically:
|
|
|
198
199
|
| `-r, --repo <path>` | Repository to scan (default: current directory) |
|
|
199
200
|
| `--no-cache` | Skip reading/writing the cache |
|
|
200
201
|
| `-o, --output <path>` | Save findings as a markdown report, e.g. `report.md` |
|
|
201
|
-
| `--severity <level>` | Only show findings at or above this level: `high` \| `medium` \| `low` |
|
|
202
|
+
| `--severity <level>` | Only show findings at or above this level: `high` \| `medium` \| `low` \| `verify` |
|
|
202
203
|
|
|
203
204
|
```bash
|
|
204
205
|
# Export a shareable findings report
|
|
@@ -277,6 +278,9 @@ eventbridge:
|
|
|
277
278
|
rds:
|
|
278
279
|
enabled: false
|
|
279
280
|
|
|
281
|
+
s3:
|
|
282
|
+
enabled: false
|
|
283
|
+
|
|
280
284
|
kafka:
|
|
281
285
|
enabled: false
|
|
282
286
|
|
|
@@ -343,7 +347,8 @@ Works from AWS APIs, database schema introspection, and IaC files — no depende
|
|
|
343
347
|
| SQS | Missing DLQs, unencrypted queues, large backlogs |
|
|
344
348
|
| Kafka (kafkajs) | Producer/consumer topic mapping from code |
|
|
345
349
|
| Secrets Manager | Missing secret rotation |
|
|
346
|
-
| Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge), missing DLQ on trigger source |
|
|
350
|
+
| Lambda | Default memory (128 MB), high timeouts, triggers (SQS/DynamoDB/Kinesis/EventBridge/S3), missing DLQ on trigger source |
|
|
351
|
+
| S3 | Public access blocking (verify), missing versioning, missing encryption |
|
|
347
352
|
| EventBridge | Rules, schedules, event patterns, target Lambda functions |
|
|
348
353
|
| RDS | Publicly accessible, no backups, unencrypted, no deletion protection, single-AZ |
|
|
349
354
|
| CloudWatch Logs | Log groups with no retention policy |
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { S3Client, ListBucketsCommand, GetBucketNotificationConfigurationCommand, GetBucketVersioningCommand, GetBucketEncryptionCommand, GetPublicAccessBlockCommand, } from '@aws-sdk/client-s3';
|
|
2
|
+
import { fromIni } from '@aws-sdk/credential-providers';
|
|
3
|
+
import { logger } from '../../core/index.js';
|
|
4
|
+
function clientConfig(cfg) {
|
|
5
|
+
const region = cfg.region ?? 'us-east-1';
|
|
6
|
+
const base = { region };
|
|
7
|
+
if (cfg.endpoint)
|
|
8
|
+
base.endpoint = cfg.endpoint;
|
|
9
|
+
if (cfg.profile)
|
|
10
|
+
base.credentials = fromIni({ profile: cfg.profile });
|
|
11
|
+
return base;
|
|
12
|
+
}
|
|
13
|
+
export async function extractS3Metadata(cfg = {}) {
|
|
14
|
+
const client = new S3Client(clientConfig(cfg));
|
|
15
|
+
const buckets = [];
|
|
16
|
+
try {
|
|
17
|
+
const listRes = await client.send(new ListBucketsCommand({}));
|
|
18
|
+
const rawBuckets = (listRes.Buckets ?? []).slice(0, 200);
|
|
19
|
+
for (const bucket of rawBuckets) {
|
|
20
|
+
const name = bucket.Name ?? '';
|
|
21
|
+
if (!name)
|
|
22
|
+
continue;
|
|
23
|
+
const arn = `arn:aws:s3:::${name}`;
|
|
24
|
+
const createdAt = bucket.CreationDate?.toISOString();
|
|
25
|
+
const [notifResult, versionResult, encryptResult, pabResult] = await Promise.allSettled([
|
|
26
|
+
client.send(new GetBucketNotificationConfigurationCommand({ Bucket: name })),
|
|
27
|
+
client.send(new GetBucketVersioningCommand({ Bucket: name })),
|
|
28
|
+
client.send(new GetBucketEncryptionCommand({ Bucket: name })),
|
|
29
|
+
client.send(new GetPublicAccessBlockCommand({ Bucket: name })),
|
|
30
|
+
]);
|
|
31
|
+
const notifications = [];
|
|
32
|
+
if (notifResult.status === 'fulfilled') {
|
|
33
|
+
for (const config of notifResult.value.LambdaFunctionConfigurations ?? []) {
|
|
34
|
+
const lambdaArn = config.LambdaFunctionArn ?? '';
|
|
35
|
+
const lambdaName = lambdaArn.split(':').pop() ?? lambdaArn;
|
|
36
|
+
const rules = config.Filter?.Key?.FilterRules ?? [];
|
|
37
|
+
const prefix = rules.find((r) => r.Name?.toLowerCase() === 'prefix')?.Value;
|
|
38
|
+
const suffix = rules.find((r) => r.Name?.toLowerCase() === 'suffix')?.Value;
|
|
39
|
+
const notification = {
|
|
40
|
+
events: config.Events ?? [],
|
|
41
|
+
lambdaArn,
|
|
42
|
+
lambdaName,
|
|
43
|
+
};
|
|
44
|
+
if (prefix !== undefined)
|
|
45
|
+
notification.prefix = prefix;
|
|
46
|
+
if (suffix !== undefined)
|
|
47
|
+
notification.suffix = suffix;
|
|
48
|
+
notifications.push(notification);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const versioned = versionResult.status === 'fulfilled'
|
|
52
|
+
? versionResult.value.Status === 'Enabled'
|
|
53
|
+
: false;
|
|
54
|
+
const encrypted = encryptResult.status === 'fulfilled'
|
|
55
|
+
? (encryptResult.value.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0
|
|
56
|
+
: false;
|
|
57
|
+
let publicAccessBlocked = false;
|
|
58
|
+
if (pabResult.status === 'fulfilled') {
|
|
59
|
+
const pab = pabResult.value.PublicAccessBlockConfiguration ?? {};
|
|
60
|
+
publicAccessBlocked = !!(pab.BlockPublicAcls && pab.IgnorePublicAcls && pab.BlockPublicPolicy && pab.RestrictPublicBuckets);
|
|
61
|
+
}
|
|
62
|
+
buckets.push({ name, arn, createdAt, versioned, encrypted, publicAccessBlocked, notifications });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
logger.warn(`S3 list failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
}
|
|
68
|
+
return buckets;
|
|
69
|
+
}
|
|
70
|
+
export async function validateS3Access(cfg = {}) {
|
|
71
|
+
await new S3Client(clientConfig(cfg)).send(new ListBucketsCommand({}));
|
|
72
|
+
}
|
|
@@ -183,3 +183,64 @@ export class LambdaHighTimeoutAnalyzer {
|
|
|
183
183
|
return findings;
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
+
// ─── S3 ──────────────────────────────────────────────────────────────────────
|
|
187
|
+
export class S3PublicAccessAnalyzer {
|
|
188
|
+
name = 'S3PublicAccessAnalyzer';
|
|
189
|
+
async analyze(graph) {
|
|
190
|
+
const findings = [];
|
|
191
|
+
for (const node of graph.nodes) {
|
|
192
|
+
if (node.type !== 'bucket')
|
|
193
|
+
continue;
|
|
194
|
+
if (node.publicAccessBlocked === false) {
|
|
195
|
+
findings.push({
|
|
196
|
+
severity: 'verify',
|
|
197
|
+
issue: `S3 bucket "${node.name}" has public access blocking disabled`,
|
|
198
|
+
description: `Public access blocking is disabled on "${node.name}". This is expected for static website hosting and public asset buckets. Confirm this is intentional before treating it as a security issue.`,
|
|
199
|
+
recommendation: `If "${node.name}" is not intentionally public, enable all four S3 Block Public Access settings: BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets.`,
|
|
200
|
+
metadata: { bucketName: node.name, provider: node.provider },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return findings;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export class S3MissingVersioningAnalyzer {
|
|
208
|
+
name = 'S3MissingVersioningAnalyzer';
|
|
209
|
+
async analyze(graph) {
|
|
210
|
+
const findings = [];
|
|
211
|
+
for (const node of graph.nodes) {
|
|
212
|
+
if (node.type !== 'bucket')
|
|
213
|
+
continue;
|
|
214
|
+
if (node.versioned === false) {
|
|
215
|
+
findings.push({
|
|
216
|
+
severity: 'medium',
|
|
217
|
+
issue: `S3 bucket "${node.name}" does not have versioning enabled`,
|
|
218
|
+
description: `"${node.name}" has versioning disabled. Without versioning, accidental deletes or overwrites are unrecoverable. Versioning is required for cross-region replication and Object Lock.`,
|
|
219
|
+
recommendation: `Enable versioning on "${node.name}" via the S3 console or IaC. Consider adding a lifecycle rule to expire old versions and manage storage costs.`,
|
|
220
|
+
metadata: { bucketName: node.name, provider: node.provider },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return findings;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export class S3UnencryptedAnalyzer {
|
|
228
|
+
name = 'S3UnencryptedAnalyzer';
|
|
229
|
+
async analyze(graph) {
|
|
230
|
+
const findings = [];
|
|
231
|
+
for (const node of graph.nodes) {
|
|
232
|
+
if (node.type !== 'bucket')
|
|
233
|
+
continue;
|
|
234
|
+
if (node.encrypted === false) {
|
|
235
|
+
findings.push({
|
|
236
|
+
severity: 'medium',
|
|
237
|
+
issue: `S3 bucket "${node.name}" does not have server-side encryption configured`,
|
|
238
|
+
description: `"${node.name}" has no SSE (Server-Side Encryption) configuration. Data at rest is unencrypted. AWS S3 has enabled SSE-S3 by default since January 2023 for new buckets, but older buckets or those without explicit config should be verified.`,
|
|
239
|
+
recommendation: `Enable SSE on "${node.name}" using SSE-S3 (AES-256) or SSE-KMS. Specify the encryption configuration in your IaC to make it explicit.`,
|
|
240
|
+
metadata: { bucketName: node.name, provider: node.provider },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return findings;
|
|
245
|
+
}
|
|
246
|
+
}
|
package/dist/analyzers/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { MissingIndexAnalyzer, NplusOneAnalyzer, LargeSelectAnalyzer } from './p
|
|
|
4
4
|
export { MissingMySQLIndexAnalyzer, MySQLFullTableScanAnalyzer } from './mysql.js';
|
|
5
5
|
export { MissingMongoIndexAnalyzer, MongoCollectionScanAnalyzer } from './mongodb.js';
|
|
6
6
|
export { IaCDriftAnalyzer } from './terraform.js';
|
|
7
|
-
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, } from './aws-services.js';
|
|
7
|
+
export { MissingDLQAnalyzer, UnencryptedQueueAnalyzer, LargeQueueBacklogAnalyzer, MissingSecretRotationAnalyzer, MissingLogRetentionAnalyzer, LambdaDefaultMemoryAnalyzer, LambdaHighTimeoutAnalyzer, LambdaMissingTriggerDLQAnalyzer, S3PublicAccessAnalyzer, S3MissingVersioningAnalyzer, S3UnencryptedAnalyzer, } from './aws-services.js';
|
|
8
8
|
export { RDSPubliclyAccessibleAnalyzer, RDSNoBackupAnalyzer, RDSUnencryptedAnalyzer, RDSNoDeletionProtectionAnalyzer, RDSNoMultiAZAnalyzer, } from './rds.js';
|
|
9
9
|
export async function runAllAnalyzers(graph, analyzers) {
|
|
10
10
|
const allFindings = [];
|
|
@@ -19,12 +19,12 @@ export async function runAllAnalyzers(graph, analyzers) {
|
|
|
19
19
|
logger.warn(`Analyzer "${analyzer.name}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
-
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
22
|
+
const severityOrder = { high: 0, medium: 1, low: 2, verify: 3 };
|
|
23
23
|
allFindings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
24
24
|
return allFindings;
|
|
25
25
|
}
|
|
26
26
|
export function summarizeFindings(findings) {
|
|
27
|
-
const counts = { total: findings.length, high: 0, medium: 0, low: 0 };
|
|
27
|
+
const counts = { total: findings.length, high: 0, medium: 0, low: 0, verify: 0 };
|
|
28
28
|
for (const f of findings)
|
|
29
29
|
counts[f.severity]++;
|
|
30
30
|
return counts;
|
|
@@ -10,16 +10,18 @@ import { extractMongoMetadata } from '../../adapters/db/mongodb.js';
|
|
|
10
10
|
import { extractIaCSchema } from '../../adapters/iac/terraform.js';
|
|
11
11
|
import { extractSQSMetadata, extractSNSMetadata, extractSSMMetadata, extractSecretsMetadata, extractLambdaMetadata, extractEventBridgeMetadata, extractRDSMetadata, } from '../../adapters/aws/services.js';
|
|
12
12
|
import { extractLogsMetadata } from '../../adapters/aws/logs.js';
|
|
13
|
+
import { extractS3Metadata } from '../../adapters/aws/s3.js';
|
|
13
14
|
import { scanRepository } from '../../context/index.js';
|
|
14
15
|
import { buildGraph } from '../../graph/index.js';
|
|
15
|
-
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, } from '../../analyzers/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
17
|
import { printFinding, printSummaryBox, log, printHeader } from '../utils.js';
|
|
17
|
-
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1 };
|
|
18
|
+
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, verify: 0 };
|
|
18
19
|
function buildMarkdownReport(findings, projectName) {
|
|
19
20
|
const date = new Date().toISOString().split('T')[0];
|
|
20
21
|
const high = findings.filter((f) => f.severity === 'high');
|
|
21
22
|
const medium = findings.filter((f) => f.severity === 'medium');
|
|
22
23
|
const low = findings.filter((f) => f.severity === 'low');
|
|
24
|
+
const verify = findings.filter((f) => f.severity === 'verify');
|
|
23
25
|
const renderGroup = (label, emoji, group) => {
|
|
24
26
|
if (group.length === 0)
|
|
25
27
|
return '';
|
|
@@ -30,10 +32,11 @@ function buildMarkdownReport(findings, projectName) {
|
|
|
30
32
|
`# infrawise report — ${projectName}`,
|
|
31
33
|
`_Generated: ${date}_`,
|
|
32
34
|
'',
|
|
33
|
-
`**${findings.length} finding(s)**: ${high.length} high · ${medium.length} medium · ${low.length} low`,
|
|
35
|
+
`**${findings.length} finding(s)**: ${high.length} high · ${medium.length} medium · ${low.length} low · ${verify.length} verify`,
|
|
34
36
|
renderGroup('High severity', '🔴', high),
|
|
35
37
|
renderGroup('Medium severity', '🟡', medium),
|
|
36
38
|
renderGroup('Low severity', '🟢', low),
|
|
39
|
+
renderGroup('Verify (check intent)', '🔵', verify),
|
|
37
40
|
'',
|
|
38
41
|
'_Generated by [infrawise](https://github.com/Sidd27/infrawise) — MCP server for AWS infrastructure analysis_',
|
|
39
42
|
].filter((l) => l !== undefined).join('\n');
|
|
@@ -192,6 +195,18 @@ export async function runAnalyze(options = {}) {
|
|
|
192
195
|
s.warn(chalk.yellow('RDS skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
|
|
193
196
|
}
|
|
194
197
|
}
|
|
198
|
+
// ── S3 ────────────────────────────────────────────────────────────────────────
|
|
199
|
+
if (config.s3?.enabled === true) {
|
|
200
|
+
const s = mkSpinner('Extracting S3 buckets...');
|
|
201
|
+
try {
|
|
202
|
+
const result = await extractS3Metadata(awsCfg);
|
|
203
|
+
servicesMeta.s3 = result;
|
|
204
|
+
s.succeed(chalk.green('S3') + chalk.dim(` ${result.length} bucket(s)`));
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
s.warn(chalk.yellow('S3 skipped') + chalk.dim(` ${err instanceof Error ? err.message : String(err)}`));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
195
210
|
// ── CloudWatch Logs ──────────────────────────────────────────────────────────
|
|
196
211
|
if (config.cloudwatchLogs?.enabled) {
|
|
197
212
|
const s = mkSpinner('Sampling CloudWatch Logs (errors only, max 50 groups)...');
|
|
@@ -293,6 +308,11 @@ export async function runAnalyze(options = {}) {
|
|
|
293
308
|
new RDSNoDeletionProtectionAnalyzer(),
|
|
294
309
|
new RDSNoMultiAZAnalyzer(),
|
|
295
310
|
] : []),
|
|
311
|
+
...(config.s3?.enabled === true ? [
|
|
312
|
+
new S3PublicAccessAnalyzer(),
|
|
313
|
+
new S3MissingVersioningAnalyzer(),
|
|
314
|
+
new S3UnencryptedAnalyzer(),
|
|
315
|
+
] : []),
|
|
296
316
|
...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
|
|
297
317
|
];
|
|
298
318
|
findings = await runAllAnalyzers(graph, analyzers);
|
|
@@ -384,6 +404,11 @@ export async function runCodeRefresh(repoPath, config) {
|
|
|
384
404
|
new RDSPubliclyAccessibleAnalyzer(), new RDSNoBackupAnalyzer(), new RDSUnencryptedAnalyzer(),
|
|
385
405
|
new RDSNoDeletionProtectionAnalyzer(), new RDSNoMultiAZAnalyzer(),
|
|
386
406
|
] : []),
|
|
407
|
+
...(config.s3?.enabled === true ? [
|
|
408
|
+
new S3PublicAccessAnalyzer(),
|
|
409
|
+
new S3MissingVersioningAnalyzer(),
|
|
410
|
+
new S3UnencryptedAnalyzer(),
|
|
411
|
+
] : []),
|
|
387
412
|
...(iacDriftAnalyzer ? [iacDriftAnalyzer] : []),
|
|
388
413
|
];
|
|
389
414
|
const findings = await runAllAnalyzers(graph, analyzers);
|
|
@@ -10,6 +10,7 @@ import { validateMySQLAccess } from '../../adapters/db/mysql.js';
|
|
|
10
10
|
import { validateMongoAccess } from '../../adapters/db/mongodb.js';
|
|
11
11
|
import { validateSQSAccess, validateSNSAccess, validateSSMAccess, validateSecretsAccess, validateLambdaAccess, validateEventBridgeAccess, } from '../../adapters/aws/services.js';
|
|
12
12
|
import { validateLogsAccess } from '../../adapters/aws/logs.js';
|
|
13
|
+
import { validateS3Access } from '../../adapters/aws/s3.js';
|
|
13
14
|
import { printHeader } from '../utils.js';
|
|
14
15
|
async function runCheck(label, fn) {
|
|
15
16
|
const spin = ora({ text: chalk.dim(label), color: 'cyan' }).start();
|
|
@@ -189,6 +190,22 @@ export async function runDoctor(options = {}) {
|
|
|
189
190
|
};
|
|
190
191
|
}
|
|
191
192
|
}));
|
|
193
|
+
// S3
|
|
194
|
+
results.push(await runCheck('Testing S3 access...', async () => {
|
|
195
|
+
if (config?.s3?.enabled !== true)
|
|
196
|
+
return { name: 'S3', status: 'skip', message: 'Disabled in config' };
|
|
197
|
+
try {
|
|
198
|
+
await validateS3Access(awsCfg);
|
|
199
|
+
return { name: 'S3', status: 'pass', message: 'Connected' };
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
return {
|
|
203
|
+
name: 'S3', status: 'warn',
|
|
204
|
+
message: err instanceof Error ? err.message : String(err),
|
|
205
|
+
detail: 'Check IAM: s3:ListBuckets, s3:GetBucketNotificationConfiguration, s3:GetBucketVersioning, s3:GetBucketEncryption, s3:GetPublicAccessBlock',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
192
209
|
// CloudWatch Logs
|
|
193
210
|
results.push(await runCheck('Testing CloudWatch Logs access...', async () => {
|
|
194
211
|
if (!config?.cloudwatchLogs?.enabled)
|
package/dist/cli/utils.js
CHANGED
|
@@ -93,6 +93,7 @@ function severityBadge(severity) {
|
|
|
93
93
|
case 'high': return chalk.bgRed.white.bold(` HIGH `);
|
|
94
94
|
case 'medium': return chalk.bgYellow.black.bold(` MED `);
|
|
95
95
|
case 'low': return chalk.bgCyan.black.bold(` LOW `);
|
|
96
|
+
case 'verify': return chalk.bgBlue.white.bold(` VER? `);
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
export function printFinding(finding, index) {
|
|
@@ -106,6 +107,7 @@ export function printSummaryBox(findings) {
|
|
|
106
107
|
const high = findings.filter((f) => f.severity === 'high').length;
|
|
107
108
|
const medium = findings.filter((f) => f.severity === 'medium').length;
|
|
108
109
|
const low = findings.filter((f) => f.severity === 'low').length;
|
|
110
|
+
const verify = findings.filter((f) => f.severity === 'verify').length;
|
|
109
111
|
console.log('');
|
|
110
112
|
console.log(chalk.dim(' ┌─────────────────────────────┐'));
|
|
111
113
|
console.log(chalk.dim(' │') + chalk.bold(' Analysis Summary ') + chalk.dim('│'));
|
|
@@ -113,6 +115,7 @@ export function printSummaryBox(findings) {
|
|
|
113
115
|
console.log(chalk.dim(' │') + ` ${chalk.red('●')} High ${chalk.red.bold(String(high).padStart(3))} ` + chalk.dim('│'));
|
|
114
116
|
console.log(chalk.dim(' │') + ` ${chalk.yellow('●')} Medium ${chalk.yellow.bold(String(medium).padStart(3))} ` + chalk.dim('│'));
|
|
115
117
|
console.log(chalk.dim(' │') + ` ${chalk.cyan('●')} Low ${chalk.cyan.bold(String(low).padStart(3))} ` + chalk.dim('│'));
|
|
118
|
+
console.log(chalk.dim(' │') + ` ${chalk.blue('●')} Verify ${chalk.blue.bold(String(verify).padStart(3))} ` + chalk.dim('│'));
|
|
116
119
|
console.log(chalk.dim(' ├─────────────────────────────┤'));
|
|
117
120
|
console.log(chalk.dim(' │') + ` Total ${chalk.bold(String(findings.length).padStart(3))} ` + chalk.dim('│'));
|
|
118
121
|
console.log(chalk.dim(' └─────────────────────────────┘'));
|
package/dist/core/config.js
CHANGED
|
@@ -40,6 +40,7 @@ export const InfrawiseConfigSchema = z.object({
|
|
|
40
40
|
}).optional(),
|
|
41
41
|
eventbridge: z.object({ enabled: z.boolean().optional().default(true) }).optional(),
|
|
42
42
|
rds: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
43
|
+
s3: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
43
44
|
kafka: z.object({ enabled: z.boolean().optional().default(false) }).optional(),
|
|
44
45
|
cloudwatchLogs: z.object({
|
|
45
46
|
enabled: z.boolean().optional().default(false),
|
package/dist/graph/index.js
CHANGED
|
@@ -2,10 +2,12 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
2
2
|
const nodes = [];
|
|
3
3
|
const edges = [];
|
|
4
4
|
const nodeIds = new Set();
|
|
5
|
+
const nodeMap = new Map();
|
|
5
6
|
function addNode(node) {
|
|
6
7
|
if (!nodeIds.has(node.id)) {
|
|
7
8
|
nodes.push(node);
|
|
8
9
|
nodeIds.add(node.id);
|
|
10
|
+
nodeMap.set(node.id, node);
|
|
9
11
|
}
|
|
10
12
|
}
|
|
11
13
|
// ── Database tables ──────────────────────────────────────────────────────
|
|
@@ -152,7 +154,7 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
152
154
|
if (!nodeIds.has(lambdaId))
|
|
153
155
|
continue;
|
|
154
156
|
edges.push({ from: ruleId, to: lambdaId, type: 'triggers' });
|
|
155
|
-
const lambdaNodeRef =
|
|
157
|
+
const lambdaNodeRef = nodeMap.get(lambdaId);
|
|
156
158
|
if (lambdaNodeRef && lambdaNodeRef.type === 'lambda') {
|
|
157
159
|
const trigger = {
|
|
158
160
|
type: 'eventbridge',
|
|
@@ -182,13 +184,42 @@ export function buildGraph(operations, dynamoMeta, postgresMeta, mysqlMeta = [],
|
|
|
182
184
|
multiAZ: db.multiAZ,
|
|
183
185
|
});
|
|
184
186
|
}
|
|
187
|
+
// ── S3 Buckets ────────────────────────────────────────────────────────────────
|
|
188
|
+
for (const bucket of servicesMeta.s3 ?? []) {
|
|
189
|
+
const bucketId = `bucket:aws:${bucket.name}`;
|
|
190
|
+
addNode({
|
|
191
|
+
id: bucketId,
|
|
192
|
+
type: 'bucket',
|
|
193
|
+
name: bucket.name,
|
|
194
|
+
provider: 'aws',
|
|
195
|
+
versioned: bucket.versioned,
|
|
196
|
+
encrypted: bucket.encrypted,
|
|
197
|
+
publicAccessBlocked: bucket.publicAccessBlocked,
|
|
198
|
+
});
|
|
199
|
+
for (const notification of bucket.notifications) {
|
|
200
|
+
const lambdaId = `lambda:aws:${notification.lambdaName}`;
|
|
201
|
+
if (!nodeMap.has(lambdaId))
|
|
202
|
+
continue;
|
|
203
|
+
edges.push({ from: bucketId, to: lambdaId, type: 'triggers' });
|
|
204
|
+
const lambdaNode = nodeMap.get(lambdaId);
|
|
205
|
+
if (lambdaNode && lambdaNode.type === 'lambda') {
|
|
206
|
+
lambdaNode.triggers = [
|
|
207
|
+
...(lambdaNode.triggers ?? []),
|
|
208
|
+
{
|
|
209
|
+
type: 's3',
|
|
210
|
+
sourceArn: bucket.arn,
|
|
211
|
+
sourceName: bucket.name,
|
|
212
|
+
eventShape: 'event.Records[0].s3.object.key',
|
|
213
|
+
events: notification.events,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
185
219
|
// ── Code operations (functions + edges) ───────────────────────────────────
|
|
186
220
|
for (const op of operations) {
|
|
187
221
|
const funcNodeId = `function:${op.filePath}:${op.functionName}`;
|
|
188
|
-
|
|
189
|
-
nodes.push({ id: funcNodeId, type: 'function', name: op.functionName, file: op.filePath });
|
|
190
|
-
nodeIds.add(funcNodeId);
|
|
191
|
-
}
|
|
222
|
+
addNode({ id: funcNodeId, type: 'function', name: op.functionName, file: op.filePath });
|
|
192
223
|
// AWS service operations create edges to service nodes
|
|
193
224
|
if (op.serviceType === 'sqs') {
|
|
194
225
|
const queueId = `queue:aws:${op.target}`;
|
|
@@ -293,6 +324,9 @@ export function getLambdaNodes(graph) {
|
|
|
293
324
|
export function getEventBridgeRuleNodes(graph) {
|
|
294
325
|
return graph.nodes.filter((n) => n.type === 'eventbridge_rule');
|
|
295
326
|
}
|
|
327
|
+
export function getBucketNodes(graph) {
|
|
328
|
+
return graph.nodes.filter((n) => n.type === 'bucket');
|
|
329
|
+
}
|
|
296
330
|
export function getEdgesForNode(graph, nodeId) {
|
|
297
331
|
return graph.edges.filter((e) => e.from === nodeId || e.to === nodeId);
|
|
298
332
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { z } from 'zod';
|
|
|
8
8
|
import { logger } from '../core/index.js';
|
|
9
9
|
const { version } = JSON.parse(readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'));
|
|
10
10
|
import { summarizeFindings } from '../analyzers/index.js';
|
|
11
|
-
import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
|
|
11
|
+
import { getTableNodes, getFunctionNodes, getQueueNodes, getTopicNodes, getSecretNodes, getParameterNodes, getLogGroupNodes, getLambdaNodes, getEventBridgeRuleNodes, getBucketNodes, getScanEdges, getOutgoingEdges, } from '../graph/index.js';
|
|
12
12
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
13
13
|
let currentGraph = { nodes: [], edges: [] };
|
|
14
14
|
let currentFindings = [];
|
|
@@ -42,12 +42,14 @@ export function createMcpServer() {
|
|
|
42
42
|
const logGroups = getLogGroupNodes(currentGraph);
|
|
43
43
|
const lambdas = getLambdaNodes(currentGraph);
|
|
44
44
|
const functions = getFunctionNodes(currentGraph);
|
|
45
|
+
const buckets = getBucketNodes(currentGraph);
|
|
45
46
|
return toText({
|
|
46
47
|
summary: {
|
|
47
48
|
tables: tables.length, functions: functions.length,
|
|
48
49
|
queues: queues.length, topics: topics.length,
|
|
49
50
|
secrets: secrets.length, parameters: parameters.length,
|
|
50
51
|
logGroups: logGroups.length, lambdas: lambdas.length,
|
|
52
|
+
buckets: buckets.length,
|
|
51
53
|
totalNodes: currentGraph.nodes.length, totalEdges: currentGraph.edges.length,
|
|
52
54
|
findings: summarizeFindings(currentFindings),
|
|
53
55
|
},
|
|
@@ -58,6 +60,7 @@ export function createMcpServer() {
|
|
|
58
60
|
parameters: parameters.map((p) => ({ name: p.name, type: p.paramType, tier: p.tier })),
|
|
59
61
|
lambdas: lambdas.map((l) => ({ name: l.name, runtime: l.runtime, memoryMB: l.memoryMB })),
|
|
60
62
|
logGroups: logGroups.map((lg) => ({ name: lg.name, retentionDays: lg.retentionDays ?? 'never', errorCount: lg.errorCount })),
|
|
63
|
+
buckets: buckets.map((b) => ({ name: b.name, versioned: b.versioned, publicAccessBlocked: b.publicAccessBlocked })),
|
|
61
64
|
highFindings: currentFindings.filter((f) => f.severity === 'high').map((f) => ({ issue: f.issue, recommendation: f.recommendation })),
|
|
62
65
|
});
|
|
63
66
|
}));
|
|
@@ -262,6 +265,27 @@ export function createMcpServer() {
|
|
|
262
265
|
})),
|
|
263
266
|
});
|
|
264
267
|
}));
|
|
268
|
+
mcp.registerTool('get_s3_overview', {
|
|
269
|
+
description: 'Returns all S3 buckets with versioning status, encryption, public access configuration, and security findings. Call this when checking which S3 buckets exist, reviewing bucket security posture, or before writing S3 upload/delete handlers to confirm the bucket name. Do NOT call when you only need a quick infrastructure count — use get_infra_overview for that. Object contents are never included.',
|
|
270
|
+
inputSchema: z.object({}),
|
|
271
|
+
}, logged('get_s3_overview', async () => {
|
|
272
|
+
const buckets = getBucketNodes(currentGraph);
|
|
273
|
+
const bucketFindings = currentFindings.filter((f) => f.metadata?.bucketName);
|
|
274
|
+
return toText({
|
|
275
|
+
total: buckets.length,
|
|
276
|
+
note: 'Object contents are never included.',
|
|
277
|
+
buckets: buckets.map((b) => ({
|
|
278
|
+
name: b.name,
|
|
279
|
+
provider: b.provider,
|
|
280
|
+
versioned: b.versioned,
|
|
281
|
+
encrypted: b.encrypted,
|
|
282
|
+
publicAccessBlocked: b.publicAccessBlocked,
|
|
283
|
+
findings: bucketFindings
|
|
284
|
+
.filter((f) => f.metadata.bucketName === b.name)
|
|
285
|
+
.map((f) => ({ severity: f.severity, issue: f.issue })),
|
|
286
|
+
})),
|
|
287
|
+
});
|
|
288
|
+
}));
|
|
265
289
|
mcp.registerTool('get_log_errors', {
|
|
266
290
|
description: 'Returns recent error pattern summaries from CloudWatch log groups: pattern counts and frequencies grouped by log group. Raw log messages are never returned. Use the optional logGroup filter to scope to one group by name substring. Call this when investigating errors or identifying log groups with no retention policy.',
|
|
267
291
|
inputSchema: z.object({ logGroup: z.string().describe('Filter to a specific log group name (optional)').optional() }),
|
|
@@ -291,7 +315,7 @@ export function createServer(port = 3000) {
|
|
|
291
315
|
name: 'io.github.Sidd27/infrawise',
|
|
292
316
|
display_name: 'Infrawise',
|
|
293
317
|
version,
|
|
294
|
-
description: 'Infrastructure analysis MCP server — scans DynamoDB, PostgreSQL, MySQL, MongoDB, Lambda, SQS, SNS, EventBridge, Secrets Manager, SSM, CloudWatch, Terraform, CDK, and source code. Surfaces missing indexes, DLQ gaps, Lambda misconfig, and correct trigger event shapes.',
|
|
318
|
+
description: 'Infrastructure analysis MCP server — scans DynamoDB, PostgreSQL, MySQL, MongoDB, S3, Lambda, SQS, SNS, EventBridge, Secrets Manager, SSM, CloudWatch, Terraform, CDK, and source code. Surfaces missing indexes, DLQ gaps, Lambda misconfig, S3 security posture, and correct trigger event shapes.',
|
|
295
319
|
homepage: 'https://github.com/Sidd27/infrawise',
|
|
296
320
|
repository: 'https://github.com/Sidd27/infrawise',
|
|
297
321
|
transports: [{ type: 'streamable-http', url: `http://localhost:${port}/mcp` }],
|
|
@@ -299,7 +323,7 @@ export function createServer(port = 3000) {
|
|
|
299
323
|
'get_infra_overview', 'get_graph_summary', 'analyze_function',
|
|
300
324
|
'suggest_gsi', 'postgres_index_suggestions', 'suggest_mongo_index', 'mysql_index_suggestions',
|
|
301
325
|
'get_queue_details', 'get_topic_details', 'get_secrets_overview', 'get_parameter_overview',
|
|
302
|
-
'get_lambda_overview', 'get_eventbridge_details', 'get_log_errors',
|
|
326
|
+
'get_lambda_overview', 'get_eventbridge_details', 'get_s3_overview', 'get_log_errors',
|
|
303
327
|
],
|
|
304
328
|
}));
|
|
305
329
|
fastify.post('/mcp', async (request, reply) => {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infrawise",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"mcpName": "io.github.Sidd27/infrawise",
|
|
5
|
-
"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
|
+
"description": "CLI-first infrastructure intelligence platform — analyzes DynamoDB, PostgreSQL, MySQL, MongoDB, SQS, SNS, SSM, Secrets Manager, Lambda, S3, CloudWatch Logs and exposes findings as an MCP server for Claude Code",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
8
8
|
"mcp-server",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"sqs",
|
|
15
15
|
"sns",
|
|
16
16
|
"lambda",
|
|
17
|
+
"s3",
|
|
17
18
|
"eventbridge",
|
|
18
19
|
"cloudwatch",
|
|
19
20
|
"secrets-manager",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"@aws-sdk/client-cloudwatch-logs": "^3.1048.0",
|
|
67
|
+
"@aws-sdk/client-s3": "^3.1048.0",
|
|
66
68
|
"@aws-sdk/client-dynamodb": "^3.1048.0",
|
|
67
69
|
"@aws-sdk/client-eventbridge": "^3.1051.0",
|
|
68
70
|
"@aws-sdk/client-lambda": "^3.1048.0",
|
|
@@ -74,9 +76,9 @@
|
|
|
74
76
|
"@aws-sdk/credential-providers": "^3.1048.0",
|
|
75
77
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
76
78
|
"chalk": "^5.6.2",
|
|
77
|
-
"commander": "^
|
|
79
|
+
"commander": "^15.0.0",
|
|
78
80
|
"fastify": "^5.8.5",
|
|
79
|
-
"inquirer": "^
|
|
81
|
+
"inquirer": "^14.0.2",
|
|
80
82
|
"js-yaml": "^4.1.0",
|
|
81
83
|
"mongodb": "^7.2.0",
|
|
82
84
|
"mysql2": "^3.9.0",
|