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.
- open_aws_scanner/__init__.py +3 -0
- open_aws_scanner/api.py +237 -0
- open_aws_scanner/cli.py +163 -0
- open_aws_scanner/database.py +46 -0
- open_aws_scanner/scanner.py +604 -0
- open_aws_scanner-0.1.0.dist-info/METADATA +609 -0
- open_aws_scanner-0.1.0.dist-info/RECORD +10 -0
- open_aws_scanner-0.1.0.dist-info/WHEEL +5 -0
- open_aws_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- open_aws_scanner-0.1.0.dist-info/top_level.txt +1 -0
open_aws_scanner/api.py
ADDED
|
@@ -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()
|
open_aws_scanner/cli.py
ADDED
|
@@ -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)
|