From 6133776f15af60c650f2a2cfed162ec448312e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Tue, 22 Sep 2026 22:08:20 +0200 Subject: [PATCH 1/2] feat(supply-chain): import SPDX and CycloneDX documents and export derived SPDX Accept authenticated SPDX 2.3 JSON and CycloneDX 1.6 JSON uploads into declared import streams of an authorized repository. The format is detected from the document, unsupported versions and contradictory content types are rejected explicitly, original bytes are preserved, the uploader is recorded separately from the claimed producer, and a supplied subject revision is bound only as producer_asserted. Identical bytes are idempotent, per-repository quotas apply, and non-administrators need a repository-scoped upload grant. The CycloneDX normalizer keeps nested components, dependencies, hashes, and license choices as the format carries them and warns about evidence it cannot carry. A derived SPDX export names GraphNest as creator, links the preserved original by URL and hash, carries assessments as comments only, and is validated by the same reader. Migration 035 adds imports and upload grants. Co-Authored-By: Claude --- cmd/graphnest-server/main.go | 16 +- docs/openapi.yaml | 112 +++++- internal/httpapi/supply_chain_import.go | 105 ++++++ internal/httpapi/supply_chain_portfolio.go | 49 ++- .../migrations/035_supply_chain_import.sql | 38 ++ internal/postgres/supply_chain.go | 11 +- internal/postgres/supply_chain_import.go | 77 +++++ internal/supplychain/cyclonedx.go | 325 ++++++++++++++++++ internal/supplychain/cyclonedx_test.go | 142 ++++++++ internal/supplychain/importer.go | 246 +++++++++++++ internal/supplychain/license/evidence.go | 18 + internal/supplychain/model.go | 6 + internal/supplychain/service.go | 46 ++- internal/supplychain/spdx_test.go | 25 ++ internal/supplychain/spdxexport.go | 258 ++++++++++++++ pkg/api/supply_chain.go | 30 ++ .../supplychain/syft-cyclonedx-1.6.json | 84 +++++ test/integration/supply_chain_test.go | 306 +++++++++++++++++ 18 files changed, 1868 insertions(+), 26 deletions(-) create mode 100644 internal/httpapi/supply_chain_import.go create mode 100644 internal/postgres/migrations/035_supply_chain_import.sql create mode 100644 internal/postgres/supply_chain_import.go create mode 100644 internal/supplychain/cyclonedx.go create mode 100644 internal/supplychain/cyclonedx_test.go create mode 100644 internal/supplychain/importer.go create mode 100644 internal/supplychain/spdxexport.go create mode 100644 test/fixtures/supplychain/syft-cyclonedx-1.6.json diff --git a/cmd/graphnest-server/main.go b/cmd/graphnest-server/main.go index 61f508f9..56166f47 100644 --- a/cmd/graphnest-server/main.go +++ b/cmd/graphnest-server/main.go @@ -47,6 +47,7 @@ import ( "github.com/balcsida/graphnest/internal/webhook" "github.com/balcsida/graphnest/internal/webui" "github.com/balcsida/graphnest/internal/zoekt" + "github.com/jackc/pgx/v5" "github.com/modelcontextprotocol/go-sdk/mcp" "golang.org/x/net/idna" ) @@ -470,9 +471,22 @@ func newDurableRuntime(ctx context.Context, settings config.Config, logger *slog License: store, EnrichmentEcosystems: registry.Ecosystems()} supplyChainDone = startSupplyChain(loopCtx, settings.SupplyChain, store, githubClient, registry, metrics, logger) portfolio := &supplychain.Portfolio{Store: store, Snapshots: store, Authorizer: authz.NewPostgres(store), Interval: settings.SupplyChain.Interval, MaxResults: settings.Limits.MaxResults} + importer := &supplychain.Importer{Store: store, Authorizer: authz.NewPostgres(store), MaxDocumentBytes: settings.SupplyChain.MaxDocumentBytes, Limits: supplychain.Limits{MaxComponents: settings.SupplyChain.MaxComponents}} + if len(registry.Ecosystems()) > 0 { + importer.Enricher = &license.Worker{Store: store, Registry: registry} + } + authorizer := authz.NewPostgres(store) + grants := &httpapi.UploadGrants{Set: store.SetSupplyChainUploadGrant, Resolve: func(ctx context.Context, principal authn.Principal, githubID int64) (int64, error) { + repo, err := authorizer.AuthorizedRepository(ctx, principal, githubID) + if errors.Is(err, pgx.ErrNoRows) { + return 0, supplychain.ErrNotFound + } + return repo.ID, err + }} extras = append(extras, func(mux *http.ServeMux) { httpapi.RegisterSupplyChain(mux, auth.requestAuth, supplyChainService, settings.Limits.MaxResults, settings.Limits.MaxResponseBytes) - httpapi.RegisterSupplyChainPortfolio(mux, auth.requestAuth, portfolio, settings.Limits.MaxResults, settings.Limits.MaxResponseBytes) + httpapi.RegisterSupplyChainPortfolio(mux, auth.requestAuth, portfolio, settings.Limits.MaxResults, settings.Limits.MaxResponseBytes, &httpapi.DerivedExport{Service: supplyChainService, PublicOrigin: auth.requestAuth.PublicOrigin}) + httpapi.RegisterSupplyChainImports(mux, auth.requestAuth, importer, grants, settings.SupplyChain.MaxDocumentBytes, settings.Limits.MaxResponseBytes) }) } handler := newAPIHandler(settings, metrics, auth.requestAuth, searchService, repositoryService, scipService, graphService, graphQueries, webhookSecret, processor, adminService, durableReadiness{pool: pool, zoekt: backend}, auth.providers, auth.sessions, provisioning, scimService, auth.mcpOAuth, extras...) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 54d69bfb..da0dff8f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -639,7 +639,7 @@ paths: security: [{bearerAuth: []}, {sessionCookie: []}] parameters: - {name: id, in: path, required: true, description: GitHub repository ID, schema: {type: integer, format: int64, minimum: 1}} - - {name: stream, in: query, required: false, description: 'Stream key; only github:source is served in this milestone', schema: {type: string, enum: ['github:source']}} + - {name: stream, in: query, required: false, description: 'Stream key: github:source (default) or import::