Skip to content
2 changes: 2 additions & 0 deletions internal/integrations/definitions/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
118 changes: 118 additions & 0 deletions internal/integrations/definitions/fossa/api.go
Original file line number Diff line number Diff line change
@@ -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
}
113 changes: 113 additions & 0 deletions internal/integrations/definitions/fossa/builder.go
Original file line number Diff line number Diff line change
@@ -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
})
}
125 changes: 125 additions & 0 deletions internal/integrations/definitions/fossa/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading