open-aws-scanner 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """Open AWS Scanner - Find unused AWS resources costing you money."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,237 @@
1
+ """
2
+ Open AWS Scanner API
3
+ Simple, no-auth AWS cost waste scanner. Configure via config.env and go.
4
+ """
5
+ from fastapi import FastAPI, HTTPException
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from apscheduler.schedulers.background import BackgroundScheduler
8
+ from .scanner import run_scan
9
+ from .database import SessionLocal, ScanResult, ScanRun, init_db
10
+ from slowapi import Limiter, _rate_limit_exceeded_handler
11
+ from slowapi.util import get_remote_address
12
+ from slowapi.errors import RateLimitExceeded
13
+ from dotenv import load_dotenv
14
+ from datetime import datetime, timezone
15
+ import os
16
+
17
+ # Load config.env from current working directory if it exists
18
+ _config_path = os.path.join(os.getcwd(), "config.env")
19
+ if os.path.exists(_config_path):
20
+ load_dotenv(_config_path)
21
+
22
+ # --- Config ---
23
+ SCAN_INTERVAL_HOURS = int(os.getenv("SCAN_INTERVAL_HOURS", "6"))
24
+ HOST = os.getenv("HOST", "0.0.0.0")
25
+ PORT = int(os.getenv("PORT", "8000"))
26
+
27
+ limiter = Limiter(key_func=get_remote_address)
28
+ app = FastAPI(
29
+ title="Open AWS Scanner",
30
+ version="1.0.0",
31
+ description="Simple AWS waste detection — no admin, no auth, just results.",
32
+ )
33
+ app.state.limiter = limiter
34
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
35
+
36
+ app.add_middleware(
37
+ CORSMiddleware,
38
+ allow_origins=["*"],
39
+ allow_credentials=True,
40
+ allow_methods=["*"],
41
+ allow_headers=["*"],
42
+ )
43
+
44
+ scheduler = BackgroundScheduler()
45
+
46
+
47
+ # --- Startup ---
48
+
49
+ @app.on_event("startup")
50
+ def startup_event():
51
+ init_db()
52
+ # Schedule recurring scans
53
+ scheduler.add_job(
54
+ func=run_scan,
55
+ trigger="interval",
56
+ hours=SCAN_INTERVAL_HOURS,
57
+ id="scheduled_scan",
58
+ replace_existing=True,
59
+ )
60
+ scheduler.start()
61
+ print(f"[OPEN-SCANNER] Running. Scans every {SCAN_INTERVAL_HOURS}h.")
62
+
63
+
64
+ # --- Health ---
65
+
66
+ @app.get("/health")
67
+ def health_check():
68
+ return {"status": "online", "scan_interval_hours": SCAN_INTERVAL_HOURS}
69
+
70
+
71
+ # --- Dashboard ---
72
+
73
+ @app.get("/", include_in_schema=False)
74
+ def dashboard():
75
+ db = SessionLocal()
76
+ results = db.query(ScanResult).all()
77
+ runs = db.query(ScanRun).order_by(ScanRun.started_at.desc()).limit(1).all()
78
+ db.close()
79
+
80
+ total = len(results)
81
+ total_savings = sum(r.estimated_monthly_savings or 0 for r in results)
82
+ open_count = sum(1 for r in results if (r.status or "open") == "open")
83
+ fixed_count = sum(1 for r in results if r.status == "fixed")
84
+ last_scan = runs[0].completed_at.isoformat() if runs and runs[0].completed_at else "Never"
85
+
86
+ return {
87
+ "app": "Open AWS Scanner",
88
+ "status": "online",
89
+ "total_findings": total,
90
+ "open": open_count,
91
+ "fixed": fixed_count,
92
+ "potential_savings_per_month": f"${total_savings:.2f}",
93
+ "last_scan": last_scan,
94
+ "endpoints": {
95
+ "POST /scan": "Trigger a scan",
96
+ "GET /findings": "List findings (?status=open&resource_type=EC2_Instance)",
97
+ "PUT /findings/{id}/status?status=fixed": "Update finding status",
98
+ "GET /summary": "Savings breakdown by status",
99
+ "GET /scans": "Scan run history",
100
+ "GET /docs": "Swagger UI",
101
+ },
102
+ }
103
+
104
+
105
+ # --- Trigger Scan ---
106
+
107
+ @app.post("/scan")
108
+ def trigger_scan():
109
+ """Trigger a scan now."""
110
+ from threading import Thread
111
+ Thread(target=run_scan).start()
112
+ return {"status": "scan_triggered"}
113
+
114
+
115
+ # --- Get Findings ---
116
+
117
+ @app.get("/findings")
118
+ def get_findings(status: str = None, resource_type: str = None):
119
+ """Get all findings, optionally filtered by status or resource_type."""
120
+ db = SessionLocal()
121
+ query = db.query(ScanResult).order_by(ScanResult.scanned_at.desc())
122
+ if status:
123
+ query = query.filter(ScanResult.status == status)
124
+ if resource_type:
125
+ query = query.filter(ScanResult.resource_type == resource_type)
126
+ results = query.all()
127
+ db.close()
128
+
129
+ return [
130
+ {
131
+ "id": r.id,
132
+ "resource_id": r.resource_id,
133
+ "resource_name": r.resource_name,
134
+ "resource_type": r.resource_type,
135
+ "reason": r.reason,
136
+ "estimated_monthly_savings": r.estimated_monthly_savings,
137
+ "scanned_at": r.scanned_at.isoformat() if r.scanned_at else None,
138
+ "tags": r.tags,
139
+ "cpu_avg_percent": r.cpu_avg_percent,
140
+ "memory_avg_percent": r.memory_avg_percent,
141
+ "network_in_bytes": r.network_in_bytes,
142
+ "network_out_bytes": r.network_out_bytes,
143
+ "status": r.status or "open",
144
+ "status_changed_at": r.status_changed_at.isoformat() if r.status_changed_at else None,
145
+ "first_seen_at": r.first_seen_at.isoformat() if r.first_seen_at else None,
146
+ "region": r.region,
147
+ }
148
+ for r in results
149
+ ]
150
+
151
+
152
+ # --- Update Finding Status ---
153
+
154
+ @app.put("/findings/{finding_id}/status")
155
+ def update_finding_status(finding_id: int, status: str):
156
+ """Update finding status: open, fixed, dismissed, in_progress"""
157
+ if status not in ("open", "fixed", "dismissed", "in_progress"):
158
+ raise HTTPException(status_code=400, detail="Status must be: open, fixed, dismissed, in_progress")
159
+ db = SessionLocal()
160
+ finding = db.query(ScanResult).filter(ScanResult.id == finding_id).first()
161
+ if not finding:
162
+ db.close()
163
+ raise HTTPException(status_code=404, detail="Finding not found")
164
+ finding.status = status
165
+ finding.status_changed_at = datetime.now(timezone.utc)
166
+ db.commit()
167
+ db.close()
168
+ return {"id": finding_id, "status": status}
169
+
170
+
171
+ # --- Summary ---
172
+
173
+ @app.get("/summary")
174
+ def findings_summary():
175
+ """Summary of findings by status with savings totals."""
176
+ db = SessionLocal()
177
+ results = db.query(ScanResult).all()
178
+ db.close()
179
+
180
+ open_findings = [r for r in results if (r.status or "open") == "open"]
181
+ fixed_findings = [r for r in results if r.status == "fixed"]
182
+ dismissed_findings = [r for r in results if r.status == "dismissed"]
183
+ in_progress = [r for r in results if r.status == "in_progress"]
184
+
185
+ return {
186
+ "total_findings": len(results),
187
+ "total_potential_savings": sum(r.estimated_monthly_savings or 0 for r in results),
188
+ "open": {
189
+ "count": len(open_findings),
190
+ "savings": sum(r.estimated_monthly_savings or 0 for r in open_findings),
191
+ },
192
+ "fixed": {
193
+ "count": len(fixed_findings),
194
+ "savings_realized": sum(r.estimated_monthly_savings or 0 for r in fixed_findings),
195
+ },
196
+ "dismissed": {
197
+ "count": len(dismissed_findings),
198
+ "savings": sum(r.estimated_monthly_savings or 0 for r in dismissed_findings),
199
+ },
200
+ "in_progress": {
201
+ "count": len(in_progress),
202
+ "savings": sum(r.estimated_monthly_savings or 0 for r in in_progress),
203
+ },
204
+ }
205
+
206
+
207
+ # --- Scan History ---
208
+
209
+ @app.get("/scans")
210
+ def get_scan_history():
211
+ """Get recent scan runs."""
212
+ db = SessionLocal()
213
+ runs = db.query(ScanRun).order_by(ScanRun.started_at.desc()).limit(50).all()
214
+ db.close()
215
+ return [
216
+ {
217
+ "id": r.id,
218
+ "status": r.status,
219
+ "findings_count": r.findings_count,
220
+ "errors": r.errors,
221
+ "started_at": r.started_at.isoformat() if r.started_at else None,
222
+ "completed_at": r.completed_at.isoformat() if r.completed_at else None,
223
+ }
224
+ for r in runs
225
+ ]
226
+
227
+
228
+ # --- Run ---
229
+
230
+ def serve():
231
+ """Start the API server."""
232
+ import uvicorn
233
+ uvicorn.run("open_aws_scanner.api:app", host=HOST, port=PORT, reload=False)
234
+
235
+
236
+ if __name__ == "__main__":
237
+ serve()
@@ -0,0 +1,163 @@
1
+ """CLI entry point for open-aws-scanner."""
2
+ import argparse
3
+ import os
4
+ import sys
5
+ import json
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(
10
+ prog="open-aws-scanner",
11
+ description="Find unused AWS resources costing you money.",
12
+ )
13
+ subparsers = parser.add_subparsers(dest="command")
14
+
15
+ # serve - start the API server
16
+ serve_parser = subparsers.add_parser("serve", help="Start the API server")
17
+ serve_parser.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)")
18
+ serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
19
+ serve_parser.add_argument("--config", default="config.env", help="Path to config.env file")
20
+
21
+ # scan - run a one-shot scan (no server)
22
+ scan_parser = subparsers.add_parser("scan", help="Run a one-shot scan and print results")
23
+ scan_parser.add_argument("--regions", help="Comma-separated AWS regions (overrides config)")
24
+ scan_parser.add_argument("--role-arn", help="AWS role ARN to assume (overrides config)")
25
+ scan_parser.add_argument("--output", choices=["json", "table"], default="table", help="Output format")
26
+ scan_parser.add_argument("--config", default="config.env", help="Path to config.env file")
27
+
28
+ # init - create a config.env template
29
+ subparsers.add_parser("init", help="Create a config.env template in the current directory")
30
+
31
+ args = parser.parse_args()
32
+
33
+ if args.command is None:
34
+ parser.print_help()
35
+ sys.exit(0)
36
+
37
+ if args.command == "init":
38
+ _cmd_init()
39
+ elif args.command == "serve":
40
+ _cmd_serve(args)
41
+ elif args.command == "scan":
42
+ _cmd_scan(args)
43
+
44
+
45
+ def _cmd_init():
46
+ """Create a config.env template."""
47
+ config_path = os.path.join(os.getcwd(), "config.env")
48
+ if os.path.exists(config_path):
49
+ print(f"config.env already exists at {config_path}")
50
+ sys.exit(1)
51
+
52
+ template = """# Open AWS Scanner Configuration
53
+
54
+ # AWS Credentials (use IAM role ARN for cross-account scanning, or leave blank for local credentials)
55
+ AWS_ROLE_ARN=
56
+ AWS_EXTERNAL_ID=
57
+ AWS_REGIONS=us-east-1
58
+
59
+ # Optional: explicit credentials (prefer IAM roles or environment/profile instead)
60
+ # AWS_ACCESS_KEY_ID=
61
+ # AWS_SECRET_ACCESS_KEY=
62
+
63
+ # Scanner settings
64
+ SCAN_INTERVAL_HOURS=6
65
+ STAGE_MODE=false
66
+
67
+ # Server
68
+ HOST=0.0.0.0
69
+ PORT=8000
70
+ """
71
+ with open(config_path, "w") as f:
72
+ f.write(template)
73
+ print(f"Created {config_path}")
74
+ print("Edit it with your AWS configuration, then run: open-aws-scanner serve")
75
+
76
+
77
+ def _cmd_serve(args):
78
+ """Start the API server."""
79
+ from dotenv import load_dotenv
80
+
81
+ config_path = os.path.abspath(args.config)
82
+ if os.path.exists(config_path):
83
+ load_dotenv(config_path)
84
+
85
+ os.environ.setdefault("HOST", args.host)
86
+ os.environ.setdefault("PORT", str(args.port))
87
+
88
+ from .database import init_db
89
+ init_db()
90
+
91
+ import uvicorn
92
+ uvicorn.run(
93
+ "open_aws_scanner.api:app",
94
+ host=args.host,
95
+ port=args.port,
96
+ reload=False,
97
+ )
98
+
99
+
100
+ def _cmd_scan(args):
101
+ """Run a one-shot scan without starting the server."""
102
+ from dotenv import load_dotenv
103
+
104
+ config_path = os.path.abspath(args.config)
105
+ if os.path.exists(config_path):
106
+ load_dotenv(config_path)
107
+
108
+ # CLI overrides
109
+ if args.regions:
110
+ os.environ["AWS_REGIONS"] = args.regions
111
+ if args.role_arn:
112
+ os.environ["AWS_ROLE_ARN"] = args.role_arn
113
+
114
+ from .database import init_db
115
+ init_db()
116
+
117
+ # Redirect scanner logs to stderr so --output json works cleanly
118
+ from .scanner import run_scan
119
+ old_stdout = sys.stdout
120
+ sys.stdout = sys.stderr
121
+ findings = run_scan()
122
+ sys.stdout = old_stdout
123
+
124
+ if not findings:
125
+ print("\nNo waste found! Your AWS account looks clean.")
126
+ return
127
+
128
+ if args.output == "json":
129
+ print(json.dumps(findings, indent=2, default=str))
130
+ else:
131
+ _print_table(findings)
132
+
133
+
134
+ def _print_table(findings):
135
+ """Print findings as a formatted table."""
136
+ total_savings = sum(f.get("estimated_monthly_savings") or 0 for f in findings)
137
+
138
+ print(f"\n{'='*80}")
139
+ print(f" Open AWS Scanner — {len(findings)} issues found | ${total_savings:.2f}/mo potential savings")
140
+ print(f"{'='*80}\n")
141
+
142
+ # Group by resource type
143
+ by_type = {}
144
+ for f in findings:
145
+ rtype = f["resource_type"]
146
+ by_type.setdefault(rtype, []).append(f)
147
+
148
+ for rtype, items in sorted(by_type.items()):
149
+ type_savings = sum(i.get("estimated_monthly_savings") or 0 for i in items)
150
+ print(f" {rtype} ({len(items)} found, ${type_savings:.2f}/mo)")
151
+ for item in items:
152
+ savings = f" ${item['estimated_monthly_savings']:.2f}/mo" if item.get("estimated_monthly_savings") else ""
153
+ region = f" [{item['region']}]" if item.get("region") else ""
154
+ print(f" • {item['resource_name']}: {item['reason']}{savings}{region}")
155
+ print()
156
+
157
+ print(f"{'─'*80}")
158
+ print(f" Total potential savings: ${total_savings:.2f}/month")
159
+ print()
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
@@ -0,0 +1,46 @@
1
+ from sqlalchemy import create_engine, Column, Integer, String, Text, Float, DateTime
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ import datetime
5
+ import os
6
+
7
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./scanner.db")
8
+
9
+ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {})
10
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, expire_on_commit=False, bind=engine)
11
+ Base = declarative_base()
12
+
13
+
14
+ class ScanResult(Base):
15
+ __tablename__ = "scan_results"
16
+ id = Column(Integer, primary_key=True, index=True)
17
+ scan_run_id = Column(Integer)
18
+ resource_id = Column(String)
19
+ resource_name = Column(String)
20
+ resource_type = Column(String)
21
+ reason = Column(String)
22
+ estimated_monthly_savings = Column(Float)
23
+ scanned_at = Column(DateTime, default=datetime.datetime.utcnow)
24
+ tags = Column(Text, nullable=True)
25
+ cpu_avg_percent = Column(Float, nullable=True)
26
+ memory_avg_percent = Column(Float, nullable=True)
27
+ network_in_bytes = Column(Float, nullable=True)
28
+ network_out_bytes = Column(Float, nullable=True)
29
+ status = Column(String, default="open") # open, fixed, dismissed, in_progress
30
+ status_changed_at = Column(DateTime, nullable=True)
31
+ first_seen_at = Column(DateTime, nullable=True)
32
+ region = Column(String, nullable=True)
33
+
34
+
35
+ class ScanRun(Base):
36
+ __tablename__ = "scan_runs"
37
+ id = Column(Integer, primary_key=True, index=True)
38
+ status = Column(String, default="running")
39
+ findings_count = Column(Integer, default=0)
40
+ errors = Column(Text)
41
+ started_at = Column(DateTime, default=datetime.datetime.utcnow)
42
+ completed_at = Column(DateTime)
43
+
44
+
45
+ def init_db():
46
+ Base.metadata.create_all(bind=engine)