Skip to content

Commit 02f4a66

Browse files
committed
Created Function for OpenVEX ingestion and openvex fetching from github repo
1 parent 571ee88 commit 02f4a66

5 files changed

Lines changed: 586 additions & 10 deletions

File tree

normalize/sbom_graph.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727

2828
cdx "github.com/CycloneDX/cyclonedx-go"
2929
"github.com/google/uuid"
30+
ov "github.com/openvex/go-vex/pkg/vex"
3031
"github.com/package-url/packageurl-go"
3132
)
3233

@@ -141,6 +142,41 @@ func NewVexReport(report *cdx.BOM, source string) (*VexReport, error) {
141142
}, nil
142143
}
143144

145+
type VexReportOpenVEX struct {
146+
Report *ov.VEX
147+
Source string
148+
}
149+
150+
func validateVexReportOpenVEX(report *ov.VEX) error {
151+
if report.ID == "" {
152+
return fmt.Errorf("invalid OpenVEX report: missing id")
153+
}
154+
if report.Context == "" {
155+
return fmt.Errorf("invalid OpenVEX report: missing context")
156+
}
157+
if report.Author == "" {
158+
return fmt.Errorf("invalid OpenVEX report: missing author")
159+
}
160+
if report.Timestamp.IsZero() {
161+
return fmt.Errorf("invalid OpenVEX report: missing timestamp")
162+
}
163+
if report.Version == 0 {
164+
return fmt.Errorf("invalid OpenVEX report: missing version")
165+
}
166+
return nil
167+
}
168+
169+
func NewVexReportOpenVEX(report *ov.VEX, source string) (*VexReportOpenVEX, error) {
170+
if err := validateVexReportOpenVEX(report); err != nil {
171+
return nil, err
172+
}
173+
174+
return &VexReportOpenVEX{
175+
Report: report,
176+
Source: source,
177+
}, nil
178+
}
179+
144180
func edgesToDepMap(edges map[string]map[string]struct{}) map[string][]string {
145181
depMap := make(map[string][]string)
146182
for parent, children := range edges {

services/scan_service.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ import (
2222
"io"
2323
"log/slog"
2424
"net/http"
25+
"net/url"
26+
"path"
2527
"slices"
2628
"strings"
2729
"time"
2830

2931
"github.com/CycloneDX/cyclonedx-go"
32+
"github.com/google/go-github/v62/github"
3033
"github.com/google/uuid"
3134
"github.com/l3montree-dev/devguard/database/models"
3235
databasetypes "github.com/l3montree-dev/devguard/database/types"
@@ -38,6 +41,7 @@ import (
3841
"github.com/l3montree-dev/devguard/transformer"
3942
"github.com/l3montree-dev/devguard/utils"
4043
"github.com/l3montree-dev/devguard/vulndb/scan"
44+
ov "github.com/openvex/go-vex/pkg/vex"
4145
"github.com/package-url/packageurl-go"
4246
"github.com/pkg/errors"
4347
"go.opentelemetry.io/otel/attribute"
@@ -63,6 +67,12 @@ type scanService struct {
6367
utils.FireAndForgetSynchronizer
6468
}
6569

70+
var newGitHubClient = func() *github.Client {
71+
return github.NewClient(nil)
72+
}
73+
74+
var downloadRawFileFn = DownloadRawFile
75+
6676
var _ shared.ScanService = (*scanService)(nil)
6777

6878
func NewScanService(
@@ -864,3 +874,97 @@ func (s *scanService) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx.
864874
DependencyVulns: vulnDTOs,
865875
}, nil
866876
}
877+
878+
func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetUrl string) (vexReports []*normalize.VexReportOpenVEX, err error) {
879+
client := newGitHubClient()
880+
githubDomain := "https://github.com"
881+
if !strings.HasPrefix(targetUrl, githubDomain) {
882+
return nil, fmt.Errorf("invalid github repository url")
883+
}
884+
owner, repo, err := ParseGitHubURL(targetUrl)
885+
if err != nil {
886+
return nil, err
887+
}
888+
889+
// Determine default branch
890+
repository, _, err := client.Repositories.Get(ctx, owner, repo)
891+
if err != nil {
892+
return nil, err
893+
}
894+
branch := repository.GetDefaultBranch()
895+
if branch == "" {
896+
branch = "main"
897+
}
898+
899+
tree, _, err := client.Git.GetTree(
900+
ctx,
901+
owner,
902+
repo,
903+
branch,
904+
true, // recursive
905+
)
906+
if err != nil {
907+
908+
return nil, err
909+
}
910+
for _, entry := range tree.Entries {
911+
if entry.GetType() != "blob" {
912+
continue
913+
}
914+
filePath := entry.GetPath()
915+
filename := strings.ToLower(path.Base(filePath))
916+
if !strings.HasSuffix(filename, ".json") {
917+
continue
918+
}
919+
920+
content, err := downloadRawFileFn(
921+
owner,
922+
repo,
923+
branch,
924+
filePath,
925+
)
926+
if err != nil {
927+
slog.Info("download of openVEX failed", "err", err)
928+
continue
929+
}
930+
var openVEX ov.VEX
931+
err = json.Unmarshal(content, &openVEX)
932+
if err != nil {
933+
slog.Info("could not unmarshal openVEX failed", "err", err)
934+
continue
935+
}
936+
937+
vexReports = append(vexReports, &normalize.VexReportOpenVEX{
938+
Report: &openVEX,
939+
Source: targetUrl,
940+
})
941+
}
942+
return vexReports, nil
943+
}
944+
945+
func ParseGitHubURL(rawURL string) (owner string, repo string, err error) {
946+
u, err := url.Parse(rawURL)
947+
if err != nil {
948+
return "", "", err
949+
}
950+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
951+
return parts[0], parts[1], nil
952+
}
953+
954+
func DownloadRawFile(owner, repo, branch, filePath string) ([]byte, error) {
955+
956+
rawURL := fmt.Sprintf(
957+
"https://raw.githubusercontent.com/%s/%s/%s/%s",
958+
owner,
959+
repo,
960+
branch,
961+
filePath,
962+
)
963+
resp, err := http.Get(rawURL)
964+
if err != nil {
965+
return nil, err
966+
}
967+
defer resp.Body.Close()
968+
return io.ReadAll(resp.Body)
969+
970+
}

services/scan_service_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ package services
1616

1717
import (
1818
"context"
19+
"encoding/json"
1920
"net/http"
2021
"net/http/httptest"
22+
"net/url"
2123
"testing"
24+
"time"
2225

26+
"github.com/google/go-github/v62/github"
2327
"github.com/google/uuid"
2428
"github.com/l3montree-dev/devguard/database/models"
2529
"github.com/l3montree-dev/devguard/dtos"
@@ -291,3 +295,149 @@ func TestFetchSbomsFromUpstream_PassesURLNotRef(t *testing.T) {
291295
assert.Equal(t, sbomURL, invalidURLs[0].URL)
292296
})
293297
}
298+
299+
func TestFetchOpenVexFromGitHub(t *testing.T) {
300+
originalNewGitHubClient := newGitHubClient
301+
originalDownloadRawFileFn := downloadRawFileFn
302+
t.Cleanup(func() {
303+
newGitHubClient = originalNewGitHubClient
304+
downloadRawFileFn = originalDownloadRawFileFn
305+
})
306+
307+
t.Run("should fetch openvex reports from json files in the repository", func(t *testing.T) {
308+
mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
309+
switch {
310+
case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/openvex-repo":
311+
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
312+
case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/openvex-repo/git/trees/main":
313+
if got := r.URL.Query().Get("recursive"); got != "1" {
314+
t.Fatalf("expected recursive=1, got %q", got)
315+
}
316+
_, _ = w.Write([]byte(`{"tree":[{"path":"reports/openvex.json","type":"blob"},{"path":"README.md","type":"blob"}]}`))
317+
default:
318+
t.Fatalf("unexpected github api request: %s %s", r.Method, r.URL.String())
319+
}
320+
}))
321+
defer mockGitHub.Close()
322+
323+
newGitHubClient = func() *github.Client {
324+
client := github.NewClient(mockGitHub.Client())
325+
baseURL, err := url.Parse(mockGitHub.URL + "/")
326+
if err != nil {
327+
t.Fatalf("failed to parse mock github url: %v", err)
328+
}
329+
client.BaseURL = baseURL
330+
client.UploadURL = baseURL
331+
return client
332+
}
333+
334+
calls := 0
335+
downloadRawFileFn = func(owner, repo, branch, filePath string) ([]byte, error) {
336+
calls++
337+
assert.Equal(t, "octo-org", owner)
338+
assert.Equal(t, "openvex-repo", repo)
339+
assert.Equal(t, "main", branch)
340+
assert.Equal(t, "reports/openvex.json", filePath)
341+
342+
ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC)
343+
payload := map[string]any{
344+
"@context": "https://openvex.dev/ns/v0.2.0",
345+
"@id": "openvex-1",
346+
"author": "test-author",
347+
"timestamp": ts,
348+
"version": 1,
349+
"statements": []any{},
350+
}
351+
return json.Marshal(payload)
352+
}
353+
354+
service := &scanService{}
355+
reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/openvex-repo")
356+
assert.NoError(t, err)
357+
assert.Len(t, reports, 1)
358+
assert.Equal(t, "https://github.com/octo-org/openvex-repo", reports[0].Source)
359+
assert.Equal(t, "openvex-1", reports[0].Report.ID)
360+
assert.Equal(t, "test-author", reports[0].Report.Author)
361+
assert.Equal(t, 1, reports[0].Report.Version)
362+
assert.Equal(t, 1, calls)
363+
})
364+
365+
t.Run("should fetch multiple openvex reports from multiple json files", func(t *testing.T) {
366+
mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
367+
switch {
368+
case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/multi-vex-repo":
369+
_, _ = w.Write([]byte(`{"default_branch":"develop"}`))
370+
case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/multi-vex-repo/git/trees/develop":
371+
if got := r.URL.Query().Get("recursive"); got != "1" {
372+
t.Fatalf("expected recursive=1, got %q", got)
373+
}
374+
_, _ = w.Write([]byte(`{"tree":[{"path":"vex/vex1.json","type":"blob"},{"path":"vex/vex2.json","type":"blob"},{"path":"README.md","type":"blob"}]}`))
375+
default:
376+
t.Fatalf("unexpected github api request: %s %s", r.Method, r.URL.String())
377+
}
378+
}))
379+
defer mockGitHub.Close()
380+
381+
newGitHubClient = func() *github.Client {
382+
client := github.NewClient(mockGitHub.Client())
383+
baseURL, err := url.Parse(mockGitHub.URL + "/")
384+
if err != nil {
385+
t.Fatalf("failed to parse mock github url: %v", err)
386+
}
387+
client.BaseURL = baseURL
388+
client.UploadURL = baseURL
389+
return client
390+
}
391+
392+
calls := 0
393+
downloadRawFileFn = func(owner, repo, branch, filePath string) ([]byte, error) {
394+
calls++
395+
assert.Equal(t, "octo-org", owner)
396+
assert.Equal(t, "multi-vex-repo", repo)
397+
assert.Equal(t, "develop", branch)
398+
399+
ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC)
400+
var id, author string
401+
402+
if filePath == "vex/vex1.json" {
403+
id = "openvex-first"
404+
author = "author-one"
405+
} else if filePath == "vex/vex2.json" {
406+
id = "openvex-second"
407+
author = "author-two"
408+
} else {
409+
t.Fatalf("unexpected file path: %s", filePath)
410+
}
411+
412+
payload := map[string]any{
413+
"@context": "https://openvex.dev/ns/v0.2.0",
414+
"@id": id,
415+
"author": author,
416+
"timestamp": ts,
417+
"version": 1,
418+
"statements": []any{},
419+
}
420+
return json.Marshal(payload)
421+
}
422+
423+
service := &scanService{}
424+
reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/multi-vex-repo")
425+
assert.NoError(t, err)
426+
assert.Len(t, reports, 2)
427+
assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[0].Source)
428+
assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[1].Source)
429+
assert.Equal(t, "openvex-first", reports[0].Report.ID)
430+
assert.Equal(t, "openvex-second", reports[1].Report.ID)
431+
assert.Equal(t, "author-one", reports[0].Report.Author)
432+
assert.Equal(t, "author-two", reports[1].Report.Author)
433+
assert.Equal(t, 2, calls)
434+
})
435+
436+
t.Run("should reject non github urls", func(t *testing.T) {
437+
service := &scanService{}
438+
reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://example.com/repo")
439+
assert.Error(t, err)
440+
assert.Nil(t, reports)
441+
assert.Contains(t, err.Error(), "invalid github repository url")
442+
})
443+
}

0 commit comments

Comments
 (0)