diff --git a/github_actions_test.go b/github_actions_test.go new file mode 100644 index 0000000..ff58f18 --- /dev/null +++ b/github_actions_test.go @@ -0,0 +1,95 @@ +package manifests + +import ( + "testing" +) + +func TestGitHubActionsDockerPURLs(t *testing.T) { + content := []byte(`name: fixture +on: push +jobs: + test: + runs-on: ubuntu-latest + container: ghcr.io/example/job:1 + services: + database: + image: postgres:16 + steps: + - uses: actions/checkout@v4 + - uses: docker://ghcr.io/example/step:2 +`) + result, err := Parse(".github/workflows/fixture.yml", content) + if err != nil { + t.Fatal(err) + } + if result.Ecosystem != "github-actions" || result.Kind != Manifest { + t.Fatalf("file identity = %q %q, want github-actions manifest", result.Ecosystem, result.Kind) + } + want := map[string]struct { + version, ecosystem, purl string + }{ + "actions/checkout": {"v4", "github-actions", "pkg:githubactions/actions/checkout"}, + "docker://ghcr.io/example/step": {"2", "docker", "pkg:docker/ghcr.io/example/step"}, + "docker://ghcr.io/example/job": {"1", "docker", "pkg:docker/ghcr.io/example/job"}, + "docker://postgres": {"16", "docker", "pkg:docker/postgres"}, + } + if len(result.Dependencies) != len(want) { + t.Fatalf("dependencies = %+v, want %d entries", result.Dependencies, len(want)) + } + for _, dep := range result.Dependencies { + expected, ok := want[dep.Name] + if !ok { + t.Errorf("unexpected dependency %+v", dep) + continue + } + if dep.Version != expected.version || dep.Ecosystem != expected.ecosystem || dep.PURL != expected.purl || + dep.Scope != Runtime || !dep.Direct { + t.Errorf("dependency = %+v, want version %q, ecosystem %q, PURL %q, direct runtime", + dep, expected.version, expected.ecosystem, expected.purl) + } + delete(want, dep.Name) + } + if len(want) != 0 { + t.Errorf("missing dependencies: %+v", want) + } +} + +func TestGitHubActionsDockerReferences(t *testing.T) { + const digest = "sha256:7b65413af120ec5328077775022c78101f103258a1876ec2f83890bce416e896" + content := []byte(`jobs: + test: + runs-on: ubuntu-latest + container: + image: registry.example:5000/team/image:stable@` + digest + ` + services: + duplicate: + image: registry.example:5000/team/image:stable@` + digest + ` + untagged: + image: ghcr.io/example/untagged + steps: + - uses: docker://registry.example:5000/team/image:stable@` + digest + ` + - uses: docker://registry.example:5000/team/image:2 +`) + result, err := Parse(".github/workflows/fixture.yaml", content) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + digest: "pkg:docker/registry.example:5000/team/image", + "2": "pkg:docker/registry.example:5000/team/image", + "latest": "pkg:docker/ghcr.io/example/untagged", + } + if len(result.Dependencies) != len(want) { + t.Fatalf("dependencies = %+v, want %d entries", result.Dependencies, len(want)) + } + for _, dep := range result.Dependencies { + expected, ok := want[dep.Version] + if !ok || dep.PURL != expected || dep.Ecosystem != "docker" { + t.Errorf("unexpected dependency %+v, want PURL %q and docker ecosystem", dep, expected) + } + delete(want, dep.Version) + } + if len(want) != 0 { + t.Errorf("missing versions: %+v", want) + } +} diff --git a/internal/github_actions/github_actions.go b/internal/github_actions/github_actions.go index 2cf69a6..8334432 100644 --- a/internal/github_actions/github_actions.go +++ b/internal/github_actions/github_actions.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/git-pkgs/manifests/internal/core" + "github.com/git-pkgs/purl" "go.yaml.in/yaml/v3" ) @@ -88,21 +89,23 @@ func collectStepActions( if step.Uses == "" { continue } + if image, ok := strings.CutPrefix(step.Uses, "docker://"); ok { + deps = appendDockerDep(image, deps, seen) + continue + } name, version := parseGitHubAction(step.Uses) if name == "" { continue } - if !strings.HasPrefix(name, "docker://") { - location := core.NextLocation(locations, "jobs/"+url.PathEscape(jobName)+"/steps/"+url.PathEscape(name)) - *declarations = append(*declarations, core.Declaration{ - Name: name, - Version: version, - Scope: core.Runtime, - Direct: true, - Location: location, - }) - } + location := core.NextLocation(locations, "jobs/"+url.PathEscape(jobName)+"/steps/"+url.PathEscape(name)) + *declarations = append(*declarations, core.Declaration{ + Name: name, + Version: version, + Scope: core.Runtime, + Direct: true, + Location: location, + }) key := name + "@" + version if seen[key] { @@ -173,23 +176,18 @@ func appendDockerDep(image string, deps []core.Dependency, seen map[string]bool) seen[key] = true return append(deps, core.Dependency{ - Name: "docker://" + name, - Version: version, - Scope: core.Runtime, - Direct: true, + Name: "docker://" + name, + Version: version, + Scope: core.Runtime, + Direct: true, + Ecosystem: "docker", + PURL: purl.BuildPURLString("docker", name, "", ""), }) } // parseGitHubAction parses a GitHub Action reference. -// Formats: owner/repo@ref, owner/repo/path@ref, docker://image:tag +// Formats: owner/repo@ref, owner/repo/path@ref func parseGitHubAction(uses string) (name, version string) { - // Skip docker:// references (handled separately) - if strings.HasPrefix(uses, "docker://") { - image := strings.TrimPrefix(uses, "docker://") - imgName, imgVersion := core.ParseDockerImage(image) - return "docker://" + imgName, imgVersion - } - // Skip local actions if strings.HasPrefix(uses, "./") || strings.HasPrefix(uses, "../") { return "", ""