Compare commits
8 Commits
master
...
f20d51b152
| Author | SHA1 | Date | |
|---|---|---|---|
| f20d51b152 | |||
| 2fd6f9d705 | |||
| 55cd81aec0 | |||
| af253c858c | |||
| cd30cde946 | |||
| 9cc2f8183c | |||
| dbd7cb31c7 | |||
| 469334d137 |
24
Dockerfile
24
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ from pathlib import Path
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# 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.blueprints import ui # ui blueprint
|
from app.blueprints.main import bp as main_bp # ui blueprint
|
||||||
from app.blueprints import api # api blueprint
|
from app.blueprints.api import api_bp as api_bp # api blueprint
|
||||||
|
from app.blueprints.roadmap import bp as roadmap_bp # roadmap
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
"""
|
"""
|
||||||
@@ -41,9 +42,14 @@ def create_app() -> Flask:
|
|||||||
app.config["APP_NAME"] = settings.app.name
|
app.config["APP_NAME"] = settings.app.name
|
||||||
app.config["APP_VERSION"] = f"v{settings.app.version_major}.{settings.app.version_minor}"
|
app.config["APP_VERSION"] = f"v{settings.app.version_major}.{settings.app.version_minor}"
|
||||||
|
|
||||||
|
# roadmap file
|
||||||
|
app.config["ROADMAP_FILE"] = str(Path(app.root_path) / "docs" / "roadmap.yaml")
|
||||||
|
|
||||||
|
|
||||||
# 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_logger = get_app_logger()
|
app_logger = get_app_logger()
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ 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": app_name,
|
||||||
"app_version": app_version
|
"app_version": app_version,
|
||||||
|
"current_year": datetime.strftime(datetime.now(),"%Y")
|
||||||
}
|
}
|
||||||
|
|
||||||
@bp.route("/", methods=["GET"])
|
@bp.route("/", methods=["GET"])
|
||||||
@@ -133,15 +134,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
173
app/blueprints/roadmap.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -2,7 +2,15 @@ app:
|
|||||||
name: SneakyScope
|
name: SneakyScope
|
||||||
version_major: 0
|
version_major: 0
|
||||||
version_minor: 1
|
version_minor: 1
|
||||||
print_rule_loads: True
|
|
||||||
|
# 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:
|
||||||
recent_runs_count: 10
|
recent_runs_count: 10
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
214
app/docs/roadmap.yaml
Normal file
214
app/docs/roadmap.yaml
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# roadmap.yaml
|
||||||
|
updated: "2025-08-22"
|
||||||
|
|
||||||
|
roadmap:
|
||||||
|
- id: "p1-analysis-cloudflare"
|
||||||
|
priority: 1
|
||||||
|
title: "Cloudflare Detection"
|
||||||
|
goal: "Detect Cloudflare usage and badge it, with explanation of dual-use (security vs. abuse)."
|
||||||
|
tags: ["analysis"]
|
||||||
|
milestone: null
|
||||||
|
details:
|
||||||
|
- "Detection signals: DNS (CNAME to Cloudflare, AS13335), HTTP headers (cf-ray, cf-cache-status), IP ranges, and challenge pages."
|
||||||
|
- "UI: add badge + tooltip with a short explainer about legitimate protection vs. abuse evasion."
|
||||||
|
- "Edge cases: 'grey-clouded' DNS entries, partial proxy (only some records), and CDN in front of non-HTTP services."
|
||||||
|
- "Acceptance: correctly identifies Cloudflare on known test hosts and avoids false positives on non-CF CDNs."
|
||||||
|
|
||||||
|
- id: "p1-analysis-total-score"
|
||||||
|
priority: 1
|
||||||
|
title: "Total Score"
|
||||||
|
goal: "Implement a generalized site “Total Score” (0–10 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 400–500 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."
|
||||||
|
|
||||||
|
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."
|
||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
@@ -198,7 +255,7 @@ class RuleEngine:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if settings.app.print_rule_loads:
|
if settings.app.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 +287,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 +306,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.app, "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 +337,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:
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -1,36 +1,86 @@
|
|||||||
<!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; we’ll 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">
|
||||||
|
SneakyScope
|
||||||
|
</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>
|
||||||
</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>
|
||||||
|
</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">© {{ current_year }} SneakyScope {{ app_name }} {{ app_version }} - A selfhosted URL sandbox</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 %}
|
|
||||||
@@ -1,159 +1,201 @@
|
|||||||
{% 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>
|
<button
|
||||||
|
type="submit"
|
||||||
<!-- Recent Results (optional; shown only if recent_results provided) -->
|
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"
|
||||||
{% if recent_results %}
|
>
|
||||||
<div class="card" id="recent-results">
|
<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>
|
||||||
<h2>Recent Results</h2>
|
Analyse
|
||||||
<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
|
|
||||||
type="button"
|
|
||||||
class="copy-btn"
|
|
||||||
data-target="uuid-{{ loop.index }}">
|
|
||||||
📋
|
|
||||||
</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…</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';
|
* - shows overlay spinner
|
||||||
modal.style.display = 'none';
|
* - disables submit button
|
||||||
});
|
* - shows small spinner inside button
|
||||||
|
* - lets the browser continue with POST
|
||||||
|
*/
|
||||||
|
(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
|
// Hide spinner overlay if arriving from bfcache/back
|
||||||
requestAnimationFrame(() => form.submit());
|
window.addEventListener('pageshow', () => {
|
||||||
e.preventDefault();
|
hideSpinner();
|
||||||
});
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
</script>
|
if (btnSpinner) btnSpinner.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
<script>
|
form.addEventListener('submit', (e) => {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
// prevent immediate submit so UI can paint spinner first
|
||||||
const buttons = document.querySelectorAll('.copy-btn');
|
e.preventDefault();
|
||||||
buttons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
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 %}
|
||||||
|
|||||||
58
app/templates/partials/result_enrichment.html
Normal file
58
app/templates/partials/result_enrichment.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<!-- /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 }}</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>
|
||||||
113
app/templates/partials/result_forms.html
Normal file
113
app/templates/partials/result_forms.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<!-- /templates/partials/result_forms.html -->
|
||||||
|
<section id="forms" class="card">
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Forms</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>
|
||||||
120
app/templates/partials/result_scripts.html
Normal file
120
app/templates/partials/result_scripts.html
Normal 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 & 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>
|
||||||
200
app/templates/partials/result_ssl_tls.html
Normal file
200
app/templates/partials/result_ssl_tls.html
Normal 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 %}
|
||||||
|
|
||||||
120
app/templates/partials/result_text.html
Normal file
120
app/templates/partials/result_text.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!-- /templates/partials/result_text.html -->
|
||||||
|
<section id="sus_text" class="card">
|
||||||
|
<h2 class="text-lg font-semibold mb-3">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>
|
||||||
@@ -1,352 +1,128 @@
|
|||||||
{% 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>
|
<a href="{{ final_url }}" target="_blank" rel="noopener" class="break-all hover:text-blue-400">{{ final_url }}</a>
|
||||||
<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">
|
||||||
|
{{ request.host_url }}results/{{ 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><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Enrichment -->
|
<!-- Enrichment -->
|
||||||
<div class="card" id="enrichment">
|
{% include "partials/result_enrichment.html" %}
|
||||||
<h2>Enrichment</h2>
|
|
||||||
|
|
||||||
<!-- WHOIS -->
|
<!-- TLS / SSL / CERTS -->
|
||||||
{% if enrichment.whois %}
|
{% from "partials/result_ssl_tls.html" import ssl_tls_card %}
|
||||||
<h3>WHOIS</h3>
|
{{ ssl_tls_card(enrichment.ssl_tls) }}
|
||||||
<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 %}
|
<!-- Redirects -->
|
||||||
<h3>Raw WHOIS</h3>
|
<section id="redirects" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||||
<pre class="code">{{ enrichment.raw_whois }}</pre>
|
<h2 class="text-lg font-semibold mb-3">Redirects</h2>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- GeoIP / IP-API -->
|
|
||||||
{% if enrichment.geoip %}
|
|
||||||
<h3>GeoIP</h3>
|
|
||||||
{% 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 +130,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 +155,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>
|
||||||
|
{% endblock %}
|
||||||
<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 %}
|
|
||||||
|
|||||||
262
app/templates/roadmap.html
Normal file
262
app/templates/roadmap.html
Normal 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 %}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 model’s 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()
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class AppConfig:
|
|||||||
name: str = "MyApp"
|
name: str = "MyApp"
|
||||||
version_major: int = 1
|
version_major: int = 1
|
||||||
version_minor: int = 0
|
version_minor: int = 0
|
||||||
print_rule_loads: bool = False
|
log_rule_loads: bool = False
|
||||||
|
log_rule_dispatch: bool = False
|
||||||
|
log_rule_debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
32
assets/input.css
Normal file
32
assets/input.css
Normal 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); }
|
||||||
96
docs/changelog.md
Normal file
96
docs/changelog.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 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._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ 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 don’t squish other content.
|
||||||
|
|
||||||
|
* **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] – Initial Work
|
||||||
|
|
||||||
|
- 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.
|
||||||
@@ -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).
|
|
||||||
6
tailwind/package.json
Normal file
6
tailwind/package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tailwind/tailwind.config.js
Normal file
16
tailwind/tailwind.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/templates/**/*.html",
|
||||||
|
"./app/static/**/*.js",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: "#0b0f14",
|
||||||
|
nav: "#0f1720",
|
||||||
|
card: "#111826",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user