From 7a8bcd1c246c46b752f105683d4da6e144dc34af Mon Sep 17 00:00:00 2001 From: root-Manas Date: Sun, 27 Sep 2026 19:22:05 +0530 Subject: [PATCH 1/3] fix: harden recon workflow and local data --- README.md | 228 +++++++++++--------------------------- cmd/macaron/main.go | 185 ++++++++++++++++++++++++------- install.sh | 11 +- internal/app/app.go | 70 +++++++++--- internal/cfg/config.go | 74 +++++++++++-- internal/cliui/live.go | 33 ++++-- internal/engine/engine.go | 217 +++++++++++++++++++++++++----------- internal/model/model.go | 2 +- internal/store/store.go | 56 ++++++++-- 9 files changed, 564 insertions(+), 312 deletions(-) diff --git a/README.md b/README.md index 3b80f27..b98e4a0 100644 --- a/README.md +++ b/README.md @@ -1,207 +1,105 @@ # macaron -Fast, researcher-oriented recon framework. +Macaron is a command-line reconnaissance workflow that collects subdomains, checks HTTP availability, scans common TCP ports, discovers URLs, and can run Nuclei against live hosts. It saves scan history locally in SQLite and JSON. ---- +Use it only for systems you own or are authorized to assess. Port checks, HTTP probes, crawling, and Nuclei are active requests. The passive profile still probes HTTP hosts; use `--stages subdomains` for enumeration without those active stages. -## install +## Install -```bash +Macaron requires Go 1.25 or newer to build. In Linux or WSL: + +```sh git clone https://github.com/root-Manas/macaron.git cd macaron -chmod +x install.sh -./install.sh -source ~/.bashrc +go build -o macaron ./cmd/macaron ``` -Or grab a tagged binary from [Releases](https://github.com/root-Manas/macaron/releases). - ---- +The `install.sh` script builds and installs to `~/.local/bin`. Macaron's external recon tools are optional; install the tools you need and make sure they are on `PATH`. `macaron setup` shows which tools are available. `macaron setup --install` installs missing Go-based tools on Linux only. -## quick start +## 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 results -d example.com -w live -macaron export -o example.json +```sh +./macaron scan -t example.com +./macaron status +./macaron results -d example.com -w live +./macaron export -o example.json ``` ---- +Targets may be domain names, IP addresses, or bare origin URLs such as `https://example.com`. URL paths, credentials, query strings, and fragments are rejected. Target lists can come from flags, a file, or stdin: -## commands - -``` -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 +```sh +./macaron scan -t example.com -t example.org +./macaron scan --file targets.txt +cat targets.txt | ./macaron scan --stdin ``` ---- +## Scans -## scan - -```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 +```sh +./macaron scan -t example.com --profile passive +./macaron scan -t example.com --profile balanced +./macaron scan -t example.com --profile aggressive --stages subdomains,http,ports,urls,vulns ``` -**flags** - -| 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 | - -**profiles** - -| profile | behaviour | -|---------|-----------| -| `passive` | OSINT-only, low rate, no active scanning | -| `balanced` | enumeration + probing + vuln scan | -| `aggressive` | max concurrency, all stages — authorized testing only | - ---- - -## 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 +`balanced` is the default. `passive` lowers concurrency and skips port and vulnerability stages, but still runs HTTP probes and URL collection; choose stages explicitly when you need a passive-only run. `aggressive` increases rate and concurrency. The rate setting caps dispatch to the built-in HTTP and native port probes; external tools may use their own rate controls. -macaron maintains a single global key store. All tools it runs pick up keys from here automatically — you don't configure them per-tool. +| Option | Default | Purpose | +| --- | --- | --- | +| `-t, --target` | — | Target; repeatable | +| `-f, --file` | — | Read one target per line | +| `--stdin` | — | Read targets from stdin | +| `-m, --mode` | `wide` | `wide` or `narrow` | +| `-p, --profile` | `balanced` | `passive`, `balanced`, or `aggressive` | +| `--stages` | `all` | Comma-separated `subdomains,http,ports,urls,vulns` | +| `--rate` | `150` | Built-in probe dispatches per second, capped at 10,000 | +| `--threads` | `30` | Concurrent workers, maximum 500 | +| `-q, --quiet` | off | Suppress progress display | +| `--storage` | user config directory | Override the data directory | -```bash -# set keys -macaron api set securitytrails=KEY shodan=KEY virustotal=KEY github=TOKEN +`--mode wide` probes all discovered hosts. `--mode narrow` limits active HTTP and port probes to the target itself. Use profiles to adjust concurrency and stage selection. -# remove a key -macaron api unset shodan +Macaron filters discovered names to the requested domain and its subdomains. HTTP probes stop at cross-host redirects to keep a probe from silently moving to another site. Missing optional tools are skipped. Failures from installed subdomain tools and Nuclei appear in scan warnings. -# view (masked) -macaron api list +## API keys -# import from tools already on your system (subfinder, amass…) -macaron api import +Keys are stored in `config.yaml` under the Macaron data directory. On Unix-like systems the config and scan files are private to the current user. Subfinder receives a temporary provider config; Macaron does not edit Subfinder's own config. -# load many keys at once from a YAML file -macaron api bulk -f keys.yaml +```sh +./macaron api set securitytrails=KEY shodan=KEY +./macaron api list # values are masked +./macaron api unset shodan +./macaron api import # import supported local tool configs +./macaron api bulk --file keys.yaml ``` -**bulk file format** +The bulk YAML format is: ```yaml api_keys: securitytrails: YOUR_KEY shodan: YOUR_KEY - virustotal: YOUR_KEY - chaos: YOUR_KEY - binaryedge: YOUR_KEY - github: YOUR_TOKEN ``` -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. +Avoid putting live secrets in shell history or committing the bulk file. ---- +## Results and storage -## results - -```bash -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 -``` - -**-w values**: `all` · `subdomains` · `live` · `ports` · `urls` · `js` · `vulns` - ---- - -## storage layout - -``` -storage/ - macaron.db # indexed scan store - config.yaml # API keys + settings - / - .json - latest.txt +```sh +./macaron status +./macaron results -d example.com +./macaron results --id SCAN_ID -w vulns +./macaron results -d example.com -w subdomains +./macaron export --domain example.com --output example.json ``` -Override with `--storage /path/to/dir` or `MACARON_HOME` (env not yet supported — use the flag). +Result views are `all`, `subdomains`, `live`, `ports`, `urls`, `js`, and `vulns`. Macaron stores `macaron.db`, `config.yaml`, and per-target JSON snapshots in the data directory. The default is the operating system's user config directory under `macaron`; set `MACARON_HOME` or pass `--storage` to choose another location. `--storage` takes precedence. An existing `./storage` directory is retained for compatibility when neither override is set. ---- +## Development -## setup - -```bash -macaron setup # show tool inventory -macaron setup --install # auto-install missing tools that support it -``` - -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 +```sh +go build ./... +go test ./... +go vet ./... ``` -Locates and removes the macaron binary from PATH. Optionally removes the storage directory when prompted. Pass `--yes` to skip confirmation. - ---- - -## security - -Use macaron only on systems you own or have explicit written authorization to test. - +See [contributing](docs/contributing.md) for project conventions. diff --git a/cmd/macaron/main.go b/cmd/macaron/main.go index de29723..a8dc1e6 100644 --- a/cmd/macaron/main.go +++ b/cmd/macaron/main.go @@ -90,14 +90,16 @@ func runScan(args []string) int { 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.StringVarP(&mode, "mode", "m", "wide", "Probe discovered hosts (wide) or target only (narrow)") + fs.IntVar(&rate, "rate", 150, "Built-in probe dispatches per second (max 10,000)") 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) + fs.StringVar(&storage, "storage", "", "Storage root (default: user config directory)") + if code := parseFlags(fs, args); code >= 0 { + return code + } // Positional args are also targets. for _, a := range fs.Args() { @@ -118,6 +120,7 @@ func runScan(args []string) int { cliui.Err("%v", err) return 1 } + defer application.Store.Close() config, err := cfg.Load(home) if err != nil { cliui.Err("loading config: %v", err) @@ -135,6 +138,22 @@ func runScan(args []string) int { } applyProfile(profile, &mode, &rate, &threads, &stages) + switch strings.ToLower(mode) { + case "wide", "narrow": + default: + cliui.Err("invalid --mode %q", mode) + return 2 + } + switch strings.ToLower(profile) { + case "passive", "balanced", "aggressive": + default: + cliui.Err("invalid --profile %q", profile) + return 2 + } + if _, err := parseStagesStrict(stages); err != nil { + cliui.Err("%v", err) + return 2 + } if rate <= 0 { cliui.Err("--rate must be > 0") return 1 @@ -143,6 +162,14 @@ func runScan(args []string) int { cliui.Err("--threads must be > 0") return 1 } + if threads > 500 { + cliui.Err("--threads must be 500 or fewer") + return 2 + } + if rate > 10000 { + cliui.Err("--rate must be 10,000 or fewer") + return 2 + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -189,7 +216,9 @@ func runStatus(args []string) 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) + if code := parseFlags(fs, args); code >= 0 { + return code + } home, err := macaronHome(storage) if err != nil { @@ -201,6 +230,7 @@ func runStatus(args []string) int { cliui.Err("%v", err) return 1 } + defer application.Store.Close() out, err := application.ShowStatus(limit) if err != nil { cliui.Err("%v", err) @@ -226,7 +256,9 @@ func runResults(args []string) int { 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) + if code := parseFlags(fs, args); code >= 0 { + return code + } home, err := macaronHome(storage) if err != nil { @@ -238,6 +270,7 @@ func runResults(args []string) int { cliui.Err("%v", err) return 1 } + defer application.Store.Close() out, err := application.ShowResults(domain, id, what, limit) if err != nil { cliui.Err("%v", err) @@ -253,7 +286,9 @@ 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) + if code := parseFlags(fs, args); code >= 0 { + return code + } tools := app.SetupCatalog() fmt.Print(app.RenderSetup(tools)) @@ -284,7 +319,9 @@ func runExport(args []string) int { 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) + if code := parseFlags(fs, args); code >= 0 { + return code + } home, err := macaronHome(storage) if err != nil { @@ -296,6 +333,7 @@ func runExport(args []string) int { cliui.Err("%v", err) return 1 } + defer application.Store.Close() path, err := application.Export(output, domain) if err != nil { cliui.Err("%v", err) @@ -311,7 +349,9 @@ func runConfig(args []string) int { fs := pflag.NewFlagSet("config", pflag.ContinueOnError) var storage string fs.StringVar(&storage, "storage", "", "Storage root") - _ = fs.Parse(args) + if code := parseFlags(fs, args); code >= 0 { + return code + } home, err := macaronHome(storage) if err != nil { @@ -323,6 +363,7 @@ func runConfig(args []string) int { cliui.Err("%v", err) return 1 } + defer application.Store.Close() fmt.Print(application.ShowConfig()) return 0 } @@ -357,7 +398,9 @@ func apiList(args []string) int { fs := pflag.NewFlagSet("api list", pflag.ContinueOnError) var storage string fs.StringVar(&storage, "storage", "", "Storage root") - _ = fs.Parse(args) + if code := parseFlags(fs, args); code >= 0 { + return code + } home, err := macaronHome(storage) if err != nil { @@ -385,7 +428,9 @@ func apiSet(args []string) int { fs := pflag.NewFlagSet("api set", pflag.ContinueOnError) var storage string fs.StringVar(&storage, "storage", "", "Storage root") - _ = fs.Parse(args) + if code := parseFlags(fs, args); code >= 0 { + return code + } kvs := fs.Args() if len(kvs) == 0 { @@ -402,17 +447,27 @@ func apiSet(args []string) int { cliui.Err("%v", err) return 1 } - cfg.ApplySetAPI(config, kvs) + count, err := cfg.ApplySetAPI(config, kvs) + if err != nil { + cliui.Err("%v", err) + return 2 + } 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")) + cliui.OK("saved %d key(s) → %s", count, filepath.Join(home, "config.yaml")) return 0 } func apiUnset(args []string) int { - // Reuse set with empty value to delete. + fs := pflag.NewFlagSet("api unset", pflag.ContinueOnError) + var storage string + fs.StringVar(&storage, "storage", "", "Storage root") + if code := parseFlags(fs, args); code >= 0 { + return code + } + args = fs.Args() if len(args) == 0 { cliui.Err("usage: macaron api unset key [key ...]") return 1 @@ -422,14 +477,36 @@ func apiUnset(args []string) int { for i, k := range args { kvs[i] = strings.TrimSuffix(k, "=") + "=" } - return apiSet(kvs) + 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.ApplySetAPI(config, kvs) + if err != nil { + cliui.Err("%v", err) + return 2 + } + if err := cfg.Save(home, config); err != nil { + cliui.Err("%v", err) + return 1 + } + cliui.OK("removed %d key(s)", count) + return 0 } func apiImport(args []string) int { fs := pflag.NewFlagSet("api import", pflag.ContinueOnError) var storage string fs.StringVar(&storage, "storage", "", "Storage root") - _ = fs.Parse(args) + if code := parseFlags(fs, args); code >= 0 { + return code + } home, err := macaronHome(storage) if err != nil { @@ -461,7 +538,9 @@ func apiBulk(args []string) int { 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 code := parseFlags(fs, args); code >= 0 { + return code + } if file == "" && len(fs.Args()) > 0 { file = fs.Args()[0] @@ -501,7 +580,9 @@ func runUninstall(args []string) int { 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) + if code := parseFlags(fs, args); code >= 0 { + return code + } execPath, err := os.Executable() if err != nil { @@ -579,7 +660,7 @@ 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) + -m, --mode MODE wide|narrow (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) @@ -638,7 +719,8 @@ func printGuide() { macaron api import 2. enumerate with intent: - macaron scan -t target.com -p passive # low-noise, passive only + macaron scan -t target.com -p passive # lower rate; still probes HTTP hosts + macaron scan -t target.com --stages subdomains # enumeration 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 @@ -652,9 +734,9 @@ func printGuide() { macaron export -o target.json profiles: - passive osint-only, low rate, no active probing + passive lower rate and concurrency; HTTP probing still runs balanced default — enumeration + probing + vuln scan - aggressive max concurrency, all stages, authorized testing only + aggressive higher concurrency, all stages, authorized testing only authorized use only. `) @@ -691,30 +773,57 @@ func macaronHome(override string) (string, error) { if strings.TrimSpace(override) != "" { return filepath.Clean(override), nil } - cwd, err := os.Getwd() + if env := strings.TrimSpace(os.Getenv("MACARON_HOME")); env != "" { + return filepath.Clean(env), nil + } + if cwd, err := os.Getwd(); err == nil { + legacy := filepath.Join(cwd, "storage") + if info, err := os.Stat(legacy); err == nil && info.IsDir() { + return legacy, nil + } + } + configDir, err := os.UserConfigDir() if err != nil { return "", err } - return filepath.Join(cwd, "storage"), nil + return filepath.Join(configDir, "macaron"), nil } -func looksLikeDomain(s string) bool { - if strings.HasPrefix(s, "-") || strings.Contains(s, " ") { - return false +func parseFlags(fs *pflag.FlagSet, args []string) int { + fs.SetOutput(os.Stderr) + err := fs.Parse(args) + if err == nil { + return -1 } - parts := strings.Split(s, ".") - if len(parts) < 2 { - return false + if errors.Is(err, pflag.ErrHelp) { + return 0 } - tld := strings.ToLower(parts[len(parts)-1]) - // TLD must be alphabetic-only and at least 2 characters. - if len(tld) < 2 { - return false + return 2 +} + +func parseStagesStrict(raw string) (map[string]bool, error) { + valid := map[string]bool{"subdomains": true, "http": true, "ports": true, "urls": true, "vulns": true} + if strings.EqualFold(strings.TrimSpace(raw), "all") || strings.TrimSpace(raw) == "" { + return app.ParseStages("all"), nil } - for _, c := range tld { - if c < 'a' || c > 'z' { - return false + parsed := map[string]bool{} + for _, item := range strings.Split(raw, ",") { + stage := strings.ToLower(strings.TrimSpace(item)) + if !valid[stage] { + return nil, fmt.Errorf("unknown stage %q", item) } + parsed[stage] = true + } + if len(parsed) == 0 { + return nil, errors.New("no scan stages selected") + } + return parsed, nil +} + +func looksLikeDomain(s string) bool { + if strings.HasPrefix(s, "-") { + return false } - return true + targets, err := app.ParseTargets([]string{s}, "", false) + return err == nil && len(targets) == 1 } diff --git a/install.sh b/install.sh index d412abf..a5712c1 100644 --- a/install.sh +++ b/install.sh @@ -5,13 +5,18 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" if ! command -v go >/dev/null 2>&1; then - echo "[!] Go is not installed. Install Go 1.22+ and rerun." + echo "[!] Go is not installed. Install Go 1.25+ and rerun." + exit 1 +fi + +GO_VERSION="$(go env GOVERSION)" +if [ "$(printf '%s\n' go1.25.0 "$GO_VERSION" | sort -V | head -n1)" != "go1.25.0" ]; then + echo "[!] Macaron requires Go 1.25+. Found $GO_VERSION." exit 1 fi mkdir -p "$HOME/.local/bin" echo "[macaron] building binary..." -go mod tidy go build -o "$HOME/.local/bin/macaron" ./cmd/macaron chmod +x "$HOME/.local/bin/macaron" @@ -19,7 +24,7 @@ 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 + 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 diff --git a/internal/app/app.go b/internal/app/app.go index 5ad95e5..b7305de 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "io" + "net" + "net/url" "os" "os/exec" "path/filepath" @@ -69,6 +71,9 @@ func (a *App) Scan(ctx context.Context, args ScanArgs) ([]model.ScanResult, erro } results := make([]model.ScanResult, 0, len(args.Targets)) for _, t := range args.Targets { + if err := ctx.Err(); err != nil { + return results, err + } res, err := a.Engine.ScanTarget(ctx, t, engine.Options{ Mode: args.Mode, Rate: args.Rate, @@ -79,10 +84,10 @@ func (a *App) Scan(ctx context.Context, args ScanArgs) ([]model.ScanResult, erro Progress: args.Progress, }) if err != nil { - return nil, err + return results, fmt.Errorf("scan %s: %w", t, err) } if err := a.Store.SaveScan(res); err != nil { - return nil, err + return results, fmt.Errorf("save scan %s: %w", t, err) } results = append(results, res) } @@ -118,6 +123,15 @@ func (a *App) ShowStatus(limit int) (string, error) { } func (a *App) ShowResults(target string, id string, what string, limit int) (string, error) { + what = strings.ToLower(strings.TrimSpace(what)) + if what == "" { + what = "all" + } + switch what { + case "all", "subdomains", "live", "ports", "urls", "js", "vulns": + default: + return "", fmt.Errorf("unknown result view %q", what) + } var res *model.ScanResult var err error if id != "" { @@ -137,7 +151,7 @@ func (a *App) ShowResults(target string, id string, what string, limit int) (str if limit <= 0 { limit = 50 } - return formatResults(*res, strings.ToLower(what), limit), nil + return formatResults(*res, what, limit), nil } func (a *App) Export(path, target string) (string, error) { @@ -157,9 +171,15 @@ func (a *App) ShowConfig() string { func ParseTargets(raw []string, filePath string, stdin bool) ([]string, error) { seen := map[string]struct{}{} out := make([]string, 0) + invalid := make([]string, 0) add := func(t string) { + t = strings.TrimSpace(t) + if t == "" || strings.HasPrefix(t, "#") { + return + } t = normalizeTarget(t) if t == "" { + invalid = append(invalid, "invalid target") return } if _, ok := seen[t]; ok { @@ -182,12 +202,16 @@ func ParseTargets(raw []string, filePath string, stdin bool) ([]string, error) { } if stdin { b, err := ioReadAllStdin() - if err == nil { - for _, line := range strings.Split(string(b), "\n") { - add(line) - } + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(b), "\n") { + add(line) } } + if len(invalid) > 0 { + return nil, fmt.Errorf("one or more targets are invalid (expected a domain, IP, or bare origin URL)") + } sort.Strings(out) return out, nil } @@ -368,14 +392,30 @@ func ioReadAllStdin() ([]byte, error) { } func normalizeTarget(t string) string { - t = strings.TrimSpace(strings.ToLower(t)) - t = strings.TrimPrefix(t, "https://") - t = strings.TrimPrefix(t, "http://") - if i := strings.IndexRune(t, '/'); i > -1 { - t = t[:i] - } - if i := strings.IndexRune(t, ':'); i > -1 { - t = t[:i] + t = strings.TrimSpace(t) + if strings.Contains(t, "://") { + u, err := url.Parse(t) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "" + } + t = u.Hostname() + } + t = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(t)), ".") + if ip := net.ParseIP(t); ip != nil { + return ip.String() + } + if len(t) > 253 || t == "" { + return "" + } + for _, label := range strings.Split(t, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return "" + } + for _, c := range label { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { + return "" + } + } } return t } diff --git a/internal/cfg/config.go b/internal/cfg/config.go index 09da4bf..35a9c8e 100644 --- a/internal/cfg/config.go +++ b/internal/cfg/config.go @@ -1,6 +1,7 @@ package cfg import ( + "errors" "fmt" "os" "path/filepath" @@ -29,6 +30,9 @@ func Load(storageRoot string) (*Config, error) { if err := yaml.Unmarshal(b, cfg); err != nil { return nil, err } + if err := os.Chmod(path, 0o600); err != nil && !errors.Is(err, os.ErrPermission) { + return nil, err + } if cfg.APIKeys == nil { cfg.APIKeys = map[string]string{} } @@ -37,7 +41,10 @@ func Load(storageRoot string) (*Config, error) { // Save writes config to storageRoot/config.yaml. func Save(storageRoot string, cfg *Config) error { - if err := os.MkdirAll(storageRoot, 0o755); err != nil { + if err := os.MkdirAll(storageRoot, 0o700); err != nil { + return err + } + if err := os.Chmod(storageRoot, 0o700); err != nil && !errors.Is(err, os.ErrPermission) { return err } if cfg.APIKeys == nil { @@ -47,30 +54,71 @@ func Save(storageRoot string, cfg *Config) error { if err != nil { return err } - return os.WriteFile(filepath.Join(storageRoot, "config.yaml"), b, 0o644) + return writePrivateFile(filepath.Join(storageRoot, "config.yaml"), b) +} + +func writePrivateFile(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".macaron-config-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) } // ApplySetAPI merges key=value pairs into config. Empty value removes the key. -func ApplySetAPI(cfg *Config, kvs []string) { +func ApplySetAPI(cfg *Config, kvs []string) (int, error) { if cfg.APIKeys == nil { cfg.APIKeys = map[string]string{} } + count := 0 for _, kv := range kvs { parts := strings.SplitN(strings.TrimSpace(kv), "=", 2) if len(parts) != 2 { - continue + return count, fmt.Errorf("invalid key assignment %q: expected key=value", kv) } k := strings.ToLower(strings.TrimSpace(parts[0])) v := strings.TrimSpace(parts[1]) - if k == "" { - continue + if !validKeyName(k) { + return count, fmt.Errorf("invalid API key name %q", parts[0]) } if v == "" { delete(cfg.APIKeys, k) + count++ continue } cfg.APIKeys[k] = v + count++ + } + return count, nil +} + +func validKeyName(k string) bool { + if k == "" { + return false } + for _, c := range k { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { + return false + } + } + return true } // BulkLoadFile reads a YAML file of the form `api_keys: {key: value}` and @@ -86,13 +134,18 @@ func BulkLoadFile(cfg *Config, path string) (int, error) { if cfg.APIKeys == nil { cfg.APIKeys = map[string]string{} } + count := 0 for k, v := range bulk.APIKeys { k = strings.ToLower(strings.TrimSpace(k)) - if k != "" && strings.TrimSpace(v) != "" { + if validKeyName(k) && strings.TrimSpace(v) != "" { cfg.APIKeys[k] = strings.TrimSpace(v) + count++ } } - return len(bulk.APIKeys), nil + if count == 0 { + return 0, fmt.Errorf("no valid API keys found in %s", path) + } + return count, nil } // Try flat map. var flat map[string]string @@ -103,11 +156,14 @@ func BulkLoadFile(cfg *Config, path string) (int, error) { count := 0 for k, v := range flat { k = strings.ToLower(strings.TrimSpace(k)) - if k != "" && strings.TrimSpace(v) != "" { + if validKeyName(k) && strings.TrimSpace(v) != "" { cfg.APIKeys[k] = strings.TrimSpace(v) count++ } } + if count == 0 { + return 0, fmt.Errorf("no valid API keys found in %s", path) + } return count, nil } return 0, fmt.Errorf("no api_keys found in %s", path) diff --git a/internal/cliui/live.go b/internal/cliui/live.go index 61eebb4..265a0f4 100644 --- a/internal/cliui/live.go +++ b/internal/cliui/live.go @@ -16,6 +16,7 @@ type LiveRenderer struct { mu sync.Mutex color bool + interactive bool spinnerOn bool spinStop chan struct{} spinFrame int @@ -31,10 +32,18 @@ func NewLiveRenderer(out io.Writer) *LiveRenderer { if out == nil { out = os.Stdout } - useColor := strings.TrimSpace(os.Getenv("NO_COLOR")) == "" + useColor := strings.TrimSpace(os.Getenv("NO_COLOR")) == "" && strings.TrimSpace(os.Getenv("TERM")) != "dumb" + interactive := false + if f, ok := out.(*os.File); !ok || f == nil { + useColor = false + } else if info, err := f.Stat(); err != nil || info.Mode()&os.ModeCharDevice == 0 { + useColor = false + } else { + interactive = true + } return &LiveRenderer{ out: out, - color: useColor, + color: useColor, interactive: interactive, } } @@ -101,12 +110,13 @@ func (r *LiveRenderer) Close() { } func (r *LiveRenderer) startSpinnerLocked() { - if r.spinnerOn { + if r.spinnerOn || !r.interactive { return } r.spinnerOn = true r.spinStop = make(chan struct{}) - go r.spin() + stop := r.spinStop + go r.spin(stop) } func (r *LiveRenderer) stopSpinnerLocked() { @@ -115,11 +125,13 @@ func (r *LiveRenderer) stopSpinnerLocked() { } close(r.spinStop) r.spinnerOn = false - fmt.Fprint(r.out, "\r\033[2K") + if r.interactive { + fmt.Fprint(r.out, "\r\033[2K") + } } -func (r *LiveRenderer) spin() { - // Braille spinner — used by Nuclei, httpx, and other ProjectDiscovery tools. +func (r *LiveRenderer) spin(stop <-chan struct{}) { + // Braille spinner for interactive progress output. frames := []string{"⣾", "⣽", "⣻", "⣷", "⣯", "⣟", "⡿", "⢿"} ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -150,7 +162,7 @@ func (r *LiveRenderer) spin() { fmt.Fprintf(r.out, "\r\033[2K%s", line) r.lastPrinted = time.Now() r.mu.Unlock() - case <-r.spinStop: + case <-stop: return } } @@ -158,7 +170,9 @@ func (r *LiveRenderer) spin() { func (r *LiveRenderer) printLinef(format string, args ...any) { // Clear the spinner line before printing a new log line. - fmt.Fprint(r.out, "\r\033[2K") + if r.spinnerOn { + fmt.Fprint(r.out, "\r\033[2K") + } fmt.Fprintf(r.out, format+"\n", args...) } @@ -216,4 +230,3 @@ func chooseTime(v time.Time, fallback time.Time) time.Time { } return v } - diff --git a/internal/engine/engine.go b/internal/engine/engine.go index adf6f19..3c29b27 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -3,7 +3,9 @@ package engine import ( "bufio" "context" + "crypto/rand" "crypto/tls" + "encoding/hex" "encoding/json" "fmt" "io" @@ -13,7 +15,6 @@ import ( "os" "os/exec" "regexp" - "runtime" "sort" "strconv" "strings" @@ -21,6 +22,7 @@ import ( "time" "github.com/root-Manas/macaron/internal/model" + "gopkg.in/yaml.v3" ) type Options struct { @@ -44,15 +46,31 @@ func New() *Engine { TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, } return &Engine{ - httpClient: &http.Client{Timeout: 12 * time.Second, Transport: transport}, + httpClient: &http.Client{ + Timeout: 12 * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) > 0 && !strings.EqualFold(req.URL.Hostname(), via[0].URL.Hostname()) { + return http.ErrUseLastResponse + } + if len(via) >= 5 { + return fmt.Errorf("stopped after 5 redirects") + } + return nil + }, + }, } } func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (model.ScanResult, error) { + target = normalizeTarget(target) + if target == "" { + return model.ScanResult{}, fmt.Errorf("invalid scan target") + } start := time.Now() result := model.ScanResult{ - ID: fmt.Sprintf("%s-%d", sanitizeTarget(target), start.Unix()), - Target: normalizeTarget(target), + ID: fmt.Sprintf("%s-%d-%s", sanitizeTarget(target), start.UnixNano(), randomSuffix()), + Target: target, Mode: normalizeMode(opts.Mode), StartedAt: start, } @@ -65,9 +83,15 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m if opts.Threads <= 0 { opts.Threads = 30 } + if opts.Threads > 500 { + opts.Threads = 500 + } if opts.Rate <= 0 { opts.Rate = 150 } + if opts.Rate > 10000 { + opts.Rate = 10000 + } subs := map[string]struct{}{result.Target: {}} @@ -80,7 +104,7 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "subdomains", Message: warn}) } for _, s := range nativeSubs { - if looksLikeHost(s) { + if looksLikeHost(s) && withinTarget(s, result.Target) { subs[s] = struct{}{} } } @@ -88,11 +112,16 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m for _, tool := range []string{"subfinder", "assetfinder", "findomain", "amass"} { lines, err := runSubdomainTool(ctx, tool, result.Target, opts.Threads, opts.APIKeys) if err != nil { + if hasBinary(tool) && ctx.Err() == nil { + warn := fmt.Sprintf("%s failed: %v", tool, err) + result.Warnings = append(result.Warnings, warn) + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "subdomains", Message: warn}) + } continue } for _, line := range lines { s := normalizeTarget(line) - if s != "" && strings.Contains(s, result.Target) && looksLikeHost(s) { + if s != "" && withinTarget(s, result.Target) && looksLikeHost(s) { subs[s] = struct{}{} } } @@ -101,7 +130,7 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m if stKey := strings.TrimSpace(opts.APIKeys["securitytrails"]); stKey != "" { stSubs := e.securityTrailsSubdomains(ctx, result.Target, stKey) for _, s := range stSubs { - if looksLikeHost(s) { + if looksLikeHost(s) && withinTarget(s, result.Target) { subs[s] = struct{}{} } } @@ -130,7 +159,7 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m if stageOn(opts.EnabledStages, "http") { stageStart := time.Now() emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "http", Message: "probing live hosts"}) - live = e.probeHTTP(ctx, probeInputs, opts.Threads) + live = e.probeHTTP(ctx, probeInputs, opts.Threads, opts.Rate) result.LiveHosts = live emit(opts.Progress, model.StageEvent{ Timestamp: time.Now(), @@ -148,7 +177,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(ctx, probeInputs, opts.Threads, opts.Progress) + ports := scanCommonPorts(ctx, probeInputs, opts.Threads, opts.Rate, opts.Progress) result.Ports = ports emit(opts.Progress, model.StageEvent{ Timestamp: time.Now(), @@ -189,6 +218,10 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m vulns, err := runNuclei(ctx, live) if err == nil { result.Vulns = vulns + } else if ctx.Err() == nil { + warn := "nuclei failed: " + err.Error() + result.Warnings = append(result.Warnings, warn) + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "vulns", Message: warn}) } } else { result.Warnings = append(result.Warnings, "nuclei not installed: vulnerability stage skipped") @@ -217,11 +250,16 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m } result.FinishedAt = time.Now() result.DurationMS = result.FinishedAt.Sub(start).Milliseconds() + doneMessage := "scan completed" + if ctx.Err() != nil { + doneMessage = "scan interrupted; saved results may be incomplete" + result.Warnings = append(result.Warnings, "scan interrupted; results may be incomplete") + } emit(opts.Progress, model.StageEvent{ Timestamp: result.FinishedAt, Type: model.EventTargetDone, Target: result.Target, - Message: "scan completed", + Message: doneMessage, DurationMS: result.DurationMS, }) return result, nil @@ -235,7 +273,7 @@ func emit(cb func(model.StageEvent), ev model.StageEvent) { func normalizeMode(m model.Mode) model.Mode { switch m { - case model.ModeFast, model.ModeNarrow, model.ModeWide, model.ModeDeep, model.ModeOSINT: + case model.ModeNarrow, model.ModeWide: return m default: return model.ModeWide @@ -251,25 +289,23 @@ func runSubdomainTool(ctx context.Context, tool string, target string, threads i if !hasBinary(tool) { return nil, fmt.Errorf("%s missing", tool) } - cmdline := "" switch tool { case "subfinder": - base := fmt.Sprintf("subfinder -d %s -silent -all -t %d", shellEscape(target), threads) + args := []string{"-d", target, "-silent", "-all", "-t", strconv.Itoa(threads)} if provConf := writeSubfinderProviderConfig(apiKeys); provConf != "" { defer os.Remove(provConf) - base += " -provider-config " + shellEscape(provConf) + args = append(args, "-provider-config", provConf) } - cmdline = base + return runLines(ctx, 4*time.Minute, tool, args...) case "assetfinder": - cmdline = fmt.Sprintf("assetfinder --subs-only %s", shellEscape(target)) + return runLines(ctx, 4*time.Minute, tool, "--subs-only", target) case "findomain": - cmdline = fmt.Sprintf("findomain -t %s -q", shellEscape(target)) + return runLines(ctx, 4*time.Minute, tool, "-t", target, "-q") case "amass": - cmdline = fmt.Sprintf("amass enum -passive -d %s", shellEscape(target)) + return runLines(ctx, 4*time.Minute, tool, "enum", "-passive", "-d", target) default: return nil, fmt.Errorf("unsupported tool") } - return runLines(ctx, cmdline, 4*time.Minute) } // writeSubfinderProviderConfig creates a temporary provider-config.yaml with @@ -313,12 +349,21 @@ func writeSubfinderProviderConfig(apiKeys map[string]string) string { 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) - } + b, err := yaml.Marshal(providers) + if err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return "" + } + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return "" + } + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return "" } // Explicitly close before returning so the file is fully flushed and // safe for subfinder to read. @@ -329,10 +374,10 @@ func writeSubfinderProviderConfig(apiKeys map[string]string) string { return tmp.Name() } -func runLines(parent context.Context, cmdline string, timeout time.Duration) ([]string, error) { +func runLines(parent context.Context, timeout time.Duration, program string, args ...string) ([]string, error) { ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() - cmd := shellCommand(ctx, cmdline) + cmd := exec.CommandContext(ctx, program, args...) out, err := cmd.Output() if err != nil { return nil, err @@ -345,31 +390,56 @@ func runLines(parent context.Context, cmdline string, timeout time.Duration) ([] lines = append(lines, line) } } + if err := s.Err(); err != nil { + return nil, err + } return dedupe(lines), nil } -func shellCommand(ctx context.Context, cmdline string) *exec.Cmd { - if runtime.GOOS == "windows" { - return exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", cmdline) +func normalizeTarget(t string) string { + t = strings.TrimSpace(t) + if strings.Contains(t, "://") { + u, err := url.Parse(t) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "" + } + t = u.Hostname() + } + t = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(t)), ".") + if ip := net.ParseIP(t); ip != nil { + return ip.String() + } + if len(t) > 253 || t == "" { + return "" + } + for _, label := range strings.Split(t, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return "" + } + for _, c := range label { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { + return "" + } + } } - return exec.CommandContext(ctx, "sh", "-c", cmdline) + return t } -func shellEscape(s string) string { - return strings.ReplaceAll(s, "'", "") +func randomSuffix() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) } -func normalizeTarget(t string) string { - t = strings.TrimSpace(strings.ToLower(t)) - t = strings.TrimPrefix(t, "https://") - t = strings.TrimPrefix(t, "http://") - if i := strings.IndexRune(t, '/'); i > -1 { - t = t[:i] - } - if i := strings.IndexRune(t, ':'); i > -1 { - t = t[:i] +func withinTarget(host, target string) bool { + host = strings.TrimSuffix(strings.ToLower(host), ".") + target = strings.TrimSuffix(strings.ToLower(target), ".") + if net.ParseIP(target) != nil { + return host == target } - return t + return host == target || strings.HasSuffix(host, "."+target) } func sanitizeTarget(t string) string { @@ -477,7 +547,7 @@ func (e *Engine) securityTrailsSubdomains(ctx context.Context, target string, ap return dedupe(out) } -func (e *Engine) probeHTTP(ctx context.Context, hosts []string, threads int) []model.LiveHost { +func (e *Engine) probeHTTP(ctx context.Context, hosts []string, threads, rate int) []model.LiveHost { jobs := make(chan string) results := make(chan model.LiveHost, len(hosts)) wg := sync.WaitGroup{} @@ -486,11 +556,18 @@ func (e *Engine) probeHTTP(ctx context.Context, hosts []string, threads int) []m go func() { defer wg.Done() for host := range jobs { + if ctx.Err() != nil { + continue + } if !looksLikeHost(host) { continue } for _, scheme := range []string{"https", "http"} { - u := fmt.Sprintf("%s://%s", scheme, host) + urlHost := host + if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") { + urlHost = "[" + host + "]" + } + u := fmt.Sprintf("%s://%s", scheme, urlHost) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { continue @@ -507,8 +584,20 @@ func (e *Engine) probeHTTP(ctx context.Context, hosts []string, threads int) []m } }() } + ticker := time.NewTicker(time.Second / time.Duration(max(1, rate))) + defer ticker.Stop() +dispatchHosts: for _, host := range dedupe(hosts) { - jobs <- host + select { + case jobs <- host: + case <-ctx.Done(): + break dispatchHosts + } + select { + case <-ticker.C: + case <-ctx.Done(): + break dispatchHosts + } } close(jobs) wg.Wait() @@ -545,7 +634,7 @@ func extractTitle(r io.Reader) string { return t } -func scanCommonPorts(ctx context.Context, hosts []string, threads int, progress func(model.StageEvent)) []model.PortHit { +func scanCommonPorts(ctx context.Context, hosts []string, threads, rate 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) @@ -571,7 +660,7 @@ func scanCommonPorts(ctx context.Context, hosts []string, threads int, progress defer wg.Done() for j := range jobs { addr := net.JoinHostPort(j.host, strconv.Itoa(j.port)) - c, err := net.DialTimeout("tcp", addr, 900*time.Millisecond) + c, err := (&net.Dialer{Timeout: 900 * time.Millisecond}).DialContext(ctx, "tcp", addr) if err == nil { _ = c.Close() results <- model.PortHit{Host: j.host, Port: j.port} @@ -579,9 +668,21 @@ func scanCommonPorts(ctx context.Context, hosts []string, threads int, progress } }() } + ticker := time.NewTicker(time.Second / time.Duration(max(1, rate))) + defer ticker.Stop() +dispatchPorts: for _, h := range dedupe(hosts) { for _, p := range ports { - jobs <- job{host: h, port: p} + select { + case jobs <- job{host: h, port: p}: + case <-ctx.Done(): + break dispatchPorts + } + select { + case <-ticker.C: + case <-ctx.Done(): + break dispatchPorts + } } } close(jobs) @@ -615,8 +716,7 @@ func runNaabu(ctx context.Context, hosts []string, threads int) ([]model.PortHit } _ = 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) + lines, err := runLines(ctx, 10*time.Minute, "naabu", "-list", tmp.Name(), "-silent", "-c", strconv.Itoa(threads), "-top-ports", "1000") if err != nil { return nil, err } @@ -679,12 +779,12 @@ func (e *Engine) discoverURLs(ctx context.Context, hosts []string, threads int) } func runGau(ctx context.Context, host string) []string { - lines, _ := runLines(ctx, fmt.Sprintf("gau --threads 5 %s", shellEscape(host)), 3*time.Minute) + lines, _ := runLines(ctx, 3*time.Minute, "gau", "--threads", "5", host) 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) + lines, _ := runLines(ctx, 5*time.Minute, "katana", "-u", "https://"+host, "-silent", "-jc", "-d", "3") return lines } @@ -732,16 +832,7 @@ func extractJS(urls []string) []string { } func looksLikeHost(host string) bool { - if host == "" || strings.ContainsAny(host, " \t") { - return false - } - if strings.Contains(host, "*") || strings.Contains(host, "/") { - return false - } - if strings.Contains(host, "@") { - return false - } - return strings.Contains(host, ".") + return normalizeTarget(host) != "" } func stageOn(enabled map[string]bool, stage string) bool { diff --git a/internal/model/model.go b/internal/model/model.go index 4e38d6d..adf4d76 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -93,7 +93,7 @@ type SeverityCount struct { Count int `json:"count"` } -// AnalyticsReport is the response returned by /api/analytics. +// AnalyticsReport contains aggregated scan statistics. type AnalyticsReport struct { ScanCount int `json:"scan_count"` AvgDurationMS int64 `json:"avg_duration_ms"` diff --git a/internal/store/store.go b/internal/store/store.go index 1128d9f..20d644b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,13 +14,17 @@ import ( "github.com/root-Manas/macaron/internal/model" _ "modernc.org/sqlite" ) + type Store struct { baseDir string db *sql.DB } func New(baseDir string) (*Store, error) { - if err := os.MkdirAll(baseDir, 0o755); err != nil { + if err := os.MkdirAll(baseDir, 0o700); err != nil { + return nil, err + } + if err := os.Chmod(baseDir, 0o700); err != nil && !errors.Is(err, os.ErrPermission) { return nil, err } dbPath := filepath.Join(baseDir, "macaron.db") @@ -38,9 +42,15 @@ func New(baseDir string) (*Store, error) { _ = db.Close() return nil, err } + if err := os.Chmod(dbPath, 0o600); err != nil { + _ = db.Close() + return nil, err + } return s, nil } +func (s *Store) Close() error { return s.db.Close() } + func (s *Store) initSchema() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS scans ( @@ -96,14 +106,17 @@ ON CONFLICT(id) DO UPDATE SET // Keep a per-target folder mirror for easy file browsing. targetDir := filepath.Join(s.baseDir, sanitizeFilename(result.Target)) - if err := os.MkdirAll(targetDir, 0o755); err != nil { + if err := os.MkdirAll(targetDir, 0o700); err != nil { return err } - pretty, _ := json.MarshalIndent(result, "", " ") - if err := os.WriteFile(filepath.Join(targetDir, result.ID+".json"), pretty, 0o644); err != nil { + pretty, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + if err := writePrivate(filepath.Join(targetDir, result.ID+".json"), pretty); err != nil { return err } - return os.WriteFile(filepath.Join(targetDir, "latest.txt"), []byte(result.ID), 0o644) + return writePrivate(filepath.Join(targetDir, "latest.txt"), []byte(result.ID)) } func (s *Store) GetByID(id string) (*model.ScanResult, error) { @@ -215,14 +228,41 @@ func (s *Store) Export(path string, target string) (string, error) { if path == "" { path = filepath.Join(s.baseDir, fmt.Sprintf("export_%s.json", time.Now().Format("20060102_150405"))) } - if err := os.WriteFile(path, b, 0o644); err != nil { + if err := writePrivate(path, b); err != nil { return "", err } return path, nil } -// Analytics returns aggregated statistics across all scans for display in the -// dashboard analytics tab. +func writePrivate(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".macaron-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +// Analytics aggregates findings and scan durations across saved scans. func (s *Store) Analytics() (model.AnalyticsReport, error) { report := model.AnalyticsReport{} From 0f0f2e54069720fe7c23d1a41d18aaa2a0d32613 Mon Sep 17 00:00:00 2001 From: root-Manas Date: Sun, 27 Sep 2026 19:27:00 +0530 Subject: [PATCH 2/3] fix: align target parsing with new validation --- cmd/macaron/main.go | 21 +++++++++++++---- cmd/macaron/main_test.go | 4 ++-- internal/app/app.go | 49 +++++++++++++++++++++++++++------------ internal/engine/engine.go | 2 +- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/cmd/macaron/main.go b/cmd/macaron/main.go index a8dc1e6..fe30259 100644 --- a/cmd/macaron/main.go +++ b/cmd/macaron/main.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "net" "os" "os/signal" "path/filepath" @@ -747,9 +748,6 @@ authorized use only. func applyProfile(profile string, mode *string, rate *int, threads *int, stages *string) { switch strings.ToLower(strings.TrimSpace(profile)) { case "passive": - if *mode == "wide" { - *mode = "osint" - } if *rate == 150 { *rate = 40 } @@ -825,5 +823,20 @@ func looksLikeDomain(s string) bool { return false } targets, err := app.ParseTargets([]string{s}, "", false) - return err == nil && len(targets) == 1 + if err != nil || len(targets) != 1 { + return false + } + if net.ParseIP(targets[0]) != nil || strings.Contains(s, "://") { + return true + } + parts := strings.Split(targets[0], ".") + if len(parts) < 2 || len(parts[len(parts)-1]) < 2 { + return false + } + for _, c := range parts[len(parts)-1] { + if c < 'a' || c > 'z' { + return false + } + } + return true } diff --git a/cmd/macaron/main_test.go b/cmd/macaron/main_test.go index 50c88a6..d053195 100644 --- a/cmd/macaron/main_test.go +++ b/cmd/macaron/main_test.go @@ -10,7 +10,7 @@ func TestApplyProfilePassive(t *testing.T) { threads := 30 stages := "all" applyProfile("passive", &mode, &rate, &threads, &stages) - if mode != "osint" || rate != 40 || threads != 10 || stages != "subdomains,http,urls" { + if mode != "wide" || rate != 40 || threads != 10 || stages != "subdomains,http,urls" { t.Fatalf("unexpected passive values: mode=%s rate=%d threads=%d stages=%s", mode, rate, threads, stages) } } @@ -45,7 +45,7 @@ func TestLooksLikeDomain(t *testing.T) { }{ {"example.com", true}, {"sub.example.com", true}, - {"Example.COM", true}, // uppercase TLD accepted + {"Example.COM", true}, // uppercase TLD accepted {"-flag", false}, {"nodots", false}, {"has space.com", false}, diff --git a/internal/app/app.go b/internal/app/app.go index b7305de..78cae26 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -177,6 +177,10 @@ func ParseTargets(raw []string, filePath string, stdin bool) ([]string, error) { if t == "" || strings.HasPrefix(t, "#") { return } + if !validTargetInput(t) { + invalid = append(invalid, "invalid target") + return + } t = normalizeTarget(t) if t == "" { invalid = append(invalid, "invalid target") @@ -392,32 +396,47 @@ func ioReadAllStdin() ([]byte, error) { } func normalizeTarget(t string) string { - t = strings.TrimSpace(t) - if strings.Contains(t, "://") { - u, err := url.Parse(t) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { - return "" + t = strings.TrimSpace(strings.ToLower(t)) + t = strings.TrimPrefix(t, "https://") + t = strings.TrimPrefix(t, "http://") + if i := strings.IndexRune(t, '/'); i > -1 { + t = t[:i] + } + if i := strings.IndexRune(t, ':'); i > -1 { + t = t[:i] + } + return t +} + +func validTargetInput(raw string) bool { + value := strings.TrimSpace(raw) + if strings.Contains(value, "://") { + u, err := url.Parse(value) + if err != nil || (strings.ToLower(u.Scheme) != "http" && strings.ToLower(u.Scheme) != "https") || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return false } - t = u.Hostname() + value = u.Hostname() + } else if strings.ContainsAny(value, "/?#@") || (strings.Contains(value, ":") && net.ParseIP(value) == nil) { + return false } - t = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(t)), ".") - if ip := net.ParseIP(t); ip != nil { - return ip.String() + value = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".") + if net.ParseIP(value) != nil { + return true } - if len(t) > 253 || t == "" { - return "" + if len(value) == 0 || len(value) > 253 { + return false } - for _, label := range strings.Split(t, ".") { + for _, label := range strings.Split(value, ".") { if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return "" + return false } for _, c := range label { if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { - return "" + return false } } } - return t + return true } func firstN(items []string, n int) []string { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3c29b27..7186bc3 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -400,7 +400,7 @@ func normalizeTarget(t string) string { t = strings.TrimSpace(t) if strings.Contains(t, "://") { u, err := url.Parse(t) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + if err != nil || (strings.ToLower(u.Scheme) != "http" && strings.ToLower(u.Scheme) != "https") || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { return "" } t = u.Hostname() From 1b1ca2686683ed890a919a13582c04ae3173dc44 Mon Sep 17 00:00:00 2001 From: root-Manas Date: Sun, 27 Sep 2026 19:32:41 +0530 Subject: [PATCH 3/3] fix: enforce private permissions for existing data --- internal/cfg/config.go | 5 ++--- internal/store/store.go | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/cfg/config.go b/internal/cfg/config.go index 35a9c8e..defae6e 100644 --- a/internal/cfg/config.go +++ b/internal/cfg/config.go @@ -1,7 +1,6 @@ package cfg import ( - "errors" "fmt" "os" "path/filepath" @@ -30,7 +29,7 @@ func Load(storageRoot string) (*Config, error) { if err := yaml.Unmarshal(b, cfg); err != nil { return nil, err } - if err := os.Chmod(path, 0o600); err != nil && !errors.Is(err, os.ErrPermission) { + if err := os.Chmod(path, 0o600); err != nil { return nil, err } if cfg.APIKeys == nil { @@ -44,7 +43,7 @@ func Save(storageRoot string, cfg *Config) error { if err := os.MkdirAll(storageRoot, 0o700); err != nil { return err } - if err := os.Chmod(storageRoot, 0o700); err != nil && !errors.Is(err, os.ErrPermission) { + if err := os.Chmod(storageRoot, 0o700); err != nil { return err } if cfg.APIKeys == nil { diff --git a/internal/store/store.go b/internal/store/store.go index 20d644b..b9d3525 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,7 +24,7 @@ func New(baseDir string) (*Store, error) { if err := os.MkdirAll(baseDir, 0o700); err != nil { return nil, err } - if err := os.Chmod(baseDir, 0o700); err != nil && !errors.Is(err, os.ErrPermission) { + if err := os.Chmod(baseDir, 0o700); err != nil { return nil, err } dbPath := filepath.Join(baseDir, "macaron.db") @@ -109,6 +109,9 @@ ON CONFLICT(id) DO UPDATE SET if err := os.MkdirAll(targetDir, 0o700); err != nil { return err } + if err := os.Chmod(targetDir, 0o700); err != nil { + return err + } pretty, err := json.MarshalIndent(result, "", " ") if err != nil { return err