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
28 changes: 28 additions & 0 deletions apps/web/src/app/.well-known/openthreat.json/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { loadOpenThreat } from "@/lib/open-threats";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* GET /.well-known/openthreat.json
*
* ThreatCrush's OpenThreat descriptor (logicsrc.com/openthreat): the threats
* it identified in the open, and nothing from any private repository or any
* customer. Public by definition; a reader verifies it by the origin it came
* from.
*/
export async function GET() {
try {
const descriptor = await loadOpenThreat();
return NextResponse.json(descriptor, {
headers: {
"cache-control": "public, max-age=300",
"access-control-allow-origin": "*",
},
});
} catch (err) {
console.error("[openthreat] could not build descriptor", (err as Error).message);
return NextResponse.json({ error: "descriptor unavailable" }, { status: 503 });
}
}
154 changes: 154 additions & 0 deletions apps/web/src/app/account/github-app-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/lib/auth-context";
import { authHeaders } from "@/lib/auth-client";

type Installation = {
installation_id: number;
account_login: string | null;
account_type: string | null;
repository_selection: string | null;
status: string;
announce: boolean;
installed_at: string | null;
};

const linkClass = "text-tc-green underline underline-offset-4 hover:opacity-80";

/**
* The GitHub App's per-installation settings. One switch today: whether the
* findings from the installation's public repositories are announced on
* /discovery. On by default.
*/
export default function GitHubAppSettings() {
const { signedIn } = useAuth();
const [installations, setInstallations] = useState<Installation[]>([]);
const [githubLogin, setGithubLogin] = useState<string | null>(null);
const [announceColumn, setAnnounceColumn] = useState(true);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);

const load = useCallback(async () => {
try {
const res = await fetch("/api/github/installations", { headers: authHeaders(), cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
setInstallations(data.installations ?? []);
setGithubLogin(data.githubLogin ?? null);
setAnnounceColumn(data.announceColumn !== false);
} catch {
// ignore
} finally {
setLoaded(true);
}
}, []);

useEffect(() => {
if (!signedIn) return;
load();
}, [signedIn, load]);

async function toggle(installation: Installation) {
setBusy(installation.installation_id);
setError(null);
try {
const res = await fetch("/api/github/installations", {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({
installation_id: installation.installation_id,
announce: !installation.announce,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Could not save");
return;
}
setInstallations((list) =>
list.map((i) => (i.installation_id === installation.installation_id ? data.installation : i))
);
} catch {
setError("Could not save");
} finally {
setBusy(null);
}
}

if (!signedIn || !loaded) return null;

return (
<section className="mx-auto max-w-3xl px-6 pb-16">
<div className="rounded-lg border border-tc-green/20 bg-black/40 p-6">
<p className="font-mono-green text-xs uppercase tracking-widest">// github app</p>
<h2 className="mt-2 text-xl font-semibold text-tc-text">Scan announcements</h2>
<p className="mt-2 text-sm text-tc-text-dim">
Findings the ThreatCrush GitHub App makes in your <em>public</em> repositories are
announced on{" "}
<Link href="/discovery" className={linkClass}>
Discovery
</Link>{" "}
and in the OpenThreat descriptor, on by default. Private repositories are never
announced. Turn it off per installation here.
</p>

{!githubLogin ? (
<p className="mt-4 text-sm text-tc-text-dim">
Sign in with GitHub to see the installations you manage. An email sign-in has no
GitHub login to match against.
</p>
) : installations.length === 0 ? (
<p className="mt-4 text-sm text-tc-text-dim">
No installations found for <span className="font-mono text-tc-green">@{githubLogin}</span>.
Installations show here when the app was installed on your account, or by you on an
organisation.
</p>
) : (
<ul className="mt-4 divide-y divide-tc-green/10">
{installations.map((i) => (
<li key={i.installation_id} className="flex items-center justify-between gap-4 py-3">
<div>
<div className="font-mono text-tc-text">
{i.account_login ?? `installation ${i.installation_id}`}
{i.account_type ? (
<span className="ml-2 text-xs text-tc-text-dim">{i.account_type}</span>
) : null}
</div>
<div className="text-xs text-tc-text-dim">
{i.repository_selection === "all" ? "all repositories" : "selected repositories"}
{" · "}
{i.status}
</div>
</div>
<button
type="button"
disabled={busy === i.installation_id || !announceColumn}
onClick={() => toggle(i)}
aria-pressed={i.announce}
className={`rounded border px-3 py-1.5 font-mono text-xs uppercase tracking-wide transition ${
i.announce
? "border-tc-green bg-tc-green/10 text-tc-green"
: "border-tc-text-dim/40 text-tc-text-dim"
} disabled:opacity-50`}
>
{i.announce ? "announce: on" : "announce: off"}
</button>
</li>
))}
</ul>
)}

{!announceColumn && (
<p className="mt-3 text-xs text-tc-text-dim">
The announce setting is not available on this deployment yet; every installation
announces its public findings until it is.
</p>
)}
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
</div>
</section>
);
}
8 changes: 7 additions & 1 deletion apps/web/src/app/account/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"use client";

import AccountContent from "./account-content";
import GitHubAppSettings from "./github-app-settings";

export default function AccountPage() {
return <AccountContent />;
return (
<>
<AccountContent />
<GitHubAppSettings />
</>
);
}
150 changes: 150 additions & 0 deletions apps/web/src/app/api/github/installations/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

type Result = { data: unknown; error: { code?: string; message: string } | null };

const { state, resetOpenThreatCache } = vi.hoisted(() => {
type Result = { data: unknown; error: { code?: string; message: string } | null };
/** A chainable stand-in for the supabase query builder that resolves to a scripted result. */
class FakeQuery {
calls: Array<[string, unknown[]]> = [];
constructor(private readonly result: Result, log: FakeQuery[]) {
log.push(this);
}
private chain(name: string, args: unknown[]) {
this.calls.push([name, args]);
return this;
}
select(...a: unknown[]) { return this.chain("select", a); }
or(...a: unknown[]) { return this.chain("or", a); }
eq(...a: unknown[]) { return this.chain("eq", a); }
update(...a: unknown[]) { return this.chain("update", a); }
maybeSingle() { return this.chain("maybeSingle", []); }
single() { return this.chain("single", []); }
then<T>(resolve: (r: Result) => T) { return Promise.resolve(this.result).then(resolve); }
}
const state: { user: unknown; results: Result[]; queries: FakeQuery[] } = {
user: null,
results: [],
queries: [],
};
const from = () => new FakeQuery(state.results.shift() ?? { data: null, error: null }, state.queries);
return { state: Object.assign(state, { from }), resetOpenThreatCache: vi.fn() };
});

vi.mock("@/lib/api-auth", () => ({
getAuthenticatedRequestUser: vi.fn(async () => state.user),
unauthorized: () => new Response(JSON.stringify({ error: "Not authenticated" }), { status: 401 }),
getAdminClient: () => ({ from: state.from }),
}));

vi.mock("@/lib/open-threats", () => ({ resetOpenThreatCache }));

import { GET, PATCH } from "../route";
import { managesInstallation } from "@/lib/github-announce";

const mine = { installation_id: 10, account_login: "ada", sender_login: "ada", account_type: "User", repository_selection: "all", status: "active", announce: true, installed_at: null };
const orgByMe = { installation_id: 11, account_login: "acme", sender_login: "Ada", account_type: "Organization", repository_selection: "selected", status: "active", announce: false, installed_at: null };
const theirs = { installation_id: 12, account_login: "bob", sender_login: "bob", account_type: "User", repository_selection: "all", status: "active", announce: true, installed_at: null };

function patch(body: unknown) {
return PATCH(
new NextRequest("https://threatcrush.com/api/github/installations", {
method: "PATCH",
body: JSON.stringify(body),
})
);
}

beforeEach(() => {
state.user = null;
state.results = [];
state.queries = [];
resetOpenThreatCache.mockClear();
});

describe("managesInstallation", () => {
it("matches the account or the installer, case-insensitively, and never without a login", () => {
expect(managesInstallation("ada", mine)).toBe(true);
expect(managesInstallation("ADA", orgByMe)).toBe(true);
expect(managesInstallation("ada", theirs)).toBe(false);
expect(managesInstallation(null, mine)).toBe(false);
});
});

describe("GET /api/github/installations", () => {
it("requires a session", async () => {
const res = await GET(new NextRequest("https://threatcrush.com/api/github/installations"));
expect(res.status).toBe(401);
});

it("returns nothing for a sign-in without a GitHub login", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: null };
const res = await GET(new NextRequest("https://threatcrush.com/api/github/installations"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.installations).toEqual([]);
expect(body.githubLogin).toBeNull();
});

it("lists only the installations the login manages, without sender_login", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: "ada" };
state.results = [{ data: [mine, orgByMe, theirs], error: null }];
const res = await GET(new NextRequest("https://threatcrush.com/api/github/installations"));
const body = await res.json();
expect(body.installations.map((i: { installation_id: number }) => i.installation_id)).toEqual([10, 11]);
expect(body.installations[1].announce).toBe(false);
expect(JSON.stringify(body)).not.toContain("sender_login");
});
});

describe("PATCH /api/github/installations", () => {
it("refuses a sign-in without a GitHub login", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: null };
const res = await patch({ installation_id: 10, announce: false });
expect(res.status).toBe(403);
});

it("validates the body", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: "ada" };
const res = await patch({ installation_id: "x", announce: "no" });
expect(res.status).toBe(400);
});

it("answers 404 for an installation that is not the login's, and writes nothing", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: "ada" };
state.results = [{ data: theirs, error: null }];
const res = await patch({ installation_id: 12, announce: false });
expect(res.status).toBe(404);
expect(state.queries.some((q) => q.calls.some(([name]) => name === "update"))).toBe(false);
expect(resetOpenThreatCache).not.toHaveBeenCalled();
});

it("turns announcements off for the login's own installation and drops the descriptor cache", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: "ada" };
state.results = [
{ data: mine, error: null },
{ data: { ...mine, announce: false }, error: null },
];
const res = await patch({ installation_id: 10, announce: false });
const body = await res.json();
expect(res.status).toBe(200);
expect(body.installation.announce).toBe(false);
const update = state.queries[1].calls.find(([name]) => name === "update");
expect((update?.[1][0] as { announce: boolean }).announce).toBe(false);
expect(resetOpenThreatCache).toHaveBeenCalledTimes(1);
});

it("says so when the announce column is not there yet", async () => {
state.user = { userId: "u1", email: "a@example.com", githubLogin: "ada" };
state.results = [
{ data: mine, error: null },
{ data: null, error: { code: "42703", message: "column announce does not exist" } },
];
const res = await patch({ installation_id: 10, announce: false });
expect(res.status).toBe(503);
});
});

// Keep the module-level Result type in use so the linter does not flag it.
export type { Result };
Loading
Loading