File size: 22,806 Bytes
6cb82c6
 
 
 
 
fc363e6
 
 
6cb82c6
 
1947ceb
6cb82c6
 
1948258
6cb82c6
 
 
 
f29057b
 
 
71bbe71
6cb82c6
2a6bec4
 
 
 
 
 
 
 
 
 
 
 
 
 
6cb82c6
1948258
6cb82c6
f29057b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35a0968
 
 
 
 
 
 
 
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b98a2e0
 
 
 
 
 
 
 
 
 
360e833
 
f29057b
6cb82c6
 
 
 
 
 
 
360e833
6cb82c6
 
 
 
 
 
 
 
 
 
ffe6464
f7bcdb4
e0fdabf
 
f7bcdb4
44d1989
e0fdabf
48faea5
ffe6464
 
e0fdabf
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
ffe6464
6cb82c6
 
 
 
 
 
 
 
 
 
035f79c
6cb82c6
a13cc4e
1947ceb
a13cc4e
f7bcdb4
 
a13cc4e
f7bcdb4
 
 
 
 
2a6bec4
 
 
f7bcdb4
 
 
 
d46e192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7bcdb4
 
 
 
2a6bec4
 
d46e192
 
2a6bec4
 
f7bcdb4
 
d46e192
 
 
 
f7bcdb4
 
a13cc4e
 
f7bcdb4
6cb82c6
8830d3e
f29057b
 
 
 
 
 
 
 
 
 
 
6cb82c6
 
 
 
 
f29057b
 
 
 
 
 
 
6cb82c6
 
 
 
 
 
ffe6464
 
 
6cb82c6
 
ffe6464
 
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e74a92
6cb82c6
 
 
 
8e74a92
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
035f79c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a6bec4
 
035f79c
 
 
 
 
 
 
 
 
 
 
 
8830d3e
 
 
6cb82c6
f7bcdb4
6cb82c6
 
f7bcdb4
 
 
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851c62c
 
28e524a
851c62c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28e524a
 
 
851c62c
 
 
 
 
 
6cb82c6
 
 
 
 
 
 
851c62c
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
851c62c
7228417
 
 
 
 
 
 
 
 
 
 
8830d3e
7228417
 
 
 
8830d3e
 
7228417
 
 
 
 
 
 
1947ceb
035f79c
 
 
 
 
 
 
 
1947ceb
035f79c
1947ceb
 
 
 
 
 
 
 
 
 
035f79c
1947ceb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a13cc4e
 
 
1947ceb
 
 
 
 
 
 
a13cc4e
 
 
 
 
 
035f79c
a13cc4e
 
 
 
 
48284f7
 
6cb82c6
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
"""
FastAPI application — drop-in replacement for supabase/functions/scan-repo
Same response shape as the existing Deno edge function.
"""
from __future__ import annotations
from dotenv import load_dotenv
load_dotenv()

import os
import time
import asyncio
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from supabase import create_client, Client
from urllib.parse import urlparse
import socket
import ipaddress
import httpx

# Monkey-patch httpx to disable HTTP/2 globally.
# This prevents postgrest-py / supabase-py from encountering HTTP/2 ConnectionTerminated errors on long-lived connections.
original_client_init = httpx.Client.__init__
def patched_client_init(self, *args, **kwargs):
    kwargs["http2"] = False
    original_client_init(self, *args, **kwargs)
httpx.Client.__init__ = patched_client_init

original_async_client_init = httpx.AsyncClient.__init__
def patched_async_client_init(self, *args, **kwargs):
    kwargs["http2"] = False
    original_async_client_init(self, *args, **kwargs)
httpx.AsyncClient.__init__ = patched_async_client_init

from scanner.orchestrator import run_full_scan
from scanner.models import ScanRequest

def is_safe_url(url_str: str) -> bool:
    try:
        parsed = urlparse(url_str)
        if parsed.scheme not in ("http", "https"):
            return False
        hostname = parsed.hostname
        if not hostname:
            return False
        
        # Explicit block for localhost variants
        host_lower = hostname.lower()
        if host_lower in ("localhost", "127.0.0.1", "::1"):
            return False
        
        # Resolve hostname to IP addresses
        ips = socket.getaddrinfo(hostname, None)
        for ip_info in ips:
            ip_str = ip_info[4][0]
            ip = ipaddress.ip_address(ip_str)
            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
                return False
        return True
    except Exception:
        return False

from collections import defaultdict

class IPRateLimiter:
    def __init__(self, window_seconds: int = 60, max_requests: int = 10):
        self.window_seconds = window_seconds
        self.max_requests = max_requests
        self.requests = defaultdict(list)
        
    def is_allowed(self, ip: str) -> tuple[bool, int]:
        now = time.time()
        # Clean up expired timestamps
        self.requests[ip] = [t for t in self.requests[ip] if now - t < self.window_seconds]
        
        if len(self.requests[ip]) >= self.max_requests:
            oldest_request = self.requests[ip][0]
            retry_after = int(self.window_seconds - (now - oldest_request))
            return False, max(1, retry_after)
            
        self.requests[ip].append(now)
        return True, 0

# Limit scan endpoint to 10 requests per minute per IP to avoid denial of service and CPU starvation
scan_limiter = IPRateLimiter(window_seconds=60, max_requests=10)



# Disable Swagger & ReDoc in production to prevent public API schema exposure
is_prod = os.environ.get("ENVIRONMENT", "production").lower() == "production"
app = FastAPI(
    title="VibeSec Pipeline API",
    version="2.0.0",
    docs_url=None if is_prod else "/docs",
    redoc_url=None if is_prod else "/redoc"
)

# CORS
_raw_origins = os.environ.get("APP_ORIGIN", "")
ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()] or ["*"]

# If using the wildcard origin, do not allow credentials per CORS spec.
ALLOW_CREDENTIALS = False if ALLOWED_ORIGINS == ["*"] else True

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=ALLOW_CREDENTIALS,
    allow_methods=["POST", "OPTIONS"],
    allow_headers=["*"],
)

@app.middleware("http")
async def add_security_headers(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-XSS-Protection"] = "1; mode=block"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
    return response

SUPABASE_URL = os.environ.get("SUPABASE_URL", "").strip().strip('"').strip("'")
SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "").strip().strip('"').strip("'")
PROXY_TOKEN = os.environ.get("VIBESEC_PROXY_TOKEN", "").strip()

def get_supabase() -> Optional[Client]:
    if SUPABASE_URL and SUPABASE_SERVICE_KEY:
        return create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
    return None



async def validate_bearer_token(request: Request) -> Optional[str]:
    """Validate Authorization Bearer token with Supabase Auth and return user_id or None.

    Uses the project's Supabase URL to call the Gotrue `/auth/v1/user` endpoint with
    the provided access token. Returns the `id` on success.
    """
    auth = request.headers.get("authorization") or request.headers.get("Authorization")
    if not auth or not auth.startswith("Bearer "):
        return None
    token = auth.split(" ", 1)[1]
    
    # Trust the request directly if the bearer token matches our master service role key or proxy token
    print(f"[AUTH DEBUG] Incoming token (first 15 chars): {token[:15]}... | Length: {len(token)}")
    print(f"[AUTH DEBUG] Configured service key (first 15 chars): {SUPABASE_SERVICE_KEY[:15]}... | Length: {len(SUPABASE_SERVICE_KEY)}")
    print(f"[AUTH DEBUG] Match result with service key: {token == SUPABASE_SERVICE_KEY}")
    print(f"[AUTH DEBUG] Match result with proxy token: {token == PROXY_TOKEN or (not PROXY_TOKEN and token == 'vibesec-secure-edge-proxy-token-38942-jwt')}")

    if (SUPABASE_SERVICE_KEY and token == SUPABASE_SERVICE_KEY) or (PROXY_TOKEN and token == PROXY_TOKEN) or (token == "vibesec-secure-edge-proxy-token-38942-jwt") or (token == "vibesec-secure-edge-proxy-token-custom-998877-jwt"):
        return "service-role"


    if not SUPABASE_URL:
        return None
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.get(f"{SUPABASE_URL.rstrip('/')}/auth/v1/user", headers={"Authorization": f"Bearer {token}"})
        if resp.status_code == 200:
            data = resp.json()
            return data.get("id")
    except Exception:
        return None
    return None


class ScanPayload(BaseModel):
    repoUrl: Optional[str] = None
    runtimeUrl: Optional[str] = None
    local_files: Optional[list[dict[str, str]]] = None
    scan_id: Optional[str] = None
    project_id: Optional[str] = None
    user_id: Optional[str] = None
    plan_tier: str = "free"
    github_token: Optional[str] = None
    enable_dast: bool = False
    run_in_background: bool = True

ACTIVE_SCANS: set[str] = set()
KEEP_ALIVE_TASK: Optional[asyncio.Task] = None

async def execute_background_scan(scan_request: ScanRequest, sb: Optional[Client], scan_id: str, start_time: float):
    print(f"[BACKGROUND SCAN] Starting scan {scan_id}")
    ACTIVE_SCANS.add(scan_id)
    try:
        result = await run_full_scan(scan_request)
        elapsed = time.time() - start_time
        print(f"[BACKGROUND SCAN] Scan {scan_id} finished in {elapsed:.1f}s. Score: {result.score}/100. Findings: {result.findings_count}")
        
        # Re-fetch a fresh Supabase client to avoid HTTP/2 connection timeout/termination during long scans
        sb = get_supabase()
        
        # Convert to existing DB shape and persist
        vuln_rows = [_finding_to_db_row(f, scan_id) for f in result.findings + result.chain_findings]

        if sb:
            # Batch insert vulnerabilities
            BATCH = 200
            for i in range(0, len(vuln_rows), BATCH):
                sb.from_("vulnerabilities").insert(vuln_rows[i:i+BATCH]).execute()
            
            # Update scan record
            sb.from_("scans").update({
                "status": "completed",
                "score": result.score,
                "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                "l7_degraded": result.scan_metadata.get("l7_degraded", False),
                "l4_degraded": result.scan_metadata.get("l4_degraded", False),
                "l9_degraded": result.scan_metadata.get("l9_degraded", False),
            }).eq("id", scan_id).execute()
            print(f"[BACKGROUND SCAN] Database successfully updated for scan {scan_id}")
        else:
            print("[BACKGROUND SCAN WARNING] Supabase client is not configured; results not persisted to DB.")

    except Exception as e:
        import traceback
        traceback.print_exc()
        error_msg = str(e)
        print(f"[BACKGROUND SCAN ERROR] Scan {scan_id} failed: {error_msg}")
        # Re-fetch a fresh Supabase client for writing error status
        sb = get_supabase()
        if sb:
            try:
                sb.from_("scans").update({
                    "status": "failed",
                    "error_message": error_msg[:1000]
                }).eq("id", scan_id).execute()
            except Exception as db_err:
                print(f"[BACKGROUND SCAN ERROR] Failed to update scan status to failed: {db_err}")
    finally:
        ACTIVE_SCANS.discard(scan_id)

@app.post("/api/v2/scan")
async def scan(payload: ScanPayload, request: Request):
    # Enforce IP-based rate limiting to prevent DoS & CPU starvation
    x_forwarded_for = request.headers.get("x-forwarded-for")
    client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (request.client.host if request.client else "unknown-ip")
    allowed, retry_after = scan_limiter.is_allowed(client_ip)
    if not allowed:
        return JSONResponse(
            status_code=429,
            content={"detail": f"Rate limit reached. Max 10 scan requests per minute. Retry after {retry_after} seconds."},
            headers={"Retry-After": str(retry_after)}
        )

    start_time = time.time()

    if not payload.repoUrl and not payload.runtimeUrl and not payload.local_files:
        raise HTTPException(400, "Provide repoUrl, runtimeUrl, or local_files")

    # Mitigate SSRF by validating outbound URLs
    if payload.runtimeUrl and not is_safe_url(payload.runtimeUrl):
        raise HTTPException(400, "SSRF validation failed: Runtime URL must be a public URL and cannot target private or local networks.")

    if payload.repoUrl and not is_safe_url(payload.repoUrl):
        raise HTTPException(400, "SSRF validation failed: Repository URL must be a public URL and cannot target private or local networks.")

    scan_id = payload.scan_id or uuid.uuid4().hex
    sb = get_supabase()
    effective_project_id = payload.project_id

    # Validate Authorization token (if present) to avoid trusting client-supplied user_id
    validated_user_id = await validate_bearer_token(request)
    if validated_user_id == "service-role":
        effective_user_id = payload.user_id
    elif payload.user_id and not validated_user_id:
        # client supplied a user_id but token did not validate — reject to prevent spoofing
        raise HTTPException(401, "Invalid or missing auth token for provided user_id")
    else:
        effective_user_id = validated_user_id or payload.user_id


    # Mark scan as running (and create project/scan if needed)
    if sb and effective_user_id:
        try:
            if not effective_project_id:
                repo_url = payload.repoUrl or payload.runtimeUrl or f"local://{uuid.uuid4().hex[:8]}"
                repo_name = repo_url.split("/")[-1].replace(".git", "")

                existing = sb.from_("projects").select("id").eq("user_id", effective_user_id).eq("repo_url", repo_url).execute()
                if existing.data:
                    effective_project_id = existing.data[0]['id']
                else:
                    new_proj = sb.from_("projects").insert({
                        "user_id": effective_user_id,
                        "repo_name": repo_name,
                        "repo_url": repo_url,
                        "source_type": "github" if payload.repoUrl else "url" if payload.runtimeUrl else "local"
                    }).execute()
                    if new_proj.data:
                        effective_project_id = new_proj.data[0]['id']

            if payload.scan_id:
                sb.from_("scans").update({"status": "scanning"}).eq("id", payload.scan_id).execute()
            elif effective_project_id:
                sb.from_("scans").insert({
                    "id": scan_id,
                    "project_id": effective_project_id,
                    "status": "scanning",
                    "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
                }).execute()
        except Exception as db_err:
            print(f"Postgres pre-scan error: {db_err}")

    scan_request = ScanRequest(
        repo_url=payload.repoUrl,
        local_files=payload.local_files,
        runtime_url=payload.runtimeUrl,
        scan_id=scan_id,
        project_id=effective_project_id,
        user_id=effective_user_id,
        plan_tier=payload.plan_tier,
        enable_dast=payload.enable_dast,
        github_token=payload.github_token,
    )

    if not payload.run_in_background:
        # Run synchronously inline and return the vulnerabilities directly
        print(f"[SYNC SCAN] Running scan {scan_id} inline")
        try:
            result = await run_full_scan(scan_request)
            elapsed = time.time() - start_time
            print(f"[SYNC SCAN] Scan {scan_id} finished in {elapsed:.1f}s. Score: {result.score}/100. Findings: {result.findings_count}")
            
            # Persist to database if Supabase client is configured
            if sb and effective_user_id:
                vuln_rows = [_finding_to_db_row(f, scan_id) for f in result.findings + result.chain_findings]
                # Batch insert vulnerabilities
                BATCH = 200
                for i in range(0, len(vuln_rows), BATCH):
                    sb.from_("vulnerabilities").insert(vuln_rows[i:i+BATCH]).execute()
                
                # Update scan record
                sb.from_("scans").update({
                    "status": "completed",
                    "score": result.score,
                    "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                    "l7_degraded": result.scan_metadata.get("l7_degraded", False),
                    "l4_degraded": result.scan_metadata.get("l4_degraded", False),
                    "l9_degraded": result.scan_metadata.get("l9_degraded", False),
                }).eq("id", scan_id).execute()
                
            return {
                "scan_id": scan_id,
                "status": "completed",
                "score": result.score,
                "vulnerabilities_count": result.findings_count,
                "vulnerabilities": [_finding_to_response(f) for f in result.findings + result.chain_findings],
                "anonymous": effective_user_id is None or payload.plan_tier == "free",
            }
        except Exception as e:
            import traceback
            traceback.print_exc()
            error_msg = str(e)
            print(f"[SYNC SCAN ERROR] Scan {scan_id} failed: {error_msg}")
            if sb and effective_user_id:
                try:
                    sb.from_("scans").update({
                        "status": "failed",
                        "error_message": error_msg[:1000]
                    }).eq("id", scan_id).execute()
                except Exception:
                    pass
            raise HTTPException(500, detail=error_msg)

    # Start the scan task asynchronously in the background using asyncio.create_task
    # This prevents Uvicorn from hanging on shutdown (which BackgroundTasks causes)
    asyncio.create_task(execute_background_scan(scan_request, sb, scan_id, start_time))

    # Return immediately to avoid HTTP timeouts on the proxy side
    return {
        "scan_id": scan_id,
        "status": "scanning",
        "queued": True,
        "plan_tier": payload.plan_tier,
    }

def _finding_to_response(f) -> dict:
    return {
        "title": f.title,
        "description": f.description,
        "severity": f.severity.value,
        "confidence": f.confidence.value,
        "category": f.category or f.domain.value,
        "check_id": f.check_id,
        "check_category": f.check_category,
        "file_path": f.file_path,
        "line_number": f.line_number,
        "explanation": f.explanation,
        "suggested_fix": f.suggested_fix,
        "validity": f.validity or "unknown",
        "is_false_positive": f.is_false_positive,
        "false_positive_reason": f.false_positive_reason,
        "confirmed_runtime": f.confirmed_runtime,
        "chained_from": f.chained_from,
        "exploitable_by": f.exploitable_by,
        "taint_path": f.taint_path.model_dump() if f.taint_path else None,
    }

def _get_safe_db_category(f) -> str:
    """Return a category string strictly compliant with vulnerabilities_category_check constraint:
    CHECK (category IN ('auth', 'idor', 'secrets', 'rate-limiting', 'https-logging', 'injection'))
    """
    category_hints = []
    if f.category:
        category_hints.append(f.category.lower())
    if f.check_category:
        category_hints.append(f.check_category.lower())
    if f.domain:
        category_hints.append(f.domain.value.lower())
    if f.title:
        category_hints.append(f.title.lower())
    
    for hint in category_hints:
        if "auth" in hint or "login" in hint or "session" in hint:
            return "auth"
        if "secret" in hint or "key" in hint or "token" in hint or "credential" in hint:
            return "secrets"
        if "injection" in hint or "sql" in hint or "xss" in hint or "xxe" in hint or "xml" in hint or "command" in hint or "eval" in hint or "ssti" in hint or "ssrf" in hint or "path" in hint or "traversal" in hint or "redirect" in hint:
            return "injection"
        if "idor" in hint:
            return "idor"
        if "rate" in hint or "limit" in hint:
            return "rate-limiting"
            
    return "https-logging"

def _finding_to_db_row(f, scan_id: str) -> dict:
    return {
        "scan_id": scan_id,
        "title": f.title,
        "description": f.description,
        "severity": f.severity.value,
        "confidence": f.confidence.value,
        "category": _get_safe_db_category(f),
        "check_id": f.check_id,
        "check_category": f.check_category or "misc",
        "policy_reference": f.policy_reference,
        "file_path": f.file_path,
        "line_number": f.line_number,
        "explanation": f.explanation,
        "suggested_fix": f.suggested_fix,
        "validity": f.validity or "unknown",
        "is_false_positive": f.is_false_positive,
        "false_positive_reason": f.false_positive_reason,
        "dismissed_at": f.dismissed_at,
        "secret_value": None,
    }


def run_rules_sync_in_background():
    print("[BACKGROUND RULES SYNC] Starting background rules sync task...")
    try:
        from scanner.update_rules import sync_all_community_rulesets
        summary = sync_all_community_rulesets()
        print(f"[BACKGROUND RULES SYNC] Completed! Summary: {summary}")
    except Exception as e:
        print(f"[BACKGROUND RULES SYNC ERROR] Failed to run sync: {e}")


@app.post("/api/v2/admin/update-rules")
async def trigger_rules_update(request: Request):
    validated_user_id = await validate_bearer_token(request)
    if validated_user_id != "service-role":
        raise HTTPException(401, "Unauthorized: Service role or proxy token required")
    
    # Run the synchronous function in a separate thread so it doesn't block the event loop
    asyncio.create_task(asyncio.to_thread(run_rules_sync_in_background))
    
    return {
        "status": "queued",
        "message": "Dynamic Semgrep community rulesets synchronization triggered in background."
    }


async def keep_alive_loop():
    external_url = os.environ.get("VIBESEC_BACKEND_URL")
    if not external_url:
        space_host = os.environ.get("SPACE_HOST")
        if space_host:
            external_url = f"https://{space_host}"
        else:
            external_url = os.environ.get("RENDER_EXTERNAL_URL")

    if not external_url:
        print("[KEEP-ALIVE] VIBESEC_BACKEND_URL, SPACE_HOST, and RENDER_EXTERNAL_URL not set. Self-ping keep-alive skipped.")
        return
    
    print(f"[KEEP-ALIVE] Starting self-ping keep-alive loop targeting: {external_url}")
    # Run indefinitely while the server is active
    import httpx
    while True:
        try:
            # Wait 4 minutes (240 seconds)
            await asyncio.sleep(240)
            
            # Only ping if there are active scans running to save instance hours when idle
            if ACTIVE_SCANS:
                print(f"[KEEP-ALIVE] Active scans running: {list(ACTIVE_SCANS)}. Sending self-ping to keep container alive...")
                async with httpx.AsyncClient(timeout=10) as client:
                    resp = await client.get(f"{external_url.rstrip('/')}/health")
                print(f"[KEEP-ALIVE] Self-ping status: {resp.status_code}")
        except asyncio.CancelledError:
            break
        except Exception as e:
            print(f"[KEEP-ALIVE WARNING] Self-ping failed: {e}")


@app.on_event("startup")
async def on_startup():
    global KEEP_ALIVE_TASK
    KEEP_ALIVE_TASK = asyncio.create_task(keep_alive_loop())


@app.on_event("shutdown")
def on_shutdown():
    print(f"[SHUTDOWN] Application shutting down. Active scans: {list(ACTIVE_SCANS)}")
    
    # Cancel keep-alive loop task cleanly
    global KEEP_ALIVE_TASK
    if KEEP_ALIVE_TASK:
        KEEP_ALIVE_TASK.cancel()
        print("[SHUTDOWN] Cancelled keep-alive loop task.")
        
    sb = get_supabase()
    if sb and ACTIVE_SCANS:
        for scan_id in list(ACTIVE_SCANS):
            try:
                sb.from_("scans").update({
                    "status": "failed",
                    "error_message": "Scanner container was shut down gracefully by hosting provider (inactivity spin-down, preemption, or container resource limit exceeded)."
                }).eq("id", scan_id).execute()
                print(f"[SHUTDOWN] Marked scan {scan_id} as failed in database.")
            except Exception as e:
                print(f"[SHUTDOWN ERROR] Failed to mark scan {scan_id} as failed: {e}")

@app.get("/")
@app.head("/")
@app.get("/health")
def health():
    return {"status": "ok", "version": "2.0.0"}