Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Protect AI agent configuration — require maintainer review
/.claude/ @stacklok/maintain
198 changes: 124 additions & 74 deletions .github/workflows/build-containers.yml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
branches: [ main ]
workflow_dispatch:

permissions:
contents: read

jobs:

lint:
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/periodic-security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ on:
- cron: '0 2 * * 1' # Weekly on Monday at 2am UTC
workflow_dispatch: # Allow manual trigger

permissions: {}

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
discover-published-images:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
configs: ${{ steps.find-configs.outputs.configs }}
steps:
Expand Down Expand Up @@ -52,8 +56,10 @@ jobs:

- name: Extract metadata from config
id: meta
env:
CONFIG_FILE: ${{ matrix.config }}
run: |
config_file="${{ matrix.config }}"
config_file="$CONFIG_FILE"
protocol=$(echo "$config_file" | cut -d'/' -f1)
server_name=$(echo "$config_file" | cut -d'/' -f2)

Expand All @@ -70,7 +76,7 @@ jobs:
echo "version=$version" >> $GITHUB_OUTPUT

# Generate image name
image_name="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${protocol}/${server_name}"
image_name="${REGISTRY}/${IMAGE_NAME}/${protocol}/${server_name}"
echo "image_name=$image_name" >> $GITHUB_OUTPUT
echo "image_ref=${image_name}:${version}" >> $GITHUB_OUTPUT

Expand Down Expand Up @@ -215,6 +221,7 @@ jobs:
summary:
needs: scan-images
runs-on: ubuntu-latest
permissions: {}
if: always()
steps:
- name: Generate summary
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/renovate-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ on:
- 'go/**/spec.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
validate-config:
name: Validate Renovate Configuration
Expand Down Expand Up @@ -77,14 +80,15 @@ jobs:
env:
LOG_LEVEL: debug
RENOVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPO: ${{ github.repository }}
run: |
npx --yes --package renovate -- renovate \
--platform=github \
--token="${RENOVATE_TOKEN}" \
--dry-run=full \
--print-config \
--autodiscover=false \
${{ github.repository }} || {
"$GITHUB_REPO" || {
echo "::warning::Dry run failed. This might be due to missing permissions or configuration issues."
exit 0
}
43 changes: 38 additions & 5 deletions internal/provenance/npm/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/sigstore/sigstore-go/pkg/verify"
Expand Down Expand Up @@ -138,12 +139,16 @@ func (v *Verifier) verifyAttestations(
}

// Fetch the attestation bundle from URL
if err := validateNpmURL(bundleURL); err != nil {
return false, nil, fmt.Errorf("SSRF protection: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil)
if err != nil {
return false, nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := v.httpClient.Do(req)
resp, err := v.httpClient.Do(req) //nolint:gosec // G704 — URL validated against allowlist by validateNpmURL
if err != nil {
return false, nil, fmt.Errorf("failed to fetch attestation: %w", err)
}
Expand Down Expand Up @@ -199,14 +204,38 @@ func (v *Verifier) verifyBundleData(
return true, publisher, nil
}

// allowedHosts is the set of hostnames that the verifier is permitted to contact.
var allowedHosts = map[string]bool{
"registry.npmjs.org": true,
}

// validateNpmURL checks that a URL is HTTPS and targets an allowed npm host.
func validateNpmURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL %q: %w", rawURL, err)
}
if u.Scheme != "https" {
return fmt.Errorf("URL %q uses disallowed scheme %q (only https is allowed)", rawURL, u.Scheme)
}
if !allowedHosts[u.Hostname()] {
return fmt.Errorf("URL %q targets disallowed host %q", rawURL, u.Hostname())
}
return nil
}

// calculateTarballDigest downloads and hashes the tarball
func (v *Verifier) calculateTarballDigest(ctx context.Context, tarballURL string) ([]byte, error) {
if err := validateNpmURL(tarballURL); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarballURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := v.httpClient.Do(req)
resp, err := v.httpClient.Do(req) //nolint:gosec // G704 — URL validated against allowlist by validateNpmURL
if err != nil {
return nil, fmt.Errorf("failed to fetch tarball: %w", err)
}
Expand All @@ -227,14 +256,18 @@ func (v *Verifier) calculateTarballDigest(ctx context.Context, tarballURL string

// fetchPackageMetadata fetches the package metadata from the npm registry
func (v *Verifier) fetchPackageMetadata(ctx context.Context, packageName string) (*PackageMetadata, error) {
url := fmt.Sprintf("%s/%s", v.registryURL, packageName)
targetURL := fmt.Sprintf("%s/%s", v.registryURL, packageName)

if err := validateNpmURL(targetURL); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := v.httpClient.Do(req)
resp, err := v.httpClient.Do(req) //nolint:gosec // G704 — URL validated against allowlist by validateNpmURL
if err != nil {
return nil, fmt.Errorf("failed to fetch package metadata: %w", err)
}
Expand Down
46 changes: 41 additions & 5 deletions internal/provenance/pypi/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

Expand Down Expand Up @@ -195,19 +196,46 @@ func (v *Verifier) verifyProvenance(ctx context.Context, file File) (bool, *doma
return true, publisher, nil
}

// allowedHosts is the set of hostnames that the verifier is permitted to contact.
var allowedHosts = map[string]bool{
"pypi.org": true,
"files.pythonhosted.org": true,
"test.pypi.org": true,
"test-files.pythonhosted.org": true,
}

// validatePyPIURL checks that a URL is HTTPS and targets an allowed PyPI host.
func validatePyPIURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL %q: %w", rawURL, err)
}
if u.Scheme != "https" {
return fmt.Errorf("URL %q uses disallowed scheme %q (only https is allowed)", rawURL, u.Scheme)
}
if !allowedHosts[u.Hostname()] {
return fmt.Errorf("URL %q targets disallowed host %q", rawURL, u.Hostname())
}
return nil
}

// fetchSimpleMetadata fetches package metadata from PyPI Simple JSON API
func (v *Verifier) fetchSimpleMetadata(ctx context.Context, packageName string) (*SimpleMetadata, error) {
url := fmt.Sprintf("%s/%s/", v.simpleURL, packageName)
targetURL := fmt.Sprintf("%s/%s/", v.simpleURL, packageName)

if err := validatePyPIURL(targetURL); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Use PEP 691 JSON format
req.Header.Set("Accept", "application/vnd.pypi.simple.v1+json")

resp, err := v.httpClient.Do(req)
resp, err := v.httpClient.Do(req) //nolint:gosec // G704 — URL validated against allowlist by validatePyPIURL
if err != nil {
return nil, fmt.Errorf("failed to fetch package metadata: %w", err)
}
Expand All @@ -228,12 +256,16 @@ func (v *Verifier) fetchSimpleMetadata(ctx context.Context, packageName string)

// fetchProvenanceData fetches the provenance object from PyPI
func (v *Verifier) fetchProvenanceData(ctx context.Context, provenanceURL string) (*ProvenanceObject, error) {
if err := validatePyPIURL(provenanceURL); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, provenanceURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := v.httpClient.Do(req)
resp, err := v.httpClient.Do(req) //nolint:gosec // G704 — URL validated against allowlist by validatePyPIURL
if err != nil {
return nil, fmt.Errorf("failed to fetch provenance: %w", err)
}
Expand All @@ -253,12 +285,16 @@ func (v *Verifier) fetchProvenanceData(ctx context.Context, provenanceURL string

// downloadAndHashFile downloads a file and returns its SHA256 hash
func (v *Verifier) downloadAndHashFile(ctx context.Context, fileURL string) ([]byte, error) {
if err := validatePyPIURL(fileURL); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := v.httpClient.Do(req)
resp, err := v.httpClient.Do(req) //nolint:gosec // G704 — URL validated against allowlist by validatePyPIURL
if err != nil {
return nil, fmt.Errorf("failed to fetch file: %w", err)
}
Expand Down
Loading