diff --git a/README.md b/README.md index 2045d4a..b870f17 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ sif is a modular pentesting toolkit written in go. it's designed to be fast, concurrent, and extensible. run multiple scan types against targets with a single command. ```bash -./sif -u https://example.com -all +./sif -u https://example.com -headers -sh -cms -framework -git ``` ## install @@ -56,7 +56,7 @@ environment.systemPackages = [ pkgs.sif ]; nix profile install nixpkgs#sif # or just run it without installing -nix run nixpkgs#sif -- -u https://example.com -all +nix run nixpkgs#sif -- -u https://example.com -headers -sh -framework ``` the repo also ships a flake if you want to build from source: @@ -125,8 +125,8 @@ makepkg -si # framework detection (with cve lookup) ./sif -u https://example.com -framework -# everything -./sif -u https://example.com -all +# a broad sweep +./sif -u https://example.com -dirlist small -dnslist small -ports common -headers -sh -cms -framework -git -whois ``` run `./sif -h` for all options. @@ -147,6 +147,7 @@ sif has a modular architecture. modules are defined in yaml and can be extended | `-js` | javascript analysis | | `-c3` | cloud storage misconfiguration | | `-headers` | http header analysis | +| `-sh` | security header analysis (missing/weak headers) | | `-st` | subdomain takeover detection | | `-cms` | cms detection | | `-whois` | whois lookups | diff --git a/docs/development.md b/docs/development.md index 6c839f8..0b3ef69 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,7 +4,7 @@ setting up a development environment for sif. ## prerequisites -- go 1.23 or later +- go 1.25 or later - git - make @@ -28,8 +28,7 @@ sif/ │ ├── logger/ # logging utilities │ ├── modules/ # module system │ ├── scan/ # built-in scans -│ ├── styles/ # terminal styling -│ └── worker/ # worker pool +│ └── styles/ # terminal styling ├── modules/ # built-in yaml modules │ ├── http/ # http-based modules │ ├── info/ # information gathering diff --git a/docs/installation.md b/docs/installation.md index 05165a1..29b26b1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -36,7 +36,7 @@ download `sif-windows-amd64.exe` from releases and add to your PATH. ## from source -requires go 1.23+ +requires go 1.25+ ```bash git clone https://github.com/dropalldatabases/sif.git diff --git a/docs/scans.md b/docs/scans.md index 08c5493..6ceadff 100644 --- a/docs/scans.md +++ b/docs/scans.md @@ -98,16 +98,27 @@ analyzes javascript files for security issues. ## http headers (-headers) -analyzes security headers. +dumps the target's response headers. + +## security headers (-sh) + +flags missing or weak security headers and headers that leak server internals. ### checks +- strict-transport-security (https only) - content-security-policy - x-frame-options -- x-content-type-options -- strict-transport-security -- x-xss-protection +- x-content-type-options (expects nosniff) +- referrer-policy - permissions-policy +- cross-origin-opener-policy + +### flagged as disclosure + +- server +- x-powered-by +- x-aspnet-version / x-aspnetmvc-version ## cms detection (-cms) diff --git a/docs/usage.md b/docs/usage.md index cb5480c..1616617 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -95,12 +95,20 @@ scopes: `common` (top ports), `full` (all ports) ### http headers -`-headers` - analyze security headers +`-headers` - dump the target's response headers ```bash ./sif -u https://example.com -headers ``` +### security headers + +`-sh` - flag missing/weak security headers (hsts, csp, x-frame-options, ...) and headers that leak server internals + +```bash +./sif -u https://example.com -sh +``` + ### cloud storage `-c3` - check for cloud storage misconfigurations diff --git a/internal/config/config.go b/internal/config/config.go index 9325bdf..13a2a3b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type Settings struct { Template string CMS bool Headers bool + SecurityHeaders bool CloudStorage bool SubdomainTakeover bool Shodan bool @@ -90,6 +91,7 @@ func Parse() *Settings { flagSet.BoolVar(&settings.JavaScript, "js", false, "Enable JavaScript scans"), flagSet.BoolVar(&settings.CMS, "cms", false, "Enable CMS detection"), flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"), + flagSet.BoolVarP(&settings.SecurityHeaders, "security-headers", "sh", false, "Enable security header analysis (missing/weak headers)"), flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"), flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"), flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"), diff --git a/internal/output/progress.go b/internal/output/progress.go index 39f133f..cf249e1 100644 --- a/internal/output/progress.go +++ b/internal/output/progress.go @@ -105,6 +105,9 @@ func (p *Progress) render() { if !IsTTY { current := atomic.LoadInt64(&p.current) total := p.total + if total <= 0 { + return + } percent := int(current * 100 / total) // Print at 0%, 25%, 50%, 75%, 100% diff --git a/internal/output/progress_test.go b/internal/output/progress_test.go new file mode 100644 index 0000000..83833f7 --- /dev/null +++ b/internal/output/progress_test.go @@ -0,0 +1,34 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package output + +import "testing" + +// the non-tty milestone path divides current*100/total, so a zero-total bar +// used to panic with integer divide-by-zero when piped or redirected. +func TestProgressZeroTotalNoPanic(t *testing.T) { + p := NewProgress(0, "scanning") + p.Increment("item") + p.Set(0, "item") + p.Done() +} + +func TestProgressCounts(t *testing.T) { + p := NewProgress(4, "scanning") + for i := 0; i < 4; i++ { + p.Increment("x") + } + if p.current != 4 { + t.Errorf("current = %d, want 4", p.current) + } +} diff --git a/internal/scan/cloudstorage.go b/internal/scan/cloudstorage.go index 823ef51..d131a5d 100644 --- a/internal/scan/cloudstorage.go +++ b/internal/scan/cloudstorage.go @@ -31,7 +31,7 @@ type CloudStorageResult struct { } func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStorageResult, error) { - fmt.Println(styles.Separator.Render("☁️ Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "...")) + fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "...")) sanitizedURL := strings.Split(url, "://")[1] @@ -43,7 +43,7 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor } cloudlog := log.NewWithOptions(os.Stderr, log.Options{ - Prefix: "C3 ☁️", + Prefix: "C3", }).With("url", url) client := &http.Client{ @@ -81,8 +81,7 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor } func extractPotentialBuckets(url string) []string { - // This is a simple implementation. - // TODO: add more cases + // TODO: handle non-adjacent label combos and strip the tld parts := strings.Split(url, ".") var buckets []string for i, part := range parts { diff --git a/internal/scan/dork.go b/internal/scan/dork.go index 3da46b5..0610695 100644 --- a/internal/scan/dork.go +++ b/internal/scan/dork.go @@ -93,6 +93,7 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork // util.InitProgressBar() var wg sync.WaitGroup + var mu sync.Mutex wg.Add(threads) dorkResults := []DorkResult{} @@ -124,7 +125,9 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork Count: len(results), } + mu.Lock() dorkResults = append(dorkResults, result) + mu.Unlock() } } }(thread) diff --git a/internal/scan/git.go b/internal/scan/git.go index 52e5def..d2d91ab 100644 --- a/internal/scan/git.go +++ b/internal/scan/git.go @@ -74,6 +74,7 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin } var wg sync.WaitGroup + var mu sync.Mutex wg.Add(threads) foundUrls := []string{} @@ -106,7 +107,9 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" git found at ["+repourl+"]\n") } + mu.Lock() foundUrls = append(foundUrls, resp.Request.URL.String()) + mu.Unlock() } resp.Body.Close() } diff --git a/internal/scan/js/supabase.go b/internal/scan/js/supabase.go index 84cbe8c..8f9c209 100644 --- a/internal/scan/js/supabase.go +++ b/internal/scan/js/supabase.go @@ -144,7 +144,7 @@ func doSupabaseRequest(projectId, path, apikey string, auth *string) ([]byte, *h func ScanSupabase(jsContent string, jsUrl string) ([]supabaseScanResult, error) { supabaselog := log.NewWithOptions(os.Stderr, log.Options{ - Prefix: "🚧 JavaScript > Supabase ⚡️", + Prefix: "JavaScript > Supabase", }).With("url", jsUrl) var results = []supabaseScanResult{} diff --git a/internal/scan/result.go b/internal/scan/result.go index 8006024..03f3c8b 100644 --- a/internal/scan/result.go +++ b/internal/scan/result.go @@ -16,6 +16,7 @@ package scan // These provide better type safety and allow method implementations. type ( HeaderResults []HeaderResult + SecurityHeaderResults []SecurityHeaderResult DirectoryResults []DirectoryResult CloudStorageResults []CloudStorageResult DorkResults []DorkResult @@ -23,7 +24,6 @@ type ( ) // ScanResult is the interface that all scan result types implement. -// This enables type-safe handling of heterogeneous scan results. type ScanResult interface { // ResultType returns the unique identifier for this result type. ResultType() string @@ -40,6 +40,7 @@ func (r *SecurityTrailsResult) ResultType() string { return "securitytrails" } // ResultType implementations for slice result types. func (r HeaderResults) ResultType() string { return "headers" } +func (r SecurityHeaderResults) ResultType() string { return "security_headers" } func (r DirectoryResults) ResultType() string { return "dirlist" } func (r CloudStorageResults) ResultType() string { return "cloudstorage" } func (r DorkResults) ResultType() string { return "dork" } @@ -53,6 +54,7 @@ var ( _ ScanResult = (*CMSResult)(nil) _ ScanResult = (*SecurityTrailsResult)(nil) _ ScanResult = HeaderResults(nil) + _ ScanResult = SecurityHeaderResults(nil) _ ScanResult = DirectoryResults(nil) _ ScanResult = CloudStorageResults(nil) _ ScanResult = DorkResults(nil) diff --git a/internal/scan/securityheaders.go b/internal/scan/securityheaders.go new file mode 100644 index 0000000..d66eb7e --- /dev/null +++ b/internal/scan/securityheaders.go @@ -0,0 +1,162 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +type SecurityHeaderResult struct { + Header string `json:"header"` + Present bool `json:"present"` + Value string `json:"value,omitempty"` + Severity string `json:"severity"` + Note string `json:"note"` +} + +type recommendedHeader struct { + name string + severity string +} + +var recommendedHeaders = []recommendedHeader{ + {"Strict-Transport-Security", "high"}, + {"Content-Security-Policy", "medium"}, + {"X-Frame-Options", "medium"}, + {"X-Content-Type-Options", "low"}, + {"Referrer-Policy", "low"}, + {"Permissions-Policy", "low"}, + {"Cross-Origin-Opener-Policy", "low"}, +} + +// headers that leak server/framework details when present. +var disclosureHeaders = []string{"Server", "X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version"} + +const hstsMinMaxAge = 31536000 // a year, in seconds + +func SecurityHeaders(url string, timeout time.Duration, logdir string) (SecurityHeaderResults, error) { + log := output.Module("SECHEADERS") + log.Start() + + sanitizedURL := strings.Split(url, "://")[1] + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "Security Header Analysis"); err != nil { + log.Error("Error creating log file: %v", err) + return nil, err + } + } + + client := &http.Client{ + Timeout: timeout, + } + + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + results := gradeSecurityHeaders(resp.Header, strings.HasPrefix(url, "https://")) + + for _, r := range results { + line := r.Header + " " + r.Note + log.Warn("%s [%s]", line, r.Severity) + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, line+" ["+r.Severity+"]\n") + } + } + + if len(results) == 0 { + log.Success("all recommended security headers present") + } + + log.Complete(len(results), "issues") + return results, nil +} + +func gradeSecurityHeaders(header http.Header, https bool) SecurityHeaderResults { + var results SecurityHeaderResults + + for _, h := range recommendedHeaders { + // hsts does nothing over plain http, so don't flag its absence there + if h.name == "Strict-Transport-Security" && !https { + continue + } + + value := header.Get(h.name) + switch { + case value == "": + results = append(results, SecurityHeaderResult{ + Header: h.name, + Severity: h.severity, + Note: "missing", + }) + case h.name == "Strict-Transport-Security" && hstsMaxAge(value) < hstsMinMaxAge: + results = append(results, SecurityHeaderResult{ + Header: h.name, + Present: true, + Value: value, + Severity: h.severity, + Note: "max-age too short", + }) + case h.name == "X-Content-Type-Options" && !strings.EqualFold(value, "nosniff"): + results = append(results, SecurityHeaderResult{ + Header: h.name, + Present: true, + Value: value, + Severity: "low", + Note: "should be nosniff", + }) + } + } + + for _, name := range disclosureHeaders { + if value := header.Get(name); value != "" { + results = append(results, SecurityHeaderResult{ + Header: name, + Present: true, + Value: value, + Severity: "low", + Note: "discloses " + value, + }) + } + } + + return results +} + +// hstsMaxAge returns the max-age seconds from an hsts value, or 0 if absent. +func hstsMaxAge(value string) int { + for _, part := range strings.Split(value, ";") { + if age, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(part)), "max-age="); ok { + n, err := strconv.Atoi(strings.TrimSpace(age)) + if err != nil { + return 0 + } + return n + } + } + return 0 +} diff --git a/internal/scan/securityheaders_test.go b/internal/scan/securityheaders_test.go new file mode 100644 index 0000000..60298a9 --- /dev/null +++ b/internal/scan/securityheaders_test.go @@ -0,0 +1,154 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func buildHeader(kv map[string]string) http.Header { + h := http.Header{} + for k, v := range kv { + h.Set(k, v) + } + return h +} + +func findFinding(results SecurityHeaderResults, name string) (SecurityHeaderResult, bool) { + for _, r := range results { + if r.Header == name { + return r, true + } + } + return SecurityHeaderResult{}, false +} + +func TestGradeSecurityHeaders_MissingOverHTTPS(t *testing.T) { + results := gradeSecurityHeaders(http.Header{}, true) + + for _, h := range recommendedHeaders { + f, ok := findFinding(results, h.name) + if !ok { + t.Errorf("expected %s to be flagged", h.name) + continue + } + if f.Present { + t.Errorf("%s should not be marked present", h.name) + } + if f.Severity != h.severity { + t.Errorf("%s severity = %q, want %q", h.name, f.Severity, h.severity) + } + } +} + +func TestGradeSecurityHeaders_HSTSSkippedOverHTTP(t *testing.T) { + results := gradeSecurityHeaders(http.Header{}, false) + if _, ok := findFinding(results, "Strict-Transport-Security"); ok { + t.Error("HSTS should only be graded for https targets") + } +} + +func TestGradeSecurityHeaders_AllPresent(t *testing.T) { + h := buildHeader(map[string]string{ + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "geolocation=()", + "Cross-Origin-Opener-Policy": "same-origin", + }) + + if results := gradeSecurityHeaders(h, true); len(results) != 0 { + t.Errorf("expected no findings, got %d: %+v", len(results), results) + } +} + +func TestGradeSecurityHeaders_ContentTypeNotNosniff(t *testing.T) { + h := buildHeader(map[string]string{ + "Strict-Transport-Security": "max-age=63072000", + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "sniff", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "geolocation=()", + "Cross-Origin-Opener-Policy": "same-origin", + }) + + f, ok := findFinding(gradeSecurityHeaders(h, true), "X-Content-Type-Options") + if !ok { + t.Fatal("expected X-Content-Type-Options to be flagged when not nosniff") + } + if !f.Present || f.Value != "sniff" { + t.Errorf("finding = %+v, want present with value sniff", f) + } +} + +func TestGradeSecurityHeaders_WeakHSTS(t *testing.T) { + // max-age=0 actively disables hsts, so a present header still has to be flagged + h := buildHeader(map[string]string{"Strict-Transport-Security": "max-age=0"}) + + f, ok := findFinding(gradeSecurityHeaders(h, true), "Strict-Transport-Security") + if !ok { + t.Fatal("expected a short-lived hsts header to be flagged") + } + if !f.Present || f.Severity != "high" { + t.Errorf("finding = %+v, want present high", f) + } +} + +func TestGradeSecurityHeaders_Disclosure(t *testing.T) { + h := buildHeader(map[string]string{ + "Server": "Apache/2.4.1 (Ubuntu)", + "X-Powered-By": "PHP/8.1.2", + }) + + results := gradeSecurityHeaders(h, false) + for _, name := range []string{"Server", "X-Powered-By"} { + f, ok := findFinding(results, name) + if !ok { + t.Errorf("expected disclosure finding for %s", name) + continue + } + if !f.Present || f.Severity != "low" { + t.Errorf("%s finding = %+v, want present low", name, f) + } + } +} + +func TestSecurityHeaders_LiveResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("Server", "nginx/1.25.3") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + results, err := SecurityHeaders(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("SecurityHeaders returned error: %v", err) + } + + if _, ok := findFinding(results, "X-Frame-Options"); ok { + t.Error("X-Frame-Options was set, should not be flagged") + } + if _, ok := findFinding(results, "Content-Security-Policy"); !ok { + t.Error("expected missing Content-Security-Policy to be flagged") + } + if _, ok := findFinding(results, "Server"); !ok { + t.Error("expected Server disclosure to be flagged") + } +} diff --git a/internal/scan/shodan.go b/internal/scan/shodan.go index f70cc21..c6fae53 100644 --- a/internal/scan/shodan.go +++ b/internal/scan/shodan.go @@ -73,7 +73,6 @@ type shodanHostResponse struct { } // shodanMetadata represents the _shodan field in Shodan API responses. -// This provides type safety instead of using map[string]interface{}. type shodanMetadata struct { Module string `json:"module"` Crawler string `json:"crawler,omitempty"` diff --git a/internal/scan/subdomaintakeover.go b/internal/scan/subdomaintakeover.go index f13631e..23569e7 100644 --- a/internal/scan/subdomaintakeover.go +++ b/internal/scan/subdomaintakeover.go @@ -36,20 +36,10 @@ type SubdomainTakeoverResult struct { Service string `json:"service,omitempty"` } -// SubdomainTakeover checks for potential subdomain takeover vulnerabilities. -// -// Parameters: -// - url: the target URL to scan -// - dnsResults: a slice of subdomains to check (typically from Dnslist function) -// - timeout: maximum duration for each subdomain check -// - threads: number of concurrent threads to use -// - logdir: directory to store log files (empty string for no logging) -// -// Returns: -// - []SubdomainTakeoverResult: a slice of results for each checked subdomain -// - error: any error encountered during the scan +// SubdomainTakeover checks dnsResults for dangling subdomains pointing at +// unclaimed third-party services. func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, threads int, logdir string) ([]SubdomainTakeoverResult, error) { - fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("Subdomain Takeover Vulnerability Check") + "...")) + fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Subdomain Takeover Vulnerability Check") + "...")) sanitizedURL := strings.Split(url, "://")[1] @@ -61,7 +51,7 @@ func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, t } subdomainlog := log.NewWithOptions(os.Stderr, log.Options{ - Prefix: "Subdomain Takeover 🔍", + Prefix: "Subdomain Takeover", }) client := &http.Client{ diff --git a/internal/worker/pool.go b/internal/worker/pool.go deleted file mode 100644 index edf730e..0000000 --- a/internal/worker/pool.go +++ /dev/null @@ -1,170 +0,0 @@ -/* -·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· -: : -: █▀ █ █▀▀ · Blazing-fast pentesting suite : -: ▄█ █ █▀ · BSD 3-Clause License : -: : -: (c) 2022-2026 vmfunc, xyzeva, : -: lunchcat alumni & contributors : -: : -·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· -*/ - -// Package worker provides a generic worker pool for concurrent task processing. -package worker - -import "sync" - -// Pool manages a pool of workers that process items concurrently. -// It uses channel-based distribution for efficient load balancing. -type Pool[T any, R any] struct { - workers int - fn func(T) R -} - -// New creates a new worker pool with the specified number of workers -// and a processing function. -func New[T any, R any](workers int, fn func(T) R) *Pool[T, R] { - if workers < 1 { - workers = 1 - } - return &Pool[T, R]{ - workers: workers, - fn: fn, - } -} - -// Run processes all items concurrently and returns the results. -// Items are distributed via a channel for optimal load balancing. -func (p *Pool[T, R]) Run(items []T) []R { - if len(items) == 0 { - return nil - } - - input := make(chan T, len(items)) - output := make(chan R, len(items)) - - var wg sync.WaitGroup - wg.Add(p.workers) - - // Start workers - for i := 0; i < p.workers; i++ { - go func() { - defer wg.Done() - for item := range input { - output <- p.fn(item) - } - }() - } - - // Feed items to workers - for _, item := range items { - input <- item - } - close(input) - - // Wait for all workers to finish, then close output - go func() { - wg.Wait() - close(output) - }() - - // Collect results - results := make([]R, 0, len(items)) - for r := range output { - results = append(results, r) - } - - return results -} - -// RunWithFilter processes items concurrently and returns only non-zero results. -// Useful when the processing function may return zero values for filtered items. -func (p *Pool[T, R]) RunWithFilter(items []T, filter func(R) bool) []R { - if len(items) == 0 { - return nil - } - - input := make(chan T, len(items)) - output := make(chan R, len(items)) - - var wg sync.WaitGroup - wg.Add(p.workers) - - // Start workers - for i := 0; i < p.workers; i++ { - go func() { - defer wg.Done() - for item := range input { - result := p.fn(item) - if filter(result) { - output <- result - } - } - }() - } - - // Feed items to workers - for _, item := range items { - input <- item - } - close(input) - - // Wait for all workers to finish, then close output - go func() { - wg.Wait() - close(output) - }() - - // Collect results - results := make([]R, 0, len(items)/2) // Estimate half will pass filter - for r := range output { - results = append(results, r) - } - - return results -} - -// ForEach processes items concurrently without collecting results. -// Useful for side-effect operations like logging or writing to external stores. -func (p *Pool[T, R]) ForEach(items []T, callback func(R)) { - if len(items) == 0 { - return - } - - input := make(chan T, len(items)) - output := make(chan R, len(items)) - - var wg sync.WaitGroup - wg.Add(p.workers) - - // Start workers - for i := 0; i < p.workers; i++ { - go func() { - defer wg.Done() - for item := range input { - output <- p.fn(item) - } - }() - } - - // Feed items to workers - for _, item := range items { - input <- item - } - close(input) - - // Process results as they come in - var outputWg sync.WaitGroup - outputWg.Add(1) - go func() { - defer outputWg.Done() - for r := range output { - callback(r) - } - }() - - wg.Wait() - close(output) - outputWg.Wait() -} diff --git a/internal/worker/pool_test.go b/internal/worker/pool_test.go deleted file mode 100644 index 9d01424..0000000 --- a/internal/worker/pool_test.go +++ /dev/null @@ -1,183 +0,0 @@ -/* -·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· -: : -: █▀ █ █▀▀ · Blazing-fast pentesting suite : -: ▄█ █ █▀ · BSD 3-Clause License : -: : -: (c) 2022-2026 vmfunc, xyzeva, : -: lunchcat alumni & contributors : -: : -·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· -*/ - -package worker - -import ( - "sort" - "sync/atomic" - "testing" -) - -func TestPoolRun(t *testing.T) { - pool := New(4, func(x int) int { - return x * 2 - }) - - items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} - results := pool.Run(items) - - if len(results) != len(items) { - t.Errorf("Expected %d results, got %d", len(items), len(results)) - } - - // Sort results since order is not guaranteed - sort.Ints(results) - expected := []int{2, 4, 6, 8, 10, 12, 14, 16, 18, 20} - for i, v := range results { - if v != expected[i] { - t.Errorf("Expected results[%d] = %d, got %d", i, expected[i], v) - } - } -} - -func TestPoolRunEmpty(t *testing.T) { - pool := New(4, func(x int) int { - return x * 2 - }) - - results := pool.Run(nil) - if results != nil { - t.Errorf("Expected nil for empty input, got %v", results) - } - - results = pool.Run([]int{}) - if results != nil { - t.Errorf("Expected nil for empty slice, got %v", results) - } -} - -func TestPoolRunWithFilter(t *testing.T) { - pool := New(4, func(x int) int { - return x * 2 - }) - - items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} - results := pool.RunWithFilter(items, func(r int) bool { - return r > 10 // Only keep results > 10 - }) - - // Should have 5 results: 12, 14, 16, 18, 20 - if len(results) != 5 { - t.Errorf("Expected 5 results, got %d", len(results)) - } - - sort.Ints(results) - expected := []int{12, 14, 16, 18, 20} - for i, v := range results { - if v != expected[i] { - t.Errorf("Expected results[%d] = %d, got %d", i, expected[i], v) - } - } -} - -func TestPoolForEach(t *testing.T) { - var sum atomic.Int64 - - pool := New(4, func(x int) int { - return x * 2 - }) - - items := []int{1, 2, 3, 4, 5} - pool.ForEach(items, func(r int) { - sum.Add(int64(r)) - }) - - // Sum should be 2+4+6+8+10 = 30 - if sum.Load() != 30 { - t.Errorf("Expected sum = 30, got %d", sum.Load()) - } -} - -func TestPoolSingleWorker(t *testing.T) { - pool := New(1, func(x int) int { - return x + 1 - }) - - items := []int{1, 2, 3} - results := pool.Run(items) - - if len(results) != 3 { - t.Errorf("Expected 3 results, got %d", len(results)) - } - - sort.Ints(results) - expected := []int{2, 3, 4} - for i, v := range results { - if v != expected[i] { - t.Errorf("Expected results[%d] = %d, got %d", i, expected[i], v) - } - } -} - -func TestPoolZeroWorkers(t *testing.T) { - // Zero workers should default to 1 - pool := New(0, func(x int) int { - return x - }) - - if pool.workers != 1 { - t.Errorf("Expected workers = 1, got %d", pool.workers) - } -} - -func TestPoolStringProcessing(t *testing.T) { - pool := New(2, func(s string) int { - return len(s) - }) - - items := []string{"a", "bb", "ccc", "dddd"} - results := pool.Run(items) - - sort.Ints(results) - expected := []int{1, 2, 3, 4} - for i, v := range results { - if v != expected[i] { - t.Errorf("Expected results[%d] = %d, got %d", i, expected[i], v) - } - } -} - -func TestPoolStructProcessing(t *testing.T) { - type input struct { - a int - b int - } - type output struct { - sum int - prod int - } - - pool := New(3, func(in input) output { - return output{sum: in.a + in.b, prod: in.a * in.b} - }) - - items := []input{{1, 2}, {3, 4}, {5, 6}} - results := pool.Run(items) - - if len(results) != 3 { - t.Errorf("Expected 3 results, got %d", len(results)) - } - - // Verify all expected outputs are present - found := make(map[output]bool) - for _, r := range results { - found[r] = true - } - - expectedOutputs := []output{{3, 2}, {7, 12}, {11, 30}} - for _, exp := range expectedOutputs { - if !found[exp] { - t.Errorf("Expected output %v not found in results", exp) - } - } -} diff --git a/sif.go b/sif.go index 0d2bea5..e16a58f 100644 --- a/sif.go +++ b/sif.go @@ -321,6 +321,16 @@ func (app *App) Run() error { } } + 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 {