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:
@@ -1,352 +1,394 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros_ssl_tls.html" import ssl_tls_card %}
|
||||
{% block title %}Scan Results{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
|
||||
|
||||
<!-- Top Jump List -->
|
||||
<!-- Top Jump List (sticky) -->
|
||||
<nav id="top-jump-list"
|
||||
class="sticky top-0 z-20 bg-bg/80 backdrop-blur border-b border-gray-800 py-2 px-3 rounded-b xl:rounded-b-none">
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<a href="{{ url_for('main.index') }}" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Analyse Another Page</a>
|
||||
<a href="#url-overview" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">URL Overview</a>
|
||||
<a href="#enrichment" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Enrichment</a>
|
||||
<a href="#ssl" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">TLS / Certs</a>
|
||||
<a href="#redirects" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Redirects</a>
|
||||
<a href="#forms" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Forms</a>
|
||||
<a href="#scripts" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Suspicious Scripts</a>
|
||||
<a href="#screenshot" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Screenshot</a>
|
||||
<a href="#source" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Source</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sticky top nav -->
|
||||
<nav id="top-jump-list" class="top-jump-nav" aria-label="Jump to section">
|
||||
<a href="/">Analyse Another Page</a>
|
||||
<a href="#url-overview">URL Overview</a>
|
||||
<a href="#enrichment">Enrichment</a>
|
||||
<a href="#ssl">TLS / Certs</a>
|
||||
<a href="#redirects">Redirects</a>
|
||||
<a href="#forms">Forms</a>
|
||||
<a href="#scripts">Suspicious Scripts</a>
|
||||
<a href="#screenshot">Screenshot</a>
|
||||
<a href="#source">Source</a>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- URL Overview -->
|
||||
<div class="card" id="url-overview">
|
||||
<h2>URL Overview</h2>
|
||||
<p><strong>Submitted URL:</strong> {{ submitted_url }}</p>
|
||||
<p><strong>Final URL:</strong> <a href="{{ final_url }}" target="_blank">{{ final_url }}</a></p>
|
||||
<p><strong>Permalink:</strong>
|
||||
<a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}">
|
||||
{{ request.host_url }}results/{{ uuid }}
|
||||
<!-- URL Overview -->
|
||||
<section id="url-overview" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">URL Overview</h2>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="text-gray-400">Submitted URL:</span> <span class="break-all">{{ submitted_url }}</span></p>
|
||||
<p>
|
||||
<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 }}
|
||||
</a>
|
||||
</p>
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
</p>
|
||||
<p><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Enrichment -->
|
||||
<div class="card" id="enrichment">
|
||||
<h2>Enrichment</h2>
|
||||
<!-- Enrichment -->
|
||||
<section id="enrichment" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Enrichment</h2>
|
||||
|
||||
<!-- WHOIS -->
|
||||
{% if enrichment.whois %}
|
||||
<h3>WHOIS</h3>
|
||||
<table class="enrichment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<h3 class="text-base font-semibold mt-2 mb-2">WHOIS</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Field</th>
|
||||
<th class="text-left py-2 pr-4">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k, v in enrichment.whois.items() %}
|
||||
<tr>
|
||||
<td>{{ k.replace('_', ' ').title() }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for k, v in enrichment.whois.items() %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ k.replace('_', ' ').title() }}</td>
|
||||
<td class="py-2 pr-4 break-all">{{ v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if enrichment.raw_whois %}
|
||||
<h3>Raw WHOIS</h3>
|
||||
<pre class="code">{{ enrichment.raw_whois }}</pre>
|
||||
<h3 class="text-base font-semibold mt-4 mb-2">Raw WHOIS</h3>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-sm">{{ enrichment.raw_whois }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<!-- GeoIP / IP-API -->
|
||||
{% if enrichment.geoip %}
|
||||
<h3>GeoIP</h3>
|
||||
{% for ip, info in enrichment.geoip.items() %}
|
||||
<details class="card" style="padding:0.5rem; margin-bottom:0.5rem;">
|
||||
<summary>{{ ip }}</summary>
|
||||
<table class="enrichment-table">
|
||||
<h3 class="text-base font-semibold mt-4 mb-2">GeoIP</h3>
|
||||
{% for ip, info in enrichment.geoip.items() %}
|
||||
<details class="border border-gray-800 rounded-lg mb-2">
|
||||
<summary class="px-3 py-2 cursor-pointer hover:bg-gray-900/50">{{ ip }}</summary>
|
||||
<div class="px-3 pb-3 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<tbody>
|
||||
{% for key, val in info.items() %}
|
||||
<tr>
|
||||
<td>{{ key.replace('_', ' ').title() }}</td>
|
||||
<td>{{ val }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for key, val in info.items() %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap text-gray-400">{{ key.replace('_', ' ').title() }}</td>
|
||||
<td class="py-2 pr-4 break-all">{{ val }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if not enrichment.whois and not enrichment.raw_whois and not enrichment.geoip and not enrichment.bec_words %}
|
||||
<p>No enrichment data available.</p>
|
||||
<p class="text-sm text-gray-500">No enrichment data available.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- TLS / SSL / CERTS -->
|
||||
{{ ssl_tls_card(enrichment.ssl_tls) }}
|
||||
<!-- 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) }}
|
||||
</section>
|
||||
|
||||
<!-- Redirects -->
|
||||
<div class="card" id="redirects">
|
||||
<h2>Redirects</h2>
|
||||
<!-- Redirects -->
|
||||
<section id="redirects" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Redirects</h2>
|
||||
{% if redirects %}
|
||||
<table class="enrichment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
<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">Status</th>
|
||||
<th class="text-left py-2 pr-4">URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in redirects %}
|
||||
<tr>
|
||||
<td>{{ r.status }}</td>
|
||||
<td><a href="{{ r.url }}" target="_blank">{{ r.url }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for r in redirects %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ r.status }}</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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No redirects detected.</p>
|
||||
<p class="text-sm text-gray-500">No redirects detected.</p>
|
||||
{% endif %}
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
<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 -->
|
||||
<div class="card" id="forms">
|
||||
<h2>Forms</h2>
|
||||
<!-- Forms -->
|
||||
<section id="forms" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Forms</h2>
|
||||
|
||||
{% if forms and forms|length > 0 %}
|
||||
<table class="enrichment-table forms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Method</th>
|
||||
<th>Inputs</th>
|
||||
<th>Matches (Rules)</th>
|
||||
<th>Form Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in forms %}
|
||||
<tr>
|
||||
<!-- Action -->
|
||||
<td class="breakable">
|
||||
{% if f.action %}
|
||||
{{ f.action[:25] }}{% if f.action|length > 25 %}…{% endif %}
|
||||
{% else %}
|
||||
<span class="text-dim">(no action)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if forms and forms|length > 0 %}
|
||||
<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">Action</th>
|
||||
<th class="text-left py-2 pr-4">Method</th>
|
||||
<th class="text-left py-2 pr-4">Inputs</th>
|
||||
<th class="text-left py-2 pr-4">Matches (Rules)</th>
|
||||
<th class="text-left py-2 pr-4">Form Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in forms %}
|
||||
<tr class="border-b border-gray-900 align-top">
|
||||
<!-- Action -->
|
||||
<td class="py-2 pr-4 break-all">
|
||||
{% if f.action %}
|
||||
{{ f.action[:80] }}{% if f.action|length > 80 %}…{% endif %}
|
||||
{% else %}
|
||||
<span class="text-gray-500">(no action)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Method -->
|
||||
<td>{{ (f.method or 'get')|upper }}</td>
|
||||
<!-- Method -->
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ (f.method or 'get')|upper }}</td>
|
||||
|
||||
<!-- Inputs -->
|
||||
<td>
|
||||
{% if f.inputs and f.inputs|length > 0 %}
|
||||
<div class="chips">
|
||||
<!-- Inputs -->
|
||||
<td class="py-2 pr-4">
|
||||
{% if f.inputs and f.inputs|length > 0 %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for inp in f.inputs %}
|
||||
<span class="chip" title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
|
||||
{{ inp.name or '(unnamed)' }}<small> : {{ (inp.type or 'text') }}</small>
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs"
|
||||
title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
|
||||
{{ inp.name or '(unnamed)' }}<small class="text-gray-400"> : {{ (inp.type or 'text') }}</small>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-dim">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<span class="text-gray-500">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Matches (Rules) -->
|
||||
<td>
|
||||
{% if f.rules and f.rules|length > 0 %}
|
||||
<ul>
|
||||
<!-- Matches (Rules) -->
|
||||
<td class="py-2 pr-4">
|
||||
{% if f.rules and f.rules|length > 0 %}
|
||||
<ul class="space-y-1">
|
||||
{% for r in f.rules %}
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span>
|
||||
{% endif %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small> — {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
{% set sev = r.severity|lower %}
|
||||
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
|
||||
{% if sev == 'high' %} 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 %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<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 %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small class="text-gray-400"> — {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Form Snippet -->
|
||||
<td>
|
||||
{% if f.content_snippet %}
|
||||
<details>
|
||||
<summary>View snippet ({{ f.content_snippet|length }} chars)</summary>
|
||||
<pre class="code">{{ f.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-dim">No form issues detected.</p>
|
||||
{% endif %}
|
||||
<!-- Form Snippet -->
|
||||
<td class="py-2 pr-4">
|
||||
{% if f.content_snippet %}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">
|
||||
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>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No form issues detected.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Suspicious Scripts -->
|
||||
<section id="scripts" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Suspicious Scripts</h2>
|
||||
|
||||
<!-- Suspicious Scripts -->
|
||||
<div class="card" id="scripts">
|
||||
<h2>Suspicious Scripts</h2>
|
||||
{% if suspicious_scripts %}
|
||||
<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">Type</th>
|
||||
<th class="text-left py-2 pr-4">Source URL</th>
|
||||
<th class="text-left py-2 pr-4">Matches (Rules & Heuristics)</th>
|
||||
<th class="text-left py-2 pr-4">Content Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suspicious_scripts %}
|
||||
<tr class="border-b border-gray-900 align-top">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ s.type or 'unknown' }}</td>
|
||||
|
||||
{% if suspicious_scripts %}
|
||||
<table class="enrichment-table scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Source URL</th>
|
||||
<th>Matches (Rules & Heuristics)</th>
|
||||
<th>Content Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suspicious_scripts %}
|
||||
<tr>
|
||||
<!-- Type -->
|
||||
<td>{{ s.type or 'unknown' }}</td>
|
||||
<td class="py-2 pr-4 break-all">
|
||||
{% if s.src %}
|
||||
<a href="{{ s.src }}" target="_blank" rel="noopener" class="hover:text-blue-400">
|
||||
{{ s.src[:100] }}{% if s.src|length > 100 %}…{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Source URL -->
|
||||
<td class="breakable">
|
||||
{% if s.src %}
|
||||
<a href="{{ s.src }}" target="_blank" rel="noopener">{{ s.src[:50] }}</a>
|
||||
{% else %} N/A {% endif %}
|
||||
</td>
|
||||
<!-- Matches (Rules & Heuristics) -->
|
||||
<td class="py-2 pr-4" data-role="matches-cell">
|
||||
{% set has_rules = s.rules and s.rules|length > 0 %}
|
||||
{% set has_heur = s.heuristics and s.heuristics|length > 0 %}
|
||||
|
||||
<!-- Matches (Rules & Heuristics) -->
|
||||
<td data-role="matches-cell">
|
||||
{% set has_rules = s.rules and s.rules|length > 0 %}
|
||||
{% set has_heur = s.heuristics and s.heuristics|length > 0 %}
|
||||
{% if has_rules %}
|
||||
<div class="mb-1"><strong>Rules</strong></div>
|
||||
<ul class="space-y-1">
|
||||
{% for r in s.rules %}
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
{% set sev = r.severity|lower %}
|
||||
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
|
||||
{% if sev == 'high' %} 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 %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<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 %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small class="text-gray-400"> — {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if has_rules %}
|
||||
<strong>Rules</strong>
|
||||
<ul>
|
||||
{% for r in s.rules %}
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span>
|
||||
{% if has_heur %}
|
||||
<div class="mt-2 mb-1"><strong>Heuristics</strong></div>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
{% for h in s.heuristics %}
|
||||
<li>{{ h }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if not has_rules and not has_heur %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Content Snippet / Analyze external script -->
|
||||
<td class="py-2 pr-4" data-role="snippet-cell">
|
||||
{% if s.content_snippet %}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">
|
||||
View snippet ({{ s.content_snippet|length }} chars)
|
||||
</summary>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ s.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
{% if s.type == 'external' and s.src %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-analyze-snippet inline-flex items-center gap-2 rounded-lg px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs"
|
||||
data-url="{{ s.src }}"
|
||||
data-job="{{ uuid }}">
|
||||
Analyze external script
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small>— {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if has_heur %}
|
||||
<strong>Heuristics</strong>
|
||||
<ul>
|
||||
{% for h in s.heuristics %}
|
||||
<li>{{ h }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No suspicious scripts detected.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if not has_rules and not has_heur %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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>
|
||||
|
||||
<!-- Content Snippet (reused for Analyze button / dynamic snippet) -->
|
||||
<td data-role="snippet-cell">
|
||||
{% if s.content_snippet %}
|
||||
<details>
|
||||
<summary>View snippet ({{ s.content_snippet|length }} chars)</summary>
|
||||
<pre class="code">{{ s.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
{% if s.type == 'external' and s.src %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary btn-analyze-snippet"
|
||||
data-url="{{ s.src }}"
|
||||
data-job="{{ uuid }}">Analyze external script</button>
|
||||
{% else %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Screenshot -->
|
||||
<section id="screenshot" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Screenshot</h2>
|
||||
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}"
|
||||
alt="Screenshot"
|
||||
class="w-full rounded-lg border border-gray-800">
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
<!-- Source -->
|
||||
<section id="source" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Source</h2>
|
||||
<p>
|
||||
<a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}"
|
||||
target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700">
|
||||
View Source
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<p>No suspicious scripts detected.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div class="card" id="screenshot">
|
||||
<h2>Screenshot</h2>
|
||||
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}" alt="Screenshot">
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="card" id="source">
|
||||
<h2>Source</h2>
|
||||
<p><a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}" target="_blank">View Source</a></p>
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block page_js %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
/**
|
||||
* From an absolute artifact path like:
|
||||
* /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.
|
||||
* Helpers to parse artifact path and build viewer URL
|
||||
*/
|
||||
function parseArtifactPath(artifactPath) {
|
||||
if (!artifactPath) return { uuid: null, rel: null };
|
||||
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(re);
|
||||
const m = norm.match(/\/([0-9a-fA-F-]{36})\/(.+)$/);
|
||||
if (!m) return { uuid: null, rel: null };
|
||||
return { uuid: m[1], rel: m[2] };
|
||||
}
|
||||
|
||||
/** Build /view/artifact/<uuid>/<path:filename> */
|
||||
function buildViewerUrlFromAbsPath(artifactPath) {
|
||||
const { uuid, rel } = parseArtifactPath(artifactPath);
|
||||
if (!uuid || !rel) return '#';
|
||||
@@ -354,7 +396,21 @@ function buildViewerUrlFromAbsPath(artifactPath) {
|
||||
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');
|
||||
if (!btn) return;
|
||||
|
||||
@@ -365,125 +421,93 @@ document.addEventListener('click', function (e) {
|
||||
const url = btn.dataset.url;
|
||||
const job = btn.dataset.job;
|
||||
|
||||
// Replace button with a lightweight loading text
|
||||
// Replace button with lightweight loading text
|
||||
const loading = document.createElement('span');
|
||||
loading.className = 'text-dim';
|
||||
loading.className = 'text-gray-400';
|
||||
loading.textContent = 'Analyzing…';
|
||||
btn.replaceWith(loading);
|
||||
|
||||
fetch('/api/analyze_script', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, // include CSRF header if applicable
|
||||
body: JSON.stringify({ job_id: job, url: url})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
try {
|
||||
const r = await fetch('/api/analyze_script', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ job_id: job, url: url })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.ok) {
|
||||
loading.textContent = 'Error: ' + (data.error || 'Unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build the snippet details element ---
|
||||
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);
|
||||
|
||||
|
||||
// Build details with snippet
|
||||
const details = document.createElement('details');
|
||||
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');
|
||||
pre.className = 'code';
|
||||
pre.textContent = snippetText; // textContent preserves literal code safely
|
||||
pre.className = 'bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1';
|
||||
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(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);
|
||||
|
||||
// Replace "Analyzing…" with the new details block
|
||||
loading.replaceWith(details);
|
||||
|
||||
// --- Update the Matches cell with rule findings ---
|
||||
// Update Matches cell with rule findings
|
||||
if (matchesCell) {
|
||||
if (Array.isArray(data.findings) && data.findings.length) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = 'Rules';
|
||||
const strong = document.createElement('div');
|
||||
strong.className = 'mb-1';
|
||||
strong.innerHTML = '<strong>Rules</strong>';
|
||||
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 name = f.name || 'Rule';
|
||||
const desc = f.description ? ' — ' + f.description : '';
|
||||
li.textContent = name + desc;
|
||||
|
||||
// Optional badges for severity/tags if present
|
||||
li.textContent = (f.name || 'Rule') + (f.description ? ' — ' + f.description : '');
|
||||
if (f.severity) {
|
||||
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);
|
||||
li.appendChild(document.createTextNode(' '));
|
||||
li.appendChild(badge);
|
||||
}
|
||||
if (Array.isArray(f.tags)) {
|
||||
f.tags.forEach(function (t) {
|
||||
f.tags.forEach((t) => {
|
||||
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.textContent = t;
|
||||
li.appendChild(document.createTextNode(' '));
|
||||
li.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
frag.appendChild(strong);
|
||||
frag.appendChild(ul);
|
||||
|
||||
// Replace placeholder N/A or existing heuristics-only content
|
||||
matchesCell.innerHTML = '';
|
||||
matchesCell.appendChild(frag);
|
||||
} else {
|
||||
matchesCell.innerHTML = '<span class="text-dim">No rule matches.</span>';
|
||||
matchesCell.innerHTML = '<span class="text-gray-500">No rule matches.</span>';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
} catch (err) {
|
||||
loading.textContent = 'Request failed: ' + err;
|
||||
});
|
||||
}
|
||||
});
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user