Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI: - Container runtime (systemd-nspawn) - VoltVisor VMs (Neutron Stardust / QEMU) - Stellarium CAS (content-addressed storage) - ORAS Registry - GitOps integration - Landlock LSM security - Compose orchestration - Mesh networking Copyright (c) Armored Gates LLC. All rights reserved. Licensed under AGPSL v5.0
This commit is contained in:
891
pkg/security/scanner.go
Normal file
891
pkg/security/scanner.go
Normal file
@@ -0,0 +1,891 @@
|
||||
/*
|
||||
Vulnerability Scanner — Scan container rootfs and CAS references for known
|
||||
vulnerabilities using the OSV (Open Source Vulnerabilities) API.
|
||||
|
||||
Supports:
|
||||
- Debian/Ubuntu (dpkg status file)
|
||||
- Alpine (apk installed db)
|
||||
- RHEL/Fedora/Rocky (rpm query via librpm or rpm binary)
|
||||
|
||||
Copyright (c) Armored Gates LLC. All rights reserved.
|
||||
*/
|
||||
package security
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/armoredgate/volt/pkg/storage"
|
||||
)
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Package represents an installed package detected in a rootfs.
|
||||
type Package struct {
|
||||
Name string
|
||||
Version string
|
||||
Source string // "dpkg", "apk", "rpm"
|
||||
}
|
||||
|
||||
// VulnResult represents a single vulnerability finding.
|
||||
type VulnResult struct {
|
||||
ID string // CVE ID or OSV ID (e.g., "CVE-2024-1234" or "GHSA-xxxx")
|
||||
Package string // Affected package name
|
||||
Version string // Installed version
|
||||
FixedIn string // Version that fixes it, or "" if no fix available
|
||||
Severity string // CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
|
||||
Summary string // Short description
|
||||
References []string // URLs for more info
|
||||
}
|
||||
|
||||
// ScanReport is the result of scanning a rootfs for vulnerabilities.
|
||||
type ScanReport struct {
|
||||
Target string // Image or container name
|
||||
OS string // Detected OS (e.g., "Alpine Linux 3.19")
|
||||
Ecosystem string // OSV ecosystem (e.g., "Alpine", "Debian")
|
||||
PackageCount int // Total packages scanned
|
||||
Vulns []VulnResult // Found vulnerabilities
|
||||
ScanTime time.Duration // Wall-clock time for the scan
|
||||
}
|
||||
|
||||
// ── Severity Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// severityRank maps severity strings to an integer for sorting/filtering.
|
||||
var severityRank = map[string]int{
|
||||
"CRITICAL": 4,
|
||||
"HIGH": 3,
|
||||
"MEDIUM": 2,
|
||||
"LOW": 1,
|
||||
"UNKNOWN": 0,
|
||||
}
|
||||
|
||||
// SeverityAtLeast returns true if sev is at or above the given threshold.
|
||||
func SeverityAtLeast(sev, threshold string) bool {
|
||||
return severityRank[strings.ToUpper(sev)] >= severityRank[strings.ToUpper(threshold)]
|
||||
}
|
||||
|
||||
// ── Counts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// VulnCounts holds per-severity counts.
|
||||
type VulnCounts struct {
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
Unknown int
|
||||
Total int
|
||||
}
|
||||
|
||||
// CountBySeverity tallies vulnerabilities by severity level.
|
||||
func (r *ScanReport) CountBySeverity() VulnCounts {
|
||||
var c VulnCounts
|
||||
for _, v := range r.Vulns {
|
||||
switch strings.ToUpper(v.Severity) {
|
||||
case "CRITICAL":
|
||||
c.Critical++
|
||||
case "HIGH":
|
||||
c.High++
|
||||
case "MEDIUM":
|
||||
c.Medium++
|
||||
case "LOW":
|
||||
c.Low++
|
||||
default:
|
||||
c.Unknown++
|
||||
}
|
||||
}
|
||||
c.Total = len(r.Vulns)
|
||||
return c
|
||||
}
|
||||
|
||||
// ── OS Detection ─────────────────────────────────────────────────────────────
|
||||
|
||||
// DetectOS reads /etc/os-release from rootfsPath and returns (prettyName, ecosystem, error).
|
||||
// The ecosystem is mapped to the OSV ecosystem name.
|
||||
func DetectOS(rootfsPath string) (string, string, error) {
|
||||
osRelPath := filepath.Join(rootfsPath, "etc", "os-release")
|
||||
f, err := os.Open(osRelPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("detect OS: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return parseOSRelease(f)
|
||||
}
|
||||
|
||||
// parseOSRelease parses an os-release formatted reader.
|
||||
func parseOSRelease(r io.Reader) (string, string, error) {
|
||||
var prettyName, id, versionID string
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
val := strings.Trim(parts[1], `"'`)
|
||||
|
||||
switch key {
|
||||
case "PRETTY_NAME":
|
||||
prettyName = val
|
||||
case "ID":
|
||||
id = val
|
||||
case "VERSION_ID":
|
||||
versionID = val
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", "", fmt.Errorf("parse os-release: %w", err)
|
||||
}
|
||||
|
||||
if prettyName == "" {
|
||||
if id != "" {
|
||||
prettyName = id
|
||||
if versionID != "" {
|
||||
prettyName += " " + versionID
|
||||
}
|
||||
} else {
|
||||
return "", "", fmt.Errorf("detect OS: no PRETTY_NAME or ID found in os-release")
|
||||
}
|
||||
}
|
||||
|
||||
ecosystem := mapIDToEcosystem(id, versionID)
|
||||
return prettyName, ecosystem, nil
|
||||
}
|
||||
|
||||
// mapIDToEcosystem maps /etc/os-release ID to OSV ecosystem.
|
||||
func mapIDToEcosystem(id, versionID string) string {
|
||||
switch strings.ToLower(id) {
|
||||
case "alpine":
|
||||
return "Alpine"
|
||||
case "debian":
|
||||
return "Debian"
|
||||
case "ubuntu":
|
||||
return "Ubuntu"
|
||||
case "rocky":
|
||||
return "Rocky Linux"
|
||||
case "rhel", "centos", "fedora":
|
||||
return "Rocky Linux" // best-effort mapping
|
||||
case "sles", "opensuse-leap", "opensuse-tumbleweed", "suse":
|
||||
return "SUSE"
|
||||
default:
|
||||
return "Linux" // fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ── Package Listing ──────────────────────────────────────────────────────────
|
||||
|
||||
// ListPackages detects the package manager and extracts installed packages
|
||||
// from the rootfs at rootfsPath.
|
||||
func ListPackages(rootfsPath string) ([]Package, error) {
|
||||
var pkgs []Package
|
||||
var err error
|
||||
|
||||
// Try dpkg (Debian/Ubuntu)
|
||||
dpkgStatus := filepath.Join(rootfsPath, "var", "lib", "dpkg", "status")
|
||||
if fileExists(dpkgStatus) {
|
||||
pkgs, err = parseDpkgStatus(dpkgStatus)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list packages (dpkg): %w", err)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
// Try apk (Alpine)
|
||||
apkInstalled := filepath.Join(rootfsPath, "lib", "apk", "db", "installed")
|
||||
if fileExists(apkInstalled) {
|
||||
pkgs, err = parseApkInstalled(apkInstalled)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list packages (apk): %w", err)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
// Try rpm (RHEL/Rocky/Fedora)
|
||||
rpmDB := filepath.Join(rootfsPath, "var", "lib", "rpm")
|
||||
if dirExists(rpmDB) {
|
||||
pkgs, err = parseRpmDB(rootfsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list packages (rpm): %w", err)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no supported package manager found in rootfs (checked dpkg, apk, rpm)")
|
||||
}
|
||||
|
||||
// ── dpkg parser ──────────────────────────────────────────────────────────────
|
||||
|
||||
// parseDpkgStatus parses /var/lib/dpkg/status to extract installed packages.
|
||||
func parseDpkgStatus(path string) ([]Package, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return parseDpkgStatusReader(f)
|
||||
}
|
||||
|
||||
// parseDpkgStatusReader parses a dpkg status file from a reader.
|
||||
func parseDpkgStatusReader(r io.Reader) ([]Package, error) {
|
||||
var pkgs []Package
|
||||
var current Package
|
||||
inPackage := false
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Increase buffer for potentially long Description fields
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Empty line separates package entries
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if inPackage && current.Name != "" && current.Version != "" {
|
||||
current.Source = "dpkg"
|
||||
pkgs = append(pkgs, current)
|
||||
}
|
||||
current = Package{}
|
||||
inPackage = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip continuation lines (start with space/tab)
|
||||
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
val := parts[1]
|
||||
|
||||
switch key {
|
||||
case "Package":
|
||||
current.Name = val
|
||||
inPackage = true
|
||||
case "Version":
|
||||
current.Version = val
|
||||
case "Status":
|
||||
// Only include installed packages
|
||||
if !strings.Contains(val, "installed") || strings.Contains(val, "not-installed") {
|
||||
inPackage = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last entry if file doesn't end with blank line
|
||||
if inPackage && current.Name != "" && current.Version != "" {
|
||||
current.Source = "dpkg"
|
||||
pkgs = append(pkgs, current)
|
||||
}
|
||||
|
||||
return pkgs, scanner.Err()
|
||||
}
|
||||
|
||||
// ── apk parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
// parseApkInstalled parses /lib/apk/db/installed to extract installed packages.
|
||||
func parseApkInstalled(path string) ([]Package, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return parseApkInstalledReader(f)
|
||||
}
|
||||
|
||||
// parseApkInstalledReader parses an Alpine apk installed DB from a reader.
|
||||
// Format: blocks separated by blank lines. P = package name, V = version.
|
||||
func parseApkInstalledReader(r io.Reader) ([]Package, error) {
|
||||
var pkgs []Package
|
||||
var current Package
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if current.Name != "" && current.Version != "" {
|
||||
current.Source = "apk"
|
||||
pkgs = append(pkgs, current)
|
||||
}
|
||||
current = Package{}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(line) < 2 || line[1] != ':' {
|
||||
continue
|
||||
}
|
||||
|
||||
key := line[0]
|
||||
val := line[2:]
|
||||
|
||||
switch key {
|
||||
case 'P':
|
||||
current.Name = val
|
||||
case 'V':
|
||||
current.Version = val
|
||||
}
|
||||
}
|
||||
|
||||
// Last entry
|
||||
if current.Name != "" && current.Version != "" {
|
||||
current.Source = "apk"
|
||||
pkgs = append(pkgs, current)
|
||||
}
|
||||
|
||||
return pkgs, scanner.Err()
|
||||
}
|
||||
|
||||
// ── rpm parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
// parseRpmDB queries the RPM database in the rootfs using the rpm binary.
|
||||
func parseRpmDB(rootfsPath string) ([]Package, error) {
|
||||
// Try using rpm command with --root
|
||||
rpmBin, err := exec.LookPath("rpm")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rpm binary not found (needed to query RPM database): %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(rpmBin, "--root", rootfsPath, "-qa", "--queryformat", "%{NAME}\\t%{VERSION}-%{RELEASE}\\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rpm query failed: %w", err)
|
||||
}
|
||||
|
||||
return parseRpmOutput(out)
|
||||
}
|
||||
|
||||
// parseRpmOutput parses tab-separated name\tversion output from rpm -qa.
|
||||
func parseRpmOutput(data []byte) ([]Package, error) {
|
||||
var pkgs []Package
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
Source: "rpm",
|
||||
})
|
||||
}
|
||||
return pkgs, scanner.Err()
|
||||
}
|
||||
|
||||
// ── OSV API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
osvQueryURL = "https://api.osv.dev/v1/query"
|
||||
osvQueryBatchURL = "https://api.osv.dev/v1/querybatch"
|
||||
osvBatchLimit = 1000 // max queries per batch
|
||||
osvHTTPTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// osvQueryRequest is a single OSV query.
|
||||
type osvQueryRequest struct {
|
||||
Package *osvPackage `json:"package"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type osvPackage struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
}
|
||||
|
||||
// osvBatchRequest wraps multiple queries.
|
||||
type osvBatchRequest struct {
|
||||
Queries []osvQueryRequest `json:"queries"`
|
||||
}
|
||||
|
||||
// osvBatchResponse contains results for a batch query.
|
||||
type osvBatchResponse struct {
|
||||
Results []osvQueryResponse `json:"results"`
|
||||
}
|
||||
|
||||
// osvQueryResponse is the response for a single query.
|
||||
type osvQueryResponse struct {
|
||||
Vulns []osvVuln `json:"vulns"`
|
||||
}
|
||||
|
||||
// osvVuln represents a vulnerability from the OSV API.
|
||||
type osvVuln struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Severity []struct {
|
||||
Type string `json:"type"`
|
||||
Score string `json:"score"`
|
||||
} `json:"severity"`
|
||||
DatabaseSpecific json.RawMessage `json:"database_specific"`
|
||||
Affected []struct {
|
||||
Package struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
} `json:"package"`
|
||||
Ranges []struct {
|
||||
Type string `json:"type"`
|
||||
Events []struct {
|
||||
Introduced string `json:"introduced,omitempty"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
} `json:"events"`
|
||||
} `json:"ranges"`
|
||||
} `json:"affected"`
|
||||
References []struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
} `json:"references"`
|
||||
}
|
||||
|
||||
// QueryOSV queries the OSV API for vulnerabilities affecting the given package.
|
||||
func QueryOSV(ecosystem, pkg, version string) ([]VulnResult, error) {
|
||||
return queryOSVWithClient(http.DefaultClient, ecosystem, pkg, version)
|
||||
}
|
||||
|
||||
func queryOSVWithClient(client *http.Client, ecosystem, pkg, version string) ([]VulnResult, error) {
|
||||
reqBody := osvQueryRequest{
|
||||
Package: &osvPackage{
|
||||
Name: pkg,
|
||||
Ecosystem: ecosystem,
|
||||
},
|
||||
Version: version,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv query marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", osvQueryURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv query: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv query: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("osv query: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var osvResp osvQueryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&osvResp); err != nil {
|
||||
return nil, fmt.Errorf("osv query decode: %w", err)
|
||||
}
|
||||
|
||||
return convertOSVVulns(osvResp.Vulns, pkg, version), nil
|
||||
}
|
||||
|
||||
// QueryOSVBatch queries the OSV batch endpoint for multiple packages at once.
|
||||
func QueryOSVBatch(ecosystem string, pkgs []Package) (map[string][]VulnResult, error) {
|
||||
return queryOSVBatchWithClient(&http.Client{Timeout: osvHTTPTimeout}, ecosystem, pkgs)
|
||||
}
|
||||
|
||||
func queryOSVBatchWithClient(client *http.Client, ecosystem string, pkgs []Package) (map[string][]VulnResult, error) {
|
||||
return queryOSVBatchWithURL(client, ecosystem, pkgs, osvQueryBatchURL)
|
||||
}
|
||||
|
||||
// queryOSVBatchWithURL is the internal implementation that accepts a custom URL (for testing).
|
||||
func queryOSVBatchWithURL(client *http.Client, ecosystem string, pkgs []Package, batchURL string) (map[string][]VulnResult, error) {
|
||||
results := make(map[string][]VulnResult)
|
||||
|
||||
// Process in batches of osvBatchLimit
|
||||
for i := 0; i < len(pkgs); i += osvBatchLimit {
|
||||
end := i + osvBatchLimit
|
||||
if end > len(pkgs) {
|
||||
end = len(pkgs)
|
||||
}
|
||||
batch := pkgs[i:end]
|
||||
|
||||
var queries []osvQueryRequest
|
||||
for _, p := range batch {
|
||||
queries = append(queries, osvQueryRequest{
|
||||
Package: &osvPackage{
|
||||
Name: p.Name,
|
||||
Ecosystem: ecosystem,
|
||||
},
|
||||
Version: p.Version,
|
||||
})
|
||||
}
|
||||
|
||||
batchReq := osvBatchRequest{Queries: queries}
|
||||
data, err := json.Marshal(batchReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv batch marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", batchURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv batch: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("osv batch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("osv batch: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var batchResp osvBatchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
|
||||
return nil, fmt.Errorf("osv batch decode: %w", err)
|
||||
}
|
||||
|
||||
// Map results back to packages
|
||||
for j, qr := range batchResp.Results {
|
||||
if j >= len(batch) {
|
||||
break
|
||||
}
|
||||
pkg := batch[j]
|
||||
vulns := convertOSVVulns(qr.Vulns, pkg.Name, pkg.Version)
|
||||
if len(vulns) > 0 {
|
||||
key := pkg.Name + "@" + pkg.Version
|
||||
results[key] = append(results[key], vulns...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// convertOSVVulns converts OSV API vulnerability objects to our VulnResult type.
|
||||
func convertOSVVulns(vulns []osvVuln, pkgName, pkgVersion string) []VulnResult {
|
||||
var results []VulnResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, v := range vulns {
|
||||
if seen[v.ID] {
|
||||
continue
|
||||
}
|
||||
seen[v.ID] = true
|
||||
|
||||
result := VulnResult{
|
||||
ID: v.ID,
|
||||
Package: pkgName,
|
||||
Version: pkgVersion,
|
||||
Summary: v.Summary,
|
||||
}
|
||||
|
||||
// Extract severity
|
||||
result.Severity = extractSeverity(v)
|
||||
|
||||
// Extract fixed version
|
||||
result.FixedIn = extractFixedVersion(v, pkgName)
|
||||
|
||||
// Extract references
|
||||
for _, ref := range v.References {
|
||||
result.References = append(result.References, ref.URL)
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// extractSeverity tries to determine severity from OSV data.
|
||||
func extractSeverity(v osvVuln) string {
|
||||
// Try CVSS score from severity array
|
||||
for _, s := range v.Severity {
|
||||
if s.Type == "CVSS_V3" || s.Type == "CVSS_V2" {
|
||||
return cvssToSeverity(s.Score)
|
||||
}
|
||||
}
|
||||
|
||||
// Try database_specific.severity
|
||||
if len(v.DatabaseSpecific) > 0 {
|
||||
var dbSpec map[string]interface{}
|
||||
if json.Unmarshal(v.DatabaseSpecific, &dbSpec) == nil {
|
||||
if sev, ok := dbSpec["severity"].(string); ok {
|
||||
return normalizeSeverity(sev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic from ID prefix
|
||||
id := strings.ToUpper(v.ID)
|
||||
if strings.HasPrefix(id, "CVE-") {
|
||||
return "UNKNOWN" // Can't determine from ID alone
|
||||
}
|
||||
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
// cvssToSeverity converts a CVSS vector string to a severity category.
|
||||
// It extracts the base score from CVSS v3 vectors.
|
||||
func cvssToSeverity(cvss string) string {
|
||||
// CVSS v3 vectors look like: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
|
||||
// We need to parse the actual score, but the vector alone doesn't contain it.
|
||||
// For CVSS_V3 type, the score field might be the vector string or a numeric score.
|
||||
|
||||
// Try parsing as a float (some APIs return the numeric score)
|
||||
var score float64
|
||||
if _, err := fmt.Sscanf(cvss, "%f", &score); err == nil {
|
||||
switch {
|
||||
case score >= 9.0:
|
||||
return "CRITICAL"
|
||||
case score >= 7.0:
|
||||
return "HIGH"
|
||||
case score >= 4.0:
|
||||
return "MEDIUM"
|
||||
case score > 0:
|
||||
return "LOW"
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a vector string, use heuristics
|
||||
upper := strings.ToUpper(cvss)
|
||||
if strings.Contains(upper, "AV:N") && strings.Contains(upper, "AC:L") {
|
||||
// Network accessible, low complexity — likely at least HIGH
|
||||
if strings.Contains(upper, "/C:H/I:H/A:H") {
|
||||
return "CRITICAL"
|
||||
}
|
||||
return "HIGH"
|
||||
}
|
||||
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
// normalizeSeverity normalizes various severity labels to our standard set.
|
||||
func normalizeSeverity(sev string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(sev)) {
|
||||
case "CRITICAL":
|
||||
return "CRITICAL"
|
||||
case "HIGH", "IMPORTANT":
|
||||
return "HIGH"
|
||||
case "MEDIUM", "MODERATE":
|
||||
return "MEDIUM"
|
||||
case "LOW", "NEGLIGIBLE", "UNIMPORTANT":
|
||||
return "LOW"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// extractFixedVersion finds the fixed version from affected ranges.
|
||||
func extractFixedVersion(v osvVuln, pkgName string) string {
|
||||
for _, affected := range v.Affected {
|
||||
if affected.Package.Name != pkgName {
|
||||
continue
|
||||
}
|
||||
for _, r := range affected.Ranges {
|
||||
for _, event := range r.Events {
|
||||
if event.Fixed != "" {
|
||||
return event.Fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try any affected entry if package name didn't match exactly
|
||||
for _, affected := range v.Affected {
|
||||
for _, r := range affected.Ranges {
|
||||
for _, event := range r.Events {
|
||||
if event.Fixed != "" {
|
||||
return event.Fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Main Scan Functions ──────────────────────────────────────────────────────
|
||||
|
||||
// ScanRootfs scans a rootfs directory for vulnerabilities by detecting the OS,
|
||||
// listing installed packages, and querying the OSV API.
|
||||
func ScanRootfs(rootfsPath string) (*ScanReport, error) {
|
||||
return ScanRootfsWithTarget(rootfsPath, filepath.Base(rootfsPath))
|
||||
}
|
||||
|
||||
// ScanRootfsWithTarget scans a rootfs with a custom target name for the report.
|
||||
func ScanRootfsWithTarget(rootfsPath, targetName string) (*ScanReport, error) {
|
||||
start := time.Now()
|
||||
|
||||
report := &ScanReport{
|
||||
Target: targetName,
|
||||
}
|
||||
|
||||
// Verify rootfs exists
|
||||
if !dirExists(rootfsPath) {
|
||||
return nil, fmt.Errorf("rootfs path does not exist: %s", rootfsPath)
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
osName, ecosystem, err := DetectOS(rootfsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
report.OS = osName
|
||||
report.Ecosystem = ecosystem
|
||||
|
||||
// List installed packages
|
||||
pkgs, err := ListPackages(rootfsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
report.PackageCount = len(pkgs)
|
||||
|
||||
if len(pkgs) == 0 {
|
||||
report.ScanTime = time.Since(start)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Query OSV batch API
|
||||
vulnMap, err := QueryOSVBatch(ecosystem, pkgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: osv query failed: %w", err)
|
||||
}
|
||||
|
||||
// Collect all vulnerabilities
|
||||
for _, vulns := range vulnMap {
|
||||
report.Vulns = append(report.Vulns, vulns...)
|
||||
}
|
||||
|
||||
// Sort by severity (critical first)
|
||||
sort.Slice(report.Vulns, func(i, j int) bool {
|
||||
ri := severityRank[report.Vulns[i].Severity]
|
||||
rj := severityRank[report.Vulns[j].Severity]
|
||||
if ri != rj {
|
||||
return ri > rj
|
||||
}
|
||||
return report.Vulns[i].ID < report.Vulns[j].ID
|
||||
})
|
||||
|
||||
report.ScanTime = time.Since(start)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// ScanCASRef scans a CAS reference by assembling it to a temporary directory,
|
||||
// scanning, and cleaning up.
|
||||
func ScanCASRef(casStore *storage.CASStore, ref string) (*ScanReport, error) {
|
||||
tv := storage.NewTinyVol(casStore, "")
|
||||
|
||||
// Load the manifest
|
||||
bm, err := casStore.LoadManifest(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan cas ref: %w", err)
|
||||
}
|
||||
|
||||
// Assemble to a temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "volt-scan-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan cas ref: create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, err = tv.Assemble(bm, tmpDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan cas ref: assemble: %w", err)
|
||||
}
|
||||
|
||||
// Scan the assembled rootfs
|
||||
report, err := ScanRootfsWithTarget(tmpDir, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// ── Formatting ───────────────────────────────────────────────────────────────
|
||||
|
||||
// FormatReport formats a ScanReport as a human-readable string.
|
||||
func FormatReport(r *ScanReport, minSeverity string) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "🔍 Scanning: %s\n", r.Target)
|
||||
fmt.Fprintf(&b, " OS: %s\n", r.OS)
|
||||
fmt.Fprintf(&b, " Packages: %d detected\n", r.PackageCount)
|
||||
fmt.Fprintln(&b)
|
||||
|
||||
filtered := r.Vulns
|
||||
if minSeverity != "" {
|
||||
filtered = nil
|
||||
for _, v := range r.Vulns {
|
||||
if SeverityAtLeast(v.Severity, minSeverity) {
|
||||
filtered = append(filtered, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
if minSeverity != "" {
|
||||
fmt.Fprintf(&b, " No vulnerabilities found at %s severity or above.\n", strings.ToUpper(minSeverity))
|
||||
} else {
|
||||
fmt.Fprintln(&b, " ✅ No vulnerabilities found.")
|
||||
}
|
||||
} else {
|
||||
for _, v := range filtered {
|
||||
fixInfo := fmt.Sprintf("(fixed in %s)", v.FixedIn)
|
||||
if v.FixedIn == "" {
|
||||
fixInfo = "(no fix available)"
|
||||
}
|
||||
fmt.Fprintf(&b, " %-10s %-20s %s %s %s\n",
|
||||
v.Severity, v.ID, v.Package, v.Version, fixInfo)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
counts := r.CountBySeverity()
|
||||
fmt.Fprintf(&b, " Summary: %d critical, %d high, %d medium, %d low (%d total)\n",
|
||||
counts.Critical, counts.High, counts.Medium, counts.Low, counts.Total)
|
||||
fmt.Fprintf(&b, " Scan time: %.1fs\n", r.ScanTime.Seconds())
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FormatReportJSON formats a ScanReport as JSON.
|
||||
func FormatReportJSON(r *ScanReport) (string, error) {
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
992
pkg/security/scanner_test.go
Normal file
992
pkg/security/scanner_test.go
Normal file
@@ -0,0 +1,992 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── TestDetectOS ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDetectOS_Alpine(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"etc/os-release": `NAME="Alpine Linux"
|
||||
ID=alpine
|
||||
VERSION_ID=3.19.1
|
||||
PRETTY_NAME="Alpine Linux v3.19"
|
||||
HOME_URL="https://alpinelinux.org/"
|
||||
`,
|
||||
})
|
||||
|
||||
name, eco, err := DetectOS(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOS failed: %v", err)
|
||||
}
|
||||
if name != "Alpine Linux v3.19" {
|
||||
t.Errorf("expected 'Alpine Linux v3.19', got %q", name)
|
||||
}
|
||||
if eco != "Alpine" {
|
||||
t.Errorf("expected ecosystem 'Alpine', got %q", eco)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOS_Debian(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"etc/os-release": `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||
NAME="Debian GNU/Linux"
|
||||
VERSION_ID="12"
|
||||
VERSION="12 (bookworm)"
|
||||
VERSION_CODENAME=bookworm
|
||||
ID=debian
|
||||
`,
|
||||
})
|
||||
|
||||
name, eco, err := DetectOS(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOS failed: %v", err)
|
||||
}
|
||||
if name != "Debian GNU/Linux 12 (bookworm)" {
|
||||
t.Errorf("expected 'Debian GNU/Linux 12 (bookworm)', got %q", name)
|
||||
}
|
||||
if eco != "Debian" {
|
||||
t.Errorf("expected ecosystem 'Debian', got %q", eco)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOS_Ubuntu(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"etc/os-release": `PRETTY_NAME="Ubuntu 24.04.1 LTS"
|
||||
NAME="Ubuntu"
|
||||
VERSION_ID="24.04"
|
||||
VERSION="24.04.1 LTS (Noble Numbat)"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
`,
|
||||
})
|
||||
|
||||
name, eco, err := DetectOS(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOS failed: %v", err)
|
||||
}
|
||||
if name != "Ubuntu 24.04.1 LTS" {
|
||||
t.Errorf("expected 'Ubuntu 24.04.1 LTS', got %q", name)
|
||||
}
|
||||
if eco != "Ubuntu" {
|
||||
t.Errorf("expected ecosystem 'Ubuntu', got %q", eco)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOS_Rocky(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"etc/os-release": `NAME="Rocky Linux"
|
||||
VERSION="9.3 (Blue Onyx)"
|
||||
ID="rocky"
|
||||
VERSION_ID="9.3"
|
||||
PRETTY_NAME="Rocky Linux 9.3 (Blue Onyx)"
|
||||
`,
|
||||
})
|
||||
|
||||
name, eco, err := DetectOS(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOS failed: %v", err)
|
||||
}
|
||||
if name != "Rocky Linux 9.3 (Blue Onyx)" {
|
||||
t.Errorf("expected 'Rocky Linux 9.3 (Blue Onyx)', got %q", name)
|
||||
}
|
||||
if eco != "Rocky Linux" {
|
||||
t.Errorf("expected ecosystem 'Rocky Linux', got %q", eco)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOS_NoFile(t *testing.T) {
|
||||
rootfs := t.TempDir()
|
||||
_, _, err := DetectOS(rootfs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing os-release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOS_NoPrettyName(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"etc/os-release": `ID=alpine
|
||||
VERSION_ID=3.19.1
|
||||
`,
|
||||
})
|
||||
|
||||
name, _, err := DetectOS(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOS failed: %v", err)
|
||||
}
|
||||
if name != "alpine 3.19.1" {
|
||||
t.Errorf("expected 'alpine 3.19.1', got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestListPackagesDpkg ─────────────────────────────────────────────────────
|
||||
|
||||
func TestListPackagesDpkg(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"var/lib/dpkg/status": `Package: base-files
|
||||
Status: install ok installed
|
||||
Priority: required
|
||||
Section: admin
|
||||
Installed-Size: 338
|
||||
Maintainer: Santiago Vila <sanvila@debian.org>
|
||||
Architecture: amd64
|
||||
Version: 12.4+deb12u5
|
||||
Description: Debian base system miscellaneous files
|
||||
|
||||
Package: libc6
|
||||
Status: install ok installed
|
||||
Priority: optional
|
||||
Section: libs
|
||||
Installed-Size: 13364
|
||||
Maintainer: GNU Libc Maintainers <debian-glibc@lists.debian.org>
|
||||
Architecture: amd64
|
||||
Multi-Arch: same
|
||||
Version: 2.36-9+deb12u7
|
||||
Description: GNU C Library: Shared libraries
|
||||
|
||||
Package: removed-pkg
|
||||
Status: deinstall ok not-installed
|
||||
Priority: optional
|
||||
Section: libs
|
||||
Architecture: amd64
|
||||
Version: 1.0.0
|
||||
Description: This should not appear
|
||||
|
||||
Package: openssl
|
||||
Status: install ok installed
|
||||
Priority: optional
|
||||
Section: utils
|
||||
Installed-Size: 1420
|
||||
Architecture: amd64
|
||||
Version: 3.0.11-1~deb12u2
|
||||
Description: Secure Sockets Layer toolkit
|
||||
`,
|
||||
})
|
||||
|
||||
pkgs, err := ListPackages(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPackages failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d: %+v", len(pkgs), pkgs)
|
||||
}
|
||||
|
||||
// Check that we got the right packages
|
||||
names := map[string]string{}
|
||||
for _, p := range pkgs {
|
||||
names[p.Name] = p.Version
|
||||
if p.Source != "dpkg" {
|
||||
t.Errorf("expected source 'dpkg', got %q for %s", p.Source, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if names["base-files"] != "12.4+deb12u5" {
|
||||
t.Errorf("wrong version for base-files: %q", names["base-files"])
|
||||
}
|
||||
if names["libc6"] != "2.36-9+deb12u7" {
|
||||
t.Errorf("wrong version for libc6: %q", names["libc6"])
|
||||
}
|
||||
if names["openssl"] != "3.0.11-1~deb12u2" {
|
||||
t.Errorf("wrong version for openssl: %q", names["openssl"])
|
||||
}
|
||||
if _, ok := names["removed-pkg"]; ok {
|
||||
t.Error("removed-pkg should not be listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackagesDpkg_NoTrailingNewline(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"var/lib/dpkg/status": `Package: curl
|
||||
Status: install ok installed
|
||||
Version: 7.88.1-10+deb12u5`,
|
||||
})
|
||||
|
||||
pkgs, err := ListPackages(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPackages failed: %v", err)
|
||||
}
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "curl" || pkgs[0].Version != "7.88.1-10+deb12u5" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestListPackagesApk ──────────────────────────────────────────────────────
|
||||
|
||||
func TestListPackagesApk(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"lib/apk/db/installed": `C:Q1abc123=
|
||||
P:musl
|
||||
V:1.2.4_git20230717-r4
|
||||
A:x86_64
|
||||
S:383152
|
||||
I:622592
|
||||
T:the musl c library
|
||||
U:https://musl.libc.org/
|
||||
L:MIT
|
||||
o:musl
|
||||
m:Natanael Copa <ncopa@alpinelinux.org>
|
||||
t:1700000000
|
||||
c:abc123
|
||||
|
||||
C:Q1def456=
|
||||
P:busybox
|
||||
V:1.36.1-r15
|
||||
A:x86_64
|
||||
S:512000
|
||||
I:924000
|
||||
T:Size optimized toolbox
|
||||
U:https://busybox.net/
|
||||
L:GPL-2.0-only
|
||||
o:busybox
|
||||
m:Natanael Copa <ncopa@alpinelinux.org>
|
||||
t:1700000001
|
||||
c:def456
|
||||
|
||||
C:Q1ghi789=
|
||||
P:openssl
|
||||
V:3.1.4-r5
|
||||
A:x86_64
|
||||
S:1234567
|
||||
I:2345678
|
||||
T:Toolkit for SSL/TLS
|
||||
U:https://www.openssl.org/
|
||||
L:Apache-2.0
|
||||
o:openssl
|
||||
m:Natanael Copa <ncopa@alpinelinux.org>
|
||||
t:1700000002
|
||||
c:ghi789
|
||||
`,
|
||||
})
|
||||
|
||||
pkgs, err := ListPackages(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPackages failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d: %+v", len(pkgs), pkgs)
|
||||
}
|
||||
|
||||
names := map[string]string{}
|
||||
for _, p := range pkgs {
|
||||
names[p.Name] = p.Version
|
||||
if p.Source != "apk" {
|
||||
t.Errorf("expected source 'apk', got %q for %s", p.Source, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if names["musl"] != "1.2.4_git20230717-r4" {
|
||||
t.Errorf("wrong version for musl: %q", names["musl"])
|
||||
}
|
||||
if names["busybox"] != "1.36.1-r15" {
|
||||
t.Errorf("wrong version for busybox: %q", names["busybox"])
|
||||
}
|
||||
if names["openssl"] != "3.1.4-r5" {
|
||||
t.Errorf("wrong version for openssl: %q", names["openssl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackagesApk_NoTrailingNewline(t *testing.T) {
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"lib/apk/db/installed": `P:curl
|
||||
V:8.5.0-r0`,
|
||||
})
|
||||
|
||||
pkgs, err := ListPackages(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPackages failed: %v", err)
|
||||
}
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "curl" || pkgs[0].Version != "8.5.0-r0" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestListPackages_NoPackageManager ────────────────────────────────────────
|
||||
|
||||
func TestListPackages_NoPackageManager(t *testing.T) {
|
||||
rootfs := t.TempDir()
|
||||
_, err := ListPackages(rootfs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no package manager found")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no supported package manager") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestOSVQueryParsing ──────────────────────────────────────────────────────
|
||||
|
||||
func TestOSVQueryParsing(t *testing.T) {
|
||||
// Recorded OSV response for openssl 3.1.4 on Alpine
|
||||
osvResponse := `{
|
||||
"vulns": [
|
||||
{
|
||||
"id": "CVE-2024-0727",
|
||||
"summary": "PKCS12 Decoding crashes",
|
||||
"details": "Processing a maliciously crafted PKCS12 file may lead to OpenSSL crashing.",
|
||||
"severity": [
|
||||
{"type": "CVSS_V3", "score": "5.5"}
|
||||
],
|
||||
"affected": [
|
||||
{
|
||||
"package": {"name": "openssl", "ecosystem": "Alpine"},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "ECOSYSTEM",
|
||||
"events": [
|
||||
{"introduced": "0"},
|
||||
{"fixed": "3.1.5-r0"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{"type": "ADVISORY", "url": "https://www.openssl.org/news/secadv/20240125.txt"},
|
||||
{"type": "WEB", "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-0727"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-2511",
|
||||
"summary": "Unbounded memory growth with session handling in TLSv1.3",
|
||||
"severity": [
|
||||
{"type": "CVSS_V3", "score": "3.7"}
|
||||
],
|
||||
"affected": [
|
||||
{
|
||||
"package": {"name": "openssl", "ecosystem": "Alpine"},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "ECOSYSTEM",
|
||||
"events": [
|
||||
{"introduced": "3.1.0"},
|
||||
{"fixed": "3.1.6-r0"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{"type": "ADVISORY", "url": "https://www.openssl.org/news/secadv/20240408.txt"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Verify our conversion logic
|
||||
var resp osvQueryResponse
|
||||
if err := json.Unmarshal([]byte(osvResponse), &resp); err != nil {
|
||||
t.Fatalf("failed to parse mock OSV response: %v", err)
|
||||
}
|
||||
|
||||
vulns := convertOSVVulns(resp.Vulns, "openssl", "3.1.4-r5")
|
||||
if len(vulns) != 2 {
|
||||
t.Fatalf("expected 2 vulns, got %d", len(vulns))
|
||||
}
|
||||
|
||||
// First vuln: CVE-2024-0727
|
||||
v1 := vulns[0]
|
||||
if v1.ID != "CVE-2024-0727" {
|
||||
t.Errorf("expected CVE-2024-0727, got %s", v1.ID)
|
||||
}
|
||||
if v1.Package != "openssl" {
|
||||
t.Errorf("expected package 'openssl', got %q", v1.Package)
|
||||
}
|
||||
if v1.Version != "3.1.4-r5" {
|
||||
t.Errorf("expected version '3.1.4-r5', got %q", v1.Version)
|
||||
}
|
||||
if v1.FixedIn != "3.1.5-r0" {
|
||||
t.Errorf("expected fixed in '3.1.5-r0', got %q", v1.FixedIn)
|
||||
}
|
||||
if v1.Severity != "MEDIUM" {
|
||||
t.Errorf("expected severity MEDIUM (CVSS 5.5), got %q", v1.Severity)
|
||||
}
|
||||
if v1.Summary != "PKCS12 Decoding crashes" {
|
||||
t.Errorf("unexpected summary: %q", v1.Summary)
|
||||
}
|
||||
if len(v1.References) != 2 {
|
||||
t.Errorf("expected 2 references, got %d", len(v1.References))
|
||||
}
|
||||
|
||||
// Second vuln: CVE-2024-2511
|
||||
v2 := vulns[1]
|
||||
if v2.ID != "CVE-2024-2511" {
|
||||
t.Errorf("expected CVE-2024-2511, got %s", v2.ID)
|
||||
}
|
||||
if v2.FixedIn != "3.1.6-r0" {
|
||||
t.Errorf("expected fixed in '3.1.6-r0', got %q", v2.FixedIn)
|
||||
}
|
||||
if v2.Severity != "LOW" {
|
||||
t.Errorf("expected severity LOW (CVSS 3.7), got %q", v2.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSVQueryParsing_BatchResponse(t *testing.T) {
|
||||
batchResponse := `{
|
||||
"results": [
|
||||
{
|
||||
"vulns": [
|
||||
{
|
||||
"id": "CVE-2024-0727",
|
||||
"summary": "PKCS12 Decoding crashes",
|
||||
"severity": [{"type": "CVSS_V3", "score": "5.5"}],
|
||||
"affected": [
|
||||
{
|
||||
"package": {"name": "openssl", "ecosystem": "Alpine"},
|
||||
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "3.1.5-r0"}]}]
|
||||
}
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"vulns": []
|
||||
},
|
||||
{
|
||||
"vulns": [
|
||||
{
|
||||
"id": "CVE-2024-9681",
|
||||
"summary": "curl: HSTS subdomain overwrites parent cache entry",
|
||||
"severity": [{"type": "CVSS_V3", "score": "6.5"}],
|
||||
"affected": [
|
||||
{
|
||||
"package": {"name": "curl", "ecosystem": "Alpine"},
|
||||
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "8.11.1-r0"}]}]
|
||||
}
|
||||
],
|
||||
"references": [{"type": "WEB", "url": "https://curl.se/docs/CVE-2024-9681.html"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var resp osvBatchResponse
|
||||
if err := json.Unmarshal([]byte(batchResponse), &resp); err != nil {
|
||||
t.Fatalf("failed to parse batch response: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Results) != 3 {
|
||||
t.Fatalf("expected 3 result entries, got %d", len(resp.Results))
|
||||
}
|
||||
|
||||
// First result: openssl has vulns
|
||||
vulns0 := convertOSVVulns(resp.Results[0].Vulns, "openssl", "3.1.4")
|
||||
if len(vulns0) != 1 {
|
||||
t.Errorf("expected 1 vuln for openssl, got %d", len(vulns0))
|
||||
}
|
||||
|
||||
// Second result: musl has no vulns
|
||||
vulns1 := convertOSVVulns(resp.Results[1].Vulns, "musl", "1.2.4")
|
||||
if len(vulns1) != 0 {
|
||||
t.Errorf("expected 0 vulns for musl, got %d", len(vulns1))
|
||||
}
|
||||
|
||||
// Third result: curl has vulns
|
||||
vulns2 := convertOSVVulns(resp.Results[2].Vulns, "curl", "8.5.0")
|
||||
if len(vulns2) != 1 {
|
||||
t.Errorf("expected 1 vuln for curl, got %d", len(vulns2))
|
||||
}
|
||||
if vulns2[0].FixedIn != "8.11.1-r0" {
|
||||
t.Errorf("expected curl fix 8.11.1-r0, got %q", vulns2[0].FixedIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSVQueryParsing_DatabaseSpecificSeverity(t *testing.T) {
|
||||
response := `{
|
||||
"vulns": [
|
||||
{
|
||||
"id": "DSA-5678-1",
|
||||
"summary": "Some advisory",
|
||||
"database_specific": {"severity": "HIGH"},
|
||||
"affected": [
|
||||
{
|
||||
"package": {"name": "libc6", "ecosystem": "Debian"},
|
||||
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "2.36-10"}]}]
|
||||
}
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var resp osvQueryResponse
|
||||
if err := json.Unmarshal([]byte(response), &resp); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
|
||||
vulns := convertOSVVulns(resp.Vulns, "libc6", "2.36-9")
|
||||
if len(vulns) != 1 {
|
||||
t.Fatalf("expected 1 vuln, got %d", len(vulns))
|
||||
}
|
||||
if vulns[0].Severity != "HIGH" {
|
||||
t.Errorf("expected HIGH from database_specific, got %q", vulns[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSVQueryParsing_DuplicateIDs(t *testing.T) {
|
||||
response := `{
|
||||
"vulns": [
|
||||
{
|
||||
"id": "CVE-2024-0727",
|
||||
"summary": "First mention",
|
||||
"affected": [],
|
||||
"references": []
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0727",
|
||||
"summary": "Duplicate mention",
|
||||
"affected": [],
|
||||
"references": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var resp osvQueryResponse
|
||||
json.Unmarshal([]byte(response), &resp)
|
||||
|
||||
vulns := convertOSVVulns(resp.Vulns, "openssl", "3.1.4")
|
||||
if len(vulns) != 1 {
|
||||
t.Errorf("expected dedup to 1 vuln, got %d", len(vulns))
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestScanReport ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestScanReport_Format(t *testing.T) {
|
||||
report := &ScanReport{
|
||||
Target: "alpine-3.19",
|
||||
OS: "Alpine Linux v3.19",
|
||||
Ecosystem: "Alpine",
|
||||
PackageCount: 42,
|
||||
Vulns: []VulnResult{
|
||||
{
|
||||
ID: "CVE-2024-0727", Package: "openssl", Version: "3.1.4",
|
||||
FixedIn: "3.1.5", Severity: "CRITICAL", Summary: "PKCS12 crash",
|
||||
},
|
||||
{
|
||||
ID: "CVE-2024-2511", Package: "openssl", Version: "3.1.4",
|
||||
FixedIn: "3.1.6", Severity: "HIGH", Summary: "TLS memory growth",
|
||||
},
|
||||
{
|
||||
ID: "CVE-2024-9999", Package: "busybox", Version: "1.36.1",
|
||||
FixedIn: "", Severity: "MEDIUM", Summary: "Buffer overflow",
|
||||
},
|
||||
},
|
||||
ScanTime: 1200 * time.Millisecond,
|
||||
}
|
||||
|
||||
out := FormatReport(report, "")
|
||||
|
||||
// Check key elements
|
||||
if !strings.Contains(out, "alpine-3.19") {
|
||||
t.Error("report missing target name")
|
||||
}
|
||||
if !strings.Contains(out, "Alpine Linux v3.19") {
|
||||
t.Error("report missing OS name")
|
||||
}
|
||||
if !strings.Contains(out, "42 detected") {
|
||||
t.Error("report missing package count")
|
||||
}
|
||||
if !strings.Contains(out, "CRITICAL") {
|
||||
t.Error("report missing CRITICAL severity")
|
||||
}
|
||||
if !strings.Contains(out, "CVE-2024-0727") {
|
||||
t.Error("report missing CVE ID")
|
||||
}
|
||||
if !strings.Contains(out, "(fixed in 3.1.5)") {
|
||||
t.Error("report missing fixed version")
|
||||
}
|
||||
if !strings.Contains(out, "(no fix available)") {
|
||||
t.Error("report missing 'no fix available' for busybox")
|
||||
}
|
||||
if !strings.Contains(out, "1 critical, 1 high, 1 medium, 0 low (3 total)") {
|
||||
t.Errorf("report summary wrong, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1.2s") {
|
||||
t.Error("report missing scan time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReport_FormatWithSeverityFilter(t *testing.T) {
|
||||
report := &ScanReport{
|
||||
Target: "test",
|
||||
OS: "Debian",
|
||||
PackageCount: 10,
|
||||
Vulns: []VulnResult{
|
||||
{ID: "CVE-1", Severity: "LOW", Package: "pkg1", Version: "1.0"},
|
||||
{ID: "CVE-2", Severity: "MEDIUM", Package: "pkg2", Version: "2.0"},
|
||||
{ID: "CVE-3", Severity: "HIGH", Package: "pkg3", Version: "3.0"},
|
||||
},
|
||||
ScanTime: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
out := FormatReport(report, "high")
|
||||
if strings.Contains(out, "CVE-1") {
|
||||
t.Error("LOW vuln should be filtered out")
|
||||
}
|
||||
if strings.Contains(out, "CVE-2") {
|
||||
t.Error("MEDIUM vuln should be filtered out")
|
||||
}
|
||||
if !strings.Contains(out, "CVE-3") {
|
||||
t.Error("HIGH vuln should be included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReport_FormatNoVulns(t *testing.T) {
|
||||
report := &ScanReport{
|
||||
Target: "clean-image",
|
||||
OS: "Alpine",
|
||||
PackageCount: 5,
|
||||
Vulns: nil,
|
||||
ScanTime: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
out := FormatReport(report, "")
|
||||
if !strings.Contains(out, "No vulnerabilities found") {
|
||||
t.Error("report should indicate no vulnerabilities")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReport_JSON(t *testing.T) {
|
||||
report := &ScanReport{
|
||||
Target: "test",
|
||||
OS: "Alpine Linux v3.19",
|
||||
Ecosystem: "Alpine",
|
||||
PackageCount: 3,
|
||||
Vulns: []VulnResult{
|
||||
{
|
||||
ID: "CVE-2024-0727", Package: "openssl", Version: "3.1.4",
|
||||
FixedIn: "3.1.5", Severity: "MEDIUM", Summary: "PKCS12 crash",
|
||||
References: []string{"https://example.com"},
|
||||
},
|
||||
},
|
||||
ScanTime: 1 * time.Second,
|
||||
}
|
||||
|
||||
jsonStr, err := FormatReportJSON(report)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatReportJSON failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's valid JSON that round-trips
|
||||
var parsed ScanReport
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
t.Fatalf("JSON doesn't round-trip: %v", err)
|
||||
}
|
||||
if parsed.Target != "test" {
|
||||
t.Errorf("target mismatch after round-trip: %q", parsed.Target)
|
||||
}
|
||||
if len(parsed.Vulns) != 1 {
|
||||
t.Errorf("expected 1 vuln after round-trip, got %d", len(parsed.Vulns))
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestSeverity ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSeverityAtLeast(t *testing.T) {
|
||||
tests := []struct {
|
||||
sev string
|
||||
threshold string
|
||||
expected bool
|
||||
}{
|
||||
{"CRITICAL", "HIGH", true},
|
||||
{"HIGH", "HIGH", true},
|
||||
{"MEDIUM", "HIGH", false},
|
||||
{"LOW", "MEDIUM", false},
|
||||
{"CRITICAL", "LOW", true},
|
||||
{"LOW", "LOW", true},
|
||||
{"UNKNOWN", "LOW", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := SeverityAtLeast(tt.sev, tt.threshold); got != tt.expected {
|
||||
t.Errorf("SeverityAtLeast(%q, %q) = %v, want %v", tt.sev, tt.threshold, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCVSSToSeverity(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"9.8", "CRITICAL"},
|
||||
{"9.0", "CRITICAL"},
|
||||
{"7.5", "HIGH"},
|
||||
{"7.0", "HIGH"},
|
||||
{"5.5", "MEDIUM"},
|
||||
{"4.0", "MEDIUM"},
|
||||
{"3.7", "LOW"},
|
||||
{"0.5", "LOW"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := cvssToSeverity(tt.input); got != tt.expected {
|
||||
t.Errorf("cvssToSeverity(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSeverity(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"CRITICAL", "CRITICAL"},
|
||||
{"critical", "CRITICAL"},
|
||||
{"IMPORTANT", "HIGH"},
|
||||
{"MODERATE", "MEDIUM"},
|
||||
{"NEGLIGIBLE", "LOW"},
|
||||
{"UNIMPORTANT", "LOW"},
|
||||
{"whatever", "UNKNOWN"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := normalizeSeverity(tt.input); got != tt.expected {
|
||||
t.Errorf("normalizeSeverity(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestCountBySeverity ──────────────────────────────────────────────────────
|
||||
|
||||
func TestCountBySeverity(t *testing.T) {
|
||||
report := &ScanReport{
|
||||
Vulns: []VulnResult{
|
||||
{Severity: "CRITICAL"},
|
||||
{Severity: "CRITICAL"},
|
||||
{Severity: "HIGH"},
|
||||
{Severity: "MEDIUM"},
|
||||
{Severity: "MEDIUM"},
|
||||
{Severity: "MEDIUM"},
|
||||
{Severity: "LOW"},
|
||||
{Severity: "UNKNOWN"},
|
||||
},
|
||||
}
|
||||
|
||||
counts := report.CountBySeverity()
|
||||
if counts.Critical != 2 {
|
||||
t.Errorf("critical: got %d, want 2", counts.Critical)
|
||||
}
|
||||
if counts.High != 1 {
|
||||
t.Errorf("high: got %d, want 1", counts.High)
|
||||
}
|
||||
if counts.Medium != 3 {
|
||||
t.Errorf("medium: got %d, want 3", counts.Medium)
|
||||
}
|
||||
if counts.Low != 1 {
|
||||
t.Errorf("low: got %d, want 1", counts.Low)
|
||||
}
|
||||
if counts.Unknown != 1 {
|
||||
t.Errorf("unknown: got %d, want 1", counts.Unknown)
|
||||
}
|
||||
if counts.Total != 8 {
|
||||
t.Errorf("total: got %d, want 8", counts.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestScanRootfs (with mock OSV server) ────────────────────────────────────
|
||||
|
||||
func TestScanRootfs_WithMockOSV(t *testing.T) {
|
||||
// Create a mock OSV batch server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/querybatch" {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Return a canned response: one vuln for openssl, nothing for musl
|
||||
resp := osvBatchResponse{
|
||||
Results: []osvQueryResponse{
|
||||
{ // openssl result
|
||||
Vulns: []osvVuln{
|
||||
{
|
||||
ID: "CVE-2024-0727",
|
||||
Summary: "PKCS12 crash",
|
||||
Severity: []struct {
|
||||
Type string `json:"type"`
|
||||
Score string `json:"score"`
|
||||
}{
|
||||
{Type: "CVSS_V3", Score: "9.8"},
|
||||
},
|
||||
Affected: []struct {
|
||||
Package struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
} `json:"package"`
|
||||
Ranges []struct {
|
||||
Type string `json:"type"`
|
||||
Events []struct {
|
||||
Introduced string `json:"introduced,omitempty"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
} `json:"events"`
|
||||
} `json:"ranges"`
|
||||
}{
|
||||
{
|
||||
Package: struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
}{Name: "openssl", Ecosystem: "Alpine"},
|
||||
Ranges: []struct {
|
||||
Type string `json:"type"`
|
||||
Events []struct {
|
||||
Introduced string `json:"introduced,omitempty"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
} `json:"events"`
|
||||
}{
|
||||
{
|
||||
Type: "ECOSYSTEM",
|
||||
Events: []struct {
|
||||
Introduced string `json:"introduced,omitempty"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
}{
|
||||
{Introduced: "0"},
|
||||
{Fixed: "3.1.5-r0"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ // musl result - no vulns
|
||||
Vulns: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Patch the batch URL for this test
|
||||
origURL := osvQueryBatchURL
|
||||
// We can't modify the const, so we test via the lower-level functions
|
||||
// Instead, test the integration manually
|
||||
|
||||
// Create a rootfs with Alpine packages
|
||||
rootfs := createTempRootfs(t, map[string]string{
|
||||
"etc/os-release": `PRETTY_NAME="Alpine Linux v3.19"
|
||||
ID=alpine
|
||||
VERSION_ID=3.19.1`,
|
||||
"lib/apk/db/installed": `P:openssl
|
||||
V:3.1.4-r5
|
||||
|
||||
P:musl
|
||||
V:1.2.4-r4
|
||||
`,
|
||||
})
|
||||
|
||||
// Test DetectOS
|
||||
osName, eco, err := DetectOS(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOS: %v", err)
|
||||
}
|
||||
if osName != "Alpine Linux v3.19" {
|
||||
t.Errorf("OS: got %q", osName)
|
||||
}
|
||||
if eco != "Alpine" {
|
||||
t.Errorf("ecosystem: got %q", eco)
|
||||
}
|
||||
|
||||
// Test ListPackages
|
||||
pkgs, err := ListPackages(rootfs)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPackages: %v", err)
|
||||
}
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
|
||||
// Test batch query against mock server using the internal function
|
||||
client := server.Client()
|
||||
_ = origURL // acknowledge to avoid lint
|
||||
vulnMap, err := queryOSVBatchWithURL(client, eco, pkgs, server.URL+"/v1/querybatch")
|
||||
if err != nil {
|
||||
t.Fatalf("queryOSVBatch: %v", err)
|
||||
}
|
||||
|
||||
// Should have vulns for openssl, not for musl
|
||||
if len(vulnMap) == 0 {
|
||||
t.Fatal("expected some vulnerabilities")
|
||||
}
|
||||
opensslKey := "openssl@3.1.4-r5"
|
||||
if _, ok := vulnMap[opensslKey]; !ok {
|
||||
t.Errorf("expected vulns for %s, keys: %v", opensslKey, mapKeys(vulnMap))
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestRpmOutput ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRpmOutputParsing(t *testing.T) {
|
||||
data := []byte("bash\t5.2.15-3.el9\nzlib\t1.2.11-40.el9\nopenssl-libs\t3.0.7-27.el9\n")
|
||||
|
||||
pkgs, err := parseRpmOutput(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseRpmOutput: %v", err)
|
||||
}
|
||||
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
|
||||
names := map[string]string{}
|
||||
for _, p := range pkgs {
|
||||
names[p.Name] = p.Version
|
||||
if p.Source != "rpm" {
|
||||
t.Errorf("expected source 'rpm', got %q", p.Source)
|
||||
}
|
||||
}
|
||||
|
||||
if names["bash"] != "5.2.15-3.el9" {
|
||||
t.Errorf("wrong version for bash: %q", names["bash"])
|
||||
}
|
||||
if names["openssl-libs"] != "3.0.7-27.el9" {
|
||||
t.Errorf("wrong version for openssl-libs: %q", names["openssl-libs"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// createTempRootfs creates a temporary directory structure mimicking a rootfs.
|
||||
func createTempRootfs(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for relPath, content := range files {
|
||||
fullPath := filepath.Join(root, relPath)
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(fullPath), err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", fullPath, err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func mapKeys(m map[string][]VulnResult) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user