Compare commits

..

3 Commits

11 changed files with 335 additions and 41 deletions

View File

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

View File

@@ -12,6 +12,7 @@ from app.app_settings import AppSettings
from app.blueprints.main import bp as main_bp # ui blueprint from app.blueprints.main import bp as main_bp # ui blueprint
from app.blueprints.api import api_bp as api_bp # api blueprint from app.blueprints.api import api_bp as api_bp # api blueprint
from app.blueprints.roadmap import bp as roadmap_bp # roadmap from app.blueprints.roadmap import bp as roadmap_bp # roadmap
from app.blueprints.changelog import bp as changelog_bp # changelog
@@ -63,6 +64,7 @@ def create_app() -> Flask:
# roadmap file # roadmap file
app.config["ROADMAP_FILE"] = str(Path(app.root_path) / "docs" / "roadmap.yaml") app.config["ROADMAP_FILE"] = str(Path(app.root_path) / "docs" / "roadmap.yaml")
app.config["CHANGELOG_FILE"] = str(Path(app.root_path) / "docs" / "changelog.yaml")
# Configure storage directory (bind-mount is still handled by sandbox.sh) # Configure storage directory (bind-mount is still handled by sandbox.sh)
sandbox_storage_default = Path("/data") sandbox_storage_default = Path("/data")
@@ -73,6 +75,7 @@ def create_app() -> Flask:
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
app.register_blueprint(roadmap_bp) app.register_blueprint(roadmap_bp)
app.register_blueprint(changelog_bp)
app_logger = get_app_logger() app_logger = get_app_logger()

View File

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

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

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

View File

@@ -2,18 +2,6 @@
updated: "2025-08-22" updated: "2025-08-22"
roadmap: 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" - id: "p1-analysis-total-score"
priority: 1 priority: 1
title: "Total Score" title: "Total Score"
@@ -26,15 +14,6 @@ roadmap:
- "Explainability: always show a breakdown and contribution per component; include a 'Why?' link in the UI." - "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." - "Calibration: start with heuristic weights, then calibrate on a test set; store weights in settings.yaml."
- id: "p1-modal-close-fast"
priority: 1
title: "Analyze modal closes too fast"
goal: "have it wait until page reload"
tags: ["ui"]
milestone: null
details:
- "UX: user sees modal removed too quickly and thinks something broke"
- id: "p2-ui-rules-lab" - id: "p2-ui-rules-lab"
priority: 2 priority: 2
title: "Rules Lab" title: "Rules Lab"

View File

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

View File

@@ -35,6 +35,11 @@
Roadmap Roadmap
</a> </a>
</li> </li>
<li>
<a href="{{ url_for('changelog.view_changelog') }}">
Changelog
</a>
</li>
</ul> </ul>
{# Mobile toggle #} {# Mobile toggle #}
@@ -62,6 +67,11 @@
Roadmap Roadmap
</a> </a>
</li> </li>
<li>
<a href="{{ url_for('changelog.view_changelog') }}">
Chnagelog
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

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

View File

@@ -37,6 +37,9 @@
{{ ip }} - {{ ip }} -
{% if info.country %} {{ info.country }} {% endif %} - {% if info.country %} {{ info.country }} {% endif %} -
{% if info.isp %} {{ info.isp }} {% endif %} {% if info.isp %} {{ info.isp }} {% endif %}
{% if info.cloudflare %}
<span class="badge badge-warn">Cloudflare </span>
{% endif %}
</summary> </summary>
<div class="px-3 pb-3 overflow-x-auto"> <div class="px-3 pb-3 overflow-x-auto">

View File

@@ -336,6 +336,9 @@ def enrich_whois(hostname: str) -> dict:
def enrich_geoip(hostname: str) -> dict: def enrich_geoip(hostname: str) -> dict:
"""Resolve hostname to IPs and fetch info from ip-api.com.""" """Resolve hostname to IPs and fetch info from ip-api.com."""
CLOUDFLARE_ASN = "AS13335 Cloudflare"
geo_info = {} geo_info = {}
ips = extract_ips_from_url(hostname) ips = extract_ips_from_url(hostname)
for ip in ips: for ip in ips:
@@ -352,6 +355,12 @@ def enrich_geoip(hostname: str) -> dict:
resp = requests.get(f"http://ip-api.com/json/{ip_str}?fields=24313855", timeout=5) resp = requests.get(f"http://ip-api.com/json/{ip_str}?fields=24313855", timeout=5)
if resp.status_code == 200: if resp.status_code == 200:
geo_info[ip_str] = resp.json() geo_info[ip_str] = resp.json()
asname = geo_info[ip_str].get("as")
# if behind cloudflare
if CLOUDFLARE_ASN in asname:
geo_info[ip_str].update({"cloudflare":True})
else: else:
geo_info[ip_str] = {"error": f"HTTP {resp.status_code}"} geo_info[ip_str] = {"error": f"HTTP {resp.status_code}"}
except Exception as e: except Exception as e:

View File

@@ -17,7 +17,7 @@ This project follows [Semantic Versioning](https://semver.org/).
- _Nothing yet — add upcoming fixes here._ - _Nothing yet — add upcoming fixes here._
--- ---
## [v0.2]
### ✨ Features ### ✨ Features
@@ -37,6 +37,10 @@ This project follows [Semantic Versioning](https://semver.org/).
* **Modal sizing & ergonomics** * **Modal sizing & ergonomics**
Increased modal width at larger breakpoints and made the body scrollable so long details dont squish other content. Increased modal width at larger breakpoints and made the body scrollable so long details dont squish other content.
* **GeoIP Results Uplift**
Added Cloudflare detection via Geoip ASN results and Cloudflare badge on results page
Added Country - ASN notes beside collapsed IP next to GeoIP results for quick viewing.
* **Text Analysis Pipeline (Rules)** * **Text Analysis Pipeline (Rules)**
Implemented `analyse_text()` to extract visible page text and evaluate `category: text` 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`). Captures matched phrases into a deduped `content_snippet` (length capped via `settings.ui.snippet_preview_len`).
@@ -88,7 +92,7 @@ This project follows [Semantic Versioning](https://semver.org/).
--- ---
## [v0.1] Initial Work ## [v0.1]
- Implemented initial **Flask-based web UI** for URL submission and analysis. - Implemented initial **Flask-based web UI** for URL submission and analysis.
- Added **domain & IP enrichment** (WHOIS, GeoIP, ASN/ISP lookups). - Added **domain & IP enrichment** (WHOIS, GeoIP, ASN/ISP lookups).