Spaces:
Runtime error
Runtime error
Upload 43 files
Browse files- .gitignore +10 -0
- Dockerfile +24 -0
- README.md +0 -13
- app.py +8 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/api/__init__.py +0 -0
- app/api/__pycache__/__init__.cpython-311.pyc +0 -0
- app/api/__pycache__/routes_quarantine.cpython-311.pyc +0 -0
- app/api/__pycache__/routes_scan.cpython-311.pyc +0 -0
- app/api/__pycache__/routes_stats.cpython-311.pyc +0 -0
- app/api/__pycache__/routes_whitelist.cpython-311.pyc +0 -0
- app/api/routes_quarantine.py +193 -0
- app/api/routes_scan.py +174 -0
- app/api/routes_stats.py +51 -0
- app/api/routes_whitelist.py +44 -0
- app/db/__init__.py +0 -0
- app/db/__pycache__/__init__.cpython-311.pyc +0 -0
- app/db/__pycache__/database.cpython-311.pyc +0 -0
- app/db/database.py +21 -0
- app/main.py +27 -0
- app/models/__init__.py +0 -0
- app/models/__pycache__/__init__.cpython-311.pyc +0 -0
- app/models/__pycache__/bert_model.cpython-311.pyc +0 -0
- app/models/__pycache__/cnn_model.cpython-311.pyc +0 -0
- app/models/__pycache__/db_models.cpython-311.pyc +0 -0
- app/models/bert_model.py +53 -0
- app/models/cnn_model.py +138 -0
- app/models/db_models.py +50 -0
- app/schemas/__init__.py +0 -0
- app/schemas/__pycache__/__init__.cpython-311.pyc +0 -0
- app/schemas/__pycache__/schemas.cpython-311.pyc +0 -0
- app/schemas/schemas.py +26 -0
- app/services/__init__.py +0 -0
- app/services/__pycache__/__init__.cpython-311.pyc +0 -0
- app/services/__pycache__/clustering_service.cpython-311.pyc +0 -0
- app/services/__pycache__/retrain_service.cpython-311.pyc +0 -0
- app/services/clustering_service.py +94 -0
- app/services/retrain_service.py +36 -0
- models_dir/cnn_model.pt +3 -0
- models_dir/vocab.json +1 -0
- requirements.txt +10 -0
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.env
|
| 6 |
+
.venv
|
| 7 |
+
venv/
|
| 8 |
+
ENV/
|
| 9 |
+
*.sqlite3
|
| 10 |
+
.pytest_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies required for psycopg2 / C extensions
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
gcc \
|
| 8 |
+
libpq-dev \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy source code and models directory
|
| 15 |
+
COPY . .
|
| 16 |
+
COPY models_dir /app/models_dir
|
| 17 |
+
|
| 18 |
+
# Set environment variables for Hugging Face Spaces
|
| 19 |
+
ENV MODELS_DIR=/app/models_dir
|
| 20 |
+
ENV PORT=7860
|
| 21 |
+
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
CHANGED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Arxen Backend
|
| 3 |
-
emoji: 🌍
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: blue
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.20.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
-
app_file: app.py
|
| 10 |
-
pinned: false
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
import os
|
| 3 |
+
from app.main import app
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
# Hugging Face Spaces expect services to run on port 7860
|
| 7 |
+
port = int(os.environ.get("PORT", 7860))
|
| 8 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (129 Bytes). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (1.74 kB). View file
|
|
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (133 Bytes). View file
|
|
|
app/api/__pycache__/routes_quarantine.cpython-311.pyc
ADDED
|
Binary file (11.4 kB). View file
|
|
|
app/api/__pycache__/routes_scan.cpython-311.pyc
ADDED
|
Binary file (6.82 kB). View file
|
|
|
app/api/__pycache__/routes_stats.cpython-311.pyc
ADDED
|
Binary file (3.91 kB). View file
|
|
|
app/api/__pycache__/routes_whitelist.cpython-311.pyc
ADDED
|
Binary file (3.94 kB). View file
|
|
|
app/api/routes_quarantine.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import func
|
| 4 |
+
from app.db.database import get_db
|
| 5 |
+
from app.models.db_models import Quarantine, ScanLog, WhitelistDomain
|
| 6 |
+
from app.schemas.schemas import QuarantineValidateRequest
|
| 7 |
+
from app.models.cnn_model import predict_cnn
|
| 8 |
+
from app.services.clustering_service import run_quarantine_clustering
|
| 9 |
+
from app.services.retrain_service import export_retraining_dataset
|
| 10 |
+
import datetime
|
| 11 |
+
import urllib.parse
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
@router.get("/")
|
| 16 |
+
def get_quarantine_cases(db: Session = Depends(get_db)):
|
| 17 |
+
cases = db.query(Quarantine, ScanLog).join(ScanLog, Quarantine.scan_log_id == ScanLog.id).filter(Quarantine.status == 'pending').all()
|
| 18 |
+
|
| 19 |
+
result = []
|
| 20 |
+
for q, s in cases:
|
| 21 |
+
result.append({
|
| 22 |
+
"quarantine_id": q.id,
|
| 23 |
+
"url": s.url,
|
| 24 |
+
"cnn_score": s.cnn_score,
|
| 25 |
+
"bert_score": s.bert_score,
|
| 26 |
+
"cluster_id": q.cluster_id,
|
| 27 |
+
"created_at": s.created_at
|
| 28 |
+
})
|
| 29 |
+
return result
|
| 30 |
+
|
| 31 |
+
@router.post("/run-clustering")
|
| 32 |
+
def trigger_clustering(db: Session = Depends(get_db)):
|
| 33 |
+
"""
|
| 34 |
+
Menjalankan algoritma clustering berbasis kemiripan leksikal n-gram
|
| 35 |
+
untuk mengelompokkan URL pending karantina ke dalam cluster_id.
|
| 36 |
+
"""
|
| 37 |
+
res = run_quarantine_clustering(db)
|
| 38 |
+
return res
|
| 39 |
+
|
| 40 |
+
@router.get("/clusters")
|
| 41 |
+
def get_quarantine_clusters(db: Session = Depends(get_db)):
|
| 42 |
+
"""
|
| 43 |
+
Mengambil semua kasus karantina pending yang sudah dikelompokkan per cluster_id.
|
| 44 |
+
"""
|
| 45 |
+
cases = db.query(Quarantine, ScanLog).join(ScanLog, Quarantine.scan_log_id == ScanLog.id).filter(Quarantine.status == 'pending').all()
|
| 46 |
+
|
| 47 |
+
clusters_map = {}
|
| 48 |
+
unclustered = []
|
| 49 |
+
|
| 50 |
+
for q, s in cases:
|
| 51 |
+
item = {
|
| 52 |
+
"quarantine_id": q.id,
|
| 53 |
+
"url": s.url,
|
| 54 |
+
"cnn_score": s.cnn_score,
|
| 55 |
+
"bert_score": s.bert_score,
|
| 56 |
+
"created_at": s.created_at
|
| 57 |
+
}
|
| 58 |
+
if q.cluster_id:
|
| 59 |
+
c_id = f"Cluster #{q.cluster_id}"
|
| 60 |
+
if c_id not in clusters_map:
|
| 61 |
+
clusters_map[c_id] = []
|
| 62 |
+
clusters_map[c_id].append(item)
|
| 63 |
+
else:
|
| 64 |
+
unclustered.append(item)
|
| 65 |
+
|
| 66 |
+
result = [
|
| 67 |
+
{"cluster_name": c_name, "count": len(items), "items": items}
|
| 68 |
+
for c_name, items in clusters_map.items()
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
if unclustered:
|
| 72 |
+
result.append({"cluster_name": "Belum Dikelompokkan", "count": len(unclustered), "items": unclustered})
|
| 73 |
+
|
| 74 |
+
return result
|
| 75 |
+
|
| 76 |
+
@router.post("/cluster/{cluster_id}/validate")
|
| 77 |
+
def validate_cluster(cluster_id: int, request: QuarantineValidateRequest, db: Session = Depends(get_db)):
|
| 78 |
+
"""
|
| 79 |
+
Validasi massal berbasis Cluster (Human-in-the-Loop 1-Klik).
|
| 80 |
+
Memvalidasi seluruh URL di dalam satu cluster_id sekaligus.
|
| 81 |
+
"""
|
| 82 |
+
q_cases = db.query(Quarantine).filter(Quarantine.cluster_id == cluster_id, Quarantine.status == 'pending').all()
|
| 83 |
+
|
| 84 |
+
if not q_cases:
|
| 85 |
+
raise HTTPException(status_code=404, detail=f"Tidak ada URL pending di Cluster #{cluster_id}")
|
| 86 |
+
|
| 87 |
+
validated_count = 0
|
| 88 |
+
for q in q_cases:
|
| 89 |
+
q.status = request.status
|
| 90 |
+
q.validated_by = request.validated_by
|
| 91 |
+
q.validated_at = datetime.datetime.utcnow()
|
| 92 |
+
|
| 93 |
+
s = db.query(ScanLog).filter(ScanLog.id == q.scan_log_id).first()
|
| 94 |
+
if s:
|
| 95 |
+
if request.status == 'validated_safe':
|
| 96 |
+
s.final_verdict = 'safe'
|
| 97 |
+
domain = urllib.parse.urlparse(s.url).hostname
|
| 98 |
+
if domain:
|
| 99 |
+
domain = domain.lower()
|
| 100 |
+
existing_wl = db.query(WhitelistDomain).filter(WhitelistDomain.domain == domain).first()
|
| 101 |
+
if not existing_wl:
|
| 102 |
+
new_wl = WhitelistDomain(domain=domain, is_strict=0)
|
| 103 |
+
db.add(new_wl)
|
| 104 |
+
elif request.status == 'validated_phishing':
|
| 105 |
+
s.final_verdict = 'phishing'
|
| 106 |
+
|
| 107 |
+
validated_count += 1
|
| 108 |
+
|
| 109 |
+
db.commit()
|
| 110 |
+
return {
|
| 111 |
+
"message": f"Berhasil memvalidasi {validated_count} URL di Cluster #{cluster_id} sebagai '{request.status}'.",
|
| 112 |
+
"validated_count": validated_count
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@router.get("/export-dataset")
|
| 116 |
+
def export_dataset_for_retraining(db: Session = Depends(get_db)):
|
| 117 |
+
"""
|
| 118 |
+
Mengekspor seluruh data karantina yang telah divalidasi HITL menjadi
|
| 119 |
+
dataset baru untuk siklus Batch Retraining di Colab / Cloud.
|
| 120 |
+
"""
|
| 121 |
+
data = export_retraining_dataset(db)
|
| 122 |
+
return data
|
| 123 |
+
|
| 124 |
+
@router.post("/report")
|
| 125 |
+
def report_url_to_quarantine(payload: dict, db: Session = Depends(get_db)):
|
| 126 |
+
"""
|
| 127 |
+
Dipanggil dari ekstensi Chrome saat pengguna mengklik 'Laporkan ke Karantina'.
|
| 128 |
+
Membuat ScanLog + entri Quarantine agar admin bisa meninjau.
|
| 129 |
+
"""
|
| 130 |
+
url = payload.get("url")
|
| 131 |
+
if not url:
|
| 132 |
+
raise HTTPException(status_code=400, detail="URL wajib diisi")
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
cnn_score = predict_cnn(url)
|
| 136 |
+
except Exception:
|
| 137 |
+
cnn_score = 0.0
|
| 138 |
+
|
| 139 |
+
existing = db.query(Quarantine).join(ScanLog).filter(
|
| 140 |
+
ScanLog.url == url,
|
| 141 |
+
Quarantine.status == 'pending'
|
| 142 |
+
).first()
|
| 143 |
+
if existing:
|
| 144 |
+
return {"message": "URL sudah ada di karantina", "quarantine_id": existing.id}
|
| 145 |
+
|
| 146 |
+
new_log = ScanLog(
|
| 147 |
+
url=url,
|
| 148 |
+
url_decoded=url,
|
| 149 |
+
cnn_score=cnn_score,
|
| 150 |
+
bert_score=0.0,
|
| 151 |
+
final_verdict="quarantine",
|
| 152 |
+
stage_triggered="cnn_only"
|
| 153 |
+
)
|
| 154 |
+
db.add(new_log)
|
| 155 |
+
db.commit()
|
| 156 |
+
db.refresh(new_log)
|
| 157 |
+
|
| 158 |
+
new_q = Quarantine(scan_log_id=new_log.id, status='pending')
|
| 159 |
+
db.add(new_q)
|
| 160 |
+
db.commit()
|
| 161 |
+
db.refresh(new_q)
|
| 162 |
+
|
| 163 |
+
# Otomatis jalankan clustering agar URL baru langsung mendapat cluster_id jika mirip dengan yang lain
|
| 164 |
+
run_quarantine_clustering(db)
|
| 165 |
+
|
| 166 |
+
return {"message": "URL berhasil dilaporkan ke karantina", "quarantine_id": new_q.id}
|
| 167 |
+
|
| 168 |
+
@router.post("/{id}/validate")
|
| 169 |
+
def validate_quarantine(id: int, request: QuarantineValidateRequest, db: Session = Depends(get_db)):
|
| 170 |
+
q = db.query(Quarantine).filter(Quarantine.id == id).first()
|
| 171 |
+
if not q:
|
| 172 |
+
raise HTTPException(status_code=404, detail="Quarantine case not found")
|
| 173 |
+
|
| 174 |
+
q.status = request.status
|
| 175 |
+
q.validated_by = request.validated_by
|
| 176 |
+
q.validated_at = datetime.datetime.utcnow()
|
| 177 |
+
|
| 178 |
+
s = db.query(ScanLog).filter(ScanLog.id == q.scan_log_id).first()
|
| 179 |
+
if s:
|
| 180 |
+
if request.status == 'validated_safe':
|
| 181 |
+
s.final_verdict = 'safe'
|
| 182 |
+
domain = urllib.parse.urlparse(s.url).hostname
|
| 183 |
+
if domain:
|
| 184 |
+
domain = domain.lower()
|
| 185 |
+
existing_wl = db.query(WhitelistDomain).filter(WhitelistDomain.domain == domain).first()
|
| 186 |
+
if not existing_wl:
|
| 187 |
+
new_wl = WhitelistDomain(domain=domain, is_strict=0)
|
| 188 |
+
db.add(new_wl)
|
| 189 |
+
elif request.status == 'validated_phishing':
|
| 190 |
+
s.final_verdict = 'phishing'
|
| 191 |
+
|
| 192 |
+
db.commit()
|
| 193 |
+
return {"message": "Validation successful"}
|
app/api/routes_scan.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.database import get_db
|
| 4 |
+
from app.schemas.schemas import ScanUrlRequest, ScanUrlResponse, ScanDomRequest, ScanDomResponse
|
| 5 |
+
from app.models.db_models import ScanLog, Quarantine, WhitelistDomain
|
| 6 |
+
from app.models.cnn_model import predict_cnn
|
| 7 |
+
import urllib.parse
|
| 8 |
+
from app.models.bert_model import predict_bert
|
| 9 |
+
import hashlib
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.post("/url", response_model=ScanUrlResponse)
|
| 14 |
+
def scan_url(request: ScanUrlRequest, db: Session = Depends(get_db)):
|
| 15 |
+
# 0. Check Whitelist
|
| 16 |
+
domain = urllib.parse.urlparse(request.url).hostname
|
| 17 |
+
if domain:
|
| 18 |
+
domain = domain.lower()
|
| 19 |
+
whitelisted = db.query(WhitelistDomain).filter(WhitelistDomain.domain == domain).first()
|
| 20 |
+
if whitelisted:
|
| 21 |
+
if whitelisted.is_strict == 1:
|
| 22 |
+
return ScanUrlResponse(
|
| 23 |
+
verdict="safe",
|
| 24 |
+
cnn_score=0.0,
|
| 25 |
+
stage="strict_whitelist",
|
| 26 |
+
needs_dom_analysis=False
|
| 27 |
+
)
|
| 28 |
+
else:
|
| 29 |
+
return ScanUrlResponse(
|
| 30 |
+
verdict="safe",
|
| 31 |
+
cnn_score=0.0,
|
| 32 |
+
stage="smart_whitelist",
|
| 33 |
+
needs_dom_analysis=True
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# 1. Run 1D-CNN inference
|
| 37 |
+
cnn_score = predict_cnn(request.url)
|
| 38 |
+
|
| 39 |
+
# ============================================================
|
| 40 |
+
# MODE: CNN-ONLY (BERT dimatikan sementara)
|
| 41 |
+
# Set CNN_ONLY_MODE = False untuk mengaktifkan kembali BERT.
|
| 42 |
+
# ============================================================
|
| 43 |
+
CNN_ONLY_MODE = True
|
| 44 |
+
|
| 45 |
+
cnn_threshold_phishing = 0.904
|
| 46 |
+
cnn_threshold_safe = 0.10
|
| 47 |
+
|
| 48 |
+
# === HIGH-RISK HOSTING PLATFORMS ===
|
| 49 |
+
HIGH_RISK_HOSTING = {
|
| 50 |
+
"ipfs.io", "dweb.link", "cf-ipfs.com",
|
| 51 |
+
"web.app", "firebaseapp.com",
|
| 52 |
+
"sites.google.com",
|
| 53 |
+
"github.io",
|
| 54 |
+
"vercel.app", "netlify.app",
|
| 55 |
+
"workers.dev", "pages.dev",
|
| 56 |
+
"000webhostapp.com", "lima-city.de",
|
| 57 |
+
"glitch.me", "repl.co", "onrender.com",
|
| 58 |
+
}
|
| 59 |
+
is_high_risk_hosting = domain is not None and any(
|
| 60 |
+
domain == d or domain.endswith('.' + d) for d in HIGH_RISK_HOSTING
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# === TRUSTED GOVERNMENT & INSTITUTION TLDs ===
|
| 64 |
+
TRUSTED_GOV_TLDS = (
|
| 65 |
+
".go.id", ".ac.id", ".sch.id", ".mil.id", ".net.id", ".or.id",
|
| 66 |
+
".gov", ".edu", ".mil",
|
| 67 |
+
".gov.uk", ".ac.uk", ".gov.au", ".edu.au",
|
| 68 |
+
".gov.sg", ".edu.sg",
|
| 69 |
+
".gov.my", ".edu.my",
|
| 70 |
+
)
|
| 71 |
+
is_trusted_gov = domain is not None and any(
|
| 72 |
+
domain.endswith(tld) for tld in TRUSTED_GOV_TLDS
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
if cnn_score >= cnn_threshold_phishing and not is_trusted_gov:
|
| 76 |
+
# CNN sangat yakin ini phishing DAN bukan domain pemerintah — langsung blokir
|
| 77 |
+
verdict = "phishing"
|
| 78 |
+
stage = "cnn_only"
|
| 79 |
+
needs_dom = False
|
| 80 |
+
elif cnn_score <= cnn_threshold_safe or is_trusted_gov:
|
| 81 |
+
# CNN yakin aman, ATAU domain pemerintah terpercaya — anggap aman
|
| 82 |
+
verdict = "safe"
|
| 83 |
+
stage = "cnn_only"
|
| 84 |
+
needs_dom = False
|
| 85 |
+
else:
|
| 86 |
+
# Zona ambiguous atau high-risk hosting
|
| 87 |
+
if CNN_ONLY_MODE:
|
| 88 |
+
# Mode CNN-only: tandai sebagai quarantine, tidak kirim ke BERT
|
| 89 |
+
verdict = "quarantine"
|
| 90 |
+
stage = "cnn_only"
|
| 91 |
+
needs_dom = False
|
| 92 |
+
else:
|
| 93 |
+
# Mode normal: kirim ke BERT untuk analisis DOM
|
| 94 |
+
verdict = "safe" # verdict sementara, BERT akan memutuskan
|
| 95 |
+
stage = "cnn_and_bert"
|
| 96 |
+
needs_dom = True
|
| 97 |
+
|
| 98 |
+
return ScanUrlResponse(
|
| 99 |
+
verdict=verdict,
|
| 100 |
+
cnn_score=cnn_score,
|
| 101 |
+
stage=stage,
|
| 102 |
+
needs_dom_analysis=needs_dom
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
@router.post("/dom", response_model=ScanDomResponse)
|
| 106 |
+
def scan_dom(request: ScanDomRequest, db: Session = Depends(get_db)):
|
| 107 |
+
# 0. Check Smart Whitelist
|
| 108 |
+
domain = urllib.parse.urlparse(request.url).hostname
|
| 109 |
+
if domain:
|
| 110 |
+
domain = domain.lower()
|
| 111 |
+
whitelisted = db.query(WhitelistDomain).filter(WhitelistDomain.domain == domain).first()
|
| 112 |
+
if whitelisted and whitelisted.is_strict == 0:
|
| 113 |
+
current_len = len(request.sanitized_text)
|
| 114 |
+
if not whitelisted.dom_hash:
|
| 115 |
+
whitelisted.dom_hash = str(current_len)
|
| 116 |
+
db.commit()
|
| 117 |
+
return ScanDomResponse(verdict="safe", bert_score=0.0)
|
| 118 |
+
else:
|
| 119 |
+
try:
|
| 120 |
+
old_len = int(whitelisted.dom_hash)
|
| 121 |
+
if old_len > 0:
|
| 122 |
+
ratio = current_len / old_len
|
| 123 |
+
if 0.7 <= ratio <= 1.3:
|
| 124 |
+
return ScanDomResponse(verdict="safe", bert_score=0.0)
|
| 125 |
+
else:
|
| 126 |
+
# Hijacked / Drastically changed! Remove from whitelist
|
| 127 |
+
db.delete(whitelisted)
|
| 128 |
+
db.commit()
|
| 129 |
+
except ValueError:
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
# 1. Run DistilBERT inference on request.sanitized_text
|
| 133 |
+
bert_score = predict_bert(request.sanitized_text)
|
| 134 |
+
|
| 135 |
+
# 2. Threshold check
|
| 136 |
+
bert_threshold = 0.75
|
| 137 |
+
|
| 138 |
+
if bert_score >= bert_threshold:
|
| 139 |
+
verdict = "phishing"
|
| 140 |
+
elif bert_score < 0.4:
|
| 141 |
+
verdict = "safe"
|
| 142 |
+
else:
|
| 143 |
+
verdict = "quarantine"
|
| 144 |
+
|
| 145 |
+
# Re-calculate CNN score for logging purposes
|
| 146 |
+
from app.models.cnn_model import predict_cnn
|
| 147 |
+
actual_cnn_score = predict_cnn(request.url)
|
| 148 |
+
|
| 149 |
+
# Log to DB
|
| 150 |
+
new_log = ScanLog(
|
| 151 |
+
url=request.url,
|
| 152 |
+
url_decoded=request.url,
|
| 153 |
+
cnn_score=actual_cnn_score,
|
| 154 |
+
bert_score=bert_score,
|
| 155 |
+
final_verdict=verdict,
|
| 156 |
+
stage_triggered="cnn_and_bert"
|
| 157 |
+
)
|
| 158 |
+
db.add(new_log)
|
| 159 |
+
db.commit()
|
| 160 |
+
db.refresh(new_log)
|
| 161 |
+
|
| 162 |
+
if verdict == "quarantine":
|
| 163 |
+
# Check if this URL is already pending in quarantine to prevent duplicates
|
| 164 |
+
existing_q = db.query(Quarantine).join(ScanLog).filter(
|
| 165 |
+
ScanLog.url == request.url,
|
| 166 |
+
Quarantine.status == 'pending'
|
| 167 |
+
).first()
|
| 168 |
+
|
| 169 |
+
if not existing_q:
|
| 170 |
+
new_q = Quarantine(scan_log_id=new_log.id, status='pending')
|
| 171 |
+
db.add(new_q)
|
| 172 |
+
db.commit()
|
| 173 |
+
|
| 174 |
+
return ScanDomResponse(verdict=verdict, bert_score=bert_score)
|
app/api/routes_stats.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import func
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from app.db.database import get_db
|
| 6 |
+
from app.models.db_models import ScanLog, Quarantine
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
@router.get("/")
|
| 11 |
+
def get_stats(db: Session = Depends(get_db)):
|
| 12 |
+
total_scans = db.query(func.count(ScanLog.id)).scalar()
|
| 13 |
+
total_phishing = db.query(func.count(ScanLog.id)).filter(ScanLog.final_verdict == 'phishing').scalar()
|
| 14 |
+
total_quarantine = db.query(func.count(Quarantine.id)).filter(Quarantine.status == 'pending').scalar()
|
| 15 |
+
|
| 16 |
+
return {
|
| 17 |
+
"total_scans": total_scans or 0,
|
| 18 |
+
"total_blocked": total_phishing or 0,
|
| 19 |
+
"pending_quarantine": total_quarantine or 0
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@router.get("/daily")
|
| 23 |
+
def get_daily_stats(db: Session = Depends(get_db)):
|
| 24 |
+
ten_days_ago = datetime.utcnow() - timedelta(days=10)
|
| 25 |
+
|
| 26 |
+
results = (
|
| 27 |
+
db.query(
|
| 28 |
+
func.date(ScanLog.created_at).label('date'),
|
| 29 |
+
ScanLog.final_verdict,
|
| 30 |
+
func.count(ScanLog.id).label('count')
|
| 31 |
+
)
|
| 32 |
+
.filter(ScanLog.created_at >= ten_days_ago)
|
| 33 |
+
.group_by(func.date(ScanLog.created_at), ScanLog.final_verdict)
|
| 34 |
+
.all()
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
daily_data = {}
|
| 38 |
+
|
| 39 |
+
# Pre-fill last 10 days
|
| 40 |
+
for i in range(9, -1, -1):
|
| 41 |
+
day = (datetime.utcnow() - timedelta(days=i)).date()
|
| 42 |
+
daily_data[str(day)] = {"date": str(day), "safe": 0, "phishing": 0, "quarantine": 0}
|
| 43 |
+
|
| 44 |
+
for row in results:
|
| 45 |
+
date_str = str(row.date)
|
| 46 |
+
if date_str in daily_data:
|
| 47 |
+
verdict = row.final_verdict
|
| 48 |
+
if verdict in daily_data[date_str]:
|
| 49 |
+
daily_data[date_str][verdict] = row.count
|
| 50 |
+
|
| 51 |
+
return sorted(list(daily_data.values()), key=lambda x: x['date'])
|
app/api/routes_whitelist.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.db.database import get_db
|
| 4 |
+
from app.models.db_models import WhitelistDomain
|
| 5 |
+
from app.schemas.schemas import WhitelistAddRequest
|
| 6 |
+
import datetime
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
@router.get("/")
|
| 11 |
+
def get_whitelist(strict: str = None, db: Session = Depends(get_db)):
|
| 12 |
+
query = db.query(WhitelistDomain)
|
| 13 |
+
if strict is not None:
|
| 14 |
+
is_strict_val = 1 if strict.lower() == 'true' else 0
|
| 15 |
+
query = query.filter(WhitelistDomain.is_strict == is_strict_val)
|
| 16 |
+
domains = query.order_by(WhitelistDomain.created_at.desc()).all()
|
| 17 |
+
return [{"id": d.id, "domain": d.domain, "is_strict": d.is_strict == 1, "created_at": d.created_at} for d in domains]
|
| 18 |
+
|
| 19 |
+
@router.post("/")
|
| 20 |
+
def add_whitelist(request: WhitelistAddRequest, db: Session = Depends(get_db)):
|
| 21 |
+
domain = request.domain.strip().lower()
|
| 22 |
+
if not domain:
|
| 23 |
+
raise HTTPException(status_code=400, detail="Domain cannot be empty")
|
| 24 |
+
|
| 25 |
+
existing = db.query(WhitelistDomain).filter(WhitelistDomain.domain == domain).first()
|
| 26 |
+
if existing:
|
| 27 |
+
raise HTTPException(status_code=400, detail="Domain is already in whitelist")
|
| 28 |
+
|
| 29 |
+
new_domain = WhitelistDomain(domain=domain, is_strict=1)
|
| 30 |
+
db.add(new_domain)
|
| 31 |
+
db.commit()
|
| 32 |
+
db.refresh(new_domain)
|
| 33 |
+
return {"message": "Domain added to whitelist", "domain": new_domain.domain}
|
| 34 |
+
|
| 35 |
+
@router.delete("/{domain}")
|
| 36 |
+
def remove_whitelist(domain: str, db: Session = Depends(get_db)):
|
| 37 |
+
domain = domain.strip().lower()
|
| 38 |
+
existing = db.query(WhitelistDomain).filter(WhitelistDomain.domain == domain).first()
|
| 39 |
+
if not existing:
|
| 40 |
+
raise HTTPException(status_code=404, detail="Domain not found in whitelist")
|
| 41 |
+
|
| 42 |
+
db.delete(existing)
|
| 43 |
+
db.commit()
|
| 44 |
+
return {"message": "Domain removed from whitelist"}
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (132 Bytes). View file
|
|
|
app/db/__pycache__/database.cpython-311.pyc
ADDED
|
Binary file (984 Bytes). View file
|
|
|
app/db/database.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
| 4 |
+
|
| 5 |
+
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./arxen.db")
|
| 6 |
+
|
| 7 |
+
# Fix Supabase / Heroku postgres:// -> postgresql:// for SQLAlchemy compatibility
|
| 8 |
+
if DATABASE_URL.startswith("postgres://"):
|
| 9 |
+
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
|
| 10 |
+
|
| 11 |
+
engine = create_engine(DATABASE_URL)
|
| 12 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 13 |
+
|
| 14 |
+
Base = declarative_base()
|
| 15 |
+
|
| 16 |
+
def get_db():
|
| 17 |
+
db = SessionLocal()
|
| 18 |
+
try:
|
| 19 |
+
yield db
|
| 20 |
+
finally:
|
| 21 |
+
db.close()
|
app/main.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.api import routes_scan, routes_quarantine, routes_stats, routes_whitelist
|
| 4 |
+
from app.db.database import engine, Base
|
| 5 |
+
|
| 6 |
+
# Create tables
|
| 7 |
+
Base.metadata.create_all(bind=engine)
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="Arxen API", version="1.0.0")
|
| 10 |
+
|
| 11 |
+
# Setup CORS for extension and Vercel frontend
|
| 12 |
+
app.add_middleware(
|
| 13 |
+
CORSMiddleware,
|
| 14 |
+
allow_origins=["*"],
|
| 15 |
+
allow_credentials=True,
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
app.include_router(routes_scan.router, prefix="/api/scan", tags=["Scan"])
|
| 21 |
+
app.include_router(routes_quarantine.router, prefix="/api/quarantine", tags=["Quarantine"])
|
| 22 |
+
app.include_router(routes_stats.router, prefix="/api/stats", tags=["Stats"])
|
| 23 |
+
app.include_router(routes_whitelist.router, prefix="/api/whitelist", tags=["Whitelist"])
|
| 24 |
+
|
| 25 |
+
@app.get("/")
|
| 26 |
+
def read_root():
|
| 27 |
+
return {"message": "Arxen Backend is running"}
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (136 Bytes). View file
|
|
|
app/models/__pycache__/bert_model.cpython-311.pyc
ADDED
|
Binary file (3.26 kB). View file
|
|
|
app/models/__pycache__/cnn_model.cpython-311.pyc
ADDED
|
Binary file (8.22 kB). View file
|
|
|
app/models/__pycache__/db_models.cpython-311.pyc
ADDED
|
Binary file (4.28 kB). View file
|
|
|
app/models/bert_model.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
MODELS_DIR = os.environ.get("MODELS_DIR", "/app/models_dir")
|
| 8 |
+
BERT_MODEL_PATH = os.path.join(MODELS_DIR, "bert_model") # Should be a directory saved by save_pretrained
|
| 9 |
+
|
| 10 |
+
_pipeline_loaded = False
|
| 11 |
+
_bert_pipeline = None
|
| 12 |
+
|
| 13 |
+
def load_bert_model():
|
| 14 |
+
global _pipeline_loaded, _bert_pipeline
|
| 15 |
+
if os.path.exists(BERT_MODEL_PATH) and os.path.isdir(BERT_MODEL_PATH):
|
| 16 |
+
try:
|
| 17 |
+
tokenizer = AutoTokenizer.from_pretrained(BERT_MODEL_PATH)
|
| 18 |
+
model = AutoModelForSequenceClassification.from_pretrained(BERT_MODEL_PATH)
|
| 19 |
+
_bert_pipeline = pipeline("text-classification", model=model, tokenizer=tokenizer, max_length=512, truncation=True)
|
| 20 |
+
_pipeline_loaded = True
|
| 21 |
+
logger.info("DistilBERT model loaded successfully.")
|
| 22 |
+
except Exception as e:
|
| 23 |
+
logger.error(f"Failed to load DistilBERT model: {e}")
|
| 24 |
+
_pipeline_loaded = False
|
| 25 |
+
else:
|
| 26 |
+
logger.warning(f"DistilBERT model directory not found at {BERT_MODEL_PATH}. Using dummy inference fallback.")
|
| 27 |
+
_pipeline_loaded = False
|
| 28 |
+
|
| 29 |
+
# Initialize on startup
|
| 30 |
+
load_bert_model()
|
| 31 |
+
|
| 32 |
+
def predict_bert(text: str) -> float:
|
| 33 |
+
if not _pipeline_loaded:
|
| 34 |
+
# Dummy logic: look for suspicious keywords
|
| 35 |
+
score = 0.1
|
| 36 |
+
text_lower = text.lower()
|
| 37 |
+
suspicious_words = ['urgent', 'password', 'verify', 'account', 'login', 'suspend', 'blokir', 'verifikasi', 'akun']
|
| 38 |
+
found = sum(1 for w in suspicious_words if w in text_lower)
|
| 39 |
+
score += min(found * 0.15, 0.8)
|
| 40 |
+
return score
|
| 41 |
+
|
| 42 |
+
result = _bert_pipeline(text)
|
| 43 |
+
# Pipeline usually returns [{'label': 'LABEL_1', 'score': 0.9}]
|
| 44 |
+
# We assume LABEL_1 means phishing (needs to match actual training)
|
| 45 |
+
if result and isinstance(result, list):
|
| 46 |
+
res = result[0]
|
| 47 |
+
# Map label to score
|
| 48 |
+
if "phish" in res['label'].lower() or res['label'] == 'LABEL_1':
|
| 49 |
+
return res['score']
|
| 50 |
+
else:
|
| 51 |
+
return 1.0 - res['score']
|
| 52 |
+
|
| 53 |
+
return 0.0
|
app/models/cnn_model.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import logging
|
| 6 |
+
import idna
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
# Constants
|
| 11 |
+
MODELS_DIR = os.environ.get("MODELS_DIR", "/app/models_dir")
|
| 12 |
+
CNN_MODEL_PATH = os.path.join(MODELS_DIR, "cnn_model.pt")
|
| 13 |
+
CNN_CONFIG_PATH = os.path.join(MODELS_DIR, "preprocessing_config.json")
|
| 14 |
+
CNN_VOCAB_PATH = os.path.join(MODELS_DIR, "vocab.json")
|
| 15 |
+
|
| 16 |
+
# Dummy Fallback State
|
| 17 |
+
_model_loaded = False
|
| 18 |
+
_model = None
|
| 19 |
+
_vocab = {}
|
| 20 |
+
_max_length = 200
|
| 21 |
+
_padding_strategy = "post" # "post" or "pre"
|
| 22 |
+
|
| 23 |
+
class SimpleCNN(nn.Module):
|
| 24 |
+
def __init__(self, vocab_size, embedding_dim=32, max_length=200):
|
| 25 |
+
super(SimpleCNN, self).__init__()
|
| 26 |
+
self.embedding = nn.Embedding(vocab_size, embedding_dim)
|
| 27 |
+
self.conv1 = nn.Conv1d(embedding_dim, 64, kernel_size=3, padding=1)
|
| 28 |
+
self.pool = nn.AdaptiveMaxPool1d(1)
|
| 29 |
+
self.fc1 = nn.Linear(64, 32)
|
| 30 |
+
self.fc2 = nn.Linear(32, 1)
|
| 31 |
+
self.sigmoid = nn.Sigmoid()
|
| 32 |
+
|
| 33 |
+
def forward(self, x):
|
| 34 |
+
# x: (batch_size, seq_len)
|
| 35 |
+
x = self.embedding(x)
|
| 36 |
+
# x: (batch_size, seq_len, embedding_dim)
|
| 37 |
+
x = x.permute(0, 2, 1) # (batch_size, embedding_dim, seq_len)
|
| 38 |
+
x = torch.relu(self.conv1(x))
|
| 39 |
+
x = self.pool(x).squeeze(-1) # (batch_size, 64)
|
| 40 |
+
x = torch.relu(self.fc1(x))
|
| 41 |
+
x = self.fc2(x)
|
| 42 |
+
return self.sigmoid(x).squeeze(-1)
|
| 43 |
+
|
| 44 |
+
def load_cnn_model():
|
| 45 |
+
global _model_loaded, _model, _vocab, _max_length, _padding_strategy
|
| 46 |
+
|
| 47 |
+
config_path = CNN_CONFIG_PATH if os.path.exists(CNN_CONFIG_PATH) else CNN_VOCAB_PATH
|
| 48 |
+
|
| 49 |
+
if os.path.exists(config_path) and os.path.exists(CNN_MODEL_PATH):
|
| 50 |
+
try:
|
| 51 |
+
with open(config_path, "r") as f:
|
| 52 |
+
config = json.load(f)
|
| 53 |
+
_vocab = config.get("vocab", {})
|
| 54 |
+
_max_length = config.get("max_length", 200)
|
| 55 |
+
_padding_strategy = config.get("padding_strategy", "post")
|
| 56 |
+
|
| 57 |
+
vocab_size = len(_vocab) # Exact length from vocab.json (includes <PAD> and <UNK>)
|
| 58 |
+
|
| 59 |
+
_model = SimpleCNN(vocab_size=vocab_size, max_length=_max_length)
|
| 60 |
+
_model.load_state_dict(torch.load(CNN_MODEL_PATH, map_location=torch.device('cpu')))
|
| 61 |
+
_model.eval()
|
| 62 |
+
_model_loaded = True
|
| 63 |
+
logger.info(f"1D-CNN Model and config loaded successfully from {config_path}.")
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Failed to load CNN model: {e}")
|
| 66 |
+
_model_loaded = False
|
| 67 |
+
else:
|
| 68 |
+
logger.error(
|
| 69 |
+
"[CNN] Model tidak terload! "
|
| 70 |
+
f"Pastikan file '{CNN_MODEL_PATH}' dan '{CNN_VOCAB_PATH}' ada dan dapat dibaca. "
|
| 71 |
+
"Sistem tidak dapat melakukan prediksi CNN."
|
| 72 |
+
)
|
| 73 |
+
_model_loaded = False
|
| 74 |
+
|
| 75 |
+
# Initialize on startup
|
| 76 |
+
load_cnn_model()
|
| 77 |
+
|
| 78 |
+
def preprocess_url(url: str) -> str:
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
import urllib.parse
|
| 82 |
+
parsed = urllib.parse.urlparse(url)
|
| 83 |
+
|
| 84 |
+
# Ambil scheme (http/https) — PENTING untuk model, persis seperti di Colab
|
| 85 |
+
scheme = parsed.scheme if parsed.scheme else 'http'
|
| 86 |
+
|
| 87 |
+
# Ambil hostname saja (buang path, query, fragment)
|
| 88 |
+
hostname = parsed.hostname if parsed.hostname else url.split('/')[0]
|
| 89 |
+
|
| 90 |
+
# Decode punycode (internationalized domains)
|
| 91 |
+
try:
|
| 92 |
+
hostname = idna.decode(hostname).encode('utf-8').decode('utf-8')
|
| 93 |
+
except Exception:
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
# Output: scheme://hostname — sama seperti format di Colab
|
| 97 |
+
# Path sengaja dibuang agar URL panjang (mis. Google Search) tidak mengacaukan model
|
| 98 |
+
return f"{scheme}://{hostname}"
|
| 99 |
+
except Exception:
|
| 100 |
+
return url
|
| 101 |
+
|
| 102 |
+
def encode_url(url: str, vocab: dict, max_length: int, padding: str = "post") -> list:
|
| 103 |
+
encoded = []
|
| 104 |
+
for char in url:
|
| 105 |
+
encoded.append(vocab.get(char, 0)) # 0 for unknown
|
| 106 |
+
|
| 107 |
+
if len(encoded) > max_length:
|
| 108 |
+
encoded = encoded[:max_length]
|
| 109 |
+
elif len(encoded) < max_length:
|
| 110 |
+
pad_len = max_length - len(encoded)
|
| 111 |
+
if padding == "post":
|
| 112 |
+
encoded.extend([0] * pad_len)
|
| 113 |
+
else:
|
| 114 |
+
encoded = ([0] * pad_len) + encoded
|
| 115 |
+
|
| 116 |
+
return encoded
|
| 117 |
+
|
| 118 |
+
def predict_cnn(url: str) -> float:
|
| 119 |
+
decoded_url = preprocess_url(url)
|
| 120 |
+
|
| 121 |
+
if not _model_loaded:
|
| 122 |
+
logger.error("[CNN] Model tidak terload — prediksi tidak dapat dilakukan.")
|
| 123 |
+
raise RuntimeError(
|
| 124 |
+
"Model CNN tidak terload. "
|
| 125 |
+
f"Pastikan file model ada di '{MODELS_DIR}' dan container sudah direstart."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
encoded = encode_url(decoded_url, _vocab, _max_length, _padding_strategy)
|
| 129 |
+
tensor_input = torch.tensor([encoded], dtype=torch.long)
|
| 130 |
+
|
| 131 |
+
with torch.no_grad():
|
| 132 |
+
output = _model(tensor_input)
|
| 133 |
+
# Model outputs 1 for Legitimate, 0 for Phishing.
|
| 134 |
+
# We invert it so that 1 means Phishing and 0 means Legitimate.
|
| 135 |
+
raw_score = output.item()
|
| 136 |
+
score = 1.0 - raw_score
|
| 137 |
+
|
| 138 |
+
return score
|
app/models/db_models.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, Text, Enum, ForeignKey, TIMESTAMP, BigInteger
|
| 2 |
+
from sqlalchemy.sql import func
|
| 3 |
+
from app.db.database import Base
|
| 4 |
+
|
| 5 |
+
class ScanLog(Base):
|
| 6 |
+
__tablename__ = "scan_logs"
|
| 7 |
+
|
| 8 |
+
id = Column(BigInteger, primary_key=True, index=True, autoincrement=True)
|
| 9 |
+
url = Column(Text, nullable=False)
|
| 10 |
+
url_decoded = Column(Text, nullable=False)
|
| 11 |
+
cnn_score = Column(Float, nullable=False)
|
| 12 |
+
bert_score = Column(Float, nullable=True)
|
| 13 |
+
final_verdict = Column(Enum('safe', 'phishing', 'quarantine'), nullable=False)
|
| 14 |
+
stage_triggered = Column(Enum('cnn_only', 'cnn_and_bert'), nullable=False)
|
| 15 |
+
created_at = Column(TIMESTAMP, server_default=func.now())
|
| 16 |
+
|
| 17 |
+
class Quarantine(Base):
|
| 18 |
+
__tablename__ = "quarantine"
|
| 19 |
+
|
| 20 |
+
id = Column(BigInteger, primary_key=True, index=True, autoincrement=True)
|
| 21 |
+
scan_log_id = Column(BigInteger, ForeignKey("scan_logs.id"), nullable=False)
|
| 22 |
+
cluster_id = Column(Integer, nullable=True)
|
| 23 |
+
status = Column(Enum('pending', 'validated_safe', 'validated_phishing'), default='pending')
|
| 24 |
+
validated_by = Column(String(255), nullable=True)
|
| 25 |
+
validated_at = Column(TIMESTAMP, nullable=True)
|
| 26 |
+
|
| 27 |
+
class ModelThreshold(Base):
|
| 28 |
+
__tablename__ = "model_thresholds"
|
| 29 |
+
|
| 30 |
+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
| 31 |
+
model_name = Column(String(50), nullable=False)
|
| 32 |
+
threshold_value = Column(Float, nullable=False)
|
| 33 |
+
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
| 34 |
+
|
| 35 |
+
class LocalBlacklistCache(Base):
|
| 36 |
+
__tablename__ = "local_blacklist_cache"
|
| 37 |
+
|
| 38 |
+
id = Column(BigInteger, primary_key=True, index=True, autoincrement=True)
|
| 39 |
+
domain = Column(String(255), unique=True, nullable=False)
|
| 40 |
+
verdict = Column(Enum('safe', 'phishing'), nullable=False)
|
| 41 |
+
last_verified = Column(TIMESTAMP, server_default=func.now())
|
| 42 |
+
|
| 43 |
+
class WhitelistDomain(Base):
|
| 44 |
+
__tablename__ = "whitelist_domains"
|
| 45 |
+
|
| 46 |
+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
| 47 |
+
domain = Column(String(255), unique=True, nullable=False)
|
| 48 |
+
is_strict = Column(Integer, default=1) # 1 for True, 0 for False (using Integer for sqlite/mysql compat)
|
| 49 |
+
dom_hash = Column(String(255), nullable=True)
|
| 50 |
+
created_at = Column(TIMESTAMP, server_default=func.now())
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (137 Bytes). View file
|
|
|
app/schemas/__pycache__/schemas.cpython-311.pyc
ADDED
|
Binary file (1.98 kB). View file
|
|
|
app/schemas/schemas.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
class ScanUrlRequest(BaseModel):
|
| 5 |
+
url: str
|
| 6 |
+
|
| 7 |
+
class ScanUrlResponse(BaseModel):
|
| 8 |
+
verdict: str
|
| 9 |
+
cnn_score: float
|
| 10 |
+
stage: str
|
| 11 |
+
needs_dom_analysis: bool = False
|
| 12 |
+
|
| 13 |
+
class ScanDomRequest(BaseModel):
|
| 14 |
+
url: str
|
| 15 |
+
sanitized_text: str
|
| 16 |
+
|
| 17 |
+
class ScanDomResponse(BaseModel):
|
| 18 |
+
verdict: str
|
| 19 |
+
bert_score: float
|
| 20 |
+
|
| 21 |
+
class QuarantineValidateRequest(BaseModel):
|
| 22 |
+
status: str
|
| 23 |
+
validated_by: str
|
| 24 |
+
|
| 25 |
+
class WhitelistAddRequest(BaseModel):
|
| 26 |
+
domain: str
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (138 Bytes). View file
|
|
|
app/services/__pycache__/clustering_service.cpython-311.pyc
ADDED
|
Binary file (5.73 kB). View file
|
|
|
app/services/__pycache__/retrain_service.cpython-311.pyc
ADDED
|
Binary file (2.04 kB). View file
|
|
|
app/services/clustering_service.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import urllib.parse
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from app.models.db_models import Quarantine, ScanLog
|
| 6 |
+
|
| 7 |
+
def get_char_ngrams(text: str, n: int = 3) -> set:
|
| 8 |
+
"""Generate character n-grams for a given string."""
|
| 9 |
+
text = text.lower().strip()
|
| 10 |
+
if len(text) < n:
|
| 11 |
+
return {text}
|
| 12 |
+
return {text[i:i+n] for i in range(len(text) - n + 1)}
|
| 13 |
+
|
| 14 |
+
def jaccard_similarity(set1: set, set2: set) -> float:
|
| 15 |
+
"""Calculate Jaccard similarity between two sets of n-grams."""
|
| 16 |
+
if not set1 or not set2:
|
| 17 |
+
return 0.0
|
| 18 |
+
intersection = len(set1.intersection(set2))
|
| 19 |
+
union = len(set1.union(set2))
|
| 20 |
+
return intersection / union if union > 0 else 0.0
|
| 21 |
+
|
| 22 |
+
def hybrid_url_similarity(url1: str, url2: str) -> float:
|
| 23 |
+
"""
|
| 24 |
+
Calculates hybrid similarity between two URLs by combining:
|
| 25 |
+
1. 60% Domain/Hostname N-gram similarity (detects domain ecosystems / campaign patterns)
|
| 26 |
+
2. 40% Full URL N-gram similarity (detects path & structure patterns)
|
| 27 |
+
"""
|
| 28 |
+
h1 = urllib.parse.urlparse(url1).hostname or url1
|
| 29 |
+
h2 = urllib.parse.urlparse(url2).hostname or url2
|
| 30 |
+
|
| 31 |
+
sim_host = jaccard_similarity(get_char_ngrams(h1, 3), get_char_ngrams(h2, 3))
|
| 32 |
+
sim_full = jaccard_similarity(get_char_ngrams(url1, 3), get_char_ngrams(url2, 3))
|
| 33 |
+
|
| 34 |
+
return 0.6 * sim_host + 0.4 * sim_full
|
| 35 |
+
|
| 36 |
+
def run_quarantine_clustering(db: Session, similarity_threshold: float = 0.18) -> Dict[str, Any]:
|
| 37 |
+
"""
|
| 38 |
+
Groups pending quarantine URLs into clusters based on Hybrid N-Gram Similarity.
|
| 39 |
+
Threshold default = 0.18 (18%).
|
| 40 |
+
- Unrelated domains (e.g. bisa.ai vs wikipedia.org) have < 5% similarity -> Separate clusters.
|
| 41 |
+
- Related domain variants / campaigns (e.g. id.wikipedia, wikisource, wikimedia) have >= 18% similarity -> Same cluster.
|
| 42 |
+
"""
|
| 43 |
+
# Fetch all pending quarantine cases
|
| 44 |
+
cases = db.query(Quarantine, ScanLog).join(ScanLog, Quarantine.scan_log_id == ScanLog.id).filter(Quarantine.status == 'pending').all()
|
| 45 |
+
|
| 46 |
+
if not cases:
|
| 47 |
+
return {
|
| 48 |
+
"message": "Tidak ada kasus karantina pending yang perlu dikelompokkan.",
|
| 49 |
+
"clustered_count": 0,
|
| 50 |
+
"clusters_created": 0
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
items = [
|
| 54 |
+
{"quarantine": q, "scan_log": s, "url": s.url}
|
| 55 |
+
for q, s in cases
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
# Clustering algorithm (Greedy Connected Components based on Hybrid Similarity)
|
| 59 |
+
clusters = []
|
| 60 |
+
visited = set()
|
| 61 |
+
|
| 62 |
+
for i in range(len(items)):
|
| 63 |
+
if i in visited:
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
current_cluster = [i]
|
| 67 |
+
visited.add(i)
|
| 68 |
+
|
| 69 |
+
for j in range(i + 1, len(items)):
|
| 70 |
+
if j in visited:
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
sim = hybrid_url_similarity(items[i]["url"], items[j]["url"])
|
| 74 |
+
if sim >= similarity_threshold:
|
| 75 |
+
current_cluster.append(j)
|
| 76 |
+
visited.add(j)
|
| 77 |
+
|
| 78 |
+
clusters.append(current_cluster)
|
| 79 |
+
|
| 80 |
+
# Assign cluster_ids to database records
|
| 81 |
+
total_assigned = 0
|
| 82 |
+
for cluster_idx, member_indices in enumerate(clusters, start=1):
|
| 83 |
+
for idx in member_indices:
|
| 84 |
+
q_rec = items[idx]["quarantine"]
|
| 85 |
+
q_rec.cluster_id = cluster_idx
|
| 86 |
+
total_assigned += 1
|
| 87 |
+
|
| 88 |
+
db.commit()
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"message": f"Berhasil mengelompokkan {total_assigned} URL ke dalam {len(clusters)} cluster.",
|
| 92 |
+
"clustered_count": total_assigned,
|
| 93 |
+
"clusters_created": len(clusters)
|
| 94 |
+
}
|
app/services/retrain_service.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Any
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.models.db_models import Quarantine, ScanLog
|
| 4 |
+
|
| 5 |
+
def export_retraining_dataset(db: Session) -> Dict[str, Any]:
|
| 6 |
+
"""
|
| 7 |
+
Exports all validated quarantine records (validated_safe / validated_phishing)
|
| 8 |
+
into a structured dataset format ready for Batch Retraining in Google Colab.
|
| 9 |
+
|
| 10 |
+
Label Mapping:
|
| 11 |
+
- Label 1 = Legitimate (Safe)
|
| 12 |
+
- Label 0 = Phishing
|
| 13 |
+
(Matches PhiUSIIL Dataset Standard)
|
| 14 |
+
"""
|
| 15 |
+
cases = db.query(Quarantine, ScanLog).join(ScanLog, Quarantine.scan_log_id == ScanLog.id).filter(
|
| 16 |
+
Quarantine.status.in_(['validated_safe', 'validated_phishing'])
|
| 17 |
+
).all()
|
| 18 |
+
|
| 19 |
+
dataset = []
|
| 20 |
+
for q, s in cases:
|
| 21 |
+
label = 1 if q.status == 'validated_safe' else 0
|
| 22 |
+
dataset.append({
|
| 23 |
+
"quarantine_id": q.id,
|
| 24 |
+
"url": s.url,
|
| 25 |
+
"label": label,
|
| 26 |
+
"status": q.status,
|
| 27 |
+
"validated_by": q.validated_by,
|
| 28 |
+
"validated_at": q.validated_at.isoformat() if q.validated_at else None,
|
| 29 |
+
"cnn_score": s.cnn_score,
|
| 30 |
+
"bert_score": s.bert_score
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
"total_records": len(dataset),
|
| 35 |
+
"dataset": dataset
|
| 36 |
+
}
|
models_dir/cnn_model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:538121299dad478885ffaf2d057d694bb85fe0b004f8204382581b14f43f5bcc
|
| 3 |
+
size 46059
|
models_dir/vocab.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"vocab": {"a": 1, "s": 2, "7": 3, "h": 4, "3": 5, "\u00a4": 6, "l": 7, "6": 8, "i": 9, "e": 10, "*": 11, ",": 12, "!": 13, ".": 14, "\u00c3": 15, "u": 16, "\u00ac": 17, "0": 18, "8": 19, "q": 20, "\u00c5": 21, "~": 22, "z": 23, "=": 24, "]": 25, "1": 26, "5": 27, "c": 28, "\u201c": 29, ":": 30, "(": 31, "o": 32, "%": 33, "w": 34, "$": 35, "g": 36, "&": 37, "t": 38, "4": 39, "f": 40, "'": 41, "\u00c2": 42, "y": 43, "n": 44, "p": 45, "9": 46, ";": 47, "2": 48, "@": 49, "#": 50, "r": 51, "m": 52, "_": 53, "\u201a": 54, "\u20ac": 55, "/": 56, "k": 57, "\u00a2": 58, "d": 59, "+": 60, ")": 61, "\u00b9": 62, "[": 63, "-": 64, "j": 65, "\u00a3": 66, "?": 67, "x": 68, "\u00e2": 69, "v": 70, "b": 71, "<PAD>": 0, "<UNK>": 72}, "max_length": 150}
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.29.0
|
| 3 |
+
sqlalchemy==2.0.30
|
| 4 |
+
pymysql==1.1.0
|
| 5 |
+
psycopg2-binary==2.9.9
|
| 6 |
+
pydantic==2.7.1
|
| 7 |
+
transformers==4.40.1
|
| 8 |
+
torch==2.3.0
|
| 9 |
+
idna==3.7
|
| 10 |
+
cryptography
|