Skip to content

Commit fa436fb

Browse files
ralyodioclaude
andcommitted
feat(security): add Exposed Services (port-drift) scans tab
Adds a per-project Security tab (§12) in the app UI: open-findings list, recent-scan history, and a 'Run scan' button that queues a port_scans row for the prober to pick up. New port_scans/port_findings tables (owner-scoped RLS). Page degrades to empty states if the migration isn't applied yet, so the tab renders immediately on deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9afa500 commit fa436fb

5 files changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Per-project "Security" tab — exposed-services / port-drift scans
2+
// (docs/uptime-monitoring-prd.md §12). Shows open findings (ports open to the
3+
// public internet that aren't in the accepted baseline) and recent scan history.
4+
// The actual scanning runs on the off-Railway prober droplet; this page requests
5+
// scans and displays their results. Degrades to an empty state if the migration
6+
// isn't applied yet.
7+
8+
import { notFound } from "next/navigation";
9+
import { createClient } from "@/lib/supabase/server";
10+
import { RequestScanButton } from "./request-scan-button";
11+
12+
interface FindingRow {
13+
id: string;
14+
port: number;
15+
service: string | null;
16+
severity: "low" | "medium" | "high";
17+
state: "open" | "acknowledged" | "baseline" | "muted";
18+
first_seen_at: string;
19+
}
20+
21+
interface ScanRow {
22+
id: string;
23+
host: string;
24+
status: "queued" | "running" | "done" | "failed";
25+
open_ports: number[] | null;
26+
created_at: string;
27+
completed_at: string | null;
28+
}
29+
30+
const SEV_COLOR: Record<FindingRow["severity"], string> = {
31+
high: "var(--color-fail)",
32+
medium: "var(--color-warn)",
33+
low: "var(--color-muted)",
34+
};
35+
36+
function fmt(ts: string): string {
37+
return new Date(ts).toLocaleString();
38+
}
39+
40+
export default async function ProjectSecurityPage({
41+
params,
42+
}: {
43+
params: Promise<{ id: string }>;
44+
}) {
45+
const { id } = await params;
46+
const supabase = await createClient();
47+
48+
const { data: project } = await supabase
49+
.from("projects")
50+
.select("id, url")
51+
.eq("id", id)
52+
.maybeSingle();
53+
if (!project) notFound();
54+
55+
let host = project.url;
56+
try {
57+
host = new URL(project.url).host;
58+
} catch {
59+
/* keep raw url */
60+
}
61+
62+
// Both queries degrade to [] if the migration isn't applied yet.
63+
const { data: findingsData } = await supabase
64+
.from("port_findings")
65+
.select("id, port, service, severity, state, first_seen_at")
66+
.eq("project_id", id)
67+
.neq("state", "muted")
68+
.order("severity", { ascending: false });
69+
const openFindings = (findingsData ?? []).filter(
70+
(f: FindingRow) => f.state === "open",
71+
);
72+
73+
const { data: scansData } = await supabase
74+
.from("port_scans")
75+
.select("id, host, status, open_ports, created_at, completed_at")
76+
.eq("project_id", id)
77+
.order("created_at", { ascending: false })
78+
.limit(10);
79+
const scans = (scansData ?? []) as ScanRow[];
80+
81+
return (
82+
<div className="flex flex-col gap-6">
83+
<div className="flex flex-wrap items-start justify-between gap-3">
84+
<div>
85+
<h2 className="text-lg font-semibold">Exposed Services</h2>
86+
<p className="max-w-2xl text-sm text-[var(--color-muted)]">
87+
Detects ports open to the public internet on{" "}
88+
<span className="font-mono">{host}</span> that aren&apos;t in your
89+
accepted baseline — e.g. a database or cache accidentally exposed.
90+
Scans run only against your own verified host.
91+
</p>
92+
</div>
93+
<RequestScanButton projectId={id} />
94+
</div>
95+
96+
{/* Open findings */}
97+
<section className="flex flex-col gap-2">
98+
<h3 className="text-sm font-semibold text-[var(--color-muted)]">
99+
Open findings
100+
</h3>
101+
{openFindings.length === 0 ? (
102+
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-sm text-[var(--color-muted)]">
103+
No open findings. Run a scan to establish a baseline of the ports
104+
that are currently open, then we&apos;ll alert you when a{" "}
105+
<em>new</em> one appears.
106+
</div>
107+
) : (
108+
<ul className="flex flex-col gap-2">
109+
{openFindings.map((f: FindingRow) => (
110+
<li
111+
key={f.id}
112+
className="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3"
113+
>
114+
<div className="flex items-center gap-3">
115+
<span
116+
className="rounded px-2 py-0.5 text-xs font-semibold uppercase"
117+
style={{ color: SEV_COLOR[f.severity] }}
118+
>
119+
{f.severity}
120+
</span>
121+
<span className="font-mono text-sm">
122+
{host}:{f.port}
123+
{f.service ? (
124+
<span className="text-[var(--color-muted)]">
125+
{" "}
126+
({f.service})
127+
</span>
128+
) : null}
129+
</span>
130+
</div>
131+
<span className="text-xs text-[var(--color-muted)]">
132+
first seen {fmt(f.first_seen_at)}
133+
</span>
134+
</li>
135+
))}
136+
</ul>
137+
)}
138+
</section>
139+
140+
{/* Scan history */}
141+
<section className="flex flex-col gap-2">
142+
<h3 className="text-sm font-semibold text-[var(--color-muted)]">
143+
Recent scans
144+
</h3>
145+
{scans.length === 0 ? (
146+
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-sm text-[var(--color-muted)]">
147+
No scans yet.
148+
</div>
149+
) : (
150+
<div className="overflow-x-auto rounded-lg border border-[var(--color-border)]">
151+
<table className="w-full text-sm">
152+
<thead className="bg-[var(--color-card)] text-left text-[var(--color-muted)]">
153+
<tr>
154+
<th className="px-4 py-2 font-medium">Requested</th>
155+
<th className="px-4 py-2 font-medium">Status</th>
156+
<th className="px-4 py-2 font-medium">Open ports</th>
157+
</tr>
158+
</thead>
159+
<tbody>
160+
{scans.map((s) => (
161+
<tr
162+
key={s.id}
163+
className="border-t border-[var(--color-border)]"
164+
>
165+
<td className="px-4 py-2">{fmt(s.created_at)}</td>
166+
<td className="px-4 py-2">{s.status}</td>
167+
<td className="px-4 py-2 font-mono">
168+
{s.open_ports && s.open_ports.length > 0
169+
? s.open_ports.join(", ")
170+
: s.status === "done"
171+
? "none"
172+
: "—"}
173+
</td>
174+
</tr>
175+
))}
176+
</tbody>
177+
</table>
178+
</div>
179+
)}
180+
</section>
181+
</div>
182+
);
183+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { requestPortScan } from "@/app/actions/portScans";
6+
7+
export function RequestScanButton({ projectId }: { projectId: string }) {
8+
const router = useRouter();
9+
const [pending, startTransition] = useTransition();
10+
const [error, setError] = useState<string | null>(null);
11+
12+
function run() {
13+
startTransition(async () => {
14+
setError(null);
15+
const res = await requestPortScan(projectId);
16+
if (!res.ok) {
17+
setError(res.error ?? "Failed to queue scan.");
18+
return;
19+
}
20+
router.refresh();
21+
});
22+
}
23+
24+
return (
25+
<div className="flex flex-col items-start gap-1">
26+
<button
27+
onClick={run}
28+
disabled={pending}
29+
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60"
30+
>
31+
{pending ? "Queuing…" : "Run scan"}
32+
</button>
33+
{error && <p className="text-xs text-red-500">{error}</p>}
34+
</div>
35+
);
36+
}

‎app/actions/portScans.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { createClient } from "@/lib/supabase/server";
5+
6+
// Queue an exposed-services scan for a project's host (docs §12). The off-Railway
7+
// prober picks up 'queued' rows, runs the scan, and writes results back. Here we
8+
// only record the request — RLS ensures the caller owns the project.
9+
export async function requestPortScan(
10+
projectId: string,
11+
): Promise<{ ok: boolean; error?: string }> {
12+
const supabase = await createClient();
13+
const {
14+
data: { user },
15+
} = await supabase.auth.getUser();
16+
if (!user) return { ok: false, error: "Not signed in." };
17+
18+
const { data: project } = await supabase
19+
.from("projects")
20+
.select("id, url")
21+
.eq("id", projectId)
22+
.maybeSingle();
23+
if (!project) return { ok: false, error: "Project not found." };
24+
25+
let host: string;
26+
try {
27+
host = new URL(project.url).host;
28+
} catch {
29+
return { ok: false, error: "Project URL is not a valid host." };
30+
}
31+
32+
const { error } = await supabase.from("port_scans").insert({
33+
project_id: projectId,
34+
host,
35+
status: "queued",
36+
requested_by: user.id,
37+
});
38+
if (error) {
39+
// Most likely the migration hasn't been applied yet in this environment.
40+
return { ok: false, error: `Could not queue scan: ${error.message}` };
41+
}
42+
43+
revalidatePath(`/projects/${projectId}/security`);
44+
return { ok: true };
45+
}

‎components/project-tabs-nav.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ const TABS: ProjectTab[] = [
4848
href: (id) => `/projects/${id}/stats`,
4949
matches: (p, id) => p.startsWith(`/projects/${id}/stats`),
5050
},
51+
{
52+
id: "security",
53+
label: "Security",
54+
href: (id) => `/projects/${id}/security`,
55+
matches: (p, id) => p.startsWith(`/projects/${id}/security`),
56+
},
5157
{
5258
id: "autoblog",
5359
label: "Autoblog",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
-- Exposed-services / port-drift scans (docs/uptime-monitoring-prd.md §12).
2+
-- A scan records a requested/completed port scan of a project's host; findings
3+
-- are the individual open ports, diffed against an accepted baseline. The actual
4+
-- scanning runs on the off-Railway prober droplet; these tables are the web-app
5+
-- surface (request a scan, view history + findings).
6+
7+
create table if not exists public.port_scans (
8+
id uuid primary key default gen_random_uuid(),
9+
project_id uuid not null references public.projects(id) on delete cascade,
10+
host text not null,
11+
status text not null default 'queued'
12+
check (status in ('queued', 'running', 'done', 'failed')),
13+
open_ports int[] not null default '{}',
14+
error text,
15+
requested_by uuid references public.profiles(id) on delete set null,
16+
created_at timestamptz not null default now(),
17+
completed_at timestamptz
18+
);
19+
20+
create index if not exists port_scans_project_idx
21+
on public.port_scans(project_id, created_at desc);
22+
23+
create table if not exists public.port_findings (
24+
id uuid primary key default gen_random_uuid(),
25+
project_id uuid not null references public.projects(id) on delete cascade,
26+
scan_id uuid references public.port_scans(id) on delete set null,
27+
port int not null,
28+
service text,
29+
severity text not null default 'medium'
30+
check (severity in ('low', 'medium', 'high')),
31+
state text not null default 'open'
32+
check (state in ('open', 'acknowledged', 'baseline', 'muted')),
33+
first_seen_at timestamptz not null default now(),
34+
resolved_at timestamptz,
35+
unique (project_id, port)
36+
);
37+
38+
create index if not exists port_findings_project_idx
39+
on public.port_findings(project_id, state);
40+
41+
alter table public.port_scans enable row level security;
42+
alter table public.port_findings enable row level security;
43+
44+
-- Owner-scoped access, matching public.project_repos.
45+
create policy "port_scans owner select" on public.port_scans for select
46+
using (project_id in (select id from public.projects where owner_id = auth.uid()));
47+
create policy "port_scans owner insert" on public.port_scans for insert
48+
with check (project_id in (select id from public.projects where owner_id = auth.uid()));
49+
50+
create policy "port_findings owner select" on public.port_findings for select
51+
using (project_id in (select id from public.projects where owner_id = auth.uid()));
52+
create policy "port_findings owner update" on public.port_findings for update
53+
using (project_id in (select id from public.projects where owner_id = auth.uid()));

0 commit comments

Comments
 (0)