From f5f3f89e5d2ff830b4d333c5c62bb035c8f5835f Mon Sep 17 00:00:00 2001 From: j3ssie Date: Mon, 5 Oct 2026 01:27:15 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v5.1.2=20=E2=80=94=20vulns=20command?= =?UTF-8?q?,=20workflow=20dependency=20health=20check,=20ACP=20stderr=20fi?= =?UTF-8?q?xes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'osmedeus vulns' to query findings with severity/template filters and --stats - health: cross-check workflow dependencies.commands against installed binaries and registry - linter: flag reports missing the required 'path' field - db: exclude heavy vulnerability columns by default - ACP: drain stderr past the scanner limit and read it only after the drain finishes - Read long target lines from stdin and API target files - Docker: install native Chromium from the xtradeb PPA - Registry: add jq, simplify assetfinder install - Add CHANGELOG.md --- CHANGELOG.md | 21 + build/docker/Dockerfile.canary | 11 +- build/docker/Dockerfile.toolbox | 11 +- build/docker/Dockerfile.toolbox-nix | 11 +- internal/core/constants.go | 2 +- internal/database/seed.go | 8 + internal/database/stats.go | 104 +++++ internal/executor/acp_executor.go | 43 +- internal/executor/acp_executor_test.go | 47 +++ internal/linter/rules.go | 17 + internal/linter/rules_test.go | 22 + pkg/cli/db.go | 5 +- pkg/cli/health.go | 128 +++++- pkg/cli/root.go | 1 + pkg/cli/run.go | 3 + pkg/cli/usage.go | 45 +++ pkg/cli/vulns.go | 382 ++++++++++++++++++ pkg/cli/vulns_test.go | 130 ++++++ pkg/server/handlers/common.go | 25 +- .../registry-metadata-direct-fetch.json | 31 +- .../registry-metadata-direct-fetch.json | 31 +- 21 files changed, 1019 insertions(+), 59 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 pkg/cli/vulns.go create mode 100644 pkg/cli/vulns_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e9bf10 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## v5.1.2 + +### Added +- `osmedeus vulns` command to query findings with severity, confidence, template and workspace filters, plus `--stats` and `--id` detail view. +- `osmedeus health` now checks that every workflow-declared dependency command is installed or available in the registry. +- Linter flags reports that are missing the required `path` field. +- `jq` added to the binary registry. + +### Changed +- `osmedeus db` skips heavy vulnerability columns (description, PoC, HTTP request/response) by default. +- `assetfinder` registry entry now uses a single cross-platform `go install`. +- `subfinder` bumped from 2.14.0 to 2.16.0. +- Docker images install native Chromium from the xtradeb PPA instead of the snap-only stub. + +### Fixed +- ACP agent no longer hangs when stderr output exceeds the 64KiB scanner limit. +- ACP agent errors now return the full stderr instead of racing its reader. +- Reading targets from stdin or an API target file no longer fails on long lines. +- Line counting now handles long lines in buffered files. diff --git a/build/docker/Dockerfile.canary b/build/docker/Dockerfile.canary index 7088b1b..bba8264 100644 --- a/build/docker/Dockerfile.canary +++ b/build/docker/Dockerfile.canary @@ -30,7 +30,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ python3-venv \ - chromium-browser \ sudo \ golang-go \ && update-ca-certificates \ @@ -38,6 +37,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN ln -sf /usr/bin/python3 /usr/bin/python +# Ubuntu 24.04's chromium-browser package is only a stub that installs the snap, +# which cannot run in a container. Install a native build from the xtradeb PPA so +# headless screenshots and crawling (pd-httpx -system-chrome, katana, vigolium) work. +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common \ + gpg-agent \ + && add-apt-repository -y ppa:xtradeb/apps \ + && apt-get update && apt-get install -y --no-install-recommends chromium \ + && rm -rf /var/lib/apt/lists/* + ENV PATH="/root/go/bin:${PATH}" ENV GOPATH="/root/go" diff --git a/build/docker/Dockerfile.toolbox b/build/docker/Dockerfile.toolbox index 80d4ef0..f58b7fe 100644 --- a/build/docker/Dockerfile.toolbox +++ b/build/docker/Dockerfile.toolbox @@ -20,7 +20,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ python3-venv \ - chromium-browser \ sudo \ golang-go \ && update-ca-certificates \ @@ -29,6 +28,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Create symlink for python RUN ln -sf /usr/bin/python3 /usr/bin/python +# Ubuntu 24.04's chromium-browser package is only a stub that installs the snap, +# which cannot run in a container. Install a native build from the xtradeb PPA so +# headless screenshots and crawling (pd-httpx -system-chrome, katana, vigolium) work. +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common \ + gpg-agent \ + && add-apt-repository -y ppa:xtradeb/apps \ + && apt-get update && apt-get install -y --no-install-recommends chromium \ + && rm -rf /var/lib/apt/lists/* + # Set up Go environment ENV PATH="/root/go/bin:${PATH}" ENV GOPATH="/root/go" diff --git a/build/docker/Dockerfile.toolbox-nix b/build/docker/Dockerfile.toolbox-nix index 2d86809..1db0011 100644 --- a/build/docker/Dockerfile.toolbox-nix +++ b/build/docker/Dockerfile.toolbox-nix @@ -20,13 +20,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ python3-venv \ - chromium-browser \ sudo \ && rm -rf /var/lib/apt/lists/* # Create symlink for python RUN ln -sf /usr/bin/python3 /usr/bin/python +# Ubuntu 24.04's chromium-browser package is only a stub that installs the snap, +# which cannot run in a container. Install a native build from the xtradeb PPA so +# headless screenshots and crawling (pd-httpx -system-chrome, katana, vigolium) work. +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common \ + gpg-agent \ + && add-apt-repository -y ppa:xtradeb/apps \ + && apt-get update && apt-get install -y --no-install-recommends chromium \ + && rm -rf /var/lib/apt/lists/* + # Install Nix package manager (single-user mode for Docker) RUN mkdir -m 0755 /nix && \ curl -L https://nixos.org/nix/install | sh -s -- --no-daemon diff --git a/internal/core/constants.go b/internal/core/constants.go index 819ed24..39a7337 100644 --- a/internal/core/constants.go +++ b/internal/core/constants.go @@ -3,7 +3,7 @@ package core // Project metadata constants const ( // VERSION of this project - VERSION = "v5.1.1" + VERSION = "v5.1.2" // DESC description of the tool DESC = "A Modern Orchestration Engine for Security" // BINARY name of osmedeus diff --git a/internal/database/seed.go b/internal/database/seed.go index 1f4eb3d..d699e6e 100644 --- a/internal/database/seed.go +++ b/internal/database/seed.go @@ -2972,6 +2972,11 @@ func GetTableColumns(tableName string) []string { // These fields can be multi-MB per row (HTTP responses, screenshots, file contents). var AssetHeavyColumns = []string{"raw_json_data", "raw_response", "screenshot_base64_data", "blob_content"} +// VulnHeavyColumns are columns excluded from vulnerability queries by default. +// A single finding carries its full description, PoC, and both HTTP messages, so +// selecting them for a listing is megabytes of evidence nobody asked for. +var VulnHeavyColumns = []string{"vuln_desc", "vuln_poc", "detail_http_request", "detail_http_response", "raw_vuln_json"} + // GetTableRecords returns paginated records from a specific table with optional filters and search. // excludeColumns specifies columns to omit from the SELECT (useful for skipping large blob fields). func GetTableRecords(ctx context.Context, tableName string, offset, limit int, filters map[string]string, fuzzyFilters map[string]string, search string, excludeColumns []string) (*TableRecords, error) { @@ -3133,6 +3138,9 @@ func GetTableRecords(ctx context.Context, tableName string, offset, limit int, f } result.TotalCount = count fetchQuery := applyFilters(db.NewSelect().Model(&records)) + if len(excludeColumns) > 0 { + fetchQuery = fetchQuery.ExcludeColumn(excludeColumns...) + } err = fetchQuery.Order("created_at DESC").Offset(offset).Limit(limit).Scan(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch records: %w", err) diff --git a/internal/database/stats.go b/internal/database/stats.go index 5be66bd..dd589b6 100644 --- a/internal/database/stats.go +++ b/internal/database/stats.go @@ -278,3 +278,107 @@ func mapKeysToSortedSlice(m map[string]bool) []string { sort.Strings(keys) return keys } + +// VulnStatsData holds the distinct values and severity breakdown for a vulnerability listing. +type VulnStatsData struct { + Severities map[string]int `json:"severities"` + Total int `json:"total"` + Confidences []string `json:"confidences"` + AssetTypes []string `json:"asset_types"` + Workspaces []string `json:"workspaces"` + Templates []string `json:"templates"` +} + +// GetVulnStats retrieves the severity breakdown plus distinct confidences, asset types, +// workspaces and detection templates, with optional workspace and org filtering. +// An empty org means no filter, matching how every other org-scoped read behaves. +// +// Everything here is aggregated in SQL: the counts and the distinct lists are both +// bounded by cardinality (a handful of severities, tens of templates), so a database +// with a million findings costs the same as one with a thousand. +func GetVulnStats(ctx context.Context, workspace, orgUUID string) (*VulnStatsData, error) { + db := GetDB() + if db == nil { + return nil, fmt.Errorf("database not connected") + } + + scope := func(q *bun.SelectQuery) *bun.SelectQuery { + q = q.Model((*Vulnerability)(nil)) + if workspace != "" { + q = q.Where("workspace = ?", workspace) + } + if orgUUID != "" { + q = q.Where("org_uuid = ?", orgUUID) + } + return q + } + + // Severity counts, normalised so "High" and "high" are one band. + var sevRows []struct { + Severity string `bun:"severity"` + Count int `bun:"count"` + } + err := scope(db.NewSelect()). + ColumnExpr("LOWER(TRIM(severity)) AS severity"). + ColumnExpr("COUNT(*) AS count"). + GroupExpr("LOWER(TRIM(severity))"). + Scan(ctx, &sevRows) + if err != nil { + return nil, fmt.Errorf("failed to count vulnerabilities by severity: %w", err) + } + + severities := make(map[string]int, len(sevRows)) + total := 0 + for _, r := range sevRows { + sev := r.Severity + if sev == "" { + sev = "unknown" + } + severities[sev] += r.Count + total += r.Count + } + + // Distinct values, one bounded query per column. + distinct := func(column string) ([]string, error) { + var values []string + err := scope(db.NewSelect()). + ColumnExpr("DISTINCT ?", bun.Ident(column)). + Where("? != ''", bun.Ident(column)). + Scan(ctx, &values) + if err != nil { + return nil, fmt.Errorf("failed to list distinct %s: %w", column, err) + } + sort.Strings(values) + return values, nil + } + + confidences, err := distinct("confidence") + if err != nil { + return nil, err + } + assetTypes, err := distinct("asset_type") + if err != nil { + return nil, err + } + templates, err := distinct("vuln_info") + if err != nil { + return nil, err + } + + // Only meaningful when not already scoped to a single workspace. + var workspaces []string + if workspace == "" { + if workspaces, err = distinct("workspace"); err != nil { + return nil, err + } + } + + return &VulnStatsData{ + Severities: severities, + Total: total, + Confidences: confidences, + AssetTypes: assetTypes, + Workspaces: workspaces, + Templates: templates, + }, nil +} diff --git a/internal/executor/acp_executor.go b/internal/executor/acp_executor.go index afbefd0..7e4cc78 100644 --- a/internal/executor/acp_executor.go +++ b/internal/executor/acp_executor.go @@ -182,8 +182,9 @@ type RunAgentACPConfig struct { // RunAgentACP spawns an ACP agent subprocess and returns its output. // agentName can be a built-in name ("claude-code", "codex", etc.) or empty to use the default ("claude-code"). -// Returns (stdout, stderr, error). -func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACPConfig) (string, string, error) { +// Returns (stdout, stderr, error). Once the agent has started, stderr is filled in +// by the deferred cleanup after the drain finishes, so every return carries it. +func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACPConfig) (output, stderr string, err error) { log := oslogger.Get() if prompt == "" { @@ -223,6 +224,8 @@ func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACP // Create command with process group for cleanup cmd := exec.CommandContext(ctx, cmdPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Bounds cmd.Wait if a grandchild outside the process group keeps stderr open. + cmd.WaitDelay = 5 * time.Second // Apply environment variables if len(cfg.Env) > 0 { @@ -258,22 +261,37 @@ func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACP go func() { defer stderrWg.Done() scanner := bufio.NewScanner(stderrReader) + // Agents emit JSON diagnostics and stack traces, which overflow the + // scanner's default 64KiB token limit. + scanner.Buffer(make([]byte, 64*1024), 10*1024*1024) for scanner.Scan() { line := scanner.Text() stderrBuf.WriteString(line) stderrBuf.WriteByte('\n') log.Debug("acp agent stderr", zap.String("line", line)) } + // stderrReader is a synchronous io.Pipe: if this goroutine stops reading + // while the agent is still writing, the agent blocks and cmd.Wait never + // returns. Keep draining past a scanner error so that cannot happen. + if err := scanner.Err(); err != nil { + log.Debug("acp agent stderr scan stopped", zap.Error(err)) + _, _ = io.Copy(io.Discard, stderrReader) + } }() defer func() { _ = stdinPipe.Close() - _ = stderrWriter.Close() - stderrWg.Wait() if cmd.Process != nil { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + // Wait returns once exec has copied all of the agent's stderr into + // stderrWriter, so closing it afterwards drops nothing in flight. _ = cmd.Wait() + _ = stderrWriter.Close() + stderrWg.Wait() + // Read only after the drain has finished: the goroutine writes stderrBuf, + // so reading it earlier is a data race and truncates the captured stderr. + stderr = stderrBuf.String() }() // Build client options @@ -306,11 +324,10 @@ func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACP }, }) if initErr != nil { - stderrStr := stderrBuf.String() if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return "", stderrStr, fmt.Errorf("ACP initialize timed out: %w", ctx.Err()) + return "", "", fmt.Errorf("ACP initialize timed out: %w", ctx.Err()) } - return "", stderrStr, fmt.Errorf("ACP initialize failed: %w", initErr) + return "", "", fmt.Errorf("ACP initialize failed: %w", initErr) } log.Debug("ACP initialized successfully") @@ -332,11 +349,10 @@ func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACP McpServers: []acp.McpServer{}, }) if sessErr != nil { - stderrStr := stderrBuf.String() if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return "", stderrStr, fmt.Errorf("ACP session creation timed out: %w", ctx.Err()) + return "", "", fmt.Errorf("ACP session creation timed out: %w", ctx.Err()) } - return "", stderrStr, fmt.Errorf("ACP new session failed: %w", sessErr) + return "", "", fmt.Errorf("ACP new session failed: %w", sessErr) } log.Debug("ACP session created", @@ -352,13 +368,12 @@ func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACP }) agentOutput := client.collectedOutput() - stderrStr := stderrBuf.String() if promptErr != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return agentOutput, stderrStr, fmt.Errorf("ACP prompt timed out: %w", ctx.Err()) + return agentOutput, "", fmt.Errorf("ACP prompt timed out: %w", ctx.Err()) } - return agentOutput, stderrStr, fmt.Errorf("ACP prompt failed: %w", promptErr) + return agentOutput, "", fmt.Errorf("ACP prompt failed: %w", promptErr) } stopReason := "unknown" @@ -370,7 +385,7 @@ func RunAgentACP(ctx context.Context, prompt, agentName string, cfg *RunAgentACP zap.String("stopReason", stopReason), zap.Int("collectedOutputBytes", len(agentOutput))) - return agentOutput, stderrStr, nil + return agentOutput, "", nil } // ListAgentNames returns the names of all built-in ACP agents. diff --git a/internal/executor/acp_executor_test.go b/internal/executor/acp_executor_test.go index b57203e..136131f 100644 --- a/internal/executor/acp_executor_test.go +++ b/internal/executor/acp_executor_test.go @@ -2,6 +2,10 @@ package executor import ( "context" + "os" + "path/filepath" + "runtime" + "strings" "testing" "time" @@ -370,6 +374,49 @@ func TestRunAgentACP_NilConfig(t *testing.T) { } } +// TestRunAgentACP_LongStderrLine guards the stderr drain against a single line +// longer than bufio.Scanner's default 64KiB token limit. stderrReader is a +// synchronous io.Pipe, so a drain that stops early blocks the agent's writes and +// cmd.Wait never returns — the call hangs until the context deadline instead of +// failing fast. Shadows the "opencode" built-in, whose command resolves through +// PATH, with a script that emits one 200KB stderr line. +func TestRunAgentACP_LongStderrLine(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script stub is not portable to windows") + } + + binDir := t.TempDir() + stub := filepath.Join(binDir, "opencode") + script := "#!/bin/sh\nhead -c 200000 /dev/zero | tr '\\0' 'x' >&2\necho >&2\nexit 1\n" + require.NoError(t, os.WriteFile(stub, []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + // Generous enough that the context is not what rescues a stalled drain. + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + type outcome struct { + stderr string + err error + } + done := make(chan outcome, 1) + go func() { + _, stderr, err := RunAgentACP(ctx, "hello", "opencode", nil) + done <- outcome{stderr: stderr, err: err} + }() + + select { + case got := <-done: + // The handshake fails once the stub exits; what matters is that the call + // returned and the oversized line was drained rather than dropped. + assert.Error(t, got.err) + assert.NotErrorIs(t, got.err, context.DeadlineExceeded) + assert.Contains(t, got.stderr, strings.Repeat("x", 100*1024)) + case <-time.After(20 * time.Second): + t.Fatal("RunAgentACP did not return: stderr drain stalled on a long line") + } +} + func TestListAgentNames(t *testing.T) { names := ListAgentNames() assert.NotEmpty(t, names) diff --git a/internal/linter/rules.go b/internal/linter/rules.go index dd2006f..cc6fa69 100644 --- a/internal/linter/rules.go +++ b/internal/linter/rules.go @@ -650,6 +650,23 @@ func (r *MissingRequiredFieldRule) Check(wast *WorkflowAST) []LintIssue { } } + // Reports without a path are silently dropped from the run's artifacts. + // A common slip is copying a param entry and keeping 'default:'. + for i, report := range w.Reports { + if report.Path == "" { + line, col := wast.GetNodePosition(fmt.Sprintf("reports[%d]", i)) + issues = append(issues, LintIssue{ + Rule: r.Name(), + Severity: r.Severity(), + Message: fmt.Sprintf("Report '%s' is missing required 'path' field", report.Name), + Suggestion: "Add 'path: {{Output}}/...' to the report (reports take 'path', not 'default')", + Line: line, + Column: col, + Field: fmt.Sprintf("reports[%d].path", i), + }) + } + } + return issues } diff --git a/internal/linter/rules_test.go b/internal/linter/rules_test.go index 62a60fa..39df999 100644 --- a/internal/linter/rules_test.go +++ b/internal/linter/rules_test.go @@ -377,6 +377,28 @@ steps: } assert.True(t, hasTypeIssue, "should detect missing step type") }) + + t.Run("detects report without path", func(t *testing.T) { + ast := parseTestWorkflow(t, ` +name: test +kind: module +reports: + - name: good + path: "{{Output}}/good.txt" + - name: bad + default: "{{Output}}/bad.md" + type: markdown +steps: + - name: step1 + type: bash + command: echo "hello" +`) + issues := rule.Check(ast) + assert.Len(t, issues, 1) + assert.Equal(t, "reports[1].path", issues[0].Field) + assert.Contains(t, issues[0].Message, "'bad'") + assert.NotZero(t, issues[0].Line) + }) } func TestGetDefaultRules(t *testing.T) { diff --git a/pkg/cli/db.go b/pkg/cli/db.go index 5437fc3..6207875 100644 --- a/pkg/cli/db.go +++ b/pkg/cli/db.go @@ -710,8 +710,11 @@ func getDefaultExcludeColumns(tableName string) []string { if dbIncludeHeavy { return nil } - if tableName == "assets" { + switch tableName { + case "assets": return database.AssetHeavyColumns + case "vulnerabilities": + return database.VulnHeavyColumns } return nil } diff --git a/pkg/cli/health.go b/pkg/cli/health.go index 8aa8c0d..2c7e094 100644 --- a/pkg/cli/health.go +++ b/pkg/cli/health.go @@ -11,8 +11,10 @@ import ( "github.com/j3ssie/osmedeus/v5/internal/config" "github.com/j3ssie/osmedeus/v5/internal/core" + "github.com/j3ssie/osmedeus/v5/internal/installer" "github.com/j3ssie/osmedeus/v5/internal/parser" "github.com/j3ssie/osmedeus/v5/internal/terminal" + "github.com/j3ssie/osmedeus/v5/internal/utils" "github.com/j3ssie/osmedeus/v5/public" "github.com/spf13/cobra" ) @@ -58,7 +60,11 @@ func runHealth(cmd *cobra.Command, args []string) error { hasErrors = checkConfigFiles(printer, cfg) || hasErrors // 3. Check workflows - hasErrors = checkWorkflows(printer, cfg) || hasErrors + workflows, workflowErrors := checkWorkflows(printer, cfg) + hasErrors = workflowErrors || hasErrors + + // 4. Cross-check what the workflows declare against what is installed + hasErrors = checkWorkflowDependencies(printer, cfg, workflows) || hasErrors // Summary fmt.Println() @@ -204,34 +210,37 @@ func checkConfigFiles(printer *terminal.Printer, cfg *config.Config) bool { return hasErrors } -func checkWorkflows(printer *terminal.Printer, cfg *config.Config) bool { +// checkWorkflows validates every workflow and returns the ones that parsed, so a +// later pass can inspect them without re-reading and re-unmarshalling each file. +func checkWorkflows(printer *terminal.Printer, cfg *config.Config) ([]*core.Workflow, bool) { printer.Section("Workflows") if cfg.WorkflowsPath == "" { printer.Error(" Workflows path not configured") - return true + return nil, true } // Check if workflows directory exists if _, err := os.Stat(cfg.WorkflowsPath); os.IsNotExist(err) { printer.Warning(" No workflows found in %s", terminal.White(cfg.WorkflowsPath)) - return false + return nil, false } workflowFiles, err := findWorkflowYAMLFiles(cfg.WorkflowsPath) if err != nil { printer.Error(" Failed to scan workflows folder: %v", err) - return true + return nil, true } if len(workflowFiles) == 0 { printer.Error(" Workflows folder is empty: %s", terminal.White(cfg.WorkflowsPath)) - return true + return nil, true } p := parser.NewParser() validCount := 0 invalidCount := 0 + parsed := make([]*core.Workflow, 0, len(workflowFiles)) for _, filePath := range workflowFiles { relPath, _ := filepath.Rel(cfg.WorkflowsPath, filePath) @@ -243,6 +252,7 @@ func checkWorkflows(printer *terminal.Printer, cfg *config.Config) bool { invalidCount++ continue } + parsed = append(parsed, wf) if err := p.Validate(wf); err != nil { printer.Error(" [INVALID] %s (%s): %v", terminal.White(relPath), terminal.White(wf.Name), err) @@ -257,7 +267,111 @@ func checkWorkflows(printer *terminal.Printer, cfg *config.Config) bool { fmt.Println() printer.Info(" Total: %s valid, %s invalid", terminal.Green(fmt.Sprintf("%d", validCount)), terminal.Red(fmt.Sprintf("%d", invalidCount))) - return invalidCount > 0 + return parsed, invalidCount > 0 +} + +// checkWorkflowDependencies cross-checks every dependencies.commands entry declared +// by a workflow against what is actually installed, and against the binary registry. +// +// A missing declared command is a hard failure at run time ("required command not +// found: X"), so finding it here is the difference between a clear install-time +// message and a scan that dies mid-flow. Resolution deliberately mirrors the run-time +// gate in the executor (utils.LookPathWithBinaries): the binaries folder first, then +// PATH, so a tool installed where osmedeus puts it is never reported missing. +// +// Splitting the report by whether the registry knows the name matters: a registry +// entry just needs installing, while a name the registry has never heard of is +// usually a typo in the workflow. +func checkWorkflowDependencies(printer *terminal.Printer, cfg *config.Config, workflows []*core.Workflow) bool { + printer.Section("Workflow Dependencies") + + if len(workflows) == 0 { + printer.Info(" No workflows to check") + return false + } + + // command -> set of workflows that require it + required := make(map[string]map[string]bool) + for _, wf := range workflows { + if wf.Dependencies == nil { + continue + } + for _, cmdName := range wf.Dependencies.Commands { + cmdName = strings.TrimSpace(cmdName) + if cmdName == "" { + continue + } + if required[cmdName] == nil { + required[cmdName] = make(map[string]bool) + } + required[cmdName][wf.Name] = true + } + } + + if len(required) == 0 { + printer.Info(" No workflow declares a command dependency") + return false + } + + // The registry is best-effort: without it we can still report what is missing, + // just not whether it is installable. + registry, regErr := installer.LoadRegistry("", nil) + if regErr != nil { + printer.Warning(" Could not load the binary registry (%v); reporting availability only", regErr) + } + + names := make([]string, 0, len(required)) + for name := range required { + names = append(names, name) + } + sort.Strings(names) + + var missingInstallable, missingUnknown []string + for _, name := range names { + // Same resolution order the executor uses, so this predicts the run-time gate. + if _, err := utils.LookPathWithBinaries(name, cfg.BinariesPath); err == nil { + continue + } + // Indexing a nil map is safe, so no registry means every name is unknown. + if _, known := registry[name]; known { + missingInstallable = append(missingInstallable, name) + } else { + missingUnknown = append(missingUnknown, name) + } + } + + available := len(names) - len(missingInstallable) - len(missingUnknown) + printer.Success(" %d of %d declared commands available", available, len(names)) + + requiredBy := func(name string) string { + owners := make([]string, 0, len(required[name])) + for owner := range required[name] { + owners = append(owners, owner) + } + sort.Strings(owners) + return terminal.Gray(strings.Join(owners, ", ")) + } + + if len(missingInstallable) > 0 { + fmt.Println() + printer.Warning(" Missing, but in the registry:") + for _, name := range missingInstallable { + printer.Warning(" %s - required by %s", terminal.White(name), requiredBy(name)) + } + printer.Info(" Install them: %s", terminal.Yellow("osmedeus install binary --name "+strings.Join(missingInstallable, " --name "))) + } + + if len(missingUnknown) > 0 { + fmt.Println() + printer.Error(" Missing and NOT in the registry (likely a typo, or a tool you must install yourself):") + for _, name := range missingUnknown { + printer.Error(" %s - required by %s", terminal.White(name), requiredBy(name)) + } + } + + // Only an unknown name is an error: it cannot be resolved by installing, and a + // workflow declaring it fails its dependency check every time it runs. + return len(missingUnknown) > 0 } func findWorkflowYAMLFiles(root string) ([]string, error) { diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 001a65d..3e01b2d 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -337,6 +337,7 @@ func init() { rootCmd.AddCommand(clientCmd) rootCmd.AddCommand(uninstallCmd) rootCmd.AddCommand(assetsCmd) + rootCmd.AddCommand(vulnsCmd) rootCmd.AddCommand(orgCmd) rootCmd.AddCommand(agentCmd) rootCmd.AddCommand(queryCmd) diff --git a/pkg/cli/run.go b/pkg/cli/run.go index e28598e..ae0253e 100644 --- a/pkg/cli/run.go +++ b/pkg/cli/run.go @@ -1313,6 +1313,9 @@ func readTargetsFromStdin() ([]string, error) { var result []string scanner := bufio.NewScanner(os.Stdin) + // A piped target can be a long line (URL with a large query string), which + // overflows the scanner's default 64KiB token limit. + scanner.Buffer(make([]byte, 64*1024), 10*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" && !strings.HasPrefix(line, "#") { diff --git a/pkg/cli/usage.go b/pkg/cli/usage.go index ab845bd..d92d1f9 100644 --- a/pkg/cli/usage.go +++ b/pkg/cli/usage.go @@ -1689,3 +1689,48 @@ func UsageRunCancel() string { func docsFooter() string { return terminal.HiCyan("📖 Documentation: ") + terminal.HiWhite(core.DOCS) + "\n" } + +func UsageVulns() string { + return terminal.BoldCyan("◆ Description") + ` + Query and list discovered vulnerabilities from the database. + The finding counterpart to ` + terminal.Yellow("osmedeus assets") + `, with severity filtering, + a severity breakdown, and full per-finding evidence. + + Listings omit the heavy evidence columns (description, PoC, both HTTP messages). + Use ` + terminal.Yellow("--id") + ` to print one finding in full. + +` + terminal.BoldCyan("▷ Examples") + ` + ` + terminal.Green("# List all findings, most severe first") + ` + osmedeus vulns + + ` + terminal.Green("# Fuzzy search across finding fields") + ` + osmedeus vulns example.com + + ` + terminal.Green("# Filter by workspace") + ` + osmedeus vulns ` + terminal.Yellow("-w") + ` myworkspace + + ` + terminal.Green("# Only high and critical") + ` + osmedeus vulns ` + terminal.Yellow("--severity") + ` critical,high + + ` + terminal.Green("# Everything at medium or above") + ` + osmedeus vulns ` + terminal.Yellow("--min-severity") + ` medium + + ` + terminal.Green("# Filter by detection template or confidence") + ` + osmedeus vulns ` + terminal.Yellow("--template") + ` git-config + osmedeus vulns ` + terminal.Yellow("--confidence") + ` certain + + ` + terminal.Green("# Severity breakdown and distinct templates") + ` + osmedeus vulns ` + terminal.Yellow("--stats") + ` + osmedeus vulns ` + terminal.Yellow("--stats") + ` -w myworkspace + + ` + terminal.Green("# One finding in full (description, PoC, request/response)") + ` + osmedeus vulns ` + terminal.Yellow("--id") + ` 42 + + ` + terminal.Green("# Across every workspace in an org") + ` + osmedeus vulns ` + terminal.Yellow("--org") + ` acme + + ` + terminal.Green("# Custom columns and JSON output") + ` + osmedeus vulns ` + terminal.Yellow("--columns") + ` vuln_title,severity,asset_value + osmedeus vulns ` + terminal.Yellow("--json") + ` +` +} diff --git a/pkg/cli/vulns.go b/pkg/cli/vulns.go new file mode 100644 index 0000000..a4f5d59 --- /dev/null +++ b/pkg/cli/vulns.go @@ -0,0 +1,382 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/j3ssie/osmedeus/v5/internal/database" + "github.com/j3ssie/osmedeus/v5/internal/terminal" + "github.com/spf13/cobra" +) + +var ( + vulnsWorkspace string + vulnsSeverity string + vulnsMinSeverity string + vulnsConfidence string + vulnsTemplate string + vulnsType string + vulnsStats bool + vulnsLimit int + vulnsOffset int + vulnsColumns string + vulnsExcludeColumns string + vulnsAll bool + vulnsWhere []string + vulnsSearch string + vulnsValue string + vulnsDetail int64 +) + +// severityOrder is the single source of severity ordering, most severe first. It is +// the --severity whitelist, the --min-severity expansion and the --stats display +// order, so a band cannot be added to one and forgotten in the others. +var severityOrder = []string{"critical", "high", "medium", "low", "info"} + +// severityRank returns a sortable rank, highest for the most severe. An +// unrecognised or empty severity ranks lowest so it never outranks a real finding. +func severityRank(severity string) int { + for i, s := range severityOrder { + if s == severity { + return len(severityOrder) - i + } + } + return 0 +} + +var vulnsCmd = &cobra.Command{ + Use: "vulns [search]", + Aliases: []string{"vuln", "vulnerabilities", "findings"}, + Short: "Query and list discovered vulnerabilities", + Long: UsageVulns(), + RunE: runVulns, +} + +func init() { + vulnsCmd.Flags().StringVarP(&vulnsWorkspace, "workspace", "w", "", "filter by workspace name") + vulnsCmd.Flags().StringVar(&vulnsSeverity, "severity", "", "filter by exact severity (comma-separated, e.g., critical,high)") + vulnsCmd.Flags().StringVar(&vulnsMinSeverity, "min-severity", "", "only show findings at or above this severity (info|low|medium|high|critical)") + vulnsCmd.Flags().StringVar(&vulnsConfidence, "confidence", "", "filter by confidence (fuzzy match, e.g., certain)") + vulnsCmd.Flags().StringVar(&vulnsTemplate, "template", "", "filter by detection template/module id (fuzzy match on vuln_info)") + vulnsCmd.Flags().StringVar(&vulnsType, "type", "", "filter by asset_type (fuzzy match, e.g., http)") + vulnsCmd.Flags().BoolVar(&vulnsStats, "stats", false, "show vulnerability statistics (severity breakdown, templates, confidences)") + vulnsCmd.Flags().IntVar(&vulnsLimit, "limit", 50, "maximum number of records to return") + vulnsCmd.Flags().IntVar(&vulnsOffset, "offset", 0, "number of records to skip (for pagination)") + vulnsCmd.Flags().StringVar(&vulnsColumns, "columns", "", "comma-separated columns to display") + vulnsCmd.Flags().StringVar(&vulnsExcludeColumns, "exclude-columns", "", "comma-separated columns to exclude from output") + vulnsCmd.Flags().BoolVar(&vulnsAll, "all", false, "show all columns including hidden ones (id, timestamps)") + vulnsCmd.Flags().StringArrayVar(&vulnsWhere, "where", nil, "filter by column (key=value, fuzzy match, repeatable)") + vulnsCmd.Flags().StringVar(&vulnsSearch, "search", "", "search all columns for substring (case-insensitive)") + vulnsCmd.Flags().StringVarP(&vulnsValue, "value", "V", "", "filter by asset_value (fuzzy match)") + vulnsCmd.Flags().Int64Var(&vulnsDetail, "id", 0, "show one finding in full, including description, PoC and both HTTP messages") +} + +func runVulns(cmd *cobra.Command, args []string) error { + if err := connectDB(); err != nil { + return err + } + defer func() { _ = database.Close() }() + + ctx := context.Background() + + if vulnsDetail > 0 { + return runVulnDetail(ctx) + } + if vulnsStats { + return runVulnsStats(ctx) + } + return runVulnsList(ctx, args) +} + +func runVulnsList(ctx context.Context, args []string) error { + validatePagination(&vulnsLimit, &vulnsOffset) + + // Exact-match filters + filters := make(map[string]string) + if vulnsWorkspace != "" { + filters["workspace"] = vulnsWorkspace + } + + // Org filter. An empty resolution means no org was selected, so no clause is + // added and the query spans every org exactly as the assets listing does. + orgUUID, err := resolveOrgUUID(ctx) + if err != nil { + return err + } + if orgUUID != "" { + filters["org_uuid"] = orgUUID + } + + // Fuzzy-match filters (LIKE) + fuzzyFilters := parseWhereFilters(vulnsWhere) + if vulnsConfidence != "" { + fuzzyFilters["confidence"] = vulnsConfidence + } + if vulnsTemplate != "" { + fuzzyFilters["vuln_info"] = vulnsTemplate + } + if vulnsType != "" { + fuzzyFilters["asset_type"] = vulnsType + } + if vulnsValue != "" { + fuzzyFilters["asset_value"] = vulnsValue + } + + // A single --severity is an exact filter the database can apply; a list or a + // --min-severity threshold is applied after the fetch, since GetTableRecords + // takes one value per column. + wanted, err := severitySelection() + if err != nil { + return err + } + if len(wanted) == 1 { + filters["severity"] = wanted[0] + } + + search := vulnsSearch + if len(args) > 0 { + search = args[0] + } + + // Fetch a wider page when post-filtering, so the requested limit can still be met. + fetchLimit := vulnsLimit + postFilter := len(wanted) > 1 + if postFilter && fetchLimit < 10000 { + fetchLimit = 10000 + } + + records, err := database.GetTableRecords(ctx, "vulnerabilities", vulnsOffset, fetchLimit, filters, fuzzyFilters, search, getDefaultExcludeColumns("vulnerabilities")) + if err != nil { + return fmt.Errorf("failed to get vulnerabilities: %w", err) + } + + rows, ok := records.Records.([]database.Vulnerability) + if !ok { + return fmt.Errorf("unexpected record type %T from vulnerabilities query", records.Records) + } + total := records.TotalCount + if postFilter { + rows = filterBySeverity(rows, wanted) + total = len(rows) + if len(rows) > vulnsLimit { + rows = rows[:vulnsLimit] + } + } + + sortBySeverity(rows) + + if globalJSON { + jsonBytes, err := json.Marshal(rows) + if err != nil { + return fmt.Errorf("failed to format records: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + printer := terminal.NewPrinter() + + requestedColumns := parseColumns(vulnsColumns) + columns := getEffectiveColumns("vulnerabilities", requestedColumns, vulnsAll) + excludeColumns := parseExcludeColumns(vulnsExcludeColumns) + + startRecord := vulnsOffset + 1 + endRecord := vulnsOffset + len(rows) + if total == 0 { + startRecord = 0 + } + + printer.Info("Vulnerabilities") + fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, total) + + // tableDefaultColumns has an entry for this table, so default columns are never hidden. + renderTableWithTablewriter("vulnerabilities", rows, columns, globalWidth, false, excludeColumns) + + if total > endRecord { + printer.Info("Next page: osmedeus vulns --offset %d --limit %d", vulnsOffset+len(rows), vulnsLimit) + } + if len(rows) > 0 { + printer.Info("Full detail for one finding: osmedeus vulns --id ") + } + + return nil +} + +// severitySelection resolves --severity and --min-severity into the set of +// severities to show. An empty result means no severity filtering. +func severitySelection() ([]string, error) { + if vulnsSeverity != "" && vulnsMinSeverity != "" { + return nil, fmt.Errorf("only one of --severity or --min-severity can be specified") + } + + if vulnsSeverity != "" { + var out []string + for _, s := range strings.Split(vulnsSeverity, ",") { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + continue + } + if severityRank(s) == 0 { + return nil, fmt.Errorf("invalid severity %q. Must be one of: %s", s, strings.Join(severityOrder, ", ")) + } + out = append(out, s) + } + if len(out) == 0 { + return nil, fmt.Errorf("--severity was empty") + } + return out, nil + } + + if vulnsMinSeverity != "" { + min := strings.ToLower(strings.TrimSpace(vulnsMinSeverity)) + if severityRank(min) == 0 { + return nil, fmt.Errorf("invalid --min-severity %q. Must be one of: %s", min, strings.Join(severityOrder, ", ")) + } + // severityOrder is already most-severe-first, so everything at or above the + // floor is just the prefix ending at it. + for i, s := range severityOrder { + if s == min { + return severityOrder[:i+1], nil + } + } + } + + return nil, nil +} + +func filterBySeverity(rows []database.Vulnerability, wanted []string) []database.Vulnerability { + keep := make(map[string]bool, len(wanted)) + for _, w := range wanted { + keep[w] = true + } + out := make([]database.Vulnerability, 0, len(rows)) + for _, r := range rows { + if keep[normalizeSeverity(r.Severity)] { + out = append(out, r) + } + } + return out +} + +// sortBySeverity puts the most severe findings first, which is the order a human +// reads a finding list in. +func sortBySeverity(rows []database.Vulnerability) { + sort.SliceStable(rows, func(i, j int) bool { + return severityRank(normalizeSeverity(rows[i].Severity)) > severityRank(normalizeSeverity(rows[j].Severity)) + }) +} + +func normalizeSeverity(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "unknown" + } + return s +} + +func runVulnsStats(ctx context.Context) error { + orgUUID, err := resolveOrgUUID(ctx) + if err != nil { + return err + } + + stats, err := database.GetVulnStats(ctx, vulnsWorkspace, orgUUID) + if err != nil { + return fmt.Errorf("failed to get vulnerability stats: %w", err) + } + + if globalJSON { + jsonBytes, err := json.Marshal(stats) + if err != nil { + return fmt.Errorf("failed to format stats: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + printer := terminal.NewPrinter() + if vulnsWorkspace != "" { + printer.Info("Vulnerability Statistics (workspace: %s)", vulnsWorkspace) + } else { + printer.Info("Vulnerability Statistics") + } + fmt.Println() + + fmt.Printf("%s: %d\n\n", terminal.Bold("Total"), stats.Total) + + fmt.Printf("%s:\n", terminal.Bold("Severity")) + if stats.Total == 0 { + fmt.Println(" (none)") + } else { + // highest severity first, and only severities actually present + for _, sev := range append(append([]string{}, severityOrder...), "unknown") { + if n, ok := stats.Severities[sev]; ok && n > 0 { + fmt.Printf(" %s %-9s %d\n", terminal.SymbolBullet, sev, n) + } + } + } + fmt.Println() + + printStatCategory("Confidences", stats.Confidences) + printStatCategory("Asset Types", stats.AssetTypes) + printStatCategory("Templates", stats.Templates) + if vulnsWorkspace == "" { + printStatCategory("Workspaces", stats.Workspaces) + } + + return nil +} + +// runVulnDetail prints one finding in full. This is the only path that selects the +// heavy evidence columns, so a listing never pays for them. +func runVulnDetail(ctx context.Context) error { + vuln, err := database.GetVulnerabilityByID(ctx, vulnsDetail) + if err != nil { + return fmt.Errorf("no vulnerability found with id %d (list them with 'osmedeus vulns')", vulnsDetail) + } + v := *vuln + + if globalJSON { + jsonBytes, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to format record: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + printer := terminal.NewPrinter() + printer.Info("Vulnerability #%d", v.ID) + fmt.Println() + for _, f := range []struct{ label, value string }{ + {"Title", v.VulnTitle}, + {"Template", v.VulnInfo}, + {"Severity", v.Severity}, + {"Confidence", v.Confidence}, + {"Asset", v.AssetValue}, + {"Asset Type", v.AssetType}, + {"Workspace", v.Workspace}, + {"Tags", strings.Join(v.Tags, ", ")}, + {"Finding Hash", v.FindingHash}, + } { + if val := strings.TrimSpace(f.value); val != "" { + fmt.Printf(" %-13s %s\n", terminal.Bold(f.label)+":", val) + } + } + + for _, sec := range []struct{ label, value string }{ + {"Description", v.VulnDesc}, + {"Proof of Concept", v.VulnPOC}, + {"HTTP Request", v.DetailHTTPRequest}, + {"HTTP Response", v.DetailHTTPResponse}, + } { + if val := strings.TrimSpace(sec.value); val != "" { + fmt.Printf("\n%s\n%s\n", terminal.BoldCyan("▷ "+sec.label), val) + } + } + + return nil +} diff --git a/pkg/cli/vulns_test.go b/pkg/cli/vulns_test.go new file mode 100644 index 0000000..c8aba4f --- /dev/null +++ b/pkg/cli/vulns_test.go @@ -0,0 +1,130 @@ +package cli + +import ( + "testing" + + "github.com/j3ssie/osmedeus/v5/internal/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetVulnSeverityFlags clears the package-level flags severitySelection reads, so +// each case starts from the same state regardless of order. +func resetVulnSeverityFlags() { + vulnsSeverity = "" + vulnsMinSeverity = "" +} + +func TestSeveritySelection(t *testing.T) { + t.Run("no flags means no filtering", func(t *testing.T) { + resetVulnSeverityFlags() + got, err := severitySelection() + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("single severity", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsSeverity = "high" + got, err := severitySelection() + require.NoError(t, err) + assert.Equal(t, []string{"high"}, got) + }) + + t.Run("severity list is normalised and trimmed", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsSeverity = " Critical , HIGH " + got, err := severitySelection() + require.NoError(t, err) + assert.Equal(t, []string{"critical", "high"}, got) + }) + + t.Run("min-severity expands to everything at or above", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsMinSeverity = "medium" + got, err := severitySelection() + require.NoError(t, err) + // highest first, and never includes the lower bands + assert.Equal(t, []string{"critical", "high", "medium"}, got) + }) + + t.Run("min-severity info includes every real band", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsMinSeverity = "info" + got, err := severitySelection() + require.NoError(t, err) + assert.Equal(t, []string{"critical", "high", "medium", "low", "info"}, got) + // "unknown" is an internal alias for info, not a user-facing band + assert.NotContains(t, got, "unknown") + }) + + t.Run("invalid severity is rejected", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsSeverity = "bogus" + _, err := severitySelection() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid severity") + }) + + t.Run("invalid min-severity is rejected", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsMinSeverity = "bogus" + _, err := severitySelection() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --min-severity") + }) + + t.Run("the two flags are mutually exclusive", func(t *testing.T) { + resetVulnSeverityFlags() + vulnsSeverity = "high" + vulnsMinSeverity = "low" + _, err := severitySelection() + require.Error(t, err) + assert.Contains(t, err.Error(), "only one of") + }) + + resetVulnSeverityFlags() +} + +func TestNormalizeSeverity(t *testing.T) { + assert.Equal(t, "high", normalizeSeverity(" HIGH ")) + assert.Equal(t, "unknown", normalizeSeverity("")) + assert.Equal(t, "unknown", normalizeSeverity(" ")) + assert.Equal(t, "critical", normalizeSeverity("Critical")) +} + +func TestSortBySeverity(t *testing.T) { + rows := []database.Vulnerability{ + {VulnTitle: "a", Severity: "info"}, + {VulnTitle: "b", Severity: "critical"}, + {VulnTitle: "c", Severity: "medium"}, + {VulnTitle: "d", Severity: ""}, // unknown ranks with info + {VulnTitle: "e", Severity: "high"}, + } + sortBySeverity(rows) + + got := make([]string, 0, len(rows)) + for _, r := range rows { + got = append(got, r.VulnTitle) + } + // most severe first; the two lowest keep their input order (stable sort) + assert.Equal(t, []string{"b", "e", "c", "a", "d"}, got) +} + +func TestFilterBySeverity(t *testing.T) { + rows := []database.Vulnerability{ + {VulnTitle: "a", Severity: "info"}, + {VulnTitle: "b", Severity: "critical"}, + {VulnTitle: "c", Severity: "MEDIUM"}, // case-insensitive + {VulnTitle: "d", Severity: ""}, + } + + got := filterBySeverity(rows, []string{"critical", "medium"}) + require.Len(t, got, 2) + assert.Equal(t, "b", got[0].VulnTitle) + assert.Equal(t, "c", got[1].VulnTitle) + + t.Run("empty selection keeps nothing", func(t *testing.T) { + assert.Empty(t, filterBySeverity(rows, nil)) + }) +} diff --git a/pkg/server/handlers/common.go b/pkg/server/handlers/common.go index e1e54a8..14f2d78 100644 --- a/pkg/server/handlers/common.go +++ b/pkg/server/handlers/common.go @@ -1,10 +1,10 @@ package handlers import ( - "bufio" "fmt" - "os" "strings" + + "github.com/j3ssie/osmedeus/v5/internal/fileio" ) // LoginRequest represents login credentials @@ -136,24 +136,11 @@ type FunctionListResponse struct { Tags []string `json:"tags,omitempty"` } -// readTargetsFromFile reads targets from a file (one per line) +// readTargetsFromFile reads targets from a file (one per line), skipping empty +// lines and comments. Shares the CLI's reader so both paths handle long lines +// and large target files identically. func readTargetsFromFile(filePath string) ([]string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer func() { _ = file.Close() }() - - var result []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - // Skip empty lines and comments - if line != "" && !strings.HasPrefix(line, "#") { - result = append(result, line) - } - } - return result, scanner.Err() + return fileio.ReadLinesFiltered(filePath) } // dangerousChars contains shell metacharacters that could enable command injection diff --git a/platform/osmedeus-registry/registry-metadata-direct-fetch.json b/platform/osmedeus-registry/registry-metadata-direct-fetch.json index 322d25c..7486899 100644 --- a/platform/osmedeus-registry/registry-metadata-direct-fetch.json +++ b/platform/osmedeus-registry/registry-metadata-direct-fetch.json @@ -1,5 +1,5 @@ { - "_last_update_at": "2026-05-29T14:55:48.066110+00:00", + "_last_update_at": "2026-10-03T08:23:42.595150+00:00", "make": { "desc": "Essential tools for compiling software from source code", "version": "", @@ -92,11 +92,8 @@ "optional" ], "valide-command": "", - "linux": { - "amd64": "github.com/tomnomnom/assetfinder@latest" - }, - "darwin": { - "amd64": "github.com/tomnomnom/assetfinder@latest" + "command-dual": { + "dual": "github.com/tomnomnom/assetfinder@latest" } }, "nuclei": { @@ -761,6 +758,26 @@ " install coreutils" ] }, + "jq": { + "desc": "Command-line JSON processor", + "repo_link": "https://github.com/jqlang/jq", + "version": "1.7.1", + "package-manager": "direct-fetch", + "tags": [ + "utility", + "json", + "core" + ], + "valide-command": "jq --version", + "linux": { + "amd64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64", + "arm64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-arm64" + }, + "darwin": { + "amd64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-macos-amd64", + "arm64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-macos-arm64" + } + }, "rg": { "desc": "ripgrep - recursively searches directories for a regex pattern", "repo_link": "https://github.com/BurntSushi/ripgrep", @@ -962,4 +979,4 @@ "dual": "github.com/utkusen/urlhunter@latest" } } -} \ No newline at end of file +} diff --git a/public/presets/registry-metadata-direct-fetch.json b/public/presets/registry-metadata-direct-fetch.json index 322d25c..7486899 100644 --- a/public/presets/registry-metadata-direct-fetch.json +++ b/public/presets/registry-metadata-direct-fetch.json @@ -1,5 +1,5 @@ { - "_last_update_at": "2026-05-29T14:55:48.066110+00:00", + "_last_update_at": "2026-10-03T08:23:42.595150+00:00", "make": { "desc": "Essential tools for compiling software from source code", "version": "", @@ -92,11 +92,8 @@ "optional" ], "valide-command": "", - "linux": { - "amd64": "github.com/tomnomnom/assetfinder@latest" - }, - "darwin": { - "amd64": "github.com/tomnomnom/assetfinder@latest" + "command-dual": { + "dual": "github.com/tomnomnom/assetfinder@latest" } }, "nuclei": { @@ -761,6 +758,26 @@ " install coreutils" ] }, + "jq": { + "desc": "Command-line JSON processor", + "repo_link": "https://github.com/jqlang/jq", + "version": "1.7.1", + "package-manager": "direct-fetch", + "tags": [ + "utility", + "json", + "core" + ], + "valide-command": "jq --version", + "linux": { + "amd64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64", + "arm64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-arm64" + }, + "darwin": { + "amd64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-macos-amd64", + "arm64": "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-macos-arm64" + } + }, "rg": { "desc": "ripgrep - recursively searches directories for a regex pattern", "repo_link": "https://github.com/BurntSushi/ripgrep", @@ -962,4 +979,4 @@ "dual": "github.com/utkusen/urlhunter@latest" } } -} \ No newline at end of file +}