Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions actions/socket-export-sbom/README.md
Original file line number Diff line number Diff line change
@@ -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

<!-- x-release-please-start-version -->

```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
```

<!-- x-release-please-end-version -->
56 changes: 56 additions & 0 deletions actions/socket-export-sbom/action.yml
Original file line number Diff line number Diff line change
@@ -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
146 changes: 146 additions & 0 deletions actions/socket-export-sbom/main.go
Original file line number Diff line number Diff line change
@@ -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 <repo name> <output filepath>
`
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)
}
}
5 changes: 5 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading