/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · Blazing-fast pentesting suite : : ▄█ █ █▀ · BSD 3-Clause License : : : : (c) 2022-2026 vmfunc, xyzeva, : : lunchcat alumni & contributors : : : ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· */ // Package sif provides the main functionality for the SIF (Security Information Finder) tool. // It handles the initialization, configuration, and execution of various security scanning modules. package sif import ( "bufio" "context" "encoding/json" "fmt" "io" "os" "strings" "github.com/charmbracelet/log" "github.com/dropalldatabases/sif/internal/config" "github.com/dropalldatabases/sif/internal/dnsx" "github.com/dropalldatabases/sif/internal/finding" "github.com/dropalldatabases/sif/internal/httpx" "github.com/dropalldatabases/sif/internal/logger" "github.com/dropalldatabases/sif/internal/modules" "github.com/dropalldatabases/sif/internal/output" "github.com/dropalldatabases/sif/internal/report" "github.com/dropalldatabases/sif/internal/scan" "github.com/dropalldatabases/sif/internal/scan/builtin" "github.com/dropalldatabases/sif/internal/scan/frameworks" jsscan "github.com/dropalldatabases/sif/internal/scan/js" ) // App represents the main application structure for sif. // It encapsulates the configuration settings, target URLs, and logging information. type App struct { settings *config.Settings targets []string logFiles []string } // Version is set by main to the resolved build version and shown on the banner. var Version = "dev" // reportFileMode is the permission applied to written report files: owner // read/write, group/other read. reports aren't secret but may name targets. const reportFileMode = 0o644 type UrlResult struct { Url string `json:"url"` Results []ModuleResult } type ModuleResult struct { Id string `json:"id"` Data interface{} `json:"data"` } // ScanResult is the interface that all scan result types must implement. // This mirrors the definition in pkg/scan/result.go for use by the main package. type ScanResult interface { ResultType() string } // NewModuleResult creates a ModuleResult with compile-time type safety. // The data parameter must implement ScanResult, which is enforced at compile time. func NewModuleResult[T ScanResult](data T) ModuleResult { return ModuleResult{ Id: data.ResultType(), Data: data, } } // New creates a new App struct by parsing the configuration options, // figuring out the targets from list or file, etc. // // Errors if no targets are supplied through URLs or File. func New(settings *config.Settings) (*App, error) { app := &App{settings: settings} // -silent reroutes all chrome to stderr (and suppresses spinners) before the // banner prints, so stdout carries nothing but findings even on the banner. if settings.Silent { output.SetSilent(true) } if !settings.ApiMode { fmt.Fprintln(output.Writer(), output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ ")) tagline := "blazing-fast pentesting suite" if Version != "dev" { tagline += " · v" + Version } fmt.Fprintln(output.Writer(), output.Subheading.Render("\n"+tagline+"\n\nbsd 3-clause · (c) 2022-2026 vmfunc, xyzeva & contributors\n")) } else { output.SetAPIMode(true) } // Skip target requirement if just listing modules if settings.ListModules { return app, nil } // -u and -f are explicit; stdin is additive so `subfinder | sif -u extra` // still works. order: flags first, then piped lines appended. app.targets = append(app.targets, settings.URLs...) if settings.File != "" { if _, err := os.Stat(settings.File); err != nil { return nil, err } data, err := os.Open(settings.File) if err != nil { return nil, err } defer data.Close() scanner := bufio.NewScanner(data) scanner.Split(bufio.ScanLines) for scanner.Scan() { app.targets = append(app.targets, scanner.Text()) } } // when stdin is a pipe (not a terminal), drain it for targets so sif slots // into a unix pipeline: `subfinder -d x | sif -silent | notify`. keyed off // stdin's mode, never stdout - a redirected stdout (>file) is not a pipe in. piped, err := stdinPipedFn() if err != nil { return nil, err } if piped { stdinTargets, err := readTargets(stdinReader) if err != nil { return nil, fmt.Errorf("reading targets from stdin: %w", err) } app.targets = append(app.targets, stdinTargets...) } if len(app.targets) == 0 { return nil, fmt.Errorf("target(s) must be supplied with -u, -f, or stdin\n\nSee 'sif -h' for more information") } // normalize every target in place: a naked host gains a default scheme, an // explicit scheme is kept, genuinely invalid input is rejected early. for i := 0; i < len(app.targets); i++ { normalized, err := normalizeTarget(app.targets[i]) if err != nil { return nil, err } app.targets[i] = normalized } return app, nil } // defaultScheme is prepended to scheme-less targets. https is the safer default // for recon: it's what modern hosts serve and avoids a cleartext first hop. const defaultScheme = "https://" // stdin ingestion is wired through two seams so it's hermetically testable: the // pipe check and the reader can be swapped in tests without touching real fds. var ( stdinPipedFn = stdinPiped stdinReader io.Reader = os.Stdin ) // stdinPiped reports whether stdin is a pipe/redirect rather than a terminal. // a char device (the tty) means interactive with no piped input; anything else // (pipe, file redirect) is treated as a target stream. func stdinPiped() (bool, error) { info, err := os.Stdin.Stat() if err != nil { return false, fmt.Errorf("stat stdin: %w", err) } return info.Mode()&os.ModeCharDevice == 0, nil } // readTargets scans one target per line from r, dropping blank lines and // trimming surrounding whitespace. shared by the stdin path; the file path keeps // its own scanner since it preserves lines verbatim for back-compat. func readTargets(r io.Reader) ([]string, error) { var out []string scanner := bufio.NewScanner(r) scanner.Split(bufio.ScanLines) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } out = append(out, line) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("scanning targets: %w", err) } return out, nil } // normalizeTarget canonicalizes a single target. a scheme-less host gets the // default scheme; an http:// or https:// target is kept as-is. an empty string // or a non-http(s) scheme (ftp://, file://, ...) is rejected so junk can't slip // into the scan loop. func normalizeTarget(target string) (string, error) { target = strings.TrimSpace(target) if target == "" { return "", fmt.Errorf("empty target provided") } if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { return target, nil } // reject anything that carries some other scheme; "://" present but not // http(s) is a deliberate non-web target, not a naked host. if strings.Contains(target, "://") { return "", fmt.Errorf("target %s must use http:// or https:// scheme", target) } // a bare "host:port" or path-only token would also be ambiguous; require at // least a host-looking first segment (no spaces) before defaulting a scheme. if strings.ContainsAny(target, " \t") { return "", fmt.Errorf("invalid target %q", target) } return defaultScheme + target, nil } // Run runs the pentesting suite, with the targets specified, according to the // settings specified. func (app *App) Run() error { // Handle --list-modules before any other processing if app.settings.ListModules { loader, err := modules.NewLoader() if err != nil { return fmt.Errorf("failed to create module loader: %w", err) } if err := loader.LoadAll(); err != nil { log.Warnf("Failed to load modules: %v", err) } // Register built-in Go modules builtin.Register() fmt.Println("Available modules:") for _, m := range modules.All() { info := m.Info() fmt.Printf(" %-25s %s [%s]\n", info.ID, info.Name, strings.Join(info.Tags, ", ")) } return nil } if app.settings.Debug { log.SetLevel(log.DebugLevel) } if app.settings.ApiMode { log.SetLevel(5) } if app.settings.LogDir != "" { if err := logger.Init(app.settings.LogDir); err != nil { return err } defer func() { if err := logger.Close(); err != nil { log.Errorf("closing logger: %v", err) } }() } // wire proxy/headers/cookie/rate-limit into the shared http client once, // before any scanner runs. a bad proxy/header shouldn't kill the run - // scanners fall back to a plain client if this fails. if err := httpx.Configure(httpx.Options{ Proxy: app.settings.Proxy, Headers: app.settings.Header, Cookie: app.settings.Cookie, RateLimit: app.settings.RateLimit, Threads: app.settings.Threads, }); err != nil { log.Warnf("http client config failed, continuing with defaults: %v", err) } // target expansion - securitytrails discovers new domains before scanning if app.settings.SecurityTrails { expanded := app.expandTargets() if len(expanded) > 0 { output.Info("SecurityTrails discovered %d additional targets", len(expanded)) app.targets = append(app.targets, expanded...) } } scansRun := make([]string, 0, 16) // accumulate every module result across targets so the report writers can // serialize the full run after the loop. only collected when an export flag // is set, so the common path pays nothing. wantReport := app.settings.SARIF != "" || app.settings.Markdown != "" reportResults := make([]report.Result, 0, 16) // normalized findings for the whole run; the single Flatten-driven view that // notify and diff (later) consume. collected alongside the report so both // describe the same scanners from one pass. allFindings := make([]finding.Finding, 0, 16) for _, url := range app.targets { output.Info("Starting scan on %s", output.Highlight.Render(url)) moduleResults := make([]ModuleResult, 0, 16) if app.settings.LogDir != "" { if err := logger.CreateFile(&app.logFiles, url, app.settings.LogDir); err != nil { return err } } if !app.settings.NoScan { scan.Scan(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) scansRun = append(scansRun, "Basic Scan") } if app.settings.Framework { result, err := frameworks.DetectFramework(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running framework detection: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Framework Detection") } } if app.settings.Dirlist != "none" { result, err := scan.Dirlist(app.settings.Dirlist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir, scan.DirlistOptions{ MatchCodes: app.settings.DirMatchCodes, FilterCodes: app.settings.DirFilterCodes, FilterSizes: app.settings.DirFilterSizes, FilterWords: app.settings.DirFilterWords, FilterRegex: app.settings.DirFilterRegex, Calibrate: app.settings.DirCalibrate, Wordlist: app.settings.DirWordlist, Extensions: app.settings.DirExtensions, }) if err != nil { log.Errorf("Error while running directory scan: %s", err) } else { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Directory Listing") } } var dnsResults []string if app.settings.Dnslist != "none" { result, err := scan.Dnslist(app.settings.Dnslist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir, dnsx.ParseResolvers(app.settings.Resolvers)) if err != nil { log.Errorf("Error while running dns scan: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"dnslist", result}) dnsResults = result // Store the DNS results scansRun = append(scansRun, "DNS Scan") } // Only run subdomain takeover check if DNS scan is enabled if app.settings.SubdomainTakeover { result, err := scan.SubdomainTakeover(url, dnsResults, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running Subdomain Takeover Vulnerability Check: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"subdomain_takeover", result}) scansRun = append(scansRun, "Subdomain Takeover") } } } else if app.settings.SubdomainTakeover { log.Warnf("Subdomain Takeover check is enabled but DNS scan is disabled. Skipping Subdomain Takeover check.") } if app.settings.Dorking { result, err := scan.Dork(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running Dork module: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"dork", result}) scansRun = append(scansRun, "Dork") } } if app.settings.Ports != "none" { result, err := scan.Ports(context.Background(), app.settings.Ports, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running port scan: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"portscan", result}) scansRun = append(scansRun, "Port Scan") } } if app.settings.Whois { scan.Whois(url, app.settings.LogDir) scansRun = append(scansRun, "Whois") } if app.settings.Git { result, err := scan.Git(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running Git module: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"git", result}) scansRun = append(scansRun, "Git") } } if app.settings.Nuclei { result, err := scan.Nuclei(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running Nuclei module: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"nuclei", result}) scansRun = append(scansRun, "Nuclei") } } if app.settings.JavaScript { result, err := jsscan.JavascriptScan(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running JS module: %s", err) } else { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "JS") } } if app.settings.CMS { result, err := scan.CMS(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running CMS detection: %s", err) scansRun = append(scansRun, "CMS") } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) } } if app.settings.Headers { result, err := scan.Headers(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running HTTP Header Analysis: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"headers", result}) scansRun = append(scansRun, "HTTP Headers") } } if app.settings.SecurityHeaders { result, err := scan.SecurityHeaders(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running Security Header Analysis: %s", err) } else { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Security Headers") } } if app.settings.CloudStorage { result, err := scan.CloudStorage(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running C3 Scan: %s", err) } else { moduleResults = append(moduleResults, ModuleResult{"cloudstorage", result}) scansRun = append(scansRun, "Cloud Storage") } } if app.settings.Shodan { result, err := scan.Shodan(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running Shodan lookup: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Shodan") } } if app.settings.SQL { result, err := scan.SQL(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running SQL reconnaissance: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "SQL Recon") } } if app.settings.LFI { result, err := scan.LFI(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running LFI reconnaissance: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "LFI Recon") } } if app.settings.CORS { result, err := scan.CORS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running CORS probe: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "CORS") } } if app.settings.Redirect { result, err := scan.Redirect(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running open redirect probe: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Open Redirect") } } if app.settings.XSS { result, err := scan.XSS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { log.Errorf("Error while running reflected XSS probe: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Reflected XSS") } } if app.settings.Crawl { result, err := scan.Crawl(url, app.settings.CrawlDepth, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running web crawl: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Crawl") } } if app.settings.Passive { result, err := scan.Passive(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running passive discovery: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Passive") } } if app.settings.Probe { result, err := scan.Probe(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("Error while running probe: %s", err) } else if result != nil { moduleResults = append(moduleResults, NewModuleResult(result)) scansRun = append(scansRun, "Probe") } } // Load and run modules if app.settings.AllModules || app.settings.Modules != "" || app.settings.ModuleTags != "" { loader, err := modules.NewLoader() if err != nil { log.Warnf("Failed to create module loader: %v", err) } else { if err := loader.LoadAll(); err != nil { log.Warnf("Failed to load modules: %v", err) } // Register built-in Go modules builtin.Register() // Determine which modules to run var toRun []modules.Module switch { case app.settings.AllModules: toRun = modules.All() case app.settings.ModuleTags != "": for _, tag := range strings.Split(app.settings.ModuleTags, ",") { toRun = append(toRun, modules.ByTag(strings.TrimSpace(tag))...) } case app.settings.Modules != "": for _, id := range strings.Split(app.settings.Modules, ",") { if m, ok := modules.Get(strings.TrimSpace(id)); ok { toRun = append(toRun, m) } else { log.Warnf("Module not found: %s", id) } } } // Execute modules opts := modules.Options{ Timeout: app.settings.Timeout, Threads: app.settings.Threads, LogDir: app.settings.LogDir, } for _, m := range toRun { modLog := output.Module(m.Info().ID) modLog.Start() result, err := m.Execute(context.Background(), url, opts) if err != nil { modLog.Error("failed: %v", err) continue } if result != nil && len(result.Findings) > 0 { moduleResults = append(moduleResults, NewModuleResult(result)) modLog.Complete(len(result.Findings), "findings") } else { modLog.Complete(0, "findings") } } } } if app.settings.ApiMode { result := UrlResult{ Url: url, Results: moduleResults, } marshalled, err := json.Marshal(result) if err != nil { log.Errorf("failed to marshal result: %s", err) continue } fmt.Println(string(marshalled)) } allFindings = append(allFindings, collectFindings(url, moduleResults)...) // the report carries raw blobs and is only built when an export flag is // set, so the common path skips the marshalling entirely. if wantReport { reportResults = append(reportResults, collectReportResults(url, moduleResults)...) } } // the normalized findings are the handoff point for notify/diff; surface the // count now so the path is live and observable without changing output. log.Debugf("normalized %d findings across %d targets", len(allFindings), len(app.targets)) // -silent: stdout is the findings stream, one terse line each. all chrome // already went to stderr via the rerouted sink, so this is the only thing a // downstream pipe sees. if app.settings.Silent { printFindings(allFindings) } if wantReport { if err := app.writeReports(reportResults); err != nil { return err } } if !app.settings.ApiMode { output.PrintSummary(scansRun, app.logFiles) } return nil } // printFindings writes one normalized finding per line to stdout for the // -silent plain sink. a single Builder over the run avoids interleaving with // any stray stderr chrome and keeps the write to one syscall. func printFindings(findings []finding.Finding) { var b strings.Builder for i := 0; i < len(findings); i++ { b.WriteString(findings[i].Line()) b.WriteByte('\n') } fmt.Print(b.String()) } // collectFindings normalizes one target's module results through finding.Flatten // - the single normalization path that notify and diff (later bundles) build on. // every scan result struct collapses to flat, severity-ranked findings here so a // scanner is described once, not once per consumer. func collectFindings(target string, moduleResults []ModuleResult) []finding.Finding { out := make([]finding.Finding, 0, len(moduleResults)) for _, mr := range moduleResults { out = append(out, finding.Flatten(target, mr.Id, mr.Data)...) } return out } // collectReportResults flattens one target's module results into the report // model, carrying each finding as raw json so the report package stays free of // scan types. a result that won't marshal is skipped rather than failing the run. func collectReportResults(target string, moduleResults []ModuleResult) []report.Result { out := make([]report.Result, 0, len(moduleResults)) for _, mr := range moduleResults { data, err := json.Marshal(mr.Data) if err != nil { log.Warnf("report: skipping %s result for %s: %v", mr.Id, target, err) continue } out = append(out, report.Result{Target: target, Module: mr.Id, Data: data}) } return out } // writeReports serializes the collected results to the requested export files. // each writer runs independently so a bad path for one format doesn't suppress // the other. func (app *App) writeReports(results []report.Result) error { if path := app.settings.SARIF; path != "" { data, err := report.SARIF(results) if err != nil { return fmt.Errorf("build sarif report: %w", err) } if err := os.WriteFile(path, data, reportFileMode); err != nil { return fmt.Errorf("write sarif report %q: %w", path, err) } output.Success("sarif report written to %s", path) } if path := app.settings.Markdown; path != "" { data := report.Markdown(results) if err := os.WriteFile(path, data, reportFileMode); err != nil { return fmt.Errorf("write markdown report %q: %w", path, err) } output.Success("markdown report written to %s", path) } return nil } // expandTargets queries SecurityTrails for each original target and returns // newly discovered domains (subdomains + associated) for target expansion func (app *App) expandTargets() []string { seen := make(map[string]struct{}) for _, t := range app.targets { seen[t] = struct{}{} } // snapshot original targets - don't expand discovered ones originals := make([]string, len(app.targets)) copy(originals, app.targets) var expanded []string for _, url := range originals { result, err := scan.SecurityTrails(url, app.settings.Timeout, app.settings.LogDir) if err != nil { log.Errorf("SecurityTrails error for %s: %v", url, err) continue } if result == nil { continue } for _, d := range result.DiscoveredURLs() { if _, exists := seen[d]; !exists { seen[d] = struct{}{} expanded = append(expanded, d) } } } return expanded }