diff --git a/.github/workflows/container-ci.yml b/.github/workflows/container-ci.yml index 366d1e11e..dcfd6f840 100644 --- a/.github/workflows/container-ci.yml +++ b/.github/workflows/container-ci.yml @@ -10,6 +10,7 @@ on: - Dockerfile.dockerignore - compose.yaml - compose.apparmor.yaml + - compose.findings.yaml - docker/** - plugins/codex-security/** - sdk/typescript/** @@ -21,6 +22,7 @@ on: - Dockerfile.dockerignore - compose.yaml - compose.apparmor.yaml + - compose.findings.yaml - docker/** - plugins/codex-security/** - sdk/typescript/** @@ -101,6 +103,60 @@ jobs: docker run --rm codex-security:ci bulk-scan --help docker run --rm codex-security:ci info --json + - name: Verify findings service and persistent SQLite storage + shell: bash + run: | + set -euo pipefail + compose=(docker compose -p findings-ci -f compose.findings.yaml) + trap '"${compose[@]}" logs; "${compose[@]}" down --volumes' EXIT + "${compose[@]}" up --build --detach + check_routes() { + "${compose[@]}" exec -T findings node --input-type=module <<'JS' + import assert from "node:assert/strict"; + import { setTimeout } from "node:timers/promises"; + const base = "http://127.0.0.1:3000"; + for (let attempt = 0; ; attempt++) { + try { + await fetch(`${base}/v1/findings`, { signal: AbortSignal.timeout(1000) }); + break; + } catch (error) { + if (attempt === 100) throw error; + await setTimeout(100); + } + } + for (const [method, path] of [ + ["GET", "/v1/findings?limit=50&offset=0"], + ["POST", "/v1/bulk/findings"], + ]) { + const response = await fetch(`${base}${path}`, { + method, + ...(method === "POST" ? { body: '{"findings":[]}' } : {}), + }); + assert.equal(response.status, 501); + assert.deepEqual(await response.json(), { error: "not_implemented" }); + } + JS + } + check_routes + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:3000/v1/findings)" = 501 + "${compose[@]}" exec -T findings python3 - <<'PY' + import sqlite3 + with sqlite3.connect("/state/workbench.sqlite3") as db: + assert db.execute("SELECT COUNT(*) FROM schema_migrations").fetchone()[0] > 0 + assert db.execute("SELECT COUNT(*) FROM findings").fetchone()[0] == 0 + db.execute("INSERT INTO findings (id, fingerprint, rule_id, identity_anchor, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", ("example-finding", "example-fingerprint", "example-rule", "example-anchor", "2026-01-01", "2026-01-01")) + PY + "${compose[@]}" stop --timeout 10 + container_id=$("${compose[@]}" ps --all --quiet findings) + test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0 + "${compose[@]}" up --detach --force-recreate + check_routes + "${compose[@]}" exec -T findings python3 - <<'PY' + import sqlite3 + with sqlite3.connect("/state/workbench.sqlite3") as db: + assert db.execute("SELECT id FROM findings").fetchall() == [("example-finding",)] + PY + - name: Validate hardened customer Compose configuration env: CODEX_SECURITY_IMAGE: codex-security:ci diff --git a/Dockerfile b/Dockerfile index 43ba910ae..c8825e2b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN pnpm run types \ && pnpm pack --pack-destination /build/package \ && node scripts/check-package.mjs /build/package/*.tgz -FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 +FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS runtime LABEL org.opencontainers.image.title="Codex Security" \ org.opencontainers.image.description="Noninteractive, resumable Codex Security CSV repository scans" \ @@ -59,5 +59,21 @@ ENV CODEX_HOME=/state \ USER 10001:10001 WORKDIR /state +FROM runtime AS findings-service + +LABEL org.opencontainers.image.description="Codex Security findings API" + +ENV HOST=0.0.0.0 \ + PORT=3000 \ + CODEX_SECURITY_STATE_DIR=/state + +WORKDIR /usr/local/lib/node_modules/@openai/codex-security +EXPOSE 3000 + +ENTRYPOINT ["node"] +CMD ["dist/server/index.js"] + +FROM runtime AS scanner + ENTRYPOINT ["/usr/local/bin/codex-security-entrypoint"] CMD ["--help"] diff --git a/README.md b/README.md index 3c97d1505..37884b096 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ await security.close(); Use the included Docker Compose configuration for scans of many repositories. See the [container quick start](sdk/typescript/README.md#containerized-bulk-scans) for more detail. +## Findings service (preview) + +The [findings service](sdk/typescript/README.md#findings-service-preview) runs +from the SDK in Docker with persistent SQLite storage. Its two HTTP endpoints +are currently stubs; they do not insert findings or run deduplication. + ## Other providers To use another inference provider, set its API key and select a model: diff --git a/compose.findings.yaml b/compose.findings.yaml new file mode 100644 index 000000000..31c1f9fd0 --- /dev/null +++ b/compose.findings.yaml @@ -0,0 +1,14 @@ +services: + findings: + build: + context: . + target: findings-service + init: true + env_file: docker/findings.env + ports: + - "127.0.0.1:3000:3000" + volumes: + - findings-state:/state + +volumes: + findings-state: diff --git a/docker/findings.env b/docker/findings.env new file mode 100644 index 000000000..c4f8a8a9e --- /dev/null +++ b/docker/findings.env @@ -0,0 +1,3 @@ +HOST=0.0.0.0 +PORT=3000 +CODEX_SECURITY_STATE_DIR=/state diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index fb5e5968e..d73910d4b 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -910,6 +910,70 @@ command manifest, `scan --schema --format json` for a command schema, and MCP exposes only the read-only `info` command because the transport cannot cancel active scans. +## Findings service (preview) + +From the repository root, build and start the findings API: + +```bash +docker compose -f compose.findings.yaml up --build -d +curl -i http://127.0.0.1:3000/v1/findings +``` + +The `findings-service` Docker target starts the compiled SDK server used by the +packaged `start:server` script, without invoking the CLI. Docker runs Node +directly so stop signals reach the server. The existing default Docker target +and bulk-scan Compose configuration are unchanged. + +This first stage only initializes storage and serves mocked endpoints: + +| Method | Path | Current behavior | +| ------ | ------------------- | --------------------------------- | +| `GET` | `/v1/findings` | Log the route and return HTTP 501 | +| `POST` | `/v1/bulk/findings` | Log the route and return HTTP 501 | + +Each stub returns `{"error":"not_implemented"}`; unknown routes return HTTP 404 +with `{"error":"not_found"}`. Request bodies are not processed or logged. No +findings or embeddings are written by these endpoints. + +Storage initializes before the server listens. The SQLite adapter reuses the +bundled workbench's schema and migrations at +`$CODEX_SECURITY_STATE_DIR/workbench.sqlite3`. The `findings-state` named volume +persists that database across container restarts. Stop the service with +`docker compose -f compose.findings.yaml down`; add `--volumes` only when you +intend to delete the stored data. + +`docker/findings.env` contains non-secret container defaults: `HOST=0.0.0.0`, +`PORT=3000`, and `CODEX_SECURITY_STATE_DIR=/state`. Compose publishes the port +only on the host's loopback interface. There is no API authentication in this +preview. Do not expose it to an untrusted network; use an authenticated proxy +before sharing access. No model credentials are needed for the stubs. + +To run locally, use Node.js and Python 3 as described in the prerequisites. +From `sdk/typescript`, install dependencies, build, and start: + +```bash +pnpm install --frozen-lockfile +pnpm run build +pnpm run start:server +``` + +Local defaults are `HOST=127.0.0.1` and `PORT=3000`. The existing +`CODEX_SECURITY_STATE_DIR` and `PYTHON` settings select storage and Python; +without a state override, the service uses the same default state directory as +the CLI. These settings also work on Windows. + +HTTP routing, server startup, and the SQLite adapter live separately under +`src/server/`. Startup accepts a `FindingsStore` interface; the SQLite +implementation owns workbench access. The interface currently covers only +initialization, and will grow with actual data operations rather than with +unused provider abstractions. + +The next stage will persist the existing `Finding` model and embeddings in +SQLite, return IDs from bulk insertion, and list findings with pagination +defaulting to 50. A later stage will add candidate retrieval to the API and run +screening, independent pair review, and whole-group review locally through the +SDK and CLI. None of those operations are implemented in this preview. + ## Containerized bulk scans Create `repositories.csv` as described under [Bulk scans](#bulk-scans). diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 13e4b8306..efd49998e 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -53,6 +53,7 @@ "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", "prepack": "node --run build:plugin && node --run build", + "start:server": "node dist/server/index.js", "test": "node --run build:plugin && bun test --timeout 30000 ./tests-ts", "test:ci": "node -e \"require('node:fs').mkdirSync('reports',{recursive:true})\" && pnpm run test --coverage --coverage-reporter=text --coverage-reporter=lcov --reporter=junit --reporter-outfile=reports/junit.xml", "test:mcp": "node --run build:plugin && npm --prefix ../../plugins/codex-security/mcp-app run test:mcp", diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 531f8146d..ab48965a5 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -195,6 +195,11 @@ const distFiles = new Set( "scan-history-renderer", "scan-logs", "scan-sessions", + "server/index", + "server/routes", + "server/server", + "server/sqlite-store", + "server/storage", "targets", "thread-source", "trusted-executable", @@ -217,6 +222,7 @@ for (const file of files) { ? normalized === "package" || normalized === "package/bin" || normalized === "package/dist" || + normalized === "package/dist/server" || pluginDirectories.has(normalized) : allowedRoot.has(normalized) || distFiles.has(normalized) || diff --git a/sdk/typescript/src/server/index.ts b/sdk/typescript/src/server/index.ts new file mode 100644 index 000000000..d06bd9f20 --- /dev/null +++ b/sdk/typescript/src/server/index.ts @@ -0,0 +1,34 @@ +import { startFindingsServer } from "./server.js"; +import { SqliteFindingsStore } from "./sqlite-store.js"; + +async function main(): Promise { + const host = process.env["HOST"] ?? "127.0.0.1"; + const port = Number(process.env["PORT"] ?? 3000); + const server = await startFindingsServer({ + store: new SqliteFindingsStore(), + host, + port, + }); + const address = server.address(); + if (address !== null && typeof address !== "string") { + console.log( + `Findings service listening on ${address.address}:${address.port}`, + ); + } + + const shutdown = () => { + server.close((error) => { + if (error !== undefined) { + console.error(error); + process.exitCode = 1; + } + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/sdk/typescript/src/server/routes.ts b/sdk/typescript/src/server/routes.ts new file mode 100644 index 000000000..7e9b89ea9 --- /dev/null +++ b/sdk/typescript/src/server/routes.ts @@ -0,0 +1,22 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export function handleFindingsRequest( + request: IncomingMessage, + response: ServerResponse, +): void { + const path = request.url?.split("?", 1)[0]; + const route = `${request.method} ${path}`; + request.resume(); + + switch (route) { + case "GET /v1/findings": + case "POST /v1/bulk/findings": + console.log(route); + response.writeHead(501, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "not_implemented" })); + return; + default: + response.writeHead(404, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + } +} diff --git a/sdk/typescript/src/server/server.ts b/sdk/typescript/src/server/server.ts new file mode 100644 index 000000000..32148acb0 --- /dev/null +++ b/sdk/typescript/src/server/server.ts @@ -0,0 +1,16 @@ +import { once } from "node:events"; +import { createServer, type Server } from "node:http"; +import { handleFindingsRequest } from "./routes.js"; +import type { FindingsStore } from "./storage.js"; + +export async function startFindingsServer(options: { + store: FindingsStore; + host: string; + port: number; +}): Promise { + await options.store.initialize(); + const server = createServer(handleFindingsRequest); + server.listen(options.port, options.host); + await once(server, "listening"); + return server; +} diff --git a/sdk/typescript/src/server/sqlite-store.ts b/sdk/typescript/src/server/sqlite-store.ts new file mode 100644 index 000000000..0cd591275 --- /dev/null +++ b/sdk/typescript/src/server/sqlite-store.ts @@ -0,0 +1,31 @@ +import { + bundledPluginRoot, + codexSecurityStateDirectory, + resolvePluginPython, + runWorkbench, +} from "../runtime.js"; +import type { FindingsStore } from "./storage.js"; + +export class SqliteFindingsStore implements FindingsStore { + constructor(private readonly environment: NodeJS.ProcessEnv = process.env) {} + + async initialize(): Promise { + const environment = { + ...this.environment, + CODEX_SECURITY_STATE_DIR: codexSecurityStateDirectory(this.environment), + }; + const [python, pluginRoot] = await Promise.all([ + resolvePluginPython({ environment }), + bundledPluginRoot(), + ]); + await runWorkbench( + { + python, + pluginRoot, + environment, + failureMessage: "Could not initialize the findings database", + }, + ["database-info"], + ); + } +} diff --git a/sdk/typescript/src/server/storage.ts b/sdk/typescript/src/server/storage.ts new file mode 100644 index 000000000..d69730613 --- /dev/null +++ b/sdk/typescript/src/server/storage.ts @@ -0,0 +1,3 @@ +export interface FindingsStore { + initialize(): Promise; +} diff --git a/sdk/typescript/tests-ts/findings-server.test.ts b/sdk/typescript/tests-ts/findings-server.test.ts new file mode 100644 index 000000000..592976e9c --- /dev/null +++ b/sdk/typescript/tests-ts/findings-server.test.ts @@ -0,0 +1,149 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import type { Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import { resolvePluginPython, runCodexCommand } from "../src/runtime.js"; +import { startFindingsServer } from "../src/server/server.js"; +import { SqliteFindingsStore } from "../src/server/sqlite-store.js"; + +const servers: Server[] = []; +const directories: string[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) { + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + } + for (const directory of directories.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("serves only the two mock routes after storage is initialized", async () => { + let initialized = false; + const server = await startFindingsServer({ + host: "127.0.0.1", + port: 0, + store: { + async initialize() { + initialized = true; + }, + }, + }); + servers.push(server); + expect(initialized).toBe(true); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("No port"); + const base = `http://127.0.0.1:${address.port}`; + const log = spyOn(console, "log").mockImplementation(() => undefined); + try { + for (const [method, path] of [ + ["GET", "/v1/findings?limit=50&offset=0"], + ["POST", "/v1/bulk/findings"], + ] as const) { + const response = await fetch(`${base}${path}`, { + method, + ...(method === "POST" ? { body: "synthetic request body" } : {}), + }); + expect(response.status).toBe(501); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual({ error: "not_implemented" }); + } + expect(log.mock.calls).toEqual([ + ["GET /v1/findings"], + ["POST /v1/bulk/findings"], + ]); + + for (const [method, path] of [ + ["GET", "/unknown"], + ["POST", "/v1/findings"], + ["GET", "/v1/bulk/findings"], + ["POST", "/v1/bulk/findings/dedupe"], + ]) { + const response = await fetch(`${base}${path}`, { method }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_found" }); + } + } finally { + log.mockRestore(); + } +}); + +test("does not start when storage initialization fails", async () => { + await expect( + startFindingsServer({ + host: "127.0.0.1", + port: 0, + store: { + async initialize() { + throw new Error("Storage unavailable"); + }, + }, + }), + ).rejects.toThrow("Storage unavailable"); +}); + +test("initializes and reopens the CLI database without losing findings", async () => { + const directory = await mkdtemp(join(tmpdir(), "findings-store-")); + directories.push(directory); + const environment = { + ...process.env, + CODEX_SECURITY_STATE_DIR: join(directory, "state with spaces"), + }; + const store = new SqliteFindingsStore(environment); + await store.initialize(); + const python = await resolvePluginPython({ environment }); + const database = join( + environment.CODEX_SECURITY_STATE_DIR, + "workbench.sqlite3", + ); + const inserted = await runCodexCommand( + { command: python }, + [ + "-c", + `import sqlite3, sys +with sqlite3.connect(sys.argv[1]) as db: + assert db.execute("SELECT COUNT(*) FROM schema_migrations").fetchone()[0] > 0 + db.execute("INSERT INTO findings (id, fingerprint, rule_id, identity_anchor, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", ("example-finding", "example-fingerprint", "example-rule", "example-anchor", "2026-01-01", "2026-01-01")) +`, + database, + ], + environment, + ); + expect(inserted.success).toBe(true); + + await store.initialize(); + const reopened = await runCodexCommand( + { command: python }, + [ + "-c", + `import sqlite3, sys +with sqlite3.connect(sys.argv[1]) as db: + assert db.execute("SELECT id FROM findings").fetchall() == [("example-finding",)] + assert db.execute("PRAGMA journal_mode").fetchone()[0] == "wal" +`, + database, + ], + environment, + ); + expect(reopened.success).toBe(true); +}); + +test("reports a database startup failure for an unusable state path", async () => { + const directory = await mkdtemp(join(tmpdir(), "findings-store-")); + directories.push(directory); + const state = join(directory, "not-a-directory"); + await writeFile(state, "synthetic file"); + const store = new SqliteFindingsStore({ + ...process.env, + CODEX_SECURITY_STATE_DIR: state, + }); + await expect(store.initialize()).rejects.toThrow( + "Could not initialize the findings database", + ); +});