diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e05d595..af78f75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write # Required for keyless cosign signing jobs: release: @@ -30,3 +31,40 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install cosign + uses: sigstore/cosign-installer@v4.0.0 + + - name: Sign release artifacts + run: | + # Sign all tar.gz files + for file in dist/*.tar.gz; do + if [ -f "$file" ]; then + echo "Signing $file" + cosign sign-blob --yes "$file" \ + --bundle "${file}.sigstore.json" + fi + done + + # Sign all zip files + for file in dist/*.zip; do + if [ -f "$file" ]; then + echo "Signing $file" + cosign sign-blob --yes "$file" \ + --bundle "${file}.sigstore.json" + fi + done + + # Sign checksums file + if [ -f "dist/checksums.txt" ]; then + echo "Signing checksums.txt" + cosign sign-blob --yes "dist/checksums.txt" \ + --bundle "dist/checksums.txt.sigstore.json" + fi + + - name: Upload signatures to release + uses: softprops/action-gh-release@v2 + with: + files: dist/*.sigstore.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index befad41..093a24a 100644 --- a/Makefile +++ b/Makefile @@ -45,3 +45,10 @@ lint: ## Run linter @which golangci-lint || go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest golangci-lint run +.PHONY: release +release: ## Create a release using GoReleaser + goreleaser release --clean + +.PHONY: release-snapshot +release-snapshot: ## Create a snapshot release (no tagging required) + goreleaser build --snapshot --clean --single-target diff --git a/README.md b/README.md index f094a92..1cb8d92 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,35 @@ sudo mv wraith osv-scanner /usr/local/bin/ For Windows, download the `.zip` file from the releases page and extract both `wraith.exe` and `osv-scanner.exe` to a directory in your PATH. +### Verifying Release Signatures + +All release artifacts are signed with [Sigstore cosign](https://github.com/sigstore/cosign) for supply chain security. + +```bash +# Install cosign +brew install cosign # macOS +# or download from https://github.com/sigstore/cosign/releases + +# Verify a release artifact +cosign verify-blob wraith_linux_amd64.tar.gz \ + --bundle wraith_linux_amd64.tar.gz.sigstore.json \ + --certificate-identity-regexp 'https://github.com/ghostsecurity/wraith/.github/workflows/release.yml' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' +``` + +**macOS Security Warning:** + +When running the binary on macOS, you may see a Gatekeeper warning. This is because the binary is not signed with an Apple Developer certificate. To bypass: + +```bash +# Remove quarantine attribute +xattr -d com.apple.quarantine ./wraith ./osv-scanner + +# Or right-click the binary in Finder and select "Open" +``` + +The binaries are safe to run - verify with cosign signatures above. + ## CLI Usage ```bash @@ -50,10 +79,74 @@ wraith scan --output report.md go.mod > **Note:** Flags must come before the lockfile path. +### Offline Mode + +Wraith can scan without network access using a locally cached vulnerability database. + +```bash +# Download the vulnerability database for offline use +wraith download-db + +# Scan using only the local database (no network requests) +wraith scan --offline go.mod + +# Download fresh database and scan in one command +wraith scan --offline --download-db go.mod +``` + +The database is stored in: +- Linux: `~/.cache/osv-scanner/` +- macOS: `~/Library/Caches/osv-scanner/` +- Windows: `%LOCALAPPDATA%\osv-scanner\` + +### License Scanning + +Check dependencies for license compliance. + +```bash +# Show license information for all dependencies +wraith scan --licenses go.mod + +# Enforce a license allowlist (fails if dependencies use other licenses) +wraith scan --license-allowlist MIT,Apache-2.0,BSD-3-Clause go.mod +``` + +When using `--license-allowlist`, the scan will fail (exit code 1) if any dependency has a license not in the allowlist. Dependencies with undetectable licenses are reported as `UNKNOWN`. + +### Custom Configuration + +Use an [osv-scanner config file](https://google.github.io/osv-scanner/configuration/) for advanced options like ignoring specific vulnerabilities or packages. + +Config files are automatically discovered in these locations (no flag needed): +- `osv-scanner.toml` in the current directory +- `.osv-scanner.toml` in the current directory +- Parent directories (walking up) + +```bash +# Automatic discovery - just add osv-scanner.toml to your repo +wraith scan go.mod + +# Or specify a config file explicitly +wraith scan --config /path/to/custom-config.toml go.mod +``` + +Example `osv-scanner.toml`: +```toml +[[IgnoredVulns]] +id = "GO-2024-1234" +reason = "False positive - not exploitable in our usage" + +[[PackageOverrides]] +name = "stdlib" +ecosystem = "Go" +ignore = true +reason = "Go stdlib license (BSD-3-Clause) not detected by osv-scanner" +``` + ### Exit Codes -- `0`: No vulnerabilities found -- `1`: Vulnerabilities found or error occurred +- `0`: No vulnerabilities or license violations found +- `1`: Vulnerabilities found, license violations found, or error occurred ## Library Installation @@ -113,13 +206,17 @@ func main() { // Set custom timeout scanner.SetTimeout(10 * time.Minute) - // Scan lockfile - result, err := scanner.ScanLockfile("poetry.lock") + // Scan with options + result, err := scanner.ScanLockfile("poetry.lock", + wraith.WithOffline(), // Use local DB only + wraith.WithLicenseAllowlist("MIT", "Apache-2.0"), // Check licenses + wraith.WithConfigFile("osv-scanner.toml"), // Custom config + ) if err != nil { log.Fatalf("Scan failed: %v", err) } - // Process results + // Process vulnerability results for _, pkg := range result.GetPackagesWithVulnerabilities() { fmt.Printf("Package: %s v%s (%s)\n", pkg.Package.Name, @@ -127,12 +224,30 @@ func main() { pkg.Package.Ecosystem) for _, vuln := range pkg.Vulnerabilities { - fmt.Printf(" 🚨 %s: %s\n", vuln.ID, vuln.Summary) + fmt.Printf(" - %s: %s\n", vuln.ID, vuln.Summary) } } + + // Process license violations + for _, pkg := range result.GetPackagesWithLicenseViolations() { + fmt.Printf("License violation: %s v%s - %v\n", + pkg.Package.Name, + pkg.Package.Version, + pkg.LicenseViolations) + } } ``` +### Scan Options + +| Option | Description | +|--------|-------------| +| `WithOffline()` | Scan using only the local vulnerability database | +| `WithOfflineDownload()` | Download/refresh the local database before scanning | +| `WithConfigFile(path)` | Use a custom osv-scanner config file | +| `WithLicenses()` | Enable license scanning (informational) | +| `WithLicenseAllowlist(licenses...)` | Enforce a license allowlist | + ### Pipeline Integration ```go diff --git a/cmd/wraith/main.go b/cmd/wraith/main.go index c49c6ec..e9f65e1 100644 --- a/cmd/wraith/main.go +++ b/cmd/wraith/main.go @@ -33,6 +33,8 @@ func main() { switch os.Args[1] { case "scan": os.Exit(runScan(os.Args[2:])) + case "download-db": + os.Exit(runDownloadDB()) case "version", "--version", "-v": fmt.Printf("wraith %s\n", version) os.Exit(0) @@ -46,22 +48,58 @@ func main() { } } +func runDownloadDB() int { + fmt.Println("Downloading vulnerability database for offline scanning...") + + scanner, err := wraith.NewScanner() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Use a longer timeout for database download + scanner.SetTimeout(10 * time.Minute) + + if err := scanner.DownloadDB(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + fmt.Println("Database downloaded successfully. You can now use --offline for scans.") + return 0 +} + func runScan(args []string) int { fs := flag.NewFlagSet("scan", flag.ExitOnError) format := fs.String("format", "text", "Output format: text, json, md") outputFile := fs.String("output", "", "Write output to file (implies --format md if .md extension)") noColor := fs.Bool("no-color", false, "Disable colored output (text format only)") + // Offline mode flags + offline := fs.Bool("offline", false, "Scan using only local vulnerability database (no network)") + downloadDB := fs.Bool("download-db", false, "Download/refresh local vulnerability database before scanning") + + // Config file flag + configFile := fs.String("config", "", "Path to custom osv-scanner config file") + + // License scanning flags + licenses := fs.Bool("licenses", false, "Enable license scanning") + licenseAllowlist := fs.String("license-allowlist", "", "Comma-separated list of allowed licenses (e.g., MIT,Apache-2.0)") + fs.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: wraith scan [options] \n\n") fmt.Fprintf(os.Stderr, "Scan a lockfile for known vulnerabilities.\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fs.PrintDefaults() fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " wraith scan go.mod # Text output to terminal\n") - fmt.Fprintf(os.Stderr, " wraith scan --format json go.mod # JSON output to stdout\n") - fmt.Fprintf(os.Stderr, " wraith scan --format md go.mod # Markdown output to stdout\n") - fmt.Fprintf(os.Stderr, " wraith scan --output report.md go.mod # Write markdown to file\n") + fmt.Fprintf(os.Stderr, " wraith scan go.mod # Text output to terminal\n") + fmt.Fprintf(os.Stderr, " wraith scan --format json go.mod # JSON output to stdout\n") + fmt.Fprintf(os.Stderr, " wraith scan --format md go.mod # Markdown output to stdout\n") + fmt.Fprintf(os.Stderr, " wraith scan --output report.md go.mod # Write markdown to file\n") + fmt.Fprintf(os.Stderr, " wraith scan --offline --download-db go.mod # Offline scan with DB refresh\n") + fmt.Fprintf(os.Stderr, " wraith scan --config osv-scanner.toml go.mod # Use custom config\n") + fmt.Fprintf(os.Stderr, " wraith scan --licenses go.mod # Include license info\n") + fmt.Fprintf(os.Stderr, " wraith scan --license-allowlist MIT,Apache-2.0 go.mod # Check license violations\n") } if err := fs.Parse(args); err != nil { @@ -84,7 +122,32 @@ func runScan(args []string) int { useColor := !*noColor && isTerminal() && *outputFile == "" - result, err := wraith.QuickScan(lockfile) + // Create scanner + scanner, err := wraith.NewScanner() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Build scan options + var options []wraith.ScanOption + if *offline { + options = append(options, wraith.WithOffline()) + } + if *downloadDB { + options = append(options, wraith.WithOfflineDownload()) + } + if *configFile != "" { + options = append(options, wraith.WithConfigFile(*configFile)) + } + if *licenseAllowlist != "" { + licenses := strings.Split(*licenseAllowlist, ",") + options = append(options, wraith.WithLicenseAllowlist(licenses...)) + } else if *licenses { + options = append(options, wraith.WithLicenses()) + } + + result, err := scanner.ScanLockfile(lockfile, options...) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 @@ -121,13 +184,19 @@ func runScan(args []string) int { func formatJSON(result *wraith.OSVScanResult) (string, int) { output := struct { - PackageCount int `json:"package_count"` - VulnerabilityCount int `json:"vulnerability_count"` - Results []wraith.ScanResult `json:"results"` + PackageCount int `json:"package_count"` + VulnerabilityCount int `json:"vulnerability_count"` + LicenseViolationCount int `json:"license_violation_count,omitempty"` + LicenseSummary []wraith.LicenseSummary `json:"license_summary,omitempty"` + Results []wraith.ScanResult `json:"results"` + LicenseViolationDetails []licenseViolation `json:"license_violations,omitempty"` }{ - PackageCount: result.GetPackageCount(), - VulnerabilityCount: result.GetVulnerabilityCount(), - Results: result.ToSimplifiedResults(), + PackageCount: result.GetPackageCount(), + VulnerabilityCount: result.GetVulnerabilityCount(), + LicenseViolationCount: result.GetLicenseViolationCount(), + LicenseSummary: result.LicenseSummary, + Results: result.ToSimplifiedResults(), + LicenseViolationDetails: getLicenseViolations(result), } data, err := json.MarshalIndent(output, "", " ") @@ -136,17 +205,39 @@ func formatJSON(result *wraith.OSVScanResult) (string, int) { } exitCode := 0 - if result.GetVulnerabilityCount() > 0 { + if result.GetVulnerabilityCount() > 0 || result.HasLicenseViolations() { exitCode = 1 } return string(data) + "\n", exitCode } +type licenseViolation struct { + Package string `json:"package"` + Version string `json:"version"` + Licenses []string `json:"licenses"` + Violations []string `json:"violations"` +} + +func getLicenseViolations(result *wraith.OSVScanResult) []licenseViolation { + var violations []licenseViolation + for _, pkg := range result.GetPackagesWithLicenseViolations() { + violations = append(violations, licenseViolation{ + Package: pkg.Package.Name, + Version: pkg.Package.Version, + Licenses: pkg.Licenses, + Violations: pkg.LicenseViolations, + }) + } + return violations +} + func formatMarkdown(result *wraith.OSVScanResult, lockfile string) (string, int) { var sb strings.Builder pkgCount := result.GetPackageCount() vulnCount := result.GetVulnerabilityCount() vulnPackages := result.GetPackagesWithVulnerabilities() + licenseViolationCount := result.GetLicenseViolationCount() + licenseViolationPackages := result.GetPackagesWithLicenseViolations() sb.WriteString("# Vulnerability Scan Report\n\n") sb.WriteString(fmt.Sprintf("**Scanned:** `%s` \n", lockfile)) @@ -157,48 +248,83 @@ func formatMarkdown(result *wraith.OSVScanResult, lockfile string) (string, int) sb.WriteString("|--------|-------|\n") sb.WriteString(fmt.Sprintf("| Packages scanned | %d |\n", pkgCount)) sb.WriteString(fmt.Sprintf("| Vulnerabilities found | %d |\n", vulnCount)) - sb.WriteString(fmt.Sprintf("| Affected packages | %d |\n\n", len(vulnPackages))) + sb.WriteString(fmt.Sprintf("| Affected packages | %d |\n", len(vulnPackages))) + if licenseViolationCount > 0 || len(result.LicenseSummary) > 0 { + sb.WriteString(fmt.Sprintf("| License violations | %d |\n", licenseViolationCount)) + } + sb.WriteString("\n") + + // License summary + if len(result.LicenseSummary) > 0 { + sb.WriteString("## License Summary\n\n") + sb.WriteString("| License | Count |\n") + sb.WriteString("|---------|-------|\n") + for _, ls := range result.LicenseSummary { + sb.WriteString(fmt.Sprintf("| %s | %d |\n", ls.Name, ls.Count)) + } + sb.WriteString("\n") + } + + hasIssues := vulnCount > 0 || licenseViolationCount > 0 - if vulnCount == 0 { - sb.WriteString("✅ **No vulnerabilities found!**\n") + if !hasIssues { + sb.WriteString("✅ **No issues found!**\n") return sb.String(), 0 } - sb.WriteString("## Vulnerabilities\n\n") + // Vulnerabilities section + if vulnCount > 0 { + sb.WriteString("## Vulnerabilities\n\n") - for _, pkg := range vulnPackages { - sb.WriteString(fmt.Sprintf("### %s@%s\n\n", pkg.Package.Name, pkg.Package.Version)) - sb.WriteString(fmt.Sprintf("**Ecosystem:** %s\n\n", pkg.Package.Ecosystem)) + for _, pkg := range vulnPackages { + sb.WriteString(fmt.Sprintf("### %s@%s\n\n", pkg.Package.Name, pkg.Package.Version)) + sb.WriteString(fmt.Sprintf("**Ecosystem:** %s\n\n", pkg.Package.Ecosystem)) - for _, vuln := range pkg.Vulnerabilities { - sb.WriteString(fmt.Sprintf("#### %s\n\n", vuln.ID)) + for _, vuln := range pkg.Vulnerabilities { + sb.WriteString(fmt.Sprintf("#### %s\n\n", vuln.ID)) - if vuln.Summary != "" { - sb.WriteString(fmt.Sprintf("%s\n\n", vuln.Summary)) - } + if vuln.Summary != "" { + sb.WriteString(fmt.Sprintf("%s\n\n", vuln.Summary)) + } - // CVEs - cves := extractCVEs(vuln.Aliases) - if len(cves) > 0 { - sb.WriteString(fmt.Sprintf("**CVEs:** %s\n\n", strings.Join(cves, ", "))) - } + // CVEs + cves := extractCVEs(vuln.Aliases) + if len(cves) > 0 { + sb.WriteString(fmt.Sprintf("**CVEs:** %s\n\n", strings.Join(cves, ", "))) + } - // Severity - if len(vuln.Severity) > 0 { - sb.WriteString(fmt.Sprintf("**Severity:** `%s`\n\n", vuln.Severity[0].Score)) - } + // Severity + if len(vuln.Severity) > 0 { + sb.WriteString(fmt.Sprintf("**Severity:** `%s`\n\n", vuln.Severity[0].Score)) + } - // References - if len(vuln.References) > 0 { - sb.WriteString("**References:**\n") - for _, ref := range vuln.References { - sb.WriteString(fmt.Sprintf("- [%s](%s)\n", ref.URL, ref.URL)) + // References + if len(vuln.References) > 0 { + sb.WriteString("**References:**\n") + for _, ref := range vuln.References { + sb.WriteString(fmt.Sprintf("- [%s](%s)\n", ref.URL, ref.URL)) + } + sb.WriteString("\n") } - sb.WriteString("\n") + + sb.WriteString("---\n\n") } + } + } - sb.WriteString("---\n\n") + // License violations section + if licenseViolationCount > 0 { + sb.WriteString("## License Violations\n\n") + sb.WriteString("| Package | Version | License | Violation |\n") + sb.WriteString("|---------|---------|---------|----------|\n") + for _, pkg := range licenseViolationPackages { + sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", + pkg.Package.Name, + pkg.Package.Version, + strings.Join(pkg.Licenses, ", "), + strings.Join(pkg.LicenseViolations, ", "))) } + sb.WriteString("\n") } return sb.String(), 1 @@ -209,6 +335,8 @@ func formatText(result *wraith.OSVScanResult, useColor bool) (string, int) { pkgCount := result.GetPackageCount() vulnCount := result.GetVulnerabilityCount() vulnPackages := result.GetPackagesWithVulnerabilities() + licenseViolationCount := result.GetLicenseViolationCount() + licenseViolationPackages := result.GetPackagesWithLicenseViolations() sb.WriteString(fmt.Sprintf("\n%s\n", divider(50))) sb.WriteString(fmt.Sprintf("%s SCAN SUMMARY %s\n", bold("", useColor), "")) @@ -216,15 +344,39 @@ func formatText(result *wraith.OSVScanResult, useColor bool) (string, int) { sb.WriteString(fmt.Sprintf("Packages scanned: %s\n", bold(fmt.Sprintf("%d", pkgCount), useColor))) - if vulnCount == 0 { - sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n\n", green("0", useColor))) - sb.WriteString(fmt.Sprintf("%s No vulnerabilities found!\n\n", green("✓", useColor))) + // Show license summary if available + if len(result.LicenseSummary) > 0 { + sb.WriteString(fmt.Sprintf("\n%s License Summary %s\n", bold("", useColor), "")) + for _, ls := range result.LicenseSummary { + sb.WriteString(fmt.Sprintf(" %s: %d\n", ls.Name, ls.Count)) + } + sb.WriteString("\n") + } + + hasIssues := vulnCount > 0 || licenseViolationCount > 0 + + if !hasIssues { + sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", green("0", useColor))) + if licenseViolationCount == 0 && len(result.LicenseSummary) > 0 { + sb.WriteString(fmt.Sprintf("License violations: %s\n", green("0", useColor))) + } + sb.WriteString(fmt.Sprintf("\n%s No issues found!\n\n", green("✓", useColor))) return sb.String(), 0 } - sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", red(fmt.Sprintf("%d", vulnCount), useColor))) - sb.WriteString(fmt.Sprintf("Affected packages: %s\n\n", red(fmt.Sprintf("%d", len(vulnPackages)), useColor))) + if vulnCount > 0 { + sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", red(fmt.Sprintf("%d", vulnCount), useColor))) + sb.WriteString(fmt.Sprintf("Affected packages: %s\n", red(fmt.Sprintf("%d", len(vulnPackages)), useColor))) + } else { + sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", green("0", useColor))) + } + + if licenseViolationCount > 0 { + sb.WriteString(fmt.Sprintf("License violations: %s\n", red(fmt.Sprintf("%d", licenseViolationCount), useColor))) + } + sb.WriteString("\n") + // Display vulnerabilities for _, pkg := range vulnPackages { sb.WriteString(fmt.Sprintf("%s %s %s (%s)\n", red("●", useColor), @@ -250,7 +402,20 @@ func formatText(result *wraith.OSVScanResult, useColor bool) (string, int) { sb.WriteString("\n") } - sb.WriteString(fmt.Sprintf("%s Review and address the vulnerabilities above.\n\n", yellow("!", useColor))) + // Display license violations + if licenseViolationCount > 0 { + sb.WriteString(fmt.Sprintf("%s LICENSE VIOLATIONS %s\n\n", bold("", useColor), "")) + for _, pkg := range licenseViolationPackages { + sb.WriteString(fmt.Sprintf("%s %s %s\n", + red("●", useColor), + bold(pkg.Package.Name, useColor), + cyan(pkg.Package.Version, useColor))) + sb.WriteString(fmt.Sprintf(" License: %s\n", strings.Join(pkg.Licenses, ", "))) + sb.WriteString(fmt.Sprintf(" Violation: %s\n\n", red(strings.Join(pkg.LicenseViolations, ", "), useColor))) + } + } + + sb.WriteString(fmt.Sprintf("%s Review and address the issues above.\n\n", yellow("!", useColor))) return sb.String(), 1 } @@ -271,15 +436,16 @@ USAGE: wraith [options] COMMANDS: - scan Scan a lockfile for vulnerabilities - version Print version information - help Show this help message + scan Scan a lockfile for vulnerabilities + download-db Download vulnerability database for offline scanning + version Print version information + help Show this help message EXAMPLES: wraith scan go.mod wraith scan --format json poetry.lock - wraith scan --format md go.mod - wraith scan --output report.md go.mod + wraith download-db && wraith scan --offline go.mod + wraith scan --license-allowlist MIT,Apache-2.0 go.mod Run 'wraith scan --help' for more information on the scan command. `) diff --git a/pkg/convert.go b/pkg/convert.go index e817ca0..73fc63c 100644 --- a/pkg/convert.go +++ b/pkg/convert.go @@ -126,3 +126,39 @@ func extractCVSSScore(cvssString string) float64 { } return 0.0 } + +// GetLicenseViolationCount returns the total number of license violations found +func (r *OSVScanResult) GetLicenseViolationCount() int { + count := 0 + for _, source := range r.Results { + for _, pkg := range source.Packages { + count += len(pkg.LicenseViolations) + } + } + return count +} + +// GetPackagesWithLicenseViolations returns packages that have license violations +func (r *OSVScanResult) GetPackagesWithLicenseViolations() []PackageResult { + var packages []PackageResult + for _, source := range r.Results { + for _, pkg := range source.Packages { + if len(pkg.LicenseViolations) > 0 { + packages = append(packages, pkg) + } + } + } + return packages +} + +// HasLicenseViolations returns true if any license violations were found +func (r *OSVScanResult) HasLicenseViolations() bool { + for _, source := range r.Results { + for _, pkg := range source.Packages { + if len(pkg.LicenseViolations) > 0 { + return true + } + } + } + return false +} diff --git a/pkg/scanner.go b/pkg/scanner.go index 66df29e..6ca58f2 100644 --- a/pkg/scanner.go +++ b/pkg/scanner.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" ) @@ -19,15 +20,49 @@ type Scanner struct { // ScanOptions configures the scanning behavior type ScanOptions struct { - // Future options can be added here - // TODO: Add support for offline mode - // TODO: Add support for custom config files - // TODO: Add support for license scanning + // Offline mode - scan without network access + Offline bool + DownloadOfflineDBs bool // Download/refresh local DB before offline scan + + // Custom config file + ConfigFile string + + // License scanning + Licenses bool // Enable license scanning + LicenseAllowlist []string // Allowlist of permitted licenses (e.g., "MIT", "Apache-2.0") } // ScanOption is a functional option for configuring scans type ScanOption func(*ScanOptions) +// WithOffline enables offline scanning mode (no network requests) +func WithOffline() ScanOption { + return func(o *ScanOptions) { o.Offline = true } +} + +// WithOfflineDownload downloads/refreshes the local vulnerability database +func WithOfflineDownload() ScanOption { + return func(o *ScanOptions) { o.DownloadOfflineDBs = true } +} + +// WithConfigFile specifies a custom osv-scanner config file +func WithConfigFile(path string) ScanOption { + return func(o *ScanOptions) { o.ConfigFile = path } +} + +// WithLicenses enables license scanning with summary +func WithLicenses() ScanOption { + return func(o *ScanOptions) { o.Licenses = true } +} + +// WithLicenseAllowlist enables license scanning with an allowlist +func WithLicenseAllowlist(licenses ...string) ScanOption { + return func(o *ScanOptions) { + o.Licenses = true + o.LicenseAllowlist = licenses + } +} + // NewScanner creates a new scanner instance // It will look for osv-scanner in the following order: // 1. Provided binary path (if specified) @@ -92,13 +127,32 @@ func (s *Scanner) ScanLockfile(lockfilePath string, options ...ScanOption) (*OSV ctx, cancel := context.WithTimeout(context.Background(), s.timeout) defer cancel() - // Always use JSON format and scan the specific lockfile - // OSV-Scanner command structure: osv-scanner scan source --lockfile --format json - cmd := exec.CommandContext(ctx, s.binaryPath, - "scan", "source", - "--lockfile", absPath, - "--format", "json", - ) + // Build base args + args := []string{"scan", "source", "--lockfile", absPath, "--format", "json"} + + // Add offline mode flags + if opts.DownloadOfflineDBs { + args = append(args, "--download-offline-databases") + } + if opts.Offline { + args = append(args, "--offline") + } + + // Add custom config file + if opts.ConfigFile != "" { + args = append(args, "--config="+opts.ConfigFile) + } + + // Add license scanning flags + if opts.Licenses { + if len(opts.LicenseAllowlist) > 0 { + args = append(args, "--licenses="+strings.Join(opts.LicenseAllowlist, ",")) + } else { + args = append(args, "--licenses") + } + } + + cmd := exec.CommandContext(ctx, s.binaryPath, args...) // Execute command and capture stdout/stderr separately output, err := s.executeOSVScanner(ctx, cmd) @@ -125,6 +179,38 @@ func (s *Scanner) GetBinaryPath() string { return s.binaryPath } +// DownloadDB downloads or refreshes the local vulnerability database for offline scanning +func (s *Scanner) DownloadDB() error { + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + // osv-scanner requires a scan target even when just downloading the database + // We scan the current directory with offline mode to trigger the download + cmd := exec.CommandContext(ctx, s.binaryPath, + "scan", "source", + "--offline", + "--download-offline-databases", + ".", + ) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("database download timed out after %v", s.timeout) + } + // Ignore exit code 1 (vulnerabilities found) - we only care about the download + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return nil + } + return fmt.Errorf("failed to download database: %s", stderr.String()) + } + + return nil +} + // executeOSVScanner handles the actual command execution and manages OSV-scanner's exit code behavior // OSV-scanner exits with code 1 when vulnerabilities are found, but still provides valid JSON output func (s *Scanner) executeOSVScanner(ctx context.Context, cmd *exec.Cmd) ([]byte, error) { diff --git a/pkg/scanner_test.go b/pkg/scanner_test.go index 076ca6d..f467373 100644 --- a/pkg/scanner_test.go +++ b/pkg/scanner_test.go @@ -187,26 +187,69 @@ func TestScannerAPI(t *testing.T) { }) } -// TestFutureOptions validates that our options system is ready for expansion -func TestFutureOptions(t *testing.T) { - t.Run("options system extensibility", func(t *testing.T) { - // Verify our options pattern will work for future features +// TestScanOptions validates all scan option functions +func TestScanOptions(t *testing.T) { + t.Run("WithOffline sets offline mode", func(t *testing.T) { opts := &ScanOptions{} + WithOffline()(opts) + if !opts.Offline { + t.Error("expected Offline to be true") + } + }) - // Future option functions would look like this: - // withOffline := func(o *ScanOptions) { o.Offline = true } - // withConfigFile := func(configPath string) ScanOption { - // return func(o *ScanOptions) { o.ConfigFile = configPath } - // } + t.Run("WithOfflineDownload sets download flag", func(t *testing.T) { + opts := &ScanOptions{} + WithOfflineDownload()(opts) + if !opts.DownloadOfflineDBs { + t.Error("expected DownloadOfflineDBs to be true") + } + }) - // For now, just verify the pattern works - dummyOption := func(o *ScanOptions) { - // This would set some option in the future + t.Run("WithConfigFile sets config path", func(t *testing.T) { + opts := &ScanOptions{} + WithConfigFile("/path/to/config.toml")(opts) + if opts.ConfigFile != "/path/to/config.toml" { + t.Errorf("expected ConfigFile to be '/path/to/config.toml', got '%s'", opts.ConfigFile) } + }) - dummyOption(opts) + t.Run("WithLicenses enables license scanning", func(t *testing.T) { + opts := &ScanOptions{} + WithLicenses()(opts) + if !opts.Licenses { + t.Error("expected Licenses to be true") + } + }) - t.Log("Options system ready for future expansion") + t.Run("WithLicenseAllowlist enables licenses with allowlist", func(t *testing.T) { + opts := &ScanOptions{} + WithLicenseAllowlist("MIT", "Apache-2.0")(opts) + if !opts.Licenses { + t.Error("expected Licenses to be true") + } + if len(opts.LicenseAllowlist) != 2 { + t.Errorf("expected 2 licenses in allowlist, got %d", len(opts.LicenseAllowlist)) + } + if opts.LicenseAllowlist[0] != "MIT" || opts.LicenseAllowlist[1] != "Apache-2.0" { + t.Errorf("unexpected allowlist values: %v", opts.LicenseAllowlist) + } + }) + + t.Run("multiple options can be combined", func(t *testing.T) { + opts := &ScanOptions{} + WithOffline()(opts) + WithConfigFile("/config.toml")(opts) + WithLicenseAllowlist("MIT")(opts) + + if !opts.Offline { + t.Error("expected Offline to be true") + } + if opts.ConfigFile != "/config.toml" { + t.Error("expected ConfigFile to be set") + } + if !opts.Licenses || len(opts.LicenseAllowlist) != 1 { + t.Error("expected Licenses and allowlist to be set") + } }) } diff --git a/pkg/wraith.go b/pkg/wraith.go index 960217f..db8673e 100644 --- a/pkg/wraith.go +++ b/pkg/wraith.go @@ -6,6 +6,13 @@ import "time" type OSVScanResult struct { Results []SourceResult `json:"results"` ExperimentalConfig *ExperimentalConfig `json:"experimental_config,omitempty"` + LicenseSummary []LicenseSummary `json:"license_summary,omitempty"` +} + +// LicenseSummary represents license count information +type LicenseSummary struct { + Name string `json:"name"` + Count int `json:"count"` } // SourceResult represents scan results for a single source (lockfile, directory, etc.) @@ -22,9 +29,11 @@ type Source struct { // PackageResult represents vulnerability information for a single package type PackageResult struct { - Package Package `json:"package"` - Vulnerabilities []Vulnerability `json:"vulnerabilities,omitempty"` - Groups []VulnerabilityGroup `json:"groups,omitempty"` + Package Package `json:"package"` + Vulnerabilities []Vulnerability `json:"vulnerabilities,omitempty"` + Groups []VulnerabilityGroup `json:"groups,omitempty"` + Licenses []string `json:"licenses,omitempty"` + LicenseViolations []string `json:"license_violations,omitempty"` } // Package represents basic package information