Compare commits

..

No commits in common. "main" and "archive" have entirely different histories.

20 changed files with 0 additions and 1930 deletions

173
AGENTS.md
View File

@ -1,173 +0,0 @@
<!-- vibe42-agents-version: v3-guided-2026-06-01 -->
# Vibe42 — учебная песочница для лендингов
Workspace юзера `sot`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/sot/<repo>/`,
2. ощущением «это было легко» — без серверов, БД, токенов, конфигов.
Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код.
---
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?»
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
- Промо хобби (фотография / музыка / спорт)
- Резюме / personal page с контактами
- Афиша мероприятия (концерт, день рождения, мастер-класс)
- Меню заведения / прайс услуг
- Лендинг продукта или будущего проекта (waitlist)
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
4. Сразу делай `./new-project <name>` и собирай страницу. Не спрашивай разрешения на каждый шаг.
---
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
Скажи: «Давай определимся, я задам 3 коротких вопроса:
1. Это для тебя лично, для проекта/бизнеса, или для события?
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
---
## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ
| Запрос | Что делаем вместо |
|--------|-------------------|
| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram |
| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) |
| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium |
| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp |
| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) |
| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` |
**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут.
---
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
### A — Промо продукта/услуги
**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер).
### B — Personal / резюме
**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки).
### C — Афиша мероприятия
**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт).
### D — Меню / прайс
**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка).
### E — Waitlist для будущего проекта
**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма).
Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического.
---
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
Как только готов первый рабочий вариант (даже грубый):
1. **Сразу запушь:**
```bash
git add -A
git commit -m "v1"
git push origin HEAD:pages
```
2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**:
> 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/sot/<repo>/**
3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?»
4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push.
---
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
1. **Только статика — HTML + CSS + JS в браузере.**
2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
3. **Никакой аутентификации / OAuth / JWT.**
4. **Никакого Docker, nginx, sudo, системных настроек.**
5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
6. **НИКОГДА `git init` в workspace root (`/workspaces/sot`)** — это папка-контейнер юзера, не репозиторий.
---
## ✅ ВСЕГДА работай через `./new-project`
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
```bash
cd /workspaces/sot
./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/
cd NAME
# теперь создавай index.html / style.css / script.js внутри ./NAME
```
`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`.
---
## 🌐 Git и публикация
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
- Профиль юзера: https://git.vibe42.kz/sot
- Pages (живые лендинги): https://pages.git.vibe42.kz/sot/<repo>/
- Креды уже в `/workspaces/sot/.git-credentials` — git push/clone работают без пароля
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
### Опубликовать лендинг
```bash
git add -A
git commit -m "site"
git push origin HEAD:pages
```
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно.
---
## 🔧 Когда что-то идёт не так
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
- ❌ Использовать `gh` CLI или GitHub API
- ❌ Вызывать Gitea Pages-API (его нет)
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
- ❌ Говорить «это сложно» — переформулируй в простое
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
---
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.

BIN
MetaGuardSOT.zip Normal file

Binary file not shown.

View File

@ -1,14 +0,0 @@
# MetaGuardSOT
Как запустить на Windows:
1. **https://winpython.github.io/** → WinPython portable → распаковать
2. **https://git.vibe42.kz/sot/MetaGuardSOT** → Download ZIP → распаковать
3. В папке проекта (cmd):
```
WinPython\python.exe -m pip install -r requirements.txt
WinPython\python.exe app.py
```
4. Браузер: http://127.0.0.1:5000
Если установлен Python — просто запустить `run.bat`.

Binary file not shown.

205
app.py
View File

@ -1,205 +0,0 @@
# app.py — MetaGuardSOT Web Interface
from __future__ import annotations
import os
import sys
import shutil
import tempfile
import threading
from dataclasses import asdict
from typing import List
from flask import Flask, render_template, request, jsonify, send_file
from core import scan_target, ScanResult, sanitize_file
from reporters import export_json, export_csv, export_html
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 MB
app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
results_store: List[ScanResult] = []
scan_progress = {
"status": "idle",
"total": 0,
"current": 0,
"current_file": "",
}
scan_lock = threading.Lock()
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/upload", methods=["POST"])
def upload_files():
files = request.files.getlist("files")
if not files or all(f.filename == "" for f in files):
return jsonify({"error": "No files uploaded"}), 400
saved = []
upload_dir = app.config["UPLOAD_FOLDER"]
for f in files:
if f.filename:
safe_name = f.filename.replace("/", "_").replace("\\", "_")
dest = os.path.join(upload_dir, safe_name)
f.save(dest)
saved.append(safe_name)
return jsonify({"files": saved, "count": len(saved)})
@app.route("/api/scan", methods=["POST"])
def scan():
data = request.get_json()
target = data.get("target", "")
recursive = data.get("recursive", True)
context = data.get("context", "OSINT")
if not target:
upload_dir = app.config["UPLOAD_FOLDER"]
if os.path.isdir(upload_dir) and os.listdir(upload_dir):
target = upload_dir
else:
return jsonify({"error": "No files to scan. Upload files first."}), 400
with scan_lock:
scan_progress["status"] = "scanning"
scan_progress["total"] = 0
scan_progress["current"] = 0
scan_progress["current_file"] = ""
def progress_callback(current, total, filepath):
with scan_lock:
scan_progress["current"] = current
scan_progress["total"] = total
scan_progress["current_file"] = filepath
def run_scan():
global results_store
try:
results = scan_target(target, recursive, context, progress_callback=progress_callback)
results_store = results
with scan_lock:
scan_progress["status"] = "completed"
scan_progress["current_file"] = ""
except Exception as e:
with scan_lock:
scan_progress["status"] = "error"
scan_progress["current_file"] = str(e)
thread = threading.Thread(target=run_scan)
thread.daemon = True
thread.start()
return jsonify({"status": "started"})
@app.route("/api/scan/progress")
def get_scan_progress():
with scan_lock:
p = dict(scan_progress)
return jsonify(p)
@app.route("/api/results")
def get_results():
out = []
for r in results_store:
out.append({
"path": r.path,
"file_type": r.file_type,
"size_bytes": r.size_bytes,
"sha256": r.sha256,
"metadata": r.metadata,
"context": r.context,
"total_risk": r.total_risk,
"level": r.level,
"findings": [{"name": f.name, "severity": f.severity, "evidence": f.evidence} for f in r.findings],
})
return jsonify({"results": out, "count": len(out)})
@app.route("/api/result/<int:index>")
def get_result(index):
if 0 <= index < len(results_store):
r = results_store[index]
return jsonify({
"path": r.path,
"file_type": r.file_type,
"size_bytes": r.size_bytes,
"sha256": r.sha256,
"metadata": r.metadata,
"context": r.context,
"total_risk": r.total_risk,
"level": r.level,
"findings": [{"name": f.name, "severity": f.severity, "evidence": f.evidence} for f in r.findings],
})
return jsonify({"error": "Not found"}), 404
@app.route("/api/sanitize/<int:index>", methods=["POST"])
def sanitize(index):
if 0 <= index < len(results_store):
r = results_store[index]
if r.file_type != "image":
return jsonify({"error": "Sanitization supported only for image files"}), 400
try:
clean_path = sanitize_file(r.path, r.file_type)
return jsonify({"sanitized_path": clean_path})
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"error": "Not found"}), 404
@app.route("/api/export/<fmt>")
def export(fmt):
if not results_store:
return jsonify({"error": "No results to export. Run scan first."}), 400
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f".{fmt}")
tmp.close()
try:
if fmt == "json":
export_json(results_store, tmp.name)
elif fmt == "csv":
export_csv(results_store, tmp.name)
elif fmt == "html":
export_html(results_store, tmp.name)
else:
return jsonify({"error": f"Unknown format: {fmt}"}), 400
return send_file(tmp.name, as_attachment=True, download_name=f"report.{fmt}")
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
@app.route("/api/clear", methods=["POST"])
def clear():
global results_store
results_store = []
upload_dir = app.config["UPLOAD_FOLDER"]
if os.path.isdir(upload_dir):
shutil.rmtree(upload_dir)
os.makedirs(upload_dir, exist_ok=True)
return jsonify({"status": "cleared"})
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
debug = os.environ.get("DEBUG", "1") == "1"
print(f"MetaGuardSOT starting on http://127.0.0.1:{port}")
app.run(host="0.0.0.0", port=port, debug=debug, threaded=True)

423
core.py
View File

@ -1,423 +0,0 @@
# core.py
from __future__ import annotations
import os
import hashlib
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image
import exifread
from PyPDF2 import PdfReader
from docx import Document
import openpyxl
from pptx import Presentation
@dataclass
class Finding:
name: str
severity: str
evidence: str
@dataclass
class ScanResult:
path: str
file_type: str
size_bytes: int
sha256: str
metadata: Dict[str, Any]
context: str
total_risk: int
level: str
findings: List[Finding]
def sha256_file(path: str, chunk_size: int = 1024 * 1024) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(chunk_size), b""):
h.update(chunk)
return h.hexdigest()
def detect_file_type(path: str) -> str:
ext = os.path.splitext(path.lower())[1]
if ext in [".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp"]:
return "image"
if ext == ".pdf":
return "pdf"
if ext == ".docx":
return "docx"
if ext == ".xlsx":
return "xlsx"
if ext == ".pptx":
return "pptx"
if ext in [".txt", ".log", ".md"]:
return "text"
return "unknown"
def normalize_dt(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, str):
v = value.strip()
return v if v else None
return str(value)
def _convert_gps(values, ref: str) -> Optional[float]:
try:
d = float(values[0].num) / float(values[0].den)
m = float(values[1].num) / float(values[1].den)
s = float(values[2].num) / float(values[2].den)
coord = d + (m / 60.0) + (s / 3600.0)
if ref in ["S", "W"]:
coord = -coord
return coord
except Exception:
return None
def extract_gps(tags: Dict[str, Any]) -> Optional[Dict[str, Any]]:
lat = tags.get("GPS GPSLatitude")
lat_ref = tags.get("GPS GPSLatitudeRef")
lon = tags.get("GPS GPSLongitude")
lon_ref = tags.get("GPS GPSLongitudeRef")
if not (lat and lat_ref and lon and lon_ref):
return None
lat_val = _convert_gps(lat.values, str(lat_ref))
lon_val = _convert_gps(lon.values, str(lon_ref))
if lat_val is None or lon_val is None:
return None
return {"lat": lat_val, "lon": lon_val}
def extract_image_metadata(path: str) -> Dict[str, Any]:
meta: Dict[str, Any] = {}
try:
with Image.open(path) as img:
meta["image_format"] = img.format
meta["image_mode"] = img.mode
meta["image_size"] = {"width": img.size[0], "height": img.size[1]}
except Exception as e:
meta["image_open_error"] = str(e)
try:
with open(path, "rb") as f:
tags = exifread.process_file(f, details=True)
meta["all_exif_tags"] = {str(k): str(v) for k, v in tags.items()}
meta["author"] = (
str(tags.get("Image Artist")) if tags.get("Image Artist") else
str(tags.get("EXIF Artist")) if tags.get("EXIF Artist") else
str(tags.get("Artist")) if tags.get("Artist") else
str(tags.get("Image XPAuthor")) if tags.get("Image XPAuthor") else
None
)
meta["camera_make"] = str(tags.get("Image Make")) if tags.get("Image Make") else None
meta["camera_model"] = str(tags.get("Image Model")) if tags.get("Image Model") else None
meta["software"] = str(tags.get("Image Software")) if tags.get("Image Software") else None
meta["datetime_original"] = (
str(tags.get("EXIF DateTimeOriginal")) if tags.get("EXIF DateTimeOriginal") else None
)
meta["datetime"] = (
str(tags.get("Image DateTime")) if tags.get("Image DateTime") else None
)
meta["serial_number"] = (
str(tags.get("EXIF BodySerialNumber")) if tags.get("EXIF BodySerialNumber") else
str(tags.get("Image SerialNumber")) if tags.get("Image SerialNumber") else
str(tags.get("MakerNote SerialNumber")) if tags.get("MakerNote SerialNumber") else
None
)
meta["owner_name"] = (
str(tags.get("MakerNote OwnerName")) if tags.get("MakerNote OwnerName") else
str(tags.get("EXIF OwnerName")) if tags.get("EXIF OwnerName") else
None
)
gps = extract_gps(tags)
if gps:
meta["gps"] = gps
except Exception as e:
meta["exif_error"] = str(e)
return meta
def sanitize_image_metadata(path: str) -> str:
base, ext = os.path.splitext(path)
clean_path = f"{base}_sanitized{ext}"
with Image.open(path) as img:
data = list(img.getdata())
clean_img = Image.new(img.mode, img.size)
clean_img.putdata(data)
if img.format and img.format.upper() in ["JPEG", "JPG"]:
clean_img.save(clean_path, format="JPEG", quality=95)
elif img.format and img.format.upper() == "PNG":
clean_img.save(clean_path, format="PNG")
else:
clean_img.save(clean_path)
return clean_path
def extract_pdf_metadata(path: str) -> Dict[str, Any]:
meta: Dict[str, Any] = {}
try:
r = PdfReader(path)
info = r.metadata
meta["pages"] = len(r.pages)
if info:
meta["author"] = str(info.get("/Author")) if info.get("/Author") else None
meta["title"] = str(info.get("/Title")) if info.get("/Title") else None
meta["creator"] = str(info.get("/Creator")) if info.get("/Creator") else None
meta["producer"] = str(info.get("/Producer")) if info.get("/Producer") else None
meta["created"] = normalize_dt(info.get("/CreationDate"))
meta["modified"] = normalize_dt(info.get("/ModDate"))
meta["keywords"] = str(info.get("/Keywords")) if info.get("/Keywords") else None
meta["subject"] = str(info.get("/Subject")) if info.get("/Subject") else None
except Exception as e:
meta["pdf_error"] = str(e)
return meta
def extract_docx_metadata(path: str) -> Dict[str, Any]:
meta: Dict[str, Any] = {}
try:
doc = Document(path)
core = doc.core_properties
meta["author"] = getattr(core, "author", None)
meta["last_modified_by"] = getattr(core, "last_modified_by", None)
meta["title"] = getattr(core, "title", None)
meta["subject"] = getattr(core, "subject", None)
meta["keywords"] = getattr(core, "keywords", None)
meta["created"] = normalize_dt(getattr(core, "created", None))
meta["modified"] = normalize_dt(getattr(core, "modified", None))
except Exception as e:
meta["docx_error"] = str(e)
return meta
def extract_xlsx_metadata(path: str) -> Dict[str, Any]:
meta: Dict[str, Any] = {}
try:
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
props = wb.properties
meta["author"] = getattr(props, "creator", None)
meta["last_modified_by"] = getattr(props, "lastModifiedBy", None)
meta["title"] = getattr(props, "title", None)
meta["subject"] = getattr(props, "subject", None)
meta["keywords"] = getattr(props, "keywords", None)
meta["created"] = normalize_dt(getattr(props, "created", None))
meta["modified"] = normalize_dt(getattr(props, "modified", None))
wb.close()
except Exception as e:
meta["xlsx_error"] = str(e)
return meta
def extract_pptx_metadata(path: str) -> Dict[str, Any]:
meta: Dict[str, Any] = {}
try:
prs = Presentation(path)
props = prs.core_properties
meta["author"] = getattr(props, "author", None)
meta["last_modified_by"] = getattr(props, "last_modified_by", None)
meta["title"] = getattr(props, "title", None)
meta["subject"] = getattr(props, "subject", None)
meta["keywords"] = getattr(props, "keywords", None)
meta["created"] = normalize_dt(getattr(props, "created", None))
meta["modified"] = normalize_dt(getattr(props, "modified", None))
except Exception as e:
meta["pptx_error"] = str(e)
return meta
def extract_text_metadata(path: str) -> Dict[str, Any]:
meta: Dict[str, Any] = {}
try:
st = os.stat(path)
meta["mtime"] = datetime.fromtimestamp(st.st_mtime).isoformat()
meta["ctime"] = datetime.fromtimestamp(st.st_ctime).isoformat()
except Exception as e:
meta["text_error"] = str(e)
return meta
def normalize_metadata(file_type: str, meta: Dict[str, Any]) -> Dict[str, Any]:
norm: Dict[str, Any] = {}
norm["author"] = meta.get("author")
norm["serial_number"] = meta.get("serial_number")
norm["owner_name"] = meta.get("owner_name")
norm["last_modified_by"] = meta.get("last_modified_by")
norm["title"] = meta.get("title")
norm["subject"] = meta.get("subject")
norm["keywords"] = meta.get("keywords")
norm["software"] = meta.get("software") or meta.get("creator") or meta.get("producer")
norm["created"] = meta.get("created") or meta.get("datetime_original")
norm["modified"] = meta.get("modified") or meta.get("datetime")
if "gps" in meta:
norm["gps"] = meta["gps"]
if file_type == "image":
norm["camera_make"] = meta.get("camera_make")
norm["camera_model"] = meta.get("camera_model")
norm["image_format"] = meta.get("image_format")
norm["image_size"] = meta.get("image_size")
if file_type == "pdf":
norm["pages"] = meta.get("pages")
return norm
IMPACT = {
"GPS": 4,
"EMAIL_PHONE": 4,
"USERNAME_AUTHOR": 3,
"PATH": 3,
"DEVICE_ID": 4,
"DEVICE_MODEL": 2,
"SOFTWARE": 2,
"TIMESTAMPS": 1,
"TITLE_KEYWORDS": 2,
"COMPANY_FIELDS": 3,
}
LIKELIHOOD = {
"OSINT": {
"GPS": 4, "EMAIL_PHONE": 4, "USERNAME_AUTHOR": 3, "PATH": 2, "DEVICE_ID": 3,
"DEVICE_MODEL": 3, "SOFTWARE": 3, "TIMESTAMPS": 2, "TITLE_KEYWORDS": 3, "COMPANY_FIELDS": 3,
},
"INTERNAL": {
"GPS": 2, "EMAIL_PHONE": 3, "USERNAME_AUTHOR": 4, "PATH": 4, "DEVICE_ID": 2,
"DEVICE_MODEL": 2, "SOFTWARE": 2, "TIMESTAMPS": 2, "TITLE_KEYWORDS": 3, "COMPANY_FIELDS": 4,
}
}
def _has_email_like(s: Optional[str]) -> bool:
if not s:
return False
return "@" in s and "." in s
def evaluate_risk(norm: Dict[str, Any], context: str) -> Tuple[int, str, List[Finding]]:
ctx = "OSINT" if context == "OSINT" else "INTERNAL"
L = LIKELIHOOD[ctx]
findings: List[Finding] = []
total = 0
has_gps = isinstance(norm.get("gps"), dict) and "lat" in norm["gps"] and "lon" in norm["gps"]
if has_gps:
score = IMPACT["GPS"] * L["GPS"]
total += score
findings.append(Finding("GPS_LOCATION_PRESENT", "HIGH", f"{norm['gps']} (score={score})"))
authorish = norm.get("author") or norm.get("last_modified_by")
if authorish:
score = IMPACT["USERNAME_AUTHOR"] * L["USERNAME_AUTHOR"]
total += score
findings.append(Finding("AUTHOR_OR_USER_PRESENT", "MEDIUM", f"{authorish} (score={score})"))
software = norm.get("software")
if software:
score = IMPACT["SOFTWARE"] * L["SOFTWARE"]
total += score
findings.append(Finding("SOFTWARE_FINGERPRINT", "LOW", f"{software} (score={score})"))
device_model = norm.get("camera_model")
if device_model:
score = IMPACT["DEVICE_MODEL"] * L["DEVICE_MODEL"]
total += score
findings.append(Finding("DEVICE_MODEL_PRESENT", "MEDIUM", f"{device_model} (score={score})"))
title_kw = (norm.get("title") or "") + " " + (norm.get("keywords") or "")
if title_kw.strip():
score = IMPACT["TITLE_KEYWORDS"] * L["TITLE_KEYWORDS"]
total += score
findings.append(Finding("TITLE_OR_KEYWORDS_PRESENT", "LOW", f"(score={score})"))
if has_gps and authorish:
findings.append(Finding("CRITICAL_COMBO", "HIGH", "GPS + Author/User detected"))
total += 33
if total <= 19:
level = "LOW"
elif total <= 39:
level = "MEDIUM"
elif total <= 69:
level = "HIGH"
else:
level = "CRITICAL"
return total, level, findings
def extract_by_type(path: str, ftype: str) -> Dict[str, Any]:
if ftype == "image":
return extract_image_metadata(path)
if ftype == "pdf":
return extract_pdf_metadata(path)
if ftype == "docx":
return extract_docx_metadata(path)
if ftype == "xlsx":
return extract_xlsx_metadata(path)
if ftype == "pptx":
return extract_pptx_metadata(path)
if ftype == "text":
return extract_text_metadata(path)
return {}
def sanitize_file(path: str, ftype: str) -> str:
if ftype == "image":
return sanitize_image_metadata(path)
raise ValueError(f"Sanitization is not supported for file type: {ftype}")
def iter_files(target: str, recursive: bool) -> List[str]:
if os.path.isfile(target):
return [target]
res: List[str] = []
for root, dirs, files in os.walk(target):
for fn in files:
res.append(os.path.join(root, fn))
if not recursive:
break
return res
def scan_target(target: str, recursive: bool, context: str, progress_callback=None) -> List[ScanResult]:
files = iter_files(target, recursive)
results: List[ScanResult] = []
total = len(files)
for i, p in enumerate(files):
if progress_callback:
progress_callback(i + 1, total, p)
ftype = detect_file_type(p)
size = os.path.getsize(p) if os.path.exists(p) else -1
sha = sha256_file(p)
raw = extract_by_type(p, ftype)
norm = normalize_metadata(ftype, raw)
total_risk, level, findings = evaluate_risk(norm, context)
results.append(ScanResult(
path=p,
file_type=ftype,
size_bytes=size,
sha256=sha,
metadata=norm,
context=context,
total_risk=total_risk,
level=level,
findings=findings
))
return results

110
design.md
View File

@ -1,110 +0,0 @@
<!-- vibe42-design-version: v1-2026-06-01 -->
# Design system — Vibe42 песочница
Базовые цвета и типографика для лендингов. Можно отклоняться, но начинай с этого.
## Палитра
| Token | Hex | Использование |
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
## Типографика
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
```
| Уровень | Размер | Вес | line-height |
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
| small | 14px | 400 | 1.5 |
На мобиле — h1 уменьши до 36px, h2 до 28px.
## Лейаут
- max-width контента: **1140px** (контейнер с padding по бокам)
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- gap между блоками внутри секции: `24-32px`
- border-radius: `8px` (кнопки, карточки), `16px` (большие карточки)
## Кнопки
```css
.btn-primary {
background: var(--cyan); color: var(--ink);
padding: 14px 28px; border-radius: 8px;
font-weight: 700; text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: transparent; color: var(--ink);
border: 2px solid var(--ink);
padding: 12px 26px; border-radius: 8px;
}
```
## Стартер `index.html`
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title>
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
*{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.btn:hover{background:#1be5ff}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
</style>
</head>
<body>
<section class="hero">
<div class="container">
<h1>Заголовок проекта</h1>
<p>Подзаголовок — пара предложений о чём это.</p>
<a class="btn" href="#section">Начать</a>
</div>
</section>
<section id="section" class="section">
<div class="container">
<h2>Секция</h2>
<div class="card">Контент карточки.</div>
<div class="card">Контент карточки.</div>
</div>
</section>
</body>
</html>
```
## Чем НЕ пользоваться
- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS более чем достаточно
## Чем МОЖНО (если очень надо)
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>` — для прототипа OK
- **Lottie animations через CDN**
- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400`
- **Шрифты Google Fonts через `<link>`** в head

View File

@ -1,94 +0,0 @@
# reporters.py
from __future__ import annotations
import csv
import json
from dataclasses import asdict
from typing import List
from datetime import datetime
from core import ScanResult
def export_json(results: List[ScanResult], out_path: str) -> None:
payload = []
for r in results:
d = asdict(r)
payload.append(d)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def export_csv(results: List[ScanResult], out_path: str) -> None:
fields = [
"path","file_type","size_bytes","sha256","context",
"total_risk","level","author","last_modified_by","software",
"created","modified","gps_lat","gps_lon","title","keywords"
]
with open(out_path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for r in results:
gps = r.metadata.get("gps") if isinstance(r.metadata.get("gps"), dict) else {}
row = {
"path": r.path,
"file_type": r.file_type,
"size_bytes": r.size_bytes,
"sha256": r.sha256,
"context": r.context,
"total_risk": r.total_risk,
"level": r.level,
"author": r.metadata.get("author"),
"last_modified_by": r.metadata.get("last_modified_by"),
"software": r.metadata.get("software"),
"created": r.metadata.get("created"),
"modified": r.metadata.get("modified"),
"gps_lat": gps.get("lat"),
"gps_lon": gps.get("lon"),
"title": r.metadata.get("title"),
"keywords": r.metadata.get("keywords"),
}
w.writerow(row)
def export_html(results: List[ScanResult], out_path: str) -> None:
now = datetime.now().isoformat(sep=" ", timespec="seconds")
rows = []
for r in results:
gps = r.metadata.get("gps") if isinstance(r.metadata.get("gps"), dict) else {}
rows.append(f"""
<tr>
<td>{r.level}</td>
<td>{r.total_risk}</td>
<td>{r.file_type}</td>
<td style="max-width:420px; word-break:break-all;">{r.path}</td>
<td>{r.metadata.get("author","") or ""}</td>
<td>{gps.get("lat","")}</td>
<td>{gps.get("lon","")}</td>
<td>{r.metadata.get("software","") or ""}</td>
</tr>
""")
html = f"""
<html><head><meta charset="utf-8">
<title>Metadata Risk Report</title>
<style>
body {{ font-family: Arial, sans-serif; padding: 16px; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ccc; padding: 8px; font-size: 14px; }}
th {{ background: #f3f3f3; }}
</style>
</head><body>
<h2>Metadata Risk Report</h2>
<div>Generated: {now}</div>
<table>
<thead>
<tr>
<th>Level</th><th>Score</th><th>Type</th><th>Path</th>
<th>Author/User</th><th>GPS Lat</th><th>GPS Lon</th><th>Software</th>
</tr>
</thead>
<tbody>
{''.join(rows)}
</tbody>
</table>
</body></html>
"""
with open(out_path, "w", encoding="utf-8") as f:
f.write(html)

View File

@ -1,7 +0,0 @@
flask>=3.0
Pillow>=10.0
exifread>=3.0
PyPDF2>=3.0
python-docx>=1.0
openpyxl>=3.1
python-pptx>=0.6

42
run.bat
View File

@ -1,42 +0,0 @@
@echo off
chcp 65001 >nul
title MetaGuardSOT
echo ============================================
echo MetaGuardSOT - Metadata Risk Scanner
echo ============================================
echo.
where python >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ОШИБКА] Python не найден!
echo.
echo Скачай и установи Python с сайта:
echo https://www.python.org/downloads/
echo.
echo ВАЖНО: при установке поставь галочку "Add Python to PATH"
echo.
echo После установки перезапусти этот файл.
echo.
pause
exit /b 1
)
echo [1/3] Устанавливаю зависимости...
python -m pip install -r requirements.txt
if %ERRORLEVEL% neq 0 (
echo [ОШИБКА] Не удалось установить зависимости.
pause
exit /b 1
)
echo OK
echo [2/3] Запускаю сервер...
echo.
echo Открой в браузере: http://127.0.0.1:5000
echo Закрой это окно, чтобы остановить сервер.
echo.
start http://127.0.0.1:5000
python app.py
pause

View File

@ -1,347 +0,0 @@
let selectedFiles = [];
let scanResults = [];
let selectedIndex = -1;
let sortField = null;
let sortDir = 'asc';
const fileInput = document.getElementById('fileInput');
const uploadInfo = document.getElementById('uploadInfo');
const resultsBody = document.getElementById('resultsBody');
const detailsContent = document.getElementById('detailsContent');
const emptyState = document.getElementById('emptyState');
const btnScan = document.getElementById('btnScan');
const btnSanitize = document.getElementById('btnSanitize');
const statusText = document.getElementById('statusText');
const dropZone = document.getElementById('dropZone');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
fileInput.addEventListener('change', handleFileSelect);
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
handleFileSelect();
}
});
document.querySelectorAll('#resultsTable th.sortable').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = 'asc';
}
document.querySelectorAll('#resultsTable th.sortable').forEach(h => {
h.classList.remove('asc', 'desc');
});
th.classList.add(sortDir);
renderTable();
});
});
function handleFileSelect() {
selectedFiles = Array.from(fileInput.files);
if (selectedFiles.length === 0) {
uploadInfo.textContent = 'No files selected';
} else if (selectedFiles.length === 1) {
uploadInfo.textContent = selectedFiles[0].name;
} else {
uploadInfo.textContent = `${selectedFiles.length} files selected`;
}
}
async function uploadFiles() {
if (!selectedFiles.length) return false;
setStatus('Uploading files...');
const formData = new FormData();
for (const file of selectedFiles) {
formData.append('files', file);
}
try {
const resp = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
setStatus('Upload failed: ' + data.error);
return false;
}
setStatus(`Uploaded ${data.count} file(s)`);
return true;
} catch (e) {
setStatus('Upload failed: ' + e.message);
return false;
}
}
async function startScan() {
const context = document.getElementById('context').value;
const recursive = document.getElementById('recursive').checked;
const uploaded = await uploadFiles();
if (!uploaded && !selectedFiles.length) {
setStatus('No files to scan. Select files first.');
return;
}
btnScan.disabled = true;
btnScan.textContent = 'Scanning...';
progressBar.style.display = 'flex';
progressFill.style.width = '0%';
progressText.textContent = 'Starting...';
try {
const resp = await fetch('/api/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: '', recursive, context })
});
const data = await resp.json();
if (data.error) {
setStatus('Scan failed: ' + data.error);
btnScan.disabled = false;
btnScan.textContent = 'Scan';
progressBar.style.display = 'none';
return;
}
await pollProgress();
await fetchResults();
} catch (e) {
setStatus('Scan failed: ' + e.message);
btnScan.disabled = false;
btnScan.textContent = 'Scan';
progressBar.style.display = 'none';
}
}
async function pollProgress() {
let done = false;
while (!done) {
await new Promise(r => setTimeout(r, 300));
try {
const resp = await fetch('/api/scan/progress');
const p = await resp.json();
const pct = p.total > 0 ? Math.round((p.current / p.total) * 100) : 0;
progressFill.style.width = Math.min(pct, 100) + '%';
const name = p.current_file ? p.current_file.split('/').pop().split('\\').pop() : '';
progressText.textContent = name
? `${p.current} / ${p.total} - ${name}`
: `${p.current} / ${p.total}`;
if (p.status === 'completed' || p.status === 'error') {
progressFill.style.width = p.status === 'completed' ? '100%' : progressFill.style.width;
progressText.textContent = p.status === 'completed'
? `Complete - ${p.total} file(s)`
: 'Error: ' + (p.current_file || 'unknown');
done = true;
await new Promise(r => setTimeout(r, 500));
}
} catch (e) {
done = true;
}
}
}
async function fetchResults() {
try {
const resp = await fetch('/api/results');
const data = await resp.json();
if (data.error) {
setStatus('Failed to load results: ' + data.error);
} else {
scanResults = data.results;
selectedIndex = -1;
sortField = null;
sortDir = 'asc';
document.querySelectorAll('#resultsTable th.sortable').forEach(h => h.classList.remove('asc', 'desc'));
renderTable();
setStatus(`Scan complete: ${data.count} file(s) analyzed`);
}
} catch (e) {
setStatus('Failed to load results: ' + e.message);
} finally {
btnScan.disabled = false;
btnScan.textContent = 'Scan';
setTimeout(() => { progressBar.style.display = 'none'; }, 1000);
}
}
function getSortValue(r, field) {
switch (field) {
case 'level': {
const order = { 'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3 };
return order[r.level] ?? 99;
}
case 'score': return r.total_risk;
case 'type': return r.file_type || '';
case 'path': return r.path || '';
case 'author': return (r.metadata.author || r.metadata.last_modified_by || '').toLowerCase();
default: return '';
}
}
function renderTable() {
resultsBody.innerHTML = '';
emptyState.style.display = scanResults.length ? 'none' : 'block';
let items = scanResults.map((r, i) => ({ r, i }));
if (sortField) {
items.sort((a, b) => {
const va = getSortValue(a.r, sortField);
const vb = getSortValue(b.r, sortField);
if (typeof va === 'number' && typeof vb === 'number') {
return sortDir === 'asc' ? va - vb : vb - va;
}
const cmp = String(va).localeCompare(String(vb));
return sortDir === 'asc' ? cmp : -cmp;
});
}
items.forEach(({ r, i }) => {
const tr = document.createElement('tr');
tr.dataset.index = i;
const author = r.metadata.author || r.metadata.last_modified_by || '';
tr.innerHTML = `
<td class="level-${r.level}">${r.level}</td>
<td class="level-${r.level}">${r.total_risk}</td>
<td>${r.file_type}</td>
<td class="path-cell" title="${escapeHtml(r.path)}">${escapeHtml(r.path)}</td>
<td>${escapeHtml(author)}</td>
`;
tr.addEventListener('click', () => selectRow(i));
resultsBody.appendChild(tr);
});
}
function selectRow(index) {
selectedIndex = index;
const r = scanResults[index];
document.querySelectorAll('#resultsBody tr').forEach(tr => {
tr.classList.toggle('selected', parseInt(tr.dataset.index) === index);
});
btnSanitize.disabled = r.file_type !== 'image';
let html = '';
html += `<span class="level-${r.level}"><b>${r.level}</b> (Score: ${r.total_risk})</span>\n\n`;
html += `<b>Path:</b> ${escapeHtml(r.path)}\n`;
html += `<b>Type:</b> ${r.file_type}\n`;
html += `<b>Size:</b> ${formatSize(r.size_bytes)}\n`;
html += `<b>SHA-256:</b> ${r.sha256}\n`;
html += `<b>Context:</b> ${r.context}\n\n`;
html += `<b>Metadata:</b>\n`;
for (const [k, v] of Object.entries(r.metadata)) {
if (k === 'all_exif_tags') continue;
if (typeof v === 'object' && v !== null) {
html += ` ${k}: ${JSON.stringify(v)}\n`;
} else {
html += ` ${k}: ${v}\n`;
}
}
if (r.findings && r.findings.length) {
html += `\n<b>Findings:</b>\n`;
r.findings.forEach(f => {
html += ` <span class="finding-${f.severity}">${f.severity}</span> | ${f.name} | ${f.evidence}\n`;
});
}
detailsContent.innerHTML = html;
}
async function exportReport(fmt) {
if (!scanResults.length) {
setStatus('No results to export. Run scan first.');
return;
}
setStatus(`Exporting ${fmt.toUpperCase()}...`);
try {
const resp = await fetch(`/api/export/${fmt}`);
if (!resp.ok) {
const data = await resp.json();
setStatus('Export failed: ' + (data.error || resp.statusText));
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `report.${fmt}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus(`Exported ${fmt.toUpperCase()} successfully`);
} catch (e) {
setStatus('Export failed: ' + e.message);
}
}
async function sanitizeFile() {
if (selectedIndex < 0) {
setStatus('Select a file first.');
return;
}
const r = scanResults[selectedIndex];
if (r.file_type !== 'image') {
setStatus('Sanitization supported only for image files.');
return;
}
setStatus('Sanitizing...');
try {
const resp = await fetch(`/api/sanitize/${selectedIndex}`, { method: 'POST' });
const data = await resp.json();
if (data.error) {
setStatus('Sanitization failed: ' + data.error);
} else {
setStatus(`Sanitized copy created: ${data.sanitized_path}`);
}
} catch (e) {
setStatus('Sanitization failed: ' + e.message);
}
}
function setStatus(msg) {
statusText.textContent = msg;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@ -1,414 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0b0f14;
--bg-secondary: #0f141b;
--bg-tertiary: #161b22;
--border: #2d333b;
--gridline: #21262d;
--text-primary: #e6edf3;
--text-secondary: #c9d1d9;
--text-muted: #6e7681;
--accent: #2f81f7;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: baseline;
gap: 12px;
}
header h1 {
font-size: 16px;
font-weight: 600;
}
header .subtitle {
color: var(--text-muted);
font-size: 12px;
}
main {
flex: 1;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.controls {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.upload-area {
display: flex;
align-items: center;
gap: 12px;
}
.upload-info {
color: var(--text-secondary);
font-size: 13px;
}
.scan-controls {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.progress-bar-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar-track {
flex: 1;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: var(--accent);
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-text {
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
min-width: 100px;
text-align: right;
}
.checkbox-label input[type="checkbox"] {
accent-color: var(--accent);
}
.context-selector {
display: flex;
align-items: center;
gap: 8px;
}
.context-selector label {
color: var(--text-secondary);
}
select {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
cursor: pointer;
}
select:focus {
border-color: var(--accent);
}
.btn {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover {
background: #1f2630;
}
.btn:disabled {
color: var(--text-muted);
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: #388bfd;
}
.btn-scan {
background: #238636;
border-color: #2ea043;
color: #fff;
}
.btn-scan:hover {
background: #2ea043;
}
.btn-scan:disabled {
background: var(--bg-tertiary);
border-color: var(--border);
color: var(--text-muted);
}
.btn-export {
background: var(--bg-secondary);
}
.btn-sanitize {
background: #3d1e00;
border-color: #7e4b1a;
color: #f0a030;
}
.btn-sanitize:hover:not(:disabled) {
background: #4a2500;
}
.btn-sanitize:disabled {
color: var(--text-muted);
border-color: var(--border);
}
.results-container {
flex: 1;
display: flex;
gap: 16px;
min-height: 0;
overflow: hidden;
}
.table-section {
flex: 2;
display: flex;
flex-direction: column;
min-width: 0;
}
.table-wrapper {
flex: 1;
overflow: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
position: sticky;
top: 0;
z-index: 1;
}
th {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background: #1f2630;
}
th.sortable::after {
content: ' \25B4\25BE';
font-size: 10px;
opacity: 0.3;
}
th.sortable.asc::after {
content: ' \25B4';
opacity: 0.8;
}
th.sortable.desc::after {
content: ' \25BE';
opacity: 0.8;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--gridline);
white-space: nowrap;
}
tr {
cursor: pointer;
transition: background 0.1s;
}
tr:hover {
background: rgba(47, 129, 247, 0.08);
}
tr.selected {
background: rgba(47, 129, 247, 0.15);
}
td.path-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 40px 20px;
font-size: 14px;
}
.details-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 280px;
max-width: 400px;
}
.details-section h3 {
font-size: 14px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.details-content {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
overflow: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.export-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.status-bar {
background: var(--bg-tertiary);
border-top: 1px solid var(--border);
padding: 6px 20px;
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.status-bar::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.level-LOW {
color: var(--green);
}
.level-MEDIUM {
color: var(--yellow);
}
.level-HIGH {
color: var(--red);
}
.level-CRITICAL {
color: var(--red);
font-weight: bold;
}
.finding-LOW {
color: var(--text-muted);
}
.finding-MEDIUM {
color: var(--yellow);
}
.finding-HIGH {
color: var(--red);
}
@media (max-width: 900px) {
.results-container {
flex-direction: column;
}
.details-section {
max-width: none;
}
}

View File

@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MetaGuardSOT</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>MetaGuardSOT</h1>
<p class="subtitle">Metadata Risk Scanner</p>
</header>
<main>
<section class="controls">
<div class="upload-area" id="dropZone">
<input type="file" id="fileInput" multiple hidden>
<button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">
Select Files
</button>
<span class="upload-info" id="uploadInfo">No files selected</span>
</div>
<div class="scan-controls">
<label class="checkbox-label">
<input type="checkbox" id="recursive" checked>
Recursive
</label>
<div class="context-selector">
<label for="context">Context:</label>
<select id="context">
<option value="OSINT">OSINT</option>
<option value="INTERNAL">INTERNAL</option>
</select>
</div>
<button class="btn btn-scan" id="btnScan" onclick="startScan()">Scan</button>
</div>
</section>
<div class="progress-bar-container" id="progressBar" style="display:none">
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<span class="progress-text" id="progressText">0 / 0</span>
</div>
<div class="results-container">
<section class="table-section">
<div class="table-wrapper">
<table id="resultsTable">
<thead>
<tr>
<th data-sort="level" class="sortable">Level</th>
<th data-sort="score" class="sortable">Score</th>
<th data-sort="type" class="sortable">Type</th>
<th data-sort="path" class="sortable">Path</th>
<th data-sort="author" class="sortable">Author/User</th>
</tr>
</thead>
<tbody id="resultsBody">
</tbody>
</table>
</div>
<p class="empty-state" id="emptyState">Upload files and click Scan to begin</p>
</section>
<section class="details-section">
<h3>Details</h3>
<div class="details-content" id="detailsContent">
<p class="empty-state">Select a row to view details</p>
</div>
<div class="export-buttons">
<button class="btn btn-export" onclick="exportReport('json')">Export JSON</button>
<button class="btn btn-export" onclick="exportReport('csv')">Export CSV</button>
<button class="btn btn-export" onclick="exportReport('html')">Export HTML</button>
<button class="btn btn-sanitize" id="btnSanitize" onclick="sanitizeFile()" disabled>Sanitize Copy</button>
</div>
</section>
</div>
</main>
<div class="status-bar" id="statusBar">
<span id="statusText">Ready</span>
</div>
<script src="/static/script.js"></script>
</body>
</html>

View File

View File

@ -1 +0,0 @@
python3

View File

@ -1 +0,0 @@
/usr/bin/python3

View File

@ -1 +0,0 @@
python3

View File

@ -1 +0,0 @@
lib

View File

@ -1,5 +0,0 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /srv/opencode/workspaces/users/sot/MetaGuardSOT/venv