blacklist: add domain reputation check via checker-blacklist
Some checks reported errors
continuous-integration/drone/push Build was killed

Integrates the checker-blacklist module behind a new POST /blacklist/domain
endpoint that aggregates reputation/blocklist sources for a given domain,
plus a SvelteKit UI under /blacklist/domain mirroring the existing IP
blacklist flow. Per-source credentials (VirusTotal, Safe Browsing) are
exposed as CLI flags; free sources run unconditionally.

Closes: #96
This commit is contained in:
nemunaire 2026-06-04 18:38:45 +09:00
commit f14209d4fa
13 changed files with 655 additions and 21 deletions

View file

@ -0,0 +1,260 @@
<script lang="ts">
import type { DomainBlacklistSourceResult } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
results: DomainBlacklistSourceResult[];
}
let { results }: Props = $props();
// Paid sources that show a "configure API key" hint when disabled.
const paidSourceIds = new Set(["virustotal", "safebrowsing"]);
type Bucket = "listed" | "errored" | "clean" | "disabled";
function classify(r: DomainBlacklistSourceResult): Bucket {
if (!r.enabled) return "disabled";
if (r.error) return "errored";
if (r.listed) return "listed";
return "clean";
}
function severityRank(sev: string | undefined): number {
switch (sev) {
case "crit":
return 0;
case "warn":
return 1;
case "info":
return 2;
default:
return 3;
}
}
function bucketRank(b: Bucket): number {
switch (b) {
case "listed":
return 0;
case "errored":
return 1;
case "clean":
return 2;
case "disabled":
return 3;
}
}
let sorted = $derived(
[...results].sort((a, b) => {
const ba = classify(a);
const bb = classify(b);
if (ba !== bb) return bucketRank(ba) - bucketRank(bb);
if (ba === "listed") {
const r = severityRank(a.severity) - severityRank(b.severity);
if (r !== 0) return r;
}
return a.source_name.localeCompare(b.source_name);
}),
);
function statusLabel(r: DomainBlacklistSourceResult): string {
if (!r.enabled) return "Disabled";
if (r.error) return "Error";
if (r.listed) {
if (r.severity && r.severity !== "ok") {
return `Listed (${r.severity})`;
}
return "Listed";
}
return "Clean";
}
function statusBadgeClass(r: DomainBlacklistSourceResult): string {
if (!r.enabled) return "bg-secondary";
if (r.error) return "bg-dark";
if (r.listed) {
switch (r.severity) {
case "crit":
return "bg-danger";
case "warn":
return "bg-warning text-dark";
case "info":
return "bg-info text-dark";
default:
return "bg-danger";
}
}
return "bg-success";
}
let openRows = $state(new Set<string>());
function rowKey(r: DomainBlacklistSourceResult): string {
return `${r.source_id}::${r.subject ?? ""}`;
}
function toggle(key: string) {
const next = new Set(openRows);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
openRows = next;
}
function hasDetails(r: DomainBlacklistSourceResult): boolean {
return (r.reasons?.length ?? 0) > 1 || (r.evidence?.length ?? 0) > 0;
}
function firstReason(r: DomainBlacklistSourceResult): string {
if (r.error) return r.error;
if (r.reasons && r.reasons.length > 0) return r.reasons[0];
if (!r.enabled && paidSourceIds.has(r.source_id)) {
return "API key not configured by the operator";
}
if (!r.enabled) return "Source disabled";
return "—";
}
</script>
<div class="card shadow-sm mt-4" id="domain-blacklist-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0">
<i class="bi bi-shield-shaded me-2"></i>
Source Verdicts
</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col" class="text-nowrap">Status</th>
<th scope="col">Source</th>
<th scope="col">Detail</th>
<th scope="col" class="text-end text-nowrap">Links</th>
</tr>
</thead>
<tbody>
{#each sorted as r (rowKey(r))}
{@const key = rowKey(r)}
{@const open = openRows.has(key)}
{@const expandable = hasDetails(r)}
<tr class:text-muted={!r.enabled}>
<td class="text-nowrap">
<span class="badge {statusBadgeClass(r)}">{statusLabel(r)}</span>
</td>
<td>
<div class="fw-semibold">{r.source_name}</div>
<small class="text-muted">
<code>{r.source_id}</code>
{#if r.subject}
· <code>{r.subject}</code>
{/if}
</small>
</td>
<td>
<span class="detail-text">{firstReason(r)}</span>
{#if expandable}
<button
type="button"
class="btn btn-link btn-sm p-0 ms-1 align-baseline"
onclick={() => toggle(key)}
aria-expanded={open}
>
{open ? "Hide details" : "Show details"}
</button>
{/if}
</td>
<td class="text-end text-nowrap">
{#if r.lookup_url}
<a
href={r.lookup_url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-outline-secondary"
title="Open lookup page"
aria-label="Open lookup page"
>
<i class="bi bi-box-arrow-up-right"></i>
</a>
{/if}
</td>
</tr>
{#if expandable && open}
<tr class="detail-row">
<td></td>
<td colspan="3">
{#if r.reasons && r.reasons.length > 0}
<ul class="small mb-2">
{#each r.reasons as reason}
<li>{reason}</li>
{/each}
</ul>
{/if}
{#if r.evidence && r.evidence.length > 0}
<table
class="table table-sm table-bordered mb-0 evidence-table"
>
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Value</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{#each r.evidence as ev}
<tr>
<td class="text-nowrap">{ev.label}</td>
<td>
<code class="small">{ev.value}</code>
</td>
<td class="text-nowrap">
{#if ev.status}
<span
class="badge bg-light text-dark"
>{ev.status}</span
>
{:else}
<span class="text-muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if r.reference}
<p class="small text-muted mt-2 mb-0">
Reference: {r.reference}
</p>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
</div>
<style>
.detail-text {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
.detail-row td {
background-color: rgba(0, 0, 0, 0.025);
}
.evidence-table code {
word-break: break-all;
}
</style>

View file

@ -6,6 +6,7 @@ export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as DomainBlacklistCard } from "./DomainBlacklistCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as EmailPathCard } from "./EmailPathCard.svelte";
export { default as ErrorDisplay } from "./ErrorDisplay.svelte";

View file

@ -161,6 +161,10 @@
<i class="bi bi-envelope-plus me-1"></i>
Send Test Email
</a>
<a href="/domain" class="btn btn-sm btn-outline-primary ms-1">
<i class="bi bi-shield-shaded me-1"></i>
Check a Domain
</a>
</div>
</div>
</div>

View file

@ -4,7 +4,7 @@
import { testDomain } from "$lib/api";
import type { DomainTestResponse } from "$lib/api/types.gen";
import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components";
import { DnsRecordsCard, DomainBlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
import { theme } from "$lib/stores/theme";
let domain = $derived(page.params.domain);
@ -12,6 +12,44 @@
let error = $state<string | null>(null);
let result = $state<DomainTestResponse | null>(null);
let blacklist = $derived(result?.blacklist ?? null);
let blacklistSummary = $derived.by(() => {
if (!blacklist) return null;
const enabled = blacklist.results.filter((r) => r.enabled);
const disabled = blacklist.results.length - enabled.length;
const errored = enabled.filter((r) => r.error).length;
const listed = enabled.filter((r) => r.listed);
const critical = listed.filter((r) => r.severity === "crit").length;
return {
total: blacklist.results.length,
enabled: enabled.length,
disabled,
errored,
listed: listed.length,
critical,
};
});
type Verdict = "danger" | "warn" | "inconclusive" | "ok";
let blacklistVerdict = $derived.by<Verdict | null>(() => {
const s = blacklistSummary;
if (!s) return null;
if (s.critical > 0) return "danger";
if (s.listed > 0) return "warn";
if (s.enabled > 0 && s.errored === s.enabled) return "inconclusive";
return "ok";
});
function formatCollectedAt(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
async function analyzeDomain() {
loading = true;
error = null;
@ -74,7 +112,9 @@
<span class="visually-hidden">Loading...</span>
</div>
<h3 class="h5">Analyzing {domain}...</h3>
<p class="text-muted mb-0">Checking DNS records and configuration</p>
<p class="text-muted mb-0">
Checking DNS records, configuration and domain reputation
</p>
</div>
</div>
{:else if error}
@ -116,14 +156,31 @@
<p class="text-muted mb-0">Domain Configuration Score</p>
{/if}
</div>
<div class="offset-md-3 col-md-3 text-center">
<div class="col-md-6">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
class="d-flex justify-content-md-end justify-content-center gap-3"
>
<GradeDisplay score={result.score} grade={result.grade} />
<small class="text-muted d-block">DNS</small>
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
score={result.score}
grade={result.grade}
/>
<small class="text-muted d-block">DNS</small>
</div>
{#if blacklist}
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay grade={blacklist.grade ?? "?"} />
<small class="text-muted d-block">Reputation</small>
</div>
{/if}
</div>
</div>
</div>
@ -144,6 +201,119 @@
domainOnly={true}
/>
<!-- Domain Reputation / Blacklist -->
{#if blacklist && blacklistSummary}
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-7 text-center text-md-start mb-3 mb-md-0">
<h3 class="h5 mb-2">
<i class="bi bi-shield-shaded me-2"></i>
Domain Reputation
</h3>
{#if blacklist.registered_domain && blacklist.registered_domain !== result.domain}
<p class="text-muted small mb-2">
Registered domain:
<code>{blacklist.registered_domain}</code>
</p>
{/if}
{#if blacklistVerdict === "danger"}
<div class="alert alert-danger mb-0 d-inline-block">
<i class="bi bi-exclamation-octagon me-2"></i>
<strong
>Listed on {blacklistSummary.critical} high-severity
source{blacklistSummary.critical > 1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
This domain is reported by sources flagged
<em>critical</em>. Take action to delist.
</p>
</div>
{:else if blacklistVerdict === "warn"}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong
>Listed on {blacklistSummary.listed} source{blacklistSummary.listed >
1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
Listed without critical severity — review the
source verdicts below.
</p>
</div>
{:else if blacklistVerdict === "inconclusive"}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-question-octagon me-2"></i>
<strong>Inconclusive</strong>
<p class="mb-0 mt-1 small">
All enabled sources returned errors. Try again
later.
</p>
</div>
{:else}
<div class="alert alert-success mb-0 d-inline-block">
<i class="bi bi-check-circle me-2"></i>
<strong>No source reports this domain</strong>
<p class="mb-0 mt-1 small">
Clean across all {blacklistSummary.enabled} enabled
source{blacklistSummary.enabled > 1 ? "s" : ""}.
</p>
</div>
{/if}
</div>
<div class="col-md-5 text-center">
<div
class="p-3 rounded summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<div class="d-flex justify-content-around mb-2">
<div>
<div class="h4 mb-0">
{blacklistSummary.enabled}
</div>
<small class="text-muted">Enabled</small>
</div>
<div>
<div class="h4 mb-0 text-danger">
{blacklistSummary.listed}
</div>
<small class="text-muted">Listed</small>
</div>
<div>
<div class="h4 mb-0 text-secondary">
{blacklistSummary.disabled}
</div>
<small class="text-muted">Disabled</small>
</div>
</div>
{#if blacklistSummary.errored > 0}
<small class="text-muted d-block">
{blacklistSummary.errored} source{blacklistSummary.errored >
1
? "s"
: ""} errored
</small>
{/if}
<small class="text-muted d-block">
Collected {formatCollectedAt(
blacklist.collected_at,
)}
</small>
</div>
</div>
</div>
</div>
</div>
<DomainBlacklistCard results={blacklist.results} />
{/if}
<!-- Next Steps -->
<div class="card shadow-sm border-primary mt-4">
<div class="card-body">
@ -152,9 +322,9 @@
Want Complete Email Analysis?
</h3>
<p class="mb-3">
This domain-only test checks DNS configuration. For comprehensive
deliverability testing including DKIM verification, content
analysis, spam scoring, and blacklist checks:
This domain test checks DNS configuration and domain reputation. For
comprehensive deliverability testing including DKIM verification,
content analysis, spam scoring, and sending-IP blacklist checks:
</p>
<a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i>