Skip to content

Commit 20bee1d

Browse files
feat(suse): replace CVRF feed with advisory CSAF updater
Switch SUSE security data from the legacy CVRF XML archive to the upstream CSAF feed, storing trimmed native JSON under csaf/suse/. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3942832 commit 20bee1d

36 files changed

Lines changed: 1255 additions & 14031 deletions

.github/workflows/update.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,15 @@ jobs:
116116
commit-message: "CWE"
117117

118118
- if: always()
119-
name: SUSE CVRF
119+
name: SUSE CSAF
120120
uses: ./.github/actions/update-source
121121
with:
122-
target: suse-cvrf
122+
target: suse-csaf
123123
client-id: ${{ secrets.SA_GH_VULN_LIST_UPDATE_GH_APP_CLIENT_ID }}
124124
private-key: ${{ secrets.SA_GH_VULN_LIST_UPDATE_GH_APP_PRIVATE_KEY }}
125125
owner: ${{ github.repository_owner }}
126126
repository: ${{ env.VULN_LIST_DIR }}
127-
commit-message: "SUSE CVRF"
127+
commit-message: "SUSE CSAF"
128128

129129
- if: always()
130130
name: GitLab Advisory Database

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ https://github.com/aquasecurity/vuln-list/
2020
$ vuln-list-update -h
2121
Usage of vuln-list-update:
2222
-target string
23-
update target (nvd, alpine, alpine-unfixed, redhat, redhat-oval, debian, ubuntu, amazon, oracle-oval, suse-cvrf, photon, arch-linux, ghsa, glad, cwe, osv, mariner, kevc, wolfi, chainguard, azure, openeuler, echo, minimos, rootio, seal)
23+
update target (nvd, alpine, alpine-unfixed, redhat, redhat-oval, debian, ubuntu, amazon, oracle-oval, suse-csaf, photon, arch-linux, ghsa, glad, cwe, osv, mariner, kevc, wolfi, chainguard, azure, openeuler, echo, minimos, rootio, seal)
2424
-target-branch string
2525
alternative repository branch (only glad)
2626
-target-uri string

main.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ import (
3131
"github.com/aquasecurity/vuln-list-update/rocky"
3232
"github.com/aquasecurity/vuln-list-update/rootio"
3333
"github.com/aquasecurity/vuln-list-update/seal"
34-
susecvrf "github.com/aquasecurity/vuln-list-update/suse/cvrf"
34+
susecsaf "github.com/aquasecurity/vuln-list-update/suse/csaf"
3535
"github.com/aquasecurity/vuln-list-update/ubuntu"
3636
"github.com/aquasecurity/vuln-list-update/utils"
3737
"github.com/aquasecurity/vuln-list-update/wolfi"
3838
)
3939

4040
var (
4141
target = flag.String("target", "", "update target (nvd, alpine, alpine-unfixed, redhat, redhat-oval, "+
42-
"redhat-csaf-vex, debian, ubuntu, amazon, oracle-oval, suse-cvrf, photon, arch-linux, glad, cwe, osvdev, mariner, kevc, wolfi, "+
42+
"redhat-csaf-vex, debian, ubuntu, amazon, oracle-oval, suse-csaf, photon, arch-linux, glad, cwe, osvdev, mariner, kevc, wolfi, "+
4343
"chainguard, azure, openeuler, echo, minimos, eoldates, rootio)")
4444
vulnListDir = flag.String("vuln-list-dir", "", "vuln-list dir")
4545
targetUri = flag.String("target-uri", "", "alternative repository URI (only glad)")
@@ -108,10 +108,10 @@ func run() error {
108108
if err := oc.Update(); err != nil {
109109
return xerrors.Errorf("Oracle OVAL update error: %w", err)
110110
}
111-
case "suse-cvrf":
112-
sc := susecvrf.NewConfig()
111+
case "suse-csaf":
112+
sc := susecsaf.NewConfig()
113113
if err := sc.Update(); err != nil {
114-
return xerrors.Errorf("SUSE CVRF update error: %w", err)
114+
return xerrors.Errorf("SUSE CSAF update error: %w", err)
115115
}
116116
case "photon":
117117
pc := photon.NewConfig()

suse/csaf/csaf.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package csaf
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"compress/bzip2"
7+
"compress/gzip"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"log"
13+
"path/filepath"
14+
"regexp"
15+
"strings"
16+
"unicode/utf8"
17+
18+
csaflib "github.com/csaf-poc/csaf_distribution/v3/csaf"
19+
"github.com/spf13/afero"
20+
"golang.org/x/xerrors"
21+
22+
"github.com/aquasecurity/vuln-list-update/utils"
23+
)
24+
25+
const (
26+
csafArchiveURL = "https://ftp.suse.com/pub/projects/security/csaf.tar.bz2"
27+
csafDir = "csaf"
28+
suseDir = "suse"
29+
retries = 5
30+
)
31+
32+
var fileRegexp = regexp.MustCompile(`^(suse-su|opensuse-su)-`)
33+
34+
type Config struct {
35+
VulnListDir string
36+
URL string
37+
AppFs afero.Fs
38+
}
39+
40+
// archiveEntry is a single JSON document from the SUSE CSAF tar archive.
41+
type archiveEntry struct {
42+
Filename string
43+
Data []byte
44+
}
45+
46+
func NewConfig() Config {
47+
return Config{
48+
VulnListDir: utils.VulnListDir(),
49+
URL: csafArchiveURL,
50+
AppFs: afero.NewOsFs(),
51+
}
52+
}
53+
54+
func (c Config) Update() error {
55+
log.Print("Fetching SUSE CSAF archive...")
56+
57+
return walkArchive(c.URL, retries, fileRegexp, func(e archiveEntry) error {
58+
osName, err := osNameFromFilename(e.Filename)
59+
if err != nil {
60+
log.Printf("skip %s: %v", e.Filename, err)
61+
return nil
62+
}
63+
64+
var adv csaflib.Advisory
65+
if err := json.Unmarshal(e.Data, &adv); err != nil {
66+
log.Printf("skip invalid CSAF json (%s): %v", e.Filename, err)
67+
return nil
68+
}
69+
70+
if err := adv.Validate(); err != nil {
71+
log.Printf("skip invalid CSAF advisory (%s): %v", e.Filename, err)
72+
return nil
73+
}
74+
75+
if adv.Document == nil || adv.Document.Tracking == nil || adv.Document.Tracking.ID == nil {
76+
log.Printf("skip advisory without tracking id (%s)", e.Filename)
77+
return nil
78+
}
79+
80+
dir := filepath.Join(csafDir, suseDir, osName)
81+
if err := c.savePerYear(dir, string(*adv.Document.Tracking.ID), adv); err != nil {
82+
return xerrors.Errorf("failed to save CSAF: %w", err)
83+
}
84+
return nil
85+
})
86+
}
87+
88+
func osNameFromFilename(filename string) (string, error) {
89+
match := fileRegexp.FindStringSubmatch(filename)
90+
if len(match) < 2 {
91+
return "", fmt.Errorf("unexpected filename")
92+
}
93+
switch match[1] {
94+
case "suse-su":
95+
return "suse", nil
96+
case "opensuse-su":
97+
return "opensuse", nil
98+
default:
99+
return "", fmt.Errorf("unknown prefix %q", match[1])
100+
}
101+
}
102+
103+
func (c Config) savePerYear(dirName, advisoryID string, data any) error {
104+
s := strings.Split(advisoryID, "-")
105+
if len(s) < 4 {
106+
log.Printf("invalid advisory ID format: %s", advisoryID)
107+
return nil
108+
}
109+
110+
year := strings.Split(s[2], ":")[0]
111+
if len(year) < 4 {
112+
log.Printf("invalid advisory ID format: %s", advisoryID)
113+
return nil
114+
}
115+
116+
yearDir := filepath.Join(c.VulnListDir, dirName, year)
117+
fileName := fmt.Sprintf("%s.json", strings.Replace(advisoryID, ":", "-", 1))
118+
if err := utils.WriteJSON(c.AppFs, yearDir, fileName, data); err != nil {
119+
return xerrors.Errorf("failed to write file: %w", err)
120+
}
121+
return nil
122+
}
123+
124+
func walkArchive(url string, retries int, nameRegexp *regexp.Regexp, handler func(archiveEntry) error) error {
125+
body, err := utils.FetchURL(url, "", retries)
126+
if err != nil {
127+
return xerrors.Errorf("failed to download archive: %w", err)
128+
}
129+
130+
decompressed, err := decompressArchive(url, body)
131+
if err != nil {
132+
return err
133+
}
134+
135+
tr := tar.NewReader(decompressed)
136+
for {
137+
hdr, err := tr.Next()
138+
switch {
139+
case errors.Is(err, io.EOF):
140+
return nil
141+
case err != nil:
142+
return xerrors.Errorf("failed to read tar entry: %w", err)
143+
case hdr.Typeflag != tar.TypeReg:
144+
continue
145+
}
146+
147+
filename := filepath.Base(hdr.Name)
148+
if !strings.HasSuffix(filename, ".json") {
149+
continue
150+
}
151+
if nameRegexp != nil && !nameRegexp.MatchString(filename) {
152+
continue
153+
}
154+
155+
data, err := io.ReadAll(tr)
156+
if err != nil {
157+
return xerrors.Errorf("failed to read tar entry data: %w", err)
158+
}
159+
if len(data) == 0 {
160+
log.Printf("empty json: %s", filename)
161+
continue
162+
}
163+
if !utf8.Valid(data) {
164+
log.Printf("invalid UTF-8: %s", filename)
165+
data = []byte(strings.ToValidUTF8(string(data), ""))
166+
}
167+
168+
if err := handler(archiveEntry{Filename: filename, Data: data}); err != nil {
169+
return err
170+
}
171+
}
172+
}
173+
174+
func decompressArchive(url string, body []byte) (io.Reader, error) {
175+
switch {
176+
case strings.HasSuffix(url, ".tar.bz2"):
177+
return bzip2.NewReader(bytes.NewReader(body)), nil
178+
case strings.HasSuffix(url, ".tar.gz"):
179+
return gzip.NewReader(bytes.NewReader(body))
180+
default:
181+
return nil, xerrors.Errorf("unsupported archive format: %s", url)
182+
}
183+
}

suse/csaf/csaf_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package csaf_test
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"compress/gzip"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"testing"
11+
12+
"github.com/spf13/afero"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/aquasecurity/vuln-list-update/suse/csaf"
17+
)
18+
19+
func createArchive(t *testing.T, dir string) []byte {
20+
t.Helper()
21+
22+
var buf bytes.Buffer
23+
gw := gzip.NewWriter(&buf)
24+
tw := tar.NewWriter(gw)
25+
require.NoError(t, tw.AddFS(os.DirFS(dir)))
26+
require.NoError(t, tw.Close())
27+
require.NoError(t, gw.Close())
28+
29+
return buf.Bytes()
30+
}
31+
32+
func TestConfig_Update(t *testing.T) {
33+
testCases := []struct {
34+
name string
35+
appFs afero.Fs
36+
archiveDir string
37+
goldenFiles map[string]string
38+
}{
39+
{
40+
name: "positive test",
41+
appFs: afero.NewMemMapFs(),
42+
archiveDir: "testdata/csaf",
43+
goldenFiles: map[string]string{
44+
"/tmp/csaf/suse/suse/2019/SUSE-SU-2019-0048-2.json": "testdata/golden/SUSE-SU-2019-0048-2.json",
45+
"/tmp/csaf/suse/opensuse/2019/openSUSE-SU-2019-0003-1.json": "testdata/golden/openSUSE-SU-2019-0003-1.json",
46+
},
47+
},
48+
{
49+
name: "broken JSON is skipped",
50+
appFs: afero.NewMemMapFs(),
51+
archiveDir: "testdata/broken-csaf",
52+
goldenFiles: map[string]string{},
53+
},
54+
}
55+
for _, tc := range testCases {
56+
t.Run(tc.name, func(t *testing.T) {
57+
archiveData := createArchive(t, tc.archiveDir)
58+
59+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
_, err := w.Write(archiveData)
61+
assert.NoError(t, err, tc.name)
62+
}))
63+
defer ts.Close()
64+
65+
c := csaf.Config{
66+
VulnListDir: "/tmp",
67+
URL: ts.URL + "/csaf.tar.gz",
68+
AppFs: tc.appFs,
69+
}
70+
err := c.Update()
71+
require.NoError(t, err, tc.name)
72+
73+
fileCount := 0
74+
err = afero.Walk(c.AppFs, "/", func(path string, info os.FileInfo, err error) error {
75+
if err != nil {
76+
return err
77+
}
78+
if info.IsDir() {
79+
return nil
80+
}
81+
fileCount++
82+
83+
actual, err := afero.ReadFile(c.AppFs, path)
84+
require.NoError(t, err, tc.name)
85+
86+
goldenPath, ok := tc.goldenFiles[path]
87+
require.True(t, ok, "unexpected output file: %s", path)
88+
expected, err := os.ReadFile(goldenPath)
89+
require.NoError(t, err, tc.name)
90+
91+
assert.JSONEq(t, string(expected), string(actual), tc.name)
92+
93+
return nil
94+
})
95+
require.NoError(t, err, tc.name)
96+
assert.Equal(t, len(tc.goldenFiles), fileCount, tc.name)
97+
})
98+
}
99+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{

0 commit comments

Comments
 (0)