first commit

This commit is contained in:
shafi54 2026-08-18 19:38:47 +05:30
commit 52b200574b
1283 changed files with 51745 additions and 0 deletions

135
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,135 @@
# Deploying Cerebrus to Cloud Run
Two independent Cloud Run services, built with **Cloud Build** → **Artifact
Registry**, region **us-central1**. Each app's `.env` is **baked into its image**
(no Secret Manager).
- `cerebrus-backend` — Express/Bun API (`oven/bun` runtime, listens on `$PORT`).
- `cerebrus-frontend` — Vite SPA served by a small **Bun** server (built assets, `$PORT`).
> Secrets are baked into the images, so **keep the Artifact Registry repo private**
> — anyone who can pull an image can read its `.env`.
## Prerequisites
- `gcloud` CLI authenticated (`gcloud auth login`) with Owner/Editor on the project.
- A GCP **project id** (billing enabled).
- Supabase `DATABASE_URL`, WorkOS app, and a GitHub OAuth App (see `apps/backend/README.md`).
## One-time: create the env files
```bash
cp apps/backend/.env.production.example apps/backend/.env.production
cp apps/frontend/.env.production.example apps/frontend/.env.production
```
Fill in the secrets now (WorkOS, Supabase, GitHub, `APP_ENCRYPTION_KEY`). Leave the
URL fields at their placeholder for the first deploy — you'll set them in step 2.
These files are gitignored; they only ever exist locally and inside the images.
## Deploy (two-phase, because each service needs the other's URL)
### Phase 1 — first deploy to learn the URLs
```bash
./deploy.sh <PROJECT_ID>
```
This enables APIs, creates the `cerebrus` Artifact Registry repo, grants Cloud
Build deploy permission, builds + deploys both services, and prints their URLs, e.g.:
```
cerebrus-backend https://cerebrus-backend-abc123-uc.a.run.app
cerebrus-frontend https://cerebrus-frontend-def456-uc.a.run.app
```
> **One origin.** The browser only ever talks to the **frontend** URL. The Bun
> server in the frontend image reverse-proxies `/auth` and `/api` to the backend,
> so the session cookie stays first-party (cross-site `*.run.app` cookies are
> blocked by browsers). So every public URL you configure is the **frontend** URL —
> and the frontend needs the backend URL to proxy to (`BACKEND_URL`, set once below).
`deploy.sh` builds and deploys the backend, frontend, and scanner job. It reads
`BACKEND_URL` from `apps/frontend/.env.production` and sets it on the frontend
Cloud Run service automatically. You manage the rest of the config via the
`.env.production` files (backend config is baked into its image).
### Phase 2 — wire the URLs and redeploy
1. Edit **`apps/backend/.env.production`**:
- `NODE_ENV=production`
- `FRONTEND_URL=<frontend url>`
- `WORKOS_REDIRECT_URI=<frontend url>/auth/callback`
- `GITHUB_REDIRECT_URI=<frontend url>/api/github/callback`
- `SCAN_CALLBACK_URL=<backend url>` (must be the backend, not the frontend,
because the scanner calls `/internal/*`)
2. **`apps/frontend/.env.production`**:
- `BACKEND_URL=<backend url>`
- leave `VITE_API_URL` unset (same-origin)
3. Update the external dashboards (all on the **frontend** origin):
- **WorkOS**: add `<frontend url>/auth/callback` as a Redirect URI, and set the
sign-out redirect to `<frontend url>`.
- **GitHub OAuth App**: set the Authorization callback URL to
`<frontend url>/api/github/callback`.
4. Redeploy. `deploy.sh` now reads `BACKEND_URL` from `apps/frontend/.env.production`
and sets it on the frontend Cloud Run service automatically. It also builds and
deploys the scanner Cloud Run job used when `SCAN_RUNNER=cloudrun`:
```bash
./deploy.sh <PROJECT_ID>
```
To skip the scanner job, run with `DEPLOY_SCANNER=0 ./deploy.sh <PROJECT_ID>`.
## Database
Supabase Postgres is publicly reachable, so Cloud Run connects with no extra
networking. Apply migrations once (from your machine, against the same `DATABASE_URL`):
```bash
cd apps/backend && bun run db:migrate
```
## Cloud Run scanner permissions (when `SCAN_RUNNER=cloudrun`)
The backend Cloud Run service launches the scanner Cloud Run job. It authenticates
as the service account whose key is inlined in `GOOGLE_SERVICE_ACCOUNT_JSON`.
1. In `apps/backend/.env.production`, remove any `GOOGLE_APPLICATION_CREDENTIALS`
line and add the full service-account key JSON on one line:
```env
GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."}
```
2. Grant that service account permission to run jobs:
```bash
SA_EMAIL="cerebrus-job-exec@local-volt-486423-t2.iam.gserviceaccount.com"
PROJECT_ID=local-volt-486423-t2
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${SA_EMAIL}" --role="roles/run.admin" --condition=None
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${SA_EMAIL}" --role="roles/iam.serviceAccountUser" --condition=None
```
For local dev you can keep using `GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json`
instead of inlining the JSON.
## How env is handled
| | Where it's read | How it's configured |
| --- | --- | --- |
| Backend | runtime (`bun` auto-loads `/app/.env`) | `COPY apps/backend/.env.production ./.env` in the Dockerfile |
| Frontend build | **build time** (Vite inlines `VITE_*`) | `vite build` reads `apps/frontend/.env.production` |
| Frontend runtime | runtime (`process.env.BACKEND_URL`) | `deploy.sh` reads `BACKEND_URL` from `apps/frontend/.env.production` and sets it on the Cloud Run service |
`PORT` is **not** set in `.env.production` — Cloud Run injects `8080`, and a real
env var always wins over the file. The session cookie stays **first-party**
because the browser only talks to the frontend origin (the Bun server proxies `/auth` +
`/api` to the backend) — no cross-site/third-party cookies involved.
## Notes
- `deploy.sh` now sets the frontend's `BACKEND_URL` Cloud Run env var automatically
from `apps/frontend/.env.production` on every deploy.
- Build context is the **repo root** (the bun workspace must resolve); the
Dockerfiles live in each app but are built with `-f apps/<app>/Dockerfile .`.
- `.gcloudignore` exists so Cloud Build keeps `.env.production` in the upload
(otherwise gcloud falls back to `.gitignore`, which excludes all `.env*`).
- Services are deployed `--allow-unauthenticated` (public web app). Remove that
flag in the cloudbuild configs to require IAM auth.

69
apps/backend/.env.example Normal file
View file

@ -0,0 +1,69 @@
# Server
PORT=3001
NODE_ENV=development
# Frontend origin (CORS + post-login redirect target)
FRONTEND_URL=http://localhost:5173
# WorkOS AuthKit — https://dashboard.workos.com
# Configure WORKOS_REDIRECT_URI as a Redirect URI in the WorkOS dashboard.
WORKOS_API_KEY=
WORKOS_CLIENT_ID=
# Must be at least 32 characters. Generate: openssl rand -base64 32
WORKOS_COOKIE_PASSWORD=
WORKOS_REDIRECT_URI=http://localhost:3001/auth/callback
# Optional local-only identity used by Docker/E2E runs when WorkOS credentials
# are intentionally unavailable. Ignored when NODE_ENV=production.
LOCAL_DEV_AUTH_EMAIL=
LOCAL_DEV_AUTH_FIRST_NAME=Guest
LOCAL_DEV_AUTH_LAST_NAME=
# Supabase Postgres connection string (BACKEND ONLY — never expose to the browser).
# Supabase dashboard → Project Settings → Database → Connection string (URI).
# Use the connection pooler URI for serverless / many short connections.
DATABASE_URL=
# GitHub OAuth App — https://github.com/settings/developers (OAuth Apps).
# Set the app's "Authorization callback URL" to GITHUB_REDIRECT_URI.
# The 'repo' scope (used to list private repos) grants broad repo access.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=http://localhost:3001/api/github/callback
# Secret used to encrypt stored GitHub tokens at rest (any random string).
# Generate: openssl rand -base64 32
APP_ENCRYPTION_KEY=
# Scanner (apps/cli). SCAN_RUNNER: inline (in-process, default for dev — no Docker) |
# docker (run the container locally) | cloudrun (trigger the Cloud Run Job).
SCAN_RUNNER=inline
# Backend URL the scanner POSTs results to (docker/cloudrun modes).
# For docker, use http://host.docker.internal:3001 so the container can reach the host.
SCAN_CALLBACK_URL=http://localhost:3001
# Shared secret for /internal/scan-result (required for docker/cloudrun).
SCAN_CALLBACK_SECRET=
# docker mode: the built scanner image (e.g. cerebrus-scanner).
SCANNER_IMAGE=
# cloudrun mode: the Cloud Run Job to execute.
GCP_PROJECT=
GCP_REGION=us-central1
SCAN_JOB_NAME=cerebrus-scanner
# DeepSeek — the LLM that performs the file-by-file vulnerability analysis + chaining.
# Required for a real scan (docker/cloudrun pass it to the container; inline reads it here).
DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-reasoner
DEEPSEEK_BASE_URL=https://api.deepseek.com
# Max files analyzed in parallel (deepseek-reasoner is slow — keep this modest).
DEEPSEEK_CONCURRENCY=4
# Per-scan logs are written to <SCAN_LOG_DIR>/scan-<scanId>.log (host-readable).
SCAN_LOG_DIR=./scan-logs
# Ecosystems for the optional bulk `cve:sync` (on-demand OSV querybatch needs no config).
OSV_ECOSYSTEMS=npm,PyPI,Go,crates.io,Maven,RubyGems
# Auto-fix: after a scan finds vulnerabilities, open a PR on the user's repo applying
# the suggested fixes (uses the user's GitHub token). Set to "false" to disable.
SCAN_AUTOFIX=true

View file

@ -0,0 +1,46 @@
# Production env BAKED into the backend image (no Secret Manager).
# Copy to .env.production and fill in. Keep .env.production out of git (it holds secrets).
# PORT is intentionally omitted — Cloud Run injects it (8080) and that wins over this file.
#
# IMPORTANT: the browser only ever talks to ONE origin — the FRONTEND service URL.
# The frontend (nginx) reverse-proxies /auth and /api to the backend, so the
# session cookie stays first-party. Every public URL below is the FRONTEND URL.
NODE_ENV=production
# The deployed FRONTEND service URL (set after the first deploy). The single
# public origin. Exact origin, no trailing slash.
FRONTEND_URL=https://cerebrus-frontend-XXXXXXXX-uc.a.run.app
# WorkOS — https://dashboard.workos.com
WORKOS_API_KEY=
WORKOS_CLIENT_ID=
WORKOS_COOKIE_PASSWORD=
# FRONTEND URL + /auth/callback (proxied to the backend). Register this exact URL
# as a Redirect URI in the WorkOS dashboard.
WORKOS_REDIRECT_URI=https://cerebrus-frontend-XXXXXXXX-uc.a.run.app/auth/callback
# Supabase Postgres connection string (use the pooler URI).
DATABASE_URL=
# GitHub OAuth App — https://github.com/settings/developers
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# FRONTEND URL + /api/github/callback (proxied to the backend). Set this exact URL
# as the OAuth App's Authorization callback URL.
GITHUB_REDIRECT_URI=https://cerebrus-frontend-XXXXXXXX-uc.a.run.app/api/github/callback
# Encrypts stored GitHub tokens at rest. openssl rand -base64 32
APP_ENCRYPTION_KEY=
# Scanner: run scans as Cloud Run Jobs in production.
SCAN_RUNNER=cloudrun
# The deployed BACKEND service URL the scanner POSTs results to.
SCAN_CALLBACK_URL=https://cerebrus-backend-XXXXXXXX-uc.a.run.app
# Shared secret the scanner sends as x-scan-secret. openssl rand -base64 32
SCAN_CALLBACK_SECRET=
# Cloud Run Job to execute (deployed via apps/cli/cloudbuild.yaml). The backend's
# service account needs run.developer + run.jobsExecutorWithOverrides on the job.
GCP_PROJECT=
GCP_REGION=us-central1
SCAN_JOB_NAME=cerebrus-scanner

31
apps/backend/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# Build context is the repo ROOT (so the bun workspace resolves).
# ---- Build: install workspace deps and bundle the backend to one file ----
FROM oven/bun:1.3.14 AS build
WORKDIR /repo
# Manifests first for cached installs.
COPY package.json bun.lock turbo.json ./
COPY packages/typescript-config/package.json ./packages/typescript-config/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY apps/backend/package.json ./apps/backend/
COPY apps/frontend/package.json ./apps/frontend/
COPY apps/cli/package.json ./apps/cli/
RUN bun install --frozen-lockfile
# Sources, then bundle to a self-contained dist/index.js.
COPY . .
RUN cd apps/backend && bun build src/index.ts --target bun --outdir dist
# ---- Runtime: minimal image with the bundle + baked env ----
FROM oven/bun:1.3.14-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /repo/apps/backend/dist ./dist
# Bake the production env into the image (no Secret Manager). Cloud Run injects
# PORT at runtime, which takes precedence over any value in this file.
COPY apps/backend/.env.production ./.env
EXPOSE 8080
USER bun
CMD ["bun", "dist/index.js"]

68
apps/backend/README.md Normal file
View file

@ -0,0 +1,68 @@
# @cerebrus/backend
Express + TypeScript API (run by Bun) that owns **WorkOS AuthKit** authentication
and all database access via **Drizzle ORM** over **Supabase Postgres**.
The browser never talks to Supabase directly — `DATABASE_URL` lives only here.
## Setup
```bash
cp .env.example .env # fill in the values below
bun install # from the repo root
```
### Environment
| Var | Notes |
| --- | --- |
| `PORT` | Default `3001`. |
| `FRONTEND_URL` | Browser origin, used for CORS and the post-login redirect. |
| `WORKOS_API_KEY` / `WORKOS_CLIENT_ID` | From the [WorkOS dashboard](https://dashboard.workos.com). |
| `WORKOS_COOKIE_PASSWORD` | 32+ chars. `openssl rand -base64 32`. |
| `WORKOS_REDIRECT_URI` | Must also be registered as a Redirect URI in WorkOS (default `http://localhost:3001/auth/callback`). |
| `DATABASE_URL` | Supabase Postgres connection string (use the pooler URI). |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | From a [GitHub OAuth App](https://github.com/settings/developers). |
| `GITHUB_REDIRECT_URI` | Must equal the OAuth App's Authorization callback URL (default `http://localhost:3001/api/github/callback`). |
| `APP_ENCRYPTION_KEY` | Encrypts stored GitHub tokens at rest. `openssl rand -base64 32`. |
### GitHub OAuth App
Create one at GitHub → Settings → Developer settings → **OAuth Apps** → New OAuth App.
Set **Authorization callback URL** to `GITHUB_REDIRECT_URI`. Copy the client id and a
generated client secret into `.env`. The connection requests the `repo` scope so we
can list **private** repositories — note this grants broad repo access (an OAuth App
has no read-only private scope; use a GitHub App if you need least privilege).
Auth and DB are independent: the server boots with neither set and reports status
at `GET /health`. Each integration activates as soon as its vars are present.
## Run
```bash
bun run dev # watch mode (also runs via `bun run dev` at the repo root, alongside the frontend)
bun run start # one-off
```
## Database (Drizzle + Supabase)
```bash
bun run db:generate # SQL migration from src/db/schema.ts -> src/db/migrations (offline)
bun run db:migrate # apply migrations to DATABASE_URL
bun run db:push # push schema directly (dev convenience)
```
## Routes
- `GET /health` — liveness + `{ authConfigured, dbConfigured }`.
- `GET /auth/login` — redirect to WorkOS AuthKit.
- `GET /auth/callback` — exchange code, set sealed-session cookie, upsert user.
- `GET /auth/me``{ user | null, configured }`.
- `GET /auth/logout` — clear cookie, redirect through WorkOS logout.
- `GET /api/me`**protected**; WorkOS user + their Supabase row.
- `GET /api/github/status`**protected**; `{ configured, connected, login }`.
- `GET /api/github/connect`**protected**; start GitHub OAuth.
- `GET /api/github/callback` — store the encrypted token, redirect to the app.
- `GET /api/github/repos`**protected**; the user's repos (public + private), each flagged `connected`.
- `POST /api/github/repos/connect` · `POST /api/github/repos/disconnect`**protected**; toggle a project.
- `GET /api/github/projects`**protected**; the user's connected repositories.

View file

@ -0,0 +1,26 @@
# Build + push + deploy the backend. Run from the repo root:
# gcloud builds submit --config apps/backend/cloudbuild.yaml \
# --substitutions=_REGION=us-central1,_IMAGE=us-central1-docker.pkg.dev/PROJECT/cerebrus/backend .
steps:
- name: gcr.io/cloud-builders/docker
args: ["build", "-f", "apps/backend/Dockerfile", "-t", "${_IMAGE}:${BUILD_ID}", "-t", "${_IMAGE}:latest", "."]
- name: gcr.io/cloud-builders/docker
args: ["push", "--all-tags", "${_IMAGE}"]
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: gcloud
args:
- "run"
- "deploy"
- "cerebrus-backend"
- "--image=${_IMAGE}:${BUILD_ID}"
- "--region=${_REGION}"
- "--platform=managed"
- "--port=8080"
- "--allow-unauthenticated"
images:
- "${_IMAGE}"
substitutions:
_REGION: "us-central1"
_IMAGE: "us-central1-docker.pkg.dev/${PROJECT_ID}/cerebrus/backend"
options:
logging: CLOUD_LOGGING_ONLY

View file

@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
// drizzle-kit reads DATABASE_URL for `migrate`/`push`. `generate` works offline
// (no connection needed), so an empty fallback is fine for codegen.
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
});

View file

@ -0,0 +1,3 @@
import base from "@cerebrus/eslint-config/base";
export default base;

42
apps/backend/package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "@cerebrus/backend",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"build": "bun build src/index.ts --target bun --outdir dist",
"check-types": "tsc --noEmit",
"lint": "eslint .",
"test": "bun test src",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@cerebrus/cli": "*",
"@workos-inc/node": "^7.69.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"drizzle-orm": "^0.44.6",
"express": "^5.1.0",
"pino": "^9.6.0",
"pino-http": "^10.4.0",
"postgres": "^3.4.7",
"zod": "^4.1.12"
},
"devDependencies": {
"@cerebrus/eslint-config": "*",
"@cerebrus/typescript-config": "*",
"@types/cookie-parser": "^1.4.9",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.13.2",
"drizzle-kit": "^0.31.5",
"eslint": "^10.5.0",
"pino-pretty": "^13.0.0",
"typescript": "~6.0.2"
}
}

View file

@ -0,0 +1,98 @@
import { logger } from "../lib/logger";
import { advisoriesForPackages, upsertAdvisories } from "../db/advisories";
import { canonicalEcosystem, cveIdFromAliases, fetchVulns, fixedVersionFor, queryBatch, toAdvisoryRow } from "./osv";
import { firstFixedVersion, versionIsAffected, type OsvAffectedLike } from "./semver";
export interface DepInput {
ecosystem: string;
name: string;
version: string;
}
export interface CveMatch {
ecosystem: string;
name: string;
version: string;
osvId: string;
cveId?: string | null;
severity?: string | null;
summary?: string | null;
fixedVersion?: string | null;
}
// Given the dependencies parsed from a repo's manifests, returns the known CVEs
// affecting them. Primary path: OSV querybatch (authoritative, server-side version
// matching) + enrichment fetch, caching every advisory into Postgres so the local
// CVE DB grows over time. If OSV is unreachable, falls back to the cached advisories
// with the vendored version matcher.
export async function matchAdvisories(deps: DepInput[]): Promise<CveMatch[]> {
if (deps.length === 0) return [];
try {
return await matchViaOsv(deps);
} catch (err) {
logger.warn({ err }, "OSV lookup failed — falling back to cached advisories");
return matchViaCache(deps);
}
}
async function matchViaOsv(deps: DepInput[]): Promise<CveMatch[]> {
const idsPerDep = await queryBatch(deps);
const allIds = idsPerDep.flat();
if (allIds.length === 0) return [];
const records = await fetchVulns(allIds);
// Cache/enrich every advisory we resolved, keyed to the dep that surfaced it.
const rows = [];
for (let i = 0; i < deps.length; i++) {
for (const id of idsPerDep[i] ?? []) {
const rec = records.get(id);
if (rec) rows.push(toAdvisoryRow(rec, deps[i].ecosystem, deps[i].name));
}
}
await upsertAdvisories(rows).catch((err) => logger.warn({ err }, "advisory cache upsert failed"));
const matches: CveMatch[] = [];
for (let i = 0; i < deps.length; i++) {
const dep = deps[i];
for (const id of idsPerDep[i] ?? []) {
const rec = records.get(id);
matches.push({
ecosystem: canonicalEcosystem(dep.ecosystem),
name: dep.name,
version: dep.version,
osvId: id,
cveId: rec ? cveIdFromAliases(rec) : null,
severity: rec ? toAdvisoryRow(rec, dep.ecosystem, dep.name).severity : null,
summary: rec?.summary ?? null,
fixedVersion: rec ? fixedVersionFor(rec, dep.ecosystem, dep.name) : null,
});
}
}
return matches;
}
async function matchViaCache(deps: DepInput[]): Promise<CveMatch[]> {
const advisories = await advisoriesForPackages(deps.map((d) => ({ ecosystem: canonicalEcosystem(d.ecosystem), name: d.name })));
const matches: CveMatch[] = [];
for (const dep of deps) {
const canon = canonicalEcosystem(dep.ecosystem).toLowerCase();
for (const adv of advisories) {
if (adv.packageName !== dep.name || adv.ecosystem.toLowerCase() !== canon) continue;
const ranges = adv.rangesRaw as OsvAffectedLike;
if (!versionIsAffected(dep.version, ranges)) continue;
const aliases = (adv.aliases as string[] | null) ?? [];
matches.push({
ecosystem: adv.ecosystem,
name: dep.name,
version: dep.version,
osvId: adv.osvId,
cveId: aliases.find((a) => a.startsWith("CVE-")) ?? adv.osvId,
severity: adv.severity,
summary: adv.summary,
fixedVersion: firstFixedVersion(ranges),
});
}
}
return matches;
}

146
apps/backend/src/cve/osv.ts Normal file
View file

@ -0,0 +1,146 @@
import { logger } from "../lib/logger";
import type { NewAdvisory } from "../db/schema";
import { firstFixedVersion, type OsvAffectedLike } from "./semver";
// Thin client for OSV.dev (https://osv.dev). `querybatch` does authoritative,
// server-side version matching and returns the vuln ids affecting each queried
// {package, version}; we then fetch full records to enrich + cache them.
const OSV_BASE = "https://api.osv.dev/v1";
export interface OsvPackageQuery {
ecosystem: string;
name: string;
version: string;
}
interface OsvSeverity {
type?: string;
score?: string;
}
export interface OsvAffected extends OsvAffectedLike {
package?: { ecosystem?: string; name?: string };
database_specific?: { severity?: string };
}
export interface OsvRecord {
id: string;
modified?: string;
summary?: string;
details?: string;
aliases?: string[];
severity?: OsvSeverity[];
affected?: OsvAffected[];
database_specific?: { severity?: string };
}
// Maps OSV's ecosystem casing. Manifests give us lowercase-ish keys; OSV uses
// canonical names (npm, PyPI, Go, crates.io, Maven, RubyGems, Packagist, ...).
const ECOSYSTEM_CANON: Record<string, string> = {
npm: "npm",
pypi: "PyPI",
go: "Go",
"crates.io": "crates.io",
cargo: "crates.io",
maven: "Maven",
rubygems: "RubyGems",
gem: "RubyGems",
packagist: "Packagist",
composer: "Packagist",
};
export function canonicalEcosystem(ecosystem: string): string {
return ECOSYSTEM_CANON[ecosystem.toLowerCase()] ?? ecosystem;
}
// POST /querybatch — returns, per query (index-aligned), the vuln ids affecting it.
export async function queryBatch(queries: OsvPackageQuery[]): Promise<string[][]> {
if (queries.length === 0) return [];
const body = {
queries: queries.map((q) => ({
package: { ecosystem: canonicalEcosystem(q.ecosystem), name: q.name },
version: q.version,
})),
};
const res = await fetch(`${OSV_BASE}/querybatch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`OSV querybatch failed (${res.status}): ${await res.text()}`);
const data = (await res.json()) as { results?: { vulns?: { id: string }[] }[] };
return (data.results ?? []).map((r) => (r.vulns ?? []).map((v) => v.id));
}
// GET /vulns/{id} — full record for enrichment/caching.
export async function fetchVuln(id: string): Promise<OsvRecord> {
const res = await fetch(`${OSV_BASE}/vulns/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`OSV vuln fetch failed for ${id} (${res.status})`);
return (await res.json()) as OsvRecord;
}
// Best-effort severity label from an OSV record (GHSA-style database_specific first,
// else a coarse bucket from a CVSS base score if present).
function deriveSeverity(record: OsvRecord, affected?: OsvAffected): string | null {
const raw = affected?.database_specific?.severity ?? record.database_specific?.severity;
if (raw) return raw.toLowerCase();
const cvss = record.severity?.find((s) => s.type?.startsWith("CVSS"))?.score;
const score = cvss ? Number.parseFloat(cvss) : NaN;
if (!Number.isNaN(score)) {
if (score >= 9) return "critical";
if (score >= 7) return "high";
if (score >= 4) return "medium";
return "low";
}
return null;
}
function pickAffected(record: OsvRecord, ecosystem: string, name: string): OsvAffected | undefined {
const canon = canonicalEcosystem(ecosystem);
return record.affected?.find(
(a) => a.package?.name === name && (a.package?.ecosystem ?? "").toLowerCase() === canon.toLowerCase(),
);
}
export function cveIdFromAliases(record: OsvRecord): string | null {
return record.aliases?.find((a) => a.startsWith("CVE-")) ?? record.id;
}
// Maps a full OSV record to an `advisories` row for a specific queried package.
export function toAdvisoryRow(record: OsvRecord, ecosystem: string, name: string): NewAdvisory {
const affected = pickAffected(record, ecosystem, name);
return {
osvId: record.id,
ecosystem: canonicalEcosystem(ecosystem),
packageName: name,
severity: deriveSeverity(record, affected),
summary: record.summary ?? null,
details: record.details ?? null,
aliases: record.aliases ?? [],
rangesRaw: { ranges: affected?.ranges ?? [], versions: affected?.versions ?? [] },
modified: record.modified ? new Date(record.modified) : null,
};
}
export function fixedVersionFor(record: OsvRecord, ecosystem: string, name: string): string | null {
const affected = pickAffected(record, ecosystem, name);
return affected ? firstFixedVersion(affected) : null;
}
// Fetches many vuln records with bounded concurrency; failures are logged and skipped.
export async function fetchVulns(ids: string[]): Promise<Map<string, OsvRecord>> {
const out = new Map<string, OsvRecord>();
const unique = [...new Set(ids)];
const limit = 6;
for (let i = 0; i < unique.length; i += limit) {
const batch = unique.slice(i, i + limit);
const records = await Promise.all(
batch.map((id) =>
fetchVuln(id).catch((err) => {
logger.warn({ err, id }, "OSV vuln fetch failed");
return null;
}),
),
);
for (const rec of records) if (rec) out.set(rec.id, rec);
}
return out;
}

View file

@ -0,0 +1,80 @@
// A deliberately minimal version comparator + OSV-range matcher. OSV's querybatch
// API does authoritative version matching server-side; this is only the offline
// fallback used when OSV is unreachable but we have cached advisories. It handles
// dotted numeric versions and simple introduced/fixed/last_affected ranges. Complex
// pre-release / build-metadata semantics are best-effort (documented limitation).
function normalize(version: string): string {
return version.trim().replace(/^[v=]+/, "").replace(/^\D*/, "");
}
// Splits into numeric-ish parts; non-numeric segments compare as 0 so we degrade
// gracefully rather than throw on odd version strings.
function parts(version: string): number[] {
return normalize(version)
.split(/[.+-]/)
.map((p) => {
const n = Number.parseInt(p, 10);
return Number.isNaN(n) ? 0 : n;
});
}
export function compareVersions(a: string, b: string): number {
const pa = parts(a);
const pb = parts(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff < 0 ? -1 : 1;
}
return 0;
}
interface OsvEvent {
introduced?: string;
fixed?: string;
last_affected?: string;
}
interface OsvRange {
type?: string;
events?: OsvEvent[];
}
export interface OsvAffectedLike {
ranges?: OsvRange[];
versions?: string[];
}
// Is `version` affected by this OSV affected-blob? Exact `versions[]` membership
// wins; otherwise we walk each range's introduced→fixed/last_affected windows.
export function versionIsAffected(version: string, affected: OsvAffectedLike): boolean {
const v = normalize(version);
if (affected.versions?.some((x) => normalize(x) === v)) return true;
for (const range of affected.ranges ?? []) {
let affectedInWindow = false;
for (const ev of range.events ?? []) {
if (ev.introduced !== undefined) {
// "introduced" opens a window; assume affected until a later fixed/last_affected closes it.
if (compareVersions(v, ev.introduced) >= 0) affectedInWindow = true;
}
if (ev.fixed !== undefined && affectedInWindow) {
if (compareVersions(v, ev.fixed) >= 0) affectedInWindow = false;
}
if (ev.last_affected !== undefined && affectedInWindow) {
if (compareVersions(v, ev.last_affected) > 0) affectedInWindow = false;
}
}
if (affectedInWindow) return true;
}
return false;
}
// First "fixed" version across the affected ranges, if any (used as the upgrade hint).
export function firstFixedVersion(affected: OsvAffectedLike): string | null {
for (const range of affected.ranges ?? []) {
for (const ev of range.events ?? []) {
if (ev.fixed) return ev.fixed;
}
}
return null;
}

View file

@ -0,0 +1,38 @@
import { and, inArray, sql } from "drizzle-orm";
import { getDb } from "./client";
import { advisories, type Advisory, type NewAdvisory } from "./schema";
// Upserts advisories keyed by osvId. On conflict, refreshes the record only when
// the incoming `modified` is newer (or unknown), so re-syncing is cheap and safe.
export async function upsertAdvisories(rows: NewAdvisory[]): Promise<void> {
if (rows.length === 0) return;
await getDb()
.insert(advisories)
.values(rows)
.onConflictDoUpdate({
target: advisories.osvId,
set: {
ecosystem: sql`excluded.ecosystem`,
packageName: sql`excluded.package_name`,
severity: sql`excluded.severity`,
summary: sql`excluded.summary`,
details: sql`excluded.details`,
aliases: sql`excluded.aliases`,
rangesRaw: sql`excluded.ranges_raw`,
modified: sql`excluded.modified`,
updatedAt: sql`now()`,
},
});
}
// All cached advisories for the given (ecosystem, package) pairs. Ecosystem match
// is case-insensitive since OSV and manifests disagree on casing (npm vs NPM).
export async function advisoriesForPackages(pairs: { ecosystem: string; name: string }[]): Promise<Advisory[]> {
if (pairs.length === 0) return [];
const names = [...new Set(pairs.map((p) => p.name))];
const ecosystems = [...new Set(pairs.map((p) => p.ecosystem.toLowerCase()))];
return getDb()
.select()
.from(advisories)
.where(and(inArray(advisories.packageName, names), inArray(sql`lower(${advisories.ecosystem})`, ecosystems)));
}

View file

@ -0,0 +1,20 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "../env";
import * as schema from "./schema";
let dbInstance: PostgresJsDatabase<typeof schema> | undefined;
// Lazily creates the Drizzle client over Supabase Postgres. The connection is
// only opened on first use, so the server boots fine when DATABASE_URL is unset.
// `prepare: false` keeps it compatible with Supabase's transaction pooler.
export function getDb(): PostgresJsDatabase<typeof schema> {
if (!env.dbConfigured) {
throw Object.assign(new Error("Database is not configured. Set DATABASE_URL."), { status: 503 });
}
if (!dbInstance) {
const sql = postgres(env.DATABASE_URL as string, { prepare: false });
dbInstance = drizzle(sql, { schema });
}
return dbInstance;
}

View file

@ -0,0 +1,49 @@
import { asc, eq, sql } from "drizzle-orm";
import { getDb } from "./client";
import { findings, findingChains, scans, type Finding, type FindingChain, type NewFinding, type NewFindingChain } from "./schema";
// Appends findings for a scan. Idempotent on id so retried streaming batches (the
// scanner supplies stable ids) don't duplicate rows. Bumps the scan's findingCount
// by however many rows were actually inserted.
export async function insertFindings(scanId: string, rows: NewFinding[]): Promise<void> {
if (rows.length === 0) return;
const inserted = await getDb()
.insert(findings)
.values(rows.map((row) => ({ ...row, scanId })))
.onConflictDoNothing({ target: findings.id })
.returning({ id: findings.id });
if (inserted.length > 0) {
await getDb()
.update(scans)
.set({ findingCount: sql`${scans.findingCount} + ${inserted.length}` })
.where(eq(scans.id, scanId));
}
}
export async function insertChains(scanId: string, rows: NewFindingChain[]): Promise<void> {
if (rows.length === 0) return;
await getDb()
.insert(findingChains)
.values(rows.map((row) => ({ ...row, scanId })));
}
// Patches progress fields on the scan row (files analyzed so far, stage). Also
// flips a queued scan to running the first time progress arrives.
export async function updateScanProgress(
scanId: string,
patch: { filesScanned?: number; fileCount?: number; stage?: string },
): Promise<void> {
const set: Record<string, unknown> = { status: sql`case when ${scans.status} = 'queued' then 'running' else ${scans.status} end` };
if (patch.filesScanned !== undefined) set.filesScanned = patch.filesScanned;
if (patch.fileCount !== undefined) set.fileCount = patch.fileCount;
if (patch.stage !== undefined) set.stage = patch.stage;
await getDb().update(scans).set(set).where(eq(scans.id, scanId));
}
export async function findingsForScan(scanId: string): Promise<Finding[]> {
return getDb().select().from(findings).where(eq(findings.scanId, scanId)).orderBy(asc(findings.createdAt));
}
export async function chainsForScan(scanId: string): Promise<FindingChain[]> {
return getDb().select().from(findingChains).where(eq(findingChains.scanId, scanId)).orderBy(asc(findingChains.createdAt));
}

View file

@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { getDb } from "./client";
import { connectedRepositories, githubConnections, scans } from "./schema";
export interface FixStatusPatch {
fixStatus: "creating" | "opened" | "no_fixes" | "failed";
fixPrUrl?: string | null;
fixPrNumber?: number | null;
fixedCount?: number | null;
skippedCount?: number | null;
fixError?: string | null;
}
export async function setFixStatus(scanId: string, patch: FixStatusPatch): Promise<void> {
await getDb().update(scans).set(patch).where(eq(scans.id, scanId));
}
export interface ScanContext {
owner: string;
name: string;
defaultBranch: string | null;
accessTokenEncrypted: string;
}
// Resolves everything the fix step needs from just a scanId: the target repo and the
// owning user's encrypted GitHub token. Returns null if the repo/scan is gone or the
// user has no GitHub connection (nothing to push with).
export async function loadScanContext(scanId: string): Promise<ScanContext | null> {
const [row] = await getDb()
.select({
owner: connectedRepositories.owner,
name: connectedRepositories.name,
defaultBranch: connectedRepositories.defaultBranch,
accessTokenEncrypted: githubConnections.accessTokenEncrypted,
})
.from(scans)
.innerJoin(connectedRepositories, eq(connectedRepositories.id, scans.connectedRepositoryId))
.innerJoin(githubConnections, eq(githubConnections.userId, connectedRepositories.userId))
.where(eq(scans.id, scanId))
.limit(1);
return row ?? null;
}

View file

@ -0,0 +1,48 @@
import { and, desc, eq } from "drizzle-orm";
import { getDb } from "./client";
import { connectedImages, type ConnectedImage, type NewConnectedImage } from "./schema";
// Persists an image the user picked to scan. Idempotent on the (user, registry, repo,
// tag) unique key — reconnecting returns the existing row.
export async function connectImage(row: NewConnectedImage): Promise<ConnectedImage> {
const [inserted] = await getDb().insert(connectedImages).values(row).onConflictDoNothing().returning();
if (inserted) return inserted;
const [existing] = await getDb()
.select()
.from(connectedImages)
.where(
and(
eq(connectedImages.userId, row.userId),
eq(connectedImages.registryConnectionId, row.registryConnectionId),
eq(connectedImages.repository, row.repository),
eq(connectedImages.tag, row.tag ?? "latest"),
),
)
.limit(1);
return existing;
}
export async function listImages(userId: string): Promise<ConnectedImage[]> {
return getDb()
.select()
.from(connectedImages)
.where(eq(connectedImages.userId, userId))
.orderBy(desc(connectedImages.connectedAt));
}
export async function getImage(userId: string, id: string): Promise<ConnectedImage | null> {
const [row] = await getDb()
.select()
.from(connectedImages)
.where(and(eq(connectedImages.id, id), eq(connectedImages.userId, userId)))
.limit(1);
return row ?? null;
}
export async function deleteImage(userId: string, id: string): Promise<void> {
await getDb().delete(connectedImages).where(and(eq(connectedImages.id, id), eq(connectedImages.userId, userId)));
}
export async function updateImageDigest(id: string, digest: string): Promise<void> {
await getDb().update(connectedImages).set({ lastDigest: digest }).where(eq(connectedImages.id, id));
}

View file

@ -0,0 +1,10 @@
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workos_user_id" text NOT NULL,
"email" text NOT NULL,
"first_name" text,
"last_name" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_workos_user_id_unique" UNIQUE("workos_user_id")
);

View file

@ -0,0 +1,29 @@
CREATE TABLE "connected_repositories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"github_repo_id" text NOT NULL,
"full_name" text NOT NULL,
"name" text NOT NULL,
"owner" text NOT NULL,
"private" boolean DEFAULT false NOT NULL,
"default_branch" text,
"html_url" text,
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "connected_repositories_user_repo_unique" UNIQUE("user_id","github_repo_id")
);
--> statement-breakpoint
CREATE TABLE "github_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"github_user_id" text NOT NULL,
"github_login" text NOT NULL,
"access_token_encrypted" text NOT NULL,
"scope" text,
"token_type" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "github_connections_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "connected_repositories" ADD CONSTRAINT "connected_repositories_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "github_connections" ADD CONSTRAINT "github_connections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,11 @@
CREATE TABLE "scans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"connected_repository_id" uuid NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"file_count" integer,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "scans" ADD CONSTRAINT "scans_connected_repository_id_connected_repositories_id_fk" FOREIGN KEY ("connected_repository_id") REFERENCES "public"."connected_repositories"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,48 @@
CREATE TABLE "advisories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"osv_id" text NOT NULL,
"ecosystem" text NOT NULL,
"package_name" text NOT NULL,
"severity" text,
"summary" text,
"details" text,
"aliases" jsonb,
"ranges_raw" jsonb NOT NULL,
"modified" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "advisories_osv_id_unique" UNIQUE("osv_id")
);
--> statement-breakpoint
CREATE TABLE "finding_chains" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"scan_id" uuid NOT NULL,
"title" text NOT NULL,
"severity" text NOT NULL,
"description" text NOT NULL,
"steps" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "findings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"scan_id" uuid NOT NULL,
"file_path" text NOT NULL,
"severity" text NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"vulnerable_code" text NOT NULL,
"suggested_fix" text NOT NULL,
"start_line" integer,
"end_line" integer,
"category" text,
"cve_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "files_scanned" integer;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "finding_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "stage" text;--> statement-breakpoint
ALTER TABLE "finding_chains" ADD CONSTRAINT "finding_chains_scan_id_scans_id_fk" FOREIGN KEY ("scan_id") REFERENCES "public"."scans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "findings" ADD CONSTRAINT "findings_scan_id_scans_id_fk" FOREIGN KEY ("scan_id") REFERENCES "public"."scans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "advisories_ecosystem_package_idx" ON "advisories" USING btree ("ecosystem","package_name");

View file

@ -0,0 +1,6 @@
ALTER TABLE "scans" ADD COLUMN "fix_status" text;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "fix_pr_url" text;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "fix_pr_number" integer;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "fixed_count" integer;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "skipped_count" integer;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "fix_error" text;

View file

@ -0,0 +1,33 @@
CREATE TABLE "connected_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"registry_connection_id" uuid NOT NULL,
"repository" text NOT NULL,
"tag" text DEFAULT 'latest' NOT NULL,
"image_ref" text NOT NULL,
"name" text NOT NULL,
"last_digest" text,
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "connected_images_unique" UNIQUE("user_id","registry_connection_id","repository","tag")
);
--> statement-breakpoint
CREATE TABLE "registry_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" text NOT NULL,
"host" text NOT NULL,
"username" text,
"secret_encrypted" text,
"extra" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "registry_connections_user_type_host_unique" UNIQUE("user_id","type","host")
);
--> statement-breakpoint
ALTER TABLE "scans" ALTER COLUMN "connected_repository_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "target_type" text DEFAULT 'repo' NOT NULL;--> statement-breakpoint
ALTER TABLE "scans" ADD COLUMN "connected_image_id" uuid;--> statement-breakpoint
ALTER TABLE "connected_images" ADD CONSTRAINT "connected_images_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connected_images" ADD CONSTRAINT "connected_images_registry_connection_id_registry_connections_id_fk" FOREIGN KEY ("registry_connection_id") REFERENCES "public"."registry_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "registry_connections" ADD CONSTRAINT "registry_connections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scans" ADD CONSTRAINT "scans_connected_image_id_connected_images_id_fk" FOREIGN KEY ("connected_image_id") REFERENCES "public"."connected_images"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,85 @@
{
"id": "477250b6-b61f-46dc-afc0-73156e3ddbca",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workos_user_id": {
"name": "workos_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_workos_user_id_unique": {
"name": "users_workos_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"workos_user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,276 @@
{
"id": "c1bd980e-f301-4f57-b44d-f97abf9f84cd",
"prevId": "477250b6-b61f-46dc-afc0-73156e3ddbca",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.connected_repositories": {
"name": "connected_repositories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_repo_id": {
"name": "github_repo_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private": {
"name": "private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"default_branch": {
"name": "default_branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"html_url": {
"name": "html_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"connected_at": {
"name": "connected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"connected_repositories_user_id_users_id_fk": {
"name": "connected_repositories_user_id_users_id_fk",
"tableFrom": "connected_repositories",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connected_repositories_user_repo_unique": {
"name": "connected_repositories_user_repo_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"github_repo_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.github_connections": {
"name": "github_connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_user_id": {
"name": "github_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"github_login": {
"name": "github_login",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token_encrypted": {
"name": "access_token_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"github_connections_user_id_users_id_fk": {
"name": "github_connections_user_id_users_id_fk",
"tableFrom": "github_connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"github_connections_user_id_unique": {
"name": "github_connections_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workos_user_id": {
"name": "workos_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_workos_user_id_unique": {
"name": "users_workos_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"workos_user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,348 @@
{
"id": "2b85b410-27ac-4e5a-9f13-dd8b76aa206e",
"prevId": "c1bd980e-f301-4f57-b44d-f97abf9f84cd",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.connected_repositories": {
"name": "connected_repositories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_repo_id": {
"name": "github_repo_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private": {
"name": "private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"default_branch": {
"name": "default_branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"html_url": {
"name": "html_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"connected_at": {
"name": "connected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"connected_repositories_user_id_users_id_fk": {
"name": "connected_repositories_user_id_users_id_fk",
"tableFrom": "connected_repositories",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connected_repositories_user_repo_unique": {
"name": "connected_repositories_user_repo_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"github_repo_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.github_connections": {
"name": "github_connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_user_id": {
"name": "github_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"github_login": {
"name": "github_login",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token_encrypted": {
"name": "access_token_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"github_connections_user_id_users_id_fk": {
"name": "github_connections_user_id_users_id_fk",
"tableFrom": "github_connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"github_connections_user_id_unique": {
"name": "github_connections_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.scans": {
"name": "scans",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"connected_repository_id": {
"name": "connected_repository_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"file_count": {
"name": "file_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"scans_connected_repository_id_connected_repositories_id_fk": {
"name": "scans_connected_repository_id_connected_repositories_id_fk",
"tableFrom": "scans",
"tableTo": "connected_repositories",
"columnsFrom": [
"connected_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workos_user_id": {
"name": "workos_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_workos_user_id_unique": {
"name": "users_workos_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"workos_user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,663 @@
{
"id": "0559d6e1-6e5c-4b35-88db-01585aa239a5",
"prevId": "2b85b410-27ac-4e5a-9f13-dd8b76aa206e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.advisories": {
"name": "advisories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"osv_id": {
"name": "osv_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ecosystem": {
"name": "ecosystem",
"type": "text",
"primaryKey": false,
"notNull": true
},
"package_name": {
"name": "package_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false
},
"aliases": {
"name": "aliases",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ranges_raw": {
"name": "ranges_raw",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"modified": {
"name": "modified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"advisories_ecosystem_package_idx": {
"name": "advisories_ecosystem_package_idx",
"columns": [
{
"expression": "ecosystem",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "package_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"advisories_osv_id_unique": {
"name": "advisories_osv_id_unique",
"nullsNotDistinct": false,
"columns": [
"osv_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.connected_repositories": {
"name": "connected_repositories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_repo_id": {
"name": "github_repo_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private": {
"name": "private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"default_branch": {
"name": "default_branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"html_url": {
"name": "html_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"connected_at": {
"name": "connected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"connected_repositories_user_id_users_id_fk": {
"name": "connected_repositories_user_id_users_id_fk",
"tableFrom": "connected_repositories",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connected_repositories_user_repo_unique": {
"name": "connected_repositories_user_repo_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"github_repo_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.finding_chains": {
"name": "finding_chains",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"scan_id": {
"name": "scan_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"steps": {
"name": "steps",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"finding_chains_scan_id_scans_id_fk": {
"name": "finding_chains_scan_id_scans_id_fk",
"tableFrom": "finding_chains",
"tableTo": "scans",
"columnsFrom": [
"scan_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.findings": {
"name": "findings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"scan_id": {
"name": "scan_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vulnerable_code": {
"name": "vulnerable_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"suggested_fix": {
"name": "suggested_fix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_line": {
"name": "start_line",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_line": {
"name": "end_line",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cve_id": {
"name": "cve_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"findings_scan_id_scans_id_fk": {
"name": "findings_scan_id_scans_id_fk",
"tableFrom": "findings",
"tableTo": "scans",
"columnsFrom": [
"scan_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.github_connections": {
"name": "github_connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_user_id": {
"name": "github_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"github_login": {
"name": "github_login",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token_encrypted": {
"name": "access_token_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"github_connections_user_id_users_id_fk": {
"name": "github_connections_user_id_users_id_fk",
"tableFrom": "github_connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"github_connections_user_id_unique": {
"name": "github_connections_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.scans": {
"name": "scans",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"connected_repository_id": {
"name": "connected_repository_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"file_count": {
"name": "file_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"files_scanned": {
"name": "files_scanned",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"finding_count": {
"name": "finding_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"stage": {
"name": "stage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"scans_connected_repository_id_connected_repositories_id_fk": {
"name": "scans_connected_repository_id_connected_repositories_id_fk",
"tableFrom": "scans",
"tableTo": "connected_repositories",
"columnsFrom": [
"connected_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workos_user_id": {
"name": "workos_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_workos_user_id_unique": {
"name": "users_workos_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"workos_user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,699 @@
{
"id": "9e1720f9-1a84-4e75-8725-8171c331bc98",
"prevId": "0559d6e1-6e5c-4b35-88db-01585aa239a5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.advisories": {
"name": "advisories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"osv_id": {
"name": "osv_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ecosystem": {
"name": "ecosystem",
"type": "text",
"primaryKey": false,
"notNull": true
},
"package_name": {
"name": "package_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false
},
"aliases": {
"name": "aliases",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ranges_raw": {
"name": "ranges_raw",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"modified": {
"name": "modified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"advisories_ecosystem_package_idx": {
"name": "advisories_ecosystem_package_idx",
"columns": [
{
"expression": "ecosystem",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "package_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"advisories_osv_id_unique": {
"name": "advisories_osv_id_unique",
"nullsNotDistinct": false,
"columns": [
"osv_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.connected_repositories": {
"name": "connected_repositories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_repo_id": {
"name": "github_repo_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private": {
"name": "private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"default_branch": {
"name": "default_branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"html_url": {
"name": "html_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"connected_at": {
"name": "connected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"connected_repositories_user_id_users_id_fk": {
"name": "connected_repositories_user_id_users_id_fk",
"tableFrom": "connected_repositories",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connected_repositories_user_repo_unique": {
"name": "connected_repositories_user_repo_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"github_repo_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.finding_chains": {
"name": "finding_chains",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"scan_id": {
"name": "scan_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"steps": {
"name": "steps",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"finding_chains_scan_id_scans_id_fk": {
"name": "finding_chains_scan_id_scans_id_fk",
"tableFrom": "finding_chains",
"tableTo": "scans",
"columnsFrom": [
"scan_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.findings": {
"name": "findings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"scan_id": {
"name": "scan_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vulnerable_code": {
"name": "vulnerable_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"suggested_fix": {
"name": "suggested_fix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_line": {
"name": "start_line",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_line": {
"name": "end_line",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cve_id": {
"name": "cve_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"findings_scan_id_scans_id_fk": {
"name": "findings_scan_id_scans_id_fk",
"tableFrom": "findings",
"tableTo": "scans",
"columnsFrom": [
"scan_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.github_connections": {
"name": "github_connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_user_id": {
"name": "github_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"github_login": {
"name": "github_login",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token_encrypted": {
"name": "access_token_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"github_connections_user_id_users_id_fk": {
"name": "github_connections_user_id_users_id_fk",
"tableFrom": "github_connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"github_connections_user_id_unique": {
"name": "github_connections_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.scans": {
"name": "scans",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"connected_repository_id": {
"name": "connected_repository_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"file_count": {
"name": "file_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"files_scanned": {
"name": "files_scanned",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"finding_count": {
"name": "finding_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"stage": {
"name": "stage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fix_status": {
"name": "fix_status",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fix_pr_url": {
"name": "fix_pr_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fix_pr_number": {
"name": "fix_pr_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fixed_count": {
"name": "fixed_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"skipped_count": {
"name": "skipped_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fix_error": {
"name": "fix_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"scans_connected_repository_id_connected_repositories_id_fk": {
"name": "scans_connected_repository_id_connected_repositories_id_fk",
"tableFrom": "scans",
"tableTo": "connected_repositories",
"columnsFrom": [
"connected_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workos_user_id": {
"name": "workos_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_workos_user_id_unique": {
"name": "users_workos_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"workos_user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,927 @@
{
"id": "5827a037-8fb3-4e23-b75c-f5d690d6643e",
"prevId": "9e1720f9-1a84-4e75-8725-8171c331bc98",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.advisories": {
"name": "advisories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"osv_id": {
"name": "osv_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ecosystem": {
"name": "ecosystem",
"type": "text",
"primaryKey": false,
"notNull": true
},
"package_name": {
"name": "package_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false
},
"aliases": {
"name": "aliases",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ranges_raw": {
"name": "ranges_raw",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"modified": {
"name": "modified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"advisories_ecosystem_package_idx": {
"name": "advisories_ecosystem_package_idx",
"columns": [
{
"expression": "ecosystem",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "package_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"advisories_osv_id_unique": {
"name": "advisories_osv_id_unique",
"nullsNotDistinct": false,
"columns": [
"osv_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.connected_images": {
"name": "connected_images",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"registry_connection_id": {
"name": "registry_connection_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"repository": {
"name": "repository",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'latest'"
},
"image_ref": {
"name": "image_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_digest": {
"name": "last_digest",
"type": "text",
"primaryKey": false,
"notNull": false
},
"connected_at": {
"name": "connected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"connected_images_user_id_users_id_fk": {
"name": "connected_images_user_id_users_id_fk",
"tableFrom": "connected_images",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"connected_images_registry_connection_id_registry_connections_id_fk": {
"name": "connected_images_registry_connection_id_registry_connections_id_fk",
"tableFrom": "connected_images",
"tableTo": "registry_connections",
"columnsFrom": [
"registry_connection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connected_images_unique": {
"name": "connected_images_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"registry_connection_id",
"repository",
"tag"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.connected_repositories": {
"name": "connected_repositories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_repo_id": {
"name": "github_repo_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private": {
"name": "private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"default_branch": {
"name": "default_branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"html_url": {
"name": "html_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"connected_at": {
"name": "connected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"connected_repositories_user_id_users_id_fk": {
"name": "connected_repositories_user_id_users_id_fk",
"tableFrom": "connected_repositories",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connected_repositories_user_repo_unique": {
"name": "connected_repositories_user_repo_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"github_repo_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.finding_chains": {
"name": "finding_chains",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"scan_id": {
"name": "scan_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"steps": {
"name": "steps",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"finding_chains_scan_id_scans_id_fk": {
"name": "finding_chains_scan_id_scans_id_fk",
"tableFrom": "finding_chains",
"tableTo": "scans",
"columnsFrom": [
"scan_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.findings": {
"name": "findings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"scan_id": {
"name": "scan_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vulnerable_code": {
"name": "vulnerable_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"suggested_fix": {
"name": "suggested_fix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_line": {
"name": "start_line",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_line": {
"name": "end_line",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cve_id": {
"name": "cve_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"findings_scan_id_scans_id_fk": {
"name": "findings_scan_id_scans_id_fk",
"tableFrom": "findings",
"tableTo": "scans",
"columnsFrom": [
"scan_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.github_connections": {
"name": "github_connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"github_user_id": {
"name": "github_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"github_login": {
"name": "github_login",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token_encrypted": {
"name": "access_token_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"github_connections_user_id_users_id_fk": {
"name": "github_connections_user_id_users_id_fk",
"tableFrom": "github_connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"github_connections_user_id_unique": {
"name": "github_connections_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.registry_connections": {
"name": "registry_connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"secret_encrypted": {
"name": "secret_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"extra": {
"name": "extra",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"registry_connections_user_id_users_id_fk": {
"name": "registry_connections_user_id_users_id_fk",
"tableFrom": "registry_connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"registry_connections_user_type_host_unique": {
"name": "registry_connections_user_type_host_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"type",
"host"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.scans": {
"name": "scans",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'repo'"
},
"connected_repository_id": {
"name": "connected_repository_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"connected_image_id": {
"name": "connected_image_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"file_count": {
"name": "file_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"files_scanned": {
"name": "files_scanned",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"finding_count": {
"name": "finding_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"stage": {
"name": "stage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fix_status": {
"name": "fix_status",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fix_pr_url": {
"name": "fix_pr_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fix_pr_number": {
"name": "fix_pr_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fixed_count": {
"name": "fixed_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"skipped_count": {
"name": "skipped_count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fix_error": {
"name": "fix_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"scans_connected_repository_id_connected_repositories_id_fk": {
"name": "scans_connected_repository_id_connected_repositories_id_fk",
"tableFrom": "scans",
"tableTo": "connected_repositories",
"columnsFrom": [
"connected_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"scans_connected_image_id_connected_images_id_fk": {
"name": "scans_connected_image_id_connected_images_id_fk",
"tableFrom": "scans",
"tableTo": "connected_images",
"columnsFrom": [
"connected_image_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workos_user_id": {
"name": "workos_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_workos_user_id_unique": {
"name": "users_workos_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"workos_user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1782660286911,
"tag": "0000_nappy_darkstar",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782679895454,
"tag": "0001_parched_mercury",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782801265021,
"tag": "0002_pink_alex_power",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1783008905222,
"tag": "0003_classy_roland_deschain",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1783016510687,
"tag": "0004_peaceful_golden_guardian",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1783026891296,
"tag": "0005_elite_living_tribunal",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,45 @@
import { and, desc, eq } from "drizzle-orm";
import { getDb } from "./client";
import { registryConnections, type NewRegistryConnection, type RegistryConnection } from "./schema";
// Upserts a registry connection keyed on (userId, type, host) so reconnecting the
// same registry refreshes its credential instead of duplicating.
export async function upsertRegistryConnection(row: NewRegistryConnection): Promise<RegistryConnection> {
const [saved] = await getDb()
.insert(registryConnections)
.values(row)
.onConflictDoUpdate({
target: [registryConnections.userId, registryConnections.type, registryConnections.host],
set: {
username: row.username ?? null,
secretEncrypted: row.secretEncrypted ?? null,
extra: row.extra ?? null,
updatedAt: new Date(),
},
})
.returning();
return saved;
}
export async function listRegistryConnections(userId: string): Promise<RegistryConnection[]> {
return getDb()
.select()
.from(registryConnections)
.where(eq(registryConnections.userId, userId))
.orderBy(desc(registryConnections.createdAt));
}
export async function getRegistryConnection(userId: string, id: string): Promise<RegistryConnection | null> {
const [row] = await getDb()
.select()
.from(registryConnections)
.where(and(eq(registryConnections.id, id), eq(registryConnections.userId, userId)))
.limit(1);
return row ?? null;
}
export async function deleteRegistryConnection(userId: string, id: string): Promise<void> {
await getDb()
.delete(registryConnections)
.where(and(eq(registryConnections.id, id), eq(registryConnections.userId, userId)));
}

View file

@ -0,0 +1,105 @@
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import { getDb } from "./client";
import { scans, type Scan } from "./schema";
export async function createScan(connectedRepositoryId: string): Promise<string> {
const [row] = await getDb()
.insert(scans)
.values({ connectedRepositoryId, targetType: "repo", status: "queued" })
.returning({ id: scans.id });
return row.id;
}
export async function createImageScan(connectedImageId: string): Promise<string> {
const [row] = await getDb()
.insert(scans)
.values({ connectedImageId, targetType: "image", status: "queued" })
.returning({ id: scans.id });
return row.id;
}
export async function getScan(scanId: string): Promise<Scan | null> {
const [row] = await getDb().select().from(scans).where(eq(scans.id, scanId)).limit(1);
return row ?? null;
}
export async function markScanRunning(scanId: string): Promise<void> {
await getDb().update(scans).set({ status: "running" }).where(eq(scans.id, scanId));
}
export async function completeScan(scanId: string, fileCount: number): Promise<void> {
await getDb()
.update(scans)
.set({ status: "completed", fileCount, filesScanned: fileCount, stage: "done", error: null, finishedAt: new Date() })
.where(eq(scans.id, scanId));
}
export async function failScan(scanId: string, error: string): Promise<void> {
await getDb()
.update(scans)
.set({ status: "failed", error, finishedAt: new Date() })
.where(eq(scans.id, scanId));
}
// Mark any queued/running scans for a connected repository as failed. Used when the
// user retries a scan so the old one doesn't stay stuck in "Scanning…" forever.
export async function failScansForRepo(connectedRepositoryId: string, error: string): Promise<void> {
await getDb()
.update(scans)
.set({ status: "failed", error, finishedAt: new Date() })
.where(
and(
eq(scans.connectedRepositoryId, connectedRepositoryId),
or(eq(scans.status, "queued"), eq(scans.status, "running")),
),
);
}
// Mark any queued/running scans older than `maxAgeMinutes` as failed. Run on startup
// to clean up scans that were abandoned by a crash, deploy, or failed callback.
export async function failStaleScans(maxAgeMinutes: number, error: string): Promise<number> {
const cutoff = sql`now() - interval '${sql.raw(String(maxAgeMinutes))} minutes'`;
const result = await getDb()
.update(scans)
.set({ status: "failed", error, finishedAt: new Date() })
.where(
and(
or(eq(scans.status, "queued"), eq(scans.status, "running")),
sql`${scans.createdAt} < ${cutoff}`,
),
)
.returning({ id: scans.id });
return result.length;
}
// Latest scan per connected repository, keyed by connectedRepositoryId.
export async function latestScansForRepos(repoIds: string[]): Promise<Map<string, Scan>> {
if (repoIds.length === 0) return new Map();
const rows = await getDb()
.select()
.from(scans)
.where(inArray(scans.connectedRepositoryId, repoIds))
.orderBy(desc(scans.createdAt));
const latest = new Map<string, Scan>();
for (const row of rows) {
const key = row.connectedRepositoryId;
if (key && !latest.has(key)) latest.set(key, row);
}
return latest;
}
// Latest scan per connected image, keyed by connectedImageId.
export async function latestScansForImages(imageIds: string[]): Promise<Map<string, Scan>> {
if (imageIds.length === 0) return new Map();
const rows = await getDb()
.select()
.from(scans)
.where(inArray(scans.connectedImageId, imageIds))
.orderBy(desc(scans.createdAt));
const latest = new Map<string, Scan>();
for (const row of rows) {
const key = row.connectedImageId;
if (key && !latest.has(key)) latest.set(key, row);
}
return latest;
}

View file

@ -0,0 +1,196 @@
import { pgTable, uuid, text, boolean, integer, timestamp, jsonb, unique, index } from "drizzle-orm/pg-core";
// Application users, synced from WorkOS on each login (see db/users.ts).
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
workosUserId: text("workos_user_id").notNull().unique(),
email: text("email").notNull(),
firstName: text("first_name"),
lastName: text("last_name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// A user's connected GitHub account. One per user; the OAuth access token is
// stored encrypted (AES-256-GCM, see lib/crypto.ts) and only decrypted to call
// the GitHub API from the backend.
export const githubConnections = pgTable("github_connections", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
githubUserId: text("github_user_id").notNull(),
githubLogin: text("github_login").notNull(),
accessTokenEncrypted: text("access_token_encrypted").notNull(),
scope: text("scope"),
tokenType: text("token_type"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// Repositories a user has explicitly connected as projects.
export const connectedRepositories = pgTable(
"connected_repositories",
{
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
githubRepoId: text("github_repo_id").notNull(),
fullName: text("full_name").notNull(),
name: text("name").notNull(),
owner: text("owner").notNull(),
private: boolean("private").notNull().default(false),
defaultBranch: text("default_branch"),
htmlUrl: text("html_url"),
connectedAt: timestamp("connected_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique("connected_repositories_user_repo_unique").on(table.userId, table.githubRepoId)],
);
// A container registry a user has connected (Docker Hub, GHCR, GAR, ECR, …). Multiple
// per user; the pull credential is stored encrypted (see lib/crypto.ts).
export const registryConnections = pgTable(
"registry_connections",
{
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(), // dockerhub | ghcr | gar | ecr | generic
host: text("host").notNull(),
username: text("username"),
secretEncrypted: text("secret_encrypted"),
extra: jsonb("extra"), // provider-specific (e.g. { region } for ECR, { project } for GAR)
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique("registry_connections_user_type_host_unique").on(table.userId, table.type, table.host)],
);
// A specific container image the user has picked to scan from a connected registry.
export const connectedImages = pgTable(
"connected_images",
{
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
registryConnectionId: uuid("registry_connection_id")
.notNull()
.references(() => registryConnections.id, { onDelete: "cascade" }),
repository: text("repository").notNull(), // e.g. library/nginx
tag: text("tag").notNull().default("latest"),
imageRef: text("image_ref").notNull(), // full ref, e.g. docker.io/library/nginx:latest
name: text("name").notNull(),
lastDigest: text("last_digest"),
connectedAt: timestamp("connected_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique("connected_images_unique").on(table.userId, table.registryConnectionId, table.repository, table.tag),
],
);
// Scans of a connected repository OR container image (targetType). The row tracks
// lifecycle (queued → running → completed/failed), progress, and result counts. The
// actual vulnerabilities live in `findings` / `findingChains`.
export const scans = pgTable("scans", {
id: uuid("id").defaultRandom().primaryKey(),
// Exactly one target FK is set, per targetType.
targetType: text("target_type").notNull().default("repo"), // repo | image
connectedRepositoryId: uuid("connected_repository_id").references(() => connectedRepositories.id, {
onDelete: "cascade",
}),
connectedImageId: uuid("connected_image_id").references(() => connectedImages.id, { onDelete: "cascade" }),
status: text("status").notNull().default("queued"),
fileCount: integer("file_count"),
// Progress: how many eligible files have been analyzed so far (of fileCount).
filesScanned: integer("files_scanned"),
findingCount: integer("finding_count").notNull().default(0),
// Coarse lifecycle stage for the UI: download | analyzing | chaining | done.
stage: text("stage"),
error: text("error"),
// Auto-fix PR lifecycle: creating | opened | no_fixes | failed (null = not attempted).
fixStatus: text("fix_status"),
fixPrUrl: text("fix_pr_url"),
fixPrNumber: integer("fix_pr_number"),
fixedCount: integer("fixed_count"),
skippedCount: integer("skipped_count"),
fixError: text("fix_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp("finished_at", { withTimezone: true }),
});
// A single vulnerability found in one file. `vulnerableCode` is the exact snippet
// to replace (rendered RED in the UI); `suggestedFix` is the proposed replacement
// (rendered GREEN). The `id` may be supplied by the scanner so chains can reference
// it before it's persisted.
export const findings = pgTable("findings", {
id: uuid("id").defaultRandom().primaryKey(),
scanId: uuid("scan_id")
.notNull()
.references(() => scans.id, { onDelete: "cascade" }),
filePath: text("file_path").notNull(),
severity: text("severity").notNull(), // critical | high | medium | low
title: text("title").notNull(),
description: text("description").notNull(),
vulnerableCode: text("vulnerable_code").notNull(),
suggestedFix: text("suggested_fix").notNull(),
startLine: integer("start_line"),
endLine: integer("end_line"),
category: text("category"), // code | dependency
cveId: text("cve_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// An attack path chaining multiple findings, produced by the chaining pass.
export const findingChains = pgTable("finding_chains", {
id: uuid("id").defaultRandom().primaryKey(),
scanId: uuid("scan_id")
.notNull()
.references(() => scans.id, { onDelete: "cascade" }),
title: text("title").notNull(),
severity: text("severity").notNull(),
description: text("description").notNull(),
steps: jsonb("steps").notNull(), // [{ findingId?, filePath, note }]
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// Cache of CVE/security advisories, synced from OSV.dev. Grows on demand as scans
// query dependency versions; can also be bulk-loaded (see cve:sync).
export const advisories = pgTable(
"advisories",
{
id: uuid("id").defaultRandom().primaryKey(),
osvId: text("osv_id").notNull().unique(),
ecosystem: text("ecosystem").notNull(),
packageName: text("package_name").notNull(),
severity: text("severity"),
summary: text("summary"),
details: text("details"),
aliases: jsonb("aliases"), // string[] of CVE/GHSA ids
rangesRaw: jsonb("ranges_raw").notNull(), // OSV affected[].ranges + versions
modified: timestamp("modified", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("advisories_ecosystem_package_idx").on(table.ecosystem, table.packageName)],
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type GithubConnection = typeof githubConnections.$inferSelect;
export type ConnectedRepository = typeof connectedRepositories.$inferSelect;
export type NewConnectedRepository = typeof connectedRepositories.$inferInsert;
export type Scan = typeof scans.$inferSelect;
export type Finding = typeof findings.$inferSelect;
export type NewFinding = typeof findings.$inferInsert;
export type FindingChain = typeof findingChains.$inferSelect;
export type NewFindingChain = typeof findingChains.$inferInsert;
export type Advisory = typeof advisories.$inferSelect;
export type NewAdvisory = typeof advisories.$inferInsert;
export type RegistryConnection = typeof registryConnections.$inferSelect;
export type NewRegistryConnection = typeof registryConnections.$inferInsert;
export type ConnectedImage = typeof connectedImages.$inferSelect;
export type NewConnectedImage = typeof connectedImages.$inferInsert;

View file

@ -0,0 +1,37 @@
import { eq } from "drizzle-orm";
import { getDb } from "./client";
import { users } from "./schema";
export interface WorkosUserInput {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
}
// Upsert the WorkOS user into Supabase and return the local users.id.
export async function upsertUser(user: WorkosUserInput): Promise<string> {
const [row] = await getDb()
.insert(users)
.values({
workosUserId: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
})
.onConflictDoUpdate({
target: users.workosUserId,
set: { email: user.email, firstName: user.firstName, lastName: user.lastName, updatedAt: new Date() },
})
.returning({ id: users.id });
return row.id;
}
export async function getUserId(workosUserId: string): Promise<string | null> {
const [row] = await getDb()
.select({ id: users.id })
.from(users)
.where(eq(users.workosUserId, workosUserId))
.limit(1);
return row?.id ?? null;
}

View file

@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import { isLocalDevAuthEnabled } from "./env";
describe("local integration authentication", () => {
test("is available only when explicitly configured outside production", () => {
expect(isLocalDevAuthEnabled("development", "guest@cefense.local")).toBe(true);
expect(isLocalDevAuthEnabled("test", "guest@cefense.local")).toBe(true);
expect(isLocalDevAuthEnabled("development", undefined)).toBe(false);
});
test("cannot replace WorkOS in production", () => {
expect(isLocalDevAuthEnabled("production", "guest@cefense.local")).toBe(false);
});
});

95
apps/backend/src/env.ts Normal file
View file

@ -0,0 +1,95 @@
import { z } from "zod";
// Treat empty-string env vars (common when copying .env.example) as unset, so
// optional integrations stay "not configured" instead of failing validation.
const cleaned = Object.fromEntries(
Object.entries(process.env).map(([key, value]) => [key, value === "" ? undefined : value]),
);
const schema = z.object({
PORT: z.coerce.number().default(3001),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).optional(),
FRONTEND_URL: z.string().default("http://localhost:5173"),
WORKOS_API_KEY: z.string().optional(),
WORKOS_CLIENT_ID: z.string().optional(),
WORKOS_COOKIE_PASSWORD: z.string().min(32, "WORKOS_COOKIE_PASSWORD must be at least 32 characters").optional(),
WORKOS_REDIRECT_URI: z.string().default("http://localhost:3001/auth/callback"),
// Explicit local-only identity for Docker/E2E runs. It is ignored in
// production so WorkOS remains the sole production authentication backend.
LOCAL_DEV_AUTH_EMAIL: z.email().optional(),
LOCAL_DEV_AUTH_FIRST_NAME: z.string().default("Guest"),
LOCAL_DEV_AUTH_LAST_NAME: z.string().default(""),
DATABASE_URL: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITHUB_REDIRECT_URI: z.string().default("http://localhost:3001/api/github/callback"),
APP_ENCRYPTION_KEY: z.string().optional(),
// Scanner (apps/cli) orchestration.
SCAN_RUNNER: z.enum(["inline", "docker", "cloudrun"]).default("inline"),
SCAN_CALLBACK_URL: z.string().default("http://localhost:3001"),
SCAN_CALLBACK_SECRET: z.string().optional(),
SCANNER_IMAGE: z.string().optional(),
GCP_PROJECT: z.string().optional(),
GCP_REGION: z.string().default("us-central1"),
SCAN_JOB_NAME: z.string().default("cerebrus-scanner"),
// Service-account key JSON, inlined. Preferred for Cloud Run because the file is
// baked into the image with the rest of the env. For local dev you can instead set
// GOOGLE_APPLICATION_CREDENTIALS to a file path.
GOOGLE_SERVICE_ACCOUNT_JSON: z.string().optional(),
// Path to a service-account key JSON file. Useful for local dev; ignored if
// GOOGLE_SERVICE_ACCOUNT_JSON is set.
GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(),
// DeepSeek — the LLM that performs the actual file-by-file vulnerability analysis.
DEEPSEEK_API_KEY: z.string().optional(),
DEEPSEEK_MODEL: z.string().default("deepseek-reasoner"),
DEEPSEEK_BASE_URL: z.string().default("https://api.deepseek.com"),
DEEPSEEK_CONCURRENCY: z.coerce.number().int().positive().default(4),
// Per-scan logs are written here (host-readable; docker mode redirects the
// container's stdout/stderr into <SCAN_LOG_DIR>/scan-<scanId>.log).
SCAN_LOG_DIR: z.string().default("./scan-logs"),
// Ecosystems to bulk-load for the optional `cve:sync` script (on-demand querybatch
// needs no config).
OSV_ECOSYSTEMS: z.string().default("npm,PyPI,Go,crates.io,Maven,RubyGems"),
// Auto-fix: after a scan finds vulnerabilities, open a PR on the user's repo with the
// suggested fixes applied. Set to "false" to disable (findings still show, no PR).
SCAN_AUTOFIX: z.string().default("true"),
});
const parsed = schema.safeParse(cleaned);
if (!parsed.success) {
console.error("Invalid environment configuration:\n", z.treeifyError(parsed.error));
process.exit(1);
}
const raw = parsed.data;
export function isLocalDevAuthEnabled(
nodeEnv: "development" | "production" | "test",
email?: string,
): boolean {
return nodeEnv !== "production" && Boolean(email);
}
export const env = {
...raw,
isProd: raw.NODE_ENV === "production",
// Auth and DB are independently optional so the server boots un-configured and
// surfaces a clear status instead of crashing.
authConfigured: Boolean(raw.WORKOS_API_KEY && raw.WORKOS_CLIENT_ID && raw.WORKOS_COOKIE_PASSWORD),
localDevAuthEnabled: isLocalDevAuthEnabled(raw.NODE_ENV, raw.LOCAL_DEV_AUTH_EMAIL),
dbConfigured: Boolean(raw.DATABASE_URL),
githubConfigured: Boolean(raw.GITHUB_CLIENT_ID && raw.GITHUB_CLIENT_SECRET && raw.APP_ENCRYPTION_KEY),
scannerConfigured: Boolean(raw.DEEPSEEK_API_KEY),
autofixEnabled: raw.SCAN_AUTOFIX !== "false",
};

View file

@ -0,0 +1,168 @@
import { env } from "../env";
import { logger } from "../lib/logger";
import { decrypt } from "../lib/crypto";
import { loadScanContext, setFixStatus } from "../db/fix";
import { getScan } from "../db/scans";
import { findingsForScan } from "../db/findings";
import type { Finding } from "../db/schema";
import {
createCommit,
createPullRequest,
createRef,
createTree,
getCommitTree,
getFileContent,
getRef,
getRepoDefaultBranch,
type TreeFile,
} from "../lib/github";
interface Skipped {
finding: Finding;
reason: string;
}
// After a scan completes, open a PR on the user's repo applying the suggested fixes.
// Fire-and-forget: callers `void createFixPr(scanId)` and never await it. Every exit
// path records a fixStatus so the dashboard can reflect what happened.
export async function createFixPr(scanId: string): Promise<void> {
if (!env.autofixEnabled) return;
try {
// Auto-fix PRs only apply to repo scans. Image scans have no repo to open a PR
// against — bail before loadScanContext (whose repo join would return null and
// mislabel the scan as a failed fix).
const scan = await getScan(scanId);
if (!scan || scan.targetType !== "repo") return;
const ctx = await loadScanContext(scanId);
if (!ctx) {
await setFixStatus(scanId, { fixStatus: "failed", fixError: "GitHub connection unavailable — reconnect GitHub." });
return;
}
let token: string;
try {
token = decrypt(ctx.accessTokenEncrypted);
} catch {
await setFixStatus(scanId, { fixStatus: "failed", fixError: "Stored GitHub token unreadable — reconnect GitHub." });
return;
}
const findings = await findingsForScan(scanId);
const codeFindings = findings.filter((f) => (f.category === "code" || f.category == null) && f.vulnerableCode && f.suggestedFix);
const depFindings = findings.filter((f) => f.category === "dependency");
if (codeFindings.length === 0) {
await setFixStatus(scanId, { fixStatus: "no_fixes", fixedCount: 0, skippedCount: 0 });
return;
}
await setFixStatus(scanId, { fixStatus: "creating" });
const { owner, name } = ctx;
const base = ctx.defaultBranch ?? (await getRepoDefaultBranch(token, owner, name));
// Apply each fix to its file, but only when the vulnerable snippet is an exact,
// unique substring — otherwise skip it (never corrupt the file).
const byFile = new Map<string, Finding[]>();
for (const f of codeFindings) {
const list = byFile.get(f.filePath);
if (list) list.push(f);
else byFile.set(f.filePath, [f]);
}
const changedFiles: TreeFile[] = [];
const fixed: Finding[] = [];
const skipped: Skipped[] = [];
for (const [path, fileFindings] of byFile) {
let content: string;
try {
content = (await getFileContent(token, owner, name, path, base)).content;
} catch {
for (const f of fileFindings) skipped.push({ finding: f, reason: "file not found on the default branch" });
continue;
}
let updated = content;
let fileChanged = false;
for (const f of fileFindings) {
const occurrences = updated.split(f.vulnerableCode).length - 1;
if (occurrences === 1) {
updated = updated.replace(f.vulnerableCode, f.suggestedFix);
fixed.push(f);
fileChanged = true;
} else {
skipped.push({
finding: f,
reason: occurrences === 0 ? "snippet not found (file may have changed)" : "snippet appears multiple times (ambiguous)",
});
}
}
if (fileChanged) changedFiles.push({ path, content: updated });
}
if (changedFiles.length === 0) {
await setFixStatus(scanId, { fixStatus: "no_fixes", fixedCount: 0, skippedCount: skipped.length });
return;
}
// Build one commit on a new branch via the Git Data API, then open the PR.
const baseSha = await getRef(token, owner, name, base);
const baseTree = await getCommitTree(token, owner, name, baseSha);
const newTree = await createTree(token, owner, name, baseTree, changedFiles);
const title = `Cerebrus: fix ${fixed.length} security finding${fixed.length === 1 ? "" : "s"}`;
const commitSha = await createCommit(token, owner, name, title, newTree, baseSha);
const branch = await createRef(token, owner, name, `cerebrus/fix-${scanId.slice(0, 8)}`, commitSha);
const pr = await createPullRequest(token, owner, name, {
title,
head: branch,
base,
body: buildPrBody(fixed, skipped, depFindings, base),
});
await setFixStatus(scanId, {
fixStatus: "opened",
fixPrUrl: pr.url,
fixPrNumber: pr.number,
fixedCount: fixed.length,
skippedCount: skipped.length,
});
logger.info({ scanId, pr: pr.number, fixed: fixed.length, skipped: skipped.length }, "opened fix PR");
} catch (err) {
const message = scrubToken(err instanceof Error ? err.message : String(err));
logger.error({ err, scanId }, "fix PR failed");
await setFixStatus(scanId, { fixStatus: "failed", fixError: message }).catch(() => {});
}
}
function buildPrBody(fixed: Finding[], skipped: Skipped[], deps: Finding[], base: string): string {
const lines: string[] = [];
lines.push(`Automated security fixes generated by **Cerebrus** from a scan of \`${base}\`.`);
lines.push("");
lines.push(`## Fixed (${fixed.length})`);
for (const f of fixed) lines.push(`- \`${f.severity}\` \`${f.filePath}\`${f.title}`);
if (skipped.length > 0) {
lines.push("");
lines.push(`## Skipped — needs manual review (${skipped.length})`);
for (const s of skipped) lines.push(`- \`${s.finding.filePath}\`${s.finding.title} _(${s.reason})_`);
}
if (deps.length > 0) {
lines.push("");
lines.push(`## Vulnerable dependencies (${deps.length})`);
for (const d of deps) lines.push(`- ${d.cveId ? `\`${d.cveId}\` ` : ""}${d.title}${d.suggestedFix}`);
}
lines.push("");
lines.push(
"> ⚠️ These fixes were generated by an LLM (DeepSeek). Review them carefully before merging — they are not guaranteed to be correct or complete.",
);
return lines.join("\n");
}
// Defensive: strip anything that looks like a GitHub token from error text before it
// lands in the DB / logs.
function scrubToken(message: string): string {
return message.replace(/gh[opsu]_[A-Za-z0-9]{20,}/g, "***");
}

57
apps/backend/src/index.ts Normal file
View file

@ -0,0 +1,57 @@
import express from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import { env } from "./env";
import { logger } from "./lib/logger";
import { httpLogger } from "./middleware/logging";
import { healthRouter } from "./routes/health";
import { authRouter } from "./routes/auth";
import { githubRouter } from "./routes/github";
import { registryRouter } from "./routes/registry";
import { internalRouter } from "./routes/internal";
import { apiRouter } from "./routes/api";
import { errorHandler } from "./middleware/error";
import { failStaleScans } from "./db/scans";
const app = express();
// Behind the Cloud Run proxy in production: trust X-Forwarded-* for correct
// protocol/client IP (and secure-cookie handling).
if (env.isProd) app.set("trust proxy", true);
app.use(httpLogger);
app.use(express.json());
app.use(cookieParser());
app.use(cors({ origin: env.FRONTEND_URL, credentials: true }));
app.use(healthRouter);
app.use("/internal", internalRouter);
app.use("/auth", authRouter);
app.use("/api/github", githubRouter);
app.use("/api/registry", registryRouter);
app.use("/api", apiRouter);
app.use(errorHandler);
app.listen(env.PORT, async () => {
logger.info(
{
port: env.PORT,
authConfigured: env.authConfigured,
dbConfigured: env.dbConfigured,
githubConfigured: env.githubConfigured,
},
`cerebrus-backend listening on port ${env.PORT}`,
);
// Mark scans that were left queued/running across a crash/deploy as failed so the
// UI offers a retry instead of staying stuck.
if (env.dbConfigured) {
try {
const count = await failStaleScans(30, "Scan timed out — no completion signal received");
if (count > 0) logger.info({ count }, "Marked stale scans as failed");
} catch (err) {
logger.error({ err }, "Failed to mark stale scans as failed");
}
}
});

View file

@ -0,0 +1,30 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { env } from "../env";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
// Derive a fixed 32-byte key from APP_ENCRYPTION_KEY so any-length secret works.
function getKey(): Buffer {
return createHash("sha256").update(env.APP_ENCRYPTION_KEY ?? "").digest();
}
// AES-256-GCM. Output is base64 of iv | authTag | ciphertext.
export function encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ciphertext]).toString("base64");
}
export function decrypt(payload: string): string {
const buf = Buffer.from(payload, "base64");
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = buf.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}

View file

@ -0,0 +1,82 @@
import { createSign } from "node:crypto";
import { Buffer } from "node:buffer";
import { readFileSync } from "node:fs";
import { env } from "../env";
// Google Cloud auth for triggering the Cloud Run scanner job. The service-account
// key can be supplied two ways:
// • GOOGLE_SERVICE_ACCOUNT_JSON — the key JSON inlined in the env (used on Cloud
// Run so no extra file needs to be copied into the image).
// • GOOGLE_APPLICATION_CREDENTIALS — path to a key JSON file (useful for local dev).
// The JWT-bearer grant is used to exchange the key for an access token.
interface SaKey {
client_email: string;
private_key: string;
token_uri: string;
project_id: string;
}
let cachedKey: SaKey | null | undefined;
function loadKey(): SaKey | null {
if (cachedKey !== undefined) return cachedKey;
let raw: string | undefined;
if (env.GOOGLE_SERVICE_ACCOUNT_JSON) {
raw = env.GOOGLE_SERVICE_ACCOUNT_JSON;
} else if (env.GOOGLE_APPLICATION_CREDENTIALS) {
try {
raw = readFileSync(env.GOOGLE_APPLICATION_CREDENTIALS, "utf8");
} catch (err) {
throw new Error(`Could not read GOOGLE_APPLICATION_CREDENTIALS (${env.GOOGLE_APPLICATION_CREDENTIALS})`, { cause: err });
}
}
if (!raw) {
cachedKey = null;
return null;
}
if (!raw.trim()) throw new Error(`Service-account key JSON is empty — paste the key into GOOGLE_SERVICE_ACCOUNT_JSON or the credentials file.`);
const parsed = JSON.parse(raw) as SaKey;
cachedKey = parsed; // only cached on success
return parsed;
}
// Project id: explicit GCP_PROJECT wins, else the key's project_id.
export function gcpProjectId(): string | undefined {
return env.GCP_PROJECT ?? loadKey()?.project_id ?? undefined;
}
export async function gcpAccessToken(): Promise<string> {
const key = loadKey();
if (!key) throw new Error("No GCP service account configured — set GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS");
return tokenFromKey(key);
}
function base64url(input: object): string {
return Buffer.from(JSON.stringify(input)).toString("base64url");
}
async function tokenFromKey(key: SaKey): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: key.client_email,
scope: "https://www.googleapis.com/auth/cloud-platform",
aud: key.token_uri,
iat: now,
exp: now + 3600,
};
const signingInput = `${base64url({ alg: "RS256", typ: "JWT" })}.${base64url(claims)}`;
const signature = createSign("RSA-SHA256").update(signingInput).sign(key.private_key, "base64url");
const assertion = `${signingInput}.${signature}`;
const res = await fetch(key.token_uri, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
});
if (!res.ok) throw new Error(`Service-account token exchange failed (${res.status}): ${await res.text()}`);
return ((await res.json()) as { access_token: string }).access_token;
}

View file

@ -0,0 +1,242 @@
import { Buffer } from "node:buffer";
import { env } from "../env";
import { logger } from "./logger";
const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
const GITHUB_API = "https://api.github.com";
const SCOPES = "repo read:user";
const MAX_PAGES = 5;
export interface GithubRepo {
githubRepoId: string;
name: string;
fullName: string;
owner: string;
private: boolean;
defaultBranch: string | null;
htmlUrl: string | null;
description: string | null;
updatedAt: string | null;
}
export interface GithubViewer {
id: string;
login: string;
}
// Thrown when GitHub rejects the stored token (401) — the user must reconnect.
export class GithubReconnectError extends Error {
constructor() {
super("GitHub token rejected — reconnect required.");
this.name = "GithubReconnectError";
}
}
export function getAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
client_id: env.GITHUB_CLIENT_ID ?? "",
redirect_uri: env.GITHUB_REDIRECT_URI,
scope: SCOPES,
state,
allow_signup: "true",
});
return `${GITHUB_AUTHORIZE_URL}?${params.toString()}`;
}
export async function exchangeCode(code: string): Promise<{ accessToken: string; scope: string; tokenType: string }> {
const res = await fetch(GITHUB_TOKEN_URL, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: env.GITHUB_REDIRECT_URI,
}),
});
const data = (await res.json()) as {
access_token?: string;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || "GitHub token exchange failed.");
}
return { accessToken: data.access_token, scope: data.scope ?? "", tokenType: data.token_type ?? "bearer" };
}
export async function fetchViewer(token: string): Promise<GithubViewer> {
const res = await fetch(`${GITHUB_API}/user`, { headers: githubHeaders(token) });
if (!res.ok) throw apiError(res.status);
const data = (await res.json()) as { id: number; login: string };
return { id: String(data.id), login: data.login };
}
// All repos the user can access (public + private), newest first.
export async function listRepos(token: string): Promise<GithubRepo[]> {
const repos: GithubRepo[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const params = new URLSearchParams({
visibility: "all",
affiliation: "owner,collaborator,organization_member",
per_page: "100",
sort: "updated",
page: String(page),
});
const res = await fetch(`${GITHUB_API}/user/repos?${params.toString()}`, { headers: githubHeaders(token) });
if (!res.ok) throw apiError(res.status);
const batch = (await res.json()) as GithubApiRepo[];
for (const repo of batch) repos.push(mapRepo(repo));
if (batch.length < 100) return repos;
}
logger.warn(`GitHub repo listing capped at ${MAX_PAGES * 100} repositories.`);
return repos;
}
interface GithubApiRepo {
id: number;
name: string;
full_name: string;
owner: { login: string };
private: boolean;
default_branch: string | null;
html_url: string | null;
description: string | null;
updated_at: string | null;
}
function mapRepo(repo: GithubApiRepo): GithubRepo {
return {
githubRepoId: String(repo.id),
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
private: repo.private,
defaultBranch: repo.default_branch ?? null,
htmlUrl: repo.html_url ?? null,
description: repo.description ?? null,
updatedAt: repo.updated_at ?? null,
};
}
function githubHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "cerebrus-backend",
};
}
function apiError(status: number): Error {
if (status === 401) return new GithubReconnectError();
return new Error(`GitHub API error (${status}).`);
}
// ---- Write API (used by the auto-fix step to branch, commit, and open a PR) ----
// Carries the HTTP status so callers can special-case (e.g. 422 branch-exists).
export class GithubHttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "GithubHttpError";
}
}
async function ghRequest<T>(token: string, method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${GITHUB_API}${path}`, {
method,
headers: body ? { ...githubHeaders(token), "Content-Type": "application/json" } : githubHeaders(token),
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 401) throw new GithubReconnectError();
if (!res.ok) throw new GithubHttpError(res.status, `GitHub ${method} ${path}${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
function encodePath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
export async function getRepoDefaultBranch(token: string, owner: string, repo: string): Promise<string> {
const data = await ghRequest<{ default_branch: string }>(token, "GET", `/repos/${owner}/${repo}`);
return data.default_branch;
}
export async function getRef(token: string, owner: string, repo: string, branch: string): Promise<string> {
const data = await ghRequest<{ object: { sha: string } }>(token, "GET", `/repos/${owner}/${repo}/git/ref/heads/${encodePath(branch)}`);
return data.object.sha;
}
export async function getCommitTree(token: string, owner: string, repo: string, sha: string): Promise<string> {
const data = await ghRequest<{ tree: { sha: string } }>(token, "GET", `/repos/${owner}/${repo}/git/commits/${sha}`);
return data.tree.sha;
}
export async function getFileContent(
token: string,
owner: string,
repo: string,
path: string,
ref: string,
): Promise<{ content: string; sha: string }> {
const data = await ghRequest<{ content: string; sha: string; encoding: string }>(
token,
"GET",
`/repos/${owner}/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`,
);
return { content: Buffer.from(data.content, "base64").toString("utf8"), sha: data.sha };
}
export interface TreeFile {
path: string;
content: string;
}
export async function createTree(token: string, owner: string, repo: string, baseTreeSha: string, files: TreeFile[]): Promise<string> {
const data = await ghRequest<{ sha: string }>(token, "POST", `/repos/${owner}/${repo}/git/trees`, {
base_tree: baseTreeSha,
tree: files.map((f) => ({ path: f.path, mode: "100644", type: "blob", content: f.content })),
});
return data.sha;
}
export async function createCommit(token: string, owner: string, repo: string, message: string, treeSha: string, parentSha: string): Promise<string> {
const data = await ghRequest<{ sha: string }>(token, "POST", `/repos/${owner}/${repo}/git/commits`, {
message,
tree: treeSha,
parents: [parentSha],
});
return data.sha;
}
// Creates refs/heads/<branch> at sha. On a 422 (branch already exists), retries once
// with a "-2" suffix and returns the branch name that actually got created.
export async function createRef(token: string, owner: string, repo: string, branch: string, sha: string): Promise<string> {
try {
await ghRequest(token, "POST", `/repos/${owner}/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha });
return branch;
} catch (err) {
if (err instanceof GithubHttpError && err.status === 422) {
const alt = `${branch}-2`;
await ghRequest(token, "POST", `/repos/${owner}/${repo}/git/refs`, { ref: `refs/heads/${alt}`, sha });
return alt;
}
throw err;
}
}
export async function createPullRequest(
token: string,
owner: string,
repo: string,
pr: { title: string; head: string; base: string; body: string },
): Promise<{ url: string; number: number }> {
const data = await ghRequest<{ html_url: string; number: number }>(token, "POST", `/repos/${owner}/${repo}/pulls`, pr);
return { url: data.html_url, number: data.number };
}

View file

@ -0,0 +1,34 @@
import { pino } from "pino";
import { env } from "../env";
// Map pino levels onto Google Cloud Logging severities so Cloud Run renders the
// right level (and groups errors) instead of treating every line as default.
const SEVERITY_BY_LEVEL: Record<string, string> = {
trace: "DEBUG",
debug: "DEBUG",
info: "INFO",
warn: "WARNING",
error: "ERROR",
fatal: "CRITICAL",
};
export const logger = pino(
env.isProd
? {
// Structured JSON to stdout — Cloud Run/Cloud Logging parses it.
level: env.LOG_LEVEL ?? "info",
messageKey: "message",
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level: (label) => ({ severity: SEVERITY_BY_LEVEL[label] ?? "DEFAULT" }),
},
}
: {
// Human-friendly output in development.
level: env.LOG_LEVEL ?? "debug",
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss.l", ignore: "pid,hostname" },
},
},
);

View file

@ -0,0 +1,28 @@
import { WorkOS } from "@workos-inc/node";
import type { CookieOptions } from "express";
import { env } from "../env";
let instance: WorkOS | undefined;
// Lazily constructed so the server can boot without WorkOS credentials. Only
// called from paths guarded by env.authConfigured.
export function getWorkos(): WorkOS {
if (!instance) {
instance = new WorkOS(env.WORKOS_API_KEY, { clientId: env.WORKOS_CLIENT_ID });
}
return instance;
}
export const SESSION_COOKIE = "wos_session";
// Env-aware cookie options: cross-site (SameSite=None;Secure) in production,
// Lax over http in dev (localhost ports are same-site, so Lax is delivered).
export function sessionCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: env.isProd,
sameSite: env.isProd ? "none" : "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 24 * 30,
};
}

View file

@ -0,0 +1,96 @@
import type { Request, RequestHandler } from "express";
import type { User as WorkOSUser } from "@workos-inc/node";
import { env } from "../env";
import { getWorkos, SESSION_COOKIE, sessionCookieOptions } from "../lib/workos";
export interface AuthUser {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
}
// Express Request carrying the authenticated user (set by withAuth).
export interface AuthedRequest extends Request {
user: AuthUser;
}
export function toAuthUser(user: WorkOSUser): AuthUser {
return {
id: user.id,
email: user.email,
firstName: user.firstName ?? null,
lastName: user.lastName ?? null,
};
}
export function localDevAuthUser(): AuthUser | null {
if (!env.localDevAuthEnabled || !env.LOCAL_DEV_AUTH_EMAIL) return null;
return {
id: `local-dev:${env.LOCAL_DEV_AUTH_EMAIL}`,
email: env.LOCAL_DEV_AUTH_EMAIL,
firstName: env.LOCAL_DEV_AUTH_FIRST_NAME || null,
lastName: env.LOCAL_DEV_AUTH_LAST_NAME || null,
};
}
// Authenticates the WorkOS sealed-session cookie, refreshing it transparently
// when the access token has expired. Attaches req.user or responds 401/503.
export const withAuth: RequestHandler = async (req, res, next) => {
const localUser = localDevAuthUser();
if (localUser) {
(req as AuthedRequest).user = localUser;
next();
return;
}
if (!env.authConfigured) {
res.status(503).json({ error: "Authentication is not configured." });
return;
}
const sessionData = req.cookies?.[SESSION_COOKIE] as string | undefined;
if (!sessionData) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const cookiePassword = env.WORKOS_COOKIE_PASSWORD as string;
const session = getWorkos().userManagement.loadSealedSession({ sessionData, cookiePassword });
try {
const result = await session.authenticate();
if (result.authenticated) {
(req as AuthedRequest).user = toAuthUser(result.user);
next();
return;
}
if (result.reason === "no_session_cookie_provided") {
res.status(401).json({ error: "Not authenticated" });
return;
}
// Access token expired — refresh and re-seal the cookie.
const refreshed = await session.refresh();
if (!refreshed.authenticated || !refreshed.sealedSession) {
res.clearCookie(SESSION_COOKIE, sessionCookieOptions());
res.status(401).json({ error: "Session expired" });
return;
}
res.cookie(SESSION_COOKIE, refreshed.sealedSession, sessionCookieOptions());
const reauth = await getWorkos()
.userManagement.loadSealedSession({ sessionData: refreshed.sealedSession, cookiePassword })
.authenticate();
if (!reauth.authenticated) {
res.status(401).json({ error: "Session expired" });
return;
}
(req as AuthedRequest).user = toAuthUser(reauth.user);
next();
} catch {
res.clearCookie(SESSION_COOKIE, sessionCookieOptions());
res.status(401).json({ error: "Not authenticated" });
}
};

View file

@ -0,0 +1,16 @@
import type { ErrorRequestHandler } from "express";
import { logger } from "../lib/logger";
interface HttpError extends Error {
status?: number;
}
// Central error handler. Express identifies it by its 4-argument arity, so all
// four params must stay even when unused.
export const errorHandler: ErrorRequestHandler = (err: HttpError, req, res, _next) => {
const status = typeof err.status === "number" ? err.status : 500;
if (status >= 500) {
(req.log ?? logger).error({ err }, "Unhandled request error");
}
res.status(status).json({ error: err.message || "Internal Server Error" });
};

View file

@ -0,0 +1,36 @@
import { randomUUID } from "node:crypto";
import { pinoHttp } from "pino-http";
import { logger } from "../lib/logger";
// Per-request logging. Emits one line per completed request with method, path,
// status, and latency; assigns/propagates a request id; and maps status codes to
// log levels. Sensitive headers are never logged (we only serialize safe fields).
export const httpLogger = pinoHttp({
logger,
genReqId: (req, res) => {
const incoming = req.headers["x-request-id"];
const id = (Array.isArray(incoming) ? incoming[0] : incoming) ?? randomUUID();
res.setHeader("x-request-id", id);
return id;
},
customLogLevel: (_req, res, err) => {
if (err || res.statusCode >= 500) return "error";
if (res.statusCode >= 400) return "warn";
return "info";
},
customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`,
customErrorMessage: (req, res, err) => `${req.method} ${req.url} ${res.statusCode}${err.message}`,
// Correlate with Cloud Run request traces when available.
customProps: (req) => {
const trace = req.headers["x-cloud-trace-context"];
return typeof trace === "string" ? { traceId: trace.split("/")[0] } : {};
},
serializers: {
req: (req) => ({ id: req.id, method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
// Don't spam logs with Cloud Run health checks.
autoLogging: {
ignore: (req) => req.url === "/health",
},
});

View file

@ -0,0 +1,25 @@
// Maps a stored registry connection to the concrete OCI pull endpoint + repository
// normalization the scanner needs. Scanning is uniform OCI; only these differ per type.
export function pullEndpoint(type: string, host: string): string {
switch (type) {
case "dockerhub":
return "registry-1.docker.io";
case "ghcr":
return "ghcr.io";
default:
return host; // gar (*-docker.pkg.dev), ecr, generic — host is the endpoint
}
}
// Docker Hub official images live under `library/`; a bare name needs that prefix.
export function normalizeRepository(type: string, repository: string): string {
if (type === "dockerhub" && !repository.includes("/")) return `library/${repository}`;
return repository;
}
// Human-readable ref stored on the image row (e.g. docker.io/library/nginx:latest).
export function displayImageRef(type: string, host: string, repository: string, tag: string): string {
const displayHost = type === "dockerhub" ? "docker.io" : host;
return `${displayHost}/${repository}:${tag}`;
}

View file

@ -0,0 +1,80 @@
import { Buffer } from "node:buffer";
import { logger } from "../lib/logger";
import type { RegistryConnection } from "../db/schema";
export interface RegistryImage {
repository: string; // e.g. myuser/app or library/nginx
description?: string | null;
}
// Lists image repositories available in a connected registry so the user can pick one.
// Docker Hub + GHCR are supported; other types return [] and rely on manual image-ref
// entry. Failures degrade to [] (the manual add path still works).
export async function listRegistryImages(conn: RegistryConnection, secret: string | null): Promise<RegistryImage[]> {
try {
if (conn.type === "dockerhub") return await listDockerHub(conn, secret);
if (conn.type === "ghcr") return await listGhcr(conn, secret);
return [];
} catch (err) {
logger.warn({ err, type: conn.type }, "registry image listing failed");
return [];
}
}
async function listDockerHub(conn: RegistryConnection, secret: string | null): Promise<RegistryImage[]> {
const namespace = conn.username;
if (!namespace) return [];
let jwt: string | null = null;
if (secret) {
const login = await fetch("https://hub.docker.com/v2/users/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: namespace, password: secret }),
});
if (login.ok) jwt = ((await login.json()) as { token?: string }).token ?? null;
}
const res = await fetch(`https://hub.docker.com/v2/repositories/${encodeURIComponent(namespace)}/?page_size=100`, {
headers: jwt ? { Authorization: `JWT ${jwt}` } : {},
});
if (!res.ok) return [];
const data = (await res.json()) as { results?: { name: string; description?: string }[] };
return (data.results ?? []).map((r) => ({ repository: `${namespace}/${r.name}`, description: r.description }));
}
async function listGhcr(conn: RegistryConnection, secret: string | null): Promise<RegistryImage[]> {
const owner = conn.username;
if (!owner || !secret) return [];
const res = await fetch("https://api.github.com/user/packages?package_type=container&per_page=100", {
headers: {
Authorization: `Bearer ${secret}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "cerebrus-backend",
},
});
if (!res.ok) return [];
const data = (await res.json()) as { name: string }[];
return data.map((p) => ({ repository: `${owner}/${p.name}` }));
}
// Validates a registry credential by minting an OCI pull token (a bare /v2/ returns 401
// even for valid creds). Returns true if the credential works (or if anonymous is fine).
export async function validateCredential(endpoint: string, username: string | null, secret: string | null): Promise<boolean> {
try {
const ping = await fetch(`https://${endpoint}/v2/`, { headers: { "User-Agent": "cerebrus-backend" } });
if (ping.status === 200) return true;
const challenge = ping.headers.get("www-authenticate");
if (!challenge) return true;
const realm = /realm="([^"]+)"/.exec(challenge)?.[1];
const service = /service="([^"]+)"/.exec(challenge)?.[1];
if (!realm) return true;
const url = new URL(realm);
if (service) url.searchParams.set("service", service);
const headers: Record<string, string> = { "User-Agent": "cerebrus-backend" };
if (username && secret) headers.Authorization = `Basic ${Buffer.from(`${username}:${secret}`).toString("base64")}`;
const tok = await fetch(url, { headers });
return tok.ok;
} catch {
return false;
}
}

View file

@ -0,0 +1,27 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { env } from "../env";
import { withAuth, type AuthedRequest } from "../middleware/auth";
import { getDb } from "../db/client";
import { users } from "../db/schema";
export const apiRouter: Router = Router();
// Protected: returns the authenticated WorkOS user plus their Supabase row,
// demonstrating the frontend → backend → Supabase access boundary.
apiRouter.get("/me", withAuth, async (req, res) => {
const { user } = req as AuthedRequest;
if (!env.dbConfigured) {
res.json({ user, profile: null, dbConfigured: false });
return;
}
const [profile] = await getDb()
.select()
.from(users)
.where(eq(users.workosUserId, user.id))
.limit(1);
res.json({ user, profile: profile ?? null, dbConfigured: true });
});

View file

@ -0,0 +1,112 @@
import { Router } from "express";
import type { User as WorkOSUser } from "@workos-inc/node";
import { env } from "../env";
import { getWorkos, SESSION_COOKIE, sessionCookieOptions } from "../lib/workos";
import { localDevAuthUser, toAuthUser, type AuthUser } from "../middleware/auth";
import { upsertUser } from "../db/users";
import { logger } from "../lib/logger";
export const authRouter: Router = Router();
// Kick off the WorkOS AuthKit hosted login. Falls back to the frontend when
// WorkOS isn't configured yet, so the UI stays usable with placeholder env.
authRouter.get("/login", (_req, res) => {
if (env.localDevAuthEnabled) {
res.redirect(`${env.FRONTEND_URL}/app`);
return;
}
if (!env.authConfigured) {
res.redirect(`${env.FRONTEND_URL}/login`);
return;
}
const authorizationUrl = getWorkos().userManagement.getAuthorizationUrl({
provider: "authkit",
clientId: env.WORKOS_CLIENT_ID as string,
redirectUri: env.WORKOS_REDIRECT_URI,
});
res.redirect(authorizationUrl);
});
// WorkOS redirects here with a one-time code. Exchange it for a sealed session,
// set the cookie, sync the user into Supabase, and return to the app.
authRouter.get("/callback", async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : undefined;
if (!env.authConfigured || !code) {
res.redirect(`${env.FRONTEND_URL}/login?error=auth`);
return;
}
const { user, sealedSession } = await getWorkos().userManagement.authenticateWithCode({
clientId: env.WORKOS_CLIENT_ID as string,
code,
session: { sealSession: true, cookiePassword: env.WORKOS_COOKIE_PASSWORD as string },
});
if (sealedSession) {
res.cookie(SESSION_COOKIE, sealedSession, sessionCookieOptions());
}
await syncUser(user);
res.redirect(`${env.FRONTEND_URL}/app/feed`);
});
// Lightweight auth-state probe for the frontend. Always 200.
authRouter.get("/me", async (req, res) => {
const localUser = localDevAuthUser();
if (localUser) {
await syncUser(localUser);
res.json({ user: localUser, configured: true });
return;
}
if (!env.authConfigured) {
res.json({ user: null, configured: false });
return;
}
const sessionData = req.cookies?.[SESSION_COOKIE] as string | undefined;
if (!sessionData) {
res.json({ user: null, configured: true });
return;
}
try {
const result = await getWorkos()
.userManagement.loadSealedSession({ sessionData, cookiePassword: env.WORKOS_COOKIE_PASSWORD as string })
.authenticate();
res.json({ user: result.authenticated ? toAuthUser(result.user) : null, configured: true });
} catch {
res.json({ user: null, configured: true });
}
});
// Clear the local session and bounce through the WorkOS logout endpoint.
authRouter.get("/logout", async (req, res) => {
const sessionData = req.cookies?.[SESSION_COOKIE] as string | undefined;
res.clearCookie(SESSION_COOKIE, sessionCookieOptions());
if (env.authConfigured && sessionData) {
try {
const logoutUrl = await getWorkos()
.userManagement.loadSealedSession({ sessionData, cookiePassword: env.WORKOS_COOKIE_PASSWORD as string })
.getLogoutUrl();
res.redirect(logoutUrl);
return;
} catch {
// Fall through to the frontend if the session can't be unsealed.
}
}
res.redirect(env.FRONTEND_URL);
});
// Upsert the WorkOS user into Supabase via Drizzle. Login still succeeds even if
// the DB write fails, so errors are logged and swallowed.
async function syncUser(user: WorkOSUser | AuthUser): Promise<void> {
if (!env.dbConfigured) return;
try {
await upsertUser({
id: user.id,
email: user.email,
firstName: user.firstName ?? null,
lastName: user.lastName ?? null,
});
} catch (err) {
logger.error({ err }, "Failed to sync user to database");
}
}

View file

@ -0,0 +1,332 @@
import { Router } from "express";
import type { CookieOptions } from "express";
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { and, desc, eq } from "drizzle-orm";
import { env } from "../env";
import { withAuth, type AuthedRequest } from "../middleware/auth";
import { getDb } from "../db/client";
import { upsertUser } from "../db/users";
import { githubConnections, connectedRepositories, type ConnectedRepository } from "../db/schema";
import { encrypt, decrypt } from "../lib/crypto";
import { exchangeCode, fetchViewer, getAuthorizeUrl, listRepos, GithubReconnectError } from "../lib/github";
import { createScan, failScansForRepo, latestScansForRepos } from "../db/scans";
import { chainsForScan, findingsForScan } from "../db/findings";
import { triggerScan } from "../scan/runner";
export const githubRouter: Router = Router();
// Queues a scan for a connected repo using the user's GitHub token. Returns the
// scan id, or null if the GitHub token is missing/unusable (skip silently).
async function startScan(userId: string, project: ConnectedRepository): Promise<string | null> {
const connection = await loadConnection(userId);
if (!connection) return null;
let token: string;
try {
token = decrypt(connection.accessTokenEncrypted);
} catch {
return null;
}
// Abandon any previously queued/running scan for this repo so the UI doesn't stay
// stuck in "Scanning…" if a callback was lost.
await failScansForRepo(project.id, "Abandoned — retry started");
const scanId = await createScan(project.id);
// Await so the cloudrun jobs:run call completes within this request (Cloud Run
// freezes CPU after the response; inline/docker resolve immediately).
await triggerScan({ scanId, owner: project.owner, repo: project.name, ref: project.defaultBranch ?? undefined, token });
return scanId;
}
const STATE_COOKIE = "gh_oauth_state";
function stateCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: env.isProd,
sameSite: env.isProd ? "none" : "lax",
path: "/",
maxAge: 1000 * 60 * 10,
};
}
// Ensures a local users row exists for the authenticated WorkOS user and returns
// its id (the FK used by the github tables).
function resolveUserId(req: AuthedRequest): Promise<string> {
return upsertUser({
id: req.user.id,
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
});
}
async function loadConnection(userId: string) {
const [connection] = await getDb()
.select()
.from(githubConnections)
.where(eq(githubConnections.userId, userId))
.limit(1);
return connection ?? null;
}
// Whether the user has a GitHub account connected.
githubRouter.get("/status", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ configured: env.githubConfigured, connected: false });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const connection = await loadConnection(userId);
res.json({ configured: env.githubConfigured, connected: Boolean(connection), login: connection?.githubLogin ?? null });
});
// Start the GitHub OAuth flow (top-level redirect; carries the WorkOS session).
githubRouter.get("/connect", withAuth, (_req, res) => {
if (!env.githubConfigured) {
res.redirect(`${env.FRONTEND_URL}/app/repositories?github=unconfigured`);
return;
}
const state = randomBytes(16).toString("hex");
res.cookie(STATE_COOKIE, state, stateCookieOptions());
res.redirect(getAuthorizeUrl(state));
});
// OAuth callback: validate state, exchange code, store the encrypted token.
githubRouter.get("/callback", withAuth, async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : undefined;
const state = typeof req.query.state === "string" ? req.query.state : undefined;
const expectedState = req.cookies?.[STATE_COOKIE] as string | undefined;
res.clearCookie(STATE_COOKIE, stateCookieOptions());
if (!env.githubConfigured || !env.dbConfigured || !code || !state || state !== expectedState) {
res.redirect(`${env.FRONTEND_URL}/app/repositories?github=error`);
return;
}
const { accessToken, scope, tokenType } = await exchangeCode(code);
const viewer = await fetchViewer(accessToken);
const userId = await resolveUserId(req as AuthedRequest);
const accessTokenEncrypted = encrypt(accessToken);
await getDb()
.insert(githubConnections)
.values({ userId, githubUserId: viewer.id, githubLogin: viewer.login, accessTokenEncrypted, scope, tokenType })
.onConflictDoUpdate({
target: githubConnections.userId,
set: { githubUserId: viewer.id, githubLogin: viewer.login, accessTokenEncrypted, scope, tokenType, updatedAt: new Date() },
});
res.redirect(`${env.FRONTEND_URL}/app/repositories?github=connected`);
});
// List the user's GitHub repos (public + private), each flagged if connected.
githubRouter.get("/repos", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ connected: false, repos: [] });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const connection = await loadConnection(userId);
if (!connection) {
res.json({ connected: false, repos: [] });
return;
}
// Decrypt the stored token. A failure here means it was encrypted with a
// different APP_ENCRYPTION_KEY (or is corrupt) — the token is unusable, so ask
// the user to reconnect rather than 500.
let token: string;
try {
token = decrypt(connection.accessTokenEncrypted);
} catch {
res.json({ connected: true, needsReconnect: true, repos: [] });
return;
}
let repos;
try {
repos = await listRepos(token);
} catch (err) {
if (err instanceof GithubReconnectError) {
res.json({ connected: true, needsReconnect: true, repos: [] });
return;
}
throw err;
}
const connectedRows = await getDb()
.select({ githubRepoId: connectedRepositories.githubRepoId })
.from(connectedRepositories)
.where(eq(connectedRepositories.userId, userId));
const connectedIds = new Set(connectedRows.map((row) => row.githubRepoId));
res.json({
connected: true,
login: connection.githubLogin,
repos: repos.map((repo) => ({ ...repo, connected: connectedIds.has(repo.githubRepoId) })),
});
});
const connectSchema = z.object({
githubRepoId: z.string(),
fullName: z.string(),
name: z.string(),
owner: z.string(),
private: z.boolean(),
defaultBranch: z.string().nullable().optional(),
htmlUrl: z.string().nullable().optional(),
});
// Connect a repo as a project — persisted to connected_repositories.
githubRouter.post("/repos/connect", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.status(503).json({ error: "Database is not configured." });
return;
}
const parsed = connectSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid repository payload." });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const repo = parsed.data;
const [inserted] = await getDb()
.insert(connectedRepositories)
.values({
userId,
githubRepoId: repo.githubRepoId,
fullName: repo.fullName,
name: repo.name,
owner: repo.owner,
private: repo.private,
defaultBranch: repo.defaultBranch ?? null,
htmlUrl: repo.htmlUrl ?? null,
})
.onConflictDoNothing()
.returning();
const [project] = inserted
? [inserted]
: await getDb()
.select()
.from(connectedRepositories)
.where(and(eq(connectedRepositories.userId, userId), eq(connectedRepositories.githubRepoId, repo.githubRepoId)))
.limit(1);
// Kick off the first scan (non-blocking; failures here don't fail the connect).
await startScan(userId, project);
res.json({ project });
});
// Re-run a scan for an already-connected project.
githubRouter.post("/projects/:githubRepoId/scan", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.status(503).json({ error: "Database is not configured." });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const githubRepoId = String(req.params.githubRepoId);
const [project] = await getDb()
.select()
.from(connectedRepositories)
.where(and(eq(connectedRepositories.userId, userId), eq(connectedRepositories.githubRepoId, githubRepoId)))
.limit(1);
if (!project) {
res.status(404).json({ error: "Project not found." });
return;
}
const scanId = await startScan(userId, project);
if (!scanId) {
res.status(409).json({ error: "GitHub connection unavailable — reconnect GitHub." });
return;
}
res.json({ scanId });
});
const disconnectSchema = z.object({ githubRepoId: z.string() });
// Disconnect a previously connected repo.
githubRouter.post("/repos/disconnect", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.status(503).json({ error: "Database is not configured." });
return;
}
const parsed = disconnectSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid payload." });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
await getDb()
.delete(connectedRepositories)
.where(and(eq(connectedRepositories.userId, userId), eq(connectedRepositories.githubRepoId, parsed.data.githubRepoId)));
res.json({ ok: true });
});
// The user's connected projects.
githubRouter.get("/projects", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ projects: [] });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const projects = await getDb()
.select()
.from(connectedRepositories)
.where(eq(connectedRepositories.userId, userId))
.orderBy(desc(connectedRepositories.connectedAt));
const scanByRepo = await latestScansForRepos(projects.map((project) => project.id));
res.json({
projects: projects.map((project) => {
const scan = scanByRepo.get(project.id);
return {
...project,
scan: scan
? {
status: scan.status,
fileCount: scan.fileCount,
filesScanned: scan.filesScanned,
findingCount: scan.findingCount,
stage: scan.stage,
finishedAt: scan.finishedAt,
fixStatus: scan.fixStatus,
fixPrUrl: scan.fixPrUrl,
fixPrNumber: scan.fixPrNumber,
fixedCount: scan.fixedCount,
skippedCount: scan.skippedCount,
}
: null,
};
}),
});
});
// Findings + attack chains for a connected project's most recent scan.
githubRouter.get("/projects/:githubRepoId/findings", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ findings: [], chains: [] });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const githubRepoId = String(req.params.githubRepoId);
const [project] = await getDb()
.select()
.from(connectedRepositories)
.where(and(eq(connectedRepositories.userId, userId), eq(connectedRepositories.githubRepoId, githubRepoId)))
.limit(1);
if (!project) {
res.status(404).json({ error: "Project not found." });
return;
}
const latestScan = (await latestScansForRepos([project.id])).get(project.id);
if (!latestScan) {
res.json({ findings: [], chains: [] });
return;
}
const [findings, chains] = await Promise.all([findingsForScan(latestScan.id), chainsForScan(latestScan.id)]);
res.json({ findings, chains });
});

View file

@ -0,0 +1,16 @@
import { Router } from "express";
import { env } from "../env";
export const healthRouter: Router = Router();
// Liveness + integration-configuration probe. Always 200 so it works before any
// credentials are set.
healthRouter.get("/health", (_req, res) => {
res.json({
ok: true,
service: "cerebrus-backend",
authConfigured: env.authConfigured || env.localDevAuthEnabled,
dbConfigured: env.dbConfigured,
githubConfigured: env.githubConfigured,
});
});

View file

@ -0,0 +1,159 @@
import { Router, type NextFunction, type Request, type Response } from "express";
import { z } from "zod";
import { env } from "../env";
import { completeScan, failScan, markScanRunning } from "../db/scans";
import { printScanLogSummary } from "../scan/log";
import { logger } from "../lib/logger";
import { insertChains, insertFindings, updateScanProgress } from "../db/findings";
import { matchAdvisories } from "../cve/match";
import { createFixPr } from "../fix/autofix";
import type { NewFinding, NewFindingChain } from "../db/schema";
export const internalRouter: Router = Router();
// Scanner callbacks are authenticated by a shared secret (not a user session), so
// this whole router is mounted outside the withAuth routers. Guard every route.
function requireScanSecret(req: Request, res: Response, next: NextFunction): void {
if (!env.SCAN_CALLBACK_SECRET || req.headers["x-scan-secret"] !== env.SCAN_CALLBACK_SECRET) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
internalRouter.use(requireScanSecret);
const resultSchema = z.object({
scanId: z.string(),
status: z.enum(["running", "completed", "failed"]),
fileCount: z.number().int().nonnegative().optional(),
error: z.string().optional(),
});
// Terminal (and initial "running") lifecycle transitions.
internalRouter.post("/scan-result", async (req, res) => {
const parsed = resultSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid payload" });
return;
}
const { scanId, status, fileCount, error } = parsed.data;
if (status === "running") {
await markScanRunning(scanId);
} else if (status === "completed") {
await completeScan(scanId, fileCount ?? 0);
// Await within the request: on Cloud Run background work after the response is
// frozen. createFixPr catches its own errors, so this never fails the callback.
await createFixPr(scanId);
} else {
await failScan(scanId, error ?? "Scan failed");
}
// Emit a consolidated log block to stdout so scan output appears in Cloud Run logs
// regardless of whether the scanner ran inline, in Docker, or as a Cloud Run job.
logger.info({ scanId, status }, "Scanner callback received");
printScanLogSummary(scanId);
res.json({ ok: true });
});
const progressSchema = z.object({
scanId: z.string(),
filesScanned: z.number().int().nonnegative().optional(),
fileCount: z.number().int().nonnegative().optional(),
stage: z.string().optional(),
});
// Progress heartbeat during a scan (drives the "n/total files" UI). Also flips a
// still-queued scan to running.
internalRouter.post("/scan/progress", async (req, res) => {
const parsed = progressSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid payload" });
return;
}
const { scanId, ...patch } = parsed.data;
await updateScanProgress(scanId, patch);
res.json({ ok: true });
});
const findingSchema = z.object({
id: z.string(),
filePath: z.string(),
severity: z.enum(["critical", "high", "medium", "low"]),
title: z.string(),
description: z.string(),
vulnerableCode: z.string(),
suggestedFix: z.string(),
startLine: z.number().int().nullable().optional(),
endLine: z.number().int().nullable().optional(),
category: z.enum(["code", "dependency", "os-package", "secret", "misconfig"]).optional(),
cveId: z.string().nullable().optional(),
});
const findingsSchema = z.object({ scanId: z.string(), findings: z.array(findingSchema) });
// Streaming append of findings as the scanner discovers them.
internalRouter.post("/scan/findings", async (req, res) => {
const parsed = findingsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid payload" });
return;
}
const { scanId, findings } = parsed.data;
const rows: NewFinding[] = findings.map((f) => ({
id: f.id,
scanId,
filePath: f.filePath,
severity: f.severity,
title: f.title,
description: f.description,
vulnerableCode: f.vulnerableCode,
suggestedFix: f.suggestedFix,
startLine: f.startLine ?? null,
endLine: f.endLine ?? null,
category: f.category ?? "code",
cveId: f.cveId ?? null,
}));
await insertFindings(scanId, rows);
res.json({ ok: true });
});
const chainSchema = z.object({
title: z.string(),
severity: z.enum(["critical", "high", "medium", "low"]),
description: z.string(),
steps: z.array(z.object({ findingId: z.string().optional(), filePath: z.string(), note: z.string() })),
});
const chainsSchema = z.object({ scanId: z.string(), chains: z.array(chainSchema) });
// Attack-chain results from the chaining pass.
internalRouter.post("/scan/chains", async (req, res) => {
const parsed = chainsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid payload" });
return;
}
const { scanId, chains } = parsed.data;
const rows: NewFindingChain[] = chains.map((c) => ({
scanId,
title: c.title,
severity: c.severity,
description: c.description,
steps: c.steps,
}));
await insertChains(scanId, rows);
res.json({ ok: true });
});
const cveCheckSchema = z.object({
deps: z.array(z.object({ ecosystem: z.string(), name: z.string(), version: z.string() })),
});
// Dependency → CVE lookup (OSV querybatch + cached advisories). Called by the CLI
// before its chaining pass. Matching lives here so the DB/OSV logic stays backend-side.
internalRouter.post("/scan/cve-check", async (req, res) => {
const parsed = cveCheckSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid payload" });
return;
}
const matches = await matchAdvisories(parsed.data.deps);
res.json({ matches });
});

View file

@ -0,0 +1,229 @@
import { Router } from "express";
import { z } from "zod";
import { env } from "../env";
import { withAuth, type AuthedRequest } from "../middleware/auth";
import { upsertUser } from "../db/users";
import { encrypt, decrypt } from "../lib/crypto";
import {
deleteRegistryConnection,
getRegistryConnection,
listRegistryConnections,
upsertRegistryConnection,
} from "../db/registry";
import { connectImage, getImage, listImages } from "../db/images";
import { createImageScan, latestScansForImages } from "../db/scans";
import { chainsForScan, findingsForScan } from "../db/findings";
import { triggerImageScan } from "../scan/runner";
import { displayImageRef, normalizeRepository, pullEndpoint } from "../registry/endpoints";
import { listRegistryImages, validateCredential } from "../registry/list";
import type { RegistryConnection } from "../db/schema";
export const registryRouter: Router = Router();
function resolveUserId(req: AuthedRequest): Promise<string> {
return upsertUser({ id: req.user.id, email: req.user.email, firstName: req.user.firstName, lastName: req.user.lastName });
}
// Never leak the encrypted secret to the browser.
function sanitize(conn: RegistryConnection) {
return {
id: conn.id,
type: conn.type,
host: conn.host,
username: conn.username,
extra: conn.extra,
createdAt: conn.createdAt,
};
}
function requireDb(res: import("express").Response): boolean {
if (!env.dbConfigured) {
res.status(503).json({ error: "Database is not configured." });
return false;
}
return true;
}
registryRouter.get("/connections", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ connections: [] });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const connections = await listRegistryConnections(userId);
res.json({ connections: connections.map(sanitize) });
});
const connectSchema = z.object({
type: z.enum(["dockerhub", "ghcr", "gar", "ecr", "generic"]),
host: z.string().min(1),
username: z.string().optional(),
secret: z.string().optional(),
extra: z.record(z.string(), z.unknown()).optional(),
});
registryRouter.post("/connect", withAuth, async (req, res) => {
if (!requireDb(res)) return;
const parsed = connectSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid registry payload." });
return;
}
const { type, host, username, secret, extra } = parsed.data;
const endpoint = pullEndpoint(type, host);
if (!(await validateCredential(endpoint, username ?? null, secret ?? null))) {
res.status(400).json({ error: "Could not authenticate to the registry with those credentials." });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const conn = await upsertRegistryConnection({
userId,
type,
host,
username: username ?? null,
secretEncrypted: secret ? encrypt(secret) : null,
extra: extra ?? null,
});
res.json({ connection: sanitize(conn) });
});
registryRouter.delete("/connections/:id", withAuth, async (req, res) => {
if (!requireDb(res)) return;
const userId = await resolveUserId(req as AuthedRequest);
await deleteRegistryConnection(userId, String(req.params.id));
res.json({ ok: true });
});
// Available image repositories in a connected registry (for the picker).
registryRouter.get("/connections/:id/images", withAuth, async (req, res) => {
if (!requireDb(res)) return;
const userId = await resolveUserId(req as AuthedRequest);
const conn = await getRegistryConnection(userId, String(req.params.id));
if (!conn) {
res.status(404).json({ error: "Registry connection not found." });
return;
}
const secret = conn.secretEncrypted ? safeDecrypt(conn.secretEncrypted) : null;
const images = await listRegistryImages(conn, secret);
res.json({ images });
});
const connectImageSchema = z.object({
registryConnectionId: z.string(),
repository: z.string().min(1),
tag: z.string().optional(),
});
registryRouter.post("/images/connect", withAuth, async (req, res) => {
if (!requireDb(res)) return;
const parsed = connectImageSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid image payload." });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const conn = await getRegistryConnection(userId, parsed.data.registryConnectionId);
if (!conn) {
res.status(404).json({ error: "Registry connection not found." });
return;
}
const tag = parsed.data.tag ?? "latest";
const repository = normalizeRepository(conn.type, parsed.data.repository.trim());
const image = await connectImage({
userId,
registryConnectionId: conn.id,
repository,
tag,
imageRef: displayImageRef(conn.type, conn.host, repository, tag),
name: repository.split("/").pop() ?? repository,
});
const scanId = await startImageScan(conn, repository, tag, image.id);
res.json({ image, scanId });
});
registryRouter.get("/images", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ images: [] });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const images = await listImages(userId);
const scanByImage = await latestScansForImages(images.map((i) => i.id));
res.json({
images: images.map((image) => {
const scan = scanByImage.get(image.id);
return {
...image,
scan: scan
? {
status: scan.status,
findingCount: scan.findingCount,
stage: scan.stage,
finishedAt: scan.finishedAt,
}
: null,
};
}),
});
});
registryRouter.get("/images/:id/findings", withAuth, async (req, res) => {
if (!env.dbConfigured) {
res.json({ findings: [], chains: [] });
return;
}
const userId = await resolveUserId(req as AuthedRequest);
const image = await getImage(userId, String(req.params.id));
if (!image) {
res.status(404).json({ error: "Image not found." });
return;
}
const scan = (await latestScansForImages([image.id])).get(image.id);
if (!scan) {
res.json({ findings: [], chains: [] });
return;
}
const [findings, chains] = await Promise.all([findingsForScan(scan.id), chainsForScan(scan.id)]);
res.json({ findings, chains });
});
registryRouter.post("/images/:id/scan", withAuth, async (req, res) => {
if (!requireDb(res)) return;
const userId = await resolveUserId(req as AuthedRequest);
const image = await getImage(userId, String(req.params.id));
if (!image) {
res.status(404).json({ error: "Image not found." });
return;
}
const conn = await getRegistryConnection(userId, image.registryConnectionId);
if (!conn) {
res.status(409).json({ error: "Registry connection unavailable — reconnect the registry." });
return;
}
const scanId = await startImageScan(conn, image.repository, image.tag, image.id);
res.json({ scanId });
});
// Creates the scan row and dispatches the image scan (awaited so the cloudrun jobs:run
// call completes within the request — see runner.ts).
async function startImageScan(conn: RegistryConnection, repository: string, tag: string, imageId: string): Promise<string> {
const scanId = await createImageScan(imageId);
const secret = conn.secretEncrypted ? safeDecrypt(conn.secretEncrypted) : null;
await triggerImageScan({
scanId,
imageRef: `${repository}:${tag}`,
registryHost: pullEndpoint(conn.type, conn.host),
registryType: conn.type,
registryUsername: conn.username ?? undefined,
registryToken: secret ?? undefined,
});
return scanId;
}
function safeDecrypt(payload: string): string | null {
try {
return decrypt(payload);
} catch {
return null;
}
}

View file

@ -0,0 +1,77 @@
import type { ChainInput, CveMatch, DepInput, FindingInput, Reporter } from "@cerebrus/cli";
import { completeScan, failScan, markScanRunning } from "../db/scans";
import { insertChains, insertFindings, updateScanProgress } from "../db/findings";
import { matchAdvisories } from "../cve/match";
import type { NewFinding, NewFindingChain } from "../db/schema";
import type { ScanLog } from "./log";
// Reporter used by inline mode: the scan runs in-process inside the backend, so it
// writes straight to Postgres (and the shared CVE matcher) instead of the HTTP
// callbacks the containerized HttpReporter uses. Same interface, different transport.
export class DbReporter implements Reporter {
private readonly scanId: string;
private readonly log?: ScanLog;
constructor(scanId: string, log?: ScanLog) {
this.scanId = scanId;
this.log = log;
}
async setRunning(): Promise<void> {
this.log?.write("scan running");
await markScanRunning(this.scanId);
}
async progress(filesScanned: number, fileCount: number, stage?: string): Promise<void> {
this.log?.write(`progress ${filesScanned}/${fileCount}${stage ? ` (${stage})` : ""}`);
await updateScanProgress(this.scanId, { filesScanned, fileCount, stage });
}
async addFindings(findings: FindingInput[]): Promise<void> {
if (findings.length === 0) return;
this.log?.write(`+${findings.length} finding(s): ${findings.map((f) => `${f.severity} ${f.filePath}`).join(", ")}`);
const rows: NewFinding[] = findings.map((f) => ({
id: f.id,
scanId: this.scanId,
filePath: f.filePath,
severity: f.severity,
title: f.title,
description: f.description,
vulnerableCode: f.vulnerableCode,
suggestedFix: f.suggestedFix,
startLine: f.startLine ?? null,
endLine: f.endLine ?? null,
category: f.category ?? "code",
cveId: f.cveId ?? null,
}));
await insertFindings(this.scanId, rows);
}
async addChains(chains: ChainInput[]): Promise<void> {
if (chains.length === 0) return;
this.log?.write(`+${chains.length} attack chain(s)`);
const rows: NewFindingChain[] = chains.map((c) => ({
scanId: this.scanId,
title: c.title,
severity: c.severity,
description: c.description,
steps: c.steps,
}));
await insertChains(this.scanId, rows);
}
matchDependencies(deps: DepInput[]): Promise<CveMatch[]> {
this.log?.write(`cve-check ${deps.length} dependency(ies)`);
return matchAdvisories(deps);
}
async complete(fileCount: number): Promise<void> {
this.log?.write(`scan completed (${fileCount} files)`);
await completeScan(this.scanId, fileCount);
}
async fail(error: string): Promise<void> {
this.log?.write(`scan failed: ${error}`);
await failScan(this.scanId, error);
}
}

View file

@ -0,0 +1,58 @@
import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { env } from "../env";
// Per-scan logs live at <SCAN_LOG_DIR>/scan-<scanId>.log so a user can `tail` a
// running scan and inspect failures after the fact. The dir is resolved relative to
// the backend's CWD.
function ensureLogDir(): string {
const dir = resolve(env.SCAN_LOG_DIR);
mkdirSync(dir, { recursive: true });
return dir;
}
export function scanLogPath(scanId: string): string {
return join(ensureLogDir(), `scan-${scanId}.log`);
}
// Opens an append fd for a scan log — used by the docker runner to redirect the
// container's stdout/stderr straight into the host file.
export function openScanLogFd(scanId: string): number {
return openSync(scanLogPath(scanId), "a");
}
export interface ScanLog {
write(message: string): void;
close(): void;
}
// A simple line-writer used by inline mode (the scan runs in-process, so we can't
// just redirect a child's stdio). Timestamps each line to mirror container logs.
// Uses synchronous writes so the file is always current and can be summarized
// immediately after a scan finishes.
export function openScanLog(scanId: string): ScanLog {
const path = scanLogPath(scanId);
return {
write(message: string) {
appendFileSync(path, `[${new Date().toISOString()}] ${message}\n`);
},
close() {
// Sync writes flush immediately; nothing to close.
},
};
}
// Prints the contents of a scan log to stdout as a single summarized block so it
// shows up in Cloud Run / gcloud logs. Safe to call even if the log does not exist.
export function printScanLogSummary(scanId: string): void {
const path = scanLogPath(scanId);
if (!existsSync(path)) return;
const content = readFileSync(path, "utf-8").trimEnd();
if (!content) return;
console.log(`===Logs Summary=== [scanId=${scanId}]`);
for (const line of content.split("\n")) {
console.log(line);
}
console.log(`===End Logs Summary=== [scanId=${scanId}]`);
}

View file

@ -0,0 +1,200 @@
import { spawn } from "node:child_process";
import { closeSync } from "node:fs";
import { scanImage, scanRepository } from "@cerebrus/cli";
import { env } from "../env";
import { logger } from "../lib/logger";
import { failScan } from "../db/scans";
import { DbReporter } from "./dbReporter";
import { openScanLog, openScanLogFd, printScanLogSummary } from "./log";
import { createFixPr } from "../fix/autofix";
import { gcpAccessToken, gcpProjectId } from "../lib/gcp";
export interface ScanJob {
scanId: string;
owner: string;
repo: string;
ref?: string;
token: string;
}
export interface ImageScanJob {
scanId: string;
imageRef: string; // "repository:tag"
registryHost: string; // pull endpoint
registryType: string;
registryUsername?: string;
registryToken?: string;
}
// --- Repo scans -------------------------------------------------------------------
export function triggerScan(job: ScanJob): Promise<void> {
const envArgs = scanEnvArgs(job);
switch (env.SCAN_RUNNER) {
case "inline":
runInlineRepo(job);
return Promise.resolve();
case "docker":
launchDocker(job.scanId, envArgs);
return Promise.resolve();
case "cloudrun":
return launchCloudRun(job.scanId, envArgs);
}
return Promise.resolve();
}
function runInlineRepo(job: ScanJob): void {
void (async () => {
const log = openScanLog(job.scanId);
const reporter = new DbReporter(job.scanId, log);
try {
await scanRepository({ owner: job.owner, repo: job.repo, ref: job.ref, token: job.token }, reporter);
await createFixPr(job.scanId); // repo-only; no-ops otherwise
} catch (err) {
await reporter.fail(err instanceof Error ? err.message : String(err)).catch(() => {});
} finally {
printScanLogSummary(job.scanId);
log.close();
}
})();
}
// --- Image scans ------------------------------------------------------------------
export function triggerImageScan(job: ImageScanJob): Promise<void> {
const envArgs = imageScanEnvArgs(job);
switch (env.SCAN_RUNNER) {
case "inline":
runInlineImage(job);
return Promise.resolve();
case "docker":
launchDocker(job.scanId, envArgs);
return Promise.resolve();
case "cloudrun":
return launchCloudRun(job.scanId, envArgs);
}
return Promise.resolve();
}
function runInlineImage(job: ImageScanJob): void {
void (async () => {
const log = openScanLog(job.scanId);
const reporter = new DbReporter(job.scanId, log);
try {
// No createFixPr — image scans have no repo to open a PR against.
await scanImage(
{
imageRef: job.imageRef,
registryHost: job.registryHost,
username: job.registryUsername,
token: job.registryToken,
},
reporter,
);
} catch (err) {
await reporter.fail(err instanceof Error ? err.message : String(err)).catch(() => {});
} finally {
printScanLogSummary(job.scanId);
log.close();
}
})();
}
// --- Shared launchers -------------------------------------------------------------
function launchDocker(scanId: string, envArgs: string[]): void {
if (!env.SCANNER_IMAGE) {
void failScan(scanId, "SCANNER_IMAGE is not configured");
return;
}
const args = [
"run",
"--rm",
"--add-host=host.docker.internal:host-gateway",
...envArgs.flatMap((e) => ["-e", e]),
env.SCANNER_IMAGE,
];
// Redirect container stdout/stderr into the per-scan host log file.
const logFd = openScanLogFd(scanId);
const proc = spawn("docker", args, { stdio: ["ignore", logFd, logFd], detached: true });
closeSync(logFd); // child has its own dup
proc.on("error", (err) => {
logger.error({ err }, "Failed to launch scanner container");
void failScan(scanId, `docker launch failed: ${err.message}`);
});
proc.unref();
}
async function launchCloudRun(scanId: string, envArgs: string[]): Promise<void> {
try {
const project = gcpProjectId();
if (!project) throw new Error("GCP project not configured (set GCP_PROJECT or GOOGLE_SERVICE_ACCOUNT_JSON / GOOGLE_APPLICATION_CREDENTIALS)");
logger.info({ scanId, jobName: env.SCAN_JOB_NAME, region: env.GCP_REGION }, "Launching Cloud Run scanner job");
const token = await gcpAccessToken();
const url = `https://${env.GCP_REGION}-run.googleapis.com/v2/projects/${project}/locations/${env.GCP_REGION}/jobs/${env.SCAN_JOB_NAME}:run`;
const body = { overrides: { containerOverrides: [{ env: envArgs.map((pair) => splitEnv(pair)) }] } };
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Cloud Run job run failed (${res.status}): ${await res.text()}`);
logger.info({ scanId, jobName: env.SCAN_JOB_NAME }, "Cloud Run scanner job launched");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ scanId, err: message }, "Failed to launch Cloud Run scanner job");
await failScan(scanId, message).catch(() => {});
}
}
// --- Env builders -----------------------------------------------------------------
// DeepSeek config passed to every scanner container (never DB creds).
function deepseekEnvArgs(): string[] {
return [
`DEEPSEEK_API_KEY=${env.DEEPSEEK_API_KEY ?? ""}`,
`DEEPSEEK_MODEL=${env.DEEPSEEK_MODEL}`,
`DEEPSEEK_BASE_URL=${env.DEEPSEEK_BASE_URL}`,
`DEEPSEEK_CONCURRENCY=${env.DEEPSEEK_CONCURRENCY}`,
];
}
function callbackEnvArgs(scanId: string): string[] {
return [
`SCAN_ID=${scanId}`,
`SCAN_CALLBACK_URL=${env.SCAN_CALLBACK_URL}`,
`SCAN_CALLBACK_SECRET=${env.SCAN_CALLBACK_SECRET ?? ""}`,
];
}
function scanEnvArgs(job: ScanJob): string[] {
const pairs = [
...callbackEnvArgs(job.scanId),
`SCAN_TARGET=repo`,
`REPO_OWNER=${job.owner}`,
`REPO_NAME=${job.repo}`,
`GITHUB_TOKEN=${job.token}`,
...deepseekEnvArgs(),
];
if (job.ref) pairs.push(`REPO_REF=${job.ref}`);
return pairs;
}
function imageScanEnvArgs(job: ImageScanJob): string[] {
const pairs = [
...callbackEnvArgs(job.scanId),
`SCAN_TARGET=image`,
`IMAGE_REF=${job.imageRef}`,
`REGISTRY_TYPE=${job.registryType}`,
`REGISTRY_HOST=${job.registryHost}`,
...deepseekEnvArgs(),
];
if (job.registryUsername) pairs.push(`REGISTRY_USERNAME=${job.registryUsername}`);
if (job.registryToken) pairs.push(`REGISTRY_TOKEN=${job.registryToken}`);
return pairs;
}
function splitEnv(pair: string): { name: string; value: string } {
const idx = pair.indexOf("=");
return { name: pair.slice(0, idx), value: pair.slice(idx + 1) };
}

View file

@ -0,0 +1,8 @@
{
"extends": "@cerebrus/typescript-config/bun-app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"types": ["node", "bun"]
},
"include": ["src", "drizzle.config.ts"]
}

22
apps/cli/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# Build context is the repo ROOT (so the bun workspace resolves).
# ---- Build: install workspace deps and bundle the scanner ----
FROM oven/bun:1.3.14 AS build
WORKDIR /repo
COPY package.json bun.lock turbo.json ./
COPY packages/typescript-config/package.json ./packages/typescript-config/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY apps/backend/package.json ./apps/backend/
COPY apps/frontend/package.json ./apps/frontend/
COPY apps/cli/package.json ./apps/cli/
RUN bun install --frozen-lockfile
COPY . .
RUN cd apps/cli && bun build src/index.ts --target bun --outdir dist
# ---- Runtime: run-to-completion scanner (tar ships in the Debian-based image) ----
FROM oven/bun:1.3.14-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /repo/apps/cli/dist ./dist
CMD ["bun", "dist/index.js"]

27
apps/cli/cloudbuild.yaml Normal file
View file

@ -0,0 +1,27 @@
# Build + push the scanner image and create/update the Cloud Run JOB. Run from the
# repo root (separate from deploy.sh, which deploys only the two services):
# gcloud builds submit --config apps/cli/cloudbuild.yaml \
# --substitutions=_REGION=us-central1,_IMAGE=us-central1-docker.pkg.dev/PROJECT/cerebrus/scanner .
steps:
- name: gcr.io/cloud-builders/docker
args: ["build", "-f", "apps/cli/Dockerfile", "-t", "${_IMAGE}:${BUILD_ID}", "-t", "${_IMAGE}:latest", "."]
- name: gcr.io/cloud-builders/docker
args: ["push", "--all-tags", "${_IMAGE}"]
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: gcloud
args:
- "run"
- "jobs"
- "deploy"
- "cerebrus-scanner"
- "--image=${_IMAGE}:${BUILD_ID}"
- "--region=${_REGION}"
- "--max-retries=1"
- "--task-timeout=600"
images:
- "${_IMAGE}"
substitutions:
_REGION: "us-central1"
_IMAGE: "us-central1-docker.pkg.dev/${PROJECT_ID}/cerebrus/scanner"
options:
logging: CLOUD_LOGGING_ONLY

View file

@ -0,0 +1,3 @@
import base from "@cerebrus/eslint-config/base";
export default base;

23
apps/cli/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@cerebrus/cli",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "src/scan.ts",
"exports": {
".": "./src/scan.ts"
},
"scripts": {
"start": "bun src/index.ts",
"build": "bun build src/index.ts --target bun --outdir dist",
"check-types": "tsc --noEmit",
"lint": "eslint ."
},
"devDependencies": {
"@cerebrus/eslint-config": "*",
"@cerebrus/typescript-config": "*",
"@types/node": "^24.13.2",
"eslint": "^10.5.0",
"typescript": "~6.0.2"
}
}

115
apps/cli/src/deepseek.ts Normal file
View file

@ -0,0 +1,115 @@
// Minimal DeepSeek client (OpenAI-compatible chat completions) over global fetch —
// no SDK, keeping the CLI dependency-free.
//
// IMPORTANT: the default model is `deepseek-reasoner` (R1), which does NOT support
// `response_format: json_object` or tool-calling and returns its chain-of-thought in
// a separate `reasoning_content` field. So we never send response_format/tools, read
// only `message.content` (ignoring reasoning_content), and parse JSON out of it
// defensively with retries.
export interface DeepSeekConfig {
apiKey: string;
model: string;
baseUrl: string;
concurrency: number;
}
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export function loadDeepSeekConfig(): DeepSeekConfig {
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not set — cannot run the scan.");
return {
apiKey,
model: process.env.DEEPSEEK_MODEL || "deepseek-reasoner",
baseUrl: process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com",
concurrency: Math.max(1, Number.parseInt(process.env.DEEPSEEK_CONCURRENCY || "4", 10) || 4),
};
}
export class JsonParseError extends Error {}
export async function callDeepSeek(config: DeepSeekConfig, messages: ChatMessage[]): Promise<string> {
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.apiKey}` },
body: JSON.stringify({ model: config.model, messages, stream: false }),
});
if (!res.ok) throw new Error(`DeepSeek request failed (${res.status}): ${await res.text()}`);
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("DeepSeek returned an empty response");
return content;
}
// Extracts a JSON value from a possibly-noisy LLM reply: tries a direct parse, then
// strips markdown fences, then scans for the first balanced {...} / [...] region
// (respecting string literals + escapes so braces inside strings don't fool it).
export function parseJsonLoose<T>(raw: string): T {
const attempts: string[] = [raw.trim()];
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) attempts.push(fence[1].trim());
const balanced = extractBalanced(raw);
if (balanced) attempts.push(balanced);
for (const candidate of attempts) {
try {
return JSON.parse(candidate) as T;
} catch {
// try the next candidate
}
}
throw new JsonParseError("Could not parse JSON from model reply");
}
function extractBalanced(raw: string): string | null {
const start = raw.search(/[[{]/);
if (start === -1) return null;
const open = raw[start];
const close = open === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < raw.length; i++) {
const ch = raw[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return raw.slice(start, i + 1);
}
}
return null;
}
// Calls the model and parses JSON, retrying with a corrective nudge when the reply
// isn't valid JSON.
export async function askForJson<T>(config: DeepSeekConfig, messages: ChatMessage[], retries = 2): Promise<T> {
const convo = [...messages];
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
const reply = await callDeepSeek(config, convo);
try {
return parseJsonLoose<T>(reply);
} catch (err) {
lastErr = err;
convo.push({ role: "assistant", content: reply });
convo.push({
role: "user",
content: "Your previous reply was not valid JSON. Reply with ONLY the JSON value — no prose, no markdown code fences.",
});
}
}
throw lastErr instanceof Error ? lastErr : new JsonParseError(String(lastErr));
}

193
apps/cli/src/image.ts Normal file
View file

@ -0,0 +1,193 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { OciClient, type ImageConfig, type RegistryAuth } from "./registry/oci";
import { inventoryOsPackages } from "./osdb";
import { scanSecrets } from "./secrets";
import { askForJson, loadDeepSeekConfig } from "./deepseek";
import type { RawFinding } from "./prompts";
import type { ChatMessage } from "./deepseek";
import type { CveMatch, FindingInput, Reporter, Severity } from "./reporter";
import { error, info, warn } from "./logger";
export interface ImageScanOptions {
imageRef: string; // "repository:tag" or "repository@sha256:…" (host is separate)
registryHost: string; // pull endpoint, e.g. registry-1.docker.io
username?: string;
token?: string;
}
export interface ImageScanResult {
packageCount: number;
findingCount: number;
}
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
const LAYER_BYTES_CAP = 800 * 1024 * 1024; // cumulative compressed layer budget
// Pulls the image, inventories OS packages → OSV CVEs, scans for baked secrets, and
// runs a DeepSeek config/runtime analysis — streaming findings to the reporter.
export async function scanImage(opts: ImageScanOptions, reporter: Reporter): Promise<ImageScanResult> {
const { repository, ref } = splitRef(opts.imageRef);
const auth: RegistryAuth = { host: opts.registryHost, username: opts.username, token: opts.token };
const client = new OciClient(auth, repository);
const workDir = await mkdtemp(join(tmpdir(), "cerebrus-image-"));
const rootfs = join(workDir, "rootfs");
let findingCount = 0;
try {
await reporter.setRunning();
await reporter.progress(0, 0, "pull");
info(`Pulling ${opts.registryHost}/${repository}:${ref}`);
await client.authenticate();
const { config, layers } = await client.resolve(ref);
await mkdir(rootfs, { recursive: true });
let budget = LAYER_BYTES_CAP;
for (const layer of layers) {
if (layer.size > budget) {
warn(`skipping layer ${layer.digest} (${layer.size} bytes, over remaining budget)`);
continue;
}
budget -= layer.size;
await client.extractLayer(layer, rootfs);
}
// OS packages → OSV CVEs.
await reporter.progress(0, 0, "packages");
const inv = await inventoryOsPackages(rootfs);
info(`Distro: ${inv.distro ?? "unknown"} (${inv.ecosystem ?? "no OSV ecosystem"}), ${inv.packages.length} packages`);
if (inv.packages.length > 0) {
await reporter.progress(0, inv.packages.length, "cve-check");
const matches = await reporter.matchDependencies(inv.packages);
const cveFindings = matches.map((m) => toCveFinding(m));
if (cveFindings.length > 0) {
await reporter.addFindings(cveFindings);
findingCount += cveFindings.length;
info(` ${cveFindings.length} package CVE finding(s)`);
}
}
// Baked-in secrets.
await reporter.progress(0, 0, "secrets");
const secretFindings = await scanSecrets(rootfs);
if (secretFindings.length > 0) {
await reporter.addFindings(secretFindings);
findingCount += secretFindings.length;
info(` ${secretFindings.length} secret finding(s)`);
}
// Config / runtime misconfig via DeepSeek.
await reporter.progress(0, 0, "misconfig");
const misconfigFindings = await analyzeConfig(config, inv.distro, inv.packages.length);
if (misconfigFindings.length > 0) {
await reporter.addFindings(misconfigFindings);
findingCount += misconfigFindings.length;
info(` ${misconfigFindings.length} misconfig finding(s)`);
}
await reporter.complete(inv.packages.length);
return { packageCount: inv.packages.length, findingCount };
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
function toCveFinding(m: CveMatch): FindingInput {
return {
id: randomUUID(),
filePath: `pkg:${m.name}`,
severity: normalizeSeverity(m.severity ?? "high"),
title: `${m.name} ${m.version}: ${m.cveId ?? m.osvId}`,
description: m.summary ?? `Known vulnerability ${m.osvId} affects ${m.name} ${m.version} in this image.`,
vulnerableCode: `${m.name} ${m.version}`,
suggestedFix: m.fixedVersion
? `Upgrade ${m.name} to ${m.fixedVersion} or later — rebuild the image on a patched base.`
: `Upgrade ${m.name} to a patched version / rebuild on an updated base image.`,
category: "os-package",
cveId: m.cveId ?? m.osvId,
};
}
async function analyzeConfig(config: ImageConfig, distro: string | null, packageCount: number): Promise<FindingInput[]> {
const summary = {
user: config.User || "(root)",
exposedPorts: Object.keys(config.ExposedPorts ?? {}),
env: redactEnv(config.Env ?? []),
entrypoint: config.Entrypoint ?? config.Cmd ?? [],
workingDir: config.WorkingDir ?? null,
volumes: Object.keys(config.Volumes ?? {}),
labels: config.Labels ?? {},
distro,
packageCount,
};
try {
const { findings } = await askForJson<{ findings?: RawFinding[] }>(loadDeepSeekConfig(), configPrompt(summary));
return (findings ?? [])
.filter((f) => f.title && f.description)
.map((f) => ({
id: randomUUID(),
filePath: "image-config",
severity: normalizeSeverity(f.severity),
title: (f.title as string).slice(0, 300),
description: f.description as string,
vulnerableCode: f.vulnerable_code ?? "",
suggestedFix: f.suggested_fix ?? "",
category: "misconfig" as const,
}));
} catch (err) {
error(" misconfig analysis failed", { reason: err instanceof Error ? err.message : String(err) });
return [];
}
}
function configPrompt(summary: unknown): ChatMessage[] {
return [
{
role: "system",
content:
"You are a container security auditor. Given a container image's runtime configuration and metadata, identify " +
"real cloud/runtime security risks: running as root, exposed sensitive ports (SSH/DB/daemon), secrets in env " +
"vars, missing non-root USER, dangerous entrypoints (curl|bash), writable/host volumes, or an end-of-life base " +
"image. Ignore cosmetic issues. Reply with a single JSON object only.",
},
{
role: "user",
content:
`Image configuration:\n${JSON.stringify(summary, null, 2)}\n\n` +
`Return JSON exactly: {"findings":[{"severity":"critical|high|medium|low","title":"short title",` +
`"description":"the runtime/cloud impact","vulnerable_code":"the offending setting, e.g. USER root or EXPOSE 22",` +
`"suggested_fix":"how to remediate"}]}. If nothing is wrong, return {"findings":[]}.`,
},
];
}
// Masks env values that look like secrets so we never send/store credentials.
function redactEnv(env: string[]): string[] {
const secretKey = /(secret|token|key|pass|pwd|credential)/i;
return env.map((pair) => {
const idx = pair.indexOf("=");
if (idx === -1) return pair;
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (secretKey.test(key) || /^[A-Za-z0-9_\-./+]{20,}$/.test(value)) return `${key}=<redacted>`;
return pair;
});
}
function normalizeSeverity(value: string | undefined): Severity {
const v = (value ?? "").toLowerCase();
if (v === "moderate") return "medium";
return (SEVERITIES as string[]).includes(v) ? (v as Severity) : "medium";
}
function splitRef(ref: string): { repository: string; ref: string } {
if (ref.includes("@")) {
const [repository, digest] = ref.split("@");
return { repository, ref: digest };
}
const i = ref.lastIndexOf(":");
if (i > 0) return { repository: ref.slice(0, i), ref: ref.slice(i + 1) };
return { repository: ref, ref: "latest" };
}

75
apps/cli/src/index.ts Normal file
View file

@ -0,0 +1,75 @@
// Container entrypoint for the scanner. Reads config from env and runs either a repo
// scan (SCAN_TARGET unset/"repo") or a container-image scan (SCAN_TARGET="image"),
// streaming results back to the backend via the HTTP reporter. All logging goes to
// stdout/stderr as structured JSON so Cloud Run / Cloud Logging captures it.
import { scanRepository } from "./scan";
import { scanImage } from "./image";
import { HttpReporter } from "./reporter";
import { error, info } from "./logger";
const SCAN_ID = process.env.SCAN_ID;
const SCAN_TARGET = process.env.SCAN_TARGET || "repo";
const SCAN_CALLBACK_URL = process.env.SCAN_CALLBACK_URL;
const SCAN_CALLBACK_SECRET = process.env.SCAN_CALLBACK_SECRET;
async function main(): Promise<void> {
info("=== Scanner starting ===", {
scanTarget: SCAN_TARGET,
callbackUrl: SCAN_CALLBACK_URL,
hasCallbackSecret: Boolean(SCAN_CALLBACK_SECRET),
nodeEnv: process.env.NODE_ENV,
});
if (!SCAN_ID) {
error("Missing required env: SCAN_ID");
process.exit(1);
}
if (!SCAN_CALLBACK_URL) {
error("Missing required env: SCAN_CALLBACK_URL");
process.exit(1);
}
const reporter = new HttpReporter(SCAN_ID, SCAN_CALLBACK_URL, SCAN_CALLBACK_SECRET ?? "");
try {
if (SCAN_TARGET === "image") {
const imageRef = process.env.IMAGE_REF;
const registryHost = process.env.REGISTRY_HOST;
if (!imageRef || !registryHost) {
error("Missing required env for image scan: IMAGE_REF, REGISTRY_HOST");
process.exit(1);
}
info("Starting image scan", { imageRef, registryHost });
const { packageCount, findingCount } = await scanImage(
{
imageRef,
registryHost,
username: process.env.REGISTRY_USERNAME || undefined,
token: process.env.REGISTRY_TOKEN || undefined,
},
reporter,
);
info("=== Scanner finished ===", { imageRef, packageCount, findingCount });
} else {
const owner = process.env.REPO_OWNER;
const repo = process.env.REPO_NAME;
if (!owner || !repo) {
error("Missing required env for repo scan: REPO_OWNER, REPO_NAME");
process.exit(1);
}
info("Starting repo scan", { owner, repo, ref: process.env.REPO_REF || "default" });
const { fileCount, findingCount } = await scanRepository(
{ owner, repo, ref: process.env.REPO_REF || undefined, token: process.env.GITHUB_TOKEN || undefined },
reporter,
);
info("=== Scanner finished ===", { owner, repo, fileCount, findingCount });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
error("=== Scanner failed ===", { error: message });
await reporter.fail(message);
process.exit(1);
}
}
await main();

24
apps/cli/src/logger.ts Normal file
View file

@ -0,0 +1,24 @@
// Tiny structured logger for the scanner. Emits one JSON line per log entry so
// Cloud Run / Cloud Logging captures it reliably (plain multi-line logs often get
// split or dropped when containers exit quickly).
const SCAN_ID = process.env.SCAN_ID;
const SCAN_TARGET = process.env.SCAN_TARGET || "repo";
export function log(level: "info" | "error" | "warn", message: string, extra?: Record<string, unknown>): void {
const entry = {
timestamp: new Date().toISOString(),
level,
scanId: SCAN_ID,
scanTarget: SCAN_TARGET,
message,
...extra,
};
const out = level === "error" ? console.error : console.log;
out(JSON.stringify(entry));
}
/** Convenience wrappers */
export const info = (message: string, extra?: Record<string, unknown>) => log("info", message, extra);
export const warn = (message: string, extra?: Record<string, unknown>) => log("warn", message, extra);
export const error = (message: string, extra?: Record<string, unknown>) => log("error", message, extra);

126
apps/cli/src/manifests.ts Normal file
View file

@ -0,0 +1,126 @@
import { basename } from "node:path";
import type { DepInput } from "./reporter";
// Pragmatic dependency-manifest parsers. Goal: extract {ecosystem, name, version}
// with a *concrete* version we can hand to OSV. Range specifiers are reduced to their
// lower-bound version; entries without a pinnable version (*, latest, workspace:,
// git/file refs) are skipped. Maven (pom.xml/gradle) is intentionally not parsed yet.
// Pulls the first concrete version out of a range string ("^1.2.3" → "1.2.3").
function cleanVersion(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed || /^(?:\*|latest|workspace:|file:|link:|git[+:]|https?:|github:)/i.test(trimmed)) return null;
const m = trimmed.match(/\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?/);
return m ? m[0] : null;
}
function parsePackageJson(content: string): DepInput[] {
const deps: DepInput[] = [];
try {
const json = JSON.parse(content) as Record<string, unknown>;
for (const key of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
const block = json[key];
if (!block || typeof block !== "object") continue;
for (const [name, spec] of Object.entries(block as Record<string, string>)) {
const version = cleanVersion(String(spec));
if (version) deps.push({ ecosystem: "npm", name, version });
}
}
} catch {
// malformed package.json — skip
}
return deps;
}
function parseRequirementsTxt(content: string): DepInput[] {
const deps: DepInput[] = [];
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
const m = trimmed.match(/^([A-Za-z0-9._-]+)\s*==\s*([0-9][^\s;#]*)/);
if (m) deps.push({ ecosystem: "PyPI", name: m[1], version: m[2] });
}
return deps;
}
function parsePyproject(content: string): DepInput[] {
const deps: DepInput[] = [];
// PEP 621 / poetry: `name = "^1.2.3"` and `"name==1.2.3"` list entries.
for (const m of content.matchAll(/^([A-Za-z0-9._-]+)\s*=\s*["']([^"']+)["']/gm)) {
if (m[1] === "python") continue;
const version = cleanVersion(m[2]);
if (version) deps.push({ ecosystem: "PyPI", name: m[1], version });
}
for (const m of content.matchAll(/["']([A-Za-z0-9._-]+)\s*==\s*([0-9][^"']*)["']/g)) {
deps.push({ ecosystem: "PyPI", name: m[1], version: m[2] });
}
return deps;
}
function parseGoMod(content: string): DepInput[] {
const deps: DepInput[] = [];
for (const m of content.matchAll(/^\s*([\w./-]+)\s+v(\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)/gm)) {
if (m[1] === "go" || m[1] === "module" || m[1] === "toolchain") continue;
deps.push({ ecosystem: "Go", name: m[1], version: m[2] });
}
return deps;
}
function parseCargoToml(content: string): DepInput[] {
const deps: DepInput[] = [];
for (const m of content.matchAll(/^([A-Za-z0-9._-]+)\s*=\s*(?:["']([^"']+)["']|\{[^}]*version\s*=\s*["']([^"']+)["'])/gm)) {
const version = cleanVersion(m[2] ?? m[3] ?? "");
if (version) deps.push({ ecosystem: "crates.io", name: m[1], version });
}
return deps;
}
function parseComposerJson(content: string): DepInput[] {
const deps: DepInput[] = [];
try {
const json = JSON.parse(content) as Record<string, unknown>;
for (const key of ["require", "require-dev"]) {
const block = json[key];
if (!block || typeof block !== "object") continue;
for (const [name, spec] of Object.entries(block as Record<string, string>)) {
if (name === "php" || !name.includes("/")) continue;
const version = cleanVersion(String(spec));
if (version) deps.push({ ecosystem: "Packagist", name, version });
}
}
} catch {
// skip
}
return deps;
}
function parseGemfile(content: string): DepInput[] {
const deps: DepInput[] = [];
for (const m of content.matchAll(/gem\s+["']([^"']+)["']\s*,\s*["']([^"']+)["']/g)) {
const version = cleanVersion(m[2]);
if (version) deps.push({ ecosystem: "RubyGems", name: m[1], version });
}
return deps;
}
export function parseManifest(relPath: string, content: string): DepInput[] {
switch (basename(relPath)) {
case "package.json":
return parsePackageJson(content);
case "requirements.txt":
return parseRequirementsTxt(content);
case "pyproject.toml":
case "Pipfile":
return parsePyproject(content);
case "go.mod":
return parseGoMod(content);
case "Cargo.toml":
return parseCargoToml(content);
case "composer.json":
return parseComposerJson(content);
case "Gemfile":
return parseGemfile(content);
default:
return [];
}
}

127
apps/cli/src/osdb.ts Normal file
View file

@ -0,0 +1,127 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import type { DepInput } from "./reporter";
// Reads a container rootfs and returns its installed OS packages as OSV DepInputs.
// The ecosystem string MUST be release-qualified (Debian:12 / Ubuntu:22.04:LTS /
// Alpine:v3.19) and the name MUST be the SOURCE package, or OSV returns nothing.
interface OsRelease {
id: string;
versionId: string;
}
async function readText(path: string): Promise<string | null> {
try {
return await readFile(path, "utf8");
} catch {
return null;
}
}
async function readOsRelease(rootfs: string): Promise<OsRelease | null> {
const raw = (await readText(join(rootfs, "etc/os-release"))) ?? (await readText(join(rootfs, "usr/lib/os-release")));
if (!raw) return null;
const fields: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) fields[m[1]] = m[2].replace(/^"(.*)"$/, "$1");
}
return { id: (fields.ID ?? "").toLowerCase(), versionId: fields.VERSION_ID ?? "" };
}
// Maps os-release → the OSV ecosystem string. Returns null for distros we can't map
// (the scan still runs; it just won't have OS-package CVEs).
export function osvEcosystem(os: OsRelease): string | null {
const { id, versionId } = os;
if (id === "debian") return versionId ? `Debian:${versionId}` : null;
if (id === "ubuntu") {
if (!versionId) return null;
const major = Number.parseInt(versionId, 10);
const isLts = /\.04$/.test(versionId) && major % 2 === 0;
return isLts ? `Ubuntu:${versionId}:LTS` : `Ubuntu:${versionId}`;
}
if (id === "alpine") {
const [maj, min] = versionId.split(".");
return maj && min ? `Alpine:v${maj}.${min}` : null;
}
return null;
}
// Parses one dpkg status file (blank-line-separated RFC822 stanzas). Only counts
// installed packages; keys the name on Source (fallback Package).
function parseDpkgStatus(raw: string, ecosystem: string, out: DepInput[]): void {
for (const stanza of raw.split(/\n\n+/)) {
if (!stanza.trim()) continue;
const fields: Record<string, string> = {};
for (const line of stanza.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9-]+):\s?(.*)$/);
if (m) fields[m[1]] = m[2];
}
if (fields.Status !== "install ok installed") continue;
const version = fields.Version;
if (!version) continue;
// "Source: openssl (1.1.1n)" → "openssl"; else the binary Package name.
const source = fields.Source ? fields.Source.replace(/\s*\(.*\)$/, "").trim() : undefined;
const name = source || fields.Package;
if (name) out.push({ ecosystem, name, version });
}
}
// Parses Alpine's /lib/apk/db/installed. Name keyed on origin (o:), fallback P:.
function parseApkInstalled(raw: string, ecosystem: string, out: DepInput[]): void {
for (const record of raw.split(/\n\n+/)) {
if (!record.trim()) continue;
const fields: Record<string, string> = {};
for (const line of record.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z]):(.*)$/);
if (m) fields[m[1]] = m[2];
}
const version = fields.V;
const name = fields.o || fields.P;
if (name && version) out.push({ ecosystem, name, version });
}
}
export interface Inventory {
distro: string | null;
ecosystem: string | null;
packages: DepInput[];
}
// Full OS-package inventory of an extracted rootfs.
export async function inventoryOsPackages(rootfs: string): Promise<Inventory> {
const os = await readOsRelease(rootfs);
const ecosystem = os ? osvEcosystem(os) : null;
const distro = os ? `${os.id} ${os.versionId}`.trim() : null;
const packages: DepInput[] = [];
if (!ecosystem) return { distro, ecosystem, packages };
if (ecosystem.startsWith("Alpine")) {
const apk = await readText(join(rootfs, "lib/apk/db/installed"));
if (apk) parseApkInstalled(apk, ecosystem, packages);
} else {
// Debian/Ubuntu: monolithic status, plus distroless per-package status.d/*.
const status = await readText(join(rootfs, "var/lib/dpkg/status"));
if (status) parseDpkgStatus(status, ecosystem, packages);
try {
const dir = join(rootfs, "var/lib/dpkg/status.d");
for (const entry of await readdir(dir)) {
const raw = await readText(join(dir, entry));
if (raw) parseDpkgStatus(raw, ecosystem, packages);
}
} catch {
// no status.d — normal for non-distroless
}
}
// De-dupe by name+version (source packages repeat across binary packages).
const seen = new Set<string>();
const deduped = packages.filter((p) => {
const key = `${p.name}\n${p.version}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return { distro, ecosystem, packages: deduped };
}

74
apps/cli/src/prompts.ts Normal file
View file

@ -0,0 +1,74 @@
import type { ChatMessage } from "./deepseek";
import type { CveMatch } from "./reporter";
// Model output shapes (snake_case — that's what we ask the model for).
export interface RawFinding {
severity?: string;
title?: string;
description?: string;
vulnerable_code?: string;
suggested_fix?: string;
start_line?: number | null;
end_line?: number | null;
}
export interface RawChain {
title?: string;
severity?: string;
description?: string;
steps?: { finding_id?: string; file_path?: string; note?: string }[];
}
const MAX_CONTENT_CHARS = 24000;
export function analyzeFilePrompt(relPath: string, content: string): ChatMessage[] {
const truncated = content.length > MAX_CONTENT_CHARS ? `${content.slice(0, MAX_CONTENT_CHARS)}\n/* …truncated… */` : content;
return [
{
role: "system",
content:
"You are a meticulous application security auditor. You ONLY identify real, exploitable security vulnerabilities " +
"(injection, broken authn/authz, hardcoded secrets, SSRF, path traversal, unsafe deserialization, XSS, crypto " +
"misuse, command execution, etc.). Ignore style, performance, and non-security nits. Do not invent issues. " +
"Respond with a single JSON object and nothing else.",
},
{
role: "user",
content:
`File: ${relPath}\n\n\`\`\`\n${truncated}\n\`\`\`\n\n` +
`Return JSON exactly: {"findings":[{"severity":"critical|high|medium|low","title":"short title",` +
`"description":"why it is exploitable and the impact","vulnerable_code":"the exact snippet copied VERBATIM ` +
`from the file above","suggested_fix":"the replacement code that fixes it","start_line":number|null,` +
`"end_line":number|null}]}. "vulnerable_code" MUST be an exact substring of the file so it can be highlighted. ` +
`If there are no real vulnerabilities, return {"findings":[]}.`,
},
];
}
export interface ChainFindingRef {
id: string;
filePath: string;
severity: string;
title: string;
description: string;
}
export function chainPrompt(findings: ChainFindingRef[], cves: CveMatch[]): ChatMessage[] {
return [
{
role: "system",
content:
"You are a security analyst finding ATTACK CHAINS: sequences of individually-scoped findings that combine into a " +
"higher-impact exploit path (e.g. an SSRF that reaches an endpoint protected only by a hardcoded secret). Only " +
"report chains that are genuinely linked. Respond with a single JSON object and nothing else.",
},
{
role: "user",
content:
`Findings:\n${JSON.stringify(findings)}\n\nKnown dependency CVEs:\n${JSON.stringify(cves)}\n\n` +
`Return JSON exactly: {"chains":[{"title":"short title","severity":"critical|high|medium|low",` +
`"description":"how the steps chain into an attack","steps":[{"finding_id":"id from the list above or null",` +
`"file_path":"path","note":"the role this step plays in the chain"}]}]}. If there are no meaningful chains, ` +
`return {"chains":[]}.`,
},
];
}

View file

@ -0,0 +1,166 @@
import { spawn } from "node:child_process";
import { Buffer } from "node:buffer";
import { Readable } from "node:stream";
// A minimal OCI Distribution API client over global fetch — auth token flow, manifest
// resolution (image index → linux/amd64), config blob, and streamed layer extraction.
// Enough to inventory an image; not a general-purpose registry client.
export interface RegistryAuth {
host: string; // registry endpoint, e.g. registry-1.docker.io, ghcr.io
username?: string;
token?: string; // password / PAT / short-lived access token
}
export interface ImageConfig {
User?: string;
ExposedPorts?: Record<string, unknown>;
Env?: string[];
Entrypoint?: string[];
Cmd?: string[];
WorkingDir?: string;
Volumes?: Record<string, unknown>;
Labels?: Record<string, string>;
}
interface Descriptor {
mediaType: string;
digest: string;
size: number;
platform?: { os?: string; architecture?: string };
annotations?: Record<string, string>;
}
interface Manifest {
mediaType?: string;
manifests?: Descriptor[]; // index / manifest list
config?: Descriptor; // single manifest
layers?: Descriptor[];
}
const ACCEPT = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
].join(", ");
export class OciClient {
private readonly auth: RegistryAuth;
private readonly repository: string;
private token: string | null = null;
constructor(auth: RegistryAuth, repository: string) {
this.auth = auth;
this.repository = repository;
}
private authHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = { "User-Agent": "cerebrus-scanner", ...extra };
if (this.token) headers.Authorization = `Bearer ${this.token}`;
return headers;
}
// OCI token dance: hit an endpoint, read WWW-Authenticate, mint a pull-scoped token.
async authenticate(): Promise<void> {
const res = await fetch(`https://${this.auth.host}/v2/`, { headers: { "User-Agent": "cerebrus-scanner" } });
if (res.status === 200) return; // registry needs no auth
const challenge = res.headers.get("www-authenticate");
if (!challenge || !/^Bearer/i.test(challenge)) return;
const params = parseChallenge(challenge);
if (!params.realm) return;
const url = new URL(params.realm);
if (params.service) url.searchParams.set("service", params.service);
url.searchParams.set("scope", `repository:${this.repository}:pull`);
const headers: Record<string, string> = { "User-Agent": "cerebrus-scanner" };
if (this.auth.username && this.auth.token) {
headers.Authorization = `Basic ${Buffer.from(`${this.auth.username}:${this.auth.token}`).toString("base64")}`;
}
const tokRes = await fetch(url, { headers });
if (!tokRes.ok) throw new Error(`registry auth failed (${tokRes.status}): ${await tokRes.text()}`);
const data = (await tokRes.json()) as { token?: string; access_token?: string };
this.token = data.token ?? data.access_token ?? null;
}
// Resolves the image to a single-arch manifest (linux/amd64), returning the image
// config and layer descriptors plus the manifest digest.
async resolve(ref: string): Promise<{ config: ImageConfig; layers: Descriptor[]; digest: string | null }> {
let { manifest, digest } = await this.getManifest(ref);
if (manifest.manifests && manifest.manifests.length > 0) {
const child = manifest.manifests.find(
(m) =>
m.platform?.os === "linux" &&
m.platform?.architecture === "amd64" &&
m.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest",
);
if (!child) throw new Error("no linux/amd64 manifest in the image index");
({ manifest, digest } = await this.getManifest(child.digest));
}
if (!manifest.config || !manifest.layers) throw new Error("unexpected manifest shape (no config/layers)");
const config = (await this.getBlobJson(manifest.config.digest)) as { config?: ImageConfig };
return { config: config.config ?? {}, layers: manifest.layers, digest };
}
private async getManifest(ref: string): Promise<{ manifest: Manifest; digest: string | null }> {
const res = await fetch(`https://${this.auth.host}/v2/${this.repository}/manifests/${ref}`, {
headers: this.authHeaders({ Accept: ACCEPT }),
});
if (!res.ok) throw new Error(`manifest fetch failed for ${this.repository}:${ref} (${res.status})`);
return { manifest: (await res.json()) as Manifest, digest: res.headers.get("docker-content-digest") };
}
// Blob GETs often 302/307 to a CDN that rejects the forwarded Authorization header —
// so on a redirect we re-fetch the Location WITHOUT auth.
private async getBlob(digest: string): Promise<Response> {
const res = await fetch(`https://${this.auth.host}/v2/${this.repository}/blobs/${digest}`, {
headers: this.authHeaders(),
redirect: "manual",
});
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) throw new Error(`blob ${digest} redirect had no Location`);
const redirected = await fetch(location, { headers: { "User-Agent": "cerebrus-scanner" } });
if (!redirected.ok) throw new Error(`blob ${digest} fetch failed (${redirected.status})`);
return redirected;
}
if (!res.ok) throw new Error(`blob ${digest} fetch failed (${res.status})`);
return res;
}
private async getBlobJson(digest: string): Promise<unknown> {
return (await this.getBlob(digest)).json();
}
// Streams a gzipped layer blob straight into `tar` extracting under rootfs. Skips
// zstd layers (the slim runtime image has no zstd). Returns false if skipped.
async extractLayer(layer: Descriptor, rootfs: string): Promise<boolean> {
if (layer.mediaType.includes("zstd")) {
console.warn(`skipping zstd layer ${layer.digest} (unsupported)`);
return false;
}
const res = await this.getBlob(layer.digest);
if (!res.body) throw new Error(`layer ${layer.digest} had no body`);
await new Promise<void>((resolve, reject) => {
const proc = spawn("tar", ["-xz", "-C", rootfs, "--no-same-owner", "--no-same-permissions"], {
stdio: ["pipe", "ignore", "pipe"],
});
let stderr = "";
proc.stderr.on("data", (c) => (stderr += c));
proc.on("error", reject);
proc.on("close", (code) => {
// tar exits 2 on benign issues (e.g. whiteout/device entries); tolerate it.
if (code === 0 || code === 2) resolve();
else reject(new Error(`tar failed (exit ${code}): ${stderr.trim()}`));
});
Readable.fromWeb(res.body as import("node:stream/web").ReadableStream).pipe(proc.stdin);
});
return true;
}
}
function parseChallenge(header: string): { realm?: string; service?: string } {
const out: Record<string, string> = {};
for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) out[m[1]] = m[2];
return out;
}

144
apps/cli/src/reporter.ts Normal file
View file

@ -0,0 +1,144 @@
// The reporting contract between the scan engine (scan.ts) and its transport.
// Two implementations exist: HttpReporter (below, used by the container entrypoint
// — POSTs to the backend's secret-authed /internal routes) and DbReporter (in the
// backend, used by inline mode — writes straight to Postgres). This lets scan.ts be
// transport-agnostic and keeps the CLI free of any DB dependency.
export type Severity = "critical" | "high" | "medium" | "low";
export interface FindingInput {
/** Stable id generated by the scanner so chains can reference it pre-persist. */
id: string;
filePath: string;
severity: Severity;
title: string;
description: string;
/** Exact snippet to replace (rendered RED). */
vulnerableCode: string;
/** Proposed replacement (rendered GREEN). */
suggestedFix: string;
startLine?: number | null;
endLine?: number | null;
category?: "code" | "dependency" | "os-package" | "secret" | "misconfig";
cveId?: string | null;
}
export interface ChainStep {
findingId?: string;
filePath: string;
note: string;
}
export interface ChainInput {
title: string;
severity: Severity;
description: string;
steps: ChainStep[];
}
export interface DepInput {
ecosystem: string;
name: string;
version: string;
}
export interface CveMatch {
ecosystem: string;
name: string;
version: string;
osvId: string;
cveId?: string | null;
severity?: string | null;
summary?: string | null;
fixedVersion?: string | null;
}
export interface Reporter {
setRunning(): Promise<void>;
progress(filesScanned: number, fileCount: number, stage?: string): Promise<void>;
addFindings(findings: FindingInput[]): Promise<void>;
addChains(chains: ChainInput[]): Promise<void>;
matchDependencies(deps: DepInput[]): Promise<CveMatch[]>;
complete(fileCount: number): Promise<void>;
fail(error: string): Promise<void>;
}
import { error, warn } from "./logger";
// HTTP transport for containerized runners (docker / cloudrun). Every call posts to
// the backend's /internal/* routes with the shared secret. Non-fatal calls swallow
// network errors (a dropped progress ping shouldn't kill a scan); only `fail` and
// `matchDependencies` surface problems to the caller.
export class HttpReporter implements Reporter {
private readonly scanId: string;
private readonly baseUrl: string;
private readonly secret: string;
constructor(scanId: string, baseUrl: string, secret: string) {
this.scanId = scanId;
this.baseUrl = baseUrl;
this.secret = secret;
if (!secret) {
warn("[reporter] SCAN_CALLBACK_SECRET is empty — the backend will reject every callback (findings will NOT stream).");
}
}
private post(path: string, body: unknown): Promise<Response> {
return fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-scan-secret": this.secret },
body: JSON.stringify(body),
});
}
private async postSafe(path: string, body: unknown, label: string): Promise<void> {
try {
const res = await this.post(path, body);
if (!res.ok) error(`[reporter] ${label}${res.status} ${await res.text()}`);
} catch (err) {
error(`[reporter] ${label} failed`, { reason: err instanceof Error ? err.message : String(err) });
}
}
setRunning(): Promise<void> {
return this.postSafe("/internal/scan-result", { scanId: this.scanId, status: "running" }, "setRunning");
}
progress(filesScanned: number, fileCount: number, stage?: string): Promise<void> {
return this.postSafe("/internal/scan/progress", { scanId: this.scanId, filesScanned, fileCount, stage }, "progress");
}
addFindings(findings: FindingInput[]): Promise<void> {
if (findings.length === 0) return Promise.resolve();
return this.postSafe("/internal/scan/findings", { scanId: this.scanId, findings }, "addFindings");
}
addChains(chains: ChainInput[]): Promise<void> {
if (chains.length === 0) return Promise.resolve();
return this.postSafe("/internal/scan/chains", { scanId: this.scanId, chains }, "addChains");
}
async matchDependencies(deps: DepInput[]): Promise<CveMatch[]> {
if (deps.length === 0) return [];
try {
const res = await this.post("/internal/scan/cve-check", { deps });
if (!res.ok) {
error(`[reporter] matchDependencies → ${res.status} ${await res.text()}`);
return [];
}
const data = (await res.json()) as { matches?: CveMatch[] };
return data.matches ?? [];
} catch (err) {
error("[reporter] matchDependencies failed", { reason: err instanceof Error ? err.message : String(err) });
return [];
}
}
complete(fileCount: number): Promise<void> {
return this.postSafe("/internal/scan-result", { scanId: this.scanId, status: "completed", fileCount }, "complete");
}
fail(error: string): Promise<void> {
return this.postSafe("/internal/scan-result", { scanId: this.scanId, status: "failed", error }, "fail");
}
}

248
apps/cli/src/scan.ts Normal file
View file

@ -0,0 +1,248 @@
import { spawn } from "node:child_process";
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { askForJson, loadDeepSeekConfig, type DeepSeekConfig } from "./deepseek";
import { collectFiles, isManifest, type ScanFile } from "./walk";
import { parseManifest } from "./manifests";
import { error, info } from "./logger";
import { analyzeFilePrompt, chainPrompt, type ChainFindingRef, type RawChain, type RawFinding } from "./prompts";
import type { ChainInput, CveMatch, DepInput, FindingInput, Reporter, Severity } from "./reporter";
export interface ScanOptions {
owner: string;
repo: string;
/** Git ref (branch/tag/sha). Defaults to the repo's default branch. */
ref?: string;
/** GitHub token (required for private repos). */
token?: string;
}
export interface ScanResult {
fileCount: number;
findingCount: number;
}
export type {
Reporter,
FindingInput,
ChainInput,
ChainStep,
DepInput,
CveMatch,
Severity,
} from "./reporter";
export { HttpReporter } from "./reporter";
export { scanImage } from "./image";
export type { ImageScanOptions, ImageScanResult } from "./image";
const GITHUB_API = "https://api.github.com";
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
// Downloads + extracts the repo, then runs a DeepSeek-powered, file-by-file
// vulnerability analysis, a dependency→CVE check, and a chaining pass — streaming
// results to the injected reporter as they are found.
export async function scanRepository(opts: ScanOptions, reporter: Reporter): Promise<ScanResult> {
const config = loadDeepSeekConfig();
const workDir = await mkdtemp(join(tmpdir(), "cerebrus-scan-"));
try {
await reporter.setRunning();
await reporter.progress(0, 0, "download");
info(`Downloading ${opts.owner}/${opts.repo}${opts.ref ? `@${opts.ref}` : ""}`);
const tarballPath = join(workDir, "repo.tar.gz");
await downloadTarball(opts, tarballPath);
const extractDir = join(workDir, "extract");
await mkdir(extractDir);
await extractTarball(tarballPath, extractDir);
const files = await collectFiles(extractDir);
const total = files.length;
info(`Analyzing ${total} files with ${config.model} (concurrency ${config.concurrency})`);
await reporter.progress(0, total, "analyzing");
const collected: ChainFindingRef[] = [];
await analyzeFiles(files, config, reporter, total, collected);
// Dependency → CVE pass.
const cves = await runCveCheck(files, reporter, collected, total);
// Chaining pass over everything we found.
await runChaining(collected, cves, config, reporter, total);
await reporter.complete(total);
return { fileCount: total, findingCount: collected.length };
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
// Bounded-concurrency, per-file analysis. Each file is isolated: a failure (network,
// unparseable reply) is logged and skipped so one bad file never fails the scan.
async function analyzeFiles(
files: ScanFile[],
config: DeepSeekConfig,
reporter: Reporter,
total: number,
collected: ChainFindingRef[],
): Promise<void> {
let done = 0;
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < files.length) {
const file = files[cursor++];
try {
const content = await readFile(file.absPath, "utf8");
const { findings } = await askForJson<{ findings?: RawFinding[] }>(config, analyzeFilePrompt(file.relPath, content));
const mapped = (findings ?? [])
.filter((f) => f.vulnerable_code && f.title)
.map((f) => toFinding(file.relPath, f));
if (mapped.length > 0) {
await reporter.addFindings(mapped);
for (const f of mapped) {
collected.push({ id: f.id, filePath: f.filePath, severity: f.severity, title: f.title, description: f.description });
}
info(` ${file.relPath}: ${mapped.length} finding(s)`);
}
} catch (err) {
error(` ${file.relPath}: analysis failed`, { file: file.relPath, reason: err instanceof Error ? err.message : String(err) });
} finally {
done++;
await reporter.progress(done, total, "analyzing");
}
}
};
await Promise.all(Array.from({ length: Math.min(config.concurrency, files.length) }, worker));
}
// Parses dependency manifests, asks the backend to match versions against OSV/CVEs,
// and emits a dependency finding per match.
async function runCveCheck(files: ScanFile[], reporter: Reporter, collected: ChainFindingRef[], total: number): Promise<CveMatch[]> {
const pathByKey = new Map<string, string>();
const deps: DepInput[] = [];
for (const file of files.filter((f) => isManifest(f.relPath))) {
let content: string;
try {
content = await readFile(file.absPath, "utf8");
} catch {
continue;
}
for (const dep of parseManifest(file.relPath, content)) {
deps.push(dep);
pathByKey.set(depKey(dep), file.relPath);
}
}
if (deps.length === 0) return [];
info(`Checking ${deps.length} dependencies against the CVE database`);
await reporter.progress(total, total, "cve-check");
const matches = await reporter.matchDependencies(deps);
if (matches.length === 0) return [];
const findings: FindingInput[] = matches.map((m) => {
const filePath = pathByKey.get(depKey(m)) ?? `${m.name} (dependency)`;
return {
id: randomUUID(),
filePath,
severity: normalizeSeverity(m.severity ?? "high"),
title: `${m.name}@${m.version}: ${m.cveId ?? m.osvId}`,
description: m.summary ?? `Known vulnerability ${m.osvId} affects ${m.name} ${m.version}.`,
vulnerableCode: `${m.name}: ${m.version}`,
suggestedFix: m.fixedVersion ? `${m.name}: ${m.fixedVersion}` : `Upgrade ${m.name} to a patched version.`,
category: "dependency",
cveId: m.cveId ?? m.osvId,
};
});
await reporter.addFindings(findings);
for (const f of findings) {
collected.push({ id: f.id, filePath: f.filePath, severity: f.severity, title: f.title, description: f.description });
}
info(` ${findings.length} vulnerable dependency(ies)`);
return matches;
}
async function runChaining(
collected: ChainFindingRef[],
cves: CveMatch[],
config: DeepSeekConfig,
reporter: Reporter,
total: number,
): Promise<void> {
if (collected.length < 2) return; // nothing to chain
info("Correlating findings into attack chains");
await reporter.progress(total, total, "chaining");
try {
const { chains } = await askForJson<{ chains?: RawChain[] }>(config, chainPrompt(collected, cves));
const mapped: ChainInput[] = (chains ?? [])
.filter((c) => c.title && c.description)
.map((c) => ({
title: c.title as string,
severity: normalizeSeverity(c.severity ?? "high"),
description: c.description as string,
steps: (c.steps ?? []).map((s) => ({ findingId: s.finding_id, filePath: s.file_path ?? "", note: s.note ?? "" })),
}));
if (mapped.length > 0) {
await reporter.addChains(mapped);
info(` ${mapped.length} attack chain(s)`);
}
} catch (err) {
error(" chaining failed", { reason: err instanceof Error ? err.message : String(err) });
}
}
function toFinding(relPath: string, raw: RawFinding): FindingInput {
return {
id: randomUUID(),
filePath: relPath,
severity: normalizeSeverity(raw.severity),
title: (raw.title ?? "Untitled finding").slice(0, 300),
description: raw.description ?? "",
vulnerableCode: raw.vulnerable_code ?? "",
suggestedFix: raw.suggested_fix ?? "",
startLine: raw.start_line ?? null,
endLine: raw.end_line ?? null,
category: "code",
};
}
function normalizeSeverity(value: string | undefined): Severity {
const v = (value ?? "").toLowerCase();
return (SEVERITIES as string[]).includes(v) ? (v as Severity) : "medium";
}
function depKey(d: { ecosystem: string; name: string; version: string }): string {
return `${d.ecosystem.toLowerCase()}\n${d.name}\n${d.version}`;
}
async function downloadTarball(opts: ScanOptions, dest: string): Promise<void> {
const url = `${GITHUB_API}/repos/${opts.owner}/${opts.repo}/tarball/${opts.ref ?? ""}`;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "cerebrus-scanner",
"X-GitHub-Api-Version": "2022-11-28",
};
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
const res = await fetch(url, { headers, redirect: "follow" });
if (!res.ok) {
throw new Error(`Tarball download failed for ${opts.owner}/${opts.repo} (${res.status})`);
}
await writeFile(dest, Buffer.from(await res.arrayBuffer()));
}
function extractTarball(tarball: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn("tar", ["-xzf", tarball, "-C", dest]);
let stderr = "";
proc.stderr.on("data", (chunk) => {
stderr += chunk;
});
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`tar extraction failed (exit ${code}): ${stderr.trim()}`));
});
});
}

113
apps/cli/src/secrets.ts Normal file
View file

@ -0,0 +1,113 @@
import { randomUUID } from "node:crypto";
import { readdir, readFile, stat } from "node:fs/promises";
import { extname, join, relative } from "node:path";
import type { FindingInput } from "./reporter";
// Scans an extracted image rootfs for baked-in secrets. Values are ALWAYS redacted
// before they leave this function (into findings or, later, the DeepSeek prompt).
const MAX_FILE_BYTES = 64 * 1024;
const MAX_FILES = 5000;
const MAX_FINDINGS = 50;
const SKIP_DIRS = new Set(["proc", "sys", "dev", "run"]);
const SKIP_EXTS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".pdf", ".woff", ".woff2", ".ttf",
".so", ".a", ".o", ".bin", ".exe", ".dll", ".class", ".pyc", ".wasm", ".zip", ".gz", ".xz",
".mo", ".deb", ".apk",
]);
interface Pattern {
name: string;
re: RegExp;
severity: "high" | "medium";
}
const PATTERNS: Pattern[] = [
{ name: "AWS access key id", re: /AKIA[0-9A-Z]{16}/g, severity: "high" },
{ name: "Private key", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g, severity: "high" },
{ name: "GitHub token", re: /gh[opsu]_[A-Za-z0-9]{30,}/g, severity: "high" },
{ name: "Slack token", re: /xox[baprs]-[A-Za-z0-9-]{10,}/g, severity: "high" },
{
name: "Hardcoded credential",
re: /(?:api[_-]?key|secret|token|passwd|password)["']?\s*[:=]\s*["'][A-Za-z0-9_\-./+]{16,}["']/gi,
severity: "medium",
},
];
function redact(secret: string): string {
const head = secret.slice(0, 4);
return `${head}${"*".repeat(Math.max(4, Math.min(12, secret.length - 4)))}`;
}
async function isProbablyText(path: string): Promise<boolean> {
try {
const fd = await readFile(path);
const sample = fd.subarray(0, 512);
return !sample.includes(0); // null byte → binary
} catch {
return false;
}
}
export async function scanSecrets(rootfs: string): Promise<FindingInput[]> {
const findings: FindingInput[] = [];
const seen = new Set<string>();
let filesScanned = 0;
const stack: string[] = [rootfs];
while (stack.length > 0 && filesScanned < MAX_FILES && findings.length < MAX_FINDINGS) {
const dir = stack.pop() as string;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) stack.push(full);
continue;
}
if (!entry.isFile() || SKIP_EXTS.has(extname(entry.name).toLowerCase())) continue;
let size: number;
try {
size = (await stat(full)).size;
} catch {
continue;
}
if (size === 0 || size > MAX_FILE_BYTES) continue;
filesScanned++;
if (!(await isProbablyText(full))) continue;
let content: string;
try {
content = await readFile(full, "utf8");
} catch {
continue;
}
const relPath = relative(rootfs, full);
for (const pattern of PATTERNS) {
pattern.re.lastIndex = 0;
const match = pattern.re.exec(content);
if (!match) continue;
const key = `${pattern.name}\n${redact(match[0])}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id: randomUUID(),
filePath: relPath,
severity: pattern.severity,
title: `${pattern.name} baked into image`,
description: `A ${pattern.name.toLowerCase()} appears in \`${relPath}\`. Secrets embedded in image layers persist in the registry history and are readable by anyone who can pull the image.`,
vulnerableCode: `${match[0].slice(0, 4)}… (${pattern.name}, redacted)`,
suggestedFix: "Remove the secret from the image and inject it at runtime (env var / secret store); rotate the exposed credential.",
category: "secret",
});
if (findings.length >= MAX_FINDINGS) break;
}
}
}
return findings;
}

128
apps/cli/src/walk.ts Normal file
View file

@ -0,0 +1,128 @@
import { readdir, stat } from "node:fs/promises";
import { basename, extname, join, relative } from "node:path";
export interface ScanFile {
/** Absolute path on disk. */
absPath: string;
/** Path relative to the repo root (GitHub tarball wrapper stripped). */
relPath: string;
}
const MAX_FILE_BYTES = 256 * 1024;
// Directories that are never worth analyzing (deps output, VCS, build artifacts).
const SKIP_DIRS = new Set([
".git",
"node_modules",
"dist",
"build",
"out",
".next",
".nuxt",
".svelte-kit",
"coverage",
"vendor",
"target",
".venv",
"venv",
"__pycache__",
".turbo",
".cache",
]);
// Lockfiles carry no code to audit (huge + noisy). Dependency *manifests* are kept.
const LOCKFILES = new Set([
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lock",
"bun.lockb",
"Cargo.lock",
"poetry.lock",
"Gemfile.lock",
"composer.lock",
"go.sum",
"Pipfile.lock",
]);
// Binary / media / non-source extensions we never send to the model.
const SKIP_EXTS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg", ".pdf",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".mp3", ".mp4", ".mov", ".avi", ".webm", ".wav", ".ogg", ".flac",
".zip", ".gz", ".tar", ".tgz", ".rar", ".7z", ".bz2",
".wasm", ".so", ".dylib", ".dll", ".exe", ".bin", ".class", ".o", ".a",
".map", ".lock", ".snap", ".ipynb",
]);
function isMinified(name: string): boolean {
return /\.min\.[^.]+$/.test(name) || name.endsWith(".min.js") || name.endsWith(".min.css");
}
function shouldSkipFile(name: string): boolean {
if (LOCKFILES.has(name)) return true;
if (isMinified(name)) return true;
if (SKIP_EXTS.has(extname(name).toLowerCase())) return true;
return false;
}
// GitHub tarballs wrap everything in a single `owner-repo-sha/` folder; treat that as
// the repo root so file paths in findings read naturally.
async function repoRoot(extractDir: string): Promise<string> {
const entries = await readdir(extractDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory());
if (dirs.length === 1 && entries.length === 1) return join(extractDir, dirs[0].name);
return extractDir;
}
// Walks the extracted repo and returns the files eligible for analysis, applying the
// skip rules and size cap. Iterative DFS (no recursion depth limits).
export async function collectFiles(extractDir: string): Promise<ScanFile[]> {
const root = await repoRoot(extractDir);
const files: ScanFile[] = [];
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop() as string;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) stack.push(full);
} else if (entry.isFile()) {
if (shouldSkipFile(entry.name)) continue;
let size: number;
try {
size = (await stat(full)).size;
} catch {
continue;
}
if (size === 0 || size > MAX_FILE_BYTES) continue;
files.push({ absPath: full, relPath: relative(root, full) });
}
}
}
return files;
}
// Dependency manifest basenames we parse for CVE matching (see manifests.ts).
export const MANIFEST_NAMES = new Set([
"package.json",
"requirements.txt",
"pyproject.toml",
"Pipfile",
"go.mod",
"Cargo.toml",
"Gemfile",
"composer.json",
"pom.xml",
"build.gradle",
]);
export function isManifest(relPath: string): boolean {
return MANIFEST_NAMES.has(basename(relPath));
}

7
apps/cli/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "@cerebrus/typescript-config/bun-app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
},
"include": ["src"]
}

View file

@ -0,0 +1,2 @@
# Base URL of the @cerebrus/backend API (WorkOS auth + Supabase access).
VITE_API_URL=http://localhost:3001

View file

@ -0,0 +1,7 @@
# The frontend calls its OWN origin (/auth, /api), which the Bun server
# reverse-proxies to the backend (keeping the session cookie first-party). So no
# backend URL is baked into the bundle — leave VITE_API_URL unset.
#
# The backend the server proxies to is set via the BACKEND_URL env var on the
# frontend Cloud Run service (e.g. https://cerebrus-backend-xxxxx.run.app).
# VITE_API_URL=

31
apps/frontend/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# Build context is the repo ROOT (so the bun workspace resolves).
# ---- Build: produce the static SPA. The app calls its OWN origin (/auth, /api),
# which the Bun server reverse-proxies to the backend, so no backend URL is baked. ----
FROM oven/bun:1.3.14 AS build
WORKDIR /repo
COPY package.json bun.lock turbo.json ./
COPY packages/typescript-config/package.json ./packages/typescript-config/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY apps/backend/package.json ./apps/backend/
COPY apps/frontend/package.json ./apps/frontend/
COPY apps/cli/package.json ./apps/cli/
RUN bun install --frozen-lockfile
COPY . .
# vite build runs in "production" mode and loads apps/frontend/.env.production.
RUN cd apps/frontend && bun run build
# ---- Runtime: Bun serving the static build on $PORT and proxying backend routes ----
FROM oven/bun:1.3.14-slim AS runtime
WORKDIR /app
ENV PORT=8080
# BACKEND_URL is set at deploy time by cloudbuild.yaml from the value in
# apps/frontend/.env.production. Do not bake a default here.
COPY apps/frontend/server.ts ./server.ts
COPY --from=build /repo/apps/frontend/dist ./dist
EXPOSE 8080
USER bun
CMD ["bun", "server.ts"]

77
apps/frontend/README.md Normal file
View file

@ -0,0 +1,77 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View file

@ -0,0 +1,28 @@
# Build + push + deploy the frontend. Run from the repo root:
# gcloud builds submit --config apps/frontend/cloudbuild.yaml \
# --substitutions=_REGION=us-central1,_IMAGE=us-central1-docker.pkg.dev/PROJECT/cerebrus/frontend .
steps:
- name: gcr.io/cloud-builders/docker
args: ["build", "-f", "apps/frontend/Dockerfile", "-t", "${_IMAGE}:${BUILD_ID}", "-t", "${_IMAGE}:latest", "."]
- name: gcr.io/cloud-builders/docker
args: ["push", "--all-tags", "${_IMAGE}"]
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: gcloud
args:
- "run"
- "deploy"
- "cerebrus-frontend"
- "--image=${_IMAGE}:${BUILD_ID}"
- "--region=${_REGION}"
- "--platform=managed"
- "--port=8080"
- "--allow-unauthenticated"
- "--set-env-vars=BACKEND_URL=${_BACKEND_URL}"
images:
- "${_IMAGE}"
substitutions:
_REGION: "us-central1"
_IMAGE: "us-central1-docker.pkg.dev/${PROJECT_ID}/cerebrus/frontend"
_BACKEND_URL: "http://localhost:3001"
options:
logging: CLOUD_LOGGING_ONLY

View file

@ -0,0 +1,3 @@
import react from "@cerebrus/eslint-config/react";
export default react;

17
apps/frontend/index.html Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Cefense turns observed attacks into repo-specific matches, reviewable fixes, and verified closure." />
<meta property="og:title" content="Cefense — From attack to proven fix." />
<meta property="og:description" content="Connect code, see reachable risk, review the fix, and prove the path closed." />
<meta property="og:image" content="/og-v4.png" />
<title>Cefense — From attack to proven fix.</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,40 @@
{
"name": "@cerebrus/frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"check-types": "tsc -b",
"test": "bun test src/ported",
"preview": "vite preview",
"start": "bun server.ts"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.0",
"zod": "^4.1.12",
"zustand": "^5.0.11"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@cerebrus/eslint-config": "*",
"@cerebrus/typescript-config": "*",
"@tailwindcss/postcss": "4.2.1",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.5.0",
"tailwindcss": "4.2.1",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}
}

View file

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="15" fill="#171816"/>
<circle cx="16" cy="16" r="8" stroke="#FBFBF8" stroke-width="1.35"/>
<circle cx="16" cy="16" r="3" fill="#FBFBF8"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

View file

@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-target-arrow"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 7a5 5 0 1 0 5 5" />
<path d="M13 3.055a9 9 0 1 0 7.941 7.945" />
<path d="M15 6v3h3l3 -3h-3v-3l-3 3" />
<path d="M15 9l-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 535 B

View file

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-terminal-2"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 9l3 3l-3 3" />
<path d="M13 15l3 0" />
<path d="M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -12" />
</svg>

After

Width:  |  Height:  |  Size: 484 B

View file

@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-spider"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 4v2l5 5" />
<path d="M2.5 9.5l1.5 1.5h6" />
<path d="M4 19v-2l6 -6" />
<path d="M19 4v2l-5 5" />
<path d="M21.5 9.5l-1.5 1.5h-6" />
<path d="M20 19v-2l-6 -6" />
<path d="M8 15a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
<path d="M10 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
</svg>

After

Width:  |  Height:  |  Size: 614 B

View file

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-shield-bolt"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M13.342 20.566c-.436 .17 -.884 .315 -1.342 .434a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 .117 6.34" />
<path d="M19 16l-2 3h4l-2 3" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

View file

@ -0,0 +1,23 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-binary-tree"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M16 4a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M16 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M11 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M21 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M5.058 18.306l2.88 -4.606" />
<path d="M10.061 10.303l2.877 -4.604" />
<path d="M10.065 13.705l2.876 4.6" />
<path d="M15.063 5.7l2.881 4.61" />
</svg>

After

Width:  |  Height:  |  Size: 749 B

View file

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-cloud-lock"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M19 18a3.5 3.5 0 0 0 0 -7h-1c.397 -1.768 -.285 -3.593 -1.788 -4.787c-1.503 -1.193 -3.6 -1.575 -5.5 -1s-3.315 2.019 -3.712 3.787c-2.199 -.088 -4.155 1.326 -4.666 3.373c-.512 2.047 .564 4.154 2.566 5.027" />
<path d="M8 16a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1l0 -3" />
<path d="M10 15v-2a2 2 0 1 1 4 0v2" />
</svg>

After

Width:  |  Height:  |  Size: 684 B

Some files were not shown because too many files have changed in this diff Show more