diff --git a/alinux/alinux.go b/alinux/alinux.go new file mode 100644 index 00000000..058b8638 --- /dev/null +++ b/alinux/alinux.go @@ -0,0 +1,260 @@ +package alinux + +import ( + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/cheggaaa/pb/v3" + "golang.org/x/xerrors" + + "github.com/aquasecurity/vuln-list-update/utils" +) + +const ( + retry = 3 + alinuxDir = "alinux" +) + +var ( + ovalURLs = map[string]string{ + "2": "https://mirrors.aliyun.com/alinux/cve/data/OVAL/alinux-2.1903.oval.xml", + "3": "https://mirrors.aliyun.com/alinux/cve/data/OVAL/alinux-3.2104.oval.xml", + "4": "https://mirrors.aliyun.com/alinux/cve/data/OVAL/alinux-4.oval.xml", + } + + // rpmVerRe parses "package_name is earlier than epoch:version-release" from OVAL test comments + rpmVerRe = regexp.MustCompile(`^(\S+)\s+is earlier than\s+(.+)$`) +) + +// Config holds configuration for the Alinux updater +type Config struct { + ovalURLs map[string]string + vulnListDir string +} + +type option func(*Config) + +// With sets internal values for testing +func With(ovalURLs map[string]string, vulnListDir string) option { + return func(c *Config) { + c.ovalURLs = ovalURLs + c.vulnListDir = vulnListDir + } +} + +// NewConfig creates a new Config +func NewConfig(opts ...option) *Config { + config := &Config{ + ovalURLs: ovalURLs, + vulnListDir: utils.VulnListDir(), + } + for _, opt := range opts { + opt(config) + } + return config +} + +// Update fetches and parses OVAL data for all Alinux versions +func (c *Config) Update() error { + for version, url := range c.ovalURLs { + log.Printf("Fetching security advisories of Alibaba Cloud Linux %s...\n", version) + if err := c.update(version, url); err != nil { + return xerrors.Errorf("failed to update security advisories of Alibaba Cloud Linux %s: %w", version, err) + } + } + return nil +} + +func (c *Config) update(version, url string) error { + dir := filepath.Join(c.vulnListDir, alinuxDir, version) + if err := os.RemoveAll(dir); err != nil { + return xerrors.Errorf("unable to remove alinux directory: %w", err) + } + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return xerrors.Errorf("failed to mkdir: %w", err) + } + + advisories, err := fetchOVAL(url) + if err != nil { + return xerrors.Errorf("failed to fetch OVAL data: %w", err) + } + + bar := pb.StartNew(len(advisories)) + for _, adv := range advisories { + filePath := filepath.Join(dir, fmt.Sprintf("%s.json", adv.ID)) + if err := utils.Write(filePath, adv); err != nil { + return xerrors.Errorf("failed to write Alinux advisory: %w", err) + } + bar.Increment() + } + bar.Finish() + + return nil +} + +func fetchOVAL(url string) ([]ALSA, error) { + resp, err := http.Get(url) + if err != nil { + return nil, xerrors.Errorf("failed to fetch OVAL XML: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, xerrors.Errorf("failed to fetch OVAL XML: status %d", resp.StatusCode) + } + + return parseOVAL(resp.Body) +} + +func parseOVAL(r io.Reader) ([]ALSA, error) { + var ovalDefs OvalDefinitions + if err := xml.NewDecoder(r).Decode(&ovalDefs); err != nil { + return nil, xerrors.Errorf("failed to decode OVAL XML: %w", err) + } + + // Build lookup maps for tests, objects, and states + testMap := make(map[string]RPMInfoTest) + for _, t := range ovalDefs.Tests.RPMInfoTests { + testMap[t.ID] = t + } + objectMap := make(map[string]RPMInfoObject) + for _, o := range ovalDefs.Objects.RPMInfoObjects { + objectMap[o.ID] = o + } + stateMap := make(map[string]RPMInfoState) + for _, s := range ovalDefs.States.RPMInfoStates { + stateMap[s.ID] = s + } + + var advisories []ALSA + for _, def := range ovalDefs.Definitions { + if def.Class != "patch" { + continue + } + + adv := convertDefinition(def, testMap, objectMap, stateMap) + if adv.ID == "" || len(adv.CveIDs) == 0 { + continue + } + advisories = append(advisories, adv) + } + + return advisories, nil +} + +func convertDefinition(def Definition, testMap map[string]RPMInfoTest, + objectMap map[string]RPMInfoObject, stateMap map[string]RPMInfoState) ALSA { + + meta := def.Metadata + + // Extract advisory ID from reference + advisoryID := meta.Reference.RefID + + // Extract CVE IDs + var cveIDs []string + var refs []CveRef + for _, cve := range meta.Advisory.Cves { + cveIDs = append(cveIDs, cve.CveID) + refs = append(refs, CveRef{ + ID: cve.CveID, + Href: cve.Href, + Cvss3: cve.Cvss3, + Impact: cve.Impact, + }) + } + + // Extract packages from criteria + packages := extractPackages(def.Criteria, testMap, objectMap, stateMap) + + return ALSA{ + ID: advisoryID, + Title: meta.Title, + Severity: meta.Advisory.Severity, + Description: meta.Desc, + Issued: DateJSON{Date: meta.Advisory.Issued.Date}, + Updated: DateJSON{Date: meta.Advisory.Updated.Date}, + Packages: packages, + CveIDs: cveIDs, + References: refs, + } +} + +func extractPackages(criteria Criteria, testMap map[string]RPMInfoTest, + objectMap map[string]RPMInfoObject, stateMap map[string]RPMInfoState) []Package { + + var packages []Package + + for _, criterion := range criteria.Criterions { + pkg := extractPackageFromTest(criterion.TestRef, testMap, objectMap, stateMap) + if pkg != nil { + packages = append(packages, *pkg) + } + } + + for _, subCriteria := range criteria.Criterias { + packages = append(packages, extractPackages(subCriteria, testMap, objectMap, stateMap)...) + } + + return packages +} + +func extractPackageFromTest(testRef string, testMap map[string]RPMInfoTest, + objectMap map[string]RPMInfoObject, stateMap map[string]RPMInfoState) *Package { + + test, ok := testMap[testRef] + if !ok { + return nil + } + + obj, ok := objectMap[test.ObjectRef.Ref] + if !ok { + return nil + } + + state, ok := stateMap[test.StateRef.Ref] + if !ok { + return nil + } + + pkgName := obj.Name + evr := state.EVR.Value + + epoch, version, release := parseEVR(evr) + + return &Package{ + Name: pkgName, + Epoch: epoch, + Version: version, + Release: release, + } +} + +// parseEVR parses epoch:version-release string +// Examples: "0:3.11.13-4.0.1.al8", "1:11-openjdk-11.0.16.0.8-1.al8" +func parseEVR(evr string) (epoch, version, release string) { + epoch = "0" + + // Split epoch + parts := strings.SplitN(evr, ":", 2) + if len(parts) == 2 { + epoch = parts[0] + evr = parts[1] + } + + // Split version-release + lastDash := strings.LastIndex(evr, "-") + if lastDash < 0 { + version = evr + return + } + version = evr[:lastDash] + release = evr[lastDash+1:] + return +} diff --git a/alinux/alinux_test.go b/alinux/alinux_test.go new file mode 100644 index 00000000..baa24f4f --- /dev/null +++ b/alinux/alinux_test.go @@ -0,0 +1,61 @@ +package alinux + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_parseEVR(t *testing.T) { + tests := []struct { + name string + evr string + wantEpoch string + wantVersion string + wantRelease string + }{ + { + name: "version-release without epoch", + evr: "7.61.1-22.al8.3", + wantEpoch: "0", + wantVersion: "7.61.1", + wantRelease: "22.al8.3", + }, + { + name: "with epoch", + evr: "1:1.0.2k-25.al2", + wantEpoch: "1", + wantVersion: "1.0.2k", + wantRelease: "25.al2", + }, + { + name: "kernel version", + evr: "5.10.134-16.3.al8", + wantEpoch: "0", + wantVersion: "5.10.134", + wantRelease: "16.3.al8", + }, + { + name: "explicit epoch 0", + evr: "0:2.14.5-1.59.al7", + wantEpoch: "0", + wantVersion: "2.14.5", + wantRelease: "1.59.al7", + }, + { + name: "version only no release", + evr: "1.0.0", + wantEpoch: "0", + wantVersion: "1.0.0", + wantRelease: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + epoch, version, release := parseEVR(tt.evr) + assert.Equal(t, tt.wantEpoch, epoch) + assert.Equal(t, tt.wantVersion, version) + assert.Equal(t, tt.wantRelease, release) + }) + } +} diff --git a/alinux/csaf.go b/alinux/csaf.go new file mode 100644 index 00000000..cf7b7d57 --- /dev/null +++ b/alinux/csaf.go @@ -0,0 +1,470 @@ +package alinux + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/cheggaaa/pb/v3" + "golang.org/x/xerrors" + + "github.com/aquasecurity/vuln-list-update/utils" +) + +const ( + csafAdvisoryBaseURL = "https://mirrors.aliyun.com/alinux/cve/data/CSAF/advisories/" + csafVEXBaseURL = "https://mirrors.aliyun.com/alinux/cve/data/CSAF/vex/" + alinuxCSAFVEXDir = "alinux-csaf-vex" +) + +var ( + // advisoryFileRe matches advisory filenames like "alinux2-sa-2019_0001.json" + advisoryFileRe = regexp.MustCompile(`href="(alinux\d+-sa-\d+_\d+\.json)"`) + // vexFileRe matches VEX filenames like "CVE-2024-0567.json" + vexFileRe = regexp.MustCompile(`href="(CVE-\d+-\d+\.json)"`) + // productVersionRe extracts major version from product names like "Alinux 2.1903", "Alinux 3.2104", "Alinux 4" + productVersionRe = regexp.MustCompile(`(?i)Alinux\s+(\d+)`) + // advisoryVersionRe extracts major version from advisory filenames like "alinux2-sa-..." + advisoryVersionRe = regexp.MustCompile(`^alinux(\d+)-`) +) + +// CSAFConfig holds configuration for the Alinux CSAF updater +type CSAFConfig struct { + vulnListDir string + retry int +} + +type csafOption func(*CSAFConfig) + +// WithCSAFVulnListDir sets the vuln-list directory for testing +func WithCSAFVulnListDir(dir string) csafOption { + return func(c *CSAFConfig) { + c.vulnListDir = dir + } +} + +// NewCSAFConfig creates a new CSAFConfig +func NewCSAFConfig(opts ...csafOption) *CSAFConfig { + config := &CSAFConfig{ + vulnListDir: utils.VulnListDir(), + retry: retry, + } + for _, opt := range opts { + opt(config) + } + return config +} + +// Update fetches and processes both CSAF advisories and VEX data +func (c *CSAFConfig) Update() error { + log.Println("Fetching Alibaba Cloud Linux CSAF advisories...") + if err := c.updateAdvisories(); err != nil { + return xerrors.Errorf("failed to update CSAF advisories: %w", err) + } + + log.Println("Fetching Alibaba Cloud Linux CSAF VEX data...") + if err := c.updateVEX(); err != nil { + return xerrors.Errorf("failed to update CSAF VEX: %w", err) + } + + return nil +} + +// updateAdvisories fetches all CSAF advisory files and converts them to ALSA format +func (c *CSAFConfig) updateAdvisories() error { + // Fetch the directory listing to get all advisory file names + fileNames, err := fetchFileList(csafAdvisoryBaseURL, advisoryFileRe) + if err != nil { + return xerrors.Errorf("failed to fetch advisory file list: %w", err) + } + log.Printf("Found %d CSAF advisory files\n", len(fileNames)) + + // Group files by version based on filename prefix + versionFiles := map[string][]string{} + for _, name := range fileNames { + m := advisoryVersionRe.FindStringSubmatch(name) + if len(m) != 2 { + log.Printf("Skipping unrecognized advisory file: %s\n", name) + continue + } + ver := m[1] + versionFiles[ver] = append(versionFiles[ver], name) + } + + // Process each version + for ver, files := range versionFiles { + log.Printf("Processing Alinux %s CSAF advisories (%d files)...\n", ver, len(files)) + dir := filepath.Join(c.vulnListDir, alinuxDir, ver) + if err := os.RemoveAll(dir); err != nil { + return xerrors.Errorf("unable to remove directory: %w", err) + } + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return xerrors.Errorf("failed to mkdir: %w", err) + } + + bar := pb.StartNew(len(files)) + for _, fileName := range files { + advisories, err := c.fetchAndParseCSAFAdvisory(csafAdvisoryBaseURL + fileName) + if err != nil { + log.Printf("Warning: failed to process %s: %v\n", fileName, err) + bar.Increment() + continue + } + for _, adv := range advisories { + filePath := filepath.Join(dir, fmt.Sprintf("%s.json", adv.ID)) + if err := utils.Write(filePath, adv); err != nil { + return xerrors.Errorf("failed to write advisory %s: %w", adv.ID, err) + } + } + bar.Increment() + } + bar.Finish() + } + + return nil +} + +// updateVEX fetches all CSAF VEX files and stores them +func (c *CSAFConfig) updateVEX() error { + // Fetch the directory listing to get all VEX file names + fileNames, err := fetchFileList(csafVEXBaseURL, vexFileRe) + if err != nil { + return xerrors.Errorf("failed to fetch VEX file list: %w", err) + } + log.Printf("Found %d CSAF VEX files\n", len(fileNames)) + + vexDir := filepath.Join(c.vulnListDir, alinuxCSAFVEXDir) + if err := os.RemoveAll(vexDir); err != nil { + return xerrors.Errorf("unable to remove VEX directory: %w", err) + } + + bar := pb.StartNew(len(fileNames)) + for _, fileName := range fileNames { + cveID := strings.TrimSuffix(fileName, ".json") + url := csafVEXBaseURL + fileName + + data, err := utils.FetchURL(url, "", retry) + if err != nil { + log.Printf("Warning: failed to fetch VEX %s: %v\n", fileName, err) + bar.Increment() + continue + } + + // Validate JSON + var doc CSAFDocument + if err := json.Unmarshal(data, &doc); err != nil { + log.Printf("Warning: failed to parse VEX %s: %v\n", fileName, err) + bar.Increment() + continue + } + + // Store using CVE-per-year directory structure + if err := utils.SaveCVEPerYear(vexDir, cveID, doc); err != nil { + return xerrors.Errorf("failed to save VEX %s: %w", cveID, err) + } + bar.Increment() + } + bar.Finish() + + return nil +} + +// fetchAndParseCSAFAdvisory downloads a CSAF advisory and converts it to ALSA format +func (c *CSAFConfig) fetchAndParseCSAFAdvisory(url string) ([]ALSA, error) { + data, err := utils.FetchURL(url, "", c.retry) + if err != nil { + return nil, xerrors.Errorf("failed to fetch %s: %w", url, err) + } + + var doc CSAFDocument + if err := json.Unmarshal(data, &doc); err != nil { + return nil, xerrors.Errorf("failed to parse CSAF advisory: %w", err) + } + + return convertCSAFToALSA(doc) +} + +// convertCSAFToALSA converts a CSAF advisory document to ALSA format +func convertCSAFToALSA(doc CSAFDocument) ([]ALSA, error) { + advisoryID := doc.Document.Tracking.ID + severity := doc.Document.AggregateSeverity.Text + + // Extract description from notes + var description string + for _, note := range doc.Document.Notes { + if note.Category == "description" { + description = note.Text + break + } + } + + // Build a map of product_id -> relationship for quick lookup + relMap := buildRelationshipMap(doc.ProductTree.Relationships) + + // Process each vulnerability + for _, vuln := range doc.Vulnerabilities { + cveID := vuln.CVE + if cveID == "" { + continue + } + + // Extract CVSS3 score string + var cvss3 string + if len(vuln.Scores) > 0 { + cvss3 = vuln.Scores[0].CvssV3.VectorString + } + + // Extract threat severity (per-CVE) + var impact string + for _, t := range vuln.Threats { + if t.Category == "impact" { + impact = t.Details + break + } + } + + // Extract CVE description + var cveDescription string + for _, note := range vuln.Notes { + if note.Category == "description" { + cveDescription = note.Text + break + } + } + + // Extract packages from fixed product_ids + // Product IDs are in format "Platform:NEVRA" (e.g., "Alinux 2.1903:keepalived-1.3.5-8.3.al7.x86_64") + packages := extractPackagesFromFixed(vuln.ProductStatus.Fixed, relMap) + + refs := []CveRef{{ + ID: cveID, + Href: fmt.Sprintf("https://alas.aliyuncs.com/cves/detail/%s", cveID), + Cvss3: cvss3, + Impact: impact, + }} + + if len(packages) == 0 { + continue + } + + // Use the CVE-level description if available, otherwise use advisory-level + desc := cveDescription + if desc == "" { + desc = description + } + + alsa := ALSA{ + ID: advisoryID, + Title: doc.Document.Title, + Severity: severity, + Description: desc, + Issued: DateJSON{Date: doc.Document.Tracking.InitialReleaseDate}, + Updated: DateJSON{Date: doc.Document.Tracking.CurrentReleaseDate}, + Packages: packages, + CveIDs: []string{cveID}, + References: refs, + } + + return []ALSA{alsa}, nil + } + + // If multiple CVEs, create a single advisory with all CVEs + if len(doc.Vulnerabilities) > 1 { + var cveIDs []string + var refs []CveRef + var allPackages []Package + + for _, vuln := range doc.Vulnerabilities { + if vuln.CVE == "" { + continue + } + cveIDs = append(cveIDs, vuln.CVE) + + var cvss3, impact string + if len(vuln.Scores) > 0 { + cvss3 = vuln.Scores[0].CvssV3.VectorString + } + for _, t := range vuln.Threats { + if t.Category == "impact" { + impact = t.Details + break + } + } + + refs = append(refs, CveRef{ + ID: vuln.CVE, + Href: fmt.Sprintf("https://alas.aliyuncs.com/cves/detail/%s", vuln.CVE), + Cvss3: cvss3, + Impact: impact, + }) + + pkgs := extractPackagesFromFixed(vuln.ProductStatus.Fixed, relMap) + allPackages = append(allPackages, pkgs...) + } + + allPackages = deduplicatePackages(allPackages) + + if len(cveIDs) > 0 && len(allPackages) > 0 { + alsa := ALSA{ + ID: advisoryID, + Title: doc.Document.Title, + Severity: severity, + Description: description, + Issued: DateJSON{Date: doc.Document.Tracking.InitialReleaseDate}, + Updated: DateJSON{Date: doc.Document.Tracking.CurrentReleaseDate}, + Packages: allPackages, + CveIDs: cveIDs, + References: refs, + } + return []ALSA{alsa}, nil + } + } + + return nil, nil +} + +// buildRelationshipMap builds a lookup map from product_id to relationship +func buildRelationshipMap(rels []CSAFRelationship) map[string]CSAFRelationship { + m := make(map[string]CSAFRelationship, len(rels)) + for _, rel := range rels { + m[rel.FullProductName.ProductID] = rel + } + return m +} + +// extractPackagesFromFixed extracts package info from fixed product IDs +func extractPackagesFromFixed(fixedIDs []string, relMap map[string]CSAFRelationship) []Package { + seen := map[string]bool{} + var packages []Package + + for _, productID := range fixedIDs { + rel, ok := relMap[productID] + if !ok { + // Try to parse directly from the product ID format "Platform:NEVRA" + parts := strings.SplitN(productID, ":", 2) + if len(parts) != 2 { + continue + } + rel = CSAFRelationship{ + ProductReference: parts[1], + RelatesToProductReference: parts[0], + } + } + + nevra := rel.ProductReference + name, epoch, version, release, arch, err := parseNEVRA(nevra) + if err != nil { + continue + } + + // Skip source and debug packages + if arch == "src" { + continue + } + if strings.Contains(name, "-debuginfo") || strings.Contains(name, "-debugsource") { + continue + } + + // Deduplicate by package name (different arches have same version) + key := fmt.Sprintf("%s-%s-%s-%s", name, epoch, version, release) + if seen[key] { + continue + } + seen[key] = true + + packages = append(packages, Package{ + Name: name, + Epoch: epoch, + Version: version, + Release: release, + }) + } + + return packages +} + +// parseNEVRA parses an RPM NEVRA string: name-[epoch:]version-release.arch +func parseNEVRA(nevra string) (name, epoch, version, release, arch string, err error) { + // Split arch: last '.' separates arch + lastDot := strings.LastIndex(nevra, ".") + if lastDot < 0 { + return "", "", "", "", "", fmt.Errorf("invalid NEVRA: no arch separator: %s", nevra) + } + arch = nevra[lastDot+1:] + rest := nevra[:lastDot] + + // Split release: last '-' separates release + lastDash := strings.LastIndex(rest, "-") + if lastDash < 0 { + return "", "", "", "", "", fmt.Errorf("invalid NEVRA: no release separator: %s", nevra) + } + release = rest[lastDash+1:] + rest = rest[:lastDash] + + // Split version: last '-' separates version from name + lastDash = strings.LastIndex(rest, "-") + if lastDash < 0 { + return "", "", "", "", "", fmt.Errorf("invalid NEVRA: no version separator: %s", nevra) + } + version = rest[lastDash+1:] + name = rest[:lastDash] + + // Check for epoch in version (epoch:version) + epoch = "0" + if i := strings.Index(version, ":"); i >= 0 { + epoch = version[:i] + version = version[i+1:] + } + + return +} + +// deduplicatePackages removes duplicate packages by name+version+release +func deduplicatePackages(pkgs []Package) []Package { + seen := map[string]bool{} + var result []Package + for _, pkg := range pkgs { + key := fmt.Sprintf("%s-%s-%s-%s", pkg.Name, pkg.Epoch, pkg.Version, pkg.Release) + if seen[key] { + continue + } + seen[key] = true + result = append(result, pkg) + } + return result +} + +// fetchFileList fetches an HTML directory listing and extracts file names matching the pattern +func fetchFileList(baseURL string, pattern *regexp.Regexp) ([]string, error) { + resp, err := http.Get(baseURL) + if err != nil { + return nil, xerrors.Errorf("failed to fetch directory listing: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, xerrors.Errorf("HTTP error fetching directory listing: status %d", resp.StatusCode) + } + + // Read full body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, xerrors.Errorf("failed to read response body: %w", err) + } + + matches := pattern.FindAllStringSubmatch(string(body), -1) + var fileNames []string + for _, m := range matches { + if len(m) == 2 { + fileNames = append(fileNames, m[1]) + } + } + + return fileNames, nil +} diff --git a/alinux/csaf_test.go b/alinux/csaf_test.go new file mode 100644 index 00000000..6d5ca434 --- /dev/null +++ b/alinux/csaf_test.go @@ -0,0 +1,319 @@ +package alinux + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parseNEVRA(t *testing.T) { + tests := []struct { + name string + nevra string + wantName string + wantEpoch string + wantVersion string + wantRelease string + wantArch string + wantErr bool + }{ + { + name: "simple package", + nevra: "curl-7.61.1-22.al8.3.x86_64", + wantName: "curl", + wantEpoch: "0", + wantVersion: "7.61.1", + wantRelease: "22.al8.3", + wantArch: "x86_64", + }, + { + name: "package with epoch", + nevra: "openssl-1:1.0.2k-25.al2.x86_64", + wantName: "openssl", + wantEpoch: "1", + wantVersion: "1.0.2k", + wantRelease: "25.al2", + wantArch: "x86_64", + }, + { + name: "multi-dash package name", + nevra: "kernel-headers-5.10.134-16.3.al8.x86_64", + wantName: "kernel-headers", + wantEpoch: "0", + wantVersion: "5.10.134", + wantRelease: "16.3.al8", + wantArch: "x86_64", + }, + { + name: "source package", + nevra: "curl-7.61.1-22.al8.3.src", + wantName: "curl", + wantEpoch: "0", + wantVersion: "7.61.1", + wantRelease: "22.al8.3", + wantArch: "src", + }, + { + name: "noarch package", + nevra: "tzdata-2023c-1.al8.noarch", + wantName: "tzdata", + wantEpoch: "0", + wantVersion: "2023c", + wantRelease: "1.al8", + wantArch: "noarch", + }, + { + name: "no arch separator", + nevra: "invalid-package", + wantErr: true, + }, + { + name: "no release separator", + nevra: "invalid.x86_64", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, epoch, ver, rel, arch, err := parseNEVRA(tt.nevra) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantEpoch, epoch) + assert.Equal(t, tt.wantVersion, ver) + assert.Equal(t, tt.wantRelease, rel) + assert.Equal(t, tt.wantArch, arch) + }) + } +} + +func Test_deduplicatePackages(t *testing.T) { + tests := []struct { + name string + pkgs []Package + want []Package + }{ + { + name: "with duplicates", + pkgs: []Package{ + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + {Name: "libcurl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + }, + want: []Package{ + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + {Name: "libcurl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + }, + }, + { + name: "no duplicates", + pkgs: []Package{ + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + {Name: "libcurl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + }, + want: []Package{ + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + {Name: "libcurl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := deduplicatePackages(tt.pkgs) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_convertCSAFToALSA(t *testing.T) { + tests := []struct { + name string + doc CSAFDocument + want []ALSA + wantNil bool + }{ + { + name: "single CVE", + doc: CSAFDocument{ + Document: CSAFDocumentMeta{ + Title: "Test Advisory", + Tracking: CSAFTracking{ + ID: "ALINUX3-SA-2024:0001", + InitialReleaseDate: "2024-01-15", + CurrentReleaseDate: "2024-01-15", + }, + AggregateSeverity: CSAFAggregateSeverity{Text: "Important"}, + Notes: []CSAFNote{ + {Category: "description", Text: "Advisory description"}, + }, + }, + ProductTree: CSAFProductTree{ + Relationships: []CSAFRelationship{ + { + ProductReference: "curl-7.61.1-22.al8.3.x86_64", + RelatesToProductReference: "Alinux 3", + FullProductName: CSAFFullProductName{ProductID: "Alinux 3:curl-7.61.1-22.al8.3.x86_64"}, + }, + }, + }, + Vulnerabilities: []CSAFVulnerability{ + { + CVE: "CVE-2024-0001", + ProductStatus: CSAFProductStatus{ + Fixed: []string{"Alinux 3:curl-7.61.1-22.al8.3.x86_64"}, + }, + Scores: []CSAFScore{ + {CvssV3: CSAFCvssV3{VectorString: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", BaseScore: 7.5}}, + }, + Threats: []CSAFThreat{{Category: "impact", Details: "Important"}}, + }, + }, + }, + want: []ALSA{ + { + ID: "ALINUX3-SA-2024:0001", + Title: "Test Advisory", + Severity: "Important", + Description: "Advisory description", + Issued: DateJSON{Date: "2024-01-15"}, + Updated: DateJSON{Date: "2024-01-15"}, + CveIDs: []string{"CVE-2024-0001"}, + Packages: []Package{ + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + }, + References: []CveRef{ + {ID: "CVE-2024-0001", Href: "https://alas.aliyuncs.com/cves/detail/CVE-2024-0001", Cvss3: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", Impact: "Important"}, + }, + }, + }, + }, + { + name: "empty vulnerabilities", + doc: CSAFDocument{ + Document: CSAFDocumentMeta{ + Title: "Empty Advisory", + Tracking: CSAFTracking{ + ID: "ALINUX3-SA-2024:0002", + }, + }, + }, + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := convertCSAFToALSA(tt.doc) + require.NoError(t, err) + if tt.wantNil { + assert.Nil(t, got) + return + } + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_extractPackagesFromFixed(t *testing.T) { + relMap := map[string]CSAFRelationship{ + "Alinux 3:curl-7.61.1-22.al8.3.x86_64": { + ProductReference: "curl-7.61.1-22.al8.3.x86_64", + RelatesToProductReference: "Alinux 3", + }, + "Alinux 3:curl-7.61.1-22.al8.3.src": { + ProductReference: "curl-7.61.1-22.al8.3.src", + RelatesToProductReference: "Alinux 3", + }, + "Alinux 3:curl-debuginfo-7.61.1-22.al8.3.x86_64": { + ProductReference: "curl-debuginfo-7.61.1-22.al8.3.x86_64", + RelatesToProductReference: "Alinux 3", + }, + "Alinux 3:libcurl-7.61.1-22.al8.3.x86_64": { + ProductReference: "libcurl-7.61.1-22.al8.3.x86_64", + RelatesToProductReference: "Alinux 3", + }, + } + + tests := []struct { + name string + fixedIDs []string + want []Package + }{ + { + name: "extract packages, skip src and debuginfo", + fixedIDs: []string{ + "Alinux 3:curl-7.61.1-22.al8.3.x86_64", + "Alinux 3:curl-7.61.1-22.al8.3.src", + "Alinux 3:curl-debuginfo-7.61.1-22.al8.3.x86_64", + "Alinux 3:libcurl-7.61.1-22.al8.3.x86_64", + }, + want: []Package{ + {Name: "curl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + {Name: "libcurl", Epoch: "0", Version: "7.61.1", Release: "22.al8.3"}, + }, + }, + { + name: "empty fixed IDs", + fixedIDs: []string{}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPackagesFromFixed(tt.fixedIDs, relMap) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_fetchFileList(t *testing.T) { + tests := []struct { + name string + html string + pattern string + want []string + }{ + { + name: "advisory files", + html: ` +alinux2-sa-2023_0001.json +alinux3-sa-2024_0001.json +other.txt +`, + pattern: "advisory", + want: []string{"alinux2-sa-2023_0001.json", "alinux3-sa-2024_0001.json"}, + }, + { + name: "VEX files", + html: ` +CVE-2023-12345.json +CVE-2024-0001.json +README.md +`, + pattern: "vex", + want: []string{"CVE-2023-12345.json", "CVE-2024-0001.json"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tt.html) + })) + defer ts.Close() + + var re = advisoryFileRe + if tt.pattern == "vex" { + re = vexFileRe + } + got, err := fetchFileList(ts.URL, re) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/alinux/csaf_types.go b/alinux/csaf_types.go new file mode 100644 index 00000000..d659e86e --- /dev/null +++ b/alinux/csaf_types.go @@ -0,0 +1,116 @@ +package alinux + +// CSAFDocument represents a CSAF advisory or VEX document +type CSAFDocument struct { + Document CSAFDocumentMeta `json:"document"` + ProductTree CSAFProductTree `json:"product_tree"` + Vulnerabilities []CSAFVulnerability `json:"vulnerabilities"` +} + +// CSAFDocumentMeta holds the document-level metadata +type CSAFDocumentMeta struct { + AggregateSeverity CSAFAggregateSeverity `json:"aggregate_severity"` + Category string `json:"category"` + Notes []CSAFNote `json:"notes"` + References []CSAFReference `json:"references"` + Title string `json:"title"` + Tracking CSAFTracking `json:"tracking"` +} + +// CSAFAggregateSeverity holds the overall severity rating +type CSAFAggregateSeverity struct { + Text string `json:"text"` +} + +// CSAFNote holds advisory notes (summary, description, legal) +type CSAFNote struct { + Category string `json:"category"` + Text string `json:"text"` + Title string `json:"title"` +} + +// CSAFReference holds reference links +type CSAFReference struct { + Category string `json:"category"` + Summary string `json:"summary"` + URL string `json:"url"` +} + +// CSAFTracking holds tracking metadata +type CSAFTracking struct { + CurrentReleaseDate string `json:"current_release_date"` + ID string `json:"id"` + InitialReleaseDate string `json:"initial_release_date"` +} + +// CSAFProductTree holds the product tree with relationships +type CSAFProductTree struct { + Relationships []CSAFRelationship `json:"relationships"` +} + +// CSAFRelationship maps a package (product_reference) to a platform (relates_to_product_reference) +type CSAFRelationship struct { + Category string `json:"category"` + FullProductName CSAFFullProductName `json:"full_product_name"` + ProductReference string `json:"product_reference"` + RelatesToProductReference string `json:"relates_to_product_reference"` +} + +// CSAFFullProductName identifies the combined product +type CSAFFullProductName struct { + Name string `json:"name"` + ProductID string `json:"product_id"` +} + +// CSAFVulnerability represents a vulnerability entry in CSAF +type CSAFVulnerability struct { + CVE string `json:"cve"` + Notes []CSAFNote `json:"notes,omitempty"` + ProductStatus CSAFProductStatus `json:"product_status"` + References []CSAFReference `json:"references,omitempty"` + Remediations []CSAFRemediation `json:"remediations,omitempty"` + Scores []CSAFScore `json:"scores,omitempty"` + Threats []CSAFThreat `json:"threats,omitempty"` + Flags []CSAFFlag `json:"flags,omitempty"` + Title string `json:"title,omitempty"` +} + +// CSAFProductStatus groups product IDs by their vulnerability status +type CSAFProductStatus struct { + Fixed []string `json:"fixed,omitempty"` + KnownNotAffected []string `json:"known_not_affected,omitempty"` +} + +// CSAFScore holds CVSS scoring information +type CSAFScore struct { + CvssV3 CSAFCvssV3 `json:"cvss_v3"` + Products []string `json:"products"` +} + +// CSAFCvssV3 holds CVSSv3 score details +type CSAFCvssV3 struct { + BaseScore float64 `json:"baseScore"` + BaseSeverity string `json:"baseSeverity"` + VectorString string `json:"vectorString"` + Version string `json:"version"` +} + +// CSAFThreat holds threat/impact information +type CSAFThreat struct { + Category string `json:"category"` + Date string `json:"date"` + Details string `json:"details"` +} + +// CSAFFlag holds flag information (e.g., vulnerable_code_not_present) +type CSAFFlag struct { + Label string `json:"label"` + ProductIDs []string `json:"product_ids"` +} + +// CSAFRemediation holds remediation information +type CSAFRemediation struct { + Category string `json:"category"` + Details string `json:"details"` + ProductIDs []string `json:"product_ids"` +} diff --git a/alinux/types.go b/alinux/types.go new file mode 100644 index 00000000..d6ce88ea --- /dev/null +++ b/alinux/types.go @@ -0,0 +1,177 @@ +package alinux + +import "encoding/xml" + +// OvalDefinitions represents the root element of an OVAL XML document +type OvalDefinitions struct { + XMLName xml.Name `xml:"oval_definitions"` + Definitions []Definition `xml:"definitions>definition"` + Tests Tests `xml:"tests"` + Objects Objects `xml:"objects"` + States States `xml:"states"` +} + +// Definition represents an OVAL definition (security advisory) +type Definition struct { + ID string `xml:"id,attr"` + Class string `xml:"class,attr"` + Metadata Metadata `xml:"metadata"` + Criteria Criteria `xml:"criteria"` +} + +// Metadata contains advisory metadata +type Metadata struct { + Title string `xml:"title"` + Affected Affected `xml:"affected"` + Reference Reference `xml:"reference"` + Desc string `xml:"description"` + Advisory Advisory `xml:"advisory"` +} + +// Affected has platform info +type Affected struct { + Family string `xml:"family,attr"` + Platform string `xml:"platform"` +} + +// Reference has the advisory reference +type Reference struct { + RefID string `xml:"ref_id,attr"` + RefURL string `xml:"ref_url,attr"` + Source string `xml:"source,attr"` +} + +// Advisory contains detailed advisory info +type Advisory struct { + From string `xml:"from,attr"` + Severity string `xml:"severity"` + Rights string `xml:"rights"` + Issued Date `xml:"issued"` + Updated Date `xml:"updated"` + Cves []Cve `xml:"cve"` + AffectedCPEList []string `xml:"affected_cpe_list>cpe"` +} + +// Cve contains CVE details +type Cve struct { + CveID string `xml:",chardata"` + Cvss3 string `xml:"cvss3,attr"` + Impact string `xml:"impact,attr"` + Cwe string `xml:"cwe,attr"` + Href string `xml:"href,attr"` + Public string `xml:"public,attr"` +} + +// Date has a date attribute +type Date struct { + Date string `xml:"date,attr"` +} + +// Criteria contains test criteria +type Criteria struct { + Operator string `xml:"operator,attr"` + Criterions []Criterion `xml:"criterion"` + Criterias []Criteria `xml:"criteria"` +} + +// Criterion is a single test condition +type Criterion struct { + TestRef string `xml:"test_ref,attr"` + Comment string `xml:"comment,attr"` +} + +// Tests section +type Tests struct { + RPMInfoTests []RPMInfoTest `xml:"rpminfo_test"` + TextFileContent54Tests []TextFileContent54Test `xml:"textfilecontent54_test"` +} + +// RPMInfoTest represents an RPM package version test +type RPMInfoTest struct { + ID string `xml:"id,attr"` + Comment string `xml:"comment,attr"` + Check string `xml:"check,attr"` + ObjectRef ObjectRef `xml:"object"` + StateRef StateRef `xml:"state"` +} + +// TextFileContent54Test represents a text file content test +type TextFileContent54Test struct { + ID string `xml:"id,attr"` + Comment string `xml:"comment,attr"` + ObjectRef ObjectRef `xml:"object"` + StateRef StateRef `xml:"state"` +} + +// ObjectRef references an object +type ObjectRef struct { + Ref string `xml:"object_ref,attr"` +} + +// StateRef references a state +type StateRef struct { + Ref string `xml:"state_ref,attr"` +} + +// Objects section +type Objects struct { + RPMInfoObjects []RPMInfoObject `xml:"rpminfo_object"` +} + +// RPMInfoObject contains the package name +type RPMInfoObject struct { + ID string `xml:"id,attr"` + Name string `xml:"name"` +} + +// States section +type States struct { + RPMInfoStates []RPMInfoState `xml:"rpminfo_state"` +} + +// RPMInfoState contains version comparison info +type RPMInfoState struct { + ID string `xml:"id,attr"` + EVR EVR `xml:"evr"` +} + +// EVR contains epoch:version-release info +type EVR struct { + Datatype string `xml:"datatype,attr"` + Operation string `xml:"operation,attr"` + Value string `xml:",chardata"` +} + +// ALSA represents a simplified Alinux Security Advisory for JSON output +type ALSA struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Description string `json:"description"` + Issued DateJSON `json:"issued"` + Updated DateJSON `json:"updated"` + Packages []Package `json:"packages"` + CveIDs []string `json:"cveids"` + References []CveRef `json:"references"` +} + +// DateJSON wraps a date string for JSON output +type DateJSON struct { + Date string `json:"date"` +} + +// Package has affected package information +type Package struct { + Name string `json:"name"` + Epoch string `json:"epoch"` + Version string `json:"version"` + Release string `json:"release"` +} + +// CveRef has CVE reference information +type CveRef struct { + ID string `json:"id"` + Href string `json:"href"` + Cvss3 string `json:"cvss3,omitempty"` + Impact string `json:"impact,omitempty"` +} diff --git a/main.go b/main.go index e3b25e7c..a4c88f58 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "golang.org/x/xerrors" + "github.com/aquasecurity/vuln-list-update/alinux" "github.com/aquasecurity/vuln-list-update/alma" "github.com/aquasecurity/vuln-list-update/alpine" alpineunfixed "github.com/aquasecurity/vuln-list-update/alpine-unfixed" @@ -39,7 +40,7 @@ import ( var ( target = flag.String("target", "", "update target (nvd, alpine, alpine-unfixed, redhat, redhat-oval, "+ - "redhat-csaf-vex, debian, ubuntu, amazon, oracle-oval, suse-cvrf, photon, arch-linux, glad, cwe, osvdev, mariner, kevc, wolfi, "+ + "redhat-csaf-vex, debian, ubuntu, amazon, alinux, alinux-csaf, oracle-oval, suse-cvrf, photon, arch-linux, glad, cwe, osvdev, mariner, kevc, wolfi, "+ "chainguard, azure, openeuler, echo, minimos, eoldates, rootio)") vulnListDir = flag.String("vuln-list-dir", "", "vuln-list dir") targetUri = flag.String("target-uri", "", "alternative repository URI (only glad)") @@ -103,6 +104,16 @@ func run() error { if err := ac.Update(); err != nil { return xerrors.Errorf("Amazon Linux update error: %w", err) } + case "alinux": + alc := alinux.NewConfig() + if err := alc.Update(); err != nil { + return xerrors.Errorf("Alibaba Cloud Linux update error: %w", err) + } + case "alinux-csaf": + alc := alinux.NewCSAFConfig() + if err := alc.Update(); err != nil { + return xerrors.Errorf("Alibaba Cloud Linux CSAF update error: %w", err) + } case "oracle-oval": oc := oracleoval.NewConfig() if err := oc.Update(); err != nil {