From db836baf0ed9b402c77d1aabb94ffe718a85b85c Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 9 Dec 2025 15:10:47 -0500 Subject: [PATCH 1/2] feat: sbom generation ubuntu and nix packages --- .github/workflows/ami-release-nix.yml | 24 +- flake.lock | 260 ++++++++---- flake.nix | 1 + nix/checks.nix | 27 ++ nix/devShells.nix | 10 + nix/fmt.nix | 24 +- nix/packages/default.nix | 12 + nix/packages/sbom/cmd/sbom/main.go | 199 ++++++++++ nix/packages/sbom/default.nix | 43 ++ nix/packages/sbom/go.mod | 3 + nix/packages/sbom/internal/merge/merger.go | 320 +++++++++++++++ nix/packages/sbom/internal/nix/wrapper.go | 35 ++ nix/packages/sbom/internal/spdx/types.go | 57 +++ .../sbom/internal/ubuntu/generator.go | 370 ++++++++++++++++++ scripts/nix-provision.sh | 16 + stage2-nix-psql.pkr.hcl | 7 + 16 files changed, 1333 insertions(+), 75 deletions(-) create mode 100644 nix/packages/sbom/cmd/sbom/main.go create mode 100644 nix/packages/sbom/default.nix create mode 100644 nix/packages/sbom/go.mod create mode 100644 nix/packages/sbom/internal/merge/merger.go create mode 100644 nix/packages/sbom/internal/nix/wrapper.go create mode 100644 nix/packages/sbom/internal/spdx/types.go create mode 100644 nix/packages/sbom/internal/ubuntu/generator.go diff --git a/.github/workflows/ami-release-nix.yml b/.github/workflows/ami-release-nix.yml index 3654de90f..f6bf7ef4e 100644 --- a/.github/workflows/ami-release-nix.yml +++ b/.github/workflows/ami-release-nix.yml @@ -137,6 +137,17 @@ jobs: -e "postgres_major_version=${{ env.POSTGRES_MAJOR_VERSION }}" \ manifest-playbook.yml + - name: Upload SBOM to s3 staging + run: | + PG_VERSION=${{ steps.process_release_version.outputs.version }} + if [ -f "ubuntu-sbom-${PG_VERSION}.spdx.json" ]; then + aws s3 cp "ubuntu-sbom-${PG_VERSION}.spdx.json" \ + "s3://${{ secrets.ARTIFACTS_BUCKET }}/manifests/postgres-${PG_VERSION}/sbom.spdx.json" + echo "SBOM uploaded to staging" + else + echo "Warning: SBOM file not found, skipping upload" + fi + - name: Upload nix flake revision to s3 staging run: | aws s3 cp /tmp/pg_binaries.tar.gz s3://${{ secrets.ARTIFACTS_BUCKET }}/upgrades/postgres/supabase-postgres-${{ steps.process_release_version.outputs.version }}/20.04.tar.gz @@ -157,7 +168,18 @@ jobs: -e "internal_artifacts_bucket=${{ secrets.PROD_ARTIFACTS_BUCKET }}" \ -e "postgres_major_version=${{ env.POSTGRES_MAJOR_VERSION }}" \ manifest-playbook.yml - + + - name: Upload SBOM to s3 prod + run: | + PG_VERSION=${{ steps.process_release_version.outputs.version }} + if [ -f "ubuntu-sbom-${PG_VERSION}.spdx.json" ]; then + aws s3 cp "ubuntu-sbom-${PG_VERSION}.spdx.json" \ + "s3://${{ secrets.PROD_ARTIFACTS_BUCKET }}/manifests/postgres-${PG_VERSION}/sbom.spdx.json" + echo "SBOM uploaded to prod" + else + echo "Warning: SBOM file not found, skipping upload" + fi + - name: Upload nix flake revision to s3 prod run: | aws s3 cp /tmp/pg_binaries.tar.gz s3://${{ secrets.PROD_ARTIFACTS_BUCKET }}/upgrades/postgres/supabase-postgres-${{ steps.process_release_version.outputs.version }}/20.04.tar.gz diff --git a/flake.lock b/flake.lock index a28edf942..fdf5cc01e 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,22 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1746162366, + "narHash": "sha256-5SSSZ/oQkwfcAz/o/6TlejlVGqeK08wyREBQ5qFFPhM=", + "owner": "nix-community", + "repo": "flake-compat", + "rev": "0f158086a2ecdbb138cd0429410e44994f1b7e4b", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" @@ -55,6 +71,39 @@ "type": "github" } }, + "flake-parts_3": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_2" + }, + "locked": { + "lastModified": 1760948891, + "narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-root": { + "locked": { + "lastModified": 1723604017, + "narHash": "sha256-rBtQ8gg+Dn4Sx/s+pvjdq3CB2wQNzx9XGFq/JVGCB6k=", + "owner": "srid", + "repo": "flake-root", + "rev": "b759a56851e10cb13f6b8e5698af7b59c44be26e", + "type": "github" + }, + "original": { + "owner": "srid", + "repo": "flake-root", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -95,6 +144,32 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": [ + "sbomnix", + "flake-compat" + ], + "gitignore": "gitignore_2", + "nixpkgs": [ + "sbomnix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1760663237, + "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ @@ -116,6 +191,28 @@ "type": "github" } }, + "gitignore_2": { + "inputs": { + "nixpkgs": [ + "sbomnix", + "git-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nix": { "flake": false, "locked": { @@ -155,6 +252,27 @@ "type": "github" } }, + "nix-eval-jobs": { + "inputs": { + "flake-parts": "flake-parts_2", + "nix": "nix", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1760478325, + "narHash": "sha256-hA+NOH8KDcsuvH7vJqSwk74PyZP3MtvI/l+CggZcnTc=", + "owner": "nix-community", + "repo": "nix-eval-jobs", + "rev": "daa42f9e9c84aeff1e325dd50fda321f53dfd02c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-eval-jobs", + "type": "github" + } + }, "nix-fast-build": { "inputs": { "flake-parts": [ @@ -181,27 +299,6 @@ "type": "github" } }, - "nix-eval-jobs": { - "inputs": { - "flake-parts": "flake-parts_2", - "nix": "nix", - "nixpkgs": "nixpkgs_2", - "treefmt-nix": "treefmt-nix" - }, - "locked": { - "lastModified": 1760478325, - "narHash": "sha256-hA+NOH8KDcsuvH7vJqSwk74PyZP3MtvI/l+CggZcnTc=", - "owner": "nix-community", - "repo": "nix-eval-jobs", - "rev": "daa42f9e9c84aeff1e325dd50fda321f53dfd02c", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-eval-jobs", - "type": "github" - } - }, "nix2container": { "inputs": { "flake-utils": [ @@ -227,18 +324,15 @@ }, "nixpkgs": { "locked": { - "lastModified": 1712666087, - "narHash": "sha256-WwjUkWsjlU8iUImbivlYxNyMB1L5YVqE8QotQdL9jWc=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "a76c4553d7e741e17f289224eda135423de0491d", - "type": "github" + "lastModified": 315532800, + "narHash": "sha256-vhAtaRMIQiEghARviANBmSnhGz9Qf2IQJ+nQgsDXnVs=", + "rev": "c12c63cd6c5eb34c7b4c3076c6a99e00fcab86ec", + "type": "tarball", + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877036.c12c63cd6c5e/nixexprs.tar.xz" }, "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" + "type": "tarball", + "url": "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz" } }, "nixpkgs-go124": { @@ -272,6 +366,21 @@ "type": "github" } }, + "nixpkgs-lib_2": { + "locked": { + "lastModified": 1754788789, + "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, "nixpkgs-pgbackrest": { "locked": { "lastModified": 1761373498, @@ -289,34 +398,6 @@ } }, "nixpkgs_2": { - "locked": { - "lastModified": 315532800, - "narHash": "sha256-vhAtaRMIQiEghARviANBmSnhGz9Qf2IQJ+nQgsDXnVs=", - "rev": "c12c63cd6c5eb34c7b4c3076c6a99e00fcab86ec", - "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877036.c12c63cd6c5e/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz" - } - }, - "nixpkgs_3": { - "locked": { - "lastModified": 1697269602, - "narHash": "sha256-dSzV7Ud+JH4DPVD9od53EgDrxUVQOcSj4KGjggCDVJI=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9cb540e9c1910d74a7e10736277f6eb9dff51c81", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_4": { "locked": { "lastModified": 1712666087, "narHash": "sha256-WwjUkWsjlU8iUImbivlYxNyMB1L5YVqE8QotQdL9jWc=", @@ -332,18 +413,18 @@ "type": "github" } }, - "nixpkgs_5": { + "nixpkgs_3": { "locked": { - "lastModified": 1744536153, - "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "lastModified": 1761114652, + "narHash": "sha256-f/QCJM/YhrV/lavyCVz8iU3rlZun6d+dAiC3H+CDle4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "rev": "01f116e4df6a15f4ccdffb1bcd41096869fb385c", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixpkgs-unstable", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } @@ -354,14 +435,15 @@ "flake-utils": "flake-utils", "git-hooks": "git-hooks", "nix-editor": "nix-editor", + "nix-eval-jobs": "nix-eval-jobs", "nix-fast-build": "nix-fast-build", "nix2container": "nix2container", - "nixpkgs": "nixpkgs", - "nix-eval-jobs": "nix-eval-jobs", + "nixpkgs": "nixpkgs_2", "nixpkgs-go124": "nixpkgs-go124", "nixpkgs-pgbackrest": "nixpkgs-pgbackrest", "rust-overlay": "rust-overlay", - "treefmt-nix": "treefmt-nix_2" + "sbomnix": "sbomnix", + "treefmt-nix": "treefmt-nix_3" } }, "rust-overlay": { @@ -384,6 +466,29 @@ "type": "github" } }, + "sbomnix": { + "inputs": { + "flake-compat": "flake-compat_2", + "flake-parts": "flake-parts_3", + "flake-root": "flake-root", + "git-hooks-nix": "git-hooks-nix", + "nixpkgs": "nixpkgs_3", + "treefmt-nix": "treefmt-nix_2" + }, + "locked": { + "lastModified": 1762923401, + "narHash": "sha256-FMtgP0ejCsfRSNxjKRaog6+SzeahpkR+Hd8DlR56NyY=", + "owner": "tiiuae", + "repo": "sbomnix", + "rev": "0abce31bdf01223d66b43c77b97c33547120bc68", + "type": "github" + }, + "original": { + "owner": "tiiuae", + "repo": "sbomnix", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -421,6 +526,27 @@ } }, "treefmt-nix_2": { + "inputs": { + "nixpkgs": [ + "sbomnix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1760945191, + "narHash": "sha256-ZRVs8UqikBa4Ki3X4KCnMBtBW0ux1DaT35tgsnB1jM4=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "f56b1934f5f8fcab8deb5d38d42fd692632b47c2", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + }, + "treefmt-nix_3": { "inputs": { "nixpkgs": [ "nixpkgs" diff --git a/flake.nix b/flake.nix index efd0b7e9e..d645ab1a1 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,7 @@ nixpkgs-go124.url = "github:Nixos/nixpkgs/d2ac4dfa61fba987a84a0a81555da57ae0b9a2b0"; nixpkgs-pgbackrest.url = "github:nixos/nixpkgs/nixos-unstable-small"; nix-eval-jobs.url = "github:nix-community/nix-eval-jobs"; + sbomnix.url = "github:tiiuae/sbomnix"; }; outputs = diff --git a/nix/checks.nix b/nix/checks.nix index 81e0b4117..9efc95289 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -395,6 +395,33 @@ packer pg_regress ; + + # SBOM tool checks + sbom-builds = pkgs.runCommand "check-sbom-builds" { } '' + # Check the binary exists and shows help + ${self'.packages.sbom}/bin/sbom help > /dev/null + echo "SUCCESS: sbom binary builds and runs" + touch $out + ''; + + sbomnix-available = + let + sbomnixPkg = self'.packages.sbomnix; + in + pkgs.runCommand "check-sbomnix-available" { } '' + # Check sbomnix is executable and functional + export PATH="${sbomnixPkg}/bin:$PATH" + if ! command -v sbomnix &> /dev/null; then + echo "ERROR: sbomnix not found in PATH" + exit 1 + fi + if ! sbomnix --help &> /dev/null; then + echo "ERROR: sbomnix --help failed" + exit 1 + fi + echo "SUCCESS: sbomnix is available and functional" + touch $out + ''; } // pkgs.lib.optionalAttrs (system == "aarch64-linux") ( { diff --git a/nix/devShells.nix b/nix/devShells.nix index 03768a770..b0a64a75a 100644 --- a/nix/devShells.nix +++ b/nix/devShells.nix @@ -59,6 +59,16 @@ nushell pythonEnv config.treefmt.build.wrapper + + # Go development + go + gopls + gotools + + # SBOM tools + self'.packages.sbom + self'.packages.sbomnix + python3Packages.spdx-tools ] ++ self'.packages.docs.nativeBuildInputs; shellHook = '' diff --git a/nix/fmt.nix b/nix/fmt.nix index 08763e5b8..e94401f08 100644 --- a/nix/fmt.nix +++ b/nix/fmt.nix @@ -4,14 +4,24 @@ perSystem = { pkgs, ... }: { - treefmt.flakeCheck = false; - treefmt.programs = { - deadnix.enable = true; - nixfmt = { - enable = true; - package = pkgs.nixfmt-rfc-style; + treefmt = { + flakeCheck = false; + programs = { + deadnix.enable = true; + nixfmt = { + enable = true; + package = pkgs.nixfmt-rfc-style; + }; + ruff-format.enable = true; + gofmt.enable = true; + }; + + settings = { + global.excludes = [ + "*.sum" + "vendor/*" + ]; }; - ruff-format.enable = true; }; }; } diff --git a/nix/packages/default.nix b/nix/packages/default.nix index b3fc83a0f..f14fcd65d 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -7,9 +7,12 @@ lib, pkgs, self', + system, ... }: let + sbomnix = inputs.sbomnix.packages.${system}.default; + sbomPkgs = pkgs.callPackage ./sbom { inherit sbomnix; }; activeVersion = "15"; # Function to create the pg_regress package makePgRegress = @@ -29,6 +32,15 @@ { packages = ( { + # SBOM tools + inherit (sbomPkgs) + sbom + sbom-ubuntu + sbom-nix + sbom-generator + sbomnix + ; + build-test-ami = pkgs.callPackage ./build-test-ami.nix { }; cleanup-ami = pkgs.callPackage ./cleanup-ami.nix { }; dbmate-tool = pkgs.callPackage ./dbmate-tool.nix { inherit (self.supabase) defaults; }; diff --git a/nix/packages/sbom/cmd/sbom/main.go b/nix/packages/sbom/cmd/sbom/main.go new file mode 100644 index 000000000..032446c20 --- /dev/null +++ b/nix/packages/sbom/cmd/sbom/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/supabase/postgres/nix/packages/sbom/internal/merge" + "github.com/supabase/postgres/nix/packages/sbom/internal/nix" + "github.com/supabase/postgres/nix/packages/sbom/internal/ubuntu" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + subcommand := os.Args[1] + + switch subcommand { + case "ubuntu": + ubuntuCommand(os.Args[2:]) + case "nix": + nixCommand(os.Args[2:]) + case "combined": + combinedCommand(os.Args[2:]) + case "help", "--help", "-h": + printUsage() + default: + fmt.Printf("Unknown subcommand: %s\n\n", subcommand) + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Println("sbom - SPDX SBOM generator for Ubuntu and Nix systems") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" sbom [flags]") + fmt.Println() + fmt.Println("Subcommands:") + fmt.Println(" ubuntu Generate Ubuntu-only SBOM") + fmt.Println(" nix Generate Nix-only SBOM") + fmt.Println(" combined Generate and merge both Ubuntu and Nix SBOMs") + fmt.Println(" help Show this help message") + fmt.Println() + fmt.Println("Run 'sbom --help' for subcommand-specific help") +} + +func ubuntuCommand(args []string) { + fs := flag.NewFlagSet("ubuntu", flag.ExitOnError) + outputFile := fs.String("output", "ubuntu-sbom.spdx.json", "Output file path") + includeFiles := fs.Bool("include-files", false, "Include file checksums for each package") + progress := fs.Bool("progress", true, "Show progress indicators") + noProgress := fs.Bool("no-progress", false, "Disable progress indicators") + + fs.Usage = func() { + fmt.Println("Usage: sbom ubuntu [flags]") + fmt.Println() + fmt.Println("Generate Ubuntu-only SBOM") + fmt.Println() + fmt.Println("Flags:") + fs.PrintDefaults() + } + + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + showProgress := *progress && !*noProgress + + generator := ubuntu.NewGenerator(*includeFiles, showProgress) + + doc, err := generator.Generate() + if err != nil { + log.Fatalf("Failed to generate SBOM: %v", err) + } + + if err := generator.Save(doc, *outputFile); err != nil { + log.Fatalf("Failed to save SBOM: %v", err) + } + + fmt.Printf("Ubuntu SBOM generated successfully: %s\n", *outputFile) +} + +func nixCommand(args []string) { + fs := flag.NewFlagSet("nix", flag.ExitOnError) + outputFile := fs.String("output", "nix-sbom.spdx.json", "Output file path") + + fs.Usage = func() { + fmt.Println("Usage: sbom nix [flags]") + fmt.Println() + fmt.Println("Generate Nix-only SBOM using sbomnix") + fmt.Println() + fmt.Println("Arguments:") + fmt.Println(" derivation-path Path to the Nix derivation (required)") + fmt.Println() + fmt.Println("Flags:") + fs.PrintDefaults() + } + + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + if fs.NArg() < 1 { + fmt.Println("Error: derivation path required") + fmt.Println() + fs.Usage() + os.Exit(1) + } + + derivationPath := fs.Arg(0) + + // Use sbomnix from PATH + wrapper := nix.NewWrapper("sbomnix") + + if err := wrapper.Generate(derivationPath, *outputFile); err != nil { + log.Fatalf("Failed to generate Nix SBOM: %v", err) + } + + fmt.Printf("Nix SBOM generated successfully: %s\n", *outputFile) +} + +func combinedCommand(args []string) { + fs := flag.NewFlagSet("combined", flag.ExitOnError) + nixTarget := fs.String("nix-target", "", "Path to Nix derivation (required)") + outputFile := fs.String("output", "merged-sbom.spdx.json", "Output file path") + includeFiles := fs.Bool("include-files", false, "Include file checksums for Ubuntu packages") + progress := fs.Bool("progress", true, "Show progress indicators") + noProgress := fs.Bool("no-progress", false, "Disable progress indicators") + + fs.Usage = func() { + fmt.Println("Usage: sbom combined --nix-target [flags]") + fmt.Println() + fmt.Println("Generate and merge both Ubuntu and Nix SBOMs") + fmt.Println() + fmt.Println("Flags:") + fs.PrintDefaults() + } + + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + if *nixTarget == "" { + fmt.Println("Error: --nix-target is required") + fmt.Println() + fs.Usage() + os.Exit(1) + } + + showProgress := *progress && !*noProgress + + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "sbom-combined-*") + if err != nil { + log.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + ubuntuSBOM := fmt.Sprintf("%s/ubuntu-sbom.spdx.json", tmpDir) + nixSBOM := fmt.Sprintf("%s/nix-sbom.spdx.json", tmpDir) + + // Generate Ubuntu SBOM + fmt.Println("Generating Ubuntu SBOM...") + ubuntuGen := ubuntu.NewGenerator(*includeFiles, showProgress) + ubuntuDoc, err := ubuntuGen.Generate() + if err != nil { + log.Fatalf("Failed to generate Ubuntu SBOM: %v", err) + } + if err := ubuntuGen.Save(ubuntuDoc, ubuntuSBOM); err != nil { + log.Fatalf("Failed to save Ubuntu SBOM: %v", err) + } + + // Generate Nix SBOM + fmt.Println("Generating Nix SBOM...") + nixWrapper := nix.NewWrapper("sbomnix") + if err := nixWrapper.Generate(*nixTarget, nixSBOM); err != nil { + log.Fatalf("Failed to generate Nix SBOM: %v", err) + } + + // Merge SBOMs + fmt.Println("Merging SBOMs...") + merger := merge.NewMerger() + mergedDoc, err := merger.Merge(ubuntuSBOM, nixSBOM) + if err != nil { + log.Fatalf("Failed to merge SBOMs: %v", err) + } + + if err := merger.Save(mergedDoc, *outputFile); err != nil { + log.Fatalf("Failed to save merged SBOM: %v", err) + } + + fmt.Printf("Merged SBOM generated successfully: %s\n", *outputFile) +} diff --git a/nix/packages/sbom/default.nix b/nix/packages/sbom/default.nix new file mode 100644 index 000000000..f19575777 --- /dev/null +++ b/nix/packages/sbom/default.nix @@ -0,0 +1,43 @@ +{ pkgs, sbomnix }: +let + sbom = pkgs.buildGoModule { + pname = "sbom"; + version = "1.0.0"; + src = ./.; + vendorHash = null; + + subPackages = [ "cmd/sbom" ]; + + meta = with pkgs.lib; { + description = "SPDX SBOM generator with Ubuntu and Nix support"; + license = licenses.asl20; + mainProgram = "sbom"; + }; + }; + + # Wrapper script for Ubuntu-only SBOM + sbom-ubuntu = pkgs.writeShellScriptBin "sbom-ubuntu" '' + ${sbom}/bin/sbom ubuntu "$@" + ''; + + # Wrapper script for Nix-only SBOM + sbom-nix = pkgs.writeShellScriptBin "sbom-nix" '' + export PATH="${sbomnix}/bin:$PATH" + ${sbom}/bin/sbom nix "$@" + ''; + + # Wrapper script for merged SBOM (main entry point) + sbom-generator = pkgs.writeShellScriptBin "sbom-generator" '' + export PATH="${sbomnix}/bin:$PATH" + ${sbom}/bin/sbom combined "$@" + ''; +in +{ + inherit + sbom + sbom-ubuntu + sbom-nix + sbom-generator + sbomnix + ; +} diff --git a/nix/packages/sbom/go.mod b/nix/packages/sbom/go.mod new file mode 100644 index 000000000..d83dd62a1 --- /dev/null +++ b/nix/packages/sbom/go.mod @@ -0,0 +1,3 @@ +module github.com/supabase/postgres/nix/packages/sbom + +go 1.21 diff --git a/nix/packages/sbom/internal/merge/merger.go b/nix/packages/sbom/internal/merge/merger.go new file mode 100644 index 000000000..6e7765f51 --- /dev/null +++ b/nix/packages/sbom/internal/merge/merger.go @@ -0,0 +1,320 @@ +package merge + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/supabase/postgres/nix/packages/sbom/internal/spdx" +) + +type Merger struct{} + +func NewMerger() *Merger { + return &Merger{} +} + +func (m *Merger) Merge(ubuntuPath, nixPath string) (*spdx.Document, error) { + // Load Ubuntu SBOM + ubuntuDoc, err := m.loadDocument(ubuntuPath) + if err != nil { + return nil, fmt.Errorf("failed to load Ubuntu SBOM: %w", err) + } + + // Load Nix SBOM + nixDoc, err := m.loadDocument(nixPath) + if err != nil { + return nil, fmt.Errorf("failed to load Nix SBOM: %w", err) + } + + // Create merged document + mergedDoc := &spdx.Document{ + SPDXVersion: "SPDX-2.3", + DataLicense: "CC0-1.0", + SPDXID: "SPDXRef-DOCUMENT", + Name: fmt.Sprintf("Ubuntu-Nix-System-SBOM-%s", time.Now().Format("2006-01-02")), + DocumentNamespace: fmt.Sprintf("https://sbom.ubuntu-nix.system/%s", generateUUID()), + CreationInfo: spdx.CreationInfo{ + Created: time.Now().UTC().Format(time.RFC3339), + Creators: m.mergeCreators(ubuntuDoc, nixDoc), + LicenseListVersion: "3.20", + }, + Packages: []spdx.Package{}, + Relationships: []spdx.Relationship{}, + } + + // Create the single root System package + systemPkg := spdx.Package{ + SPDXID: "SPDXRef-System", + Name: "Ubuntu-Nix-System", + DownloadLocation: "NOASSERTION", + FilesAnalyzed: false, + LicenseConcluded: "NOASSERTION", + LicenseDeclared: "NOASSERTION", + CopyrightText: "NOASSERTION", + Description: "Combined Ubuntu and Nix package system", + } + mergedDoc.Packages = append(mergedDoc.Packages, systemPkg) + + // Add document describes relationship + mergedDoc.Relationships = append(mergedDoc.Relationships, spdx.Relationship{ + SPDXElementID: "SPDXRef-DOCUMENT", + RelatedSPDXElement: "SPDXRef-System", + RelationshipType: "DESCRIBES", + }) + + // Process Ubuntu packages (skip the root package) + ubuntuCount := 0 + for _, pkg := range ubuntuDoc.Packages { + if pkg.SPDXID == "SPDXRef-Ubuntu-System" || pkg.SPDXID == "SPDXRef-System" { + continue // Skip root packages + } + + // Ensure SPDXID has Ubuntu prefix + if !strings.HasPrefix(pkg.SPDXID, "SPDXRef-Ubuntu-") { + pkg.SPDXID = m.renumberSPDXID(pkg.SPDXID, "Ubuntu") + } + + mergedDoc.Packages = append(mergedDoc.Packages, pkg) + + // Add relationship to system root + mergedDoc.Relationships = append(mergedDoc.Relationships, spdx.Relationship{ + SPDXElementID: "SPDXRef-System", + RelatedSPDXElement: pkg.SPDXID, + RelationshipType: "CONTAINS", + }) + ubuntuCount++ + } + + // Process Nix packages (skip any root packages) + nixCount := 0 + for _, pkg := range nixDoc.Packages { + // Skip root/system packages + if strings.Contains(strings.ToLower(pkg.Name), "system") && + (pkg.SPDXID == "SPDXRef-DOCUMENT" || strings.HasSuffix(pkg.SPDXID, "-System")) { + continue + } + + // Ensure SPDXID has Nix prefix to avoid conflicts + if !strings.HasPrefix(pkg.SPDXID, "SPDXRef-Nix-") { + pkg.SPDXID = m.renumberSPDXID(pkg.SPDXID, "Nix") + } + + // Clean up invalid CPE references from sbomnix + pkg.ExternalRefs = m.cleanExternalRefs(pkg.ExternalRefs) + + mergedDoc.Packages = append(mergedDoc.Packages, pkg) + + // Add relationship to system root + mergedDoc.Relationships = append(mergedDoc.Relationships, spdx.Relationship{ + SPDXElementID: "SPDXRef-System", + RelatedSPDXElement: pkg.SPDXID, + RelationshipType: "CONTAINS", + }) + nixCount++ + } + + fmt.Printf("Merged %d Ubuntu packages and %d Nix packages\n", ubuntuCount, nixCount) + + return mergedDoc, nil +} + +func (m *Merger) loadDocument(path string) (*spdx.Document, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var doc spdx.Document + if err := json.Unmarshal(data, &doc); err != nil { + return nil, err + } + + return &doc, nil +} + +func (m *Merger) mergeCreators(ubuntuDoc, nixDoc *spdx.Document) []string { + creatorMap := make(map[string]bool) + var creators []string + + // Add creators from both documents + for _, creator := range ubuntuDoc.CreationInfo.Creators { + if !creatorMap[creator] { + creators = append(creators, creator) + creatorMap[creator] = true + } + } + + for _, creator := range nixDoc.CreationInfo.Creators { + if !creatorMap[creator] { + creators = append(creators, creator) + creatorMap[creator] = true + } + } + + // Add merger tool + mergerTool := "Tool: ubuntu-nix-sbom-merger-1.0" + if !creatorMap[mergerTool] { + creators = append(creators, mergerTool) + } + + return creators +} + +func (m *Merger) renumberSPDXID(originalID, prefix string) string { + // Extract the base name from the SPDXID + re := regexp.MustCompile(`SPDXRef-(.+)`) + matches := re.FindStringSubmatch(originalID) + + if len(matches) > 1 { + baseName := matches[1] + return fmt.Sprintf("SPDXRef-%s-%s", prefix, baseName) + } + + // Fallback: just add prefix + return fmt.Sprintf("SPDXRef-%s-%s", prefix, strings.TrimPrefix(originalID, "SPDXRef-")) +} + +func (m *Merger) Save(doc *spdx.Document, outputPath string) error { + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + return encoder.Encode(doc) +} + +func (m *Merger) cleanExternalRefs(refs []spdx.ExternalRef) []spdx.ExternalRef { + // CPE 2.3 regex pattern - validates proper CPE format + // Format: cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other + cpePattern := regexp.MustCompile(`^cpe:2\.3:[aho\*\-](:(((\?*|\*?)([a-zA-Z0-9\-\._]|(\\[\\\*\?!"#$%&'\(\)\+,\/:;<=>@\[\]\^` + "`" + `\{\|}~]))+(\?*|\*?))|[\*\-])){5}(:(([a-zA-Z]{2,3}(-([a-zA-Z]{2}|[0-9]{3}))?)|[\*\-]))(:(((\?*|\*?)([a-zA-Z0-9\-\._]|(\\[\\\*\?!"#$%&'\(\)\+,\/:;<=>@\[\]\^` + "`" + `\{\|}~]))+(\?*|\*?))|[\*\-])){4}$`) + + cleaned := []spdx.ExternalRef{} + for _, ref := range refs { + // If it's a CPE reference, validate and fix it if needed + if ref.Type == "cpe23Type" { + if cpePattern.MatchString(ref.Locator) { + // Valid CPE, keep it as-is + cleaned = append(cleaned, ref) + } else { + // Invalid CPE, try to fix it + fixedCPE := m.fixCPEFormat(ref.Locator) + ref.Locator = fixedCPE + cleaned = append(cleaned, ref) + } + } else { + // Not a CPE reference, keep it + cleaned = append(cleaned, ref) + } + } + return cleaned +} + +func (m *Merger) fixCPEFormat(cpe string) string { + // Parse malformed CPE from sbomnix and fix it + // Common issue: cpe:2.3:a:product:product::*:*:*:*:*:*:* + // Should be: cpe:2.3:a:vendor:product:version:*:*:*:*:*:*:* + // CPE 2.3 has 13 components total (including the cpe:2.3 prefix) + + if !strings.HasPrefix(cpe, "cpe:2.3:") { + return cpe // Not a CPE, return as-is + } + + parts := strings.Split(cpe, ":") + if len(parts) < 4 { + return cpe // Too short, can't fix + } + + // Extract the part (a=application, h=hardware, o=os) + part := parts[2] + + // Extract what looks like product name (usually parts[3] or parts[4]) + productName := parts[3] + + // Check if vendor field is missing or same as product (common sbomnix issue) + // Example: cpe:2.3:a:pg_cron:pg_cron::*:*:*:*:*:*:* + // The pattern is: part:product:product:empty_version:... + // We want: part:vendor:product:version:... + + vendor := "*" // Default to wildcard if we can't determine vendor + product := productName + version := "*" + + // If there are 4+ parts after cpe:2.3, check the structure + if len(parts) >= 5 { + // Check if parts[3] and parts[4] are the same (common in sbomnix output) + if parts[3] == parts[4] { + // Likely format: cpe:2.3:a:product:product::... + // Use the product name for both vendor and product + vendor = parts[3] + product = parts[3] + // Check if there's a version in parts[5] + if len(parts) >= 6 && parts[5] != "" && parts[5] != "*" { + version = parts[5] + } + } else { + // Different vendor and product, keep them + vendor = parts[3] + product = parts[4] + if len(parts) >= 6 && parts[5] != "" && parts[5] != "*" { + version = parts[5] + } + } + } + + // Sanitize vendor/product names - remove invalid characters + vendor = sanitizeCPEComponent(vendor) + product = sanitizeCPEComponent(product) + version = sanitizeCPEComponent(version) + + // Build a valid CPE 2.3 string with 13 components + // Format: cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other + fixedCPE := fmt.Sprintf("cpe:2.3:%s:%s:%s:%s:*:*:*:*:*:*:*", + part, vendor, product, version) + + return fixedCPE +} + +func sanitizeCPEComponent(component string) string { + // Remove or replace characters that aren't allowed in CPE components + // Allowed: alphanumeric, dash, underscore, period + // Replace underscores with dashes (more standard) + component = strings.ReplaceAll(component, "_", "-") + + // If empty or just wildcards, return wildcard + if component == "" || component == "*" { + return "*" + } + + // Keep only valid CPE characters + re := regexp.MustCompile(`[^a-zA-Z0-9\-\.\*]`) + component = re.ReplaceAllString(component, "-") + + // Remove leading/trailing dashes + component = strings.Trim(component, "-") + + // If we ended up with nothing, return wildcard + if component == "" { + return "*" + } + + return component +} + +func generateUUID() string { + // Simple UUID v4 generation + b := make([]byte, 16) + for i := range b { + b[i] = byte(time.Now().UnixNano() & 0xff) + } + + return fmt.Sprintf("%x-%x-%x-%x-%x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} diff --git a/nix/packages/sbom/internal/nix/wrapper.go b/nix/packages/sbom/internal/nix/wrapper.go new file mode 100644 index 000000000..8f866a8a0 --- /dev/null +++ b/nix/packages/sbom/internal/nix/wrapper.go @@ -0,0 +1,35 @@ +package nix + +import ( + "fmt" + "os" + "os/exec" +) + +type Wrapper struct { + SbomnixPath string +} + +func NewWrapper(sbomnixPath string) *Wrapper { + return &Wrapper{ + SbomnixPath: sbomnixPath, + } +} + +func (w *Wrapper) Generate(derivationPath, outputPath string) error { + // Validate derivation path exists + if _, err := os.Stat(derivationPath); err != nil { + return fmt.Errorf("derivation path does not exist: %s", derivationPath) + } + + // Call sbomnix + cmd := exec.Command(w.SbomnixPath, derivationPath, fmt.Sprintf("--spdx=%s", outputPath)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("sbomnix failed: %w", err) + } + + return nil +} diff --git a/nix/packages/sbom/internal/spdx/types.go b/nix/packages/sbom/internal/spdx/types.go new file mode 100644 index 000000000..51348b104 --- /dev/null +++ b/nix/packages/sbom/internal/spdx/types.go @@ -0,0 +1,57 @@ +package spdx + +// Document represents an SPDX document structure +type Document struct { + SPDXVersion string `json:"spdxVersion"` + DataLicense string `json:"dataLicense"` + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + DocumentNamespace string `json:"documentNamespace"` + CreationInfo CreationInfo `json:"creationInfo"` + Packages []Package `json:"packages"` + Relationships []Relationship `json:"relationships"` +} + +type CreationInfo struct { + Created string `json:"created"` + Creators []string `json:"creators"` + LicenseListVersion string `json:"licenseListVersion"` +} + +type Package struct { + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + DownloadLocation string `json:"downloadLocation"` + FilesAnalyzed bool `json:"filesAnalyzed"` + VerificationCode *Verification `json:"verificationCode,omitempty"` + Checksums []Checksum `json:"checksums,omitempty"` + HomePage string `json:"homePage,omitempty"` + LicenseConcluded string `json:"licenseConcluded"` + LicenseDeclared string `json:"licenseDeclared"` + CopyrightText string `json:"copyrightText"` + Description string `json:"description,omitempty"` + PackageVersion string `json:"versionInfo,omitempty"` + Supplier string `json:"supplier,omitempty"` + ExternalRefs []ExternalRef `json:"externalRefs,omitempty"` +} + +type Verification struct { + Value string `json:"packageVerificationCodeValue"` +} + +type Checksum struct { + Algorithm string `json:"algorithm"` + Value string `json:"checksumValue"` +} + +type Relationship struct { + SPDXElementID string `json:"spdxElementId"` + RelatedSPDXElement string `json:"relatedSpdxElement"` + RelationshipType string `json:"relationshipType"` +} + +type ExternalRef struct { + Category string `json:"referenceCategory"` + Type string `json:"referenceType"` + Locator string `json:"referenceLocator"` +} diff --git a/nix/packages/sbom/internal/ubuntu/generator.go b/nix/packages/sbom/internal/ubuntu/generator.go new file mode 100644 index 000000000..e3385dfb1 --- /dev/null +++ b/nix/packages/sbom/internal/ubuntu/generator.go @@ -0,0 +1,370 @@ +package ubuntu + +import ( + "bufio" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/supabase/postgres/nix/packages/sbom/internal/spdx" +) + +type DpkgPackage struct { + Name string + Version string + Architecture string + Status string + Maintainer string + Homepage string + Description string + License string + Copyright string +} + +type Generator struct { + IncludeFiles bool + ShowProgress bool +} + +func NewGenerator(includeFiles, showProgress bool) *Generator { + return &Generator{ + IncludeFiles: includeFiles, + ShowProgress: showProgress, + } +} + +func (g *Generator) Generate() (*spdx.Document, error) { + packages, err := g.getInstalledPackages() + if err != nil { + return nil, fmt.Errorf("failed to get packages: %w", err) + } + + doc := &spdx.Document{ + SPDXVersion: "SPDX-2.3", + DataLicense: "CC0-1.0", + SPDXID: "SPDXRef-DOCUMENT", + Name: fmt.Sprintf("Ubuntu-System-SBOM-%s", time.Now().Format("2006-01-02")), + DocumentNamespace: fmt.Sprintf("https://sbom.ubuntu.system/%s", generateUUID()), + CreationInfo: spdx.CreationInfo{ + Created: time.Now().UTC().Format(time.RFC3339), + Creators: []string{"Tool: ubuntu-sbom-generator-1.0"}, + LicenseListVersion: "3.20", + }, + Packages: []spdx.Package{}, + Relationships: []spdx.Relationship{}, + } + + // Add root package representing the Ubuntu system + rootPkg := spdx.Package{ + SPDXID: "SPDXRef-Ubuntu-System", + Name: "Ubuntu-System", + DownloadLocation: "NOASSERTION", + FilesAnalyzed: false, + LicenseConcluded: "NOASSERTION", + LicenseDeclared: "NOASSERTION", + CopyrightText: "NOASSERTION", + } + doc.Packages = append(doc.Packages, rootPkg) + + // Process each package + for i, pkg := range packages { + if g.ShowProgress && i%100 == 0 { + fmt.Printf("Processing package %d/%d...\n", i+1, len(packages)) + } + + spdxPkg := g.packageToSPDX(pkg, i+1) + doc.Packages = append(doc.Packages, spdxPkg) + + // Add relationship + doc.Relationships = append(doc.Relationships, spdx.Relationship{ + SPDXElementID: "SPDXRef-Ubuntu-System", + RelatedSPDXElement: spdxPkg.SPDXID, + RelationshipType: "CONTAINS", + }) + } + + // Add document describes relationship + doc.Relationships = append(doc.Relationships, spdx.Relationship{ + SPDXElementID: "SPDXRef-DOCUMENT", + RelatedSPDXElement: "SPDXRef-Ubuntu-System", + RelationshipType: "DESCRIBES", + }) + + return doc, nil +} + +func (g *Generator) getInstalledPackages() ([]DpkgPackage, error) { + cmd := exec.Command("dpkg-query", "-W", "-f=${Package}\t${Version}\t${Architecture}\t${Status}\t${Maintainer}\t${Homepage}\t${Description}\n") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var packages []DpkgPackage + scanner := bufio.NewScanner(strings.NewReader(string(output))) + + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, "\t") + + if len(parts) >= 7 && strings.Contains(parts[3], "installed") { + pkg := DpkgPackage{ + Name: parts[0], + Version: parts[1], + Architecture: parts[2], + Status: parts[3], + Maintainer: parts[4], + Homepage: parts[5], + Description: parts[6], + } + + // Try to get license information + pkg.License, pkg.Copyright = g.getPackageLicense(pkg.Name) + + packages = append(packages, pkg) + } + } + + fmt.Printf("Found %d installed packages\n", len(packages)) + return packages, nil +} + +func (g *Generator) getPackageLicense(packageName string) (string, string) { + copyrightPath := fmt.Sprintf("/usr/share/doc/%s/copyright", packageName) + + content, err := os.ReadFile(copyrightPath) + if err != nil { + return "NOASSERTION", "NOASSERTION" + } + + text := string(content) + + // Extract license + license := "NOASSERTION" + licenseRe := regexp.MustCompile(`(?i)License:\s*(.+?)(?:\n\n|\n[A-Z]|\z)`) + if matches := licenseRe.FindStringSubmatch(text); len(matches) > 1 { + license = normalizeLicense(strings.TrimSpace(matches[1])) + } + + // Get first 200 chars of copyright or NOASSERTION + copyright := "NOASSERTION" + if len(text) > 0 { + if len(text) > 200 { + copyright = text[:200] + "..." + } else { + copyright = text + } + } + + return license, copyright +} + +func (g *Generator) packageToSPDX(pkg DpkgPackage, id int) spdx.Package { + spdxPkg := spdx.Package{ + SPDXID: fmt.Sprintf("SPDXRef-Ubuntu-Package-%d-%s", id, sanitizeName(pkg.Name)), + Name: pkg.Name, + PackageVersion: pkg.Version, + DownloadLocation: "NOASSERTION", + FilesAnalyzed: false, + LicenseConcluded: pkg.License, + LicenseDeclared: pkg.License, + CopyrightText: pkg.Copyright, + Description: pkg.Description, + } + + if pkg.Homepage != "" && pkg.Homepage != "(none)" { + spdxPkg.HomePage = pkg.Homepage + } + + if pkg.Maintainer != "" && pkg.Maintainer != "(none)" { + spdxPkg.Supplier = fmt.Sprintf("Organization: %s", pkg.Maintainer) + } + + // Add external reference for the package + spdxPkg.ExternalRefs = []spdx.ExternalRef{ + { + Category: "PACKAGE-MANAGER", + Type: "purl", + Locator: fmt.Sprintf("pkg:deb/ubuntu/%s@%s?arch=%s", pkg.Name, pkg.Version, pkg.Architecture), + }, + } + + // If include-files is set, calculate package verification + if g.IncludeFiles { + if checksum := g.calculatePackageChecksum(pkg.Name); checksum != "" { + spdxPkg.Checksums = []spdx.Checksum{ + { + Algorithm: "SHA256", + Value: checksum, + }, + } + } + } + + return spdxPkg +} + +func (g *Generator) calculatePackageChecksum(packageName string) string { + cmd := exec.Command("dpkg", "-L", packageName) + output, err := cmd.Output() + if err != nil { + return "" + } + + h := sha256.New() + scanner := bufio.NewScanner(strings.NewReader(string(output))) + + for scanner.Scan() { + filePath := scanner.Text() + if filePath == "" || strings.HasSuffix(filePath, "/") { + continue + } + + if fileHash := hashFile(filePath); fileHash != "" { + h.Write([]byte(fileHash)) + } + } + + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func hashFile(path string) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + return "" + } + + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func (g *Generator) Save(doc *spdx.Document, outputPath string) error { + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + return encoder.Encode(doc) +} + +func normalizeLicense(license string) string { + // Map common license strings to SPDX identifiers + license = strings.TrimSpace(license) + + // If empty, return NOASSERTION + if license == "" { + return "NOASSERTION" + } + + // Normalize to lowercase for case-insensitive matching + licenseLower := strings.ToLower(license) + + // Check for known SPDX patterns (case-insensitive) + replacements := map[string]string{ + "gpl-2": "GPL-2.0-only", + "gpl-2+": "GPL-2.0-or-later", + "gpl-3": "GPL-3.0-only", + "gpl-3+": "GPL-3.0-or-later", + "lgpl-2": "LGPL-2.0-only", + "lgpl-2+": "LGPL-2.0-or-later", + "lgpl-2.1": "LGPL-2.1-only", + "lgpl-2.1+": "LGPL-2.1-or-later", + "lgpl-3": "LGPL-3.0-only", + "lgpl-3+": "LGPL-3.0-or-later", + "apache-2": "Apache-2.0", + "apache": "NOASSERTION", + "bsd": "BSD-3-Clause", + "mit/x11": "MIT", + "expat": "MIT", + "mit-1": "MIT", + "mit-style": "MIT", + "psf": "Python-2.0", + "public-domain": "NOASSERTION", + "openldap-2.8": "NOASSERTION", + "hylafax": "NOASSERTION", + "ubuntu-font-licence-1.0": "Ubuntu-Font-1.0", + "go": "NOASSERTION", + "epl-1": "EPL-1.0", + "dom4j": "NOASSERTION", + "fastcgi": "NOASSERTION", + "other": "NOASSERTION", + "eclipse-public-license-v1.0": "EPL-1.0", + "edl-1.0": "BSD-3-Clause", + "nrl-2-clause": "NOASSERTION", + "tidy": "NOASSERTION", + "purdue": "NOASSERTION", + "mpl-2": "MPL-2.0", + } + + // Check for exact match first (case-insensitive) + if mapped, ok := replacements[licenseLower]; ok { + return mapped + } + + // Check for prefix match (case-insensitive) + for old, new := range replacements { + if strings.HasPrefix(licenseLower, old) { + return new + } + } + + // Check if it looks like a valid SPDX identifier + validSPDXPattern := regexp.MustCompile(`^[A-Za-z0-9.\-]+(\s+(AND|OR|WITH)\s+[A-Za-z0-9.\-]+)*$`) + + if validSPDXPattern.MatchString(license) { + return license + } + + // If it contains copyright statements, full sentences, or invalid characters, return NOASSERTION + invalidPatterns := []string{ + "Copyright", "copyright", "Permission is hereby", "The files", + "Formerly,", "build-aux", "Portions", "free software", + "<", ">", "'", ",", + } + + for _, pattern := range invalidPatterns { + if strings.Contains(license, pattern) { + return "NOASSERTION" + } + } + + // If license string is longer than 50 chars, it's probably license text + if len(license) > 50 { + return "NOASSERTION" + } + + // Default: if we can't confidently map it, use NOASSERTION + return "NOASSERTION" +} + +func sanitizeName(name string) string { + // Replace non-alphanumeric characters with hyphens for SPDX IDs + re := regexp.MustCompile(`[^a-zA-Z0-9-.]`) + return re.ReplaceAllString(name, "-") +} + +func generateUUID() string { + // Simple UUID v4 generation + b := make([]byte, 16) + for i := range b { + b[i] = byte(time.Now().UnixNano() & 0xff) + } + + return fmt.Sprintf("%x-%x-%x-%x-%x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} diff --git a/scripts/nix-provision.sh b/scripts/nix-provision.sh index 9fbd37153..a5705793f 100644 --- a/scripts/nix-provision.sh +++ b/scripts/nix-provision.sh @@ -54,6 +54,21 @@ EOF $ARGS } +function generate_sbom { + echo "Generating SBOM for Ubuntu packages..." + # Run the sbom tool for Ubuntu packages only (Nix SBOM will be generated separately) + # The sbom binary is built into the Nix flake + #shellcheck disable=SC1091 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + # Generate Ubuntu-only SBOM (dpkg packages) + nix run "github:supabase/postgres/${GIT_SHA}#sbom" -- ubuntu \ + --output /tmp/ubuntu-sbom.spdx.json \ + --no-progress + + echo "Ubuntu SBOM generated at /tmp/ubuntu-sbom.spdx.json" +} + function cleanup_packages { sudo apt-get -y remove --purge ansible sudo add-apt-repository --yes --remove ppa:ansible/ansible @@ -62,4 +77,5 @@ function cleanup_packages { install_packages install_nix execute_stage2_playbook +generate_sbom cleanup_packages diff --git a/stage2-nix-psql.pkr.hcl b/stage2-nix-psql.pkr.hcl index 2f25b6ada..aaa0cad01 100644 --- a/stage2-nix-psql.pkr.hcl +++ b/stage2-nix-psql.pkr.hcl @@ -144,4 +144,11 @@ build { script = "scripts/nix-provision.sh" } + # Download the SBOM generated on the instance + provisioner "file" { + source = "/tmp/ubuntu-sbom.spdx.json" + destination = "ubuntu-sbom-${var.postgres-version}.spdx.json" + direction = "download" + } + } From 1f0921da901cfe208d38b731a1be62d8b1807044 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 9 Dec 2025 15:17:57 -0500 Subject: [PATCH 2/2] fix: https://cwe.mitre.org/data/definitions/23.html issue --- .../sbom/internal/ubuntu/generator.go | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/nix/packages/sbom/internal/ubuntu/generator.go b/nix/packages/sbom/internal/ubuntu/generator.go index e3385dfb1..0dd874bf4 100644 --- a/nix/packages/sbom/internal/ubuntu/generator.go +++ b/nix/packages/sbom/internal/ubuntu/generator.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "strings" "time" @@ -136,7 +137,18 @@ func (g *Generator) getInstalledPackages() ([]DpkgPackage, error) { } func (g *Generator) getPackageLicense(packageName string) (string, string) { - copyrightPath := fmt.Sprintf("/usr/share/doc/%s/copyright", packageName) + // Sanitize package name to prevent path traversal + cleanName := filepath.Clean(packageName) + if strings.Contains(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.Contains(cleanName, string(filepath.Separator)) { + return "NOASSERTION", "NOASSERTION" + } + + copyrightPath := filepath.Join("/usr/share/doc", cleanName, "copyright") + + // Verify the resolved path is within the expected directory + if !strings.HasPrefix(copyrightPath, "/usr/share/doc/") { + return "NOASSERTION", "NOASSERTION" + } content, err := os.ReadFile(copyrightPath) if err != nil { @@ -211,7 +223,13 @@ func (g *Generator) packageToSPDX(pkg DpkgPackage, id int) spdx.Package { } func (g *Generator) calculatePackageChecksum(packageName string) string { - cmd := exec.Command("dpkg", "-L", packageName) + // Sanitize package name to prevent command injection + cleanName := filepath.Clean(packageName) + if strings.Contains(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.Contains(cleanName, string(filepath.Separator)) { + return "" + } + + cmd := exec.Command("dpkg", "-L", cleanName) output, err := cmd.Output() if err != nil { return "" @@ -235,7 +253,21 @@ func (g *Generator) calculatePackageChecksum(packageName string) string { } func hashFile(path string) string { - file, err := os.Open(path) + // Sanitize path to prevent path traversal + cleanPath := filepath.Clean(path) + + // Reject paths with traversal attempts or that aren't absolute + if strings.Contains(cleanPath, "..") || !filepath.IsAbs(cleanPath) { + return "" + } + + // Verify it's a regular file (not a symlink to outside, directory, etc.) + info, err := os.Lstat(cleanPath) + if err != nil || !info.Mode().IsRegular() { + return "" + } + + file, err := os.Open(cleanPath) if err != nil { return "" }