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/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/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/config.go b/internal/app/config.go new file mode 100644 index 0000000..64f190f --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,154 @@ +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 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, + 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..ad04614 --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,182 @@ +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 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: + 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/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 +} 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) + } +} 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/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/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/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()) 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)) 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/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) } 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. 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) +}