From 15f2cc7d23489ff3c42f9e95efe36426b9fc10cc Mon Sep 17 00:00:00 2001 From: Sounil Yu <4305467+sounil@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:38:22 -0400 Subject: [PATCH 1/6] Add web user interface --- .DS_Store | Bin 0 -> 10244 bytes .gitignore | 4 + WEBUI_SPEC.md | 389 ++++++ apps/.DS_Store | Bin 0 -> 8196 bytes apps/openant-cli/.DS_Store | Bin 0 -> 8196 bytes apps/openant-cli/cmd/root.go | 1 + apps/openant-cli/cmd/serve.go | 90 ++ .../openant-cli/internal/python/invoke_ctx.go | 107 ++ .../report/templates/report-reskin.gohtml | 2 +- apps/openant-cli/internal/server/server.go | 1075 +++++++++++++++++ apps/openant-cli/ui/.index.html.swp | Bin 0 -> 16384 bytes apps/openant-cli/ui/.summary.html.swp | Bin 0 -> 12288 bytes apps/openant-cli/ui/disclosure.html | 68 ++ apps/openant-cli/ui/embed.go | 7 + apps/openant-cli/ui/index.html | 207 ++++ apps/openant-cli/ui/scan.html | 350 ++++++ apps/openant-cli/ui/summary.html | 55 + libs/.DS_Store | Bin 0 -> 6148 bytes libs/openant-core/core/reporter.py | 3 +- libs/openant-core/core/scanner.py | 2 + libs/openant-core/openant/cli.py | 3 + libs/openant-core/report/generator.py | 4 +- .../report/prompts/disclosure.txt | 2 + 23 files changed, 2365 insertions(+), 4 deletions(-) create mode 100644 .DS_Store create mode 100644 WEBUI_SPEC.md create mode 100644 apps/.DS_Store create mode 100644 apps/openant-cli/.DS_Store create mode 100644 apps/openant-cli/cmd/serve.go create mode 100644 apps/openant-cli/internal/python/invoke_ctx.go create mode 100644 apps/openant-cli/internal/server/server.go create mode 100644 apps/openant-cli/ui/.index.html.swp create mode 100644 apps/openant-cli/ui/.summary.html.swp create mode 100644 apps/openant-cli/ui/disclosure.html create mode 100644 apps/openant-cli/ui/embed.go create mode 100644 apps/openant-cli/ui/index.html create mode 100644 apps/openant-cli/ui/scan.html create mode 100644 apps/openant-cli/ui/summary.html create mode 100644 libs/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c0e67f72a1d6499d299f188addbc121eb88cc24a GIT binary patch literal 10244 zcmeHMU2GIp6uxJ=&>2SPfUW$v0}~cPfdb1Sp~&C1+baJBwx!$hv(D~}bi#C|?#yn1 zTCGnefJR^ZjWN;q6CdzFQ1r>fm|$XjKqSJ8Bs{6SnD~=uVm$X}OMfu&1p{R6X70J? z-gEAqbLKm9=H4ZQKx^4rO^87Vk@2EbyAP{d6gp0?NktMpbP!OV6b6HVg6kKMKR-!2 z>WDlLc_8vYc|6;2OB0$dd-R|T=f0IrVxhLB4OI>ISeCm=Q-AXWykLjkrr&TlYr z0%=a8I`Tl|fyo}AVG|=J8NgpKcK==&aGfAbCNG0j6Pq%%wpOWCYY%1n{K0H!hUK8u zEFGX+r|-LkYTGRAckIEuR-f^`&~!Yz#1tIMMv;Tvj%WFUt$x|F0;Zeg1BxOmd2PP88&op< zJfbFBwdbm^M^>h(>U6z3*VB98U|!S8fxXXm&CsdXZDwc(d95x$^R^XzZ%@gtpjq8T z-!1ohI5M+ny2UaI+byn}bxv^UV##;iJ$~SXj_>v4Li<=q$LK`khH|}uUkWp9vYdnS zbE#Q$jgA4T9PGDC0a8UdGh_CGMU5*`>o%s_x9!?{sZN_Y>wdLC?M2Udq4T(7mwL>g zXnU699qAf$tgx@U;sj2?wL3kt-&STqlX9tU&fIwq#&w+w+*GKF=E?=Ol()+~r$z6-(C6;XJtdVtt{0fR1EJHrAF zTEKhOsB85u*DTnsqFH>MX7ypcyCSUBcJR(kYJ;9D`u+WAVxaK87CoaMrpxX$gRo=3 z_UJ8L;A4;ASZ~m0mh6r}gq*me?byp6BKA|IcFPHUHi+KXy-?SC=yWZX#cY^2XKtOI z)w^WvqKs&8yo!S`$7jINE+?uuOQe_aX5#bGHP&K|PNb8(yPlKLnkF`#BuOjTP7Y&` z9Ve&AOXN**mYgFWlP}3Pejz92Ub8XoRJZf-R7SR>;6k z$UzVELJ|7FfurC;8BV|u48v3KEIbF#!wc{tyasQ<+wcy&3m?LH_!K^aFW@443*W(I z_yK-`pWz1lCe=!^q)4-*xTH(-r6p3Mv`ktqZI-r3Tcy2HuauYixD8S@)DDf>#2V6Z zB%`km3^j4|C!M)F>e&@Bw@TXW<-tA~63oBK|5|pMc_B<53L1jzw`LTk>4r zJA!=_^)ccShZOFi#3<~E5@VDYS;CAG<84cfH$yE-jO-IKN{o{}|L*+6C{R2hF^$>oL4&p>iXf zG8L@G{Skl+Jy&eRi^__V5!OzNz7bBDI?6D0r2o^O0k=A8i}wG~{(r(}&b_?<{|EQ{ BeUJbE literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 5aa0e7b..232b8b5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ node_modules/ apps/openant-cli/bin/ libs/openant-core/parsers/go/go_parser/go_parser # docs/ +CLAUDE.md +.gitignore +.claude +.git diff --git a/WEBUI_SPEC.md b/WEBUI_SPEC.md new file mode 100644 index 0000000..75d70ab --- /dev/null +++ b/WEBUI_SPEC.md @@ -0,0 +1,389 @@ +# OpenAnt Web UI — Specification + +## Overview + +`openant serve` starts a local HTTP server (default `http://localhost:8080`, falls back to +an OS-assigned port) and opens the browser automatically. It provides a browser-based +interface to the full OpenAnt scan pipeline — no CLI knowledge required. + +Scan outputs are stored under `~/.openant/webui//` and persist across server +restarts. + +--- + +## Server + +### Startup (`cmd/serve.go`) + +- Resolves `~/.openant/webui/` as the output root; creates it if absent. +- Detects and validates the Python environment (same `ensurePython()` used by all CLI + commands). +- Binds to `127.0.0.1:8080`; if that port is taken, falls back to any available port + on `127.0.0.1`. +- Prints the bound URL to stdout and opens it in the system browser (`open `). +- On `SIGINT` or `SIGTERM`: cancels all in-flight scan jobs (Python subprocess process + groups are killed), then exits cleanly. + +### Routes + +| Method | Path | Handler | +|--------|------|---------| +| `GET` | `/` | Home page (new scan form + scan history) | +| `POST` | `/scan` | Start a new scan job | +| `GET` | `/scan/{id}` | Scan status / live log page | +| `GET` | `/scan/{id}/logs` | SSE stream of log lines | +| `GET` | `/report/{id}` | Serve the completed HTML report file | +| `GET` | `/summary/{id}` | Render the Markdown summary as HTML | +| `GET` | `/disclosures/{id}` | JSON list of disclosure reports for a job | +| `GET` | `/disclosure/{id}/{filename}` | Render a single disclosure Markdown file as HTML | +| `DELETE` | `/scan/{id}` | Cancel + delete a scan job and its output | + +HTML templates (`ui/index.html`, `ui/scan.html`, `ui/summary.html`, `ui/disclosure.html`) +are embedded into the binary at compile time via `//go:embed` in `ui/embed.go`. + +--- + +## Data Model + +### Job + +Each scan is a `Job` with the following fields: + +| Field | Type | Description | +|-------|------|-------------| +| `ID` | `string` | 16-char hex, cryptographically random | +| `Repo` | `string` | Repository URL or local path submitted by the user | +| `StartedAt` | `time.Time` | UTC timestamp when the job was created | +| `Status` | `string` | `"running"` \| `"done"` \| `"error"` | +| `LogBuf` | `[]string` | Append-only buffer of stderr log lines | +| `ReportPath` | `string` | Absolute path to the completed `report.html`; empty until done | +| `SummaryPath` | `string` | Absolute path to `SUMMARY_REPORT.md`; empty until done | +| `DisclosurePaths` | `[]string` | Absolute paths to per-vulnerability disclosure Markdown files; empty until done | +| `Cancel` | `context.CancelFunc` | Cancels the scan subprocess; nil for completed/recovered jobs | + +### Persistence + +Each job directory under `~/.openant/webui//` contains: + +| File / Directory | Description | +|------------------|-------------| +| `meta.json` | `{id, repo, started_at}` — written immediately on job creation | +| `logs.txt` | Full log buffer persisted when the scan finishes (success or error) | +| `report.html` | Interactive HTML vulnerability report (generated after scan) | +| `report/SUMMARY_REPORT.md` | LLM-generated Markdown summary report | +| `report/disclosures/DISCLOSURE_NN_.md` | Per-vulnerability disclosure documents (one file per confirmed finding) | +| `repo/` | Git clone of the target repository (URL scans only; skipped for local paths) | + +On server startup, all subdirectories of `~/.openant/webui/` are scanned. Directories +containing a `report.html` are restored as `"done"` jobs, including their `SummaryPath` +and `DisclosurePaths`. Directories without one are restored as `"error"` (they cannot +be resumed). `meta.json` is used to recover `Repo` and `StartedAt`; if absent, the repo +URL is inferred from `repo/.git/config` (origin remote URL) and the directory mtime is +used for `StartedAt`. + +--- + +## Scan Lifecycle (`POST /scan`) + +### Input (form fields) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `repo` | `string` | Yes | GitHub URL (`https://...`) or absolute local path | +| `language` | `string` | No | `auto` (default), `go`, `python`, `javascript`, `c`, `ruby`, `php` | +| `model` | `string` | No | `opus` (default, thorough) or `sonnet` (fast) | +| `api_key` | `string` | No | Anthropic API key; pre-filled from config if available | +| `verify` | checkbox | No | Enable Stage 2 attacker simulation (`--verify`); checked by default | +| `dynamic_test` | checkbox | No | Enable Docker-based dynamic testing (`--dynamic-test`); unchecked by default | + +### Exit code semantics + +The Python scanner (`python -m openant scan`) uses a grep-like exit code convention: + +| Exit code | Meaning | +|-----------|---------| +| `0` | Scan succeeded; no vulnerabilities found | +| `1` | Scan succeeded; one or more vulnerabilities found | +| `2+` | Scan failed (parse error, API error, etc.) | + +The web server treats exit codes 0 and 1 as success. Exit code ≥ 2 marks the job as +`"error"`. This means a scan that finds vulnerabilities correctly completes and enables +the report/summary/disclosure buttons. + +### Execution flow (background goroutine) + +1. **Clone** (URL inputs only): `git clone --depth 1 /repo/`. Clone stderr + is streamed to the job log with `[clone]` prefix. On failure, job is set to `"error"`. + +2. **Scan**: Runs `python -m openant scan --output [flags]` via + `InvokeCtx`. All stderr lines are streamed to the job log in real time. Flags passed: + - `--language ` if specified + - `--model ` if not opus + - `--verify` if enabled + - `--dynamic-test` if enabled + - `--repo-url ` when the input was a URL (so the original URL is embedded in + `pipeline_output.json` at scan time, making it available to the LLM report generators) + + Exit code ≥ 2 marks the job as `"error"`. Exit codes 0 and 1 are both treated as + success (see exit code semantics above). + +3. **Patch pipeline_output.json**: The `repository.url` field is updated with the + original `repo` value. This is a belt-and-suspenders step; for URL-based scans the + URL is already present (passed via `--repo-url`). For local-path scans it fills in + whatever the user provided. + +4. **HTML report**: Calls `python -m openant report-data [--dataset ...] + [--pipeline-output ...]` to obtain a JSON envelope on stdout. The JSON is unmarshalled + into `report.ReportData` and passed to `report.GenerateReskin(data, outDir+"/report.html")` + — a Go-native template renderer. Log lines are prefixed `[report]`. On failure, job is + set to `"error"` and `"no report.html produced"` is logged. + +5. **Markdown summary**: Calls `python -m openant report --format summary + --output /SUMMARY_REPORT.md [--pipeline-output ...]` via `InvokeCtx`. Non-fatal: + a non-zero exit is logged but does not change the job status. + +6. **Collect disclosures**: Scans `/report/disclosures/` for `*.md` files + (generated by the Python scan's report step). Paths are stored in `job.DisclosurePaths`. + +7. **Persist logs**: Full `LogBuf` is written to `logs.txt`. + +8. **Mark done**: `job.SetDone(reportPath, summaryPath, disclosurePaths)`. + +### Cancellation + +- Each job has a `context.Context`; cancelling it sends `SIGKILL` to the Python process + group (covering all child processes, e.g. parallel workers). +- `DELETE /scan/{id}` cancels the job (if running), removes it from the in-memory map, + and deletes its output directory from disk. +- On server shutdown, all in-flight jobs are cancelled. + +--- + +## Pages + +### Home (`/`) — `ui/index.html` + +**Layout**: Two-column grid. Left column: New Scan form. Right column: How It Works +panel. Below the grid: Recent Scans history list. + +**New Scan form**: +- Repository URL or local path — text input, required, autofocused. +- Language — dropdown: `auto-detect`, `Go`, `Python`, `JavaScript`, `C / C++`, `Ruby`, + `PHP`. Selecting `auto` omits the `--language` flag. +- Model — dropdown: `opus (thorough)` (default), `sonnet (fast)`. Selecting `opus` omits + the `--model` flag (opus is the default in Python CLI). +- Anthropic API Key — password input. Pre-filled with the stored API key if one exists + (shown with "Pre-filled from…" notice); otherwise shows a "Run `openant set-api-key`" + hint. +- Options checkboxes: + - "Stage 2 attacker simulation (--verify)" — checked by default. + - "Dynamic testing via Docker (--dynamic-test)" — unchecked by default. +- "Start Scan" button — full-width, dark. Submits form; server redirects to + `/scan/`. + +**How It Works panel** (informational): +- Lists the 5 pipeline stages: Parse → Enhance → Analyze → Verify → Report. +- Note: "Scans run locally on your machine." + +**Recent Scans list**: +- Shows all known jobs, newest first, as rows with: + - Monospace repo URL/path (wraps with `word-break: break-all`; never truncated). + - Status badge: `Running` (yellow), `Done` (green), `Error` (red). + - For `running`: "View →" link to `/scan/`. + - For `done`: "Logs →" link, "Summary →" link (only if summary exists), + "Report →" link. + - For `error`: "View →" link to `/scan/`. + - Delete button (trash icon) — calls `DELETE /scan/` via `fetch`, removes the + row from the DOM. If no rows remain, shows "No scans yet" empty state. +- Empty state: "No scans yet. Submit a repo above to get started." + +--- + +### Scan Status (`/scan/{id}`) — `ui/scan.html` + +**Purpose**: Live scan monitoring page. Connects to the SSE log stream and renders +progress in real time. The header displays the full repo URL/path with +`word-break: break-all` so it is never truncated. + +**Components**: + +**Pipeline step tracker**: Five pill-shaped step indicators — `Parse`, `Enhance`, +`Analyze`, `Verify`, `Report`. Each pill is wrapped in a `.step-item` container that +also displays a numeric count badge below it. Each pill can be in one of three visual +states: +- **Inactive** (default): grey border, light grey background, muted text. +- **Active**: dark border, white background, bold text. +- **Done**: dark fill, white text, prefixed with `✓`. + +Step transitions are driven by log line pattern matching: +- `[parse]` in line → activate Parse. +- `✓ parse` → complete Parse. +- `[enhance]` → complete Parse + activate Enhance. +- `✓ enhance` → complete Enhance. +- `[analyze]` or `[detect]` → complete Enhance + activate Analyze. +- `✓ analy` or `✓ detect` → complete Analyze. +- `[verify]` → complete Analyze + activate Verify. +- `✓ verify` → complete Verify. +- `[report]` → complete Verify + activate Report. +- `✓ report` → complete Report. + +On scan completion (`done` SSE event), all remaining non-done steps are marked done. + +**Funnel counts**: Each step pill displays a live unit count beneath it, extracted from +log lines via regex as they arrive over SSE: +- **Parse**: total units extracted (e.g. `"Parsed 342 units"`). +- **Enhance**: units passed to enhancement (e.g. `"Enhancing 300 units"`). +- **Analyze**: units passed to analysis, or potential findings count. +- **Verify**: units/findings passed to verification. +- **Report**: validated findings that become disclosures, extracted from log lines such as + `"pipeline_output.json: N findings"`, `"Generating N disclosures in parallel"`, or + `"Disclosures: N files in …"`. +Counts are formatted with `toLocaleString()` (e.g. `1,234`). A stage's count element +remains blank until a matching log line is observed. + +**Log stream**: Dark terminal panel (`#0d1117` background, 360px height, scrollable). +Each log line is appended as a `
` with syntax coloring: +- `finding` (orange, bold): lines matching `/potential|vulner|finding|! /` that don't + start with `✓`. +- `ok` (green): lines starting with `✓` or matching `/complete\b/`. +- `step-tag` (blue): lines starting with `[parse]`, `[enhance]`, `[analyze]`, + `[detect]`, `[verify]`, `[report]`, `[dynamic`. +- `progress` (yellow): lines matching `→`, `analyzing batch`, `running`, `scanning`, + `processing`. +- Timestamps (`HH:MM:SS` prefix) are rendered in a dimmed color, separated from the + rest of the line. +- Log stream auto-scrolls to the bottom on each new line. + +**Status bar**: Animated pulsing dot + status text. +- While running: dot is yellow/pulsing; text shows "Scanning…". +- On `done` event: dot turns green (no animation); text shows "Scan complete". The + finding count is already captured in the Report step pill and does not need to be + repeated in the status bar. +- On `error` event: dot turns red; text shows "Scan failed"; error banner shown below. + +**Report/Summary buttons**: Initially disabled (grey, `pointer-events: none`). +Activated (`ready` class: dark background, clickable) when the `done` SSE event fires +with `status === "done"`. Open in new tabs. +- "View Summary" — links to `/summary/{id}`; activated only if a HEAD request to that + URL returns 200 (i.e. the summary file was actually produced). +- "View HTML Report" — links to `/report/{id}`; always activated on `done`. + +**Disclosure report buttons**: Shown below the Report/Summary buttons, only when +disclosure files were generated (i.e., at least one confirmed vulnerability was found). +- Populated on `done` by fetching `GET /disclosures/{id}`. +- Section header: "Disclosure Reports" (uppercase label, small caps style). +- One button per disclosure file. Button label is the vulnerability title extracted from + the first `# Security Disclosure: ` heading in the Markdown file; falls back to + the humanised filename if parsing fails. +- Buttons have a red-tinted style (red border, light red background) to signal security + findings. Open the disclosure page in a new tab. +- Each button is displayed on its own line (column flex layout, full-width block). + +**SSE transport** (`GET /scan/{id}/logs`): +- Server sends log lines as `data: <line>\n\n` events at up to 200ms polling intervals. +- On scan completion, sends `event: done\ndata: <status>\n\n` and closes the stream. +- Client reconnects automatically on `onerror` (unless stream is already closed). + +--- + +### Summary (`/summary/{id}`) — `ui/summary.html` + +- Reads `SUMMARY_REPORT.md` from disk. +- Renders it as HTML using the `marked.js` library (loaded from CDN). +- Minimal styled page: header with "Knostic OpenAnt / scan summary" and a "← Home" + back link; content in a white bordered panel. +- Returns 404 if summary is not yet available. +- The summary Markdown includes the repository URL in its `**Repository:**` field, + sourced from `pipeline_output.json` which is populated with the original repo URL + at scan time via `--repo-url`. + +--- + +### Disclosure (`/disclosure/{id}/{filename}`) — `ui/disclosure.html` + +- Serves a single per-vulnerability disclosure document as a human-readable HTML page. +- The `{filename}` path component is validated: only base filenames (no path separators + or `..`) are accepted, and the file must be in the job's known `DisclosurePaths` list + (no path traversal). +- Reads the corresponding `*.md` file from `<outDir>/report/disclosures/<filename>`. +- Renders the Markdown using `marked.js` (loaded from CDN). +- Page title and breadcrumb header show the vulnerability name (derived from the + `# Security Disclosure: <title>` heading in the file). +- Breadcrumb: `← Home / Scan / <vuln name>`, linking back to the scan page. +- Styled consistently with the summary page: white content panel, monospace code blocks + on dark background, full table support. +- Post-render JS makes `**Repository:**` lines into clickable links when the value is + an HTTP URL. +- Returns 404 if the file is not in the job's known disclosures or cannot be read. + +### Disclosure List API (`GET /disclosures/{id}`) + +Returns a JSON array of disclosure info objects for a completed job: + +```json +[ + { + "name": "DISCLOSURE_01_SQL_INJECTION.md", + "label": "Sql Injection", + "url": "/disclosure/<id>/DISCLOSURE_01_SQL_INJECTION.md" + } +] +``` + +- `name`: base filename of the disclosure Markdown file. +- `label`: human-readable vulnerability title, extracted from the first `#` heading in + the file (stripping the `"Security Disclosure: "` prefix). Falls back to a + filename-derived label (strips `DISCLOSURE_NN_` prefix, replaces underscores with + spaces, title-cases the result). +- `url`: relative URL to the disclosure page. +- Returns an empty array `[]` if no disclosures exist. Never returns 404 for valid job IDs. + +--- + +### HTML Report (`/report/{id}`) + +- Serves the `report.html` file directly via `http.ServeFile`. +- Returns 404 if the report is not yet available. +- The report header shows the repository name as a clickable link to the repository URL + when `RepoURL` is populated in the report data. Falls back to plain text when the URL + is not available (e.g. local-path scans without a remote). + +--- + +## Report Content — Repository Location + +The repository URL is captured in all three report types when the scan input is a URL: + +| Report | How URL appears | +|--------|----------------| +| **Security Analysis Report** (HTML) | Repository name in the header is a `<a href>` link to the repo URL | +| **Summary Report** (Markdown) | `**Repository:** <url>` field near the top of the document | +| **Security Disclosure Report** (Markdown) | `**Repository:** <url>` field in the document header, below `**Product:**`; omitted if no URL is available | + +The URL flows as follows: +1. Web UI passes `--repo-url <original-url>` to `python -m openant scan` for URL-based scans. +2. Python's `scan_repository()` receives `repo_url` and passes it to `build_pipeline_output()`. +3. `build_pipeline_output()` writes it to `pipeline_output.json` as `repository.url`. +4. The LLM report generators (`generate_summary_report`, `generate_disclosure_docs`) read + it from `pipeline_output.json` and include it in the generated Markdown. +5. The Go HTML report renderer reads it from `pipeline_output.json` via the `report-data` + subcommand and includes it in `ReportData.RepoURL`. + +For CLI scans (local paths), `--repo-url` can be passed manually; otherwise the URL field +is empty and omitted from reports. + +--- + +## Security & Scope + +- Server binds exclusively to `127.0.0.1` (localhost only); not accessible from the + network. +- The Anthropic API key submitted via the form is used only to set `ANTHROPIC_API_KEY` + in the Python subprocess environment; it is never logged or written to disk by the + web UI. +- Repository URLs are passed directly to `git clone`; no URL validation is performed + beyond checking that the `repo` field is non-empty. +- Disclosure file serving validates that the requested `{filename}` is a base filename + with no path components, and that it exists in the job's known `DisclosurePaths` list, + preventing path traversal attacks. +- The delete endpoint removes the entire job output directory with `os.RemoveAll`. diff --git a/apps/.DS_Store b/apps/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4a2a64a400b4f2c3e0c1dc6b0f17bee2b39cb8af GIT binary patch literal 8196 zcmeHMU2GIp6uxI#=nNC+w6;=kr)*dn+S0<ZNGS5R?Y0Vq($a0|w*0KKJ0qPiovAys zTS~3gCliWBU;K?R(fAV|@Ig@Y$;6moVtha(!iyw4sl1r@lW1Z*_ukn;Tl(ZrYMh(Q zJ@?#m&b@Qae&^mjyMz#E%j=s7sUd`j_)x0I*xjX3Ieq3e!JjEb3TRJ=Mr`7dW|Ae= zJndj2h(Hj5AOb-If(Qf=SPT)sJDWCffqS1zgFc8r5P|<?1nBt?#)s0lPp3F#M+bJS z1VBnl0M>AfbpW#o`!w#;DNdOwuF0zh@J+!lF@T$+JWSP*#(g@)DK{s;%?bEBgI}Ql z+a2w~Or1cS)1VI`5JX^p1Zeum#37@^z}o%J`rXWoxuY4&$)Lu?%&29$UNm|Ygp$%F zOUufnGP&$na@ZM5dYYGa+qB#f%Jn&pl_`$3%wf|QODk0g$M!VSHge3s)D2WQI$+wm zGuGzhZQW(INiL8iQA#T{6BDuRP2sKEC!4|(o1?Lorts$2mgwZ9BvwZ@?dVOPv_@>_ z40j0pLxC)x<KXnd-dS!T!M!U<7QMUfdnZcEWO=zdkQy91ax|?ddDrMQEX^|uM!V)2 zZdxgK(b;`j$L`4)1$1&C>sa{_8zmK4&C2Fc*=V(_q<NZaXLF8a^*FBSnT|b}@{Cg+ zmC=P}iBdzZlk*a6v6PAHbFE6cNB1Z-b`Kjl7pWwjTd{J@y87nWww>{geO-sHlq(gL z_sG@q5XRB=%tuTkH>kN;!`4mvME{tnd&2_-(={`e(PwKThExGf(v|X>Rjcm{sVY~v zZBSHA<um4^dD9zLBBIFl2&-%5qdGd+<>+)5T~sdMI=FkCx>h|xC5fDYz3v+{s2k)y z7u^*VjVo@HqjKL#1c0Qtd}fQhRqeO5jA2QN&i84NA5aGhzPHMLKDkS-R#RDLWCUGw zB|g`xCe-6}+XI^Gb&nc0eWgoW_8`iJ++i(ebdMnb{Wt2wS@t2~JT>ajP0wLMjK;yW zsyaxQYt?n;!|Iw<<!Vyx7nRE*qQSHaCW0P61CD($Qbb)O-pGfMPjWZdhy^+lkMij% zPG)*~>8T`2+DIokjwv`!rpfc<HFBO@ARmy=$d}|Axk<hwKayX_ujDuKJCwpQsDcpG z!Fs5N28h8Ph(jAB-~gmx5QZQN!(hTmupkeoVFD)Mad--zhG*becn)5M*WnF#6W)UN z;39kkAH%0`8NPzA;VOI!-@^}Z3w{>L1W}NLN+BeuLane~s23WA4Z?0=kFZxbBn$~@ zVVL_M6cg{*j8AMJj3Zg#Si!{ej}_tE-4ieL(B6HW`wuMI#JjeFrCL?>y(`0YjhpUo z+0{A&Nsi$nZ0}44mI1T!hGhUl$Q<-uVL2(+t|=zr?X=`++K;JYoDyjUD`mcRWSxkq zqb%{+`UYh8D@!>WYThVfnkoWg<xp&kgvqFs_-ykwWPvmCj-3*wrBdp%wQ*z#V?v?! z4qWDvBK}jD|D0SV-;f*R7GnMnC;<^xLKu-AgBIA$u%3VpI0)U)1HI4($KW`mK||DA zFakD=LJl$i7^40OcoNRQG@M1`zW^`7OYjQ3inxCp-hp@FeK-#n;6pzHzCiq6hnsUS z-8CE2@YBMWE+lid<=7`sqnHu1jTa?X!r!Wf*8d#~|Ng%y-#vJ#AOb-I{+<XxX?wE0 z4cA)e?4q@H7@vpnp^fTIamq}vW92wfR*oZ``NI&$VN|6yai30ciZj^$^&bNM`WNru P{txc|*`@M-@c#c3u)c)3 literal 0 HcmV?d00001 diff --git a/apps/openant-cli/.DS_Store b/apps/openant-cli/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..528dbabe0476a325a80276ffce5da64f06fef2ee GIT binary patch literal 8196 zcmeHMU2GIp6u#fI*cm3!0b41!0~;1XfdXZbP~>mhZI%B5+tO|MS$B6vI$=6fcV@Rh zt=1<KP@^yY#+YdQi4XW7DEee#OfWG%AQIt45}s6EO#DeSF`hejworZ^H3a4+_nv$1 zIp^LvXTEdq-dV;NI?BdM#+n#oGF47Bm6}@=F6Z^8B84NhL_zkfV7mqCpD7I6)=k>c zKoo%}0#O8_2t*NxB5)@}fX-}Q<lCJ4LK?ME1fmGslM&$iL!2t7sgRBf8f+cZ1Xlo} z{0a~h>TA41G#=7aNXG>w2^9!aqQVs66$8SY#^a%0Dx~9r3Uh|=@_}$=gf|oftJD1P zK%F5aXw*g#h$3)v1bF&Ltjw&!h&O!w{oV6hTQ5MWshx6PU7b>=)*Z+Ux+57s@0Y!f zd}$x&dR*5oRQvhDUdtTGX$@)D@$;5rmI4FIFiB;9pXC_tNQYZ?3@@<F2!W!=N=|DU z9ZjxniLYKe))F6G*_v!?iLXqqY8@L><i^B`b$fD$?IFiKE*>A@7Qm+6;LeGvFSb`n z3twt7{96R~<x=R2tV~taX?kC_e_-GKoTimMbB}4~eXC-2=6%!4Y4sktwx#GgyGmw- zT<R;jc6rDl$&6y&E|y8zY`5)<bxdd%ORjD2ay`qpT&F+ln@4;u;}cC1Wd}UB<fntl zvKGxRv}W=(x`(;3x7RFrL>1-a^jY&3G%ri8S)c0KvSau8dTqwc`_)EufTHI3*5j60 z>d$*c(=jaPQ16Ik_=9~F%d-l$+2iDgOl1aIl=JmXv*$b*({-V6&7i89Ef=gO%9ejr zOUQDtMqHn-?l;K69j?JwF=XvD%|oj%)aUE_xFlUNsWn1_#rjgU$0K)TEiV+8tF3C! zVS3$)=82J2>T12$&KFEu(G0OpoBFWcR|&n<wu!+FYNMVlx<f<cqNj+lc0H{h<jZc) zdw%z@>F`H7D`bz5Y``1Lm(1=Ff?N1RTWK_Si8RiQx(v&A10h9Y=X_o7=hL+tM&QGo zrrGs+M(>rib26d9aw--<oZf*zx}2zDERk9wx`{7JmxEpq=tQbj3^xce(eeXLXRWM* zZDj}9Fgwalu$S3e>=ZlAK4D+6Z`nn5h5f{SWq+_g*<b8$)M6?c5W_+&LNgX4iA_kM z18HnW7X27N5reRB7&gi{hEa^+DLjkk@d94NOL!e`;~l(<_wW(U;4^%VFL4gv;d@-b zkN6qC;3|HX>LgiGq?uAo(xti5BB@zgA}y6RN}Ht3(r#%$%1ML52dSE72PS+9I?_=h z!InX37H%2R$y=vc?9t6zwr<;g$EMlE=|N&OG(IpZzHrHkhuSu@PXJILwhGegp&#IV zQkDdG5AbjUR?i0+sLq{NO}T4HD3Y?562&|s^L*9nLhZx?nG!{t60*&UiQT5%C)n7s z<uWCuCIze-ORiEV^|YFhUABf;2u5AEUZEt^YD0EzidYsfu~=spO><V0|0{%l&CanO z*kyK=5dIHpAY&HdgzF^QurWY(8eQ0lZtTJy^xyyvB8NO-+{O?b45LH{f08i%G@ik6 zoWOI0^H=aHUc(!BlTiLXKEQ|g7^iR=pN4q<hR}WqS8hP?j>!ndZ&d_WG9|}$okPS| z(LP!AuI6flcTN2M-*x-%|95o^BIlwA-0KlQZD*#lgQ9S|J&@nE<5VA`$_v*U7c?-T pX3~cM;wSzvq<)-KxlJmh<ATx%mH+)i!1X_~qw_yH|L@WB|6jIpU=jcT literal 0 HcmV?d00001 diff --git a/apps/openant-cli/cmd/root.go b/apps/openant-cli/cmd/root.go index fb3eaf1..4a17506 100644 --- a/apps/openant-cli/cmd/root.go +++ b/apps/openant-cli/cmd/root.go @@ -78,6 +78,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&projectFlag, "project", "p", "", "Project to use (overrides active project, e.g. grafana/grafana)") rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(scanCmd) rootCmd.AddCommand(parseCmd) rootCmd.AddCommand(enhanceCmd) diff --git a/apps/openant-cli/cmd/serve.go b/apps/openant-cli/cmd/serve.go new file mode 100644 index 0000000..5fd7662 --- /dev/null +++ b/apps/openant-cli/cmd/serve.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "syscall" + + "github.com/knostic/open-ant-cli/internal/output" + "github.com/knostic/open-ant-cli/internal/server" + "github.com/spf13/cobra" +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the OpenAnt web UI", + Long: `Serve starts a local HTTP server (default http://localhost:8080) and opens +the browser automatically. + +Scan outputs are stored under ~/.openant/webui/ and persist across restarts. +Use Ctrl+C to stop the server; all in-flight scans are cancelled gracefully.`, + Args: cobra.NoArgs, + Run: runServe, +} + +var serveAddr string + +func init() { + serveCmd.Flags().StringVar(&serveAddr, "addr", "127.0.0.1:8080", "Address to listen on") +} + +func runServe(_ *cobra.Command, _ []string) { + rt, err := ensurePython() + if err != nil { + output.PrintError(err.Error()) + os.Exit(2) + } + + // Resolve output root: ~/.openant/webui/ + home, err := os.UserHomeDir() + if err != nil { + output.PrintError("cannot determine home directory: " + err.Error()) + os.Exit(2) + } + outDir := filepath.Join(home, ".openant", "webui") + if err := os.MkdirAll(outDir, 0750); err != nil { + output.PrintError("cannot create output directory: " + err.Error()) + os.Exit(2) + } + + srv, err := server.New(rt.Path, outDir) + if err != nil { + output.PrintError("failed to initialise server: " + err.Error()) + os.Exit(2) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + url, err := srv.Start(ctx, serveAddr) + if err != nil { + output.PrintError("failed to start server: " + err.Error()) + os.Exit(2) + } + + fmt.Println("OpenAnt web UI running at", url) + openBrowser(url) + fmt.Println("Press Ctrl+C to stop.") + + <-ctx.Done() + fmt.Println("\nShutting down…") +} + +// openBrowser opens url in the default system browser. +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + _ = cmd.Start() +} diff --git a/apps/openant-cli/internal/python/invoke_ctx.go b/apps/openant-cli/internal/python/invoke_ctx.go new file mode 100644 index 0000000..496d39d --- /dev/null +++ b/apps/openant-cli/internal/python/invoke_ctx.go @@ -0,0 +1,107 @@ +package python + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "syscall" +) + +// InvokeCtx runs `python -m openant <args>` with context-cancellation support. +// Each stderr line is passed to onLog in real time. stdout is discarded. +// On context cancellation, SIGKILL is sent to the entire process group so all +// child processes (e.g. parallel workers) are killed. +// Returns the exit code and any error (cancelled runs return exit code -1, nil). +func InvokeCtx(ctx context.Context, pythonPath string, args []string, workDir, apiKey string, onLog func(string)) (int, error) { + _, code, err := invokeCtxInner(ctx, pythonPath, args, workDir, apiKey, onLog, false) + return code, err +} + +// InvokeCtxCapture runs `python -m openant <args>` like InvokeCtx but also +// captures and returns the full stdout content (e.g. JSON output). +func InvokeCtxCapture(ctx context.Context, pythonPath string, args []string, workDir, apiKey string, onLog func(string)) (stdout string, exitCode int, err error) { + return invokeCtxInner(ctx, pythonPath, args, workDir, apiKey, onLog, true) +} + +func invokeCtxInner(ctx context.Context, pythonPath string, args []string, workDir, apiKey string, onLog func(string), captureStdout bool) (string, int, error) { + cmdArgs := append([]string{"-m", "openant"}, args...) + cmd := exec.Command(pythonPath, cmdArgs...) + + if workDir != "" { + cmd.Dir = workDir + } + cmd.Env = os.Environ() + if apiKey != "" { + cmd.Env = setEnv(cmd.Env, "ANTHROPIC_API_KEY", apiKey) + } + // New process group so we can kill all descendants at once. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stderr, err := cmd.StderrPipe() + if err != nil { + return "", 0, fmt.Errorf("stderr pipe: %w", err) + } + + var stdoutBuf bytes.Buffer + if captureStdout { + cmd.Stdout = &stdoutBuf + } else { + cmd.Stdout = nil // discard + } + + if err := cmd.Start(); err != nil { + return "", 0, fmt.Errorf("start: %w", err) + } + + // Kill process group when context is cancelled. + stopKiller := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + case <-stopKiller: + } + }() + + // Stream stderr to onLog. + stderrDone := make(chan struct{}) + go func() { + defer close(stderrDone) + sc := bufio.NewScanner(stderr) + for sc.Scan() { + if onLog != nil { + onLog(sc.Text()) + } + } + }() + + // If capturing stdout we need to drain it; cmd.Stdout = &stdoutBuf handles that. + // For non-capture, stdout goes to /dev/null via nil. + // Wait for stderr to finish before cmd.Wait(). + <-stderrDone + + // Also drain any remaining stdout bytes (belt-and-suspenders for the pipe). + if captureStdout { + _, _ = io.Copy(&stdoutBuf, bytes.NewReader(nil)) // no-op; already set via cmd.Stdout + } + + exitErr := cmd.Wait() + close(stopKiller) + + if exitErr != nil { + if ee, ok := exitErr.(*exec.ExitError); ok { + return stdoutBuf.String(), ee.ExitCode(), nil + } + if ctx.Err() != nil { + return "", -1, nil + } + return "", 0, fmt.Errorf("wait: %w", exitErr) + } + return stdoutBuf.String(), 0, nil +} diff --git a/apps/openant-cli/internal/report/templates/report-reskin.gohtml b/apps/openant-cli/internal/report/templates/report-reskin.gohtml index d706300..d910826 100644 --- a/apps/openant-cli/internal/report/templates/report-reskin.gohtml +++ b/apps/openant-cli/internal/report/templates/report-reskin.gohtml @@ -105,7 +105,7 @@ <div> <h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-knostic-navy mb-1">{{.Title}}</h1> <div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-knostic-muted mt-2"> - {{if .RepoName}}<span title="Repository"><strong class="text-knostic-navy">{{.RepoName}}</strong></span>{{end}} + {{if .RepoName}}{{if .RepoURL}}<a href="{{.RepoURL}}" target="_blank" rel="noopener" title="Repository" class="text-knostic-navy font-semibold hover:underline">{{.RepoName}}</a>{{else}}<span title="Repository"><strong class="text-knostic-navy">{{.RepoName}}</strong></span>{{end}}{{end}} {{if .ShortCommit}}<code class="text-xs bg-knostic-card-bg border border-knostic-border-light px-2 py-0.5 rounded text-knostic-navy">{{.ShortCommit}}</code>{{end}} {{if .Language}}<span class="capitalize">{{.Language}}</span>{{end}} </div> diff --git a/apps/openant-cli/internal/server/server.go b/apps/openant-cli/internal/server/server.go new file mode 100644 index 0000000..9436ff9 --- /dev/null +++ b/apps/openant-cli/internal/server/server.go @@ -0,0 +1,1075 @@ +// Package server implements the OpenAnt web UI HTTP server. +package server + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/knostic/open-ant-cli/internal/config" + "github.com/knostic/open-ant-cli/internal/python" + "github.com/knostic/open-ant-cli/internal/report" + "github.com/knostic/open-ant-cli/internal/types" + uifiles "github.com/knostic/open-ant-cli/ui" +) + + +// Job status constants. +const ( + StatusRunning = "running" + StatusDone = "done" + StatusError = "error" +) + +// jobMeta is the on-disk metadata written immediately on job creation. +type jobMeta struct { + ID string `json:"id"` + Repo string `json:"repo"` + StartedAt time.Time `json:"started_at"` +} + +// Job represents a single scan job. +type Job struct { + mu sync.Mutex + ID string + Repo string + StartedAt time.Time + Status string + LogBuf []string + ReportPath string + SummaryPath string + DisclosurePaths []string + Cancel context.CancelFunc + + // Internal scan parameters (not exposed via API) + apiKey string + language string + model string + verify bool + dynamicTest bool + ctx context.Context +} + +func (j *Job) addLog(line string) { + j.mu.Lock() + defer j.mu.Unlock() + j.LogBuf = append(j.LogBuf, line) +} + +func (j *Job) setDone(reportPath, summaryPath string, disclosurePaths []string) { + j.mu.Lock() + defer j.mu.Unlock() + j.Status = StatusDone + j.ReportPath = reportPath + j.SummaryPath = summaryPath + j.DisclosurePaths = disclosurePaths +} + +func (j *Job) setError() { + j.mu.Lock() + defer j.mu.Unlock() + j.Status = StatusError +} + +func (j *Job) snapshot() (status string, logs []string, reportPath, summaryPath string) { + j.mu.Lock() + defer j.mu.Unlock() + logs = make([]string, len(j.LogBuf)) + copy(logs, j.LogBuf) + return j.Status, logs, j.ReportPath, j.SummaryPath +} + +// manager is the in-memory job store. +type manager struct { + mu sync.RWMutex + jobs map[string]*Job + outDir string +} + +func newManager(outDir string) *manager { + return &manager{jobs: make(map[string]*Job), outDir: outDir} +} + +func (m *manager) add(j *Job) { + m.mu.Lock() + defer m.mu.Unlock() + m.jobs[j.ID] = j +} + +func (m *manager) get(id string) (*Job, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + j, ok := m.jobs[id] + return j, ok +} + +func (m *manager) remove(id string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.jobs, id) +} + +func (m *manager) all() []*Job { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]*Job, 0, len(m.jobs)) + for _, j := range m.jobs { + out = append(out, j) + } + sort.Slice(out, func(i, k int) bool { + return out[i].StartedAt.After(out[k].StartedAt) + }) + return out +} + +func (m *manager) cancelAll() { + m.mu.RLock() + defer m.mu.RUnlock() + for _, j := range m.jobs { + j.mu.Lock() + if j.Cancel != nil { + j.Cancel() + } + j.mu.Unlock() + } +} + +// Server is the web UI HTTP server. +type Server struct { + pythonPath string + outDir string + mgr *manager + tmplIndex *template.Template + tmplScan *template.Template + tmplSum *template.Template + tmplDisclosure *template.Template +} + +// New creates a new Server. It parses UI templates and recovers any existing +// jobs from disk at outDir. +func New(pythonPath, outDir string) (*Server, error) { + tmplIndex, err := template.ParseFS(uifiles.FS, "index.html") + if err != nil { + return nil, fmt.Errorf("parse index.html: %w", err) + } + tmplScan, err := template.ParseFS(uifiles.FS, "scan.html") + if err != nil { + return nil, fmt.Errorf("parse scan.html: %w", err) + } + tmplSum, err := template.ParseFS(uifiles.FS, "summary.html") + if err != nil { + return nil, fmt.Errorf("parse summary.html: %w", err) + } + tmplDisclosure, err := template.ParseFS(uifiles.FS, "disclosure.html") + if err != nil { + return nil, fmt.Errorf("parse disclosure.html: %w", err) + } + + s := &Server{ + pythonPath: pythonPath, + outDir: outDir, + mgr: newManager(outDir), + tmplIndex: tmplIndex, + tmplScan: tmplScan, + tmplSum: tmplSum, + tmplDisclosure: tmplDisclosure, + } + s.recoverJobs() + return s, nil +} + +// recoverJobs scans outDir for existing job directories and restores them. +func (s *Server) recoverJobs() { + entries, err := os.ReadDir(s.outDir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() { + continue + } + id := e.Name() + jobDir := filepath.Join(s.outDir, id) + + job := &Job{ID: id} + + // Try to read meta.json. + if data, err := os.ReadFile(filepath.Join(jobDir, "meta.json")); err == nil { + var m jobMeta + if json.Unmarshal(data, &m) == nil { + job.Repo = m.Repo + job.StartedAt = m.StartedAt + } + } + + // Fall back: infer repo from git config and mtime from dir. + if job.Repo == "" { + job.Repo = inferRepoURL(jobDir) + } + if job.StartedAt.IsZero() { + if info, err := os.Stat(jobDir); err == nil { + job.StartedAt = info.ModTime() + } + } + + // Determine status from presence of report.html. + reportPath := filepath.Join(jobDir, "report.html") + if _, err := os.Stat(reportPath); err == nil { + job.Status = StatusDone + job.ReportPath = reportPath + // Look for summary. + for _, sp := range []string{ + filepath.Join(jobDir, "report", "SUMMARY_REPORT.md"), + filepath.Join(jobDir, "SUMMARY_REPORT.md"), + } { + if _, err := os.Stat(sp); err == nil { + job.SummaryPath = sp + break + } + } + // Look for disclosure reports. + job.DisclosurePaths = findDisclosures(jobDir) + } else { + job.Status = StatusError + } + + // Load persisted logs if available. + if data, err := os.ReadFile(filepath.Join(jobDir, "logs.txt")); err == nil { + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + job.LogBuf = lines + } + + s.mgr.add(job) + } +} + +// inferRepoURL tries to read the origin remote URL from repo/.git/config. +func inferRepoURL(jobDir string) string { + gitConfig := filepath.Join(jobDir, "repo", ".git", "config") + f, err := os.Open(gitConfig) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + inOrigin := false + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == `[remote "origin"]` { + inOrigin = true + continue + } + if inOrigin && strings.HasPrefix(line, "url =") { + return strings.TrimSpace(strings.TrimPrefix(line, "url =")) + } + if strings.HasPrefix(line, "[") { + inOrigin = false + } + } + return "" +} + +// Handler returns the HTTP handler for the server. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", s.handleIndex) + mux.HandleFunc("POST /scan", s.handleStartScan) + mux.HandleFunc("GET /scan/{id}", s.handleScanPage) + mux.HandleFunc("GET /scan/{id}/logs", s.handleScanLogs) + mux.HandleFunc("GET /report/{id}", s.handleReport) + mux.HandleFunc("GET /summary/{id}", s.handleSummary) + mux.HandleFunc("GET /disclosures/{id}", s.handleDisclosureList) + mux.HandleFunc("GET /disclosure/{id}/{filename}", s.handleDisclosure) + mux.HandleFunc("DELETE /scan/{id}", s.handleDeleteScan) + return mux +} + +// Start binds the server and begins serving. Tries addr first, then falls +// back to any available port on 127.0.0.1. Returns the bound URL. +func (s *Server) Start(ctx context.Context, addr string) (string, error) { + ln, err := net.Listen("tcp", addr) + if err != nil { + // Fall back to OS-assigned port. + ln, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", fmt.Errorf("listen: %w", err) + } + } + url := "http://" + ln.Addr().String() + srv := &http.Server{Handler: s.Handler()} + + go func() { + <-ctx.Done() + s.mgr.cancelAll() + _ = srv.Shutdown(context.Background()) + }() + + go func() { + _ = srv.Serve(ln) + }() + + return url, nil +} + +// ─── Handlers ────────────────────────────────────────────────────────────── + +type jobView struct { + ID string + Repo string + StartedAt string + Status string + HasReport bool + HasSummary bool +} + +type indexData struct { + Jobs []*jobView + APIKey string + HasAPIKey bool + APIKeySource string +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + cfg, _ := config.Load() + apiKey := "" + apiKeySource := "" + if cfg != nil && cfg.APIKey != "" { + apiKey = cfg.APIKey + apiKeySource = "~/.config/openant/config.json" + } + + jobs := s.mgr.all() + views := make([]*jobView, 0, len(jobs)) + for _, j := range jobs { + j.mu.Lock() + v := &jobView{ + ID: j.ID, + Repo: j.Repo, + StartedAt: j.StartedAt.Format("2006-01-02 15:04:05"), + Status: j.Status, + HasReport: j.ReportPath != "", + HasSummary: j.SummaryPath != "", + } + j.mu.Unlock() + views = append(views, v) + } + + d := indexData{ + Jobs: views, + APIKey: apiKey, + HasAPIKey: apiKey != "", + APIKeySource: apiKeySource, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmplIndex.Execute(w, d); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (s *Server) handleStartScan(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + + repo := strings.TrimSpace(r.FormValue("repo")) + if repo == "" { + http.Error(w, "repo is required", http.StatusBadRequest) + return + } + + language := r.FormValue("language") + if language == "auto" { + language = "" + } + model := r.FormValue("model") + apiKey := r.FormValue("api_key") + if apiKey == "" { + // Fall back to configured key. + apiKey = config.ResolveAPIKey("") + } + verify := r.FormValue("verify") == "on" + dynamicTest := r.FormValue("dynamic_test") == "on" + + id, err := randomID() + if err != nil { + http.Error(w, "failed to generate ID", http.StatusInternalServerError) + return + } + + jobDir := filepath.Join(s.outDir, id) + if err := os.MkdirAll(jobDir, 0750); err != nil { + http.Error(w, "failed to create job dir", http.StatusInternalServerError) + return + } + + // Write meta.json immediately. + meta := jobMeta{ID: id, Repo: repo, StartedAt: time.Now().UTC()} + if data, err := json.Marshal(meta); err == nil { + _ = os.WriteFile(filepath.Join(jobDir, "meta.json"), data, 0640) + } + + ctx, cancel := context.WithCancel(context.Background()) + job := &Job{ + ID: id, + Repo: repo, + StartedAt: meta.StartedAt, + Status: StatusRunning, + Cancel: cancel, + ctx: ctx, + apiKey: apiKey, + language: language, + model: model, + verify: verify, + dynamicTest: dynamicTest, + } + s.mgr.add(job) + + go s.runJob(job) + + http.Redirect(w, r, "/scan/"+id, http.StatusSeeOther) +} + +type scanPageData struct { + ID string + Repo string +} + +func (s *Server) handleScanPage(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + job.mu.Lock() + repo := job.Repo + job.mu.Unlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmplScan.Execute(w, scanPageData{ID: id, Repo: repo}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (s *Server) handleScanLogs(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + + flusher, canFlush := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + // Send all existing lines immediately, then poll for more. + job.mu.Lock() + initial := make([]string, len(job.LogBuf)) + copy(initial, job.LogBuf) + initStatus := job.Status + job.mu.Unlock() + + for _, line := range initial { + fmt.Fprintf(w, "data: %s\n\n", line) + } + sent := len(initial) + + if initStatus != StatusRunning { + fmt.Fprintf(w, "event: done\ndata: %s\n\n", initStatus) + if canFlush { + flusher.Flush() + } + return + } + if canFlush { + flusher.Flush() + } + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + } + + job.mu.Lock() + logs := job.LogBuf + status := job.Status + job.mu.Unlock() + + for i := sent; i < len(logs); i++ { + fmt.Fprintf(w, "data: %s\n\n", logs[i]) + } + sent = len(logs) + + if status != StatusRunning { + fmt.Fprintf(w, "event: done\ndata: %s\n\n", status) + if canFlush { + flusher.Flush() + } + return + } + if canFlush { + flusher.Flush() + } + } +} + +func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + job.mu.Lock() + rp := job.ReportPath + job.mu.Unlock() + if rp == "" { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, rp) +} + +type summaryData struct { + ID string + MarkdownJSON template.JS // full JSON-encoded string literal (incl. outer quotes) +} + +func (s *Server) handleSummary(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + job.mu.Lock() + sp := job.SummaryPath + job.mu.Unlock() + if sp == "" { + http.NotFound(w, r) + return + } + data, err := os.ReadFile(sp) + if err != nil { + http.NotFound(w, r) + return + } + // json.Marshal produces a properly-escaped JS string literal including outer quotes. + mdJSON, _ := json.Marshal(string(data)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmplSum.Execute(w, summaryData{ + ID: id, + MarkdownJSON: template.JS(mdJSON), + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +type disclosureInfo struct { + Name string `json:"name"` + Label string `json:"label"` + URL string `json:"url"` +} + +func (s *Server) handleDisclosureList(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + job.mu.Lock() + paths := make([]string, len(job.DisclosurePaths)) + copy(paths, job.DisclosurePaths) + job.mu.Unlock() + + infos := make([]disclosureInfo, 0, len(paths)) + for _, p := range paths { + name := filepath.Base(p) + label := disclosureTitleFromFile(p) + if label == "" { + label = disclosureLabel(name) + } + infos = append(infos, disclosureInfo{ + Name: name, + Label: label, + URL: "/disclosure/" + id + "/" + name, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(infos) +} + +type disclosureData struct { + ID string + Name string + MarkdownJSON template.JS +} + +func (s *Server) handleDisclosure(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + filename := filepath.Base(r.PathValue("filename")) // sanitize: strip any path components + if filename == "." || filename == "" { + http.NotFound(w, r) + return + } + + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + + // Verify the file is one of the job's known disclosure paths. + job.mu.Lock() + var matchedPath string + for _, p := range job.DisclosurePaths { + if filepath.Base(p) == filename { + matchedPath = p + break + } + } + job.mu.Unlock() + + if matchedPath == "" { + http.NotFound(w, r) + return + } + + data, err := os.ReadFile(matchedPath) + if err != nil { + http.NotFound(w, r) + return + } + + mdJSON, _ := json.Marshal(string(data)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmplDisclosure.Execute(w, disclosureData{ + ID: id, + Name: disclosureLabel(filename), + MarkdownJSON: template.JS(mdJSON), + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// disclosureTitleFromFile reads the first markdown heading from a disclosure +// file and returns the vulnerability title. +// e.g. "# Security Disclosure: Mail Account Credential Theft" → "Mail Account Credential Theft" +// Returns empty string if the title cannot be extracted. +func disclosureTitleFromFile(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if strings.HasPrefix(line, "#") { + // Strip all leading '#' and whitespace. + title := strings.TrimSpace(strings.TrimLeft(line, "#")) + // Strip common "Security Disclosure:" prefix. + for _, prefix := range []string{ + "Security Disclosure: ", + "Security Disclosure:", + } { + if strings.HasPrefix(title, prefix) { + return strings.TrimSpace(strings.TrimPrefix(title, prefix)) + } + } + return title + } + } + return "" +} + +// disclosureLabel converts a disclosure filename to a human-readable label. +// e.g. "DISCLOSURE_01_SQL_INJECTION.md" → "Sql Injection" +var reDisclosurePrefix = regexp.MustCompile(`(?i)^DISCLOSURE_\d+_`) + +func disclosureLabel(filename string) string { + name := strings.TrimSuffix(filename, ".md") + name = reDisclosurePrefix.ReplaceAllString(name, "") + words := strings.FieldsFunc(name, func(r rune) bool { return r == '_' || r == '-' }) + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + strings.ToLower(w[1:]) + } + } + return strings.Join(words, " ") +} + +// findDisclosures returns absolute paths to all .md files in the disclosures +// subdirectory of outDir. +func findDisclosures(outDir string) []string { + discDir := filepath.Join(outDir, "report", "disclosures") + entries, err := os.ReadDir(discDir) + if err != nil { + return nil + } + var paths []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + paths = append(paths, filepath.Join(discDir, e.Name())) + } + } + sort.Strings(paths) + return paths +} + +func (s *Server) handleDeleteScan(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := s.mgr.get(id) + if !ok { + http.NotFound(w, r) + return + } + job.mu.Lock() + if job.Cancel != nil { + job.Cancel() + } + job.mu.Unlock() + + s.mgr.remove(id) + _ = os.RemoveAll(filepath.Join(s.outDir, id)) + w.WriteHeader(http.StatusNoContent) +} + +// ─── Background job runner ───────────────────────────────────────────────── + +func (s *Server) runJob(job *Job) { + outDir := filepath.Join(s.outDir, job.ID) + + defer func() { + // Panic recovery — log and mark error rather than silently dropping the goroutine. + if r := recover(); r != nil { + job.addLog(fmt.Sprintf("[error] internal panic: %v", r)) + job.mu.Lock() + if job.Status == StatusRunning { + job.Status = StatusError + } + job.mu.Unlock() + } + // Persist full log buffer to logs.txt. + job.mu.Lock() + logData := strings.Join(job.LogBuf, "\n") + "\n" + job.mu.Unlock() + _ = os.WriteFile(filepath.Join(outDir, "logs.txt"), []byte(logData), 0640) + }() + + job.addLog("→ Starting scan of " + job.Repo) + + // Determine local path: clone if URL, use directly if local path. + localPath := job.Repo + isURL := strings.HasPrefix(job.Repo, "https://") || + strings.HasPrefix(job.Repo, "http://") || + strings.HasPrefix(job.Repo, "git@") + + if isURL { + cloneDir := filepath.Join(outDir, "repo") + job.addLog("[clone] Cloning " + job.Repo + "…") + if err := cloneRepo(job.ctx, job.Repo, cloneDir, job.addLog); err != nil { + if job.ctx.Err() == nil { + job.addLog("[clone] Error: " + err.Error()) + job.setError() + } + return + } + localPath = cloneDir + } + + // Build scan args. + args := []string{"scan", localPath, "--output", outDir} + if job.language != "" { + args = append(args, "--language", job.language) + } + if job.model != "" && job.model != "opus" { + args = append(args, "--model", job.model) + } + if job.verify { + args = append(args, "--verify") + } + if job.dynamicTest { + args = append(args, "--dynamic-test") + } + if isURL { + args = append(args, "--repo-url", job.Repo) + } + + job.addLog("→ Running: python -m openant " + strings.Join(args, " ")) + + exitCode, err := python.InvokeCtx(job.ctx, s.pythonPath, args, "", job.apiKey, job.addLog) + if job.ctx.Err() != nil { + return // cancelled — don't mark error + } + if err != nil { + job.addLog("[error] scan failed to start: " + err.Error()) + job.setError() + return + } + // Exit code 1 means "scan succeeded but found vulnerabilities" (like grep). + // Exit code 2+ means actual failure. + if exitCode >= 2 { + job.addLog(fmt.Sprintf("[error] scan exited with code %d", exitCode)) + job.setError() + return + } + + // Patch pipeline_output.json with the original repo URL. + patchPipelineOutput(outDir, job.Repo, job.addLog) + + // Locate or generate report.html. + reportPath := filepath.Join(outDir, "report.html") + if !fileExists(reportPath) { + // Scan step may have placed it in a subdirectory. + for _, alt := range []string{ + filepath.Join(outDir, "final-reports", "report.html"), + filepath.Join(outDir, "final-reports", "report-reskin.html"), + } { + if fileExists(alt) { + if data, err := os.ReadFile(alt); err == nil { + _ = os.WriteFile(reportPath, data, 0640) + } + break + } + } + } + + // If still missing, try explicit report generation (non-fatal). + if !fileExists(reportPath) { + if err := s.generateHTMLReport(job.ctx, outDir, reportPath, job.apiKey, job.addLog); err != nil { + if job.ctx.Err() != nil { + return + } + job.addLog("[report] Warning: " + err.Error()) + // Continue — mark done only if we found something. + } + } + + if job.ctx.Err() != nil { + return + } + if !fileExists(reportPath) { + job.addLog("[error] no report.html produced; marking scan as error") + job.setError() + return + } + + // Generate Markdown summary (non-fatal — requires API key / LLM). + summaryPath := filepath.Join(outDir, "SUMMARY_REPORT.md") + if err := s.generateSummary(job.ctx, outDir, summaryPath, job.apiKey, job.addLog); err != nil { + if job.ctx.Err() != nil { + return + } + job.addLog("[report] Warning: summary not generated: " + err.Error()) + summaryPath = "" // leave button disabled + } + // Also check pre-existing locations (e.g. from a previous run). + if summaryPath == "" || !fileExists(summaryPath) { + summaryPath = "" + for _, sp := range []string{ + filepath.Join(outDir, "report", "SUMMARY_REPORT.md"), + filepath.Join(outDir, "SUMMARY_REPORT.md"), + } { + if fileExists(sp) { + summaryPath = sp + break + } + } + } + + disclosurePaths := findDisclosures(outDir) + + job.setDone(reportPath, summaryPath, disclosurePaths) +} + +// cloneRepo runs git clone --depth 1 and streams stderr to onLog. +func cloneRepo(ctx context.Context, repo, dest string, onLog func(string)) error { + cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", repo, dest) + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + sc := bufio.NewScanner(stderr) + for sc.Scan() { + onLog("[clone] " + sc.Text()) + } + return cmd.Wait() +} + +// patchPipelineOutput updates the repository.url field in pipeline_output.json. +func patchPipelineOutput(outDir, repo string, onLog func(string)) { + path := filepath.Join(outDir, "pipeline_output.json") + data, err := os.ReadFile(path) + if err != nil { + return // file doesn't exist, skip silently + } + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return + } + if repoField, ok := obj["repository"]; ok { + if repoMap, ok := repoField.(map[string]any); ok { + repoMap["url"] = repo + } + } + patched, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return + } + if err := os.WriteFile(path, patched, 0640); err != nil { + onLog("[report] Warning: could not patch pipeline_output.json: " + err.Error()) + } +} + +// generateHTMLReport uses `python -m openant report-data` to get pre-computed +// report JSON, then renders it with Go's embedded HTML template — the same +// pipeline the `openant report -f html` CLI command uses. +func (s *Server) generateHTMLReport(ctx context.Context, outDir, reportPath, apiKey string, onLog func(string)) error { + resultsPath := findResultsFile(outDir) + if resultsPath == "" { + return fmt.Errorf("no results file found in %s", outDir) + } + + args := []string{"report-data", resultsPath} + if ds := findDatasetFile(outDir); ds != "" { + args = append(args, "--dataset", ds) + } + + onLog("[report] Generating HTML report…") + stdout, exitCode, err := python.InvokeCtxCapture(ctx, s.pythonPath, args, "", apiKey, func(line string) { + onLog("[report] " + line) + }) + if err != nil { + return err + } + if exitCode != 0 { + return fmt.Errorf("report-data exited with code %d", exitCode) + } + + // Parse the JSON envelope that Python writes to stdout. + var envelope types.Envelope + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope); err != nil { + return fmt.Errorf("parse report-data output: %w", err) + } + if envelope.Status != "success" { + if len(envelope.Errors) > 0 { + return fmt.Errorf("report-data: %s", envelope.Errors[0]) + } + return fmt.Errorf("report-data returned status %q", envelope.Status) + } + + // Re-marshal then unmarshal into ReportData (same as report.go does). + dataBytes, err := json.Marshal(envelope.Data) + if err != nil { + return fmt.Errorf("marshal report data: %w", err) + } + var reportData report.ReportData + if err := json.Unmarshal(dataBytes, &reportData); err != nil { + return fmt.Errorf("parse report data: %w", err) + } + + return report.GenerateReskin(reportData, reportPath) +} + +// generateSummary runs `python -m openant report --format summary` to produce +// SUMMARY_REPORT.md. This step makes LLM calls so it requires an API key. +func (s *Server) generateSummary(ctx context.Context, outDir, outputPath, apiKey string, onLog func(string)) error { + resultsPath := findResultsFile(outDir) + if resultsPath == "" { + return fmt.Errorf("no results file found in %s", outDir) + } + + args := []string{"report", resultsPath, "--format", "summary", "--output", outputPath} + if po := filepath.Join(outDir, "pipeline_output.json"); fileExists(po) { + args = append(args, "--pipeline-output", po) + } + + onLog("[report] Generating Markdown summary…") + exitCode, err := python.InvokeCtx(ctx, s.pythonPath, args, "", apiKey, func(line string) { + onLog("[report] " + line) + }) + if err != nil { + return err + } + if exitCode != 0 { + return fmt.Errorf("summary generation exited with code %d", exitCode) + } + if !fileExists(outputPath) { + return fmt.Errorf("summary file not produced at %s", outputPath) + } + return nil +} + +// findResultsFile locates the primary results JSON in the output directory. +func findResultsFile(outDir string) string { + for _, name := range []string{ + "results_verified.json", + "results_analyzed.json", + "results.json", + } { + p := filepath.Join(outDir, name) + if fileExists(p) { + return p + } + } + return "" +} + +// findDatasetFile locates the best available dataset JSON in the output directory. +// Prefers the enhanced dataset; falls back to the original parsed dataset. +func findDatasetFile(outDir string) string { + for _, name := range []string{ + "dataset_enhanced.json", + "dataset.json", + } { + p := filepath.Join(outDir, name) + if fileExists(p) { + return p + } + } + return "" +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// randomID generates a 16-character cryptographically random hex string. +func randomID() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + + diff --git a/apps/openant-cli/ui/.index.html.swp b/apps/openant-cli/ui/.index.html.swp new file mode 100644 index 0000000000000000000000000000000000000000..7b14c111d23f6c5d97eff135dc08efb44f12fcd8 GIT binary patch literal 16384 zcmeHOTa4pY8FneRLR%2LzysA;SG&oUv14bFnM@`bx>ve%mR38vRZDN2#K(y>j-A@h zT)Lf7gg^owP*GJ-ALtVjLPC8&2#F_nLKQCv0fL7XsVd?Lsp5f4k$~?%$4=~IW=abP z37pw4mt*@s=l}l8`TscEv+lcenXQ?&hR6Fg?fD<P?7jGsb@MOoyg`e@N#OZ(;P}VZ z1IFdE_ZpWQm-(LQhkbs;HBTymwR5541%ui>V-cJQk{Tb6<64LhJV=bb@6{$=%?li{ zXAYCm5u%P?P<gaupk&|>25!-o>$XJ)Zr4w+4}Rd<A?K7sB?Bb`B?Bb`B?Bb`B?Bb` zB?Bb`|Emln;~TZlK)JW23a+NFH_W~MJ$*csj<@EH|1CY<Nynd=JDz?lUrGi_21*7> z21*7>21*7>21*7>21*7>21*7>2L1~Sa7WYXnD-Pl{J8(0_5ZKEPt$$_{0jIH@HlW0 z*Z}@;tEN2)ynYLQF93MJDd5GMHSKdi1^DTEHSIy54*c~!n)WT=(?ADk0l&OS)4mQg zfLGtGX<r6bfRn(>@6xnqfsX?xffK+V->GRo0eoNsc<vpV_6gwCw`<xZ;KRW8Zq&30 zV89FbLSq7~12+I1`mF$@=SkA_O+6Sh9pQ^a7`-H5*EM|hy<nR@wTI!3h}d=3<NfVH zgr7_}ZFal6?6zEYogL&)#Yt|6-#EnH<D$(hvlWTaI_rmi7~v9YZDq~j>nss_iQ$NT z81cjlgEkAoK&-Pdcj#9WZMHn#W3BPtI_rgzBO)W>jyH+htT7vFGaDzxq3=2DcE@s7 z9SGPRdRTuv=6y^agu4+Rr|au+XCMp_MPbC)f+nu$iM81>`)<8nhd8O3y3)Y{apC-x z4tIOPo?U74Rd;!{aAh<J0w^_qrR%z_esgx^8edtpto)U@!tvs<&-dHR3t$Ua2ERPn z&hb<~f+rgX){BN%ZAwc><LnA=FihHPWjbNp=YhfdWQ;gJuj>m4;=VTs3{X3Q4f-NT zM6}KZeB4$>qLYkx7{Oa$@>B&WaU>EBaRrZ9ExJUj)mmpG9u2&}NJ5B|OP9<`5s?^& znFyDUJRfX~!XT7Jp3||Vbj-ZX=JW@zfD;+LNN_Aqz8Kv1^UI9F-L}tT{4!_g`3`1F z-L4lVNjS>9Gq<L2H&4<a@*MDiX9E<$VDLe#VKNFpm+eN3Pm)kR9Y>|TLN=R*0GW`f zvANac{NA<bFQ-auB)sPfQlSv46sJl|A#Q{%8Up;!g_#UJ3VGKDGi>NNjtB~~#6tQC z8`d-1sDQT@olJUlYT%4eTL!WubQtc!vkcg1J7#7OCbam1!xa5xWo4!4FG|*oH%sP- zg7z&Wszog&->K%la1jrbcIGn5;u?LA#Yq&(h@SVfL(iU9Cym7H+D4->RVd>}8bC(; zFb!4PM6OsAANpV`)!k4^)FlD(RHSZW9C;vr|L_#Pyu7TW(&>yP5f5TdW#pOvAdB10 zcwL@RFwELfDWyM&V#@d9&{MfW=6jh%rujpPn@xhvAq&@x7-Y!IGqVt!^HZ{*lrznf zX@cDKoMZ@3x2%&>ZdG!T<P@Am1sTMcIl9#T*B^@hHj)GLc~212%8g}`D<skeCDLr& zlGFlGt<>bwC{v+cFrFk2Ci`R2)#YF>+|wV%EZOR%gH9ble&T&fzm#3%ewtBM6r9^P z{;B+(&%XA|dxkI`lyas4c}V%<C5GkOtExh;FTjU&!7XQnyk0%UH3SP1xO?vMnw z-LOB2mspJ2rJtxzkeGT(MpRaKfMV=a+@Z8)$v=wuXwc);wI$nXF4gVU68!!2kzt_< z5-IoSz>rz6$PGDjwwlar;Uu9TPrX}ua%Ow!xl<yM58%u)vB~)hNjxil4iApJmr)q% z4=<sbn0elq$1!b?G_E+5zArt#?RpV*#n3g#OQ-&?_UUxn$wFg31d9Q1WTr?R`LHC| zBz+6MIkmjm=aKW4Hy@c@=WU@vh{VEq3cE)>4dwRU<Mz&};p)>R-#HSF$60m6_l(rY z*cr!rr!k*NwF6#{E+OEKD*LBrPT8i<$JQRp4e2+sT2re@Db4np<ypZs;vOo0a6Vn4 zjTAaF1H}y0GPyE^u4XeWhk~PCXDH4{P{whqx7JugJ$;x@5bx<#Lpyhk%>KCxY_onu zZGoNz1*xq1W%FQIrd6k|BPV*!rj}#dHp=v=ov6@aiis(;Z<IS#kxi5ILT4XKm#fW^ zq=BD&`{+gXW7LF8>@4+CmwA6ne!hqnX^H7uVh{>;?UKI4u7t2hh!DgMY{Vk+Xzwey zVbjImVh$OhZl3NDmbEifdf&0c?r67r!VM!K-*OiuVUaQedXGz{(u{$jEYRfEHf2SI zcqdNweX*f+5--8)-6*{iug}t7wzF5BdWLNY?D$@?&$iG;1S0Cx)VW$mHUI%1VT;@G z#O^qZ5*==Vsvt<Zx{QOaBX+#LP(eYt2?YrE4HW;r=-Q^GV`?d;Kjcv?aM`u17mXGT zli$z_enO8eIv8|y5zzRF^Y@&)`oO&x7#-QrsQ+)EAMT(>rv5*>e^34YkAWwF*U``a z4tN3h1~39{0{(>F{%63q0T<{1e@1WrdB6ZZ1pFNR{SSeMfV+VXumpSr_%3?=ZvxK& zPXk{9z6d-841kNkt-$Zm|NjL&|4YF4fv=#~|15AF*a!B2M}XUa-=g>b4ln`k2W|s? z4c&ae0UiPF2krwlf%5?APTv!hk2l9ar>1<UQ==e<Z`I;{<c*UJjRODRw5mVpJw38l z#(XbMOpk)$uB(?fS(jBhj<>`5XftED>1dqn8*ze8QQ!Dv$f(ne*}h0jwl(REyo7Nk z8(-c-8^+K*4n%^sgGY&Q%uWrWHY#dr#ypo*X*zc5l$!RjiHP>MWIYr{XMDd}F;y!a z?p98lz6b`%kafFVW?}Qu<iaT?Awi1iG6yBq2oY*x)(gI%w`cb+In|0>r$J{`Qq9%$ zsF6=IJef$*Sa5ZEP>H1y`WUG=Y*g4sndhX}kw%djZnGzzV3A2QO=7v=y46@1Ygi4X zhAWc(uv&qEcu-UFmXPp}JBA^?ud1BCuzBI?g$j&jCPNWaBi7xJ`qOkLuu@bzqiW^6 z{1S{MLob$gVLWi~4T#UYBxb1CP&$3AavJK2WD*7InmY+pFRZ>j+rsQsXadPa7g=I< z)3T<_pxK!7k9cRGO8>5IH*{=@^d*gYPr37KxYyOGX0=!FPiL;@`)C{{Q3UgxqdH38 z=!`i`>vZ+Y7F#h_o98UnG*?><0&6lGL##POFUj$aK1g{a(`*h6IbLs?bv14oc!z&G zt7fxrG1FddnYLY5gLocdoU2b#4o;~z>6#rSDL(^5GiwLWXXF~>lbU-C7eJq(0ci^d zv9Jb?e@d_AR8xAKYci#kd{*i*i`I0`#;QS*WJ(?-#-J@JvM`05fZ6up3uxlMjbZ)~ zT~4YM^nL66%ri%l2vX4+9AOsS<R>y~Y*I!^<EzpS$DVMqAjGbC)f)*oXEpQ2Y8Kl6 zVL!8%!7zi7>WeOAt@yAi%u7#&!*CD_2HJ!()rsL&;V6t{t;kb0VK~cNYxr>Hx<OBi zCo*e+wIXNuQn^|nEZ{rdfG1%DV!d%MMA2{VqJ9xq(H~W*NlG)s9GNPa(Q#xmn#17< z2luebILsvi7-#<qhN?J+%+SfU4D241xsh_GZwhbm_40KQI0v(B<fu&px$@t$i=^ov z^ZK#KO}wy>Y~^aZm|M0Q?DQu1zBI@-x!K(WoymHztE=3qvoHYj{q3%v%bAty*Ud}k z4-P7)^^Mn_|Ju{oaiut-YxbC5mpann*`!ov(=7|+&q%mkUF`;`Xno^8BvSUu7rxr5 z@ddd>0^U$C89hpBnP`U18QW!7f3$~EbX(sb4~UXOCnGy!(=qSj@z!L7er*5X;5Zf3 zIY&>G3d!dB7dhGt2XQ7!n=vn)6C6>!As%DqmfDtex-+8ZJm(FGllyZ<+i@0|n<%}J zQk*LvAS5`uiGem?wsaJ2Xh`!TsRi<S%*0<&cWhKl9HH)cXsu2-IJiJ}l@>|w$Sydo z_~czAbaZqV3c4%u4i4s)nP!LF%5z1dRF66#eM3p0V(?4?*O`yaL!%Re#rUK&r9vf* z&T|-G*RE{VsEW`TWjBMoysKkAa%Ze}7P`|cN0ky{wo}$G3&q9%Z~bz3_J$AUgF<>V edDwJ=m^m8xxbnHfqIV(eNs2-Ddy5ez1L_~$+z=Q5 literal 0 HcmV?d00001 diff --git a/apps/openant-cli/ui/.summary.html.swp b/apps/openant-cli/ui/.summary.html.swp new file mode 100644 index 0000000000000000000000000000000000000000..02187947b9661c291b97e9b9368f978d1eeaf3bb GIT binary patch literal 12288 zcmeHN&2QX96rY0lZlS;l38bkb)DNw{l1;MNFG2c2Te`Gr+KQggtUcZxVtb4|n`{~i zJ@wo}k3Ap++$jG50&(Kbi4&D7RpLe^1Xp-7wm05QKMp;CX04w*W6yi@-tWCP<0#X2 zE}iP(70X5N+mFy&KmNwP|I^uf>zzF)<D-~`^2MWnYnRR5>N&Gl?~$<;a*sSj=CfR( zeU$qw9@u9S8owJ0n<PnQbLfy*m|n>25wo*V6p?hx8j9#4${uZ!eybVK3{)8yiNI{^ zLksh+BOlKh2k`5!U967KZOwpYKr^5j&<tn>Gy|Fe&46a$f6jnN_Mp$f$EONEmy7$J znS0UIH_d=%Kr^5j&<tn>Gy|Fe&46Y=GoTsJ3}^=agA5QKp)a06=;pIvJpBK^{QLj@ zD+t{I{0jI9a0PH0unPG5WrQ{WuL6GFhtSu6>wxos{eY(dKkNmafE$2IfO{_?bQ^F5 zunKq+@a2mLEdu^|0mcH}1N{CxLf-+d0X6{~Fa&%AI0iTf*bBJ-972Bp?g4%Qd<*y# zkO4w~0eA!O7x;A>@D<<|K>GP1K>suYngPv#X5h&fXxl?kA_}{xEj!RCZm4bdxxWR^ z?aWJABD%=u-YBB6um)5d52<XfZk_TE9rSoCpmXr>lgL^uj%j-GLhlUjU^qgyPJJsO zX+{spj?Xva!z~0eWkS7`4`AT-w$&?MpI$rr!PToR)GqBU8aPY6jxiJ>$(pw9`LXqJ z=F^aEq*hFY9Vd~UPgmTmh{+)aFfq|&`)s3&Fm9I(Xl4nCG4ne{$ca1_XLQfx4P2ao zyL;^$PywTqBxt~1NV2SB6nq&<y6gbcl_3#fu_4JHQsUFJi{#`=*O~{@uH~ofD(Z)B z_x+e>f_b<&o(Hn7npqiIOr6oh=-L%If`=&$I)-g@?_R%wPx6SiZ2~iw6SvDWkPlQk zh^>%H#dC$@cpG~><Y^PnH5!c;4j>)Oj9sBk>{u%)jav9B47UnTu^56xpLpwo6wWF7 zAQm*_RY-19HW-Q~UUHmWg0+ekAG9RIm((|LBN=0NJ{h-gpQk`r6T9#v<013$9Cc}( zf_g#6!XzOZ#7!L1K+LKq{g8svP3DUsC~%y&OLZnV7?LCdm&#U4rs05rDmgEnwY3k& zDc3(4aUnJ4HkoqHCSKSv%y;}{pSDUjiUDq2`cYE4w6ye?sgjh!XkSfKVKwIQj%o<S zjJHy1HVg{^ToZG^Gi6C(eF|AV${;S%I=(^EAmp3oxQWR~@W+@~n5Qx$AQ20<nmFQ+ zuL<$M$mwL*Wd#tnxD#5lGz5s8Nw!8~SF(i+yv!qI_PG!|YGUQpR7#G*N@nqJB9bST zlZSZTkrPhIC3rG)qCHQ;GJFdQK<JdrZ~<g0HR7rYnIx0UWJl)ntl0dFm^D8oI1gTd zpsS25LRLEbfLbUjXJWOA>a?Bi?1W2|2`7T?L{duQGM6FGWm(jfIU$RwIV|ehwU%3I z<aE@7pe(ir*6vIaN`64eWKG$Y9l2<#c#O@Wh8uazN^+G+dAd$k4hKu-a<cO^4_kr> zwGxy;45V?DkX4tpQU+F5n7fpy*XvU%NpmPmrZPh5l#~?;NabClzfxa;x=;(2Pd%Oz z!8j~6c}-7Q(pa3Vu!W|Xs&;(Wb(d$VT1BUYeU>F5fnp3o3LL1l(PV-~Q0E?O=`?NO z0ZAYtlPJsW08E`KWfM+<ht$lrG7zodRfzX`k9cdUe*(6H8aCGGfKz<&lu^UyIV2{p z;f%zYnbDNV&{tNjxnNOVJ8Pgz<dqY8q=t_)n|&Jal&U5Pz)Y~H&&P7tQQN^J|G}v; z_^<%(1YPQCXZcRnR^jeeH&%T2+D*J`Kh4LXc0>iiF^Oo$*kE)s;i)hn^6Dd~W2n^Y z_;iDLG|$5tW-$|tgk}au>}kif9J#kEHQrEu<A{!N@xlqSAs@>q${T!);gH0GjzQI@ Sz=30Dk6yTZ?l@MTY3N^nf_Jb0 literal 0 HcmV?d00001 diff --git a/apps/openant-cli/ui/disclosure.html b/apps/openant-cli/ui/disclosure.html new file mode 100644 index 0000000..bfdf50c --- /dev/null +++ b/apps/openant-cli/ui/disclosure.html @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>{{.Name}} — Knostic OpenAnt + + + +
+ ← Home + / + Scan + / + {{.Name}} +
+
+
+ Loading… +
+
+ + + + diff --git a/apps/openant-cli/ui/embed.go b/apps/openant-cli/ui/embed.go new file mode 100644 index 0000000..fc7995d --- /dev/null +++ b/apps/openant-cli/ui/embed.go @@ -0,0 +1,7 @@ +// Package ui provides the embedded web UI templates for the openant serve command. +package ui + +import "embed" + +//go:embed index.html scan.html summary.html disclosure.html +var FS embed.FS diff --git a/apps/openant-cli/ui/index.html b/apps/openant-cli/ui/index.html new file mode 100644 index 0000000..ada9c7d --- /dev/null +++ b/apps/openant-cli/ui/index.html @@ -0,0 +1,207 @@ + + + + + +Knostic OpenAnt — Security Scanner + + + +
+

Knostic OpenAnt

+ LLM-powered security scanner +
+
+
+ +
+

New Scan

+
+ + + + + + + + + + + + {{if .HasAPIKey}} +
Pre-filled from {{.APIKeySource}}
+ {{else}} +
Run openant set-api-key <key> to pre-fill
+ {{end}} + +
+ + +
+
+ + +
+ + +
+
+ + +
+

How It Works

+
    +
  1. Parse — Extract functions and call graphs from the repository
  2. +
  3. Enhance — Add security context to each code unit
  4. +
  5. Analyze — Stage 1 LLM vulnerability detection
  6. +
  7. Verify — Stage 2 attacker simulation to eliminate false positives
  8. +
  9. Report — Generate interactive HTML vulnerability report
  10. +
+
Scans run locally on your machine. Your code never leaves.
+
+
+ + +
+

Recent Scans

+
+ {{if .Jobs}} + {{range .Jobs}} +
+
+
+ {{.Repo}} + +
+
{{.StartedAt}}
+
+ + {{if eq .Status "running"}}Running{{else if eq .Status "done"}}Done{{else}}Error{{end}} + +
+ {{if eq .Status "running"}} + View → + {{else if eq .Status "done"}} + Logs → + {{if .HasSummary}}Summary →{{end}} + Report → + {{else}} + View → + {{end}} + +
+
+ {{end}} + {{else}} +
No scans yet. Submit a repo above to get started.
+ {{end}} +
+
+
+ + + + diff --git a/apps/openant-cli/ui/scan.html b/apps/openant-cli/ui/scan.html new file mode 100644 index 0000000..792b6fc --- /dev/null +++ b/apps/openant-cli/ui/scan.html @@ -0,0 +1,350 @@ + + + + + +Scan — Knostic OpenAnt + + + +
+ ← Home + / + {{.Repo}} +
+
+ +
+
+
Parse
+
+
+
+
Enhance
+
+
+
+
Analyze
+
+
+
+
Verify
+
+
+
+
Report
+
+
+
+ + +
Scan failed. Check the log above for details.
+ + +
+
+ Scanning… +
+ + +
+
+ scan / {{.ID}} +
+
+
+ + + + + + +
+ + + + diff --git a/apps/openant-cli/ui/summary.html b/apps/openant-cli/ui/summary.html new file mode 100644 index 0000000..48a2d73 --- /dev/null +++ b/apps/openant-cli/ui/summary.html @@ -0,0 +1,55 @@ + + + + + +Scan Summary — Knostic OpenAnt + + + +
+ ← Home + / + Scan + / + Summary +
+
+
+ Loading summary… +
+
+ + + + diff --git a/libs/.DS_Store b/libs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..aecbb64cfd008794dbd916399d90c45040835912 GIT binary patch literal 6148 zcmeHK!Ab)$5S_HucBw)S3OxqAR%|PR#miFb54fTSmAY#eU0gS%-MWWT*t7nSU*h*T zlcZv)y?78QGcb9R$;^hlESUrVM0YZ10n`AXf=XC$u=zn~oODSF)c|HuIC-4^cF5JKF$@B24`Fv!NkAXWj!ya&TL8mG{9U4L{qusNGKmEFCAlk@&l{G6y)L#n|4Ps_H&IlQ5&$>X^D5PKUL>!L8DIu}#Q>cT5|z-km>bkb2R3wlr126V z3EK3QAe0tei@8A@K@lbu(WDCd#1JMO{nEy{7IT9p9fY14zhh?>_Jtz!?C6&|9E59- zTV{Y6SY)7Rx;3i*XRF`;i%HyL2AF}hVnCF7UayNwvbA+-aa3z1>K!Tx#pMRSQqa() g7-Ojv*HN{gUy_07TFeci2Zb*Jng(u|fj?#76J1tL&;S4c literal 0 HcmV?d00001 diff --git a/libs/openant-core/core/reporter.py b/libs/openant-core/core/reporter.py index 4f604dd..e9cf9c3 100644 --- a/libs/openant-core/core/reporter.py +++ b/libs/openant-core/core/reporter.py @@ -345,6 +345,7 @@ def generate_disclosure_docs( os.makedirs(output_dir, exist_ok=True) product_name = pipeline_data["repository"]["name"] + repo_url = pipeline_data["repository"].get("url", "") all_usages = [] count = 0 @@ -362,7 +363,7 @@ def generate_disclosure_docs( def _one(args): i, finding = args - disclosure_text, usage = _generate_disclosure(finding, product_name) + disclosure_text, usage = _generate_disclosure(finding, product_name, repo_url=repo_url) safe_name = finding["short_name"].replace(" ", "_").upper() filename = f"DISCLOSURE_{i:02d}_{safe_name}.md" filepath = os.path.join(output_dir, filename) diff --git a/libs/openant-core/core/scanner.py b/libs/openant-core/core/scanner.py index 08e2dfe..abf2ac4 100644 --- a/libs/openant-core/core/scanner.py +++ b/libs/openant-core/core/scanner.py @@ -55,6 +55,7 @@ def scan_repository( dynamic_test: bool = False, workers: int = 8, backoff_seconds: int = 30, + repo_url: str = "", ) -> ScanResult: """Scan a repository for vulnerabilities. @@ -374,6 +375,7 @@ def _step_label(name: str) -> str: results_path=active_results_path, output_path=pipeline_output_path, repo_name=os.path.basename(repo_path), + repo_url=repo_url, language=result.language, application_type=( app_context_path and _read_app_type(app_context_path) diff --git a/libs/openant-core/openant/cli.py b/libs/openant-core/openant/cli.py index 4c7d3a7..51924ae 100644 --- a/libs/openant-core/openant/cli.py +++ b/libs/openant-core/openant/cli.py @@ -70,6 +70,7 @@ def cmd_scan(args): dynamic_test=args.dynamic_test, workers=args.workers, backoff_seconds=args.backoff, + repo_url=getattr(args, "repo_url", "") or "", ) _output_json(success(result.to_dict())) @@ -940,6 +941,8 @@ def main(): help="Number of parallel workers for LLM steps (default: 8)") scan_p.add_argument("--backoff", type=int, default=30, help="Seconds to wait when rate-limited (default: 30)") + scan_p.add_argument("--repo-url", default="", + help="Repository URL (included in reports; auto-detected when scanning a URL)") scan_p.set_defaults(func=cmd_scan) # --------------------------------------------------------------- diff --git a/libs/openant-core/report/generator.py b/libs/openant-core/report/generator.py index 25a55e8..9048f81 100644 --- a/libs/openant-core/report/generator.py +++ b/libs/openant-core/report/generator.py @@ -151,7 +151,7 @@ def generate_summary_report(pipeline_data: dict) -> tuple[str, dict]: return response.content[0].text, _extract_usage(response) -def generate_disclosure(vulnerability_data: dict, product_name: str) -> tuple[str, dict]: +def generate_disclosure(vulnerability_data: dict, product_name: str, repo_url: str = "") -> tuple[str, dict]: """Generate a disclosure document for a single vulnerability. Returns: @@ -162,7 +162,7 @@ def generate_disclosure(vulnerability_data: dict, product_name: str) -> tuple[st system_prompt = load_prompt("system") - vuln_with_product = {**vulnerability_data, "product_name": product_name} + vuln_with_product = {**vulnerability_data, "product_name": product_name, "repo_url": repo_url} user_prompt = load_prompt("disclosure").replace( "{vulnerability_data}", json.dumps(vuln_with_product, indent=2) diff --git a/libs/openant-core/report/prompts/disclosure.txt b/libs/openant-core/report/prompts/disclosure.txt index 5477eb4..6f93c9b 100644 --- a/libs/openant-core/report/prompts/disclosure.txt +++ b/libs/openant-core/report/prompts/disclosure.txt @@ -8,6 +8,7 @@ OUTPUT FORMAT: # Security Disclosure: {short_title} **Product:** {product_name} +**Repository:** {repo_url} **Type:** CWE-{cwe_id} ({cwe_name}) **Affected:** {affected_versions} @@ -68,6 +69,7 @@ INSTRUCTIONS: - Steps to Reproduce: if dynamic_testing data exists, use those steps; otherwise write "[REQUIRES DYNAMIC TESTING]" for each step - Impact: 3-5 bullet points, no sub-bullets - Suggested Fix: show minimal code change, use comments to indicate what was added +- Repository: if repo_url is empty or null, omit the **Repository:** line entirely - Do not include CVSS scores or severity ratings - Do not include disclosure timelines - Do not include references section From 7780564277e2b2a7cf61b2c98fae08dd9ee7fa50 Mon Sep 17 00:00:00 2001 From: Sounil Yu <4305467+sounil@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:41:24 -0400 Subject: [PATCH 2/6] Delete apps/openant-cli/ui/.index.html.swp --- apps/openant-cli/ui/.index.html.swp | Bin 16384 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/openant-cli/ui/.index.html.swp diff --git a/apps/openant-cli/ui/.index.html.swp b/apps/openant-cli/ui/.index.html.swp deleted file mode 100644 index 7b14c111d23f6c5d97eff135dc08efb44f12fcd8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 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 From dbee5c13be86ee5ba158ec6ba1ebfc7bc12071ac Mon Sep 17 00:00:00 2001 From: Sounil Yu <4305467+sounil@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:42:52 -0400 Subject: [PATCH 3/6] Delete apps/openant-cli/ui/.summary.html.swp --- apps/openant-cli/ui/.summary.html.swp | Bin 12288 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/openant-cli/ui/.summary.html.swp diff --git a/apps/openant-cli/ui/.summary.html.swp b/apps/openant-cli/ui/.summary.html.swp deleted file mode 100644 index 02187947b9661c291b97e9b9368f978d1eeaf3bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeHN&2QX96rY0lZlS;l38bkb)DNw{l1;MNFG2c2Te`Gr+KQggtUcZxVtb4|n`{~i zJ@wo}k3Ap++$jG50&(Kbi4&D7RpLe^1Xp-7wm05QKMp;CX04w*W6yi@-tWCP<0#X2 zE}iP(70X5N+mFy&KmNwP|I^uf>zzF)N&Gl?~$<;a*sSj=CfR( zeU$qw9@u9S8owJ0n25wo*V6p?hx8j9#4${uZ!eybVK3{)8yiNI{^ zLksh+BOlKh2k`5!U967KZOwpYKr^5j&Gy|Fe&46a$f6jnN_Mp$f$EONEmy7$J znS0UIH_d=%Kr^5j&Gy|Fe&46Y=GoTsJ3}^=agA5QKp)a06=;pIvJpBK^{QLj@ zD+t{I{0jI9a0PH0unPG5WrQ{WuL6GFhtSu6>wxos{eY(dKkNmafE$2IfO{_?bQ^F5 zunKq+@a2mLEdu^|0mcH}1N{CxLf-+d0X6{~Fa&%AI0iTf*bBJ-972Bp?g4%Qd<*y# zkO4w~0eA!O7x;A>@D<<|K>GP1K>suYngPv#X5h&fXxl?kA_}{xEj!RCZm4bdxxWR^ z?aWJABD%=u-YBB6um)5d52lgL^uj%j-GLhlUjU^qgyPJJsO zX+{spj?Xva!z~0eWkS7`4`AT-w$&?MpI$rr!PToR)GqBU8aPY6jxiJ>$(pw9`LXqJ z=F^aEq*hFY9Vd~UPgmTmh{+)aFfq|&`)s3&Fm9I(Xl4nCG4ne{$ca1_XLQfx4P2ao zyL;^$PywTqBxt~1NV2SB6nq&f_b<&o(Hn7npqiIOr6oh=-L%If`=&$I)-g@?_R%wPx6SiZ2~iw6SvDWkPlQk zh^>%H#dC$@cpG~>)Oj9sBk>{u%)jav9B47UnTu^56xpLpwo6wWF7 zAQm*_RY-19HW-Q~UUHmWg0+ekAG9RIm((|LBN=0NJ{h-gpQk`r6T9#v<013$9Cc}( zf_g#6!XzOZ#7!L1K+LKq{g8svP3DUsC~%y&OLZnV7?LCdm&#U4rs05rDmgEnwY3k& zDc3(4aUnJ4HkoqHCSKSv%y;}{pSDUjiUDq2`cYE4w6ye?sgjh!XkSfKVKwIQj%oa?Bi?1W2|2`7T?L{duQGM6FGWm(jfIU$RwIV|ehwU%3I z zwGxy;45V?DkX4tpQU+F5n7fpy*XvU%NpmPmrZPh5l#~?;NabClzfxa;x=;(2Pd%Oz z!8j~6c}-7Q(pa3Vu!W|Xs&;(Wb(d$VT1BUYeU>F5fnp3o3LL1l(PV-~Q0E?O=`?NO z0ZAYtlPJsW08E`KWfM+iiF^Oo$*kE)s;i)hn^6Dd~W2n^Y z_;iDLG|$5tW-$|tgk}au>}kif9J#kEHQrEuq${T!);gH0GjzQI@ Sz=30Dk6yTZ?l@MTY3N^nf_Jb0 From e0efd31f6755ec9dfe4feaa6a32dbcb13726c53f Mon Sep 17 00:00:00 2001 From: Sounil Yu <4305467+sounil@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:45:36 -0400 Subject: [PATCH 4/6] Delete apps/openant-cli/.DS_Store --- apps/openant-cli/.DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/openant-cli/.DS_Store diff --git a/apps/openant-cli/.DS_Store b/apps/openant-cli/.DS_Store deleted file mode 100644 index 528dbabe0476a325a80276ffce5da64f06fef2ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6u#fI*cm3!0b41!0~;1XfdXZbP~>mhZI%B5+tO|MS$B6vI$=6fcV@Rh zt=1c zKoo%}0#O8_2t*NxB5)@}fX-}QTA41G#=7aNXG>w2^9!aqQVs66$8SY#^a%0Dx~9r3Uh|=@_}$=gf|oftJD1P zK%F5aXw*g#h$3)v1bF&Ltjw&!h&O!w{oV6hTQ5MWshx6PU7b>=)*Z+Ux+57s@0Y!f zd}$x&dR*5oRQvhDUdtTGX$@)D@$;5rmI4FIFiB;9pXC_tNQYZ?3@@A@7Qm+6;LeGvFSb`n z3twt7{96R~1-a^jY&3G%ri8S)c0KvSau8dTqwc`_)EufTHI3*5j60 z>d$*c(=jaPQ16Ik_=9~F%d-l$+2iDgOl1aIl=JmXv*$b*({-V6&7i89Ef=gO%9ejr zOUQDtMqHn-?l;K69j?JwF=XvD%|oj%)aUE_xFlUNsWn1_#rjgU$0K)TEiV+8tF3C! zVS3$)=82J2>T12$&KFEu(G0OpoBFWcR|&nF`H7D`bz5Y``1Lm(1=Ff?N1RTWK_Si8RiQx(v&A10h9Y=X_o7=hL+tM&QGo zrrGs+M(>rib26d9aw--=ZlAK4D+6Z`nn5h5f{SWq+_g*LgiGq?uAo(xti5BB@zgA}y6RN}Ht3(r#%$%1ML52dSE72PS+9I?_=h z!InX37H%2R$y=vc?9t6zwr<;g$EMlE=|N&OG(IpZzHrHkhuSu@PXJILwhGegp&#IV zQkDdG5AbjUR?i0+sLq{NO}T4HD3Y?562&|s^L*9nLhZx?nG!{t60*&UiQT5%C)n7s z!ndZ&d_WG9|}$okPS| z(LP!AuI6flcTN2M-*x-%|95o^BIlwA-0KlQZD*#lgQ9S|J&@nE<5VA`$_v*U7c?-T pX3~cM;wSzvq<)-KxlJmh Date: Tue, 14 Apr 2026 19:45:44 -0400 Subject: [PATCH 5/6] Delete apps/.DS_Store --- apps/.DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/.DS_Store diff --git a/apps/.DS_Store b/apps/.DS_Store deleted file mode 100644 index 4a2a64a400b4f2c3e0c1dc6b0f17bee2b39cb8af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6uxI#=nNC+w6;=kr)*dn+S0AfbpW#o`!w#;DNdOwuF0zh@J+!lF@T$+JWSP*#(g@)DK{s;%?bEBgI}Ql z+a2w~Or1cS)1VI`5JX^p1Zeum#37@^z}o%J`rXWoxuY4&$)Lu?%&29$UNm|Ygp$%F zOUufnGP&$na@ZM5dYYGa+qB#f%Jn&pl_`$3%wf|QODk0g$M!VSHge3s)D2WQI$+wm zGuGzhZQW(INiL8iQA#T{6BDuRP2sKEC!4|(o1?Lorts$2mgwZ9BvwZ@?dVOPv_@>_ z40j0pLxC)xsa{_8zmK4&C2Fc*=V(_q{geO-sHlq(gL z_sG@q5XRB=%tuTkH>kN;!`4mvME{tnd&2_-(={`e(PwKThExGf(v|X>Rjcm{sVY~v zZBSHA+)5T~sdMI=FkCx>h|xC5fDYz3v+{s2k)y z7u^*VjVo@HqjKL#1c0Qtd}fQhRqeO5jA2QN&i84NA5aGhzPHMLKDkS-R#RDLWCUGw zB|g`xCe-6}+XI^Gb&nc0eWgoW_8`iJ++i(ebdMnb{Wt2wS@t2~JT>ajP0wLMjK;yW zsyaxQYt?n;!|Iw<8T`2+DIokjwv`!rpfcL1W}NLN+BeuLane~s23WA4Z?0=kFZxbBn$~@ zVVL_M6cg{*j8AMJj3Zg#Si!{ej}_tE-4ieL(B6HW`wuMI#JjeFrCL?>y(`0YjhpUo z+0{A&Nsi$nZ0}44mI1T!hGhUl$Q<-uVL2(+t|=zr?X=`++K;JYoDyjUD`mcRWSxkq zqb%{+`UYh8D@!>WYThVfnkoWg Date: Tue, 14 Apr 2026 19:45:51 -0400 Subject: [PATCH 6/6] Delete .DS_Store --- .DS_Store | Bin 10244 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c0e67f72a1d6499d299f188addbc121eb88cc24a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMU2GIp6uxJ=&>2SPfUW$v0}~cPfdb1Sp~&C1+baJBwx!$hv(D~}bi#C|?#yn1 zTCGnefJR^ZjWN;q6CdzFQ1r>fm|$XjKqSJ8Bs{6SnD~=uVm$X}OMfu&1p{R6X70J? z-gEAqbLKm9=H4ZQKx^4rO^87Vk@2EbyAP{d6gp0?NktMpbP!OV6b6HVg6kKMKR-!2 z>WDlLc_8vYc|6;2OB0$dd-R|T=f0IrVxhLB4OI>ISeCm=Q-AXWykLjkrr&TlYr z0%=a8I`Tl|fyo}AVG|=J8NgpKcK==&aGfAbCNG0j6Pq%%wpOWCYY%1n{K0H!hUK8u zEFGX+r|-LkYTGRAckIEuR-f^`&~!Yz#1tIMMv;Tvj%WFUt$x|F0;Zeg1BxOmd2PP88&op< zJfbFBwdbm^M^>h(>U6z3*VB98U|!S8fxXXm&CsdXZDwc(d95x$^R^XzZ%@gtpjq8T z-!1ohI5M+ny2UaI+byn}bxv^UV##;iJ$~SXj_>v4Li<=q$LK`khH|}uUkWp9vYdnS zbE#Q$jgA4T9PGDC0a8UdGh_CGMU5*`>o%s_x9!?{sZN_Y>wdLC?M2Udq4T(7mwL>g zXnU699qAf$tgx@U;sj2?wL3kt-&STqlX9tU&fIwq#&w+w+*GKF=E?=Ol()+~r$z6-(C6;XJtdVtt{0fR1EJHrAF zTEKhOsB85u*DTnsqFH>MX7ypcyCSUBcJR(kYJ;9D`u+WAVxaK87CoaMrpxX$gRo=3 z_UJ8L;A4;ASZ~m0mh6r}gq*me?byp6BKA|IcFPHUHi+KXy-?SC=yWZX#cY^2XKtOI z)w^WvqKs&8yo!S`$7jINE+?uuOQe_aX5#bGHP&K|PNb8(yPlKLnkF`#BuOjTP7Y&` z9Ve&AOXN**mYgFWlP}3Pejz92Ub8XoRJZf-R7SR>;6k z$UzVELJ|7FfurC;8BV|u48v3KEIbF#!wc{tyasQ<+wcy&3m?LH_!K^aFW@443*W(I z_yK-`pWz1lCe=!^q)4-*xTH(-r6p3Mv`ktqZI-r3Tcy2HuauYixD8S@)DDf>#2V6Z zB%`km3^j4|C!M)F>e&@Bw@TXW<-tA~63oBK|5|pMc_B<53L1jzw`LTk>4r zJA!=_^)ccShZOFi#3<~E5@VDYS;CAG<84cfH$yE-jO-IKN{o{}|L*+6C{R2hF^$>oL4&p>iXf zG8L@G{Skl+Jy&eRi^__V5!OzNz7bBDI?6D0r2o^O0k=A8i}wG~{(r(}&b_?<{|EQ{ BeUJbE