diff --git a/internal/integrations/definitions/catalog/catalog.go b/internal/integrations/definitions/catalog/catalog.go index 591f845af7..b51f24286f 100644 --- a/internal/integrations/definitions/catalog/catalog.go +++ b/internal/integrations/definitions/catalog/catalog.go @@ -7,6 +7,7 @@ import ( "github.com/theopenlane/core/internal/integrations/definitions/azuresecuritycenter" "github.com/theopenlane/core/internal/integrations/definitions/cloudflare" "github.com/theopenlane/core/internal/integrations/definitions/email" + "github.com/theopenlane/core/internal/integrations/definitions/fossa" "github.com/theopenlane/core/internal/integrations/definitions/gcpscc" "github.com/theopenlane/core/internal/integrations/definitions/githubapp" "github.com/theopenlane/core/internal/integrations/definitions/googledrive" @@ -37,6 +38,7 @@ func Builders(cfg Config, federationIssuer string, devMode bool) []registry.Buil azuresecuritycenter.Builder(), cloudflare.Builder(&cfg.CloudflareRuntime), email.Builder(&cfg.Email, devMode), + fossa.Builder(), gcpscc.Builder(federationIssuer), githubapp.Builder(cfg.GitHubApp), googledrive.Builder(cfg.GoogleDrive), diff --git a/internal/integrations/definitions/fossa/api.go b/internal/integrations/definitions/fossa/api.go new file mode 100644 index 0000000000..884c1f92db --- /dev/null +++ b/internal/integrations/definitions/fossa/api.go @@ -0,0 +1,118 @@ +package fossa + +import ( + "context" + "encoding/json" + "strconv" +) + +const ( + // pathIssues lists issues for a scope, category and filter + pathIssues = "/api/v2/issues" + // pathIssueCategories returns issue counts per category + pathIssueCategories = "/api/v2/issues/categories" + // pathOrganization returns the organization details for the authenticated token + pathOrganization = "/api/cli/organization" +) + +const ( + // categoryVulnerability identifies security vulnerability issues + categoryVulnerability = "vulnerability" + // categoryLicensing identifies OSS license compliance issues + categoryLicensing = "licensing" + // statusActive limits collection to issues that still require attention + statusActive = "active" + // statusAll includes issues that have been dismissed in FOSSA + statusAll = "all" + // scopeGlobal collects issues across every project in the organization + scopeGlobal = "global" +) + +// issuesResponse is the envelope returned by the issues endpoint. Each issue is retained as raw +// JSON so the untouched provider payload is what reaches the mapping layer. +type issuesResponse struct { + // Issues is the page of issues returned for the requested category + Issues []json.RawMessage `json:"issues"` +} + +// issueIdentity is the minimal projection of an issue needed to build an ingest envelope +type issueIdentity struct { + // ID is the numeric FOSSA issue identifier + ID int64 `json:"id"` + // Projects lists the FOSSA projects the issue was found in + Projects []issueProject `json:"projects"` +} + +// issueProject is a FOSSA project reference attached to an issue +type issueProject struct { + // ID is the FOSSA project locator, for example git+github.com/org/repo + ID string `json:"id"` +} + +// resourceID returns the project locator used as the ingest envelope resource +func (i issueIdentity) resourceID() string { + for _, project := range i.Projects { + if project.ID != "" { + return project.ID + } + } + + return "" +} + +// organizationResponse holds the organization details reported for the authenticated token +type organizationResponse struct { + // OrganizationID is the numeric FOSSA organization identifier + OrganizationID int64 `json:"organizationId"` + // Subscription is the FOSSA subscription tier for the organization + Subscription string `json:"subscription"` + // UsesSAML reports whether the organization authenticates through SAML + UsesSAML bool `json:"usesSAML"` +} + +// identifier renders the organization ID as the stable string used for installation identity +func (o organizationResponse) identifier() string { + if o.OrganizationID == 0 { + return "" + } + + return strconv.FormatInt(o.OrganizationID, 10) +} + +// issueCategories returns the issue counts keyed by category +func (c *APIClient) issueCategories(ctx context.Context) (map[string]int, error) { + counts := map[string]int{} + if err := c.get(ctx, pathIssueCategories, nil, &counts); err != nil { + return nil, err + } + + return counts, nil +} + +// organization returns the organization details for the authenticated token +func (c *APIClient) organization(ctx context.Context) (organizationResponse, error) { + org := organizationResponse{} + if err := c.get(ctx, pathOrganization, nil, &org); err != nil { + return organizationResponse{}, err + } + + return org, nil +} + +// listIssues fetches one page of issues for the supplied category and status +func (c *APIClient) listIssues(ctx context.Context, category, status string, page int) ([]json.RawMessage, error) { + params := map[string]string{ + "category": category, + "status": status, + "scope[type]": scopeGlobal, + "page": strconv.Itoa(page), + "count": strconv.Itoa(issuePageSize), + } + + response := issuesResponse{} + if err := c.get(ctx, pathIssues, params, &response); err != nil { + return nil, err + } + + return response.Issues, nil +} diff --git a/internal/integrations/definitions/fossa/builder.go b/internal/integrations/definitions/fossa/builder.go new file mode 100644 index 0000000000..31bd3b1fbb --- /dev/null +++ b/internal/integrations/definitions/fossa/builder.go @@ -0,0 +1,113 @@ +package fossa + +import ( + "github.com/theopenlane/core/internal/ent/entityops" + "github.com/theopenlane/core/internal/integrations/providerkit" + "github.com/theopenlane/core/internal/integrations/registry" + "github.com/theopenlane/core/internal/integrations/types" + "github.com/theopenlane/core/pkg/gala" + "github.com/theopenlane/core/pkg/jsonx" +) + +// Builder returns the FOSSA definition builder +func Builder() registry.Builder { + return registry.Builder(func() (types.Definition, error) { + return types.Definition{ + DefinitionSpec: types.DefinitionSpec{ + ID: definitionID.ID(), + Family: "FOSSA", + DisplayName: "FOSSA", + Description: "Collect FOSSA security vulnerabilities and OSS license compliance issues from scanned projects.", + Category: "security-posture", + DocsURL: "https://docs.theopenlane.io/docs/platform/integrations/fossa", + Tags: []string{"vulnerabilities", "findings", "licensing", "sbom"}, + Active: false, + Visible: false, + }, + UserInput: &types.UserInputRegistration{ + Schema: jsonx.SchemaFrom[UserInput](), + }, + CredentialRegistrations: []types.CredentialRegistration{ + { + Ref: fossaCredential.ID(), + Name: "FOSSA API Token", + Description: "FOSSA API token with full access, used to read issues and organization details.", + Schema: fossaSchema, + Recommended: true, + }, + }, + Connections: []types.ConnectionRegistration{ + { + CredentialRef: fossaCredential.ID(), + Name: "FOSSA API Token", + Description: "Configure FOSSA access using an API token generated from Account Settings, Integrations, API.", + CredentialRefs: []types.CredentialSlotID{fossaCredential.ID()}, + ClientRefs: []types.ClientID{fossaClient.ID()}, + ValidationOperation: healthCheckOperation.Name(), + Integration: installation.Registration(), + Disconnect: &types.DisconnectRegistration{ + CredentialRef: fossaCredential.ID(), + Description: "Removes the stored FOSSA API token from Openlane. If the token is no longer needed, revoke it from your FOSSA account settings.", + }, + }, + }, + Clients: []types.ClientRegistration{ + { + Ref: fossaClient.ID(), + CredentialRefs: []types.CredentialSlotID{fossaCredential.ID()}, + Description: "FOSSA REST API client", + Build: ClientBuilder{}.Build, + }, + }, + Operations: []types.OperationRegistration{ + { + Name: healthCheckOperation.Name(), + Description: "Validate FOSSA access", + Topic: definitionID.OperationTopic(healthCheckOperation.Name()), + ClientRef: fossaClient.ID(), + Policy: types.ExecutionPolicy{Inline: true}, + Handle: HealthCheck{}.Handle(), + ConfigSchema: healthCheckSchema, + }, + { + Name: vulnerabilitySyncOperation.Name(), + Description: "Collect FOSSA security vulnerabilities, and optionally OSS license compliance findings", + Topic: definitionID.OperationTopic(vulnerabilitySyncOperation.Name()), + ClientRef: fossaClient.ID(), + ConfigSchema: vulnerabilitySyncSchema, + Policy: types.ExecutionPolicy{Reconcile: true}, + // no Disabled resolver, security vulnerability collection is always on + ConfigResolver: providerkit.ConfigFrom(func(u UserInput) VulnerabilitySync { return u.VulnerabilitySync }), + Ingest: []types.IngestContract{ + { + Schema: entityops.SchemaVulnerability.Name, + }, + { + Schema: entityops.SchemaFinding.Name, + }, + }, + IngestHandle: VulnerabilityCollect{}.IngestHandle(), + SkipDefaultLookback: true, + RequiredPermissions: []string{"FOSSA API token with full access"}, + Schedule: gala.NewFullFetchSchedule(), + }, + }, + Mappings: []types.MappingRegistration{ + { + Schema: entityops.SchemaVulnerability.Name, + Spec: types.MappingOverride{ + FilterExpr: "true", + MapExpr: mapExprVulnerability, + }, + }, + { + Schema: entityops.SchemaFinding.Name, + Spec: types.MappingOverride{ + FilterExpr: "true", + MapExpr: mapExprFinding, + }, + }, + }, + }, nil + }) +} diff --git a/internal/integrations/definitions/fossa/client.go b/internal/integrations/definitions/fossa/client.go new file mode 100644 index 0000000000..98a744e5e9 --- /dev/null +++ b/internal/integrations/definitions/fossa/client.go @@ -0,0 +1,125 @@ +package fossa + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/theopenlane/httpsling" + "github.com/theopenlane/httpsling/httpclient" + + "github.com/theopenlane/core/internal/integrations/types" +) + +const ( + // defaultBaseURL is the FOSSA SaaS base URL used when the credential does not override it + defaultBaseURL = "https://app.fossa.com" + // fossaRequestTimeout is the per-request timeout for FOSSA API calls + fossaRequestTimeout = 30 * time.Second +) + +// APIClient is a thin FOSSA REST API client with the base URL and bearer token pre-applied +type APIClient struct { + // requester is the underlying httpsling requester carrying the base URL and auth header + requester *httpsling.Requester +} + +// NewAPIClient constructs a FOSSA API client for the supplied base URL and token +func NewAPIClient(baseURL, token string) (*APIClient, error) { + requester, err := httpsling.New( + httpsling.Client(httpclient.Timeout(fossaRequestTimeout)), + httpsling.URL(baseURL), + ) + if err != nil { + return nil, ErrClientBuild + } + + if err := requester.Apply(httpsling.BearerAuth(token)); err != nil { + return nil, ErrClientBuild + } + + return &APIClient{requester: requester}, nil +} + +// get issues a GET request against the FOSSA API and decodes a successful response into out. +// +// httpsling does not treat a non-2xx status as an error; it unmarshals whatever body came back +// into out and returns any unmarshal error. The status is therefore checked before the decode +// result is trusted, so an auth failure surfaces as ErrUnauthorized rather than a decode error. +func (c *APIClient) get(ctx context.Context, path string, params map[string]string, out any) error { + opts := make([]httpsling.Option, 0, len(params)+1) + opts = append(opts, httpsling.Get(path)) + + for key, value := range params { + opts = append(opts, httpsling.QueryParam(key, value)) + } + + resp, err := c.requester.ReceiveWithContext(ctx, out, opts...) + if resp != nil { + defer resp.Body.Close() //nolint:errcheck + } + + if resp == nil { + return ErrAPIRequest + } + + if !httpsling.IsSuccess(resp) { + return statusError(resp.StatusCode) + } + + if err != nil { + return ErrAPIRequest + } + + return nil +} + +// statusError maps a non-success FOSSA response status to a sentinel error +func statusError(status int) error { + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + return ErrUnauthorized + case http.StatusTooManyRequests: + return ErrRateLimited + default: + return ErrAPIRequest + } +} + +// ClientBuilder builds FOSSA API clients for one installation +type ClientBuilder struct{} + +// Build constructs the FOSSA API client for one installation +func (ClientBuilder) Build(_ context.Context, req types.ClientBuildRequest) (any, error) { + cred, err := resolveCredential(req.Credentials) + if err != nil { + return nil, err + } + + if cred.APIToken == "" { + return nil, ErrAPITokenMissing + } + + return NewAPIClient(baseURLOrDefault(cred.BaseURL), cred.APIToken) +} + +// resolveCredential extracts the CredentialSchema from the provided credential bindings +func resolveCredential(bindings types.CredentialBindings) (CredentialSchema, error) { + cred, ok, err := fossaCredential.Resolve(bindings) + if err != nil || !ok { + return CredentialSchema{}, ErrCredentialDecode + } + + return cred, nil +} + +// baseURLOrDefault normalizes the configured base URL, falling back to the FOSSA SaaS endpoint +func baseURLOrDefault(baseURL string) string { + trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if trimmed == "" { + return defaultBaseURL + } + + return trimmed +} diff --git a/internal/integrations/definitions/fossa/client_test.go b/internal/integrations/definitions/fossa/client_test.go new file mode 100644 index 0000000000..a18d35fec0 --- /dev/null +++ b/internal/integrations/definitions/fossa/client_test.go @@ -0,0 +1,165 @@ +package fossa + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/theopenlane/core/internal/integrations/types" + "github.com/theopenlane/core/pkg/jsonx" +) + +// TestBaseURLOrDefault verifies base URL normalization and the SaaS fallback +func TestBaseURLOrDefault(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "empty falls back to saas", input: "", expected: defaultBaseURL}, + {name: "whitespace falls back to saas", input: " ", expected: defaultBaseURL}, + {name: "trailing slash trimmed", input: "https://fossa.internal/", expected: "https://fossa.internal"}, + {name: "on premise host preserved", input: "https://fossa.internal", expected: "https://fossa.internal"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, baseURLOrDefault(tt.input)) + }) + } +} + +// TestResolveCredential verifies credential resolution from bindings +func TestResolveCredential(t *testing.T) { + t.Run("decodes into credential schema", func(t *testing.T) { + raw, err := jsonx.ToRawMessage(CredentialSchema{APIToken: "token-123", BaseURL: "https://fossa.internal"}) + require.NoError(t, err) + + bindings := types.CredentialBindings{ + {Ref: fossaCredential.ID(), Credential: types.CredentialSet{Data: raw}}, + } + + cred, err := resolveCredential(bindings) + require.NoError(t, err) + + assert.Equal(t, "token-123", cred.APIToken) + assert.Equal(t, "https://fossa.internal", cred.BaseURL) + }) + + t.Run("returns decode error for invalid provider data", func(t *testing.T) { + bindings := types.CredentialBindings{ + {Ref: fossaCredential.ID(), Credential: types.CredentialSet{Data: []byte(`{`)}}, + } + + _, err := resolveCredential(bindings) + assert.ErrorIs(t, err, ErrCredentialDecode) + }) + + t.Run("returns decode error when unbound", func(t *testing.T) { + _, err := resolveCredential(types.CredentialBindings{}) + assert.ErrorIs(t, err, ErrCredentialDecode) + }) +} + +// TestClientBuilderRequiresToken verifies a bound credential with no token is rejected up front +func TestClientBuilderRequiresToken(t *testing.T) { + raw, err := jsonx.ToRawMessage(CredentialSchema{}) + require.NoError(t, err) + + bindings := types.CredentialBindings{ + {Ref: fossaCredential.ID(), Credential: types.CredentialSet{Data: raw}}, + } + + _, err = ClientBuilder{}.Build(context.Background(), types.ClientBuildRequest{Credentials: bindings}) + assert.ErrorIs(t, err, ErrAPITokenMissing) +} + +// TestClientSendsBearerTokenAndEncodesQuery verifies the auth header is applied and that bracketed +// query keys such as scope[type] are percent-encoded rather than passed through literally +func TestClientSendsBearerTokenAndEncodesQuery(t *testing.T) { + var ( + gotAuth string + gotRawQuery string + gotPath string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotRawQuery = r.URL.RawQuery + gotPath = r.URL.Path + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues":[]}`)) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + issues, err := client.listIssues(context.Background(), categoryVulnerability, statusActive, 1) + require.NoError(t, err) + assert.Empty(t, issues) + + assert.Equal(t, "Bearer token-123", gotAuth) + assert.Equal(t, pathIssues, gotPath) + assert.Contains(t, gotRawQuery, "scope%5Btype%5D=global") + assert.NotContains(t, gotRawQuery, "scope[type]") +} + +// TestClientStatusErrors verifies non-success responses map to sentinels rather than decode errors. +// httpsling does not treat a non-2xx status as an error and still unmarshals the body, so this +// guards the ordering of the status check against the decode result. +func TestClientStatusErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + expected error + }{ + {name: "unauthorized", status: http.StatusUnauthorized, body: `{"error":"bad token"}`, expected: ErrUnauthorized}, + {name: "forbidden", status: http.StatusForbidden, body: `{"error":"push only token"}`, expected: ErrUnauthorized}, + {name: "rate limited", status: http.StatusTooManyRequests, body: `slow down`, expected: ErrRateLimited}, + {name: "server error", status: http.StatusInternalServerError, body: `boom`, expected: ErrAPIRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + _, err = client.issueCategories(context.Background()) + assert.ErrorIs(t, err, tt.expected) + }) + } +} + +// TestOrganizationIdentifier verifies the numeric organization ID is rendered as a stable string +func TestOrganizationIdentifier(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"organizationId":63774,"subscription":"Free","usesSAML":false}`)) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + org, err := client.organization(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "63774", org.identifier()) + assert.Equal(t, "Free", org.Subscription) + + assert.Empty(t, organizationResponse{}.identifier(), "a missing organization ID must not render as 0") +} diff --git a/internal/integrations/definitions/fossa/doc.go b/internal/integrations/definitions/fossa/doc.go new file mode 100644 index 0000000000..b395592f2c --- /dev/null +++ b/internal/integrations/definitions/fossa/doc.go @@ -0,0 +1,11 @@ +// Package fossa defines the FOSSA software composition analysis integration definition. +// +// FOSSA scans project dependencies and reports issues in three categories: vulnerability, +// licensing, and quality. This definition syncs security vulnerabilities unconditionally and +// OSS license compliance issues only when the installation opts in; quality issues are out of scope. +// +// A fresh FOSSA organization reports no vulnerability issues until a project containing a +// vulnerable dependency has been scanned. To produce one for local testing, scan a project +// pinned to a dependency with a known CVE, for example npm axios@1.15.0 or Go +// github.com/gogo/protobuf@v1.3.1, then confirm a non-zero count from /api/v2/issues/categories. +package fossa diff --git a/internal/integrations/definitions/fossa/errors.go b/internal/integrations/definitions/fossa/errors.go new file mode 100644 index 0000000000..c0b21c9065 --- /dev/null +++ b/internal/integrations/definitions/fossa/errors.go @@ -0,0 +1,32 @@ +package fossa + +import "errors" + +var ( + // ErrAPITokenMissing indicates the installation credential has no FOSSA API token + ErrAPITokenMissing = errors.New("fossa: api token is required") + // ErrCredentialDecode indicates the stored credential could not be decoded + ErrCredentialDecode = errors.New("fossa: unable to decode credential") + // ErrCredentialMetadataInvalid indicates installation metadata could not be resolved from the credential + ErrCredentialMetadataInvalid = errors.New("fossa: unable to resolve credential metadata") + // ErrClientBuild indicates the FOSSA API client could not be constructed + ErrClientBuild = errors.New("fossa: unable to build api client") + // ErrUnauthorized indicates FOSSA rejected the API token + ErrUnauthorized = errors.New("fossa: api token was rejected, a full access token is required") + // ErrRateLimited indicates FOSSA rate limited the request + ErrRateLimited = errors.New("fossa: rate limited by the api") + // ErrAPIRequest indicates the FOSSA API returned an unexpected status + ErrAPIRequest = errors.New("fossa: unexpected api response") + // ErrIssuesFetchFailed indicates issues could not be listed + ErrIssuesFetchFailed = errors.New("fossa: unable to list issues") + // ErrIssueEncode indicates an issue payload could not be encoded into an ingest envelope + ErrIssueEncode = errors.New("fossa: unable to encode issue payload") + // ErrCategoriesFetchFailed indicates the issue category counts could not be retrieved + ErrCategoriesFetchFailed = errors.New("fossa: unable to fetch issue categories") + // ErrOrganizationFetchFailed indicates the organization details could not be retrieved + ErrOrganizationFetchFailed = errors.New("fossa: unable to fetch organization details") + // ErrResultEncode indicates an operation result could not be encoded + ErrResultEncode = errors.New("fossa: unable to encode operation result") + // ErrOperationConfigInvalid indicates the operation configuration could not be decoded + ErrOperationConfigInvalid = errors.New("fossa: unable to decode operation config") +) diff --git a/internal/integrations/definitions/fossa/examples/finding.json b/internal/integrations/definitions/fossa/examples/finding.json new file mode 100644 index 0000000000..ff90109b83 --- /dev/null +++ b/internal/integrations/definitions/fossa/examples/finding.json @@ -0,0 +1,39 @@ +{ + "id": 20012351, + "type": "policy_flag", + "createdAt": "2026-08-16T03:49:42.532Z", + "source": { + "id": "go+github.com/fumiama/go-docx$v0.0.0-20250506085032-0c30fd09304b", + "name": "github.com/fumiama/go-docx", + "url": "https://github.com/fumiama/go-docx", + "version": "v0.0.0-20250506085032-0c30fd09304b", + "packageManager": "go" + }, + "depths": { + "direct": 1, + "deep": 0 + }, + "statuses": { + "active": 1, + "ignored": 0 + }, + "projects": [ + { + "id": "git+github.com/example-org/example-repo", + "status": "active", + "depth": 1, + "title": "example-repo", + "scannedAt": "2026-08-16T03:49:43.482762+00:00", + "analyzedAt": "2026-08-16T03:46:43.398Z", + "url": "https://app.fossa.com/projects/git%2Bgithub.com%2Fexample-org%2Fexample-repo", + "firstFoundAt": "2026-08-16T03:49:42.532+00:00", + "defaultBranch": "main", + "latest": true, + "revisionId": "git+github.com/example-org/example-repo$0000000000000000000000000000000000000000", + "revisionScanId": 117041474 + } + ], + "url": "https://app.fossa.com/issues/licensing/20012351", + "details": "These packages contain code files that may require you to disclose your source code under a compatible license, unless they're distributed and run as completely separate processes & packages.", + "license": "AGPL-3.0-only" +} diff --git a/internal/integrations/definitions/fossa/examples/vulnerability.json b/internal/integrations/definitions/fossa/examples/vulnerability.json new file mode 100644 index 0000000000..aced8fe643 --- /dev/null +++ b/internal/integrations/definitions/fossa/examples/vulnerability.json @@ -0,0 +1,76 @@ +{ + "id": 20062524, + "type": "vulnerability", + "createdAt": "2026-08-18T04:33:32.561Z", + "source": { + "id": "npm+ajv$6.12.6", + "name": "ajv", + "url": "https://ajv.js.org/", + "version": "6.12.6", + "packageManager": "npm" + }, + "depths": { + "direct": 0, + "deep": 1 + }, + "statuses": { + "active": 1, + "ignored": 0 + }, + "projects": [ + { + "id": "git+github.com/example-org/example-repo", + "status": "active", + "depth": 2, + "title": "example-repo", + "scannedAt": "2026-08-18T04:33:33.54962+00:00", + "analyzedAt": "2026-08-18T04:30:24.311Z", + "url": "https://app.fossa.com/projects/git%2Bgithub.com%2Fexample-org%2Fexample-repo", + "firstFoundAt": "2026-08-18T04:33:32.561+00:00", + "defaultBranch": "main", + "latest": true, + "revisionId": "git+github.com/example-org/example-repo$0000000000000000000000000000000000000000", + "revisionScanId": 117224055 + } + ], + "url": "https://app.fossa.com/issues/vulnerability/20062524", + "vulnId": "CVE-2025-69873_npm+ajv", + "title": "Inefficient Regular Expression Complexity", + "cve": "CVE-2025-69873", + "cvss": 2.9, + "severity": "low", + "details": "ajv (Another JSON Schema Validator) before 8.18.0 is vulnerable to Regular Expression Denial of Service (ReDoS) when the $data option is enabled.", + "remediation": { + "partialFix": "6.14.0", + "partialFixDistance": "MINOR", + "completeFix": "6.14.0", + "completeFixDistance": "MINOR" + }, + "metrics": [ + { "name": "Attack Vector", "value": "Local" }, + { "name": "Attack Complexity", "value": "High" }, + { "name": "Privileges Required", "value": "None" }, + { "name": "User Interaction", "value": "None" }, + { "name": "Scope", "value": "Unchanged" }, + { "name": "Confidentiality Impact", "value": "None" }, + { "name": "Integrity Impact", "value": "None" }, + { "name": "Availability Impact", "value": "Low" } + ], + "cveStatus": "COMPLETED", + "cwes": ["CWE-1333", "CWE-400"], + "published": "2026-02-11T19:15:50.000Z", + "affectedVersionRanges": ["<6.14.0", ">=7.0.0-alpha.0,<8.18.0"], + "patchedVersionRanges": [], + "references": [ + "https://access.redhat.com/security/cve/CVE-2025-69873", + "https://github.com/advisories/GHSA-2g4f-4pwh-qvx6", + "https://github.com/ajv-validator/ajv/releases/tag/v6.14.0" + ], + "cvssVector": "CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", + "exploitability": "UNKNOWN", + "epss": { + "score": 0.00492, + "percentile": 0.40057 + }, + "cpes": [] +} diff --git a/internal/integrations/definitions/fossa/examples/vulnerabilitysync/main.go b/internal/integrations/definitions/fossa/examples/vulnerabilitysync/main.go new file mode 100644 index 0000000000..6f685edfbc --- /dev/null +++ b/internal/integrations/definitions/fossa/examples/vulnerabilitysync/main.go @@ -0,0 +1,132 @@ +//go:build ignore + +// Verifies the FOSSA client and the VulnerabilitySync operation against the real FOSSA API. +// +// FOSSA_TOKEN= go run main.go [-licensing] [-include-ignored] [-base-url https://app.fossa.com] +// +// The token must be a full access token; push-only tokens cannot read issues. +// +// FOSSA has no self-hostable local instance, so this drives the real SaaS API. A free FOSSA +// account is enough. Note that a fresh organization reports no vulnerability issues until a +// project containing a vulnerable dependency has been scanned; to seed one: +// +// mkdir /tmp/fossa-vuln-fixture && cd /tmp/fossa-vuln-fixture +// printf 'module fossa-vuln-fixture\ngo 1.21\nrequire github.com/gogo/protobuf v1.3.1\n' > go.mod +// go mod tidy +// FOSSA_API_KEY=$FOSSA_TOKEN fossa analyze --project fossa-vuln-fixture +// +// then wait for the scan and confirm a non-zero count from GET /api/v2/issues/categories. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/theopenlane/core/internal/ent/entityops" + "github.com/theopenlane/core/internal/integrations/definitions/fossa" +) + +func main() { + licensing := flag.Bool("licensing", false, "also collect OSS license compliance findings") + includeIgnored := flag.Bool("include-ignored", false, "include issues dismissed in FOSSA") + baseURL := flag.String("base-url", "https://app.fossa.com", "FOSSA base URL") + flag.Parse() + + token := os.Getenv("FOSSA_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "FOSSA_TOKEN is required") + os.Exit(1) + } + + ctx := context.Background() + + client, err := fossa.NewAPIClient(*baseURL, token) + if err != nil { + fmt.Fprintf(os.Stderr, "client: %v\n", err) + os.Exit(1) + } + + // step 1: health check, which doubles as a token permission check + health, err := fossa.HealthCheck{}.Run(ctx, client) + if err != nil { + fmt.Fprintf(os.Stderr, "health check: %v\n", err) + os.Exit(1) + } + + fmt.Printf("health: %s\n\n", string(health)) + + // step 2: run the real sync operation, not a reimplementation of it + cfg := fossa.VulnerabilitySync{ + EnableLicenseFindings: *licensing, + IncludeIgnored: *includeIgnored, + } + + payloads, err := fossa.VulnerabilityCollect{}.Run(ctx, client, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "collect: %v\n", err) + os.Exit(1) + } + + for _, set := range payloads { + fmt.Printf("%s — %d envelopes\n", set.Schema, len(set.Envelopes)) + + for _, envelope := range set.Envelopes { + summary := struct { + ID int64 `json:"id"` + CVE string `json:"cve"` + License string `json:"license"` + Severity string `json:"severity"` + CVSS float64 `json:"cvss"` + Remediation struct { + CompleteFix string `json:"completeFix"` + PartialFix string `json:"partialFix"` + } `json:"remediation"` + Source struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"source"` + }{} + + if err := json.Unmarshal(envelope.Payload, &summary); err != nil { + fmt.Fprintf(os.Stderr, " decode: %v\n", err) + + continue + } + + label := summary.CVE + if label == "" { + label = summary.License + } + + fix := summary.Remediation.CompleteFix + if fix == "" { + fix = summary.Remediation.PartialFix + } + + if fix == "" { + fix = "-" + } + + fmt.Printf(" %-10d %-20s %-9s %5.1f fix=%-12s %s@%s\n", + summary.ID, label, summary.Severity, summary.CVSS, fix, + summary.Source.Name, summary.Source.Version) + } + + fmt.Println() + } + + // the ticket's core config contract: license findings must be absent unless enabled + if !*licensing { + for _, set := range payloads { + if set.Schema == entityops.SchemaFinding.Name && len(set.Envelopes) > 0 { + fmt.Fprintf(os.Stderr, "FAIL: %d finding envelopes emitted with license findings disabled\n", len(set.Envelopes)) + os.Exit(1) + } + } + + fmt.Println("ok: no license findings emitted while disabled") + } +} diff --git a/internal/integrations/definitions/fossa/installation.go b/internal/integrations/definitions/fossa/installation.go new file mode 100644 index 0000000000..0f4eb70bb7 --- /dev/null +++ b/internal/integrations/definitions/fossa/installation.go @@ -0,0 +1,46 @@ +package fossa + +import ( + "context" + + "github.com/theopenlane/core/internal/integrations/types" + "github.com/theopenlane/core/pkg/logx" +) + +// resolveInstallationMetadata derives the FOSSA organization identity from the bound credential +func resolveInstallationMetadata(ctx context.Context, req types.InstallationRequest) (InstallationMetadata, bool, error) { + if _, bound := req.Credentials.Resolve(fossaCredential.ID()); !bound { + return InstallationMetadata{}, true, nil + } + + cred, ok, err := fossaCredential.Resolve(req.Credentials) + if err != nil { + logx.FromContext(ctx).Error().Err(err).Msg("fossa: error resolving credential") + + return InstallationMetadata{}, false, ErrCredentialMetadataInvalid + } + + if !ok { + return InstallationMetadata{}, ok, nil + } + + baseURL := baseURLOrDefault(cred.BaseURL) + + client, err := NewAPIClient(baseURL, cred.APIToken) + if err != nil { + return InstallationMetadata{}, false, err + } + + org, err := client.organization(ctx) + if err != nil { + logx.FromContext(ctx).Error().Err(err).Msg("fossa: error fetching organization details") + + return InstallationMetadata{}, false, ErrOrganizationFetchFailed + } + + return InstallationMetadata{ + OrganizationID: org.identifier(), + BaseURL: baseURL, + Subscription: org.Subscription, + }, true, nil +} diff --git a/internal/integrations/definitions/fossa/mappings.go b/internal/integrations/definitions/fossa/mappings.go new file mode 100644 index 0000000000..4308cdff86 --- /dev/null +++ b/internal/integrations/definitions/fossa/mappings.go @@ -0,0 +1,77 @@ +package fossa + +import ( + "github.com/theopenlane/core/internal/ent/entityops" + "github.com/theopenlane/core/internal/integrations/providerkit" +) + +// mapExprVulnerability is the CEL mapping expression for FOSSA security vulnerability issues. +// +// Three provider quirks drive the shape of these expressions: +// - cvss arrives as a whole number for integral scores, so it is coerced with double() +// - FOSSA reports exploitability as a string enum, which cannot satisfy the numeric +// exploitability field, so the EPSS probability is used instead +// - affectedVersionRanges is a list while vulnerable_version_range is a single string +var mapExprVulnerability = providerkit.CelMapExpr([]providerkit.CelMapEntry{ + {Key: entityops.InputKeyVulnerabilityExternalID, Expr: `'id' in payload && payload.id != null ? string(payload.id) : ""`}, + {Key: entityops.InputKeyVulnerabilityCveID, Expr: `'cve' in payload && payload.cve != null ? payload.cve : ""`}, + {Key: entityops.InputKeyVulnerabilityDisplayName, Expr: `'cve' in payload && payload.cve != null && payload.cve != "" ? payload.cve : ('title' in payload && payload.title != null ? payload.title : "")`}, + {Key: entityops.InputKeyVulnerabilitySummary, Expr: `'title' in payload && payload.title != null ? payload.title : ""`}, + {Key: entityops.InputKeyVulnerabilityDescription, Expr: `'details' in payload && payload.details != null ? payload.details : ""`}, + {Key: entityops.InputKeyVulnerabilitySeverity, Expr: `dyn('severity' in payload && payload.severity != null ? payload.severity.upperAscii() : "")`}, + {Key: entityops.InputKeyVulnerabilityScore, Expr: `'cvss' in payload && payload.cvss != null ? double(payload.cvss) : 0.0`}, + {Key: entityops.InputKeyVulnerabilityVector, Expr: `'cvssVector' in payload && payload.cvssVector != null ? payload.cvssVector : ""`}, + {Key: entityops.InputKeyVulnerabilityCweIds, Expr: `'cwes' in payload && payload.cwes != null ? payload.cwes : []`}, + {Key: entityops.InputKeyVulnerabilityExploitability, Expr: `'epss' in payload && payload.epss != null && 'score' in payload.epss && payload.epss.score != null ? double(payload.epss.score) : 0.0`}, + {Key: entityops.InputKeyVulnerabilityPackageName, Expr: `'source' in payload && payload.source != null && 'name' in payload.source ? payload.source.name : ""`}, + {Key: entityops.InputKeyVulnerabilityPackageEcosystem, Expr: `'source' in payload && payload.source != null && 'packageManager' in payload.source ? payload.source.packageManager : ""`}, + {Key: entityops.InputKeyVulnerabilityVulnerableVersionRange, Expr: `'affectedVersionRanges' in payload && payload.affectedVersionRanges != null && size(payload.affectedVersionRanges) > 0 ? payload.affectedVersionRanges.join(", ") : ""`}, + {Key: entityops.InputKeyVulnerabilityFixAvailable, Expr: `'remediation' in payload && payload.remediation != null && 'completeFix' in payload.remediation && payload.remediation.completeFix != null && payload.remediation.completeFix != ""`}, + {Key: entityops.InputKeyVulnerabilityFirstPatchedVersion, Expr: `'remediation' in payload && payload.remediation != null && 'completeFix' in payload.remediation && payload.remediation.completeFix != null ? payload.remediation.completeFix : ""`}, + {Key: entityops.InputKeyVulnerabilityDependencyScope, Expr: `dyn('depths' in payload && payload.depths != null && 'direct' in payload.depths && payload.depths.direct > 0 ? "DIRECT" : "TRANSITIVE")`}, + {Key: entityops.InputKeyVulnerabilityReferences, Expr: `'references' in payload && payload.references != null ? payload.references : []`}, + {Key: entityops.InputKeyVulnerabilityExternalURI, Expr: `'url' in payload && payload.url != null ? payload.url : ""`}, + {Key: entityops.InputKeyVulnerabilityExternalOwnerID, Expr: `resource != "" ? resource : ""`}, + {Key: entityops.InputKeyVulnerabilityCategory, Expr: `'type' in payload && payload.type != null ? payload.type : ""`}, + {Key: entityops.InputKeyVulnerabilityOpen, Expr: `'statuses' in payload && payload.statuses != null && 'active' in payload.statuses ? payload.statuses.active > 0 : false`}, + {Key: entityops.InputKeyVulnerabilityVulnerabilityStatusName, Expr: `dyn('statuses' in payload && payload.statuses != null && 'active' in payload.statuses && payload.statuses.active > 0 ? "ACTIVE" : "IGNORED")`}, + {Key: entityops.InputKeyVulnerabilityPublishedAt, Expr: `'published' in payload ? payload.published : null`}, + {Key: entityops.InputKeyVulnerabilityDiscoveredAt, Expr: `'createdAt' in payload ? payload.createdAt : null`}, + {Key: entityops.InputKeyVulnerabilitySourceUpdatedAt, Expr: `'projects' in payload && payload.projects != null && size(payload.projects) > 0 && payload.projects[0] != null && 'scannedAt' in payload.projects[0] ? payload.projects[0].scannedAt : null`}, + // metadata carries the remediation guidance and scoring detail that has no dedicated field: + // the partial fix and upgrade distances, the EPSS percentile, and the CVSS metric breakdown + {Key: entityops.InputKeyVulnerabilityMetadata, Expr: `{ + "remediation": 'remediation' in payload && payload.remediation != null ? payload.remediation : {}, + "epss": 'epss' in payload && payload.epss != null ? payload.epss : {}, + "cvssMetrics": 'metrics' in payload && payload.metrics != null ? payload.metrics : [], + "fossaVulnId": 'vulnId' in payload && payload.vulnId != null ? payload.vulnId : "", + "cveStatus": 'cveStatus' in payload && payload.cveStatus != null ? payload.cveStatus : "", + "fossaExploitability": 'exploitability' in payload && payload.exploitability != null ? payload.exploitability : "", + "patchedVersionRanges": 'patchedVersionRanges' in payload && payload.patchedVersionRanges != null ? payload.patchedVersionRanges : [] + }`}, + {Key: entityops.InputKeyVulnerabilitySource, Expr: `"FOSSA"`}, + {Key: entityops.InputKeyVulnerabilityRawPayload, Expr: "payload"}, +}) + +// mapExprFinding is the CEL mapping expression for FOSSA OSS license compliance issues. +// +// Licensing issues carry no severity or score, so those fields are deliberately left unmapped +// rather than given a fabricated value. +var mapExprFinding = providerkit.CelMapExpr([]providerkit.CelMapEntry{ + {Key: entityops.InputKeyFindingExternalID, Expr: `'id' in payload && payload.id != null ? string(payload.id) : ""`}, + {Key: entityops.InputKeyFindingDisplayName, Expr: `('license' in payload && payload.license != null && payload.license != "" ? payload.license : "License policy issue") + ('source' in payload && payload.source != null && 'name' in payload.source && payload.source.name != "" ? " in " + payload.source.name : "")`}, + {Key: entityops.InputKeyFindingDescription, Expr: `'details' in payload && payload.details != null ? payload.details : ""`}, + {Key: entityops.InputKeyFindingCategory, Expr: `'type' in payload && payload.type != null ? payload.type : ""`}, + {Key: entityops.InputKeyFindingCategories, Expr: `'type' in payload && payload.type != null ? [payload.type] : []`}, + {Key: entityops.InputKeyFindingResourceName, Expr: `'source' in payload && payload.source != null && 'name' in payload.source ? payload.source.name : ""`}, + {Key: entityops.InputKeyFindingExternalOwnerID, Expr: `resource != "" ? resource : ""`}, + {Key: entityops.InputKeyFindingExternalURI, Expr: `'url' in payload && payload.url != null ? payload.url : ""`}, + {Key: entityops.InputKeyFindingOpen, Expr: `'statuses' in payload && payload.statuses != null && 'active' in payload.statuses ? payload.statuses.active > 0 : false`}, + {Key: entityops.InputKeyFindingFindingStatusName, Expr: `dyn('statuses' in payload && payload.statuses != null && 'active' in payload.statuses && payload.statuses.active > 0 ? "ACTIVE" : "IGNORED")`}, + {Key: entityops.InputKeyFindingReportedAt, Expr: `'createdAt' in payload ? payload.createdAt : null`}, + {Key: entityops.InputKeyFindingSourceUpdatedAt, Expr: `'projects' in payload && payload.projects != null && size(payload.projects) > 0 && payload.projects[0] != null && 'scannedAt' in payload.projects[0] ? payload.projects[0].scannedAt : null`}, + {Key: entityops.InputKeyFindingTargets, Expr: `'projects' in payload && payload.projects != null && size(payload.projects) > 0 ? payload.projects.filter(p, p != null && 'id' in p).map(p, p.id) : []`}, + {Key: entityops.InputKeyFindingTargetDetails, Expr: `'projects' in payload && payload.projects != null && size(payload.projects) > 0 ? indexBy(payload.projects.filter(p, p != null && 'id' in p), "id") : {}`}, + {Key: entityops.InputKeyFindingSource, Expr: `"FOSSA"`}, + {Key: entityops.InputKeyFindingRawPayload, Expr: "payload"}, +}) diff --git a/internal/integrations/definitions/fossa/mappings_test.go b/internal/integrations/definitions/fossa/mappings_test.go new file mode 100644 index 0000000000..38c0978e5b --- /dev/null +++ b/internal/integrations/definitions/fossa/mappings_test.go @@ -0,0 +1,212 @@ +package fossa + +import ( + "encoding/json" + "testing" + + "gotest.tools/v3/assert" + + "github.com/theopenlane/core/internal/integrations/mappingtest" + "github.com/theopenlane/core/internal/integrations/providerkit" + "github.com/theopenlane/core/internal/integrations/types" +) + +func TestMappingExpressionsValid(t *testing.T) { + def, err := Builder()() + assert.NilError(t, err) + + for _, m := range def.Mappings { + name := m.Schema + if m.Variant != "" { + name += "/" + m.Variant + } + + t.Run(name+"/filter", func(t *testing.T) { + assert.NilError(t, providerkit.ValidateExpr(m.Spec.FilterExpr)) + }) + + t.Run(name+"/map", func(t *testing.T) { + assert.NilError(t, providerkit.ValidateExpr(m.Spec.MapExpr)) + }) + } +} + +// TestNullArrayPayloads guards against CEL "no such overload: size" errors that occur when +// array and object fields like projects, cwes, references or source are present in the payload +// but carry an explicit null value rather than being absent. +func TestNullArrayPayloads(t *testing.T) { + def, err := Builder()() + assert.NilError(t, err) + + vulnSpec := mappingtest.MappingSpec(t, def.Mappings, "Vulnerability") + findingSpec := mappingtest.MappingSpec(t, def.Mappings, "Finding") + + t.Run("vulnerability_null_fields", func(t *testing.T) { + payload, err := json.Marshal(map[string]any{ + "id": 20062524, + "type": "vulnerability", + "source": nil, + "projects": nil, + "cwes": nil, + "references": nil, + "affectedVersionRanges": nil, + "remediation": nil, + "epss": nil, + "statuses": nil, + "depths": nil, + "severity": nil, + "cvss": nil, + }) + assert.NilError(t, err) + + mapped := mappingtest.EvalMap(t, vulnSpec, types.MappingEnvelope{Payload: json.RawMessage(payload)}) + + assert.Equal(t, "20062524", mapped["external_id"]) + assert.Equal(t, "", mapped["cve_id"]) + assert.Equal(t, "", mapped["severity"]) + assert.Equal(t, float64(0), mapped["score"]) + assert.Equal(t, float64(0), mapped["exploitability"]) + assert.Equal(t, "", mapped["package_name"]) + assert.Equal(t, "", mapped["package_ecosystem"]) + assert.Equal(t, "", mapped["vulnerable_version_range"]) + assert.Equal(t, false, mapped["fix_available"]) + assert.Equal(t, "", mapped["first_patched_version"]) + assert.Equal(t, "TRANSITIVE", mapped["dependency_scope"]) + assert.Equal(t, false, mapped["open"]) + assert.DeepEqual(t, []any{}, mapped["cwe_ids"]) + assert.DeepEqual(t, []any{}, mapped["references"]) + assert.DeepEqual(t, map[string]any{}, mapped["metadata"].(map[string]any)["remediation"]) + }) + + t.Run("finding_null_fields", func(t *testing.T) { + payload, err := json.Marshal(map[string]any{ + "id": 20012351, + "type": "policy_flag", + "source": nil, + "projects": nil, + "statuses": nil, + "license": nil, + }) + assert.NilError(t, err) + + mapped := mappingtest.EvalMap(t, findingSpec, types.MappingEnvelope{Payload: json.RawMessage(payload)}) + + assert.Equal(t, "20012351", mapped["external_id"]) + assert.Equal(t, "policy_flag", mapped["category"]) + assert.Equal(t, "", mapped["resource_name"]) + assert.Equal(t, false, mapped["open"]) + assert.DeepEqual(t, []any{}, mapped["targets"]) + assert.DeepEqual(t, map[string]any{}, mapped["target_details"]) + }) +} + +// TestIntegralCvssScore guards the double() coercion on cvss. FOSSA reports whole-number scores +// as JSON integers, and the mapping layer normalizes whole floats to integers before evaluation, +// so an uncoerced expression would fail to produce a float64 for the score field. +func TestIntegralCvssScore(t *testing.T) { + def, err := Builder()() + assert.NilError(t, err) + + vulnSpec := mappingtest.MappingSpec(t, def.Mappings, "Vulnerability") + + payload, err := json.Marshal(map[string]any{ + "id": 1, + "cvss": 10, + "epss": map[string]any{"score": 0}, + }) + assert.NilError(t, err) + + mapped := mappingtest.EvalMap(t, vulnSpec, types.MappingEnvelope{Payload: json.RawMessage(payload)}) + + assert.Equal(t, float64(10), mapped["score"]) + assert.Equal(t, float64(0), mapped["exploitability"]) +} + +func TestExamplePayloads(t *testing.T) { + def, err := Builder()() + assert.NilError(t, err) + + vulnSpec := mappingtest.MappingSpec(t, def.Mappings, "Vulnerability") + findingSpec := mappingtest.MappingSpec(t, def.Mappings, "Finding") + + t.Run("vulnerability_json", func(t *testing.T) { + envelope := types.MappingEnvelope{ + Resource: "git+github.com/example-org/example-repo", + Payload: mappingtest.LoadExample(t, "examples", "vulnerability.json"), + } + + assert.Assert(t, mappingtest.AssertFiltered(t, vulnSpec, envelope), "expected vulnerability.json to pass the Vulnerability filter") + + mapped := mappingtest.EvalMap(t, vulnSpec, envelope) + + assert.Equal(t, "20062524", mapped["external_id"]) + assert.Equal(t, "CVE-2025-69873", mapped["cve_id"]) + assert.Equal(t, "CVE-2025-69873", mapped["display_name"]) + assert.Equal(t, "Inefficient Regular Expression Complexity", mapped["summary"]) + assert.Equal(t, "LOW", mapped["severity"]) + assert.Equal(t, 2.9, mapped["score"]) + assert.Equal(t, "CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", mapped["vector"]) + assert.Equal(t, 0.00492, mapped["exploitability"]) + assert.Equal(t, "ajv", mapped["package_name"]) + assert.Equal(t, "npm", mapped["package_ecosystem"]) + assert.Equal(t, "<6.14.0, >=7.0.0-alpha.0,<8.18.0", mapped["vulnerable_version_range"]) + assert.Equal(t, true, mapped["fix_available"]) + assert.Equal(t, "6.14.0", mapped["first_patched_version"]) + assert.Equal(t, "TRANSITIVE", mapped["dependency_scope"]) + assert.Equal(t, "vulnerability", mapped["category"]) + assert.Equal(t, true, mapped["open"]) + assert.Equal(t, "ACTIVE", mapped["vulnerability_status_name"]) + assert.Equal(t, "git+github.com/example-org/example-repo", mapped["external_owner_id"]) + assert.Equal(t, "https://app.fossa.com/issues/vulnerability/20062524", mapped["external_uri"]) + assert.Equal(t, "2026-02-11T19:15:50.000Z", mapped["published_at"]) + assert.Equal(t, "2026-08-18T04:33:32.561Z", mapped["discovered_at"]) + assert.Equal(t, "2026-08-18T04:33:33.54962+00:00", mapped["source_updated_at"]) + assert.Equal(t, "FOSSA", mapped["source"]) + assert.DeepEqual(t, []any{"CWE-1333", "CWE-400"}, mapped["cwe_ids"]) + + // remediation guidance is a ticket requirement: the complete fix is promoted to a first + // class field, and the partial fix plus upgrade distances are preserved in metadata + metadata, ok := mapped["metadata"].(map[string]any) + assert.Assert(t, ok, "expected metadata to be an object") + + remediation, ok := metadata["remediation"].(map[string]any) + assert.Assert(t, ok, "expected metadata.remediation to be an object") + + assert.Equal(t, "6.14.0", remediation["completeFix"]) + assert.Equal(t, "6.14.0", remediation["partialFix"]) + assert.Equal(t, "MINOR", remediation["completeFixDistance"]) + assert.Equal(t, "MINOR", remediation["partialFixDistance"]) + + assert.Equal(t, "CVE-2025-69873_npm+ajv", metadata["fossaVulnId"]) + assert.Equal(t, "COMPLETED", metadata["cveStatus"]) + assert.Equal(t, "UNKNOWN", metadata["fossaExploitability"]) + + epss, ok := metadata["epss"].(map[string]any) + assert.Assert(t, ok, "expected metadata.epss to be an object") + assert.Equal(t, 0.40057, epss["percentile"]) + }) + + t.Run("finding_json", func(t *testing.T) { + envelope := types.MappingEnvelope{ + Resource: "git+github.com/example-org/example-repo", + Payload: mappingtest.LoadExample(t, "examples", "finding.json"), + } + + assert.Assert(t, mappingtest.AssertFiltered(t, findingSpec, envelope), "expected finding.json to pass the Finding filter") + + mapped := mappingtest.EvalMap(t, findingSpec, envelope) + + assert.Equal(t, "20012351", mapped["external_id"]) + assert.Equal(t, "AGPL-3.0-only in github.com/fumiama/go-docx", mapped["display_name"]) + assert.Equal(t, "policy_flag", mapped["category"]) + assert.Equal(t, "github.com/fumiama/go-docx", mapped["resource_name"]) + assert.Equal(t, "git+github.com/example-org/example-repo", mapped["external_owner_id"]) + assert.Equal(t, "https://app.fossa.com/issues/licensing/20012351", mapped["external_uri"]) + assert.Equal(t, true, mapped["open"]) + assert.Equal(t, "ACTIVE", mapped["finding_status_name"]) + assert.Equal(t, "2026-08-16T03:49:42.532Z", mapped["reported_at"]) + assert.Equal(t, "FOSSA", mapped["source"]) + assert.DeepEqual(t, []any{"policy_flag"}, mapped["categories"]) + assert.DeepEqual(t, []any{"git+github.com/example-org/example-repo"}, mapped["targets"]) + }) +} diff --git a/internal/integrations/definitions/fossa/operation_health.go b/internal/integrations/definitions/fossa/operation_health.go new file mode 100644 index 0000000000..ddd7e1433f --- /dev/null +++ b/internal/integrations/definitions/fossa/operation_health.go @@ -0,0 +1,53 @@ +package fossa + +import ( + "context" + "encoding/json" + + "github.com/theopenlane/core/internal/integrations/providerkit" + "github.com/theopenlane/core/internal/integrations/types" + "github.com/theopenlane/core/pkg/logx" +) + +// HealthCheck holds the result of a FOSSA health check +type HealthCheck struct { + // OrganizationID is the FOSSA organization the token authenticates against + OrganizationID string `json:"organizationId,omitempty"` + // Subscription is the FOSSA subscription tier for the organization + Subscription string `json:"subscription,omitempty"` + // IssueCounts is the number of open issues per FOSSA issue category + IssueCounts map[string]int `json:"issueCounts,omitempty"` +} + +// Handle adapts the health check to the generic operation registration boundary +func (h HealthCheck) Handle() types.OperationHandler { + return providerkit.WithClientRequest(fossaClient, func(ctx context.Context, _ types.OperationRequest, client *APIClient) (json.RawMessage, error) { + return h.Run(ctx, client) + }) +} + +// Run validates FOSSA access by reading the organization details and issue category counts. +// Both calls require read access, so a push-only token fails here rather than at collection time. +func (HealthCheck) Run(ctx context.Context, c *APIClient) (json.RawMessage, error) { + org, err := c.organization(ctx) + if err != nil { + logx.FromContext(ctx).Error().Err(err).Msg("fossa: error fetching organization details") + + return nil, err + } + + counts, err := c.issueCategories(ctx) + if err != nil { + logx.FromContext(ctx).Error().Err(err).Msg("fossa: error fetching issue categories") + + return nil, ErrCategoriesFetchFailed + } + + details := HealthCheck{ + OrganizationID: org.identifier(), + Subscription: org.Subscription, + IssueCounts: counts, + } + + return providerkit.EncodeResult(details, ErrResultEncode) +} diff --git a/internal/integrations/definitions/fossa/operation_vulnerability_sync.go b/internal/integrations/definitions/fossa/operation_vulnerability_sync.go new file mode 100644 index 0000000000..6d904d6bee --- /dev/null +++ b/internal/integrations/definitions/fossa/operation_vulnerability_sync.go @@ -0,0 +1,126 @@ +package fossa + +import ( + "context" + "encoding/json" + + "github.com/theopenlane/core/internal/ent/entityops" + "github.com/theopenlane/core/internal/integrations/providerkit" + "github.com/theopenlane/core/internal/integrations/types" + "github.com/theopenlane/core/pkg/logx" +) + +const ( + // issuePageSize is the page size requested from the issues endpoint + issuePageSize = 100 + // maxIssuePages bounds a single category sweep so a misbehaving API can never page forever + maxIssuePages = 200 +) + +// VulnerabilityCollect collects FOSSA issues for vulnerability and finding ingestion +type VulnerabilityCollect struct{} + +// IngestHandle adapts issue collection to the ingest operation registration boundary +func (v VulnerabilityCollect) IngestHandle() types.IngestHandler { + return providerkit.WithClientRequestConfig(fossaClient, vulnerabilitySyncOperation, ErrOperationConfigInvalid, + func(ctx context.Context, _ types.OperationRequest, client *APIClient, cfg VulnerabilitySync) ([]types.IngestPayloadSet, error) { + return v.Run(ctx, client, cfg) + }) +} + +// Run collects FOSSA issues, routing security vulnerabilities to the vulnerability schema and +// OSS license policy issues to the finding schema. Vulnerabilities are always collected; license +// findings are collected only when the installation has opted in. +func (VulnerabilityCollect) Run(ctx context.Context, c *APIClient, cfg VulnerabilitySync) ([]types.IngestPayloadSet, error) { + status := statusActive + if cfg.IncludeIgnored { + status = statusAll + } + + vulnerabilityEnvelopes, err := collectCategory(ctx, c, categoryVulnerability, status) + if err != nil { + return nil, err + } + + payloads := []types.IngestPayloadSet{ + { + Schema: entityops.SchemaVulnerability.Name, + Envelopes: vulnerabilityEnvelopes, + }, + } + + if !cfg.EnableLicenseFindings { + logx.FromContext(ctx).Debug().Msg("fossa: license compliance findings are disabled, skipping the licensing category") + + return payloads, nil + } + + findingEnvelopes, err := collectCategory(ctx, c, categoryLicensing, status) + if err != nil { + return nil, err + } + + return append(payloads, types.IngestPayloadSet{ + Schema: entityops.SchemaFinding.Name, + Envelopes: findingEnvelopes, + }), nil +} + +// collectCategory pages through every issue in one FOSSA category and builds ingest envelopes. +// +// FOSSA returns no total count and no next-page cursor, so paging stops on the first page that +// contributes no issue the sweep has not already seen. That covers both an empty trailing page and +// an API that ignores the page parameter and keeps replaying the same results. +func collectCategory(ctx context.Context, c *APIClient, category, status string) ([]types.MappingEnvelope, error) { + var ( + envelopes []types.MappingEnvelope + seen = map[int64]struct{}{} + ) + + for page := 1; page <= maxIssuePages; page++ { + if err := ctx.Err(); err != nil { + return nil, err + } + + issues, err := c.listIssues(ctx, category, status, page) + if err != nil { + logx.FromContext(ctx).Error().Err(err).Str("category", category).Msg("fossa: error listing issues") + + return nil, ErrIssuesFetchFailed + } + + if len(issues) == 0 { + return envelopes, nil + } + + added := 0 + + for _, issue := range issues { + identity := issueIdentity{} + if err := json.Unmarshal(issue, &identity); err != nil { + return nil, ErrIssueEncode + } + + if _, duplicate := seen[identity.ID]; duplicate { + continue + } + + seen[identity.ID] = struct{}{} + + envelopes = append(envelopes, providerkit.RawEnvelope(identity.resourceID(), issue)) + added++ + } + + if added == 0 { + return envelopes, nil + } + } + + logx.FromContext(ctx).Warn(). + Str("category", category). + Int("max_pages", maxIssuePages). + Int("collected", len(envelopes)). + Msg("fossa: reached the maximum page count, some issues may not have been collected") + + return envelopes, nil +} diff --git a/internal/integrations/definitions/fossa/operation_vulnerability_sync_test.go b/internal/integrations/definitions/fossa/operation_vulnerability_sync_test.go new file mode 100644 index 0000000000..a0b9343b89 --- /dev/null +++ b/internal/integrations/definitions/fossa/operation_vulnerability_sync_test.go @@ -0,0 +1,259 @@ +package fossa + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/theopenlane/core/internal/ent/entityops" + "github.com/theopenlane/core/internal/integrations/types" +) + +// issuePage renders a page of issues with sequential IDs starting at start +func issuePage(category string, start, count int) string { + issues := make([]string, 0, count) + + for i := range count { + issues = append(issues, fmt.Sprintf( + `{"id":%d,"type":%q,"projects":[{"id":"git+github.com/example-org/example-repo"}]}`, + start+i, category)) + } + + return `{"issues":[` + strings.Join(issues, ",") + `]}` +} + +// envelopeIDs extracts the issue IDs carried by a set of envelopes +func envelopeIDs(t *testing.T, envelopes []types.MappingEnvelope) []int64 { + t.Helper() + + ids := make([]int64, 0, len(envelopes)) + + for _, envelope := range envelopes { + identity := issueIdentity{} + require.NoError(t, json.Unmarshal(envelope.Payload, &identity)) + + ids = append(ids, identity.ID) + } + + return ids +} + +// payloadSet returns the envelopes registered for one schema +func payloadSet(payloads []types.IngestPayloadSet, schema string) ([]types.MappingEnvelope, bool) { + for _, set := range payloads { + if set.Schema == schema { + return set.Envelopes, true + } + } + + return nil, false +} + +// TestCollectPagesUntilEmpty verifies a normal multi-page sweep collects every issue and stops on +// the first empty page +func TestCollectPagesUntilEmpty(t *testing.T) { + var requests int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requests, 1) + + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(issuePage(categoryVulnerability, 1, 3))) + case "2": + _, _ = w.Write([]byte(issuePage(categoryVulnerability, 4, 2))) + default: + _, _ = w.Write([]byte(`{"issues":[]}`)) + } + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + payloads, err := VulnerabilityCollect{}.Run(context.Background(), client, VulnerabilitySync{}) + require.NoError(t, err) + + envelopes, ok := payloadSet(payloads, entityops.SchemaVulnerability.Name) + require.True(t, ok) + + assert.Equal(t, []int64{1, 2, 3, 4, 5}, envelopeIDs(t, envelopes)) + assert.Equal(t, int32(3), atomic.LoadInt32(&requests), "expected two content pages plus one empty page") + assert.Equal(t, "git+github.com/example-org/example-repo", envelopes[0].Resource) +} + +// TestCollectTerminatesWhenPageParamIgnored is the critical pagination guard. FOSSA returns no +// total and no cursor, and the observed API ignores the count parameter, so an implementation that +// keyed termination off the returned page size would loop forever against a server that replays +// the same results for every page. Termination must come from seeing no new issue IDs. +func TestCollectTerminatesWhenPageParamIgnored(t *testing.T) { + var requests int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&requests, 1) + + w.Header().Set("Content-Type", "application/json") + // always the same five issues, whatever page was requested + _, _ = w.Write([]byte(issuePage(categoryVulnerability, 1, 5))) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + payloads, err := VulnerabilityCollect{}.Run(context.Background(), client, VulnerabilitySync{}) + require.NoError(t, err) + + envelopes, ok := payloadSet(payloads, entityops.SchemaVulnerability.Name) + require.True(t, ok) + + assert.Equal(t, []int64{1, 2, 3, 4, 5}, envelopeIDs(t, envelopes), "duplicates must not be ingested twice") + assert.Equal(t, int32(2), atomic.LoadInt32(&requests), "must stop after the first page that adds nothing new") + assert.Less(t, int(atomic.LoadInt32(&requests)), maxIssuePages, "must not fall through to the page cap") +} + +// TestLicenseFindingsDisabledByDefault verifies the ticket's core config contract: security +// vulnerabilities are always collected, license compliance findings only on opt in +func TestLicenseFindingsDisabledByDefault(t *testing.T) { + categories := map[string]int32{} + + newServer := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + category := r.URL.Query().Get("category") + categories[category]++ + + w.Header().Set("Content-Type", "application/json") + + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`{"issues":[]}`)) + + return + } + + _, _ = w.Write([]byte(issuePage(category, 1, 2))) + })) + } + + t.Run("disabled by default", func(t *testing.T) { + categories = map[string]int32{} + + server := newServer() + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + payloads, err := VulnerabilityCollect{}.Run(context.Background(), client, VulnerabilitySync{}) + require.NoError(t, err) + + vulns, ok := payloadSet(payloads, entityops.SchemaVulnerability.Name) + require.True(t, ok) + assert.Len(t, vulns, 2) + + _, hasFindings := payloadSet(payloads, entityops.SchemaFinding.Name) + assert.False(t, hasFindings, "no finding payload set may be emitted while license findings are disabled") + + assert.Zero(t, categories[categoryLicensing], "the licensing category must not be requested") + assert.NotZero(t, categories[categoryVulnerability]) + }) + + t.Run("enabled by config", func(t *testing.T) { + categories = map[string]int32{} + + server := newServer() + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + payloads, err := VulnerabilityCollect{}.Run(context.Background(), client, VulnerabilitySync{EnableLicenseFindings: true}) + require.NoError(t, err) + + vulns, ok := payloadSet(payloads, entityops.SchemaVulnerability.Name) + require.True(t, ok) + assert.Len(t, vulns, 2) + + findings, hasFindings := payloadSet(payloads, entityops.SchemaFinding.Name) + require.True(t, hasFindings) + assert.Len(t, findings, 2) + + assert.NotZero(t, categories[categoryLicensing]) + }) +} + +// TestIncludeIgnoredStatus verifies the status parameter reflects the include ignored toggle +func TestIncludeIgnoredStatus(t *testing.T) { + tests := []struct { + name string + cfg VulnerabilitySync + expected string + }{ + {name: "active only by default", cfg: VulnerabilitySync{}, expected: statusActive}, + {name: "all when including ignored", cfg: VulnerabilitySync{IncludeIgnored: true}, expected: statusAll}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotStatus string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotStatus = r.URL.Query().Get("status") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues":[]}`)) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + _, err = VulnerabilityCollect{}.Run(context.Background(), client, tt.cfg) + require.NoError(t, err) + + assert.Equal(t, tt.expected, gotStatus) + }) + } +} + +// TestCollectPropagatesAPIErrors verifies a failed page aborts the sweep rather than silently +// ingesting a partial result set +func TestCollectPropagatesAPIErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + _, err = VulnerabilityCollect{}.Run(context.Background(), client, VulnerabilitySync{}) + assert.ErrorIs(t, err, ErrIssuesFetchFailed) +} + +// TestCollectHonorsContextCancellation verifies the sweep stops when the context is cancelled +func TestCollectHonorsContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(issuePage(categoryVulnerability, 1, 2))) + })) + defer server.Close() + + client, err := NewAPIClient(server.URL, "token-123") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = VulnerabilityCollect{}.Run(ctx, client, VulnerabilitySync{}) + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/internal/integrations/definitions/fossa/types.go b/internal/integrations/definitions/fossa/types.go new file mode 100644 index 0000000000..fd146bcb18 --- /dev/null +++ b/internal/integrations/definitions/fossa/types.go @@ -0,0 +1,63 @@ +package fossa + +import ( + "github.com/theopenlane/core/internal/integrations/providerkit" + "github.com/theopenlane/core/internal/integrations/types" +) + +var ( + // definitionID is the stable identifier for the FOSSA integration definition + definitionID = types.NewDefinitionRef("def_01K0FOSSA00000000000000001") + // installation is the typed installation metadata handle for the FOSSA definition + installation = types.NewInstallationRef(resolveInstallationMetadata) + // fossaSchema is the credential schema for the FOSSA API token + fossaSchema, fossaCredential = providerkit.CredentialSchema[CredentialSchema]() + // fossaClient is the client ref for the FOSSA REST API client used by this definition + fossaClient = types.NewClientRef[*APIClient]() + // healthCheckSchema is the operation schema for the FOSSA health check operation + healthCheckSchema, healthCheckOperation = providerkit.OperationSchema[HealthCheck]() + // vulnerabilitySyncSchema is the operation schema for the FOSSA vulnerability sync operation + vulnerabilitySyncSchema, vulnerabilitySyncOperation = providerkit.OperationSchema[VulnerabilitySync]() +) + +// UserInput holds installation-specific configuration collected from the user +type UserInput struct { + // VulnerabilitySync includes the configuration for issues collected from FOSSA + VulnerabilitySync VulnerabilitySync `json:"vulnerabilitySync,omitempty" jsonschema:"title=FOSSA Issue Sync"` +} + +// VulnerabilitySync are the configuration settings for the FOSSA issue sync. +// There is deliberately no disable flag; security vulnerability collection is always on. +type VulnerabilitySync struct { + // EnableLicenseFindings opts in to OSS license compliance issues, which are not collected by default + EnableLicenseFindings bool `json:"enableLicenseFindings,omitempty" jsonschema:"title=Enable License Compliance Findings,description=Also collect FOSSA OSS license policy issues as findings. Security vulnerabilities are always collected."` + // IncludeIgnored collects issues that have been dismissed in FOSSA in addition to active ones + IncludeIgnored bool `json:"includeIgnored,omitempty" jsonschema:"title=Include Ignored Issues,description=Include issues that have been dismissed in FOSSA. By default only active issues are collected."` + // FilterExpr limits imported records to envelopes matching the CEL expression + FilterExpr string `json:"filterExpr,omitempty" jsonschema:"title=Filter Expression,description=Optional CEL expression to apply to records before ingesting,example=Example: payload.severity == 'critical' || payload.severity == 'high'"` +} + +// CredentialSchema holds the FOSSA API credentials for one installation +type CredentialSchema struct { + // APIToken is the FOSSA API token used to authenticate requests + APIToken string `json:"apiToken" jsonschema:"required,title=API Token,secret=true,description=FOSSA API token with full access. Push-only tokens cannot read issues."` + // BaseURL is the FOSSA API base URL, overridden only for on-premise deployments + BaseURL string `json:"baseUrl,omitempty" jsonschema:"title=Base URL,description=FOSSA base URL. Leave blank to use https://app.fossa.com."` +} + +// InstallationMetadata holds the stable FOSSA organization identity for one installation +type InstallationMetadata struct { + // OrganizationID is the FOSSA organization identifier + OrganizationID string `json:"organizationId,omitempty" jsonschema:"title=Organization ID"` + // BaseURL is the FOSSA base URL used for this installation + BaseURL string `json:"baseUrl,omitempty" jsonschema:"title=Base URL"` + // Subscription is the FOSSA subscription tier reported for the organization + Subscription string `json:"subscription,omitempty" jsonschema:"title=Subscription"` +} + +// InstallationIdentity implements types.InstallationIdentifiable +func (m InstallationMetadata) InstallationIdentity() types.IntegrationInstallationIdentity { + return types.IntegrationInstallationIdentity{ + ExternalID: m.OrganizationID, + } +}