Repository: google/ko
Pull request #743 (2022-07-01 17:42:57)

Start emitting multi-arch SBOMs for `SPDX` with `ko`
Tweaking some things to prepare for the meat of: https://github.com/google/ko/issues/655

codecov-commenter: # [Codecov](https://codecov.io/gh/google/ko/pull/743?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) Report
> Merging [#743](https://codecov.io/gh/google/ko/pull/743?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) (cbb70cc) into [main](https://codecov.io/gh/google/ko/commit/31d1c612ba637332a76d51a7c0222f2119b9ce38?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) (31d1c61) will **decrease** coverage by `0.28%`.
> The diff coverage is `29.72%`.

```diff
@@            Coverage Diff             @@
##             main     #743      +/-   ##
==========================================
- Coverage   51.38%   51.09%   -0.29%     
==========================================
  Files          44       44              
  Lines        3295     3327      +32     
==========================================
+ Hits         1693     1700       +7     
- Misses       1390     1412      +22     
- Partials      212      215       +3     
```


| [Impacted Files](https://codecov.io/gh/google/ko/pull/743?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) | Coverage Δ | |
|---|---|---|
| [pkg/commands/deps.go](https://codecov.io/gh/google/ko/pull/743/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL2NvbW1hbmRzL2RlcHMuZ28=) | `13.08% <0.00%> (ø)` | |
| [pkg/build/gobuild.go](https://codecov.io/gh/google/ko/pull/743/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL2J1aWxkL2dvYnVpbGQuZ28=) | `59.84% <30.55%> (-1.60%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/google/ko/pull/743?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/ko/pull/743?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Last update [31d1c61...cbb70cc](https://codecov.io/gh/google/ko/pull/743?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).

mattmoor: Ok, I pushed what I have so far.  I haven't tried things yet to make sure they work, but I'll do that now.
mattmoor: Oh fun, I think there are some Go 1.18 problems :-/

mattmoor: Here's an example of an image SBOM with the latest refactoring:
```json
{
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "sbom-sha256:8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85",
  "spdxVersion": "SPDX-2.2",
  "creationInfo": {
    "created": "2022-07-01T02:27:39Z",
    "creators": [
      "Tool: ko (devel)"
    ]
  },
  "dataLicense": "CC0-1.0",
  "documentNamespace": "http://spdx.org/spdxdocs/kosha256:8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85",
  "documentDescribes": [
    "SPDXRef-Package-sha256-8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85"
  ],
  "packages": [
    {
      "SPDXID": "SPDXRef-Package-sha256-8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85",
      "name": "sha256:8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85",
      "versionInfo": "",
      "filesAnalyzed": false,
      "licenseDeclared": "NOASSERTION",
      "licenseConcluded": "NOASSERTION",
      "downloadLocation": "",
      "copyrightText": "NOASSERTION",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE_MANAGER",
          "referenceLocator": "pkg:oci/image@sha256:8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85",
          "referenceType": "purl"
        }
      ]
    },
    {
      "SPDXID": "SPDXRef-Package-github.com.google.ko-(devel)",
      "name": "github.com/google/ko",
      "versionInfo": "",
      "filesAnalyzed": false,
      "licenseDeclared": "NOASSERTION",
      "licenseConcluded": "NOASSERTION",
      "downloadLocation": "https://github.com/google/ko",
      "copyrightText": "NOASSERTION",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE_MANAGER",
          "referenceLocator": "pkg:golang/github.com/google/ko@(devel)?type=module",
          "referenceType": "purl"
        }
      ]
    },
    {
      "SPDXID": "SPDXRef-Package-github.com.google.go-containerregistry-v0.10.0",
      "name": "github.com/google/go-containerregistry",
      "versionInfo": "v0.10.0",
      "filesAnalyzed": false,
      "licenseDeclared": "NOASSERTION",
      "licenseConcluded": "NOASSERTION",
      "downloadLocation": "https://proxy.golang.org/github.com/google/go-containerregistry/@v/v0.10.0.zip",
      "copyrightText": "NOASSERTION",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "a9dfdfbf69d06a319925e9da35c75a82197048f8d0d0da6137d87302baf65968"
        }
      ],
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE_MANAGER",
          "referenceLocator": "pkg:golang/github.com/google/go-containerregistry@v0.10.0?type=module",
          "referenceType": "purl"
        }
      ]
    }
  ],
  "relationships": [
    {
      "spdxElementId": "SPDXRef-DOCUMENT",
      "relationshipType": "DESCRIBES",
      "relatedSpdxElement": "SPDXRef-Package-sha256-8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85"
    },
    {
      "spdxElementId": "SPDXRef-Package-sha256-8955c18a6362f783536ec377fa0508646102895d89c08ee9d2f186c26e949d85",
      "relationshipType": "CONTAINS",
      "relatedSpdxElement": "SPDXRef-Package-github.com.google.ko-(devel)"
    },
    {
      "spdxElementId": "SPDXRef-Package-github.com.google.ko-(devel)",
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-github.com.google.go-containerregistry-v0.10.0"
    }
  ]
}
```



mattmoor: Here's an example of a multi-arch SBOM with this change:
```json
{
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "sbom-sha256:3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6",
  "spdxVersion": "SPDX-2.2",
  "creationInfo": {
    "created": "2022-07-01T02:27:39Z",
    "creators": [
      "Tool: ko (devel)"
    ]
  },
  "dataLicense": "CC0-1.0",
  "documentNamespace": "http://spdx.org/spdxdocs/kosha256:3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6",
  "documentDescribes": [
    "SPDXRef-Package-sha256-3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6"
  ],
  "packages": [
    {
      "SPDXID": "SPDXRef-Package-sha256-3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6",
      "name": "sha256:3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6",
      "versionInfo": "",
      "filesAnalyzed": false,
      "licenseDeclared": "NOASSERTION",
      "licenseConcluded": "NOASSERTION",
      "downloadLocation": "",
      "copyrightText": "NOASSERTION",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6"
        }
      ],
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE_MANAGER",
          "referenceLocator": "pkg:oci/index@sha256:3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6",
          "referenceType": "purl"
        }
      ]
    }
  ],
  "relationships": [
    {
      "spdxElementId": "DocumentRef-amd64-image-sbom:SPDXRef-Package-sha256-f03f678ab9cefa198a2334c4ea1e4d740c9b9686643db54b0053a80e6d5041e9",
      "relationshipType": "VARIANT_OF",
      "relatedSpdxElement": "SPDXRef-Package-sha256-3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6"
    },
    {
      "spdxElementId": "DocumentRef-arm64-image-sbom:SPDXRef-Package-sha256-569a8f91aed6b872d3e0c00f339b834ba671b2d04da80a14823f0db381890928",
      "relationshipType": "VARIANT_OF",
      "relatedSpdxElement": "SPDXRef-Package-sha256-3b72bb7f7ce12357a17d40f5198708823333790dd20d460613a9cdaabc97bdd6"
    }
  ],
  "externalDocumentRefs": [
    {
      "checksum": {
        "algorithm": "SHA256",
        "checksumValue": "c066df5c956def2fd36a340d4dcf67a51bbaf9e31201ae43088406b5ff99681a"
      },
      "externalDocumentId": "DocumentRef-amd64-image-sbom",
      "spdxDocument": "pkg:oci/sbom@sha256:f03f678ab9cefa198a2334c4ea1e4d740c9b9686643db54b0053a80e6d5041e9"
    },
    {
      "checksum": {
        "algorithm": "SHA256",
        "checksumValue": "03789b9d525f55260ea83ee2c173a54c40fda3f99110fd328d270e8a01e8b3e1"
      },
      "externalDocumentId": "DocumentRef-arm64-image-sbom",
      "spdxDocument": "pkg:oci/sbom@sha256:569a8f91aed6b872d3e0c00f339b834ba671b2d04da80a14823f0db381890928"
    }
  ]
}

```
puerco: OK, its missing the external document refs, you need to merge this patch first:

https://github.com/google/ko/pull/741
mattmoor: It's in there, I didn't see your PR until just now!
```json
  "externalDocumentRefs": [
    {
      "checksum": {
        "algorithm": "SHA256",
        "checksumValue": "c066df5c956def2fd36a340d4dcf67a51bbaf9e31201ae43088406b5ff99681a"
      },
      "externalDocumentId": "DocumentRef-amd64-image-sbom",
      "spdxDocument": "pkg:oci/sbom@sha256:f03f678ab9cefa198a2334c4ea1e4d740c9b9686643db54b0053a80e6d5041e9"
    },
    {
      "checksum": {
        "algorithm": "SHA256",
        "checksumValue": "03789b9d525f55260ea83ee2c173a54c40fda3f99110fd328d270e8a01e8b3e1"
      },
      "externalDocumentId": "DocumentRef-arm64-image-sbom",
      "spdxDocument": "pkg:oci/sbom@sha256:569a8f91aed6b872d3e0c00f339b834ba671b2d04da80a14823f0db381890928"
    }
  ]
```
puerco: Ah sorry I ran it through the validator and it failed because it could not find the ref. I assumed it was not there. But I just saw that it's failing because the `spdxDocument` field has the purl in it. This prevents the validator from pulling the doc to complete the sbom data :( 
puerco: If we cannot reliably get a link to the SBOMs of the arch images, we can simply not reference the sboms, only reference the image as a package.

We can think about it semantically as having two separate SBOM as opposed to this approach which is more akin to having one single SBOM broken into different files. From our discussion today and what I was discussing with @jonjohnsonjr I think I'm leaning more towards defining them this way.
mattmoor: Let me try getting the validator running against index SBOMs here.  I was being a bit lazy since it doesn't have a registry and just uses `ko deps`.
mattmoor: oh it does 😈 
mattmoor: > From our discussion today and what I was discussing with @jonjohnsonjr I think I'm leaning more towards defining them this way

Just as packages?  Ok, yeah I think that makes sense, and then we have the same problem for index -> image as we do for image -> other stuff's sboms.
mattmoor: Ok I switched it to using Packages, lmk if there’s a better ref type
mattmoor: Ok, so I realized my first crack at packages lost the platform information, so I brought it back in the `version` field of the package: https://github.com/google/ko/runs/7163036130?check_suite_focus=true#step:7:130
mattmoor: Here's a screenshot highlighting the digest of the image
<img width="875" alt="image" src="https://user-images.githubusercontent.com/2442466/177014386-4a880a29-c117-4387-b92d-548f7b5e4898.png">

mattmoor: Another reason I just thought of for why we need a non-native SPDX way of linking to other SBOMs is that we might have a mix of SPDX (derived) and CycloneDX (base).  That's not the case here, but clearly we need to handle that for base images.
puerco: The platform info can be encoded in the purl. But I'm looking and for some reason, it only has an `arch` field!?!?!   
https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci

We could propose another `os` field or simply cram the whole linux/arm/v8 field in there. I would prefer to have it there because  the purl is that key to link elements spread across sboms in spdx and cyclonedx
mattmoor: Sure, just lmk what you'd like to see here and we can adjust!

mattmoor: Added the architecture to the pURLs as well.
puerco: I've opened https://github.com/package-url/purl-spec/pull/179 to propose adding a `os` qualifier to the purl spec. Its the missing bit to complete the platform information.

Patch:
diff --git a/.github/workflows/sbom.yaml b/.github/workflows/sbom.yaml
index acd15bad..c334ea80 100644
--- a/.github/workflows/sbom.yaml
+++ b/.github/workflows/sbom.yaml
@@ -55,14 +55,14 @@ jobs:
       - name: Generate and Validate
         run: |
           img=$(go run ./ build ./)
-          go run ./ deps $img --sbom=cyclonedx > sbom.json
-          ./cyclonedx-linux-x64 validate --input-file=sbom.json --fail-on-errors
+          go run ./ deps $img --sbom=cyclonedx > cyclonedx.json
+          ./cyclonedx-linux-x64 validate --input-file=cyclonedx.json --fail-on-errors
 
       - uses: actions/upload-artifact@v3
         if: ${{ always() }}
         with:
-          name: sbom.json
-          path: sbom.json
+          name: cyclonedx.json
+          path: cyclonedx.json
 
   spdx:
     name: Validate SPDX SBOM
@@ -90,12 +90,53 @@ jobs:
       - name: Generate and Validate
         run: |
           img=$(go run ./ build ./)
-          go run ./ deps $img --sbom=spdx | tee sbom.json
+          go run ./ deps $img --sbom=spdx | tee spdx.json
 
-          java -jar ./tools-java-1.0.4-jar-with-dependencies.jar Verify sbom.json
+          java -jar ./tools-java-1.0.4-jar-with-dependencies.jar Verify spdx.json
 
       - uses: actions/upload-artifact@v3
         if: ${{ always() }}
         with:
-          name: sbom.json
-          path: sbom.json
+          name: spdx.json
+          path: spdx.json
+
+  spdx-multi-arch:
+    name: Validate SPDX multi-arch SBOM
+    runs-on: ubuntu-latest
+
+    env:
+      KO_DOCKER_REPO: localhost:1338
+
+    steps:
+      - uses: actions/setup-go@v3
+        with:
+          go-version: 1.17
+          check-latest: true
+      - name: Install cmd/registry
+        run: |
+          go install github.com/google/go-containerregistry/cmd/registry@latest
+          registry &
+      - uses: actions/checkout@v3
+
+      - name: Install SPDX Tools
+        run: |
+          wget https://github.com/spdx/tools-java/releases/download/v1.0.4/tools-java-1.0.4.zip
+          unzip tools-java-1.0.4.zip
+
+      - name: Install Cosign
+        uses: sigstore/cosign-installer@v2.4.0
+        with:
+          cosign-release: 'v1.7.2'
+
+      - name: Generate and Validate
+        run: |
+          img=$(go run ./ build --platform=linux/amd64,linux/arm64 ./)
+          cosign download sbom $img | tee spdx-multi-arch.json
+
+          java -jar ./tools-java-1.0.4-jar-with-dependencies.jar Verify spdx-multi-arch.json
+
+      - uses: actions/upload-artifact@v3
+        if: ${{ always() }}
+        with:
+          name: spdx-multi-arch.json
+          path: spdx-multi-arch.json
diff --git a/internal/sbom/cyclonedx.go b/internal/sbom/cyclonedx.go
index 572b1bdc..bb3132c3 100644
--- a/internal/sbom/cyclonedx.go
+++ b/internal/sbom/cyclonedx.go
@@ -19,36 +19,11 @@ import (
 	"encoding/base64"
 	"encoding/hex"
 	"encoding/json"
-	"fmt"
 	"strings"
 
-	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/sigstore/cosign/pkg/oci"
 )
 
-func bomRef(path, version string) string {
-	return fmt.Sprintf("pkg:golang/%s@%s?type=module", path, version)
-}
-
-func goRef(path, version string) string {
-	// Try to lowercase the first 2 path elements to comply with spec
-	// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang
-	p := strings.Split(path, "/")
-	if len(p) > 2 {
-		path = strings.Join(
-			append(
-				[]string{strings.ToLower(p[0]), strings.ToLower(p[1])},
-				p[2:]...,
-			), "/",
-		)
-	}
-	return fmt.Sprintf("pkg:golang/%s@%s?type=module", path, version)
-}
-
-func ociRef(path string, imgDigest v1.Hash) string {
-	parts := strings.Split(path, "/")
-	return fmt.Sprintf("pkg:oci/%s@%s", parts[len(parts)-1], imgDigest.String())
-}
-
 func h1ToSHA256(s string) string {
 	if !strings.HasPrefix(s, "h1:") {
 		return ""
@@ -60,7 +35,7 @@ func h1ToSHA256(s string) string {
 	return hex.EncodeToString(b)
 }
 
-func GenerateCycloneDX(mod []byte) ([]byte, error) {
+func GenerateImageCycloneDX(mod []byte) ([]byte, error) {
 	var err error
 	mod, err = massageGoVersionM(mod)
 	if err != nil {
@@ -78,11 +53,11 @@ func GenerateCycloneDX(mod []byte) ([]byte, error) {
 		Version:     1,
 		Metadata: metadata{
 			Component: component{
-				BOMRef:  bomRef(bi.Main.Path, bi.Main.Version),
+				BOMRef:  bomRef(&bi.Main),
 				Type:    "application",
 				Name:    bi.Main.Path,
 				Version: bi.Main.Version,
-				Purl:    bomRef(bi.Main.Path, bi.Main.Version),
+				Purl:    bomRef(&bi.Main),
 				ExternalReferences: []externalReference{{
 					URL:  "https://" + bi.Main.Path,
 					Type: "vcs",
@@ -97,12 +72,12 @@ func GenerateCycloneDX(mod []byte) ([]byte, error) {
 			// TODO: include bi.Settings?
 		},
 		Dependencies: []dependency{{
-			Ref: bomRef(bi.Main.Path, bi.Main.Version),
+			Ref: bomRef(&bi.Main),
 		}},
 		Compositions: []composition{{
 			Aggregate: "complete",
 			Dependencies: []string{
-				bomRef(bi.Main.Path, bi.Main.Version),
+				bomRef(&bi.Main),
 			},
 		}, {
 			Aggregate:    "unknown",
@@ -115,12 +90,12 @@ func GenerateCycloneDX(mod []byte) ([]byte, error) {
 			continue
 		}
 		comp := component{
-			BOMRef:  bomRef(dep.Path, dep.Version),
+			BOMRef:  bomRef(dep),
 			Type:    "library",
 			Name:    dep.Path,
 			Version: dep.Version,
 			Scope:   "required",
-			Purl:    bomRef(dep.Path, dep.Version),
+			Purl:    bomRef(dep),
 			ExternalReferences: []externalReference{{
 				URL:  "https://" + dep.Path,
 				Type: "vcs",
@@ -133,12 +108,12 @@ func GenerateCycloneDX(mod []byte) ([]byte, error) {
 			}}
 		}
 		doc.Components = append(doc.Components, comp)
-		doc.Dependencies[0].DependsOn = append(doc.Dependencies[0].DependsOn, bomRef(dep.Path, dep.Version))
+		doc.Dependencies[0].DependsOn = append(doc.Dependencies[0].DependsOn, bomRef(dep))
 		doc.Dependencies = append(doc.Dependencies, dependency{
-			Ref: bomRef(dep.Path, dep.Version),
+			Ref: bomRef(dep),
 		})
 
-		doc.Compositions[1].Dependencies = append(doc.Compositions[1].Dependencies, bomRef(dep.Path, dep.Version))
+		doc.Compositions[1].Dependencies = append(doc.Compositions[1].Dependencies, bomRef(dep))
 	}
 
 	var buf bytes.Buffer
@@ -150,6 +125,10 @@ func GenerateCycloneDX(mod []byte) ([]byte, error) {
 	return buf.Bytes(), nil
 }
 
+func GenerateIndexCycloneDX(sii oci.SignedImageIndex) ([]byte, error) {
+	return nil, nil
+}
+
 type document struct {
 	BOMFormat    string        `json:"bomFormat"`
 	SpecVersion  string        `json:"specVersion"`
diff --git a/internal/sbom/mod.go b/internal/sbom/mod.go
index c3925d18..2da560a8 100644
--- a/internal/sbom/mod.go
+++ b/internal/sbom/mod.go
@@ -15,7 +15,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// TODO: All of this is copied from:
+// TODO: Most of this is copied from:
 // https://cs.opensource.google/go/go/+/master:src/debug/buildinfo/buildinfo.go
 // https://cs.opensource.google/go/go/+/master:src/runtime/debug/mod.go
 // It should be replaced with runtime/buildinfo.Read on the binary file when Go 1.18 is released.
@@ -28,6 +28,32 @@ import (
 	"strings"
 )
 
+func modulePackageName(mod *Module) string {
+	return fmt.Sprintf("SPDXRef-Package-%s-%s",
+		strings.ReplaceAll(mod.Path, "/", "."),
+		mod.Version)
+}
+
+func bomRef(mod *Module) string {
+	return fmt.Sprintf("pkg:golang/%s@%s?type=module", mod.Path, mod.Version)
+}
+
+func goRef(mod *Module) string {
+	path := mod.Path
+	// Try to lowercase the first 2 path elements to comply with spec
+	// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang
+	p := strings.Split(path, "/")
+	if len(p) > 2 {
+		path = strings.Join(
+			append(
+				[]string{strings.ToLower(p[0]), strings.ToLower(p[1])},
+				p[2:]...,
+			), "/",
+		)
+	}
+	return fmt.Sprintf("pkg:golang/%s@%s?type=module", path, mod.Version)
+}
+
 // BuildInfo represents the build information read from a Go binary.
 // https://cs.opensource.google/go/go/+/release-branch.go1.18:src/runtime/debug/mod.go;drc=release-branch.go1.18;l=41
 type BuildInfo struct {
diff --git a/internal/sbom/mod_1.18.go b/internal/sbom/mod_1.18.go
index d8bf2b4a..68574765 100644
--- a/internal/sbom/mod_1.18.go
+++ b/internal/sbom/mod_1.18.go
@@ -20,6 +20,7 @@ package sbom
 import (
 	"fmt"
 	"runtime/debug"
+	"strings"
 )
 
 type BuildInfo debug.BuildInfo
@@ -32,3 +33,29 @@ func ParseBuildInfo(data string) (*BuildInfo, error) {
 	bi := BuildInfo(*dbi)
 	return &bi, nil
 }
+
+func modulePackageName(mod *debug.Module) string {
+	return fmt.Sprintf("SPDXRef-Package-%s-%s",
+		strings.ReplaceAll(mod.Path, "/", "."),
+		mod.Version)
+}
+
+func bomRef(mod *debug.Module) string {
+	return fmt.Sprintf("pkg:golang/%s@%s?type=module", mod.Path, mod.Version)
+}
+
+func goRef(mod *debug.Module) string {
+	path := mod.Path
+	// Try to lowercase the first 2 path elements to comply with spec
+	// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang
+	p := strings.Split(path, "/")
+	if len(p) > 2 {
+		path = strings.Join(
+			append(
+				[]string{strings.ToLower(p[0]), strings.ToLower(p[1])},
+				p[2:]...,
+			), "/",
+		)
+	}
+	return fmt.Sprintf("pkg:golang/%s@%s?type=module", path, mod.Version)
+}
diff --git a/internal/sbom/spdx.go b/internal/sbom/spdx.go
index 9b5649fb..cd69a568 100644
--- a/internal/sbom/spdx.go
+++ b/internal/sbom/spdx.go
@@ -17,16 +17,40 @@ package sbom
 import (
 	"bytes"
 	"encoding/json"
+	"errors"
 	"fmt"
+	"net/url"
 	"strings"
 	"time"
 
 	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/types"
+	"github.com/sigstore/cosign/pkg/oci"
 )
 
+type qualifier struct {
+	key   string
+	value string
+}
+
+// ociRef constructs a pURL for the OCI image according to:
+// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci
+func ociRef(path string, imgDigest v1.Hash, qual ...qualifier) string {
+	parts := strings.Split(path, "/")
+	purl := fmt.Sprintf("pkg:oci/%s@%s", parts[len(parts)-1], imgDigest.String())
+	if num := len(qual); num > 0 {
+		qs := make(url.Values, num)
+		for _, q := range qual {
+			qs.Add(q.key, q.value)
+		}
+		purl = purl + "?" + qs.Encode()
+	}
+	return purl
+}
+
 const dateFormat = "2006-01-02T15:04:05Z"
 
-func GenerateSPDX(koVersion string, date time.Time, mod []byte, imgDigest v1.Hash) ([]byte, error) {
+func GenerateImageSPDX(koVersion string, mod []byte, img oci.SignedImage) ([]byte, error) {
 	var err error
 	mod, err = massageGoVersionM(mod)
 	if err != nil {
@@ -38,26 +62,49 @@ func GenerateSPDX(koVersion string, date time.Time, mod []byte, imgDigest v1.Has
 		return nil, err
 	}
 
-	mainPackageID := "SPDXRef-Package-" + strings.ReplaceAll(bi.Main.Path, "/", ".")
-
-	doc := Document{
-		Version:           Version,
-		DataLicense:       "CC0-1.0",
-		ID:                "SPDXRef-DOCUMENT",
-		Name:              bi.Main.Path,
-		Namespace:         "http://spdx.org/spdxdocs/" + bi.Main.Path,
-		DocumentDescribes: []string{mainPackageID},
-		CreationInfo: CreationInfo{
-			Created:  date.Format(dateFormat),
-			Creators: []string{"Tool: ko " + koVersion},
-		},
-		Packages:      make([]Package, 0, 1+len(bi.Deps)),
-		Relationships: make([]Relationship, 0, 1+len(bi.Deps)),
+	imgDigest, err := img.Digest()
+	if err != nil {
+		return nil, err
+	}
+	cfg, err := img.ConfigFile()
+	if err != nil {
+		return nil, err
 	}
 
+	doc, imageID := starterDocument(koVersion, cfg.Created.Time, imgDigest)
+
+	// image -> main package -> transitive deps
+	doc.Packages = make([]Package, 0, 2+len(bi.Deps))
+	doc.Relationships = make([]Relationship, 0, 2+len(bi.Deps))
+
 	doc.Relationships = append(doc.Relationships, Relationship{
 		Element: "SPDXRef-DOCUMENT",
 		Type:    "DESCRIBES",
+		Related: imageID,
+	})
+
+	doc.Packages = append(doc.Packages, Package{
+		ID:   imageID,
+		Name: imgDigest.String(),
+		// TODO: PackageSupplier: "Organization: " + bs.Main.Path
+		DownloadLocation: NOASSERTION,
+		FilesAnalyzed:    false,
+		// TODO: PackageHomePage:  "https://" + bi.Main.Path,
+		LicenseConcluded: NOASSERTION,
+		LicenseDeclared:  NOASSERTION,
+		CopyrightText:    NOASSERTION,
+		ExternalRefs: []ExternalRef{{
+			Category: "PACKAGE_MANAGER",
+			Type:     "purl",
+			Locator:  ociRef("image", imgDigest),
+		}},
+	})
+
+	mainPackageID := modulePackageName(&bi.Main)
+
+	doc.Relationships = append(doc.Relationships, Relationship{
+		Element: imageID,
+		Type:    "CONTAINS",
 		Related: mainPackageID,
 	})
 
@@ -74,14 +121,12 @@ func GenerateSPDX(koVersion string, date time.Time, mod []byte, imgDigest v1.Has
 		ExternalRefs: []ExternalRef{{
 			Category: "PACKAGE_MANAGER",
 			Type:     "purl",
-			Locator:  ociRef(bi.Path, imgDigest),
+			Locator:  goRef(&bi.Main),
 		}},
 	})
 
 	for _, dep := range bi.Deps {
-		depID := fmt.Sprintf("SPDXRef-Package-%s-%s",
-			strings.ReplaceAll(dep.Path, "/", "."),
-			dep.Version)
+		depID := modulePackageName(dep)
 
 		doc.Relationships = append(doc.Relationships, Relationship{
 			Element: mainPackageID,
@@ -90,8 +135,8 @@ func GenerateSPDX(koVersion string, date time.Time, mod []byte, imgDigest v1.Has
 		})
 
 		pkg := Package{
-			Name:    dep.Path,
 			ID:      depID,
+			Name:    dep.Path,
 			Version: dep.Version,
 			// TODO: PackageSupplier: "Organization: " + dep.Path
 			DownloadLocation: fmt.Sprintf("https://proxy.golang.org/%s/@v/%s.zip", dep.Path, dep.Version),
@@ -102,7 +147,7 @@ func GenerateSPDX(koVersion string, date time.Time, mod []byte, imgDigest v1.Has
 			ExternalRefs: []ExternalRef{{
 				Category: "PACKAGE_MANAGER",
 				Type:     "purl",
-				Locator:  goRef(dep.Path, dep.Version),
+				Locator:  goRef(dep),
 			}},
 		}
 
@@ -125,6 +170,151 @@ func GenerateSPDX(koVersion string, date time.Time, mod []byte, imgDigest v1.Has
 	return buf.Bytes(), nil
 }
 
+func extractDate(sii oci.SignedImageIndex) (*time.Time, error) {
+	im, err := sii.IndexManifest()
+	if err != nil {
+		return nil, err
+	}
+	for _, desc := range im.Manifests {
+		switch desc.MediaType {
+		case types.OCIManifestSchema1, types.DockerManifestSchema2:
+			si, err := sii.SignedImage(desc.Digest)
+			if err != nil {
+				return nil, err
+			}
+			cfg, err := si.ConfigFile()
+			if err != nil {
+				return nil, err
+			}
+			return &cfg.Created.Time, nil
+
+		default:
+			// We shouldn't need to handle nested indices, since we don't build
+			// them, but if we do we will need to do some sort of recursion here.
+			return nil, fmt.Errorf("unknown media type: %v", desc.MediaType)
+		}
+	}
+	return nil, errors.New("unable to extract date, no imaged found")
+}
+
+func GenerateIndexSPDX(koVersion string, sii oci.SignedImageIndex) ([]byte, error) {
+	indexDigest, err := sii.Digest()
+	if err != nil {
+		return nil, err
+	}
+
+	date, err := extractDate(sii)
+	if err != nil {
+		return nil, err
+	}
+
+	doc, indexID := starterDocument(koVersion, *date, indexDigest)
+	doc.Packages = []Package{{
+		ID:               indexID,
+		Name:             indexDigest.String(),
+		DownloadLocation: NOASSERTION,
+		FilesAnalyzed:    false,
+		LicenseConcluded: NOASSERTION,
+		LicenseDeclared:  NOASSERTION,
+		CopyrightText:    NOASSERTION,
+		Checksums: []Checksum{{
+			Algorithm: strings.ToUpper(indexDigest.Algorithm),
+			Value:     indexDigest.Hex,
+		}},
+		ExternalRefs: []ExternalRef{{
+			Category: "PACKAGE_MANAGER",
+			Type:     "purl",
+			Locator:  ociRef("index", indexDigest),
+		}},
+	}}
+
+	im, err := sii.IndexManifest()
+	if err != nil {
+		return nil, err
+	}
+	for _, desc := range im.Manifests {
+		switch desc.MediaType {
+		case types.OCIManifestSchema1, types.DockerManifestSchema2:
+			si, err := sii.SignedImage(desc.Digest)
+			if err != nil {
+				return nil, err
+			}
+
+			imageDigest, err := si.Digest()
+			if err != nil {
+				return nil, err
+			}
+
+			depID := ociPackageName(imageDigest)
+
+			doc.Relationships = append(doc.Relationships, Relationship{
+				Element: ociPackageName(indexDigest),
+				Type:    "CONTAINS",
+				Related: depID,
+			})
+
+			pkg := Package{
+				ID:      depID,
+				Name:    imageDigest.String(),
+				Version: desc.Platform.String(),
+				// TODO: PackageSupplier: "Organization: " + dep.Path
+				DownloadLocation: NOASSERTION,
+				FilesAnalyzed:    false,
+				LicenseConcluded: NOASSERTION,
+				LicenseDeclared:  NOASSERTION,
+				CopyrightText:    NOASSERTION,
+				ExternalRefs: []ExternalRef{{
+					Category: "PACKAGE_MANAGER",
+					Type:     "purl",
+					Locator: ociRef("image", imageDigest, qualifier{
+						key:   "arch",
+						value: desc.Platform.Architecture,
+					}),
+				}},
+				Checksums: []Checksum{{
+					Algorithm: strings.ToUpper(imageDigest.Algorithm),
+					Value:     imageDigest.Hex,
+				}},
+			}
+
+			doc.Packages = append(doc.Packages, pkg)
+
+		default:
+			// We shouldn't need to handle nested indices, since we don't build
+			// them, but if we do we will need to do some sort of recursion here.
+			return nil, fmt.Errorf("unknown media type: %v", desc.MediaType)
+		}
+	}
+
+	var buf bytes.Buffer
+	enc := json.NewEncoder(&buf)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(doc); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+func ociPackageName(d v1.Hash) string {
+	return fmt.Sprintf("SPDXRef-Package-%s-%s", d.Algorithm, d.Hex)
+}
+
+func starterDocument(koVersion string, date time.Time, d v1.Hash) (Document, string) {
+	digestID := ociPackageName(d)
+	return Document{
+		ID:      "SPDXRef-DOCUMENT",
+		Version: Version,
+		CreationInfo: CreationInfo{
+			Created:  date.Format(dateFormat),
+			Creators: []string{"Tool: ko " + koVersion},
+		},
+		DataLicense:       "CC0-1.0",
+		Name:              "sbom-" + d.String(),
+		Namespace:         "http://spdx.org/spdxdocs/ko" + d.String(),
+		DocumentDescribes: []string{digestID},
+	}, digestID
+}
+
 // Below this is forked from here:
 // https://github.com/kubernetes-sigs/bom/blob/main/pkg/spdx/json/v2.2.2/types.go
 
diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go
index f220d4ee..a5c0f02e 100644
--- a/pkg/build/gobuild.go
+++ b/pkg/build/gobuild.go
@@ -62,7 +62,8 @@ const (
 type GetBase func(context.Context, string) (name.Reference, Result, error)
 
 type builder func(context.Context, string, string, v1.Platform, Config) (string, error)
-type sbomber func(context.Context, string, string, v1.Image) ([]byte, types.MediaType, error)
+
+type sbomber func(context.Context, string, string, oci.SignedEntity) ([]byte, types.MediaType, error)
 
 type platformMatcher struct {
 	spec      []string
@@ -300,55 +301,76 @@ func build(ctx context.Context, ip string, dir string, platform v1.Platform, con
 	return file, nil
 }
 
-func goversionm(ctx context.Context, file string, appPath string, _ v1.Image) ([]byte, types.MediaType, error) {
-	sbom := bytes.NewBuffer(nil)
-	cmd := exec.CommandContext(ctx, "go", "version", "-m", file)
-	cmd.Stdout = sbom
-	cmd.Stderr = os.Stderr
-	if err := cmd.Run(); err != nil {
-		return nil, "", err
-	}
+func goversionm(ctx context.Context, file string, appPath string, se oci.SignedEntity) ([]byte, types.MediaType, error) {
+	switch se.(type) {
+	case oci.SignedImage:
+		sbom := bytes.NewBuffer(nil)
+		cmd := exec.CommandContext(ctx, "go", "version", "-m", file)
+		cmd.Stdout = sbom
+		cmd.Stderr = os.Stderr
+		if err := cmd.Run(); err != nil {
+			return nil, "", err
+		}
+
+		// In order to get deterministics SBOMs replace our randomized
+		// file name with the path the app will get inside of the container.
+		return []byte(strings.Replace(sbom.String(), file, appPath, 1)), "application/vnd.go.version-m", nil
 
-	// In order to get deterministics SBOMs replace our randomized
-	// file name with the path the app will get inside of the container.
-	return []byte(strings.Replace(sbom.String(), file, appPath, 1)), "application/vnd.go.version-m", nil
+	case oci.SignedImageIndex:
+		return nil, "", nil
+
+	default:
+		return nil, "", fmt.Errorf("unrecognized type: %T", se)
+	}
 }
 
 func spdx(version string) sbomber {
-	return func(ctx context.Context, file string, appPath string, img v1.Image) ([]byte, types.MediaType, error) {
-		b, _, err := goversionm(ctx, file, appPath, img)
-		if err != nil {
-			return nil, "", err
-		}
+	return func(ctx context.Context, file string, appPath string, se oci.SignedEntity) ([]byte, types.MediaType, error) {
+		switch obj := se.(type) {
+		case oci.SignedImage:
+			b, _, err := goversionm(ctx, file, appPath, obj)
+			if err != nil {
+				return nil, "", err
+			}
 
-		cfg, err := img.ConfigFile()
-		if err != nil {
-			return nil, "", err
-		}
-		imgDigest, err := img.Digest()
-		if err != nil {
-			return nil, "", err
-		}
-		b, err = sbom.GenerateSPDX(version, cfg.Created.Time, b, imgDigest)
-		if err != nil {
-			return nil, "", err
+			b, err = sbom.GenerateImageSPDX(version, b, obj)
+			if err != nil {
+				return nil, "", err
+			}
+			return b, ctypes.SPDXJSONMediaType, nil
+
+		case oci.SignedImageIndex:
+			b, err := sbom.GenerateIndexSPDX(version, obj)
+			return b, ctypes.SPDXJSONMediaType, err
+
+		default:
+			return nil, "", fmt.Errorf("unrecognized type: %T", se)
 		}
-		return b, ctypes.SPDXJSONMediaType, nil
 	}
 }
 
 func cycloneDX() sbomber {
-	return func(ctx context.Context, file string, appPath string, img v1.Image) ([]byte, types.MediaType, error) {
-		b, _, err := goversionm(ctx, file, appPath, img)
-		if err != nil {
-			return nil, "", err
-		}
+	return func(ctx context.Context, file string, appPath string, se oci.SignedEntity) ([]byte, types.MediaType, error) {
+		switch obj := se.(type) {
+		case oci.SignedImage:
+			b, _, err := goversionm(ctx, file, appPath, obj)
+			if err != nil {
+				return nil, "", err
+			}
 
-		b, err = sbom.GenerateCycloneDX(b)
-		if err != nil {
-			return nil, "", err
+			b, err = sbom.GenerateImageCycloneDX(b)
+			if err != nil {
+				return nil, "", err
+			}
+			return b, ctypes.CycloneDXJSONMediaType, nil
+
+		case oci.SignedImageIndex:
+			b, err := sbom.GenerateIndexCycloneDX(obj)
+			return b, ctypes.SPDXJSONMediaType, err
+
+		default:
+			return nil, "", fmt.Errorf("unrecognized type: %T", se)
 		}
-		return b, ctypes.CycloneDXJSONMediaType, nil
 	}
 }
 
@@ -810,7 +832,7 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl
 	si := signed.Image(image)
 
 	if g.sbom != nil {
-		sbom, mt, err := g.sbom(ctx, file, appPath, image)
+		sbom, mt, err := g.sbom(ctx, file, appPath, si)
 		if err != nil {
 			return nil, err
 		}
@@ -992,8 +1014,23 @@ func (g *gobuild) buildAll(ctx context.Context, ref string, baseIndex v1.ImageIn
 			im.Annotations).(v1.ImageIndex),
 		adds...)
 
-	// TODO(mattmoor): If we want to attach anything (e.g. signatures, attestations, SBOM)
-	// at the index level, we would do it here!
+	if g.sbom != nil {
+		sbom, mt, err := g.sbom(ctx, "", "", idx)
+		if err != nil {
+			return nil, err
+		}
+		if sbom != nil {
+			f, err := static.NewFile(sbom, static.WithLayerMediaType(mt))
+			if err != nil {
+				return nil, err
+			}
+			idx, err = ocimutate.AttachFileToImageIndex(idx, "sbom", f)
+			if err != nil {
+				return nil, err
+			}
+		}
+	}
+
 	return idx, nil
 }
 
diff --git a/pkg/commands/deps.go b/pkg/commands/deps.go
index 1d82b8e4..5f6ecccc 100644
--- a/pkg/commands/deps.go
+++ b/pkg/commands/deps.go
@@ -30,6 +30,7 @@ import (
 	"github.com/google/go-containerregistry/pkg/v1/mutate"
 	"github.com/google/go-containerregistry/pkg/v1/remote"
 	"github.com/google/ko/internal/sbom"
+	"github.com/sigstore/cosign/pkg/oci/signed"
 	"github.com/spf13/cobra"
 )
 
@@ -132,19 +133,15 @@ If the image was not built using ko, or if it was built without embedding depend
 					[]byte(n),
 					[]byte(path.Join("/ko-app", filepath.Base(filepath.Clean(h.Name)))),
 					1)
-				imgDigest, err := img.Digest()
-				if err != nil {
-					return err
-				}
 				switch sbomType {
 				case "spdx":
-					b, err := sbom.GenerateSPDX(Version, cfg.Created.Time, mod, imgDigest)
+					b, err := sbom.GenerateImageSPDX(Version, mod, signed.Image(img))
 					if err != nil {
 						return err
 					}
 					io.Copy(os.Stdout, bytes.NewReader(b))
 				case "cyclonedx":
-					b, err := sbom.GenerateCycloneDX(mod)
+					b, err := sbom.GenerateImageCycloneDX(mod)
 					if err != nil {
 						return err
 					}


Test patch:
diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go
index 0bd93af4..afe8e22b 100644
--- a/pkg/build/gobuild_test.go
+++ b/pkg/build/gobuild_test.go
@@ -392,14 +392,14 @@ func TestBuildConfig(t *testing.T) {
 	}
 }
 
-func nilGetBase(_ context.Context, _ string) (name.Reference, Result, error) {
+func nilGetBase(context.Context, string) (name.Reference, Result, error) {
 	return nil, nil, nil
 }
 
 const wantSBOM = "This is our fake SBOM"
 
 // A helper method we use to substitute for the default "build" method.
-func fauxSBOM(_ context.Context, _ string, _ string, _ v1.Image) ([]byte, types.MediaType, error) {
+func fauxSBOM(context.Context, string, string, oci.SignedEntity) ([]byte, types.MediaType, error) {
 	return []byte(wantSBOM), "application/vnd.garbage", nil
 }