This commit is contained in:
2026-01-02 15:29:43 +00:00
parent 1cbf6c5d9e
commit c6edad4402
34 changed files with 2842 additions and 449 deletions
+287
View File
@@ -0,0 +1,287 @@
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()
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()
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 {
affected := make([]GHSAAdvisory, 0)
for _, advisory := range advisories {
// 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(affected, advisory)
}
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"`
}
+194
View File
@@ -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)
output, err := 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
}
+193
View File
@@ -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"`
}
+234
View File
@@ -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"`
}
+209
View File
@@ -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")
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)
if err != nil {
return err
}
return os.WriteFile(dst, input, 0644)
}
// 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"`
}
+5
View File
@@ -104,6 +104,11 @@ func (w *RescanWorker) rescanPackages(ctx context.Context) {
}
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
}
+80
View File
@@ -7,7 +7,12 @@ import (
"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"
)
@@ -72,6 +77,48 @@ func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Mana
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")
}
@@ -101,6 +148,15 @@ func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, versio
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().
@@ -433,3 +489,27 @@ func (m *Manager) Health(ctx context.Context) error {
}
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
}
}