From 08414f0a72a7ec4befa74349026f192aa2dd847a Mon Sep 17 00:00:00 2001 From: Sounil Yu <4305467+sounil@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:10:05 -0400 Subject: [PATCH] feat(webui): add web user interface --- 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/WEBUI_SPEC.md | 389 ++++++ 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/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 + 16 files changed, 2361 insertions(+), 4 deletions(-) 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/WEBUI_SPEC.md 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 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 ` 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 ` 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 @@

{{.Title}}

- {{if .RepoName}}{{.RepoName}}{{end}} + {{if .RepoName}}{{if .RepoURL}}{{.RepoName}}{{else}}{{.RepoName}}{{end}}{{end}} {{if .ShortCommit}}{{.ShortCommit}}{{end}} {{if .Language}}{{.Language}}{{end}}
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/WEBUI_SPEC.md b/apps/openant-cli/ui/WEBUI_SPEC.md new file mode 100644 index 0000000..75d70ab --- /dev/null +++ b/apps/openant-cli/ui/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/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/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