diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index fd8c095..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7920c0d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: Sentinel CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.21', '1.22', '1.23'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Coverage report + run: go tool cover -func=coverage.out + + build: + name: Build + runs-on: ubuntu-latest + needs: test + + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + BINARY_NAME="sentinel" + if [ "${{ matrix.goos }}" = "windows" ]; then + BINARY_NAME="sentinel.exe" + fi + go build -ldflags="-s -w" -o "dist/${BINARY_NAME}" ./cmd/sentinel + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sentinel-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/ + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run go vet + run: go vet ./... + + - name: Check formatting + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "Files not formatted:" + gofmt -s -l . + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c392c8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Binary +sentinel +sentinel.exe +dist/ + +# Reports (keep examples, ignore generated) +*.html +!templates/*.html + +# OS files +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Go +vendor/ + +# Sensitive +.env +*.key +*.pem diff --git a/README.md b/README.md index 54167a5..eabd1d5 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,201 @@ -# Sentinel - Advanced Blackbox Security Scanner +# Sentinel v2.0 - Advanced Blackbox Security Scanner -Sentinel is a modular, high-performance security scanning framework written in Go. It is designed for authorized security assessments and penetration testing. +Sentinel is a modular, high-performance security scanning framework written in Go. Designed for authorized security assessments and penetration testing. ## Features -- **Reconnaissance**: Passive subdomain enumeration using Certificate Transparency logs (crt.sh). -- **Vulnerability Scanning**: - - Missing Security Headers (X-Frame-Options, CSP, etc.) - - Information Disclosure (Server headers) - - Sensitive File Detection (/.git, /.env, /robots.txt, etc.) -- **Reporting**: JSON output for easy integration with other tools. -- **Concurrency**: Fast parallel scanning. +### Reconnaissance +- Passive subdomain enumeration via Certificate Transparency logs (crt.sh) +- DNS bruteforce with 100+ common subdomain wordlist +- DNS resolution verification (filter dead subdomains) -## Usage +### Vulnerability Scanning +- **Security Headers** - 9 headers checked (CSP, HSTS, X-Frame-Options, Permissions-Policy, etc.) +- **Sensitive Files** - 35+ paths tested (.git, .env, backups, admin panels, API docs, Docker files, AWS creds, etc.) +- **TLS/SSL** - Version check (TLS 1.0/1.1 deprecated), certificate expiry, self-signed detection +- **CORS** - Misconfiguration detection (wildcard, reflected origin, credentials leak) +- **HTTP Methods** - Dangerous methods detection (PUT, DELETE, TRACE, etc.) +- **Info Disclosure** - Server, X-Powered-By, ASP.NET version headers -Build the tool: +### Reporting +- **JSON** structured report with CVSS scores and remediation advice +- **HTML** professional dark-themed report with severity charts and risk scoring +- Risk score calculation (0-10) with severity-weighted algorithm + +### Performance & Safety +- Configurable concurrency (goroutines + semaphore) +- Rate limiting to avoid target overload/ban +- Proxy support (Burp Suite, ZAP, SOCKS) +- Custom User-Agent +- Configurable timeouts + +## Quick Start + +### Build ```bash go build -o sentinel ./cmd/sentinel ``` -Run a scan: +### Basic Scan +```bash +./sentinel -target example.com +``` + +### Full Scan (all checks) ```bash ./sentinel -target example.com -full ``` -Options: -- `-target`: Target domain (required) -- `-full`: Enable full scan (includes sensitive file checks) -- `-concurrency`: Number of concurrent workers (default: 10) -- `-output`: Output file (default: report.json) +### With Config File +```bash +./sentinel -config sentinel.yml +``` + +## CLI Options + +| Flag | Default | Description | +|------|---------|-------------| +| `-target` | *(required)* | Target domain | +| `-full` | `false` | Enable full scan (sensitive files, all checks) | +| `-output` | `report.json` | Output file path | +| `-concurrency` | `10` | Number of concurrent workers (max 50) | +| `-rate` | `100` | Rate limit in ms between requests | +| `-timeout` | `10` | HTTP timeout in seconds | +| `-proxy` | - | HTTP proxy URL (e.g., `http://127.0.0.1:8080`) | +| `-ua` | `Sentinel/2.0` | Custom User-Agent string | +| `-config` | - | Path to YAML config file | +| `-verbose` | `false` | Enable debug output | +| `-no-html` | `false` | Disable HTML report generation | +| `-no-dns-brute` | `false` | Disable DNS bruteforce | +| `-no-resolve` | `false` | Don't filter unresolved subdomains | + +## Configuration File + +Create a `sentinel.yml` for reusable configs: + +```yaml +target: "example.com" +output: "report.json" +concurrency: 10 +full_scan: true +rate_limit_ms: 100 +timeout_s: 10 +report_html: true + +recon: + crtsh: true + dns_bruteforce: true + resolve_only: true + +scan: + headers: true + sensitive_files: true + tls: true + cors: true + http_methods: true + server_info: true +``` ## Architecture -- `cmd/sentinel`: CLI entry point. -- `pkg/recon`: Reconnaissance modules (Subdomain enumeration). -- `pkg/scan`: Vulnerability detection logic. -- `pkg/report`: Reporting handling. +``` +sentinel/ +├── cmd/sentinel/ # CLI entry point +│ └── main.go +├── pkg/ +│ ├── config/ # YAML config loader + validation +│ │ ├── config.go +│ │ └── config_test.go +│ ├── recon/ # Reconnaissance modules +│ │ └── subdomain.go # crt.sh + DNS bruteforce + resolution +│ ├── scan/ # Vulnerability detection +│ │ ├── scan.go # Headers, TLS, CORS, methods, files +│ │ └── scan_test.go +│ ├── report/ # Report generation +│ │ ├── report.go # JSON + HTML reports with CVSS +│ │ └── report_test.go +│ └── logger/ # Colored logging with severity levels +│ └── logger.go +├── .github/workflows/ # CI/CD (test, build, lint) +│ └── ci.yml +├── sentinel.yml # Example config +├── .gitignore +└── go.mod +``` + +## Running Tests + +```bash +go test -v ./... +``` + +With coverage: +```bash +go test -race -coverprofile=coverage.out ./... +go tool cover -func=coverage.out +``` + +## CI/CD + +GitHub Actions pipeline included: +- **Test** - Runs on Go 1.21/1.22/1.23 with race detection +- **Build** - Cross-compiles for Linux/macOS/Windows (amd64 + arm64) +- **Lint** - `go vet` + `gofmt` checks ## Disclaimer -This tool is for educational and authorized testing purposes only. Do not use on systems you do not have permission to test. +This tool is for **educational and authorized testing purposes only**. Do not use on systems you do not have explicit permission to test. Unauthorized scanning may violate laws and regulations. + +--- + +## Changelog + +### v2.0.0 - Major Upgrade + +> Complete rewrite of the scanning engine, reporting system, and CLI interface. + +#### Reconnaissance +| Change | Detail | +|--------|--------| +| **Added** DNS bruteforce | 100+ common subdomain prefixes (`admin`, `api`, `staging`, `vpn`, `ci`, etc.) | +| **Added** DNS resolution | Filters out non-resolving subdomains before scanning | +| **Improved** crt.sh parser | Better handling of wildcard and multi-line entries | + +#### Vulnerability Scanning +| Change | Detail | +|--------|--------| +| **Added** TLS/SSL analysis | Protocol version check (TLS 1.0/1.1 flagged), certificate expiry, self-signed detection | +| **Added** CORS misconfiguration | Wildcard origin, reflected origin, credentials leak detection | +| **Added** HTTP methods audit | Detects dangerous methods (PUT, DELETE, TRACE) via OPTIONS and direct probing | +| **Added** Extended info disclosure | `X-Powered-By`, `X-AspNet-Version`, `X-AspNetMvc-Version` headers | +| **Expanded** Security headers | 3 → 9 headers (added HSTS, Permissions-Policy, Referrer-Policy, COOP, CORP) | +| **Expanded** Sensitive files | 4 → 35+ paths (Docker, AWS, Spring Boot, GraphQL, Swagger, backups, CI configs) | +| **Added** CVSS scoring | Each finding carries a CVSS v3.1 base score | +| **Added** Remediation guidance | Actionable fix suggestions for every finding | + +#### Reporting +| Change | Detail | +|--------|--------| +| **Added** HTML report | Professional dark-themed report with severity breakdown, risk gauge, and per-finding remediation | +| **Added** Risk scoring | Weighted 0-10 score with severity levels (Safe, Low, Medium, High, Critical) | +| **Improved** JSON structure | Full report metadata (target, date, duration, summary stats, risk score) | + +#### CLI & Configuration +| Change | Detail | +|--------|--------| +| **Added** YAML config file | Reusable scan profiles via `sentinel.yml` | +| **Added** Proxy support | Route traffic through Burp Suite, ZAP, or any HTTP proxy | +| **Added** Custom User-Agent | Configurable UA string to avoid fingerprinting | +| **Added** Rate limiting | Configurable delay between requests (default: 100ms) | +| **Added** Verbose mode | Debug-level output for troubleshooting | +| **Added** Granular flags | `--no-html`, `--no-dns-brute`, `--no-resolve` for fine-grained control | +| **Improved** CLI output | ASCII banner, colored severity levels, progress tracking, phased execution display | + +#### Engineering +| Change | Detail | +|--------|--------| +| **Added** Unit tests | 19 tests across `config`, `scan`, and `report` packages | +| **Added** CI/CD pipeline | GitHub Actions: test (Go 1.21–1.23), cross-compile build, lint | +| **Added** Logger package | Structured colored logging with severity levels (Info, Warn, Error, Debug, Phase) | +| **Added** `.gitignore` | Proper ignore rules for binaries, reports, and IDE files | +| **Improved** Go modules | Explicit dependencies (`yaml.v3`, `fatih/color`) with `go mod tidy` | diff --git a/cmd/sentinel/main.go b/cmd/sentinel/main.go index 0f5f52a..a0f13bc 100644 --- a/cmd/sentinel/main.go +++ b/cmd/sentinel/main.go @@ -1,64 +1,190 @@ package main import ( + "crypto/tls" "flag" "fmt" - "log" + "net/http" + "net/url" "os" + "time" + + "sentinel/pkg/config" + "sentinel/pkg/logger" "sentinel/pkg/recon" - "sentinel/pkg/scan" "sentinel/pkg/report" + "sentinel/pkg/scan" ) func main() { + // CLI flags target := flag.String("target", "", "Target domain (e.g., example.com)") output := flag.String("output", "report.json", "Output file for the report") concurrency := flag.Int("concurrency", 10, "Number of concurrent workers") - fullScan := flag.Bool("full", false, "Enable full scan (Crawling + Vuln checks)") + fullScan := flag.Bool("full", false, "Enable full scan (sensitive files, all checks)") + configFile := flag.String("config", "", "Path to YAML config file") + verbose := flag.Bool("verbose", false, "Enable verbose/debug output") + rateLimit := flag.Int("rate", 100, "Rate limit in ms between requests") + timeout := flag.Int("timeout", 10, "HTTP timeout in seconds") + proxy := flag.String("proxy", "", "HTTP proxy URL (e.g., http://127.0.0.1:8080)") + noHTML := flag.Bool("no-html", false, "Disable HTML report generation") + noDNSBrute := flag.Bool("no-dns-brute", false, "Disable DNS bruteforce") + noResolve := flag.Bool("no-resolve", false, "Don't filter unresolved subdomains") + userAgent := flag.String("ua", "Sentinel/2.0 Security Scanner", "Custom User-Agent string") flag.Parse() - if *target == "" { - fmt.Println("Usage: sentinel -target [options]") + // Load config + var cfg *config.Config + if *configFile != "" { + var err error + cfg, err = config.LoadFromFile(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } + } else { + cfg = config.DefaultConfig() + } + + // CLI flags override config file + if *target != "" { + cfg.Target = *target + } + if *output != "report.json" || cfg.Output == "" { + cfg.Output = *output + } + cfg.Concurrency = *concurrency + cfg.FullScan = *fullScan + cfg.Verbose = *verbose + cfg.RateLimit = *rateLimit + cfg.Timeout = *timeout + cfg.Proxy = *proxy + cfg.ReportHTML = !*noHTML + cfg.UserAgent = *userAgent + cfg.Recon.DNSBrute = !*noDNSBrute + cfg.Recon.ResolveOnly = !*noResolve + + // Validate + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Println("\nUsage: sentinel -target [options]") flag.PrintDefaults() os.Exit(1) } - fmt.Printf("[+] Starting Sentinel scan against %s\n", *target) + // Setup logger + logger.Verbose = cfg.Verbose - // 1. Reconnaissance - fmt.Println("[*] Phase 1: Reconnaissance (Subdomain Enumeration)") - subdomains, err := recon.EnumerateSubdomains(*target) - if err != nil { - log.Fatalf("[-] Recon failed: %v", err) + // Print banner + logger.Banner() + + startTime := time.Now() + logger.Info("Target: %s", cfg.Target) + logger.Info("Concurrency: %d | Rate Limit: %dms | Timeout: %ds", cfg.Concurrency, cfg.RateLimit, cfg.Timeout) + if cfg.FullScan { + logger.Info("Mode: FULL SCAN (all checks enabled)") + } else { + logger.Info("Mode: QUICK SCAN (use -full for complete scan)") } - fmt.Printf("[+] Found %d subdomains\n", len(subdomains)) - for _, sub := range subdomains { - fmt.Printf(" - %s\n", sub) + + // Build HTTP client + client := buildHTTPClient(cfg) + + // ═══════════════════════════════════════════ + // Phase 1: Reconnaissance + // ═══════════════════════════════════════════ + logger.Phase(1, "Reconnaissance (Subdomain Enumeration)") + + subdomains, err := recon.EnumerateSubdomains(cfg.Target, cfg.Recon.DNSBrute, cfg.Recon.ResolveOnly, client) + if err != nil { + logger.Error("Recon failed: %v", err) + os.Exit(1) } - // Add target itself to scan list if not present - foundTarget := false + aliveCount := 0 + var targetList []string for _, sub := range subdomains { - if sub == *target { - foundTarget = true - break + if sub.Alive { + aliveCount++ } + targetList = append(targetList, sub.Name) + logger.Debug(" [%s] %s (alive=%v)", sub.Source, sub.Name, sub.Alive) + } + logger.Info("Found %d subdomains (%d alive)", len(subdomains), aliveCount) + + // ═══════════════════════════════════════════ + // Phase 2: Vulnerability Scanning + // ═══════════════════════════════════════════ + logger.Phase(2, "Vulnerability Scanning") + + vulns := scan.RunScan(targetList, cfg, client) + + // Print findings with colors + fmt.Println() + for _, v := range vulns { + logger.Result(v.Severity, v.URL, v.Description) } - if !foundTarget { - subdomains = append(subdomains, *target) + + // ═══════════════════════════════════════════ + // Phase 3: Report Generation + // ═══════════════════════════════════════════ + logger.Phase(3, "Report Generation") + + if err := report.Generate(vulns, cfg.Target, cfg.Output, startTime, cfg.ReportHTML); err != nil { + logger.Error("Failed to generate report: %v", err) + os.Exit(1) } - // 2. Scanning - fmt.Println("[*] Phase 2: Vulnerability Scanning") - vulns := scan.RunScan(subdomains, *concurrency, *fullScan) - fmt.Printf("[+] Scan complete. Found %d potential issues.\n", len(vulns)) + logger.Info("Scan completed in %s", time.Since(startTime).Round(time.Second)) +} - // 3. Reporting - fmt.Println("[*] Phase 3: Generating Report") - if err := report.Generate(vulns, *output); err != nil { - log.Printf("[-] Failed to generate report: %v", err) - } else { - fmt.Printf("[+] Report saved to %s\n", *output) +// buildHTTPClient creates an HTTP client with proxy, timeout, and TLS config +func buildHTTPClient(cfg *config.Config) *http.Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + MaxIdleConns: 100, + IdleConnTimeout: 30 * time.Second, + } + + // Proxy support + if cfg.Proxy != "" { + proxyURL, err := url.Parse(cfg.Proxy) + if err != nil { + logger.Warn("Invalid proxy URL: %v (continuing without proxy)", err) + } else { + transport.Proxy = http.ProxyURL(proxyURL) + logger.Info("Using proxy: %s", cfg.Proxy) + } + } + + client := &http.Client{ + Timeout: time.Duration(cfg.Timeout) * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + // Set User-Agent via a custom RoundTripper wrapper + client.Transport = &uaTransport{ + inner: transport, + userAgent: cfg.UserAgent, } + + return client +} + +// uaTransport wraps http.RoundTripper to inject User-Agent +type uaTransport struct { + inner http.RoundTripper + userAgent string +} + +func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", t.userAgent) + return t.inner.RoundTrip(req) } diff --git a/go.mod b/go.mod index 3fad530..4542f7a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,12 @@ module sentinel -go 1.25.0 +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 + +require ( + github.com/fatih/color v1.16.0 + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d6e3f4f --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..9bdb468 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config holds the full Sentinel configuration +type Config struct { + Target string `yaml:"target"` + Output string `yaml:"output"` + Concurrency int `yaml:"concurrency"` + FullScan bool `yaml:"full_scan"` + RateLimit int `yaml:"rate_limit_ms"` // milliseconds between requests + Timeout int `yaml:"timeout_s"` // HTTP timeout in seconds + Proxy string `yaml:"proxy"` + UserAgent string `yaml:"user_agent"` + Verbose bool `yaml:"verbose"` + ReportHTML bool `yaml:"report_html"` + Recon ReconConfig `yaml:"recon"` + Scan ScanConfig `yaml:"scan"` +} + +type ReconConfig struct { + CrtSh bool `yaml:"crtsh"` + DNSBrute bool `yaml:"dns_bruteforce"` + ResolveOnly bool `yaml:"resolve_only"` // only keep subdomains that resolve +} + +type ScanConfig struct { + Headers bool `yaml:"headers"` + SensitiveFiles bool `yaml:"sensitive_files"` + TLS bool `yaml:"tls"` + CORS bool `yaml:"cors"` + HTTPMethods bool `yaml:"http_methods"` + ServerInfo bool `yaml:"server_info"` +} + +// DefaultConfig returns sensible defaults +func DefaultConfig() *Config { + return &Config{ + Output: "report.json", + Concurrency: 10, + RateLimit: 100, + Timeout: 10, + UserAgent: "Sentinel/2.0 Security Scanner", + Verbose: false, + ReportHTML: true, + Recon: ReconConfig{ + CrtSh: true, + DNSBrute: true, + ResolveOnly: true, + }, + Scan: ScanConfig{ + Headers: true, + SensitiveFiles: true, + TLS: true, + CORS: true, + HTTPMethods: true, + ServerInfo: true, + }, + } +} + +// LoadFromFile loads config from a YAML file, merging with defaults +func LoadFromFile(path string) (*Config, error) { + cfg := DefaultConfig() + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %v", err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config YAML: %v", err) + } + + return cfg, nil +} + +// Validate checks that the config is valid +func (c *Config) Validate() error { + if c.Target == "" { + return fmt.Errorf("target domain is required") + } + if c.Concurrency < 1 { + c.Concurrency = 1 + } + if c.Concurrency > 50 { + c.Concurrency = 50 + } + if c.Timeout < 1 { + c.Timeout = 10 + } + if c.RateLimit < 0 { + c.RateLimit = 0 + } + return nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..c50dd92 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "os" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Concurrency != 10 { + t.Errorf("expected concurrency 10, got %d", cfg.Concurrency) + } + if cfg.Timeout != 10 { + t.Errorf("expected timeout 10, got %d", cfg.Timeout) + } + if cfg.Output != "report.json" { + t.Errorf("expected output report.json, got %s", cfg.Output) + } + if !cfg.Recon.CrtSh { + t.Error("expected crtsh to be enabled by default") + } + if !cfg.Scan.Headers { + t.Error("expected headers scan to be enabled by default") + } + if !cfg.Scan.TLS { + t.Error("expected TLS scan to be enabled by default") + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr bool + }{ + { + name: "empty target", + cfg: &Config{}, + wantErr: true, + }, + { + name: "valid config", + cfg: &Config{Target: "example.com", Concurrency: 5, Timeout: 10}, + wantErr: false, + }, + { + name: "negative concurrency gets corrected", + cfg: &Config{Target: "example.com", Concurrency: -1}, + wantErr: false, + }, + { + name: "excessive concurrency gets capped", + cfg: &Config{Target: "example.com", Concurrency: 100}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateConcurrencyCap(t *testing.T) { + cfg := &Config{Target: "example.com", Concurrency: 100} + _ = cfg.Validate() + if cfg.Concurrency != 50 { + t.Errorf("expected concurrency capped at 50, got %d", cfg.Concurrency) + } + + cfg2 := &Config{Target: "example.com", Concurrency: -5} + _ = cfg2.Validate() + if cfg2.Concurrency != 1 { + t.Errorf("expected concurrency minimum 1, got %d", cfg2.Concurrency) + } +} + +func TestLoadFromFile(t *testing.T) { + content := ` +target: test.example.com +output: test-report.json +concurrency: 5 +full_scan: true +rate_limit_ms: 200 +timeout_s: 15 +verbose: true +recon: + crtsh: true + dns_bruteforce: false +scan: + headers: true + tls: false +` + tmpFile, err := os.CreateTemp("", "sentinel-config-*.yml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write([]byte(content)); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + tmpFile.Close() + + cfg, err := LoadFromFile(tmpFile.Name()) + if err != nil { + t.Fatalf("LoadFromFile() error: %v", err) + } + + if cfg.Target != "test.example.com" { + t.Errorf("expected target test.example.com, got %s", cfg.Target) + } + if cfg.Concurrency != 5 { + t.Errorf("expected concurrency 5, got %d", cfg.Concurrency) + } + if !cfg.FullScan { + t.Error("expected full_scan true") + } + if cfg.RateLimit != 200 { + t.Errorf("expected rate_limit 200, got %d", cfg.RateLimit) + } + if !cfg.Recon.CrtSh { + t.Error("expected crtsh true") + } + if cfg.Recon.DNSBrute { + t.Error("expected dns_bruteforce false") + } + if cfg.Scan.TLS { + t.Error("expected tls false") + } +} + +func TestLoadFromFileNotFound(t *testing.T) { + _, err := LoadFromFile("/nonexistent/config.yml") + if err == nil { + t.Error("expected error for nonexistent file") + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..bc6c74a --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,89 @@ +package logger + +import ( + "fmt" + "time" + + "github.com/fatih/color" +) + +var Verbose bool + +var ( + green = color.New(color.FgGreen).SprintFunc() + red = color.New(color.FgRed).SprintFunc() + yellow = color.New(color.FgYellow).SprintFunc() + cyan = color.New(color.FgCyan).SprintFunc() + magenta = color.New(color.FgMagenta).SprintFunc() + bold = color.New(color.Bold).SprintFunc() +) + +func timestamp() string { + return time.Now().Format("15:04:05") +} + +// Info logs informational messages +func Info(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s %s\n", cyan("["+timestamp()+"]"), green("[+]"), msg) +} + +// Warn logs warning messages +func Warn(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s %s\n", cyan("["+timestamp()+"]"), yellow("[!]"), msg) +} + +// Error logs error messages +func Error(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s %s\n", cyan("["+timestamp()+"]"), red("[-]"), msg) +} + +// Phase logs a scan phase header +func Phase(phase int, name string) { + fmt.Printf("\n%s %s %s\n", cyan("["+timestamp()+"]"), magenta(fmt.Sprintf("[Phase %d]", phase)), bold(name)) + fmt.Println("─────────────────────────────────────────────") +} + +// Debug logs only when Verbose is enabled +func Debug(format string, args ...interface{}) { + if !Verbose { + return + } + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s %s\n", cyan("["+timestamp()+"]"), yellow("[DBG]"), msg) +} + +// Result logs a finding with severity coloring +func Result(severity, url, desc string) { + var sevColor func(a ...interface{}) string + switch severity { + case "Critical": + sevColor = color.New(color.FgRed, color.Bold).SprintFunc() + case "High": + sevColor = red + case "Medium": + sevColor = yellow + case "Low": + sevColor = cyan + default: + sevColor = color.New(color.FgWhite).SprintFunc() + } + fmt.Printf(" %s %-50s %s\n", sevColor(fmt.Sprintf("%-8s", severity)), url, desc) +} + +// Banner prints the Sentinel ASCII banner +func Banner() { + banner := ` + ____ _ _ _ + / ___| ___ _ __ | |_(_)_ __ ___| | + \___ \ / _ \ '_ \| __| | '_ \ / _ \ | + ___) | __/ | | | |_| | | | | __/ | + |____/ \___|_| |_|\__|_|_| |_|\___|_| + v2.0 + Advanced Blackbox Security Scanner + ───────────────────────────────────── +` + fmt.Print(magenta(banner)) +} diff --git a/pkg/recon/subdomain.go b/pkg/recon/subdomain.go index 2e2bb74..e538317 100644 --- a/pkg/recon/subdomain.go +++ b/pkg/recon/subdomain.go @@ -1,56 +1,169 @@ package recon import ( + "context" "encoding/json" "fmt" "io" + "net" "net/http" "strings" + "sync" + "time" + + "sentinel/pkg/logger" ) -// CrtShEntry represents a certificate transparency entry from crt.sh +// CrtShEntry represents a certificate transparency entry type CrtShEntry struct { NameValue string `json:"name_value"` } -// EnumerateSubdomains queries public sources (crt.sh) for subdomains -func EnumerateSubdomains(domain string) ([]string, error) { - fmt.Printf("[*] Querying crt.sh for %s...\n", domain) +// SubdomainResult holds a subdomain with its resolution info +type SubdomainResult struct { + Name string `json:"name"` + IPs []string `json:"ips,omitempty"` + Alive bool `json:"alive"` + Source string `json:"source"` +} + +// commonSubdomains is a wordlist for DNS bruteforce +var commonSubdomains = []string{ + "www", "mail", "ftp", "smtp", "pop", "imap", "webmail", + "admin", "portal", "api", "dev", "staging", "test", "beta", + "app", "apps", "cdn", "cloud", "ns1", "ns2", "ns3", + "mx", "mx1", "mx2", "vpn", "remote", "gateway", + "blog", "shop", "store", "docs", "wiki", "forum", + "git", "gitlab", "github", "ci", "jenkins", "deploy", + "monitor", "grafana", "kibana", "elastic", "prometheus", + "db", "database", "mysql", "postgres", "redis", "mongo", + "backup", "bak", "old", "new", "v2", "v3", + "internal", "intranet", "extranet", "corp", "office", + "auth", "sso", "login", "oauth", "id", "identity", + "s3", "storage", "assets", "static", "media", "images", + "status", "health", "metrics", "logs", + "demo", "sandbox", "preview", "stage", "uat", "qa", + "support", "help", "helpdesk", "ticket", + "cpanel", "whm", "plesk", "panel", + "m", "mobile", "wap", + "pay", "payment", "billing", "checkout", + "crm", "erp", "hr", +} + +// EnumerateSubdomains performs passive + active subdomain enumeration +func EnumerateSubdomains(domain string, dnsbrute bool, resolveOnly bool, client *http.Client) ([]SubdomainResult, error) { + subdomainMap := make(map[string]string) // name -> source + var mu sync.Mutex + + // Phase 1: crt.sh passive enumeration + logger.Info("Querying crt.sh for %s...", domain) + crtResults, err := queryCrtSh(domain, client) + if err != nil { + logger.Warn("crt.sh failed: %v (continuing...)", err) + } else { + mu.Lock() + for _, sub := range crtResults { + subdomainMap[sub] = "crt.sh" + } + mu.Unlock() + logger.Info("crt.sh returned %d unique subdomains", len(crtResults)) + } + + // Phase 2: DNS bruteforce + if dnsbrute { + logger.Info("Starting DNS bruteforce (%d words)...", len(commonSubdomains)) + bruteResults := dnsBruteforce(domain) + mu.Lock() + for _, sub := range bruteResults { + if _, exists := subdomainMap[sub]; !exists { + subdomainMap[sub] = "bruteforce" + } + } + mu.Unlock() + logger.Info("Bruteforce found %d additional subdomains", len(bruteResults)) + } + + // Ensure the target itself is included + if _, exists := subdomainMap[domain]; !exists { + subdomainMap[domain] = "target" + } + + // Phase 3: DNS resolution + logger.Info("Resolving %d subdomains...", len(subdomainMap)) + var results []SubdomainResult + var wg sync.WaitGroup + sem := make(chan struct{}, 20) // limit concurrent DNS lookups + + for name, source := range subdomainMap { + wg.Add(1) + sem <- struct{}{} + go func(n, s string) { + defer wg.Done() + defer func() { <-sem }() + + ips, err := net.LookupHost(n) + alive := err == nil && len(ips) > 0 + + if resolveOnly && !alive { + return + } + + mu.Lock() + results = append(results, SubdomainResult{ + Name: n, + IPs: ips, + Alive: alive, + Source: s, + }) + mu.Unlock() + + if alive { + logger.Debug(" ✓ %s → %s", n, strings.Join(ips, ", ")) + } + }(name, source) + } + + wg.Wait() + return results, nil +} + +// queryCrtSh queries certificate transparency logs +func queryCrtSh(domain string, client *http.Client) ([]string, error) { url := fmt.Sprintf("https://crt.sh/?q=%%25.%s&output=json", domain) - resp, err := http.Get(url) + resp, err := client.Get(url) if err != nil { - return nil, fmt.Errorf("crt.sh request failed: %v", err) + return nil, fmt.Errorf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("crt.sh returned status: %d", resp.StatusCode) + return nil, fmt.Errorf("returned status: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read crt.sh response: %v", err) + return nil, fmt.Errorf("failed to read response: %v", err) } - // Sometimes crt.sh returns HTML error pages instead of JSON on overload if strings.Contains(string(body), "") { - return nil, fmt.Errorf("crt.sh returned HTML instead of JSON (likely overloaded)") + return nil, fmt.Errorf("returned HTML instead of JSON (likely overloaded)") } var entries []CrtShEntry if err := json.Unmarshal(body, &entries); err != nil { - return nil, fmt.Errorf("failed to parse crt.sh JSON: %v", err) + return nil, fmt.Errorf("failed to parse JSON: %v", err) } subdomains := make(map[string]struct{}) for _, entry := range entries { - // Clean up wildcard entries like *.example.com name := strings.ReplaceAll(entry.NameValue, "*.", "") - // Split multi-line entries names := strings.Split(name, "\n") for _, n := range names { - subdomains[n] = struct{}{} + n = strings.TrimSpace(n) + if n != "" { + subdomains[n] = struct{}{} + } } } @@ -58,6 +171,41 @@ func EnumerateSubdomains(domain string) ([]string, error) { for sub := range subdomains { result = append(result, sub) } - return result, nil } + +// dnsBruteforce tries common subdomain names +func dnsBruteforce(domain string) []string { + var found []string + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 30) + + for _, prefix := range commonSubdomains { + wg.Add(1) + sem <- struct{}{} + go func(p string) { + defer wg.Done() + defer func() { <-sem }() + + candidate := fmt.Sprintf("%s.%s", p, domain) + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 3 * time.Second} + return d.DialContext(ctx, network, "8.8.8.8:53") + }, + } + + _, err := resolver.LookupHost(context.Background(), candidate) + if err == nil { + mu.Lock() + found = append(found, candidate) + mu.Unlock() + } + }(prefix) + } + + wg.Wait() + return found +} diff --git a/pkg/report/report.go b/pkg/report/report.go index 67d7a87..c9840ff 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -3,12 +3,127 @@ package report import ( "encoding/json" "fmt" + "html/template" "os" - "sentinel/pkg/scan" // Import the scan package to access ScanResult + "sort" + "strings" + "time" + + "sentinel/pkg/logger" + "sentinel/pkg/scan" ) -// Generate creates a JSON report from scan results -func Generate(results []scan.ScanResult, filename string) error { +// Report holds the full scan report data +type Report struct { + Target string `json:"target"` + ScanDate string `json:"scan_date"` + Duration string `json:"duration"` + TotalIssues int `json:"total_issues"` + RiskScore float64 `json:"risk_score"` + RiskLevel string `json:"risk_level"` + Summary SeveritySummary `json:"summary"` + Results []scan.ScanResult `json:"results"` +} + +// SeveritySummary holds counts per severity +type SeveritySummary struct { + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Info int `json:"info"` +} + +// Generate creates JSON and optional HTML reports +func Generate(results []scan.ScanResult, target, filename string, startTime time.Time, generateHTML bool) error { + // Sort results by severity + severityOrder := map[string]int{"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4} + sort.Slice(results, func(i, j int) bool { + return severityOrder[results[i].Severity] < severityOrder[results[j].Severity] + }) + + // Build summary + summary := SeveritySummary{} + var totalCVSS float64 + for _, r := range results { + switch r.Severity { + case "Critical": + summary.Critical++ + case "High": + summary.High++ + case "Medium": + summary.Medium++ + case "Low": + summary.Low++ + case "Info": + summary.Info++ + } + totalCVSS += r.CVSSScore + } + + // Calculate risk score (weighted average) + riskScore := 0.0 + if len(results) > 0 { + weighted := float64(summary.Critical)*10 + float64(summary.High)*7.5 + float64(summary.Medium)*5 + float64(summary.Low)*2.5 + float64(summary.Info)*0.5 + riskScore = weighted / float64(len(results)) + if riskScore > 10 { + riskScore = 10 + } + } + + riskLevel := "Safe" + switch { + case riskScore >= 8: + riskLevel = "Critical" + case riskScore >= 6: + riskLevel = "High" + case riskScore >= 4: + riskLevel = "Medium" + case riskScore >= 2: + riskLevel = "Low" + } + + report := Report{ + Target: target, + ScanDate: time.Now().Format("2006-01-02 15:04:05"), + Duration: time.Since(startTime).Round(time.Second).String(), + TotalIssues: len(results), + RiskScore: riskScore, + RiskLevel: riskLevel, + Summary: summary, + Results: results, + } + + // Print summary to console + logger.Info("═══════════════════════════════════════") + logger.Info("SCAN SUMMARY for %s", target) + logger.Info("═══════════════════════════════════════") + logger.Info("Total Issues: %d", report.TotalIssues) + logger.Info("Risk Score: %.1f/10 (%s)", report.RiskScore, report.RiskLevel) + logger.Info("Critical: %d | High: %d | Medium: %d | Low: %d | Info: %d", + summary.Critical, summary.High, summary.Medium, summary.Low, summary.Info) + logger.Info("Duration: %s", report.Duration) + + // Generate JSON report + if err := generateJSON(report, filename); err != nil { + return err + } + logger.Info("JSON report saved to %s", filename) + + // Generate HTML report + if generateHTML { + htmlFile := strings.TrimSuffix(filename, ".json") + ".html" + if err := generateHTMLReport(report, htmlFile); err != nil { + logger.Warn("Failed to generate HTML report: %v", err) + } else { + logger.Info("HTML report saved to %s", htmlFile) + } + } + + return nil +} + +func generateJSON(report Report, filename string) error { file, err := os.Create(filename) if err != nil { return fmt.Errorf("failed to create report file: %v", err) @@ -17,9 +132,166 @@ func Generate(results []scan.ScanResult, filename string) error { encoder := json.NewEncoder(file) encoder.SetIndent("", " ") - if err := encoder.Encode(results); err != nil { - return fmt.Errorf("failed to encode results to JSON: %v", err) + return encoder.Encode(report) +} + +func generateHTMLReport(report Report, filename string) error { + tmpl, err := template.New("report").Funcs(template.FuncMap{ + "severityColor": func(sev string) string { + switch sev { + case "Critical": + return "#dc2626" + case "High": + return "#ea580c" + case "Medium": + return "#d97706" + case "Low": + return "#2563eb" + default: + return "#6b7280" + } + }, + "riskColor": func(level string) string { + switch level { + case "Critical": + return "#dc2626" + case "High": + return "#ea580c" + case "Medium": + return "#d97706" + case "Low": + return "#2563eb" + default: + return "#16a34a" + } + }, + "tof": func(i int) float64 { return float64(i) }, + "mulf": func(a, b float64) float64 { return a * b }, + "divf": func(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b + }, + }).Parse(htmlTemplate) + if err != nil { + return fmt.Errorf("template parse error: %v", err) } - return nil + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create HTML file: %v", err) + } + defer file.Close() + + return tmpl.Execute(file, report) } + +const htmlTemplate = ` + + + + +Sentinel Security Report - {{.Target}} + + + +
+
+

SENTINEL

+
Security Report for {{.Target}}
+
Scanned on {{.ScanDate}} | Duration: {{.Duration}}
+
+ +
+
+
{{printf "%.1f" .RiskScore}}
+
Risk Score ({{.RiskLevel}})
+
+
+
{{.Summary.Critical}}
+
Critical
+
+
+
{{.Summary.High}}
+
High
+
+
+
{{.Summary.Medium}}
+
Medium
+
+
+
{{.Summary.Low}}
+
Low
+
+
+
{{.Summary.Info}}
+
Info
+
+
+ + {{if .Results}} +
+ {{if .Summary.Critical}}
{{end}} + {{if .Summary.High}}
{{end}} + {{if .Summary.Medium}}
{{end}} + {{if .Summary.Low}}
{{end}} + {{if .Summary.Info}}
{{end}} +
+ {{end}} + +
+

Findings ({{.TotalIssues}} issues)

+ {{range .Results}} +
+
+ {{.Type}} + + {{.Severity}} + CVSS {{printf "%.1f" .CVSSScore}} + +
+
{{.URL}}
+
{{.Description}}
+ {{if .Remediation}} +
Remediation: {{.Remediation}}
+ {{end}} +
+ {{end}} +
+ + +
+ +` diff --git a/pkg/report/report_test.go b/pkg/report/report_test.go new file mode 100644 index 0000000..38f5f07 --- /dev/null +++ b/pkg/report/report_test.go @@ -0,0 +1,186 @@ +package report + +import ( + "encoding/json" + "os" + "testing" + "time" + + "sentinel/pkg/scan" +) + +func TestGenerateJSON(t *testing.T) { + results := []scan.ScanResult{ + { + URL: "https://example.com", + Type: "Missing Header", + Severity: "Medium", + Description: "Missing security header: X-Frame-Options", + CVSSScore: 4.3, + Remediation: "Add X-Frame-Options header", + }, + { + URL: "https://example.com", + Type: "Info Leak", + Severity: "Low", + Description: "Server header exposed: nginx", + CVSSScore: 2.6, + }, + } + + tmpFile, err := os.CreateTemp("", "sentinel-report-*.json") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + err = Generate(results, "example.com", tmpFile.Name(), time.Now().Add(-30*time.Second), false) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + // Read and verify JSON + data, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("failed to read report: %v", err) + } + + var report Report + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("failed to parse report JSON: %v", err) + } + + if report.Target != "example.com" { + t.Errorf("expected target example.com, got %s", report.Target) + } + if report.TotalIssues != 2 { + t.Errorf("expected 2 issues, got %d", report.TotalIssues) + } + if report.Summary.Medium != 1 { + t.Errorf("expected 1 medium issue, got %d", report.Summary.Medium) + } + if report.Summary.Low != 1 { + t.Errorf("expected 1 low issue, got %d", report.Summary.Low) + } + if report.RiskScore <= 0 { + t.Error("expected positive risk score") + } +} + +func TestGenerateHTML(t *testing.T) { + results := []scan.ScanResult{ + { + URL: "https://example.com/.env", + Type: "Sensitive File", + Severity: "Critical", + Description: "Environment file exposed", + CVSSScore: 9.8, + Remediation: "Block access to .env files", + }, + { + URL: "https://example.com", + Type: "Missing Header", + Severity: "Medium", + Description: "Missing CSP header", + CVSSScore: 5.0, + }, + } + + tmpJSON, err := os.CreateTemp("", "sentinel-report-*.json") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpJSON.Close() + defer os.Remove(tmpJSON.Name()) + + // HTML file will be generated alongside + htmlFile := tmpJSON.Name()[:len(tmpJSON.Name())-5] + ".html" + defer os.Remove(htmlFile) + + err = Generate(results, "example.com", tmpJSON.Name(), time.Now().Add(-60*time.Second), true) + if err != nil { + t.Fatalf("Generate() with HTML error: %v", err) + } + + // Check HTML file exists + if _, err := os.Stat(htmlFile); os.IsNotExist(err) { + t.Error("expected HTML report file to be created") + } + + // Check HTML content + htmlData, err := os.ReadFile(htmlFile) + if err != nil { + t.Fatalf("failed to read HTML report: %v", err) + } + + htmlStr := string(htmlData) + if len(htmlStr) < 100 { + t.Error("HTML report seems too short") + } +} + +func TestRiskScoreCalculation(t *testing.T) { + results := []scan.ScanResult{ + {Severity: "Critical", CVSSScore: 9.8}, + {Severity: "Critical", CVSSScore: 9.5}, + } + + tmpFile, err := os.CreateTemp("", "sentinel-report-*.json") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + err = Generate(results, "example.com", tmpFile.Name(), time.Now(), false) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + data, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("failed to read report: %v", err) + } + + var report Report + json.Unmarshal(data, &report) + + if report.RiskLevel != "Critical" { + t.Errorf("expected Critical risk level for all-critical findings, got %s", report.RiskLevel) + } + if report.RiskScore > 10 { + t.Error("risk score should be capped at 10") + } +} + +func TestEmptyResults(t *testing.T) { + var results []scan.ScanResult + + tmpFile, err := os.CreateTemp("", "sentinel-report-*.json") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + err = Generate(results, "safe.example.com", tmpFile.Name(), time.Now(), false) + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + data, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("failed to read report: %v", err) + } + + var report Report + json.Unmarshal(data, &report) + + if report.RiskLevel != "Safe" { + t.Errorf("expected Safe risk level for no findings, got %s", report.RiskLevel) + } + if report.RiskScore != 0 { + t.Errorf("expected 0 risk score, got %.1f", report.RiskScore) + } +} diff --git a/pkg/scan/scan.go b/pkg/scan/scan.go index c7262f1..e290a6c 100644 --- a/pkg/scan/scan.go +++ b/pkg/scan/scan.go @@ -1,35 +1,106 @@ package scan import ( + "crypto/tls" "fmt" + "net" "net/http" "strings" "sync" "time" - "sentinel/pkg/crawl" - "sentinel/pkg/fuzz" + "sentinel/pkg/config" + "sentinel/pkg/logger" ) +// ScanResult represents a single vulnerability finding type ScanResult struct { - URL string `json:"url"` - Type string `json:"type"` // SQLi, XSS, Info, etc. - Severity string `json:"severity"` // High, Medium, Low, Info - Description string `json:"description"` + URL string `json:"url"` + Type string `json:"type"` + Severity string `json:"severity"` // Critical, High, Medium, Low, Info + Description string `json:"description"` + CVSSScore float64 `json:"cvss_score"` + Remediation string `json:"remediation,omitempty"` } -func RunScan(targets []string, concurrency int, fullScan bool) []ScanResult { +// securityHeaders defines headers to check with their severity and remediation +var securityHeaders = []struct { + Name string + Severity string + CVSSScore float64 + Remediation string +}{ + {"X-Frame-Options", "Medium", 4.3, "Add 'X-Frame-Options: DENY' or 'SAMEORIGIN' header to prevent clickjacking"}, + {"X-Content-Type-Options", "Low", 3.1, "Add 'X-Content-Type-Options: nosniff' header"}, + {"Content-Security-Policy", "Medium", 5.0, "Implement a Content-Security-Policy header to prevent XSS and injection attacks"}, + {"Strict-Transport-Security", "High", 6.1, "Add 'Strict-Transport-Security: max-age=31536000; includeSubDomains' header"}, + {"Permissions-Policy", "Low", 2.5, "Add Permissions-Policy header to control browser features"}, + {"Referrer-Policy", "Low", 3.0, "Add 'Referrer-Policy: strict-origin-when-cross-origin' header"}, + {"X-XSS-Protection", "Low", 2.0, "Add 'X-XSS-Protection: 1; mode=block' header (legacy browsers)"}, + {"Cross-Origin-Opener-Policy", "Low", 2.5, "Add 'Cross-Origin-Opener-Policy: same-origin' header"}, + {"Cross-Origin-Resource-Policy", "Low", 2.5, "Add 'Cross-Origin-Resource-Policy: same-origin' header"}, +} + +// sensitiveFiles defines files/paths to check for exposure +var sensitiveFiles = []struct { + Path string + Description string + Severity string + CVSSScore float64 +}{ + {"/.git/HEAD", "Git repository exposed", "Critical", 9.1}, + {"/.git/config", "Git config exposed", "Critical", 9.1}, + {"/.env", "Environment file exposed (may contain secrets)", "Critical", 9.8}, + {"/.env.backup", "Environment backup exposed", "Critical", 9.8}, + {"/.env.local", "Local environment file exposed", "Critical", 9.8}, + {"/robots.txt", "Robots.txt found (may disclose paths)", "Info", 0.0}, + {"/sitemap.xml", "Sitemap found (may disclose structure)", "Info", 0.0}, + {"/admin", "Admin panel accessible", "High", 7.5}, + {"/admin/login", "Admin login page found", "Medium", 5.0}, + {"/wp-admin", "WordPress admin found", "Medium", 5.0}, + {"/wp-login.php", "WordPress login found", "Medium", 5.0}, + {"/phpinfo.php", "PHP info page exposed", "High", 7.3}, + {"/.htaccess", "Apache htaccess exposed", "High", 6.5}, + {"/.htpasswd", "Apache htpasswd exposed", "Critical", 9.5}, + {"/server-status", "Apache server-status exposed", "High", 7.0}, + {"/server-info", "Apache server-info exposed", "High", 7.0}, + {"/.svn/entries", "SVN repository exposed", "Critical", 9.1}, + {"/.DS_Store", "macOS DS_Store file exposed", "Medium", 4.0}, + {"/backup.sql", "SQL backup file exposed", "Critical", 9.8}, + {"/database.sql", "SQL database dump exposed", "Critical", 9.8}, + {"/dump.sql", "SQL dump exposed", "Critical", 9.8}, + {"/config.php", "PHP config file exposed", "High", 8.0}, + {"/config.yml", "YAML config file exposed", "High", 8.0}, + {"/config.json", "JSON config file exposed", "High", 8.0}, + {"/web.config", "IIS web.config exposed", "High", 7.5}, + {"/crossdomain.xml", "Flash crossdomain policy found", "Medium", 4.3}, + {"/.well-known/security.txt", "Security.txt found (informational)", "Info", 0.0}, + {"/api/swagger.json", "Swagger API docs exposed", "Medium", 5.3}, + {"/api/v1/", "API endpoint accessible", "Info", 0.0}, + {"/graphql", "GraphQL endpoint found", "Medium", 5.0}, + {"/.dockerenv", "Docker environment file exposed", "High", 7.0}, + {"/docker-compose.yml", "Docker compose file exposed", "High", 8.0}, + {"/Dockerfile", "Dockerfile exposed", "Medium", 5.5}, + {"/.aws/credentials", "AWS credentials file exposed", "Critical", 10.0}, + {"/debug", "Debug endpoint accessible", "High", 7.5}, + {"/trace", "Trace endpoint accessible", "High", 7.5}, + {"/actuator", "Spring Boot actuator exposed", "High", 7.5}, + {"/actuator/health", "Spring Boot health endpoint", "Info", 0.0}, + {"/elmah.axd", "ELMAH error log exposed", "High", 7.0}, +} + +// dangerousHTTPMethods lists HTTP methods that should typically be disabled +var dangerousHTTPMethods = []string{"PUT", "DELETE", "TRACE", "CONNECT", "PATCH"} + +// RunScan performs all security checks on the given targets +func RunScan(targets []string, cfg *config.Config, client *http.Client) []ScanResult { var results []ScanResult var mu sync.Mutex - sem := make(chan struct{}, concurrency) + sem := make(chan struct{}, cfg.Concurrency) var wg sync.WaitGroup - client := &http.Client{ - Timeout: 10 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse // Don't follow redirects automatically for header checks - }, - } + total := len(targets) + completed := 0 for _, target := range targets { wg.Add(1) @@ -38,111 +109,400 @@ func RunScan(targets []string, concurrency int, fullScan bool) []ScanResult { defer wg.Done() defer func() { <-sem }() - // Ensure target has protocol - url := t - if !strings.HasPrefix(url, "http") { - url = "https://" + url + url := ensureProtocol(t) + logger.Debug("Scanning %s", url) + + var localResults []ScanResult + + // Rate limiting + if cfg.RateLimit > 0 { + time.Sleep(time.Duration(cfg.RateLimit) * time.Millisecond) } - fmt.Printf("[Scan] Checking %s\n", url) + // 1. Header checks + if cfg.Scan.Headers { + localResults = append(localResults, checkHeaders(url, client)...) + } - // 1. Basic Availability & Headers check - resp, err := client.Get(url) - if err != nil { - // Try HTTP if HTTPS fails - url = "http://" + t - resp, err = client.Get(url) - if err != nil { - return // Host down or unreachable - } + // 2. Server info leak + if cfg.Scan.ServerInfo { + localResults = append(localResults, checkServerInfo(url, client)...) } - defer resp.Body.Close() - - // Check Security Headers - headers := []string{"X-Frame-Options", "X-Content-Type-Options", "Content-Security-Policy"} - for _, h := range headers { - if resp.Header.Get(h) == "" { - mu.Lock() - results = append(results, ScanResult{ - URL: url, - Type: "Missing Header", - Severity: "Low", - Description: fmt.Sprintf("Missing security header: %s", h), - }) - mu.Unlock() - } + + // 3. TLS/SSL checks + if cfg.Scan.TLS { + localResults = append(localResults, checkTLS(t)...) } - // Check Server Header (Info Leak) - if server := resp.Header.Get("Server"); server != "" { - mu.Lock() - results = append(results, ScanResult{ - URL: url, - Type: "Info Leak", - Severity: "Info", - Description: fmt.Sprintf("Server header exposed: %s", server), - }) - mu.Unlock() + // 4. CORS misconfiguration + if cfg.Scan.CORS { + localResults = append(localResults, checkCORS(url, client)...) } - // 2. Common Vulnerabilities & Fuzzing - if fullScan { - // Check for sensitive files - commonFiles := []string{"/.git/HEAD", "/.env", "/robots.txt", "/admin"} - for _, file := range commonFiles { - checkURL := url + file - r, err := client.Get(checkURL) - if err == nil && r.StatusCode == 200 { - mu.Lock() - results = append(results, ScanResult{ - URL: checkURL, - Type: "Sensitive File", - Severity: "High", // Could be critical depending on file - Description: fmt.Sprintf("Found accessible file: %s", file), - }) - mu.Unlock() - r.Body.Close() - } - } + // 5. HTTP methods + if cfg.Scan.HTTPMethods { + localResults = append(localResults, checkHTTPMethods(url, client)...) + } - // 3. Crawler & Fuzzer (Intelligent Scan) - fmt.Printf("[Crawler] Starting crawl on %s\n", url) - crawledURLs, err := crawl.Crawl(url) - if err == nil { - fmt.Printf("[Crawler] Found %d interesting URLs on %s\n", len(crawledURLs), url) - for _, crawledURL := range crawledURLs { - // SQLi Check - isSQLi, details := fuzz.CheckSQLi(crawledURL) - if isSQLi { - mu.Lock() - results = append(results, ScanResult{ - URL: crawledURL, - Type: "SQL Injection", - Severity: "Critical", - Description: details, - }) - mu.Unlock() - } - - // XSS Check - isXSS, details := fuzz.CheckXSS(crawledURL) - if isXSS { - mu.Lock() - results = append(results, ScanResult{ - URL: crawledURL, - Type: "Reflected XSS", - Severity: "High", - Description: details, - }) - mu.Unlock() - } - } - } + // 6. Sensitive files (full scan only) + if cfg.FullScan && cfg.Scan.SensitiveFiles { + localResults = append(localResults, checkSensitiveFiles(url, client, cfg.RateLimit)...) } + mu.Lock() + results = append(results, localResults...) + completed++ + logger.Info("Progress: %d/%d hosts scanned", completed, total) + mu.Unlock() + }(target) } wg.Wait() return results } + +func ensureProtocol(target string) string { + if !strings.HasPrefix(target, "http") { + return "https://" + target + } + return target +} + +// checkHeaders verifies security headers are present +func checkHeaders(url string, client *http.Client) []ScanResult { + var results []ScanResult + + resp, err := client.Get(url) + if err != nil { + // Fallback to HTTP + httpURL := strings.Replace(url, "https://", "http://", 1) + resp, err = client.Get(httpURL) + if err != nil { + return results + } + } + defer resp.Body.Close() + + for _, h := range securityHeaders { + if resp.Header.Get(h.Name) == "" { + results = append(results, ScanResult{ + URL: url, + Type: "Missing Header", + Severity: h.Severity, + Description: fmt.Sprintf("Missing security header: %s", h.Name), + CVSSScore: h.CVSSScore, + Remediation: h.Remediation, + }) + } + } + + return results +} + +// checkServerInfo checks for information disclosure in response headers +func checkServerInfo(url string, client *http.Client) []ScanResult { + var results []ScanResult + + resp, err := client.Get(url) + if err != nil { + return results + } + defer resp.Body.Close() + + // Server header + if server := resp.Header.Get("Server"); server != "" { + results = append(results, ScanResult{ + URL: url, + Type: "Info Leak", + Severity: "Low", + Description: fmt.Sprintf("Server header exposed: %s", server), + CVSSScore: 2.6, + Remediation: "Remove or obfuscate the Server header to prevent version disclosure", + }) + } + + // X-Powered-By header + if poweredBy := resp.Header.Get("X-Powered-By"); poweredBy != "" { + results = append(results, ScanResult{ + URL: url, + Type: "Info Leak", + Severity: "Low", + Description: fmt.Sprintf("X-Powered-By header exposed: %s", poweredBy), + CVSSScore: 2.6, + Remediation: "Remove the X-Powered-By header to prevent technology disclosure", + }) + } + + // X-AspNet-Version header + if aspnet := resp.Header.Get("X-AspNet-Version"); aspnet != "" { + results = append(results, ScanResult{ + URL: url, + Type: "Info Leak", + Severity: "Medium", + Description: fmt.Sprintf("ASP.NET version exposed: %s", aspnet), + CVSSScore: 3.7, + Remediation: "Remove X-AspNet-Version header from responses", + }) + } + + // X-AspNetMvc-Version header + if mvc := resp.Header.Get("X-AspNetMvc-Version"); mvc != "" { + results = append(results, ScanResult{ + URL: url, + Type: "Info Leak", + Severity: "Medium", + Description: fmt.Sprintf("ASP.NET MVC version exposed: %s", mvc), + CVSSScore: 3.7, + Remediation: "Remove X-AspNetMvc-Version header from responses", + }) + } + + return results +} + +// checkTLS checks TLS/SSL configuration +func checkTLS(host string) []ScanResult { + var results []ScanResult + + // Strip protocol + host = strings.TrimPrefix(host, "https://") + host = strings.TrimPrefix(host, "http://") + + // Add port if missing + if !strings.Contains(host, ":") { + host = host + ":443" + } + + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 5 * time.Second}, + "tcp", + host, + &tls.Config{InsecureSkipVerify: true}, + ) + if err != nil { + results = append(results, ScanResult{ + URL: host, + Type: "TLS", + Severity: "High", + Description: fmt.Sprintf("TLS connection failed: %v", err), + CVSSScore: 7.4, + Remediation: "Ensure TLS is properly configured and the certificate is valid", + }) + return results + } + defer conn.Close() + + state := conn.ConnectionState() + + // Check TLS version + switch state.Version { + case tls.VersionTLS10: + results = append(results, ScanResult{ + URL: host, + Type: "TLS", + Severity: "High", + Description: "TLS 1.0 is supported (deprecated and insecure)", + CVSSScore: 7.4, + Remediation: "Disable TLS 1.0 and use TLS 1.2 or higher", + }) + case tls.VersionTLS11: + results = append(results, ScanResult{ + URL: host, + Type: "TLS", + Severity: "Medium", + Description: "TLS 1.1 is supported (deprecated)", + CVSSScore: 5.9, + Remediation: "Disable TLS 1.1 and use TLS 1.2 or higher", + }) + case tls.VersionTLS12: + logger.Debug("TLS 1.2 supported on %s", host) + case tls.VersionTLS13: + logger.Debug("TLS 1.3 supported on %s (excellent)", host) + } + + // Check certificate expiration + if len(state.PeerCertificates) > 0 { + cert := state.PeerCertificates[0] + + if time.Now().After(cert.NotAfter) { + results = append(results, ScanResult{ + URL: host, + Type: "TLS", + Severity: "Critical", + Description: fmt.Sprintf("SSL certificate expired on %s", cert.NotAfter.Format("2006-01-02")), + CVSSScore: 9.1, + Remediation: "Renew the SSL/TLS certificate immediately", + }) + } else if time.Until(cert.NotAfter) < 30*24*time.Hour { + results = append(results, ScanResult{ + URL: host, + Type: "TLS", + Severity: "Medium", + Description: fmt.Sprintf("SSL certificate expires soon: %s", cert.NotAfter.Format("2006-01-02")), + CVSSScore: 4.0, + Remediation: "Plan certificate renewal before expiration", + }) + } + + // Check for self-signed + if cert.Issuer.CommonName == cert.Subject.CommonName { + results = append(results, ScanResult{ + URL: host, + Type: "TLS", + Severity: "Medium", + Description: "Self-signed certificate detected", + CVSSScore: 5.3, + Remediation: "Use a certificate from a trusted Certificate Authority", + }) + } + } + + return results +} + +// checkCORS checks for CORS misconfigurations +func checkCORS(url string, client *http.Client) []ScanResult { + var results []ScanResult + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return results + } + req.Header.Set("Origin", "https://evil-attacker.com") + + resp, err := client.Do(req) + if err != nil { + return results + } + defer resp.Body.Close() + + acao := resp.Header.Get("Access-Control-Allow-Origin") + acac := resp.Header.Get("Access-Control-Allow-Credentials") + + if acao == "*" { + sev := "Medium" + cvss := 5.3 + desc := "CORS allows any origin (Access-Control-Allow-Origin: *)" + if acac == "true" { + sev = "High" + cvss = 8.1 + desc = "CORS allows any origin WITH credentials (critical misconfiguration)" + } + results = append(results, ScanResult{ + URL: url, + Type: "CORS Misconfiguration", + Severity: sev, + Description: desc, + CVSSScore: cvss, + Remediation: "Restrict Access-Control-Allow-Origin to trusted domains only", + }) + } else if acao == "https://evil-attacker.com" { + results = append(results, ScanResult{ + URL: url, + Type: "CORS Misconfiguration", + Severity: "High", + Description: "CORS reflects arbitrary Origin header (allows any domain)", + CVSSScore: 8.1, + Remediation: "Validate Origin header against a whitelist of trusted domains", + }) + } + + return results +} + +// checkHTTPMethods checks for dangerous HTTP methods enabled +func checkHTTPMethods(url string, client *http.Client) []ScanResult { + var results []ScanResult + + // First check OPTIONS + req, err := http.NewRequest("OPTIONS", url, nil) + if err != nil { + return results + } + + resp, err := client.Do(req) + if err != nil { + return results + } + defer resp.Body.Close() + + allow := resp.Header.Get("Allow") + if allow != "" { + for _, method := range dangerousHTTPMethods { + if strings.Contains(strings.ToUpper(allow), method) { + sev := "Medium" + cvss := 5.3 + if method == "PUT" || method == "DELETE" { + sev = "High" + cvss = 7.5 + } + if method == "TRACE" { + sev = "Medium" + cvss = 5.3 + } + results = append(results, ScanResult{ + URL: url, + Type: "HTTP Method", + Severity: sev, + Description: fmt.Sprintf("Dangerous HTTP method enabled: %s", method), + CVSSScore: cvss, + Remediation: fmt.Sprintf("Disable the %s HTTP method if not required", method), + }) + } + } + } + + // Check TRACE specifically + req, err = http.NewRequest("TRACE", url, nil) + if err != nil { + return results + } + resp2, err := client.Do(req) + if err == nil { + defer resp2.Body.Close() + if resp2.StatusCode == 200 { + results = append(results, ScanResult{ + URL: url, + Type: "HTTP Method", + Severity: "Medium", + Description: "TRACE method is enabled (potential XST vulnerability)", + CVSSScore: 5.3, + Remediation: "Disable the TRACE HTTP method to prevent Cross-Site Tracing attacks", + }) + } + } + + return results +} + +// checkSensitiveFiles probes for common sensitive files/paths +func checkSensitiveFiles(baseURL string, client *http.Client, rateLimit int) []ScanResult { + var results []ScanResult + + for _, file := range sensitiveFiles { + if rateLimit > 0 { + time.Sleep(time.Duration(rateLimit) * time.Millisecond) + } + + checkURL := baseURL + file.Path + resp, err := client.Get(checkURL) + if err != nil { + continue + } + + if resp.StatusCode == 200 { + results = append(results, ScanResult{ + URL: checkURL, + Type: "Sensitive File", + Severity: file.Severity, + Description: file.Description, + CVSSScore: file.CVSSScore, + Remediation: fmt.Sprintf("Block public access to %s", file.Path), + }) + } + resp.Body.Close() + } + + return results +} diff --git a/pkg/scan/scan_test.go b/pkg/scan/scan_test.go new file mode 100644 index 0000000..5e4c029 --- /dev/null +++ b/pkg/scan/scan_test.go @@ -0,0 +1,213 @@ +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + + "sentinel/pkg/config" +) + +func TestCheckHeaders(t *testing.T) { + // Server with no security headers + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "TestServer/1.0") + w.WriteHeader(200) + w.Write([]byte("OK")) + })) + defer ts.Close() + + client := ts.Client() + results := checkHeaders(ts.URL, client) + + if len(results) == 0 { + t.Error("expected missing header findings, got none") + } + + // Check that at least X-Frame-Options is flagged + found := false + for _, r := range results { + if r.Type == "Missing Header" { + found = true + break + } + } + if !found { + t.Error("expected Missing Header type findings") + } +} + +func TestCheckHeadersAllPresent(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'self'") + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + w.Header().Set("Permissions-Policy", "camera=()") + w.Header().Set("Referrer-Policy", "strict-origin") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.WriteHeader(200) + })) + defer ts.Close() + + client := ts.Client() + results := checkHeaders(ts.URL, client) + + if len(results) != 0 { + t.Errorf("expected 0 missing headers, got %d", len(results)) + for _, r := range results { + t.Logf(" - %s", r.Description) + } + } +} + +func TestCheckServerInfo(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "Apache/2.4.41") + w.Header().Set("X-Powered-By", "PHP/7.4.3") + w.WriteHeader(200) + })) + defer ts.Close() + + client := ts.Client() + results := checkServerInfo(ts.URL, client) + + if len(results) != 2 { + t.Errorf("expected 2 info leak findings, got %d", len(results)) + } +} + +func TestCheckCORS(t *testing.T) { + // Wildcard CORS + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(200) + })) + defer ts.Close() + + client := ts.Client() + results := checkCORS(ts.URL, client) + + if len(results) != 1 { + t.Errorf("expected 1 CORS finding, got %d", len(results)) + } + if len(results) > 0 && results[0].Severity != "Medium" { + t.Errorf("expected Medium severity, got %s", results[0].Severity) + } +} + +func TestCheckCORSReflected(t *testing.T) { + // Reflecting Origin + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + w.WriteHeader(200) + })) + defer ts.Close() + + client := ts.Client() + results := checkCORS(ts.URL, client) + + if len(results) != 1 { + t.Errorf("expected 1 CORS finding, got %d", len(results)) + } + if len(results) > 0 && results[0].Severity != "High" { + t.Errorf("expected High severity for reflected origin, got %s", results[0].Severity) + } +} + +func TestCheckSensitiveFiles(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.env": + w.WriteHeader(200) + w.Write([]byte("DB_PASSWORD=secret")) + case "/.git/HEAD": + w.WriteHeader(200) + w.Write([]byte("ref: refs/heads/main")) + default: + w.WriteHeader(404) + } + })) + defer ts.Close() + + client := ts.Client() + results := checkSensitiveFiles(ts.URL, client, 0) + + criticalCount := 0 + for _, r := range results { + if r.Severity == "Critical" { + criticalCount++ + } + } + + if criticalCount < 2 { + t.Errorf("expected at least 2 critical findings (.env and .git), got %d", criticalCount) + } +} + +func TestCheckHTTPMethods(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + w.Header().Set("Allow", "GET, POST, PUT, DELETE, OPTIONS") + w.WriteHeader(200) + return + } + if r.Method == "TRACE" { + w.WriteHeader(200) + return + } + w.WriteHeader(200) + })) + defer ts.Close() + + client := ts.Client() + results := checkHTTPMethods(ts.URL, client) + + if len(results) == 0 { + t.Error("expected findings for dangerous HTTP methods") + } +} + +func TestRunScan(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "TestServer") + w.WriteHeader(200) + })) + defer ts.Close() + + cfg := config.DefaultConfig() + cfg.Target = "test" + cfg.Concurrency = 2 + cfg.RateLimit = 0 + cfg.FullScan = false + + targets := []string{ts.URL} + results := RunScan(targets, cfg, ts.Client()) + + if len(results) == 0 { + t.Error("expected at least some findings from scan") + } +} + +func TestEnsureProtocol(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"example.com", "https://example.com"}, + {"https://example.com", "https://example.com"}, + {"http://example.com", "http://example.com"}, + } + + for _, tt := range tests { + result := ensureProtocol(tt.input) + if result != tt.expected { + t.Errorf("ensureProtocol(%s) = %s, want %s", tt.input, result, tt.expected) + } + } +} diff --git a/report.json b/report.json deleted file mode 100644 index a390e05..0000000 --- a/report.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "url": "https://asterbook.com", - "type": "Missing Header", - "severity": "Low", - "description": "Missing security header: X-Frame-Options" - }, - { - "url": "https://asterbook.com", - "type": "Missing Header", - "severity": "Low", - "description": "Missing security header: X-Content-Type-Options" - }, - { - "url": "https://asterbook.com", - "type": "Missing Header", - "severity": "Low", - "description": "Missing security header: Content-Security-Policy" - }, - { - "url": "https://asterbook.com", - "type": "Info Leak", - "severity": "Info", - "description": "Server header exposed: cloudflare" - }, - { - "url": "https://www.asterbook.com", - "type": "Missing Header", - "severity": "Low", - "description": "Missing security header: X-Frame-Options" - }, - { - "url": "https://www.asterbook.com", - "type": "Missing Header", - "severity": "Low", - "description": "Missing security header: X-Content-Type-Options" - }, - { - "url": "https://www.asterbook.com", - "type": "Missing Header", - "severity": "Low", - "description": "Missing security header: Content-Security-Policy" - }, - { - "url": "https://www.asterbook.com", - "type": "Info Leak", - "severity": "Info", - "description": "Server header exposed: cloudflare" - }, - { - "url": "https://asterbook.com/robots.txt", - "type": "Sensitive File", - "severity": "High", - "description": "Found accessible file: /robots.txt" - }, - { - "url": "https://www.asterbook.com/robots.txt", - "type": "Sensitive File", - "severity": "High", - "description": "Found accessible file: /robots.txt" - } -] diff --git a/sentinel b/sentinel deleted file mode 100755 index bcd5c79..0000000 Binary files a/sentinel and /dev/null differ diff --git a/sentinel.yml b/sentinel.yml new file mode 100644 index 0000000..133729e --- /dev/null +++ b/sentinel.yml @@ -0,0 +1,56 @@ +# Sentinel Configuration File +# Copy this file and customize for your needs + +# Target domain to scan +target: "example.com" + +# Output file path +output: "report.json" + +# Number of concurrent workers (1-50) +concurrency: 10 + +# Enable full scan (includes sensitive file checks) +full_scan: true + +# Rate limiting between requests (milliseconds) +rate_limit_ms: 100 + +# HTTP request timeout (seconds) +timeout_s: 10 + +# HTTP proxy (for Burp Suite, ZAP, etc.) +# proxy: "http://127.0.0.1:8080" + +# Custom User-Agent +user_agent: "Sentinel/2.0 Security Scanner" + +# Enable verbose/debug output +verbose: false + +# Generate HTML report alongside JSON +report_html: true + +# Reconnaissance settings +recon: + # Query crt.sh for Certificate Transparency logs + crtsh: true + # DNS bruteforce with common subdomain wordlist + dns_bruteforce: true + # Only keep subdomains that resolve (recommended) + resolve_only: true + +# Scan modules +scan: + # Check for missing security headers + headers: true + # Check for sensitive files (.env, .git, etc.) + sensitive_files: true + # Check TLS/SSL configuration + tls: true + # Check CORS misconfiguration + cors: true + # Check for dangerous HTTP methods + http_methods: true + # Check for server info leaks + server_info: true