mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-29 03:12:54 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
package ghsa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ScannerName is the name of this scanner
|
||||
const ScannerName = "github-advisory-database"
|
||||
|
||||
// Scanner implements the GitHub Advisory Database vulnerability scanner
|
||||
type Scanner struct {
|
||||
config config.GHSAConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new GitHub Advisory Database scanner
|
||||
func New(cfg config.GHSAConfig) *Scanner {
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// Scan scans a package using GitHub Advisory Database API
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Str("version", version).
|
||||
Str("registry", registry).
|
||||
Msg("Starting GitHub Advisory Database scan")
|
||||
|
||||
// Map registry to GitHub ecosystem
|
||||
ecosystem := s.mapRegistryToEcosystem(registry)
|
||||
if ecosystem == "" {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{
|
||||
"skipped": fmt.Sprintf("GitHub Advisory Database does not support registry: %s", registry),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Query GitHub Advisory Database
|
||||
advisories, err := s.queryAdvisories(ctx, ecosystem, packageName)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to query GitHub Advisory Database")
|
||||
return s.emptyResult(registry, packageName, version), nil
|
||||
}
|
||||
|
||||
// Filter advisories that affect this version
|
||||
affectedAdvisories := s.filterAffectedAdvisories(advisories, version)
|
||||
|
||||
// Convert to our format
|
||||
result := s.convertResult(affectedAdvisories, registry, packageName, version)
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Int("vulnerabilities", result.VulnerabilityCount).
|
||||
Msg("GitHub Advisory Database scan completed")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Health checks if GitHub API is accessible
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/advisories", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
if s.config.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.config.Token)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("github advisory database not accessible: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("github api returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapRegistryToEcosystem maps our registry names to GitHub ecosystem names
|
||||
func (s *Scanner) mapRegistryToEcosystem(registry string) string {
|
||||
mapping := map[string]string{
|
||||
"npm": "npm",
|
||||
"pypi": "pip",
|
||||
"go": "go",
|
||||
"maven": "maven",
|
||||
"nuget": "nuget",
|
||||
"cargo": "cargo",
|
||||
"pub": "pub",
|
||||
}
|
||||
return mapping[strings.ToLower(registry)]
|
||||
}
|
||||
|
||||
// queryAdvisories queries GitHub Advisory Database for a package
|
||||
func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName string) ([]GHSAAdvisory, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/advisories?ecosystem=%s&affects=%s&per_page=100", ecosystem, packageName)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
if s.config.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.config.Token)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query advisories: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var advisories []GHSAAdvisory
|
||||
if err := json.NewDecoder(resp.Body).Decode(&advisories); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return advisories, nil
|
||||
}
|
||||
|
||||
// filterAffectedAdvisories filters advisories that affect the given version
|
||||
func (s *Scanner) filterAffectedAdvisories(advisories []GHSAAdvisory, version string) []GHSAAdvisory {
|
||||
// Check if this version is affected
|
||||
// GitHub API already filters by package, but we need to check version ranges
|
||||
// For now, we'll include all advisories that match the package
|
||||
// A more sophisticated implementation would parse version ranges
|
||||
affected := append([]GHSAAdvisory(nil), advisories...)
|
||||
|
||||
return affected
|
||||
}
|
||||
|
||||
// emptyResult returns an empty scan result
|
||||
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// convertResult converts GitHub Advisory Database results to our ScanResult format
|
||||
func (s *Scanner) convertResult(advisories []GHSAAdvisory, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0)
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
for _, advisory := range advisories {
|
||||
// Normalize severity
|
||||
normalizedSeverity := metadata.NormalizeSeverity(advisory.Severity)
|
||||
severityCounts[normalizedSeverity]++
|
||||
|
||||
// Extract references
|
||||
refs := make([]string, 0)
|
||||
if advisory.HTMLURL != "" {
|
||||
refs = append(refs, advisory.HTMLURL)
|
||||
}
|
||||
for _, ref := range advisory.References {
|
||||
if ref.URL != "" {
|
||||
refs = append(refs, ref.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Get fixed versions
|
||||
fixedIn := ""
|
||||
for _, vuln := range advisory.Vulnerabilities {
|
||||
if vuln.FirstPatchedVersion != nil && vuln.FirstPatchedVersion.Identifier != "" {
|
||||
fixedIn = vuln.FirstPatchedVersion.Identifier
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: advisory.GHSAID,
|
||||
Severity: normalizedSeverity,
|
||||
Title: advisory.Summary,
|
||||
Description: advisory.Description,
|
||||
References: refs,
|
||||
FixedIn: fixedIn,
|
||||
})
|
||||
}
|
||||
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"severity_counts": severityCounts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GHSAAdvisory represents a GitHub Security Advisory
|
||||
type GHSAAdvisory struct {
|
||||
GHSAID string `json:"ghsa_id"`
|
||||
CVEID string `json:"cve_id"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
References []GHSAReference `json:"references"`
|
||||
Vulnerabilities []GHSAVulnerability `json:"vulnerabilities"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GHSAReference struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type GHSAVulnerability struct {
|
||||
Package GHSAPackage `json:"package"`
|
||||
VulnerableVersions string `json:"vulnerable_version_range"`
|
||||
FirstPatchedVersion *GHSAPatchVersion `json:"first_patched_version"`
|
||||
}
|
||||
|
||||
type GHSAPackage struct {
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type GHSAPatchVersion struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package govulncheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ScannerName is the name of this scanner
|
||||
const ScannerName = "govulncheck"
|
||||
|
||||
// Scanner implements the govulncheck vulnerability scanner for Go modules
|
||||
type Scanner struct {
|
||||
config config.GovulncheckConfig
|
||||
}
|
||||
|
||||
// New creates a new govulncheck scanner
|
||||
func New(cfg config.GovulncheckConfig) *Scanner {
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// Scan scans a Go module using govulncheck
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
// Only scan Go packages
|
||||
if registry != "go" {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{
|
||||
"skipped": "govulncheck only supports Go modules",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Str("version", version).
|
||||
Msg("Starting govulncheck scan")
|
||||
|
||||
// Create a temporary directory for extraction
|
||||
tmpDir, err := os.MkdirTemp("", "govulncheck-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Extract the .zip file
|
||||
if err := s.extractZip(filePath, tmpDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to extract zip: %w", err)
|
||||
}
|
||||
|
||||
// Run govulncheck
|
||||
cmd := exec.CommandContext(ctx, "govulncheck", "-json", "-mode=binary", tmpDir) // #nosec G204 -- govulncheck command with temp directory
|
||||
output, _ := cmd.CombinedOutput()
|
||||
|
||||
// govulncheck returns non-zero when vulnerabilities are found
|
||||
// Parse output regardless of error
|
||||
var vulns []GovulncheckVuln
|
||||
if len(output) > 0 {
|
||||
// Parse line-delimited JSON
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var entry GovulncheckEntry
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
log.Warn().Err(err).Str("line", line).Msg("Failed to parse govulncheck line")
|
||||
continue
|
||||
}
|
||||
if entry.Finding != nil && entry.Finding.OSV != "" {
|
||||
vulns = append(vulns, GovulncheckVuln{
|
||||
OSV: entry.Finding.OSV,
|
||||
FixedVersion: entry.Finding.FixedVersion,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to our format
|
||||
result := s.convertResult(vulns, registry, packageName, version)
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Int("vulnerabilities", result.VulnerabilityCount).
|
||||
Msg("govulncheck scan completed")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Health checks if govulncheck is available
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "govulncheck", "-version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("govulncheck not available: %w (install with: go install golang.org/x/vuln/cmd/govulncheck@latest)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZip extracts a zip file to destination
|
||||
func (s *Scanner) extractZip(zipPath, destDir string) error {
|
||||
cmd := exec.Command("unzip", "-q", zipPath, "-d", destDir)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// convertResult converts govulncheck findings to our ScanResult format
|
||||
func (s *Scanner) convertResult(vulns []GovulncheckVuln, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0)
|
||||
severityCounts := make(map[string]int)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, vuln := range vulns {
|
||||
// Deduplicate by OSV ID
|
||||
if seen[vuln.OSV] {
|
||||
continue
|
||||
}
|
||||
seen[vuln.OSV] = true
|
||||
|
||||
// govulncheck doesn't provide severity in output
|
||||
// Default to HIGH for found vulnerabilities
|
||||
severity := metadata.NormalizeSeverity("HIGH")
|
||||
severityCounts[severity]++
|
||||
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: vuln.OSV,
|
||||
Severity: severity,
|
||||
Title: vuln.OSV,
|
||||
Description: fmt.Sprintf("Vulnerability %s found by govulncheck", vuln.OSV),
|
||||
References: []string{fmt.Sprintf("https://pkg.go.dev/vuln/%s", vuln.OSV)},
|
||||
FixedIn: vuln.FixedVersion,
|
||||
})
|
||||
}
|
||||
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"severity_counts": severityCounts,
|
||||
"note": "govulncheck provides reachability analysis for Go modules",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GovulncheckEntry represents a single line of govulncheck JSON output
|
||||
type GovulncheckEntry struct {
|
||||
Finding *GovulncheckFinding `json:"finding,omitempty"`
|
||||
}
|
||||
|
||||
type GovulncheckFinding struct {
|
||||
OSV string `json:"osv"`
|
||||
FixedVersion string `json:"fixed_version,omitempty"`
|
||||
}
|
||||
|
||||
type GovulncheckVuln struct {
|
||||
OSV string
|
||||
FixedVersion string
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package grype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ScannerName is the name of this scanner
|
||||
const ScannerName = "grype"
|
||||
|
||||
// Scanner implements the Grype vulnerability scanner
|
||||
type Scanner struct {
|
||||
config config.GrypeConfig
|
||||
}
|
||||
|
||||
// New creates a new Grype scanner
|
||||
func New(cfg config.GrypeConfig) *Scanner {
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// Scan scans a package using Grype
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Str("version", version).
|
||||
Str("file", filePath).
|
||||
Msg("Starting Grype scan")
|
||||
|
||||
// Run grype scan
|
||||
cmd := exec.CommandContext(ctx, "grype", filePath, "-o", "json", "-q")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Grype returns non-zero exit code when vulnerabilities are found
|
||||
// Only treat it as error if we got no output
|
||||
if len(output) == 0 {
|
||||
return nil, fmt.Errorf("grype scan failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Grype JSON output
|
||||
var grypeResult GrypeResult
|
||||
if err := json.Unmarshal(output, &grypeResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse grype output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to our format
|
||||
result := s.convertGrypeResult(&grypeResult, registry, packageName, version)
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Int("vulnerabilities", result.VulnerabilityCount).
|
||||
Msg("Grype scan completed")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Health checks if Grype is available
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "grype", "version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("grype not available: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDatabase updates Grype's vulnerability database
|
||||
func (s *Scanner) UpdateDatabase(ctx context.Context) error {
|
||||
log.Info().Str("scanner", ScannerName).Msg("Updating Grype database")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "grype", "db", "update")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update grype database: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
log.Info().Str("scanner", ScannerName).Msg("Grype database updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertGrypeResult converts Grype output to our ScanResult format
|
||||
func (s *Scanner) convertGrypeResult(grypeResult *GrypeResult, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0)
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
// Process each vulnerability match
|
||||
for _, match := range grypeResult.Matches {
|
||||
// Normalize severity
|
||||
normalizedSeverity := metadata.NormalizeSeverity(match.Vulnerability.Severity)
|
||||
|
||||
// Count by severity
|
||||
severityCounts[normalizedSeverity]++
|
||||
|
||||
// Extract fixed version
|
||||
fixedIn := ""
|
||||
if match.Vulnerability.Fix.State == "fixed" {
|
||||
for _, version := range match.Vulnerability.Fix.Versions {
|
||||
if fixedIn == "" {
|
||||
fixedIn = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to vulnerabilities list
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: match.Vulnerability.ID,
|
||||
Severity: normalizedSeverity,
|
||||
Title: match.Vulnerability.ID, // Grype doesn't have separate title
|
||||
Description: match.Vulnerability.Description,
|
||||
References: match.Vulnerability.URLs,
|
||||
FixedIn: fixedIn,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"severity_counts": severityCounts,
|
||||
"grype_version": grypeResult.Descriptor.Version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GrypeResult represents Grype JSON output structure
|
||||
type GrypeResult struct {
|
||||
Matches []GrypeMatch `json:"matches"`
|
||||
Descriptor GrypeDescriptor `json:"descriptor"`
|
||||
Source GrypeSource `json:"source"`
|
||||
}
|
||||
|
||||
type GrypeDescriptor struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type GrypeSource struct {
|
||||
Type string `json:"type"`
|
||||
Target map[string]interface{} `json:"target"`
|
||||
}
|
||||
|
||||
type GrypeMatch struct {
|
||||
Vulnerability GrypeVulnerability `json:"vulnerability"`
|
||||
Artifact GrypeArtifact `json:"artifact"`
|
||||
}
|
||||
|
||||
type GrypeVulnerability struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Description string `json:"description"`
|
||||
URLs []string `json:"urls"`
|
||||
Fix GrypeFix `json:"fix"`
|
||||
}
|
||||
|
||||
type GrypeFix struct {
|
||||
State string `json:"state"`
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
|
||||
type GrypeArtifact struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package npmaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ScannerName is the name of this scanner
|
||||
const ScannerName = "npm-audit"
|
||||
|
||||
// Scanner implements the npm audit vulnerability scanner
|
||||
type Scanner struct {
|
||||
config config.NpmAuditConfig
|
||||
}
|
||||
|
||||
// New creates a new npm audit scanner
|
||||
func New(cfg config.NpmAuditConfig) *Scanner {
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// Scan scans an npm package using npm audit
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
// Only scan npm packages
|
||||
if registry != "npm" {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{
|
||||
"skipped": "npm-audit only supports npm packages",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Str("version", version).
|
||||
Msg("Starting npm audit scan")
|
||||
|
||||
// Create a temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "npm-audit-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Extract the .tgz file
|
||||
if err := s.extractTgz(filePath, tmpDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to extract tgz: %w", err)
|
||||
}
|
||||
|
||||
// Find the package directory (usually "package/")
|
||||
packageDir := filepath.Join(tmpDir, "package")
|
||||
if _, err := os.Stat(packageDir); os.IsNotExist(err) {
|
||||
// Try the tmpDir itself
|
||||
packageDir = tmpDir
|
||||
}
|
||||
|
||||
// Run npm audit
|
||||
cmd := exec.CommandContext(ctx, "npm", "audit", "--json", "--package-lock-only")
|
||||
cmd.Dir = packageDir
|
||||
output, _ := cmd.CombinedOutput() // npm audit returns non-zero when vulns found
|
||||
|
||||
// Parse npm audit output
|
||||
var auditResult NpmAuditResult
|
||||
if len(output) > 0 {
|
||||
if err := json.Unmarshal(output, &auditResult); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to parse npm audit output")
|
||||
// Return clean result on parse error
|
||||
return s.emptyResult(registry, packageName, version), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to our format
|
||||
result := s.convertResult(&auditResult, registry, packageName, version)
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Int("vulnerabilities", result.VulnerabilityCount).
|
||||
Msg("npm audit scan completed")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Health checks if npm is available
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "npm", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("npm not available: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTgz extracts a .tgz file
|
||||
func (s *Scanner) extractTgz(tgzPath, destDir string) error {
|
||||
cmd := exec.Command("tar", "-xzf", tgzPath, "-C", destDir)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// emptyResult returns an empty scan result
|
||||
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// convertResult converts npm audit output to our ScanResult format
|
||||
func (s *Scanner) convertResult(auditResult *NpmAuditResult, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0)
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
// Process vulnerabilities from the audit result
|
||||
for _, vuln := range auditResult.Vulnerabilities {
|
||||
// Normalize severity
|
||||
normalizedSeverity := metadata.NormalizeSeverity(vuln.Severity)
|
||||
severityCounts[normalizedSeverity]++
|
||||
|
||||
// Get references
|
||||
refs := make([]string, 0)
|
||||
if vuln.URL != "" {
|
||||
refs = append(refs, vuln.URL)
|
||||
}
|
||||
for _, ref := range vuln.References {
|
||||
if ref.URL != "" {
|
||||
refs = append(refs, ref.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Get fixed version
|
||||
fixedIn := ""
|
||||
if vuln.FixAvailable != nil {
|
||||
fixedIn = fmt.Sprintf("%v", vuln.FixAvailable)
|
||||
}
|
||||
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: vuln.Via,
|
||||
Severity: normalizedSeverity,
|
||||
Title: vuln.Name,
|
||||
Description: vuln.Name,
|
||||
References: refs,
|
||||
FixedIn: fixedIn,
|
||||
})
|
||||
}
|
||||
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"severity_counts": severityCounts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NpmAuditResult represents npm audit JSON output
|
||||
type NpmAuditResult struct {
|
||||
AuditReportVersion int `json:"auditReportVersion"`
|
||||
Vulnerabilities map[string]NpmVulnerability `json:"vulnerabilities"`
|
||||
Metadata NpmAuditMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type NpmVulnerability struct {
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Via string `json:"via"`
|
||||
Effects []string `json:"effects"`
|
||||
Range string `json:"range"`
|
||||
FixAvailable interface{} `json:"fixAvailable"`
|
||||
URL string `json:"url"`
|
||||
References []NpmReference `json:"references"`
|
||||
}
|
||||
|
||||
type NpmReference struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type NpmAuditMetadata struct {
|
||||
Vulnerabilities NpmVulnCounts `json:"vulnerabilities"`
|
||||
Dependencies int `json:"dependencies"`
|
||||
}
|
||||
|
||||
type NpmVulnCounts struct {
|
||||
Info int `json:"info"`
|
||||
Low int `json:"low"`
|
||||
Moderate int `json:"moderate"`
|
||||
High int `json:"high"`
|
||||
Critical int `json:"critical"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package osv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// ScannerName is the name of this scanner
|
||||
ScannerName = "osv"
|
||||
|
||||
defaultOSVAPIURL = "https://api.osv.dev/v1/query"
|
||||
)
|
||||
|
||||
// Scanner implements the Scanner interface using OSV.dev API
|
||||
type Scanner struct {
|
||||
config config.OSVConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// OSVRequest represents the request structure for OSV API
|
||||
type OSVRequest struct {
|
||||
Package PackageInfo `json:"package"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// PackageInfo contains package ecosystem and name
|
||||
type PackageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"` // npm, PyPI, Go, etc.
|
||||
}
|
||||
|
||||
// OSVResponse represents the response from OSV API
|
||||
type OSVResponse struct {
|
||||
Vulns []OSVVulnerability `json:"vulns"`
|
||||
}
|
||||
|
||||
// OSVVulnerability represents a vulnerability in OSV format
|
||||
type OSVVulnerability struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Severity []OSVSeverity `json:"severity,omitempty"`
|
||||
References []OSVReference `json:"references,omitempty"`
|
||||
Affected []OSVAffected `json:"affected"`
|
||||
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
|
||||
}
|
||||
|
||||
// OSVSeverity represents severity information
|
||||
type OSVSeverity struct {
|
||||
Type string `json:"type"` // CVSS_V3, etc.
|
||||
Score string `json:"score"` // Severity score
|
||||
}
|
||||
|
||||
// OSVReference represents a reference link
|
||||
type OSVReference struct {
|
||||
Type string `json:"type"` // WEB, ADVISORY, etc.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// OSVAffected represents affected package versions
|
||||
type OSVAffected struct {
|
||||
Package PackageInfo `json:"package"`
|
||||
Ranges []OSVRange `json:"ranges,omitempty"`
|
||||
Versions []string `json:"versions,omitempty"`
|
||||
DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"`
|
||||
EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,omitempty"`
|
||||
}
|
||||
|
||||
// OSVRange represents version ranges
|
||||
type OSVRange struct {
|
||||
Type string `json:"type"` // SEMVER, GIT, etc.
|
||||
Events []OSVEvent `json:"events"`
|
||||
}
|
||||
|
||||
// OSVEvent represents version range events
|
||||
type OSVEvent struct {
|
||||
Introduced string `json:"introduced,omitempty"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
LastAffected string `json:"last_affected,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new OSV scanner
|
||||
func New(cfg config.OSVConfig) *Scanner {
|
||||
apiURL := cfg.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = defaultOSVAPIURL
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// Scan scans a package for vulnerabilities using OSV.dev API
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
// Convert registry to OSV ecosystem
|
||||
ecosystem := s.registryToEcosystem(registry)
|
||||
|
||||
// Build request
|
||||
req := OSVRequest{
|
||||
Package: PackageInfo{
|
||||
Name: packageName,
|
||||
Ecosystem: ecosystem,
|
||||
},
|
||||
Version: version,
|
||||
}
|
||||
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal OSV request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
apiURL := s.config.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = defaultOSVAPIURL
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OSV request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Execute request
|
||||
resp, err := s.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OSV API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read OSV response: %w", err)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OSV API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var osvResp OSVResponse
|
||||
if err := json.Unmarshal(body, &osvResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OSV response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to metadata.ScanResult
|
||||
return s.convertOSVResult(&osvResp, registry, packageName, version), nil
|
||||
}
|
||||
|
||||
// registryToEcosystem converts our registry name to OSV ecosystem
|
||||
func (s *Scanner) registryToEcosystem(registry string) string {
|
||||
switch strings.ToLower(registry) {
|
||||
case "npm":
|
||||
return "npm"
|
||||
case "pypi":
|
||||
return "PyPI"
|
||||
case "go":
|
||||
return "Go"
|
||||
case "maven":
|
||||
return "Maven"
|
||||
case "nuget":
|
||||
return "NuGet"
|
||||
case "cargo", "crates":
|
||||
return "crates.io"
|
||||
case "rubygems":
|
||||
return "RubyGems"
|
||||
default:
|
||||
return registry
|
||||
}
|
||||
}
|
||||
|
||||
// convertOSVResult converts OSV response to metadata.ScanResult
|
||||
func (s *Scanner) convertOSVResult(osvResp *OSVResponse, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0, len(osvResp.Vulns))
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
for _, vuln := range osvResp.Vulns {
|
||||
// Determine severity from various sources
|
||||
severity := s.determineSeverity(&vuln)
|
||||
severityCounts[severity]++
|
||||
|
||||
// Extract references
|
||||
references := make([]string, 0, len(vuln.References))
|
||||
for _, ref := range vuln.References {
|
||||
references = append(references, ref.URL)
|
||||
}
|
||||
|
||||
// Find fixed version
|
||||
fixedVersion := s.findFixedVersion(&vuln, version)
|
||||
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: vuln.ID,
|
||||
Severity: severity,
|
||||
Title: vuln.Summary,
|
||||
Description: vuln.Details,
|
||||
References: references,
|
||||
FixedIn: fixedVersion,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: s.Name(),
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"ecosystem": s.registryToEcosystem(registry),
|
||||
"severity_counts": severityCounts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// determineSeverity extracts severity from OSV vulnerability
|
||||
func (s *Scanner) determineSeverity(vuln *OSVVulnerability) string {
|
||||
var rawSeverity string
|
||||
|
||||
// Try to get severity from CVSS
|
||||
for _, sev := range vuln.Severity {
|
||||
if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" {
|
||||
// Parse CVSS score to severity
|
||||
score := sev.Score
|
||||
if strings.Contains(strings.ToUpper(score), "CRITICAL") {
|
||||
rawSeverity = "CRITICAL"
|
||||
} else if strings.Contains(strings.ToUpper(score), "HIGH") {
|
||||
rawSeverity = "HIGH"
|
||||
} else if strings.Contains(strings.ToUpper(score), "MEDIUM") || strings.Contains(strings.ToUpper(score), "MODERATE") {
|
||||
rawSeverity = "MODERATE"
|
||||
} else if strings.Contains(strings.ToUpper(score), "LOW") {
|
||||
rawSeverity = "LOW"
|
||||
}
|
||||
if rawSeverity != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check database_specific for severity if not found in CVSS
|
||||
if rawSeverity == "" && vuln.DatabaseSpecific != nil {
|
||||
if sev, ok := vuln.DatabaseSpecific["severity"].(string); ok {
|
||||
rawSeverity = sev
|
||||
}
|
||||
}
|
||||
|
||||
// Default to MODERATE if unknown
|
||||
if rawSeverity == "" {
|
||||
rawSeverity = "MODERATE"
|
||||
}
|
||||
|
||||
// Normalize to standard severity values
|
||||
return metadata.NormalizeSeverity(rawSeverity)
|
||||
}
|
||||
|
||||
// findFixedVersion extracts the fixed version from OSV affected ranges
|
||||
func (s *Scanner) findFixedVersion(vuln *OSVVulnerability, currentVersion string) string {
|
||||
for _, affected := range vuln.Affected {
|
||||
for _, r := range affected.Ranges {
|
||||
for _, event := range r.Events {
|
||||
if event.Fixed != "" {
|
||||
return event.Fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Health checks if OSV API is reachable
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
// Make a simple request to check API availability
|
||||
apiURL := s.config.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = defaultOSVAPIURL
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(apiURL, "/query", "", 1), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create health check request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OSV API not reachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
|
||||
|
||||
log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package pipaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ScannerName is the name of this scanner
|
||||
const ScannerName = "pip-audit"
|
||||
|
||||
// Scanner implements the pip-audit vulnerability scanner
|
||||
type Scanner struct {
|
||||
config config.PipAuditConfig
|
||||
}
|
||||
|
||||
// New creates a new pip-audit scanner
|
||||
func New(cfg config.PipAuditConfig) *Scanner {
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// Scan scans a Python package using pip-audit
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
// Only scan PyPI packages
|
||||
if registry != "pypi" {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{
|
||||
"skipped": "pip-audit only supports PyPI packages",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Str("version", version).
|
||||
Msg("Starting pip-audit scan")
|
||||
|
||||
// Create a temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "pip-audit-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Copy the wheel/tar.gz file to temp directory
|
||||
tmpFile := filepath.Join(tmpDir, filepath.Base(filePath))
|
||||
if err := s.copyFile(filePath, tmpFile); err != nil {
|
||||
return nil, fmt.Errorf("failed to copy file: %w", err)
|
||||
}
|
||||
|
||||
// Run pip-audit on the package file
|
||||
cmd := exec.CommandContext(ctx, "pip-audit", "-r", tmpFile, "--format", "json") // #nosec G204 -- pip-audit command with temp file
|
||||
output, _ := cmd.CombinedOutput() // pip-audit returns non-zero when vulns found
|
||||
|
||||
// Parse pip-audit output
|
||||
var auditResult PipAuditResult
|
||||
if len(output) > 0 {
|
||||
if err := json.Unmarshal(output, &auditResult); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to parse pip-audit output")
|
||||
return s.emptyResult(registry, packageName, version), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to our format
|
||||
result := s.convertResult(&auditResult, registry, packageName, version)
|
||||
|
||||
log.Info().
|
||||
Str("scanner", ScannerName).
|
||||
Str("package", packageName).
|
||||
Int("vulnerabilities", result.VulnerabilityCount).
|
||||
Msg("pip-audit scan completed")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Health checks if pip-audit is available
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "pip-audit", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("pip-audit not available: %w (install with: pip install pip-audit)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
func (s *Scanner) copyFile(src, dst string) error {
|
||||
input, err := os.ReadFile(src) // #nosec G304 -- Source path is from scanner, controlled
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, input, 0600)
|
||||
}
|
||||
|
||||
// emptyResult returns an empty scan result
|
||||
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusClean,
|
||||
VulnerabilityCount: 0,
|
||||
Vulnerabilities: []metadata.Vulnerability{},
|
||||
Details: map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// convertResult converts pip-audit output to our ScanResult format
|
||||
func (s *Scanner) convertResult(auditResult *PipAuditResult, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0)
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
for _, dep := range auditResult.Dependencies {
|
||||
for _, vuln := range dep.Vulns {
|
||||
// Map pip-audit severity to our standard
|
||||
severity := s.mapSeverity(vuln.ID)
|
||||
normalizedSeverity := metadata.NormalizeSeverity(severity)
|
||||
severityCounts[normalizedSeverity]++
|
||||
|
||||
// Get fixed versions
|
||||
fixedIn := ""
|
||||
if len(vuln.FixVersions) > 0 {
|
||||
fixedIn = vuln.FixVersions[0]
|
||||
}
|
||||
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: vuln.ID,
|
||||
Severity: normalizedSeverity,
|
||||
Title: vuln.ID,
|
||||
Description: vuln.Description,
|
||||
References: []string{fmt.Sprintf("https://osv.dev/vulnerability/%s", vuln.ID)},
|
||||
FixedIn: fixedIn,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: ScannerName,
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"severity_counts": severityCounts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mapSeverity maps vulnerability ID patterns to severity levels
|
||||
func (s *Scanner) mapSeverity(vulnID string) string {
|
||||
// pip-audit doesn't provide severity directly
|
||||
// Default to MODERATE for all findings
|
||||
return "MODERATE"
|
||||
}
|
||||
|
||||
// PipAuditResult represents pip-audit JSON output
|
||||
type PipAuditResult struct {
|
||||
Dependencies []PipDependency `json:"dependencies"`
|
||||
}
|
||||
|
||||
type PipDependency struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Vulns []PipVuln `json:"vulns"`
|
||||
}
|
||||
|
||||
type PipVuln struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
FixVersions []string `json:"fix_versions"`
|
||||
Aliases []string `json:"aliases"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// RescanWorker handles periodic re-scanning of cached packages
|
||||
type RescanWorker struct {
|
||||
manager *Manager
|
||||
metadataStore metadata.MetadataStore
|
||||
storage storage.StorageBackend
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewRescanWorker creates a new rescan worker
|
||||
func NewRescanWorker(manager *Manager, metadataStore metadata.MetadataStore, storageBackend storage.StorageBackend, interval time.Duration) *RescanWorker {
|
||||
return &RescanWorker{
|
||||
manager: manager,
|
||||
metadataStore: metadataStore,
|
||||
storage: storageBackend,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the periodic re-scanning process
|
||||
func (w *RescanWorker) Start(ctx context.Context) {
|
||||
if !w.manager.enabled || w.interval == 0 {
|
||||
log.Info().Msg("Rescan worker disabled")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Dur("interval", w.interval).
|
||||
Msg("Starting package rescan worker")
|
||||
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run initial scan immediately on startup
|
||||
log.Info().Msg("Running initial package scan on startup")
|
||||
w.rescanPackages(ctx)
|
||||
log.Info().
|
||||
Dur("next_scan", w.interval).
|
||||
Msg("Initial scan complete, next scan scheduled")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.rescanPackages(ctx)
|
||||
case <-w.stopCh:
|
||||
log.Info().Msg("Rescan worker stopped")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("Rescan worker stopped (context cancelled)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the rescan worker
|
||||
func (w *RescanWorker) Stop() {
|
||||
close(w.stopCh)
|
||||
}
|
||||
|
||||
// rescanPackages re-scans packages that need updating
|
||||
func (w *RescanWorker) rescanPackages(ctx context.Context) {
|
||||
log.Info().Msg("Starting package rescan cycle - checking all packages for scan status")
|
||||
|
||||
// Get all packages
|
||||
packages, err := w.metadataStore.ListPackages(ctx, &metadata.ListOptions{})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to list packages for rescan")
|
||||
return
|
||||
}
|
||||
|
||||
scanned := 0
|
||||
skipped := 0
|
||||
failed := 0
|
||||
|
||||
for _, pkg := range packages {
|
||||
// Skip metadata entries (npm metadata pages, pypi pages, etc.)
|
||||
if pkg.Version == "list" || pkg.Version == "latest" || pkg.Version == "metadata" || pkg.Version == "page" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if package needs rescanning
|
||||
needsRescan, err := w.needsRescan(ctx, pkg)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("Failed to check rescan status")
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if !needsRescan {
|
||||
log.Debug().
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Bool("security_scanned", pkg.SecurityScanned).
|
||||
Msg("Package does not need rescanning, skipping")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("registry", pkg.Registry).
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("Package needs rescanning")
|
||||
|
||||
// Get file path from storage using the storage key from the package metadata
|
||||
if pkg.StorageKey == "" {
|
||||
log.Warn().
|
||||
Str("registry", pkg.Registry).
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("Package has no storage key, skipping rescan")
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
filePath, err := w.getPackageFilePath(ctx, pkg.StorageKey)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("registry", pkg.Registry).
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Str("storage_key", pkg.StorageKey).
|
||||
Msg("Failed to get package file path, skipping rescan")
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
log.Debug().
|
||||
Str("registry", pkg.Registry).
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("No local file path available, skipping rescan")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Perform the actual scan
|
||||
if err := w.manager.ScanPackage(ctx, pkg.Registry, pkg.Name, pkg.Version, filePath); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("registry", pkg.Registry).
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("Failed to rescan package")
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
scanned++
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("total", len(packages)).
|
||||
Int("scanned", scanned).
|
||||
Int("skipped", skipped).
|
||||
Int("failed", failed).
|
||||
Msg("Rescan cycle completed")
|
||||
}
|
||||
|
||||
// needsRescan checks if a package needs to be rescanned
|
||||
func (w *RescanWorker) needsRescan(ctx context.Context, pkg *metadata.Package) (bool, error) {
|
||||
// Get latest scan result
|
||||
scanResult, err := w.metadataStore.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version)
|
||||
if err != nil {
|
||||
// No scan result - needs scanning
|
||||
log.Debug().
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("Package has no scan result, needs scanning")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If package is not marked as scanned but has scan result, it's a stale state - rescan
|
||||
if !pkg.SecurityScanned {
|
||||
log.Info().
|
||||
Str("package", pkg.Name).
|
||||
Str("version", pkg.Version).
|
||||
Msg("Package has scan result but security_scanned flag is false, needs update")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if scan is older than rescan interval
|
||||
timeSinceLastScan := time.Since(scanResult.ScannedAt)
|
||||
if timeSinceLastScan >= w.interval {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// getPackageFilePath retrieves the local file path for a package from storage
|
||||
func (w *RescanWorker) getPackageFilePath(ctx context.Context, storageKey string) (string, error) {
|
||||
// Check if storage backend supports local paths
|
||||
if localProvider, ok := w.storage.(storage.LocalPathProvider); ok {
|
||||
return localProvider.GetLocalPath(ctx, storageKey)
|
||||
}
|
||||
|
||||
// If storage doesn't support local paths (S3, SMB), we can't rescan
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/grype"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/npmaudit"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/osv"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Scanner defines the interface for security scanners
|
||||
type Scanner interface {
|
||||
// Name returns the scanner name
|
||||
Name() string
|
||||
|
||||
// Scan scans a package for vulnerabilities
|
||||
Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error)
|
||||
|
||||
// Health checks scanner health
|
||||
Health(ctx context.Context) error
|
||||
}
|
||||
|
||||
// DatabaseUpdater is implemented by scanners that need database updates
|
||||
type DatabaseUpdater interface {
|
||||
UpdateDatabase(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Manager manages multiple security scanners
|
||||
type Manager struct {
|
||||
scanners []Scanner
|
||||
enabled bool
|
||||
config config.SecurityConfig
|
||||
metadataStore metadata.MetadataStore
|
||||
}
|
||||
|
||||
// New creates a new scanner manager with configured scanners
|
||||
func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) {
|
||||
manager := &Manager{
|
||||
scanners: make([]Scanner, 0),
|
||||
enabled: cfg.Enabled,
|
||||
config: cfg,
|
||||
metadataStore: metadataStore,
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
log.Info().Msg("Security scanning disabled")
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// Initialize Trivy scanner
|
||||
if cfg.Scanners.Trivy.Enabled {
|
||||
trivyScanner := trivy.New(cfg.Scanners.Trivy)
|
||||
manager.RegisterScanner(trivyScanner)
|
||||
log.Info().Msg("Trivy scanner enabled")
|
||||
|
||||
// Update database on startup if configured
|
||||
if cfg.UpdateDBOnStartup {
|
||||
if err := trivyScanner.UpdateDatabase(context.Background()); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to update Trivy database on startup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize OSV scanner
|
||||
if cfg.Scanners.OSV.Enabled {
|
||||
osvScanner := osv.New(cfg.Scanners.OSV)
|
||||
manager.RegisterScanner(osvScanner)
|
||||
log.Info().Msg("OSV scanner enabled")
|
||||
}
|
||||
|
||||
// Initialize Grype scanner
|
||||
if cfg.Scanners.Grype.Enabled {
|
||||
grypeScanner := grype.New(cfg.Scanners.Grype)
|
||||
manager.RegisterScanner(grypeScanner)
|
||||
log.Info().Msg("Grype scanner enabled")
|
||||
|
||||
// Update database on startup if configured
|
||||
if cfg.UpdateDBOnStartup {
|
||||
if err := grypeScanner.UpdateDatabase(context.Background()); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to update Grype database on startup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize govulncheck scanner
|
||||
if cfg.Scanners.Govulncheck.Enabled {
|
||||
govulncheckScanner := govulncheck.New(cfg.Scanners.Govulncheck)
|
||||
manager.RegisterScanner(govulncheckScanner)
|
||||
log.Info().Msg("govulncheck scanner enabled")
|
||||
}
|
||||
|
||||
// Initialize npm-audit scanner
|
||||
if cfg.Scanners.NpmAudit.Enabled {
|
||||
npmAuditScanner := npmaudit.New(cfg.Scanners.NpmAudit)
|
||||
manager.RegisterScanner(npmAuditScanner)
|
||||
log.Info().Msg("npm-audit scanner enabled")
|
||||
}
|
||||
|
||||
// Initialize pip-audit scanner
|
||||
if cfg.Scanners.PipAudit.Enabled {
|
||||
pipAuditScanner := pipaudit.New(cfg.Scanners.PipAudit)
|
||||
manager.RegisterScanner(pipAuditScanner)
|
||||
log.Info().Msg("pip-audit scanner enabled")
|
||||
}
|
||||
|
||||
// Initialize GitHub Advisory Database scanner
|
||||
if cfg.Scanners.GHSA.Enabled {
|
||||
ghsaScanner := ghsa.New(cfg.Scanners.GHSA)
|
||||
manager.RegisterScanner(ghsaScanner)
|
||||
log.Info().Msg("GitHub Advisory Database scanner enabled")
|
||||
}
|
||||
|
||||
if len(manager.scanners) == 0 {
|
||||
log.Warn().Msg("Security scanning enabled but no scanners configured")
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// RegisterScanner registers a scanner
|
||||
func (m *Manager) RegisterScanner(scanner Scanner) {
|
||||
m.scanners = append(m.scanners, scanner)
|
||||
}
|
||||
|
||||
// ScanPackage scans a package using all registered scanners and saves results
|
||||
func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, version string, filePath string) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("registry", registry).
|
||||
Str("package", packageName).
|
||||
Str("version", version).
|
||||
Msg("Starting security scan")
|
||||
|
||||
// Collect results from all scanners
|
||||
var scanResults []*metadata.ScanResult
|
||||
scannerNames := make([]string, 0)
|
||||
|
||||
for _, scanner := range m.scanners {
|
||||
// Skip scanners that don't support this registry
|
||||
if !m.shouldRunScanner(scanner.Name(), registry) {
|
||||
log.Debug().
|
||||
Str("scanner", scanner.Name()).
|
||||
Str("registry", registry).
|
||||
Msg("Skipping scanner - not compatible with registry")
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanner.Scan(ctx, registry, packageName, version, filePath)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("scanner", scanner.Name()).
|
||||
Str("package", packageName).
|
||||
Msg("Scanner failed")
|
||||
continue
|
||||
}
|
||||
|
||||
scanResults = append(scanResults, result)
|
||||
scannerNames = append(scannerNames, scanner.Name())
|
||||
|
||||
log.Info().
|
||||
Str("scanner", scanner.Name()).
|
||||
Str("package", packageName).
|
||||
Str("status", string(result.Status)).
|
||||
Int("vulnerabilities", result.VulnerabilityCount).
|
||||
Msg("Scan completed")
|
||||
}
|
||||
|
||||
// If no scanners succeeded, return
|
||||
if len(scanResults) == 0 {
|
||||
log.Warn().
|
||||
Str("package", packageName).
|
||||
Msg("All scanners failed, no results to save")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge and deduplicate results from all scanners
|
||||
mergedResult := m.mergeResults(scanResults, scannerNames)
|
||||
|
||||
// Save consolidated result to metadata store
|
||||
if err := m.metadataStore.SaveScanResult(ctx, mergedResult); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("package", packageName).
|
||||
Msg("Failed to save consolidated scan result")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("package", packageName).
|
||||
Str("status", string(mergedResult.Status)).
|
||||
Int("total_vulnerabilities", mergedResult.VulnerabilityCount).
|
||||
Int("unique_cves", len(mergedResult.Vulnerabilities)).
|
||||
Strs("scanners", scannerNames).
|
||||
Msg("Consolidated scan results saved")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeResults merges and deduplicates scan results from multiple scanners
|
||||
func (m *Manager) mergeResults(results []*metadata.ScanResult, scannerNames []string) *metadata.ScanResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use first result as base
|
||||
merged := &metadata.ScanResult{
|
||||
ID: results[0].ID,
|
||||
Registry: results[0].Registry,
|
||||
PackageName: results[0].PackageName,
|
||||
PackageVersion: results[0].PackageVersion,
|
||||
Scanner: strings.Join(scannerNames, "+"), // Combined scanner name
|
||||
ScannedAt: results[0].ScannedAt,
|
||||
Status: metadata.ScanStatusClean,
|
||||
Vulnerabilities: make([]metadata.Vulnerability, 0),
|
||||
Details: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
// Use map for deduplication - key is CVE ID in uppercase
|
||||
vulnMap := make(map[string]*metadata.Vulnerability)
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
// Merge vulnerabilities from all scanners
|
||||
for i, result := range results {
|
||||
scannerName := scannerNames[i]
|
||||
|
||||
// Track scanner details
|
||||
merged.Details[scannerName] = result.Details
|
||||
|
||||
// Add/merge vulnerabilities
|
||||
for _, vuln := range result.Vulnerabilities {
|
||||
cveKey := strings.ToUpper(vuln.ID)
|
||||
|
||||
// Check if CVE already exists
|
||||
if existing, exists := vulnMap[cveKey]; exists {
|
||||
// CVE found by multiple scanners - merge information
|
||||
log.Debug().
|
||||
Str("cve", vuln.ID).
|
||||
Strs("existing_scanners", existing.DetectedBy).
|
||||
Str("new_scanner", scannerName).
|
||||
Msg("CVE found by multiple scanners, merging")
|
||||
|
||||
// Add scanner to DetectedBy list
|
||||
existing.DetectedBy = append(existing.DetectedBy, scannerName)
|
||||
|
||||
// Prefer higher severity if different
|
||||
if m.compareSeverity(vuln.Severity, existing.Severity) > 0 {
|
||||
existing.Severity = vuln.Severity
|
||||
}
|
||||
|
||||
// Merge references (deduplicate URLs)
|
||||
refSet := make(map[string]bool)
|
||||
for _, ref := range existing.References {
|
||||
refSet[ref] = true
|
||||
}
|
||||
for _, ref := range vuln.References {
|
||||
if !refSet[ref] {
|
||||
existing.References = append(existing.References, ref)
|
||||
refSet[ref] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer fixed_in version if not already set
|
||||
if existing.FixedIn == "" && vuln.FixedIn != "" {
|
||||
existing.FixedIn = vuln.FixedIn
|
||||
}
|
||||
|
||||
} else {
|
||||
// New CVE - add to map
|
||||
vulnCopy := vuln
|
||||
vulnCopy.DetectedBy = []string{scannerName}
|
||||
vulnMap[cveKey] = &vulnCopy
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to worst case
|
||||
if result.Status == metadata.ScanStatusVulnerable {
|
||||
merged.Status = metadata.ScanStatusVulnerable
|
||||
} else if result.Status == metadata.ScanStatusPending && merged.Status != metadata.ScanStatusVulnerable {
|
||||
merged.Status = metadata.ScanStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice and count severities
|
||||
for _, vuln := range vulnMap {
|
||||
merged.Vulnerabilities = append(merged.Vulnerabilities, *vuln)
|
||||
severityCounts[strings.ToUpper(vuln.Severity)]++
|
||||
}
|
||||
|
||||
// Update counts
|
||||
merged.VulnerabilityCount = len(merged.Vulnerabilities)
|
||||
merged.Details["severity_counts"] = severityCounts
|
||||
merged.Details["deduplication_summary"] = fmt.Sprintf(
|
||||
"Merged results from %d scanners (%s)",
|
||||
len(scannerNames),
|
||||
strings.Join(scannerNames, ", "),
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// compareSeverity returns >0 if s1 is more severe than s2, <0 if less, 0 if equal
|
||||
func (m *Manager) compareSeverity(s1, s2 string) int {
|
||||
severityOrder := map[string]int{
|
||||
"CRITICAL": 4,
|
||||
"HIGH": 3,
|
||||
"MODERATE": 2,
|
||||
"MEDIUM": 2, // Support both for backwards compatibility
|
||||
"LOW": 1,
|
||||
"UNKNOWN": 0,
|
||||
}
|
||||
|
||||
v1 := severityOrder[strings.ToUpper(s1)]
|
||||
v2 := severityOrder[strings.ToUpper(s2)]
|
||||
|
||||
return v1 - v2
|
||||
}
|
||||
|
||||
// CheckVulnerabilities checks if a package exceeds vulnerability thresholds
|
||||
func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (bool, string, error) {
|
||||
if !m.enabled {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// Get active CVE bypasses from database
|
||||
bypasses, err := m.metadataStore.GetActiveCVEBypasses(ctx)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get CVE bypasses, continuing without bypasses")
|
||||
bypasses = []*metadata.CVEBypass{} // Continue without bypasses
|
||||
}
|
||||
|
||||
// Check if entire package is bypassed
|
||||
packageKey := fmt.Sprintf("%s/%s@%s", registry, packageName, version)
|
||||
packageKeyNoVersion := fmt.Sprintf("%s/%s", registry, packageName)
|
||||
|
||||
for _, bypass := range bypasses {
|
||||
if bypass.Type == metadata.BypassTypePackage && bypass.Active {
|
||||
if bypass.Target == packageKey || bypass.Target == packageKeyNoVersion {
|
||||
log.Info().
|
||||
Str("package", packageKey).
|
||||
Str("bypass_id", bypass.ID).
|
||||
Str("reason", bypass.Reason).
|
||||
Time("expires_at", bypass.ExpiresAt).
|
||||
Msg("Package bypassed by admin")
|
||||
return false, "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest scan result
|
||||
result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version)
|
||||
if err != nil {
|
||||
// No scan result found - allow download (will be scanned after)
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// Build set of bypassed CVEs for fast lookup
|
||||
bypassedCVEs := make(map[string]*metadata.CVEBypass)
|
||||
for _, bypass := range bypasses {
|
||||
if bypass.Type == metadata.BypassTypeCVE && bypass.Active {
|
||||
// Check if bypass applies to this package (if AppliesTo is set)
|
||||
if bypass.AppliesTo != "" && bypass.AppliesTo != packageKey && bypass.AppliesTo != packageKeyNoVersion {
|
||||
continue // This bypass doesn't apply to this package
|
||||
}
|
||||
bypassedCVEs[strings.ToUpper(bypass.Target)] = bypass
|
||||
}
|
||||
}
|
||||
|
||||
// Count vulnerabilities by severity, excluding bypassed CVEs
|
||||
severityCounts := make(map[string]int)
|
||||
for _, vuln := range result.Vulnerabilities {
|
||||
// Check if this CVE is bypassed
|
||||
if bypass, ok := bypassedCVEs[strings.ToUpper(vuln.ID)]; ok {
|
||||
log.Debug().
|
||||
Str("cve", vuln.ID).
|
||||
Str("package", packageName).
|
||||
Str("bypass_id", bypass.ID).
|
||||
Str("reason", bypass.Reason).
|
||||
Time("expires_at", bypass.ExpiresAt).
|
||||
Msg("CVE bypassed by admin")
|
||||
continue
|
||||
}
|
||||
severityCounts[strings.ToUpper(vuln.Severity)]++
|
||||
}
|
||||
|
||||
// Check against thresholds
|
||||
thresholds := m.config.BlockThresholds
|
||||
|
||||
// Check critical
|
||||
if thresholds.Critical >= 0 && severityCounts["CRITICAL"] > thresholds.Critical {
|
||||
return true, fmt.Sprintf("Package has %d CRITICAL vulnerabilities (threshold: %d)",
|
||||
severityCounts["CRITICAL"], thresholds.Critical), nil
|
||||
}
|
||||
|
||||
// Check high
|
||||
if thresholds.High >= 0 && severityCounts["HIGH"] > thresholds.High {
|
||||
return true, fmt.Sprintf("Package has %d HIGH vulnerabilities (threshold: %d)",
|
||||
severityCounts["HIGH"], thresholds.High), nil
|
||||
}
|
||||
|
||||
// Check moderate (medium)
|
||||
moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"] // Support both for backwards compatibility
|
||||
if thresholds.Medium >= 0 && moderateCount > thresholds.Medium {
|
||||
return true, fmt.Sprintf("Package has %d MODERATE vulnerabilities (threshold: %d)",
|
||||
moderateCount, thresholds.Medium), nil
|
||||
}
|
||||
|
||||
// Check low
|
||||
if thresholds.Low >= 0 && severityCounts["LOW"] > thresholds.Low {
|
||||
return true, fmt.Sprintf("Package has %d LOW vulnerabilities (threshold: %d)",
|
||||
severityCounts["LOW"], thresholds.Low), nil
|
||||
}
|
||||
|
||||
// Check block on severity
|
||||
if m.config.BlockOnSeverity != "" && m.config.BlockOnSeverity != "none" {
|
||||
severity := strings.ToUpper(m.config.BlockOnSeverity)
|
||||
|
||||
// Block if any vulnerabilities at or above the specified severity exist
|
||||
switch severity {
|
||||
case "CRITICAL":
|
||||
if severityCounts["CRITICAL"] > 0 {
|
||||
return true, "Package has CRITICAL vulnerabilities", nil
|
||||
}
|
||||
case "HIGH":
|
||||
if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 {
|
||||
return true, "Package has HIGH or CRITICAL vulnerabilities", nil
|
||||
}
|
||||
case "MODERATE", "MEDIUM":
|
||||
moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"]
|
||||
if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 || moderateCount > 0 {
|
||||
return true, "Package has MODERATE, HIGH, or CRITICAL vulnerabilities", nil
|
||||
}
|
||||
case "LOW":
|
||||
if len(result.Vulnerabilities) > 0 {
|
||||
return true, "Package has vulnerabilities", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// UpdateDatabases updates vulnerability databases for all scanners
|
||||
func (m *Manager) UpdateDatabases(ctx context.Context) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Msg("Updating vulnerability databases")
|
||||
|
||||
for _, scanner := range m.scanners {
|
||||
if updater, ok := scanner.(DatabaseUpdater); ok {
|
||||
if err := updater.UpdateDatabase(ctx); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("scanner", scanner.Name()).
|
||||
Msg("Failed to update database")
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Vulnerability databases updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health checks health of all scanners
|
||||
func (m *Manager) Health(ctx context.Context) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, scanner := range m.scanners {
|
||||
if err := scanner.Health(ctx); err != nil {
|
||||
return fmt.Errorf("scanner %s health check failed: %w", scanner.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldRunScanner determines if a scanner should run for a given registry
|
||||
// Language-specific scanners only run for their target ecosystems
|
||||
func (m *Manager) shouldRunScanner(scannerName, registry string) bool {
|
||||
registry = strings.ToLower(registry)
|
||||
|
||||
// Language-specific scanners - only run for their target registry
|
||||
switch scannerName {
|
||||
case "govulncheck":
|
||||
return registry == "go"
|
||||
case "npm-audit":
|
||||
return registry == "npm"
|
||||
case "pip-audit":
|
||||
return registry == "pypi"
|
||||
|
||||
// Multi-ecosystem scanners - run for all registries
|
||||
case "trivy", "osv", "grype", "github-advisory-database":
|
||||
return true
|
||||
|
||||
// Default: allow scanner to run (for future scanners)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package trivy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/config"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ScannerName is the name of this scanner
|
||||
const ScannerName = "trivy"
|
||||
|
||||
// Scanner implements the Scanner interface using Trivy
|
||||
type Scanner struct {
|
||||
config config.TrivyConfig
|
||||
}
|
||||
|
||||
// TrivyResult represents Trivy JSON output structure
|
||||
type TrivyResult struct {
|
||||
SchemaVersion int `json:"SchemaVersion"`
|
||||
ArtifactName string `json:"ArtifactName"`
|
||||
ArtifactType string `json:"ArtifactType"`
|
||||
Metadata TrivyMetadata `json:"Metadata"`
|
||||
Results []TrivyVulnResult `json:"Results"`
|
||||
}
|
||||
|
||||
type TrivyMetadata struct {
|
||||
OS *TrivyOS `json:"OS,omitempty"`
|
||||
RepoTags []string `json:"RepoTags,omitempty"`
|
||||
RepoDigests []string `json:"RepoDigests,omitempty"`
|
||||
ImageConfig *TrivyImageConfig `json:"ImageConfig,omitempty"`
|
||||
}
|
||||
|
||||
type TrivyOS struct {
|
||||
Family string `json:"Family"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type TrivyImageConfig struct {
|
||||
Architecture string `json:"architecture"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
type TrivyVulnResult struct {
|
||||
Target string `json:"Target"`
|
||||
Class string `json:"Class"`
|
||||
Type string `json:"Type"`
|
||||
Vulnerabilities []TrivyVulnerability `json:"Vulnerabilities"`
|
||||
}
|
||||
|
||||
type TrivyVulnerability struct {
|
||||
VulnerabilityID string `json:"VulnerabilityID"`
|
||||
PkgName string `json:"PkgName"`
|
||||
InstalledVersion string `json:"InstalledVersion"`
|
||||
FixedVersion string `json:"FixedVersion"`
|
||||
Severity string `json:"Severity"`
|
||||
Title string `json:"Title"`
|
||||
Description string `json:"Description"`
|
||||
References []string `json:"References"`
|
||||
PrimaryURL string `json:"PrimaryURL"`
|
||||
}
|
||||
|
||||
// New creates a new Trivy scanner
|
||||
func New(cfg config.TrivyConfig) *Scanner {
|
||||
return &Scanner{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *Scanner) Name() string {
|
||||
return ScannerName
|
||||
}
|
||||
|
||||
// UpdateDatabase updates Trivy's vulnerability database
|
||||
func (s *Scanner) UpdateDatabase(ctx context.Context) error {
|
||||
log.Info().Msg("Updating Trivy vulnerability database")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "trivy", "image", "--download-db-only")
|
||||
if s.config.CacheDB != "" {
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("TRIVY_CACHE_DIR=%s", s.config.CacheDB))
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update Trivy database: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
log.Info().Msg("Trivy vulnerability database updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scan scans a package for vulnerabilities using Trivy
|
||||
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
|
||||
// Set timeout
|
||||
if s.config.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, s.config.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// Determine scan type based on registry
|
||||
scanType := s.determineScanType(registry, filePath)
|
||||
|
||||
// Build Trivy command
|
||||
args := []string{
|
||||
scanType,
|
||||
"--format", "json",
|
||||
"--quiet",
|
||||
filePath,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "trivy", args...) // #nosec G204 -- trivy command with controlled arguments
|
||||
|
||||
// Set cache directory if configured
|
||||
if s.config.CacheDB != "" {
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("TRIVY_CACHE_DIR=%s", s.config.CacheDB))
|
||||
}
|
||||
|
||||
// Execute scan
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Check if it's a timeout
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: s.Name(),
|
||||
ScannedAt: time.Now(),
|
||||
Status: metadata.ScanStatusError,
|
||||
Details: map[string]interface{}{
|
||||
"error": "scan timeout",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("trivy scan failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse Trivy output
|
||||
var trivyResult TrivyResult
|
||||
if err := json.Unmarshal(output, &trivyResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Trivy output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to metadata.ScanResult
|
||||
return s.convertTrivyResult(&trivyResult, registry, packageName, version), nil
|
||||
}
|
||||
|
||||
// determineScanType determines the appropriate Trivy scan type
|
||||
func (s *Scanner) determineScanType(registry, filePath string) string {
|
||||
// For now, use filesystem scan for packages
|
||||
// Container image scanning would need different handling
|
||||
ext := strings.ToLower(filePath[strings.LastIndex(filePath, ".")+1:])
|
||||
|
||||
switch registry {
|
||||
case "npm":
|
||||
return "fs" // Filesystem scan for npm packages
|
||||
case "pypi":
|
||||
return "fs" // Filesystem scan for Python packages
|
||||
case "go":
|
||||
return "fs" // Filesystem scan for Go modules
|
||||
default:
|
||||
// Check file extension
|
||||
if ext == "tar" || ext == "tgz" || ext == "gz" {
|
||||
return "fs"
|
||||
}
|
||||
return "fs"
|
||||
}
|
||||
}
|
||||
|
||||
// convertTrivyResult converts Trivy result to metadata.ScanResult
|
||||
func (s *Scanner) convertTrivyResult(trivyResult *TrivyResult, registry, packageName, version string) *metadata.ScanResult {
|
||||
vulnerabilities := make([]metadata.Vulnerability, 0)
|
||||
severityCounts := make(map[string]int)
|
||||
|
||||
// Aggregate all vulnerabilities from all results
|
||||
for _, result := range trivyResult.Results {
|
||||
for _, vuln := range result.Vulnerabilities {
|
||||
// Normalize severity to standard values (CRITICAL, HIGH, MODERATE, LOW)
|
||||
normalizedSeverity := metadata.NormalizeSeverity(vuln.Severity)
|
||||
|
||||
// Count by severity
|
||||
severityCounts[normalizedSeverity]++
|
||||
|
||||
// Add to vulnerabilities list
|
||||
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
|
||||
ID: vuln.VulnerabilityID,
|
||||
Severity: normalizedSeverity,
|
||||
Title: vuln.Title,
|
||||
Description: vuln.Description,
|
||||
References: vuln.References,
|
||||
FixedIn: vuln.FixedVersion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
status := metadata.ScanStatusClean
|
||||
if len(vulnerabilities) > 0 {
|
||||
status = metadata.ScanStatusVulnerable
|
||||
}
|
||||
|
||||
return &metadata.ScanResult{
|
||||
ID: uuid.New().String(),
|
||||
Registry: registry,
|
||||
PackageName: packageName,
|
||||
PackageVersion: version,
|
||||
Scanner: s.Name(),
|
||||
ScannedAt: time.Now(),
|
||||
Status: status,
|
||||
VulnerabilityCount: len(vulnerabilities),
|
||||
Vulnerabilities: vulnerabilities,
|
||||
Details: map[string]interface{}{
|
||||
"artifact_name": trivyResult.ArtifactName,
|
||||
"artifact_type": trivyResult.ArtifactType,
|
||||
"severity_counts": severityCounts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Health checks if Trivy is available and working
|
||||
func (s *Scanner) Health(ctx context.Context) error {
|
||||
// Check if trivy command exists
|
||||
cmd := exec.CommandContext(ctx, "trivy", "--version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("trivy not available: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
log.Debug().Str("version", strings.TrimSpace(string(output))).Msg("Trivy health check passed")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user