From 1df333fc2fdbdfd358278e28e18ec19bb018ea24 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 10 Aug 2026 09:13:43 -0400 Subject: [PATCH 1/3] feat(ci): add a full SBOM - app deps, OTP/Elixir runtime, container OS packages mix.lock has exact versions + checksums for the Hex dependency tree, but it's not a real SBOM - wrong format (Erlang-term syntax, not SPDX/ CycloneDX), no license data, and doesn't cover the OTP/ERTS runtime Burrito bundles into each binary or the Alpine packages baked into the container image. Two real SBOMs, kept as separate files since a container SBOM has no use to anyone not using the container: - App + Hex deps + Erlang/OTP + Elixir runtime, via erlef/mix_sbom (verified: includes the OTP standard library apps - kernel/stdlib/ crypto/ssl/etc - and Elixir at the exact pinned version by default, no manual merge needed). Generated in the burrito job, attached to the release as sbom.cdx.json in the same atomic `gh release create` call that creates the release. - Container OS packages (Alpine/apk), via aquasecurity/trivy-action (--format cyclonedx). Generated in the container job, which runs after the release is already published (and thus immutable, #18) - published as a workflow artifact, not a release asset, since it genuinely can't be attached to the already-locked release. Closes #53. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/main.yaml | 43 ++++++++++++++++++++++++++++++++++++- Readme.adoc | 6 ++++++ app/mix.exs | 3 ++- app/mix.lock | 4 ++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 71fbd3f..f836882 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -154,6 +154,22 @@ jobs: run: | version=$(ruby -rjson -e "print JSON.parse(File.read('../.release-please-manifest.json'))['.']") printf 'tag_name=v%s\n' "$version" >> "$GITHUB_OUTPUT" + - + # Covers the app + Hex deps + the actual Erlang/OTP and Elixir + # versions this release was built with (mix_sbom includes those by + # default - verified they show up as individual OTP application + # components like kernel/stdlib/crypto/ssl, not one umbrella + # "erlang" entry). Not the container image's own OS packages - + # that's a separate SBOM, generated in the container job below, + # since it's meaningless to anyone not using the container. + name: Generate the app SBOM + uses: erlef/mix_sbom@v0 + id: sbom + with: + project-path: ${{ github.workspace }}/app + reuse-beam: true + schema: "1.6" + format: "json" - # One atomic command creates the tag, the release, and uploads # every asset together - no separate release object sits around @@ -163,7 +179,7 @@ jobs: name: Create the release with every asset attached env: GH_TOKEN: ${{ github.token }} - run: gh release create "${{ steps.version.outputs.tag_name }}" burrito_out/*.tar.gz burrito_out/SHA256SUMS --generate-notes + run: gh release create "${{ steps.version.outputs.tag_name }}" burrito_out/*.tar.gz burrito_out/SHA256SUMS "${{ steps.sbom.outputs.sbom-path }}" --generate-notes working-directory: app - # release-please labels its own release PR "autorelease: pending" @@ -215,9 +231,34 @@ jobs: env: APP_VERSION: ${{ needs.burrito.outputs.tag_name }} run: ./ci/build_image.sh "${{ needs.burrito.outputs.tag_name }}" + - + # Covers the container's own OS packages (Alpine/apk - ca-certificates, + # bash) - meaningless to anyone not using the container, which is + # exactly why it's a separate file from the app SBOM in the burrito + # job above, not merged into it. Scans the local image ci/build_image.sh + # just built (tagged : - no registry prefix), + # before publishing, though nothing here gates the publish step on it. + name: Generate the container SBOM + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: "linear-cli-ex:${{ needs.burrito.outputs.tag_name }}" + scan-type: image + format: cyclonedx + output: container-sbom.cdx.json - name: Publish the image env: GITHUB_TOKEN: ${{ github.token }} GITHUB_ACTOR: ${{ github.actor }} run: ./ci/publish.sh "${{ needs.burrito.outputs.tag_name }}" + - + # Can't attach this to the GitHub release the burrito job already + # created - it's published (and thus immutable, see #18) by the time + # this job runs. A workflow artifact is the honest option here, not + # a release asset pretending to be one. + name: Upload the container SBOM as a workflow artifact + uses: actions/upload-artifact@v7 + with: + name: container-sbom + path: container-sbom.cdx.json + retention-days: 90 diff --git a/Readme.adoc b/Readme.adoc index 5cd33be..659e066 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -66,6 +66,12 @@ $ sudo mv lc/* /usr/local/bin/ ---- <1> Linux: `sha256sum -c -` instead of `shasum -a 256 -c -`. +Every release also publishes `sbom.cdx.json` - a CycloneDX SBOM covering the +app, its Hex dependencies, and the Erlang/OTP and Elixir versions it was +built with. The container image has its own separate SBOM (its OS packages - +irrelevant if you're not using the container), published as a workflow +artifact on the release's build rather than a release asset. + NOTE: A Homebrew tap is planned but not yet available. On macOS, Gatekeeper blocks `lc` itself (the wrapper scripts are plain shell, diff --git a/app/mix.exs b/app/mix.exs index f0be1dd..56c9f47 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -73,7 +73,8 @@ defmodule LinearCli.MixProject do {:burrito, "~> 1.6"}, {:sourceror, "~> 1.8", only: [:dev, :test]}, {:ash, "~> 3.0"}, - {:igniter, "~> 0.6", only: [:dev, :test]} + {:igniter, "~> 0.6", only: [:dev, :test]}, + {:sbom, "~> 0.8", only: :dev, runtime: false} # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} ] diff --git a/app/mix.lock b/app/mix.lock index 033a179..a63148f 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -14,6 +14,7 @@ "exqlite": {:hex, :exqlite, "0.39.0", "08f8ff646e14d14bab289f4cfc61b95ffc5fe7c82d420049a480eee69ca8b292", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "603de0f7637adc88275fa12ccbd58954ff6000f75386e876565b49032d9aede9"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, + "hex_core": {:hex, :hex_core, "0.15.0", "8eadc0ccb08e3742f2313073d04f39eaa7904617329039e9d3c402f5dd227673", [:rebar3], [], "hexpm", "c2093764c7af8ef0818c104fa141eba431e7be93f8374638c45c7037b26a52f8"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "igniter": {:hex, :igniter, "0.8.3", "9de74d3885efae43b0b58dc6f7b816963c4bbd391e6b6fe6922ee21c4e384c76", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "afc5e3848d885e680da5c3b65e5e7717555a08cd12305190ff2be76427af39ff"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, @@ -33,10 +34,13 @@ "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, + "protobuf": {:hex, :protobuf, "0.16.1", "7bf0c3e6b24fd297e2e3722ecb985c3900bcc55d004b7faf0737e248c3e64691", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "97fb9f1fe48c38c4ebfc0f8a7a20adb22b2491bbfbdb192fab9a6821d5d604e1"}, + "purl": {:hex, :purl, "0.3.0", "b6e2f792e944007ed474299e56ea43ea4819f064a4a4f67e4153e1ba02d066de", [:mix, :rebar3], [{:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "158cf2c0533517fe0d68752bd790392f065c5ffdfbde38524890066ebe640302"}, "reactor": {:hex, :reactor, "1.0.4", "e19b3bc00776935282e5e0fc1b0dcb87682ec6f3c238ce3150fe7f50bbde502a", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ba5ec7c9ddc7781f65b6e9818456c4c6b4e997a9a926d83b6f3f12192ef5b395"}, "req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "sbom": {:hex, :sbom, "0.10.0", "b99be5407bc196d0ad71b8061126a67aae46dc3bfaa852b4c1c04645dd1ad984", [:mix], [{:hex_core, "~> 0.15.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.16.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.3.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "a8116ef965c1ebd103e223545794bd0a6691edd3ec678ec07972d473e2badc95"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, "spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"}, "spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"}, From 2e8ff10facc003cc1f2065e5cb27173782553f74 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 10 Aug 2026 09:22:46 -0400 Subject: [PATCH 2/3] fix: set output-file to match documented name in Readme --- .github/workflows/main.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f836882..9ed48c8 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -170,6 +170,7 @@ jobs: reuse-beam: true schema: "1.6" format: "json" + output-file: "sbom.cdx.json" - # One atomic command creates the tag, the release, and uploads # every asset together - no separate release object sits around From 525b08ca450708be9380cd88dfa8d7608a8d4915 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 10 Aug 2026 09:53:09 -0400 Subject: [PATCH 3/3] fix: widen sbom constraint, fix the SBOM release-asset filename Two review findings on #54: - `~> 0.8` doesn't allow 0.10.0 (Elixir's ~> pins the second segment tight for pre-1.0 versions), but mix.lock already resolved to 0.10.0 - the exact version already tested. Widened to `~> 0.10` rather than downgrading. - erlef/mix_sbom has no output-filename input (verified directly in the action's own script: it always writes to $RUNNER_TEMP/$RANDOM.cdx.json, exposed via sbom-path). gh release create uses a file's own basename as the actual download filename - the `#label` syntax only sets a cosmetic display label, not the filename - so without an explicit rename, the Readme's documented .../download/sbom.cdx.json URL would 404. Added a rename step. --- .github/workflows/main.yaml | 14 ++++++++++++-- app/mix.exs | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 9ed48c8..62ac774 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -170,7 +170,17 @@ jobs: reuse-beam: true schema: "1.6" format: "json" - output-file: "sbom.cdx.json" + - + # mix_sbom has no output-filename input - it always writes to + # $RUNNER_TEMP/$RANDOM.cdx.json internally (verified directly in + # the action's own script) and exposes that random path via + # sbom-path. gh release create uses a file's own basename as the + # asset's actual download filename (the `#label` syntax only sets + # a cosmetic display label, not the filename) - without this + # rename, the Readme's documented .../download/sbom.cdx.json URL + # would 404. + name: Rename the SBOM to a stable filename + run: mv "${{ steps.sbom.outputs.sbom-path }}" sbom.cdx.json - # One atomic command creates the tag, the release, and uploads # every asset together - no separate release object sits around @@ -180,7 +190,7 @@ jobs: name: Create the release with every asset attached env: GH_TOKEN: ${{ github.token }} - run: gh release create "${{ steps.version.outputs.tag_name }}" burrito_out/*.tar.gz burrito_out/SHA256SUMS "${{ steps.sbom.outputs.sbom-path }}" --generate-notes + run: gh release create "${{ steps.version.outputs.tag_name }}" burrito_out/*.tar.gz burrito_out/SHA256SUMS sbom.cdx.json --generate-notes working-directory: app - # release-please labels its own release PR "autorelease: pending" diff --git a/app/mix.exs b/app/mix.exs index 56c9f47..f1a900f 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -74,7 +74,7 @@ defmodule LinearCli.MixProject do {:sourceror, "~> 1.8", only: [:dev, :test]}, {:ash, "~> 3.0"}, {:igniter, "~> 0.6", only: [:dev, :test]}, - {:sbom, "~> 0.8", only: :dev, runtime: false} + {:sbom, "~> 0.10", only: :dev, runtime: false} # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} ]