RantoG commited on
Commit
665eeaf
·
verified ·
1 Parent(s): f468eac

Upload 43 files

Browse files
Files changed (43) hide show
  1. .gitignore +10 -0
  2. Dockerfile +24 -0
  3. README.md +0 -13
  4. app.py +8 -0
  5. app/__init__.py +0 -0
  6. app/__pycache__/__init__.cpython-311.pyc +0 -0
  7. app/__pycache__/main.cpython-311.pyc +0 -0
  8. app/api/__init__.py +0 -0
  9. app/api/__pycache__/__init__.cpython-311.pyc +0 -0
  10. app/api/__pycache__/routes_quarantine.cpython-311.pyc +0 -0
  11. app/api/__pycache__/routes_scan.cpython-311.pyc +0 -0
  12. app/api/__pycache__/routes_stats.cpython-311.pyc +0 -0
  13. app/api/__pycache__/routes_whitelist.cpython-311.pyc +0 -0
  14. app/api/routes_quarantine.py +193 -0
  15. app/api/routes_scan.py +174 -0
  16. app/api/routes_stats.py +51 -0
  17. app/api/routes_whitelist.py +44 -0
  18. app/db/__init__.py +0 -0
  19. app/db/__pycache__/__init__.cpython-311.pyc +0 -0
  20. app/db/__pycache__/database.cpython-311.pyc +0 -0
  21. app/db/database.py +21 -0
  22. app/main.py +27 -0
  23. app/models/__init__.py +0 -0
  24. app/models/__pycache__/__init__.cpython-311.pyc +0 -0
  25. app/models/__pycache__/bert_model.cpython-311.pyc +0 -0
  26. app/models/__pycache__/cnn_model.cpython-311.pyc +0 -0
  27. app/models/__pycache__/db_models.cpython-311.pyc +0 -0
  28. app/models/bert_model.py +53 -0
  29. app/models/cnn_model.py +138 -0
  30. app/models/db_models.py +50 -0
  31. app/schemas/__init__.py +0 -0
  32. app/schemas/__pycache__/__init__.cpython-311.pyc +0 -0
  33. app/schemas/__pycache__/schemas.cpython-311.pyc +0 -0
  34. app/schemas/schemas.py +26 -0
  35. app/services/__init__.py +0 -0
  36. app/services/__pycache__/__init__.cpython-311.pyc +0 -0
  37. app/services/__pycache__/clustering_service.cpython-311.pyc +0 -0
  38. app/services/__pycache__/retrain_service.cpython-311.pyc +0 -0
  39. app/services/clustering_service.py +94 -0
  40. app/services/retrain_service.py +36 -0
  41. models_dir/cnn_model.pt +3 -0
  42. models_dir/vocab.json +1 -0
  43. 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