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:
j3ssie
2026-10-05 01:27:15 +08:00
parent 9e30bb49a8
commit f5f3f89e5d
21 changed files with 1019 additions and 59 deletions
+21
View File
@@ -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.
+10 -1
View File
@@ -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"
+10 -1
View File
@@ -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"
+10 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8
View File
@@ -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)
+104
View File
@@ -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
}
+29 -14
View File
@@ -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.
+47
View File
@@ -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)
+17
View File
@@ -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
}
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+1
View File
@@ -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)
+3
View File
@@ -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, "#") {
+45
View File
@@ -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") + `
`
}
+382
View File
@@ -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
}
+130
View File
@@ -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))
})
}
+6 -19
View File
@@ -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"
}
}
}
}