diff --git a/README.md b/README.md index 84233b5..0d43728 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,10 @@ -# macaronV2 +# macaron -Fast reconnaissance workflow in Go with SQLite-backed persistence and an operator-focused dashboard. +Fast, researcher-oriented recon framework. Runs a chained enumeration pipeline — subdomains → live probing → port mapping → URL harvesting → vuln scanning — and stores everything in a local SQLite-backed store you can query and export. -``` - ╔╦╗╔═╗╔═╗╔═╗╦═╗╔═╗╔╗╔ - ║║║╠═╣║ ╠═╣╠╦╝║ ║║║║ - ╩ ╩╩ ╩╚═╝╩ ╩╩╚═╚═╝╝╚╝ - - Fast Recon Workflow v3.0.0 - github.com/root-Manas/macaron - ──────────────────────────────────────── -``` - -## The Model - -`macaronV2` is designed around one simple loop: - -1. `setup` toolchain and keys -2. `scan` targets with an explicit profile -3. `status/results` to triage findings -4. `serve` to inspect everything in one dashboard -5. `export` to share/report +--- -## Quick Start +## install ```bash git clone https://github.com/root-Manas/macaron.git @@ -30,128 +12,196 @@ cd macaron chmod +x install.sh ./install.sh source ~/.bashrc +``` + +Or grab a tagged binary from [Releases](https://github.com/root-Manas/macaron/releases). + +--- -macaron setup -macaron scan example.com -prf balanced +## quick start + +```bash +macaron setup --install # check and install missing tools +macaron api set securitytrails=KEY shodan=KEY +macaron scan -t example.com macaron status -macaron serve +macaron results -d example.com -w live +macaron export -o example.json ``` -## Core Commands +--- + +## commands ``` -USAGE - macaron scan example.com - macaron status - macaron results -dom example.com -wht live - macaron serve -adr 127.0.0.1:8088 - macaron setup - macaron export -out results.json - -SCAN FLAGS - -scn TARGET Scan one or more targets (repeatable) - -fil FILE Read targets from file - -inp Read targets from stdin - -mod MODE Scan mode: wide|narrow|fast|deep|osint - -stg LIST Stages: subdomains,http,ports,urls,vulns - -prf NAME Profile: passive|balanced|aggressive - -rte N Request rate hint (default: 150) - -thr N Worker threads (default: 30) - -OUTPUT FLAGS - -sts Show recent scan summaries - -res Show scan results - -dom DOMAIN Filter by domain - -wht TYPE Result view: all|subdomains|live|ports|urls|js|vulns - -lim N Output limit (default: 50) - -exp Export results to JSON - -qut Quiet mode (suppress banner and progress) - -API KEYS - -sak k=v Set API key (e.g. -sak securitytrails=KEY) - -shk Show masked API keys - -DASHBOARD - -srv Start browser dashboard - -adr ADDR Bind address (default: 127.0.0.1:8088) - -TOOLS & CONFIG - -stp Show tool installation status - -ins Install missing supported tools (Linux) - -lst List external tool availability - -str DIR Custom storage root (default: ./storage) - -nc Disable color output - -ver Show version +macaron scan run recon pipeline +macaron status list past scans +macaron results query scan output +macaron setup tool inventory + auto-install +macaron export dump results to JSON +macaron config show storage paths +macaron api manage global API keys +macaron uninstall remove macaron from this machine +macaron guide workflow walkthrough +macaron version print version ``` -## Profiles +--- -| Profile | Description | -|-------------|----------------------------------------------------| -| `passive` | Low-noise, low-rate, mostly passive collection | -| `balanced` | Default practical workflow (recommended) | -| `aggressive`| High-throughput for authorized deep testing only | +## scan -## CLI UX +```bash +macaron scan -t target.com +macaron scan -t target.com -p passive +macaron scan -t target.com -p aggressive --stages subdomains,http,ports,urls,vulns +macaron scan -f targets.txt -p balanced -q +cat domains.txt | macaron scan --stdin +``` -macaron follows the same UX patterns as ProjectDiscovery tools (nuclei, httpx, subfinder): +**flags** -- **Colored log levels**: `[INF]`, `[WRN]`, `[ERR]`, `[OK]` with distinct colors -- **Live progress**: Braille-spinner with stage and elapsed time during scans -- **Colored tables**: Vulns highlighted in red, live hosts in green -- **Compact flags**: Short (`-scn`, `-mod`, `-prf`) with full-word aliases also accepted -- **NO_COLOR support**: Respects the `NO_COLOR` environment variable -- **Quiet mode**: `-qut` suppresses banner and progress for scripted use +| flag | default | description | +|------|---------|-------------| +| `-t, --target` | — | target domain (repeatable) | +| `-f, --file` | — | read targets from file | +| `--stdin` | — | read targets from stdin | +| `-m, --mode` | `wide` | `wide` \| `narrow` \| `fast` \| `deep` \| `osint` | +| `-p, --profile` | `balanced` | `passive` \| `balanced` \| `aggressive` | +| `--stages` | `all` | comma-separated: `subdomains,http,ports,urls,vulns` | +| `--rate` | `150` | request rate hint | +| `--threads` | `30` | concurrent workers | +| `-q, --quiet` | — | suppress progress output | +| `--storage` | `./storage` | custom storage root | -## Storage +**profiles** -Default storage root: `./storage` +| profile | behaviour | +|---------|-----------| +| `passive` | OSINT-only, low rate, no active scanning | +| `balanced` | enumeration + probing + vuln scan | +| `aggressive` | max concurrency, all stages — authorized testing only | -```text -storage/ - macaron.db - config.yaml - / - .json - latest.txt -``` +--- + +## pipeline stages + +| stage | what it does | tools used | +|-------|-------------|------------| +| `subdomains` | passive + active enumeration | crt.sh, subfinder, assetfinder, findomain, amass + SecurityTrails API | +| `http` | probe live hosts, grab titles | httpx fallback (native prober) | +| `ports` | TCP port sweep | naabu (if installed), native TCP dial fallback | +| `urls` | passive + active URL harvest | Wayback CDX, gau, katana | +| `vulns` | template-based vuln detection | nuclei | + +Tools are used automatically when installed. Missing tools are skipped — pipeline keeps running. + +--- + +## api management -## Setup & API Keys +macaron maintains a single global key store. All tools it runs pick up keys from here automatically — you don't configure them per-tool. ```bash -macaron setup -macaron -ins -macaron -sak securitytrails=YOUR_KEY -macaron -shk +# set keys +macaron api set securitytrails=KEY shodan=KEY virustotal=KEY github=TOKEN + +# remove a key +macaron api unset shodan + +# view (masked) +macaron api list + +# import from tools already on your system (subfinder, amass…) +macaron api import + +# load many keys at once from a YAML file +macaron api bulk -f keys.yaml ``` -## Stage Control +**bulk file format** -```bash -macaron scan example.com -stg subdomains,http,urls +```yaml +api_keys: + securitytrails: YOUR_KEY + shodan: YOUR_KEY + virustotal: YOUR_KEY + chaos: YOUR_KEY + binaryedge: YOUR_KEY + github: YOUR_TOKEN ``` -Available stages: `subdomains`, `http`, `ports`, `urls`, `vulns` +Keys are written to `/config.yaml`. When subfinder runs, macaron injects the configured keys via a temporary provider config — your existing subfinder config is never modified. + +--- -## Dashboard +## results ```bash -macaron serve -# or with custom address: -macaron serve -adr 127.0.0.1:8088 +macaron status # recent scans +macaron results -d example.com # full JSON +macaron results -d example.com -w live # live hosts only +macaron results -d example.com -w vulns # findings only +macaron results --id # specific scan ``` -Open `http://127.0.0.1:8088` — includes scan list with mode filters, health badges, URL yield trend, and geo map. +**-w values**: `all` · `subdomains` · `live` · `ports` · `urls` · `js` · `vulns` -## Release +--- + +## storage layout + +``` +storage/ + macaron.db # indexed scan store + config.yaml # API keys + settings + / + .json + latest.txt +``` + +Override with `--storage /path/to/dir` or `MACARON_HOME` (env not yet supported — use the flag). + +--- + +## setup ```bash -git tag v3.0.1 -git push origin v3.0.1 +macaron setup # show tool inventory +macaron setup --install # auto-install missing tools that support it ``` -Tagged releases build and publish binaries for Linux, macOS, and Windows. +Supported tools (auto-installed with `--install`): + +| tool | role | +|------|------| +| subfinder | subdomain enumeration | +| assetfinder | subdomain enumeration | +| amass | subdomain enumeration | +| httpx | HTTP probing | +| dnsx | DNS resolution | +| naabu | port scanning | +| gau | passive URL discovery | +| waybackurls | passive URL discovery | +| katana | active web crawling | +| gospider | active web crawling | +| hakrawler | active web crawling | +| ffuf | content fuzzing | +| gobuster | content fuzzing | +| nuclei | vulnerability scanning | + +--- + +## uninstall + +```bash +macaron uninstall +``` + +Locates and removes the macaron binary from PATH. Optionally removes the storage directory when prompted. Pass `--yes` to skip confirmation. + +--- + +## security -## Security Note +Use macaron only on systems you own or have explicit written authorization to test. -Use only on systems you own or are explicitly authorized to test. diff --git a/cmd/macaron/main.go b/cmd/macaron/main.go index 8c4f06b..de29723 100644 --- a/cmd/macaron/main.go +++ b/cmd/macaron/main.go @@ -1,13 +1,13 @@ package main import ( + "bufio" "context" "errors" "fmt" "os" "os/signal" "path/filepath" - "runtime" "strings" "syscall" "time" @@ -16,109 +16,101 @@ import ( "github.com/root-Manas/macaron/internal/cfg" "github.com/root-Manas/macaron/internal/cliui" "github.com/root-Manas/macaron/internal/model" - "github.com/root-Manas/macaron/internal/ui" "github.com/spf13/pflag" ) -const version = "3.0.0" +const version = "3.1.0" func main() { os.Exit(run()) } func run() int { - normalizeLegacyArgs() - normalizeCommandArgs() - normalizeCompactFlags() - - var ( - scanTargets []string - status bool - results bool - listTools bool - export bool - configCmd bool - pipeline bool - serve bool - filePath string - useStdin bool - domain string - scanID string - what string - mode string - fast bool - narrow bool - rate int - threads int - limit int - output string - quiet bool - noColor bool - showVersion bool - serveAddr string - storagePath string - stages string - setAPI []string - showAPI bool - setup bool - installTools bool - profile string - guide bool - ) - - pflag.StringArrayVar(&scanTargets, "scn", nil, "Scan target(s)") - pflag.BoolVar(&status, "sts", false, "Show scan status") - pflag.BoolVar(&results, "res", false, "Show results") - pflag.BoolVar(&listTools, "lst", false, "List external tool availability") - pflag.BoolVar(&export, "exp", false, "Export results to JSON") - pflag.BoolVar(&configCmd, "cfg", false, "Show config paths") - pflag.BoolVar(&pipeline, "pip", false, "Show pipeline path (v2 native pipeline is built-in)") - pflag.BoolVar(&serve, "srv", false, "Start web dashboard server") - - pflag.StringVar(&filePath, "fil", "", "Read targets from file") - pflag.BoolVar(&useStdin, "inp", false, "Read targets from stdin") - pflag.StringVar(&domain, "dom", "", "Filter by domain") - pflag.StringVar(&scanID, "sid", "", "Fetch specific scan ID") - pflag.StringVar(&what, "wht", "all", "Result view: all|subdomains|live|ports|urls|js|vulns") - pflag.StringVar(&mode, "mod", "wide", "Mode: wide|narrow|fast|deep|osint") - pflag.BoolVar(&fast, "fst", false, "Shortcut for mode fast") - pflag.BoolVar(&narrow, "nrw", false, "Shortcut for mode narrow") - pflag.IntVar(&rate, "rte", 150, "Request rate hint") - pflag.IntVar(&threads, "thr", 30, "Worker threads") - pflag.IntVar(&limit, "lim", 50, "Output limit") - pflag.StringVar(&output, "out", "", "Output file") - pflag.BoolVar(&quiet, "qut", false, "Quiet output (no banner, no progress)") - pflag.BoolVar(&noColor, "nc", false, "Disable color output") - pflag.BoolVar(&showVersion, "ver", false, "Show version") - pflag.StringVar(&serveAddr, "adr", "127.0.0.1:8088", "Dashboard bind address") - pflag.StringVar(&storagePath, "str", "", "Storage root directory (default: ./storage)") - pflag.StringVar(&stages, "stg", "all", "Comma-separated stages: subdomains,http,ports,urls,vulns") - pflag.StringArrayVar(&setAPI, "sak", nil, "Set API key as name=value (repeatable). Use empty value to unset.") - pflag.BoolVar(&showAPI, "shk", false, "Show configured API keys (masked)") - pflag.BoolVar(&setup, "stp", false, "Show setup screen with tool installation status") - pflag.BoolVar(&installTools, "ins", false, "Install missing supported tools (Linux)") - pflag.StringVar(&profile, "prf", "balanced", "Workflow profile: passive|balanced|aggressive") - pflag.BoolVar(&guide, "gud", false, "Show first-principles workflow guide") - pflag.Parse() - - if noColor { - os.Setenv("NO_COLOR", "1") - } - - if showVersion { - cliui.PrintBanner(version, false) - fmt.Printf("macaronV2 %s (Go %s, stable)\n", version, runtime.Version()) + if len(os.Args) < 2 { + cliui.PrintBanner(os.Stderr, version, false) + printHelp() return 0 } - if guide { - cliui.PrintBanner(version, quiet) + + cmd := strings.ToLower(strings.TrimSpace(os.Args[1])) + + switch cmd { + case "scan": + return runScan(os.Args[2:]) + case "status": + return runStatus(os.Args[2:]) + case "results": + return runResults(os.Args[2:]) + case "setup": + return runSetup(os.Args[2:]) + case "export": + return runExport(os.Args[2:]) + case "config": + return runConfig(os.Args[2:]) + case "api": + return runAPI(os.Args[2:]) + case "uninstall": + return runUninstall(os.Args[2:]) + case "guide": printGuide() return 0 + case "version", "--version", "-version", "-v": + fmt.Printf("macaron %s\n", version) + return 0 + case "help", "--help", "-help", "-h": + cliui.PrintBanner(os.Stderr, version, false) + printHelp() + return 0 + default: + // Legacy compat: treat unknown first arg as a scan target if it looks like a domain. + if looksLikeDomain(cmd) { + return runScan(os.Args[1:]) + } + cliui.Err("unknown command: %s (run 'macaron help')", cmd) + return 1 + } +} + +// ─── scan ──────────────────────────────────────────────────────────────────── + +func runScan(args []string) int { + fs := pflag.NewFlagSet("scan", pflag.ContinueOnError) + var ( + targets []string + file string + stdin bool + mode string + rate int + threads int + stages string + profile string + quiet bool + storage string + ) + fs.StringArrayVarP(&targets, "target", "t", nil, "Target domain(s) (repeatable)") + fs.StringVarP(&file, "file", "f", "", "Read targets from file (one per line)") + fs.BoolVar(&stdin, "stdin", false, "Read targets from stdin") + fs.StringVarP(&mode, "mode", "m", "wide", "Scan mode: wide|narrow|fast|deep|osint") + fs.IntVar(&rate, "rate", 150, "Request rate hint") + fs.IntVar(&threads, "threads", 30, "Concurrent workers") + fs.StringVar(&stages, "stages", "all", "Comma-separated stages: subdomains,http,ports,urls,vulns") + fs.StringVarP(&profile, "profile", "p", "balanced", "Workflow profile: passive|balanced|aggressive") + fs.BoolVarP(&quiet, "quiet", "q", false, "Suppress progress output") + fs.StringVar(&storage, "storage", "", "Storage root (default: ./storage)") + _ = fs.Parse(args) + + // Positional args are also targets. + for _, a := range fs.Args() { + targets = append(targets, a) } - home, err := macaronHome(storagePath) + if !quiet { + cliui.PrintBanner(os.Stderr, version, false) + } + + home, err := macaronHome(storage) if err != nil { - cliui.Err("storage: %v", err) + cliui.Err("%v", err) return 1 } application, err := app.New(home) @@ -131,143 +123,27 @@ func run() int { cliui.Err("loading config: %v", err) return 1 } - if len(setAPI) > 0 { - cfg.ApplySetAPI(config, setAPI) - if err := cfg.Save(home, config); err != nil { - cliui.Err("saving config: %v", err) - return 1 - } - cliui.OK("API keys saved → %s", filepath.Join(home, "config.yaml")) - return 0 - } - if showAPI { - items := cfg.MaskedKeys(config) - if len(items) == 0 { - cliui.Info("No API keys configured") - return 0 - } - cliui.Info("Configured API keys:") - for _, item := range items { - fmt.Printf(" %s %s\n", cliui.CyanText("•"), item) - } - return 0 - } - if setup || installTools { - cliui.PrintBanner(version, quiet) - tools := app.SetupCatalog() - fmt.Print(app.RenderSetup(tools)) - if installTools { - cliui.Info("Installing missing tools…") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() - installed, err := app.InstallMissingTools(ctx, tools) - if err != nil { - cliui.Err("setup: %v", err) - return 1 - } - if len(installed) == 0 { - cliui.Info("No installable missing tools found.") - } else { - cliui.OK("Installed: %s", strings.Join(installed, ", ")) - } - fmt.Print(app.RenderSetup(app.SetupCatalog())) - } - return 0 - } - - if configCmd { - fmt.Print(application.ShowConfig()) - return 0 - } - if pipeline { - cliui.Info("Pipeline (macaronV2 native): %s", filepath.Join(home, "pipeline.v2.yaml")) - return 0 - } - if listTools { - cliui.PrintBanner(version, quiet) - for _, t := range app.ListTools() { - if t.Installed { - fmt.Printf(" %s %-14s %s\n", cliui.GreenText("✔"), t.Name, cliui.Muted("installed")) - } else { - fmt.Printf(" %s %-14s %s\n", cliui.YellowText("✘"), t.Name, cliui.Muted("missing")) - } - } - return 0 - } - if status { - cliui.PrintBanner(version, quiet) - out, err := application.ShowStatus(limit) - if err != nil { - cliui.Err("%v", err) - return 1 - } - fmt.Print(out) - return 0 - } - if results { - out, err := application.ShowResults(domain, scanID, what, limit) - if err != nil { - cliui.Err("%v", err) - return 1 - } - fmt.Print(out) - return 0 - } - if export { - path, err := application.Export(output, domain) - if err != nil { - cliui.Err("%v", err) - return 1 - } - cliui.OK("Exported → %s", path) - return 0 - } - if serve { - cliui.PrintBanner(version, quiet) - cliui.Info("Starting dashboard on http://%s", serveAddr) - server := ui.New(application.Store) - if err := server.Serve(serveAddr); err != nil { - cliui.Err("%v", err) - return 1 - } - return 0 - } - targets, err := app.ParseTargets(scanTargets, filePath, useStdin) + allTargets, err := app.ParseTargets(targets, file, stdin) if err != nil && !errors.Is(err, os.ErrNotExist) { cliui.Err("%v", err) return 1 } - if len(targets) == 0 { - printHelp() - return 0 + if len(allTargets) == 0 { + cliui.Err("no targets provided — use -t or pass positional args") + return 1 } + applyProfile(profile, &mode, &rate, &threads, &stages) - if fast { - mode = "fast" - } - if narrow { - mode = "narrow" - } if rate <= 0 { - cliui.Err("--rte (rate) must be > 0") + cliui.Err("--rate must be > 0") return 1 } if threads <= 0 { - cliui.Err("--thr (threads) must be > 0") + cliui.Err("--threads must be > 0") return 1 } - cliui.PrintBanner(version, quiet) - if !quiet { - cliui.Info("Profile: %s | mode: %s | stages: %s | rate: %d | threads: %d", - cliui.Highlight(profile), cliui.Highlight(mode), - cliui.Highlight(stages), rate, threads) - for _, t := range targets { - cliui.Info("Target: %s", cliui.Highlight(t)) - } - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -277,9 +153,11 @@ func run() int { if !quiet { renderer = cliui.NewLiveRenderer(os.Stdout) defer renderer.Close() + cliui.Info("profile=%s mode=%s stages=%s rate=%d threads=%d targets=%d", + profile, mode, stages, rate, threads, len(allTargets)) } res, err := application.Scan(ctx, app.ScanArgs{ - Targets: targets, + Targets: allTargets, Mode: modeVal, Rate: rate, Threads: threads, @@ -297,193 +175,493 @@ func run() int { return 1 } if !quiet { - elapsed := time.Since(start).Round(time.Millisecond) - cliui.OK("Completed %d target(s) in %s", len(res), elapsed) - fmt.Println() fmt.Println(app.RenderScanSummary(res)) + cliui.OK("finished %d target(s) in %s", len(res), time.Since(start).Round(time.Millisecond)) } return 0 } -func printHelp() { - c := cliui.CyanText - b := cliui.Highlight - m := cliui.Muted - - cliui.PrintBanner(version, false) - fmt.Printf("%s\n", b("USAGE")) - fmt.Printf(" macaron %s example.com\n", c("scan")) - fmt.Printf(" macaron %s\n", c("status")) - fmt.Printf(" macaron %s -dom example.com -wht live\n", c("results")) - fmt.Printf(" macaron %s -adr 127.0.0.1:8088\n", c("serve")) - fmt.Printf(" macaron %s\n", c("setup")) - fmt.Printf(" macaron %s -out results.json\n\n", c("export")) - - fmt.Printf("%s\n", b("SCAN FLAGS")) - fmt.Printf(" %s TARGET %s\n", c("-scn"), m("Scan one or more targets (repeatable)")) - fmt.Printf(" %s FILE %s\n", c("-fil"), m("Read targets from file")) - fmt.Printf(" %s %s\n", c("-inp"), m("Read targets from stdin")) - fmt.Printf(" %s MODE %s\n", c("-mod"), m("Scan mode: wide|narrow|fast|deep|osint")) - fmt.Printf(" %s LIST %s\n", c("-stg"), m("Stages: subdomains,http,ports,urls,vulns")) - fmt.Printf(" %s NAME %s\n", c("-prf"), m("Profile: passive|balanced|aggressive")) - fmt.Printf(" %s N %s\n", c("-rte"), m("Request rate hint (default: 150)")) - fmt.Printf(" %s N %s\n\n", c("-thr"), m("Worker threads (default: 30)")) - - fmt.Printf("%s\n", b("OUTPUT FLAGS")) - fmt.Printf(" %s %s\n", c("-sts"), m("Show recent scan summaries")) - fmt.Printf(" %s %s\n", c("-res"), m("Show scan results")) - fmt.Printf(" %s DOMAIN %s\n", c("-dom"), m("Filter by domain")) - fmt.Printf(" %s ID %s\n", c("-sid"), m("Fetch specific scan ID")) - fmt.Printf(" %s TYPE %s\n", c("-wht"), m("Result view: all|subdomains|live|ports|urls|js|vulns")) - fmt.Printf(" %s N %s\n", c("-lim"), m("Output limit (default: 50)")) - fmt.Printf(" %s FILE %s\n", c("-out"), m("Output file for export")) - fmt.Printf(" %s %s\n", c("-exp"), m("Export results to JSON")) - fmt.Printf(" %s %s\n\n", c("-qut"), m("Quiet mode (suppress banner and progress)")) - - fmt.Printf("%s\n", b("API KEYS")) - fmt.Printf(" %s k=v %s\n", c("-sak"), m("Set API key (e.g. -sak securitytrails=KEY)")) - fmt.Printf(" %s %s\n\n", c("-shk"), m("Show masked API keys")) - - fmt.Printf("%s\n", b("DASHBOARD")) - fmt.Printf(" %s %s\n", c("-srv"), m("Start browser dashboard")) - fmt.Printf(" %s ADDR %s\n\n", c("-adr"), m("Bind address (default: 127.0.0.1:8088)")) - - fmt.Printf("%s\n", b("TOOLS & CONFIG")) - fmt.Printf(" %s %s\n", c("-stp"), m("Show tool installation status")) - fmt.Printf(" %s %s\n", c("-ins"), m("Install missing supported tools (Linux)")) - fmt.Printf(" %s %s\n", c("-lst"), m("List external tool availability")) - fmt.Printf(" %s DIR %s\n", c("-str"), m("Custom storage root (default: ./storage)")) - fmt.Printf(" %s %s\n", c("-cfg"), m("Show config paths")) - fmt.Printf(" %s %s\n", c("-gud"), m("Show first-principles workflow guide")) - fmt.Printf(" %s %s\n", c("-nc"), m("Disable color output")) - fmt.Printf(" %s %s\n\n", c("-ver"), m("Show version")) - - fmt.Printf("%s\n", b("EXAMPLES")) - fmt.Printf(" %s\n", m("# Passive OSINT scan")) - fmt.Printf(" macaron scan example.com %s passive\n\n", c("-prf")) - fmt.Printf(" %s\n", m("# Aggressive full scan")) - fmt.Printf(" macaron scan example.com %s aggressive %s subdomains,http,ports,urls,vulns\n\n", c("-prf"), c("-stg")) - fmt.Printf(" %s\n", m("# View live hosts from last scan")) - fmt.Printf(" macaron results %s example.com %s live\n\n", c("-dom"), c("-wht")) - fmt.Printf(" %s\n", m("# Use API key for better coverage")) - fmt.Printf(" macaron %s securitytrails=YOUR_KEY\n\n", c("-sak")) +// ─── status ────────────────────────────────────────────────────────────────── + +func runStatus(args []string) int { + fs := pflag.NewFlagSet("status", pflag.ContinueOnError) + var limit int + var storage string + fs.IntVarP(&limit, "limit", "n", 50, "Number of recent scans to show") + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + application, err := app.New(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + out, err := application.ShowStatus(limit) + if err != nil { + cliui.Err("%v", err) + return 1 + } + fmt.Print(out) + return 0 +} + +// ─── results ───────────────────────────────────────────────────────────────── + +func runResults(args []string) int { + fs := pflag.NewFlagSet("results", pflag.ContinueOnError) + var ( + domain string + id string + what string + limit int + storage string + ) + fs.StringVarP(&domain, "domain", "d", "", "Filter by target domain") + fs.StringVar(&id, "id", "", "Fetch specific scan by ID") + fs.StringVarP(&what, "what", "w", "all", "View: all|subdomains|live|ports|urls|js|vulns") + fs.IntVarP(&limit, "limit", "n", 50, "Output limit per category") + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + application, err := app.New(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + out, err := application.ShowResults(domain, id, what, limit) + if err != nil { + cliui.Err("%v", err) + return 1 + } + fmt.Print(out) + return 0 } -func normalizeLegacyArgs() { - for i, arg := range os.Args { - if arg == "-setup" { - os.Args[i] = "-stp" +// ─── setup ─────────────────────────────────────────────────────────────────── + +func runSetup(args []string) int { + fs := pflag.NewFlagSet("setup", pflag.ContinueOnError) + var install bool + fs.BoolVarP(&install, "install", "i", false, "Auto-install missing tools that support it") + _ = fs.Parse(args) + + tools := app.SetupCatalog() + fmt.Print(app.RenderSetup(tools)) + + if install { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + installed, err := app.InstallMissingTools(ctx, tools) + if err != nil { + cliui.Err("install error: %v", err) + return 1 } - if arg == "-install-tools" { - os.Args[i] = "-ins" + if len(installed) == 0 { + cliui.Info("no installable tools were missing") + } else { + cliui.OK("installed: %s", strings.Join(installed, ", ")) } + fmt.Print(app.RenderSetup(app.SetupCatalog())) } + return 0 } -func normalizeCommandArgs() { - if len(os.Args) < 2 { - return +// ─── export ────────────────────────────────────────────────────────────────── + +func runExport(args []string) int { + fs := pflag.NewFlagSet("export", pflag.ContinueOnError) + var output, domain, storage string + fs.StringVarP(&output, "output", "o", "", "Output file path") + fs.StringVarP(&domain, "domain", "d", "", "Filter by domain") + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 } - cmd := strings.ToLower(strings.TrimSpace(os.Args[1])) - rest := os.Args[2:] - switch cmd { - case "scan": - args := []string{os.Args[0]} - for _, tok := range rest { - if strings.HasPrefix(tok, "-") { - args = append(args, tok) - continue - } - args = append(args, "--scn", tok) - } - if len(args) == 1 { - args = append(args, "--scn") - } - os.Args = args - case "status": - os.Args = append([]string{os.Args[0], "--sts"}, rest...) - case "results": - os.Args = append([]string{os.Args[0], "--res"}, rest...) - case "serve": - os.Args = append([]string{os.Args[0], "--srv"}, rest...) - case "setup": - os.Args = append([]string{os.Args[0], "--stp"}, rest...) - case "export": - os.Args = append([]string{os.Args[0], "--exp"}, rest...) - case "config": - os.Args = append([]string{os.Args[0], "--cfg"}, rest...) - case "guide": - os.Args = append([]string{os.Args[0], "--gud"}, rest...) + application, err := app.New(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + path, err := application.Export(output, domain) + if err != nil { + cliui.Err("%v", err) + return 1 } + cliui.OK("exported → %s", path) + return 0 } -func normalizeCompactFlags() { - flagMap := map[string]string{ - "scan": "scn", "s": "scn", "scn": "scn", - "status": "sts", "S": "sts", "sts": "sts", - "results": "res", "R": "res", "res": "res", - "list-tools": "lst", "L": "lst", "lst": "lst", - "export": "exp", "E": "exp", "exp": "exp", - "config": "cfg", "C": "cfg", "cfg": "cfg", - "pipeline": "pip", "P": "pip", "pip": "pip", - "serve": "srv", "srv": "srv", - "file": "fil", "F": "fil", "fil": "fil", - "stdin": "inp", "inp": "inp", - "domain": "dom", "d": "dom", "dom": "dom", - "id": "sid", "sid": "sid", - "what": "wht", "w": "wht", "wht": "wht", - "mode": "mod", "m": "mod", "mod": "mod", - "fast": "fst", "f": "fst", "fst": "fst", - "narrow": "nrw", "n": "nrw", "nrw": "nrw", - "rate": "rte", "rte": "rte", - "threads": "thr", "thr": "thr", - "limit": "lim", "lim": "lim", - "output": "out", "o": "out", "out": "out", - "quiet": "qut", "q": "qut", "qut": "qut", - "no-color": "nc", "nc": "nc", - "version": "ver", "ver": "ver", - "addr": "adr", "adr": "adr", - "storage": "str", "str": "str", - "stages": "stg", "stg": "stg", - "set-api": "sak", "sak": "sak", - "show-api": "shk", "shk": "shk", - "setup": "stp", "stp": "stp", - "install-tools": "ins", "ins": "ins", - "profile": "prf", "prf": "prf", - "guide": "gud", "gud": "gud", - } - args := make([]string, 0, len(os.Args)) - args = append(args, os.Args[0]) - for _, arg := range os.Args[1:] { - if strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && len(arg) > 2 { - key := strings.TrimPrefix(arg, "-") - val := "" - if i := strings.IndexRune(key, '='); i >= 0 { - val = key[i:] - key = key[:i] - } - if mapped, ok := flagMap[key]; ok { - args = append(args, "--"+mapped+val) - continue - } +// ─── config ────────────────────────────────────────────────────────────────── + +func runConfig(args []string) int { + fs := pflag.NewFlagSet("config", pflag.ContinueOnError) + var storage string + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + application, err := app.New(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + fmt.Print(application.ShowConfig()) + return 0 +} + +// ─── api ───────────────────────────────────────────────────────────────────── + +func runAPI(args []string) int { + if len(args) == 0 { + printAPIHelp() + return 0 + } + sub := strings.ToLower(strings.TrimSpace(args[0])) + rest := args[1:] + switch sub { + case "list": + return apiList(rest) + case "set": + return apiSet(rest) + case "unset": + return apiUnset(rest) + case "import": + return apiImport(rest) + case "bulk": + return apiBulk(rest) + default: + cliui.Err("unknown api subcommand: %s (list|set|unset|import|bulk)", sub) + return 1 + } +} + +func apiList(args []string) int { + fs := pflag.NewFlagSet("api list", pflag.ContinueOnError) + var storage string + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + config, err := cfg.Load(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + items := cfg.MaskedKeys(config) + if len(items) == 0 { + cliui.Info("no API keys configured — use 'macaron api set key=value'") + return 0 + } + fmt.Printf("configured API keys (%s):\n", filepath.Join(home, "config.yaml")) + for _, item := range items { + fmt.Printf(" %s\n", item) + } + return 0 +} + +func apiSet(args []string) int { + fs := pflag.NewFlagSet("api set", pflag.ContinueOnError) + var storage string + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + kvs := fs.Args() + if len(kvs) == 0 { + cliui.Err("usage: macaron api set key=value [key=value ...]") + return 1 + } + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + config, err := cfg.Load(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + cfg.ApplySetAPI(config, kvs) + if err := cfg.Save(home, config); err != nil { + cliui.Err("%v", err) + return 1 + } + cliui.OK("saved %d key(s) → %s", len(kvs), filepath.Join(home, "config.yaml")) + return 0 +} + +func apiUnset(args []string) int { + // Reuse set with empty value to delete. + if len(args) == 0 { + cliui.Err("usage: macaron api unset key [key ...]") + return 1 + } + // Append "=" to each key so ApplySetAPI treats it as a deletion. + kvs := make([]string, len(args)) + for i, k := range args { + kvs[i] = strings.TrimSuffix(k, "=") + "=" + } + return apiSet(kvs) +} + +func apiImport(args []string) int { + fs := pflag.NewFlagSet("api import", pflag.ContinueOnError) + var storage string + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + config, err := cfg.Load(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + imported := cfg.ImportFromTools(config) + if len(imported) == 0 { + cliui.Info("no new keys found in installed tool configs") + return 0 + } + if err := cfg.Save(home, config); err != nil { + cliui.Err("%v", err) + return 1 + } + for _, line := range imported { + cliui.OK("imported: %s", line) + } + return 0 +} + +func apiBulk(args []string) int { + fs := pflag.NewFlagSet("api bulk", pflag.ContinueOnError) + var file, storage string + fs.StringVarP(&file, "file", "f", "", "YAML file with api_keys map (required)") + fs.StringVar(&storage, "storage", "", "Storage root") + _ = fs.Parse(args) + + if file == "" && len(fs.Args()) > 0 { + file = fs.Args()[0] + } + if file == "" { + cliui.Err("usage: macaron api bulk --file keys.yaml") + return 1 + } + home, err := macaronHome(storage) + if err != nil { + cliui.Err("%v", err) + return 1 + } + config, err := cfg.Load(home) + if err != nil { + cliui.Err("%v", err) + return 1 + } + count, err := cfg.BulkLoadFile(config, file) + if err != nil { + cliui.Err("%v", err) + return 1 + } + if err := cfg.Save(home, config); err != nil { + cliui.Err("%v", err) + return 1 + } + cliui.OK("loaded %d key(s) from %s", count, file) + return 0 +} + +// ─── uninstall ─────────────────────────────────────────────────────────────── + +func runUninstall(args []string) int { + fs := pflag.NewFlagSet("uninstall", pflag.ContinueOnError) + var storage string + var yes bool + fs.StringVar(&storage, "storage", "", "Storage root to also remove (optional)") + fs.BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + _ = fs.Parse(args) + + execPath, err := os.Executable() + if err != nil { + cliui.Err("cannot locate binary: %v", err) + return 1 + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + cliui.Err("cannot resolve symlink: %v", err) + return 1 + } + + fmt.Printf("binary path : %s\n", execPath) + + home, _ := macaronHome(storage) + removeStorage := false + + if !yes { + fmt.Printf("remove binary %s? [y/N] ", execPath) + if !readYes() { + fmt.Println("aborted") + return 0 } - if strings.HasPrefix(arg, "--") { - key := strings.TrimPrefix(arg, "--") - val := "" - if i := strings.IndexRune(key, '='); i >= 0 { - val = key[i:] - key = key[:i] - } - if mapped, ok := flagMap[key]; ok { - args = append(args, "--"+mapped+val) - continue - } + if home != "" { + fmt.Printf("remove storage directory %s? [y/N] ", home) + removeStorage = readYes() } - args = append(args, arg) + } else { + removeStorage = storage != "" + } + + if err := os.Remove(execPath); err != nil { + cliui.Err("failed to remove binary: %v", err) + return 1 } - os.Args = args + cliui.OK("removed binary: %s", execPath) + + if removeStorage && home != "" { + if err := os.RemoveAll(home); err != nil { + cliui.Warn("could not remove storage: %v", err) + } else { + cliui.OK("removed storage: %s", home) + } + } + + fmt.Println("macaron uninstalled") + return 0 +} + +func readYes() bool { + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + return strings.ToLower(strings.TrimSpace(scanner.Text())) == "y" +} + +// ─── help ──────────────────────────────────────────────────────────────────── + +func printHelp() { + fmt.Print(`usage: + macaron [flags] + +commands: + scan run a recon pipeline against one or more targets + status show scan history + results inspect scan output + setup check / install required tools + export dump results to JSON + config show storage and config paths + api manage global API keys + uninstall remove macaron from this system + guide first-principles workflow walkthrough + version print version + +scan flags: + -t, --target DOMAIN target domain (repeatable) + -f, --file FILE read targets from file + --stdin read targets from stdin + -m, --mode MODE wide|narrow|fast|deep|osint (default: wide) + -p, --profile NAME passive|balanced|aggressive (default: balanced) + --stages LIST subdomains,http,ports,urls,vulns (default: all) + --rate N request rate hint (default: 150) + --threads N workers (default: 30) + -q, --quiet suppress progress output + --storage DIR custom storage root + +api subcommands: + macaron api list + macaron api set key=value [key=value ...] + macaron api unset key [key ...] + macaron api import # pull keys from installed tool configs + macaron api bulk -f keys.yaml # load many keys from a YAML file + +examples: + macaron scan -t example.com + macaron scan -t example.com -p aggressive --stages subdomains,http,vulns + macaron scan -f targets.txt -p passive -q + macaron status + macaron results -d example.com -w vulns + macaron api set securitytrails=YOURKEY shodan=YOURKEY + macaron api import + macaron setup --install + macaron uninstall +`) +} + +func printAPIHelp() { + fmt.Print(`macaron api — global API key management + +subcommands: + list show configured keys (masked) + set key=value ... set one or more keys + unset key ... remove key(s) + import import from installed tool configs (subfinder, amass…) + bulk -f keys.yaml load many keys at once from a YAML file + +the bulk file format: + api_keys: + securitytrails: YOUR_KEY + shodan: YOUR_KEY + virustotal: YOUR_KEY + +keys set here are automatically injected into supported tools (e.g. subfinder) +when macaron runs them, without touching your tool-specific configs. +`) +} + +func printGuide() { + fmt.Print(`macaron — first-principles workflow + +1. provision once: + macaron setup --install + macaron api set securitytrails=KEY shodan=KEY virustotal=KEY + # or pull keys already in your tools: + macaron api import + +2. enumerate with intent: + macaron scan -t target.com -p passive # low-noise, passive only + macaron scan -t target.com -p balanced # default practical pipeline + macaron scan -t target.com -p aggressive \ + --stages subdomains,http,ports,urls,vulns # full depth + +3. triage: + macaron status + macaron results -d target.com -w live + macaron results -d target.com -w vulns + +4. share: + macaron export -o target.json + +profiles: + passive osint-only, low rate, no active probing + balanced default — enumeration + probing + vuln scan + aggressive max concurrency, all stages, authorized testing only + +authorized use only. +`) } +// ─── helpers ───────────────────────────────────────────────────────────────── + func applyProfile(profile string, mode *string, rate *int, threads *int, stages *string) { switch strings.ToLower(strings.TrimSpace(profile)) { case "passive": @@ -506,43 +684,9 @@ func applyProfile(profile string, mode *string, rate *int, threads *int, stages if *threads == 30 { *threads = 70 } - default: - // balanced defaults are already encoded in flags. } } -func printGuide() { - b := cliui.Highlight - c := cliui.CyanText - m := cliui.Muted - g := cliui.GreenText - - fmt.Printf("%s\n\n", b("WORKFLOW GUIDE — first principles")) - - fmt.Printf("%s %s\n", g("1)"), b("Setup once")) - fmt.Printf(" macaron %s\n", c("setup")) - fmt.Printf(" macaron %s\n", c("-ins")) - fmt.Printf(" macaron %s securitytrails=YOUR_KEY\n\n", c("-sak")) - - fmt.Printf("%s %s\n", g("2)"), b("Run intentional scans")) - fmt.Printf(" macaron scan target.com %s passive\n", c("-prf")) - fmt.Printf(" macaron scan target.com %s balanced\n", c("-prf")) - fmt.Printf(" macaron scan target.com %s aggressive %s subdomains,http,ports,urls,vulns\n\n", c("-prf"), c("-stg")) - - fmt.Printf("%s %s\n", g("3)"), b("Inspect and decide")) - fmt.Printf(" macaron %s\n", c("status")) - fmt.Printf(" macaron %s %s target.com %s live\n", c("results"), c("-dom"), c("-wht")) - fmt.Printf(" macaron %s\n\n", c("serve")) - - fmt.Printf("%s %s\n", g("4)"), b("Export / share")) - fmt.Printf(" macaron %s %s target.json\n\n", c("export"), c("-out")) - - fmt.Printf("%s\n", b("PROFILES")) - fmt.Printf(" %s %s\n", c("passive"), m("low-noise, low-rate, mostly passive collection")) - fmt.Printf(" %s %s\n", c("balanced"), m("default practical pipeline")) - fmt.Printf(" %s %s\n\n", c("aggressive"), m("high concurrency for authorized deep testing only")) -} - func macaronHome(override string) (string, error) { if strings.TrimSpace(override) != "" { return filepath.Clean(override), nil @@ -553,3 +697,24 @@ func macaronHome(override string) (string, error) { } return filepath.Join(cwd, "storage"), nil } + +func looksLikeDomain(s string) bool { + if strings.HasPrefix(s, "-") || strings.Contains(s, " ") { + return false + } + parts := strings.Split(s, ".") + if len(parts) < 2 { + return false + } + tld := strings.ToLower(parts[len(parts)-1]) + // TLD must be alphabetic-only and at least 2 characters. + if len(tld) < 2 { + return false + } + for _, c := range tld { + if c < 'a' || c > 'z' { + return false + } + } + return true +} diff --git a/cmd/macaron/main_test.go b/cmd/macaron/main_test.go index fb4d47d..50c88a6 100644 --- a/cmd/macaron/main_test.go +++ b/cmd/macaron/main_test.go @@ -1,75 +1,9 @@ package main import ( - "os" "testing" ) -func withArgs(args []string, fn func()) { - orig := osArgs() - setOsArgs(args) - defer setOsArgs(orig) - fn() -} - -func TestNormalizeLegacySetup(t *testing.T) { - withArgs([]string{"macaron", "-setup"}, func() { - normalizeLegacyArgs() - if osArgs()[1] != "-stp" { - t.Fatalf("expected -stp, got %s", osArgs()[1]) - } - }) -} - -func TestNormalizeCommandScan(t *testing.T) { - withArgs([]string{"macaron", "scan", "example.com", "--fast"}, func() { - normalizeCompactFlags() - normalizeCommandArgs() - args := osArgs() - want := []string{"macaron", "--scn", "example.com", "--fst"} - if len(args) != len(want) { - t.Fatalf("unexpected len: %#v", args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("idx %d: got %q want %q", i, args[i], want[i]) - } - } - }) -} - -func TestNormalizeLongToCompact(t *testing.T) { - withArgs([]string{"macaron", "--scan", "example.com", "--threads", "20"}, func() { - normalizeCompactFlags() - args := osArgs() - want := []string{"macaron", "--scn", "example.com", "--thr", "20"} - if len(args) != len(want) { - t.Fatalf("unexpected len: %#v", args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("idx %d: got %q want %q", i, args[i], want[i]) - } - } - }) -} - -func TestNormalizeSingleDashCompact(t *testing.T) { - withArgs([]string{"macaron", "-stp", "-ver"}, func() { - normalizeCompactFlags() - args := osArgs() - want := []string{"macaron", "--stp", "--ver"} - if len(args) != len(want) { - t.Fatalf("unexpected len: %#v", args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("idx %d: got %q want %q", i, args[i], want[i]) - } - } - }) -} - func TestApplyProfilePassive(t *testing.T) { mode := "wide" rate := 150 @@ -92,10 +26,47 @@ func TestApplyProfileAggressive(t *testing.T) { } } -func osArgs() []string { - return append([]string(nil), os.Args...) +func TestApplyProfileBalanced(t *testing.T) { + mode := "wide" + rate := 150 + threads := 30 + stages := "all" + applyProfile("balanced", &mode, &rate, &threads, &stages) + // balanced leaves defaults unchanged + if mode != "wide" || rate != 150 || threads != 30 || stages != "all" { + t.Fatalf("unexpected balanced values: mode=%s rate=%d threads=%d stages=%s", mode, rate, threads, stages) + } +} + +func TestLooksLikeDomain(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"example.com", true}, + {"sub.example.com", true}, + {"Example.COM", true}, // uppercase TLD accepted + {"-flag", false}, + {"nodots", false}, + {"has space.com", false}, + {"example.123", false}, // numeric tld + {"example.", false}, // empty tld + {"example.c", false}, // tld too short + } + for _, c := range cases { + got := looksLikeDomain(c.in) + if got != c.want { + t.Errorf("looksLikeDomain(%q) = %v, want %v", c.in, got, c.want) + } + } } -func setOsArgs(v []string) { - os.Args = append([]string(nil), v...) +func TestMacaronHomeOverride(t *testing.T) { + got, err := macaronHome("/tmp/test-storage") + if err != nil { + t.Fatal(err) + } + if got != "/tmp/test-storage" { + t.Fatalf("expected /tmp/test-storage, got %s", got) + } } diff --git a/docs/dashboard-analytics.png b/docs/dashboard-analytics.png new file mode 100644 index 0000000..f6a6e05 Binary files /dev/null and b/docs/dashboard-analytics.png differ diff --git a/docs/dashboard-main.png b/docs/dashboard-main.png new file mode 100644 index 0000000..de00704 Binary files /dev/null and b/docs/dashboard-main.png differ diff --git a/install.sh b/install.sh index 1ea1f9a..d412abf 100644 --- a/install.sh +++ b/install.sh @@ -10,14 +10,25 @@ if ! command -v go >/dev/null 2>&1; then fi mkdir -p "$HOME/.local/bin" -echo "[macaronV2] building binary..." +echo "[macaron] building binary..." go mod tidy go build -o "$HOME/.local/bin/macaron" ./cmd/macaron chmod +x "$HOME/.local/bin/macaron" -if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null; then - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc" -fi +PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' + +add_to_profile() { + local profile="$1" + if [ -f "$profile" ] && ! grep -qF 'HOME/.local/bin' "$profile" 2>/dev/null; then + echo "$PATH_LINE" >> "$profile" + echo "[macaron] added PATH entry to $profile" + fi +} + +add_to_profile "$HOME/.bashrc" +add_to_profile "$HOME/.zshrc" +add_to_profile "$HOME/.profile" -echo "[macaronV2] installed to $HOME/.local/bin/macaron" -echo "[macaronV2] run: macaron --version" +echo "[macaron] installed to $HOME/.local/bin/macaron" +echo "[macaron] restart your shell or run: export PATH=\"\$HOME/.local/bin:\$PATH\"" +echo "[macaron] then run: macaron --version" diff --git a/internal/app/app.go b/internal/app/app.go index 706f470..5ad95e5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,13 +16,18 @@ import ( "time" "github.com/jedib0t/go-pretty/v6/table" - "github.com/jedib0t/go-pretty/v6/text" "github.com/root-Manas/macaron/internal/engine" "github.com/root-Manas/macaron/internal/model" "github.com/root-Manas/macaron/internal/store" ) -var toolNames = []string{"subfinder", "assetfinder", "findomain", "nuclei"} +var toolNames = []string{ + "subfinder", "assetfinder", "findomain", "amass", + "nuclei", "httpx", "dnsx", "naabu", + "gau", "waybackurls", "katana", + "ffuf", "gobuster", "feroxbuster", + "gospider", "hakrawler", +} type SetupTool struct { Name string @@ -90,28 +95,20 @@ func (a *App) ShowStatus(limit int) (string, error) { return "", err } if len(summaries) == 0 { - return "No scans found. Run: macaron scan example.com\n", nil + return "no scans on record. run: macaron scan ", nil } b := strings.Builder{} + b.WriteString("scan history\n") tw := table.NewWriter() - tw.SetStyle(tableStyle()) tw.AppendHeader(table.Row{"ID", "TARGET", "MODE", "LIVE", "URLS", "VULNS", "FINISHED"}) for _, s := range summaries { - vulnCell := strconv.Itoa(s.Stats.Vulns) - if s.Stats.Vulns > 0 { - vulnCell = text.Colors{text.FgRed, text.Bold}.Sprint(vulnCell) - } - liveCell := strconv.Itoa(s.Stats.LiveHosts) - if s.Stats.LiveHosts > 0 { - liveCell = text.Colors{text.FgGreen}.Sprint(liveCell) - } tw.AppendRow(table.Row{ - text.Colors{text.FgCyan}.Sprint(s.ID), - text.Colors{text.Bold}.Sprint(s.Target), + s.ID, + s.Target, s.Mode, - liveCell, + strconv.Itoa(s.Stats.LiveHosts), strconv.Itoa(s.Stats.URLs), - vulnCell, + strconv.Itoa(s.Stats.Vulns), s.FinishedAt.Format(time.RFC3339), }) } @@ -246,16 +243,30 @@ func ListTools() []model.ToolStatus { func SetupCatalog() []SetupTool { tools := []SetupTool{ + // Subdomain enumeration {Name: "subfinder", Binary: "subfinder", Required: true, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest"}, {Name: "assetfinder", Binary: "assetfinder", Required: true, InstallMethod: "go", InstallCmd: "go install github.com/tomnomnom/assetfinder@latest"}, - {Name: "findomain", Binary: "findomain", Required: false, InstallMethod: "manual", InstallCmd: "apt install findomain OR download release binary"}, - {Name: "nuclei", Binary: "nuclei", Required: true, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest"}, + {Name: "findomain", Binary: "findomain", Required: false, InstallMethod: "manual", InstallCmd: "https://github.com/Findomain/Findomain/releases"}, + {Name: "amass", Binary: "amass", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/owasp-amass/amass/v4/...@master"}, + // HTTP probing & tech detection {Name: "httpx", Binary: "httpx", Required: true, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/httpx/cmd/httpx@latest"}, + // DNS resolution & brute {Name: "dnsx", Binary: "dnsx", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/dnsx/cmd/dnsx@latest"}, + // Port scanning {Name: "naabu", Binary: "naabu", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/naabu/v2/cmd/naabu@latest"}, + // URL discovery (passive) {Name: "gau", Binary: "gau", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/lc/gau/v2/cmd/gau@latest"}, {Name: "waybackurls", Binary: "waybackurls", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/tomnomnom/waybackurls@latest"}, + // Active crawling {Name: "katana", Binary: "katana", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/katana/cmd/katana@latest"}, + {Name: "gospider", Binary: "gospider", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/jaeles-project/gospider@latest"}, + {Name: "hakrawler", Binary: "hakrawler", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/hakluke/hakrawler@latest"}, + // Content discovery / fuzzing + {Name: "ffuf", Binary: "ffuf", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/ffuf/ffuf/v2@latest"}, + {Name: "gobuster", Binary: "gobuster", Required: false, InstallMethod: "go", InstallCmd: "go install github.com/OJ/gobuster/v3@latest"}, + {Name: "feroxbuster", Binary: "feroxbuster", Required: false, InstallMethod: "manual", InstallCmd: "https://github.com/epi052/feroxbuster/releases"}, + // Vulnerability scanning + {Name: "nuclei", Binary: "nuclei", Required: true, InstallMethod: "go", InstallCmd: "go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest"}, } for i := range tools { _, err := execLookPath(tools[i].Binary) @@ -266,28 +277,40 @@ func SetupCatalog() []SetupTool { func RenderSetup(tools []SetupTool) string { tw := table.NewWriter() - tw.SetStyle(tableStyle()) - tw.AppendHeader(table.Row{"TOOL", "REQUIRED", "STATUS", "INSTALL"}) + tw.AppendHeader(table.Row{"TOOL", "ROLE", "REQUIRED", "STATUS", "INSTALL"}) + + roleMap := map[string]string{ + "subfinder": "subdomain enum", + "assetfinder": "subdomain enum", + "findomain": "subdomain enum", + "amass": "subdomain enum", + "httpx": "http probe", + "dnsx": "dns resolve", + "naabu": "port scan", + "gau": "url discovery", + "waybackurls": "url discovery", + "katana": "active crawl", + "gospider": "active crawl", + "hakrawler": "active crawl", + "ffuf": "content fuzz", + "gobuster": "content fuzz", + "feroxbuster": "content fuzz", + "nuclei": "vuln scan", + } + for _, t := range tools { - required := text.Colors{text.FgYellow}.Sprint("no") + required := "optional" if t.Required { - required = text.Colors{text.FgCyan, text.Bold}.Sprint("yes") + required = "required" } - var status string + status := "missing" if t.Installed { - status = text.Colors{text.FgGreen, text.Bold}.Sprint("✔ installed") - } else { - status = text.Colors{text.FgRed}.Sprint("✘ missing") + status = "installed" } - tw.AppendRow(table.Row{ - text.Colors{text.Bold}.Sprint(t.Name), - required, - status, - text.Colors{text.Faint}.Sprint(t.InstallCmd), - }) + tw.AppendRow(table.Row{t.Name, roleMap[t.Name], required, status, t.InstallCmd}) } b := strings.Builder{} - b.WriteString("macaron setup\n") + b.WriteString("tool inventory\n") b.WriteString(tw.Render()) b.WriteString("\n") return b.String() @@ -314,24 +337,15 @@ func InstallMissingTools(ctx context.Context, tools []SetupTool) ([]string, erro func RenderScanSummary(results []model.ScanResult) string { tw := table.NewWriter() - tw.SetStyle(tableStyle()) tw.AppendHeader(table.Row{"TARGET", "MODE", "SUBDOMAINS", "LIVE", "URLS", "VULNS", "DURATION"}) for _, r := range results { - vulnCell := strconv.Itoa(r.Stats.Vulns) - if r.Stats.Vulns > 0 { - vulnCell = text.Colors{text.FgRed, text.Bold}.Sprint(vulnCell) - } - liveCell := strconv.Itoa(r.Stats.LiveHosts) - if r.Stats.LiveHosts > 0 { - liveCell = text.Colors{text.FgGreen}.Sprint(liveCell) - } tw.AppendRow(table.Row{ - text.Colors{text.Bold}.Sprint(r.Target), + r.Target, r.Mode, r.Stats.Subdomains, - liveCell, + r.Stats.LiveHosts, r.Stats.URLs, - vulnCell, + r.Stats.Vulns, fmt.Sprintf("%dms", r.DurationMS), }) } @@ -413,12 +427,3 @@ func ParseStages(raw string) map[string]bool { } return out } - -// tableStyle returns a consistent styled table style for all output tables. -func tableStyle() table.Style { - s := table.StyleRounded - s.Color.Header = text.Colors{text.FgCyan, text.Bold} - s.Color.Border = text.Colors{text.Faint} - s.Color.Separator = text.Colors{text.Faint} - return s -} diff --git a/internal/cfg/config.go b/internal/cfg/config.go index a3e7cf8..09da4bf 100644 --- a/internal/cfg/config.go +++ b/internal/cfg/config.go @@ -1,6 +1,7 @@ package cfg import ( + "fmt" "os" "path/filepath" "sort" @@ -9,10 +10,12 @@ import ( "gopkg.in/yaml.v3" ) +// Config holds macaron's persistent settings. type Config struct { APIKeys map[string]string `yaml:"api_keys"` } +// Load reads config from storageRoot/config.yaml. Missing file returns empty config. func Load(storageRoot string) (*Config, error) { cfg := &Config{APIKeys: map[string]string{}} path := filepath.Join(storageRoot, "config.yaml") @@ -32,6 +35,7 @@ func Load(storageRoot string) (*Config, error) { return cfg, nil } +// Save writes config to storageRoot/config.yaml. func Save(storageRoot string, cfg *Config) error { if err := os.MkdirAll(storageRoot, 0o755); err != nil { return err @@ -46,6 +50,7 @@ func Save(storageRoot string, cfg *Config) error { return os.WriteFile(filepath.Join(storageRoot, "config.yaml"), b, 0o644) } +// ApplySetAPI merges key=value pairs into config. Empty value removes the key. func ApplySetAPI(cfg *Config, kvs []string) { if cfg.APIKeys == nil { cfg.APIKeys = map[string]string{} @@ -68,6 +73,136 @@ func ApplySetAPI(cfg *Config, kvs []string) { } } +// BulkLoadFile reads a YAML file of the form `api_keys: {key: value}` and +// merges its contents into cfg. It also accepts a flat map[string]string. +func BulkLoadFile(cfg *Config, path string) (int, error) { + b, err := os.ReadFile(path) + if err != nil { + return 0, err + } + // Try structured Config format first. + var bulk Config + if err := yaml.Unmarshal(b, &bulk); err == nil && len(bulk.APIKeys) > 0 { + if cfg.APIKeys == nil { + cfg.APIKeys = map[string]string{} + } + for k, v := range bulk.APIKeys { + k = strings.ToLower(strings.TrimSpace(k)) + if k != "" && strings.TrimSpace(v) != "" { + cfg.APIKeys[k] = strings.TrimSpace(v) + } + } + return len(bulk.APIKeys), nil + } + // Try flat map. + var flat map[string]string + if err := yaml.Unmarshal(b, &flat); err == nil && len(flat) > 0 { + if cfg.APIKeys == nil { + cfg.APIKeys = map[string]string{} + } + count := 0 + for k, v := range flat { + k = strings.ToLower(strings.TrimSpace(k)) + if k != "" && strings.TrimSpace(v) != "" { + cfg.APIKeys[k] = strings.TrimSpace(v) + count++ + } + } + return count, nil + } + return 0, fmt.Errorf("no api_keys found in %s", path) +} + +// ImportFromTools scans well-known tool config locations and imports any API +// keys found there into cfg. Returns a summary of what was imported. +func ImportFromTools(cfg *Config) []string { + if cfg.APIKeys == nil { + cfg.APIKeys = map[string]string{} + } + var imported []string + + // subfinder: ~/.config/subfinder/provider-config.yaml + // format: provider:\n - KEY + if keys := readSubfinderConfig(); len(keys) > 0 { + for k, v := range keys { + if _, exists := cfg.APIKeys[k]; !exists { + cfg.APIKeys[k] = v + imported = append(imported, fmt.Sprintf("subfinder → %s", k)) + } + } + } + + // amass: ~/.config/amass/config.yaml / config.ini + if keys := readAmassConfig(); len(keys) > 0 { + for k, v := range keys { + if _, exists := cfg.APIKeys[k]; !exists { + cfg.APIKeys[k] = v + imported = append(imported, fmt.Sprintf("amass → %s", k)) + } + } + } + + sort.Strings(imported) + return imported +} + +// WriteSubfinderProviderConfig writes macaron's API keys in subfinder's +// provider-config.yaml format to the given path, so subfinder picks them up. +func WriteSubfinderProviderConfig(cfg *Config, path string) error { + // Map macaron key names → subfinder provider names. + providerMap := map[string]string{ + "securitytrails": "securitytrails", + "virustotal": "virustotal", + "shodan": "shodan", + "censys_id": "censys", + "censys_secret": "censys", + "binaryedge": "binaryedge", + "c99": "c99", + "chaos": "chaos", + "hunter": "hunter", + "intelx": "intelx", + "passivetotal_key": "passivetotal", + "passivetotal_usr": "passivetotal", + "recon_dev": "recon", + "robtex": "robtex", + "urlscan": "urlscan", + "zoomeye": "zoomeye", + "fullhunt": "fullhunt", + "github": "github", + "leakix": "leakix", + "netlas": "netlas", + "fofa_email": "fofa", + "fofa_key": "fofa", + "quake": "quake", + "hunterhow": "hunterhow", + } + + // Build provider → []key map. + providers := map[string][]string{} + for macaronKey, providerName := range providerMap { + v, ok := cfg.APIKeys[macaronKey] + if !ok || strings.TrimSpace(v) == "" { + continue + } + providers[providerName] = append(providers[providerName], v) + } + + if len(providers) == 0 { + return nil + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + b, err := yaml.Marshal(providers) + if err != nil { + return err + } + return os.WriteFile(path, b, 0o600) +} + +// MaskedKeys returns sorted key=masked-value strings for display. func MaskedKeys(cfg *Config) []string { keys := make([]string, 0, len(cfg.APIKeys)) for k, v := range cfg.APIKeys { @@ -84,3 +219,80 @@ func MaskedKeys(cfg *Config) []string { sort.Strings(keys) return keys } + +// ─── internal helpers ──────────────────────────────────────────────────────── + +func readSubfinderConfig() map[string]string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + path := filepath.Join(home, ".config", "subfinder", "provider-config.yaml") + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var raw map[string][]string + if err := yaml.Unmarshal(b, &raw); err != nil { + return nil + } + out := make(map[string]string, len(raw)) + for provider, keys := range raw { + if len(keys) > 0 && strings.TrimSpace(keys[0]) != "" { + out[strings.ToLower(provider)] = strings.TrimSpace(keys[0]) + } + } + return out +} + +func readAmassConfig() map[string]string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + // amass stores keys in config.yaml under `data_sources` + for _, candidate := range []string{ + filepath.Join(home, ".config", "amass", "config.yaml"), + filepath.Join(home, ".config", "amass", "config.ini"), + } { + b, err := os.ReadFile(candidate) + if err != nil { + continue + } + // Best-effort: look for api_key or apikey patterns. + var raw map[string]any + if err := yaml.Unmarshal(b, &raw); err != nil { + continue + } + out := flattenAPIKeys(raw) + if len(out) > 0 { + return out + } + } + return nil +} + +// flattenAPIKeys recursively extracts API keys from an arbitrary config map. +// For string leaf values, it includes entries whose key name contains "key" +// or "token". For nested map[string]any values, it recurses and prefixes +// child key names with the parent key joined by "_" +// (e.g. parent "virustotal" + child "key" → "virustotal_key"). +func flattenAPIKeys(m map[string]any) map[string]string { + out := make(map[string]string) + for k, v := range m { + switch val := v.(type) { + case string: + lk := strings.ToLower(k) + if strings.Contains(lk, "key") || strings.Contains(lk, "token") { + if strings.TrimSpace(val) != "" { + out[strings.ToLower(k)] = strings.TrimSpace(val) + } + } + case map[string]any: + for kk, vv := range flattenAPIKeys(val) { + out[k+"_"+kk] = vv + } + } + } + return out +} diff --git a/internal/cliui/banner.go b/internal/cliui/banner.go index a6f67d1..3ac32b7 100644 --- a/internal/cliui/banner.go +++ b/internal/cliui/banner.go @@ -2,131 +2,63 @@ package cliui import ( "fmt" + "io" "os" "strings" ) -// ANSI color codes. -const ( - cReset = "\033[0m" - cBold = "\033[1m" - cDim = "\033[2m" - cCyan = "\033[36m" - cGreen = "\033[32m" - cYellow = "\033[33m" - cRed = "\033[31m" - cMagenta = "\033[35m" -) - -// colorEnabled returns true unless NO_COLOR is set (https://no-color.org). -func colorEnabled() bool { - return strings.TrimSpace(os.Getenv("NO_COLOR")) == "" -} +const art = ` + ___ ___ _ ___ _ ___ ___ _ _ +| \/ | /_\ / __| /_\ | _ \/ _ \| \| | +| |\/| |/ _ \ (__ / _ \| / (_) | .` + "`" + ` | +|_| |_/_/ \_\___/_/ \_\_|_\\___/|_|\_|` -func cp(codes, v string) string { - if !colorEnabled() { - return v - } - return codes + v + cReset -} - -// Info writes a cyan [INF] prefixed line to stderr. -func Info(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - prefix := cp(cCyan+cBold, "[INF]") - fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) -} - -// Warn writes a yellow [WRN] prefixed line to stderr. -func Warn(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - prefix := cp(cYellow+cBold, "[WRN]") - fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) -} - -// Err writes a red [ERR] prefixed line to stderr. -func Err(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - prefix := cp(cRed+cBold, "[ERR]") - fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) -} - -// OK writes a green [OK] prefixed line to stderr. -func OK(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - prefix := cp(cGreen+cBold, "[OK]") - fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) -} - -// PrintBanner writes the macaron startup banner to stderr. -// It respects NO_COLOR and is silent when quiet is true. -func PrintBanner(version string, quiet bool) { +// PrintBanner writes the macaron banner to w. If quiet is true, nothing is written. +func PrintBanner(w io.Writer, version string, quiet bool) { if quiet { return } - c := colorEnabled() - - teal := func(s string) string { - if !c { - return s - } - return cCyan + cBold + s + cReset - } - dim := func(s string) string { - if !c { - return s - } - return cDim + s + cReset - } - magenta := func(s string) string { - if !c { - return s - } - return cMagenta + cBold + s + cReset - } - - // Box-drawing ASCII art for "MACARON" — safe in Go raw string literals. - art := []string{ - `╔╦╗╔═╗╔═╗╔═╗╦═╗╔═╗╔╗╔`, - `║║║╠═╣║ ╠═╣╠╦╝║ ║║║║`, - `╩ ╩╩ ╩╚═╝╩ ╩╩╚═╚═╝╝╚╝`, + if w == nil { + w = os.Stderr } - - fmt.Fprintln(os.Stderr) - for _, line := range art { - fmt.Fprintf(os.Stderr, " %s\n", teal(line)) + noColor := strings.TrimSpace(os.Getenv("NO_COLOR")) != "" + if noColor { + fmt.Fprintf(w, "%s\n offensive recon framework %s\n\n", art, version) + return } - fmt.Fprintf(os.Stderr, "\n %s %s\n", magenta("Fast Recon Workflow"), dim("v"+version)) - fmt.Fprintf(os.Stderr, " %s\n", dim("github.com/root-Manas/macaron")) - fmt.Fprintf(os.Stderr, " %s\n\n", dim(strings.Repeat("─", 40))) -} - -// Highlight wraps v in bold white. -func Highlight(v string) string { - return cp(cBold, v) + fmt.Fprintf(w, "\033[1;35m%s\033[0m\n \033[2;37moffensive recon framework\033[0m \033[1;37m%s\033[0m\n\n", art, version) } -// Muted wraps v in dim white. -func Muted(v string) string { - return cp(cDim, v) +// Info prints a cyan [INFO] prefixed line. +func Info(format string, a ...any) { + printPrefixed("36", "INFO", format, a...) } -// GreenText wraps v in green. -func GreenText(v string) string { - return cp(cGreen, v) +// OK prints a green [OK] prefixed line. +func OK(format string, a ...any) { + printPrefixed("32", "OK", format, a...) } -// RedText wraps v in red. -func RedText(v string) string { - return cp(cRed, v) +// Warn prints a yellow [WARN] prefixed line. +func Warn(format string, a ...any) { + printPrefixed("33", "WARN", format, a...) } -// YellowText wraps v in yellow. -func YellowText(v string) string { - return cp(cYellow, v) +// Err prints a red [ERR] prefixed line to stderr. +func Err(format string, a ...any) { + noColor := strings.TrimSpace(os.Getenv("NO_COLOR")) != "" + label := "[ERR]" + if !noColor { + label = "\033[31m[ERR]\033[0m" + } + fmt.Fprintf(os.Stderr, label+" "+format+"\n", a...) } -// CyanText wraps v in cyan. -func CyanText(v string) string { - return cp(cCyan, v) +func printPrefixed(code, tag, format string, a ...any) { + noColor := strings.TrimSpace(os.Getenv("NO_COLOR")) != "" + label := "[" + tag + "]" + if !noColor { + label = "\033[" + code + "m[" + tag + "]\033[0m" + } + fmt.Printf(label+" "+format+"\n", a...) } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 56c43e2..adf6f19 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -85,8 +85,8 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m } } - for _, tool := range []string{"subfinder", "assetfinder", "findomain"} { - lines, err := runSubdomainTool(ctx, tool, result.Target, opts.Threads) + for _, tool := range []string{"subfinder", "assetfinder", "findomain", "amass"} { + lines, err := runSubdomainTool(ctx, tool, result.Target, opts.Threads, opts.APIKeys) if err != nil { continue } @@ -148,7 +148,7 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m if stageOn(opts.EnabledStages, "ports") { stageStart := time.Now() emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "ports", Message: "scanning common ports"}) - ports := scanCommonPorts(probeInputs, opts.Threads) + ports := scanCommonPorts(ctx, probeInputs, opts.Threads, opts.Progress) result.Ports = ports emit(opts.Progress, model.StageEvent{ Timestamp: time.Now(), @@ -247,24 +247,88 @@ func hasBinary(name string) bool { return err == nil } -func runSubdomainTool(ctx context.Context, tool string, target string, threads int) ([]string, error) { +func runSubdomainTool(ctx context.Context, tool string, target string, threads int, apiKeys map[string]string) ([]string, error) { if !hasBinary(tool) { return nil, fmt.Errorf("%s missing", tool) } cmdline := "" switch tool { case "subfinder": - cmdline = fmt.Sprintf("subfinder -d %s -silent -all -t %d", shellEscape(target), threads) + base := fmt.Sprintf("subfinder -d %s -silent -all -t %d", shellEscape(target), threads) + if provConf := writeSubfinderProviderConfig(apiKeys); provConf != "" { + defer os.Remove(provConf) + base += " -provider-config " + shellEscape(provConf) + } + cmdline = base case "assetfinder": cmdline = fmt.Sprintf("assetfinder --subs-only %s", shellEscape(target)) case "findomain": cmdline = fmt.Sprintf("findomain -t %s -q", shellEscape(target)) + case "amass": + cmdline = fmt.Sprintf("amass enum -passive -d %s", shellEscape(target)) default: return nil, fmt.Errorf("unsupported tool") } return runLines(ctx, cmdline, 4*time.Minute) } +// writeSubfinderProviderConfig creates a temporary provider-config.yaml with +// macaron's API keys so subfinder uses them without changing the user's own +// subfinder config. Returns the temp file path, or "" if nothing to write. +// The caller is responsible for removing the returned file when done. +func writeSubfinderProviderConfig(apiKeys map[string]string) string { + if len(apiKeys) == 0 { + return "" + } + // macaron key name → subfinder provider name + mapping := map[string]string{ + "securitytrails": "securitytrails", + "virustotal": "virustotal", + "shodan": "shodan", + "binaryedge": "binaryedge", + "c99": "c99", + "chaos": "chaos", + "hunter": "hunter", + "intelx": "intelx", + "urlscan": "urlscan", + "zoomeye": "zoomeye", + "fullhunt": "fullhunt", + "github": "github", + "leakix": "leakix", + "netlas": "netlas", + "quake": "quake", + } + providers := make(map[string][]string) + for macaronKey, providerName := range mapping { + v, ok := apiKeys[macaronKey] + if !ok || strings.TrimSpace(v) == "" { + continue + } + providers[providerName] = append(providers[providerName], v) + } + if len(providers) == 0 { + return "" + } + tmp, err := os.CreateTemp("", "macaron_sfprov_*.yaml") + if err != nil { + return "" + } + // Write YAML manually — keep it simple. + for provider, keys := range providers { + fmt.Fprintf(tmp, "%s:\n", provider) + for _, k := range keys { + fmt.Fprintf(tmp, " - %s\n", k) + } + } + // Explicitly close before returning so the file is fully flushed and + // safe for subfinder to read. + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return "" + } + return tmp.Name() +} + func runLines(parent context.Context, cmdline string, timeout time.Duration) ([]string, error) { ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() @@ -481,8 +545,19 @@ func extractTitle(r io.Reader) string { return t } -func scanCommonPorts(hosts []string, threads int) []model.PortHit { - ports := []int{80, 443, 8080, 8443, 3000, 5000, 8000, 9000} +func scanCommonPorts(ctx context.Context, hosts []string, threads int, progress func(model.StageEvent)) []model.PortHit { + // Use naabu if available — it is significantly faster and supports more ports. + if hasBinary("naabu") { + hits, err := runNaabu(ctx, hosts, threads) + if err != nil { + // naabu is installed but failed (e.g. permissions); warn and fall through. + emit(progress, model.StageEvent{Type: model.EventWarn, Stage: "ports", Message: "naabu failed (" + err.Error() + ") — falling back to native port scan"}) + } else if len(hits) > 0 { + return hits + } + } + // Fallback: native TCP dial on common web ports. + ports := []int{80, 443, 8080, 8443, 3000, 5000, 8000, 8888, 9000, 9090, 4443, 3443} type job struct { host string port int @@ -526,13 +601,52 @@ func scanCommonPorts(hosts []string, threads int) []model.PortHit { return out } +func runNaabu(ctx context.Context, hosts []string, threads int) ([]model.PortHit, error) { + if len(hosts) == 0 { + return nil, nil + } + tmp, err := os.CreateTemp("", "macaron_naabu_hosts_*.txt") + if err != nil { + return nil, err + } + defer os.Remove(tmp.Name()) + for _, h := range hosts { + fmt.Fprintln(tmp, h) + } + _ = tmp.Close() + + cmd := fmt.Sprintf("naabu -list %s -silent -c %d -top-ports 1000", shellEscape(tmp.Name()), threads) + lines, err := runLines(ctx, cmd, 10*time.Minute) + if err != nil { + return nil, err + } + out := make([]model.PortHit, 0, len(lines)) + for _, line := range lines { + // naabu output: host:port + if i := strings.LastIndex(line, ":"); i > 0 { + host := line[:i] + portStr := line[i+1:] + port, err := strconv.Atoi(portStr) + if err != nil { + continue + } + out = append(out, model.PortHit{Host: host, Port: port}) + } + } + return out, nil +} + func (e *Engine) discoverURLs(ctx context.Context, hosts []string, threads int) []string { hosts = dedupe(hosts) if len(hosts) == 0 { return nil } jobs := make(chan string) - results := make(chan []string, len(hosts)) + // Buffer for up to 3 URL sources per host: Wayback CDX, gau, katana. + // This upper bound is stable since we always emit exactly those three + // (when available) per host goroutine. + const urlSourcesPerHost = 3 + results := make(chan []string, len(hosts)*urlSourcesPerHost) wg := sync.WaitGroup{} for i := 0; i < threads; i++ { wg.Add(1) @@ -540,6 +654,14 @@ func (e *Engine) discoverURLs(ctx context.Context, hosts []string, threads int) defer wg.Done() for host := range jobs { results <- e.waybackURLs(ctx, host) + // Try gau if available (combines wayback + common crawl + otx + urlscan). + if hasBinary("gau") { + results <- runGau(ctx, host) + } + // Try katana (active crawler) if available. + if hasBinary("katana") { + results <- runKatana(ctx, host) + } } }() } @@ -556,6 +678,16 @@ func (e *Engine) discoverURLs(ctx context.Context, hosts []string, threads int) return dedupe(all) } +func runGau(ctx context.Context, host string) []string { + lines, _ := runLines(ctx, fmt.Sprintf("gau --threads 5 %s", shellEscape(host)), 3*time.Minute) + return lines +} + +func runKatana(ctx context.Context, host string) []string { + lines, _ := runLines(ctx, fmt.Sprintf("katana -u https://%s -silent -jc -d 3", shellEscape(host)), 5*time.Minute) + return lines +} + func (e *Engine) waybackURLs(ctx context.Context, host string) []string { u := fmt.Sprintf("https://web.archive.org/cdx/search/cdx?url=%s/*&output=json&fl=original&collapse=urlkey&limit=300", url.QueryEscape(host)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) @@ -627,7 +759,7 @@ func runNuclei(ctx context.Context, hosts []model.LiveHost) ([]model.Vulnerabili if len(hosts) == 0 { return nil, nil } - tmp, err := os.CreateTemp("", "macaronv2_hosts_*.txt") + tmp, err := os.CreateTemp("", "macaron_hosts_*.txt") if err != nil { return nil, err } diff --git a/internal/model/model.go b/internal/model/model.go index 1955361..4e38d6d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -70,6 +70,39 @@ type ToolStatus struct { Installed bool `json:"installed"` } +// DayStat holds aggregated scan findings for a single calendar day. +type DayStat struct { + Day string `json:"day"` + Scans int `json:"scans"` + Subdomains int `json:"subdomains"` + LiveHosts int `json:"live_hosts"` + URLs int `json:"urls"` + Vulns int `json:"vulns"` +} + +// TargetRank holds a target ranked by vuln and live host counts. +type TargetRank struct { + Target string `json:"target"` + Vulns int `json:"vulns"` + LiveHosts int `json:"live_hosts"` +} + +// SeverityCount holds a vulnerability severity level and its total count. +type SeverityCount struct { + Severity string `json:"severity"` + Count int `json:"count"` +} + +// AnalyticsReport is the response returned by /api/analytics. +type AnalyticsReport struct { + ScanCount int `json:"scan_count"` + AvgDurationMS int64 `json:"avg_duration_ms"` + Totals ScanStats `json:"totals"` + Days []DayStat `json:"days"` + TopTargets []TargetRank `json:"top_targets"` + SeverityDist []SeverityCount `json:"severity_dist"` +} + type StageEventType string const ( diff --git a/internal/store/store.go b/internal/store/store.go index 99ed00a..1128d9f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,7 +14,6 @@ import ( "github.com/root-Manas/macaron/internal/model" _ "modernc.org/sqlite" ) - type Store struct { baseDir string db *sql.DB @@ -222,6 +221,156 @@ func (s *Store) Export(path string, target string) (string, error) { return path, nil } +// Analytics returns aggregated statistics across all scans for display in the +// dashboard analytics tab. +func (s *Store) Analytics() (model.AnalyticsReport, error) { + report := model.AnalyticsReport{} + + // Per-day scan counts and cumulative findings. + rows, err := s.db.Query(` + SELECT id, target, finished_at, duration_ms, stats_json + FROM scans + ORDER BY finished_at ASC + `) + if err != nil { + return report, err + } + defer rows.Close() + + dayMap := map[string]*model.DayStat{} + targetVulns := map[string]int{} + targetLive := map[string]int{} + var totalStats model.ScanStats + totalDurationMS := int64(0) + scanCount := 0 + + for rows.Next() { + var id, target, finishedAtRaw, statsRaw string + var durationMS int64 + if err := rows.Scan(&id, &target, &finishedAtRaw, &durationMS, &statsRaw); err != nil { + continue + } + _ = id + var stats model.ScanStats + if err := json.Unmarshal([]byte(statsRaw), &stats); err != nil { + continue + } + t, _ := time.Parse(time.RFC3339Nano, finishedAtRaw) + day := t.UTC().Format("2006-01-02") + + if _, ok := dayMap[day]; !ok { + dayMap[day] = &model.DayStat{Day: day} + } + d := dayMap[day] + d.Scans++ + d.Subdomains += stats.Subdomains + d.LiveHosts += stats.LiveHosts + d.URLs += stats.URLs + d.Vulns += stats.Vulns + + targetVulns[target] += stats.Vulns + targetLive[target] += stats.LiveHosts + + totalStats.Subdomains += stats.Subdomains + totalStats.LiveHosts += stats.LiveHosts + totalStats.Ports += stats.Ports + totalStats.URLs += stats.URLs + totalStats.JSFiles += stats.JSFiles + totalStats.Vulns += stats.Vulns + totalDurationMS += durationMS + scanCount++ + } + if err := rows.Err(); err != nil { + return report, err + } + + // Collect day stats sorted by date. + days := make([]model.DayStat, 0, len(dayMap)) + for _, d := range dayMap { + days = append(days, *d) + } + sort.Slice(days, func(i, j int) bool { return days[i].Day < days[j].Day }) + + // Top 10 targets by vuln count, then by live hosts. + type kv struct { + target string + vulns int + live int + } + ranked := make([]kv, 0, len(targetVulns)) + for t := range targetVulns { + ranked = append(ranked, kv{target: t, vulns: targetVulns[t], live: targetLive[t]}) + } + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].vulns != ranked[j].vulns { + return ranked[i].vulns > ranked[j].vulns + } + return ranked[i].live > ranked[j].live + }) + topTargets := make([]model.TargetRank, 0, 10) + for i, r := range ranked { + if i >= 10 { + break + } + topTargets = append(topTargets, model.TargetRank{Target: r.target, Vulns: r.vulns, LiveHosts: r.live}) + } + + // Severity distribution requires reading full payloads. + sevRows, err := s.db.Query(`SELECT payload_json FROM scans`) + if err != nil { + return report, err + } + defer sevRows.Close() + sevMap := map[string]int{} + for sevRows.Next() { + var p string + if err := sevRows.Scan(&p); err != nil { + continue + } + res, err := decodeScan(p) + if err != nil { + continue + } + for _, v := range res.Vulns { + sev := strings.ToLower(strings.TrimSpace(v.Severity)) + if sev == "" { + sev = "unknown" + } + sevMap[sev]++ + } + } + if err := sevRows.Err(); err != nil { + return report, err + } + + sevDist := make([]model.SeverityCount, 0, len(sevMap)) + for sev, count := range sevMap { + sevDist = append(sevDist, model.SeverityCount{Severity: sev, Count: count}) + } + sort.Slice(sevDist, func(i, j int) bool { + order := map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4} + oi := order[sevDist[i].Severity] + oj := order[sevDist[j].Severity] + if oi != oj { + return oi < oj + } + return sevDist[i].Count > sevDist[j].Count + }) + + avgDurationMS := int64(0) + if scanCount > 0 { + avgDurationMS = totalDurationMS / int64(scanCount) + } + + report.ScanCount = scanCount + report.Totals = totalStats + report.AvgDurationMS = avgDurationMS + report.Days = days + report.TopTargets = topTargets + report.SeverityDist = sevDist + return report, nil +} + func decodeScan(payload string) (*model.ScanResult, error) { var res model.ScanResult if err := json.Unmarshal([]byte(payload), &res); err != nil { diff --git a/internal/ui/assets/index.html b/internal/ui/assets/index.html deleted file mode 100644 index 5ba19ed..0000000 --- a/internal/ui/assets/index.html +++ /dev/null @@ -1,593 +0,0 @@ - - - - - - macaronV2 Ops Console - - - - - - - - -
-
-
-

macaronV2 OPS Console

-
Intent-first recon: setup -> scan -> inspect -> export
-
-
-
loading scans...
-
refreshing...
-
-
- - - -
-
Select a scan from the left panel.
-
-
- - - - diff --git a/internal/ui/server.go b/internal/ui/server.go deleted file mode 100644 index ca659f4..0000000 --- a/internal/ui/server.go +++ /dev/null @@ -1,213 +0,0 @@ -package ui - -import ( - "embed" - "encoding/json" - "fmt" - "net" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/root-Manas/macaron/internal/store" -) - -//go:embed assets/index.html -var assets embed.FS - -type Server struct { - Store *store.Store - cacheMu sync.Mutex - geoCache map[string]heatPoint -} - -func New(st *store.Store) *Server { - return &Server{ - Store: st, - geoCache: map[string]heatPoint{}, - } -} - -func (s *Server) Serve(addr string) error { - mux := http.NewServeMux() - mux.HandleFunc("/", s.handleIndex) - mux.HandleFunc("/api/scans", s.handleScans) - mux.HandleFunc("/api/results", s.handleResults) - mux.HandleFunc("/api/heat", s.handleHeat) - fmt.Printf("macaronV2 dashboard on http://%s\n", addr) - return http.ListenAndServe(addr, mux) -} - -func (s *Server) handleScans(w http.ResponseWriter, r *http.Request) { - items, err := s.Store.Summaries(200) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, items) -} - -func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { - id := strings.TrimSpace(r.URL.Query().Get("id")) - target := strings.TrimSpace(r.URL.Query().Get("target")) - if id == "" && target == "" { - http.Error(w, "id or target is required", http.StatusBadRequest) - return - } - var ( - data any - err error - ) - if id != "" { - data, err = s.Store.GetByID(id) - } else { - data, err = s.Store.LatestByTarget(target) - } - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - writeJSON(w, data) -} - -func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - b, err := assets.ReadFile("assets/index.html") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(b) -} - -type heatPoint struct { - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Count int `json:"count"` - Country string `json:"country"` - City string `json:"city"` -} - -func (s *Server) handleHeat(w http.ResponseWriter, r *http.Request) { - summaries, err := s.Store.Summaries(200) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - acc := map[string]heatPoint{} - for _, sm := range summaries { - scan, err := s.Store.GetByID(sm.ID) - if err != nil { - continue - } - for _, live := range scan.LiveHosts { - host := hostFromURL(live.URL) - if host == "" { - continue - } - ip := firstResolvableIP(host) - if ip == "" { - continue - } - p, ok := s.lookupGeo(ip) - if !ok { - continue - } - key := fmt.Sprintf("%.2f,%.2f", p.Lat, p.Lon) - cur := acc[key] - cur.Lat = p.Lat - cur.Lon = p.Lon - cur.Country = p.Country - cur.City = p.City - cur.Count++ - acc[key] = cur - } - } - out := make([]heatPoint, 0, len(acc)) - for _, p := range acc { - out = append(out, p) - } - writeJSON(w, out) -} - -func hostFromURL(raw string) string { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return "" - } - return u.Hostname() -} - -func firstResolvableIP(host string) string { - ips, err := net.LookupIP(host) - if err != nil { - return "" - } - for _, ip := range ips { - if ip == nil { - continue - } - v4 := ip.To4() - if v4 == nil { - continue - } - if v4.IsPrivate() || v4.IsLoopback() || v4.IsUnspecified() { - continue - } - return v4.String() - } - return "" -} - -func (s *Server) lookupGeo(ip string) (heatPoint, bool) { - s.cacheMu.Lock() - if p, ok := s.geoCache[ip]; ok { - s.cacheMu.Unlock() - return p, true - } - s.cacheMu.Unlock() - - client := &http.Client{Timeout: 4 * time.Second} - req, _ := http.NewRequest(http.MethodGet, "http://ip-api.com/json/"+ip+"?fields=status,country,city,lat,lon", nil) - resp, err := client.Do(req) - if err != nil { - return heatPoint{}, false - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return heatPoint{}, false - } - var payload struct { - Status string `json:"status"` - Country string `json:"country"` - City string `json:"city"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return heatPoint{}, false - } - if payload.Status != "success" { - return heatPoint{}, false - } - point := heatPoint{ - Lat: payload.Lat, - Lon: payload.Lon, - Country: payload.Country, - City: payload.City, - Count: 1, - } - s.cacheMu.Lock() - s.geoCache[ip] = point - s.cacheMu.Unlock() - return point, true -} - -func writeJSON(w http.ResponseWriter, data any) { - w.Header().Set("Content-Type", "application/json") - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - _ = enc.Encode(data) -}