diff --git a/cmd/db-gen/main.go b/cmd/db-gen/main.go new file mode 100644 index 00000000..3e46ddf9 --- /dev/null +++ b/cmd/db-gen/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/glebarez/sqlite" + v1 "github.com/xeol-io/xeol/xeol/db/v1" + "github.com/xeol-io/xeol/xeol/db/v1/store/model" + "gorm.io/gorm" +) + +type EOLResponse struct { + Cycle string `json:"cycle"` + ReleaseDate string `json:"releaseDate"` + Eol interface{} `json:"eol"` // can be bool or string + Latest string `json:"latest"` + LatestReleaseDate string `json:"latestReleaseDate"` + Lts interface{} `json:"lts"` +} + +// PurlModel matches the DB schema used by the xeol store. +type PurlModel struct { + ID int `gorm:"primary_key;column:id;"` + ProductID int `gorm:"column:product_id"` + Purl string `gorm:"column:purl"` +} + +func (m PurlModel) TableName() string { return "purls" } + +func main() { + dbPath := "xeol.db" + _ = os.Remove(dbPath) + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + + if err := db.AutoMigrate(&model.IDModel{}, &model.ProductModel{}, &PurlModel{}, &model.CycleModel{}); err != nil { + log.Fatalf("failed to migrate: %v", err) + } + + // 1. Insert DB metadata + db.Create(&model.IDModel{ + SchemaVersion: v1.SchemaVersion, + BuildTimestamp: time.Now().UTC().Format(time.RFC3339Nano), + }) + + // 2. Seed the Terraform BINARY lifecycle (from endoflife.date) + seedProduct(db, productSpec{ + Name: "Terraform", + Permalink: "terraform", + Purl: "pkg:terraform/hashicorp/terraform", + APIURL: "https://endoflife.date/api/terraform.json", + }) + + // 3. Seed hashicorp/aws provider with known EOL cycles. + // The AWS provider follows a major-version deprecation model; v4.x and below + // are EOL as of 2024-01-31 (announced by HashiCorp). + seedProviderWithStaticCycles(db, productSpec{ + Name: "Terraform AWS Provider", + Permalink: "terraform-provider-aws", + Purl: "pkg:terraform/hashicorp/aws", + }, []staticCycle{ + {Cycle: "4", EolDate: "2024-01-31", Latest: "4.67.0"}, + {Cycle: "3", EolDate: "2023-04-30", Latest: "3.76.1"}, + {Cycle: "2", EolDate: "2022-09-30", Latest: "2.70.0"}, + }) + + fmt.Println("✅ xeol.db generated successfully with Terraform binary + provider EOL data!") +} + +type productSpec struct { + Name string + Permalink string + Purl string + APIURL string +} + +type staticCycle struct { + Cycle string + EolDate string + Latest string +} + +func seedProduct(db *gorm.DB, spec productSpec) { + product := model.ProductModel{Name: spec.Name, Permalink: spec.Permalink} + db.Create(&product) + db.Create(&PurlModel{ProductID: product.ID, Purl: spec.Purl}) + + resp, err := http.Get(spec.APIURL) + if err != nil { + log.Fatalf("failed to fetch %s: %v", spec.APIURL, err) + } + defer resp.Body.Close() + + var data []EOLResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + log.Fatalf("failed to decode %s: %v", spec.APIURL, err) + } + + for _, item := range data { + cycle := model.CycleModel{ + ProductID: product.ID, + ProductName: product.Name, + ProductPermalink: product.Permalink, + ReleaseCycle: item.Cycle, + LatestRelease: item.Latest, + } + if d, err := time.Parse("2006-01-02", item.ReleaseDate); err == nil { + cycle.ReleaseDate = d + } + if d, err := time.Parse("2006-01-02", item.LatestReleaseDate); err == nil { + cycle.LatestReleaseDate = d + } + if eolBool, ok := item.Eol.(bool); ok { + cycle.EolBool = eolBool + } else if eolStr, ok := item.Eol.(string); ok { + if d, err := time.Parse("2006-01-02", eolStr); err == nil { + cycle.Eol = d + } + } + db.Create(&cycle) + } + fmt.Printf(" seeded %s (%d cycles)\n", spec.Name, len(data)) +} + +func seedProviderWithStaticCycles(db *gorm.DB, spec productSpec, cycles []staticCycle) { + product := model.ProductModel{Name: spec.Name, Permalink: spec.Permalink} + db.Create(&product) + db.Create(&PurlModel{ProductID: product.ID, Purl: spec.Purl}) + + for _, c := range cycles { + cycle := model.CycleModel{ + ProductID: product.ID, + ProductName: product.Name, + ProductPermalink: product.Permalink, + ReleaseCycle: c.Cycle, + LatestRelease: c.Latest, + } + if d, err := time.Parse("2006-01-02", c.EolDate); err == nil { + cycle.Eol = d + } + db.Create(&cycle) + } + fmt.Printf(" seeded %s (%d static cycles)\n", spec.Name, len(cycles)) +} diff --git a/go.mod b/go.mod index 4aac7862..5fb47c88 100644 --- a/go.mod +++ b/go.mod @@ -212,6 +212,7 @@ require ( github.com/nwaples/rardecode v1.1.2 // indirect github.com/opencontainers/runtime-spec v1.1.0 // indirect github.com/opencontainers/selinux v1.11.0 // indirect + github.com/owenrumney/go-sarif/v2 v2.3.3 // indirect github.com/pborman/indent v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 702656e2..cf5d06f7 100644 --- a/go.sum +++ b/go.sum @@ -287,6 +287,7 @@ github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46 h1:vmXNl+HDfqqXgr0uY1UgK1GAhps8nbAAtqHNBcgyf+4= github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46/go.mod h1:olhPNdiiAAMiSujemd1O/sc6GcyePr23f/6uGKtthNg= github.com/aquasecurity/go-version v0.0.0-20210121072130-637058cfe492 h1:rcEG5HI490FF0a7zuvxOxen52ddygCfNVjP0XOCMl+M= @@ -907,6 +908,10 @@ github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaL github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= github.com/oras-project/oras-credentials-go v0.3.1 h1:sfGqZ8sjPifEaOtjHOQTPr8D+Tql4bpw58Dd9wjmm9w= github.com/oras-project/oras-credentials-go v0.3.1/go.mod h1:fFCebDQo0Do+gnM96uV9YUnRay0pwuRQupypvofsp4s= +github.com/owenrumney/go-sarif v1.1.1 h1:QNObu6YX1igyFKhdzd7vgzmw7XsWN3/6NMGuDzBgXmE= +github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U= +github.com/owenrumney/go-sarif/v2 v2.3.3 h1:ubWDJcF5i3L/EIOER+ZyQ03IfplbSU1BLOE26uKQIIU= +github.com/owenrumney/go-sarif/v2 v2.3.3/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= @@ -1091,6 +1096,8 @@ github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7r github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= github.com/vifraa/gopom v1.0.0 h1:L9XlKbyvid8PAIK8nr0lihMApJQg/12OBvMA28BcWh0= github.com/vifraa/gopom v1.0.0/go.mod h1:oPa1dcrGrtlO37WPDBm5SqHAT+wTgF8An1Q71Z6Vv4o= +github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= +github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b h1:uWNQ0khA6RdFzODOMwKo9XXu7fuewnnkHykUtuKru8s= @@ -1117,6 +1124,7 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= diff --git a/internal/format/format.go b/internal/format/format.go index a5d62cb3..bc51194b 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -8,6 +8,7 @@ const ( UnknownFormat Format = "unknown" JSONFormat Format = "json" TableFormat Format = "table" + SarifFormat Format = "sarif" ) // Format is a dedicated type to represent a specific kind of presenter output format. @@ -26,6 +27,8 @@ func Parse(userInput string) Format { return JSONFormat case strings.ToLower(TableFormat.String()): return TableFormat + case strings.ToLower(SarifFormat.String()): + return SarifFormat default: return UnknownFormat } @@ -35,4 +38,5 @@ func Parse(userInput string) Format { var AvailableFormats = []Format{ JSONFormat, TableFormat, + SarifFormat, } diff --git a/internal/format/presenter.go b/internal/format/presenter.go index ba3bfee3..25561ca1 100644 --- a/internal/format/presenter.go +++ b/internal/format/presenter.go @@ -5,6 +5,7 @@ import ( "github.com/xeol-io/xeol/xeol/presenter/json" "github.com/xeol-io/xeol/xeol/presenter/models" + "github.com/xeol-io/xeol/xeol/presenter/sarif" "github.com/xeol-io/xeol/xeol/presenter/table" ) @@ -15,6 +16,8 @@ func GetPresenter(format Format, pb models.PresenterConfig) presenter.Presenter return json.NewPresenter(pb) case TableFormat: return table.NewPresenter(pb) + case SarifFormat: + return sarif.NewPresenter(pb) default: return nil } diff --git a/xeol/db/v1/store/model/cycle.go b/xeol/db/v1/store/model/cycle.go index 007c2950..27320db4 100644 --- a/xeol/db/v1/store/model/cycle.go +++ b/xeol/db/v1/store/model/cycle.go @@ -14,6 +14,7 @@ type CycleModel struct { ProductName string `gorm:"column:product_name"` ProductPermalink string `gorm:"column:product_permalink"` ID int `gorm:"primary_key;column:id;"` + ProductID int `gorm:"column:product_id"` ReleaseCycle string `gorm:"column:release_cycle"` Eol time.Time `gorm:"column:eol"` EolBool bool `gorm:"column:eol_bool"` diff --git a/xeol/matcher/matchers.go b/xeol/matcher/matchers.go index f0adc325..b8067f48 100644 --- a/xeol/matcher/matchers.go +++ b/xeol/matcher/matchers.go @@ -15,6 +15,7 @@ import ( "github.com/xeol-io/xeol/xeol/match" distroMatcher "github.com/xeol-io/xeol/xeol/matcher/distro" pkgMatcher "github.com/xeol-io/xeol/xeol/matcher/packages" + terraformMatcher "github.com/xeol-io/xeol/xeol/matcher/terraform" "github.com/xeol-io/xeol/xeol/pkg" ) @@ -24,14 +25,16 @@ type Monitor struct { // Config contains values used by individual matcher structs for advanced configuration type Config struct { - Packages pkgMatcher.MatcherConfig - Distro distroMatcher.MatcherConfig + Packages pkgMatcher.MatcherConfig + Distro distroMatcher.MatcherConfig + Terraform terraformMatcher.MatcherConfig } func NewDefaultMatchers(_ Config) []Matcher { return []Matcher{ &pkgMatcher.Matcher{}, &distroMatcher.Matcher{}, + &terraformMatcher.Matcher{}, } } @@ -77,6 +80,7 @@ func FindMatches(store interface { distroMatcher := &distroMatcher.Matcher{ UseCPEs: true, } + terraformMatcher := &terraformMatcher.Matcher{} progressMonitor := trackMatcher(len(packages)) defer progressMonitor.SetCompleted() @@ -95,7 +99,15 @@ func FindMatches(store interface { progressMonitor.PackagesProcessed.Increment() log.Debugf("searching for eol matches for pkg=%s", p) - pkgMatch, err := defaultMatcher.Match(store, p, eolMatchDate) + var pkgMatch match.Match + var err error + + if p.Type == "terraform" { + pkgMatch, err = terraformMatcher.Match(store, p, eolMatchDate) + } else { + pkgMatch, err = defaultMatcher.Match(store, p, eolMatchDate) + } + if err != nil { log.Debugf("matcher failed for pkg=%s: %+v", p, err) } diff --git a/xeol/matcher/terraform/matcher.go b/xeol/matcher/terraform/matcher.go new file mode 100644 index 00000000..e092fd6f --- /dev/null +++ b/xeol/matcher/terraform/matcher.go @@ -0,0 +1,41 @@ +package terraform + +import ( + "time" + + syftPkg "github.com/anchore/syft/syft/pkg" + + "github.com/xeol-io/xeol/xeol/eol" + "github.com/xeol-io/xeol/xeol/match" + "github.com/xeol-io/xeol/xeol/pkg" + "github.com/xeol-io/xeol/xeol/search" +) + +type Matcher struct {} + +type MatcherConfig struct {} + +func NewTerraformMatcher(cfg MatcherConfig) *Matcher { + return &Matcher{} +} + +func (m *Matcher) PackageTypes() []syftPkg.Type { + // Let's assume Syft might have a generic type or we just intercept Terraform specifically + // "terraform" or "hashicorp" might be the package type returned by Syft's lock file cataloger + return []syftPkg.Type{"terraform"} +} + +func (m *Matcher) Type() match.MatcherType { + return match.PackageMatcher +} + +func (m *Matcher) Match(store eol.Provider, p pkg.Package, eolMatchDate time.Time) (match.Match, error) { + // If the package is not terraform, don't match + if p.Type != "terraform" { + return match.Match{}, nil + } + + // We can mutate the PURL here if needed before passing to store, + // or just rely on search.ByPackagePURL if it matches the DB schema. + return search.ByPackagePURL(store, p, m.Type(), eolMatchDate) +} diff --git a/xeol/pkg/cataloger/terraform/cataloger.go b/xeol/pkg/cataloger/terraform/cataloger.go new file mode 100644 index 00000000..0b16c085 --- /dev/null +++ b/xeol/pkg/cataloger/terraform/cataloger.go @@ -0,0 +1,94 @@ +// Package terraform provides a custom cataloger for .terraform.lock.hcl files. +// It parses the lockfile and emits lightweight provider records. +// The caller (xeol/pkg) converts these records into xeol.Package objects. +package terraform + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Provider holds the parsed data from one provider block. +type Provider struct { + // Address is the full provider address, e.g. "registry.terraform.io/hashicorp/aws" + Address string + // Version is the pinned version string, e.g. "6.25.0" + Version string + // LockfilePath is the absolute path of the .terraform.lock.hcl file this came from. + LockfilePath string +} + +const lockfileName = ".terraform.lock.hcl" + +// CatalogDirectory walks root looking for .terraform.lock.hcl files and returns +// one Provider record per provider block found. +func CatalogDirectory(root string) ([]Provider, error) { + var all []Provider + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == lockfileName { + providers, parseErr := parseLockfile(path) + if parseErr != nil { + // Warn but keep walking + return nil + } + all = append(all, providers...) + } + return nil + }) + return all, err +} + +// parseLockfile parses .terraform.lock.hcl with a simple line-based scanner. +// +// The lockfile format is: +// +// provider "registry.terraform.io/hashicorp/aws" { +// version = "6.25.0" +// ... +// } +func parseLockfile(path string) ([]Provider, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + providerHeaderRe := regexp.MustCompile(`^\s*provider\s+"([^"]+)"\s*\{`) + versionRe := regexp.MustCompile(`^\s*version\s*=\s*"([^"]+)"`) + + var providers []Provider + var current *Provider + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + + if current == nil { + if m := providerHeaderRe.FindStringSubmatch(line); m != nil { + current = &Provider{Address: m[1], LockfilePath: path} + } + continue + } + + // Inside a provider block — look for version + if m := versionRe.FindStringSubmatch(line); m != nil { + current.Version = m[1] + } + + // End of block + if strings.TrimSpace(line) == "}" { + if current.Version != "" { + providers = append(providers, *current) + } + current = nil + } + } + return providers, scanner.Err() +} diff --git a/xeol/pkg/provider.go b/xeol/pkg/provider.go index 2b06dffe..fa4580dc 100644 --- a/xeol/pkg/provider.go +++ b/xeol/pkg/provider.go @@ -3,10 +3,13 @@ package pkg import ( "errors" "fmt" + "strings" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/sbom" "github.com/bmatcuk/doublestar/v2" + + tfcataloger "github.com/xeol-io/xeol/xeol/pkg/cataloger/terraform" ) var errDoesNotProvide = fmt.Errorf("cannot provide packages from the given source") @@ -30,9 +33,69 @@ func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom return packages, Context{}, s, err } - return syftProvider(userInput, config) + packages, ctx, s, err = syftProvider(userInput, config) + + // Inject our custom Terraform lockfile cataloger for dir: inputs. + // Syft v1.10.0 has no native terraform cataloger, so we do it ourselves. + if dirPath := extractDirPath(userInput); dirPath != "" { + tfPackages, tfErr := catalogTerraform(dirPath) + if tfErr != nil { + fmt.Printf("terraform cataloger warning: %v\n", tfErr) + } else { + packages = append(packages, tfPackages...) + } + } + + return packages, ctx, s, err } +// extractDirPath returns the filesystem path for a dir: prefixed input, else "". +func extractDirPath(userInput string) string { + if strings.HasPrefix(userInput, "dir:") { + return strings.TrimPrefix(userInput, "dir:") + } + return "" +} + +// catalogTerraform calls the terraform cataloger and converts its output into xeol Packages. +func catalogTerraform(dirPath string) ([]Package, error) { + providers, err := tfcataloger.CatalogDirectory(dirPath) + if err != nil { + return nil, err + } + + var pkgs []Package + for _, p := range providers { + pkgs = append(pkgs, terraformProviderToPackage(p)) + } + return pkgs, nil +} + +// terraformProviderToPackage converts a raw cataloger Provider into an xeol Package. +// PURL format: pkg:terraform//@ +func terraformProviderToPackage(p tfcataloger.Provider) Package { + parts := strings.Split(p.Address, "/") + name := p.Address + purlName := p.Address + if len(parts) >= 2 { + purlName = strings.Join(parts[len(parts)-2:], "/") + name = parts[len(parts)-1] + } + + purl := fmt.Sprintf("pkg:terraform/%s@%s", purlName, p.Version) + loc := file.NewLocation(p.LockfilePath) + + return Package{ + ID: ID(fmt.Sprintf("terraform-%s-%s", purlName, p.Version)), + Name: name, + Version: p.Version, + Locations: file.NewLocationSet(loc), + Type: "terraform", + PURL: purl, + } +} + + // This will filter the provided packages list based on a set of exclusion expressions. Globs // are allowed for the exclusions. A package will be *excluded* only if *all locations* match // one of the provided exclusions. diff --git a/xeol/presenter/format.go b/xeol/presenter/format.go index e5f35aa1..a1b1d472 100644 --- a/xeol/presenter/format.go +++ b/xeol/presenter/format.go @@ -8,6 +8,7 @@ const ( unknownFormat format = "unknown" jsonFormat format = "json" tableFormat format = "table" + sarifFormat format = "sarif" ) // format is a dedicated type to represent a specific kind of presenter output format. @@ -26,6 +27,8 @@ func parse(userInput string) format { return jsonFormat case strings.ToLower(tableFormat.String()): return tableFormat + case strings.ToLower(sarifFormat.String()): + return sarifFormat default: return unknownFormat } @@ -35,4 +38,5 @@ func parse(userInput string) format { var AvailableFormats = []format{ jsonFormat, tableFormat, + sarifFormat, } diff --git a/xeol/presenter/presenter.go b/xeol/presenter/presenter.go index e77698cd..d6a6ae06 100644 --- a/xeol/presenter/presenter.go +++ b/xeol/presenter/presenter.go @@ -5,6 +5,7 @@ import ( "github.com/xeol-io/xeol/xeol/presenter/json" "github.com/xeol-io/xeol/xeol/presenter/models" + "github.com/xeol-io/xeol/xeol/presenter/sarif" "github.com/xeol-io/xeol/xeol/presenter/table" ) @@ -19,6 +20,8 @@ func GetPresenter(c Config, pb models.PresenterConfig) Presenter { return json.NewPresenter(pb) case tableFormat: return table.NewPresenter(pb) + case sarifFormat: + return sarif.NewPresenter(pb) default: return nil } diff --git a/xeol/presenter/sarif/presenter.go b/xeol/presenter/sarif/presenter.go new file mode 100644 index 00000000..851fc842 --- /dev/null +++ b/xeol/presenter/sarif/presenter.go @@ -0,0 +1,91 @@ +package sarif + +import ( + "fmt" + "io" + + "github.com/owenrumney/go-sarif/v2/sarif" + "github.com/xeol-io/xeol/internal" + "github.com/xeol-io/xeol/internal/version" + "github.com/xeol-io/xeol/xeol/match" + "github.com/xeol-io/xeol/xeol/pkg" + "github.com/xeol-io/xeol/xeol/presenter/models" +) + +// Presenter is a generic struct for holding fields needed for reporting +type Presenter struct { + matches match.Matches + packages []pkg.Package + context pkg.Context + appConfig interface{} + dbStatus interface{} +} + +// NewPresenter is a *Presenter constructor +func NewPresenter(pb models.PresenterConfig) *Presenter { + return &Presenter{ + matches: pb.Matches, + packages: pb.Packages, + context: pb.Context, + appConfig: pb.AppConfig, + dbStatus: pb.DBStatus, + } +} + +// Present creates a SARIF-based reporting +func (pres *Presenter) Present(output io.Writer) error { + doc, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.appConfig, pres.dbStatus) + if err != nil { + return err + } + + report, err := sarif.New(sarif.Version210) + if err != nil { + return err + } + + run := sarif.NewRunWithInformationURI(internal.ApplicationName, "https://github.com/xeol-io/xeol") + run.Tool.Driver.WithVersion(version.FromBuild().Version) + + for _, match := range doc.Matches { + ruleId := "EOL-PACKAGE" + message := fmt.Sprintf("Package %s version %s reached End-Of-Life on %s", match.Artifact.Name, match.Artifact.Version, match.Cycle.Eol) + + run.AddRule(ruleId). + WithDescription(message). + WithHelpURI("https://github.com/xeol-io/xeol"). + WithShortDescription(sarif.NewMultiformatMessageString(fmt.Sprintf("%s is EOL", match.Artifact.Name))) + + result := sarif.NewRuleResult(ruleId). + WithMessage(sarif.NewMessage().WithText(message)). + WithLevel("warning") + + for _, loc := range match.Artifact.Locations { + result.AddLocation( + sarif.NewLocationWithPhysicalLocation( + sarif.NewPhysicalLocation(). + WithArtifactLocation( + sarif.NewSimpleArtifactLocation(loc.RealPath), + ), + ), + ) + } + + if len(match.Artifact.Locations) == 0 { + result.AddLocation( + sarif.NewLocationWithPhysicalLocation( + sarif.NewPhysicalLocation(). + WithArtifactLocation( + sarif.NewSimpleArtifactLocation("image"), + ), + ), + ) + } + + run.AddResult(result) + } + + report.AddRun(run) + + return report.PrettyWrite(output) +}