Compare commits
No commits in common. "main" and "archive" have entirely different histories.
173
AGENTS.md
173
AGENTS.md
@ -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
BIN
MetaGuardSOT.zip
Normal file
Binary file not shown.
14
README.md
14
README.md
@ -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.
Binary file not shown.
205
app.py
205
app.py
@ -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
423
core.py
@ -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
110
design.md
@ -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
|
||||
94
reporters.py
94
reporters.py
@ -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)
|
||||
@ -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
42
run.bat
@ -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
|
||||
347
static/script.js
347
static/script.js
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
414
static/style.css
414
static/style.css
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -1 +0,0 @@
|
||||
python3
|
||||
@ -1 +0,0 @@
|
||||
/usr/bin/python3
|
||||
@ -1 +0,0 @@
|
||||
python3
|
||||
@ -1 +0,0 @@
|
||||
lib
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user