Compare commits
3 Commits
b59bf67329
...
v0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a04256f75 | |||
| 248fce5655 | |||
| d5cc9df699 |
@@ -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
|
||||
* **BeautifulSoup4** for parsing
|
||||
* **Rules Engine**
|
||||
|
||||
* YAML regex rules (`config/suspicious_rules.yaml`)
|
||||
* Function rules (`app/rules/function_rules.py`) registered on startup
|
||||
* **Artifacts**: persistent path mounted at `/data` (configurable)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.app_settings import AppSettings
|
||||
from app.blueprints.main import bp as main_bp # ui blueprint
|
||||
from app.blueprints.api import api_bp as api_bp # api blueprint
|
||||
from app.blueprints.roadmap import bp as roadmap_bp # roadmap
|
||||
from app.blueprints.changelog import bp as changelog_bp # changelog
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +64,7 @@ def create_app() -> Flask:
|
||||
|
||||
# roadmap file
|
||||
app.config["ROADMAP_FILE"] = str(Path(app.root_path) / "docs" / "roadmap.yaml")
|
||||
app.config["CHANGELOG_FILE"] = str(Path(app.root_path) / "docs" / "changelog.yaml")
|
||||
|
||||
# Configure storage directory (bind-mount is still handled by sandbox.sh)
|
||||
sandbox_storage_default = Path("/data")
|
||||
@@ -73,6 +75,7 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(roadmap_bp)
|
||||
app.register_blueprint(changelog_bp)
|
||||
|
||||
app_logger = get_app_logger()
|
||||
|
||||
|
||||
71
app/blueprints/changelog.py
Normal file
71
app/blueprints/changelog.py
Normal 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
80
app/docs/changelog.yaml
Normal 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."
|
||||
@@ -2,18 +2,6 @@
|
||||
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"
|
||||
@@ -26,15 +14,6 @@ roadmap:
|
||||
- "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: "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"
|
||||
priority: 2
|
||||
title: "Rules Lab"
|
||||
|
||||
@@ -43,24 +43,24 @@ def build_rules_engine() -> RuleEngine:
|
||||
function=FunctionRuleAdapter(form_action_missing, category="form", adapter=adapter, rule_name="form_action_missing"),
|
||||
))
|
||||
|
||||
# add(Rule(
|
||||
# name="form_http_on_https_page",
|
||||
# description="Form submits via HTTP from HTTPS page",
|
||||
# category="form",
|
||||
# rule_type="function",
|
||||
# function=FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page"),
|
||||
# ))
|
||||
add(Rule(
|
||||
name="form_http_on_https_page",
|
||||
description="Form submits via HTTP from HTTPS page",
|
||||
category="form",
|
||||
rule_type="function",
|
||||
function=FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page"),
|
||||
))
|
||||
|
||||
# add(Rule("form_http_on_https_page", "Form submits via HTTP from HTTPS page", "form", "function",
|
||||
# FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page")))
|
||||
# add(Rule("form_submits_to_different_host", "Form submits to a different host", "form", "function",
|
||||
# FunctionRuleAdapter(form_submits_to_different_host, category="form", adapter=adapter, rule_name="form_submits_to_different_host")))
|
||||
# add(Rule("script_src_uses_data_or_blob", "Script src uses data:/blob: URL", "script", "function",
|
||||
# FunctionRuleAdapter(script_src_uses_data_or_blob, category="script", adapter=adapter, rule_name="script_src_uses_data_or_blob")))
|
||||
# add(Rule("script_src_has_dangerous_extension", "External script with dangerous extension", "script", "function",
|
||||
# FunctionRuleAdapter(script_src_has_dangerous_extension, category="script", adapter=adapter, rule_name="script_src_has_dangerous_extension")))
|
||||
# add(Rule("script_third_party_host", "Script is from a third-party host", "script", "function",
|
||||
# FunctionRuleAdapter(script_third_party_host, category="script", adapter=adapter, rule_name="script_third_party_host")))
|
||||
add(Rule("form_http_on_https_page", "Form submits via HTTP from HTTPS page", "form", "function",
|
||||
FunctionRuleAdapter(form_http_on_https_page, category="form", adapter=adapter, rule_name="form_http_on_https_page")))
|
||||
add(Rule("form_submits_to_different_host", "Form submits to a different host", "form", "function",
|
||||
FunctionRuleAdapter(form_submits_to_different_host, category="form", adapter=adapter, rule_name="form_submits_to_different_host")))
|
||||
add(Rule("script_src_uses_data_or_blob", "Script src uses data:/blob: URL", "script", "function",
|
||||
FunctionRuleAdapter(script_src_uses_data_or_blob, category="script", adapter=adapter, rule_name="script_src_uses_data_or_blob")))
|
||||
add(Rule("script_src_has_dangerous_extension", "External script with dangerous extension", "script", "function",
|
||||
FunctionRuleAdapter(script_src_has_dangerous_extension, category="script", adapter=adapter, rule_name="script_src_has_dangerous_extension")))
|
||||
add(Rule("script_third_party_host", "Script is from a third-party host", "script", "function",
|
||||
FunctionRuleAdapter(script_third_party_host, category="script", adapter=adapter, rule_name="script_third_party_host")))
|
||||
|
||||
log.info("Registered %d total rules (YAML + function)", len(eng.rules))
|
||||
return eng
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
Roadmap
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('changelog.view_changelog') }}">
|
||||
Changelog
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# Mobile toggle #}
|
||||
@@ -62,6 +67,11 @@
|
||||
Roadmap
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('changelog.view_changelog') }}">
|
||||
Chnagelog
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
136
app/templates/changelog.html
Normal file
136
app/templates/changelog.html
Normal 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 you’re 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 %}
|
||||
@@ -37,6 +37,9 @@
|
||||
{{ ip }} -
|
||||
{% if info.country %} {{ info.country }} {% endif %} -
|
||||
{% if info.isp %} {{ info.isp }} {% endif %}
|
||||
{% if info.cloudflare %}
|
||||
<span class="badge badge-warn">Cloudflare </span>
|
||||
{% endif %}
|
||||
|
||||
</summary>
|
||||
<div class="px-3 pb-3 overflow-x-auto">
|
||||
|
||||
@@ -336,6 +336,9 @@ def enrich_whois(hostname: str) -> dict:
|
||||
|
||||
def enrich_geoip(hostname: str) -> dict:
|
||||
"""Resolve hostname to IPs and fetch info from ip-api.com."""
|
||||
|
||||
CLOUDFLARE_ASN = "AS13335 Cloudflare"
|
||||
|
||||
geo_info = {}
|
||||
ips = extract_ips_from_url(hostname)
|
||||
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)
|
||||
if resp.status_code == 200:
|
||||
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:
|
||||
geo_info[ip_str] = {"error": f"HTTP {resp.status_code}"}
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,7 +17,7 @@ This project follows [Semantic Versioning](https://semver.org/).
|
||||
- _Nothing yet — add upcoming fixes here._
|
||||
|
||||
---
|
||||
|
||||
## [v0.2]
|
||||
|
||||
### ✨ Features
|
||||
|
||||
@@ -37,6 +37,10 @@ This project follows [Semantic Versioning](https://semver.org/).
|
||||
* **Modal sizing & ergonomics**
|
||||
Increased modal width at larger breakpoints and made the body scrollable so long details don’t squish other content.
|
||||
|
||||
* **GeoIP Results Uplift**
|
||||
Added Cloudflare detection via Geoip ASN results and Cloudflare badge on results page
|
||||
Added Country - ASN notes beside collapsed IP next to GeoIP results for quick viewing.
|
||||
|
||||
* **Text Analysis Pipeline (Rules)**
|
||||
Implemented `analyse_text()` to extract visible page text and evaluate `category: text` rules.
|
||||
Captures matched phrases into a deduped `content_snippet` (length capped via `settings.ui.snippet_preview_len`).
|
||||
@@ -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.
|
||||
- Added **domain & IP enrichment** (WHOIS, GeoIP, ASN/ISP lookups).
|
||||
|
||||
Reference in New Issue
Block a user