Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/container-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- Dockerfile.dockerignore
- compose.yaml
- compose.apparmor.yaml
- compose.findings.yaml
- docker/**
- plugins/codex-security/**
- sdk/typescript/**
Expand All @@ -21,6 +22,7 @@ on:
- Dockerfile.dockerignore
- compose.yaml
- compose.apparmor.yaml
- compose.findings.yaml
- docker/**
- plugins/codex-security/**
- sdk/typescript/**
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simple test. this is extracted to a script in the next PR

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
Expand Down
18 changes: 17 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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"]
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions compose.findings.yaml
Original file line number Diff line number Diff line change
@@ -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:
3 changes: 3 additions & 0 deletions docker/findings.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
HOST=0.0.0.0
PORT=3000
CODEX_SECURITY_STATE_DIR=/state
64 changes: 64 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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) ||
Expand Down
34 changes: 34 additions & 0 deletions sdk/typescript/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { startFindingsServer } from "./server.js";
import { SqliteFindingsStore } from "./sqlite-store.js";

async function main(): Promise<void> {
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;
});
22 changes: 22 additions & 0 deletions sdk/typescript/src/server/routes.ts
Original file line number Diff line number Diff line change
@@ -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" }));
}
}
16 changes: 16 additions & 0 deletions sdk/typescript/src/server/server.ts
Original file line number Diff line number Diff line change
@@ -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<Server> {
await options.store.initialize();
const server = createServer(handleFindingsRequest);
server.listen(options.port, options.host);
await once(server, "listening");
return server;
}
31 changes: 31 additions & 0 deletions sdk/typescript/src/server/sqlite-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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"],
);
}
}
3 changes: 3 additions & 0 deletions sdk/typescript/src/server/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface FindingsStore {
initialize(): Promise<void>;
}
Loading
Loading