diff --git a/workspaces/scorecard/examples/openssf-scorecard-only.yaml b/workspaces/scorecard/examples/openssf-scorecard-only.yaml new file mode 100644 index 0000000000..00adb4328d --- /dev/null +++ b/workspaces/scorecard/examples/openssf-scorecard-only.yaml @@ -0,0 +1,14 @@ +--- +# Component with OpenSSF Scorecard +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: openssf-scorecard-only + annotations: + github.com/project-slug: backstage/backstage + openssf/project: backstage/backstage + backstage.io/source-location: url:https://github.com/backstage/backstage +spec: + type: service + owner: guests + lifecycle: experimental diff --git a/workspaces/scorecard/packages/backend/package.json b/workspaces/scorecard/packages/backend/package.json index 23c935c0b9..6f3dbb7da9 100644 --- a/workspaces/scorecard/packages/backend/package.json +++ b/workspaces/scorecard/packages/backend/package.json @@ -48,6 +48,7 @@ "@red-hat-developer-hub/backstage-plugin-scorecard-backend": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf": "workspace:^", "app": "link:../app", "better-sqlite3": "^9.0.0", "node-gyp": "^10.0.0", diff --git a/workspaces/scorecard/packages/backend/src/index.ts b/workspaces/scorecard/packages/backend/src/index.ts index e8b86ef04c..fabda70bd2 100644 --- a/workspaces/scorecard/packages/backend/src/index.ts +++ b/workspaces/scorecard/packages/backend/src/index.ts @@ -72,4 +72,9 @@ backend.add( '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira' ), ); +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf' + ), +); backend.start(); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/README.md new file mode 100644 index 0000000000..1bbaf6fdae --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/README.md @@ -0,0 +1,127 @@ +# Scorecard Backend Module for OpenSSF + +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides [OpenSSF Security Scorecard](https://securityscorecards.dev/) metrics for software components registered in the Backstage catalog. + +## Overview + +The OpenSSF Security Scorecards project provides automated security assessments for open source projects hosted on GitHub. This module fetches scorecard data from the public OpenSSF API and exposes individual security check scores as metrics in Backstage. + +## Prerequisites + +Before installing this module, ensure that the Scorecard backend plugin is integrated into your Backstage instance. Follow the [Scorecard backend plugin README](../scorecard-backend/README.md) for setup instructions. + +## Installation + +To install this backend module: + +```bash +# From your root directory +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf +``` + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +// Scorecard backend plugin +backend.add( + import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), +); + +// Install the OpenSSF module +/* highlight-add-next-line */ +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf' + ), +); + +backend.start(); +``` + +## Entity Annotations + +For the OpenSSF metrics to work, your catalog entities must have the required annotation: + +```yaml +# catalog-info.yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: my-service + annotations: + # Required: GitHub repository in owner/repo format + openssf/project: owner/repo +spec: + type: service + lifecycle: production + owner: my-team +``` + +The `openssf/project` annotation should contain the GitHub repository path in `owner/repo` format (e.g., `kubernetes/kubernetes`). + +## Configuration + +This module uses the public OpenSSF Security Scorecards API (`api.securityscorecards.dev`) and does not require any additional configuration in `app-config.yaml`. + +### Thresholds + +Thresholds define conditions that determine which category a metric value belongs to (`error`, `warning`, or `success`). Check out detailed explanation of [threshold configuration](../scorecard-backend/docs/thresholds.md). + +All OpenSSF metrics use the following **fixed** thresholds: + +| Category | Expression | Description | +| -------- | ---------- | --------------------------------- | +| Error | `<2` | Score less than 2 | +| Warning | `2-7` | Score between 2 and 7 (inclusive) | +| Success | `>7` | Score greater than 7 | + +> **Note:** These thresholds are not configurable via `app-config.yaml`. They are defined in the module source code. + +## Available Metrics + +This module provides 18 metrics corresponding to the [OpenSSF Security Scorecard checks](https://github.com/ossf/scorecard/blob/main/docs/checks.md). Each metric returns a score from 0 to 10. + +| Metric ID | Risk | Description | +| -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | +| `openssf.binary_artifacts` | High | Determines if the project has generated executable (binary) artifacts in the source repository. | +| `openssf.branch_protection` | High | Determines if the default and release branches are protected with GitHub's branch protection or repository rules settings. | +| `openssf.cii_best_practices` | Low | Determines if the project has an OpenSSF (formerly CII) Best Practices Badge. | +| `openssf.ci_tests` | Low | Determines if the project runs tests before pull requests are merged. | +| `openssf.code_review` | High | Determines if the project requires human code review before pull requests are merged. | +| `openssf.contributors` | Low | Determines if the project has contributors from multiple organizations. | +| `openssf.dangerous_workflow` | Critical | Determines if the project's GitHub Action workflows avoid dangerous patterns. | +| `openssf.dependency_update_tool` | High | Determines if the project uses a dependency update tool. | +| `openssf.fuzzing` | Medium | Determines if the project uses fuzzing. | +| `openssf.license` | Low | Determines if the project has defined a license. | +| `openssf.maintained` | High | Determines if the project is "actively maintained". | +| `openssf.packaging` | Medium | Determines if the project is published as a package that others can easily download, install, update, and uninstall. | +| `openssf.pinned_dependencies` | Medium | Determines if the project has declared and pinned the dependencies of its build process. | +| `openssf.sast` | Medium | Determines if the project uses static code analysis. | +| `openssf.security_policy` | Medium | Determines if the project has published a security policy. | +| `openssf.signed_releases` | High | Determines if the project cryptographically signs release artifacts. | +| `openssf.token_permissions` | High | Determines if the project's automated workflow tokens follow the principle of least privilege. | +| `openssf.vulnerabilities` | High | Determines if the project has open, unfixed vulnerabilities in its codebase or dependencies using OSV. | + +## Troubleshooting + +### Metric shows "not found" + +This can occur if: + +- The repository has not been analyzed by OpenSSF Scorecards yet +- The repository is private (OpenSSF only analyzes public repositories) +- The repository path in the annotation is incorrect +- The metric score is lower than -1 or higher than 10. + +### No data for my repository + +OpenSSF Security Scorecards only analyzes **public GitHub repositories**. Private repositories and repositories on other Git hosting services are not supported. + +To verify your repository has scorecard data, visit: + +``` +https://api.securityscorecards.dev/projects/github.com/{owner}/{repo} +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/package.json new file mode 100644 index 0000000000..32455ebd58 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/package.json @@ -0,0 +1,47 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "description": "The openssf backend module for the scorecard plugin.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-openssf" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "scorecard", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-scorecard-backend" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.5.0", + "@backstage/catalog-client": "^1.12.1", + "@backstage/catalog-model": "^1.7.6", + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.10.0", + "@backstage/cli": "^0.34.5" + }, + "files": [ + "dist" + ] +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/report.api.md new file mode 100644 index 0000000000..b7526723bd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +const scorecardOpenSFFModule: BackendFeature; +export default scorecardOpenSFFModule; +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts new file mode 100644 index 0000000000..ca5f406255 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OpenSSFClient } from './OpenSSFClient'; +import { OpenSSFResponse } from './types'; + +describe('OpenSSFClient', () => { + let client: OpenSSFClient; + + const mockOpenSSFResponse: OpenSSFResponse = { + date: '2024-01-15', + repo: { + name: 'github.com/owner/test', + commit: 'abc123', + }, + scorecard: { + version: '4.0.0', + commit: 'def456', + }, + score: 7.5, + checks: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + client = new OpenSSFClient(); + globalThis.fetch = jest.fn(); + }); + + describe('getScorecard', () => { + it('should return the scorecard', async () => { + // mocked fetch behaviour for the test + (globalThis.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockOpenSSFResponse), + }); + + const scorecard = await client.getScorecard('owner', 'test'); + expect(scorecard).toEqual(mockOpenSSFResponse); + }); + + it('should throw an error if the API returns a non-ok response', async () => { + // mock response from the API + (globalThis.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect(client.getScorecard('wrong', 'test')).rejects.toThrow( + 'OpenSSF API request failed with status 404: Not Found', + ); + }); + + it('should throw an error if API request fails', async () => { + // mocked fetch behaviour for the test + (globalThis.fetch as jest.Mock).mockRejectedValue( + new Error('API request failed'), + ); + + await expect(client.getScorecard('owner', 'test')).rejects.toThrow( + 'API request failed', + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.ts new file mode 100644 index 0000000000..efda22c59f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.ts @@ -0,0 +1,51 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OpenSSFResponse } from './types'; + +export class OpenSSFClient { + private readonly baseUrl: string; + private readonly gitServiceHost: string; + + constructor( + baseUrl: string = 'https://api.securityscorecards.dev/projects', + gitServiceHost: string = 'github.com', + ) { + this.baseUrl = baseUrl; + this.gitServiceHost = gitServiceHost; + } + + async getScorecard(owner: string, repo: string): Promise { + const apiUrl = `${this.baseUrl}/${this.gitServiceHost}/${owner}/${repo}`; + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `OpenSSF API request failed with status ${response.status}: ${response.statusText}`, + ); + } + + const data: OpenSSFResponse = await response.json(); + + return data; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/types.ts new file mode 100644 index 0000000000..ce9b777410 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/types.ts @@ -0,0 +1,42 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface OpenSSFCheckDocumentation { + short: string; + url: string; +} + +export interface OpenSSFCheck { + name: string; + score: number; + reason: string | null; + details: string[] | null; + documentation: OpenSSFCheckDocumentation; +} + +export interface OpenSSFResponse { + date: string; + repo: { + name: string; + commit: string; + }; + scorecard: { + version: string; + commit: string; + }; + score: number; + checks: OpenSSFCheck[]; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/utils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/utils.ts new file mode 100644 index 0000000000..03ec0cf43d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type Entity, stringifyEntityRef } from '@backstage/catalog-model'; + +export const getRepositoryInformationFromEntity = ( + entity: Entity, +): { owner: string; repo: string } => { + const projectSlug = entity.metadata.annotations?.['openssf/project']; + if (!projectSlug) { + throw new Error( + `Missing annotation 'openssf/project' for entity ${stringifyEntityRef( + entity, + )}`, + ); + } + + const [owner, repo] = projectSlug.split('/'); + if (!owner || !repo) { + throw new Error( + `Invalid format of 'openssf/project' ${projectSlug} for entity ${stringifyEntityRef( + entity, + )}`, + ); + } + + return { owner, repo }; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/index.ts new file mode 100644 index 0000000000..934f1f3d6a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The openssf backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardOpenSFFModule as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/AbstractMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/AbstractMetricProvider.test.ts new file mode 100644 index 0000000000..5d51a618c3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/AbstractMetricProvider.test.ts @@ -0,0 +1,246 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { type Entity } from '@backstage/catalog-model'; +import { + DEFAULT_NUMBER_THRESHOLDS, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { OpenSSFClient } from '../clients/OpenSSFClient'; +import { OpenSSFResponse } from '../clients/types'; +import { AbstractMetricProvider } from './AbstractMetricProvider'; + +// Mock the OpenSSFClient module +jest.mock('../clients/OpenSSFClient'); + +// Concrete implementation for testing the abstract class +class TestMetricProvider extends AbstractMetricProvider { + getMetricName(): string { + return 'Test-Metric'; + } + + getMetricDisplayTitle(): string { + return 'Test Metric Title'; + } + + getMetricDescription(): string { + return 'Test metric description'; + } +} + +describe('AbstractMetricProvider', () => { + let provider: TestMetricProvider; + let mockGetScorecard: jest.Mock; + + const mockOpenSSFResponse: OpenSSFResponse = { + date: '2024-01-15', + repo: { + name: 'github.com/owner/test', + commit: 'abc123', + }, + scorecard: { + version: '4.0.0', + commit: 'def456', + }, + score: 7.5, + checks: [ + { + name: 'Test-Metric', + score: 8, + reason: 'Test reason', + details: null, + documentation: { + short: 'Short doc', + url: 'https://example.com', + }, + }, + { + name: 'Other-Metric', + score: 6, + reason: 'Other reason', + details: null, + documentation: { + short: 'Other doc', + url: 'https://example.com/other', + }, + }, + ], + }; + + const createMockEntity = (projectSlug?: string): Entity => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: projectSlug ? { 'openssf/project': projectSlug } : undefined, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock for OpenSSFClient + mockGetScorecard = jest.fn().mockResolvedValue(mockOpenSSFResponse); + (OpenSSFClient as jest.Mock).mockImplementation(() => ({ + getScorecard: mockGetScorecard, + })); + + provider = new TestMetricProvider(); + }); + + describe('getProviderDatasourceId', () => { + it('should return "openssf"', () => { + expect(provider.getProviderDatasourceId()).toBe('openssf'); + }); + }); + + describe('getProviderId', () => { + it('should return normalized provider ID with openssf prefix', () => { + expect(provider.getProviderId()).toBe('openssf.test_metric'); + }); + + it('should convert hyphens to underscores and lowercase', () => { + // The metric name is "Test-Metric", should become "test_metric" + expect(provider.getProviderId()).toBe('openssf.test_metric'); + }); + }); + + describe('getMetricType', () => { + it('should return "number"', () => { + expect(provider.getMetricType()).toBe('number'); + }); + }); + + describe('getMetric', () => { + it('should return metric object with correct properties', () => { + const metric = provider.getMetric(); + + expect(metric).toEqual({ + id: 'openssf.test_metric', + title: 'Test Metric Title', + description: 'Test metric description', + type: 'number', + history: true, + }); + }); + }); + + describe('getMetricThresholds', () => { + it('should return default thresholds when none provided', () => { + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + + it('should return custom thresholds when provided', () => { + const customThresholds: ThresholdConfig = { + rules: [ + { key: 'success', expression: '>9' }, + { key: 'warning', expression: '7-9' }, + { key: 'error', expression: '<7' }, + ], + }; + const customProvider = new TestMetricProvider(customThresholds); + + expect(customProvider.getMetricThresholds()).toEqual(customThresholds); + }); + }); + + describe('getCatalogFilter', () => { + it('should return filter for openssf/project-slug annotation', () => { + expect(provider.getCatalogFilter()).toEqual({ + 'metadata.annotations.openssf/project': CATALOG_FILTER_EXISTS, + }); + }); + }); + + describe('calculateMetric', () => { + it('should call OpenSSFClient with owner and repo from entity', async () => { + const entity = createMockEntity('owner/test'); + + await provider.calculateMetric(entity); + + expect(mockGetScorecard).toHaveBeenCalledWith('owner', 'test'); + }); + + it('should return the score for the matching metric', async () => { + const entity = createMockEntity('owner/test'); + + const score = await provider.calculateMetric(entity); + + // provider has getMetricName() returning 'Test-Metric', so score should be 8 + expect(score).toBe(8); + }); + + it('should throw error when metric is not found in scorecard', async () => { + const responseWithoutMetric: OpenSSFResponse = { + ...mockOpenSSFResponse, + checks: [ + { + name: 'Different-Metric', + score: 5, + reason: 'Different reason', + details: null, + documentation: { + short: 'Different doc', + url: 'https://example.com/different', + }, + }, + ], + }; + mockGetScorecard.mockResolvedValue(responseWithoutMetric); + + const entity = createMockEntity('owner/test'); + + await expect(provider.calculateMetric(entity)).rejects.toThrow( + "OpenSSF check 'Test-Metric' not found in scorecard for owner/test", + ); + }); + + it('should throw error when entity is missing openssf/project annotation', async () => { + const entity = createMockEntity(); + + await expect(provider.calculateMetric(entity)).rejects.toThrow( + "Missing annotation 'openssf/project'", + ); + }); + + it('should throw error when project slug has invalid format', async () => { + const entity = createMockEntity('invalid-slug-without-slash'); + + await expect(provider.calculateMetric(entity)).rejects.toThrow( + "Invalid format of 'openssf/project'", + ); + }); + + it('should throw error when metric score is less than 0', async () => { + const entity = createMockEntity('owner/test'); + mockOpenSSFResponse.checks[0].score = -1; + mockOpenSSFResponse.checks[0].reason = 'Repository not found.'; + await expect(provider.calculateMetric(entity)).rejects.toThrow( + "OpenSSF check 'Test-Metric' has invalid score -1 for owner/test. Reason: Repository not found.", + ); + }); + + it('should throw error when metric score is greater than 10', async () => { + const entity = createMockEntity('owner/test'); + mockOpenSSFResponse.checks[0].score = 11; + await expect(provider.calculateMetric(entity)).rejects.toThrow( + "OpenSSF check 'Test-Metric' has invalid score 11 for owner/test", + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/AbstractMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/AbstractMetricProvider.ts new file mode 100644 index 0000000000..cddf5c1506 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/AbstractMetricProvider.ts @@ -0,0 +1,113 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { type Entity } from '@backstage/catalog-model'; +import { + DEFAULT_NUMBER_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; + +import { OpenSSFClient } from '../clients/OpenSSFClient'; +import { getRepositoryInformationFromEntity } from '../clients/utils'; + +/** + * Abstract base class for OpenSSF metric providers. + * Extracts a specific check from the OpenSSF scorecard response. + * + * Subclasses must implement: + * - getCheckName(): The name of the check to extract (e.g., "Maintained", "Code-Review") + * - getMetricName(): The metric name for the provider ID (e.g., "maintained", "code_review") + * - getMetricTitle(): Display title for the metric + * - getMetricDescription(): Description of what the metric measures + */ +export abstract class AbstractMetricProvider + implements MetricProvider<'number'> +{ + protected readonly openSSFClient: OpenSSFClient; + protected readonly thresholds: ThresholdConfig; + + constructor(thresholds?: ThresholdConfig) { + this.openSSFClient = new OpenSSFClient(); + this.thresholds = thresholds ?? DEFAULT_NUMBER_THRESHOLDS; + } + + abstract getMetricName(): string; + + abstract getMetricDisplayTitle(): string; + + abstract getMetricDescription(): string; + + getProviderDatasourceId(): string { + return 'openssf'; + } + + getProviderId(): string { + const normalizedName = this.getMetricName() + .toLowerCase() + .replace(/-/g, '_'); + return `openssf.${normalizedName}`; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: this.getProviderId(), + title: this.getMetricDisplayTitle(), + description: this.getMetricDescription(), + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return this.thresholds; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.openssf/project': CATALOG_FILTER_EXISTS, + }; + } + + async calculateMetric(entity: Entity): Promise { + const { owner, repo } = getRepositoryInformationFromEntity(entity); + const scorecard = await this.openSSFClient.getScorecard(owner, repo); + + const metricName = this.getMetricName(); + const metric = scorecard.checks.find(c => c.name === metricName); + + if (!metric) { + throw new Error( + `OpenSSF check '${metricName}' not found in scorecard for ${owner}/${repo}`, + ); + } else if (metric.score < 0 || metric.score > 10) { + throw new Error( + `OpenSSF check '${metricName}' has invalid score ${ + metric.score + } for ${owner}/${repo}. Reason: ${ + metric.reason ?? 'No reason provided' + }`, + ); + } + return metric.score; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.test.ts new file mode 100644 index 0000000000..1a710addbd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createDefaultOpenSSFMetricProviders, + DefaultOpenSSFMetricProvider, +} from './DefaultOpenSSFMetricProvider'; +import { OPENSSF_METRICS, OPENSSF_THRESHOLDS } from './OpenSSFConfig'; + +describe('DefaultOpenSSFMetricProviderTests', () => { + it('should create a default OpenSSF metric provider', () => { + const provider = new DefaultOpenSSFMetricProvider( + OPENSSF_METRICS[0], + OPENSSF_THRESHOLDS, + ); + expect(provider.getMetricDisplayTitle()).toBe( + OPENSSF_METRICS[0].displayTitle, + ); + expect(provider.getMetricDescription()).toBe( + OPENSSF_METRICS[0].description, + ); + expect(provider.getMetricThresholds()).toBe(OPENSSF_THRESHOLDS); + }); + + it('should create a default OpenSSF metric provider with custom thresholds', () => { + const provider = new DefaultOpenSSFMetricProvider( + OPENSSF_METRICS[0], + OPENSSF_THRESHOLDS, + ); + expect(provider).toBeDefined(); + }); + + it('should create all default OpenSSF metric providers', () => { + const providers = createDefaultOpenSSFMetricProviders(OPENSSF_THRESHOLDS); + expect(providers.length).toBe(OPENSSF_METRICS.length); + for (const provider of providers) { + expect(provider).toBeInstanceOf(DefaultOpenSSFMetricProvider); + expect(provider.getMetricThresholds()).toBe(OPENSSF_THRESHOLDS); + } + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.ts new file mode 100644 index 0000000000..968488bdbe --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.ts @@ -0,0 +1,58 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ThresholdConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { AbstractMetricProvider } from './AbstractMetricProvider'; +import { OPENSSF_METRICS, OpenSSFMetricConfig } from './OpenSSFConfig'; + +/** + * Default metric provider for OpenSSF Security Scorecards. + * Extracts a specific check from the OpenSSF scorecard response based on the provided configuration. + */ +export class DefaultOpenSSFMetricProvider extends AbstractMetricProvider { + constructor( + private readonly config: OpenSSFMetricConfig, + thresholds?: ThresholdConfig, + ) { + super(thresholds); + } + + getMetricName(): string { + return this.config.name; + } + + getMetricDisplayTitle(): string { + return this.config.displayTitle; + } + + getMetricDescription(): string { + return this.config.description; + } +} + +/** + * Creates all default OpenSSF metric providers. + * @param thresholds Optional threshold configuration to apply to all providers + * @returns Array of OpenSSF metric providers + */ +export function createDefaultOpenSSFMetricProviders( + thresholds?: ThresholdConfig, +): MetricProvider<'number'>[] { + return OPENSSF_METRICS.map( + config => new DefaultOpenSSFMetricProvider(config, thresholds), + ); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFConfig.ts new file mode 100644 index 0000000000..3018edcb6f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFConfig.ts @@ -0,0 +1,151 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ThresholdConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +/** + * Configuration for an OpenSSF metric provider. + */ +export interface OpenSSFMetricConfig { + /** The name of the OpenSSF check (e.g., "Maintained", "Code-Review") */ + name: string; + /** Display title for the metric (e.g., "OpenSSF Maintained") */ + displayTitle: string; + /** Description of what the metric measures */ + description: string; +} + +/** + * All available OpenSSF Security Scorecard metrics. + */ +export const OPENSSF_METRICS: OpenSSFMetricConfig[] = [ + { + name: 'Binary-Artifacts', + displayTitle: 'OpenSSF Binary Artifacts', + description: + 'Determines if the project has generated executable (binary) artifacts in the source repository according to OpenSSF Security Scorecards.', + }, + { + name: 'Branch-Protection', + displayTitle: 'OpenSSF Branch Protection', + description: + "Determines if the default and release branches are protected with GitHub's branch protection settings according to OpenSSF Security Scorecards.", + }, + { + name: 'CII-Best-Practices', + displayTitle: 'OpenSSF CII Best Practices', + description: + 'Determines if the project has an OpenSSF (formerly CII) Best Practices Badge according to OpenSSF Security Scorecards.', + }, + { + name: 'CI-Tests', + displayTitle: 'OpenSSF CI Tests', + description: + 'Determines if the project runs tests before pull requests are merged according to OpenSSF Security Scorecards.', + }, + { + name: 'Code-Review', + displayTitle: 'OpenSSF Code Review', + description: + 'Determines if the project requires human code review before pull requests (aka merge requests) are merged according to OpenSSF Security Scorecards.', + }, + { + name: 'Contributors', + displayTitle: 'OpenSSF Contributors', + description: + 'Determines if the project has a set of contributors from multiple organizations (e.g., companies) according to OpenSSF Security Scorecards.', + }, + { + name: 'Dangerous-Workflow', + displayTitle: 'OpenSSF Dangerous Workflow', + description: + "Determines if the project's GitHub Action workflows avoid dangerous patterns according to OpenSSF Security Scorecards.", + }, + { + name: 'Dependency-Update-Tool', + displayTitle: 'OpenSSF Dependency Update Tool', + description: + 'Determines if the project uses a dependency update tool according to OpenSSF Security Scorecards.', + }, + { + name: 'Fuzzing', + displayTitle: 'OpenSSF Fuzzing', + description: + 'Determines if the project uses fuzzing according to OpenSSF Security Scorecards.', + }, + { + name: 'License', + displayTitle: 'OpenSSF License', + description: + 'Determines if the project has defined a license according to OpenSSF Security Scorecards.', + }, + { + name: 'Maintained', + displayTitle: 'OpenSSF Maintained', + description: + 'Determines if the project is "actively maintained" according to OpenSSF Security Scorecards.', + }, + { + name: 'Packaging', + displayTitle: 'OpenSSF Packaging', + description: + 'Determines if the project is published as a package that others can easily download, install, easily update, and uninstall according to OpenSSF Security Scorecards.', + }, + { + name: 'Pinned-Dependencies', + displayTitle: 'OpenSSF Pinned Dependencies', + description: + 'Determines if the project has declared and pinned the dependencies of its build process according to OpenSSF Security Scorecards.', + }, + { + name: 'SAST', + displayTitle: 'OpenSSF SAST', + description: + 'Determines if the project uses static code analysis according to OpenSSF Security Scorecards.', + }, + { + name: 'Security-Policy', + displayTitle: 'OpenSSF Security Policy', + description: + 'Determines if the project has published a security policy according to OpenSSF Security Scorecards.', + }, + { + name: 'Signed-Releases', + displayTitle: 'OpenSSF Signed Releases', + description: + 'Determines if the project cryptographically signs release artifacts according to OpenSSF Security Scorecards.', + }, + { + name: 'Token-Permissions', + displayTitle: 'OpenSSF Token Permissions', + description: + "Determines if the project's workflows follow the principle of least privilege according to OpenSSF Security Scorecards.", + }, + { + name: 'Vulnerabilities', + displayTitle: 'OpenSSF Vulnerabilities', + description: + 'Determines if the project has open, known unfixed vulnerabilities according to OpenSSF Security Scorecards.', + }, +]; + +export const OPENSSF_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'error', expression: '<2' }, + { key: 'warning', expression: '2-7' }, + { key: 'success', expression: '>7' }, + ], +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/module.ts new file mode 100644 index 0000000000..9ca937c14c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/module.ts @@ -0,0 +1,37 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { createDefaultOpenSSFMetricProviders } from './metricProviders/DefaultOpenSSFMetricProvider'; +import { OPENSSF_THRESHOLDS } from './metricProviders/OpenSSFConfig'; + +export const scorecardOpenSFFModule = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'openssf', + register(reg) { + reg.registerInit({ + deps: { + metrics: scorecardMetricsExtensionPoint, + }, + async init({ metrics }) { + // Register all default OpenSSF metric providers + metrics.addMetricProvider( + ...createDefaultOpenSSFMetricProviders(OPENSSF_THRESHOLDS), + ); + }, + }); + }, +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index ce21286518..0ac27cf7e2 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -75,15 +75,17 @@ The Scorecard plugin collects metrics from third-party data sources using metric The following metric providers are available: -| Provider | Metric ID | Title | Description | Type | -| ---------- | ------------------ | ---------------- | ------------------------------------- | ------ | -| **GitHub** | `github.open_prs` | GitHub open PRs | Count of open Pull Requests in GitHub | number | -| **Jira** | `jira.open_issues` | Jira open issues | The number of opened issues in Jira | number | +| Provider | Metric ID | Title | Description | Type | +| ----------- | ------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------ | +| **GitHub** | `github.open_prs` | GitHub open PRs | Count of open Pull Requests in GitHub | number | +| **Jira** | `jira.open_issues` | Jira open issues | The number of opened issues in Jira | number | +| **OpenSSF** | `openssf.*` | OpenSSF Security Scorecards | 18 security metrics from OpenSSF Scorecards (e.g., `openssf.code_review`, `openssf.maintained`). Each returns a score from 0-10. | number | To use these providers, install the corresponding backend modules: - GitHub: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github`](../scorecard-backend-module-github/README.md) - Jira: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira`](../scorecard-backend-module-jira/README.md) +- OpenSSF: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf`](../scorecard-backend-module-openssf/README.md) ## Thresholds diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md b/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md index 929526ac99..b2b7b6f586 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md @@ -179,3 +179,4 @@ The following are examples of existing metric providers that you can reference: - **GitHub Datasource**: [GithubOpenPRsProvider](../../scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.ts) - **Jira Datasource**: [JiraOpenIssuesProvider](../../scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.ts) +- **OpenSSF Datasource**: [DefaultOpenSSFMetricProvider](../../scorecard-backend-module-openssf/src/metricProviders/DefaultOpenSSFMetricProvider.ts) diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index d25c737194..9e95ef78ae 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -11089,6 +11089,20 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf@workspace:plugins/scorecard-backend-module-openssf": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf@workspace:plugins/scorecard-backend-module-openssf" + dependencies: + "@backstage/backend-plugin-api": ^1.5.0 + "@backstage/backend-test-utils": ^1.10.0 + "@backstage/catalog-client": ^1.12.1 + "@backstage/catalog-model": ^1.7.6 + "@backstage/cli": ^0.34.5 + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-scorecard-backend@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend@workspace:plugins/scorecard-backend": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend@workspace:plugins/scorecard-backend" @@ -16454,6 +16468,7 @@ __metadata: "@red-hat-developer-hub/backstage-plugin-scorecard-backend": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf": "workspace:^" app: "link:../app" better-sqlite3: ^9.0.0 node-gyp: ^10.0.0