From 2c9ab2d07c9a717823fd41f135e16393898e9c4c Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 12:40:30 +0200 Subject: [PATCH 1/7] fix(modules): enforce scope on text artifacts via explicit Scoped flag Add Artifact.Scoped so new host/URL/IP/host:port list artifacts opt into scope filtering at their declaration site instead of relying on the scopedTextArtifacts allowlist alone (kept as a safety net). Mark the eight target-list artifacts Scoped: true and add a regression test proving an unknown artifact name is filtered when Scoped is set. --- AGENTS.md | 2 +- .../modules/attacksurface/attacksurface.go | 7 +-- internal/modules/context.go | 12 ++++- internal/modules/context_test.go | 49 +++++++++++++++++++ internal/modules/dnsbrute/dnsbrute.go | 7 +-- internal/modules/dnsx/dnsx.go | 7 +-- internal/modules/gau/gau.go | 7 +-- internal/modules/httpx/httpx.go | 7 +-- internal/modules/katana/katana.go | 7 +-- internal/modules/naabu/naabu.go | 7 +-- internal/modules/subfinder/subfinder.go | 7 +-- 11 files changed, 92 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 36a3083..9f6e547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ScanForge is a Go CLI that orchestrates external recon/security tools into a sin - Every scanner is a `modules.Module` (`internal/modules/module.go`) declaring `Name()`, `Description()`, `Requires()`, `Produces()`, and `Run(ctx, *RunContext, runner.Executor)`. - `internal/orchestrator` builds a DAG from the modules selected by a profile, rejects duplicate producers/cycles/unresolvable deps at build time, then executes ready modules in waves (concurrent per wave, in profile-declared order for determinism). Upstream failure marks dependents `skipped` rather than aborting. - New scanner integrations: add `internal/modules//.go`, then wire it into `buildRegistry()` in `internal/app/app.go` (modules take `cfg.ToolPath("")`). Follow `internal/modules/dnsx/dnsx.go` as the pattern. -- Scope enforcement is the central security boundary. `internal/modules/context.go` `filterArtifact` is the single choke point: every line-oriented text artifact named in `scopedTextArtifacts` (subdomains, resolved_hosts, alive_urls, crawled_urls, open_ports, historical_urls) is filtered against the scope before any downstream module reads it; rejections land in `00_meta/scope-rejections.jsonl`. Adding a module that produces one of these artifacts needs no extra wiring. Never re-derive targets by parsing tool output directly. +- Scope enforcement is the central security boundary. `internal/modules/context.go` `filterArtifact` is the single choke point: every line-oriented text artifact declared with `Scoped: true` (or named in the legacy `scopedTextArtifacts` allowlist: subdomains, resolved_hosts, alive_urls, crawled_urls, open_ports, historical_urls, attack_surface_urls) is filtered against the scope before any downstream module reads it; rejections land in `00_meta/scope-rejections.jsonl`. New modules producing host/URL/IP/host:port list artifacts must set `Artifact.Scoped: true` — do not rely on the allowlist alone. Raw tool outputs (JSONL/XML) and derived data (wordlists, paths, secrets) must NOT set `Scoped`. Never re-derive targets by parsing tool output directly. - Modules must execute through the injected `runner.Executor` (never `os/exec` directly); `--dry-run` selects `NewDryRunExecutor`, which records commands without network access. - `scanforge plan TARGET --preset deep` prints the validated waves without running anything — use it to sanity-check profiles before `run`. - `scanforge.yaml` is user config (gitignored); `scope.txt` is an example scope file (also gitignored). Scope is mandatory, config is not. Runs land in `runs///` (dirs `00_meta/`…`06_vulns/`, plus `report.json`/`report.md`). diff --git a/internal/modules/attacksurface/attacksurface.go b/internal/modules/attacksurface/attacksurface.go index 17e08d6..d8ad472 100644 --- a/internal/modules/attacksurface/attacksurface.go +++ b/internal/modules/attacksurface/attacksurface.go @@ -118,9 +118,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, _ runner.E } if err := runCtx.AddArtifact("attack_surface_urls", modules.Artifact{ - Name: "attack_surface_urls", - Type: "text", - Path: outputRel, + Name: "attack_surface_urls", + Type: "text", + Path: outputRel, + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish attack surface: %w", err) } diff --git a/internal/modules/context.go b/internal/modules/context.go index dbf26b1..4861940 100644 --- a/internal/modules/context.go +++ b/internal/modules/context.go @@ -20,6 +20,14 @@ type Artifact struct { Name string Type string Path string + // Scoped marks line-oriented text artifacts that must be filtered + // against the effective scope before publication: host, URL, IP and + // host:port lists consumed by downstream modules. Raw tool outputs + // (JSONL/XML) and derived data (wordlists, paths, secrets) must NOT set + // it: they are produced from already-scoped inputs and their lines do + // not parse as targets. The legacy scopedTextArtifacts allowlist is kept + // as a safety net for the well-known artifact names. + Scoped bool } type RunContext struct { @@ -242,8 +250,8 @@ var scopedTextArtifacts = map[string]struct{}{ } func (c *RunContext) filterArtifact(name string, artifact Artifact) error { - _, requiresScopeFilter := scopedTextArtifacts[name] - if c.Scope == nil || c.Run == nil || c.DryRun || artifact.Type != "text" || !requiresScopeFilter { + _, legacyScoped := scopedTextArtifacts[name] + if c.Scope == nil || c.Run == nil || c.DryRun || artifact.Type != "text" || (!artifact.Scoped && !legacyScoped) { return nil } diff --git a/internal/modules/context_test.go b/internal/modules/context_test.go index 6bb69a7..b7068ac 100644 --- a/internal/modules/context_test.go +++ b/internal/modules/context_test.go @@ -220,6 +220,55 @@ func TestAddArtifactDoesNotFilterNonTargetText(t *testing.T) { } } +func TestAddArtifactScopedFlagFiltersUnknownArtifactNames(t *testing.T) { + root := t.TempDir() + run := &storage.Run{ + RootDir: root, + MetaDir: filepath.Join(root, "00_meta"), + Manifest: storage.RunManifest{Outputs: make(map[string]string)}, + } + if err := os.MkdirAll(run.MetaDir, 0755); err != nil { + t.Fatal(err) + } + + scope := &scanScope.Scope{ + ExactHosts: map[string]struct{}{"example.com": {}}, + Wildcards: []string{"example.com"}, + } + ctx := NewRunContext("example.com", "full", false, run, scope) + + // A future module producing a host list under a name that is NOT in the + // legacy scopedTextArtifacts allowlist must still be filtered when it + // declares Scoped: true. This is the regression guard for the explicit + // opt-in mechanism. + artifactPath := filepath.Join(root, "vhosts.txt") + input := "vhost.example.com\nvhost.outside.test\n" + if err := os.WriteFile(artifactPath, []byte(input), 0644); err != nil { + t.Fatal(err) + } + + err := ctx.AddArtifact("vhosts", Artifact{ + Name: "vhosts", + Type: "text", + Path: "vhosts.txt", + Scoped: true, + }) + if err != nil { + t.Fatalf("AddArtifact() error = %v", err) + } + + filtered, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + if string(filtered) != "vhost.example.com\n" { + t.Fatalf("filtered artifact = %q, want only the in-scope host", filtered) + } + if got := ctx.RejectedCount("vhosts"); got != 1 { + t.Fatalf("RejectedCount() = %d, want 1", got) + } +} + func TestRunContextEmitFindingNoSinkIsNoop(t *testing.T) { ctx := NewRunContext("example.com", "passive", false, nil) // Must not panic when no sink was installed (e.g. a module test that diff --git a/internal/modules/dnsbrute/dnsbrute.go b/internal/modules/dnsbrute/dnsbrute.go index 1fd5ae5..eed8717 100644 --- a/internal/modules/dnsbrute/dnsbrute.go +++ b/internal/modules/dnsbrute/dnsbrute.go @@ -113,9 +113,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r } if err := runCtx.AddArtifact("brute_subdomains", modules.Artifact{ - Name: "brute_subdomains", - Type: "text", - Path: "01_subdomains/brute.txt", + Name: "brute_subdomains", + Type: "text", + Path: "01_subdomains/brute.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish bruteforce results: %w", err) } diff --git a/internal/modules/dnsx/dnsx.go b/internal/modules/dnsx/dnsx.go index 0d8fc55..325288e 100644 --- a/internal/modules/dnsx/dnsx.go +++ b/internal/modules/dnsx/dnsx.go @@ -73,9 +73,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r return nil, fmt.Errorf("failed to publish DNS results: %w", err) } if err := runCtx.AddArtifact("resolved_hosts", modules.Artifact{ - Name: "resolved_hosts", - Type: "text", - Path: "01_subdomains/dnsx.txt", + Name: "resolved_hosts", + Type: "text", + Path: "01_subdomains/dnsx.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish resolved hosts: %w", err) } diff --git a/internal/modules/gau/gau.go b/internal/modules/gau/gau.go index a3946e3..b525aec 100644 --- a/internal/modules/gau/gau.go +++ b/internal/modules/gau/gau.go @@ -50,9 +50,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r return nil, fmt.Errorf("failed to run command %q: %w", cmd.Name, err) } if err := runCtx.AddArtifact("historical_urls", modules.Artifact{ - Name: "historical_urls", - Type: "text", - Path: "05_content/gau.txt", + Name: "historical_urls", + Type: "text", + Path: "05_content/gau.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish historical URLs: %w", err) } diff --git a/internal/modules/httpx/httpx.go b/internal/modules/httpx/httpx.go index 71cd441..7ecf1c0 100644 --- a/internal/modules/httpx/httpx.go +++ b/internal/modules/httpx/httpx.go @@ -104,9 +104,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r return nil, fmt.Errorf("failed to publish HTTP results: %w", err) } if err := runCtx.AddArtifact("alive_urls", modules.Artifact{ - Name: "alive_urls", - Type: "text", - Path: "02_http/alive.txt", + Name: "alive_urls", + Type: "text", + Path: "02_http/alive.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish alive URLs: %w", err) } diff --git a/internal/modules/katana/katana.go b/internal/modules/katana/katana.go index 05a3304..d63f393 100644 --- a/internal/modules/katana/katana.go +++ b/internal/modules/katana/katana.go @@ -58,9 +58,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r } if err := runCtx.AddArtifact("crawled_urls", modules.Artifact{ - Name: "crawled_urls", - Type: "text", - Path: "05_content/katana.txt", + Name: "crawled_urls", + Type: "text", + Path: "05_content/katana.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish crawled URLs: %w", err) } diff --git a/internal/modules/naabu/naabu.go b/internal/modules/naabu/naabu.go index 0944eae..5b774ba 100644 --- a/internal/modules/naabu/naabu.go +++ b/internal/modules/naabu/naabu.go @@ -54,9 +54,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r } if err := runCtx.AddArtifact("open_ports", modules.Artifact{ - Name: "open_ports", - Type: "text", - Path: "03_ports/naabu.txt", + Name: "open_ports", + Type: "text", + Path: "03_ports/naabu.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("failed to publish open ports: %w", err) } diff --git a/internal/modules/subfinder/subfinder.go b/internal/modules/subfinder/subfinder.go index c656f5b..22bfbed 100644 --- a/internal/modules/subfinder/subfinder.go +++ b/internal/modules/subfinder/subfinder.go @@ -75,9 +75,10 @@ func (m *Module) Run(ctx context.Context, runCtx *modules.RunContext, executor r } if err := runCtx.AddArtifact("subdomains", modules.Artifact{ - Name: "subdomains", - Type: "text", - Path: "01_subdomains/subfinder.txt", + Name: "subdomains", + Type: "text", + Path: "01_subdomains/subfinder.txt", + Scoped: true, }); err != nil { return nil, fmt.Errorf("publish subdomains artifact: %w", err) } From 30edcc9de0656257ea35fad611c13f2f5873abd1 Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 12:40:33 +0200 Subject: [PATCH 2/7] fix(update): rebuild with version ldflags and install over running binary go install without ldflags produced binaries reporting 0.0.1/dev/unknown, so the update appeared to never change the version. Resolve the latest release and commit via go list -m and git ls-remote, rebuild with the same ldflags as the Makefile/release pipeline, and replace the running binary in place (falling back to GOBIN with a warning). Extract updateTools for the --tools flag. --- internal/app/update.go | 250 ++++++++++++++++++++++++++++++++---- internal/app/update_test.go | 230 +++++++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 internal/app/update_test.go diff --git a/internal/app/update.go b/internal/app/update.go index 6aa2124..3b3c617 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -2,56 +2,250 @@ package app import ( "context" + "encoding/json" "fmt" "os" "os/exec" + "path/filepath" + "runtime" + "strings" + "time" "github.com/MikeRoss27/scanforge/internal/ui" + "github.com/MikeRoss27/scanforge/internal/version" ) +const updateModulePath = "github.com/MikeRoss27/scanforge" + type UpdateOptions struct { Tools bool } +// moduleInfo is the subset of `go list -m -json` output we need. +type moduleInfo struct { + Path string `json:"Path"` + Version string `json:"Version"` +} + func (a *App) Update(ctx context.Context, opts UpdateOptions) error { if _, err := exec.LookPath("go"); err != nil { return fmt.Errorf("the 'go' command was not found in PATH, which is required for updating: %w", err) } - ui.Info("Updating scanforge...") - cmd := exec.CommandContext(ctx, "go", "install", "github.com/MikeRoss27/scanforge/cmd/scanforge@latest") + latest, err := latestVersion(ctx) + if err != nil { + return fmt.Errorf("failed to resolve the latest scanforge version: %w", err) + } + + if version.Version == strings.TrimPrefix(latest.Version, "v") && version.Commit != "dev" { + ui.Info("ScanForge is already up to date (%s).", latest.Version) + return a.updateTools(ctx, opts) + } + + ui.Info("Updating scanforge %s -> %s ...", version.Version, latest.Version) + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot locate the running scanforge binary: %w", err) + } + dest, err := installDest(ctx, exe) + if err != nil { + return err + } + if err := buildBinary(ctx, latest, dest); err != nil { + return err + } + + if filepath.Dir(dest) != filepath.Dir(exe) { + ui.Warn("Installed to %s, but the running binary lives in %s. Add %s to your PATH or move the binary.", + dest, filepath.Dir(exe), filepath.Dir(dest)) + } + ui.Success("ScanForge updated to %s (%s).", latest.Version, dest) + + return a.updateTools(ctx, opts) +} + +// latestVersion resolves the newest tagged release of the module through the +// Go module proxy (same resolution `go install @latest` would use). +func latestVersion(ctx context.Context) (moduleInfo, error) { + out, err := exec.CommandContext(ctx, "go", "list", "-m", "-json", updateModulePath+"@latest").Output() + if err != nil { + return moduleInfo{}, err + } + var info moduleInfo + if err := json.Unmarshal(out, &info); err != nil { + return moduleInfo{}, err + } + if info.Version == "" { + return moduleInfo{}, fmt.Errorf("no version reported for %s@latest", updateModulePath) + } + return info, nil +} + +// installDest returns where the new binary should be written: the directory of +// the running executable when writable, otherwise $GOBIN (or $GOPATH/bin). +func installDest(ctx context.Context, exe string) (string, error) { + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + if isWritableDir(filepath.Dir(exe)) { + return filepath.Join(filepath.Dir(exe), binaryName()), nil + } + binDir, err := goBinDir(ctx) + if err != nil { + return "", fmt.Errorf("cannot write next to the running binary (%s) and cannot locate GOBIN: %w", exe, err) + } + return filepath.Join(binDir, binaryName()), nil +} + +func binaryName() string { + if runtime.GOOS == "windows" { + return "scanforge.exe" + } + return "scanforge" +} + +func isWritableDir(dir string) bool { + f, err := os.CreateTemp(dir, ".scanforge-write-test-*") + if err != nil { + return false + } + name := f.Name() + _ = f.Close() + _ = os.Remove(name) + return true +} + +func goBinDir(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "go", "env", "GOBIN").Output() + if err != nil { + return "", err + } + if bin := strings.TrimSpace(string(out)); bin != "" { + return bin, nil + } + out, err = exec.CommandContext(ctx, "go", "env", "GOPATH").Output() + if err != nil { + return "", err + } + if gopath := strings.TrimSpace(string(out)); gopath != "" { + return filepath.Join(gopath, "bin"), nil + } + return "", fmt.Errorf("neither GOBIN nor GOPATH is set") +} + +// buildBinary installs the given module version with the same ldflags as +// release builds (version/commit/date metadata), then moves the binary into +// place at dest. A plain `go install` would produce a binary reporting +// version 0.0.1/dev, so the metadata must be injected explicitly. +func buildBinary(ctx context.Context, info moduleInfo, dest string) error { + commit, err := resolveCommit(ctx, info.Version) + if err != nil { + ui.Warn("Could not resolve the commit for %s: %v", info.Version, err) + commit = "unknown" + } + date := time.Now().UTC().Format("2006-01-02T15:04:05Z") + ldflags := fmt.Sprintf( + "-s -w -X github.com/MikeRoss27/scanforge/internal/version.Version=%s -X github.com/MikeRoss27/scanforge/internal/version.Commit=%s -X github.com/MikeRoss27/scanforge/internal/version.Date=%s", + strings.TrimPrefix(info.Version, "v"), commit, date) + + cmd := exec.CommandContext(ctx, "go", "install", + "-ldflags", ldflags, + updateModulePath+"/cmd/scanforge@"+info.Version) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to update scanforge: %w", err) - } - ui.Success("ScanForge updated successfully.") - - if opts.Tools { - tools := []string{ - "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest", - "github.com/projectdiscovery/dnsx/cmd/dnsx@latest", - "github.com/projectdiscovery/httpx/cmd/httpx@latest", - "github.com/projectdiscovery/naabu/v2/cmd/naabu@latest", - "github.com/projectdiscovery/katana/cmd/katana@latest", - "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest", - "github.com/ffuf/ffuf/v2@latest", + return fmt.Errorf("failed to build scanforge %s: %w", info.Version, err) + } + + binDir, err := goBinDir(ctx) + if err != nil { + return err + } + built := filepath.Join(binDir, binaryName()) + if err := moveFile(built, dest); err != nil { + return fmt.Errorf("failed to install the new binary at %s: %w", dest, err) + } + return nil +} + +// resolveCommit returns the short commit hash a release tag points to, +// preferring the peeled (annotated tag) ref. +func resolveCommit(ctx context.Context, tag string) (string, error) { + out, err := exec.CommandContext(ctx, "git", "ls-remote", "https://"+updateModulePath, + "refs/tags/"+tag+"^{}", "refs/tags/"+tag).Output() + if err != nil { + return "", err + } + short := func(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha + } + var peeled, plain string + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue } - fmt.Println() - ui.Info("Updating external tools...") - for _, tool := range tools { - ui.Info("Updating %s ...", tool) - tcmd := exec.CommandContext(ctx, "go", "install", tool) - - if err := tcmd.Run(); err != nil { - ui.Warn("Failed to update %s: %v", tool, err) - } else { - ui.Success("Updated %s", tool) - } + if strings.HasSuffix(fields[1], "^{}") { + peeled = fields[0] + } else { + plain = fields[0] } - ui.Success("External tools updated.") } + switch { + case peeled != "": + return short(peeled), nil + case plain != "": + return short(plain), nil + default: + return "", fmt.Errorf("tag %s not found in https://%s", tag, updateModulePath) + } +} +// moveFile renames src over dest, falling back to a copy when the rename +// crosses filesystems or is otherwise refused. +func moveFile(src, dest string) error { + if err := os.Rename(src, dest); err == nil { + return nil + } + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.WriteFile(dest, data, 0o755); err != nil { + return err + } + return os.Remove(src) +} + +func (a *App) updateTools(ctx context.Context, opts UpdateOptions) error { + if !opts.Tools { + return nil + } + tools := []string{ + "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest", + "github.com/projectdiscovery/dnsx/cmd/dnsx@latest", + "github.com/projectdiscovery/httpx/cmd/httpx@latest", + "github.com/projectdiscovery/naabu/v2/cmd/naabu@latest", + "github.com/projectdiscovery/katana/cmd/katana@latest", + "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest", + "github.com/ffuf/ffuf/v2@latest", + } + fmt.Println() + ui.Info("Updating external tools...") + for _, tool := range tools { + ui.Info("Updating %s ...", tool) + tcmd := exec.CommandContext(ctx, "go", "install", tool) + + if err := tcmd.Run(); err != nil { + ui.Warn("Failed to update %s: %v", tool, err) + } else { + ui.Success("Updated %s", tool) + } + } + ui.Success("External tools updated.") return nil } diff --git a/internal/app/update_test.go b/internal/app/update_test.go new file mode 100644 index 0000000..b9f9835 --- /dev/null +++ b/internal/app/update_test.go @@ -0,0 +1,230 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MikeRoss27/scanforge/internal/version" +) + +// fakeTool writes an executable shell script into dir and returns its path. +func fakeTool(t *testing.T, dir, name, script string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestUpdateRequiresGo(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if err := (&App{}).Update(context.Background(), UpdateOptions{}); err == nil { + t.Fatal("expected an error when go is not in PATH") + } +} + +func TestLatestVersion(t *testing.T) { + dir := t.TempDir() + record := filepath.Join(dir, "args") + fakeTool(t, dir, "go", `echo "$@" >> "`+record+`" +case "$1" in + list) echo '{"Path":"github.com/MikeRoss27/scanforge","Version":"v0.5.0"}';; +esac`) + t.Setenv("PATH", dir) + + info, err := latestVersion(context.Background()) + if err != nil { + t.Fatal(err) + } + if info.Version != "v0.5.0" { + t.Fatalf("Version = %q, want v0.5.0", info.Version) + } + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "list -m -json github.com/MikeRoss27/scanforge@latest") { + t.Fatalf("go invocation = %q", data) + } +} + +func TestLatestVersionError(t *testing.T) { + dir := t.TempDir() + fakeTool(t, dir, "go", `echo boom >&2; exit 1`) + t.Setenv("PATH", dir) + + if _, err := latestVersion(context.Background()); err == nil { + t.Fatal("expected an error when go list fails") + } +} + +func TestInstallDest(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "bin", "scanforge") + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + + dest, err := installDest(context.Background(), exe) + if err != nil { + t.Fatal(err) + } + if dest != filepath.Join(dir, "bin", "scanforge") { + t.Fatalf("dest = %q", dest) + } +} + +func TestInstallDestFallsBackToGOBIN(t *testing.T) { + dir := t.TempDir() + // The executable's directory does not exist, so it is not writable. + exe := filepath.Join(dir, "missing", "scanforge") + + gobin := filepath.Join(dir, "gobin") + if err := os.MkdirAll(gobin, 0o755); err != nil { + t.Fatal(err) + } + fakeTool(t, dir, "go", `echo "`+gobin+`"`) + t.Setenv("PATH", dir) + + dest, err := installDest(context.Background(), exe) + if err != nil { + t.Fatal(err) + } + if dest != filepath.Join(gobin, "scanforge") { + t.Fatalf("dest = %q", dest) + } +} + +func TestBuildBinaryInstallsWithMetadata(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "scanforge") + gobin := filepath.Join(dir, "gobin") + if err := os.MkdirAll(gobin, 0o755); err != nil { + t.Fatal(err) + } + record := filepath.Join(dir, "args") + fakeTool(t, dir, "go", `echo "$@" >> "`+record+`" +case "$1" in + env) echo "`+gobin+`";; + install) : > "`+gobin+`/scanforge";; +esac`) + fakeTool(t, dir, "git", `echo "990b8635e24c0c1e8de5af1e72d233bb43bff5e7 refs/tags/v0.5.0^{}"`) + t.Setenv("PATH", dir) + + info := moduleInfo{Path: updateModulePath, Version: "v0.5.0"} + if err := buildBinary(context.Background(), info, dest); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dest); err != nil { + t.Fatalf("dest not created: %v", err) + } + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + args := string(data) + if !strings.Contains(args, "install -ldflags") { + t.Fatalf("go install not invoked: %q", args) + } + for _, want := range []string{ + "version.Version=0.5.0", + "version.Commit=990b863", + "version.Date=", + "cmd/scanforge@v0.5.0", + } { + if !strings.Contains(args, want) { + t.Fatalf("go args %q missing %q", args, want) + } + } +} + +func TestBuildBinaryToleratesMissingGit(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "scanforge") + gobin := filepath.Join(dir, "gobin") + if err := os.MkdirAll(gobin, 0o755); err != nil { + t.Fatal(err) + } + record := filepath.Join(dir, "args") + fakeTool(t, dir, "go", `echo "$@" >> "`+record+`" +case "$1" in + env) echo "`+gobin+`";; + install) : > "`+gobin+`/scanforge";; +esac`) + t.Setenv("PATH", dir) // no git available + + info := moduleInfo{Path: updateModulePath, Version: "v0.5.0"} + if err := buildBinary(context.Background(), info, dest); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dest); err != nil { + t.Fatalf("dest not created: %v", err) + } + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "version.Commit=unknown") { + t.Fatalf("expected fallback commit: %q", data) + } +} + +func TestResolveCommitPrefersPeeledTag(t *testing.T) { + dir := t.TempDir() + fakeTool(t, dir, "git", `echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.5.0" +echo "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v0.5.0^{}"`) + t.Setenv("PATH", dir) + + commit, err := resolveCommit(context.Background(), "v0.5.0") + if err != nil { + t.Fatal(err) + } + if commit != "bbbbbbb" { + t.Fatalf("commit = %q, want bbbbbbb", commit) + } +} + +func TestResolveCommitNotFound(t *testing.T) { + dir := t.TempDir() + fakeTool(t, dir, "git", `exit 0`) + t.Setenv("PATH", dir) + + if _, err := resolveCommit(context.Background(), "v9.9.9"); err == nil { + t.Fatal("expected an error for an unknown tag") + } +} + +func TestUpdateAlreadyUpToDate(t *testing.T) { + oldVersion, oldCommit := version.Version, version.Commit + version.Version = "0.5.0" + version.Commit = "990b863" + defer func() { + version.Version, version.Commit = oldVersion, oldCommit + }() + + dir := t.TempDir() + record := filepath.Join(dir, "args") + fakeTool(t, dir, "go", `echo "$@" >> "`+record+`" +case "$1" in + list) echo '{"Path":"github.com/MikeRoss27/scanforge","Version":"v0.5.0"}';; +esac`) + t.Setenv("PATH", dir) + + if err := (&App{}).Update(context.Background(), UpdateOptions{}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "install") { + t.Fatalf("install must be skipped when already up to date: %q", data) + } +} From 2b163bc3ccb3144b4dee15f82f7adb07dbd4505c Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 12:40:35 +0200 Subject: [PATCH 3/7] feat(cli): polish help output with command groups and examples Organize the root help into Core/Reports/Configuration/Maintenance groups (disabling cobra command sorting), and give every subcommand a descriptive Long description and Examples section. --- internal/cli/auth.go | 26 ++++++++++++++++---------- internal/cli/diff.go | 7 +++++-- internal/cli/doctor.go | 11 +++++++++-- internal/cli/export.go | 7 +++++-- internal/cli/init.go | 11 +++++++++-- internal/cli/plan.go | 15 +++++++++++---- internal/cli/run.go | 21 +++++++++++++++++++-- internal/cli/update.go | 10 +++++++--- internal/cli/version.go | 5 +++-- 9 files changed, 84 insertions(+), 29 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 6c7dd5f..711bbeb 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -12,15 +12,19 @@ import ( func NewAuthCommand(app *app.App) *cobra.Command { cmd := &cobra.Command{ - Use: "auth", - Short: "Manage API keys and authentication for security tools", - Long: `Manage API keys for underlying tools like subfinder, nuclei, etc.`, + Use: "auth", + GroupID: groupConfig, + Short: "Manage API keys for the security tools", + Long: `Manages API keys for the tools that need them (shodan, chaos, +github, virustotal, ...). Keys are stored locally, listed in a masked +form, and applied to the tools with 'scanforge auth sync'.`, } setCmd := &cobra.Command{ - Use: "set [provider] [api_key]", - Short: "Set an API key for a specific provider (e.g. shodan, github, chaos)", - Args: cobra.ExactArgs(2), + Use: "set [provider] [api_key]", + Short: "Set an API key for a specific provider (e.g. shodan, github, chaos)", + Example: " scanforge auth set shodan ", + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { provider := strings.ToLower(args[0]) key := args[1] @@ -42,8 +46,9 @@ func NewAuthCommand(app *app.App) *cobra.Command { } listCmd := &cobra.Command{ - Use: "list", - Short: "List configured API providers", + Use: "list", + Short: "List configured API providers (keys are masked)", + Example: " scanforge auth list", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := auth.Load() if err != nil { @@ -70,8 +75,9 @@ func NewAuthCommand(app *app.App) *cobra.Command { } syncCmd := &cobra.Command{ - Use: "sync", - Short: "Synchronize API keys with underlying tools configurations", + Use: "sync", + Short: "Apply the configured API keys to the tools", + Example: " scanforge auth sync", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := auth.Load() if err != nil { diff --git a/internal/cli/diff.go b/internal/cli/diff.go index 68ddcfe..3d1c394 100644 --- a/internal/cli/diff.go +++ b/internal/cli/diff.go @@ -12,11 +12,14 @@ import ( func NewDiffCommand(application *app.App) *cobra.Command { var jsonOut bool cmd := &cobra.Command{ - Use: "diff ", - Short: "Show what changed between two runs of the same target", + Use: "diff ", + GroupID: groupReports, + Short: "Show what changed between two runs of the same target", Long: "Loads the two run directories (runs//), " + "reconsolidates their reports from the raw artifacts and lists the " + "assets, ports and vulnerabilities that appeared or disappeared.", + Example: ` scanforge diff runs/example.com/2026-08-18T10:00:00Z runs/example.com/2026-08-19T10:00:00Z + scanforge diff runs/example.com/2026-08-18T10:00:00Z runs/example.com/2026-08-19T10:00:00Z --json`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { delta, rendered, err := application.Diff(cmd.Context(), app.DiffOptions{ diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index fe85160..83aa326 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -11,8 +11,15 @@ func NewDoctorCommand(application *app.App) *cobra.Command { var verbose bool cmd := &cobra.Command{ - Use: "doctor", - Short: "Check local dependencies", + Use: "doctor", + GroupID: groupConfig, + Short: "Check local dependencies and configuration", + Long: `Verifies that the external tools required by the selected profile are +installed and that the workspace is ready for a scan (writable runs +directory, scanforge.yaml and scope.txt present).`, + Example: ` scanforge doctor + scanforge doctor --profile web + scanforge doctor --json`, RunE: func(cmd *cobra.Command, args []string) error { return application.Doctor(cmd.Context(), app.DoctorOptions{ Profile: profile, diff --git a/internal/cli/export.go b/internal/cli/export.go index 6cec3a4..fc2f739 100644 --- a/internal/cli/export.go +++ b/internal/cli/export.go @@ -12,12 +12,15 @@ func NewExportCommand(application *app.App) *cobra.Command { var format string var out string cmd := &cobra.Command{ - Use: "export ", - Short: "Export a run report in a machine-readable format", + Use: "export ", + GroupID: groupReports, + Short: "Export a run report in a machine-readable format", Long: "Reconsolidates the report of a run directory " + "(runs//) and writes it as SARIF 2.1.0 " + "(GitHub code scanning, GitLab SAST) or as DefectDojo generic " + "findings for import-scan.", + Example: ` scanforge export runs/example.com/2026-08-18T10:00:00Z + scanforge export runs/example.com/2026-08-18T10:00:00Z --format defectdojo -o findings.json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { exportFormat, err := app.ParseExportFormat(format) diff --git a/internal/cli/init.go b/internal/cli/init.go index 072bf08..c90c823 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -9,8 +9,15 @@ func NewInitCommand(application *app.App) *cobra.Command { var force bool cmd := &cobra.Command{ - Use: "init", - Short: "Create default ScanForge config files", + Use: "init", + GroupID: groupConfig, + Short: "Create default ScanForge config files", + Long: `Creates the default configuration files in the current directory: +scanforge.yaml (profiles, tool paths, options) and scope.txt (the targets +you are authorized to scan). Scope is mandatory: scans refuse to run +without it.`, + Example: ` scanforge init + scanforge init --force # overwrite existing files`, RunE: func(cmd *cobra.Command, args []string) error { return application.Init(cmd.Context(), app.InitOptions{ Force: force, diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 3ca179f..984da91 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -19,9 +19,16 @@ func NewPlanCommand(application *app.App) *cobra.Command { var exclusions []string var targetsFile string cmd := &cobra.Command{ - Use: "plan ", - Short: "Show the validated scan pipeline without creating a run", - Args: cobra.MaximumNArgs(1), + Use: "plan ", + GroupID: groupCore, + Short: "Preview the validated scan pipeline without running it", + Long: `Validates the profile, scope and module dependencies, then prints the +execution waves (which modules run, in what order, and what each one +requires) without executing anything. Use it to sanity-check a profile +before running a scan.`, + Example: ` scanforge plan example.com --preset deep + scanforge plan --targets targets.txt --profile web`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // --profile takes precedence; --preset is shorthand for one of // the built-in profile names. @@ -46,7 +53,7 @@ func NewPlanCommand(application *app.App) *cobra.Command { return nil }, } - cmd.Flags().StringVarP(&profile, "profile", "p", "", "Scan preset/profile to inspect") + cmd.Flags().StringVarP(&profile, "profile", "p", "", "Scan profile to inspect (default from config)") cmd.Flags().StringVar(&preset, "preset", "", "User-oriented preset (safe, recon, web, ports, vuln, deep)") cmd.Flags().StringVar(&targetsFile, "targets", "", "File with one target per line (multi-target engagement; exclusive with a positional target)") cmd.Flags().StringVarP(&scopeFile, "scope", "s", "", "Scope file (default from config)") diff --git a/internal/cli/run.go b/internal/cli/run.go index a2bfefe..ea1f13f 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -42,8 +42,25 @@ func NewRunCommand(application *app.App) *cobra.Command { cmd := &cobra.Command{ Use: "run ", Aliases: []string{"scan"}, - Short: "Run a scan profile against an authorized target", - Args: cobra.MaximumNArgs(1), + GroupID: groupCore, + Short: "Run a scan against an authorized target", + Long: `Executes the scan pipeline for a target (or a list of targets) using +the selected profile or preset. The effective scope is built from scope.txt +and every artifact is filtered against it before downstream modules consume +it. Results are written to runs/// with a consolidated +report (report.json / report.md).`, + Example: ` # Scan a single target with the default profile + scanforge run example.com + + # Deep preset: full subdomain enumeration, port scan and vulnerability scan + scanforge run example.com --preset deep + + # Multi-target engagement from a file + scanforge run --targets targets.txt --profile web + + # Preview the commands without executing them + scanforge run example.com --dry-run`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // --profile takes precedence; --preset is shorthand for one of // the built-in profile names. diff --git a/internal/cli/update.go b/internal/cli/update.go index 3ca4757..3199ce3 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -9,9 +9,13 @@ func NewUpdateCommand(application *app.App) *cobra.Command { var opts app.UpdateOptions cmd := &cobra.Command{ - Use: "update", - Short: "Update scanforge and its dependencies", - Long: `Update scanforge to the latest version via go install. You can also update external tools using the --tools flag.`, + Use: "update", + GroupID: groupMaintenance, + Short: "Update scanforge to the latest release", + Long: `Update scanforge to the latest release, replacing the running binary. +You can also update external tools using the --tools flag.`, + Example: ` scanforge update + scanforge update --tools # also update subfinder, nuclei, ...`, RunE: func(cmd *cobra.Command, args []string) error { return application.Update(cmd.Context(), opts) }, diff --git a/internal/cli/version.go b/internal/cli/version.go index 0fa03d1..57ffbf9 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -11,8 +11,9 @@ import ( func NewVersionCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "version", - Short: "Print ScanForge version information", + Use: "version", + GroupID: groupMaintenance, + Short: "Print ScanForge version information", Run: func(cmd *cobra.Command, args []string) { var body string body += fmt.Sprintf("%-10s %s\n", ui.DimBold("VERSION"), ui.Primary(version.Version)) From 27134ed009a28bcd450861c90ef5eab72a6a5bad Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 12:40:37 +0200 Subject: [PATCH 4/7] feat(cli): add config validate command and CLI smoke tests scanforge config validate loads scanforge.yaml and checks the config version, default profile resolution, custom profiles referencing only known modules, custom tool paths existing on disk, and a parseable default scope file, exiting non-zero on problems. Add CLI tests covering root/subcommand help, version output and the validate command. --- internal/app/config.go | 147 ++++++++++++++++++++++++++++++++++ internal/app/config_test.go | 153 ++++++++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 147 ++++++++++++++++++++++++++++++++++ internal/cli/config.go | 43 ++++++++++ internal/cli/root.go | 34 +++++++- 5 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 internal/app/config.go create mode 100644 internal/app/config_test.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/config.go diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..13f6584 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,147 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/MikeRoss27/scanforge/internal/config" + "github.com/MikeRoss27/scanforge/internal/profile" + scanscope "github.com/MikeRoss27/scanforge/internal/scope" + "github.com/MikeRoss27/scanforge/internal/ui" +) + +// ValidateConfigResult is the outcome of a config validation: the resolved +// config path plus problems (hard errors) and warnings (soft issues). +type ValidateConfigResult struct { + Path string + Problems []string + Warnings []string +} + +// ValidateConfig loads scanforge.yaml and checks that it is usable: supported +// config version, resolvable default profile, custom profiles referencing only +// known modules, custom tool paths that exist on disk, and a parseable +// default scope file. A missing default scope file is only a warning: it is +// the normal state before `scanforge init` has run. +func (a *App) ValidateConfig(ctx context.Context) (*ValidateConfigResult, error) { + path := config.ResolvePath(a.ConfigPath) + cfg, err := config.Load(path) + if err != nil { + return nil, err + } + + result := &ValidateConfigResult{Path: path} + + if cfg.ConfigVersion != config.DefaultConfigVersion { + result.Problems = append(result.Problems, + fmt.Sprintf("config_version %d is not supported (expected %d)", cfg.ConfigVersion, config.DefaultConfigVersion)) + } + + if _, err := profile.Resolve(cfg.DefaultProfile, cfg.Profiles); err != nil { + result.Problems = append(result.Problems, + fmt.Sprintf("default_profile %q: %v", cfg.DefaultProfile, err)) + } + + registry := buildRegistry(cfg) + profileNames := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + profileNames = append(profileNames, name) + } + sort.Strings(profileNames) + for _, name := range profileNames { + for _, moduleName := range cfg.Profiles[name] { + if _, ok := registry.Get(moduleName); !ok { + result.Problems = append(result.Problems, + fmt.Sprintf("profile %q references unknown module %q", name, moduleName)) + } + } + } + + for tool, toolPath := range customToolPaths(cfg) { + if _, err := os.Stat(toolPath); err != nil { + result.Problems = append(result.Problems, + fmt.Sprintf("tool %q path %q does not exist", tool, toolPath)) + } + } + + if cfg.DefaultScope != "" { + if _, err := scanscope.LoadFromFile(cfg.DefaultScope); err != nil { + if errors.Is(err, os.ErrNotExist) { + result.Warnings = append(result.Warnings, + fmt.Sprintf("default_scope file %q not found (run `scanforge init`)", cfg.DefaultScope)) + } else { + result.Problems = append(result.Problems, + fmt.Sprintf("default_scope file %q: %v", cfg.DefaultScope, err)) + } + } + } + + return result, nil +} + +// customToolPaths returns the tools whose configured path is not a bare +// command name (those are resolved through PATH at run time and cannot be +// statically checked). +func customToolPaths(cfg *config.Config) map[string]string { + paths := map[string]string{ + "subfinder": cfg.Tools.Subfinder, + "dnsx": cfg.Tools.Dnsx, + "httpx": cfg.Tools.Httpx, + "naabu": cfg.Tools.Naabu, + "nmap": cfg.Tools.Nmap, + "whatweb": cfg.Tools.Whatweb, + "wafw00f": cfg.Tools.Wafw00f, + "katana": cfg.Tools.Katana, + "ffuf": cfg.Tools.Ffuf, + "nuclei": cfg.Tools.Nuclei, + "gau": cfg.Tools.Gau, + "tlsx": cfg.Tools.Tlsx, + "shuffledns": cfg.Tools.Shuffledns, + "chromium": cfg.Tools.Chromium, + } + for name, path := range paths { + if path == "" || !strings.ContainsAny(path, `/\`) { + delete(paths, name) + } + } + return paths +} + +// PrintValidateConfig renders the validation result and returns an +// ExitCodeError when problems were found, so the shell sees a failing +// command without os.Exit in library code. +func (a *App) PrintValidateConfig(result *ValidateConfigResult) error { + fmt.Println(ui.Bold(ui.Primary("ScanForge Config Validation"))) + fmt.Println() + fmt.Printf("Config file: %s\n", result.Path) + + if len(result.Problems) == 0 && len(result.Warnings) == 0 { + fmt.Println() + ui.Success("Configuration is valid.") + return nil + } + + if len(result.Problems) > 0 { + fmt.Println() + fmt.Println(ui.Header("Problems", ui.AccentRed)) + for _, problem := range result.Problems { + fmt.Printf(" - %s\n", problem) + } + } + if len(result.Warnings) > 0 { + fmt.Println() + fmt.Println(ui.Header("Warnings", ui.AccentYellow)) + for _, warning := range result.Warnings { + fmt.Printf(" - %s\n", warning) + } + } + + if len(result.Problems) > 0 { + return ExitCodeError{Code: 1} + } + return nil +} diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 0000000..d0ab4fc --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,153 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfig(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "scanforge.yaml") + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func TestValidateConfigValid(t *testing.T) { + scopeFile := filepath.Join(t.TempDir(), "scope.txt") + if err := os.WriteFile(scopeFile, []byte("example.com\n"), 0644); err != nil { + t.Fatal(err) + } + + app := New(writeConfig(t, "config_version: 1\ndefault_scope: "+scopeFile+"\n")) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 0 { + t.Fatalf("unexpected problems: %v", result.Problems) + } + if len(result.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", result.Warnings) + } +} + +func TestValidateConfigUnsupportedVersion(t *testing.T) { + app := New(writeConfig(t, "config_version: 99\n")) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 1 || !strings.Contains(result.Problems[0], "config_version 99") { + t.Fatalf("problems = %v, want config_version complaint", result.Problems) + } +} + +func TestValidateConfigUnknownDefaultProfile(t *testing.T) { + app := New(writeConfig(t, "config_version: 1\ndefault_profile: nope\n")) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 1 || !strings.Contains(result.Problems[0], `default_profile "nope"`) { + t.Fatalf("problems = %v, want default_profile complaint", result.Problems) + } +} + +func TestValidateConfigProfileReferencesUnknownModule(t *testing.T) { + app := New(writeConfig(t, `config_version: 1 +profiles: + custom: + - subfinder + - not-a-module +`)) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 1 || !strings.Contains(result.Problems[0], `profile "custom" references unknown module "not-a-module"`) { + t.Fatalf("problems = %v, want unknown module complaint", result.Problems) + } +} + +func TestValidateConfigCustomToolPathMissing(t *testing.T) { + app := New(writeConfig(t, `config_version: 1 +tools: + nuclei: /nonexistent/nuclei +`)) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 1 || !strings.Contains(result.Problems[0], `tool "nuclei" path "/nonexistent/nuclei" does not exist`) { + t.Fatalf("problems = %v, want missing tool path complaint", result.Problems) + } +} + +func TestValidateConfigCustomToolPathExists(t *testing.T) { + tool := filepath.Join(t.TempDir(), "nuclei") + if err := os.WriteFile(tool, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + + app := New(writeConfig(t, "config_version: 1\ntools:\n nuclei: "+tool+"\n")) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + for _, problem := range result.Problems { + if strings.Contains(problem, "nuclei") { + t.Fatalf("unexpected tool problem: %v", result.Problems) + } + } +} + +func TestValidateConfigMissingScopeFileIsWarning(t *testing.T) { + app := New(writeConfig(t, "config_version: 1\ndefault_scope: /nonexistent/scope.txt\n")) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 0 { + t.Fatalf("unexpected problems: %v", result.Problems) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "default_scope") { + t.Fatalf("warnings = %v, want missing scope warning", result.Warnings) + } +} + +func TestValidateConfigBrokenScopeFileIsProblem(t *testing.T) { + scopeFile := filepath.Join(t.TempDir(), "scope.txt") + if err := os.WriteFile(scopeFile, []byte("not a valid host!!!\n"), 0644); err != nil { + t.Fatal(err) + } + + app := New(writeConfig(t, "config_version: 1\ndefault_scope: "+scopeFile+"\n")) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 1 || !strings.Contains(result.Problems[0], "default_scope") { + t.Fatalf("problems = %v, want broken scope complaint", result.Problems) + } +} + +func TestValidateConfigMissingFileReturnsDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist.yaml") + app := New(path) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + // A missing config file is a valid (default) configuration: scope.txt + // absent from the working directory is the only expected warning. + if len(result.Problems) != 0 { + t.Fatalf("unexpected problems: %v", result.Problems) + } + if result.Path != path { + t.Fatalf("path = %q, want %q", result.Path, path) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..7c23b2b --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,147 @@ +package cli + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MikeRoss27/scanforge/internal/app" +) + +// execute runs the command tree with the given args and returns the captured +// stdout/stderr and the error from Execute. Command handlers print through +// fmt/os.Stdout in the app layer, not only through cobra's writers, so the +// real stdout is redirected for the duration of the call. +func execute(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := NewRootCommand() + var out, errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs(args) + + oldStdout := os.Stdout + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writeEnd + execErr := cmd.Execute() + _ = writeEnd.Close() + os.Stdout = oldStdout + stdout, _ := io.ReadAll(readEnd) + _ = readEnd.Close() + + return out.String() + errBuf.String() + string(stdout), execErr +} + +func TestRootHelpListsGroupsAndCommands(t *testing.T) { + output, err := execute(t, "--help") + if err != nil { + t.Fatalf("--help error = %v", err) + } + for _, want := range []string{ + "Core Commands:", + "Reports & Analysis:", + "Configuration:", + "Maintenance:", + "Run a scan against an authorized target", + "Preview the validated scan pipeline without running it", + "Show what changed between two runs of the same target", + "Export a run report in a machine-readable format", + "Create default ScanForge config files", + "Manage API keys for the security tools", + "Inspect and validate scanforge.yaml", + "Check local dependencies and configuration", + "Update scanforge to the latest release", + "Print ScanForge version information", + } { + if !strings.Contains(output, want) { + t.Errorf("root help missing %q", want) + } + } +} + +func TestSubcommandHelp(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"run", "--help"}, "Executes the scan pipeline for a target"}, + {[]string{"plan", "--help"}, "Validates the profile, scope and module dependencies"}, + {[]string{"doctor", "--help"}, "Verifies that the external tools required"}, + {[]string{"update", "--help"}, "Update scanforge to the latest release, replacing the running binary"}, + {[]string{"config", "validate", "--help"}, "Loads scanforge.yaml and checks that it is usable"}, + {[]string{"diff", "--help"}, "Loads the two run directories"}, + {[]string{"export", "--help"}, "Reconsolidates the report of a run directory"}, + {[]string{"init", "--help"}, "Creates the default configuration files"}, + {[]string{"auth", "--help"}, "Manages API keys for the tools"}, + {[]string{"version", "--help"}, "Print ScanForge version information"}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, "_"), func(t *testing.T) { + output, err := execute(t, tc.args...) + if err != nil { + t.Fatalf("%v error = %v", tc.args, err) + } + if !strings.Contains(output, tc.want) { + t.Errorf("%v help missing %q", tc.args, tc.want) + } + }) + } +} + +func TestVersionCommandPrintsVersion(t *testing.T) { + output, err := execute(t, "version") + if err != nil { + t.Fatalf("version error = %v", err) + } + for _, want := range []string{"SCANFORGE", "VERSION", "COMMIT", "GO"} { + if !strings.Contains(output, want) { + t.Errorf("version output missing %q: %q", want, output) + } + } +} + +func TestConfigValidateCommandFailsOnBadConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "scanforge.yaml") + if err := os.WriteFile(path, []byte("config_version: 99\n"), 0644); err != nil { + t.Fatal(err) + } + + output, err := execute(t, "config", "validate", "--config", path) + if err == nil { + t.Fatal("expected error for invalid config") + } + var exitErr app.ExitCodeError + if !errors.As(err, &exitErr) || exitErr.Code != 1 { + t.Fatalf("error = %v, want ExitCodeError{Code: 1}", err) + } + if !strings.Contains(output, "config_version 99") { + t.Errorf("output missing problem detail: %q", output) + } +} + +func TestConfigValidateCommandSucceedsOnValidConfig(t *testing.T) { + dir := t.TempDir() + scopeFile := filepath.Join(dir, "scope.txt") + if err := os.WriteFile(scopeFile, []byte("example.com\n"), 0644); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "scanforge.yaml") + if err := os.WriteFile(path, []byte("config_version: 1\ndefault_scope: "+scopeFile+"\n"), 0644); err != nil { + t.Fatal(err) + } + + output, err := execute(t, "config", "validate", "--config", path) + if err != nil { + t.Fatalf("config validate error = %v", err) + } + if !strings.Contains(output, "Configuration is valid") { + t.Errorf("output missing success message: %q", output) + } +} diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..448123b --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,43 @@ +package cli + +import ( + "github.com/MikeRoss27/scanforge/internal/app" + "github.com/spf13/cobra" +) + +func NewConfigCommand(application *app.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + GroupID: groupConfig, + Short: "Inspect and validate scanforge.yaml", + Long: `Inspect and validate the scanforge.yaml configuration file.`, + } + + cmd.AddCommand(NewConfigValidateCommand(application)) + + return cmd +} + +func NewConfigValidateCommand(application *app.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate scanforge.yaml", + Long: `Loads scanforge.yaml and checks that it is usable: supported config +version, resolvable default profile, profiles referencing only known +modules, custom tool paths that exist on disk, and a parseable default +scope file. + +Exits with a non-zero status when problems are found.`, + Example: ` scanforge config validate + scanforge config validate --config /etc/scanforge.yaml`, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := application.ValidateConfig(cmd.Context()) + if err != nil { + return err + } + return application.PrintValidateConfig(result) + }, + } + + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 4bc38a7..f0b4df0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,6 +6,14 @@ import ( "github.com/spf13/cobra" ) +// Command group IDs used to organize the root help output. +const ( + groupCore = "core" + groupReports = "reports" + groupConfig = "config" + groupMaintenance = "maintenance" +) + func NewRootCommand() *cobra.Command { var configPath string application := app.New("") @@ -13,8 +21,19 @@ func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "scanforge", Short: "Authorized pentest scan orchestrator", - Long: `ScanForge is a CLI tool that orchestrates external security tools -for authorized pentest and recon workflows.`, + Long: `ScanForge orchestrates external security tools (subfinder, dnsx, httpx, +nuclei, ...) into a single, scope-enforced pipeline for authorized pentest +and recon engagements. + +Every target is validated against scope.txt before any tool runs, and each +scan produces a consolidated report under runs///. + +Typical workflow: + scanforge init create scanforge.yaml and scope.txt + scanforge doctor verify that the required tools are installed + scanforge plan preview the validated pipeline without running it + scanforge run execute the scan and produce a report + scanforge diff compare two runs to track changes over time`, // Runtime errors (a failed module, a deadlock, ...) must not dump // the full flag reference: the scan summary already explains what // happened. Errors are printed once by cmd/scanforge/main.go. @@ -32,15 +51,24 @@ for authorized pentest and recon workflows.`, return err }) + // Keep commands in a logical order instead of alphabetical. + cobra.EnableCommandSorting = false + + cmd.AddGroup(&cobra.Group{ID: groupCore, Title: "Core Commands:"}) + cmd.AddGroup(&cobra.Group{ID: groupReports, Title: "Reports & Analysis:"}) + cmd.AddGroup(&cobra.Group{ID: groupConfig, Title: "Configuration:"}) + cmd.AddGroup(&cobra.Group{ID: groupMaintenance, Title: "Maintenance:"}) + cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to scanforge.yaml (overrides SCANFORGE_CONFIG and ./scanforge.yaml)") cmd.AddCommand(NewRunCommand(application)) cmd.AddCommand(NewPlanCommand(application)) cmd.AddCommand(NewDiffCommand(application)) cmd.AddCommand(NewExportCommand(application)) - cmd.AddCommand(NewDoctorCommand(application)) cmd.AddCommand(NewInitCommand(application)) cmd.AddCommand(NewAuthCommand(application)) + cmd.AddCommand(NewConfigCommand(application)) + cmd.AddCommand(NewDoctorCommand(application)) cmd.AddCommand(NewUpdateCommand(application)) cmd.AddCommand(NewVersionCommand()) From 8b3f14e81753080f70b74a3690528b1bb0c7e603 Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 12:47:03 +0200 Subject: [PATCH 5/7] refactor(app): split app.go into focused files app.go (785 lines) is now app.go (types and options only), run.go (run flow), events.go (orchestrator event consumption and manifest finalization), output.go (report generation and terminal rendering), registry.go (module wiring) and commands.go (doctor/init). Purely mechanical: no behavior change. --- internal/app/app.go | 699 --------------------------------------- internal/app/commands.go | 83 +++++ internal/app/events.go | 179 ++++++++++ internal/app/output.go | 250 ++++++++++++++ internal/app/registry.go | 53 +++ internal/app/run.go | 191 +++++++++++ 6 files changed, 756 insertions(+), 699 deletions(-) create mode 100644 internal/app/commands.go create mode 100644 internal/app/events.go create mode 100644 internal/app/output.go create mode 100644 internal/app/registry.go create mode 100644 internal/app/run.go diff --git a/internal/app/app.go b/internal/app/app.go index 0c07062..20673f0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,51 +3,11 @@ package app import ( - "context" - "errors" "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - "time" - tea "github.com/charmbracelet/bubbletea" - "golang.org/x/term" - - "github.com/MikeRoss27/scanforge/internal/ascii" "github.com/MikeRoss27/scanforge/internal/config" - "github.com/MikeRoss27/scanforge/internal/doctor" - "github.com/MikeRoss27/scanforge/internal/initcmd" "github.com/MikeRoss27/scanforge/internal/modules" - "github.com/MikeRoss27/scanforge/internal/modules/attacksurface" - "github.com/MikeRoss27/scanforge/internal/modules/dnsbrute" - "github.com/MikeRoss27/scanforge/internal/modules/dnsx" - "github.com/MikeRoss27/scanforge/internal/modules/ffuf" - "github.com/MikeRoss27/scanforge/internal/modules/gau" - "github.com/MikeRoss27/scanforge/internal/modules/httpcheck" - "github.com/MikeRoss27/scanforge/internal/modules/httpx" - "github.com/MikeRoss27/scanforge/internal/modules/jssecrets" - "github.com/MikeRoss27/scanforge/internal/modules/jsverify" - "github.com/MikeRoss27/scanforge/internal/modules/katana" - "github.com/MikeRoss27/scanforge/internal/modules/naabu" - "github.com/MikeRoss27/scanforge/internal/modules/nmap" - "github.com/MikeRoss27/scanforge/internal/modules/nuclei" - "github.com/MikeRoss27/scanforge/internal/modules/payloadgen" - "github.com/MikeRoss27/scanforge/internal/modules/screenshot" - "github.com/MikeRoss27/scanforge/internal/modules/subfinder" - "github.com/MikeRoss27/scanforge/internal/modules/techcve" - "github.com/MikeRoss27/scanforge/internal/modules/tlsx" - "github.com/MikeRoss27/scanforge/internal/modules/wafw00f" - "github.com/MikeRoss27/scanforge/internal/modules/whatweb" - "github.com/MikeRoss27/scanforge/internal/orchestrator" - "github.com/MikeRoss27/scanforge/internal/report" - "github.com/MikeRoss27/scanforge/internal/runner" "github.com/MikeRoss27/scanforge/internal/storage" - "github.com/MikeRoss27/scanforge/internal/tui" - "github.com/MikeRoss27/scanforge/internal/ui" - "github.com/MikeRoss27/scanforge/internal/version" ) type App struct { @@ -124,662 +84,3 @@ type runSession struct { cfg *config.Config reportErr error } - -// Run resolves the target(s) — one positional target or a --targets file — -// and executes the profile against each of them, keeping per-target runs and -// reports separated under runs//. A failing target does not abort -// the rest of the engagement. -func (a *App) Run(ctx context.Context, opts RunOptions) error { - opts, err := a.applyWizard(opts) - if err != nil { - return err - } - targets, err := expandTargets(opts.Target, opts.TargetsFile) - if err != nil { - return err - } - var errs []error - for _, target := range targets { - one := opts - one.Target = target - if err := a.runOne(ctx, one); err != nil { - errs = append(errs, fmt.Errorf("target %s: %w", target, err)) - } - } - return errors.Join(errs...) -} - -func (a *App) runOne(ctx context.Context, opts RunOptions) error { - session, err := a.prepareRun(opts) - if err != nil { - return err - } - - results, runErr := session.execute(ctx) - - manifestErr := session.finalizeManifest(results, runErr) - rep := session.generateReports() - printRunSummaryBox(os.Stdout, session.scanRun, results, rep) - printFindingsTable(rep) - - if !opts.DryRun { - if err := notifyWebhook(ctx, session.cfg, session.scanRun, rep); err != nil { - ui.Warn("Webhook notification failed: %v", err) - } - } - - // Best-effort report parse warnings (session.reportErr) are already - // printed and must not fail the run: a truncated tool output should - // never flip a valid scan's exit code for CI pipelines. - return errors.Join(runErr, manifestErr) -} - -// prepareRun loads the config, resolves the profile and effective scope, and -// creates the run directory with the effective scope persisted in the -// manifest. -func (a *App) prepareRun(opts RunOptions) (*runSession, error) { - cfg, err := a.loadConfig() - if err != nil { - return nil, err - } - - if opts.Target == "" { - return nil, fmt.Errorf("target is required") - } - - profile := opts.Profile - if profile == "" { - profile = cfg.DefaultProfile - } - - effective, err := resolveScope( - cfg, - opts.Target, - opts.Scope, - opts.ScopeMode, - opts.ScopeAdd, - opts.Exclusions, - ) - if err != nil { - return nil, err - } - - if _, err := cfg.ProfileModules(profile); err != nil { - return nil, err - } - if effective.proposal.Source == scopeSourceImplicit { - if err := a.confirmScope(effective.proposal, opts.ConfirmScope); err != nil { - return nil, err - } - } - - store := storage.NewRunStore(config.WorkspaceDir(cfg)) - - scanRun, err := store.Create(opts.Target) - if err != nil { - return nil, fmt.Errorf("failed to create run directory: %w", err) - } - scanRun.Manifest.Profile = profile - effectiveScopePath := scanRun.Path("00_meta", "effective-scope.txt") - if err := effective.value.WriteFile(effectiveScopePath); err != nil { - return nil, fmt.Errorf("failed to persist effective scope: %w", err) - } - scanRun.Manifest.ScopePath = "00_meta/effective-scope.txt" - scanRun.Manifest.ScopeSource = effective.proposal.Source - scanRun.Manifest.ScopeMode = effective.proposal.Mode - scanRun.Manifest.Outputs["effective_scope"] = scanRun.Manifest.ScopePath - if err := scanRun.WriteManifest(); err != nil { - return nil, fmt.Errorf("failed to record effective scope in manifest: %w", err) - } - - return &runSession{ - opts: opts, - profile: profile, - effective: effective, - scanRun: scanRun, - cfg: cfg, - }, nil -} - -// execute builds the registry and executor, then runs the orchestrator in a -// goroutine while its events are consumed on the terminal. The returned error -// is the orchestrator-level failure; a TUI startup error aborts the run early. -func (s *runSession) execute(ctx context.Context) ([]*modules.Result, error) { - var executor runner.Executor - if s.opts.DryRun { - executor = runner.NewDryRunExecutor(s.opts.Verbose) - } else { - executor = runner.NewRealExecutor(s.opts.Verbose) - } - - ascii.PrintBanner() - fmt.Println(ui.Dim(" by MikeRoss · v" + version.Version)) - fmt.Println() - - printRunInfoPanel(s.opts, s.profile, s.scanRun, *s.effective) - - orch := orchestrator.New(executor, buildRegistry(s.cfg)) - eventChan := make(chan orchestrator.Event) - - var results []*modules.Result - var runErr error - - // runCtx is cancelled when the user quits the TUI early so the scan - // actually stops instead of lingering in the background. - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - // done synchronizes the results assignment with the consumer below: the - // event channel is closed by orchestrator.Run before it returns, so - // waiting on the channel alone would still race with the write to - // results/runErr. - done := make(chan struct{}) - go func() { - defer close(done) - results, runErr = orch.Run(runCtx, s.scanRun, orchestrator.Options{ - Target: s.opts.Target, - Profile: s.profile, - Config: s.cfg, - DryRun: s.opts.DryRun, - Verbose: s.opts.Verbose, - Scope: s.effective.value, - Proxy: s.opts.Proxy, - Headers: s.opts.Headers, - Nuclei: s.opts.Nuclei, - Ffuf: s.opts.Ffuf, - NmapConcurrency: s.opts.NmapConcurrency, - }, eventChan) - }() - - if err := s.consumeEvents(cancel, eventChan, done); err != nil { - // The TUI failed, but the orchestrator still ran: keep its results so - // the manifest and reports reflect what actually happened. - return results, errors.Join(runErr, err) - } - return results, runErr -} - -// consumeEvents renders scan progress either through the Bubble Tea TUI (when -// the output is a terminal and the run is real) or as plain log lines, and -// returns once the orchestrator goroutine has finished. A non-nil error means -// the TUI itself failed and the scan was aborted. -func (s *runSession) consumeEvents(cancel context.CancelFunc, eventChan <-chan orchestrator.Event, done <-chan struct{}) error { - if !s.opts.DryRun && term.IsTerminal(int(os.Stdout.Fd())) { - model := tui.NewScanModel(eventChan) - if _, err := tea.NewProgram(model).Run(); err != nil { - cancel() - drainEvents(eventChan) - <-done - return err - } - // Replay warnings collected in the scan view; once the TUI is gone - // nothing else would surface them. - for _, warning := range model.Warnings() { - ui.Warn("%s", warning) - } - // The user may have quit the UI before the scan finished: cancel the - // run and drain the remaining events so the orchestrator can return. - cancel() - drainEvents(eventChan) - <-done - return nil - } - for event := range eventChan { - s.printEvent(event) - } - <-done - return nil -} - -// drainEvents discards remaining orchestrator events in the background so the -// orchestrator can unblock and return after the TUI has gone away. -func drainEvents(eventChan <-chan orchestrator.Event) { - go func() { - for range eventChan { - } - }() -} - -func (s *runSession) printEvent(event orchestrator.Event) { - switch e := event.(type) { - case orchestrator.WaveStartEvent: - fmt.Println(ui.WaveHeader(e.Wave, strings.Join(e.Modules, ", "))) - case orchestrator.ModuleStartEvent: - if s.opts.Verbose { - ui.Info("Running module %q...", e.Name) - } - case orchestrator.ModuleDoneEvent: - if s.opts.Verbose || !s.opts.DryRun { - printModuleResult(os.Stdout, e) - } - case orchestrator.DeadlockEvent: - ui.Warn("%s", e.Message) - case orchestrator.WarningEvent: - ui.Warn("%s", e.Message) - case orchestrator.FindingEvent: - printFinding(e) - } -} - -// printFinding renders a finding the moment a module reports it, e.g. -// " [nuclei] CRITICAL exposed-git-config · https://example.com/.git/config", -// so long-running scanners like nuclei don't leave the operator staring at a -// blank terminal for the module's entire duration. -func printFinding(e orchestrator.FindingEvent) { - severity := e.Severity - if severity == "" { - severity = "info" - } - line := ui.Dim("["+e.Module+"]") + " " + ui.Severity(strings.ToUpper(severity)) + " " + ui.Bold(e.Title) - if e.Target != "" { - line += ui.Secondary(" · " + e.Target) - } - fmt.Println(" " + line) -} - -// printModuleResult renders a compact, aligned completion line for a module, -// e.g. " ✓ subfinder 2.1s · 8 subdomains". Skipped and aborted modules -// get their own mark and an explicit status: a module that never ran must -// never render like one that succeeded. -func printModuleResult(out io.Writer, e orchestrator.ModuleDoneEvent) { - mark := "✓" - color := ui.Green - switch { - case e.Failed: - mark = "✗" - color = ui.Red - case e.Status == "skipped": - mark = "↓" - color = ui.Yellow - case e.Status == "aborted": - mark = "◌" - color = ui.Orange - } - name := color(ui.Bold(fmt.Sprintf("%-13s", e.Name))) - - var parts []string - if e.Dur > 0 { - parts = append(parts, ui.Dim(e.Dur.Round(time.Millisecond).String())) - } - switch { - case e.Failed && e.Status != "" && e.Status != "failed": - parts = append(parts, ui.Red(e.Status)) - case e.Failed: - parts = append(parts, ui.Red("failed")) - case e.Status == "skipped": - parts = append(parts, ui.Dim("skipped (dependency missing)")) - case e.Status == "aborted": - parts = append(parts, ui.Orange("aborted")) - case e.Summary != "": - parts = append(parts, ui.Secondary(e.Summary)) - } - - line := fmt.Sprintf(" %s %s", color(mark), name) - if len(parts) > 0 { - line += " " + strings.Join(parts, " · ") - } - _, _ = fmt.Fprintln(out, line) -} - -// finalizeManifest records completion time, per-module results and the run -// status (completed/partial/failed) in the run manifest. -func (s *runSession) finalizeManifest(results []*modules.Result, runErr error) error { - s.scanRun.Manifest.CompletedAt = time.Now().Format(time.RFC3339) - completedModules := 0 - for _, result := range results { - if result.Status == "completed" { - completedModules++ - } - } - // A user-initiated abort is recorded as such, not as a failure. - if errors.Is(runErr, orchestrator.ErrRunAborted) { - s.scanRun.Manifest.Status = "aborted" - } else { - switch { - case runErr == nil && completedModules == len(results): - s.scanRun.Manifest.Status = "completed" - case completedModules == 0: - s.scanRun.Manifest.Status = "failed" - default: - s.scanRun.Manifest.Status = "partial" - } - } - - for _, result := range results { - s.scanRun.Manifest.Modules = append(s.scanRun.Manifest.Modules, storage.ModuleResult{ - Name: result.Name, - Status: result.Status, - }) - for key, value := range result.OutputFiles { - s.scanRun.Manifest.Outputs[key] = value - } - } - - return s.scanRun.WriteManifest() -} - -// generateReports renders report.json and report.md from the raw artifacts -// and prints the terminal summary. Failures are downgraded to warnings so a -// scan always ends with the summary box. -func (s *runSession) generateReports() *report.Report { - fmt.Println() - ui.Info("Generating report...") - - rep, err := report.GenerateReport(s.scanRun.RootDir, &s.scanRun.Manifest) - if err != nil { - ui.Warn("Failed to generate report: %v", err) - s.reportErr = err - return rep - } - - jsonPath := filepath.Join(s.scanRun.RootDir, "report.json") - mdPath := filepath.Join(s.scanRun.RootDir, "report.md") - if err := errors.Join(rep.WriteJSON(jsonPath), rep.WriteMarkdown(mdPath)); err != nil { - ui.Warn("Failed to write report: %v", err) - s.reportErr = err - } - return rep -} - -// printRunInfoPanel renders a sleek bordered panel with the run configuration -// instead of the old full-width cyan block. -func printRunInfoPanel(opts RunOptions, profile string, scanRun *storage.Run, effective effectiveScope) { - kv := func(key, val string) string { - return fmt.Sprintf("%-9s %s", ui.DimBold(key), val) - } - - dryTag := ui.Green("OFF") - if opts.DryRun { - dryTag = ui.Yellow("ON") - } - - var b strings.Builder - fmt.Fprintf(&b, "%s\n", kv("TARGET", ui.Primary(opts.Target))) - fmt.Fprintf(&b, "%s\n", kv("PROFILE", ui.Secondary(profile))) - fmt.Fprintf(&b, "%s\n", kv("SCOPE", ui.Dim(fmt.Sprintf("%s (%s, mode %s)", scanRun.Manifest.ScopePath, effective.proposal.Source, effective.proposal.Mode)))) - fmt.Fprintf(&b, "%s\n", kv("DRY RUN", dryTag)) - fmt.Fprintf(&b, "%s", kv("OUTPUT", ui.Dim(scanRun.RootDir))) - - fmt.Println(ui.PanelWith("⚡ RUN STARTED", b.String(), ui.Accent, ui.Accent)) - fmt.Println() -} - -// printRunSummaryBox renders a single, hard-to-miss closing panel. Unlike -// the mid-scroll module log and report.PrintTerminalSummary (which only -// prints when report generation succeeds), this always renders so a run -// never ends in just one easy-to-miss text line. -func printRunSummaryBox(out io.Writer, scanRun *storage.Run, results []*modules.Result, rep *report.Report) { - duration := "unknown" - if started, err := time.Parse(time.RFC3339, scanRun.Manifest.StartedAt); err == nil { - if completed, err := time.Parse(time.RFC3339, scanRun.Manifest.CompletedAt); err == nil { - duration = completed.Sub(started).Round(time.Second).String() - } - } - - completedCount := 0 - var failedModules, skippedModules []string - for _, res := range results { - switch res.Status { - case "completed": - completedCount++ - case "skipped", "aborted": - // Never ran to completion, but not a failure either: listing - // these under FAILED would overstate what went wrong. - skippedModules = append(skippedModules, fmt.Sprintf("%s (%s)", res.Name, res.Status)) - default: - failedModules = append(failedModules, fmt.Sprintf("%s (%s)", res.Name, res.Status)) - } - } - - kv := func(key, val string) string { - return fmt.Sprintf("%-9s %s", ui.DimBold(key), val) - } - - border := ui.Accent - switch scanRun.Manifest.Status { - case "completed": - border = ui.AccentGreen - case "partial": - border = ui.AccentYellow - case "failed": - border = ui.AccentRed - } - - var b strings.Builder - fmt.Fprintf(&b, "%s\n", statusLine(scanRun.Manifest.Status)) - fmt.Fprintf(&b, "%s\n", kv("DURATION", ui.Dim(duration))) - fmt.Fprintf(&b, "%s\n", kv("MODULES", ui.ProgressBar(completedCount, len(results), 20))) - if stats := formatRunStats(rep); stats != "" { - fmt.Fprintf(&b, "%s\n", kv("STATS", stats)) - } - fmt.Fprintf(&b, "%s\n", kv("FINDINGS", formatSeverityCounts(countBySeverity(rep)))) - if len(failedModules) > 0 { - fmt.Fprintf(&b, "%s\n", kv("FAILED", ui.Red(wrapModuleList(failedModules)))) - } - if len(skippedModules) > 0 { - fmt.Fprintf(&b, "%s\n", kv("SKIPPED", ui.Yellow(wrapModuleList(skippedModules)))) - } - fmt.Fprintf(&b, "%s", kv("OUTPUT", ui.Dim(scanRun.RootDir))) - - _, _ = fmt.Fprintln(out, ui.PanelWith("🏁 SCAN SUMMARY", b.String(), border, border)) -} - -// wrapModuleList renders "name (status)" entries on lines of at most -// ~maxListWidth characters so a long skip list cannot stretch the summary -// panel past the terminal width; continuation lines align under the kv value -// column. Widths are approximated from the plain names (module names and -// statuses are ASCII), which stays on the safe side once ANSI colors are -// layered on. -func wrapModuleList(items []string) string { - const maxListWidth = 66 - indent := strings.Repeat(" ", 10) // value column of the "%-9s " kv layout - - var lines []string - current := "" - for _, item := range items { - if current == "" { - current = item - continue - } - if len(current)+len(", ")+len(item) > maxListWidth { - lines = append(lines, current) - current = item - continue - } - current += ", " + item - } - if current != "" { - lines = append(lines, current) - } - return strings.Join(lines, "\n"+indent) -} - -// printFindingsTable renders the severity-sorted findings table below the -// summary box when the run produced any vulnerabilities. -func printFindingsTable(rep *report.Report) { - if table := report.FormatFindingsTable(rep); table != "" { - fmt.Println() - fmt.Println(table) - } -} - -// formatRunStats builds a compact one-line inventory of what the scan found, -// e.g. "3 assets · 5 ports · 12 paths · nginx, React". Returns an empty -// string when nothing was discovered so the summary box stays clean. -func formatRunStats(rep *report.Report) string { - if rep == nil { - return "" - } - - var assets, ports, paths int - techSet := make(map[string]bool) - for _, asset := range rep.Assets { - assets++ - ports += len(asset.Ports) - paths += len(asset.Paths) - for _, t := range asset.Technologies { - techSet[t] = true - } - } - - var parts []string - if assets > 0 { - parts = append(parts, fmt.Sprintf("%d asset(s)", assets)) - } - if ports > 0 { - parts = append(parts, fmt.Sprintf("%d port(s)", ports)) - } - if paths > 0 { - parts = append(parts, fmt.Sprintf("%d path(s)", paths)) - } - - techs := make([]string, 0, len(techSet)) - for t := range techSet { - techs = append(techs, t) - } - sort.Strings(techs) - if len(techs) > 0 { - const maxTechs = 4 - if len(techs) > maxTechs { - techs = append(techs[:maxTechs], "…") - } - parts = append(parts, ui.Secondary(strings.Join(techs, ", "))) - } - - return strings.Join(parts, " · ") -} - -func statusLine(status string) string { - switch status { - case "completed": - return ui.Green(ui.Bold("✓ COMPLETED")) - case "partial": - return ui.Yellow(ui.Bold("◐ PARTIAL")) - case "failed": - return ui.Red(ui.Bold("✗ FAILED")) - default: - return ui.DimBold(strings.ToUpper(status)) - } -} - -func countBySeverity(rep *report.Report) map[string]int { - counts := make(map[string]int) - if rep == nil { - return counts - } - for _, asset := range rep.Assets { - for _, vuln := range asset.Vulnerabilities { - counts[strings.ToLower(vuln.Severity)]++ - } - } - return counts -} - -func formatSeverityCounts(counts map[string]int) string { - levels := []string{"critical", "high", "medium", "low", "info"} - - var parts []string - total := 0 - for _, key := range levels { - if n := counts[key]; n > 0 { - parts = append(parts, ui.Severity(fmt.Sprintf("%d %s", n, key))) - total += n - } - } - if total == 0 { - return ui.Green("none") - } - return strings.Join(parts, ", ") -} - -func buildRegistry(cfg *config.Config) *modules.Registry { - registry := modules.NewRegistry() - registry.Register(subfinder.New(cfg.ToolPath("subfinder"))) - registry.Register(dnsbrute.New(cfg.ToolPath("shuffledns"))) - registry.Register(dnsx.New(cfg.ToolPath("dnsx"))) - registry.Register(httpx.New(cfg.ToolPath("httpx"))) - registry.Register(naabu.New(cfg.ToolPath("naabu"))) - registry.Register(nmap.New(cfg.ToolPath("nmap"))) - registry.Register(whatweb.New(cfg.ToolPath("whatweb"))) - registry.Register(wafw00f.New(cfg.ToolPath("wafw00f"))) - registry.Register(katana.New(cfg.ToolPath("katana"))) - registry.Register(jssecrets.New()) - registry.Register(jsverify.New(cfg.ToolPath("chromium"))) - registry.Register(attacksurface.New()) - registry.Register(ffuf.New(cfg.ToolPath("ffuf"))) - registry.Register(nuclei.New(cfg.ToolPath("nuclei"))) - registry.Register(gau.New(cfg.ToolPath("gau"))) - registry.Register(tlsx.New(cfg.ToolPath("tlsx"))) - registry.Register(techcve.New()) - registry.Register(httpcheck.New()) - registry.Register(payloadgen.New()) - registry.Register(screenshot.New(cfg.ToolPath("httpx"))) - return registry -} - -func (a *App) Doctor(ctx context.Context, opts DoctorOptions) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - - runner := doctor.New(nil) - checks, exitCode, err := runner.Run(ctx, doctor.Options{ - Profile: opts.Profile, - JSON: opts.JSON, - Verbose: opts.Verbose, - Config: cfg, - }) - if err != nil { - return err - } - - if opts.JSON { - output, err := doctor.FormatChecksJSON(checks) - if err != nil { - return err - } - fmt.Println(output) - } else { - fmt.Println(ui.Bold(ui.Primary("ScanForge Doctor v" + version.Version))) - fmt.Println() - fmt.Print(doctor.FormatChecks(checks)) - } - - if exitCode != 0 { - return ExitCodeError{Code: exitCode} - } - - return nil -} - -func (a *App) Init(ctx context.Context, opts InitOptions) error { - result, err := initcmd.Run(initcmd.Options{Force: opts.Force}) - if err != nil { - for _, path := range result.Created { - ui.Success("Created: %s", path) - } - for _, path := range result.Skipped { - ui.Info("Skipped: %s", path) - } - return err - } - - for _, path := range result.Created { - ui.Success("Created: %s", path) - } - for _, path := range result.Skipped { - ui.Info("Skipped: %s", path) - } - - fmt.Println() - fmt.Println(ui.Header("Initialization Complete", ui.AccentGreen)) - - ui.Info("Next steps:") - fmt.Printf(" %s %s\n", ui.Primary("1."), ui.Bold("scanforge doctor")) - fmt.Printf(" %s %s\n", ui.Primary("2."), ui.Bold("scanforge plan example.com")) - fmt.Printf(" %s %s\n", ui.Primary("3."), ui.Bold("scanforge run example.com --dry-run")) - fmt.Printf(" %s %s\n", ui.Primary("4."), ui.Bold("scanforge run example.com")) - - return nil -} diff --git a/internal/app/commands.go b/internal/app/commands.go new file mode 100644 index 0000000..4047c59 --- /dev/null +++ b/internal/app/commands.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "fmt" + + "github.com/MikeRoss27/scanforge/internal/doctor" + "github.com/MikeRoss27/scanforge/internal/initcmd" + "github.com/MikeRoss27/scanforge/internal/ui" + "github.com/MikeRoss27/scanforge/internal/version" +) + +// Doctor checks the local environment (tools, workspace, config) for the +// selected profile and reports the results, exiting non-zero when a required +// tool is missing. +func (a *App) Doctor(ctx context.Context, opts DoctorOptions) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + + runner := doctor.New(nil) + checks, exitCode, err := runner.Run(ctx, doctor.Options{ + Profile: opts.Profile, + JSON: opts.JSON, + Verbose: opts.Verbose, + Config: cfg, + }) + if err != nil { + return err + } + + if opts.JSON { + output, err := doctor.FormatChecksJSON(checks) + if err != nil { + return err + } + fmt.Println(output) + } else { + fmt.Println(ui.Bold(ui.Primary("ScanForge Doctor v" + version.Version))) + fmt.Println() + fmt.Print(doctor.FormatChecks(checks)) + } + + if exitCode != 0 { + return ExitCodeError{Code: exitCode} + } + + return nil +} + +// Init creates the default configuration files (scanforge.yaml, scope.txt) +// in the current directory. +func (a *App) Init(ctx context.Context, opts InitOptions) error { + result, err := initcmd.Run(initcmd.Options{Force: opts.Force}) + if err != nil { + for _, path := range result.Created { + ui.Success("Created: %s", path) + } + for _, path := range result.Skipped { + ui.Info("Skipped: %s", path) + } + return err + } + + for _, path := range result.Created { + ui.Success("Created: %s", path) + } + for _, path := range result.Skipped { + ui.Info("Skipped: %s", path) + } + + fmt.Println() + fmt.Println(ui.Header("Initialization Complete", ui.AccentGreen)) + + ui.Info("Next steps:") + fmt.Printf(" %s %s\n", ui.Primary("1."), ui.Bold("scanforge doctor")) + fmt.Printf(" %s %s\n", ui.Primary("2."), ui.Bold("scanforge plan example.com")) + fmt.Printf(" %s %s\n", ui.Primary("3."), ui.Bold("scanforge run example.com --dry-run")) + fmt.Printf(" %s %s\n", ui.Primary("4."), ui.Bold("scanforge run example.com")) + + return nil +} diff --git a/internal/app/events.go b/internal/app/events.go new file mode 100644 index 0000000..47ec482 --- /dev/null +++ b/internal/app/events.go @@ -0,0 +1,179 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" + + "github.com/MikeRoss27/scanforge/internal/modules" + "github.com/MikeRoss27/scanforge/internal/orchestrator" + "github.com/MikeRoss27/scanforge/internal/storage" + "github.com/MikeRoss27/scanforge/internal/tui" + "github.com/MikeRoss27/scanforge/internal/ui" +) + +// consumeEvents renders scan progress either through the Bubble Tea TUI (when +// the output is a terminal and the run is real) or as plain log lines, and +// returns once the orchestrator goroutine has finished. A non-nil error means +// the TUI itself failed and the scan was aborted. +func (s *runSession) consumeEvents(cancel context.CancelFunc, eventChan <-chan orchestrator.Event, done <-chan struct{}) error { + if !s.opts.DryRun && term.IsTerminal(int(os.Stdout.Fd())) { + model := tui.NewScanModel(eventChan) + if _, err := tea.NewProgram(model).Run(); err != nil { + cancel() + drainEvents(eventChan) + <-done + return err + } + // Replay warnings collected in the scan view; once the TUI is gone + // nothing else would surface them. + for _, warning := range model.Warnings() { + ui.Warn("%s", warning) + } + // The user may have quit the UI before the scan finished: cancel the + // run and drain the remaining events so the orchestrator can return. + cancel() + drainEvents(eventChan) + <-done + return nil + } + for event := range eventChan { + s.printEvent(event) + } + <-done + return nil +} + +// drainEvents discards remaining orchestrator events in the background so the +// orchestrator can unblock and return after the TUI has gone away. +func drainEvents(eventChan <-chan orchestrator.Event) { + go func() { + for range eventChan { + } + }() +} + +func (s *runSession) printEvent(event orchestrator.Event) { + switch e := event.(type) { + case orchestrator.WaveStartEvent: + fmt.Println(ui.WaveHeader(e.Wave, strings.Join(e.Modules, ", "))) + case orchestrator.ModuleStartEvent: + if s.opts.Verbose { + ui.Info("Running module %q...", e.Name) + } + case orchestrator.ModuleDoneEvent: + if s.opts.Verbose || !s.opts.DryRun { + printModuleResult(os.Stdout, e) + } + case orchestrator.DeadlockEvent: + ui.Warn("%s", e.Message) + case orchestrator.WarningEvent: + ui.Warn("%s", e.Message) + case orchestrator.FindingEvent: + printFinding(e) + } +} + +// printFinding renders a finding the moment a module reports it, e.g. +// " [nuclei] CRITICAL exposed-git-config · https://example.com/.git/config", +// so long-running scanners like nuclei don't leave the operator staring at a +// blank terminal for the module's entire duration. +func printFinding(e orchestrator.FindingEvent) { + severity := e.Severity + if severity == "" { + severity = "info" + } + line := ui.Dim("["+e.Module+"]") + " " + ui.Severity(strings.ToUpper(severity)) + " " + ui.Bold(e.Title) + if e.Target != "" { + line += ui.Secondary(" · " + e.Target) + } + fmt.Println(" " + line) +} + +// printModuleResult renders a compact, aligned completion line for a module, +// e.g. " ✓ subfinder 2.1s · 8 subdomains". Skipped and aborted modules +// get their own mark and an explicit status: a module that never ran must +// never render like one that succeeded. +func printModuleResult(out io.Writer, e orchestrator.ModuleDoneEvent) { + mark := "✓" + color := ui.Green + switch { + case e.Failed: + mark = "✗" + color = ui.Red + case e.Status == "skipped": + mark = "↓" + color = ui.Yellow + case e.Status == "aborted": + mark = "◌" + color = ui.Orange + } + name := color(ui.Bold(fmt.Sprintf("%-13s", e.Name))) + + var parts []string + if e.Dur > 0 { + parts = append(parts, ui.Dim(e.Dur.Round(time.Millisecond).String())) + } + switch { + case e.Failed && e.Status != "" && e.Status != "failed": + parts = append(parts, ui.Red(e.Status)) + case e.Failed: + parts = append(parts, ui.Red("failed")) + case e.Status == "skipped": + parts = append(parts, ui.Dim("skipped (dependency missing)")) + case e.Status == "aborted": + parts = append(parts, ui.Orange("aborted")) + case e.Summary != "": + parts = append(parts, ui.Secondary(e.Summary)) + } + + line := fmt.Sprintf(" %s %s", color(mark), name) + if len(parts) > 0 { + line += " " + strings.Join(parts, " · ") + } + _, _ = fmt.Fprintln(out, line) +} + +// finalizeManifest records completion time, per-module results and the run +// status (completed/partial/failed) in the run manifest. +func (s *runSession) finalizeManifest(results []*modules.Result, runErr error) error { + s.scanRun.Manifest.CompletedAt = time.Now().Format(time.RFC3339) + completedModules := 0 + for _, result := range results { + if result.Status == "completed" { + completedModules++ + } + } + // A user-initiated abort is recorded as such, not as a failure. + if errors.Is(runErr, orchestrator.ErrRunAborted) { + s.scanRun.Manifest.Status = "aborted" + } else { + switch { + case runErr == nil && completedModules == len(results): + s.scanRun.Manifest.Status = "completed" + case completedModules == 0: + s.scanRun.Manifest.Status = "failed" + default: + s.scanRun.Manifest.Status = "partial" + } + } + + for _, result := range results { + s.scanRun.Manifest.Modules = append(s.scanRun.Manifest.Modules, storage.ModuleResult{ + Name: result.Name, + Status: result.Status, + }) + for key, value := range result.OutputFiles { + s.scanRun.Manifest.Outputs[key] = value + } + } + + return s.scanRun.WriteManifest() +} diff --git a/internal/app/output.go b/internal/app/output.go new file mode 100644 index 0000000..3377405 --- /dev/null +++ b/internal/app/output.go @@ -0,0 +1,250 @@ +package app + +import ( + "errors" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/MikeRoss27/scanforge/internal/modules" + "github.com/MikeRoss27/scanforge/internal/report" + "github.com/MikeRoss27/scanforge/internal/storage" + "github.com/MikeRoss27/scanforge/internal/ui" +) + +// generateReports renders report.json and report.md from the raw artifacts +// and prints the terminal summary. Failures are downgraded to warnings so a +// scan always ends with the summary box. +func (s *runSession) generateReports() *report.Report { + fmt.Println() + ui.Info("Generating report...") + + rep, err := report.GenerateReport(s.scanRun.RootDir, &s.scanRun.Manifest) + if err != nil { + ui.Warn("Failed to generate report: %v", err) + s.reportErr = err + return rep + } + + jsonPath := filepath.Join(s.scanRun.RootDir, "report.json") + mdPath := filepath.Join(s.scanRun.RootDir, "report.md") + if err := errors.Join(rep.WriteJSON(jsonPath), rep.WriteMarkdown(mdPath)); err != nil { + ui.Warn("Failed to write report: %v", err) + s.reportErr = err + } + return rep +} + +// printRunInfoPanel renders a sleek bordered panel with the run configuration +// instead of the old full-width cyan block. +func printRunInfoPanel(opts RunOptions, profile string, scanRun *storage.Run, effective effectiveScope) { + kv := func(key, val string) string { + return fmt.Sprintf("%-9s %s", ui.DimBold(key), val) + } + + dryTag := ui.Green("OFF") + if opts.DryRun { + dryTag = ui.Yellow("ON") + } + + var b strings.Builder + fmt.Fprintf(&b, "%s\n", kv("TARGET", ui.Primary(opts.Target))) + fmt.Fprintf(&b, "%s\n", kv("PROFILE", ui.Secondary(profile))) + fmt.Fprintf(&b, "%s\n", kv("SCOPE", ui.Dim(fmt.Sprintf("%s (%s, mode %s)", scanRun.Manifest.ScopePath, effective.proposal.Source, effective.proposal.Mode)))) + fmt.Fprintf(&b, "%s\n", kv("DRY RUN", dryTag)) + fmt.Fprintf(&b, "%s", kv("OUTPUT", ui.Dim(scanRun.RootDir))) + + fmt.Println(ui.PanelWith("⚡ RUN STARTED", b.String(), ui.Accent, ui.Accent)) + fmt.Println() +} + +// printRunSummaryBox renders a single, hard-to-miss closing panel. Unlike +// the mid-scroll module log and report.PrintTerminalSummary (which only +// prints when report generation succeeds), this always renders so a run +// never ends in just one easy-to-miss text line. +func printRunSummaryBox(out io.Writer, scanRun *storage.Run, results []*modules.Result, rep *report.Report) { + duration := "unknown" + if started, err := time.Parse(time.RFC3339, scanRun.Manifest.StartedAt); err == nil { + if completed, err := time.Parse(time.RFC3339, scanRun.Manifest.CompletedAt); err == nil { + duration = completed.Sub(started).Round(time.Second).String() + } + } + + completedCount := 0 + var failedModules, skippedModules []string + for _, res := range results { + switch res.Status { + case "completed": + completedCount++ + case "skipped", "aborted": + // Never ran to completion, but not a failure either: listing + // these under FAILED would overstate what went wrong. + skippedModules = append(skippedModules, fmt.Sprintf("%s (%s)", res.Name, res.Status)) + default: + failedModules = append(failedModules, fmt.Sprintf("%s (%s)", res.Name, res.Status)) + } + } + + kv := func(key, val string) string { + return fmt.Sprintf("%-9s %s", ui.DimBold(key), val) + } + + border := ui.Accent + switch scanRun.Manifest.Status { + case "completed": + border = ui.AccentGreen + case "partial": + border = ui.AccentYellow + case "failed": + border = ui.AccentRed + } + + var b strings.Builder + fmt.Fprintf(&b, "%s\n", statusLine(scanRun.Manifest.Status)) + fmt.Fprintf(&b, "%s\n", kv("DURATION", ui.Dim(duration))) + fmt.Fprintf(&b, "%s\n", kv("MODULES", ui.ProgressBar(completedCount, len(results), 20))) + if stats := formatRunStats(rep); stats != "" { + fmt.Fprintf(&b, "%s\n", kv("STATS", stats)) + } + fmt.Fprintf(&b, "%s\n", kv("FINDINGS", formatSeverityCounts(countBySeverity(rep)))) + if len(failedModules) > 0 { + fmt.Fprintf(&b, "%s\n", kv("FAILED", ui.Red(wrapModuleList(failedModules)))) + } + if len(skippedModules) > 0 { + fmt.Fprintf(&b, "%s\n", kv("SKIPPED", ui.Yellow(wrapModuleList(skippedModules)))) + } + fmt.Fprintf(&b, "%s", kv("OUTPUT", ui.Dim(scanRun.RootDir))) + + _, _ = fmt.Fprintln(out, ui.PanelWith("🏁 SCAN SUMMARY", b.String(), border, border)) +} + +// wrapModuleList renders "name (status)" entries on lines of at most +// ~maxListWidth characters so a long skip list cannot stretch the summary +// panel past the terminal width; continuation lines align under the kv value +// column. Widths are approximated from the plain names (module names and +// statuses are ASCII), which stays on the safe side once ANSI colors are +// layered on. +func wrapModuleList(items []string) string { + const maxListWidth = 66 + indent := strings.Repeat(" ", 10) // value column of the "%-9s " kv layout + + var lines []string + current := "" + for _, item := range items { + if current == "" { + current = item + continue + } + if len(current)+len(", ")+len(item) > maxListWidth { + lines = append(lines, current) + current = item + continue + } + current += ", " + item + } + if current != "" { + lines = append(lines, current) + } + return strings.Join(lines, "\n"+indent) +} + +// printFindingsTable renders the severity-sorted findings table below the +// summary box when the run produced any vulnerabilities. +func printFindingsTable(rep *report.Report) { + if table := report.FormatFindingsTable(rep); table != "" { + fmt.Println() + fmt.Println(table) + } +} + +// formatRunStats builds a compact one-line inventory of what the scan found, +// e.g. "3 assets · 5 ports · 12 paths · nginx, React". Returns an empty +// string when nothing was discovered so the summary box stays clean. +func formatRunStats(rep *report.Report) string { + if rep == nil { + return "" + } + + var assets, ports, paths int + techSet := make(map[string]bool) + for _, asset := range rep.Assets { + assets++ + ports += len(asset.Ports) + paths += len(asset.Paths) + for _, t := range asset.Technologies { + techSet[t] = true + } + } + + var parts []string + if assets > 0 { + parts = append(parts, fmt.Sprintf("%d asset(s)", assets)) + } + if ports > 0 { + parts = append(parts, fmt.Sprintf("%d port(s)", ports)) + } + if paths > 0 { + parts = append(parts, fmt.Sprintf("%d path(s)", paths)) + } + + techs := make([]string, 0, len(techSet)) + for t := range techSet { + techs = append(techs, t) + } + sort.Strings(techs) + if len(techs) > 0 { + const maxTechs = 4 + if len(techs) > maxTechs { + techs = append(techs[:maxTechs], "…") + } + parts = append(parts, ui.Secondary(strings.Join(techs, ", "))) + } + + return strings.Join(parts, " · ") +} + +func statusLine(status string) string { + switch status { + case "completed": + return ui.Green(ui.Bold("✓ COMPLETED")) + case "partial": + return ui.Yellow(ui.Bold("◐ PARTIAL")) + case "failed": + return ui.Red(ui.Bold("✗ FAILED")) + default: + return ui.DimBold(strings.ToUpper(status)) + } +} + +func countBySeverity(rep *report.Report) map[string]int { + counts := make(map[string]int) + if rep == nil { + return counts + } + for _, asset := range rep.Assets { + for _, vuln := range asset.Vulnerabilities { + counts[strings.ToLower(vuln.Severity)]++ + } + } + return counts +} + +func formatSeverityCounts(counts map[string]int) string { + levels := []string{"critical", "high", "medium", "low", "info"} + + var parts []string + total := 0 + for _, key := range levels { + if n := counts[key]; n > 0 { + parts = append(parts, ui.Severity(fmt.Sprintf("%d %s", n, key))) + total += n + } + } + if total == 0 { + return ui.Green("none") + } + return strings.Join(parts, ", ") +} diff --git a/internal/app/registry.go b/internal/app/registry.go new file mode 100644 index 0000000..43f6936 --- /dev/null +++ b/internal/app/registry.go @@ -0,0 +1,53 @@ +package app + +import ( + "github.com/MikeRoss27/scanforge/internal/config" + "github.com/MikeRoss27/scanforge/internal/modules" + "github.com/MikeRoss27/scanforge/internal/modules/attacksurface" + "github.com/MikeRoss27/scanforge/internal/modules/dnsbrute" + "github.com/MikeRoss27/scanforge/internal/modules/dnsx" + "github.com/MikeRoss27/scanforge/internal/modules/ffuf" + "github.com/MikeRoss27/scanforge/internal/modules/gau" + "github.com/MikeRoss27/scanforge/internal/modules/httpcheck" + "github.com/MikeRoss27/scanforge/internal/modules/httpx" + "github.com/MikeRoss27/scanforge/internal/modules/jssecrets" + "github.com/MikeRoss27/scanforge/internal/modules/jsverify" + "github.com/MikeRoss27/scanforge/internal/modules/katana" + "github.com/MikeRoss27/scanforge/internal/modules/naabu" + "github.com/MikeRoss27/scanforge/internal/modules/nmap" + "github.com/MikeRoss27/scanforge/internal/modules/nuclei" + "github.com/MikeRoss27/scanforge/internal/modules/payloadgen" + "github.com/MikeRoss27/scanforge/internal/modules/screenshot" + "github.com/MikeRoss27/scanforge/internal/modules/subfinder" + "github.com/MikeRoss27/scanforge/internal/modules/techcve" + "github.com/MikeRoss27/scanforge/internal/modules/tlsx" + "github.com/MikeRoss27/scanforge/internal/modules/wafw00f" + "github.com/MikeRoss27/scanforge/internal/modules/whatweb" +) + +// buildRegistry wires every scanner integration into a registry, resolving +// tool paths from the config. New modules are registered here. +func buildRegistry(cfg *config.Config) *modules.Registry { + registry := modules.NewRegistry() + registry.Register(subfinder.New(cfg.ToolPath("subfinder"))) + registry.Register(dnsbrute.New(cfg.ToolPath("shuffledns"))) + registry.Register(dnsx.New(cfg.ToolPath("dnsx"))) + registry.Register(httpx.New(cfg.ToolPath("httpx"))) + registry.Register(naabu.New(cfg.ToolPath("naabu"))) + registry.Register(nmap.New(cfg.ToolPath("nmap"))) + registry.Register(whatweb.New(cfg.ToolPath("whatweb"))) + registry.Register(wafw00f.New(cfg.ToolPath("wafw00f"))) + registry.Register(katana.New(cfg.ToolPath("katana"))) + registry.Register(jssecrets.New()) + registry.Register(jsverify.New(cfg.ToolPath("chromium"))) + registry.Register(attacksurface.New()) + registry.Register(ffuf.New(cfg.ToolPath("ffuf"))) + registry.Register(nuclei.New(cfg.ToolPath("nuclei"))) + registry.Register(gau.New(cfg.ToolPath("gau"))) + registry.Register(tlsx.New(cfg.ToolPath("tlsx"))) + registry.Register(techcve.New()) + registry.Register(httpcheck.New()) + registry.Register(payloadgen.New()) + registry.Register(screenshot.New(cfg.ToolPath("httpx"))) + return registry +} diff --git a/internal/app/run.go b/internal/app/run.go new file mode 100644 index 0000000..b8a2809 --- /dev/null +++ b/internal/app/run.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/MikeRoss27/scanforge/internal/ascii" + "github.com/MikeRoss27/scanforge/internal/config" + "github.com/MikeRoss27/scanforge/internal/modules" + "github.com/MikeRoss27/scanforge/internal/orchestrator" + "github.com/MikeRoss27/scanforge/internal/runner" + "github.com/MikeRoss27/scanforge/internal/storage" + "github.com/MikeRoss27/scanforge/internal/ui" + "github.com/MikeRoss27/scanforge/internal/version" +) + +// Run resolves the target(s) — one positional target or a --targets file — +// and executes the profile against each of them, keeping per-target runs and +// reports separated under runs//. A failing target does not abort +// the rest of the engagement. +func (a *App) Run(ctx context.Context, opts RunOptions) error { + opts, err := a.applyWizard(opts) + if err != nil { + return err + } + targets, err := expandTargets(opts.Target, opts.TargetsFile) + if err != nil { + return err + } + var errs []error + for _, target := range targets { + one := opts + one.Target = target + if err := a.runOne(ctx, one); err != nil { + errs = append(errs, fmt.Errorf("target %s: %w", target, err)) + } + } + return errors.Join(errs...) +} + +func (a *App) runOne(ctx context.Context, opts RunOptions) error { + session, err := a.prepareRun(opts) + if err != nil { + return err + } + + results, runErr := session.execute(ctx) + + manifestErr := session.finalizeManifest(results, runErr) + rep := session.generateReports() + printRunSummaryBox(os.Stdout, session.scanRun, results, rep) + printFindingsTable(rep) + + if !opts.DryRun { + if err := notifyWebhook(ctx, session.cfg, session.scanRun, rep); err != nil { + ui.Warn("Webhook notification failed: %v", err) + } + } + + // Best-effort report parse warnings (session.reportErr) are already + // printed and must not fail the run: a truncated tool output should + // never flip a valid scan's exit code for CI pipelines. + return errors.Join(runErr, manifestErr) +} + +// prepareRun loads the config, resolves the profile and effective scope, and +// creates the run directory with the effective scope persisted in the +// manifest. +func (a *App) prepareRun(opts RunOptions) (*runSession, error) { + cfg, err := a.loadConfig() + if err != nil { + return nil, err + } + + if opts.Target == "" { + return nil, fmt.Errorf("target is required") + } + + profile := opts.Profile + if profile == "" { + profile = cfg.DefaultProfile + } + + effective, err := resolveScope( + cfg, + opts.Target, + opts.Scope, + opts.ScopeMode, + opts.ScopeAdd, + opts.Exclusions, + ) + if err != nil { + return nil, err + } + + if _, err := cfg.ProfileModules(profile); err != nil { + return nil, err + } + if effective.proposal.Source == scopeSourceImplicit { + if err := a.confirmScope(effective.proposal, opts.ConfirmScope); err != nil { + return nil, err + } + } + + store := storage.NewRunStore(config.WorkspaceDir(cfg)) + + scanRun, err := store.Create(opts.Target) + if err != nil { + return nil, fmt.Errorf("failed to create run directory: %w", err) + } + scanRun.Manifest.Profile = profile + effectiveScopePath := scanRun.Path("00_meta", "effective-scope.txt") + if err := effective.value.WriteFile(effectiveScopePath); err != nil { + return nil, fmt.Errorf("failed to persist effective scope: %w", err) + } + scanRun.Manifest.ScopePath = "00_meta/effective-scope.txt" + scanRun.Manifest.ScopeSource = effective.proposal.Source + scanRun.Manifest.ScopeMode = effective.proposal.Mode + scanRun.Manifest.Outputs["effective_scope"] = scanRun.Manifest.ScopePath + if err := scanRun.WriteManifest(); err != nil { + return nil, fmt.Errorf("failed to record effective scope in manifest: %w", err) + } + + return &runSession{ + opts: opts, + profile: profile, + effective: effective, + scanRun: scanRun, + cfg: cfg, + }, nil +} + +// execute builds the registry and executor, then runs the orchestrator in a +// goroutine while its events are consumed on the terminal. The returned error +// is the orchestrator-level failure; a TUI startup error aborts the run early. +func (s *runSession) execute(ctx context.Context) ([]*modules.Result, error) { + var executor runner.Executor + if s.opts.DryRun { + executor = runner.NewDryRunExecutor(s.opts.Verbose) + } else { + executor = runner.NewRealExecutor(s.opts.Verbose) + } + + ascii.PrintBanner() + fmt.Println(ui.Dim(" by MikeRoss · v" + version.Version)) + fmt.Println() + + printRunInfoPanel(s.opts, s.profile, s.scanRun, *s.effective) + + orch := orchestrator.New(executor, buildRegistry(s.cfg)) + eventChan := make(chan orchestrator.Event) + + var results []*modules.Result + var runErr error + + // runCtx is cancelled when the user quits the TUI early so the scan + // actually stops instead of lingering in the background. + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // done synchronizes the results assignment with the consumer below: the + // event channel is closed by orchestrator.Run before it returns, so + // waiting on the channel alone would still race with the write to + // results/runErr. + done := make(chan struct{}) + go func() { + defer close(done) + results, runErr = orch.Run(runCtx, s.scanRun, orchestrator.Options{ + Target: s.opts.Target, + Profile: s.profile, + Config: s.cfg, + DryRun: s.opts.DryRun, + Verbose: s.opts.Verbose, + Scope: s.effective.value, + Proxy: s.opts.Proxy, + Headers: s.opts.Headers, + Nuclei: s.opts.Nuclei, + Ffuf: s.opts.Ffuf, + NmapConcurrency: s.opts.NmapConcurrency, + }, eventChan) + }() + + if err := s.consumeEvents(cancel, eventChan, done); err != nil { + // The TUI failed, but the orchestrator still ran: keep its results so + // the manifest and reports reflect what actually happened. + return results, errors.Join(runErr, err) + } + return results, runErr +} From 30965f0aa46a35ed5e16eae5c93e1897e5de59d1 Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 12:47:03 +0200 Subject: [PATCH 6/7] refactor(report): split parsers.go by input format parsers.go (799 lines) becomes scan.go (shared line/JSON scanning helpers), parsers_text.go (hosts, ports, katana, whatweb, waf), parsers_json.go (httpx, ffuf, nuclei, techcve, httpcheck, dnsx, tlsx, jssecrets, jsverify), parsers_nmap.go (XML collection) and parsers_screenshots.go. Verified function-by-function against the original: no missing or altered bodies. --- .../report/{parsers.go => parsers_json.go} | 348 +----------------- internal/report/parsers_nmap.go | 107 ++++++ internal/report/parsers_screenshots.go | 40 ++ internal/report/parsers_text.go | 142 +++++++ internal/report/scan.go | 89 +++++ 5 files changed, 384 insertions(+), 342 deletions(-) rename internal/report/{parsers.go => parsers_json.go} (62%) create mode 100644 internal/report/parsers_nmap.go create mode 100644 internal/report/parsers_screenshots.go create mode 100644 internal/report/parsers_text.go create mode 100644 internal/report/scan.go diff --git a/internal/report/parsers.go b/internal/report/parsers_json.go similarity index 62% rename from internal/report/parsers.go rename to internal/report/parsers_json.go index 19385e9..41aa5bf 100644 --- a/internal/report/parsers.go +++ b/internal/report/parsers_json.go @@ -3,89 +3,11 @@ package report import ( "bufio" "encoding/json" - "encoding/xml" - "fmt" - "io/fs" - "net" "net/url" "os" - "path/filepath" - "regexp" - "sort" - "strconv" "strings" ) -// ParseHosts parses a simple text file with one host/domain/URL per line. -func ParseHosts(path string, report *Report) error { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer func() { _ = file.Close() }() - - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - // If it's a URL, extract host - host := line - if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") { - if u, err := url.Parse(line); err == nil { - host = u.Hostname() - } - } - - report.GetOrCreateAsset(host) - } - return scanner.Err() -} - -// ParsePorts parses host:port format (e.g., from naabu) -func ParsePorts(path string, report *Report) error { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer func() { _ = file.Close() }() - - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - host, portStr, err := net.SplitHostPort(line) - if err != nil { - pos := strings.LastIndexByte(line, ':') - if pos < 1 { - continue - } - host, portStr = strings.Trim(line[:pos], "[]"), line[pos+1:] - } - portNum, err := strconv.Atoi(portStr) - if err == nil && portNum > 0 && portNum <= 65535 { - asset := report.GetOrCreateAsset(normalizeAssetName(host)) - if _, ok := asset.Ports[portNum]; !ok { - asset.Ports[portNum] = &Port{Number: portNum} - } - } - } - return scanner.Err() -} - // ParseHttpx parses httpx JSONL output func ParseHttpx(path string, report *Report) error { file, err := os.Open(path) @@ -172,7 +94,7 @@ func ParseHttpx(path string, report *Report) error { return scanner.Err() } -// ParseFfuf parses ffuf JSON output +// ParseFfuf parses the ffuf module's single-document JSON output. func ParseFfuf(path string, report *Report) error { data, err := os.ReadFile(path) if err != nil { @@ -208,33 +130,6 @@ func ParseFfuf(path string, report *Report) error { return nil } -// ParseKatana parses Katana raw URLs -func ParseKatana(path string, report *Report) error { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer func() { _ = file.Close() }() - - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - if u, err := url.Parse(line); err == nil && u.Hostname() != "" { - asset := report.GetOrCreateAsset(u.Hostname()) - asset.Paths = appendUnique(asset.Paths, line) - } - } - return scanner.Err() -} - // ParseNuclei parses Nuclei JSONL output func ParseNuclei(path string, report *Report) error { file, err := os.Open(path) @@ -357,39 +252,6 @@ func ParseTechCVE(path string, report *Report) error { }) } -// ParseScreenshots records the PNG snapshots captured by the screenshot -// module. path is the screenshots directory; filenames are stored as-is so -// report.md can list them relative to the run root. -func ParseScreenshots(path string, report *Report) error { - var files []string - err := filepath.WalkDir(path, func(p string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - return nil - } - if filepath.Ext(entry.Name()) != ".png" { - return nil - } - rel, relErr := filepath.Rel(path, p) - if relErr != nil { - return relErr - } - files = append(files, filepath.ToSlash(rel)) - return nil - }) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - sort.Strings(files) - report.Screenshots = append(report.Screenshots, files...) - return nil -} - // ParseHTTPChecks parses the httpcheck module's hardening-gap findings. func ParseHTTPChecks(path string, report *Report) error { return scanJSONLines(path, func(line []byte) { @@ -437,6 +299,8 @@ func httpCheckTitle(check string) string { return check } +// ParseDnsx parses the dnsx module's JSONL output (resolved hosts, IPs, +// CNAMEs, CDN and ASN metadata). func ParseDnsx(path string, report *Report) error { return scanJSONLines(path, func(line []byte) { var record struct { @@ -471,6 +335,7 @@ func ParseDnsx(path string, report *Report) error { }) } +// ParseTlsx parses the tlsx module's JSONL output (TLS certificate details). func ParseTlsx(path string, report *Report) error { return scanJSONLines(path, func(line []byte) { var record struct { @@ -511,6 +376,8 @@ func ParseTlsx(path string, report *Report) error { }) } +// ParseJSSecrets parses the jssecrets module's JSONL output: endpoint +// discoveries become asset paths, everything else becomes a finding. func ParseJSSecrets(path string, report *Report) error { return scanJSONLines(path, func(line []byte) { var record struct { @@ -594,206 +461,3 @@ func ParseJSVerify(path string, report *Report) error { report.JSVerified = append(report.JSVerified, record) }) } - -func ParseNmapCollection(path string, report *Report) error { - info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - var paths []string - if info.IsDir() { - err = filepath.WalkDir(path, func(candidate string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".xml") { - paths = append(paths, candidate) - } - return nil - }) - } else { - paths = []string{path} - } - if err != nil { - return err - } - for _, xmlPath := range paths { - if err := parseNmapXML(xmlPath, report); err != nil { - return err - } - } - return nil -} - -func parseNmapXML(path string, report *Report) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - var result struct { - Hosts []struct { - Addresses []struct { - Addr string `xml:"addr,attr"` - Type string `xml:"addrtype,attr"` - } `xml:"address"` - Hostnames []struct { - Name string `xml:"name,attr"` - } `xml:"hostnames>hostname"` - Ports []struct { - ID int `xml:"portid,attr"` - Protocol string `xml:"protocol,attr"` - State struct { - Value string `xml:"state,attr"` - } `xml:"state"` - Service struct { - Name string `xml:"name,attr"` - Product string `xml:"product,attr"` - Version string `xml:"version,attr"` - } `xml:"service"` - } `xml:"ports>port"` - } `xml:"host"` - } - if err := xml.Unmarshal(data, &result); err != nil { - return err - } - for _, host := range result.Hosts { - name := "" - if len(host.Hostnames) > 0 { - name = normalizeAssetName(host.Hostnames[0].Name) - } - for _, address := range host.Addresses { - if name == "" && (address.Type == "ipv4" || address.Type == "ipv6") { - name = address.Addr - } - } - if name == "" { - continue - } - asset := report.GetOrCreateAsset(name) - for _, address := range host.Addresses { - if address.Type == "ipv4" || address.Type == "ipv6" { - asset.IPs = appendUnique(asset.IPs, address.Addr) - } - } - for _, port := range host.Ports { - if port.State.Value != "open" { - continue - } - asset.Ports[port.ID] = &Port{ - Number: port.ID, Protocol: port.Protocol, Service: port.Service.Name, - Product: port.Service.Product, Version: port.Service.Version, - } - } - } - return nil -} - -var urlPattern = regexp.MustCompile(`https?://[^\s]+`) -var wafPattern = regexp.MustCompile(`(?i)is behind (?:a |an )?(.+?)(?: WAF)?(?:\.|$)`) -var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`) - -func stripANSI(s string) string { - return ansiEscapePattern.ReplaceAllString(s, "") -} - -func ParseWhatWeb(path string, report *Report) error { - return scanLines(path, func(line string) { - rawURL := urlPattern.FindString(line) - host := normalizeAssetName(rawURL) - if host == "" { - return - } - asset := report.GetOrCreateAsset(host) - for _, field := range strings.Fields(line) { - if pos := strings.IndexByte(field, '['); pos > 0 { - asset.Technologies = appendUnique(asset.Technologies, strings.Trim(field[:pos], ",")) - } - } - }) -} - -func ParseWAF(path string, report *Report) error { - currentHost := "" - return scanLines(path, func(line string) { - rawURL := urlPattern.FindString(line) - if host := normalizeAssetName(rawURL); host != "" { - currentHost = host - } - match := wafPattern.FindStringSubmatch(line) - if currentHost != "" && len(match) == 2 { - asset := report.GetOrCreateAsset(currentHost) - asset.WAFs = appendUnique(asset.WAFs, strings.TrimSpace(match[1])) - } - }) -} - -func scanJSONLines(path string, consume func([]byte)) error { - return scanLines(path, func(line string) { consume([]byte(line)) }) -} - -func scanLines(path string, consume func(string)) error { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer func() { _ = file.Close() }() - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(stripANSI(scanner.Text())) - if line != "" { - consume(line) - } - } - return scanner.Err() -} - -func normalizeAssetName(value string) string { - value = strings.TrimSpace(stripANSI(value)) - if value == "" { - return "" - } - if parsed, err := url.Parse(value); err == nil && parsed.Hostname() != "" { - return strings.ToLower(parsed.Hostname()) - } - if host, _, err := net.SplitHostPort(value); err == nil { - return strings.ToLower(strings.Trim(host, "[]")) - } - return strings.ToLower(strings.Trim(strings.TrimSuffix(value, "."), "[]")) -} - -func rawString(value json.RawMessage) string { - if len(value) == 0 || string(value) == "null" { - return "" - } - var text string - if json.Unmarshal(value, &text) == nil { - return text - } - var number json.Number - if json.Unmarshal(value, &number) == nil { - return number.String() - } - return fmt.Sprint(string(value)) -} - -func rawInt(value json.RawMessage) int { - text := rawString(value) - number, _ := strconv.Atoi(text) - return number -} - -func appendUnique(slice []string, val string) []string { - for _, item := range slice { - if item == val { - return slice - } - } - return append(slice, val) -} diff --git a/internal/report/parsers_nmap.go b/internal/report/parsers_nmap.go new file mode 100644 index 0000000..9beca63 --- /dev/null +++ b/internal/report/parsers_nmap.go @@ -0,0 +1,107 @@ +package report + +import ( + "encoding/xml" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// ParseNmapCollection parses either a single nmap XML file or a directory of +// per-host XML files (the nmap module's collection layout). +func ParseNmapCollection(path string, report *Report) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var paths []string + if info.IsDir() { + err = filepath.WalkDir(path, func(candidate string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".xml") { + paths = append(paths, candidate) + } + return nil + }) + } else { + paths = []string{path} + } + if err != nil { + return err + } + for _, xmlPath := range paths { + if err := parseNmapXML(xmlPath, report); err != nil { + return err + } + } + return nil +} + +func parseNmapXML(path string, report *Report) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var result struct { + Hosts []struct { + Addresses []struct { + Addr string `xml:"addr,attr"` + Type string `xml:"addrtype,attr"` + } `xml:"address"` + Hostnames []struct { + Name string `xml:"name,attr"` + } `xml:"hostnames>hostname"` + Ports []struct { + ID int `xml:"portid,attr"` + Protocol string `xml:"protocol,attr"` + State struct { + Value string `xml:"state,attr"` + } `xml:"state"` + Service struct { + Name string `xml:"name,attr"` + Product string `xml:"product,attr"` + Version string `xml:"version,attr"` + } `xml:"service"` + } `xml:"ports>port"` + } `xml:"host"` + } + if err := xml.Unmarshal(data, &result); err != nil { + return err + } + for _, host := range result.Hosts { + name := "" + if len(host.Hostnames) > 0 { + name = normalizeAssetName(host.Hostnames[0].Name) + } + for _, address := range host.Addresses { + if name == "" && (address.Type == "ipv4" || address.Type == "ipv6") { + name = address.Addr + } + } + if name == "" { + continue + } + asset := report.GetOrCreateAsset(name) + for _, address := range host.Addresses { + if address.Type == "ipv4" || address.Type == "ipv6" { + asset.IPs = appendUnique(asset.IPs, address.Addr) + } + } + for _, port := range host.Ports { + if port.State.Value != "open" { + continue + } + asset.Ports[port.ID] = &Port{ + Number: port.ID, Protocol: port.Protocol, Service: port.Service.Name, + Product: port.Service.Product, Version: port.Service.Version, + } + } + } + return nil +} diff --git a/internal/report/parsers_screenshots.go b/internal/report/parsers_screenshots.go new file mode 100644 index 0000000..f34358b --- /dev/null +++ b/internal/report/parsers_screenshots.go @@ -0,0 +1,40 @@ +package report + +import ( + "os" + "path/filepath" + "sort" +) + +// ParseScreenshots records the PNG snapshots captured by the screenshot +// module. path is the screenshots directory; filenames are stored as-is so +// report.md can list them relative to the run root. +func ParseScreenshots(path string, report *Report) error { + var files []string + err := filepath.WalkDir(path, func(p string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + if filepath.Ext(entry.Name()) != ".png" { + return nil + } + rel, relErr := filepath.Rel(path, p) + if relErr != nil { + return relErr + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + sort.Strings(files) + report.Screenshots = append(report.Screenshots, files...) + return nil +} diff --git a/internal/report/parsers_text.go b/internal/report/parsers_text.go new file mode 100644 index 0000000..c235683 --- /dev/null +++ b/internal/report/parsers_text.go @@ -0,0 +1,142 @@ +package report + +import ( + "bufio" + "net" + "net/url" + "os" + "strconv" + "strings" +) + +// ParseHosts parses a simple text file with one host/domain/URL per line. +func ParseHosts(path string, report *Report) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // If it's a URL, extract host + host := line + if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") { + if u, err := url.Parse(line); err == nil { + host = u.Hostname() + } + } + + report.GetOrCreateAsset(host) + } + return scanner.Err() +} + +// ParsePorts parses host:port format (e.g., from naabu) +func ParsePorts(path string, report *Report) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + host, portStr, err := net.SplitHostPort(line) + if err != nil { + pos := strings.LastIndexByte(line, ':') + if pos < 1 { + continue + } + host, portStr = strings.Trim(line[:pos], "[]"), line[pos+1:] + } + portNum, err := strconv.Atoi(portStr) + if err == nil && portNum > 0 && portNum <= 65535 { + asset := report.GetOrCreateAsset(normalizeAssetName(host)) + if _, ok := asset.Ports[portNum]; !ok { + asset.Ports[portNum] = &Port{Number: portNum} + } + } + } + return scanner.Err() +} + +// ParseKatana parses Katana raw URLs +func ParseKatana(path string, report *Report) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + if u, err := url.Parse(line); err == nil && u.Hostname() != "" { + asset := report.GetOrCreateAsset(u.Hostname()) + asset.Paths = appendUnique(asset.Paths, line) + } + } + return scanner.Err() +} + +// ParseWhatWeb parses the whatweb module's text output, extracting the +// target host and the bracketed technology names per line. +func ParseWhatWeb(path string, report *Report) error { + return scanLines(path, func(line string) { + rawURL := urlPattern.FindString(line) + host := normalizeAssetName(rawURL) + if host == "" { + return + } + asset := report.GetOrCreateAsset(host) + for _, field := range strings.Fields(line) { + if pos := strings.IndexByte(field, '['); pos > 0 { + asset.Technologies = appendUnique(asset.Technologies, strings.Trim(field[:pos], ",")) + } + } + }) +} + +// ParseWAF parses the wafw00f module's text output, tracking the current +// host across lines and recording each detected WAF product. +func ParseWAF(path string, report *Report) error { + currentHost := "" + return scanLines(path, func(line string) { + rawURL := urlPattern.FindString(line) + if host := normalizeAssetName(rawURL); host != "" { + currentHost = host + } + match := wafPattern.FindStringSubmatch(line) + if currentHost != "" && len(match) == 2 { + asset := report.GetOrCreateAsset(currentHost) + asset.WAFs = appendUnique(asset.WAFs, strings.TrimSpace(match[1])) + } + }) +} diff --git a/internal/report/scan.go b/internal/report/scan.go new file mode 100644 index 0000000..3d741c7 --- /dev/null +++ b/internal/report/scan.go @@ -0,0 +1,89 @@ +package report + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "net/url" + "os" + "regexp" + "strconv" + "strings" +) + +var urlPattern = regexp.MustCompile(`https?://[^\s]+`) +var wafPattern = regexp.MustCompile(`(?i)is behind (?:a |an )?(.+?)(?: WAF)?(?:\.|$)`) +var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`) + +func stripANSI(s string) string { + return ansiEscapePattern.ReplaceAllString(s, "") +} + +func scanJSONLines(path string, consume func([]byte)) error { + return scanLines(path, func(line string) { consume([]byte(line)) }) +} + +func scanLines(path string, consume func(string)) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer func() { _ = file.Close() }() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(stripANSI(scanner.Text())) + if line != "" { + consume(line) + } + } + return scanner.Err() +} + +func normalizeAssetName(value string) string { + value = strings.TrimSpace(stripANSI(value)) + if value == "" { + return "" + } + if parsed, err := url.Parse(value); err == nil && parsed.Hostname() != "" { + return strings.ToLower(parsed.Hostname()) + } + if host, _, err := net.SplitHostPort(value); err == nil { + return strings.ToLower(strings.Trim(host, "[]")) + } + return strings.ToLower(strings.Trim(strings.TrimSuffix(value, "."), "[]")) +} + +func rawString(value json.RawMessage) string { + if len(value) == 0 || string(value) == "null" { + return "" + } + var text string + if json.Unmarshal(value, &text) == nil { + return text + } + var number json.Number + if json.Unmarshal(value, &number) == nil { + return number.String() + } + return fmt.Sprint(string(value)) +} + +func rawInt(value json.RawMessage) int { + text := rawString(value) + number, _ := strconv.Atoi(text) + return number +} + +func appendUnique(slice []string, val string) []string { + for _, item := range slice { + if item == val { + return slice + } + } + return append(slice, val) +} From 9a358f10ea1fcdc61718f4629e4407c9ed66ef81 Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 19 Aug 2026 13:06:41 +0200 Subject: [PATCH 7/7] feat(config): per-module timeouts via module_timeouts Bound each module invocation with context.WithTimeout when a module_timeouts entry is configured in scanforge.yaml; a module exceeding its limit is killed, reported as failed with an actionable message, and its dependents are skipped. Zero/unset keeps the module's own default. config validate now rejects unknown module names in module_timeouts. Docs updated (en/fr/zh). --- docs/USAGE.md | 10 ++++ docs/fr/USAGE.md | 11 +++++ docs/zh/USAGE.md | 10 ++++ internal/app/config.go | 7 +++ internal/app/config_test.go | 29 ++++++++++++ internal/config/config.go | 13 +++++- internal/config/config_test.go | 48 ++++++++++++++++++++ internal/config/defaults.go | 5 +- internal/orchestrator/orchestrator.go | 31 ++++++++++++- internal/orchestrator/orchestrator_test.go | 53 ++++++++++++++++++++++ 10 files changed, 214 insertions(+), 3 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 97845d5..7ba7db4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -64,6 +64,16 @@ profiles: - nuclei ``` +Per-module time limits are set under `module_timeouts` with Go durations. A +module exceeding its limit is killed and reported as failed, and its +dependents are skipped; unset modules keep their own default: + +```yaml +module_timeouts: + nuclei: 45m + katana: 20m +``` + ## Built-in nuclei templates `--nuclei-include-custom` adds the templates bundled in the `templates/` diff --git a/docs/fr/USAGE.md b/docs/fr/USAGE.md index be08bcf..5aa4f5c 100644 --- a/docs/fr/USAGE.md +++ b/docs/fr/USAGE.md @@ -64,6 +64,17 @@ profiles: - nuclei ``` +Les limites de temps par module se configurent sous `module_timeouts` avec des +durées Go. Un module qui dépasse sa limite est tué et signalé comme échoué, et +ses dépendants sont marqués `skipped` ; les modules sans limite gardent leur +défaut : + +```yaml +module_timeouts: + nuclei: 45m + katana: 20m +``` + ## Templates nuclei intégrés `--nuclei-include-custom` ajoute au run nuclei les templates livrés dans le diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 55ffa84..65a8a4f 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -60,6 +60,16 @@ profiles: - nuclei ``` +每个模块的时间限制在 `module_timeouts` 下配置,使用 Go 时长格式。超过限制的 +模块会被终止并标记为失败,其依赖模块会被标记为 `skipped`;未配置的模块使用 +各自的默认值: + +```yaml +module_timeouts: + nuclei: 45m + katana: 20m +``` + ## 推荐流程 首先检查依赖和计划: diff --git a/internal/app/config.go b/internal/app/config.go index 13f6584..64f190f 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -61,6 +61,13 @@ func (a *App) ValidateConfig(ctx context.Context) (*ValidateConfigResult, error) } } + for name := range cfg.ModuleTimeouts { + if _, ok := registry.Get(name); !ok { + result.Problems = append(result.Problems, + fmt.Sprintf("module_timeouts references unknown module %q", name)) + } + } + for tool, toolPath := range customToolPaths(cfg) { if _, err := os.Stat(toolPath); err != nil { result.Problems = append(result.Problems, diff --git a/internal/app/config_test.go b/internal/app/config_test.go index d0ab4fc..ad04614 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -73,6 +73,35 @@ profiles: } } +func TestValidateConfigModuleTimeoutUnknownModule(t *testing.T) { + app := New(writeConfig(t, `config_version: 1 +module_timeouts: + nuclei: 45m + not-a-module: 10m +`)) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 1 || !strings.Contains(result.Problems[0], `module_timeouts references unknown module "not-a-module"`) { + t.Fatalf("problems = %v, want unknown module complaint", result.Problems) + } +} + +func TestValidateConfigModuleTimeoutKnownModule(t *testing.T) { + app := New(writeConfig(t, `config_version: 1 +module_timeouts: + nuclei: 45m +`)) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if len(result.Problems) != 0 { + t.Fatalf("unexpected problems: %v", result.Problems) + } +} + func TestValidateConfigCustomToolPathMissing(t *testing.T) { app := New(writeConfig(t, `config_version: 1 tools: diff --git a/internal/config/config.go b/internal/config/config.go index b581c65..bf36d68 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/MikeRoss27/scanforge/internal/profile" "gopkg.in/yaml.v3" @@ -17,7 +18,11 @@ type Config struct { DefaultScope string `yaml:"default_scope"` Tools Tools `yaml:"tools"` Profiles map[string][]string `yaml:"profiles"` - Webhook Webhook `yaml:"webhook"` + // ModuleTimeouts bounds how long a module may run before it is killed, + // keyed by module name with Go duration values (e.g. "30m", "1h30m"). + // Zero (unset) means the module's own default applies. + ModuleTimeouts map[string]time.Duration `yaml:"module_timeouts"` + Webhook Webhook `yaml:"webhook"` } // Webhook holds the end-of-run notification endpoint. The payload is a @@ -182,6 +187,12 @@ tools: # passive: # - subfinder # - httpx + +# per-module time limits (Go durations); a module exceeding its limit is +# killed and reported as failed, and its dependents are skipped +# module_timeouts: +# nuclei: 45m +# katana: 20m `, DefaultConfigVersion, DefaultWorkspace, DefaultProfile, DefaultScope) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4260bb8..d6c3fc5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestDefault(t *testing.T) { @@ -92,6 +93,53 @@ func TestLoadInvalidYAML(t *testing.T) { } } +func TestLoadModuleTimeouts(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "scanforge.yaml") + + content := `config_version: 1 +module_timeouts: + nuclei: 45m + katana: 1h30m + ffuf: 0s +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := cfg.ModuleTimeouts["nuclei"]; got != 45*time.Minute { + t.Fatalf("nuclei timeout = %v, want 45m", got) + } + if got := cfg.ModuleTimeouts["katana"]; got != 90*time.Minute { + t.Fatalf("katana timeout = %v, want 1h30m", got) + } + if got := cfg.ModuleTimeouts["ffuf"]; got != 0 { + t.Fatalf("ffuf timeout = %v, want 0 (module default)", got) + } +} + +func TestLoadInvalidModuleTimeout(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "scanforge.yaml") + + content := `config_version: 1 +module_timeouts: + nuclei: not-a-duration +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + if _, err := Load(path); err == nil { + t.Fatal("expected a parse error for an invalid duration") + } +} + func TestResolvePath(t *testing.T) { t.Setenv("SCANFORGE_CONFIG", "env.yaml") diff --git a/internal/config/defaults.go b/internal/config/defaults.go index b69efd7..ae5c661 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,5 +1,7 @@ package config +import "time" + const ( DefaultConfigFile = "scanforge.yaml" DefaultWorkspace = "runs" @@ -28,6 +30,7 @@ func Default() *Config { Gau: "gau", Tlsx: "tlsx", }, - Profiles: map[string][]string{}, + Profiles: map[string][]string{}, + ModuleTimeouts: map[string]time.Duration{}, } } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 24d8f7a..a161193 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -170,6 +170,19 @@ func (o *Orchestrator) Run(ctx context.Context, scanRun *storage.Run, opts Optio outChan <- ModuleStartEvent{Name: m.Name()} } + // A per-module timeout (config module_timeouts) bounds the + // whole module invocation, not just a single command: modules + // run several commands (e.g. nuclei template update + scan) + // and may stall between them. Zero means the module's own + // default applies. + moduleCtx := ctx + timeout := moduleTimeout(opts.Config, m.Name()) + if timeout > 0 { + var cancel context.CancelFunc + moduleCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + // A panicking module (third-party library bug, unexpected // input shape) must surface as a failure, not crash the whole // scan: without the recovery, the goroutine would never send @@ -182,9 +195,16 @@ func (o *Orchestrator) Run(ctx context.Context, scanRun *storage.Run, opts Optio result, err = nil, fmt.Errorf("module %q panicked: %v", m.Name(), r) } }() - result, err = m.Run(ctx, runCtx, o.executor) + result, err = m.Run(moduleCtx, runCtx, o.executor) }() + // A deadline kill surfaces as context.DeadlineExceeded, which + // hides what actually went wrong; translate it into a message + // operators can act on. + if err != nil && timeout > 0 && errors.Is(err, context.DeadlineExceeded) { + err = fmt.Errorf("module %q timed out after %s: %w", m.Name(), timeout, err) + } + // A module returning (nil, nil) violates the contract; treat it // as a failure instead of letting a nil result panic downstream. if result == nil && err == nil { @@ -277,3 +297,12 @@ func (o *Orchestrator) Run(ctx context.Context, scanRun *storage.Run, opts Optio } return results, errors.Join(runErrors...) } + +// moduleTimeout returns the configured per-module timeout for name, or zero +// when none is set so the module's own default applies. +func moduleTimeout(cfg *config.Config, name string) time.Duration { + if cfg == nil { + return 0 + } + return cfg.ModuleTimeouts[name] +} diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 72880fd..5f37d44 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -278,6 +278,59 @@ func TestOrchestratorDeadlockDoesNotOverstate(t *testing.T) { } } +// TestOrchestratorModuleTimeout: a module exceeding its configured +// module_timeouts limit is killed via context deadline and reported as a +// failure with an actionable message; its dependents are skipped. +func TestOrchestratorModuleTimeout(t *testing.T) { + reg := modules.NewRegistry() + reg.Register(&mockModule{name: "slow", delay: 200 * time.Millisecond, produces: []string{"slow_art"}}) + reg.Register(&mockModule{name: "dependent", requires: []string{"slow_art"}}) + + cfg := config.Default() + cfg.ModuleTimeouts["slow"] = 20 * time.Millisecond + cfg.Profiles["test"] = []string{"slow", "dependent"} + + results, err := New(runner.NewDryRunExecutor(false), reg).Run( + context.Background(), nil, + Options{Target: "example.com", Profile: "test", Config: cfg}, + nil, + ) + if err == nil || !strings.Contains(err.Error(), `module "slow" timed out after 20ms`) { + t.Fatalf("expected a timeout error, got %v", err) + } + if len(results) != 2 { + t.Fatalf("result count = %d, want 2", len(results)) + } + if results[0].Status != "failed" { + t.Fatalf("status = %q, want failed", results[0].Status) + } + if results[1].Status != "skipped" { + t.Fatalf("dependent status = %q, want skipped", results[1].Status) + } +} + +// TestOrchestratorNoTimeoutLeavesModuleDefault: without a configured +// module_timeouts entry the module runs to completion regardless of delay. +func TestOrchestratorNoTimeoutLeavesModuleDefault(t *testing.T) { + reg := modules.NewRegistry() + reg.Register(&mockModule{name: "slow", delay: 20 * time.Millisecond}) + + cfg := config.Default() + cfg.Profiles["test"] = []string{"slow"} + + results, err := New(runner.NewDryRunExecutor(false), reg).Run( + context.Background(), nil, + Options{Target: "example.com", Profile: "test", Config: cfg}, + nil, + ) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(results) != 1 || results[0].Status != "completed" { + t.Fatalf("results = %+v, want a single completed module", results) + } +} + // TestAbortCancelsRemainingModules covers the user-quit path: cancelling the // context must mark running modules aborted, return ErrRunAborted, and never // report "context canceled" as a module failure.