diff --git a/actions/socket-export-sbom/README.md b/actions/socket-export-sbom/README.md new file mode 100644 index 000000000..5ef015aad --- /dev/null +++ b/actions/socket-export-sbom/README.md @@ -0,0 +1,66 @@ +# socket-export-sbom + +Composite action (step) to get the latest scan id for a repo enrolled in the socket.dev GitHub App and then fetch the spdx sbom from socket using the latest scan id. + +A good use case is including this sbom as part of a public repo's release artifacts when creating a new release + +## Inputs + +| Name | Type | Description | Default Value | Required | +| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------- | +| `socket_api_token` | `string` | GitHub token used to authenticate with `gh`. Requires permission to query for protected branches and delete branches (`contents: write`) and pull requests (`pull_requests: read`) | `none` | true | +| `socket_base_url` | `string` | Base URL of the socket api endpoint. | `"api.socket.dev/v0"` | true | +| `socket_org_name` | `string` | Name of the socket org. | `"grafana"` | true | +| `output_file` | `string` | Name of the file to save the socket sbom on the runner. | `"spdx.json"` | false | + +## Examples + +### Runs as a workflow dispatch but typical use case should run on release + + + +```yaml +name: Get Repo SBOM from Socket API + +on: + workflow_dispatch: + inputs: + output_file: + description: "Output file path for the SBOM" + required: false + default: "spdx.json" + +jobs: + export-sbom: + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Get vault secrets + id: vault-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@get-vault-secrets/v1.3.0 + with: + repo_secrets: | + SOCKET_API_TOKEN=socket:SOCKET_SBOM_API_KEY + export_env: false + + - name: Export SBOM from Socket + id: export-sbom + uses: grafana/shared-workflows/actions/socket-export-sbom@socket-export-sbom/v0.1.0 + with: + socket_api_token: ${{ fromJSON(steps.vault-secrets.outputs.secrets).SOCKET_API_TOKEN }} + output_file: ${{ inputs.output_file }} + + - name: Upload SBOM artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: "sbom" + path: ${{ steps.export-sbom.outputs.path }} + retention-days: 30 +``` + + diff --git a/actions/socket-export-sbom/action.yml b/actions/socket-export-sbom/action.yml new file mode 100644 index 000000000..07983e0c2 --- /dev/null +++ b/actions/socket-export-sbom/action.yml @@ -0,0 +1,56 @@ +name: Export SPDX SBOM from Socket.dev API +description: Export SPDX SBOM from Socket.dev API for a given repository. + +inputs: + socket_api_token: + description: "Socket API token for authentication" + required: true + socket_base_url: + description: "Socket base url" + required: true + default: "api.socket.dev/v0" + socket_org: + description: "Socket org name" + required: true + default: "grafana" + output_file: + description: "Name of the file to save the sbom" + required: false + +outputs: + path: + description: "Path to the exported sbom file" + value: ${{ steps.export-sbom.outputs.path }} + +runs: + using: "composite" + steps: + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: "1.25.4" + + - name: Extract repository name + id: repo-name + shell: bash + run: | + REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2) + echo "name=$REPO_NAME" >> $GITHUB_OUTPUT + + - name: Export SPDX SBOM from Socket.dev + id: export-sbom + shell: bash + env: + SOCKET_API_TOKEN: ${{ inputs.socket_api_token }} + SOCKET_BASE_URL: ${{ inputs.socket_base_url }} + SOCKET_ORG: ${{ inputs.socket_org }} + REPO_NAME: ${{ steps.repo-name.outputs.name }} + OUTPUT_FILE: ${{ inputs.output_file }} + ACTION_PATH: ${{ github.action_path }} + run: | + # Extract basename if output_file is provided (handles both filenames and paths) + if [[ -n "$OUTPUT_FILE" ]]; then + OUTPUT_FILE=$(basename "$OUTPUT_FILE") + fi + go run main.go $REPO_NAME $OUTPUT_FILE + echo "path=ACTION_PATH/$OUTPUT_FILE" >> $GITHUB_OUTPUT diff --git a/actions/socket-export-sbom/main.go b/actions/socket-export-sbom/main.go new file mode 100644 index 000000000..de0d3f802 --- /dev/null +++ b/actions/socket-export-sbom/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" +) + +type Client struct { + APIKey string + BaseURL string + Org string + HTTPClient *http.Client +} + +func NewClient(apiKey, org, baseURL string) *Client { + return &Client{ + APIKey: apiKey, + BaseURL: baseURL, + Org: org, + HTTPClient: http.DefaultClient, + } +} + +type Repo struct { + Slug string + LastScanID string `json:"head_full_scan_id"` +} + +// doRequest handles the common HTTP request logic: building URL, creating request, +// adding auth header, executing request, and reading response body. +// Returns the response body bytes, status code, and error. +func (client *Client) doRequest(method, path string) ([]byte, int, error) { + URL := fmt.Sprintf("%s%s", client.BaseURL, path) + authHeaderValue := fmt.Sprintf("Bearer %s", client.APIKey) + req, err := http.NewRequestWithContext(context.Background(), method, URL, nil) + if err != nil { + return nil, 0, err + } + req.Header.Add("Authorization", authHeaderValue) + resp, err := client.HTTPClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return data, resp.StatusCode, nil +} + +func (client *Client) GetRepoLastScanID(name string) (string, error) { + path := fmt.Sprintf("/orgs/%s/repos/%s", client.Org, name) + data, statusCode, err := client.doRequest("GET", path) + if err != nil { + return "", err + } + if statusCode != http.StatusOK { + return "", fmt.Errorf("invalid status code %d", statusCode) + } + + var r Repo + err = json.Unmarshal(data, &r) + if err != nil { + return "", err + } + return r.LastScanID, nil +} + +// It creates the file if it doesn't exist and overwrites it if it does. +func saveDataToFile(data []byte, filepath string) error { + err := os.WriteFile(filepath, data, 0644) + if err != nil { + return fmt.Errorf("failed to write file %s: %w", filepath, err) + } + return nil +} + +func (client *Client) ExportSBOM(scanID, filepath string) error { + path := fmt.Sprintf("/orgs/%s/export/spdx/%s", client.Org, scanID) + + data, statusCode, err := client.doRequest("GET", path) + if err != nil { + return err + } + + if statusCode != http.StatusOK { + return fmt.Errorf("invalid status code %d", statusCode) + } + + err = saveDataToFile(data, filepath) + if err != nil { + return err + } + return nil +} + +func main() { + Usage := ` + Usage: main.go + ` + key := os.Getenv("SOCKET_API_TOKEN") + if key == "" { + log.Fatal("SOCKET_API_TOKEN not provided") + os.Exit(1) + } + baseURL := os.Getenv("SOCKET_BASE_URL") + if baseURL == "" { + log.Fatal("Please specify socket base url, e.g. 'api.socket.dev/v0'") + os.Exit(1) + } + org := os.Getenv("SOCKET_ORG") + if org == "" { + log.Fatal("Please specify socket org name, e.g. 'grafana'") + os.Exit(1) + } + if len(os.Args) < 3 { + log.Println(Usage) + os.Exit(0) + } + client := NewClient(key, org, baseURL) + repo, output := os.Args[1], os.Args[2] + id, err := client.GetRepoLastScanID(repo) + if err != nil { + log.Printf("ERROR: could not get scan id for %s: %s", repo, err) + os.Exit(1) + } + if id == "" { + log.Printf("ERROR: no valid scan found for repo: %s", repo) + os.Exit(1) + + } + log.Printf("Last scan id for %s is %s", repo, id) + log.Printf("exporting sbom to %s", output) + err = client.ExportSBOM(id, output) + if err != nil { + log.Printf("ERROR: failed to export SBOM: %s\n", err) + os.Exit(1) + } +} diff --git a/release-please-config.json b/release-please-config.json index 4ca3acdd9..bea2bd1fd 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -180,6 +180,11 @@ "extra-files": ["README.md"], "initial-version": "0.1.0" }, + "actions/socket-export-sbom": { + "package-name": "socket-export-sbom", + "extra-files": ["README.md"], + "initial-version": "0.1.0" + }, "actions/docker-import-digests-push-manifest": { "package-name": "docker-import-digests-push-manifest", "extra-files": ["README.md"],