From 4ac041fd73411ee744e7d861241b5dd4e29ed992 Mon Sep 17 00:00:00 2001 From: j3ssie Date: Thu, 12 Feb 2026 01:04:03 +0700 Subject: [PATCH] feat: add workflow help metadata, artifact optional flag, and search/filter improvements - Add WorkflowHelp struct with Usage and ExampleTargets for CLI documentation - Add Optional field to Artifact model and database schema with migration support - Implement workflow search functionality by name, description, and tags in CLI list command - Add --usage and --search flags to workflow list command with multiple filtering options - Display workflow usage info in show command when Help is defined - Support help inheritance in workflow extends/inheritance resolver - Update vulnerability counters from database after SARIF imports - Add comprehensive Help unit tests covering parsing, cloning, and mutation isolation - Improve test helpers with streaming output, diagnostics, and file validation utilities - Add fourth general canary test for domain-list-recon flow with artifact validation --- Makefile | 25 +- build/docker/Dockerfile.canary | 4 + build/docker/canary-entrypoint.sh | 2 +- go.mod | 2 +- internal/core/clone.go | 21 + internal/core/report.go | 1 + internal/core/report_test.go | 105 +++++ internal/core/workflow.go | 23 ++ internal/core/workflow_help_test.go | 128 ++++++ internal/database/database.go | 21 + internal/database/models.go | 1 + internal/executor/artifact_export.go | 2 + internal/functions/sarif_functions.go | 56 +++ internal/parser/inheritance.go | 5 + internal/parser/inheritance_test.go | 62 +++ pkg/cli/usage.go | 15 +- pkg/cli/workflow.go | 125 +++++- pkg/server/handlers/artifacts.go | 1 + test/e2e/canary_test.go | 366 +++++++++++++++++- .../workflows/test-example-report.yaml | 4 + 20 files changed, 931 insertions(+), 38 deletions(-) create mode 100644 internal/core/report_test.go create mode 100644 internal/core/workflow_help_test.go diff --git a/Makefile b/Makefile index cd6a7be..571523a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run test test-unit test-integration test-workflow-integration test-e2e test-e2e-verbose test-e2e-ssh test-e2e-api test-e2e-nix test-e2e-install test-docker test-ssh test-distributed test-canary-all test-canary-repo test-canary-domain test-canary-ip canary-up canary-down test-all test-summary test-ci clean install install-gotestsum lint fmt db-seed db-clean db-migrate run-server-debug swagger update-ui snapshot-release github-release run-github-action docker-toolbox docker-toolbox-run docker-toolbox-shell docker-publish +.PHONY: build run test test-unit test-integration test-workflow-integration test-e2e test-e2e-verbose test-e2e-ssh test-e2e-api test-e2e-nix test-e2e-install test-docker test-ssh test-distributed test-canary-all test-canary-repo test-canary-domain test-canary-ip test-canary-general canary-up canary-down test-all test-summary test-ci clean install install-gotestsum lint fmt db-seed db-clean db-migrate run-server-debug swagger update-ui snapshot-release github-release run-github-action docker-toolbox docker-toolbox-run docker-toolbox-shell docker-publish # Go parameters GOCMD=go @@ -27,9 +27,11 @@ endif ifeq ($(GOTESTSUM_EXISTS),yes) TESTCMD=@$(GOTESTSUM_PATH) TESTFLAGS=--format testdox --format-hide-empty-pkg --hide-summary=skipped,output -- + CANARY_TESTFLAGS=--format standard-verbose -- -v else TESTCMD=$(GOTEST) TESTFLAGS=-v + CANARY_TESTFLAGS=-v endif # Build flags @@ -195,6 +197,8 @@ test-e2e-install: build install-gotestsum # Build and start the canary container (shared setup for individual targets) canary-up: install-gotestsum + @echo "$(PREFIX) Cleaning up any existing canary container..." + -docker-compose -f build/docker/docker-compose.canary.yaml down -v 2>/dev/null @echo "$(PREFIX) Building canary Docker image..." docker-compose -f build/docker/docker-compose.canary.yaml build @echo "$(PREFIX) Starting canary container..." @@ -208,28 +212,34 @@ canary-down: @echo "$(PREFIX) Cleaning up canary container..." docker-compose -f build/docker/docker-compose.canary.yaml down -v -# Run ALL canary scans (builds container, runs all 3, cleans up — 30-60min) +# Run ALL canary scans (builds container, runs all 4, cleans up — 60-120min) test-canary-all: canary-up - @echo "$(PREFIX) Running all canary tests (30-60 minutes)..." - $(TESTCMD) $(TESTFLAGS) -run TestCanary_FullSuite -timeout 60m ./test/e2e/... || ($(MAKE) canary-down && exit 1) + @echo "$(PREFIX) Running all canary tests (60-120 minutes)..." + $(TESTCMD) $(CANARY_TESTFLAGS) -run TestCanary_FullSuite -timeout 120m ./test/e2e/... || ($(MAKE) canary-down && exit 1) @$(MAKE) canary-down # Repo scan canary (juice-shop SAST, ~25min) test-canary-repo: canary-up @echo "$(PREFIX) Running repo scan canary test..." - $(TESTCMD) $(TESTFLAGS) -run TestCanary_Repo -timeout 30m ./test/e2e/... || ($(MAKE) canary-down && exit 1) + $(TESTCMD) $(CANARY_TESTFLAGS) -run TestCanary_Repo -timeout 30m ./test/e2e/... || ($(MAKE) canary-down && exit 1) @$(MAKE) canary-down # Domain-lite scan canary (hackerone.com, ~20min) test-canary-domain: canary-up @echo "$(PREFIX) Running domain-lite scan canary test..." - $(TESTCMD) $(TESTFLAGS) -run TestCanary_Domain -timeout 25m ./test/e2e/... || ($(MAKE) canary-down && exit 1) + $(TESTCMD) $(CANARY_TESTFLAGS) -run TestCanary_Domain -timeout 25m ./test/e2e/... || ($(MAKE) canary-down && exit 1) @$(MAKE) canary-down # CIDR scan canary (IP list, ~25min) test-canary-ip: canary-up @echo "$(PREFIX) Running CIDR scan canary test..." - $(TESTCMD) $(TESTFLAGS) -run TestCanary_CIDR -timeout 30m ./test/e2e/... || ($(MAKE) canary-down && exit 1) + $(TESTCMD) $(CANARY_TESTFLAGS) -run TestCanary_CIDR -timeout 30m ./test/e2e/... || ($(MAKE) canary-down && exit 1) + @$(MAKE) canary-down + +# Domain-list-recon scan canary (hackerone.com subdomains, ~40min) +test-canary-general: canary-up + @echo "$(PREFIX) Running general scan canary test..." + $(TESTCMD) $(CANARY_TESTFLAGS) -run TestCanary_General -timeout 45m ./test/e2e/... || ($(MAKE) canary-down && exit 1) @$(MAKE) canary-down # All tests @@ -414,6 +424,7 @@ help: @echo " make test-canary-repo Run repo scan canary (juice-shop SAST, ~25min)" @echo " make test-canary-domain Run domain-lite canary (hackerone.com, ~20min)" @echo " make test-canary-ip Run CIDR scan canary (IP list, ~25min)" + @echo " make test-canary-general Run general scan canary (hackerone.com, ~40min)" @echo " make test-coverage Run tests with coverage report" @echo " make test-summary Quick pass/fail summary (dots format)" @echo " make test-ci Run tests with JUnit XML output" diff --git a/build/docker/Dockerfile.canary b/build/docker/Dockerfile.canary index 189f09e..d06111b 100644 --- a/build/docker/Dockerfile.canary +++ b/build/docker/Dockerfile.canary @@ -19,6 +19,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + build-essential \ curl \ wget \ git \ @@ -56,6 +57,9 @@ RUN osmedeus install base --preset # This is for sast test RUN osmedeus install binary --name trivy --name semgrep --name kingfisher --name bearer +# This is for general test (probe-dns, recon-http-fp, scan-content) +RUN osmedeus install binary --name dnsx --name httpx --name puredns --name massdns --name ffuf + # Copy the canary entrypoint COPY build/docker/canary-entrypoint.sh /usr/local/bin/canary-entrypoint.sh RUN chmod +x /usr/local/bin/canary-entrypoint.sh diff --git a/build/docker/canary-entrypoint.sh b/build/docker/canary-entrypoint.sh index c2ea27a..988e457 100755 --- a/build/docker/canary-entrypoint.sh +++ b/build/docker/canary-entrypoint.sh @@ -7,7 +7,7 @@ set -euo pipefail echo "[canary] Starting osmedeus API server on :8002 ..." -osmedeus serve --debug --port 8002 --host 0.0.0.0 -A & +osmedeus serve --port 8002 --host 0.0.0.0 -A & SERVER_PID=$! # Give the server a moment to bind diff --git a/go.mod b/go.mod index d8ab6ef..1961887 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( golang.org/x/net v0.48.0 golang.org/x/sync v0.19.0 golang.org/x/term v0.38.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -157,7 +158,6 @@ require ( golang.org/x/tools v0.39.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect mellium.im/sasl v0.3.2 // indirect modernc.org/libc v1.67.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/core/clone.go b/internal/core/clone.go index 55e5486..94ebd29 100644 --- a/internal/core/clone.go +++ b/internal/core/clone.go @@ -46,6 +46,9 @@ func (w *Workflow) Clone() *Workflow { copy(cloned.Reports, w.Reports) } + // Deep copy Help + cloned.Help = w.Help.Clone() + // Deep copy Preferences cloned.Preferences = w.Preferences.Clone() @@ -150,6 +153,24 @@ func (p *Preferences) Clone() *Preferences { return cloned } +// Clone creates a deep copy of WorkflowHelp +func (h *WorkflowHelp) Clone() *WorkflowHelp { + if h == nil { + return nil + } + + cloned := &WorkflowHelp{ + Usage: h.Usage, + } + + if len(h.ExampleTargets) > 0 { + cloned.ExampleTargets = make([]string, len(h.ExampleTargets)) + copy(cloned.ExampleTargets, h.ExampleTargets) + } + + return cloned +} + // Clone creates a deep copy of RunnerConfig func (r *RunnerConfig) Clone() *RunnerConfig { if r == nil { diff --git a/internal/core/report.go b/internal/core/report.go index f5e4839..fe42f36 100644 --- a/internal/core/report.go +++ b/internal/core/report.go @@ -6,6 +6,7 @@ type Report struct { Path string `yaml:"path"` Type string `yaml:"type"` // text, csv, json, etc. Description string `yaml:"description"` + Optional bool `yaml:"optional,omitempty"` } // IsTextReport returns true if this is a text report diff --git a/internal/core/report_test.go b/internal/core/report_test.go new file mode 100644 index 0000000..3777e12 --- /dev/null +++ b/internal/core/report_test.go @@ -0,0 +1,105 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestReportOptionalField(t *testing.T) { + tests := []struct { + name string + yamlStr string + expected bool + }{ + { + name: "optional true", + yamlStr: ` +name: test-report +path: /tmp/test.txt +type: text +optional: true +`, + expected: true, + }, + { + name: "optional false", + yamlStr: ` +name: test-report +path: /tmp/test.txt +type: text +optional: false +`, + expected: false, + }, + { + name: "optional omitted defaults to false", + yamlStr: ` +name: test-report +path: /tmp/test.txt +type: text +`, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var report Report + err := yaml.Unmarshal([]byte(tt.yamlStr), &report) + require.NoError(t, err) + assert.Equal(t, tt.expected, report.Optional) + assert.Equal(t, "test-report", report.Name) + assert.Equal(t, "text", report.Type) + }) + } +} + +func TestReportOptionalInWorkflow(t *testing.T) { + workflowYAML := ` +name: test-workflow +kind: module +description: Test workflow with mixed optional reports + +reports: + - name: required-report + path: "{{Output}}/required.txt" + type: text + description: A required report + + - name: optional-report + path: "{{Output}}/optional.json" + type: json + description: An optional report + optional: true + + - name: another-required + path: "{{Output}}/another.csv" + type: csv + +steps: + - name: test-step + type: bash + commands: + - echo "hello" +` + var workflow Workflow + err := yaml.Unmarshal([]byte(workflowYAML), &workflow) + require.NoError(t, err) + + require.Len(t, workflow.Reports, 3) + + // First report: required (default) + assert.Equal(t, "required-report", workflow.Reports[0].Name) + assert.False(t, workflow.Reports[0].Optional) + + // Second report: explicitly optional + assert.Equal(t, "optional-report", workflow.Reports[1].Name) + assert.True(t, workflow.Reports[1].Optional) + + // Third report: required (omitted) + assert.Equal(t, "another-required", workflow.Reports[2].Name) + assert.False(t, workflow.Reports[2].Optional) +} diff --git a/internal/core/workflow.go b/internal/core/workflow.go index 7b37060..1553d43 100644 --- a/internal/core/workflow.go +++ b/internal/core/workflow.go @@ -23,6 +23,12 @@ func (t *TagList) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } +// WorkflowHelp contains usage documentation for a workflow +type WorkflowHelp struct { + ExampleTargets []string `yaml:"example_targets,omitempty"` + Usage string `yaml:"usage,omitempty"` +} + // Workflow represents either a Module or Flow type Workflow struct { Kind WorkflowKind `yaml:"kind"` @@ -30,6 +36,7 @@ type Workflow struct { Description string `yaml:"description"` Tags TagList `yaml:"tags,omitempty"` Hidden bool `yaml:"hidden,omitempty"` + Help *WorkflowHelp `yaml:"help,omitempty"` Params []Param `yaml:"params"` Triggers []Trigger `yaml:"triggers"` Dependencies *Dependencies `yaml:"dependencies"` @@ -186,3 +193,19 @@ func (w *Workflow) GetCronTriggers() []Trigger { } return triggers } + +// GetUsage returns the usage string from Help, or empty string if Help is nil +func (w *Workflow) GetUsage() string { + if w.Help == nil { + return "" + } + return w.Help.Usage +} + +// GetExampleTargets returns example targets from Help, or nil if Help is nil +func (w *Workflow) GetExampleTargets() []string { + if w.Help == nil { + return nil + } + return w.Help.ExampleTargets +} diff --git a/internal/core/workflow_help_test.go b/internal/core/workflow_help_test.go new file mode 100644 index 0000000..3dd5796 --- /dev/null +++ b/internal/core/workflow_help_test.go @@ -0,0 +1,128 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestWorkflowHelpYAMLParsing(t *testing.T) { + t.Run("both fields", func(t *testing.T) { + data := ` +kind: module +name: test +help: + example_targets: ['example.com', 'httpbin.org'] + usage: osmedeus run -m test -t +steps: + - name: s1 + type: bash + command: echo hi +` + var wf Workflow + err := yaml.Unmarshal([]byte(data), &wf) + require.NoError(t, err) + require.NotNil(t, wf.Help) + assert.Equal(t, "osmedeus run -m test -t ", wf.Help.Usage) + assert.Equal(t, []string{"example.com", "httpbin.org"}, wf.Help.ExampleTargets) + }) + + t.Run("usage only", func(t *testing.T) { + data := ` +kind: module +name: test +help: + usage: osmedeus run -m test -t +steps: + - name: s1 + type: bash + command: echo hi +` + var wf Workflow + err := yaml.Unmarshal([]byte(data), &wf) + require.NoError(t, err) + require.NotNil(t, wf.Help) + assert.Equal(t, "osmedeus run -m test -t ", wf.Help.Usage) + assert.Empty(t, wf.Help.ExampleTargets) + }) + + t.Run("omitted", func(t *testing.T) { + data := ` +kind: module +name: test +steps: + - name: s1 + type: bash + command: echo hi +` + var wf Workflow + err := yaml.Unmarshal([]byte(data), &wf) + require.NoError(t, err) + assert.Nil(t, wf.Help) + }) +} + +func TestWorkflowGetUsage(t *testing.T) { + t.Run("nil help", func(t *testing.T) { + wf := &Workflow{} + assert.Equal(t, "", wf.GetUsage()) + }) + + t.Run("non-nil help", func(t *testing.T) { + wf := &Workflow{ + Help: &WorkflowHelp{Usage: "osmedeus run -m test -t example.com"}, + } + assert.Equal(t, "osmedeus run -m test -t example.com", wf.GetUsage()) + }) +} + +func TestWorkflowGetExampleTargets(t *testing.T) { + t.Run("nil help", func(t *testing.T) { + wf := &Workflow{} + assert.Nil(t, wf.GetExampleTargets()) + }) + + t.Run("non-nil help", func(t *testing.T) { + wf := &Workflow{ + Help: &WorkflowHelp{ExampleTargets: []string{"example.com", "httpbin.org"}}, + } + assert.Equal(t, []string{"example.com", "httpbin.org"}, wf.GetExampleTargets()) + }) +} + +func TestWorkflowHelpClone(t *testing.T) { + t.Run("nil help", func(t *testing.T) { + var h *WorkflowHelp + assert.Nil(t, h.Clone()) + }) + + t.Run("deep copy and mutation isolation", func(t *testing.T) { + original := &WorkflowHelp{ + ExampleTargets: []string{"example.com", "httpbin.org"}, + Usage: "osmedeus run -m test -t ", + } + cloned := original.Clone() + + require.NotNil(t, cloned) + assert.Equal(t, original.Usage, cloned.Usage) + assert.Equal(t, original.ExampleTargets, cloned.ExampleTargets) + + // Mutate clone and verify original is unaffected + cloned.Usage = "changed" + cloned.ExampleTargets[0] = "changed.com" + assert.Equal(t, "osmedeus run -m test -t ", original.Usage) + assert.Equal(t, "example.com", original.ExampleTargets[0]) + }) + + t.Run("empty example targets", func(t *testing.T) { + original := &WorkflowHelp{ + Usage: "some usage", + } + cloned := original.Clone() + require.NotNil(t, cloned) + assert.Equal(t, "some usage", cloned.Usage) + assert.Nil(t, cloned.ExampleTargets) + }) +} diff --git a/internal/database/database.go b/internal/database/database.go index 9ead51f..8970e7a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -217,6 +217,11 @@ func Migrate(ctx context.Context) error { return err } + // Add optional column to artifacts table if it doesn't exist (for existing databases) + if err := addArtifactsOptionalColumn(ctx); err != nil { + return err + } + return nil } @@ -258,6 +263,22 @@ func addRunsPIDColumn(ctx context.Context) error { return nil } +// addArtifactsOptionalColumn adds the optional column to artifacts table for existing databases +// This is safe to run multiple times - it checks if column exists first +func addArtifactsOptionalColumn(ctx context.Context) error { + _, err := db.ExecContext(ctx, "ALTER TABLE artifacts ADD COLUMN optional BOOLEAN DEFAULT FALSE") + if err != nil { + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "duplicate column") || + strings.Contains(errStr, "already exists") || + strings.Contains(errStr, "sqlstate 42701") { + return nil + } + return fmt.Errorf("failed to add optional column: %w", err) + } + return nil +} + // createAssetIndexes creates indexes for the assets table func createAssetIndexes(ctx context.Context) error { indexes := []string{ diff --git a/internal/database/models.go b/internal/database/models.go index 1d5d7a9..d3e7fbd 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -124,6 +124,7 @@ type Artifact struct { ContentType string `bun:"content_type" json:"content_type,omitempty"` // json, jsonl, yaml, html, md, log, pdf, png, txt, zip, folder, unknown SizeBytes int64 `bun:"size_bytes" json:"size_bytes"` LineCount int `bun:"line_count" json:"line_count"` + Optional bool `bun:"optional,default:false" json:"optional"` Description string `bun:"description" json:"description,omitempty"` CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"` diff --git a/internal/executor/artifact_export.go b/internal/executor/artifact_export.go index ed56e6d..aa6fff2 100644 --- a/internal/executor/artifact_export.go +++ b/internal/executor/artifact_export.go @@ -57,6 +57,7 @@ func RegisterArtifacts(workflow *core.Workflow, execCtx *core.ExecutionContext, ArtifactPath: renderedPath, ArtifactType: database.ArtifactTypeReport, ContentType: contentType, + Optional: report.Optional, Description: report.Description, CreatedAt: time.Now(), } @@ -77,6 +78,7 @@ func RegisterArtifacts(workflow *core.Workflow, execCtx *core.ExecutionContext, Set("content_type = EXCLUDED.content_type"). Set("size_bytes = EXCLUDED.size_bytes"). Set("line_count = EXCLUDED.line_count"). + Set("optional = EXCLUDED.optional"). Set("description = EXCLUDED.description"). Exec(ctx) diff --git a/internal/functions/sarif_functions.go b/internal/functions/sarif_functions.go index a3b2596..2b7b401 100644 --- a/internal/functions/sarif_functions.go +++ b/internal/functions/sarif_functions.go @@ -14,6 +14,7 @@ import ( "github.com/j3ssie/osmedeus/v5/internal/database" "github.com/j3ssie/osmedeus/v5/internal/logger" "github.com/j3ssie/osmedeus/v5/internal/terminal" + "github.com/uptrace/bun" "go.uber.org/zap" ) @@ -311,6 +312,11 @@ func (vf *vmFunc) dbImportSARIF(call goja.FunctionCall) goja.Value { } } + // Update workspace vulnerability counters from DB (source of truth) + if stats.New > 0 || stats.Updated > 0 { + updateWorkspaceVulnCounters(ctx, db, workspace) + } + total := stats.New + stats.Updated + stats.Unchanged logger.Get().Debug("dbImportSARIF completed", zap.String("workspace", workspace), @@ -330,6 +336,56 @@ func (vf *vmFunc) dbImportSARIF(call goja.FunctionCall) goja.Value { }) } +// updateWorkspaceVulnCounters counts vulnerabilities by severity from the DB +// and updates the workspace record with accurate counters. +func updateWorkspaceVulnCounters(ctx context.Context, db *bun.DB, workspace string) { + type severityCount struct { + Severity string `bun:"severity"` + Count int `bun:"count"` + } + var counts []severityCount + err := db.NewSelect(). + TableExpr("vulnerabilities"). + ColumnExpr("severity"). + ColumnExpr("count(*) AS count"). + Where("workspace = ?", workspace). + GroupExpr("severity"). + Scan(ctx, &counts) + if err != nil { + logger.Get().Debug("updateWorkspaceVulnCounters: failed to count vulnerabilities", zap.Error(err)) + return + } + + var total, critical, high, medium, low int + for _, c := range counts { + total += c.Count + switch strings.ToLower(c.Severity) { + case "critical": + critical = c.Count + case "high": + high = c.Count + case "medium": + medium = c.Count + case "low": + low = c.Count + } + } + + _, err = db.NewUpdate(). + Model((*database.Workspace)(nil)). + Set("total_vulns = ?", total). + Set("vuln_critical = ?", critical). + Set("vuln_high = ?", high). + Set("vuln_medium = ?", medium). + Set("vuln_low = ?", low). + Set("updated_at = ?", time.Now()). + Where("name = ?", workspace). + Exec(ctx) + if err != nil { + logger.Get().Debug("updateWorkspaceVulnCounters: failed to update workspace", zap.Error(err)) + } +} + // sarifFinding is an intermediate struct for markdown conversion type sarifFinding struct { Severity string diff --git a/internal/parser/inheritance.go b/internal/parser/inheritance.go index 174b834..a758dc3 100644 --- a/internal/parser/inheritance.go +++ b/internal/parser/inheritance.go @@ -229,6 +229,11 @@ func (r *InheritanceResolver) merge(parent, child *core.Workflow) (*core.Workflo copy(merged.Tags, child.Tags) } + // Apply child's help if set + if child.Help != nil { + merged.Help = child.Help.Clone() + } + // Apply overrides if present if child.Override != nil { if err := r.applyOverrides(merged, child.Override); err != nil { diff --git a/internal/parser/inheritance_test.go b/internal/parser/inheritance_test.go index 14a23af..2c00d87 100644 --- a/internal/parser/inheritance_test.go +++ b/internal/parser/inheritance_test.go @@ -407,6 +407,68 @@ steps: assert.Contains(t, err.Error(), "modules override can only be used with flow") } +func TestInheritanceResolver_HelpMerge(t *testing.T) { + p := NewParser() + + // Parent with help + parentContent := []byte(` +kind: module +name: parent-with-help +help: + example_targets: ['parent.com'] + usage: osmedeus run -m parent -t +steps: + - name: step1 + type: bash + command: echo parent +`) + parent, err := p.ParseContent(parentContent) + require.NoError(t, err) + + t.Run("child overrides parent help", func(t *testing.T) { + childContent := []byte(` +kind: module +name: child-with-help +extends: parent-with-help +help: + example_targets: ['child.com', 'example.org'] + usage: osmedeus run -m child -t +`) + child, err := p.ParseContent(childContent) + require.NoError(t, err) + + // Manually resolve since parent isn't on disk + loader := NewLoader(testWorkflowsDir) + resolver := NewInheritanceResolver(loader) + merged, err := resolver.merge(parent, child) + require.NoError(t, err) + + require.NotNil(t, merged.Help) + assert.Equal(t, "osmedeus run -m child -t ", merged.Help.Usage) + assert.Equal(t, []string{"child.com", "example.org"}, merged.Help.ExampleTargets) + }) + + t.Run("child without help inherits parent help", func(t *testing.T) { + childContent := []byte(` +kind: module +name: child-no-help +extends: parent-with-help +`) + child, err := p.ParseContent(childContent) + require.NoError(t, err) + + loader := NewLoader(testWorkflowsDir) + resolver := NewInheritanceResolver(loader) + merged, err := resolver.merge(parent, child) + require.NoError(t, err) + + // Should inherit parent's help + require.NotNil(t, merged.Help) + assert.Equal(t, "osmedeus run -m parent -t ", merged.Help.Usage) + assert.Equal(t, []string{"parent.com"}, merged.Help.ExampleTargets) + }) +} + func boolPtr(b bool) *bool { return &b } diff --git a/pkg/cli/usage.go b/pkg/cli/usage.go index 52e576e..ca0738d 100644 --- a/pkg/cli/usage.go +++ b/pkg/cli/usage.go @@ -157,9 +157,9 @@ func UsageWorkflow() string { Commands for listing, viewing, and validating workflows. ` + terminal.BoldCyan("▶ Subcommands") + ` - • ` + terminal.Yellow("list") + ` - List available workflows - • ` + terminal.Yellow("show") + ` - Show workflow details - • ` + terminal.Yellow("validate") + ` - Validate a workflow + • ` + terminal.Yellow("list") + ` - List available workflows (alias: ls) + • ` + terminal.Yellow("show") + ` - Show workflow details (alias: view) + • ` + terminal.Yellow("validate") + ` - Validate a workflow (alias: val) ` + terminal.BoldCyan("▶ Workflow Preferences") + ` Workflows can define execution preferences in YAML that act as defaults. @@ -724,6 +724,10 @@ func UsageAllExamples() string { ` + terminal.Green("# List all workflows") + ` osmedeus workflow list + ` + terminal.Green("# Search workflows by name or description") + ` + osmedeus workflow ls recon + osmedeus workflow ls --search subdomain + ` + terminal.Green("# Show workflow details") + ` osmedeus workflow show recon @@ -865,8 +869,9 @@ func UsageFullExample() string { ` + terminal.BoldYellow("WORKFLOW COMMAND") + ` - Manage workflows ` + terminal.Gray("───────────────────────────────────────────────────────────────────") + ` - osmedeus workflow list List available workflows - osmedeus workflow show Show workflow details + osmedeus workflow list List available workflows (alias: ls) + osmedeus workflow ls Search workflows by name or description + osmedeus workflow show Show workflow details (alias: view) osmedeus workflow validate Validate a workflow (alias: val) ` + terminal.Cyan(" List Flags:") + ` diff --git a/pkg/cli/workflow.go b/pkg/cli/workflow.go index 6631b7e..99d575b 100644 --- a/pkg/cli/workflow.go +++ b/pkg/cli/workflow.go @@ -18,9 +18,10 @@ import ( // workflowCmd represents the workflow command var workflowCmd = &cobra.Command{ - Use: "workflow", + Use: "workflow [search]", Short: "Manage workflows", Long: UsageWorkflow(), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Default to 'list' when no subcommand is specified return workflowListCmd.RunE(cmd, args) @@ -29,15 +30,22 @@ var workflowCmd = &cobra.Command{ // workflowListCmd lists available workflows var workflowListCmd = &cobra.Command{ - Use: "list", + Use: "list [search]", Aliases: []string{"ls"}, Short: "List available workflows", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.Get() if cfg == nil { return fmt.Errorf("configuration not loaded") } + // Resolve search term from positional arg or --search flag + searchTerm := searchQuery + if len(args) > 0 && args[0] != "" { + searchTerm = args[0] + } + loader := parser.NewLoader(cfg.WorkflowsPath) // List flows @@ -56,7 +64,9 @@ var workflowListCmd = &cobra.Command{ printer := terminal.NewPrinter() fmt.Println() title := fmt.Sprintf("Available Workflows (%s)", terminal.Gray(cfg.WorkflowsPath)) - if len(filterTags) > 0 { + if searchTerm != "" { + title = fmt.Sprintf("Available Workflows - Search: %s", terminal.Cyan(searchTerm)) + } else if len(filterTags) > 0 { title = fmt.Sprintf("Available Workflows - Filtered by tags: %s", terminal.Cyan(strings.Join(filterTags, ", "))) } fmt.Printf("%s %s\n", terminal.InfoSymbol(), terminal.Bold(title)) @@ -73,6 +83,7 @@ var workflowListCmd = &cobra.Command{ steps string // step/module count tags string targetTypes string + usage string // from Help.Usage } var rows []workflowRow uniqueTags := make(map[string]bool) // Collect unique tags across all workflows @@ -100,9 +111,13 @@ var workflowListCmd = &cobra.Command{ if len(filterTags) > 0 && !hasMatchingTags(wf, filterTags) { continue } + // Skip if search term doesn't match + if searchTerm != "" && !matchesSearch(wf, f, searchTerm) { + continue + } desc := "-" if wf.Description != "" { - desc = truncateString(wf.Description, 50) + desc = truncateString(wf.Description, 60) } reqParams := getRequiredParams(wf) // Track if any workflow has required Target @@ -123,13 +138,14 @@ var workflowListCmd = &cobra.Command{ } } targetTypes := getTargetTypes(wf) + usage := wf.GetUsage() name := f colorName := f if f == "general" { name = f + " (default)" colorName = terminal.Green(name) } - rows = append(rows, workflowRow{name, colorName, "flow", desc, reqParams, steps, tags, targetTypes}) + rows = append(rows, workflowRow{name, colorName, "flow", desc, reqParams, steps, tags, targetTypes, usage}) } // Load modules @@ -147,9 +163,13 @@ var workflowListCmd = &cobra.Command{ if len(filterTags) > 0 && !hasMatchingTags(wf, filterTags) { continue } + // Skip if search term doesn't match + if searchTerm != "" && !matchesSearch(wf, m, searchTerm) { + continue + } desc := "-" if wf.Description != "" { - desc = truncateString(wf.Description, 50) + desc = truncateString(wf.Description, 60) } reqParams := getRequiredParams(wf) // Track if any workflow has required Target @@ -170,13 +190,18 @@ var workflowListCmd = &cobra.Command{ } } targetTypes := getTargetTypes(wf) - rows = append(rows, workflowRow{m, m, "module", desc, reqParams, steps, tags, targetTypes}) + usage := wf.GetUsage() + rows = append(rows, workflowRow{m, m, "module", desc, reqParams, steps, tags, targetTypes, usage}) } // Check if any workflows matched the filter - if len(rows) == 0 && len(filterTags) > 0 { + if len(rows) == 0 && (len(filterTags) > 0 || searchTerm != "") { fmt.Println() - printer.Warning("No workflows found with tags: %s", strings.Join(filterTags, ", ")) + if searchTerm != "" { + printer.Warning("No workflows found matching: %s", searchTerm) + } else { + printer.Warning("No workflows found with tags: %s", strings.Join(filterTags, ", ")) + } fmt.Println() return nil } @@ -285,6 +310,34 @@ var workflowListCmd = &cobra.Command{ colorSteps, strings.Repeat(" ", stepsPad), colorTargetTypes, strings.Repeat(" ", targetTypesPad)) } + + // Print usage sub-row if --usage flag is set and workflow has usage info + if showUsage && r.usage != "" { + usageText := terminal.Gray(" Usage: " + r.usage) + usageDisplayLen := len(stripAnsi(usageText)) + usagePad := descWidth - usageDisplayLen + if usagePad < 0 { + usagePad = 0 + } + if showTags { + fmt.Printf("| %s | %s | %s%s | %s | %s | %s | %s |\n", + strings.Repeat(" ", nameWidth), + strings.Repeat(" ", typeWidth), + usageText, strings.Repeat(" ", usagePad), + strings.Repeat(" ", paramsWidth), + strings.Repeat(" ", stepsWidth), + strings.Repeat(" ", targetTypesWidth), + strings.Repeat(" ", tagsWidth)) + } else { + fmt.Printf("| %s | %s | %s%s | %s | %s | %s |\n", + strings.Repeat(" ", nameWidth), + strings.Repeat(" ", typeWidth), + usageText, strings.Repeat(" ", usagePad), + strings.Repeat(" ", paramsWidth), + strings.Repeat(" ", stepsWidth), + strings.Repeat(" ", targetTypesWidth)) + } + } } fmt.Println() @@ -349,9 +402,17 @@ var workflowListCmd = &cobra.Command{ fmt.Printf("◇ Available tags: %s\n", terminal.Gray(tagsDisplay)) } - // View workflow details hint + // Search & filter workflows hint binaryPath := os.Args[0] fmt.Println() + fmt.Println("◌ " + terminal.Bold("Search workflows:")) + fmt.Printf(" %s workflow ls %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) + fmt.Printf(" %s workflow ls --search %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) + fmt.Printf(" %s workflow ls --tags %s\n", terminal.Cyan(binaryPath), terminal.Yellow("recon,fast")) + fmt.Printf(" %s workflow ls --show-tags\n", terminal.Cyan(binaryPath)) + + // View workflow details hint + fmt.Println() fmt.Println("◌ " + terminal.Bold("View workflow details:")) fmt.Printf(" %s workflow show %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) @@ -360,12 +421,6 @@ var workflowListCmd = &cobra.Command{ fmt.Println("◌ " + terminal.Bold("Validate workflow:")) fmt.Printf(" %s workflow validate %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) - // Filter by tags hint - fmt.Println() - fmt.Println("◌ " + terminal.Bold("Filter by tags:")) - fmt.Printf(" %s workflow ls --tags %s\n", terminal.Cyan(binaryPath), terminal.Yellow("recon,fast")) - fmt.Printf(" %s workflow ls --show-tags\n", terminal.Cyan(binaryPath)) - // Example run usage fmt.Println() fmt.Println("◌ " + terminal.Bold("Example Run Usage:")) @@ -912,9 +967,10 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int // workflowShowCmd shows workflow details var workflowShowCmd = &cobra.Command{ - Use: "show [name]", - Short: "Show workflow details", - Args: cobra.ExactArgs(1), + Use: "show [name]", + Aliases: []string{"view"}, + Short: "Show workflow details", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.Get() if cfg == nil { @@ -968,6 +1024,16 @@ var workflowShowCmd = &cobra.Command{ printer.KeyValue("Description", workflow.Description) printer.KeyValue("File", terminal.Gray(workflow.FilePath)) + // Show help info + if workflow.Help != nil { + if workflow.Help.Usage != "" { + printer.KeyValue("Usage", terminal.Cyan(workflow.Help.Usage)) + } + if len(workflow.Help.ExampleTargets) > 0 { + printer.KeyValue("Example Targets", terminal.Yellow(strings.Join(workflow.Help.ExampleTargets, ", "))) + } + } + // Show parameters (categorized) if len(workflow.Params) > 0 { toggle, speed, config, general := categorizeParams(workflow.Params) @@ -1209,6 +1275,8 @@ var showVerbose bool var filterTags []string var showYaml bool var showTags bool +var showUsage bool +var searchQuery string var validateFailFast bool // Linter flags @@ -1222,6 +1290,8 @@ func init() { workflowShowCmd.Flags().BoolVar(&showYaml, "yaml", false, "show raw YAML instead of table format") workflowListCmd.Flags().StringSliceVar(&filterTags, "tags", []string{}, "filter workflows by tags (comma-separated)") workflowListCmd.Flags().BoolVar(&showTags, "show-tags", false, "show tags column in output") + workflowListCmd.Flags().BoolVar(&showUsage, "usage", false, "show usage info below description") + workflowListCmd.Flags().StringVar(&searchQuery, "search", "", "filter workflows by name, description, or tags") workflowListCmd.Flags().BoolVarP(&showVerbose, "verbose", "v", false, "show workflows with errors") workflowValidateCmd.Flags().BoolVar(&validateFailFast, "fail-fast", false, "stop on first validation failure") workflowValidateCmd.Flags().BoolVar(&lintCheck, "check", false, "exit with error code if issues found (for CI)") @@ -1250,6 +1320,23 @@ func hasMatchingTags(wf *core.Workflow, tags []string) bool { return false } +// matchesSearch checks if a workflow matches a search term (case-insensitive substring match) +func matchesSearch(wf *core.Workflow, name, searchTerm string) bool { + lower := strings.ToLower(searchTerm) + if strings.Contains(strings.ToLower(name), lower) { + return true + } + if strings.Contains(strings.ToLower(wf.Description), lower) { + return true + } + for _, tag := range wf.Tags { + if strings.Contains(strings.ToLower(tag), lower) { + return true + } + } + return false +} + // classifyInput determines if input is a file path, folder path, or workflow name func classifyInput(input string) (inputType string, resolvedPath string) { if info, err := os.Stat(input); err == nil { diff --git a/pkg/server/handlers/artifacts.go b/pkg/server/handlers/artifacts.go index 0697bd6..d6327f1 100644 --- a/pkg/server/handlers/artifacts.go +++ b/pkg/server/handlers/artifacts.go @@ -173,6 +173,7 @@ func ListArtifacts(cfg *config.Config) fiber.Handler { "content_type": a.ContentType, "size_bytes": a.SizeBytes, "line_count": a.LineCount, + "optional": a.Optional, "description": a.Description, "created_at": a.CreatedAt, "path_exists": exists, diff --git a/test/e2e/canary_test.go b/test/e2e/canary_test.go index 024c668..c7059ee 100644 --- a/test/e2e/canary_test.go +++ b/test/e2e/canary_test.go @@ -1,6 +1,7 @@ package e2e import ( + "bufio" "context" "encoding/json" "fmt" @@ -103,7 +104,9 @@ func dockerExecLong(t *testing.T, log *TestLogger, timeout time.Duration, args . output, err := cmd.CombinedOutput() out := string(output) - if len(out) > 500 { + if err != nil && len(out) > 2000 { + log.Debug("output (%d bytes, last 2000): ...%s", len(out), out[len(out)-2000:]) + } else if len(out) > 500 { log.Debug("output (%d bytes): %s...", len(out), out[:500]) } else if out != "" { log.Debug("output: %s", strings.TrimSpace(out)) @@ -112,6 +115,51 @@ func dockerExecLong(t *testing.T, log *TestLogger, timeout time.Duration, args . return out, err } +// dockerExecStream runs a command inside the canary container, streaming output +// line-by-line in real-time via t.Log so that long-running scans (20-40 min) +// produce visible progress in `go test -v`. +func dockerExecStream(t *testing.T, log *TestLogger, timeout time.Duration, args ...string) (string, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmdArgs := append([]string{"exec", canaryContainerName}, args...) + log.Debug("docker %s", strings.Join(cmdArgs, " ")) + + cmd := exec.CommandContext(ctx, "docker", cmdArgs...) + + // Merge stderr into stdout so we get a single stream. + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("StdoutPipe: %w", err) + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("Start: %w", err) + } + + // Read line-by-line; 1 MB max buffer handles long JSON lines. + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var buf strings.Builder + for scanner.Scan() { + line := scanner.Text() + log.Debug("[stream] %s", line) + buf.WriteString(line) + buf.WriteByte('\n') + } + + waitErr := cmd.Wait() + out := buf.String() + + if waitErr != nil { + return out, waitErr + } + return out, scanner.Err() +} + // ── filesystem checks inside container ─────────────────────────────────────── func fileExistsInContainer(t *testing.T, log *TestLogger, path string) bool { @@ -142,6 +190,61 @@ func findFilesInContainer(t *testing.T, log *TestLogger, dir, pattern string) [] return files } +// readFileInContainer reads a file's contents from inside the canary container. +func readFileInContainer(t *testing.T, log *TestLogger, path string) (string, error) { + t.Helper() + return dockerExec(t, log, "cat", path) +} + +// lineCountInContainer returns the number of non-empty lines in a file inside the container. +func lineCountInContainer(t *testing.T, log *TestLogger, path string) int { + t.Helper() + content, err := readFileInContainer(t, log, path) + if err != nil { + return 0 + } + count := 0 + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) != "" { + count++ + } + } + return count +} + +// validateJSONLInContainer reads a JSONL file from the container and validates each line. +// Returns the number of valid JSON lines and a list of parse errors (max 3). +func validateJSONLInContainer(t *testing.T, log *TestLogger, path string) (int, []string) { + t.Helper() + content, err := readFileInContainer(t, log, path) + if err != nil { + return 0, []string{fmt.Sprintf("failed to read file: %v", err)} + } + + var validLines int + var errors []string + for i, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if json.Valid([]byte(line)) { + validLines++ + } else if len(errors) < 3 { + errors = append(errors, fmt.Sprintf("line %d: invalid JSON", i+1)) + } + } + return validLines, errors +} + +// reportCheck describes a report file to validate inside the container. +type reportCheck struct { + name string // human-readable report name + path string // full path inside container + fileType string // "text", "jsonl", or "markdown" + soft bool // true = warn only, false = hard assert +} + // ── API helpers ────────────────────────────────────────────────────────────── // canaryAPIGet performs a GET request against the canary API and parses JSON. @@ -210,6 +313,55 @@ func findWorkspaceByName(t *testing.T, name string) map[string]any { return nil } +// ── failure diagnostics ────────────────────────────────────────────────────── + +// dumpCanaryDiagnostics prints container logs and API state when a test fails. +// Register via t.Cleanup() at the start of each canary scan function. +func dumpCanaryDiagnostics(t *testing.T) { + t.Helper() + if !t.Failed() { + return + } + + t.Log("=== CANARY DIAGNOSTICS (test failed) ===") + + // Container logs (last 100 lines) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "logs", "--tail", "100", canaryContainerName) + if logOut, err := cmd.CombinedOutput(); err == nil { + t.Logf("--- docker logs %s (last 100 lines) ---\n%s", canaryContainerName, string(logOut)) + } else { + t.Logf("--- docker logs failed: %v ---", err) + } + + // Workspace listing + if body, err := canaryAPIGetRaw("/osm/api/workspaces"); err == nil { + t.Logf("--- GET /osm/api/workspaces ---\n%s", body) + } + + // Run listing + if body, err := canaryAPIGetRaw("/osm/api/runs"); err == nil { + t.Logf("--- GET /osm/api/runs ---\n%s", body) + } + + t.Log("=== END CANARY DIAGNOSTICS ===") +} + +// canaryAPIGetRaw performs a GET and returns the raw response body. +func canaryAPIGetRaw(path string) (string, error) { + resp, err := http.Get(canaryAPIBase + path) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} + // ── main test entry points ─────────────────────────────────────────────────── // requireCanaryAPI checks that the canary container API is reachable. @@ -247,6 +399,9 @@ func TestCanary_FullSuite(t *testing.T) { t.Run("CIDRScan", func(t *testing.T) { testCanaryCIDRScan(t) }) + t.Run("GeneralScan", func(t *testing.T) { + testCanaryGeneralScan(t) + }) log.Success("All canary tests completed") } @@ -284,9 +439,21 @@ func TestCanary_CIDR(t *testing.T) { testCanaryCIDRScan(t) } +// TestCanary_General runs only the general scan canary test. +// Assumes the canary container is already running (e.g. via `make test-canary-general`). +func TestCanary_General(t *testing.T) { + if testing.Short() { + t.Skip("skipping canary tests in short mode") + } + log := NewTestLogger(t) + requireCanaryAPI(t, log) + testCanaryGeneralScan(t) +} + // ── Test 1: Repo Scan ──────────────────────────────────────────────────────── func testCanaryRepoScan(t *testing.T) { + t.Cleanup(func() { dumpCanaryDiagnostics(t) }) log := NewTestLogger(t) log.Step("Canary: Repo Scan (juice-shop)") @@ -296,7 +463,7 @@ func testCanaryRepoScan(t *testing.T) { // Run the scan log.Step("Running repo flow") - out, err := dockerExecLong(t, log, 25*time.Minute, + out, err := dockerExecStream(t, log, 25*time.Minute, "osmedeus", "run", "-f", "repo", "-t", targetURL) if err != nil { log.Error("Repo scan command error: %v\nOutput: %s", err, out) @@ -369,6 +536,7 @@ func testCanaryRepoScan(t *testing.T) { // ── Test 2: Domain-Lite Scan ───────────────────────────────────────────────── func testCanaryDomainLiteScan(t *testing.T) { + t.Cleanup(func() { dumpCanaryDiagnostics(t) }) log := NewTestLogger(t) log.Step("Canary: Domain-Lite Scan (hackerone.com)") @@ -378,8 +546,8 @@ func testCanaryDomainLiteScan(t *testing.T) { // Run the scan log.Step("Running domain-lite flow") - out, err := dockerExecLong(t, log, 20*time.Minute, - "osmedeus", "run", "-f", "domain-lite", "-t", target) + out, err := dockerExecStream(t, log, 20*time.Minute, + "osmedeus", "run", "--debug", "-f", "domain-lite", "-t", target) if err != nil { log.Error("Domain-lite scan command error: %v\nOutput: %s", err, out) } @@ -427,6 +595,7 @@ func testCanaryDomainLiteScan(t *testing.T) { // ── Test 3: CIDR Scan ──────────────────────────────────────────────────────── func testCanaryCIDRScan(t *testing.T) { + t.Cleanup(func() { dumpCanaryDiagnostics(t) }) log := NewTestLogger(t) log.Step("Canary: CIDR Scan (IP list)") @@ -454,7 +623,7 @@ func testCanaryCIDRScan(t *testing.T) { // Run the scan log.Step("Running cidr flow") - out, err := dockerExecLong(t, log, 25*time.Minute, + out, err := dockerExecStream(t, log, 25*time.Minute, "osmedeus", "run", "-f", "cidr", "-t", "/tmp/list-of-ips.txt") if err != nil { log.Error("CIDR scan command error: %v\nOutput: %s", err, out) @@ -490,3 +659,190 @@ func testCanaryCIDRScan(t *testing.T) { log.Success("CIDR scan canary passed") } + +// ── Test 4: General Scan ───────────────────────────────────────────────────── + +func testCanaryGeneralScan(t *testing.T) { + t.Cleanup(func() { dumpCanaryDiagnostics(t) }) + log := NewTestLogger(t) + log.Step("Canary: Domain-List-Recon Scan (hackerone.com subdomains)") + + expectedWS := "list-of-domains-file" + wsDir := canaryWorkspaceRoot + "/" + expectedWS + + // Create the domain list inside the container. + // NOTE: These are known hackerone.com subdomains used solely for testing + // the recon/scanning pipeline (probing, fingerprinting, content discovery). + log.Step("Creating target domain list") + domainList := strings.Join([]string{ + "hackerone.com", + "www.hackerone.com", + "api.hackerone.com", + "docs.hackerone.com", + "support.hackerone.com", + "gslink.hackerone.com", + "resources.hackerone.com", + "events.hackerone.com", + "mta-sts.hackerone.com", + "a]]b]c]d.hackerone.com", + "o1.email.hackerone.com", + "info.hackerone.com", + }, "\\n") + _, err := dockerExec(t, log, "sh", "-c", + fmt.Sprintf("printf '%s\\n' > /tmp/list-of-domains.txt", domainList)) + require.NoError(t, err, "failed to create domain list in container") + + // Verify file was created + assert.True(t, fileExistsInContainer(t, log, "/tmp/list-of-domains.txt"), + "domain list file should exist in container") + + // Run the scan (~40min timeout for domain-list-recon flow) + log.Step("Running domain-list-recon flow") + out, err := dockerExecStream(t, log, 40*time.Minute, + "osmedeus", "run", "--debug", "-f", "domain-list-recon", "-t", "/tmp/list-of-domains.txt") + if err != nil { + log.Error("Domain-list-recon scan command error: %v\nOutput: %s", err, out) + } + + // ── Filesystem checks ──────────────────────────────────────────────── + log.Step("Verifying filesystem artifacts") + + assert.True(t, dirExistsInContainer(t, log, wsDir), + "workspace directory %s should exist", wsDir) + + // Probing output (from normalize-http-probing module) + probingTxtFiles := findFilesInContainer(t, log, wsDir+"/probing", "*.txt") + log.Info("Found %d probing .txt files", len(probingTxtFiles)) + if len(probingTxtFiles) == 0 { + log.Info("WARN: no probing output — dnsx/httpx likely not installed in canary container") + } + + // HTTP fingerprint output (from recon-http-fp module) + fpFiles := findFilesInContainer(t, log, wsDir+"/fingerprint", "*.jsonl") + log.Info("Found %d fingerprint .jsonl files", len(fpFiles)) + assert.NotEmpty(t, fpFiles, "expected HTTP fingerprint JSONL files") + + // Content discovery output (from scan-content module) + cdFiles := findFilesInContainer(t, log, wsDir+"/content-discovery", "*") + log.Info("Found %d content-discovery files", len(cdFiles)) + + // Vulnerability scan output (from scan-vuln module — soft) + vulnFiles := findFilesInContainer(t, log, wsDir+"/vulnscan", "*.txt") + log.Info("Found %d vulnscan output files", len(vulnFiles)) + + // General: markdown reports across all phases + mdFiles := findFilesInContainer(t, log, wsDir, "*.md") + log.Info("Found %d markdown reports", len(mdFiles)) + + // General: all .jsonl files across workspace + jsonlFiles := findFilesInContainer(t, log, wsDir, "*.jsonl") + log.Info("Found %d total .jsonl files", len(jsonlFiles)) + assert.NotEmpty(t, jsonlFiles, "expected JSONL output files from fingerprinting/scanning") + + // ── Report content validation ──────────────────────────────────────── + log.Step("Validating report file contents") + + reports := []reportCheck{ + // normalize-http-probing outputs (soft — depends on dnsx/httpx availability) + {name: "http-results", path: wsDir + "/probing/http-list-of-domains-file.txt", fileType: "text", soft: true}, + // recon-http-fp outputs + {name: "http-fingerprint", path: wsDir + "/fingerprint/http-fingerprint-list-of-domains-file.jsonl", fileType: "jsonl", soft: false}, + {name: "http-interesting-filtered", path: wsDir + "/fingerprint/http-interesting-filtered-list-of-domains-file.md", fileType: "markdown", soft: false}, + // scan-content outputs + {name: "content-discovery-report", path: wsDir + "/content-discovery/content-discovery-report-list-of-domains-file.md", fileType: "markdown", soft: false}, + {name: "content-discovery-filtered", path: wsDir + "/content-discovery/content-discovery-filtered-list-of-domains-file.jsonl", fileType: "jsonl", soft: false}, + // scan-vuln outputs (soft — nuclei may find no vulnerabilities on the target) + {name: "nuclei-raw-json", path: wsDir + "/vulnscan/nuclei-jsonl-list-of-domains-file.txt", fileType: "text", soft: true}, + {name: "nuclei-markdown-report", path: wsDir + "/vulnscan/nuclei-overview-report-list-of-domains-file.md", fileType: "markdown", soft: true}, + } + + for _, rc := range reports { + if !fileExistsInContainer(t, log, rc.path) { + if rc.soft { + log.Info("WARN: report %s not found at %s (soft check — upstream may not have produced it)", rc.name, rc.path) + } else { + assert.Fail(t, fmt.Sprintf("report %s not found at %s", rc.name, rc.path)) + } + continue + } + + switch rc.fileType { + case "text", "markdown": + lines := lineCountInContainer(t, log, rc.path) + log.Info("Report %s (%s): %d non-empty lines", rc.name, rc.fileType, lines) + if rc.soft { + if lines == 0 { + log.Info("WARN: report %s has 0 non-empty lines", rc.name) + } + } else { + assert.Greater(t, lines, 0, "report %s should have non-empty lines", rc.name) + } + + case "jsonl": + validLines, jsonErrors := validateJSONLInContainer(t, log, rc.path) + log.Info("Report %s (jsonl): %d valid JSON lines", rc.name, validLines) + if len(jsonErrors) > 0 { + log.Info("Report %s: JSON parse errors: %s", rc.name, strings.Join(jsonErrors, "; ")) + } + if rc.soft { + if validLines == 0 { + log.Info("WARN: report %s has 0 valid JSON lines", rc.name) + } + } else { + assert.Greater(t, validLines, 0, "report %s should have valid JSON lines", rc.name) + assert.Empty(t, jsonErrors, "report %s should not have JSON parse errors", rc.name) + } + } + } + + // ── Database / API checks ──────────────────────────────────────────── + log.Step("Verifying database records via API") + + // Runs + runs := getRunsForWorkspace(t, expectedWS) + if len(runs) == 0 { + ws := findWorkspaceByName(t, expectedWS) + if ws != nil { + wsName, _ := ws["name"].(string) + runs = getRunsForWorkspace(t, wsName) + log.Info("Fallback workspace name: %s", wsName) + } + } + assert.NotEmpty(t, runs, "expected at least 1 run for workspace %s", expectedWS) + + if len(runs) > 0 { + firstRun, _ := runs[0].(map[string]any) + runUUID, _ := firstRun["run_uuid"].(string) + if runUUID != "" { + // Steps — domain-list-recon flow has multiple modules, expect step results + stepsResp := canaryAPIGet(t, fmt.Sprintf("/osm/api/runs/%s/steps", runUUID)) + stepsData, _ := stepsResp["data"].([]any) + assert.NotEmpty(t, stepsData, "expected step results for run %s", runUUID) + log.Info("Run %s has %d step results", runUUID, len(stepsData)) + + // Artifacts + artifactsResp := canaryAPIGet(t, fmt.Sprintf("/osm/api/runs/%s/artifacts", runUUID)) + artifactsData, _ := artifactsResp["data"].([]any) + log.Info("Run %s has %d artifacts", runUUID, len(artifactsData)) + } + } + + // Assets (subdomains/HTTP endpoints from recon-http-fp db_import_asset_from_file) + // NOTE: asset import depends on recon tools (httpx, etc.) which may not be + // installed in the canary container — only warn instead of hard-fail. + assets := getAssetsForWorkspace(t, expectedWS) + log.Info("Found %d assets for workspace %s", len(assets), expectedWS) + if len(assets) == 0 { + log.Info("WARN: no assets found — recon tools (httpx, etc.) likely not installed in canary container") + } + + // Vulnerabilities (from scan-vuln db_import_vuln_from_file) + vulns := getVulnsForWorkspace(t, expectedWS) + log.Info("Found %d vulnerabilities for workspace %s", len(vulns), expectedWS) + + // Workspace record + ws := findWorkspaceByName(t, expectedWS) + assert.NotNil(t, ws, "workspace record should exist in DB") + + log.Success("Domain-list-recon scan canary passed") +} diff --git a/test/testdata/workflows/test-example-report.yaml b/test/testdata/workflows/test-example-report.yaml index 7d1db75..cf47d27 100644 --- a/test/testdata/workflows/test-example-report.yaml +++ b/test/testdata/workflows/test-example-report.yaml @@ -2,6 +2,9 @@ name: test-example-report kind: module description: Test example report with nested parallel steps with mixed types tags: test,parallel,nested +help: + example_targets: ['example.com', 'httpbin.org'] + usage: osmedeus run -m test-example-report -t params: - name: target @@ -17,6 +20,7 @@ reports: path: "{{Output}}/http.json" type: json description: Structured JSON output + optional: true - name: markdown-report-report path: "{{Output}}/reports/sample-markdown-report.md"