From 684633ff19cddbe52de5a62717dfeab2d8bee329 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 02:45:26 +0000 Subject: [PATCH] Discovery: open threats as an OpenThreat descriptor, with a per-installation announce switch threatcrush.com/discovery lists what the GitHub App found in PUBLIC repositories, and /.well-known/openthreat.json serves the same list as an OpenThreat 0.1 descriptor (logicsrc.com/openthreat) for nichedb.dev and any other directory to read. What is published: findings from the latest complete scan of each public, still-attached repository on an active, announcing installation, with rule, cwe, category, severity, confidence, subject (owner/repo, URL, ref, commit), status open or fixed, first and last seen. What is never published: private repositories, installation ids, account and sender logins, organizations, servers, properties, detections, and any finding's excerpt. A sensitive finding (secrets) also withholds file, line, message and consequence. assertNoPrivateData throws if a forbidden key ever reaches the serialized descriptor. The announce switch: github_installations.announce (migration, default true). The installer turns it off under Account > Scan announcements; the API gates on the signed-in user's GitHub login matching the installation's account_login or sender_login. Until the migration is applied the code treats a missing column as announce on and the toggle answers 503 with a plain message. Tests: open-threats (12) and the installations route (9), 21 passing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014cmNRtR2vL1p89dbVQ7FZJ --- .../app/.well-known/openthreat.json/route.ts | 28 ++ .../src/app/account/github-app-settings.tsx | 154 +++++++ apps/web/src/app/account/page.tsx | 8 +- .../installations/__tests__/route.test.ts | 150 +++++++ .../src/app/api/github/installations/route.ts | 120 ++++++ .../app/discovery/openthreat.json/route.ts | 21 + apps/web/src/app/discovery/page.tsx | 218 ++++++++++ apps/web/src/app/github/installed/page.tsx | 12 + apps/web/src/app/layout.tsx | 1 + apps/web/src/app/sitemap.ts | 1 + .../src/lib/__tests__/open-threats.test.ts | 224 +++++++++++ apps/web/src/lib/api-auth.ts | 6 + apps/web/src/lib/github-announce.ts | 31 ++ apps/web/src/lib/open-threats.ts | 375 ++++++++++++++++++ .../20260913030000_installation_announce.sql | 16 + 15 files changed, 1364 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/.well-known/openthreat.json/route.ts create mode 100644 apps/web/src/app/account/github-app-settings.tsx create mode 100644 apps/web/src/app/api/github/installations/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/github/installations/route.ts create mode 100644 apps/web/src/app/discovery/openthreat.json/route.ts create mode 100644 apps/web/src/app/discovery/page.tsx create mode 100644 apps/web/src/lib/__tests__/open-threats.test.ts create mode 100644 apps/web/src/lib/github-announce.ts create mode 100644 apps/web/src/lib/open-threats.ts create mode 100644 supabase/migrations/20260913030000_installation_announce.sql diff --git a/apps/web/src/app/.well-known/openthreat.json/route.ts b/apps/web/src/app/.well-known/openthreat.json/route.ts new file mode 100644 index 0000000..3995dfe --- /dev/null +++ b/apps/web/src/app/.well-known/openthreat.json/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/account/github-app-settings.tsx b/apps/web/src/app/account/github-app-settings.tsx new file mode 100644 index 0000000..c1ec8b5 --- /dev/null +++ b/apps/web/src/app/account/github-app-settings.tsx @@ -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([]); + const [githubLogin, setGithubLogin] = useState(null); + const [announceColumn, setAnnounceColumn] = useState(true); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(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 ( +
+
+

// github app

+

Scan announcements

+

+ Findings the ThreatCrush GitHub App makes in your public repositories are + announced on{" "} + + Discovery + {" "} + and in the OpenThreat descriptor, on by default. Private repositories are never + announced. Turn it off per installation here. +

+ + {!githubLogin ? ( +

+ Sign in with GitHub to see the installations you manage. An email sign-in has no + GitHub login to match against. +

+ ) : installations.length === 0 ? ( +

+ No installations found for @{githubLogin}. + Installations show here when the app was installed on your account, or by you on an + organisation. +

+ ) : ( +
    + {installations.map((i) => ( +
  • +
    +
    + {i.account_login ?? `installation ${i.installation_id}`} + {i.account_type ? ( + {i.account_type} + ) : null} +
    +
    + {i.repository_selection === "all" ? "all repositories" : "selected repositories"} + {" · "} + {i.status} +
    +
    + +
  • + ))} +
+ )} + + {!announceColumn && ( +

+ The announce setting is not available on this deployment yet; every installation + announces its public findings until it is. +

+ )} + {error &&

{error}

} +
+
+ ); +} diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx index d5cb4bd..fbe3578 100644 --- a/apps/web/src/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -1,7 +1,13 @@ "use client"; import AccountContent from "./account-content"; +import GitHubAppSettings from "./github-app-settings"; export default function AccountPage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/web/src/app/api/github/installations/__tests__/route.test.ts b/apps/web/src/app/api/github/installations/__tests__/route.test.ts new file mode 100644 index 0000000..512c10c --- /dev/null +++ b/apps/web/src/app/api/github/installations/__tests__/route.test.ts @@ -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(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 }; diff --git a/apps/web/src/app/api/github/installations/route.ts b/apps/web/src/app/api/github/installations/route.ts new file mode 100644 index 0000000..648158a --- /dev/null +++ b/apps/web/src/app/api/github/installations/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedRequestUser, getAdminClient, unauthorized } from "@/lib/api-auth"; +import { resetOpenThreatCache } from "@/lib/open-threats"; +import { managesInstallation, type ManagedInstallation } from "@/lib/github-announce"; + +export const runtime = "nodejs"; + +/** + * The GitHub App installations a signed-in person may manage, and the one + * setting on them: whether findings from the installation's PUBLIC + * repositories are announced on /discovery (OpenThreat). The gate is + * `managesInstallation` in lib/github-announce.ts. + */ + +const SELECT = + "installation_id, account_login, account_type, sender_login, repository_selection, status, announce, installed_at"; +const SELECT_NO_ANNOUNCE = + "installation_id, account_login, account_type, sender_login, repository_selection, status, installed_at"; + +type Row = { + installation_id: number; + account_login: string | null; + account_type: string | null; + sender_login: string | null; + repository_selection: string | null; + status: string; + announce?: boolean | null; + installed_at: string | null; +}; + +async function listMine(login: string): Promise<{ rows: Row[]; announceColumn: boolean }> { + const admin = getAdminClient(); + const filter = `account_login.ilike.${login},sender_login.ilike.${login}`; + const first = await admin.from("github_installations").select(SELECT).or(filter); + if (!first.error) return { rows: (first.data ?? []) as Row[], announceColumn: true }; + if (first.error.code === "42P01") return { rows: [], announceColumn: false }; + const second = await admin.from("github_installations").select(SELECT_NO_ANNOUNCE).or(filter); + if (second.error) throw new Error(second.error.message); + return { rows: (second.data ?? []) as Row[], announceColumn: false }; +} + +function publicView(row: Row): ManagedInstallation { + return { + installation_id: row.installation_id, + account_login: row.account_login, + account_type: row.account_type, + repository_selection: row.repository_selection, + status: row.status, + announce: row.announce !== false, + installed_at: row.installed_at, + }; +} + +export async function GET(request: NextRequest) { + const user = await getAuthenticatedRequestUser(request); + if (!user) return unauthorized(); + if (!user.githubLogin) { + return NextResponse.json({ githubLogin: null, installations: [], announceColumn: true }); + } + + try { + const { rows, announceColumn } = await listMine(user.githubLogin); + const installations = rows + .filter((row) => managesInstallation(user.githubLogin, row)) + .map(publicView); + return NextResponse.json({ githubLogin: user.githubLogin, installations, announceColumn }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + const user = await getAuthenticatedRequestUser(request); + if (!user) return unauthorized(); + if (!user.githubLogin) { + return NextResponse.json({ error: "Sign in with GitHub to manage installations" }, { status: 403 }); + } + + const body = (await request.json().catch(() => null)) as + | { installation_id?: unknown; announce?: unknown } + | null; + const installationId = Number(body?.installation_id); + if (!body || !Number.isInteger(installationId) || installationId <= 0 || typeof body.announce !== "boolean") { + return NextResponse.json( + { error: "installation_id (integer) and announce (boolean) are required" }, + { status: 400 } + ); + } + + const admin = getAdminClient(); + const found = await admin + .from("github_installations") + .select("installation_id, account_login, sender_login") + .eq("installation_id", installationId) + .maybeSingle(); + if (found.error) return NextResponse.json({ error: found.error.message }, { status: 500 }); + if (!found.data || !managesInstallation(user.githubLogin, found.data)) { + // Not found and not yours read the same, so a login cannot probe for installation ids. + return NextResponse.json({ error: "Installation not found" }, { status: 404 }); + } + + const updated = await admin + .from("github_installations") + .update({ announce: body.announce, updated_at: new Date().toISOString() }) + .eq("installation_id", installationId) + .select(SELECT) + .single(); + if (updated.error) { + if (updated.error.code === "42703") { + return NextResponse.json( + { error: "The announce setting is not available yet on this deployment" }, + { status: 503 } + ); + } + return NextResponse.json({ error: updated.error.message }, { status: 500 }); + } + + resetOpenThreatCache(); + return NextResponse.json({ installation: publicView(updated.data as Row) }); +} diff --git a/apps/web/src/app/discovery/openthreat.json/route.ts b/apps/web/src/app/discovery/openthreat.json/route.ts new file mode 100644 index 0000000..aa97068 --- /dev/null +++ b/apps/web/src/app/discovery/openthreat.json/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { loadOpenThreat } from "@/lib/open-threats"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** GET /discovery/openthreat.json: the same descriptor as /.well-known/openthreat.json, beside the page. */ +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 }); + } +} diff --git a/apps/web/src/app/discovery/page.tsx b/apps/web/src/app/discovery/page.tsx new file mode 100644 index 0000000..12155fb --- /dev/null +++ b/apps/web/src/app/discovery/page.tsx @@ -0,0 +1,218 @@ +import Link from "next/link"; +import { loadOpenThreat, type OpenThreatItem } from "@/lib/open-threats"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "Discovery — open threats ThreatCrush identified", + description: + "Threats ThreatCrush identified in the open: findings in public repositories scanned by the ThreatCrush GitHub App, published as an OpenThreat descriptor at /.well-known/openthreat.json. Never private repositories, never customer data.", + alternates: { canonical: "/discovery" }, +}; + +const linkClass = "text-tc-green underline underline-offset-4 hover:opacity-80"; + +function Code({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +const SEVERITY_CLASS: Record = { + critical: "text-red-400", + high: "text-orange-400", + medium: "text-yellow-300", + low: "text-tc-text-dim", + info: "text-tc-text-dim", +}; + +function when(iso?: string): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC"; +} + +function Row({ t }: { t: OpenThreatItem }) { + return ( + + + {t.severity ?? "unstated"} + + +
{t.title}
+
+ {t.rule} + {t.cwe ? ` · ${t.cwe}` : ""} +
+ + + {t.subject ? ( + + {t.subject.name} + + ) : ( + unstated + )} + {t.location ? ( +
+ {t.location.file}:{t.location.line} +
+ ) : t.category === "secret" ? ( +
location withheld (secret)
+ ) : null} + + + {t.status} + + + {when(t.last_seen)} + + + ); +} + +export default async function DiscoveryPage() { + let threats: OpenThreatItem[] = []; + let updated = ""; + let unavailable = false; + try { + const descriptor = await loadOpenThreat(); + threats = descriptor.threats; + updated = descriptor.updated; + } catch { + unavailable = true; + } + + const open = threats.filter((t) => t.status === "open").length; + const fixed = threats.filter((t) => t.status === "fixed").length; + const repos = new Set(threats.map((t) => t.subject?.name).filter(Boolean)).size; + + return ( +
+
+
+

// in the open

+

+ Discovery +

+

+ The threats ThreatCrush identified in the open: findings in public repositories + scanned by the ThreatCrush GitHub App, listed here as they were found and marked + fixed when a later scan no longer finds them. Nothing on this page comes from a + private repository or from a customer's servers, and the{" "} + + policy + {" "} + below says exactly what is withheld. The same list is served as an{" "} + + OpenThreat + {" "} + descriptor at /.well-known/openthreat.json, so any directory can read it. +

+ {updated && ( +

+ Updated {when(updated)} · {open} open · {fixed} fixed · {repos} repositories +

+ )} +
+ +
+ {unavailable ? ( +

+ The list could not be built right now. The descriptor at{" "} + /.well-known/openthreat.json answers with the same state. +

+ ) : threats.length === 0 ? ( +
+

+ Nothing to list yet. Findings appear here after the ThreatCrush GitHub App scans a + public repository on an installation that has announcements on. +

+

+ + Install the app + {" "} + on a public repository to be the first. +

+
+ ) : ( +
+ + + + + + + + + + + + {threats.map((t) => ( + + ))} + +
SeverityFindingSubjectStatusLast seen
+
+ )} +
+ +
+

Policy

+

What this page publishes, and what it never will.

+
    +
  • + Public repositories only. A finding is listed + only when the repository was public at the time of the scan. Private repositories are + never scanned into this list, whatever the finding. +
  • +
  • + No customer data. Nothing from a ThreatCrush + organization, a monitored server, a property, a pentest or a live detection appears + here. Those belong to the people who pay for them. +
  • +
  • + Secrets are never located. A committed + credential is published as a rule, a severity and a repository, with no file, no line + and no excerpt. It is already exposed; this page will not be the map to it. No finding + of any kind carries the matched source line. +
  • +
  • + Announcements are on by default. Installing + the GitHub App on a public repository lists its findings here. The person who installed + it can turn announcements off for that installation from{" "} + + app settings + {" "} + after signing in with the same GitHub account; the installation's repositories + leave the list within five minutes. +
  • +
  • + Fixed is a fact, not a promise. A finding is + marked fixed when a later scan of the same repository no longer reports it. A finding + that reappears is open again. +
  • +
  • + Everything here is TLP:CLEAR. It is served + from ThreatCrush's own origin, which is the whole verification a reader needs. +
  • +
+
+ +
+ Descriptor: {`${process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "") || "https://threatcrush.com"}/.well-known/openthreat.json`}{" "} + · Specification:{" "} + + logicsrc.com/openthreat + {" "} + · Also read by{" "} + + nichedb.dev + +
+
+
+ ); +} diff --git a/apps/web/src/app/github/installed/page.tsx b/apps/web/src/app/github/installed/page.tsx index f210dd6..ad96aaa 100644 --- a/apps/web/src/app/github/installed/page.tsx +++ b/apps/web/src/app/github/installed/page.tsx @@ -103,6 +103,18 @@ export default async function GitHubInstalledPage({ {" "} for what the scanner looks for and how to wire it into CI. +
  • + Findings in your public repositories are announced on{" "} + + Discovery + {" "} + by default; private repositories never are. Turn announcements off for this + installation from{" "} + + your account + {" "} + after signing in with GitHub. +
  • diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 857ab79..fb5cdff 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -207,6 +207,7 @@ export default function RootLayout({ rel="stylesheet" /> +