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.
This commit is contained in:
2025-08-22 10:36:10 -05:00
parent 965c953e00
commit 469334d137
12 changed files with 842 additions and 1109 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

@@ -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"])

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,99 +1,108 @@
{# templates/_macros_ssl_tls.html #} {# templates/_macros_ssl_tls.html #}
{% macro ssl_tls_card(ssl_tls) %} {% macro ssl_tls_card(ssl_tls) %}
<div class="card" id="ssl"> <div class="space-y-4">
<h2 class="card-title">SSL/TLS Intelligence</h2>
{# -------- 1) Error branch -------- #} {# -------- 1) Error branch -------- #}
{% if ssl_tls is none or 'error' in ssl_tls %} {% if ssl_tls is none or (ssl_tls.error is defined) or ('error' in ssl_tls) %}
<div class="badge badge-danger">Error</div> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-red-600/20 text-red-300 border-red-700">Error</span>
<p class="muted">SSL/TLS enrichment failed or is unavailable.</p> <p class="text-sm text-gray-500">SSL/TLS enrichment failed or is unavailable.</p>
{% if ssl_tls and ssl_tls.error %}<pre class="prewrap">{{ ssl_tls.error }}</pre>{% endif %} {% 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 -------- #} {# -------- 2) Skipped branch -------- #}
{% elif ssl_tls.skipped %} {% elif ssl_tls.skipped %}
<div class="badge badge-muted">Skipped</div> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-gray-700/30 text-gray-300 border-gray-700">Skipped</span>
{% if ssl_tls.reason %}<span class="muted small">{{ ssl_tls.reason }}</span>{% endif %} {% if ssl_tls.reason %}<span class="text-gray-400 text-sm ml-2">{{ ssl_tls.reason }}</span>{% endif %}
<div class="section"> <details class="mt-3">
<button class="badge badge-muted" data-toggle="tls-raw">Toggle raw</button> <summary class="cursor-pointer text-blue-300 hover:underline">Raw TLS JSON</summary>
<pre id="tls-raw" hidden>{{ ssl_tls|tojson(indent=2) }}</pre> <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>
</div> </details>
{# -------- 3) Normal branch (render probe + crt.sh) -------- #} {# -------- 3) Normal branch (render probe + crt.sh) -------- #}
{% else %} {% else %}
{# ===================== LIVE PROBE ===================== #} {# ===================== LIVE PROBE ===================== #}
{% set probe = ssl_tls.probe if ssl_tls else None %} {% set probe = ssl_tls.probe if ssl_tls else None %}
<section class="section"> <section class="space-y-2">
<div class="section-header"> <div class="flex items-center gap-2">
<h3>Live TLS Probe</h3> <h3 class="text-base font-semibold">Live TLS Probe</h3>
{% if probe %} {% if probe %}
<span class="muted">Host:</span> <code>{{ probe.hostname }}:{{ probe.port }}</code> <span class="text-gray-400 text-sm">Host:</span>
<code class="text-sm">{{ probe.hostname }}:{{ probe.port }}</code>
{% endif %} {% endif %}
</div> </div>
{% if not probe %} {% if not probe %}
<p class="muted">No probe data.</p> <p class="text-sm text-gray-500">No probe data.</p>
{% else %} {% else %}
<div class="tls-matrix"> <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'] %} {% set versions = ['TLS1.0','TLS1.1','TLS1.2','TLS1.3'] %}
{% for v in versions %} {% for v in versions %}
{% set r = probe.results_by_version.get(v) if probe.results_by_version else None %} {% set r = probe.results_by_version.get(v) if probe.results_by_version else None %}
<div class="tls-matrix-row"> <tr class="border-b border-gray-900">
<div class="tls-cell version">{{ v }}</div> <td class="py-2 pr-4 whitespace-nowrap">{{ v }}</td>
<td class="py-2 pr-4">
{% if r and r.supported %} {% if r and r.supported %}
<div class="tls-cell status"><span class="badge badge-ok">Supported</span></div> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-green-600/20 text-green-300 border-green-700">Supported</span>
<div class="tls-cell cipher">
{% if r.selected_cipher %}
<span class="chip">{{ r.selected_cipher }}</span>
{% else %} {% else %}
<span class="muted">cipher: n/a</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-gray-700/30 text-gray-300 border-gray-700">Not Supported</span>
{% endif %} {% endif %}
</div> </td>
<div class="tls-cell latency"> <td class="py-2 pr-4">
{% if r.handshake_seconds is not none %} {% if r and r.selected_cipher %}
<span class="muted">{{ '%.0f' % (r.handshake_seconds*1000) }} ms</span> <span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs">{{ r.selected_cipher }}</span>
{% elif r and r.error %}
<span class="text-gray-500 text-xs">({{ r.error }})</span>
{% else %} {% else %}
<span class="muted"></span> <span class="text-gray-500"></span>
{% endif %} {% endif %}
</div> </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 %} {% else %}
<div class="tls-cell status"><span class="badge badge-muted">Not Supported</span></div> <span class="text-gray-500"></span>
<div class="tls-cell cipher">
{% if r and r.error %}
<span class="muted small">({{ r.error }})</span>
{% else %}
<span class="muted"></span>
{% endif %} {% endif %}
</div> </td>
<div class="tls-cell latency"><span class="muted"></span></div> </tr>
{% endif %}
</div>
{% endfor %} {% endfor %}
</tbody>
</table>
</div> </div>
<div class="flag-row"> <div class="flex flex-wrap items-center gap-2 mt-2">
{% if probe.weak_protocols and probe.weak_protocols|length > 0 %} {% if probe.weak_protocols and probe.weak_protocols|length > 0 %}
<span class="badge badge-warn">Weak Protocols</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-yellow-600/20 text-yellow-300 border-yellow-700">Weak Protocols</span>
{% for wp in probe.weak_protocols %} {% for wp in probe.weak_protocols %}
<span class="chip chip-warn">{{ wp }}</span> <span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs">{{ wp }}</span>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if probe.weak_ciphers and probe.weak_ciphers|length > 0 %} {% if probe.weak_ciphers and probe.weak_ciphers|length > 0 %}
<span class="badge badge-warn">Weak Ciphers</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-yellow-600/20 text-yellow-300 border-yellow-700">Weak Ciphers</span>
{% for wc in probe.weak_ciphers %} {% for wc in probe.weak_ciphers %}
<span class="chip chip-warn">{{ wc }}</span> <span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs">{{ wc }}</span>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</div> </div>
{% if probe.errors and probe.errors|length > 0 %} {% if probe.errors and probe.errors|length > 0 %}
<details class="details"> <details class="mt-2">
<summary>Probe Notes</summary> <summary class="cursor-pointer text-blue-300 hover:underline">Probe Notes</summary>
<ul class="list"> <ul class="list-disc list-inside text-sm text-gray-400 space-y-1 mt-1">
{% for e in probe.errors %} {% for e in probe.errors %}
<li class="muted small">{{ e }}</li> <li>{{ e }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
</details> </details>
@@ -101,82 +110,86 @@
{% endif %} {% endif %}
</section> </section>
<hr class="divider"/> <hr class="border-gray-800 my-4"/>
{# ===================== CRT.SH ===================== #} {# ===================== CRT.SH ===================== #}
{% set crtsh = ssl_tls.crtsh if ssl_tls else None %} {% set crtsh = ssl_tls.crtsh if ssl_tls else None %}
<section class="section"> <section class="space-y-2">
<div class="section-header"> <div class="flex items-center flex-wrap gap-2">
<h3>Certificate Transparency (crt.sh)</h3> <h3 class="text-base font-semibold">Certificate Transparency (crt.sh)</h3>
{% if crtsh %} {% if crtsh %}
<span class="muted">Parsed:</span> <span class="text-gray-400 text-sm">Parsed:</span>
<code>{{ crtsh.hostname or 'n/a' }}</code> <code class="text-sm">{{ crtsh.hostname or 'n/a' }}</code>
{% if crtsh.root_domain %} {% if crtsh.root_domain %}
<span class="muted"> • Root:</span> <code>{{ crtsh.root_domain }}</code> <span class="text-gray-400 text-sm">• Root:</span>
{% if crtsh.is_root_domain %}<span class="badge badge-ok">Root</span>{% else %}<span class="badge badge-muted">Subdomain</span>{% endif %} <code class="text-sm">{{ crtsh.root_domain }}</code>
{% if crtsh.is_root_domain %}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-green-600/20 text-green-300 border-green-700">Root</span>
{% else %}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-gray-700/30 text-gray-300 border-gray-700">Subdomain</span>
{% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if not crtsh %} {% if not crtsh %}
<p class="muted">No CT data.</p> <p class="text-sm text-gray-500">No CT data.</p>
{% else %} {% else %}
<div class="grid two"> <div class="grid md:grid-cols-2 gap-4">
<div> <div>
<h4 class="muted">Host Certificates</h4> <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 %} {% set host_certs = crtsh.crtsh.host_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
{% if host_certs and host_certs|length > 0 %} {% if host_certs and host_certs|length > 0 %}
<ul class="list"> <ul class="space-y-1 text-xs">
{% for c in host_certs[:10] %} {% for c in host_certs[:10] %}
<li class="mono small"> <li class="font-mono">
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span> <span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-[10px]">{{ c.get('issuer_name','issuer n/a') }}</span>
<span class="muted"></span> <span class="text-gray-500"></span>
<strong>{{ c.get('name_value','(name n/a)') }}</strong> <strong class="break-all">{{ c.get('name_value','(name n/a)') }}</strong>
<span class="muted"> • not_before:</span> {{ c.get('not_before','?') }} <span class="text-gray-500"> • not_before:</span> {{ c.get('not_before','?') }}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if host_certs|length > 10 %} {% if host_certs|length > 10 %}
<div class="muted small">(+ {{ host_certs|length - 10 }} more)</div> <div class="text-gray-500 text-xs mt-1">(+ {{ host_certs|length - 10 }} more)</div>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="muted">No active host certs found.</p> <p class="text-sm text-gray-500">No active host certs found.</p>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<h4 class="muted">Wildcard on Root</h4> <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 %} {% set wc = crtsh.crtsh.wildcard_root_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
{% if wc and wc|length > 0 %} {% if wc and wc|length > 0 %}
<ul class="list"> <ul class="space-y-1 text-xs">
{% for c in wc[:10] %} {% for c in wc[:10] %}
<li class="mono small"> <li class="font-mono">
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span> <span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-[10px]">{{ c.get('issuer_name','issuer n/a') }}</span>
<span class="muted"></span> <span class="text-gray-500"></span>
<strong>{{ c.get('name_value','(name n/a)') }}</strong> <strong class="break-all">{{ c.get('name_value','(name n/a)') }}</strong>
<span class="muted"> • not_before:</span> {{ c.get('not_before','?') }} <span class="text-gray-500"> • not_before:</span> {{ c.get('not_before','?') }}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if wc|length > 10 %} {% if wc|length > 10 %}
<div class="muted small">(+ {{ wc|length - 10 }} more)</div> <div class="text-gray-500 text-xs mt-1">(+ {{ wc|length - 10 }} more)</div>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="muted">No wildcard/root certs found.</p> <p class="text-sm text-gray-500">No wildcard/root certs found.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</section> </section>
{# ===================== RAW JSON TOGGLE ===================== #} <!-- Raw JSON toggle -->
<div class="section"> <details class="mt-3">
<button class="badge badge-muted" data-toggle="tls-raw">Toggle raw</button> <summary class="cursor-pointer text-blue-300 hover:underline">Raw TLS JSON</summary>
<pre id="tls-raw" hidden>{{ ssl_tls|tojson(indent=2) }}</pre> <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>
</div> </details>
{% endif %} {% endif %}
<p><a href="#top-jump-list">Back to top</a></p> <p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -1,36 +1,76 @@
<!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" /> <!-- # Tailwind CSS # -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='tw.css') }}">
{# Your existing CSS stays; well keep only custom tweaks there. #}
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head> </head>
<body>
<header>
<h1>{{ app_name }} {{ app_version }}</h1>
</header>
{% with messages = get_flashed_messages(with_categories=true) %} <body class="bg-bg text-gray-200 min-h-screen flex flex-col">
{% if messages %} {# Top Navbar (Flowbite collapse) #}
<ul class="flash"> <nav class="bg-nav border-b border-gray-800">
{% for category, message in messages %} <div class="max-w-7xl mx-auto px-4 py-3">
<li class="{{ category }}">{{ message }}</li> <div class="flex items-center justify-between">
{% endfor %} <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>
</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>
</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>
{# Flowbite JS (enables collapse) #}
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js"></script>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>
{% block page_js %}
{% endblock %}

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>
<button type="submit">Analyze</button>
</form> </form>
</section>
<!-- Recent Results (optional; shown only if recent_results provided) --> <!-- Recent Results -->
{% if recent_results %} {% if recent_results %}
<div class="card" id="recent-results"> <section class="bg-card border border-gray-800 rounded-xl p-4" id="recent-results">
<h2>Recent Results</h2> <h2 class="text-base font-semibold mb-3">Recent Results</h2>
<table class="results-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>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> </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 %} {% 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>
<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'); /**
* Show the fullscreen spinner overlay.
*/
function showSpinner() {
const modal = document.getElementById('spinner-modal'); const modal = document.getElementById('spinner-modal');
if (!modal) return;
function showModal() { modal.classList.remove('hidden');
modal.style.display = 'block'; // allow reflow so opacity transition runs
requestAnimationFrame(() => { requestAnimationFrame(() => modal.classList.remove('opacity-0'));
modal.style.opacity = '1';
});
} }
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 /**
* Initialize form submit handling:
* - shows overlay spinner
* - 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;
const submitBtn = form.querySelector('button[type="submit"]');
const btnSpinner = document.getElementById('btn-spinner');
// Hide spinner overlay if arriving from bfcache/back
window.addEventListener('pageshow', () => { window.addEventListener('pageshow', () => {
modal.style.opacity = '0'; hideSpinner();
modal.style.display = 'none'; if (submitBtn) submitBtn.disabled = false;
if (btnSpinner) btnSpinner.classList.add('hidden');
}); });
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
showModal(); // prevent immediate submit so UI can paint spinner first
// Prevent double submission
form.querySelector('button').disabled = true;
// Allow browser to render the modal before submitting
requestAnimationFrame(() => form.submit());
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

@@ -1,264 +1,301 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros_ssl_tls.html" import ssl_tls_card %} {% 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"
<!-- Sticky top nav --> 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">
<nav id="top-jump-list" class="top-jump-nav" aria-label="Jump to section"> <div class="flex flex-wrap gap-2 text-sm">
<a href="/">Analyse Another Page</a> <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">URL Overview</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">Enrichment</a> <a href="#enrichment" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Enrichment</a>
<a href="#ssl">TLS / Certs</a> <a href="#ssl" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">TLS / Certs</a>
<a href="#redirects">Redirects</a> <a href="#redirects" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Redirects</a>
<a href="#forms">Forms</a> <a href="#forms" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Forms</a>
<a href="#scripts">Suspicious Scripts</a> <a href="#scripts" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Suspicious Scripts</a>
<a href="#screenshot">Screenshot</a> <a href="#screenshot" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Screenshot</a>
<a href="#source">Source</a> <a href="#source" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Source</a>
</div>
</nav> </nav>
<!-- URL Overview --> <!-- URL Overview -->
<div class="card" id="url-overview"> <section id="url-overview" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>URL Overview</h2> <h2 class="text-lg font-semibold mb-3">URL Overview</h2>
<p><strong>Submitted URL:</strong> {{ submitted_url }}</p> <div class="space-y-2 text-sm">
<p><strong>Final URL:</strong> <a href="{{ final_url }}" target="_blank">{{ final_url }}</a></p> <p><span class="text-gray-400">Submitted URL:</span> <span class="break-all">{{ submitted_url }}</span></p>
<p><strong>Permalink:</strong> <p>
<a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}"> <span class="text-gray-400">Final URL:</span>
<a href="{{ final_url }}" target="_blank" rel="noopener" class="break-all hover:text-blue-400">{{ final_url }}</a>
</p>
<p>
<span class="text-gray-400">Permalink:</span>
<a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}" class="break-all hover:text-blue-400">
{{ 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="#top-jump-list" 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"> <section id="enrichment" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Enrichment</h2> <h2 class="text-lg font-semibold mb-3">Enrichment</h2>
<!-- WHOIS -->
{% if enrichment.whois %} {% if enrichment.whois %}
<h3>WHOIS</h3> <h3 class="text-base font-semibold mt-2 mb-2">WHOIS</h3>
<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>Field</th> <th class="text-left py-2 pr-4">Field</th>
<th>Value</th> <th class="text-left py-2 pr-4">Value</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for k, v in enrichment.whois.items() %} {% for k, v in enrichment.whois.items() %}
<tr> <tr class="border-b border-gray-900">
<td>{{ k.replace('_', ' ').title() }}</td> <td class="py-2 pr-4 whitespace-nowrap">{{ k.replace('_', ' ').title() }}</td>
<td>{{ v }}</td> <td class="py-2 pr-4 break-all">{{ v }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endif %} {% endif %}
{% if enrichment.raw_whois %} {% if enrichment.raw_whois %}
<h3>Raw WHOIS</h3> <h3 class="text-base font-semibold mt-4 mb-2">Raw WHOIS</h3>
<pre class="code">{{ enrichment.raw_whois }}</pre> <pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-sm">{{ enrichment.raw_whois }}</pre>
{% endif %} {% endif %}
<!-- GeoIP / IP-API -->
{% if enrichment.geoip %} {% if enrichment.geoip %}
<h3>GeoIP</h3> <h3 class="text-base font-semibold mt-4 mb-2">GeoIP</h3>
{% for ip, info in enrichment.geoip.items() %} {% for ip, info in enrichment.geoip.items() %}
<details class="card" style="padding:0.5rem; margin-bottom:0.5rem;"> <details class="border border-gray-800 rounded-lg mb-2">
<summary>{{ ip }}</summary> <summary class="px-3 py-2 cursor-pointer hover:bg-gray-900/50">{{ ip }}</summary>
<table class="enrichment-table"> <div class="px-3 pb-3 overflow-x-auto">
<table class="min-w-full text-sm">
<tbody> <tbody>
{% for key, val in info.items() %} {% for key, val in info.items() %}
<tr> <tr class="border-b border-gray-900">
<td>{{ key.replace('_', ' ').title() }}</td> <td class="py-2 pr-4 whitespace-nowrap text-gray-400">{{ key.replace('_', ' ').title() }}</td>
<td>{{ val }}</td> <td class="py-2 pr-4 break-all">{{ val }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</details> </details>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if not enrichment.whois and not enrichment.raw_whois and not enrichment.geoip and not enrichment.bec_words %} {% if not enrichment.whois and not enrichment.raw_whois and not enrichment.geoip and not enrichment.bec_words %}
<p>No enrichment data available.</p> <p class="text-sm text-gray-500">No enrichment data available.</p>
{% endif %} {% endif %}
<p><a href="#top-jump-list">Back to top</a></p> <p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</div> </section>
<!-- TLS / SSL / CERTS --> <!-- TLS / SSL / CERTS -->
<section id="ssl" class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-lg font-semibold mb-3">TLS / Certs</h2>
{{ ssl_tls_card(enrichment.ssl_tls) }} {{ ssl_tls_card(enrichment.ssl_tls) }}
</section>
<!-- Redirects --> <!-- Redirects -->
<div class="card" id="redirects"> <section id="redirects" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Redirects</h2> <h2 class="text-lg font-semibold mb-3">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> </div>
{% else %}
<p class="text-sm text-gray-500">No redirects detected.</p>
{% endif %}
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
<!-- Forms --> <!-- Forms -->
<div class="card" id="forms"> <section id="forms" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Forms</h2> <h2 class="text-lg font-semibold mb-3">Forms</h2>
{% if forms and forms|length > 0 %} {% if forms and forms|length > 0 %}
<table class="enrichment-table forms-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>Action</th> <th class="text-left py-2 pr-4">Action</th>
<th>Method</th> <th class="text-left py-2 pr-4">Method</th>
<th>Inputs</th> <th class="text-left py-2 pr-4">Inputs</th>
<th>Matches (Rules)</th> <th class="text-left py-2 pr-4">Matches (Rules)</th>
<th>Form Snippet</th> <th class="text-left py-2 pr-4">Form Snippet</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for f in forms %} {% for f in forms %}
<tr> <tr class="border-b border-gray-900 align-top">
<!-- Action --> <!-- Action -->
<td class="breakable"> <td class="py-2 pr-4 break-all">
{% if f.action %} {% if f.action %}
{{ f.action[:25] }}{% if f.action|length > 25 %}…{% endif %} {{ f.action[:80] }}{% if f.action|length > 80 %}…{% endif %}
{% else %} {% else %}
<span class="text-dim">(no action)</span> <span class="text-gray-500">(no action)</span>
{% endif %} {% endif %}
</td> </td>
<!-- Method --> <!-- Method -->
<td>{{ (f.method or 'get')|upper }}</td> <td class="py-2 pr-4 whitespace-nowrap">{{ (f.method or 'get')|upper }}</td>
<!-- Inputs --> <!-- Inputs -->
<td> <td class="py-2 pr-4">
{% if f.inputs and f.inputs|length > 0 %} {% if f.inputs and f.inputs|length > 0 %}
<div class="chips"> <div class="flex flex-wrap gap-1">
{% for inp in f.inputs %} {% for inp in f.inputs %}
<span class="chip" title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}"> <span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs"
{{ inp.name or '(unnamed)' }}<small> : {{ (inp.type or 'text') }}</small> title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
{{ inp.name or '(unnamed)' }}<small class="text-gray-400"> : {{ (inp.type or 'text') }}</small>
</span> </span>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<span class="text-dim">None</span> <span class="text-gray-500">None</span>
{% endif %} {% endif %}
</td> </td>
<!-- Matches (Rules) --> <!-- Matches (Rules) -->
<td> <td class="py-2 pr-4">
{% if f.rules and f.rules|length > 0 %} {% if f.rules and f.rules|length > 0 %}
<ul> <ul class="space-y-1">
{% for r in f.rules %} {% for r in f.rules %}
<li title="{{ r.description or '' }}"> <li title="{{ r.description or '' }}">
{{ r.name }} {{ r.name }}
{% if r.severity %} {% if r.severity %}
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span> {% set sev = r.severity|lower %}
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
{% if sev == 'high' %} bg-red-600/20 text-red-300 border-red-700
{% elif sev == 'medium' %} bg-yellow-600/20 text-yellow-300 border-yellow-700
{% else %} bg-blue-600/20 text-blue-300 border-blue-700 {% endif %}">
{{ r.severity|title }}
</span>
{% endif %} {% endif %}
{% if r.tags %} {% if r.tags %}
{% for t in r.tags %} {% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span> <span class="ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if r.description %} {% if r.description %}
<small> — {{ r.description }}</small> <small class="text-gray-400"> — {{ r.description }}</small>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<span class="text-dim">N/A</span> <span class="text-gray-500">N/A</span>
{% endif %} {% endif %}
</td> </td>
<!-- Form Snippet --> <!-- Form Snippet -->
<td> <td class="py-2 pr-4">
{% if f.content_snippet %} {% if f.content_snippet %}
<details> <details>
<summary>View snippet ({{ f.content_snippet|length }} chars)</summary> <summary class="cursor-pointer text-blue-300 hover:underline">
<pre class="code">{{ f.content_snippet }}</pre> View snippet ({{ f.content_snippet|length }} chars)
</summary>
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ f.content_snippet }}</pre>
</details> </details>
{% else %} {% else %}
<span class="text-dim">N/A</span> <span class="text-gray-500">N/A</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<p class="text-dim">No form issues detected.</p> <p class="text-sm text-gray-500">No form issues detected.</p>
{% endif %} {% endif %}
<p><a href="#top-jump-list">Back to top</a></p> <p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</div> </section>
<!-- Suspicious Scripts --> <!-- Suspicious Scripts -->
<div class="card" id="scripts"> <section id="scripts" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Suspicious Scripts</h2> <h2 class="text-lg font-semibold mb-3">Suspicious Scripts</h2>
{% if suspicious_scripts %} {% if suspicious_scripts %}
<table class="enrichment-table scripts-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>Type</th> <th class="text-left py-2 pr-4">Type</th>
<th>Source URL</th> <th class="text-left py-2 pr-4">Source URL</th>
<th>Matches (Rules & Heuristics)</th> <th class="text-left py-2 pr-4">Matches (Rules &amp; Heuristics)</th>
<th>Content Snippet</th> <th class="text-left py-2 pr-4">Content Snippet</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for s in suspicious_scripts %} {% for s in suspicious_scripts %}
<tr> <tr class="border-b border-gray-900 align-top">
<!-- Type --> <td class="py-2 pr-4 whitespace-nowrap">{{ s.type or 'unknown' }}</td>
<td>{{ s.type or 'unknown' }}</td>
<!-- Source URL --> <td class="py-2 pr-4 break-all">
<td class="breakable">
{% if s.src %} {% if s.src %}
<a href="{{ s.src }}" target="_blank" rel="noopener">{{ s.src[:50] }}</a> <a href="{{ s.src }}" target="_blank" rel="noopener" class="hover:text-blue-400">
{% else %} N/A {% endif %} {{ s.src[:100] }}{% if s.src|length > 100 %}…{% endif %}
</a>
{% else %}
<span class="text-gray-500">N/A</span>
{% endif %}
</td> </td>
<!-- Matches (Rules & Heuristics) --> <!-- Matches (Rules & Heuristics) -->
<td data-role="matches-cell"> <td class="py-2 pr-4" data-role="matches-cell">
{% set has_rules = s.rules and s.rules|length > 0 %} {% set has_rules = s.rules and s.rules|length > 0 %}
{% set has_heur = s.heuristics and s.heuristics|length > 0 %} {% set has_heur = s.heuristics and s.heuristics|length > 0 %}
{% if has_rules %} {% if has_rules %}
<strong>Rules</strong> <div class="mb-1"><strong>Rules</strong></div>
<ul> <ul class="space-y-1">
{% for r in s.rules %} {% for r in s.rules %}
<li title="{{ r.description or '' }}"> <li title="{{ r.description or '' }}">
{{ r.name }} {{ r.name }}
{% if r.severity %} {% if r.severity %}
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span> {% set sev = r.severity|lower %}
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
{% if sev == 'high' %} bg-red-600/20 text-red-300 border-red-700
{% elif sev == 'medium' %} bg-yellow-600/20 text-yellow-300 border-yellow-700
{% else %} bg-blue-600/20 text-blue-300 border-blue-700 {% endif %}">
{{ r.severity|title }}
</span>
{% endif %} {% endif %}
{% if r.tags %} {% if r.tags %}
{% for t in r.tags %} {% for t in r.tags %}
<span class="chip" title="Tag: {{ t }}">{{ t }}</span> <span class="ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs" title="Tag: {{ t }}">{{ t }}</span>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if r.description %} {% if r.description %}
<small>— {{ r.description }}</small> <small class="text-gray-400"> — {{ r.description }}</small>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
@@ -266,8 +303,8 @@
{% endif %} {% endif %}
{% if has_heur %} {% if has_heur %}
<strong>Heuristics</strong> <div class="mt-2 mb-1"><strong>Heuristics</strong></div>
<ul> <ul class="list-disc list-inside space-y-1">
{% for h in s.heuristics %} {% for h in s.heuristics %}
<li>{{ h }}</li> <li>{{ h }}</li>
{% endfor %} {% endfor %}
@@ -275,26 +312,30 @@
{% endif %} {% endif %}
{% if not has_rules and not has_heur %} {% if not has_rules and not has_heur %}
<span class="text-dim">N/A</span> <span class="text-gray-500">N/A</span>
{% endif %} {% endif %}
</td> </td>
<!-- Content Snippet (reused for Analyze button / dynamic snippet) --> <!-- Content Snippet / Analyze external script -->
<td data-role="snippet-cell"> <td class="py-2 pr-4" data-role="snippet-cell">
{% if s.content_snippet %} {% if s.content_snippet %}
<details> <details>
<summary>View snippet ({{ s.content_snippet|length }} chars)</summary> <summary class="cursor-pointer text-blue-300 hover:underline">
<pre class="code">{{ s.content_snippet }}</pre> View snippet ({{ s.content_snippet|length }} chars)
</summary>
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ s.content_snippet }}</pre>
</details> </details>
{% else %} {% else %}
{% if s.type == 'external' and s.src %} {% if s.type == 'external' and s.src %}
<button <button
type="button" type="button"
class="btn btn-sm btn-primary btn-analyze-snippet" 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-url="{{ s.src }}"
data-job="{{ uuid }}">Analyze external script</button> data-job="{{ uuid }}">
Analyze external script
</button>
{% else %} {% else %}
<span class="text-dim">N/A</span> <span class="text-gray-500">N/A</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
@@ -302,51 +343,52 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<p>No suspicious scripts detected.</p> <p class="text-sm text-gray-500">No suspicious scripts detected.</p>
{% endif %} {% endif %}
<p><a href="#top-jump-list">Back to top</a></p> <p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</div> </section>
<!-- Screenshot --> <!-- Screenshot -->
<div class="card" id="screenshot"> <section id="screenshot" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Screenshot</h2> <h2 class="text-lg font-semibold mb-3">Screenshot</h2>
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}" alt="Screenshot"> <img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}"
<p><a href="#top-jump-list">Back to top</a></p> alt="Screenshot"
</div> class="w-full rounded-lg border border-gray-800">
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
<!-- Source --> <!-- Source -->
<div class="card" id="source"> <section id="source" class="bg-card border border-gray-800 rounded-xl p-4">
<h2>Source</h2> <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">View Source</a></p> <p>
<p><a href="#top-jump-list">Back to top</a></p> <a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}"
</div> 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="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
</section>
</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 +396,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 +421,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 %}

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;

6
assets/input.css Normal file
View File

@@ -0,0 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Optional tiny custom classes */
.card-shadow { box-shadow: 0 1px 2px rgba(0,0,0,.2); }

View File

@@ -1,20 +1,21 @@
# SneakyScope — Roadmap (Updated 8-21-25) # SneakyScope — Roadmap (Updated 8-21-25)
## Priority 1 Core Analysis / Stability ## Priority 1 Core Analysis / Stability
* why are the rules not finding anything now?
* if cloudflare, we notate and badge it, along with a blurp that explains how cloudflare is both used for good and evil.
* need a generalized "total score" for the site. something that is a quick 0/10 (guessing on the number), so new analyst don't have to think on the details.
*(no open items currently tracked in this bucket)* ## Priority 2 UI / UX
* Rules Lab (WYSIWYG tester): paste a rule, validate/compile, run against sample text; lightweight nav entry.
* Build reusable util classes in tailwind and replace the long class strings. Classes to build: badge, badge-ok, badge-warn, badge-danger, chip, card.
## Priority 2 API Layer ## Priority 3 API Layer
* API endpoints: `/screenshot`, `/source`, `/analyse`. * API endpoints: `/screenshot`, `/source`, `/analyse`.
* **OpenAPI**: add `POST /api/analyze_script` (request/response schemas, examples) to `openapi/openapi.yaml`; serve at `/api/openapi.yaml`. * **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`. * 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. * (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 ## Priority 4 Artifact Management & Ops
* Retention/cleanup policy for old artifacts (age/size thresholds). * Retention/cleanup policy for old artifacts (age/size thresholds).
@@ -23,11 +24,11 @@
## Priority 5 Extras / Integrations ## 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). * 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. * 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 ## 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). * 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).
* IP Lookups 0 if we are successful on domain replutation / ip reputation

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: [],
};