diff --git a/cmd/audit.go b/cmd/audit.go index c265c55..61a6031 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "strings" "sync" "time" @@ -191,25 +192,17 @@ func writeAuditReports(result engine.ScoreResult, path string) { // Set GitHub Actions outputs if in CI if ghOutput := os.Getenv("GITHUB_OUTPUT"); ghOutput != "" { - f, err := os.OpenFile(ghOutput, os.O_APPEND|os.O_WRONLY, 0644) - if err == nil { + if f, err := os.OpenFile(ghOutput, os.O_APPEND|os.O_WRONLY, 0644); err == nil { + defer f.Close() fmt.Fprintf(f, "score=%d\n", result.Score) fmt.Fprintf(f, "grade=%s\n", result.Grade) fmt.Fprintf(f, "total-findings=%d\n", len(result.Findings)) fmt.Fprintf(f, "critical-findings=%d\n", result.Summary[engine.SevCritical]) fmt.Fprintf(f, "high-findings=%d\n", result.Summary[engine.SevHigh]) - f.Close() } } } func resolveAbsPath(path string) (string, error) { - if strings.HasPrefix(path, "/") { - return path, nil - } - cwd, err := os.Getwd() - if err != nil { - return "", err - } - return cwd + "/" + path, nil + return filepath.Abs(path) } diff --git a/cmd/full.go b/cmd/full.go index 0736410..db98c6f 100644 --- a/cmd/full.go +++ b/cmd/full.go @@ -96,7 +96,6 @@ func runFull(cmd *cobra.Command, args []string) { Threads: fullThreads, Timeout: time.Duration(fullTimeout) * time.Second, UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - Verbose: fullVerbose, } eng := engine.New(scanCfg) @@ -107,7 +106,13 @@ func runFull(cmd *cobra.Command, args []string) { eng.Register(&modules.XSS{}) eng.Register(&modules.InfoDisclosure{}) - remoteResult := eng.Run() + remoteResult, runErr := eng.Run() + if runErr != nil { + exitError(fmt.Sprintf("scan engine error: %v", runErr)) + } + if len(remoteResult.Errors) > 0 && fullVerbose { + fmt.Fprintf(os.Stderr, " [!] %d module(s) failed — remote scan may be incomplete\n", len(remoteResult.Errors)) + } // --- Phase 2: Local audit --- if !fullJSON { diff --git a/cmd/scan.go b/cmd/scan.go index 9ac016c..10097dc 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -69,13 +69,10 @@ func runScan(cmd *cobra.Command, args []string) { target = strings.TrimRight(target, "/") cfg := &engine.Config{ - TargetURL: target, - Threads: scanThreads, - Timeout: time.Duration(scanTimeout) * time.Second, - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - MinScore: scanMinScore, - OutputJSON: scanJSON, - Verbose: scanVerbose, + TargetURL: target, + Threads: scanThreads, + Timeout: time.Duration(scanTimeout) * time.Second, + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", } if scanModules != "" { @@ -110,7 +107,14 @@ func runScan(cmd *cobra.Command, args []string) { // Run scanStart := time.Now() - result := eng.Run() + result, runErr := eng.Run() + if runErr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", runErr) + os.Exit(1) + } + if len(result.Errors) > 0 && scanVerbose { + fmt.Fprintf(os.Stderr, " [!] %d module(s) failed — scan may be incomplete\n", len(result.Errors)) + } scanDuration := time.Since(scanStart) // Output @@ -162,14 +166,13 @@ func runScan(cmd *cobra.Command, args []string) { // Set GitHub Actions outputs if in CI if ghOutput := os.Getenv("GITHUB_OUTPUT"); ghOutput != "" { - f, err := os.OpenFile(ghOutput, os.O_APPEND|os.O_WRONLY, 0644) - if err == nil { + if f, err := os.OpenFile(ghOutput, os.O_APPEND|os.O_WRONLY, 0644); err == nil { + defer f.Close() fmt.Fprintf(f, "score=%d\n", result.Score) fmt.Fprintf(f, "grade=%s\n", result.Grade) fmt.Fprintf(f, "total-findings=%d\n", len(result.Findings)) fmt.Fprintf(f, "critical-findings=%d\n", result.Summary[engine.SevCritical]) fmt.Fprintf(f, "high-findings=%d\n", result.Summary[engine.SevHigh]) - f.Close() } } diff --git a/pkg/engine/config.go b/pkg/engine/config.go index 7decaa8..bd9ab9d 100644 --- a/pkg/engine/config.go +++ b/pkg/engine/config.go @@ -3,22 +3,9 @@ package engine import "time" type Config struct { - TargetURL string - Threads int - Timeout time.Duration - UserAgent string - Modules []string // empty = all - MinScore int - OutputJSON bool - Verbose bool -} - -func DefaultConfig(target string) *Config { - return &Config{ - TargetURL: target, - Threads: 10, - Timeout: 15 * time.Second, - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - MinScore: 70, - } + TargetURL string + Threads int + Timeout time.Duration + UserAgent string + Modules []string // empty = all } diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index fd8cecb..ff01fe5 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -1,19 +1,27 @@ package engine import ( + "context" "fmt" "sync" "time" ) +// defaultModuleTimeout is the per-module execution timeout. +const defaultModuleTimeout = 5 * time.Minute + +// Module is the interface that all remote scan modules must implement. type Module interface { Name() string Description() string - Run(cfg *Config) ([]Finding, error) + Run(ctx context.Context, cfg *Config) ([]Finding, error) } +// Engine holds the configuration and registered modules. +// Engine is not safe for concurrent use (Register must complete before Run). type Engine struct { Config *Config + mu sync.RWMutex modules []Module } @@ -21,11 +29,27 @@ func New(cfg *Config) *Engine { return &Engine{Config: cfg} } +// Register adds a module to the engine. +// Register must not be called concurrently with Run or other Register calls. func (e *Engine) Register(m Module) { + e.mu.Lock() + defer e.mu.Unlock() e.modules = append(e.modules, m) } -func (e *Engine) Run() ScoreResult { +// Run executes all registered modules concurrently and returns the scan result. +// Module errors are aggregated in ScoreResult.Errors; Run itself only returns an +// error for fatal configuration problems. +func (e *Engine) Run() (ScoreResult, error) { + if e == nil || e.Config == nil { + return ScoreResult{}, fmt.Errorf("engine: nil config") + } + + threads := e.Config.Threads + if threads < 1 { + threads = 1 + } + start := time.Now() fmt.Printf("\n ⚡ VX Security Scanner v0.1.0\n") fmt.Printf(" Target: %s\n", e.Config.TargetURL) @@ -33,13 +57,19 @@ func (e *Engine) Run() ScoreResult { var ( allFindings []Finding + allErrors []ModuleError mu sync.Mutex wg sync.WaitGroup ) - sem := make(chan struct{}, e.Config.Threads) + sem := make(chan struct{}, threads) + + e.mu.RLock() + mods := make([]Module, len(e.modules)) + copy(mods, e.modules) + e.mu.RUnlock() - for _, mod := range e.modules { + for _, mod := range mods { if !e.shouldRun(mod) { continue } @@ -54,11 +84,17 @@ func (e *Engine) Run() ScoreResult { fmt.Printf(" [~] Running %s...\n", m.Name()) modStart := time.Now() - findings, err := m.Run(e.Config) + ctx, cancel := context.WithTimeout(context.Background(), defaultModuleTimeout) + defer cancel() + + findings, err := m.Run(ctx, e.Config) elapsed := time.Since(modStart) if err != nil { fmt.Printf(" [!] %s failed: %v (%s)\n", m.Name(), err, elapsed.Round(time.Millisecond)) + mu.Lock() + allErrors = append(allErrors, ModuleError{Module: m.Name(), Err: err}) + mu.Unlock() return } @@ -75,7 +111,9 @@ func (e *Engine) Run() ScoreResult { elapsed := time.Since(start) fmt.Printf("\n Scan completed in %s\n\n", elapsed.Round(time.Millisecond)) - return ComputeScore(allFindings) + result := ComputeScore(allFindings) + result.Errors = allErrors + return result, nil } func (e *Engine) shouldRun(m Module) bool { diff --git a/pkg/engine/finding.go b/pkg/engine/finding.go index 0458e64..4eecb7a 100644 --- a/pkg/engine/finding.go +++ b/pkg/engine/finding.go @@ -2,6 +2,12 @@ package engine import "fmt" +// ModuleError records a module name and its execution error. +type ModuleError struct { + Module string + Err error +} + type Severity int const ( diff --git a/pkg/engine/score.go b/pkg/engine/score.go index 17f1609..7797b8f 100644 --- a/pkg/engine/score.go +++ b/pkg/engine/score.go @@ -11,10 +11,13 @@ const ( ) type ScoreResult struct { - Score int `json:"score"` - Grade Grade `json:"grade"` - Findings []Finding `json:"findings"` + Score int `json:"score"` + Grade Grade `json:"grade"` + Findings []Finding `json:"findings"` Summary map[Severity]int `json:"summary"` + // Errors holds per-module errors encountered during the scan. + // A non-empty Errors slice means the scan is partial. + Errors []ModuleError `json:"errors,omitempty"` } func ComputeScore(findings []Finding) ScoreResult { diff --git a/pkg/history/history.go b/pkg/history/history.go index 9b9bbd6..bad13c3 100644 --- a/pkg/history/history.go +++ b/pkg/history/history.go @@ -47,12 +47,21 @@ func dir() (string, error) { return "", fmt.Errorf("get home dir: %w", err) } d := filepath.Join(home, ".vx", "scans") - if err := os.MkdirAll(d, 0755); err != nil { + if err := os.MkdirAll(d, 0700); err != nil { return "", fmt.Errorf("create history dir: %w", err) } return d, nil } +// safePath joins filename to dir and rejects paths that escape dir. +func safePath(dir, filename string) (string, error) { + cleaned := filepath.Clean(filename) + if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") || strings.ContainsRune(cleaned, filepath.Separator) { + return "", fmt.Errorf("invalid filename: %q", filename) + } + return filepath.Join(dir, cleaned), nil +} + // SaveScan persists a scan result to ~/.vx/scans/. func SaveScan(result engine.ScoreResult, target string, duration time.Duration) error { d, err := dir() @@ -76,7 +85,7 @@ func SaveScan(result engine.ScoreResult, target string, duration time.Duration) return fmt.Errorf("marshal scan: %w", err) } - return os.WriteFile(filepath.Join(d, filename), data, 0644) + return os.WriteFile(filepath.Join(d, filename), data, 0600) } // ListScans returns all saved scans sorted by date (newest first). @@ -132,7 +141,12 @@ func LoadScan(filename string) (*StoredScan, error) { return nil, err } - data, err := os.ReadFile(filepath.Join(d, filename)) + path, err := safePath(d, filename) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read scan file: %w", err) } diff --git a/pkg/local/deps.go b/pkg/local/deps.go index de687b2..026ba02 100644 --- a/pkg/local/deps.go +++ b/pkg/local/deps.go @@ -1,7 +1,9 @@ package local import ( + "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -21,11 +23,22 @@ type Deps struct{} func (d *Deps) Name() string { return "dependencies" } func (d *Deps) Description() string { return "Check dependencies for known vulnerabilities (OSV.dev)" } -const osvAPIURL = "https://api.osv.dev/v1/query" +const ( + osvBatchAPIURL = "https://api.osv.dev/v1/querybatch" + // maxResponseBytes caps the OSV response body to prevent memory exhaustion. + maxResponseBytes = 1 << 20 // 1 MiB + // osvRetryMax is the maximum number of attempts for a single OSV request. + osvRetryMax = 3 +) + +var httpClient = &http.Client{Timeout: 30 * time.Second} -var httpClient = &http.Client{Timeout: 10 * time.Second} +// osvBatchQuery is the request body for the OSV.dev /v1/querybatch endpoint. +type osvBatchQuery struct { + Queries []osvQuery `json:"queries"` +} -// osvQuery is the request body for the OSV.dev API. +// osvQuery is a single query within a batch. type osvQuery struct { Package osvPackage `json:"package"` Version string `json:"version,omitempty"` @@ -36,16 +49,21 @@ type osvPackage struct { Ecosystem string `json:"ecosystem"` } -// osvResponse is the response from the OSV.dev API. +// osvBatchResponse is the response from /v1/querybatch. +type osvBatchResponse struct { + Results []osvResponse `json:"results"` +} + +// osvResponse is the per-query result. type osvResponse struct { Vulns []osvVuln `json:"vulns"` } type osvVuln struct { - ID string `json:"id"` - Summary string `json:"summary"` + ID string `json:"id"` + Summary string `json:"summary"` Severity []osvSeverity `json:"severity"` - Aliases []string `json:"aliases"` + Aliases []string `json:"aliases"` } type osvSeverity struct { @@ -53,50 +71,74 @@ type osvSeverity struct { Score string `json:"score"` } +// depEntry holds a (name, version, ecosystem) triple for batch queries. +type depEntry struct { + name string + version string + ecosystem string +} + func (d *Deps) Run(cfg *AuditConfig) ([]engine.Finding, error) { + ctx := context.Background() var findings []engine.Finding + var scanErrors []string // JavaScript / TypeScript if HasLanguage(cfg, "javascript") || HasLanguage(cfg, "typescript") { - f, err := d.checkNPM(cfg) - if err != nil && cfg.Verbose { - fmt.Fprintf(os.Stderr, " [!] deps: npm check error: %v\n", err) + f, err := d.checkNPM(ctx, cfg) + if err != nil { + scanErrors = append(scanErrors, fmt.Sprintf("npm: %v", err)) + if cfg.Verbose { + fmt.Fprintf(os.Stderr, " [!] deps: npm check error: %v\n", err) + } } findings = append(findings, f...) } // PHP if HasLanguage(cfg, "php") { - f, err := d.checkComposer(cfg) - if err != nil && cfg.Verbose { - fmt.Fprintf(os.Stderr, " [!] deps: composer check error: %v\n", err) + f, err := d.checkComposer(ctx, cfg) + if err != nil { + scanErrors = append(scanErrors, fmt.Sprintf("composer: %v", err)) + if cfg.Verbose { + fmt.Fprintf(os.Stderr, " [!] deps: composer check error: %v\n", err) + } } findings = append(findings, f...) } // Go if HasLanguage(cfg, "go") { - f, err := d.checkGo(cfg) - if err != nil && cfg.Verbose { - fmt.Fprintf(os.Stderr, " [!] deps: go check error: %v\n", err) + f, err := d.checkGo(ctx, cfg) + if err != nil { + scanErrors = append(scanErrors, fmt.Sprintf("go: %v", err)) + if cfg.Verbose { + fmt.Fprintf(os.Stderr, " [!] deps: go check error: %v\n", err) + } } findings = append(findings, f...) } // Python if HasLanguage(cfg, "python") { - f, err := d.checkPython(cfg) - if err != nil && cfg.Verbose { - fmt.Fprintf(os.Stderr, " [!] deps: python check error: %v\n", err) + f, err := d.checkPython(ctx, cfg) + if err != nil { + scanErrors = append(scanErrors, fmt.Sprintf("python: %v", err)) + if cfg.Verbose { + fmt.Fprintf(os.Stderr, " [!] deps: python check error: %v\n", err) + } } findings = append(findings, f...) } // Rust if HasLanguage(cfg, "rust") { - f, err := d.checkRust(cfg) - if err != nil && cfg.Verbose { - fmt.Fprintf(os.Stderr, " [!] deps: rust check error: %v\n", err) + f, err := d.checkRust(ctx, cfg) + if err != nil { + scanErrors = append(scanErrors, fmt.Sprintf("rust: %v", err)) + if cfg.Verbose { + fmt.Fprintf(os.Stderr, " [!] deps: rust check error: %v\n", err) + } } findings = append(findings, f...) } @@ -116,11 +158,14 @@ func (d *Deps) Run(cfg *AuditConfig) ([]engine.Finding, error) { } } + if len(scanErrors) > 0 { + return findings, fmt.Errorf("dependency scan incomplete: %s", strings.Join(scanErrors, "; ")) + } return findings, nil } // checkNPM reads package.json and queries OSV.dev for each dependency. -func (d *Deps) checkNPM(cfg *AuditConfig) ([]engine.Finding, error) { +func (d *Deps) checkNPM(ctx context.Context, cfg *AuditConfig) ([]engine.Finding, error) { pkgPath := filepath.Join(cfg.Path, "package.json") data, err := os.ReadFile(pkgPath) if err != nil { @@ -135,28 +180,21 @@ func (d *Deps) checkNPM(cfg *AuditConfig) ([]engine.Finding, error) { return nil, fmt.Errorf("parse package.json: %w", err) } - var findings []engine.Finding allDeps := mergeMaps(pkg.Dependencies, pkg.DevDependencies) - + entries := make([]depEntry, 0, len(allDeps)) for name, version := range allDeps { - version = cleanVersion(version) - vulns, err := queryOSV(name, version, "npm") - if err != nil { - if cfg.Verbose { - fmt.Fprintf(os.Stderr, " [!] deps: OSV query failed for %s: %v\n", name, err) - } - continue - } - for _, v := range vulns { - findings = append(findings, vulnToFinding(name, version, v)) - } + entries = append(entries, depEntry{ + name: name, + version: cleanVersion(version), + ecosystem: "npm", + }) } - return findings, nil + return queryOSVBatch(ctx, entries) } // checkComposer reads composer.lock and queries OSV.dev. -func (d *Deps) checkComposer(cfg *AuditConfig) ([]engine.Finding, error) { +func (d *Deps) checkComposer(ctx context.Context, cfg *AuditConfig) ([]engine.Finding, error) { lockPath := filepath.Join(cfg.Path, "composer.lock") data, err := os.ReadFile(lockPath) if err != nil { @@ -173,33 +211,33 @@ func (d *Deps) checkComposer(cfg *AuditConfig) ([]engine.Finding, error) { return nil, fmt.Errorf("parse composer.lock: %w", err) } - var findings []engine.Finding + entries := make([]depEntry, 0, len(lock.Packages)) for _, pkg := range lock.Packages { - version := cleanVersion(pkg.Version) - vulns, err := queryOSV(pkg.Name, version, "Packagist") - if err != nil { - continue - } - for _, v := range vulns { - findings = append(findings, vulnToFinding(pkg.Name, version, v)) - } + entries = append(entries, depEntry{ + name: pkg.Name, + version: cleanVersion(pkg.Version), + ecosystem: "Packagist", + }) } - return findings, nil + return queryOSVBatch(ctx, entries) } -// checkGo reads go.sum and queries OSV.dev. -func (d *Deps) checkGo(cfg *AuditConfig) ([]engine.Finding, error) { +// checkGo reads go.sum line by line and queries OSV.dev. +func (d *Deps) checkGo(ctx context.Context, cfg *AuditConfig) ([]engine.Finding, error) { sumPath := filepath.Join(cfg.Path, "go.sum") - data, err := os.ReadFile(sumPath) + f, err := os.Open(sumPath) if err != nil { return nil, nil } + defer f.Close() seen := make(map[string]bool) - var findings []engine.Finding + var entries []depEntry - for _, line := range strings.Split(string(data), "\n") { + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() parts := strings.Fields(line) if len(parts) < 2 { continue @@ -214,71 +252,74 @@ func (d *Deps) checkGo(cfg *AuditConfig) ([]engine.Finding, error) { } seen[key] = true - vulns, err := queryOSV(name, version, "Go") - if err != nil { - continue - } - for _, v := range vulns { - findings = append(findings, vulnToFinding(name, version, v)) - } + entries = append(entries, depEntry{ + name: name, + version: version, + ecosystem: "Go", + }) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read go.sum: %w", err) } - return findings, nil + return queryOSVBatch(ctx, entries) } -// checkPython reads requirements.txt and queries OSV.dev. -func (d *Deps) checkPython(cfg *AuditConfig) ([]engine.Finding, error) { +// checkPython reads requirements.txt line by line and queries OSV.dev. +func (d *Deps) checkPython(ctx context.Context, cfg *AuditConfig) ([]engine.Finding, error) { reqPath := filepath.Join(cfg.Path, "requirements.txt") - data, err := os.ReadFile(reqPath) + f, err := os.Open(reqPath) if err != nil { return nil, nil } + defer f.Close() - var findings []engine.Finding - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) + var entries []depEntry + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { continue } - name, version := parsePythonDep(line) if name == "" || version == "" { continue } - - vulns, err := queryOSV(name, version, "PyPI") - if err != nil { - continue - } - for _, v := range vulns { - findings = append(findings, vulnToFinding(name, version, v)) - } + entries = append(entries, depEntry{ + name: name, + version: version, + ecosystem: "PyPI", + }) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read requirements.txt: %w", err) } - return findings, nil + return queryOSVBatch(ctx, entries) } -// checkRust reads Cargo.lock and queries OSV.dev. -func (d *Deps) checkRust(cfg *AuditConfig) ([]engine.Finding, error) { +// checkRust reads Cargo.lock line by line and queries OSV.dev. +func (d *Deps) checkRust(ctx context.Context, cfg *AuditConfig) ([]engine.Finding, error) { lockPath := filepath.Join(cfg.Path, "Cargo.lock") - data, err := os.ReadFile(lockPath) + f, err := os.Open(lockPath) if err != nil { return nil, nil } + defer f.Close() - var findings []engine.Finding - // Simple TOML parsing for Cargo.lock [[package]] entries + var entries []depEntry var currentName, currentVersion string - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) if line == "[[package]]" { if currentName != "" && currentVersion != "" { - vulns, err := queryOSV(currentName, currentVersion, "crates.io") - if err == nil { - for _, v := range vulns { - findings = append(findings, vulnToFinding(currentName, currentVersion, v)) - } - } + entries = append(entries, depEntry{ + name: currentName, + version: currentVersion, + ecosystem: "crates.io", + }) } currentName = "" currentVersion = "" @@ -291,55 +332,112 @@ func (d *Deps) checkRust(cfg *AuditConfig) ([]engine.Finding, error) { currentVersion = strings.Trim(strings.TrimPrefix(line, "version = "), `"`) } } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read Cargo.lock: %w", err) + } // Process last package if currentName != "" && currentVersion != "" { - vulns, err := queryOSV(currentName, currentVersion, "crates.io") - if err == nil { - for _, v := range vulns { - findings = append(findings, vulnToFinding(currentName, currentVersion, v)) - } - } + entries = append(entries, depEntry{ + name: currentName, + version: currentVersion, + ecosystem: "crates.io", + }) } - return findings, nil + return queryOSVBatch(ctx, entries) } -// queryOSV queries the OSV.dev API for vulnerabilities. -func queryOSV(name, version, ecosystem string) ([]osvVuln, error) { - query := osvQuery{ - Package: osvPackage{ - Name: name, - Ecosystem: ecosystem, - }, - Version: version, +// queryOSVBatch queries the OSV.dev /v1/querybatch endpoint for a list of +// dependencies in a single HTTP call, with retry/backoff on transient errors. +func queryOSVBatch(ctx context.Context, entries []depEntry) ([]engine.Finding, error) { + if len(entries) == 0 { + return nil, nil } - body, err := json.Marshal(query) - if err != nil { - return nil, err + queries := make([]osvQuery, len(entries)) + for i, e := range entries { + queries[i] = osvQuery{ + Package: osvPackage{Name: e.name, Ecosystem: e.ecosystem}, + Version: e.version, + } } - resp, err := httpClient.Post(osvAPIURL, "application/json", bytes.NewReader(body)) + reqBody, err := json.Marshal(osvBatchQuery{Queries: queries}) if err != nil { - return nil, fmt.Errorf("OSV API unreachable: %w", err) + return nil, fmt.Errorf("marshal OSV batch query: %w", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("OSV API returned status %d", resp.StatusCode) + var batchResp osvBatchResponse + if err := doOSVRequestWithRetry(ctx, reqBody, &batchResp); err != nil { + return nil, err } - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + var findings []engine.Finding + for i, res := range batchResp.Results { + if i >= len(entries) { + break + } + e := entries[i] + for _, v := range res.Vulns { + findings = append(findings, vulnToFinding(e.name, e.version, v)) + } } - var result osvResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, err + return findings, nil +} + +// doOSVRequestWithRetry performs the HTTP POST to the OSV batch endpoint with +// exponential backoff on transient status codes (429, 500, 502, 503, 504). +func doOSVRequestWithRetry(ctx context.Context, body []byte, dest interface{}) error { + var lastErr error + for attempt := 0; attempt < osvRetryMax; attempt++ { + if attempt > 0 { + // Exponential backoff: 1s, 2s, 4s (capped) + wait := time.Duration(1<= 0 && score <= 10 { return score } - // Default return 5.0 } diff --git a/pkg/local/secrets.go b/pkg/local/secrets.go index 25b4dc3..05a6a34 100644 --- a/pkg/local/secrets.go +++ b/pkg/local/secrets.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "path/filepath" "regexp" "strings" @@ -231,12 +232,9 @@ func (s *Secrets) scanFile(path string, cfg *AuditConfig) ([]engine.Finding, err } func relativeToRoot(path, root string) string { - rel, err := os.Getwd() - _ = rel - if r, err := strings.CutPrefix(path, root); err { - return strings.TrimPrefix(r, string(os.PathSeparator)) + if rel, err := filepath.Rel(root, path); err == nil { + return rel } - _ = err return path } diff --git a/pkg/modules/cookies.go b/pkg/modules/cookies.go index 4586aed..bdd17cd 100644 --- a/pkg/modules/cookies.go +++ b/pkg/modules/cookies.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "strings" @@ -12,7 +13,7 @@ type Cookies struct{} func (c *Cookies) Name() string { return "cookies" } func (c *Cookies) Description() string { return "Cookie security flags analysis" } -func (c *Cookies) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (c *Cookies) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) resp, _, err := doGet(client, cfg.TargetURL, cfg.UserAgent) if err != nil { diff --git a/pkg/modules/cors.go b/pkg/modules/cors.go index 33ff7b6..3de30c1 100644 --- a/pkg/modules/cors.go +++ b/pkg/modules/cors.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net/http" "strings" @@ -13,7 +14,7 @@ type CORS struct{} func (c *CORS) Name() string { return "cors" } func (c *CORS) Description() string { return "CORS misconfiguration testing" } -func (c *CORS) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (c *CORS) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) var findings []engine.Finding diff --git a/pkg/modules/discovery.go b/pkg/modules/discovery.go index dade09a..462b2e8 100644 --- a/pkg/modules/discovery.go +++ b/pkg/modules/discovery.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net" "net/http" @@ -15,7 +16,7 @@ type Discovery struct{} func (d *Discovery) Name() string { return "discovery" } func (d *Discovery) Description() string { return "Technology fingerprinting, sensitive paths, DNS recon" } -func (d *Discovery) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (d *Discovery) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) resp, body, err := doGet(client, cfg.TargetURL, cfg.UserAgent) if err != nil { diff --git a/pkg/modules/headers.go b/pkg/modules/headers.go index bdcb871..22b9d84 100644 --- a/pkg/modules/headers.go +++ b/pkg/modules/headers.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "strings" @@ -12,7 +13,7 @@ type Headers struct{} func (h *Headers) Name() string { return "headers" } func (h *Headers) Description() string { return "Security headers analysis" } -func (h *Headers) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (h *Headers) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) resp, _, err := doGet(client, cfg.TargetURL, cfg.UserAgent) if err != nil { diff --git a/pkg/modules/httpmethods.go b/pkg/modules/httpmethods.go index 80e2356..8d00530 100644 --- a/pkg/modules/httpmethods.go +++ b/pkg/modules/httpmethods.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "io" "net/http" @@ -14,7 +15,7 @@ type HTTPMethods struct{} func (h *HTTPMethods) Name() string { return "httpmethods" } func (h *HTTPMethods) Description() string { return "HTTP methods testing (OPTIONS, TRACE, PUT, DELETE)" } -func (h *HTTPMethods) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (h *HTTPMethods) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) var findings []engine.Finding diff --git a/pkg/modules/info.go b/pkg/modules/info.go index e51eb71..ff30113 100644 --- a/pkg/modules/info.go +++ b/pkg/modules/info.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "regexp" "strings" @@ -13,7 +14,7 @@ type InfoDisclosure struct{} func (i *InfoDisclosure) Name() string { return "info" } func (i *InfoDisclosure) Description() string { return "Information disclosure in HTML, JS, and comments" } -func (i *InfoDisclosure) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (i *InfoDisclosure) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) _, body, err := doGet(client, cfg.TargetURL, cfg.UserAgent) if err != nil { diff --git a/pkg/modules/jsdiscovery.go b/pkg/modules/jsdiscovery.go index 70ce74a..5fb7ac5 100644 --- a/pkg/modules/jsdiscovery.go +++ b/pkg/modules/jsdiscovery.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "io" "net/http" @@ -79,7 +80,7 @@ type endpoint struct { External bool } -func (j *JSDiscovery) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (j *JSDiscovery) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) host := extractHost(cfg.TargetURL) diff --git a/pkg/modules/login.go b/pkg/modules/login.go index e0a2489..c945827 100644 --- a/pkg/modules/login.go +++ b/pkg/modules/login.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "io" "net/http" @@ -23,7 +24,7 @@ var loginPaths = []string{ "/wp-login.php", "/wp-admin", } -func (l *Login) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (l *Login) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) var findings []engine.Finding diff --git a/pkg/modules/portscan.go b/pkg/modules/portscan.go index 0e57b82..a7fc2e0 100644 --- a/pkg/modules/portscan.go +++ b/pkg/modules/portscan.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net" "strings" @@ -44,7 +45,7 @@ var commonPorts = []portInfo{ {27017, "MongoDB", true}, } -func (p *PortScan) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (p *PortScan) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { host := extractHost(cfg.TargetURL) if host == "" { return nil, fmt.Errorf("could not extract host from %s", cfg.TargetURL) diff --git a/pkg/modules/redirect.go b/pkg/modules/redirect.go index 3300b36..b1fa220 100644 --- a/pkg/modules/redirect.go +++ b/pkg/modules/redirect.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net/url" "strings" @@ -34,7 +35,7 @@ var redirectPayloads = []string{ "javascript:alert(1)", } -func (o *OpenRedirect) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (o *OpenRedirect) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newNoRedirectClient(cfg) base := strings.TrimRight(cfg.TargetURL, "/") diff --git a/pkg/modules/sqli.go b/pkg/modules/sqli.go index ffb9f2c..49664e9 100644 --- a/pkg/modules/sqli.go +++ b/pkg/modules/sqli.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net/http" "net/url" @@ -89,7 +90,7 @@ type sqliEndpoint struct { name string } -func (s *SQLi) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (s *SQLi) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) base := strings.TrimRight(cfg.TargetURL, "/") diff --git a/pkg/modules/subdomain.go b/pkg/modules/subdomain.go index 5ad22f0..6acbafb 100644 --- a/pkg/modules/subdomain.go +++ b/pkg/modules/subdomain.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net" "net/http" @@ -45,7 +46,7 @@ type subdomainResult struct { httpsOK bool } -func (s *Subdomain) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (s *Subdomain) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { host := extractHost(cfg.TargetURL) if host == "" { return nil, fmt.Errorf("could not extract host from %s", cfg.TargetURL) diff --git a/pkg/modules/tls.go b/pkg/modules/tls.go index 6c56659..7a8a893 100644 --- a/pkg/modules/tls.go +++ b/pkg/modules/tls.go @@ -1,6 +1,7 @@ package modules import ( + "context" "crypto/tls" "crypto/x509" "fmt" @@ -16,7 +17,7 @@ type TLS struct{} func (t *TLS) Name() string { return "tls" } func (t *TLS) Description() string { return "TLS/SSL certificate and configuration analysis" } -func (t *TLS) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (t *TLS) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { host := extractHost(cfg.TargetURL) if host == "" { return nil, fmt.Errorf("could not extract host from %s", cfg.TargetURL) diff --git a/pkg/modules/traversal.go b/pkg/modules/traversal.go index 15fc5de..44e0b3c 100644 --- a/pkg/modules/traversal.go +++ b/pkg/modules/traversal.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net/http" "net/url" @@ -50,7 +51,7 @@ var ( windowsIniRe = regexp.MustCompile(`(?i)\[(extensions|fonts)\]`) ) -func (p *PathTraversal) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (p *PathTraversal) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) base := strings.TrimRight(cfg.TargetURL, "/") diff --git a/pkg/modules/webservice.go b/pkg/modules/webservice.go index 6b07af2..74b323e 100644 --- a/pkg/modules/webservice.go +++ b/pkg/modules/webservice.go @@ -1,6 +1,7 @@ package modules import ( + "context" "encoding/xml" "fmt" "io" @@ -15,7 +16,7 @@ type Webservice struct{} func (w *Webservice) Name() string { return "webservice" } func (w *Webservice) Description() string { return "API/webservice discovery and security testing" } -func (w *Webservice) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (w *Webservice) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) base := strings.TrimRight(cfg.TargetURL, "/") diff --git a/pkg/modules/xss.go b/pkg/modules/xss.go index 27f1ab0..355d767 100644 --- a/pkg/modules/xss.go +++ b/pkg/modules/xss.go @@ -1,6 +1,7 @@ package modules import ( + "context" "fmt" "net/http" "net/url" @@ -15,7 +16,7 @@ type XSS struct{} func (x *XSS) Name() string { return "xss" } func (x *XSS) Description() string { return "Reflected XSS detection via form inputs and URL parameters" } -func (x *XSS) Run(cfg *engine.Config) ([]engine.Finding, error) { +func (x *XSS) Run(ctx context.Context, cfg *engine.Config) ([]engine.Finding, error) { client := newHTTPClient(cfg) base := strings.TrimRight(cfg.TargetURL, "/") diff --git a/pkg/report/html.go b/pkg/report/html.go index e2ed354..9bf26c9 100644 --- a/pkg/report/html.go +++ b/pkg/report/html.go @@ -12,21 +12,20 @@ import ( ) type htmlData struct { - Target string - Date string - Score int - Grade engine.Grade - GradeColor string - ScoreColor string - Findings []engine.Finding - Modules []htmlModule - Summary map[string]int + Target string + Date string + Score int + Grade engine.Grade + GradeColor string + ScoreColor string + Findings []engine.Finding + Modules []htmlModule CriticalCount int - HighCount int - MediumCount int - LowCount int - InfoCount int - TotalCount int + HighCount int + MediumCount int + LowCount int + InfoCount int + TotalCount int } type htmlModule struct { diff --git a/pkg/report/markdown.go b/pkg/report/markdown.go index 6ee0cbd..501b2e0 100644 --- a/pkg/report/markdown.go +++ b/pkg/report/markdown.go @@ -74,7 +74,7 @@ func gradeToEmoji(g engine.Grade) string { case engine.GradeA: return "🟢" case engine.GradeB: - return "🟢" + return "🟩" case engine.GradeC: return "🟡" case engine.GradeD: diff --git a/pkg/report/sarif.go b/pkg/report/sarif.go index ccc12c4..2cb2af0 100644 --- a/pkg/report/sarif.go +++ b/pkg/report/sarif.go @@ -1,6 +1,8 @@ package report import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" @@ -73,8 +75,8 @@ func WriteSARIF(result engine.ScoreResult, path string) error { rules := make(map[string]sarifRule) var results []sarifResult - for i, f := range result.Findings { - ruleID := fmt.Sprintf("VX-%s-%03d", f.Module, i) + for _, f := range result.Findings { + ruleID := sarifRuleID(f) if _, exists := rules[ruleID]; !exists { rules[ruleID] = sarifRule{ @@ -140,6 +142,16 @@ func WriteSARIF(result engine.ScoreResult, path string) error { return enc.Encode(log) } +// sarifRuleID returns a stable rule identifier derived from the finding's module, +// CWE, and title. Two scans of the same finding always produce the same ID. +func sarifRuleID(f engine.Finding) string { + if f.CWE != "" { + return fmt.Sprintf("VX-%s-%s", f.Module, f.CWE) + } + sum := sha256.Sum256([]byte(f.Module + ":" + f.Title)) + return fmt.Sprintf("VX-%s-%s", f.Module, hex.EncodeToString(sum[:4])) +} + func sarifLevel(s engine.Severity) string { switch s { case engine.SevCritical, engine.SevHigh: