Compare commits

..

12 Commits

Author SHA1 Message Date
248fce5655 adding changelog to pages/routes 2025-09-03 13:17:33 -05:00
d5cc9df699 added cloudflare detection / badge on results page 2025-09-03 10:11:47 -05:00
b59bf67329 added notes to settings.yaml
moved core app config (name, version) out of settings and into app/app_settings.py
added ability to brand SneakyScope to any name
added caching of cert information from crt.sh (cache enable and lenght is configurable in settings.yaml)

streamlined header/footer loading to be more correct
2025-08-23 20:37:44 -05:00
5af8513e14 updating roadmap and internal docs 2025-08-23 16:12:03 -05:00
f20d51b152 fixing typo in roadmap 2025-08-22 17:24:32 -05:00
2fd6f9d705 updating change log 2025-08-22 17:23:10 -05:00
55cd81aec0 feat(text): add text analysis pipeline & surface results in UI
- engine: add analyse_text() to extract visible page text and evaluate
  category="text" rules; collect matched phrases and expose as
  `content_snippet` (deduped, length-capped via settings.ui.snippet_preview_len).
- engine: removed unused code
- browser: removed double call for enrichment
- engine: improve regex compilation — honor per-rule flags (string or list)
  and default IGNORECASE when category=="text".
- engine: add dispatch logging "[engine] applying categories: …" gated by
  settings.app.print_rule_dispatch.
- ui(templates): add `templates/partials/result_text.html` mirroring the forms
  table; renders page-level records and their matched rules.
- ui(controller): wire `analyse_text()` into scan path and expose
  `payload["suspicious_text"]`.
- rules(text): add `identity_verification_prompt`, `gated_document_access`,
  `email_collection_prompt`; broaden `credential_reset`.

fix: text indicators were not displayed due to missing analyzer and mismatched result shape.

Result shape:
  suspicious_text: [
    {
      "type": "page",
      "content_snippet": "...matched phrases…",
      "rules": [
        {"name": "...", "description": "...", "severity": "medium", "tags": ["..."]}
      ]
    }
  ]
2025-08-22 17:18:50 -05:00
af253c858c updating change log 2025-08-22 15:11:32 -05:00
cd30cde946 feat(roadmap): YAML-driven roadmap + Tailwind UI w/ filters & details modal
- Convert roadmap to YAML:
  - Add data structure: id, priority, title, goal, tags, milestone
  - Add `details` field (supports list or block string); populated initial content
  - Quote scalars and use explicit nulls to avoid YAML parse edge cases
  - Update `updated` date to 2025-08-22

- Flask blueprint + loader:
  - New /roadmap view with section switching (roadmap | backlog | open_questions)
  - Filters: q (search), tag (multi, AND), min_priority, milestone
  - Dataclasses: RoadmapData/RoadmapItem; include `details`
  - `_normalize_details()` to accept string or list, normalize to list[str]
  - Configurable path via `ROADMAP_FILE` (env or defaults)
  - Remove cache layer for simplicity

- UI (Tailwind):
  - `templates/roadmap.html` with responsive cards, tag chips, and filter form
  - Details modal (larger max width, scrollable body) showing ID/goal/priority/tags/milestone
  - Safe JSON payload to modal via `|tojson|forceescape`

- JS:
  - DOM-ready, event-delegated handler for `data-item` buttons
  - Populate modal fields and render multi-paragraph details

- Fixes & polish:
  - Resolved YAML `ScannerError` by quoting strings with `:` and `#`
  - Ensured `details` is passed through route to template and included in button payload
  - Minor styling tweaks for consistency with Tailwind setup

Usage:
- Set `ROADMAP_FILE` if not using default path
- Visit /roadmap and filter via q/tag/min_priority/milestone
2025-08-22 15:05:09 -05:00
9cc2f8183c refactor(templates): extract sections to includes; feat(css): add reusable components; fix(tables): lock column widths & stop snippet reflow
- Move large sections into partials:
  - forms → templates/_include_forms.html
  - scripts → templates/_include_scripts.html (if applicable)
- Add Tailwind component classes in assets/input.css:
  - .badge + variants (.badge-ok, .badge-warn, .badge-danger, .badge-muted, .badge-info, .badge-success, .badge-success-solid)
  - .chip
  - .card
- Override .border-gray-800 to fixed color (no opacity var)
- Stabilize table layouts:
  - Use table-fixed + <colgroup> with percentage widths
  - Forms cols: 10% / 10% / 15% / 45% / 25%
  - Scripts cols: 10% / 20% / 45% / 25%
  - Remove inner fixed-width wrapper from snippet cells; use w-full + wrapping to prevent column jitter
- Update templates to use new badge/chip classes
2025-08-22 12:55:46 -05:00
dbd7cb31c7 fixed the rules engine not being pulled from the application state after refactor earlier 2025-08-22 10:52:35 -05:00
469334d137 feat(ui): migrate to Tailwind (compiled) + Flowbite JS; new navbar/layout; Docker CSS build
- Add multi-stage CSS build that compiles Tailwind into app/static/tw.css
- Add Tailwind config with dark tokens (bg/nav/card) and purge globs
- Add assets/input.css (@tailwind base/components/utilities + small utilities)
- Replace Tailwind CDN + REMOVE Flowbite CSS (keep Flowbite JS only)
- New base_tailwind.html (top navbar, responsive container, {%- block scripts -%})
- Port pages to Tailwind look/feel with wider content column:
  - index: single-column form + recent results, fullscreen spinner overlay, copy-UUID
  - result: sticky jump list, Tailwind tables/badges, Suspicious Scripts/Forms sections
  - viewer: Monaco-based code viewer in Tailwind card, actions (copy/wrap/raw)
  - ssl_tls macro: rewritten with Tailwind (details/summary for raw JSON)
- Dockerfile: add css-builder stage and copy built tw.css into /app/app/static
- Remove Flowbite stylesheet to avoid overrides; Flowbite JS loaded with defer

BREAKING CHANGE:
Legacy CSS classes/components (.card, .badge, etc.) are replaced by Tailwind utilities.
All templates now expect tw.css to be served from /static.
2025-08-22 10:36:10 -05:00
37 changed files with 2804 additions and 1427 deletions

View File

@@ -1,3 +1,24 @@
# --- Stage 1: CSS builder (no npm) ---
FROM alpine:3.20 AS css-builder
WORKDIR /css
RUN apk add --no-cache curl
# Download Tailwind standalone CLI
# (Update version if desired; linux-x64 works on Alpine)
RUN curl -sL https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.10/tailwindcss-linux-x64 \
-o /usr/local/bin/tailwindcss && chmod +x /usr/local/bin/tailwindcss
# Config + sources
COPY tailwind/tailwind.config.js ./
COPY assets ./assets
COPY app/templates ./app/templates
COPY app/static ./app/static
# Build Tailwind CSS
RUN tailwindcss -i ./assets/input.css -o ./tw.css --minify
# --- Stage 2: Playwright python image with requirements.
# Use the official Playwright image with browsers preinstalled # Use the official Playwright image with browsers preinstalled
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
@@ -19,6 +40,9 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code (the double app is needed because the app folder needs to be inside the app folder) # Copy application code (the double app is needed because the app folder needs to be inside the app folder)
COPY app/ /app/app/ COPY app/ /app/app/
# Bring in the compiled CSS from Stage 1
COPY --from=css-builder /css/tw.css /app/app/static/tw.css
COPY entrypoint.sh ./entrypoint.sh COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh

View File

@@ -43,7 +43,6 @@ SneakyScope fetches a page in a sandbox, enriches with WHOIS/GeoIP, and runs a u
* **Playwright** for headless page fetch/render * **Playwright** for headless page fetch/render
* **BeautifulSoup4** for parsing * **BeautifulSoup4** for parsing
* **Rules Engine** * **Rules Engine**
* YAML regex rules (`config/suspicious_rules.yaml`) * YAML regex rules (`config/suspicious_rules.yaml`)
* Function rules (`app/rules/function_rules.py`) registered on startup * Function rules (`app/rules/function_rules.py`) registered on startup
* **Artifacts**: persistent path mounted at `/data` (configurable) * **Artifacts**: persistent path mounted at `/data` (configurable)

View File

@@ -2,13 +2,19 @@ import os
import logging import logging
from pathlib import Path from pathlib import Path
from flask import Flask from flask import Flask
from datetime import datetime
# Local imports # Local imports
from .utils.settings import get_settings from app.utils.settings import get_settings
from .logging_setup import wire_logging_once, get_app_logger, get_engine_logger from app.logging_setup import wire_logging_once, get_app_logger
from app.app_settings import AppSettings
from app.blueprints.main import bp as main_bp # ui blueprint
from app.blueprints.api import api_bp as api_bp # api blueprint
from app.blueprints.roadmap import bp as roadmap_bp # roadmap
from app.blueprints.changelog import bp as changelog_bp # changelog
from app.blueprints import ui # ui blueprint
from app.blueprints import api # api blueprint
def create_app() -> Flask: def create_app() -> Flask:
""" """
@@ -33,17 +39,43 @@ def create_app() -> Flask:
if not app.secret_key: if not app.secret_key:
app_logger.warning("[init] SECRET_KEY is not set; sessions may be insecure in production.") app_logger.warning("[init] SECRET_KEY is not set; sessions may be insecure in production.")
# version
version = f"v{AppSettings.version_major}.{AppSettings.version_minor}"
# allow branding for name if they don't match our name
branded_name = settings.branding.name
if branded_name == AppSettings.name:
public_name = AppSettings.name
footer = f"{AppSettings.copyright} {public_name} {version} - {AppSettings.tagline}"
else:
public_name = f"{branded_name}"
link = f'<a href="{AppSettings.url}" target="_blank">{AppSettings.name}</a>'
footer = f"{AppSettings.copyright} {public_name} powered by {link} {version} - {AppSettings.tagline}"
# web header / footer
header = f"{public_name}"
# App metadata available to templates
app.config["APP_NAME"] = public_name
app.config["APP_VERSION"] = version
app.config["WEB_HEADER"] = header
app.config["WEB_FOOTER"] = footer
# roadmap file
app.config["ROADMAP_FILE"] = str(Path(app.root_path) / "docs" / "roadmap.yaml")
app.config["CHANGELOG_FILE"] = str(Path(app.root_path) / "docs" / "changelog.yaml")
# Configure storage directory (bind-mount is still handled by sandbox.sh) # Configure storage directory (bind-mount is still handled by sandbox.sh)
sandbox_storage_default = Path("/data") sandbox_storage_default = Path("/data")
app.config["SANDBOX_STORAGE"] = str(sandbox_storage_default) app.config["SANDBOX_STORAGE"] = str(sandbox_storage_default)
# App metadata available to templates
app.config["APP_NAME"] = settings.app.name
app.config["APP_VERSION"] = f"v{settings.app.version_major}.{settings.app.version_minor}"
# Register blueprints # Register blueprints
app.register_blueprint(ui.bp) app.register_blueprint(main_bp)
app.register_blueprint(api.api_bp) app.register_blueprint(api_bp)
app.register_blueprint(roadmap_bp)
app.register_blueprint(changelog_bp)
app_logger = get_app_logger() app_logger = get_app_logger()

13
app/app_settings.py Normal file
View File

@@ -0,0 +1,13 @@
from dataclasses import dataclass
from datetime import datetime
this_year = datetime.strftime(datetime.now(),"%Y")
@dataclass
class AppSettings:
name: str = "SneakyScope"
tagline: str = "A selfhosted URL Sandbox"
url: str = "https://git.sneakygeek.net/ptarrant/SneakyScope"
copyright: str = f"© 2025 - {this_year}"
version_major: int = 1
version_minor: int = 0

View File

@@ -0,0 +1,71 @@
# app/services/changelog_loader.py
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional, Dict
import yaml
from flask import Blueprint, current_app, render_template
@dataclass
class ChangeItem:
title: str
details: List[str]
@dataclass
class VersionLog:
version: str
features: List[ChangeItem]
refactors: List[ChangeItem]
fixes: List[ChangeItem]
notes: List[str]
@dataclass
class Changelog:
unreleased: Dict[str, List[ChangeItem]]
versions: List[VersionLog]
def _coerce_items(items: Optional[List[Dict[str, Any]]]) -> List[ChangeItem]:
out: List[ChangeItem] = []
for it in items or []:
title = str(it.get("title", "")).strip()
details = [str(d) for d in (it.get("details") or [])]
out.append(ChangeItem(title=title, details=details))
return out
def load_changelog(path: Path) -> Changelog:
"""
Load changelog.yaml and coerce into dataclasses.
"""
data = yaml.safe_load(path.read_text(encoding="utf-8"))
unreleased = {
"features": _coerce_items(data.get("unreleased", {}).get("features")),
"refactors": _coerce_items(data.get("unreleased", {}).get("refactors")),
"fixes": _coerce_items(data.get("unreleased", {}).get("fixes")),
}
versions: List[VersionLog] = []
for v in data.get("versions", []):
versions.append(
VersionLog(
version=str(v.get("version")),
features=_coerce_items(v.get("features")),
refactors=_coerce_items(v.get("refactors")),
fixes=_coerce_items(v.get("fixes")),
notes=[str(n) for n in (v.get("notes") or [])],
)
)
return Changelog(unreleased=unreleased, versions=versions)
bp = Blueprint("changelog", __name__)
@bp.route("/changelog")
def view_changelog():
# Configurable path with sensible default at project root
cfg_path = current_app.config.get("CHANGELOG_FILE")
path = Path(cfg_path) if cfg_path else (Path(current_app.root_path).parent / "changelog.yaml")
changelog = load_changelog(path)
return render_template("changelog.html", changelog=changelog)

View File

@@ -1,6 +1,5 @@
# app/blueprints/ui.py # app/blueprints/ui.py
import os
import json import json
import asyncio import asyncio
from pathlib import Path from pathlib import Path
@@ -9,18 +8,16 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from app.utils.url_tools import get_url_normalizer from app.utils.url_tools import get_url_normalizer
from app.utils.browser import get_browser from app.utils.browser import get_browser
from app.utils.enrichment import enrich_url
from app.utils.settings import get_settings from app.utils.settings import get_settings
from app.utils.io_helpers import get_recent_results from app.utils.io_helpers import get_recent_results
from app.logging_setup import get_app_logger from app.logging_setup import get_app_logger
app_logger = get_app_logger() app_logger = get_app_logger()
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
settings = get_settings() settings = get_settings()
app_name = settings.app.name
app_version = f"v {settings.app.version_major}.{settings.app.version_minor}"
# --- data cleaner for tls to ensure data is standardized # --- data cleaner for tls to ensure data is standardized
@@ -68,8 +65,9 @@ def normalize_ssl_tls_for_view(ssl_tls):
def inject_app_info(): def inject_app_info():
"""Inject app name and version into all templates.""" """Inject app name and version into all templates."""
return { return {
"app_name": app_name, "app_name": current_app.config.get("APP_NAME", "SneakyScope"),
"app_version": app_version "header": current_app.config.get("WEB_HEADER", "SneakyScope"),
"footer": current_app.config.get("WEB_FOOTER", "SneakyScope"),
} }
@bp.route("/", methods=["GET"]) @bp.route("/", methods=["GET"])
@@ -114,7 +112,7 @@ def analyze():
app_logger.warning("Empty or invalid URL input") app_logger.warning("Empty or invalid URL input")
return redirect(url_for("index")) return redirect(url_for("index"))
app_logger.info(f"[*] Analyzing URL{target}") app_logger.info(f"[*] Analyzing URL {target}")
app_logger.info(f"[*] SSL Checks set to {fetch_ssl_enabled}") app_logger.info(f"[*] SSL Checks set to {fetch_ssl_enabled}")
if not target: if not target:
@@ -133,15 +131,6 @@ def analyze():
app_logger.error(f"Analysis failed for {url}: {e}") app_logger.error(f"Analysis failed for {url}: {e}")
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
# Add enrichment safely
try:
enrichment = enrich_url(url)
result["enrichment"] = enrichment
app_logger.info(f"[+] Enrichment added for {url}")
except Exception as e:
result["enrichment"] = {}
app_logger.warning(f"[!] Enrichment failed for {url}: {e}")
# Redirect to permalink page for this run # Redirect to permalink page for this run
return redirect(url_for("main.view_result", run_uuid=result["uuid"])) return redirect(url_for("main.view_result", run_uuid=result["uuid"]))

173
app/blueprints/roadmap.py Normal file
View File

@@ -0,0 +1,173 @@
"""
Roadmap view: loads data/roadmap.yaml, sorts and renders with filters.
Query params (all optional):
- q=... (substring search over title/goal/id)
- tag=tag1&tag=tag2 (multi; include if item has ALL selected tags)
- min_priority=1..9 (int; keep items with priority >= this)
- milestone=v0.2 (string; exact match on milestone)
- section=roadmap|backlog|open_questions (default=roadmap)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import time
import yaml
from flask import Blueprint, render_template, request, abort, current_app
from app.logging_setup import get_app_logger
logger = get_app_logger()
bp = Blueprint("roadmap", __name__)
@dataclass
class RoadmapItem:
id: str
title: str
goal: str
tags: List[str] = field(default_factory=list)
priority: Optional[int] = None
milestone: Optional[str] = None
details: List[str] = field(default_factory=list)
@dataclass
class RoadmapData:
updated: Optional[str]
roadmap: List[RoadmapItem]
backlog: List[RoadmapItem]
open_questions: List[RoadmapItem]
def _normalize_details(val) -> List[str]:
# Accept string (block scalar) or list of strings; normalize to list[str]
if not val:
return []
if isinstance(val, str):
# split on blank lines while preserving paragraphs
parts = [p.strip() for p in val.strip().split("\n\n")]
return [p for p in parts if p]
if isinstance(val, list):
return [str(x) for x in val if str(x).strip()]
# Fallback: stringify unknown types
return [str(val)]
def _to_items(raw: List[Dict[str, Any]]) -> List[RoadmapItem]:
items: List[RoadmapItem] = []
for obj in raw or []:
items.append(
RoadmapItem(
id=str(obj.get("id", "")),
title=str(obj.get("title", "")),
goal=str(obj.get("goal", "")),
tags=list(obj.get("tags", []) or []),
priority=obj.get("priority"),
milestone=obj.get("milestone"),
details=_normalize_details(obj.get("details")),
)
)
return items
def load_roadmap() -> RoadmapData:
"""Load YAML and return structured RoadmapData (no caching)."""
path = Path(current_app.config.get("ROADMAP_FILE"))
with path.open("r", encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
return RoadmapData(
updated=raw.get("updated"),
roadmap=_to_items(raw.get("roadmap", [])),
backlog=_to_items(raw.get("backlog", [])),
open_questions=_to_items(raw.get("open_questions", [])),
)
def _apply_filters(
items: List[RoadmapItem],
query: str,
tags: List[str],
min_priority: Optional[int],
milestone: Optional[str],
) -> List[RoadmapItem]:
def matches(item: RoadmapItem) -> bool:
# text search over id/title/goal
if query:
hay = f"{item.id} {item.title} {item.goal}".lower()
if query not in hay:
return False
# tag filter (AND)
if tags:
if not set(tags).issubset(set(item.tags)):
return False
# min priority
if min_priority is not None and item.priority is not None:
if item.priority < min_priority:
return False
# milestone
if milestone:
if (item.milestone or "").strip() != milestone.strip():
return False
return True
# sort: priority asc (None last), then title
def sort_key(i: RoadmapItem) -> Tuple[int, str]:
pri = i.priority if i.priority is not None else 9999
return (pri, i.title.lower())
return sorted([i for i in items if matches(i)], key=sort_key)
def _collect_all_tags(data: RoadmapData) -> List[str]:
seen = set()
for col in (data.roadmap, data.backlog, data.open_questions):
for i in col:
for t in i.tags:
seen.add(t)
return sorted(seen)
@bp.route("/roadmap")
def roadmap_view():
data = load_roadmap()
# which column?
section = request.args.get("section", "roadmap")
if section not in {"roadmap", "backlog", "open_questions"}:
abort(400, "invalid section")
# filters
q = (request.args.get("q") or "").strip().lower()
tags = request.args.getlist("tag")
min_priority = request.args.get("min_priority")
milestone = request.args.get("milestone") or None
try:
min_priority_val = int(min_priority) if min_priority else None
except ValueError:
min_priority_val = None
# pick list + filter
source = getattr(data, section)
items = _apply_filters(source, q, tags, min_priority_val, milestone)
# tag universe for sidebar chips
all_tags = _collect_all_tags(data)
return render_template(
"roadmap.html",
updated=data.updated,
section=section,
items=items,
all_tags=all_tags,
q=q,
selected_tags=tags,
min_priority=min_priority_val,
milestone=milestone,
)

View File

@@ -1,19 +1,46 @@
app: branding:
name: SneakyScope # you can brand your sandbox to anything you want
version_major: 0 name: Redwire Sandbox
version_minor: 1
print_rule_loads: True logging:
# logs when rules are loaded
log_rule_loads: False
# logs each category of rule ran
log_rule_dispatch: False
# logs rule pass/fail per rule
log_rule_debug: False
cache: cache:
# number of recent runs shown on front page
recent_runs_count: 10 recent_runs_count: 10
# how long to cache whois information
whois_cache_days: 7 whois_cache_days: 7
# how long to cache geoip information
geoip_cache_days: 7 geoip_cache_days: 7
# should we cache crt certificate pulls?
crt_cache_enabled: True
# how long to cache certificate information (if above is true)
crt_cache_days: 7
external_script_fetch: external_script_fetch:
# enable ability to pull external scripts
enabled: True enabled: True
# max mb of script to pull if pulling
max_total_mb: 5 max_total_mb: 5
# max time to wait for script to pull if pulling
max_time_ms: 3000 max_time_ms: 3000
# max redirects for external scripts pull if pulling
max_redirects: 3 max_redirects: 3
ui: ui:
# how many char to show in a snippet preview in the gui
snippet_preview_len: 300 snippet_preview_len: 300

View File

@@ -96,39 +96,49 @@
severity: high severity: high
tags: [credentials, form] tags: [credentials, form]
# --- Text Rules (Social Engineering / BEC) --- # --- Text Rules (Social Engineering / BEC / Lures) ---
- name: urgent_request
description: "Language suggesting urgency (common in phishing/BEC)"
category: text
type: regex
pattern: '\b(urgent|immediately|asap|action\s*required|verify\s*now)\b'
severity: medium
tags: [bec, urgency]
- name: account_suspension - name: identity_verification_prompt
description: "Threat of account suspension/closure" description: "Prompts to verify identity/account/email, often gating access"
category: text category: text
type: regex type: regex
pattern: '\b(account\s*(suspend|closure|close)|verify\s*account)\b' # e.g., "verify your identity", "confirm your email", "validate account"
pattern: '\b(verify|confirm|validate)\s+(?:your\s+)?(identity|account|email)\b'
flags: [i]
severity: medium severity: medium
tags: [bec, scare-tactics] tags: [bec, verification, gating]
- name: financial_request - name: gated_document_access
description: "Request for gift cards, wire transfer, or money" description: "Language gating document access behind an action"
category: text category: text
type: regex type: regex
pattern: '\b(gift\s*card|wire\s*transfer|bank\s*account|bitcoin|crypto|payment\s*required)\b' # e.g., "access your secure document", "unlock document", "view document" + action verbs nearby
severity: high pattern: '(secure|confidential)\s+document|access\s+(?:the|your)?\s*document|unlock\s+document'
tags: [bec, financial] flags: [i]
severity: medium
tags: [lure, document]
- name: email_collection_prompt
description: "Explicit prompt to enter/provide an email address to proceed"
category: text
type: regex
# e.g., "enter your email address", "provide email", "use your email to continue"
pattern: '\b(enter|provide|use)\s+(?:your\s+)?email\s+(?:address)?\b'
flags: [i]
severity: low
tags: [data-collection, email]
- name: credential_reset - name: credential_reset
description: "Password reset or credential reset wording" description: "Password/credential reset or login-to-continue wording"
category: text category: text
type: regex type: regex
pattern: '\b(reset\s*password|update\s*credentials|log\s*in\s*to\s*verify|password\s*expiry)\b' # includes: reset password, update credentials, log in to (verify|view|access), password expiry/expiration
pattern: '\b(reset\s*password|update\s*credentials|log\s*in\s*to\s*(?:verify|view|access)|password\s*(?:expiry|expiration|expires))\b'
flags: [i]
severity: medium severity: medium
tags: [bec, credentials] tags: [bec, credentials]
- name: suspicious_iframe - name: suspicious_iframe
description: "Iframe tag present (possible phishing/malvertising/drive-by)" description: "Iframe tag present (possible phishing/malvertising/drive-by)"
category: text category: text

80
app/docs/changelog.yaml Normal file
View File

@@ -0,0 +1,80 @@
# changelog.yaml
unreleased:
features: []
refactors: []
fixes: []
versions:
- version: "v0.2"
features:
- title: "UI Modernization"
details:
- "Migrated front-end to Tailwind CSS (compiled) with Flowbite JS components."
- "New navbar and layout system; better navigation and future expansion."
- "Docker-based CSS build for reproducible, lightweight builds."
- title: "Reusable CSS Components"
details:
- "Custom utilities: badge, badge-ok, badge-warn, badge-danger, chip, card, etc."
- "Reduces repetition and enforces consistent look."
- title: "Roadmap / Changelog (YAML-driven + in-app UI)"
details:
- "YAML-backed roadmap, in-app view at `/roadmap`."
- "Roadmap Filters: q, tag, min_priority, milestone; tag chips; Details modal that renders `details`."
- "YAML-backed Changelog, in-app view at `/changelog`."
- title: "Modal sizing & ergonomics"
details:
- "Wider modal at larger breakpoints; scrollable body for long content."
- title: "GeoIP Results Uplift"
details:
- "Cloudflare detection via GeoIP ASN; badge on results page."
- "Country/ASN notes shown beside collapsed IP next to GeoIP results."
- title: "Text Analysis Pipeline (Rules)"
details:
- "`analyse_text()` extracts visible text and evaluates `category: text` rules."
- "Captures matched phrases into deduped `content_snippet` (len capped via `settings.ui.snippet_preview_len`)."
- "Results exposed in JSON as `suspicious_text`; UI via `templates/partials/result_text.html`."
refactors:
- title: "Template Includes"
details:
- "Common UI (headers/footers/layout) extracted into Jinja includes."
- title: "Roadmap loader simplification"
details:
- "Removed cache; returns typed dataclasses and normalizes `details`."
- title: "Safer JSON in templates"
details:
- "Use `|tojson|forceescape` for embedding payloads in data attributes."
- title: "Rules Engine Regex handling"
details:
- "Honor per-rule regex flags; default IGNORECASE for `category: text` if no `i` flag."
- title: "Engine/Scanner logging"
details:
- "Dispatch-time visibility; gated by `settings.app.print_rule_dispatch`."
- title: "Code cleanup"
details:
- "Removed obsolete paths/utilities; removed duplicate `enrich_url` call."
fixes:
- title: "Table Rendering"
details:
- "Locked column widths; fixed snippet scaling to prevent reflow."
- title: "Rules Engine State"
details:
- "Fix pulling engine from app state; restores proper detections."
- title: "YAML parsing edge cases"
details:
- "Quote scalars containing `:`/`#`; use explicit `null` as needed."
- title: "/roadmap page stability"
details:
- "Return structured objects; fix `AttributeError: 'dict' object has no attribute 'roadmap'`."
- title: "Modal population"
details:
- "Pass `details` through route; DOM-ready + delegation populate reliably."
- title: "Text indicators not displayed"
details:
- "Add text analyzer; align result shape with `result_text` partial."
- version: "v0.1"
notes:
- "Initial Flask web UI for URL submission and analysis."
- "Domain & IP enrichment (WHOIS, GeoIP, ASN/ISP)."
- "First Suspicious Rules Engine for scripts/forms."
- "Basic Docker setup for sandboxed deployment."

211
app/docs/roadmap.yaml Normal file
View File

@@ -0,0 +1,211 @@
# roadmap.yaml
updated: "2025-08-22"
roadmap:
- id: "p1-analysis-total-score"
priority: 1
title: "Total Score"
goal: "Implement a generalized site “Total Score” (010 scale) to give analysts a quick risk snapshot."
tags: ["analysis"]
milestone: null
details:
- "Inputs: TLS posture, suspicious scripts/forms (severity-weighted), domain/IP reputation, server headers/misconfigs."
- "Method: weighted components with neutral defaults when data is unavailable; avoid over-penalizing partial signals."
- "Explainability: always show a breakdown and contribution per component; include a 'Why?' link in the UI."
- "Calibration: start with heuristic weights, then calibrate on a test set; store weights in settings.yaml."
- id: "p2-ui-rules-lab"
priority: 2
title: "Rules Lab"
goal: "Build a WYSIWYG Rules Lab (paste, validate, run against sample text)."
tags: ["ui"]
milestone: null
details:
- "Features: syntax-highlighted editor, rule validation, run against sample payloads, show matches/captures, timing."
- "Samples: ship a small library of example texts and rules; allow users to save their own samples (local storage)."
- "Safety: no external network calls; size/time limits to prevent runaway regex; clear error messages."
- "UX: one-click copy of rule JSON; link to docs on rule schema."
- id: "p2-ui-usage-page"
priority: 2
title: "Usage Page"
goal: "Create a “Usage” page to explain app functionality."
tags: ["ui","docs"]
milestone: null
details:
- "Content: quickstart, supported analyses, cache vs. re-run behavior, artifact locations."
- "Include: screenshots/GIFs, API curl examples, link to OpenAPI docs."
- "Notes: clarify privacy, what we store, and retention defaults."
- id: "move-changelog-into-app"
priority: 2
title: "Move Changelog into App"
goal: "Moves Changelog into App"
tags: ["ui","docs"]
milestone: null
details:
- "Notes:Makes it much easier for users to see what's happening"
- "Content: changelog.md already in docs."
- id: "p2-ui-about-page"
priority: 2
title: "About Page"
goal: "Create an “About” page with project context."
tags: ["ui","docs"]
milestone: null
details:
- "Content: project purpose, high-level architecture diagram, technology stack."
- "Meta: version, commit hash, build date; link to repo and roadmap."
- "Governance: disclaimer about intended use and limitations."
- id: "p3-api-core-endpoints"
priority: 3
title: "Core Endpoints"
goal: "Add `/screenshot`, `/source`, and `/analyse` endpoints."
tags: ["api"]
milestone: null
details:
- "Define request/response schemas; include run_id in responses to tie artifacts together."
- "Auth: simple token header; rate-limiting per token."
- "Errors: standardized JSON error body; consistent HTTP codes."
- "Docs: provide curl examples; note synchronous vs. long-running behavior."
- id: "p3-api-analyze-script"
priority: 3
title: "Analyze Script Endpoint"
goal: "Add POST /api/analyze_script in OpenAPI and serve /api/openapi.yaml."
tags: ["api"]
milestone: null
details:
- "Request: raw script text or URL; size cap; optional rule-set selection."
- "Processing: run rules engine; return matched rule names, severities, and excerpts."
- "Artifacts: store hashed script with metadata; include reference in response."
- "Validation: reject binary content; enforce content-type and max size."
- id: "p3-api-docs-ui"
priority: 3
title: "API Docs UI"
goal: "Provide interactive docs (Swagger UI or Redoc) at /api-docs."
tags: ["api"]
milestone: null
details:
- "Serve OpenAPI from /api/openapi.yaml; auto-refresh on rebuild."
- "Swagger UI 'try it out' toggle; disable in prod if needed."
- "Theming to match app; link to Usage page for context."
- id: "p3-api-json-errors"
priority: 3
title: "JSON Error Consistency"
goal: "Ensure JSON error consistency across 400500 responses."
tags: ["api", "nice-to-have"]
milestone: null
details:
- "Schema: {\"error\": {\"code\": int, \"message\": str, \"details\": object, \"correlation_id\": str}}."
- "Implement Flask error handlers; return JSON for 400/403/404/405/500."
- "Log: include correlation_id in logs; surface it in responses for support."
- id: "p4-ops-retention-policy"
priority: 4
title: "Retention Policy"
goal: "Define retention thresholds for artifacts (age/size)."
tags: ["ops"]
milestone: null
details:
- "Policy: max age per artifact type; total size caps per workspace."
- "Configuration: settings.yaml-driven; per-type overrides."
- "Safety: dry-run mode and deletion preview; minimum free space guard."
- id: "p4-ops-cleanup-scripts"
priority: 4
title: "Cleanup Scripts"
goal: "Implement cleanup/maintenance scripts, driven by settings.yaml."
tags: ["ops"]
milestone: null
details:
- "CLI: list, simulate, prune; log summary of bytes reclaimed and items removed."
- "Scheduling: optional cron/apscheduler task; lock to prevent concurrent runs."
- "Observability: emit metrics (counts, durations) to logs."
- id: "p4-ops-results-cache"
priority: 4
title: "Results Cache"
goal: "Add UX toggle: “Re-run analysis” vs. “Load from cache.”"
tags: ["ops"]
milestone: null
details:
- "Cache key: normalized URL + analysis settings; include versioning to bust on rule changes."
- "UI: clearly label cached vs. fresh; provide 'Invalidate cache' action."
- "TTL: setting-driven; guard against stale security results."
- id: "p5-intel-domain-reputation"
priority: 5
title: "Domain Reputation"
goal: "Build consolidated reputation store (URLHaus, OpenPhish)."
tags: ["intel"]
milestone: null
details:
- "Ingestion: scheduled pulls; parse feeds; dedupe and normalize indicators."
- "Storage: compact on-disk DB (e.g., sqlite/duckdb) keyed by domain/URL with timestamps."
- "Use: query during analysis; add context to findings with source + first_seen/last_seen."
- id: "p5-intel-threat-connectors"
priority: 5
title: "Threat Intel Connectors"
goal: "Add connectors for VirusTotal, ThreatFox, and future providers (via settings.yaml)."
tags: ["intel"]
milestone: null
details:
- "Config: enable per-connector with API keys via settings.yaml or env."
- "Runtime: rate limiting and backoff; cache responses to reduce cost/latency."
- "Merge: normalize verdicts and confidence; avoid double-counting against Total Score."
backlog:
- id: "backlog-scan-server-profile"
title: "Server Profile Scan"
goal: "Run lightweight nmap scan on web/alt ports, merge with headers for stack inference."
tags: ["scan"]
milestone: null
details:
- "Scope: common ports (80,443,8000,8080,8443,22); banner grab only; conservative timing."
- "Inference: combine banners + headers to guess stack (IIS vs. nginx/Apache)."
- "Controls: opt-in, with time and port limits to avoid noisy scans."
- id: "backlog-intel-ip-reputation"
title: "IP Reputation Expansion"
goal: "Expand reputation checks to IP blocklists and datasets."
tags: ["intel"]
milestone: null
details:
- "Sources: community blocklists with permissive licenses; document any commercial sources separately."
- "Model: score IPs with decay over time; avoid permanent penalties for stale abuse."
- "Integration: surface as context; do not overrule domain-level signals."
- id: "cache-crt-results"
title: "cache the crt results as a setting"
goal: "Create setting that allows CRT results to be cached for domains"
tags: ["intel"]
milestone: null
details:
- "Setting: Need to be created"
- "Model: Store the result in a way that can be pulled for root domain, and fall back to subdomain if needed"
open_questions:
- id: "design-imports-unification"
title: "Imports Unification"
goal: "Decide if imports/utilities (e.g., decorators) should be centralized in state.py."
tags: ["design"]
milestone: null
details:
- "Pros: consistent imports, fewer circular references, easier testing."
- "Cons: can become a god-module; hidden dependencies."
- "Proposal: a small 'core/state.py' for app-wide state + 'utils/' packages for helpers."
- id: "design-score-calibration"
title: "Score Calibration"
goal: "Define and calibrate methodology for the Total Score scale."
tags: ["design"]
milestone: null
details:
- "Dataset: assemble a labeled set of benign/suspicious sites for tuning."
- "Approach: start with manual weights, then fit via simple regression or grid search."
- "Outcome: publish thresholds for low/medium/high along with examples."

View File

@@ -34,18 +34,33 @@ def build_rules_engine() -> RuleEngine:
def add(rule: Rule): def add(rule: Rule):
eng.add_rule(rule) eng.add_rule(rule)
add(Rule("form_action_missing", "Form has no action attribute", "form", "function", # Form no action
FunctionRuleAdapter(form_action_missing, category="form", adapter=adapter, rule_name="form_action_missing"))) add(Rule(
add(Rule("form_http_on_https_page", "Form submits via HTTP from HTTPS page", "form", "function", name="form_action_missing",
FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page"))) description="Form has no action attribute",
add(Rule("form_submits_to_different_host", "Form submits to a different host", "form", "function", category="form",
FunctionRuleAdapter(form_submits_to_different_host, category="form", adapter=adapter, rule_name="form_submits_to_different_host"))) rule_type="function",
add(Rule("script_src_uses_data_or_blob", "Script src uses data:/blob: URL", "script", "function", function=FunctionRuleAdapter(form_action_missing, category="form", adapter=adapter, rule_name="form_action_missing"),
FunctionRuleAdapter(script_src_uses_data_or_blob, category="script", adapter=adapter, rule_name="script_src_uses_data_or_blob"))) ))
add(Rule("script_src_has_dangerous_extension", "External script with dangerous extension", "script", "function",
FunctionRuleAdapter(script_src_has_dangerous_extension, category="script", adapter=adapter, rule_name="script_src_has_dangerous_extension"))) # add(Rule(
add(Rule("script_third_party_host", "Script is from a third-party host", "script", "function", # name="form_http_on_https_page",
FunctionRuleAdapter(script_third_party_host, category="script", adapter=adapter, rule_name="script_third_party_host"))) # description="Form submits via HTTP from HTTPS page",
# category="form",
# rule_type="function",
# function=FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page"),
# ))
# add(Rule("form_http_on_https_page", "Form submits via HTTP from HTTPS page", "form", "function",
# FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page")))
# add(Rule("form_submits_to_different_host", "Form submits to a different host", "form", "function",
# FunctionRuleAdapter(form_submits_to_different_host, category="form", adapter=adapter, rule_name="form_submits_to_different_host")))
# add(Rule("script_src_uses_data_or_blob", "Script src uses data:/blob: URL", "script", "function",
# FunctionRuleAdapter(script_src_uses_data_or_blob, category="script", adapter=adapter, rule_name="script_src_uses_data_or_blob")))
# add(Rule("script_src_has_dangerous_extension", "External script with dangerous extension", "script", "function",
# FunctionRuleAdapter(script_src_has_dangerous_extension, category="script", adapter=adapter, rule_name="script_src_has_dangerous_extension")))
# add(Rule("script_third_party_host", "Script is from a third-party host", "script", "function",
# FunctionRuleAdapter(script_third_party_host, category="script", adapter=adapter, rule_name="script_third_party_host")))
log.info("Registered %d total rules (YAML + function)", len(eng.rules)) log.info("Registered %d total rules (YAML + function)", len(eng.rules))
return eng return eng

View File

@@ -3,7 +3,8 @@ rules_engine.py
""" """
import re import re
import logging import unicodedata
from collections import Counter
from dataclasses import dataclass, asdict, field from dataclasses import dataclass, asdict, field
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple, Union from typing import Callable, Dict, List, Optional, Tuple, Union
@@ -11,6 +12,18 @@ from typing import Callable, Dict, List, Optional, Tuple, Union
from app.logging_setup import get_engine_logger from app.logging_setup import get_engine_logger
from app.utils.settings import get_settings from app.utils.settings import get_settings
import re as _re
FLAG_MAP = {
"i": _re.IGNORECASE, "ignorecase": _re.IGNORECASE,
"m": _re.MULTILINE, "multiline": _re.MULTILINE,
"s": _re.DOTALL, "dotall": _re.DOTALL, "singleline": _re.DOTALL,
"x": _re.VERBOSE, "verbose": _re.VERBOSE,
"a": _re.ASCII, "ascii": _re.ASCII,
"u": _re.UNICODE, "unicode": _re.UNICODE,
"l": _re.LOCALE, "locale": _re.LOCALE,
}
settings = get_settings() settings = get_settings()
import yaml import yaml
@@ -49,20 +62,64 @@ class Rule:
""" """
Compile the regex pattern once for performance, if applicable. Compile the regex pattern once for performance, if applicable.
Behavior:
- Uses flags specified on the rule (list like ['i','m'] or a string like 'im').
- If the rule category is 'text' and no 'i' flag is set, defaults to IGNORECASE.
- Stores the compiled object on self._compiled_regex.
Returns: Returns:
bool: True if the regex is compiled and ready, False otherwise. bool: True if the regex is compiled and ready, False otherwise.
""" """
if getattr(self, "rule_type", None) != "regex" or not getattr(self, "pattern", None):
return False
re_flags = 0
# Collect flags from the rule, if any (supports "ims" or ["i","m","s"])
raw_flags = getattr(self, "flags", None)
if isinstance(raw_flags, str):
for ch in raw_flags:
mapped = FLAG_MAP.get(ch.lower())
if mapped is not None:
re_flags |= mapped
else:
logger.warning("[Rule] Unknown regex flag %r on rule '%s'", ch, getattr(self, "name", "?"))
elif isinstance(raw_flags, (list, tuple, set)):
for fl in raw_flags:
key = str(fl).lower()
mapped = FLAG_MAP.get(key)
if mapped is not None:
re_flags |= mapped
else:
logger.warning("[Rule] Unknown regex flag %r on rule '%s'", fl, getattr(self, "name", "?"))
# Default IGNORECASE for text rules if not explicitly provided
cat = (getattr(self, "category", "") or "").lower().strip()
if cat == "text" and not (re_flags & _re.IGNORECASE):
re_flags |= _re.IGNORECASE
try:
self._compiled_regex = _re.compile(self.pattern, re_flags)
# Build a compact flag summary inline (e.g., 'ims' or '-' if none)
flag_parts = []
if re_flags & _re.IGNORECASE: flag_parts.append("i")
if re_flags & _re.MULTILINE: flag_parts.append("m")
if re_flags & _re.DOTALL: flag_parts.append("s")
if re_flags & _re.VERBOSE: flag_parts.append("x")
if re_flags & _re.ASCII: flag_parts.append("a")
if re_flags & _re.UNICODE: flag_parts.append("u")
if re_flags & _re.LOCALE: flag_parts.append("l")
flag_summary = "".join(flag_parts) if flag_parts else "-"
logger.info("[Rule] Compiled regex for '%s' (flags=%s)", getattr(self, "name", "?"), flag_summary)
return True
except _re.error as rex:
self._compiled_regex = None
logger.warning("[Rule] Failed to compile regex for '%s': %s", getattr(self, "name", "?"), rex)
return False
if self.rule_type == "regex" and self.pattern:
try:
self._compiled_regex = re.compile(self.pattern, re.IGNORECASE)
logger.debug(f"[Rule] Compiled regex for '{self.name}'")
return True
except re.error as rex:
self._compiled_regex = None
logger.warning(f"[Rule] Failed to compile regex for '{self.name}': {rex}")
return False
return False
def run(self, text: str) -> Tuple[bool, str]: def run(self, text: str) -> Tuple[bool, str]:
""" """
@@ -86,6 +143,13 @@ class Rule:
return False, "No match" return False, "No match"
if self.rule_type == "function": if self.rule_type == "function":
if not callable(self.function):
logger.warning(
"[Rule] '%s' function is not callable (type=%s, value=%r)",
self.name, type(self.function).__name__, self.function
)
return False, "Invalid rule configuration: function not callable"
if callable(self.function): if callable(self.function):
try: try:
matched, reason = self.function(text) matched, reason = self.function(text)
@@ -198,7 +262,7 @@ class RuleEngine:
) )
return return
if settings.app.print_rule_loads: if settings.logconfig.log_rule_loads:
logger.info( logger.info(
"[engine] add_rule: %s/%s replace=%s -> count=%d", "[engine] add_rule: %s/%s replace=%s -> count=%d",
rule.category, rule.name, bool(replace), len(self._rules) rule.category, rule.name, bool(replace), len(self._rules)
@@ -230,6 +294,14 @@ class RuleEngine:
self.add_rule(rules[i], replace=replace) self.add_rule(rules[i], replace=replace)
i = i + 1 i = i + 1
def _normalize_for_text_rules(self, s: str) -> str:
if not s:
return ""
s = unicodedata.normalize("NFKC", s)
# collapse whitespace; keeps word boundaries sensible
s = _re.sub(r"\s+", " ", s).strip()
return s
def run_all(self, text: str, category: Optional[str] = None) -> List[Dict]: def run_all(self, text: str, category: Optional[str] = None) -> List[Dict]:
""" """
Run all rules against text. Run all rules against text.
@@ -241,6 +313,30 @@ class RuleEngine:
Returns: Returns:
List of dicts with PASS/FAIL per rule (JSON-serializable). List of dicts with PASS/FAIL per rule (JSON-serializable).
""" """
# --- dispatch visibility --- if set to true, we log applied categories
if getattr(settings.logconfig, "log_rule_dispatch", False):
all_cats = [r.category for r in self._rules]
cat_counts = Counter(all_cats)
# Which categories are being applied this run?
if category is None:
selected_categories = sorted(cat_counts.keys())
else:
selected_categories = [category]
# How many rules match the selection?
selected_rule_count = sum(1 for r in self._rules if r.category in selected_categories)
try:
logger.info(
"[engine] applying categories: %s | selected_rules=%d | totals=%s",
",".join(selected_categories),
selected_rule_count,
dict(cat_counts),
)
except Exception:
pass
# --- end dispatch visibility ---
results: List[Dict] = [] results: List[Dict] = []
index = 0 index = 0
@@ -248,12 +344,20 @@ class RuleEngine:
while index < total: while index < total:
rule = self.rules[index] rule = self.rules[index]
# if we are running a text rule, let's normalize the text.
if category == "text":
text = self._normalize_for_text_rules(text)
if category is not None and rule.category != category: if category is not None and rule.category != category:
index = index + 1 index = index + 1
continue continue
matched, reason = rule.run(text) matched, reason = rule.run(text)
# very fine-grained trace per rule:
if getattr(settings.app, "log_rule_debug", False):
logger.info(f"[engine] eval: cat:{rule.category} - rule:{rule.name} - result: {matched} - reason:{reason}" )
result_str = "FAIL" if matched else "PASS" result_str = "FAIL" if matched else "PASS"
reason_to_include: Optional[str] reason_to_include: Optional[str]
if matched: if matched:

View File

@@ -1,472 +0,0 @@
/* ==========================================================================
SneakyScope Stylesheet
Consolidated + Commented
========================================================================== */
/* ==========================================================================
0) Theme Variables
-------------------------------------------------------------------------- */
:root {
/* Typography */
--font-sans: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
/* Colors (derived from your current palette) */
--bg: #0b0f14;
--text: #e6edf3;
--header-bg: #0f1720;
--card-bg: #111826;
--border: #1f2a36; /* darker border */
--border-2: #243041; /* lighter border used on inputs/tables */
--input-bg: #0b1220;
--link: #7dd3fc;
--link-hover: #38bdf8;
/* Accents */
--accent-pill-bg: rgba(59,130,246,.18);
--accent-pill-bd: rgba(59,130,246,.45);
/* Radius & Shadows */
--radius: 12px;
}
/* ==========================================================================
1) Base / Reset
-------------------------------------------------------------------------- */
html { scroll-behavior: smooth; }
:root { font-family: var(--font-sans); }
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
a {
color: var(--link);
text-decoration: underline;
}
a:hover { color: var(--link-hover); }
img {
max-width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--border-2);
}
/* ==========================================================================
2) Layout (header/footer/main/cards)
-------------------------------------------------------------------------- */
header, footer {
padding: 1rem 1.25rem;
background: var(--header-bg);
border-bottom: 1px solid var(--border);
}
main {
/* full-width layout */
padding: 1.5rem 2rem;
max-width: 100%;
width: 100%;
margin: 0;
box-sizing: border-box;
}
.card {
background: var(--card-bg);
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 1rem;
/* anchors don't hide under sticky nav */
scroll-margin-top: 72px;
}
/* ==========================================================================
3) Form Controls & Buttons
-------------------------------------------------------------------------- */
label {
display: block;
margin-bottom: 0.5rem;
}
input[type="url"] {
width: 100%;
padding: 0.7rem;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--input-bg);
color: var(--text);
}
button, .button {
display: inline-block;
margin-top: 0.75rem;
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid var(--border-2);
background: #1a2535;
color: var(--text);
text-decoration: none;
cursor: pointer;
}
button:hover, .button:hover { filter: brightness(1.05); }
/* Flash messages */
.flash { list-style: none; padding: 0.5rem 1rem; }
.flash .error { color: #ff6b6b; }
/* Simple grid utility */
.grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.5rem 1rem;
}
/* ==========================================================================
4) Code Blocks & Details/Accordion
-------------------------------------------------------------------------- */
pre.code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9rem;
white-space: pre-wrap; /* wrap long lines */
word-break: break-all;
background: var(--input-bg);
padding: 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-2);
}
details summary {
cursor: pointer;
padding: 0.5rem;
font-weight: bold;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border);
margin-bottom: 0.5rem;
transition: background 0.3s ease;
}
details[open] summary { background: #1a2535; }
/* inner spacing when expanded */
details > ul,
details > table { padding-left: 1rem; margin: 0.5rem 0; }
/* flagged state */
details.flagged summary { border-left: 4px solid #ff6b6b; }
/* gentle transitions */
details ul, details p { transition: all 0.3s ease; }
/* readable expanded code without blowing layout */
details pre.code {
white-space: pre-wrap;
word-break: break-word;
max-height: 18rem;
overflow: auto;
}
/* ==========================================================================
5) Tables — Enrichment (generic)
-------------------------------------------------------------------------- */
.enrichment-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.enrichment-table th,
.enrichment-table td {
border: 1px solid var(--border-2);
padding: 0.5rem;
vertical-align: top;
}
.enrichment-table th {
background: var(--card-bg);
text-align: left;
}
.enrichment-table td {
width: auto;
word-break: break-word;
}
.enrichment-table tbody tr:hover { background: #1f2a36; }
.enrichment-table thead th { border-bottom: 2px solid var(--border-2); }
/* ensure nested tables don't overflow cards */
.card table { table-layout: auto; word-break: break-word; }
/* ==========================================================================
6) Tables — Shared Rules (Scripts & Forms)
-------------------------------------------------------------------------- */
.scripts-table,
.forms-table {
table-layout: fixed;
width: 100%;
}
.scripts-table td ul,
.forms-table td ul {
margin: 0.25rem 0 0.25rem 1rem;
padding-left: 1rem;
}
.scripts-table td small,
.forms-table td small { opacity: 0.85; }
/* ellipsize by default */
.scripts-table td, .scripts-table th,
.forms-table td, .forms-table th {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* allow wrapping inside expanded blocks */
.scripts-table details,
.forms-table details { white-space: normal; }
.scripts-table details > pre.code,
.forms-table details > pre.code {
white-space: pre-wrap;
max-height: 28rem;
overflow: auto;
}
/* ==========================================================================
7) Scripts Table (columns & tweaks)
-------------------------------------------------------------------------- */
/* compact inline snippet */
.scripts-table pre.code { margin: 0; padding: 0.25rem; font-size: 0.9rem; }
/* columns: Type | Source URL | Snippet | Matches */
.scripts-table th:nth-child(1) { width: 8rem; }
.scripts-table th:nth-child(2) { width: 32rem; }
.scripts-table th:nth-child(3) { width: 24rem; }
.scripts-table th:nth-child(4) { width: auto; }
/* ==========================================================================
8) Forms Table (columns & helpers)
-------------------------------------------------------------------------- */
/* columns: Action | Method | Inputs | Matches | Form Snippet */
.forms-table th:nth-child(1) { width: 15rem; } /* Action */
.forms-table th:nth-child(2) { width: 5rem; } /* Method */
.forms-table th:nth-child(3) { width: 15rem; } /* Inputs */
.forms-table th:nth-child(5) { width: 24rem; } /* Snippet */
.forms-table th:nth-child(4) { width: auto; } /* Matches grows */
/* input chips layout inside cells */
.forms-table .chips {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
white-space: normal;
}
/* ==========================================================================
9) Results Table (Recent runs list)
-------------------------------------------------------------------------- */
.results-table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
table-layout: auto;
}
.results-table thead th {
padding: 0.6rem 0.75rem;
background: var(--header-bg);
border-bottom: 1px solid var(--border);
text-align: left;
font-weight: 600;
white-space: nowrap;
}
.results-table tbody td {
padding: 0.6rem 0.75rem;
border-top: 1px solid var(--border);
vertical-align: top;
text-align: left;
}
.results-table tbody tr:nth-child(odd) { background: #0d1522; }
.results-table a { text-decoration: underline; }
/* column-specific helpers */
.results-table td.url,
.results-table td.url a {
overflow-wrap: anywhere;
word-break: break-word;
}
.results-table td.uuid {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
word-break: break-all;
max-width: 28ch;
}
.results-table td.timestamp {
text-align: right;
white-space: nowrap;
}
.results-table tbody tr:first-child { box-shadow: inset 0 0 0 1px var(--border-2); }
.results-table .copy-btn {
margin-left: 0.4rem;
padding: 0.2rem 0.45rem;
border-radius: 6px;
border: 1px solid var(--border-2);
background: #1a2535;
color: var(--text);
cursor: pointer;
line-height: 1;
font-size: 0.9rem;
}
.results-table .copy-btn:hover { filter: brightness(1.1); }
/* ==========================================================================
10) Utilities (chips, badges, helpers)
-------------------------------------------------------------------------- */
.breakable { white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
/* Generic badge + severities */
.badge {
display: inline-block;
padding: 0.1rem 0.4rem;
margin-left: 0.35rem;
border-radius: 0.4rem;
font-size: 0.75rem;
line-height: 1;
vertical-align: middle;
user-select: none;
border: 1px solid transparent; /* individual severities add their borders */
}
.sev-high { background: #fdecea; color: #b71c1c; border-color: #f5c6c4; }
.sev-medium { background: #fff8e1; color: #8a6d3b; border-color: #ffe0a3; }
.sev-low { background: #e8f5e9; color: #1b5e20; border-color: #b9e6be; }
/* Tag chips */
.chips { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.chip {
display: inline-block;
padding: 0.1rem 0.35rem;
border-radius: 999px;
font-size: 0.7rem;
line-height: 1;
background: #eef2f7;
color: #425466;
border: 1px solid #d9e2ec;
}
.checkbox-row {
display: flex; align-items: center; gap: .5rem;
margin: .5rem 0 1rem;
}
/* ==========================================================================
11) Sticky Top Jump Navigation
-------------------------------------------------------------------------- */
.top-jump-nav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: .5rem .75rem;
padding: .5rem 1rem;
margin: 0 0 1rem 0;
background: var(--card-bg);
border: 1px solid rgba(255,255,255,.08);
box-shadow: 0 4px 14px rgba(0,0,0,.25);
border-radius: 10px;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.top-jump-nav a {
display: inline-block;
padding: .4rem .75rem;
border: 1px solid rgba(255,255,255,.12);
border-radius: 999px;
text-decoration: none;
font-size: .95rem;
line-height: 1;
color: inherit;
opacity: .95;
}
.top-jump-nav a:hover,
.top-jump-nav a:focus {
opacity: 1;
background: rgba(255,255,255,.06);
outline: none;
}
.top-jump-nav a.active {
background: var(--accent-pill-bg);
border-color: var(--accent-pill-bd);
box-shadow: inset 0 0 0 1px rgba(59,130,246,.25);
}
/* --- Titles and structure --- */
.card-title { margin: 0 0 .5rem; font-size: 1.1rem; }
.section { margin-top: 1rem; }
.section-header { display: flex; gap: .5rem; align-items: baseline; flex-wrap: wrap; }
/* --- Divider --- */
.divider { border: 0; border-top: 1px solid #1f2a36; margin: 1rem 0; }
/* --- Badges / Chips --- */
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; border: 1px solid transparent; }
.badge-ok { background: #0e3820; border-color: #2c6e49; color: #bff3cf; }
.badge-warn { background: #3d290e; border-color: #9a6b18; color: #ffe2a8; }
.badge-danger { background: #401012; border-color: #a33a42; color: #ffc1c5; }
.badge-muted { background: #111826; border-color: #273447; color: #9fb0c3; }
.chip { display: inline-block; padding: .1rem .4rem; border: 1px solid #273447; border-radius: 8px; font-size: .75rem; margin-right: .25rem; }
.chip-warn { border-color: #9a6b18; }
/* --- Text helpers --- */
.muted { color: #9fb0c3; }
.small { font-size: .8rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.prewrap { white-space: pre-wrap; }
/* --- Lists / details --- */
.list { margin: .5rem 0; padding-left: 1.1rem; }
.details summary { cursor: pointer; }
/* --- Grid --- */
.grid.two { display: grid; grid-template-columns: 1fr; gap: 1rem; }
@media (min-width: 900px) {
.grid.two { grid-template-columns: 1fr 1fr; }
}
/* --- TLS Matrix --- */
.tls-matrix { border: 1px solid #1f2a36; border-radius: 10px; overflow: hidden; }
.tls-matrix-row { display: grid; grid-template-columns: 120px 140px 1fr 100px; gap: .5rem; align-items: center;
padding: .5rem .75rem; border-bottom: 1px solid #1f2a36; }
.tls-matrix-row:last-child { border-bottom: none; }
.tls-cell.version { font-weight: 600; }
.tls-cell.status {}
.tls-cell.cipher {}
.tls-cell.latency { text-align: right; }
/* ==========================================================================
12) Responsive Tweaks
-------------------------------------------------------------------------- */
@media (max-width: 1200px) {
.forms-table th:nth-child(1) { width: 22rem; }
.forms-table th:nth-child(3) { width: 16rem; }
.forms-table th:nth-child(5) { width: 18rem; }
}
@media (max-width: 768px) {
main { padding: 1rem; }
.enrichment-table,
.results-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
@media (max-width: 640px) {
.top-jump-nav { padding: .4rem .6rem; gap: .4rem .5rem; }
.top-jump-nav a { padding: .35rem .6rem; font-size: .9rem; }
}

View File

@@ -1,182 +0,0 @@
{# templates/_macros_ssl_tls.html #}
{% macro ssl_tls_card(ssl_tls) %}
<div class="card" id="ssl">
<h2 class="card-title">SSL/TLS Intelligence</h2>
{# -------- 1) Error branch -------- #}
{% if ssl_tls is none or 'error' in ssl_tls %}
<div class="badge badge-danger">Error</div>
<p class="muted">SSL/TLS enrichment failed or is unavailable.</p>
{% if ssl_tls and ssl_tls.error %}<pre class="prewrap">{{ ssl_tls.error }}</pre>{% endif %}
{# -------- 2) Skipped branch -------- #}
{% elif ssl_tls.skipped %}
<div class="badge badge-muted">Skipped</div>
{% if ssl_tls.reason %}<span class="muted small">{{ ssl_tls.reason }}</span>{% endif %}
<div class="section">
<button class="badge badge-muted" data-toggle="tls-raw">Toggle raw</button>
<pre id="tls-raw" hidden>{{ ssl_tls|tojson(indent=2) }}</pre>
</div>
{# -------- 3) Normal branch (render probe + crt.sh) -------- #}
{% else %}
{# ===================== LIVE PROBE ===================== #}
{% set probe = ssl_tls.probe if ssl_tls else None %}
<section class="section">
<div class="section-header">
<h3>Live TLS Probe</h3>
{% if probe %}
<span class="muted">Host:</span> <code>{{ probe.hostname }}:{{ probe.port }}</code>
{% endif %}
</div>
{% if not probe %}
<p class="muted">No probe data.</p>
{% else %}
<div class="tls-matrix">
{% set versions = ['TLS1.0','TLS1.1','TLS1.2','TLS1.3'] %}
{% for v in versions %}
{% set r = probe.results_by_version.get(v) if probe.results_by_version else None %}
<div class="tls-matrix-row">
<div class="tls-cell version">{{ v }}</div>
{% if r and r.supported %}
<div class="tls-cell status"><span class="badge badge-ok">Supported</span></div>
<div class="tls-cell cipher">
{% if r.selected_cipher %}
<span class="chip">{{ r.selected_cipher }}</span>
{% else %}
<span class="muted">cipher: n/a</span>
{% endif %}
</div>
<div class="tls-cell latency">
{% if r.handshake_seconds is not none %}
<span class="muted">{{ '%.0f' % (r.handshake_seconds*1000) }} ms</span>
{% else %}
<span class="muted"></span>
{% endif %}
</div>
{% else %}
<div class="tls-cell status"><span class="badge badge-muted">Not Supported</span></div>
<div class="tls-cell cipher">
{% if r and r.error %}
<span class="muted small">({{ r.error }})</span>
{% else %}
<span class="muted"></span>
{% endif %}
</div>
<div class="tls-cell latency"><span class="muted"></span></div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="flag-row">
{% if probe.weak_protocols and probe.weak_protocols|length > 0 %}
<span class="badge badge-warn">Weak Protocols</span>
{% for wp in probe.weak_protocols %}
<span class="chip chip-warn">{{ wp }}</span>
{% endfor %}
{% endif %}
{% if probe.weak_ciphers and probe.weak_ciphers|length > 0 %}
<span class="badge badge-warn">Weak Ciphers</span>
{% for wc in probe.weak_ciphers %}
<span class="chip chip-warn">{{ wc }}</span>
{% endfor %}
{% endif %}
</div>
{% if probe.errors and probe.errors|length > 0 %}
<details class="details">
<summary>Probe Notes</summary>
<ul class="list">
{% for e in probe.errors %}
<li class="muted small">{{ e }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% endif %}
</section>
<hr class="divider"/>
{# ===================== CRT.SH ===================== #}
{% set crtsh = ssl_tls.crtsh if ssl_tls else None %}
<section class="section">
<div class="section-header">
<h3>Certificate Transparency (crt.sh)</h3>
{% if crtsh %}
<span class="muted">Parsed:</span>
<code>{{ crtsh.hostname or 'n/a' }}</code>
{% if crtsh.root_domain %}
<span class="muted"> • Root:</span> <code>{{ crtsh.root_domain }}</code>
{% if crtsh.is_root_domain %}<span class="badge badge-ok">Root</span>{% else %}<span class="badge badge-muted">Subdomain</span>{% endif %}
{% endif %}
{% endif %}
</div>
{% if not crtsh %}
<p class="muted">No CT data.</p>
{% else %}
<div class="grid two">
<div>
<h4 class="muted">Host Certificates</h4>
{% set host_certs = crtsh.crtsh.host_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
{% if host_certs and host_certs|length > 0 %}
<ul class="list">
{% for c in host_certs[:10] %}
<li class="mono small">
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span>
<span class="muted"></span>
<strong>{{ c.get('name_value','(name n/a)') }}</strong>
<span class="muted"> • not_before:</span> {{ c.get('not_before','?') }}
</li>
{% endfor %}
</ul>
{% if host_certs|length > 10 %}
<div class="muted small">(+ {{ host_certs|length - 10 }} more)</div>
{% endif %}
{% else %}
<p class="muted">No active host certs found.</p>
{% endif %}
</div>
<div>
<h4 class="muted">Wildcard on Root</h4>
{% set wc = crtsh.crtsh.wildcard_root_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
{% if wc and wc|length > 0 %}
<ul class="list">
{% for c in wc[:10] %}
<li class="mono small">
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span>
<span class="muted"></span>
<strong>{{ c.get('name_value','(name n/a)') }}</strong>
<span class="muted"> • not_before:</span> {{ c.get('not_before','?') }}
</li>
{% endfor %}
</ul>
{% if wc|length > 10 %}
<div class="muted small">(+ {{ wc|length - 10 }} more)</div>
{% endif %}
{% else %}
<p class="muted">No wildcard/root certs found.</p>
{% endif %}
</div>
</div>
{% endif %}
</section>
{# ===================== RAW JSON TOGGLE ===================== #}
<div class="section">
<button class="badge badge-muted" data-toggle="tls-raw">Toggle raw</button>
<pre id="tls-raw" hidden>{{ ssl_tls|tojson(indent=2) }}</pre>
</div>
{% endif %}
<p><a href="#top-jump-list">Back to top</a></p>
</div>
{% endmacro %}

View File

@@ -1,36 +1,96 @@
<!doctype html> {# Base layout using Tailwind + Flowbite, non-destructive #}
<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{% block title %}{% endblock %} {{ app_name }} </title>
<title>{{ app_name }} {{ app_version }}</title> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<header>
<h1>{{ app_name }} {{ app_version }}</h1>
</header>
{% with messages = get_flashed_messages(with_categories=true) %} <!-- # Tailwind CSS # -->
{% if messages %} <link rel="stylesheet" href="{{ url_for('static', filename='tw.css') }}">
<ul class="flash">
{% for category, message in messages %} {# Your existing CSS stays; well keep only custom tweaks there. #}
<li class="{{ category }}">{{ message }}</li> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% endfor %} {% block head %}{% endblock %}
</head>
<body class="bg-bg text-gray-200 min-h-screen flex flex-col">
{# Top Navbar (Flowbite collapse) #}
<nav class="bg-nav border-b border-gray-800">
<div class="max-w-7xl mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<a href="{{ url_for('main.index') }}" class="text-xl font-bold text-white">
{{ header }}
</a>
{# Desktop nav #}
<ul class="hidden md:flex items-center space-x-6 text-sm">
<li>
<a href="{{ url_for('main.index') }}">
Home
</a>
</li>
<li>
<a href="{{ url_for('roadmap.roadmap_view') }}">
Roadmap
</a>
</li>
<li>
<a href="{{ url_for('changelog.view_changelog') }}">
Changelog
</a>
</li>
</ul> </ul>
{% endif %}
{% endwith %}
<main> {# Mobile toggle #}
<button data-collapse-toggle="main-menu" type="button"
class="md:hidden inline-flex items-center p-2 rounded hover:bg-gray-700"
aria-controls="main-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
{# Mobile menu #}
<div class="hidden md:hidden" id="main-menu">
<ul class="mt-2 space-y-1 text-sm">
<li>
<a href="{{ url_for('main.index') }}">
Home
</a>
</li>
<li>
<a href="{{ url_for('roadmap.roadmap_view') }}">
Roadmap
</a>
</li>
<li>
<a href="{{ url_for('changelog.view_changelog') }}">
Chnagelog
</a>
</li>
</ul>
</div>
</div>
</nav>
{# Page content wrapper #}
<main class="flex-1">
<div class="max-w-7xl mx-auto p-4 md:p-6">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </div>
</main>
<footer> {# Footer #}
<small>{{ app_name }} - A self-hosted URL analysis sandbox - {{ app_version }}</small> <footer class="bg-nav border-t border-gray-800 text-center p-4">
</footer> <p class="text-sm text-gray-400">{{ footer | safe }}</p>
</body> </footer>
{# Flowbite JS (enables collapse) #}
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html> </html>
{% block page_js %}
{% endblock %}

View File

@@ -0,0 +1,136 @@
{# templates/changelog.html #}
{% extends "base.html" %}
{% block title %}Changelog{% endblock %}
{% block content %}
<div class="mx-auto max-w-6xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">SneakyScope Changelog</h1>
{% if updated %}
<p class="text-sm text-gray-400">Last updated: {{ updated }}</p>
{% endif %}
</div>
</div>
{# Unreleased #}
{% set ur = changelog.unreleased %}
{% if ur.features or ur.refactors or ur.fixes %}
<section class="mb-8 rounded-2xl border border-gray-700 bg-gray-900 p-5">
<div class="mb-3 flex items-center gap-3">
<h2 class="text-xl font-semibold">Unreleased</h2>
<span class="badge badge-warn">WIP</span>
</div>
<div class="grid gap-6 md:grid-cols-3">
{% for title, items, icon in [
("✨ Features", ur.features, "✨"),
("🛠️ Refactors", ur.refactors, "🛠️"),
("🐛 Fixes", ur.fixes, "🐛"),
] %}
<div class="rounded-xl border border-gray-800 bg-gray-950 p-4">
<h3 class="mb-2 text-sm font-semibold text-gray-200">{{ title }}</h3>
{% if items and items|length %}
<ul class="space-y-3">
{% for it in items %}
<li class="rounded-lg border border-gray-800 bg-gray-900 p-3">
<div class="mb-1 font-medium">{{ it.title }}</div>
{% if it.details %}
<ul class="ml-5 list-disc text-sm text-gray-300">
{% for d in it.details %}
<li>{{ d }}</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-gray-400">Nothing yet — add upcoming {{ title.split(' ')[1] | lower }} here.</p>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{# Versions Accordion #}
<section>
<div id="changelog-accordion" data-accordion="collapse" class="divide-y rounded-2xl border border-gray-700 bg-gray-900">
{% for v in changelog.versions %}
<h2 id="acc-head-{{ loop.index }}">
<button type="button"
class="flex w-full items-center justify-between px-5 py-4 text-left hover:bg-gray-800"
data-accordion-target="#acc-body-{{ loop.index }}"
aria-expanded="{{ 'true' if loop.first else 'false' }}"
aria-controls="acc-body-{{ loop.index }}">
<span class="flex items-center gap-3">
<span class="font-semibold">{{ v.version }}</span>
{% if v.notes and not (v.features or v.refactors or v.fixes) %}
<span class="badge badge-ok">Notes only</span>
{% endif %}
</span>
<svg class="h-5 w-5 text-gray-300" aria-hidden="true" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5 5 1 1 5"/>
</svg>
</button>
</h2>
<div id="acc-body-{{ loop.index }}"
class="{{ '' if loop.first else 'hidden' }}"
aria-labelledby="acc-head-{{ loop.index }}">
<div class="space-y-8 px-5 pb-5 pt-1">
{% if v.notes and v.notes|length %}
<div>
<h3 class="mb-2 text-sm font-semibold text-gray-200">Notes</h3>
<ul class="ml-6 list-disc text-sm text-gray-300">
{% for n in v.notes %}
<li>{{ n }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% for section_title, items in [
("✨ Features", v.features),
("🛠️ Refactors", v.refactors),
("🐛 Fixes", v.fixes),
] %}
{% if items and items|length %}
<div>
<h3 class="mb-2 text-sm font-semibold text-gray-200">{{ section_title }}</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{% for it in items %}
<article class="rounded-2xl border border-gray-800 bg-gray-950 p-4">
<h4 class="mb-1 font-semibold leading-snug">{{ it.title }}</h4>
{% if it.details %}
<ul class="ml-5 list-disc text-sm text-gray-300">
{% for d in it.details %}
<li>{{ d }}</li>
{% endfor %}
</ul>
{% endif %}
</article>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
{# If youre not auto-initializing Flowbite elsewhere, ensure its JS is loaded globally. #}
<script>
/* Optional: if you ever render details as HTML snippets, ensure they are trusted or sanitized server-side. */
/* No extra JS needed here if Flowbite handles [data-accordion]. */
</script>
{% endblock %}

View File

@@ -1,159 +1,202 @@
{% extends 'base.html' %} {% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %} {% block content %}
<!-- Single-column stack, centered and comfortably narrow -->
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
<!-- Analysis Form --> <!-- Start a New Analysis -->
<form id="analyze-form" method="post" action="{{ url_for('main.analyze') }}" class="card"> <section class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Analyze a URL</h2> <h2 class="text-lg font-semibold mb-3">Start a New Analysis</h2>
<label for="url">Enter a URL to analyze</label>
<input id="url" name="url" type="url" placeholder="https://example.com" required />
<!-- toggle for pulling ssl/cert data --> <form id="analyze-form" action="{{ url_for('main.analyze') }}" method="post" class="space-y-3">
<label class="checkbox-row"> <div>
<input type="checkbox" name="fetch_ssl" value="1"> <label for="url" class="block text-sm text-gray-400 mb-1">Target URL or Domain</label>
Pull SSL/TLS data (crt.sh + version probe) - Warning, crt.sh can be <b>very slow</b> at times <input
</label> id="url"
name="url"
type="text"
required
class="w-full bg-[#0b0f14] border border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com"
>
</div>
<button type="submit">Analyze</button> <div class="flex flex-col gap-3 sm:flex-row sm:items-center">
</form>
<!-- Recent Results (optional; shown only if recent_results provided) -->
{% if recent_results %}
<div class="card" id="recent-results">
<h2>Recent Results</h2>
<table class="results-table">
<thead>
<tr>
<th>Timestamp</th>
<th>URL</th>
<th>UUID</th>
</tr>
</thead>
<tbody>
{% for r in recent_results %}
<tr>
<td class="timestamp">
{% if r.timestamp %}
{{ r.timestamp }}
{% else %}
N/A
{% endif %}
</td>
<td class="url">
<a href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
{{ r.final_url or r.submitted_url }}
</a>
</td>
<td class="uuid">
<code id="uuid-{{ loop.index }}">{{ r.uuid }}</code>
<button <button
type="button" type="submit"
class="copy-btn" class="inline-flex items-center gap-2 rounded-lg px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-60"
data-target="uuid-{{ loop.index }}"> >
📋 <span id="btn-spinner" class="hidden animate-spin inline-block h-4 w-4 rounded-full border-2 border-white/40 border-t-white"></span>
Analyse
</button> </button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> <!-- toggle for pulling ssl/cert data -->
{% endif %} <label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="fetch_ssl"
value="1"
class="rounded border-gray-600 bg-[#0b0f14]"
>
<span>
Pull SSL/TLS data (crt.sh + version probe)
<span class="text-gray-400">— crt.sh can be <b>very slow</b> at times</span>
</span>
</label>
</div>
</form>
</section>
<!-- Spinner Modal --> <!-- Recent Results -->
<div id="spinner-modal" style=" {% if recent_results %}
display:none; <section class="bg-card border border-gray-800 rounded-xl p-4" id="recent-results">
opacity:0; <h2 class="text-base font-semibold mb-3">Recent Results</h2>
position:fixed; <div class="overflow-x-auto">
top:0; <table class="min-w-full text-sm">
left:0; <thead class="text-gray-400 border-b border-gray-800">
width:100%; <tr>
height:100%; <th class="text-left py-2 pr-4">Timestamp</th>
background:rgba(0,0,0,0.7); <th class="text-left py-2 pr-4">URL</th>
color:#fff; <th class="text-left py-2 pr-4">UUID</th>
font-size:1.5rem; </tr>
text-align:center; </thead>
padding-top:20%; <tbody>
z-index:9999; {% for r in recent_results %}
transition: opacity 0.3s ease; <tr class="border-b border-gray-900">
"> <td class="py-2 pr-4 whitespace-nowrap">
<div> {% if r.timestamp %}{{ r.timestamp }}{% else %}N/A{% endif %}
<div class="loader" style=" </td>
border: 8px solid #f3f3f3; <td class="py-2 pr-4">
border-top: 8px solid #1a2535; <a class="hover:text-blue-400" href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
border-radius: 50%; {{ r.final_url or r.submitted_url }}
width: 60px; </a>
height: 60px; </td>
animation: spin 1s linear infinite; <td class="py-2 pr-4">
margin: 0 auto 1rem auto; <div class="flex items-center gap-2">
"></div> <code id="uuid-{{ loop.index }}" class="text-gray-300">{{ r.uuid }}</code>
Analyzing website… <button
type="button"
class="copy-btn inline-flex items-center justify-center rounded-md border border-gray-700 px-2 py-1 text-xs hover:bg-gray-800"
data-target="uuid-{{ loop.index }}"
title="Copy UUID"
>
📋
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% else %}
<section class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-base font-semibold mb-2">Recent Results</h2>
<p class="text-sm text-gray-500">No recent scans.</p>
</section>
{% endif %}
</div> </div>
</div>
<style> <!-- Fullscreen spinner overlay -->
@keyframes spin { <div
0% { transform: rotate(0deg); } id="spinner-modal"
100% { transform: rotate(360deg); } class="fixed inset-0 hidden opacity-0 transition-opacity duration-300 bg-black/70 z-50"
} role="dialog"
</style> aria-modal="true"
aria-label="Analyzing website"
>
<div class="min-h-screen flex items-center justify-center p-4 text-center">
<div class="bg-card border border-gray-800 rounded-xl px-6 py-5 shadow">
<div class="mx-auto mb-3 h-12 w-12 rounded-full border-4 border-white/30 border-t-white animate-spin"></div>
<div class="text-base">Analyzing website…<br /> If you are pulling certificates, this may take a long time </div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block page_js %} {% block scripts %}
<script> <script>
const form = document.getElementById('analyze-form'); /**
const modal = document.getElementById('spinner-modal'); * Show the fullscreen spinner overlay.
*/
function showModal() { function showSpinner() {
modal.style.display = 'block'; const modal = document.getElementById('spinner-modal');
requestAnimationFrame(() => { if (!modal) return;
modal.style.opacity = '1'; modal.classList.remove('hidden');
}); // allow reflow so opacity transition runs
requestAnimationFrame(() => modal.classList.remove('opacity-0'));
} }
function hideModal() { /**
modal.style.opacity = '0'; * Hide the fullscreen spinner overlay.
*/
function hideSpinner() {
const modal = document.getElementById('spinner-modal');
if (!modal) return;
modal.classList.add('opacity-0');
modal.addEventListener('transitionend', () => { modal.addEventListener('transitionend', () => {
modal.style.display = 'none'; modal.classList.add('hidden');
}, { once: true }); }, { once: true });
} }
// Hide spinner on initial load / back navigation /**
window.addEventListener('pageshow', () => { * Initialize form submit handling.
modal.style.opacity = '0'; */
modal.style.display = 'none'; (function initAnalyzeForm() {
}); const form = document.getElementById('analyze-form');
if (!form) return;
form.addEventListener('submit', (e) => { const submitBtn = form.querySelector('button[type="submit"]');
showModal(); const btnSpinner = document.getElementById('btn-spinner');
// Prevent double submission
form.querySelector('button').disabled = true;
// Allow browser to render the modal before submitting // Only hide the overlay when returning via BFCache (back/forward)
requestAnimationFrame(() => form.submit()); window.addEventListener('pageshow', (e) => {
e.preventDefault(); const nav = performance.getEntriesByType('navigation')[0];
}); const isBFCache = e.persisted || nav?.type === 'back_forward';
</script>
<script> if (isBFCache) {
document.addEventListener('DOMContentLoaded', () => { hideSpinner();
const buttons = document.querySelectorAll('.copy-btn'); if (submitBtn) submitBtn.disabled = false;
buttons.forEach(btn => { if (btnSpinner) btnSpinner.classList.add('hidden');
btn.addEventListener('click', () => { }
});
form.addEventListener('submit', (e) => {
// prevent immediate submit so UI can paint spinner first
e.preventDefault();
if (submitBtn) submitBtn.disabled = true;
if (btnSpinner) btnSpinner.classList.remove('hidden');
showSpinner();
// allow a frame so spinner paints, then submit
requestAnimationFrame(() => form.submit());
});
})();
/**
* Copy UUID buttons
*/
(function initCopyButtons() {
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const targetId = btn.getAttribute('data-target'); const targetId = btn.getAttribute('data-target');
const uuidText = document.getElementById(targetId).innerText; const el = document.getElementById(targetId);
if (!el) return;
navigator.clipboard.writeText(uuidText).then(() => { try {
// Give quick feedback await navigator.clipboard.writeText(el.textContent.trim());
const prev = btn.textContent;
btn.textContent = '✅'; btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 1500); setTimeout(() => { btn.textContent = prev; }, 1200);
}).catch(err => { } catch (err) {
console.error('Failed to copy UUID:', err); console.error('Failed to copy UUID:', err);
}); }
}); });
}); });
}); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,66 @@
<!-- /templates/partials/result_enrichment.html -->
<section id="enrichment" class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-lg font-semibold mb-3">Enrichment</h2>
{% if enrichment.whois %}
<h3 class="text-base font-semibold mt-2 mb-2">WHOIS</h3>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-gray-400 border-b border-gray-800">
<tr>
<th class="text-left py-2 pr-4">Field</th>
<th class="text-left py-2 pr-4">Value</th>
</tr>
</thead>
<tbody>
{% for k, v in enrichment.whois.items() %}
<tr class="border-b border-gray-900">
<td class="py-2 pr-4 whitespace-nowrap">{{ k.replace('_', ' ').title() }}</td>
<td class="py-2 pr-4 break-all">{{ v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if enrichment.raw_whois %}
<h3 class="text-base font-semibold mt-4 mb-2">Raw WHOIS</h3>
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-sm">{{ enrichment.raw_whois }}</pre>
{% endif %}
{% if enrichment.geoip %}
<h3 class="text-base font-semibold mt-4 mb-2">GeoIP</h3>
{% for ip, info in enrichment.geoip.items() %}
<details class="border border-gray-800 rounded-lg mb-2">
<summary class="px-3 py-2 cursor-pointer hover:bg-gray-900/50">
{{ ip }} -
{% if info.country %} {{ info.country }} {% endif %} -
{% if info.isp %} {{ info.isp }} {% endif %}
{% if info.cloudflare %}
<span class="badge badge-warn">Cloudflare </span>
{% endif %}
</summary>
<div class="px-3 pb-3 overflow-x-auto">
<table class="min-w-full text-sm">
<tbody>
{% for key, val in info.items() %}
<tr class="border-b border-gray-900">
<td class="py-2 pr-4 whitespace-nowrap text-gray-400">{{ key.replace('_', ' ').title() }}</td>
<td class="py-2 pr-4 break-all">{{ val }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}
{% endif %}
{% if not enrichment.whois and not enrichment.raw_whois and not enrichment.geoip and not enrichment.bec_words %}
<p class="text-sm text-gray-500">No enrichment data available.</p>
{% endif %}
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>

View File

@@ -0,0 +1,113 @@
<!-- /templates/partials/result_forms.html -->
<section id="forms" class="card">
<h2 class="text-lg font-semibold mb-3">Suspicious Form Hits</h2>
{% if forms and forms|length > 0 %}
<div class="overflow-x-auto">
<table class="w-full table-fixed text-sm"> <!-- was: min-w-full -->
<colgroup>
<col class="w-[10%]"> <!-- Action -->
<col class="w-[10%]"> <!-- Method -->
<col class="w-[15%]"> <!-- Inputs -->
<col class="w-[45%]"> <!-- Matches -->
<col class="w-[25%]"> <!-- Snippet -->
</colgroup>
<thead class="text-gray-400 border-b border-gray-800">
<tr>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Action</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Method</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Inputs</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Matches (Rules)</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Form Snippet</th>
</tr>
</thead>
<tbody>
{% for f in forms %}
<tr class="border-b border-gray-900 align-top">
<!-- Action -->
<td class="py-2 pr-4 break-all">
{% if f.action %}
{{ f.action[:80] }}{% if f.action|length > 80 %}…{% endif %}
{% else %}
<span class="text-gray-500">(no action)</span>
{% endif %}
</td>
<!-- Method -->
<td class="py-2 pr-4 whitespace-nowrap">{{ (f.method or 'get')|upper }}</td>
<!-- Inputs -->
<td class="py-2 pr-4 break-words">
{% if f.inputs and f.inputs|length > 0 %}
<div class="flex flex-wrap gap-1">
{% for inp in f.inputs %}
<span class="chip"
title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
{{ inp.name or '(unnamed)' }}<small class="text-gray-400"> : {{ (inp.type or 'text') }}</small>
</span>
{% endfor %}
</div>
{% else %}
<span class="chip">None</span>
{% endif %}
</td>
<!-- Matches (Rules) -->
<td class="py-2 pr-4 break-words">
{% if f.rules and f.rules|length > 0 %}
<ul class="space-y-1">
{% for r in f.rules %}
<li title="{{ r.description or '' }}">
{{ r.name }}
{% if r.severity %}
{% set sev = r.severity|lower %}
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
{% if sev == 'high' %} badge badge-danger
{% elif sev == 'medium' %} badge badge-warn
{% else %} badge badge-info {% endif %}">
{{ r.severity|title }}
</span>
{% endif %}
{% if r.tags %}
{% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %}
{% endif %}
{% if r.description %}
<small class="text-gray-400"> — {{ r.description }}</small>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td>
<!-- Form Snippet (let column width control it) -->
<td class="py-2 pr-4 align-top">
{% if f.content_snippet %}
<details>
<summary class="cursor-pointer text-blue-300 hover:underline">
View snippet ({{ f.content_snippet|length }} chars)
</summary>
<pre class="mt-1 bg-[#0b0f14] border border-gray-800 rounded-lg p-3
w-full max-w-full overflow-auto max-h-64
whitespace-pre-wrap break-words font-mono text-xs">{{ f.content_snippet }}</pre>
</details>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-500">No form issues detected.</p>
{% endif %}
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>

View File

@@ -0,0 +1,120 @@
<!-- /templates/partials/result_scripts.html -->
<section id="scripts" class="card">
<h2 class="text-lg font-semibold mb-3">Suspicious Scripts</h2>
{% if suspicious_scripts %}
<div class="overflow-x-auto">
<table class="w-full table-fixed text-sm">
<colgroup>
<col class="w-[10%]"> <!-- Type -->
<col class="w-[15%]"> <!-- Source -->
<col class="w-[45%]"> <!-- Matches -->
<col class="w-[30%]"> <!-- Snippet -->
</colgroup>
<thead class="text-gray-400 border-b border-gray-800">
<tr>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Type</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Source URL</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Matches (Rules &amp; Heuristics)</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Content Snippet</th>
</tr>
</thead>
<tbody>
{% for s in suspicious_scripts %}
<tr class="border-b border-gray-900 align-top">
<td class="py-2 pr-4 whitespace-nowrap">{{ s.type or 'unknown' }}</td>
<td class="py-2 pr-4 break-all">
{% if s.src %}
<a href="{{ s.src }}" target="_blank" rel="noopener" class="hover:text-blue-400">
{{ s.src[:100] }}{% if s.src|length > 100 %}…{% endif %}
</a>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td>
<!-- Matches (Rules & Heuristics) -->
<td class="py-2 pr-4 break-words" data-role="matches-cell">
{% set has_rules = s.rules and s.rules|length > 0 %}
{% set has_heur = s.heuristics and s.heuristics|length > 0 %}
{% if has_rules %}
<div class="mb-1"><strong>Rules</strong></div>
<ul class="space-y-1">
{% for r in s.rules %}
<li title="{{ r.description or '' }}">
{{ r.name }}
{% if r.severity %}
{% set sev = r.severity|lower %}
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
{% if sev == 'high' %} badge badge-danger
{% elif sev == 'medium' %} badge badge-warn
{% else %} badge badge-info {% endif %}">
{{ r.severity|title }}
</span>
{% endif %}
{% if r.tags %}
{% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %}
{% endif %}
{% if r.description %}
<small class="text-gray-400"> — {{ r.description }}</small>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% if has_heur %}
<div class="mt-2 mb-1"><strong>Heuristics</strong></div>
<ul class="list-disc list-inside space-y-1">
{% for h in s.heuristics %}
<li>{{ h }}</li>
{% endfor %}
</ul>
{% endif %}
{% if not has_rules and not has_heur %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td>
<!-- Content Snippet (let column width control it) -->
<td class="py-2 pr-4 align-top" data-role="snippet-cell">
{% if s.content_snippet %}
<details>
<summary class="cursor-pointer text-blue-300 hover:underline">
View snippet ({{ s.content_snippet|length }} chars)
</summary>
<pre class="mt-1 bg-[#0b0f14] border border-gray-800 rounded-lg p-3
w-full max-w-full overflow-auto max-h-64
whitespace-pre-wrap break-words font-mono text-xs">{{ s.content_snippet }}</pre>
</details>
{% else %}
{% if s.type == 'external' and s.src %}
<button
type="button"
class="btn-analyze-snippet inline-flex items-center gap-2 rounded-lg px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs"
data-url="{{ s.src }}"
data-job="{{ uuid }}">
Analyze external script
</button>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-500">No suspicious scripts detected.</p>
{% endif %}
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>

View File

@@ -0,0 +1,200 @@
{# templates/result_ssl_tls.html #}
{% macro ssl_tls_card(ssl_tls) %}
<section id="ssl" class="card">
<h2 class="text-lg font-semibold mb-3">TLS / Certs</h2>
<div class="space-y-4">
{# -------- 1) Error branch -------- #}
{% if ssl_tls is none or (ssl_tls.error is defined) or ('error' in ssl_tls) %}
<span class="badge badge-danger">Error</span>
<p class="text-sm text-gray-500">SSL/TLS enrichment failed or is unavailable.</p>
{% if ssl_tls and ssl_tls.error %}
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ ssl_tls.error }}</pre>
{% endif %}
{# -------- 2) Skipped branch -------- #}
{% elif ssl_tls.skipped %}
<span class="chip">Skipped</span>
{% if ssl_tls.reason %}<span class="text-gray-400 text-sm ml-2">{{ ssl_tls.reason }}</span>{% endif %}
<details class="mt-3">
<summary class="cursor-pointer text-blue-300 hover:underline">Raw TLS JSON</summary>
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ ssl_tls|tojson(indent=2) }}</pre>
</details>
{# -------- 3) Normal branch (render probe + crt.sh) -------- #}
{% else %}
{# ===================== LIVE PROBE ===================== #}
{% set probe = ssl_tls.probe if ssl_tls else None %}
<section class="space-y-2">
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold">Live TLS Probe</h3>
{% if probe %}
<span class="text-gray-400 text-sm">Host:</span>
<code class="text-sm">{{ probe.hostname }}:{{ probe.port }}</code>
{% endif %}
</div>
{% if not probe %}
<p class="text-sm text-gray-500">No probe data.</p>
{% else %}
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-gray-400 border-b border-gray-800">
<tr>
<th class="text-left py-2 pr-4">Version</th>
<th class="text-left py-2 pr-4">Status</th>
<th class="text-left py-2 pr-4">Selected Cipher</th>
<th class="text-left py-2 pr-4">Latency</th>
</tr>
</thead>
<tbody>
{% set versions = ['TLS1.0','TLS1.1','TLS1.2','TLS1.3'] %}
{% for v in versions %}
{% set r = probe.results_by_version.get(v) if probe.results_by_version else None %}
<tr class="border-b border-gray-900">
<td class="py-2 pr-4 whitespace-nowrap">{{ v }}</td>
<td class="py-2 pr-4">
{% if r and r.supported %}
<span class="badge badge-success">Supported</span>
{% else %}
<span class="badge badge-info">Not Supported</span>
{% endif %}
</td>
<td class="py-2 pr-4">
{% if r and r.selected_cipher %}
<span class="badge badge-warn">{{ r.selected_cipher }}</span>
{% elif r and r.error %}
<span class="text-gray-500 text-xs">({{ r.error }})</span>
{% else %}
<span class="text-gray-500"></span>
{% endif %}
</td>
<td class="py-2 pr-4 whitespace-nowrap">
{% if r and r.handshake_seconds is not none %}
<span class="text-gray-400">{{ '%.0f' % (r.handshake_seconds*1000) }} ms</span>
{% else %}
<span class="text-gray-500"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex flex-wrap items-center gap-2 mt-2">
{% if probe.weak_protocols and probe.weak_protocols|length > 0 %}
<span class="badge badge-warn">Weak Protocols</span>
{% for wp in probe.weak_protocols %}
<span class="chip">{{ wp }}</span>
{% endfor %}
{% endif %}
{% if probe.weak_ciphers and probe.weak_ciphers|length > 0 %}
<span class="badge badge-warn">Weak Ciphers</span>
{% for wc in probe.weak_ciphers %}
<span class="chip">{{ wc }}</span>
{% endfor %}
{% endif %}
</div>
{% if probe.errors and probe.errors|length > 0 %}
<details class="mt-2">
<summary class="cursor-pointer text-blue-300 hover:underline">Probe Notes</summary>
<ul class="list-disc list-inside text-sm text-gray-400 space-y-1 mt-1">
{% for e in probe.errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% endif %}
</section>
<hr class="border-gray-800 my-4"/>
{# ===================== CRT.SH ===================== #}
{% set crtsh = ssl_tls.crtsh if ssl_tls else None %}
<section class="space-y-2">
<div class="flex items-center flex-wrap gap-2">
<h3 class="text-base font-semibold">Certificate Transparency (crt.sh)</h3>
{% if crtsh %}
<span class="text-gray-400 text-sm">Parsed:</span>
<code class="text-sm">{{ crtsh.hostname or 'n/a' }}</code>
{% if crtsh.root_domain %}
<span class="text-gray-400 text-sm">• Root:</span>
<code class="text-sm">{{ crtsh.root_domain }}</code>
{% if crtsh.is_root_domain %}
<span class="badge badge-success">Root</span>
{% else %}
<span class="badge badge-info">Subdomain</span>
{% endif %}
{% endif %}
{% endif %}
</div>
{% if not crtsh %}
<p class="text-sm text-gray-500">No CT data.</p>
{% else %}
<div class="grid md:grid-cols-2 gap-4">
<div>
<h4 class="text-gray-400 text-sm mb-2">Host Certificates</h4>
{% set host_certs = crtsh.crtsh.host_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
{% if host_certs and host_certs|length > 0 %}
<ul class="space-y-1 text-xs">
{% for c in host_certs[:10] %}
<li class="font-mono">
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span>
<span class="text-gray-500"></span>
<strong class="break-all">{{ c.get('name_value','(name n/a)') }}</strong>
<span class="text-gray-500"> • not_before:</span> {{ c.get('not_before','?') }}
</li>
{% endfor %}
</ul>
{% if host_certs|length > 10 %}
<div class="text-gray-500 text-xs mt-1">(+ {{ host_certs|length - 10 }} more)</div>
{% endif %}
{% else %}
<p class="text-sm text-gray-500">No active host certs found.</p>
{% endif %}
</div>
<div>
<h4 class="text-gray-400 text-sm mb-2">Wildcard on Root</h4>
{% set wc = crtsh.crtsh.wildcard_root_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
{% if wc and wc|length > 0 %}
<ul class="space-y-1 text-xs">
{% for c in wc[:10] %}
<li class="font-mono">
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span>
<span class="text-gray-500"></span>
<strong class="break-all">{{ c.get('name_value','(name n/a)') }}</strong>
<span class="text-gray-500"> • not_before:</span> {{ c.get('not_before','?') }}
</li>
{% endfor %}
</ul>
{% if wc|length > 10 %}
<div class="text-gray-500 text-xs mt-1">(+ {{ wc|length - 10 }} more)</div>
{% endif %}
{% else %}
<p class="text-sm text-gray-500">No wildcard/root certs found.</p>
{% endif %}
</div>
</div>
{% endif %}
</section>
<!-- Raw JSON toggle -->
<details class="mt-3">
<summary class="cursor-pointer text-blue-300 hover:underline">Raw TLS JSON</summary>
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ ssl_tls|tojson(indent=2) }}</pre>
</details>
{% endif %}
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</div>
</section>
{% endmacro %}

View File

@@ -0,0 +1,120 @@
<!-- /templates/partials/result_text.html -->
<section id="sus_text" class="card">
<h2 class="text-lg font-semibold mb-3">Suspicious Text</h2>
{% if suspicious_text and suspicious_text|length > 0 %}
<div class="overflow-x-auto">
<table class="w-full table-fixed text-sm"> <!-- matches forms table style -->
<colgroup>
<col class="w-[10%]"> <!-- Source -->
<col class="w-[10%]"> <!-- Indicators -->
<col class="w-[15%]"> <!-- Tags -->
<col class="w-[45%]"> <!-- Matches (Rules) -->
<col class="w-[25%]"> <!-- Text Snippet -->
</colgroup>
<thead class="text-gray-400 border-b border-gray-800">
<tr>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Source</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Indicators</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Tags</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Matches (Rules)</th>
<th class="text-left py-2 pr-4 whitespace-normal break-words">Text Snippet</th>
</tr>
</thead>
<tbody>
{% for rec in suspicious_text %}
<tr class="border-b border-gray-900 align-top">
<!-- Source -->
<td class="py-2 pr-4 break-words">
{{ (rec.type or 'page')|title }}
</td>
<!-- Indicators (count of rules matched) -->
<td class="py-2 pr-4 whitespace-nowrap">
{{ rec.rules|length if rec.rules else 0 }}
</td>
<!-- Tags (unique across rules) -->
<td class="py-2 pr-4 break-words">
{% set ns = namespace(tags=[]) %}
{% if rec.rules %}
{% for r in rec.rules %}
{% if r.tags %}
{% for t in r.tags %}
{% if t not in ns.tags %}
{% set ns.tags = ns.tags + [t] %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
{% if ns.tags and ns.tags|length > 0 %}
<div class="flex flex-wrap gap-1">
{% for t in ns.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %}
</div>
{% else %}
<span class="chip">None</span>
{% endif %}
</td>
<!-- Matches (Rules) -->
<td class="py-2 pr-4 break-words">
{% if rec.rules and rec.rules|length > 0 %}
<ul class="space-y-1">
{% for r in rec.rules %}
<li title="{{ r.description or '' }}">
{{ r.name }}
{% if r.severity %}
{% set sev = r.severity|lower %}
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
{% if sev == 'high' %} badge badge-danger
{% elif sev == 'medium' %} badge badge-warn
{% else %} badge badge-info {% endif %}">
{{ r.severity|title }}
</span>
{% endif %}
{% if r.tags %}
{% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %}
{% endif %}
{% if r.description %}
<small class="text-gray-400"> — {{ r.description }}</small>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td>
<!-- Text Snippet (matched phrases; let column width control it) -->
<td class="py-2 pr-4 align-top">
{% if rec.content_snippet %}
<details>
<summary class="cursor-pointer text-blue-300 hover:underline">
View snippet ({{ rec.content_snippet|length }} chars)
</summary>
<pre class="mt-1 bg-[#0b0f14] border border-gray-800 rounded-lg p-3
w-full max-w-full overflow-auto max-h-64
whitespace-pre-wrap break-words font-mono text-xs">{{ rec.content_snippet }}</pre>
</details>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-500">No text issues detected.</p>
{% endif %}
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>

View File

@@ -1,352 +1,137 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros_ssl_tls.html" import ssl_tls_card %} {% block title %}Scan Results{% endblock %}
{% block content %} {% block content %}
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
<!-- Top Jump List --> <!-- Top Jump List (sticky) -->
<nav id="top-jump-list"
class="sticky top-0 z-20 bg-bg/80 backdrop-blur border-b border-gray-800 py-2 px-3 rounded-b xl:rounded-b-none">
<div class="flex flex-wrap gap-2 text-sm">
<a href="{{ url_for('main.index') }}" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Analyse Another Page</a>
<a href="#url-overview" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">URL Overview</a>
<a href="#enrichment" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Enrichment</a>
<a href="#ssl" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">TLS / Certs</a>
<a href="#redirects" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Redirects</a>
<a href="#forms" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Forms</a>
<a href="#scripts" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Suspicious Scripts</a>
<a href="#sus_text" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Suspicious Text</a>
<a href="#screenshot" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Screenshot</a>
<a href="#source" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Source</a>
</div>
</nav>
<!-- Sticky top nav --> <!-- URL Overview -->
<nav id="top-jump-list" class="top-jump-nav" aria-label="Jump to section"> <section id="url-overview" class="bg-card border border-gray-800 rounded-xl p-4">
<a href="/">Analyse Another Page</a> <h2 class="text-lg font-semibold mb-3">URL Overview</h2>
<a href="#url-overview">URL Overview</a> <div class="space-y-2 text-sm">
<a href="#enrichment">Enrichment</a> <p><span class="text-gray-400">Submitted URL:</span> <span class="break-all">{{ submitted_url }}</span></p>
<a href="#ssl">TLS / Certs</a> <p>
<a href="#redirects">Redirects</a> <span class="text-gray-400">Final URL:</span>
<a href="#forms">Forms</a> <span class="break-all">{{ final_url }}</span>
<a href="#scripts">Suspicious Scripts</a> </p>
<a href="#screenshot">Screenshot</a> <p>
<a href="#source">Source</a> <span class="text-gray-400">Permalink:</span>
</nav> <a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}" class="break-all hover:text-blue-400">
Permalink for {{ uuid }}
<!-- URL Overview -->
<div class="card" id="url-overview">
<h2>URL Overview</h2>
<p><strong>Submitted URL:</strong> {{ submitted_url }}</p>
<p><strong>Final URL:</strong> <a href="{{ final_url }}" target="_blank">{{ final_url }}</a></p>
<p><strong>Permalink:</strong>
<a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}">
{{ request.host_url }}results/{{ uuid }}
</a> </a>
</p> </p>
<p><a href="#top-jump-list">Back to top</a></p> <p>
</div> <span class="text-gray-400">Full Results File:</span>
<a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='results.json') }}"
target="_blank" rel="noopener"
class="break-all hover:text-blue-400">
Results File
</a>
</p>
<!-- Enrichment --> <p><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
<div class="card" id="enrichment"> </div>
<h2>Enrichment</h2> </section>
<!-- WHOIS --> <!-- Enrichment -->
{% if enrichment.whois %} {% include "partials/result_enrichment.html" %}
<h3>WHOIS</h3>
<table class="enrichment-table">
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for k, v in enrichment.whois.items() %}
<tr>
<td>{{ k.replace('_', ' ').title() }}</td>
<td>{{ v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if enrichment.raw_whois %} <!-- TLS / SSL / CERTS -->
<h3>Raw WHOIS</h3> {% from "partials/result_ssl_tls.html" import ssl_tls_card %}
<pre class="code">{{ enrichment.raw_whois }}</pre> {{ ssl_tls_card(enrichment.ssl_tls) }}
{% endif %}
<!-- GeoIP / IP-API --> <!-- Redirects -->
{% if enrichment.geoip %} <section id="redirects" class="bg-card border border-gray-800 rounded-xl p-4">
<h3>GeoIP</h3> <h2 class="text-lg font-semibold mb-3">Redirects</h2>
{% for ip, info in enrichment.geoip.items() %}
<details class="card" style="padding:0.5rem; margin-bottom:0.5rem;">
<summary>{{ ip }}</summary>
<table class="enrichment-table">
<tbody>
{% for key, val in info.items() %}
<tr>
<td>{{ key.replace('_', ' ').title() }}</td>
<td>{{ val }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endfor %}
{% endif %}
{% if not enrichment.whois and not enrichment.raw_whois and not enrichment.geoip and not enrichment.bec_words %}
<p>No enrichment data available.</p>
{% endif %}
<p><a href="#top-jump-list">Back to top</a></p>
</div>
<!-- TLS / SSL / CERTS -->
{{ ssl_tls_card(enrichment.ssl_tls) }}
<!-- Redirects -->
<div class="card" id="redirects">
<h2>Redirects</h2>
{% if redirects %} {% if redirects %}
<table class="enrichment-table"> <div class="overflow-x-auto">
<thead> <table class="min-w-full text-sm">
<tr> <thead class="text-gray-400 border-b border-gray-800">
<th>Status</th> <tr>
<th>URL</th> <th class="text-left py-2 pr-4">Status</th>
</tr> <th class="text-left py-2 pr-4">URL</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for r in redirects %} {% for r in redirects %}
<tr> <tr class="border-b border-gray-900">
<td>{{ r.status }}</td> <td class="py-2 pr-4 whitespace-nowrap">{{ r.status }}</td>
<td><a href="{{ r.url }}" target="_blank">{{ r.url }}</a></td> <td class="py-2 pr-4 break-all">
</tr> <a href="{{ r.url }}" target="_blank" rel="noopener" class="hover:text-blue-400">{{ r.url }}</a>
{% endfor %} </td>
</tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<p>No redirects detected.</p> <p class="text-sm text-gray-500">No redirects detected.</p>
{% endif %} {% endif %}
<p><a href="#top-jump-list">Back to top</a></p> <p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
<!-- Forms -->
{% include "partials/result_forms.html" %}
<!-- Suspicious Scripts -->
{% include "partials/result_scripts.html" %}
<!-- Suspicious Text -->
{% include "partials/result_text.html" with context %}
<!-- Screenshot -->
<section id="screenshot" class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-lg font-semibold mb-3">Screenshot</h2>
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}"
alt="Screenshot"
class="w-full rounded-lg border border-gray-800">
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
<!-- Source -->
<section id="source" class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-lg font-semibold mb-3">Source</h2>
<p>
<a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}"
target="_blank" rel="noopener"
class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700">
View Source
</a>
</p>
<p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
</div> </div>
<!-- Forms -->
<div class="card" id="forms">
<h2>Forms</h2>
{% if forms and forms|length > 0 %}
<table class="enrichment-table forms-table">
<thead>
<tr>
<th>Action</th>
<th>Method</th>
<th>Inputs</th>
<th>Matches (Rules)</th>
<th>Form Snippet</th>
</tr>
</thead>
<tbody>
{% for f in forms %}
<tr>
<!-- Action -->
<td class="breakable">
{% if f.action %}
{{ f.action[:25] }}{% if f.action|length > 25 %}…{% endif %}
{% else %}
<span class="text-dim">(no action)</span>
{% endif %}
</td>
<!-- Method -->
<td>{{ (f.method or 'get')|upper }}</td>
<!-- Inputs -->
<td>
{% if f.inputs and f.inputs|length > 0 %}
<div class="chips">
{% for inp in f.inputs %}
<span class="chip" title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
{{ inp.name or '(unnamed)' }}<small> : {{ (inp.type or 'text') }}</small>
</span>
{% endfor %}
</div>
{% else %}
<span class="text-dim">None</span>
{% endif %}
</td>
<!-- Matches (Rules) -->
<td>
{% if f.rules and f.rules|length > 0 %}
<ul>
{% for r in f.rules %}
<li title="{{ r.description or '' }}">
{{ r.name }}
{% if r.severity %}
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span>
{% endif %}
{% if r.tags %}
{% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %}
{% endif %}
{% if r.description %}
<small> — {{ r.description }}</small>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<span class="text-dim">N/A</span>
{% endif %}
</td>
<!-- Form Snippet -->
<td>
{% if f.content_snippet %}
<details>
<summary>View snippet ({{ f.content_snippet|length }} chars)</summary>
<pre class="code">{{ f.content_snippet }}</pre>
</details>
{% else %}
<span class="text-dim">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-dim">No form issues detected.</p>
{% endif %}
<p><a href="#top-jump-list">Back to top</a></p>
</div>
<!-- Suspicious Scripts -->
<div class="card" id="scripts">
<h2>Suspicious Scripts</h2>
{% if suspicious_scripts %}
<table class="enrichment-table scripts-table">
<thead>
<tr>
<th>Type</th>
<th>Source URL</th>
<th>Matches (Rules & Heuristics)</th>
<th>Content Snippet</th>
</tr>
</thead>
<tbody>
{% for s in suspicious_scripts %}
<tr>
<!-- Type -->
<td>{{ s.type or 'unknown' }}</td>
<!-- Source URL -->
<td class="breakable">
{% if s.src %}
<a href="{{ s.src }}" target="_blank" rel="noopener">{{ s.src[:50] }}</a>
{% else %} N/A {% endif %}
</td>
<!-- Matches (Rules & Heuristics) -->
<td data-role="matches-cell">
{% set has_rules = s.rules and s.rules|length > 0 %}
{% set has_heur = s.heuristics and s.heuristics|length > 0 %}
{% if has_rules %}
<strong>Rules</strong>
<ul>
{% for r in s.rules %}
<li title="{{ r.description or '' }}">
{{ r.name }}
{% if r.severity %}
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span>
{% endif %}
{% if r.tags %}
{% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %}
{% endif %}
{% if r.description %}
<small>— {{ r.description }}</small>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% if has_heur %}
<strong>Heuristics</strong>
<ul>
{% for h in s.heuristics %}
<li>{{ h }}</li>
{% endfor %}
</ul>
{% endif %}
{% if not has_rules and not has_heur %}
<span class="text-dim">N/A</span>
{% endif %}
</td>
<!-- Content Snippet (reused for Analyze button / dynamic snippet) -->
<td data-role="snippet-cell">
{% if s.content_snippet %}
<details>
<summary>View snippet ({{ s.content_snippet|length }} chars)</summary>
<pre class="code">{{ s.content_snippet }}</pre>
</details>
{% else %}
{% if s.type == 'external' and s.src %}
<button
type="button"
class="btn btn-sm btn-primary btn-analyze-snippet"
data-url="{{ s.src }}"
data-job="{{ uuid }}">Analyze external script</button>
{% else %}
<span class="text-dim">N/A</span>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No suspicious scripts detected.</p>
{% endif %}
<p><a href="#top-jump-list">Back to top</a></p>
</div>
<!-- Screenshot -->
<div class="card" id="screenshot">
<h2>Screenshot</h2>
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}" alt="Screenshot">
<p><a href="#top-jump-list">Back to top</a></p>
</div>
<!-- Source -->
<div class="card" id="source">
<h2>Source</h2>
<p><a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}" target="_blank">View Source</a></p>
<p><a href="#top-jump-list">Back to top</a></p>
</div>
{% endblock %} {% endblock %}
{% block page_js %} {% block scripts %}
<script> <script>
/** /**
* From an absolute artifact path like: * Helpers to parse artifact path and build viewer URL
* /data/<uuid>/scripts/fetched/0.js
* /data/<uuid>/1755803694244.js
* C:\data\<uuid>\1755803694244.js
* return { uuid, rel } where rel is the path segment(s) after the uuid.
*/ */
function parseArtifactPath(artifactPath) { function parseArtifactPath(artifactPath) {
if (!artifactPath) return { uuid: null, rel: null }; if (!artifactPath) return { uuid: null, rel: null };
const norm = String(artifactPath).replace(/\\/g, '/'); // windows -> posix const norm = String(artifactPath).replace(/\\/g, '/'); // windows -> posix
const re = /\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/(.+)$/; const m = norm.match(/\/([0-9a-fA-F-]{36})\/(.+)$/);
const m = norm.match(re);
if (!m) return { uuid: null, rel: null }; if (!m) return { uuid: null, rel: null };
return { uuid: m[1], rel: m[2] }; return { uuid: m[1], rel: m[2] };
} }
/** Build /view/artifact/<uuid>/<path:filename> */
function buildViewerUrlFromAbsPath(artifactPath) { function buildViewerUrlFromAbsPath(artifactPath) {
const { uuid, rel } = parseArtifactPath(artifactPath); const { uuid, rel } = parseArtifactPath(artifactPath);
if (!uuid || !rel) return '#'; if (!uuid || !rel) return '#';
@@ -354,7 +139,21 @@ function buildViewerUrlFromAbsPath(artifactPath) {
return `/view/artifact/${encodeURIComponent(uuid)}/${encodedRel}`; return `/view/artifact/${encodeURIComponent(uuid)}/${encodedRel}`;
} }
document.addEventListener('click', function (e) { /**
* Map severities to Tailwind badge classes
*/
function severityClass(sev) {
switch ((sev || '').toString().toLowerCase()) {
case 'high': return 'ml-2 rounded-full px-2 py-0.5 text-xs border bg-red-600/20 text-red-300 border-red-700';
case 'medium': return 'ml-2 rounded-full px-2 py-0.5 text-xs border bg-yellow-600/20 text-yellow-300 border-yellow-700';
default: return 'ml-2 rounded-full px-2 py-0.5 text-xs border bg-blue-600/20 text-blue-300 border-blue-700';
}
}
/**
* Handle "Analyze external script" buttons
*/
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-analyze-snippet'); const btn = e.target.closest('.btn-analyze-snippet');
if (!btn) return; if (!btn) return;
@@ -365,125 +164,93 @@ document.addEventListener('click', function (e) {
const url = btn.dataset.url; const url = btn.dataset.url;
const job = btn.dataset.job; const job = btn.dataset.job;
// Replace button with a lightweight loading text // Replace button with lightweight loading text
const loading = document.createElement('span'); const loading = document.createElement('span');
loading.className = 'text-dim'; loading.className = 'text-gray-400';
loading.textContent = 'Analyzing…'; loading.textContent = 'Analyzing…';
btn.replaceWith(loading); btn.replaceWith(loading);
fetch('/api/analyze_script', { try {
method: 'POST', const r = await fetch('/api/analyze_script', {
headers: { 'Content-Type': 'application/json' }, // include CSRF header if applicable method: 'POST',
body: JSON.stringify({ job_id: job, url: url}) headers: { 'Content-Type': 'application/json' },
}) body: JSON.stringify({ job_id: job, url: url })
.then(r => r.json()) });
.then(data => { const data = await r.json();
if (!data.ok) { if (!data.ok) {
loading.textContent = 'Error: ' + (data.error || 'Unknown'); loading.textContent = 'Error: ' + (data.error || 'Unknown');
return; return;
} }
// --- Build the snippet details element --- // Build details with snippet
const snippetText = data.snippet || ''; // backend should return a preview
const snippetLen = data.snippet_len || snippetText.length;
// --- File path / viewer things
const filepath = data.artifact_path || ''; // e.g., "/data/3ec90584-076e-457c-924b-861be7e11a34/1755803694244.js"
const viewerUrl = buildViewerUrlFromAbsPath(filepath);
const details = document.createElement('details'); const details = document.createElement('details');
const summary = document.createElement('summary'); const summary = document.createElement('summary');
summary.textContent = 'View snippet (' + data.snippet_len + ' chars' + (data.truncated ? ', truncated' : '') + ', ' + data.bytes + ' bytes)'; summary.className = 'cursor-pointer text-blue-300 hover:underline';
summary.textContent = 'View snippet (' + (data.snippet_len ?? (data.snippet || '').length) +
' chars' + (data.truncated ? ', truncated' : '') +
', ' + (data.bytes ?? '') + ' bytes)';
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.className = 'code'; pre.className = 'bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1';
pre.textContent = snippetText; // textContent preserves literal code safely pre.textContent = data.snippet || '';
// put things in the DOM const viewerUrl = buildViewerUrlFromAbsPath(data.artifact_path || '');
const open = document.createElement('a');
open.href = viewerUrl; open.target = '_blank'; open.rel = 'noopener';
open.className = 'ml-2 text-sm text-gray-300 hover:text-blue-400';
open.textContent = 'open in viewer';
summary.appendChild(document.createTextNode(' '));
summary.appendChild(open);
details.appendChild(summary); details.appendChild(summary);
details.appendChild(pre); details.appendChild(pre);
const link = document.createElement('a');
link.href = viewerUrl;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'open in viewer';
summary.appendChild(document.createElement('br')); // line break under the summary text
summary.appendChild(link);
loading.replaceWith(details); loading.replaceWith(details);
// Replace "Analyzing…" with the new details block // Update Matches cell with rule findings
loading.replaceWith(details);
// --- Update the Matches cell with rule findings ---
if (matchesCell) { if (matchesCell) {
if (Array.isArray(data.findings) && data.findings.length) { if (Array.isArray(data.findings) && data.findings.length) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const strong = document.createElement('strong'); const strong = document.createElement('div');
strong.textContent = 'Rules'; strong.className = 'mb-1';
strong.innerHTML = '<strong>Rules</strong>';
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.className = 'space-y-1';
data.findings.forEach(function (f) { data.findings.forEach((f) => {
const li = document.createElement('li'); const li = document.createElement('li');
const name = f.name || 'Rule'; li.textContent = (f.name || 'Rule') + (f.description ? ' — ' + f.description : '');
const desc = f.description ? ' — ' + f.description : '';
li.textContent = name + desc;
// Optional badges for severity/tags if present
if (f.severity) { if (f.severity) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'badge sev-' + String(f.severity).toLowerCase(); badge.className = severityClass(f.severity);
badge.textContent = String(f.severity).charAt(0).toUpperCase() + String(f.severity).slice(1); badge.textContent = String(f.severity).charAt(0).toUpperCase() + String(f.severity).slice(1);
li.appendChild(document.createTextNode(' ')); li.appendChild(document.createTextNode(' '));
li.appendChild(badge); li.appendChild(badge);
} }
if (Array.isArray(f.tags)) { if (Array.isArray(f.tags)) {
f.tags.forEach(function (t) { f.tags.forEach((t) => {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'chip'; chip.className = 'ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs';
chip.title = 'Tag: ' + t; chip.title = 'Tag: ' + t;
chip.textContent = t; chip.textContent = t;
li.appendChild(document.createTextNode(' ')); li.appendChild(document.createTextNode(' '));
li.appendChild(chip); li.appendChild(chip);
}); });
} }
ul.appendChild(li); ul.appendChild(li);
}); });
frag.appendChild(strong); frag.appendChild(strong);
frag.appendChild(ul); frag.appendChild(ul);
// Replace placeholder N/A or existing heuristics-only content
matchesCell.innerHTML = ''; matchesCell.innerHTML = '';
matchesCell.appendChild(frag); matchesCell.appendChild(frag);
} else { } else {
matchesCell.innerHTML = '<span class="text-dim">No rule matches.</span>'; matchesCell.innerHTML = '<span class="text-gray-500">No rule matches.</span>';
} }
} }
}) } catch (err) {
.catch(function (err) {
loading.textContent = 'Request failed: ' + err; loading.textContent = 'Request failed: ' + err;
}); }
}); });
</script> </script>
<script>
document.addEventListener('click', function (e) {
if (e.target.matches('[data-toggle]')) {
var id = e.target.getAttribute('data-toggle');
var el = document.getElementById(id);
if (el) {
var hidden = el.getAttribute('hidden') !== null;
if (hidden) { el.removeAttribute('hidden'); } else { el.setAttribute('hidden', ''); }
}
}
}, true);
</script>
{% endblock %} {% endblock %}

262
app/templates/roadmap.html Normal file
View File

@@ -0,0 +1,262 @@
{% extends "base.html" %}
{% block title %}Roadmap{% endblock %}
{% block content %}
<div class="mx-auto max-w-6xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">SneakyScope Roadmap</h1>
{% if updated %}
<p class="text-sm text-gray-400">Last updated: {{ updated }}</p>
{% endif %}
</div>
<!-- Section switcher -->
<form method="get" action="{{ url_for('roadmap.roadmap_view') }}" class="flex items-center gap-2">
<input type="hidden" name="q" value="{{ q }}">
{% for t in selected_tags %}
<input type="hidden" name="tag" value="{{ t }}">
{% endfor %}
{% if min_priority is not none %}
<input type="hidden" name="min_priority" value="{{ min_priority }}">
{% endif %}
{% if milestone %}
<input type="hidden" name="milestone" value="{{ milestone }}">
{% endif %}
<select name="section" class="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm">
<option value="roadmap" {{ "selected" if section == "roadmap" else "" }}>Roadmap</option>
<option value="backlog" {{ "selected" if section == "backlog" else "" }}>Backlog</option>
<option value="open_questions" {{ "selected" if section == "open_questions" else "" }}>Open Questions</option>
</select>
<button class="rounded-2xl bg-gray-800 px-4 py-2 text-sm font-medium hover:bg-gray-700">Go</button>
</form>
</div>
<!-- Filters -->
<form method="get" action="{{ url_for('roadmap.roadmap_view') }}" class="mb-6 grid gap-4 sm:grid-cols-12">
<input type="hidden" name="section" value="{{ section }}">
<!-- search -->
<div class="sm:col-span-5">
<label class="mb-1 block text-sm text-gray-300">Search</label>
<input name="q" value="{{ q }}" placeholder="Search title, goal, or ID"
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<!-- min priority -->
<div class="sm:col-span-2">
<label class="mb-1 block text-sm text-gray-300">Min Priority</label>
<input name="min_priority" type="number" min="1" max="9" value="{{ min_priority if min_priority is not none }}"
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<!-- milestone -->
<div class="sm:col-span-3">
<label class="mb-1 block text-sm text-gray-300">Milestone</label>
<input name="milestone" value="{{ milestone or '' }}" placeholder="e.g., v0.2"
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<!-- submit -->
<div class="sm:col-span-2 flex items-end">
<button class="w-full rounded-2xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500">Filter</button>
</div>
<!-- tags -->
<div class="sm:col-span-12">
<label class="mb-2 block text-sm text-gray-300">Tags</label>
<div class="flex flex-wrap gap-2">
{% for t in all_tags %}
<label class="inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm hover:border-gray-600">
<input type="checkbox" name="tag" value="{{ t }}" class="h-4 w-4 accent-blue-600"
{% if t in selected_tags %}checked{% endif %}>
<span class="text-gray-200">{{ t }}</span>
</label>
{% endfor %}
</div>
</div>
</form>
<!-- Empty state -->
{% if not items %}
<div class="rounded-2xl border border-gray-700 bg-gray-900 p-6 text-gray-300">
No items match your filters.
</div>
{% endif %}
<!-- Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% for it in items %}
<article class="rounded-2xl border border-gray-700 bg-gray-900 p-4">
<div class="mb-2 flex items-start justify-between gap-3">
<h2 class="text-base font-semibold leading-snug">{{ it.title }}</h2>
{% if it.priority is not none %}
<span class="badge badge-info">
P{{ it.priority }}
</span>
{% endif %}
</div>
<p class="mb-3 text-sm text-gray-300">{{ it.goal }}</p>
<!-- chips -->
<div class="mb-3 flex flex-wrap gap-2">
{% for tag in it.tags %}
<span class="chips">
{{ tag }}
</span>
{% endfor %}
{% if it.milestone %}
<span class="badge badge-success">
milestone: {{ it.milestone }}
</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<code class="text-xs text-gray-400">{{ it.id }}</code>
<!-- Placeholder for future actions (Flowbite buttons/menus) -->
<button
type="button"
class="rounded-xl border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 hover:bg-gray-700"
data-modal-target="roadmap-modal"
data-modal-toggle="roadmap-modal"
data-item='{{ {
"id": it.id,
"title": it.title,
"goal": it.goal,
"priority": it.priority,
"tags": it.tags,
"milestone": it.milestone,
"details": it.details
} | tojson | forceescape }}'
>
Details
</button>
</div>
</article>
{% endfor %}
</div>
</div>
<!-- Modal -->
<div id="roadmap-modal" tabindex="-1" aria-hidden="true"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-black/60"></div>
<!-- Make SIZE + LAYOUT CHANGES HERE -->
<div class="relative z-10 w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl
max-h-[85vh] overflow-hidden rounded-2xl border border-gray-700 bg-gray-900">
<!-- Header (sticky inside modal) -->
<div class="flex items-center justify-between gap-2 border-b border-gray-800 px-4 py-3">
<h3 id="rm-title" class="text-lg font-semibold text-gray-100">Item</h3>
<button type="button" class="rounded-lg p-2 hover:bg-gray-800" data-modal-hide="roadmap-modal" aria-label="Close"></button>
</div>
<!-- Body (scrolls if long) -->
<div class="px-4 py-4 overflow-y-auto">
<div class="mb-4 space-y-2 text-sm">
<div class="text-gray-300"><span class="font-medium">ID:</span> <code id="rm-id" class="text-gray-400"></code></div>
<div class="text-gray-300"><span class="font-medium">Goal:</span> <span id="rm-goal" class="text-gray-200"></span></div>
<div class="text-gray-300"><span class="font-medium">Priority:</span> <span id="rm-priority"></span></div>
<div class="text-gray-300"><span class="font-medium">Milestone:</span> <span id="rm-milestone"></span></div>
<div class="text-gray-300"><span class="font-medium">Tags:</span>
<span id="rm-tags" class="inline-flex flex-wrap gap-2 align-middle"></span>
</div>
</div>
<div>
<h4 class="mb-2 text-sm font-semibold text-gray-200">Details</h4>
<div id="rm-details" class="prose prose-invert max-w-none text-sm"></div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 border-t border-gray-800 px-4 py-3">
<button type="button" class="rounded-xl border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 hover:bg-gray-700" data-modal-hide="roadmap-modal">
Close
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function(){
function onReady(fn){
if(document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
onReady(function(){
const modal = document.getElementById('roadmap-modal');
function el(id){
return document.getElementById(id);
}
function pill(text){
const span = document.createElement('span');
span.className = "inline-flex items-center rounded-full border border-gray-700 bg-gray-800 px-2.5 py-0.5 text-xs text-gray-200";
span.textContent = text;
return span;
}
function setText(id, v){ el(id).textContent = (v ?? ""); }
function setTags(tags){
const holder = el("rm-tags"); holder.innerHTML = "";
(tags || []).forEach(t => holder.appendChild(pill(t)));
}
function setDetails(list){
const c = el("rm-details"); c.innerHTML = "";
if(!list || !list.length){
const p = document.createElement('p'); p.className = "text-gray-400"; p.textContent = "No additional details.";
c.appendChild(p); return;
}
list.forEach(part => {
const p = document.createElement('p'); p.className = "text-gray-200"; p.textContent = part;
c.appendChild(p);
});
}
function populate(data){
setText("rm-title", data.title || "Item");
setText("rm-id", data.id || "");
setText("rm-goal", data.goal || "");
setText("rm-priority", (data.priority != null) ? `P${data.priority}` : "");
setText("rm-milestone", data.milestone || "");
setTags(data.tags || []);
setDetails(data.details || []);
}
// Event delegation: works for all current and future buttons
document.addEventListener('click', function(ev){
const btn = ev.target.closest('[data-item]');
if(!btn) return;
try{
const raw = btn.getAttribute('data-item') || "{}";
const data = JSON.parse(raw);
populate(data);
// If not using Flowbite to open, uncomment:
// modal.classList.remove('hidden');
} catch(err){
console.error("Failed to parse data-item JSON", err);
}
});
// If not using Flowbite to close, uncomment:
// document.querySelectorAll('[data-modal-hide="roadmap-modal"]').forEach(b => {
// b.addEventListener('click', () => modal.classList.add('hidden'));
// });
});
})();
</script>
</script>
{% endblock %}

View File

@@ -1,25 +1,44 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Source Viewer{% endblock %}
{% block content %} {% block content %}
<div style="max-width:1100px;margin:0 auto;padding:1rem 1.25rem;"> <div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
<header style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;"> <section class="bg-card border border-gray-800 rounded-xl p-4">
<div> <header class="flex items-center justify-between gap-3 flex-wrap">
<h2 style="margin:0;font-size:1.1rem;">Code Viewer</h2> <div>
<div class="text-dim" style="font-size:0.9rem;"> <h2 class="text-lg font-semibold">Source Viewer</h2>
<strong>File:</strong> <span id="fileName">{{ filename }}</span> <div class="text-sm text-gray-400">
<strong>File:</strong> <span id="fileName" class="break-all">{{ filename }}</span>
</div>
</div> </div>
</div> <div class="flex items-center gap-2">
<div style="display:flex;gap:.5rem;align-items:center;"> <button id="copyBtn"
<button id="copyBtn" class="btn btn-sm">Copy</button> class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
<button id="wrapBtn" class="btn btn-sm">Toggle wrap</button> Copy
<a id="openRaw" class="btn btn-sm" href="{{ raw_url }}" target="_blank" rel="noopener">Open raw</a> </button>
<a id="downloadRaw" class="btn btn-sm" href="{{ raw_url }}" download>Download</a> <button id="wrapBtn"
</div> class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
</header> Toggle wrap
</button>
<a id="openRaw" href="{{ raw_url }}" target="_blank" rel="noopener"
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
Open raw
</a>
<a id="downloadRaw" href="{{ raw_url }}" download
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
Download
</a>
</div>
</header>
<div id="viewerStatus" class="text-dim" style="margin:.5rem 0 .75rem;"></div> <div id="viewerStatus" class="text-sm text-gray-400 mt-2 mb-3"></div>
<div id="editor" style="height:72vh;border:1px solid #1f2a36;border-radius:8px;"></div>
<div id="editor" class="h-[72vh] border border-gray-800 rounded-lg"></div>
</section>
</div> </div>
{% endblock %}
{% block scripts %}
<!-- Monaco AMD loader (no integrity to avoid mismatch) --> <!-- Monaco AMD loader (no integrity to avoid mismatch) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs/loader.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs/loader.min.js"
crossorigin="anonymous" referrerpolicy="no-referrer"></script> crossorigin="anonymous" referrerpolicy="no-referrer"></script>
@@ -36,13 +55,18 @@
if (!name) return 'plaintext'; if (!name) return 'plaintext';
const m = name.toLowerCase().match(/\.([a-z0-9]+)$/); const m = name.toLowerCase().match(/\.([a-z0-9]+)$/);
const ext = m ? m[1] : ''; const ext = m ? m[1] : '';
const map = {js:'javascript',mjs:'javascript',cjs:'javascript',ts:'typescript',json:'json', const map = {
html:'html',htm:'html',css:'css',py:'python',sh:'shell',bash:'shell', js:'javascript', mjs:'javascript', cjs:'javascript',
yml:'yaml',yaml:'yaml',md:'markdown',txt:'plaintext',log:'plaintext'}; ts:'typescript', json:'json',
html:'html', htm:'html', css:'css',
py:'python', sh:'shell', bash:'shell',
yml:'yaml', yaml:'yaml',
md:'markdown', txt:'plaintext', log:'plaintext'
};
return map[ext] || 'plaintext'; return map[ext] || 'plaintext';
} }
// Wait until the AMD loader has defined window.require // Wait until the AMD loader defines window.require
function waitForRequire(msLeft = 5000) { function waitForRequire(msLeft = 5000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const t0 = performance.now(); const t0 = performance.now();
@@ -90,15 +114,23 @@
// Buttons // Buttons
document.getElementById('copyBtn')?.addEventListener('click', async () => { document.getElementById('copyBtn')?.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(editor.getValue()); statusEl.textContent = 'Copied.'; } try {
catch (e) { statusEl.textContent = 'Copy failed: ' + e; } await navigator.clipboard.writeText(editor.getValue());
statusEl.textContent = 'Copied.';
} catch (e) {
statusEl.textContent = 'Copy failed: ' + e;
}
}); });
document.getElementById('wrapBtn')?.addEventListener('click', () => { document.getElementById('wrapBtn')?.addEventListener('click', () => {
const opts = editor.getRawOptions(); const opts = editor.getRawOptions();
editor.updateOptions({ wordWrap: opts.wordWrap === 'on' ? 'off' : 'on' }); editor.updateOptions({ wordWrap: opts.wordWrap === 'on' ? 'off' : 'on' });
}); });
statusEl.textContent = (resp.ok ? '' : `Warning: HTTP ${resp.status}`) + (text.length ? '' : ' (empty file)'); // Status
const warn = resp.ok ? '' : `Warning: HTTP ${resp.status}`;
const empty = text.length ? '' : (warn ? ' · empty file' : 'Empty file');
statusEl.textContent = warn + (warn && empty ? '' : '') + (empty || '');
}); });
} catch (err) { } catch (err) {
statusEl.textContent = 'Viewer error: ' + err.message; statusEl.textContent = 'Viewer error: ' + err.message;

View File

@@ -29,6 +29,7 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import re
from flask import current_app from flask import current_app
from playwright.async_api import async_playwright, TimeoutError as PWTimeoutError from playwright.async_api import async_playwright, TimeoutError as PWTimeoutError
@@ -48,19 +49,8 @@ class Browser:
lazily-loaded singleton factory `get_browser()`. lazily-loaded singleton factory `get_browser()`.
""" """
def __init__(self, storage_dir: Optional[Path] = None) -> None: def __init__(self) -> None:
""" storage_dir = Path("/data")
Args:
storage_dir: Base directory for run artifacts. Defaults to settings.sandbox.storage
(typically /data) if not provided.
"""
if storage_dir is None:
try:
# Prefer your settings models configured storage path
storage_dir = Path(settings.sandbox.storage)
except Exception:
storage_dir = Path("/data")
self.storage_dir: Path = storage_dir self.storage_dir: Path = storage_dir
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
@@ -69,15 +59,13 @@ class Browser:
@staticmethod @staticmethod
def _get_rule_engine(): def _get_rule_engine():
""" """
Retrieve the rules engine instance from the Flask application config. Retrieve the rules engine instance from the application state.
Returns: Returns:
RuleEngine or None: The engine if available, or None if not configured. RuleEngine or None: The engine if available, or None if not configured.
""" """
try: from app.state import get_rules_engine
return current_app.config.get("RULE_ENGINE") return get_rules_engine()
except Exception:
return None
@staticmethod @staticmethod
def _summarize_results(results: List[Dict[str, Any]]) -> Dict[str, int]: def _summarize_results(results: List[Dict[str, Any]]) -> Dict[str, int]:
@@ -98,64 +86,6 @@ class Browser:
index = index + 1 index = index + 1
return summary return summary
def run_rule_checks(self, text: str, category: str) -> Dict[str, Any]:
"""
Run all rules for a given category against provided text, returning a table-friendly model.
Args:
text: Text to analyze (HTML, snippet, etc.)
category: One of 'form', 'script', 'text' (or any category your rules use)
Returns:
{
"checks": [
{ "name": str, "description": str, "category": str,
"result": "PASS"|"FAIL", "reason": Optional[str],
"severity": Optional[str], "tags": Optional[List[str]] }, ...
],
"summary": { "fail_count": int, "total_rules": int }
}
"""
out: Dict[str, Any] = {"checks": [], "summary": {"fail_count": 0, "total_rules": 0}}
engine = self._get_rule_engine()
if engine is None:
return out
try:
engine_results = engine.run_all(text, category=category) # list of dicts
index = 0
total = len(engine_results)
while index < total:
item = engine_results[index]
normalized = {
"name": item.get("name"),
"description": item.get("description"),
"category": item.get("category"),
"result": item.get("result"), # "PASS" | "FAIL"
"reason": item.get("reason"), # present on FAIL by engine design
"severity": item.get("severity"),
"tags": item.get("tags"),
}
out["checks"].append(normalized)
index = index + 1
out["summary"] = self._summarize_results(out["checks"])
except Exception as exc:
# Preserve shape; record the error as a synthetic PASS (so UI doesn't break)
out["checks"].append({
"name": "engine_error",
"description": "Rule engine failed during evaluation",
"category": category,
"result": "PASS",
"reason": f"{exc}",
"severity": None,
"tags": None
})
out["summary"] = {"fail_count": 0, "total_rules": 1}
return out
def build_rule_checks_overview(self, full_html_text: str) -> List[Dict[str, Any]]: def build_rule_checks_overview(self, full_html_text: str) -> List[Dict[str, Any]]:
""" """
Build a top-level overview for the results page: runs each category across Build a top-level overview for the results page: runs each category across
@@ -389,6 +319,135 @@ class Browser:
return results return results
def analyze_text(self, html: str) -> List[Dict[str, Any]]:
"""
Extract visible page text and evaluate text rules.
Only include rows that matched at least one rule.
Returns a list with 0..1 records shaped like:
{
"type": "page",
"content_snippet": "<matched words/phrases joined>",
"rules": [
{"name": "...", "description": "...", "severity": "...", "tags": [...]},
...
],
}
"""
results: List[Dict[str, Any]] = []
# Short-circuit on missing html
if not html:
return results
# Extract visible text (strip scripts/styles)
try:
soup = BeautifulSoup(html, "lxml")
for tag in soup(["script", "style", "noscript", "template"]):
tag.decompose()
# Basic hidden cleanup (best-effort)
for el in soup.select('[hidden], [aria-hidden="true"]'):
el.decompose()
text = soup.get_text(separator=" ", strip=True)
if not text:
return results
# Normalize whitespace so regexes behave consistently
text = re.sub(r"\s+", " ", text).strip()
except Exception as exc:
# Keep consistency with your other analyzers
results.append({
"type": "page",
"heuristics": [f"Text extraction error: {exc}"]
})
return results
engine = self._get_rule_engine()
if engine is None:
return results
matches_for_record: List[Dict[str, Any]] = []
matched_phrases: List[str] = [] # order-preserving
seen_phrases = set()
# How many characters to show for the preview snippet
preview_len = getattr(settings.ui, "snippet_preview_len", 200)
try:
# 1) Regex rules over full page text
for r in engine.rules:
if getattr(r, "category", None) != "text":
continue
rtype = getattr(r, "rule_type", None)
if rtype == "regex":
ok, _reason = r.run(text)
if not ok:
continue
# Try to pull matched words/phrases
compiled = getattr(r, "_compiled_regex", None)
if compiled is None and getattr(r, "pattern", None):
try:
compiled = re.compile(r.pattern, re.IGNORECASE)
except re.error:
compiled = None
# Collect a few (deduped) matched phrases
if compiled is not None:
# limit per rule to avoid flooding
per_rule_count = 0
for m in compiled.finditer(text):
phrase = m.group(0).strip()
if phrase and phrase not in seen_phrases:
matched_phrases.append(phrase)
seen_phrases.add(phrase)
per_rule_count += 1
if per_rule_count >= 5: # cap per rule
break
matches_for_record.append({
"name": getattr(r, "name", "unknown_rule"),
"description": getattr(r, "description", "") or "",
"severity": getattr(r, "severity", None),
"tags": getattr(r, "tags", None),
})
elif rtype == "function":
# Optional: function-style rules can inspect the full text
facts = {"text": text, "category": "text"}
ok, reason = r.run(facts)
if ok:
matches_for_record.append({
"name": getattr(r, "name", "unknown_rule"),
"description": (reason or "") or getattr(r, "description", ""),
"severity": getattr(r, "severity", None),
"tags": getattr(r, "tags", None),
})
if matches_for_record:
# Build the snippet from matched words/phrases
joined = "".join(matched_phrases) if matched_phrases else ""
if len(joined) > preview_len:
joined = joined[:preview_len] + ""
record: Dict[str, Any] = {
"type": "page",
"content_snippet": joined or None,
"rules": matches_for_record,
}
results.append(record)
except Exception as exc:
results.append({
"type": "page",
"heuristics": [f"Text analysis error: {exc}"]
})
return results
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Fetcher / Orchestrator # Fetcher / Orchestrator
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
@@ -471,12 +530,15 @@ class Browser:
# Read back saved source # Read back saved source
html_content = source_path.read_text(encoding="utf-8") html_content = source_path.read_text(encoding="utf-8")
# Forms analysis (per-form rule checks) # Forms analysis
forms_info = self.analyze_forms(html_content, final_url) forms_info = self.analyze_forms(html_content, final_url)
# Scripts artifacts (no detection here) # Scripts artifacts
suspicious_scripts = self.analyze_scripts(html_content, base_url=final_url) suspicious_scripts = self.analyze_scripts(html_content, base_url=final_url)
# suspicious text
flagged_text = self.analyze_text(html_content)
# Enrichment # Enrichment
enrichment = enrich_url(url, fetch_ssl_enabled) enrichment = enrich_url(url, fetch_ssl_enabled)
@@ -499,7 +561,8 @@ class Browser:
"scripts": scripts_seen, "scripts": scripts_seen,
"forms": forms_info, "forms": forms_info,
"suspicious_scripts": suspicious_scripts, "suspicious_scripts": suspicious_scripts,
"rule_checks": rule_checks_overview, # table-ready for UI "suspicious_text":flagged_text,
"rule_checks": rule_checks_overview,
"enrichment": enrichment "enrichment": enrichment
} }
@@ -537,14 +600,11 @@ except Exception:
@singleton_loader @singleton_loader
def get_browser(storage_dir: Optional[Path] = None) -> Browser: def get_browser() -> Browser:
""" """
Lazily construct and cache a singleton Browser instance. Lazily construct and cache a singleton Browser instance.
Args:
storage_dir: Optional override for artifact base directory.
Returns: Returns:
Browser: The singleton instance. Browser: The singleton instance.
""" """
return Browser(storage_dir=storage_dir) return Browser()

View File

@@ -30,6 +30,8 @@ days = 24 * 60
GEOIP_DEFAULT_TTL = settings.cache.geoip_cache_days * days GEOIP_DEFAULT_TTL = settings.cache.geoip_cache_days * days
WHOIS_DEFAULT_TTL = settings.cache.whois_cache_days * days WHOIS_DEFAULT_TTL = settings.cache.whois_cache_days * days
CRT_DEFAULT_TTL = settings.cache.crt_cache_days * days
logger = get_app_logger() logger = get_app_logger()
@@ -137,6 +139,20 @@ def search_certs(domain, wildcard=True, expired=True, deduplicate=True):
"not_before": "2018-02-08T15:47:39" "not_before": "2018-02-08T15:47:39"
} }
""" """
cache_key = f"crt_cert:{domain}"
# log if caching is turned on or not
logger.info(f"CRT Cache is set to: {settings.cache.crt_cache_enabled}")
if settings.cache.crt_cache_enabled:
cached = cache.read(cache_key)
if cached:
logger.info(f"[CACHE HIT] for CRT Cert: {domain}")
return cached
else:
logger.info(f"[CACHE MISS] for CRT Cert: {domain} - {cache_key}")
base_url = "https://crt.sh/?q={}&output=json" base_url = "https://crt.sh/?q={}&output=json"
if not expired: if not expired:
base_url = base_url + "&exclude=expired" base_url = base_url + "&exclude=expired"
@@ -153,11 +169,21 @@ def search_certs(domain, wildcard=True, expired=True, deduplicate=True):
try: try:
content = req.content.decode('utf-8') content = req.content.decode('utf-8')
data = json.loads(content) data = json.loads(content)
# if caching
if settings.cache.crt_cache_enabled:
logger.info(f"Setting Cache for {cache_key}")
cache.create(cache_key, data, CRT_DEFAULT_TTL)
return data return data
except ValueError: except ValueError:
# crt.sh fixed their JSON response. This shouldn't be necessary anymore # crt.sh fixed their JSON response. This shouldn't be necessary anymore
# https://github.com/crtsh/certwatch_db/commit/f4f46ea37c23543c4cdf1a3c8867d68967641807 # https://github.com/crtsh/certwatch_db/commit/f4f46ea37c23543c4cdf1a3c8867d68967641807
data = json.loads("[{}]".format(content.replace('}{', '},{'))) data = json.loads("[{}]".format(content.replace('}{', '},{')))
# if caching
if settings.cache.crt_cache_enabled:
logger.info(f"Setting Cache for {cache_key}")
cache.create(cache_key, data, CRT_DEFAULT_TTL)
return data return data
except Exception as err: except Exception as err:
logger.error("Error retrieving cert information from CRT.sh.") logger.error("Error retrieving cert information from CRT.sh.")
@@ -200,6 +226,7 @@ def gather_crtsh_certs_for_target(target):
hostname = parse_target_to_host(target) hostname = parse_target_to_host(target)
result["hostname"] = hostname result["hostname"] = hostname
# return fake return if no hostname was able to be parsed
if hostname is None: if hostname is None:
return result return result
@@ -209,6 +236,7 @@ def gather_crtsh_certs_for_target(target):
# Always query crt.sh for the specific hostname # Always query crt.sh for the specific hostname
# (expired=False means we filter expired) # (expired=False means we filter expired)
host_certs = search_certs(hostname, wildcard=False, expired=False) host_certs = search_certs(hostname, wildcard=False, expired=False)
result["crtsh"]["host_certs"] = host_certs result["crtsh"]["host_certs"] = host_certs
@@ -308,6 +336,9 @@ def enrich_whois(hostname: str) -> dict:
def enrich_geoip(hostname: str) -> dict: def enrich_geoip(hostname: str) -> dict:
"""Resolve hostname to IPs and fetch info from ip-api.com.""" """Resolve hostname to IPs and fetch info from ip-api.com."""
CLOUDFLARE_ASN = "AS13335 Cloudflare"
geo_info = {} geo_info = {}
ips = extract_ips_from_url(hostname) ips = extract_ips_from_url(hostname)
for ip in ips: for ip in ips:
@@ -324,6 +355,12 @@ def enrich_geoip(hostname: str) -> dict:
resp = requests.get(f"http://ip-api.com/json/{ip_str}?fields=24313855", timeout=5) resp = requests.get(f"http://ip-api.com/json/{ip_str}?fields=24313855", timeout=5)
if resp.status_code == 200: if resp.status_code == 200:
geo_info[ip_str] = resp.json() geo_info[ip_str] = resp.json()
asname = geo_info[ip_str].get("as")
# if behind cloudflare
if CLOUDFLARE_ASN in asname:
geo_info[ip_str].update({"cloudflare":True})
else: else:
geo_info[ip_str] = {"error": f"HTTP {resp.status_code}"} geo_info[ip_str] = {"error": f"HTTP {resp.status_code}"}
except Exception as e: except Exception as e:

View File

@@ -53,17 +53,25 @@ class UIConfig:
@dataclass @dataclass
class Cache_Config: class Cache_Config:
recent_runs_count: int = 10
whois_cache_days: int = 7 whois_cache_days: int = 7
geoip_cache_days: int = 7 geoip_cache_days: int = 7
recent_runs_count: int = 10
crt_cache_enabled: bool = True
crt_cache_days: int = 7
@dataclass @dataclass
class AppConfig: class Logging_Config:
log_rule_loads: bool = False
log_rule_dispatch: bool = False
log_rule_debug: bool = False
@dataclass
class BrandingConfig:
name: str = "MyApp" name: str = "MyApp"
version_major: int = 1
version_minor: int = 0
print_rule_loads: bool = False
@dataclass @dataclass
@@ -71,7 +79,8 @@ class Settings:
cache: Cache_Config = field(default_factory=Cache_Config) cache: Cache_Config = field(default_factory=Cache_Config)
ui: UIConfig = field(default_factory=UIConfig) ui: UIConfig = field(default_factory=UIConfig)
external_fetch: External_FetchConfig = field(default_factory=External_FetchConfig) external_fetch: External_FetchConfig = field(default_factory=External_FetchConfig)
app: AppConfig = field(default_factory=AppConfig) branding: BrandingConfig = field(default_factory=BrandingConfig)
logconfig: Logging_Config = field(default_factory=Logging_Config)
@classmethod @classmethod
def from_yaml(cls, path: str | Path) -> "Settings": def from_yaml(cls, path: str | Path) -> "Settings":

View File

@@ -9,7 +9,7 @@ from . import create_app
# Gunicorn will look for "app" # Gunicorn will look for "app"
app = create_app() app = create_app()
from app.state import set_rules_engine, get_rules_engine from app.state import set_rules_engine
from app.logging_setup import get_app_logger from app.logging_setup import get_app_logger
from app.rules.factory import build_rules_engine from app.rules.factory import build_rules_engine

32
assets/input.css Normal file
View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
/* ---- Reusable components ---- */
@layer components {
/* Base badge + variants (compose in markup as: class="badge badge-ok") */
.badge { @apply inline-flex items-center rounded-full px-2 py-0.5 text-xs border; }
.badge-ok { @apply bg-green-600/20 text-green-300 border-green-700; }
.badge-warn { @apply bg-yellow-600/20 text-yellow-300 border-yellow-700; }
.badge-danger { @apply bg-red-600/20 text-red-300 border-red-700; }
.badge-muted { @apply bg-gray-700/30 text-gray-300 border-gray-700; }
.badge-info { @apply bg-blue-600/20 text-blue-300 border-blue-700; }
.badge-success { @apply bg-green-600/20 text-green-300 border-green-700; }
.badge-success-solid { @apply bg-green-600 text-white border-green-600; }
/* Chips (tags/pills) */
.chip { @apply rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs; }
/* Card container */
.card { @apply bg-card border border-gray-800 rounded-xl p-4; }
}
@tailwind utilities;
/* Your earlier override to remove opacity var from gray-800 borders */
@layer utilities {
.border-gray-800 { border-color: #1f2937; } /* rgb(31,41,55) */
}
/* Optional tiny custom classes */
.card-shadow { box-shadow: 0 1px 2px rgba(0,0,0,.2); }

100
docs/changelog.md Normal file
View File

@@ -0,0 +1,100 @@
# Changelog
All notable changes to this project will be documented in this file.
This project follows [Semantic Versioning](https://semver.org/).
---
## [Unreleased]
### ✨ Features
- _Nothing yet — add upcoming features here._
### 🛠️ Refactors
- _Nothing yet — add upcoming refactors here._
### 🐛 Fixes
- _Nothing yet — add upcoming fixes here._
---
## [v0.2]
### ✨ Features
* **UI Modernization**
Migrated the entire front-end to **Tailwind CSS (compiled)** with **Flowbite JS** components for better responsiveness, consistency, and developer productivity.
Introduced a **new navbar and layout system**, improving navigation and making future expansion easier.
Added **Docker-based CSS build** to keep builds reproducible and lightweight.
* **Reusable CSS Components**
Added custom utility classes (`badge`, `badge-ok`, `badge-warn`, `badge-danger`, `chip`, `card`, etc.) to replace long Tailwind strings.
This reduces repetition and ensures a consistent look across the app.
* **Roadmap (YAML-driven + in-app UI)**
Added a YAML-backed roadmap with an in-app view at `/roadmap`. Supports section switching (Roadmap / Backlog / Open Questions), filters (`q`, `tag`, `min_priority`, `milestone`), tag chips, and a **Details** modal that renders multi-paragraph content from a new `details` field.
The roadmap file path is configurable via `ROADMAP_FILE` (env or Flask config) for dev/prod flexibility.
* **Modal sizing & ergonomics**
Increased modal width at larger breakpoints and made the body scrollable so long details dont squish other content.
* **GeoIP Results Uplift**
Added Cloudflare detection via Geoip ASN results and Cloudflare badge on results page
Added Country - ASN notes beside collapsed IP next to GeoIP results for quick viewing.
* **Text Analysis Pipeline (Rules)**
Implemented `analyse_text()` to extract visible page text and evaluate `category: text` rules.
Captures matched phrases into a deduped `content_snippet` (length capped via `settings.ui.snippet_preview_len`).
Exposes results in JSON as `suspicious_text` and surfaces them in the UI via a new partial (`templates/partials/result_text.html`) that mirrors the Forms table.
### 🛠️ Refactors
* **Template Includes**
Extracted shared UI sections (headers, footers, layout chunks) into separate **Jinja includes**, improving maintainability and readability of templates.
* **Roadmap loader simplification**
Removed the cache layer; loader now returns typed dataclasses (`RoadmapData` / `RoadmapItem`) and normalizes `details` via `_normalize_details()` (accepts block string or list).
* **Safer JSON in templates**
Use `|tojson|forceescape` when embedding the item payload in `data-item` attributes to avoid escaping issues.
* **Rules Engine Regex handling**
Honor per-rule regex flags (string or list) and **default `IGNORECASE` for `category: text`** when no `i` flag is specified. Centralizes compilation in `compile_if_needed()`.
* **Engine/Scanner logging**
Added dispatch-time visibility:
`"[engine] applying categories: …"` (gated by `settings.app.print_rule_dispatch`) and a browser dispatch log including text/html lengths. Eases tracing when categories are skipped or text is empty.
* **Code cleanup**
Removed obsolete code paths and utilities that were no longer used after the recent refactors.
Eliminated a **duplicate call to `enrich_url`**, reducing redundant work and potential side-effects.
### 🐛 Fixes
* **Table Rendering**
Locked table column widths and fixed snippet scaling issues to prevent column misalignment and content reflow.
This ensures analysis results (like script and form findings) remain readable and properly aligned.
* **Rules Engine State**
Fixed a bug where the **rules engine** was not being pulled correctly from the application state after the previous refactor.
This restores proper detection of suspicious scripts/forms and ensures rule definitions (with `name` and `description`) are honored.
* **YAML parsing edge cases**
Resolved `ScannerError` by quoting scalars containing `:` / `#` and using explicit `null` where appropriate.
* **/roadmap page stability**
Fixed `AttributeError: 'dict' object has no attribute 'roadmap'` by returning structured objects from the loader.
* **Modal population**
Ensured `details` are passed through the route and included in the button payload; JS now uses DOM-ready + event delegation to reliably populate the modal.
* **Text indicators not displayed**
Addressed missing text results in the JSON/UI by introducing the text analyzer and aligning the result shape with the new `result_text` partial.
---
## [v0.1]
- Implemented initial **Flask-based web UI** for URL submission and analysis.
- Added **domain & IP enrichment** (WHOIS, GeoIP, ASN/ISP lookups).
- Built first version of the **Suspicious Rules Engine** for script and form detection.
- Basic Docker setup for sandboxed deployment.

View File

@@ -1,33 +0,0 @@
# SneakyScope — Roadmap (Updated 8-21-25)
## Priority 1 Core Analysis / Stability
*(no open items currently tracked in this bucket)*
## Priority 2 API Layer
* API endpoints: `/screenshot`, `/source`, `/analyse`.
* **OpenAPI**: add `POST /api/analyze_script` (request/response schemas, examples) to `openapi/openapi.yaml`; serve at `/api/openapi.yaml`.
* Docs UI: Swagger UI or Redoc at `/docs`.
* (Nice-to-have) API JSON error consistency: handlers for 400/403/404/405/500 that always return JSON.
## Priority 3 UI / UX
* Rules Lab (WYSIWYG tester): paste a rule, validate/compile, run against sample text; lightweight nav entry.
## Priority 4 Artifact Management & Ops
* Retention/cleanup policy for old artifacts (age/size thresholds).
* Make periodic maintenance scripts for storage; cleanup options set in `settings.yaml`.
* Results caching UX: add “Re-run analysis” vs. “Load from cache” controls in the results UI.
## Priority 5 Extras / Integrations
* Bulk URL analysis (batch/queue).
* Optional: analyst verdict tags and export (CSV/JSON).
* Domain reputation (local feeds): build and refresh a consolidated domain/URL reputation store from URLHaus database dump and OpenPhish community dataset (scheduled pulls with dedup/normalize).
* Threat intel connectors (settings-driven): add `settings.yaml` entries for VirusTotal and ThreatFox API keys (plus future providers); when present, enrich lookups and merge results into the unified reputation checks during analysis.
## Backlog / Far-Off Plans
* Server profile scan: run a lightweight nmap service/banner scan on common web/alt ports (80, 443, 8000, 8080, 8443, etc.) and SSH; combine with server headers to infer stack (e.g., IIS vs. Linux/\*nix).

View File

@@ -0,0 +1,24 @@
Blueprint -> Analyse calls browser.fetch_page_artifacts
Browser.py -> fetch_page_artifacts
grabs html_content
run analyse_forms on html_content
run analyse_scripts on html_content
enrich_url
build_rule_checks_overview
builds the structured dict for return
dumps data to disk
text flag mapping for rules
# --- Helper: map human-friendly flags to re.* constants ---
FLAG_MAP = {
"i": _re.IGNORECASE, "ignorecase": _re.IGNORECASE,
"m": _re.MULTILINE, "multiline": _re.MULTILINE,
"s": _re.DOTALL, "dotall": _re.DOTALL, "singleline": _re.DOTALL,
"x": _re.VERBOSE, "verbose": _re.VERBOSE,
"a": _re.ASCII, "ascii": _re.ASCII,
"u": _re.UNICODE, "unicode": _re.UNICODE,
"l": _re.LOCALE, "locale": _re.LOCALE,
}

View File

@@ -0,0 +1,113 @@
Got it — heres a structured outline you can keep as a “memory primer” for when you want to resume this work (whether continuing the current engine or pivoting to a SaaS build). Think of it as a **checkpoint map** of what weve covered and what directions you flagged.
---
# SneakyScope Rules Engine Refactor & SaaS Considerations (Outline)
## 1. Current State
* Engine: `app/rules/rules_engine.py` — mixed YAML + code rules, `(bool, str)` return shape expected.
* Issue: function-based rules returning `(bool, dict)` → caused invalid type warnings.
* Stopgap: `FunctionRuleAdapter` converted returns.
* Desire: Remove YAML entirely; rules defined in code only.
---
## 2. Agreed Direction
* **Rule definition approach**:
* Option B chosen → decorator-based registration.
* Every rule defined in `app/rules/` as Python functions.
* Rules register with metadata (`name`, `category`, `severity`, etc.).
* **Return shape**:
* Always return a **Result dict** (no adapter needed).
* Engine enforces schema and fills in defaults.
* **Engine relocation**:
* Move to `app/utils/rules_engine/`.
* Responsibilities: load, validate, freeze registry, run rules, aggregate results, log/report.
---
## 3. Result Schema (concept)
* **Per RuleResult**
* Required: `ok: bool`, `message: str`.
* Identity: `name`, `category`, `severity`, `tags`, `rule_version`.
* Detail: `data: object|null`.
* Timing: `duration_ms`.
* Errors: structured `error` object if exceptions occur.
* Provenance: `source_module`, optional `policy` snapshot.
* **Per AnalysisResult (run-level envelope)**
* Input scope: target URL, category, content hash, facts profile.
* Provenance: run\_id, engine\_version, ruleset\_checksum, timestamp, duration.
* Results: array of RuleResults.
* Summary: counts by severity, match count, errors, first match, top severity.
* Artifacts: references (screenshot, DOM snapshot, etc.).
* Policy snapshot: optional central policy/overrides.
---
## 4. Operational Standards
* **Determinism**: identical inputs + ruleset\_checksum → identical results.
* **Message stability**: avoid wording churn; expand via `data`.
* **Size limits**: `message ≤ 256 chars`; `data ≤ 816 KB`.
* **Errors**: `ok=false` if error present; always emit `message`.
* **Severity**: rule sets default; policy may override.
* **Tags**: controlled vocabulary; additive.
---
## 5. Migration Plan
1. Create new `rules_engine` package in `app/utils/`.
2. Add decorator/registry for rules.
3. Port all rules from YAML → Python modules grouped by category.
4. Delete YAML loader + adapters.
5. Update call sites to build `facts` and call `engine.run(...)`.
6. Add CI tests:
* Schema compliance.
* No duplicates.
* Ruleset checksum snapshot.
7. Integration tests with real fixtures.
8. Benchmark & harden (caps on input size, rule runtime).
---
## 6. SaaS Expansion (future)
* **Multi-tenancy**: separate org/user scopes for data and rule runs.
* **RBAC**: roles (admin, analyst, viewer).
* **Compliance**: logging, retention, export, audit trails.
* **Rules**: centrally maintained, not user-editable.
* **APIs**: authenticated endpoints, per-user quotas.
* **Observability**: per-tenant metrics, alerts.
* **Security**: sandboxing, strict module allowlists, compliance with SOC2/ISO.
* **Data controls**: PII redaction, encryption, retention policies.
---
## 7. Future-Proofing Hooks
* Versioning: ruleset checksum + per-rule versions.
* Extensibility: support `actions`, `links`, `evidence` in Result.
* Policy: central config for thresholds/overrides.
* Hot reload (optional, dev-only).
* Rule provenance tracking (source\_module, commit SHA).
---
✅ This outline is enough to “re-hydrate” the context later — you wont need to dig back into old logs to remember why `(bool, str)` didnt fit, why YAML was removed, or what schema we were converging on.
---
Do you want me to also save this in a **short “README-spec” style** (like `RESULTS.md`) so it can live in your repo as the contract doc for rules, or should I keep this as just your personal checkpoint outline?

6
tailwind/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"private": true,
"devDependencies": {
"tailwindcss": "^3.4.10"
}
}

View File

@@ -0,0 +1,16 @@
module.exports = {
content: [
"./app/templates/**/*.html",
"./app/static/**/*.js",
],
theme: {
extend: {
colors: {
bg: "#0b0f14",
nav: "#0f1720",
card: "#111826",
},
},
},
plugins: [],
};