mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-10-19 22:41:34 +02:00
release: v5.1.2 — vulns command, workflow dependency health check, ACP stderr fixes
- 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
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
+121
-7
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "#") {
|
||||
|
||||
@@ -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") + `
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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 <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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<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 @@
|
||||
"<auto_detect_package_manager> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "<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 @@
|
||||
"<auto_detect_package_manager> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user