first commit
135
DEPLOYMENT.md
Normal 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
|
|
@ -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
|
||||
46
apps/backend/.env.production.example
Normal 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
|
|
@ -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
|
|
@ -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.
|
||||
26
apps/backend/cloudbuild.yaml
Normal 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
|
||||
12
apps/backend/drizzle.config.ts
Normal 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 ?? "",
|
||||
},
|
||||
});
|
||||
3
apps/backend/eslint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import base from "@cerebrus/eslint-config/base";
|
||||
|
||||
export default base;
|
||||
42
apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
98
apps/backend/src/cve/match.ts
Normal 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
|
|
@ -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;
|
||||
}
|
||||
80
apps/backend/src/cve/semver.ts
Normal 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;
|
||||
}
|
||||
38
apps/backend/src/db/advisories.ts
Normal 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)));
|
||||
}
|
||||
20
apps/backend/src/db/client.ts
Normal 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;
|
||||
}
|
||||
49
apps/backend/src/db/findings.ts
Normal 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));
|
||||
}
|
||||
42
apps/backend/src/db/fix.ts
Normal 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;
|
||||
}
|
||||
48
apps/backend/src/db/images.ts
Normal 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));
|
||||
}
|
||||
10
apps/backend/src/db/migrations/0000_nappy_darkstar.sql
Normal 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")
|
||||
);
|
||||
29
apps/backend/src/db/migrations/0001_parched_mercury.sql
Normal 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;
|
||||
11
apps/backend/src/db/migrations/0002_pink_alex_power.sql
Normal 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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
85
apps/backend/src/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
276
apps/backend/src/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
348
apps/backend/src/db/migrations/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
663
apps/backend/src/db/migrations/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
699
apps/backend/src/db/migrations/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
927
apps/backend/src/db/migrations/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
48
apps/backend/src/db/migrations/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
45
apps/backend/src/db/registry.ts
Normal 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)));
|
||||
}
|
||||
105
apps/backend/src/db/scans.ts
Normal 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;
|
||||
}
|
||||
196
apps/backend/src/db/schema.ts
Normal 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;
|
||||
37
apps/backend/src/db/users.ts
Normal 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;
|
||||
}
|
||||
14
apps/backend/src/env.test.ts
Normal 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
|
|
@ -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",
|
||||
};
|
||||
168
apps/backend/src/fix/autofix.ts
Normal 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
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
30
apps/backend/src/lib/crypto.ts
Normal 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");
|
||||
}
|
||||
82
apps/backend/src/lib/gcp.ts
Normal 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;
|
||||
}
|
||||
242
apps/backend/src/lib/github.ts
Normal 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 };
|
||||
}
|
||||
34
apps/backend/src/lib/logger.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
);
|
||||
28
apps/backend/src/lib/workos.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
96
apps/backend/src/middleware/auth.ts
Normal 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" });
|
||||
}
|
||||
};
|
||||
16
apps/backend/src/middleware/error.ts
Normal 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" });
|
||||
};
|
||||
36
apps/backend/src/middleware/logging.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
25
apps/backend/src/registry/endpoints.ts
Normal 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}`;
|
||||
}
|
||||
80
apps/backend/src/registry/list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
apps/backend/src/routes/api.ts
Normal 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 });
|
||||
});
|
||||
112
apps/backend/src/routes/auth.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
332
apps/backend/src/routes/github.ts
Normal 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 });
|
||||
});
|
||||
16
apps/backend/src/routes/health.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
159
apps/backend/src/routes/internal.ts
Normal 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 });
|
||||
});
|
||||
229
apps/backend/src/routes/registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
77
apps/backend/src/scan/dbReporter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
58
apps/backend/src/scan/log.ts
Normal 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}]`);
|
||||
}
|
||||
200
apps/backend/src/scan/runner.ts
Normal 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) };
|
||||
}
|
||||
8
apps/backend/tsconfig.json
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
3
apps/cli/eslint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import base from "@cerebrus/eslint-config/base";
|
||||
|
||||
export default base;
|
||||
23
apps/cli/package.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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":[]}.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
166
apps/cli/src/registry/oci.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "@cerebrus/typescript-config/bun-app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2
apps/frontend/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Base URL of the @cerebrus/backend API (WorkOS auth + Supabase access).
|
||||
VITE_API_URL=http://localhost:3001
|
||||
7
apps/frontend/.env.production.example
Normal 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
|
|
@ -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
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
28
apps/frontend/cloudbuild.yaml
Normal 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
|
||||
3
apps/frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import react from "@cerebrus/eslint-config/react";
|
||||
|
||||
export default react;
|
||||
17
apps/frontend/index.html
Normal 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>
|
||||
40
apps/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
apps/frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
BIN
apps/frontend/public/brand/sf-bay-haze.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
apps/frontend/public/brand/sf-skyline-source.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/frontend/public/brand/sf-skyline.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/frontend/public/cerebrus-sky.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
5
apps/frontend/public/favicon.svg
Normal 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 |
24
apps/frontend/public/icons.svg
Normal 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 |
BIN
apps/frontend/public/immunity/threat-icon-atlas-64-source.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
apps/frontend/public/immunity/threat-icon-atlas-64.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/frontend/public/immunity/threat-icon-atlas-source.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/frontend/public/immunity/threat-icon-atlas.png
Normal file
|
After Width: | Height: | Size: 660 KiB |
19
apps/frontend/public/immunity/threat-icons/threat-0000.svg
Normal 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 |
17
apps/frontend/public/immunity/threat-icons/threat-0001.svg
Normal 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 |
22
apps/frontend/public/immunity/threat-icons/threat-0002.svg
Normal 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 |
16
apps/frontend/public/immunity/threat-icons/threat-0003.svg
Normal 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 |
23
apps/frontend/public/immunity/threat-icons/threat-0004.svg
Normal 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 |
17
apps/frontend/public/immunity/threat-icons/threat-0005.svg
Normal 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 |