Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions app/(app)/projects/[id]/security/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Per-project "Security" tab — exposed-services / port-drift scans
// (docs/uptime-monitoring-prd.md §12). Shows open findings (ports open to the
// public internet that aren't in the accepted baseline) and recent scan history.
// The actual scanning runs on the off-Railway prober droplet; this page requests
// scans and displays their results. Degrades to an empty state if the migration
// isn't applied yet.

import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { RequestScanButton } from "./request-scan-button";

interface FindingRow {
id: string;
port: number;
service: string | null;
severity: "low" | "medium" | "high";
state: "open" | "acknowledged" | "baseline" | "muted";
first_seen_at: string;
}

interface ScanRow {
id: string;
host: string;
status: "queued" | "running" | "done" | "failed";
open_ports: number[] | null;
created_at: string;
completed_at: string | null;
}

const SEV_COLOR: Record<FindingRow["severity"], string> = {
high: "var(--color-fail)",
medium: "var(--color-warn)",
low: "var(--color-muted)",
};

function fmt(ts: string): string {
return new Date(ts).toLocaleString();
}

export default async function ProjectSecurityPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const supabase = await createClient();

const { data: project } = await supabase
.from("projects")
.select("id, url")
.eq("id", id)
.maybeSingle();
if (!project) notFound();

let host = project.url;
try {
host = new URL(project.url).host;
} catch {
/* keep raw url */
}

// Both queries degrade to [] if the migration isn't applied yet.
const { data: findingsData } = await supabase
.from("port_findings")
.select("id, port, service, severity, state, first_seen_at")
.eq("project_id", id)
.neq("state", "muted")
.order("severity", { ascending: false });
const openFindings = (findingsData ?? []).filter(
(f: FindingRow) => f.state === "open",
);

const { data: scansData } = await supabase
.from("port_scans")
.select("id, host, status, open_ports, created_at, completed_at")
.eq("project_id", id)
.order("created_at", { ascending: false })
.limit(10);
const scans = (scansData ?? []) as ScanRow[];

return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Exposed Services</h2>
<p className="max-w-2xl text-sm text-[var(--color-muted)]">
Detects ports open to the public internet on{" "}
<span className="font-mono">{host}</span> that aren&apos;t in your
accepted baseline — e.g. a database or cache accidentally exposed.
Scans run only against your own verified host.
</p>
</div>
<RequestScanButton projectId={id} />
</div>

{/* Open findings */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--color-muted)]">
Open findings
</h3>
{openFindings.length === 0 ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-sm text-[var(--color-muted)]">
No open findings. Run a scan to establish a baseline of the ports
that are currently open, then we&apos;ll alert you when a{" "}
<em>new</em> one appears.
</div>
) : (
<ul className="flex flex-col gap-2">
{openFindings.map((f: FindingRow) => (
<li
key={f.id}
className="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3"
>
<div className="flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-semibold uppercase"
style={{ color: SEV_COLOR[f.severity] }}
>
{f.severity}
</span>
<span className="font-mono text-sm">
{host}:{f.port}
{f.service ? (
<span className="text-[var(--color-muted)]">
{" "}
({f.service})
</span>
) : null}
</span>
</div>
<span className="text-xs text-[var(--color-muted)]">
first seen {fmt(f.first_seen_at)}
</span>
</li>
))}
</ul>
)}
</section>

{/* Scan history */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--color-muted)]">
Recent scans
</h3>
{scans.length === 0 ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-sm text-[var(--color-muted)]">
No scans yet.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--color-border)]">
<table className="w-full text-sm">
<thead className="bg-[var(--color-card)] text-left text-[var(--color-muted)]">
<tr>
<th className="px-4 py-2 font-medium">Requested</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Open ports</th>
</tr>
</thead>
<tbody>
{scans.map((s) => (
<tr
key={s.id}
className="border-t border-[var(--color-border)]"
>
<td className="px-4 py-2">{fmt(s.created_at)}</td>
<td className="px-4 py-2">{s.status}</td>
<td className="px-4 py-2 font-mono">
{s.open_ports && s.open_ports.length > 0
? s.open_ports.join(", ")
: s.status === "done"
? "none"
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
36 changes: 36 additions & 0 deletions app/(app)/projects/[id]/security/request-scan-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { requestPortScan } from "@/app/actions/portScans";

export function RequestScanButton({ projectId }: { projectId: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);

function run() {
startTransition(async () => {
setError(null);
const res = await requestPortScan(projectId);
if (!res.ok) {
setError(res.error ?? "Failed to queue scan.");
return;
}
router.refresh();
});
}

return (
<div className="flex flex-col items-start gap-1">
<button
onClick={run}
disabled={pending}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60"
>
{pending ? "Queuing…" : "Run scan"}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
45 changes: 45 additions & 0 deletions app/actions/portScans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use server";

import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";

// Queue an exposed-services scan for a project's host (docs §12). The off-Railway
// prober picks up 'queued' rows, runs the scan, and writes results back. Here we
// only record the request — RLS ensures the caller owns the project.
export async function requestPortScan(
projectId: string,
): Promise<{ ok: boolean; error?: string }> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Not signed in." };

const { data: project } = await supabase
.from("projects")
.select("id, url")
.eq("id", projectId)
.maybeSingle();
if (!project) return { ok: false, error: "Project not found." };

let host: string;
try {
host = new URL(project.url).host;
} catch {
return { ok: false, error: "Project URL is not a valid host." };
}

const { error } = await supabase.from("port_scans").insert({
project_id: projectId,
host,
status: "queued",
requested_by: user.id,
});
if (error) {
// Most likely the migration hasn't been applied yet in this environment.
return { ok: false, error: `Could not queue scan: ${error.message}` };
}

revalidatePath(`/projects/${projectId}/security`);
return { ok: true };
}
6 changes: 6 additions & 0 deletions components/project-tabs-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const TABS: ProjectTab[] = [
href: (id) => `/projects/${id}/stats`,
matches: (p, id) => p.startsWith(`/projects/${id}/stats`),
},
{
id: "security",
label: "Security",
href: (id) => `/projects/${id}/security`,
matches: (p, id) => p.startsWith(`/projects/${id}/security`),
},
{
id: "autoblog",
label: "Autoblog",
Expand Down
53 changes: 53 additions & 0 deletions supabase/migrations/20260705170000_port_scans.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- Exposed-services / port-drift scans (docs/uptime-monitoring-prd.md §12).
-- A scan records a requested/completed port scan of a project's host; findings
-- are the individual open ports, diffed against an accepted baseline. The actual
-- scanning runs on the off-Railway prober droplet; these tables are the web-app
-- surface (request a scan, view history + findings).

create table if not exists public.port_scans (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
host text not null,
status text not null default 'queued'
check (status in ('queued', 'running', 'done', 'failed')),
open_ports int[] not null default '{}',
error text,
requested_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
completed_at timestamptz
);

create index if not exists port_scans_project_idx
on public.port_scans(project_id, created_at desc);

create table if not exists public.port_findings (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
scan_id uuid references public.port_scans(id) on delete set null,
port int not null,
service text,
severity text not null default 'medium'
check (severity in ('low', 'medium', 'high')),
state text not null default 'open'
check (state in ('open', 'acknowledged', 'baseline', 'muted')),
first_seen_at timestamptz not null default now(),
resolved_at timestamptz,
unique (project_id, port)
);

create index if not exists port_findings_project_idx
on public.port_findings(project_id, state);

alter table public.port_scans enable row level security;
alter table public.port_findings enable row level security;

-- Owner-scoped access, matching public.project_repos.
create policy "port_scans owner select" on public.port_scans for select
using (project_id in (select id from public.projects where owner_id = auth.uid()));
create policy "port_scans owner insert" on public.port_scans for insert
with check (project_id in (select id from public.projects where owner_id = auth.uid()));

create policy "port_findings owner select" on public.port_findings for select
using (project_id in (select id from public.projects where owner_id = auth.uid()));
create policy "port_findings owner update" on public.port_findings for update
using (project_id in (select id from public.projects where owner_id = auth.uid()));
Loading