Skip to content

Commit

Permalink
Experimental GitHub export (#836)
Browse files Browse the repository at this point in the history
  • Loading branch information
kzantow authored Mar 11, 2022
1 parent fa03723 commit 7789506
Show file tree
Hide file tree
Showing 6 changed files with 458 additions and 0 deletions.
2 changes: 2 additions & 0 deletions cmd/format_aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ func formatAliases(ids ...sbom.FormatID) (aliases []string) {
aliases = append(aliases, "cyclonedx-xml")
case syft.CycloneDxJSONFormatID:
aliases = append(aliases, "cyclonedx-json")
case syft.GitHubID:
aliases = append(aliases, "github", "github-json")
default:
aliases = append(aliases, string(id))
}
Expand Down
183 changes: 183 additions & 0 deletions internal/formats/github/encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package github

import (
"fmt"
"strings"
"time"

"github.com/mholt/archiver/v3"

"github.com/anchore/packageurl-go"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)

// toGithubModel converts the provided SBOM to a GitHub dependency model
func toGithubModel(s *sbom.SBOM) DependencySnapshot {
scanTime := time.Now().Format(time.RFC3339) // TODO is there a record of this somewhere?
v := version.FromBuild().Version
if v == "[not provided]" {
v = "0.0.0-dev"
}
return DependencySnapshot{
Version: 0,
// TODO allow property input to specify the Job, Sha, and Ref
Detector: DetectorMetadata{
Name: internal.ApplicationName,
URL: "https://github.com/anchore/syft",
Version: v,
},
Metadata: toSnapshotMetadata(s),
Manifests: toGithubManifests(s),
Scanned: scanTime,
}
}

// toSnapshotMetadata captures the linux distribution information and other metadata
func toSnapshotMetadata(s *sbom.SBOM) Metadata {
out := Metadata{}

if s.Artifacts.LinuxDistribution != nil {
d := s.Artifacts.LinuxDistribution
qualifiers := packageurl.Qualifiers{}
if len(d.IDLike) > 0 {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "like",
Value: strings.Join(d.IDLike, ","),
})
}
purl := packageurl.NewPackageURL("generic", "", d.ID, d.VersionID, qualifiers, "")
out["syft:distro"] = purl.ToString()
}

return out
}

func filesystem(p pkg.Package) string {
if len(p.Locations) > 0 {
return p.Locations[0].FileSystemID
}
return ""
}

// isArchive returns true if the path appears to be an archive
func isArchive(path string) bool {
_, err := archiver.ByExtension(path)
return err == nil
}

// toPath Generates a string representation of the package location, optionally including the layer hash
func toPath(s source.Metadata, p pkg.Package) string {
inputPath := strings.TrimPrefix(s.Path, "./")
if inputPath == "." {
inputPath = ""
}
if len(p.Locations) > 0 {
location := p.Locations[0]
packagePath := location.RealPath
if location.VirtualPath != "" {
packagePath = location.VirtualPath
}
packagePath = strings.TrimPrefix(packagePath, "/")
switch s.Scheme {
case source.ImageScheme:
image := strings.ReplaceAll(s.ImageMetadata.UserInput, ":/", "//")
return fmt.Sprintf("%s:/%s", image, packagePath)
case source.FileScheme:
if isArchive(inputPath) {
return fmt.Sprintf("%s:/%s", inputPath, packagePath)
}
return inputPath
case source.DirectoryScheme:
if inputPath != "" {
return fmt.Sprintf("%s/%s", inputPath, packagePath)
}
return packagePath
}
}
return fmt.Sprintf("%s%s", inputPath, s.ImageMetadata.UserInput)
}

// toGithubManifests manifests, each of which represents a specific location that has dependencies
func toGithubManifests(s *sbom.SBOM) Manifests {
manifests := map[string]*Manifest{}

for _, p := range s.Artifacts.PackageCatalog.Sorted() {
path := toPath(s.Source, p)
manifest, ok := manifests[path]
if !ok {
manifest = &Manifest{
Name: path,
File: FileInfo{
SourceLocation: path,
},
Resolved: DependencyGraph{},
}
fs := filesystem(p)
if fs != "" {
manifest.Metadata = Metadata{
"syft:filesystem": fs,
}
}
manifests[path] = manifest
}

name := dependencyName(p)
manifest.Resolved[name] = DependencyNode{
Purl: p.PURL,
Metadata: toDependencyMetadata(p),
Relationship: toDependencyRelationshipType(p),
Scope: toDependencyScope(p),
Dependencies: toDependencies(s, p),
}
}

out := Manifests{}
for k, v := range manifests {
out[k] = *v
}
return out
}

// dependencyName to make things a little nicer to read; this might end up being lossy
func dependencyName(p pkg.Package) string {
purl, err := packageurl.FromString(p.PURL)
if err != nil {
log.Warnf("Invalid PURL for package: '%s' PURL: '%s' (%w)", p.Name, p.PURL, err)
return ""
}
// don't use qualifiers for this
purl.Qualifiers = nil
return purl.ToString()
}

func toDependencyScope(_ pkg.Package) DependencyScope {
return DependencyScopeRuntime
}

func toDependencyRelationshipType(_ pkg.Package) DependencyRelationship {
return DependencyRelationshipDirect
}

func toDependencyMetadata(_ pkg.Package) Metadata {
// We have limited properties: up to 8 with reasonably small values
// For now, we are encoding the location as part of the key, we are encoding PURLs with most
// of the other information Grype might need; and the distro information at the top level
// so we don't need anything here yet
return Metadata{}
}

func toDependencies(s *sbom.SBOM, p pkg.Package) (out []string) {
for _, r := range s.Relationships {
if r.From.ID() == p.ID() {
if p, ok := r.To.(pkg.Package); ok {
out = append(out, dependencyName(p))
}
}
}
return
}
161 changes: 161 additions & 0 deletions internal/formats/github/encoder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package github

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"

"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)

func Test_toGithubModel(t *testing.T) {
s := sbom.SBOM{
Source: source.Metadata{
Scheme: source.ImageScheme,
ImageMetadata: source.ImageMetadata{
UserInput: "ubuntu:18.04",
Architecture: "amd64",
},
},
Artifacts: sbom.Artifacts{
LinuxDistribution: &linux.Release{
ID: "ubuntu",
VersionID: "18.04",
IDLike: []string{"debian"},
},
PackageCatalog: pkg.NewCatalog(),
},
}
for _, p := range []pkg.Package{
{
Name: "pkg-1",
Version: "1.0.1",
Locations: []source.Location{{
Coordinates: source.Coordinates{
RealPath: "/usr/lib",
FileSystemID: "fsid-1",
},
}},
},
{
Name: "pkg-2",
Version: "2.0.2",
Locations: []source.Location{{
Coordinates: source.Coordinates{
RealPath: "/usr/lib",
FileSystemID: "fsid-1",
},
}},
},
{
Name: "pkg-3",
Version: "3.0.3",
Locations: []source.Location{{
Coordinates: source.Coordinates{
RealPath: "/etc",
FileSystemID: "fsid-1",
},
}},
},
} {
p.PURL = packageurl.NewPackageURL(
"generic",
"",
p.Name,
p.Version,
nil,
"",
).ToString()
s.Artifacts.PackageCatalog.Add(p)
}

actual := toGithubModel(&s)

expected := DependencySnapshot{
Version: 0,
Detector: DetectorMetadata{
Name: "syft",
Version: "0.0.0-dev",
URL: "https://github.com/anchore/syft",
},
Metadata: Metadata{
"syft:distro": "pkg:generic/[email protected]?like=debian",
},
Scanned: actual.Scanned,
Manifests: Manifests{
"ubuntu:18.04:/usr/lib": Manifest{
Name: "ubuntu:18.04:/usr/lib",
File: FileInfo{
SourceLocation: "ubuntu:18.04:/usr/lib",
},
Metadata: Metadata{
"syft:filesystem": "fsid-1",
},
Resolved: DependencyGraph{
"pkg:generic/[email protected]": DependencyNode{
Purl: "pkg:generic/[email protected]",
Scope: DependencyScopeRuntime,
Relationship: DependencyRelationshipDirect,
},
"pkg:generic/[email protected]": DependencyNode{
Purl: "pkg:generic/[email protected]",
Scope: DependencyScopeRuntime,
Relationship: DependencyRelationshipDirect,
},
},
},
"ubuntu:18.04:/etc": Manifest{
Name: "ubuntu:18.04:/etc",
File: FileInfo{
SourceLocation: "ubuntu:18.04:/etc",
},
Metadata: Metadata{
"syft:filesystem": "fsid-1",
},
Resolved: DependencyGraph{
"pkg:generic/[email protected]": DependencyNode{
Purl: "pkg:generic/[email protected]",
Scope: DependencyScopeRuntime,
Relationship: DependencyRelationshipDirect,
},
},
},
},
}

// just using JSONEq because it gives a comprehensible diff
s1, _ := json.Marshal(expected)
s2, _ := json.Marshal(actual)
assert.JSONEq(t, string(s1), string(s2))

// Just test the other schemes:
s.Source.Path = "."
s.Source.Scheme = source.DirectoryScheme
actual = toGithubModel(&s)
assert.Equal(t, "etc", actual.Manifests["etc"].Name)

s.Source.Path = "./artifacts"
s.Source.Scheme = source.DirectoryScheme
actual = toGithubModel(&s)
assert.Equal(t, "artifacts/etc", actual.Manifests["artifacts/etc"].Name)

s.Source.Path = "/artifacts"
s.Source.Scheme = source.DirectoryScheme
actual = toGithubModel(&s)
assert.Equal(t, "/artifacts/etc", actual.Manifests["/artifacts/etc"].Name)

s.Source.Path = "./executable"
s.Source.Scheme = source.FileScheme
actual = toGithubModel(&s)
assert.Equal(t, "executable", actual.Manifests["executable"].Name)

s.Source.Path = "./archive.tar.gz"
s.Source.Scheme = source.FileScheme
actual = toGithubModel(&s)
assert.Equal(t, "archive.tar.gz:/etc", actual.Manifests["archive.tar.gz:/etc"].Name)
}
29 changes: 29 additions & 0 deletions internal/formats/github/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package github

import (
"encoding/json"
"io"

"github.com/anchore/syft/syft/sbom"
)

const ID sbom.FormatID = "github-0-json"

func Format() sbom.Format {
return sbom.NewFormat(
ID,
func(writer io.Writer, sbom sbom.SBOM) error {
bom := toGithubModel(&sbom)

bytes, err := json.MarshalIndent(bom, "", " ")
if err != nil {
return err
}
_, err = writer.Write(bytes)

return err
},
nil,
nil,
)
}
Loading

0 comments on commit 7789506

Please sign in to comment.