Compare commits

..

8 Commits

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -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()

View File

@@ -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
View File

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

View File

@@ -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

View File

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

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

View File

@@ -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,21 +62,65 @@ 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
if self.rule_type == "regex" and self.pattern:
try: try:
self._compiled_regex = re.compile(self.pattern, re.IGNORECASE) self._compiled_regex = _re.compile(self.pattern, re_flags)
logger.debug(f"[Rule] Compiled regex for '{self.name}'")
# 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 return True
except re.error as rex:
except _re.error as rex:
self._compiled_regex = None self._compiled_regex = None
logger.warning(f"[Rule] Failed to compile regex for '{self.name}': {rex}") logger.warning("[Rule] Failed to compile regex for '%s': %s", getattr(self, "name", "?"), rex)
return False
return False return False
def run(self, text: str) -> Tuple[bool, str]: def run(self, text: str) -> Tuple[bool, str]:
""" """
Run the rule on the given text. Run the rule on the given text.
@@ -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:

View File

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

View File

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

View File

@@ -1,36 +1,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" />
<title>{% block title %}{% endblock %} {{ app_name }} </title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ app_name }} {{ app_version }}</title>
<link rel="stylesheet" href="https://unpkg.com/sanitize.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<header>
<h1>{{ app_name }} {{ app_version }}</h1>
</header>
{% with messages = get_flashed_messages(with_categories=true) %} <!-- # Tailwind CSS # -->
{% if messages %} <link rel="stylesheet" href="{{ url_for('static', filename='tw.css') }}">
<ul class="flash">
{% for category, message in messages %} {# Your existing CSS stays; well keep only custom tweaks there. #}
<li class="{{ category }}">{{ message }}</li> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% endfor %} {% block head %}{% endblock %}
</head>
<body class="bg-bg text-gray-200 min-h-screen flex flex-col">
{# Top Navbar (Flowbite collapse) #}
<nav class="bg-nav border-b border-gray-800">
<div class="max-w-7xl mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<a href="{{ url_for('main.index') }}" class="text-xl font-bold text-white">
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 %}
</div>
</main> </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">
<p class="text-sm text-gray-400">© {{ current_year }} SneakyScope {{ app_name }} {{ app_version }} - A selfhosted URL sandbox</p>
</footer> </footer>
</body>
</html>
{% block page_js %} {# Flowbite JS (enables collapse) #}
{% endblock %} <script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,159 +1,201 @@
{% extends 'base.html' %} {% extends "base.html" %}
{% block content %} {% block title %}Home{% endblock %}
<!-- Analysis Form --> {% block content %}
<form id="analyze-form" method="post" action="{{ url_for('main.analyze') }}" class="card"> <!-- Single-column stack, centered and comfortably narrow -->
<h2>Analyze a URL</h2> <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">
<label for="url">Enter a URL to analyze</label>
<input id="url" name="url" type="url" placeholder="https://example.com" required /> <!-- Start a New Analysis -->
<section class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-lg font-semibold mb-3">Start a New Analysis</h2>
<form id="analyze-form" action="{{ url_for('main.analyze') }}" method="post" class="space-y-3">
<div>
<label for="url" class="block text-sm text-gray-400 mb-1">Target URL or Domain</label>
<input
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>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
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"
>
<span id="btn-spinner" class="hidden animate-spin inline-block h-4 w-4 rounded-full border-2 border-white/40 border-t-white"></span>
Analyse
</button>
<!-- toggle for pulling ssl/cert data --> <!-- toggle for pulling ssl/cert data -->
<label class="checkbox-row"> <label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="fetch_ssl" value="1"> <input
Pull SSL/TLS data (crt.sh + version probe) - Warning, crt.sh can be <b>very slow</b> at times 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> </label>
</div>
</form>
</section>
<button type="submit">Analyze</button> <!-- Recent Results -->
</form> {% if recent_results %}
<section class="bg-card border border-gray-800 rounded-xl p-4" id="recent-results">
<!-- Recent Results (optional; shown only if recent_results provided) --> <h2 class="text-base font-semibold mb-3">Recent Results</h2>
{% if recent_results %} <div class="overflow-x-auto">
<div class="card" id="recent-results"> <table class="min-w-full text-sm">
<h2>Recent Results</h2> <thead class="text-gray-400 border-b border-gray-800">
<table class="results-table">
<thead>
<tr> <tr>
<th>Timestamp</th> <th class="text-left py-2 pr-4">Timestamp</th>
<th>URL</th> <th class="text-left py-2 pr-4">URL</th>
<th>UUID</th> <th class="text-left py-2 pr-4">UUID</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for r in recent_results %} {% for r in recent_results %}
<tr> <tr class="border-b border-gray-900">
<td class="timestamp"> <td class="py-2 pr-4 whitespace-nowrap">
{% if r.timestamp %} {% if r.timestamp %}{{ r.timestamp }}{% else %}N/A{% endif %}
{{ r.timestamp }}
{% else %}
N/A
{% endif %}
</td> </td>
<td class="url"> <td class="py-2 pr-4">
<a href="{{ url_for('main.view_result', run_uuid=r.uuid) }}"> <a class="hover:text-blue-400" href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
{{ r.final_url or r.submitted_url }} {{ r.final_url or r.submitted_url }}
</a> </a>
</td> </td>
<td class="uuid"> <td class="py-2 pr-4">
<code id="uuid-{{ loop.index }}">{{ r.uuid }}</code> <div class="flex items-center gap-2">
<code id="uuid-{{ loop.index }}" class="text-gray-300">{{ r.uuid }}</code>
<button <button
type="button" type="button"
class="copy-btn" 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 }}"> data-target="uuid-{{ loop.index }}"
title="Copy UUID"
>
📋 📋
</button> </button>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endif %}
<!-- Spinner Modal -->
<div id="spinner-modal" style="
display:none;
opacity:0;
position:fixed;
top:0;
left:0;
width:100%;
height:100%;
background:rgba(0,0,0,0.7);
color:#fff;
font-size:1.5rem;
text-align:center;
padding-top:20%;
z-index:9999;
transition: opacity 0.3s ease;
">
<div>
<div class="loader" style="
border: 8px solid #f3f3f3;
border-top: 8px solid #1a2535;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem auto;
"></div>
Analyzing website…
</div> </div>
</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>
<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', () => {
hideSpinner();
if (submitBtn) submitBtn.disabled = false;
if (btnSpinner) btnSpinner.classList.add('hidden');
});
form.addEventListener('submit', (e) => {
// prevent immediate submit so UI can paint spinner first
e.preventDefault(); e.preventDefault();
});
</script>
<script> if (submitBtn) submitBtn.disabled = true;
document.addEventListener('DOMContentLoaded', () => { if (btnSpinner) btnSpinner.classList.remove('hidden');
const buttons = document.querySelectorAll('.copy-btn');
buttons.forEach(btn => { showSpinner();
btn.addEventListener('click', () => {
// allow a frame so spinner paints, then submit
requestAnimationFrame(() => form.submit());
});
})();
/**
* Copy UUID buttons
*/
(function initCopyButtons() {
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const targetId = btn.getAttribute('data-target'); const targetId = btn.getAttribute('data-target');
const uuidText = document.getElementById(targetId).innerText; const el = document.getElementById(targetId);
if (!el) return;
navigator.clipboard.writeText(uuidText).then(() => { try {
// Give quick feedback await navigator.clipboard.writeText(el.textContent.trim());
const prev = btn.textContent;
btn.textContent = '✅'; btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 1500); setTimeout(() => { btn.textContent = prev; }, 1200);
}).catch(err => { } catch (err) {
console.error('Failed to copy UUID:', err); console.error('Failed to copy UUID:', err);
}
}); });
}); });
}); })();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,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>

View 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>

View File

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

View File

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

View File

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

View File

@@ -1,352 +1,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">
<!-- 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 }} {{ 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">
<thead class="text-gray-400 border-b border-gray-800">
<tr> <tr>
<th>Status</th> <th class="text-left py-2 pr-4">Status</th>
<th>URL</th> <th class="text-left py-2 pr-4">URL</th>
</tr> </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">
<a href="{{ r.url }}" target="_blank" rel="noopener" class="hover:text-blue-400">{{ r.url }}</a>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p>No redirects detected.</p>
{% endif %}
<p><a href="#top-jump-list">Back to top</a></p>
</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> </div>
{% else %} {% else %}
<span class="text-dim">None</span> <p class="text-sm text-gray-500">No redirects detected.</p>
{% endif %} {% endif %}
</td> <p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
<!-- Matches (Rules) --> <!-- Forms -->
<td> {% include "partials/result_forms.html" %}
{% 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 --> <!-- Suspicious Scripts -->
<td> {% include "partials/result_scripts.html" %}
{% if f.content_snippet %}
<details> <!-- Suspicious Text -->
<summary>View snippet ({{ f.content_snippet|length }} chars)</summary> {% include "partials/result_text.html" with context %}
<pre class="code">{{ f.content_snippet }}</pre>
</details> <!-- Screenshot -->
{% else %} <section id="screenshot" class="bg-card border border-gray-800 rounded-xl p-4">
<span class="text-dim">N/A</span> <h2 class="text-lg font-semibold mb-3">Screenshot</h2>
{% endif %} <img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}"
</td> alt="Screenshot"
</tr> class="w-full rounded-lg border border-gray-800">
{% endfor %} <p class="mt-2"><a href="#url-overview" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</tbody> </section>
</table>
{% else %} <!-- Source -->
<p class="text-dim">No form issues detected.</p> <section id="source" class="bg-card border border-gray-800 rounded-xl p-4">
{% endif %} <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>
<p><a href="#top-jump-list">Back to top</a></p>
</div> </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 {
const r = await fetch('/api/analyze_script', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, // include CSRF header if applicable headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: job, url: url}) body: JSON.stringify({ job_id: job, url: url })
}) });
.then(r => r.json()) const data = await r.json();
.then(data => {
if (!data.ok) { if (!data.ok) {
loading.textContent = 'Error: ' + (data.error || 'Unknown'); loading.textContent = 'Error: ' + (data.error || 'Unknown');
return; return;
} }
// --- Build the snippet details element --- // Build details with snippet
const snippetText = data.snippet || ''; // backend should return a preview
const snippetLen = data.snippet_len || snippetText.length;
// --- File path / viewer things
const filepath = data.artifact_path || ''; // e.g., "/data/3ec90584-076e-457c-924b-861be7e11a34/1755803694244.js"
const viewerUrl = buildViewerUrlFromAbsPath(filepath);
const details = document.createElement('details'); const details = document.createElement('details');
const summary = document.createElement('summary'); const summary = document.createElement('summary');
summary.textContent = 'View snippet (' + data.snippet_len + ' chars' + (data.truncated ? ', truncated' : '') + ', ' + data.bytes + ' bytes)'; summary.className = 'cursor-pointer text-blue-300 hover:underline';
summary.textContent = 'View snippet (' + (data.snippet_len ?? (data.snippet || '').length) +
' chars' + (data.truncated ? ', truncated' : '') +
', ' + (data.bytes ?? '') + ' bytes)';
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.className = 'code'; pre.className = 'bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1';
pre.textContent = snippetText; // textContent preserves literal code safely pre.textContent = data.snippet || '';
// put things in the DOM const viewerUrl = buildViewerUrlFromAbsPath(data.artifact_path || '');
const open = document.createElement('a');
open.href = viewerUrl; open.target = '_blank'; open.rel = 'noopener';
open.className = 'ml-2 text-sm text-gray-300 hover:text-blue-400';
open.textContent = 'open in viewer';
summary.appendChild(document.createTextNode(' '));
summary.appendChild(open);
details.appendChild(summary); details.appendChild(summary);
details.appendChild(pre); details.appendChild(pre);
const link = document.createElement('a');
link.href = viewerUrl;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'open in viewer';
summary.appendChild(document.createElement('br')); // line break under the summary text
summary.appendChild(link);
loading.replaceWith(details); loading.replaceWith(details);
// Replace "Analyzing…" with the new details block // Update Matches cell with rule findings
loading.replaceWith(details);
// --- Update the Matches cell with rule findings ---
if (matchesCell) { if (matchesCell) {
if (Array.isArray(data.findings) && data.findings.length) { if (Array.isArray(data.findings) && data.findings.length) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const strong = document.createElement('strong'); const strong = document.createElement('div');
strong.textContent = 'Rules'; strong.className = 'mb-1';
strong.innerHTML = '<strong>Rules</strong>';
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.className = 'space-y-1';
data.findings.forEach(function (f) { data.findings.forEach((f) => {
const li = document.createElement('li'); const li = document.createElement('li');
const name = f.name || 'Rule'; li.textContent = (f.name || 'Rule') + (f.description ? ' — ' + f.description : '');
const desc = f.description ? ' — ' + f.description : '';
li.textContent = name + desc;
// Optional badges for severity/tags if present
if (f.severity) { if (f.severity) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'badge sev-' + String(f.severity).toLowerCase(); badge.className = severityClass(f.severity);
badge.textContent = String(f.severity).charAt(0).toUpperCase() + String(f.severity).slice(1); badge.textContent = String(f.severity).charAt(0).toUpperCase() + String(f.severity).slice(1);
li.appendChild(document.createTextNode(' ')); li.appendChild(document.createTextNode(' '));
li.appendChild(badge); li.appendChild(badge);
} }
if (Array.isArray(f.tags)) { if (Array.isArray(f.tags)) {
f.tags.forEach(function (t) { f.tags.forEach((t) => {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'chip'; chip.className = 'ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs';
chip.title = 'Tag: ' + t; chip.title = 'Tag: ' + t;
chip.textContent = t; chip.textContent = t;
li.appendChild(document.createTextNode(' ')); li.appendChild(document.createTextNode(' '));
li.appendChild(chip); li.appendChild(chip);
}); });
} }
ul.appendChild(li); ul.appendChild(li);
}); });
frag.appendChild(strong); frag.appendChild(strong);
frag.appendChild(ul); frag.appendChild(ul);
// Replace placeholder N/A or existing heuristics-only content
matchesCell.innerHTML = ''; matchesCell.innerHTML = '';
matchesCell.appendChild(frag); matchesCell.appendChild(frag);
} else { } else {
matchesCell.innerHTML = '<span class="text-dim">No rule matches.</span>'; matchesCell.innerHTML = '<span class="text-gray-500">No rule matches.</span>';
} }
} }
}) } catch (err) {
.catch(function (err) {
loading.textContent = 'Request failed: ' + err; loading.textContent = 'Request failed: ' + err;
}); }
}); });
</script> </script>
<script>
document.addEventListener('click', function (e) {
if (e.target.matches('[data-toggle]')) {
var id = e.target.getAttribute('data-toggle');
var el = document.getElementById(id);
if (el) {
var hidden = el.getAttribute('hidden') !== null;
if (hidden) { el.removeAttribute('hidden'); } else { el.setAttribute('hidden', ''); }
}
}
}, true);
</script>
{% endblock %} {% endblock %}

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

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

View File

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

View File

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

View File

@@ -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
View File

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

96
docs/changelog.md Normal file
View 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 dont 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.

View File

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

6
tailwind/package.json Normal file
View File

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

View File

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