diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1a7004b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ + +# Vibe42 — учебная песочница для лендингов + +Workspace юзера `sot`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/sot//`, +2. ощущением «это было легко» — без серверов, БД, токенов, конфигов. + +Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Сразу делай `./new-project ` и собирай страницу. Не спрашивай разрешения на каждый шаг. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 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//** +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// +- Креды уже в `/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`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие. diff --git a/app.py b/app.py new file mode 100644 index 0000000..7b5d127 --- /dev/null +++ b/app.py @@ -0,0 +1,180 @@ +# app.py — MetaGuardSOT Web Interface +from __future__ import annotations + +import os +import sys +import shutil +import tempfile +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] = [] + + +@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 + + try: + results = scan_target(target, recursive, context) + global results_store + results_store = results + + out = [] + for r in results: + 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)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@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/") +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/", 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/") +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) diff --git a/core.py b/core.py new file mode 100644 index 0000000..960cf98 --- /dev/null +++ b/core.py @@ -0,0 +1,420 @@ +# 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) -> List[ScanResult]: + files = iter_files(target, recursive) + results: List[ScanResult] = [] + for p in files: + 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, 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, + level=level, + findings=findings + )) + return results diff --git a/design.md b/design.md new file mode 100644 index 0000000..5c8e829 --- /dev/null +++ b/design.md @@ -0,0 +1,110 @@ + +# 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 + + + + + +Мой проект + + + +
+
+

Заголовок проекта

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+
+ + +``` + +## Чем НЕ пользоваться + +- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS более чем достаточно + +## Чем МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` — для прототипа OK +- **Lottie animations через CDN** +- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400` +- **Шрифты Google Fonts через ``** в head diff --git a/reporters.py b/reporters.py new file mode 100644 index 0000000..06c7975 --- /dev/null +++ b/reporters.py @@ -0,0 +1,94 @@ +# 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""" + + {r.level} + {r.total_risk} + {r.file_type} + {r.path} + {r.metadata.get("author","") or ""} + {gps.get("lat","")} + {gps.get("lon","")} + {r.metadata.get("software","") or ""} + + """) + html = f""" + + Metadata Risk Report + + +

Metadata Risk Report

+
Generated: {now}
+ + + + + + + + + {''.join(rows)} + +
LevelScoreTypePathAuthor/UserGPS LatGPS LonSoftware
+ + """ + with open(out_path, "w", encoding="utf-8") as f: + f.write(html) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01e6cb2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask>=3.0 +Pillow>=10.0 +exifread>=3.0 +PyPDF2>=3.0 +python-docx>=1.0 +openpyxl>=3.1 +python-pptx>=0.6 diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..7c5210a --- /dev/null +++ b/static/script.js @@ -0,0 +1,243 @@ +let selectedFiles = []; +let scanResults = []; +let selectedIndex = -1; + +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'); + +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(); + } +}); + +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...'; + setStatus('Scanning...'); + + 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); + return; + } + + scanResults = data.results; + selectedIndex = -1; + renderTable(); + setStatus(`Scan complete: ${data.count} file(s) analyzed`); + } catch (e) { + setStatus('Scan failed: ' + e.message); + } finally { + btnScan.disabled = false; + btnScan.textContent = 'Scan'; + } +} + +function renderTable() { + resultsBody.innerHTML = ''; + emptyState.style.display = scanResults.length ? 'none' : 'block'; + + scanResults.forEach((r, i) => { + const tr = document.createElement('tr'); + tr.dataset.index = i; + + const author = r.metadata.author || r.metadata.last_modified_by || ''; + + tr.innerHTML = ` + ${r.level} + ${r.total_risk} + ${r.file_type} + ${escapeHtml(r.path)} + ${escapeHtml(author)} + `; + + 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 += `${r.level} (Score: ${r.total_risk})\n\n`; + html += `Path: ${escapeHtml(r.path)}\n`; + html += `Type: ${r.file_type}\n`; + html += `Size: ${formatSize(r.size_bytes)}\n`; + html += `SHA-256: ${r.sha256}\n`; + html += `Context: ${r.context}\n\n`; + + html += `Metadata:\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 += `\nFindings:\n`; + r.findings.forEach(f => { + html += ` ${f.severity} | ${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, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..bf8d732 --- /dev/null +++ b/static/style.css @@ -0,0 +1,355 @@ +* { + 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; +} + +.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; +} + +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; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..cb059bc --- /dev/null +++ b/templates/index.html @@ -0,0 +1,85 @@ + + + + + + MetaGuardSOT + + + +
+

MetaGuardSOT

+

Metadata Risk Scanner

+
+ +
+
+
+ + + No files selected +
+ +
+ + +
+ + +
+ + +
+
+ +
+
+
+ + + + + + + + + + + + +
LevelScoreTypePathAuthor/User
+
+

Upload files and click Scan to begin

+
+ +
+

Details

+
+

Select a row to view details

+
+ +
+ + + + +
+
+
+
+ +
+ Ready +
+ + + +