Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca440

WORKDIR /build/sdk/typescript

COPY sdk/typescript/package.json sdk/typescript/pnpm-lock.yaml ./
COPY sdk/typescript/package.json sdk/typescript/pnpm-lock.yaml sdk/typescript/pnpm-workspace.yaml ./
COPY plugins/codex-security/mcp-app/package.json plugins/codex-security/mcp-app/package-lock.json /build/plugins/codex-security/mcp-app/

RUN corepack enable \
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ Use the included Docker Compose configuration for scans of many repositories. Se

The [findings service](sdk/typescript/README.md#findings-service-preview) runs
from the SDK in Docker, stores findings and embeddings in SQLite, and lists
findings with pagination. It also returns potential duplicates by embedding
similarity within a repository or an explicit all-repository scope. The
findings with pagination. Its read-only dashboard at `/dashboard` refreshes every
five seconds and shows stored findings and duplicate groups from the service's
database. It also returns potential duplicates by embedding similarity within a
repository or an explicit all-repository scope. The
`codex-security publish scan --to custom --findings-url http://localhost:3000`
command uploads completed findings and their repository ID. The SDK and
`codex-security dedupe` command retrieve candidates, run independent Codex
Expand Down
1 change: 1 addition & 0 deletions plugins/codex-security/scripts/workbench_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ def parse_args(description: str) -> argparse.Namespace:
publication.add_argument("--input-file", required=True)

subparsers.add_parser("database-info")
subparsers.add_parser("dashboard")
subparsers.add_parser("finding-workflow")
subparsers.add_parser("store-findings")
subparsers.add_parser("store-dedupe-groups")
Expand Down
115 changes: 115 additions & 0 deletions plugins/codex-security/scripts/workbench_dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Read-only dashboard projections for stored findings and duplicate groups."""

from __future__ import annotations

import argparse
import json
import sqlite3
import sys
from pathlib import Path
from typing import Any

sys.path.insert(0, str(Path(__file__).resolve().parent))
from workbench_findings import list_dedupe_groups


FINDING_RECORDS = """
SELECT findings.id, json_extract(details_json, '$.title') AS title,
COALESCE(repositories.ids, '[]') AS repositoryIds,
json_extract(details_json, '$.severity.level') AS severity,
findings.created_at AS createdAt, findings.updated_at AS updatedAt
FROM findings LEFT JOIN (
SELECT finding_id, json_group_array(repository_id) AS ids
FROM finding_repositories GROUP BY finding_id

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Avoid materializing every repository association on each dashboard refresh

This projection groups the entire finding_repositories table by finding_id, but its existing primary-key index starts with repository_id; SQLite consequently scans the full table and builds a temporary grouping index. The count, page, and detail queries can repeat that work, and every open dashboard polls again every five seconds, so latency grows with the whole shared database rather than the visible page. Add an appropriate finding_id-leading index and restructure the projection so refreshes do not repeatedly materialize unrelated findings.

) AS repositories ON repositories.finding_id = findings.id
WHERE details_json IS NOT NULL
"""

GROUP_RECORDS = """
SELECT groups.id, groups.id AS title,
(SELECT json_group_array(DISTINCT repository_id)
FROM finding_dedupe_group_members AS members
JOIN finding_repositories ON finding_repositories.finding_id = members.finding_id
WHERE members.group_id = groups.id) AS repositoryIds,
groups.created_at AS createdAt, groups.created_at AS updatedAt,
(SELECT COUNT(*) FROM finding_dedupe_group_members WHERE group_id = groups.id) AS memberCount
FROM finding_dedupe_groups AS groups
"""

RECORDS = {
"findings": FINDING_RECORDS,
"groups": GROUP_RECORDS,
}


def item(row: sqlite3.Row) -> dict[str, Any]:
result = dict(row)
result["repositoryIds"] = json.loads(result["repositoryIds"])
return result


def detail(connection: sqlite3.Connection, view: str, selected: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {"item": selected}
selected_id = selected["id"]
if view == "findings":
result["finding"] = json.loads(connection.execute(
"SELECT details_json FROM findings WHERE id = ?", (selected_id,),
).fetchone()[0])
result["groups"] = list_dedupe_groups(connection, selected_id)["groups"]
else:
result["group"] = {
"groupId": selected_id, "createdAt": selected["createdAt"],
"findingIds": [r[0] for r in connection.execute(
"SELECT finding_id FROM finding_dedupe_group_members WHERE group_id = ? ORDER BY finding_id",
(selected_id,),
)],
}
return result


def dashboard(connection: sqlite3.Connection, query: dict[str, Any]) -> dict[str, Any]:
"""One snapshot, no artifact reads, model calls, or writes."""
view = query["view"]
records = RECORDS[view]
clauses: list[str] = []
values: list[Any] = []
if query.get("query"):
connection.create_function("casefold", 1, str.casefold, deterministic=True)
columns = ["id", "title", "repositoryIds"]
clauses.append("(" + " OR ".join(f"instr(casefold(COALESCE({c}, '')), casefold(?)) > 0" for c in columns) + ")")
values.extend([query["query"]] * len(columns))
if query.get("repository"):
clauses.append("EXISTS (SELECT 1 FROM json_each(repositoryIds) WHERE value = ?)")
values.append(query["repository"])
where = " WHERE " + " AND ".join(clauses) if clauses else ""
order = "createdAt DESC, id" if query["sort"] == "newest" else "updatedAt DESC, id"
connection.execute("BEGIN")
with connection:
repositories = connection.execute("""
SELECT DISTINCT repository_id AS id, repository_id AS label
FROM finding_repositories ORDER BY repository_id
""").fetchall()
total = connection.execute(f"SELECT COUNT(*) FROM ({records}) {where}", values).fetchone()[0]
rows = connection.execute(
f"SELECT * FROM ({records}) {where} ORDER BY {order} LIMIT ? OFFSET ?",
(*values, query["limit"], query["offset"]),
).fetchall()
selected = connection.execute(
f"SELECT * FROM ({records}) WHERE id = ?", (query["id"],),
).fetchone() if query.get("id") else None
next_offset = query["offset"] + len(rows)
return {
"overview": {
"findings": connection.execute("SELECT COUNT(*) FROM findings WHERE details_json IS NOT NULL").fetchone()[0],
"groups": connection.execute("SELECT COUNT(*) FROM finding_dedupe_groups").fetchone()[0],
},
"repositories": [dict(row) for row in repositories],
"items": [item(row) for row in rows], "total": total,
"limit": query["limit"], "offset": query["offset"],
"nextOffset": next_offset if next_offset < total else None,
"detail": detail(connection, view, item(selected)) if selected is not None else None,
}


if __name__ == "__main__":
argparse.ArgumentParser(description=__doc__).parse_args()
3 changes: 3 additions & 0 deletions plugins/codex-security/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
SQLITE_RETRY_ATTEMPTS,
)
from workbench_feedback import get_scan_feedback
from workbench_dashboard import dashboard
from workbench_finding_index import index_findings
from workbench_finding_workflows import finding_workflow, register_workflow_scan
from workbench_findings import (
Expand Down Expand Up @@ -4038,6 +4039,8 @@ def main() -> None:
result = {"databasePath": str(database_path())}
elif args.command == "finding-workflow":
result = finding_workflow(connection, json.load(sys.stdin), now())
elif args.command == "dashboard":
result = dashboard(connection, json.load(sys.stdin))
elif args.command == "store-findings":
payload = json.load(sys.stdin)
result = store_findings(connection, payload["entries"], now(), payload.get("repositoryId"))
Expand Down
41 changes: 41 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,47 @@ 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.

### Read-only dashboard

Open `http://localhost:3000/dashboard` on the running findings service. The UI
uses the public OpenAI Apps SDK UI design system, follows the browser's light
or dark preference, and polls the service every five seconds. It never starts,
cancels, resumes, publishes, edits, or deduplicates anything.

The dashboard opens on Findings, followed by Duplicate groups. Both views
support search, repository filtering, sorting, pagination, and record details.
Findings show stored content and links to their duplicate groups. Groups link
back to their member findings, preserving separate overlapping groups and the
original finding records.

The dashboard reads only findings, repository associations, and duplicate groups
stored in the service's configured database. It does not display scans or
workflows: publication sends findings, not remote scan or workflow history. It
does not read report or source paths from stored records. The UI retains the last
successful data on a refresh failure and shows a connection warning until polling
succeeds.

`GET /v1/dashboard` returns a consistent read snapshot with overview counts,
repository choices, a page of records, and optional selected-record details:

- `view`: `findings` (default) or `groups`.
- `query`, `repository`: optional search text and exact repository ID.
- `sort`: `activity` (default; most recently updated first) or `newest`.
- `limit`, `offset`: existing pagination conventions, defaulting to 50 and 0.
- `id`: optional exact record ID to include in `detail`; unknown IDs return
`detail: null` without hiding the list.

Overview counts are service-wide, not filtered page totals. Responses and UI
assets are served by the same Node process; no separate frontend server, CDN,
model credentials, or new CLI flags are needed to view the dashboard. Compiled
HTML, JavaScript, and CSS are included in the npm package and container. Frontend
source, build tools, and tests are not shipped as runtime dependencies.

The existing preview access boundary is unchanged. The dashboard contains
sensitive finding content: keep the service on a trusted local endpoint or behind
an authenticated proxy. It does not add authentication or broaden the default
network binding.

### API

`POST /v1/bulk/findings` accepts `{"findings": [...]}`, using the existing SDK
Expand Down
Loading
Loading