aws-cost-calculator-cli 1.6.0__py3-none-any.whl → 1.9.1__py3-none-any.whl
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.
Potentially problematic release.
This version of aws-cost-calculator-cli might be problematic. Click here for more details.
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/METADATA +154 -23
- aws_cost_calculator_cli-1.9.1.dist-info/RECORD +15 -0
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/WHEEL +1 -1
- cost_calculator/api_client.py +2 -1
- cost_calculator/cli.py +392 -10
- cost_calculator/cur.py +244 -0
- cost_calculator/executor.py +59 -92
- cost_calculator/forensics.py +323 -0
- aws_cost_calculator_cli-1.6.0.dist-info/RECORD +0 -13
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/top_level.txt +0 -0
cost_calculator/cli.py
CHANGED
|
@@ -11,6 +11,8 @@ Usage:
|
|
|
11
11
|
import click
|
|
12
12
|
import boto3
|
|
13
13
|
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
14
16
|
from datetime import datetime, timedelta
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from cost_calculator.trends import format_trends_markdown
|
|
@@ -134,8 +136,11 @@ def load_profile(profile_name):
|
|
|
134
136
|
profile_data = response_data.get('profile', response_data)
|
|
135
137
|
profile = {'accounts': profile_data['accounts']}
|
|
136
138
|
|
|
139
|
+
# If profile has aws_profile field, use it
|
|
140
|
+
if 'aws_profile' in profile_data:
|
|
141
|
+
profile['aws_profile'] = profile_data['aws_profile']
|
|
137
142
|
# Check for AWS_PROFILE environment variable (SSO support)
|
|
138
|
-
|
|
143
|
+
elif os.environ.get('AWS_PROFILE'):
|
|
139
144
|
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
140
145
|
# Use environment credentials
|
|
141
146
|
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
@@ -144,6 +149,33 @@ def load_profile(profile_name):
|
|
|
144
149
|
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
145
150
|
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
146
151
|
}
|
|
152
|
+
else:
|
|
153
|
+
# Try to find a matching AWS profile by name
|
|
154
|
+
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
155
|
+
import subprocess
|
|
156
|
+
try:
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
['aws', 'configure', 'list-profiles'],
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
timeout=5
|
|
162
|
+
)
|
|
163
|
+
if result.returncode == 0:
|
|
164
|
+
available_profiles = result.stdout.strip().split('\n')
|
|
165
|
+
# Try exact match first
|
|
166
|
+
if profile_name in available_profiles:
|
|
167
|
+
profile['aws_profile'] = profile_name
|
|
168
|
+
# Try with common suffixes
|
|
169
|
+
elif f"{profile_name}_umbrella" in available_profiles:
|
|
170
|
+
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
171
|
+
elif f"{profile_name}-umbrella" in available_profiles:
|
|
172
|
+
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
173
|
+
elif f"{profile_name}_prod" in available_profiles:
|
|
174
|
+
profile['aws_profile'] = f"{profile_name}_prod"
|
|
175
|
+
# If no match found, leave it unset - user must provide --sso
|
|
176
|
+
except:
|
|
177
|
+
# If we can't list profiles, leave it unset - user must provide --sso
|
|
178
|
+
pass
|
|
147
179
|
|
|
148
180
|
return profile
|
|
149
181
|
else:
|
|
@@ -323,9 +355,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
323
355
|
# Calculate days in the month that the support covers
|
|
324
356
|
# Support on Nov 1 covers October (31 days)
|
|
325
357
|
support_month = support_month_date - timedelta(days=1) # Go back to previous month
|
|
326
|
-
|
|
358
|
+
import calendar
|
|
359
|
+
days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
|
|
327
360
|
|
|
328
|
-
# Support allocation: divide by 2 (
|
|
361
|
+
# Support allocation: divide by 2 (50% allocation), then by days in month
|
|
329
362
|
support_per_day = (support_cost / 2) / days_in_support_month
|
|
330
363
|
|
|
331
364
|
# Calculate daily rate
|
|
@@ -384,26 +417,147 @@ def cli():
|
|
|
384
417
|
pass
|
|
385
418
|
|
|
386
419
|
|
|
420
|
+
@cli.command('setup-cur')
|
|
421
|
+
@click.option('--database', required=True, prompt='CUR Athena Database', help='Athena database name for CUR')
|
|
422
|
+
@click.option('--table', required=True, prompt='CUR Table Name', help='CUR table name')
|
|
423
|
+
@click.option('--s3-output', required=True, prompt='S3 Output Location', help='S3 bucket for Athena query results')
|
|
424
|
+
def setup_cur(database, table, s3_output):
|
|
425
|
+
"""
|
|
426
|
+
Configure CUR (Cost and Usage Report) settings for resource-level queries
|
|
427
|
+
|
|
428
|
+
Saves CUR configuration to ~/.config/cost-calculator/cur_config.json
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
cc setup-cur --database my_cur_db --table cur_table --s3-output s3://my-bucket/
|
|
432
|
+
"""
|
|
433
|
+
import json
|
|
434
|
+
|
|
435
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
436
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
437
|
+
|
|
438
|
+
config_file = config_dir / 'cur_config.json'
|
|
439
|
+
|
|
440
|
+
config = {
|
|
441
|
+
'database': database,
|
|
442
|
+
'table': table,
|
|
443
|
+
's3_output': s3_output
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
with open(config_file, 'w') as f:
|
|
447
|
+
json.dump(config, f, indent=2)
|
|
448
|
+
|
|
449
|
+
click.echo(f"✓ CUR configuration saved to {config_file}")
|
|
450
|
+
click.echo(f" Database: {database}")
|
|
451
|
+
click.echo(f" Table: {table}")
|
|
452
|
+
click.echo(f" S3 Output: {s3_output}")
|
|
453
|
+
click.echo("")
|
|
454
|
+
click.echo("You can now use: cc drill --service 'EC2 - Other' --resources")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@cli.command('setup-api')
|
|
458
|
+
@click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
|
|
459
|
+
def setup_api(api_secret):
|
|
460
|
+
"""
|
|
461
|
+
Configure COST_API_SECRET for backend API access
|
|
462
|
+
|
|
463
|
+
Saves the API secret to the appropriate location based on your OS:
|
|
464
|
+
- Mac/Linux: ~/.zshrc or ~/.bashrc
|
|
465
|
+
- Windows: User environment variables
|
|
466
|
+
|
|
467
|
+
Example:
|
|
468
|
+
cc setup-api --api-secret your-secret-here
|
|
469
|
+
|
|
470
|
+
Or let it prompt you (input will be hidden):
|
|
471
|
+
cc setup-api
|
|
472
|
+
"""
|
|
473
|
+
system = platform.system()
|
|
474
|
+
|
|
475
|
+
if system == "Windows":
|
|
476
|
+
# Windows: Set user environment variable
|
|
477
|
+
try:
|
|
478
|
+
import winreg
|
|
479
|
+
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
|
|
480
|
+
winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
|
|
481
|
+
winreg.CloseKey(key)
|
|
482
|
+
click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
|
|
483
|
+
click.echo(" Please restart your terminal for changes to take effect")
|
|
484
|
+
except Exception as e:
|
|
485
|
+
click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
|
|
486
|
+
click.echo("\nManual setup:")
|
|
487
|
+
click.echo("1. Open System Properties > Environment Variables")
|
|
488
|
+
click.echo("2. Add new User variable:")
|
|
489
|
+
click.echo(" Name: COST_API_SECRET")
|
|
490
|
+
click.echo(f" Value: {api_secret}")
|
|
491
|
+
return
|
|
492
|
+
else:
|
|
493
|
+
# Mac/Linux: Add to shell profile
|
|
494
|
+
shell = os.environ.get('SHELL', '/bin/bash')
|
|
495
|
+
|
|
496
|
+
if 'zsh' in shell:
|
|
497
|
+
profile_file = Path.home() / '.zshrc'
|
|
498
|
+
else:
|
|
499
|
+
profile_file = Path.home() / '.bashrc'
|
|
500
|
+
|
|
501
|
+
# Check if already exists
|
|
502
|
+
export_line = f'export COST_API_SECRET="{api_secret}"'
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
if profile_file.exists():
|
|
506
|
+
content = profile_file.read_text()
|
|
507
|
+
if 'COST_API_SECRET' in content:
|
|
508
|
+
# Replace existing
|
|
509
|
+
lines = content.split('\n')
|
|
510
|
+
new_lines = []
|
|
511
|
+
for line in lines:
|
|
512
|
+
if 'COST_API_SECRET' in line and line.strip().startswith('export'):
|
|
513
|
+
new_lines.append(export_line)
|
|
514
|
+
else:
|
|
515
|
+
new_lines.append(line)
|
|
516
|
+
profile_file.write_text('\n'.join(new_lines))
|
|
517
|
+
click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
|
|
518
|
+
else:
|
|
519
|
+
# Append
|
|
520
|
+
with profile_file.open('a') as f:
|
|
521
|
+
f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
|
|
522
|
+
click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
|
|
523
|
+
else:
|
|
524
|
+
# Create new file
|
|
525
|
+
profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
|
|
526
|
+
click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
|
|
527
|
+
|
|
528
|
+
# Also set for current session
|
|
529
|
+
os.environ['COST_API_SECRET'] = api_secret
|
|
530
|
+
click.echo(f"✓ Set COST_API_SECRET for current session")
|
|
531
|
+
click.echo(f"\nTo use in new terminals, run: source {profile_file}")
|
|
532
|
+
|
|
533
|
+
except Exception as e:
|
|
534
|
+
click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
|
|
535
|
+
click.echo(f"\nManual setup: Add this line to {profile_file}:")
|
|
536
|
+
click.echo(f" {export_line}")
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
|
|
387
540
|
@cli.command()
|
|
388
541
|
@click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
|
|
389
542
|
@click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
|
|
390
543
|
@click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
|
|
391
544
|
@click.option('--window', default=30, help='Number of days to analyze (default: 30)')
|
|
392
545
|
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
393
|
-
@click.option('--sso', help='AWS SSO profile name (e.g.,
|
|
546
|
+
@click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
|
|
394
547
|
@click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
|
|
395
548
|
@click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
|
|
396
549
|
@click.option('--session-token', help='AWS Session Token (for static credentials)')
|
|
397
550
|
def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
398
|
-
"""
|
|
551
|
+
"""
|
|
552
|
+
Calculate AWS costs for the specified period
|
|
399
553
|
|
|
400
554
|
\b
|
|
401
555
|
Authentication Options:
|
|
402
556
|
1. SSO: --sso <profile_name>
|
|
403
|
-
Example: cc calculate --profile
|
|
557
|
+
Example: cc calculate --profile myprofile --sso my_sso_profile
|
|
404
558
|
|
|
405
559
|
2. Static Credentials: --access-key-id, --secret-access-key, --session-token
|
|
406
|
-
Example: cc calculate --profile
|
|
560
|
+
Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
|
|
407
561
|
|
|
408
562
|
3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
|
|
409
563
|
"""
|
|
@@ -801,14 +955,19 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
|
|
|
801
955
|
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
802
956
|
@click.option('--account', help='Filter by account ID')
|
|
803
957
|
@click.option('--usage-type', help='Filter by usage type')
|
|
958
|
+
@click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
|
|
804
959
|
@click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
|
|
805
960
|
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
806
961
|
@click.option('--sso', help='AWS SSO profile name')
|
|
807
962
|
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
808
963
|
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
809
964
|
@click.option('--session-token', help='AWS Session Token')
|
|
810
|
-
def drill(profile, weeks, service, account, usage_type, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
811
|
-
"""
|
|
965
|
+
def drill(profile, weeks, service, account, usage_type, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
966
|
+
"""
|
|
967
|
+
Drill down into cost changes by service, account, or usage type
|
|
968
|
+
|
|
969
|
+
Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
|
|
970
|
+
"""
|
|
812
971
|
|
|
813
972
|
# Load profile
|
|
814
973
|
config = load_profile(profile)
|
|
@@ -822,10 +981,19 @@ def drill(profile, weeks, service, account, usage_type, output, json_output, sso
|
|
|
822
981
|
click.echo(f" Account filter: {account}")
|
|
823
982
|
if usage_type:
|
|
824
983
|
click.echo(f" Usage type filter: {usage_type}")
|
|
984
|
+
if resources:
|
|
985
|
+
click.echo(f" Mode: Resource-level (CUR via Athena)")
|
|
825
986
|
click.echo("")
|
|
826
987
|
|
|
827
988
|
# Execute via API or locally
|
|
828
|
-
drill_data = execute_drill(config, weeks, service, account, usage_type)
|
|
989
|
+
drill_data = execute_drill(config, weeks, service, account, usage_type, resources)
|
|
990
|
+
|
|
991
|
+
# Handle resource-level output differently
|
|
992
|
+
if resources:
|
|
993
|
+
from cost_calculator.cur import format_resource_output
|
|
994
|
+
output_text = format_resource_output(drill_data)
|
|
995
|
+
click.echo(output_text)
|
|
996
|
+
return
|
|
829
997
|
|
|
830
998
|
if json_output:
|
|
831
999
|
# Output as JSON
|
|
@@ -998,5 +1166,219 @@ def profile(operation, name, accounts, description):
|
|
|
998
1166
|
click.echo(result.get('message', 'Operation completed'))
|
|
999
1167
|
|
|
1000
1168
|
|
|
1169
|
+
@cli.command()
|
|
1170
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1171
|
+
@click.option('--sso', help='AWS SSO profile to use')
|
|
1172
|
+
@click.option('--weeks', default=8, help='Number of weeks to analyze')
|
|
1173
|
+
@click.option('--account', help='Focus on specific account ID')
|
|
1174
|
+
@click.option('--service', help='Focus on specific service')
|
|
1175
|
+
@click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
|
|
1176
|
+
@click.option('--output', default='investigation_report.md', help='Output file path')
|
|
1177
|
+
def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
1178
|
+
"""
|
|
1179
|
+
Multi-stage cost investigation:
|
|
1180
|
+
1. Analyze cost trends and drill-downs
|
|
1181
|
+
2. Inventory actual resources in problem accounts
|
|
1182
|
+
3. Analyze CloudTrail events (optional)
|
|
1183
|
+
4. Generate comprehensive report
|
|
1184
|
+
"""
|
|
1185
|
+
from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
|
|
1186
|
+
from cost_calculator.api_client import call_lambda_api, is_api_configured
|
|
1187
|
+
from cost_calculator.forensics import format_investigation_report
|
|
1188
|
+
from datetime import datetime, timedelta
|
|
1189
|
+
|
|
1190
|
+
click.echo("=" * 80)
|
|
1191
|
+
click.echo("COST INVESTIGATION")
|
|
1192
|
+
click.echo("=" * 80)
|
|
1193
|
+
click.echo(f"Profile: {profile}")
|
|
1194
|
+
click.echo(f"Weeks: {weeks}")
|
|
1195
|
+
if account:
|
|
1196
|
+
click.echo(f"Account: {account}")
|
|
1197
|
+
if service:
|
|
1198
|
+
click.echo(f"Service: {service}")
|
|
1199
|
+
click.echo("")
|
|
1200
|
+
|
|
1201
|
+
# Load profile
|
|
1202
|
+
config = load_profile(profile)
|
|
1203
|
+
|
|
1204
|
+
# Override with SSO if provided
|
|
1205
|
+
if sso:
|
|
1206
|
+
config['aws_profile'] = sso
|
|
1207
|
+
|
|
1208
|
+
# Validate that we have a way to get credentials
|
|
1209
|
+
if 'aws_profile' not in config and 'credentials' not in config:
|
|
1210
|
+
import subprocess
|
|
1211
|
+
try:
|
|
1212
|
+
result = subprocess.run(
|
|
1213
|
+
['aws', 'configure', 'list-profiles'],
|
|
1214
|
+
capture_output=True,
|
|
1215
|
+
text=True,
|
|
1216
|
+
timeout=5
|
|
1217
|
+
)
|
|
1218
|
+
available = result.stdout.strip().split('\n') if result.returncode == 0 else []
|
|
1219
|
+
suggestion = f"\nAvailable AWS profiles: {', '.join(available[:5])}" if available else ""
|
|
1220
|
+
except:
|
|
1221
|
+
suggestion = ""
|
|
1222
|
+
|
|
1223
|
+
raise click.ClickException(
|
|
1224
|
+
f"Profile '{profile}' has no AWS authentication configured.\n"
|
|
1225
|
+
f"Use --sso flag to specify your AWS SSO profile:\n"
|
|
1226
|
+
f" cc investigate --profile {profile} --sso YOUR_AWS_PROFILE{suggestion}"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
# Step 1: Cost Analysis
|
|
1230
|
+
click.echo("Step 1/3: Analyzing cost trends...")
|
|
1231
|
+
try:
|
|
1232
|
+
trends_data = execute_trends(config, weeks)
|
|
1233
|
+
click.echo(f"✓ Found cost data for {weeks} weeks")
|
|
1234
|
+
except Exception as e:
|
|
1235
|
+
click.echo(f"✗ Error analyzing trends: {str(e)}")
|
|
1236
|
+
trends_data = None
|
|
1237
|
+
|
|
1238
|
+
# Step 2: Drill-down
|
|
1239
|
+
click.echo("\nStep 2/3: Drilling down into costs...")
|
|
1240
|
+
drill_data = None
|
|
1241
|
+
if service or account:
|
|
1242
|
+
try:
|
|
1243
|
+
drill_data = execute_drill(config, weeks, service, account, None, False)
|
|
1244
|
+
click.echo(f"✓ Drill-down complete")
|
|
1245
|
+
except Exception as e:
|
|
1246
|
+
click.echo(f"✗ Error in drill-down: {str(e)}")
|
|
1247
|
+
|
|
1248
|
+
# Step 3: Resource Inventory
|
|
1249
|
+
click.echo("\nStep 3/3: Inventorying resources...")
|
|
1250
|
+
inventories = []
|
|
1251
|
+
cloudtrail_analyses = []
|
|
1252
|
+
|
|
1253
|
+
# Determine which accounts to investigate
|
|
1254
|
+
accounts_to_investigate = []
|
|
1255
|
+
if account:
|
|
1256
|
+
accounts_to_investigate = [account]
|
|
1257
|
+
else:
|
|
1258
|
+
# Extract top cost accounts from trends/drill data
|
|
1259
|
+
# For now, we'll need the user to specify
|
|
1260
|
+
click.echo("⚠️ No account specified. Use --account to inventory resources.")
|
|
1261
|
+
|
|
1262
|
+
# For each account, do inventory and CloudTrail via backend API
|
|
1263
|
+
for acc_id in accounts_to_investigate:
|
|
1264
|
+
click.echo(f"\n Investigating account {acc_id}...")
|
|
1265
|
+
|
|
1266
|
+
# Get credentials (SSO or static)
|
|
1267
|
+
account_creds = get_credentials_dict(config)
|
|
1268
|
+
if not account_creds:
|
|
1269
|
+
click.echo(f" ⚠️ No credentials available for account")
|
|
1270
|
+
continue
|
|
1271
|
+
|
|
1272
|
+
# Inventory resources via backend API only
|
|
1273
|
+
if not is_api_configured():
|
|
1274
|
+
click.echo(f" ✗ API not configured. Set COST_API_SECRET environment variable.")
|
|
1275
|
+
continue
|
|
1276
|
+
|
|
1277
|
+
try:
|
|
1278
|
+
regions = ['us-west-2', 'us-east-1', 'eu-west-1']
|
|
1279
|
+
for region in regions:
|
|
1280
|
+
try:
|
|
1281
|
+
inv = call_lambda_api(
|
|
1282
|
+
'forensics',
|
|
1283
|
+
account_creds,
|
|
1284
|
+
[], # accounts not needed for forensics
|
|
1285
|
+
operation='inventory',
|
|
1286
|
+
account_id=acc_id,
|
|
1287
|
+
region=region
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
if not inv.get('error'):
|
|
1291
|
+
inventories.append(inv)
|
|
1292
|
+
click.echo(f" ✓ Inventory complete for {region}")
|
|
1293
|
+
click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
|
|
1294
|
+
click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
|
|
1295
|
+
click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
|
|
1296
|
+
break
|
|
1297
|
+
except Exception as e:
|
|
1298
|
+
continue
|
|
1299
|
+
except Exception as e:
|
|
1300
|
+
click.echo(f" ✗ Inventory error: {str(e)}")
|
|
1301
|
+
|
|
1302
|
+
# CloudTrail analysis via backend API only
|
|
1303
|
+
if not no_cloudtrail:
|
|
1304
|
+
if not is_api_configured():
|
|
1305
|
+
click.echo(f" ✗ CloudTrail skipped: API not configured")
|
|
1306
|
+
else:
|
|
1307
|
+
try:
|
|
1308
|
+
start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
|
|
1309
|
+
end_date = datetime.now().isoformat() + 'Z'
|
|
1310
|
+
|
|
1311
|
+
ct_analysis = call_lambda_api(
|
|
1312
|
+
'forensics',
|
|
1313
|
+
account_creds,
|
|
1314
|
+
[],
|
|
1315
|
+
operation='cloudtrail',
|
|
1316
|
+
account_id=acc_id,
|
|
1317
|
+
start_date=start_date,
|
|
1318
|
+
end_date=end_date,
|
|
1319
|
+
region='us-west-2'
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
cloudtrail_analyses.append(ct_analysis)
|
|
1323
|
+
|
|
1324
|
+
if ct_analysis.get('error'):
|
|
1325
|
+
click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
|
|
1326
|
+
else:
|
|
1327
|
+
click.echo(f" ✓ CloudTrail analysis complete")
|
|
1328
|
+
click.echo(f" - {len(ct_analysis['event_summary'])} event types")
|
|
1329
|
+
click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
|
|
1330
|
+
except Exception as e:
|
|
1331
|
+
click.echo(f" ✗ CloudTrail error: {str(e)}")
|
|
1332
|
+
|
|
1333
|
+
# Generate report
|
|
1334
|
+
click.echo(f"\nGenerating report...")
|
|
1335
|
+
report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
|
|
1336
|
+
|
|
1337
|
+
# Write to file
|
|
1338
|
+
with open(output, 'w') as f:
|
|
1339
|
+
f.write(report)
|
|
1340
|
+
|
|
1341
|
+
click.echo(f"\n✓ Investigation complete!")
|
|
1342
|
+
click.echo(f"✓ Report saved to: {output}")
|
|
1343
|
+
click.echo("")
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def find_account_profile(account_id):
|
|
1347
|
+
"""
|
|
1348
|
+
Find the SSO profile name for a given account ID
|
|
1349
|
+
Returns profile name or None
|
|
1350
|
+
"""
|
|
1351
|
+
import subprocess
|
|
1352
|
+
|
|
1353
|
+
try:
|
|
1354
|
+
# Get list of profiles
|
|
1355
|
+
result = subprocess.run(
|
|
1356
|
+
['aws', 'configure', 'list-profiles'],
|
|
1357
|
+
capture_output=True,
|
|
1358
|
+
text=True
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
profiles = result.stdout.strip().split('\n')
|
|
1362
|
+
|
|
1363
|
+
# Check each profile
|
|
1364
|
+
for profile in profiles:
|
|
1365
|
+
try:
|
|
1366
|
+
result = subprocess.run(
|
|
1367
|
+
['aws', 'sts', 'get-caller-identity', '--profile', profile],
|
|
1368
|
+
capture_output=True,
|
|
1369
|
+
text=True,
|
|
1370
|
+
timeout=5
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
if account_id in result.stdout:
|
|
1374
|
+
return profile
|
|
1375
|
+
except:
|
|
1376
|
+
continue
|
|
1377
|
+
|
|
1378
|
+
return None
|
|
1379
|
+
except:
|
|
1380
|
+
return None
|
|
1381
|
+
|
|
1382
|
+
|
|
1001
1383
|
if __name__ == '__main__':
|
|
1002
1384
|
cli()
|