From 8ded1ba568b6c2e34e8fc1e3af0d830bf3f4bb1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 04:17:26 +0000 Subject: [PATCH 1/4] Add GitHub Actions artifact upload command (gha) This commit introduces a new `gha` subcommand for seamless integration with GitHub Actions workflows, enabling automated upload of workflow artifacts to Vulnetix for vulnerability analysis. New Features: - `vulnetix gha upload`: Upload all workflow artifacts with metadata - `vulnetix gha status`: Check upload status by txnid or artifact UUID - Automatic GitHub Actions environment variable collection - Transaction-based artifact upload with individual tracking - JSON output support for CI/CD integration Implementation Details: - New package `internal/github` for artifact handling - GitHub Actions artifact collector with download/extraction - API client for transaction and artifact upload endpoints - Comprehensive metadata collection from GHA environment - Status checking for transactions and individual artifacts API Integration: - POST /api/github/artifact-upload - Initiate transaction - POST /api/github/artifact-upload/:txnid - Upload artifacts - GET /api/github/artifact-upload/:txnid/status - Transaction status - GET /api/github/artifact/:uuid/status - Artifact status Documentation: - Comprehensive command documentation in docs/GHA_COMMAND.md - Usage examples for GitHub Actions workflows - API endpoint specifications - Troubleshooting guide Testing: - Unit tests for artifact collector - Unit tests for uploader - All tests passing for new functionality --- README.md | 3 +- cmd/gha.go | 311 +++++++++++++++++++++++ docs/GHA_COMMAND.md | 409 +++++++++++++++++++++++++++++++ internal/github/artifact.go | 267 ++++++++++++++++++++ internal/github/artifact_test.go | 133 ++++++++++ internal/github/uploader.go | 303 +++++++++++++++++++++++ internal/github/uploader_test.go | 77 ++++++ 7 files changed, 1502 insertions(+), 1 deletion(-) create mode 100644 cmd/gha.go create mode 100644 docs/GHA_COMMAND.md create mode 100644 internal/github/artifact.go create mode 100644 internal/github/artifact_test.go create mode 100644 internal/github/uploader.go create mode 100644 internal/github/uploader_test.go diff --git a/README.md b/README.md index cf99818..59f2e7b 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,9 @@ Alternatively, you can use JSON format: ## Documentation - **[Installation](docs/README.md)** - Installation guides for all platforms -- **[CLI Reference](docs/CLI-REFERENCE.md)** - Complete command-line documentation +- **[CLI Reference](docs/CLI-REFERENCE.md)** - Complete command-line documentation - **[Usage Examples](USAGE.md)** - Comprehensive usage guide +- **[GitHub Actions Artifact Upload](docs/GHA_COMMAND.md)** - Upload workflow artifacts to Vulnetix - **[Distribution](docs/PUBLISHING.md)** - How we distribute across platforms ## Distribution diff --git a/cmd/gha.go b/cmd/gha.go new file mode 100644 index 0000000..3d146a1 --- /dev/null +++ b/cmd/gha.go @@ -0,0 +1,311 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/vulnetix/vulnetix/internal/github" +) + +var ( + // GHA command flags + ghaBaseURL string + ghaTxnID string + ghaUUID string + ghaOutputJSON bool +) + +// ghaCmd represents the gha command for GitHub Actions artifact management +var ghaCmd = &cobra.Command{ + Use: "gha", + Short: "GitHub Actions artifact management", + Long: `Manage GitHub Actions artifacts for Vulnetix. + +This command allows you to upload workflow artifacts to Vulnetix and check their status. +It is designed to work within GitHub Actions workflows.`, +} + +// ghaUploadCmd handles uploading artifacts from GitHub Actions +var ghaUploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload GitHub Actions artifacts to Vulnetix", + Long: `Upload all artifacts from the current GitHub Actions workflow run to Vulnetix. + +This command: +1. Collects all artifacts from the current workflow run +2. Gathers GitHub Actions metadata (environment variables) +3. Initiates a transaction with Vulnetix API +4. Uploads each artifact with the transaction ID +5. Reports the transaction ID and artifact UUIDs + +Example: + vulnetix gha upload --org-id + vulnetix gha upload --org-id --base-url https://api.vulnetix.com`, + RunE: runGHAUpload, +} + +// ghaStatusCmd handles checking status of uploads +var ghaStatusCmd = &cobra.Command{ + Use: "status", + Short: "Check status of artifact uploads", + Long: `Check the status of artifact uploads using transaction ID or artifact UUID. + +You can check status using either: +- Transaction ID (--txnid): Shows status of all artifacts in the transaction +- Artifact UUID (--uuid): Shows status of a specific artifact + +Examples: + vulnetix gha status --org-id --txnid + vulnetix gha status --org-id --uuid + vulnetix gha status --org-id --txnid --json`, + RunE: runGHAStatus, +} + +func runGHAUpload(cmd *cobra.Command, args []string) error { + // Validate org-id + if orgID == "" { + return fmt.Errorf("--org-id is required") + } + + if _, err := uuid.Parse(orgID); err != nil { + return fmt.Errorf("--org-id must be a valid UUID, got: %s", orgID) + } + + // Check if we're in a GitHub Actions environment + if os.Getenv("GITHUB_ACTIONS") != "true" { + fmt.Println("⚠️ Warning: Not running in GitHub Actions environment") + } + + // Get GitHub context + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return fmt.Errorf("GITHUB_TOKEN environment variable is required") + } + + apiURL := os.Getenv("GITHUB_API_URL") + if apiURL == "" { + apiURL = "https://api.github.com" + } + + repository := os.Getenv("GITHUB_REPOSITORY") + if repository == "" { + return fmt.Errorf("GITHUB_REPOSITORY environment variable is required") + } + + runID := os.Getenv("GITHUB_RUN_ID") + if runID == "" { + return fmt.Errorf("GITHUB_RUN_ID environment variable is required") + } + + fmt.Printf("🚀 Starting GitHub Actions artifact upload\n") + fmt.Printf(" Organization: %s\n", orgID) + fmt.Printf(" Repository: %s\n", repository) + fmt.Printf(" Run ID: %s\n", runID) + fmt.Println() + + // Create artifact collector + collector := github.NewArtifactCollector(token, apiURL, repository, runID) + + // List all artifacts + fmt.Println("📦 Fetching workflow artifacts...") + ctx := context.Background() + artifacts, err := collector.ListArtifacts(ctx) + if err != nil { + return fmt.Errorf("failed to list artifacts: %w", err) + } + + if len(artifacts) == 0 { + fmt.Println("⚠️ No artifacts found in this workflow run") + return nil + } + + fmt.Printf("✅ Found %d artifact(s)\n", len(artifacts)) + for i, artifact := range artifacts { + fmt.Printf(" %d. %s (%d bytes)\n", i+1, artifact.Name, artifact.SizeInBytes) + } + fmt.Println() + + // Collect metadata + artifactNames := make([]string, len(artifacts)) + for i, artifact := range artifacts { + artifactNames[i] = artifact.Name + } + metadata := github.CollectMetadata(artifactNames) + + // Create uploader + uploader := github.NewArtifactUploader(ghaBaseURL, orgID) + + // Initiate transaction + fmt.Println("🔄 Initiating upload transaction...") + txnResp, err := uploader.InitiateTransaction(metadata, artifactNames) + if err != nil { + return fmt.Errorf("failed to initiate transaction: %w", err) + } + + fmt.Printf("✅ Transaction initiated\n") + fmt.Printf(" Transaction ID: %s\n", txnResp.TxnID) + fmt.Println() + + // Upload each artifact + fmt.Println("📤 Uploading artifacts...") + uploadResults := make([]map[string]string, 0, len(artifacts)) + + for i, artifact := range artifacts { + fmt.Printf(" [%d/%d] Uploading %s...\n", i+1, len(artifacts), artifact.Name) + + // Download and extract artifact + artifactDir, err := collector.DownloadArtifact(ctx, artifact) + if err != nil { + fmt.Printf(" ❌ Failed to download: %v\n", err) + continue + } + defer os.RemoveAll(artifactDir) + + // Upload to Vulnetix + uploadResp, err := uploader.UploadArtifact(txnResp.TxnID, artifact.Name, artifactDir) + if err != nil { + fmt.Printf(" ❌ Failed to upload: %v\n", err) + continue + } + + fmt.Printf(" ✅ Uploaded successfully\n") + fmt.Printf(" UUID: %s\n", uploadResp.UUID) + fmt.Printf(" Queue Path: %s\n", uploadResp.QueuePath) + + uploadResults = append(uploadResults, map[string]string{ + "name": artifact.Name, + "uuid": uploadResp.UUID, + "queue_path": uploadResp.QueuePath, + }) + } + + fmt.Println() + fmt.Println("✅ Upload complete!") + fmt.Printf(" Transaction ID: %s\n", txnResp.TxnID) + fmt.Printf(" Uploaded: %d/%d artifacts\n", len(uploadResults), len(artifacts)) + fmt.Println() + fmt.Printf("💡 Check status with: vulnetix gha status --org-id %s --txnid %s\n", orgID, txnResp.TxnID) + fmt.Printf("🔗 View at: https://dashboard.vulnetix.com/org/%s/artifacts\n", orgID) + + // Output JSON if requested + if ghaOutputJSON { + output := map[string]interface{}{ + "txnid": txnResp.TxnID, + "artifacts": uploadResults, + } + jsonData, _ := json.MarshalIndent(output, "", " ") + fmt.Println() + fmt.Println(string(jsonData)) + } + + return nil +} + +func runGHAStatus(cmd *cobra.Command, args []string) error { + // Validate org-id + if orgID == "" { + return fmt.Errorf("--org-id is required") + } + + if _, err := uuid.Parse(orgID); err != nil { + return fmt.Errorf("--org-id must be a valid UUID, got: %s", orgID) + } + + // Require either txnid or uuid + if ghaTxnID == "" && ghaUUID == "" { + return fmt.Errorf("either --txnid or --uuid is required") + } + + if ghaTxnID != "" && ghaUUID != "" { + return fmt.Errorf("only one of --txnid or --uuid can be specified") + } + + // Create uploader + uploader := github.NewArtifactUploader(ghaBaseURL, orgID) + + var statusResp *github.StatusResponse + var err error + + if ghaTxnID != "" { + fmt.Printf("🔍 Checking transaction status: %s\n", ghaTxnID) + statusResp, err = uploader.GetTransactionStatus(ghaTxnID) + } else { + fmt.Printf("🔍 Checking artifact status: %s\n", ghaUUID) + statusResp, err = uploader.GetArtifactStatus(ghaUUID) + } + + if err != nil { + return fmt.Errorf("failed to get status: %w", err) + } + + // Output JSON if requested + if ghaOutputJSON { + jsonData, err := json.MarshalIndent(statusResp, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonData)) + return nil + } + + // Pretty print status + fmt.Println() + fmt.Printf("📊 Status: %s\n", statusResp.Status) + if statusResp.TxnID != "" { + fmt.Printf(" Transaction ID: %s\n", statusResp.TxnID) + } + if statusResp.Message != "" { + fmt.Printf(" Message: %s\n", statusResp.Message) + } + + if len(statusResp.Artifacts) > 0 { + fmt.Println() + fmt.Printf("📦 Artifacts (%d):\n", len(statusResp.Artifacts)) + for i, artifact := range statusResp.Artifacts { + fmt.Printf(" %d. %s\n", i+1, artifact.Name) + fmt.Printf(" UUID: %s\n", artifact.UUID) + fmt.Printf(" Status: %s\n", artifact.Status) + if artifact.QueuePath != "" { + fmt.Printf(" Queue Path: %s\n", artifact.QueuePath) + } + if artifact.Error != "" { + fmt.Printf(" Error: %s\n", artifact.Error) + } + } + } + + if len(statusResp.Details) > 0 { + fmt.Println() + fmt.Println("📋 Details:") + for key, value := range statusResp.Details { + fmt.Printf(" %s: %v\n", key, value) + } + } + + fmt.Println() + fmt.Printf("🔗 View at: https://dashboard.vulnetix.com/org/%s/artifacts\n", orgID) + + return nil +} + +func init() { + // Add upload subcommand + ghaUploadCmd.Flags().StringVar(&ghaBaseURL, "base-url", "https://api.vulnetix.com", "Base URL for Vulnetix API") + ghaUploadCmd.Flags().BoolVar(&ghaOutputJSON, "json", false, "Output results as JSON") + + // Add status subcommand + ghaStatusCmd.Flags().StringVar(&ghaBaseURL, "base-url", "https://api.vulnetix.com", "Base URL for Vulnetix API") + ghaStatusCmd.Flags().StringVar(&ghaTxnID, "txnid", "", "Transaction ID to check status") + ghaStatusCmd.Flags().StringVar(&ghaUUID, "uuid", "", "Artifact UUID to check status") + ghaStatusCmd.Flags().BoolVar(&ghaOutputJSON, "json", false, "Output results as JSON") + + // Add subcommands to gha command + ghaCmd.AddCommand(ghaUploadCmd, ghaStatusCmd) + + // Add gha command to root + rootCmd.AddCommand(ghaCmd) +} diff --git a/docs/GHA_COMMAND.md b/docs/GHA_COMMAND.md new file mode 100644 index 0000000..9368a1a --- /dev/null +++ b/docs/GHA_COMMAND.md @@ -0,0 +1,409 @@ +# GitHub Actions Artifact Upload Command + +## Overview + +The `gha` subcommand provides seamless integration with GitHub Actions workflows for uploading artifacts to Vulnetix. It automatically collects all workflow artifacts, gathers GitHub Actions metadata, and uploads them to the Vulnetix platform for vulnerability analysis. + +## Features + +- **Automatic Artifact Collection**: Discovers and collects all artifacts from the current GitHub Actions workflow run +- **Metadata Capture**: Automatically captures GitHub Actions environment variables for context +- **Transaction-based Upload**: Creates a transaction for tracking multiple artifact uploads +- **Status Tracking**: Check upload status using transaction ID or individual artifact UUID +- **JSON Output**: Optional JSON output for integration with other tools + +## Commands + +### `vulnetix gha upload` + +Upload all artifacts from the current GitHub Actions workflow run to Vulnetix. + +#### Usage + +```bash +vulnetix gha upload --org-id [flags] +``` + +#### Flags + +- `--org-id` (required): Organization UUID for Vulnetix operations +- `--base-url`: Base URL for Vulnetix API (default: `https://api.vulnetix.com`) +- `--json`: Output results as JSON + +#### Environment Variables Required + +The following GitHub Actions environment variables must be set (automatically available in GitHub Actions): + +- `GITHUB_TOKEN`: GitHub token for API access +- `GITHUB_REPOSITORY`: Repository name (e.g., `owner/repo`) +- `GITHUB_RUN_ID`: Workflow run ID +- `GITHUB_API_URL`: GitHub API URL (defaults to `https://api.github.com`) + +Additional metadata is collected from these variables if available: +- `GITHUB_REPOSITORY_OWNER` +- `GITHUB_RUN_NUMBER` +- `GITHUB_WORKFLOW` +- `GITHUB_JOB` +- `GITHUB_SHA` +- `GITHUB_REF_NAME` +- `GITHUB_REF_TYPE` +- `GITHUB_EVENT_NAME` +- `GITHUB_ACTOR` +- `GITHUB_SERVER_URL` +- `GITHUB_HEAD_REF` +- `GITHUB_BASE_REF` +- `RUNNER_OS` +- `RUNNER_ARCH` + +#### Example + +```bash +vulnetix gha upload --org-id 123e4567-e89b-12d3-a456-426614174000 +``` + +#### Output + +``` +🚀 Starting GitHub Actions artifact upload + Organization: 123e4567-e89b-12d3-a456-426614174000 + Repository: myorg/myrepo + Run ID: 123456789 + +📦 Fetching workflow artifacts... +✅ Found 3 artifact(s) + 1. sarif-results (1234 bytes) + 2. sbom-report (5678 bytes) + 3. test-coverage (9012 bytes) + +🔄 Initiating upload transaction... +✅ Transaction initiated + Transaction ID: txn_abc123def456 + +📤 Uploading artifacts... + [1/3] Uploading sarif-results... + ✅ Uploaded successfully + UUID: art_111222333 + Queue Path: /queue/2024/01/art_111222333 + [2/3] Uploading sbom-report... + ✅ Uploaded successfully + UUID: art_444555666 + Queue Path: /queue/2024/01/art_444555666 + [3/3] Uploading test-coverage... + ✅ Uploaded successfully + UUID: art_777888999 + Queue Path: /queue/2024/01/art_777888999 + +✅ Upload complete! + Transaction ID: txn_abc123def456 + Uploaded: 3/3 artifacts + +💡 Check status with: vulnetix gha status --org-id 123e4567-e89b-12d3-a456-426614174000 --txnid txn_abc123def456 +🔗 View at: https://dashboard.vulnetix.com/org/123e4567-e89b-12d3-a456-426614174000/artifacts +``` + +### `vulnetix gha status` + +Check the status of artifact uploads using transaction ID or artifact UUID. + +#### Usage + +```bash +# Check status by transaction ID +vulnetix gha status --org-id --txnid + +# Check status by artifact UUID +vulnetix gha status --org-id --uuid +``` + +#### Flags + +- `--org-id` (required): Organization UUID for Vulnetix operations +- `--txnid`: Transaction ID to check status (mutually exclusive with `--uuid`) +- `--uuid`: Artifact UUID to check status (mutually exclusive with `--txnid`) +- `--base-url`: Base URL for Vulnetix API (default: `https://api.vulnetix.com`) +- `--json`: Output results as JSON + +#### Examples + +**Check transaction status:** +```bash +vulnetix gha status --org-id 123e4567-e89b-12d3-a456-426614174000 --txnid txn_abc123def456 +``` + +**Check individual artifact status:** +```bash +vulnetix gha status --org-id 123e4567-e89b-12d3-a456-426614174000 --uuid art_111222333 +``` + +**Get JSON output:** +```bash +vulnetix gha status --org-id 123e4567-e89b-12d3-a456-426614174000 --txnid txn_abc123def456 --json +``` + +#### Output + +``` +🔍 Checking transaction status: txn_abc123def456 + +📊 Status: completed + Transaction ID: txn_abc123def456 + Message: All artifacts processed successfully + +📦 Artifacts (3): + 1. sarif-results + UUID: art_111222333 + Status: processed + Queue Path: /queue/2024/01/art_111222333 + 2. sbom-report + UUID: art_444555666 + Status: processed + Queue Path: /queue/2024/01/art_444555666 + 3. test-coverage + UUID: art_777888999 + Status: processing + Queue Path: /queue/2024/01/art_777888999 + +🔗 View at: https://dashboard.vulnetix.com/org/123e4567-e89b-12d3-a456-426614174000/artifacts +``` + +## GitHub Actions Workflow Integration + +### Basic Workflow Example + +```yaml +name: Security Scan +on: [push, pull_request] + +jobs: + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run SAST Scanner + run: | + # Your scanner command that produces SARIF + scanner --output results.sarif + + - name: Upload SARIF as artifact + uses: actions/upload-artifact@v4 + with: + name: sarif-results + path: results.sarif + + upload-to-vulnetix: + needs: security-scan + runs-on: ubuntu-latest + permissions: + actions: read # Required to read artifacts + steps: + - name: Install Vulnetix CLI + run: | + curl -sSL https://install.vulnetix.com/cli | bash + + - name: Upload Artifacts to Vulnetix + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + vulnetix gha upload --org-id ${{ secrets.VULNETIX_ORG_ID }} +``` + +### Advanced Workflow with Multiple Scanners + +```yaml +name: Comprehensive Security Scan +on: [push, pull_request] + +jobs: + sast-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run SAST + run: sast-tool --output sast-results.sarif + - uses: actions/upload-artifact@v4 + with: + name: sast-results + path: sast-results.sarif + + dependency-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Generate SBOM + run: sbom-tool --output sbom.json + - uses: actions/upload-artifact@v4 + with: + name: sbom-report + path: sbom.json + + container-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Scan Container + run: container-scanner --output container-scan.sarif + - uses: actions/upload-artifact@v4 + with: + name: container-scan + path: container-scan.sarif + + upload-to-vulnetix: + needs: [sast-scan, dependency-scan, container-scan] + runs-on: ubuntu-latest + permissions: + actions: read + steps: + - name: Install Vulnetix CLI + run: curl -sSL https://install.vulnetix.com/cli | bash + + - name: Upload All Artifacts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + vulnetix gha upload \ + --org-id ${{ secrets.VULNETIX_ORG_ID }} \ + --json > upload-result.json + + # Extract transaction ID for status checking + TXNID=$(jq -r '.txnid' upload-result.json) + echo "Transaction ID: $TXNID" + + # Optionally check status + vulnetix gha status \ + --org-id ${{ secrets.VULNETIX_ORG_ID }} \ + --txnid $TXNID +``` + +## API Endpoints + +The `gha` command interacts with the following Vulnetix API endpoints: + +### 1. Initiate Transaction +**POST** `https://api.vulnetix.com/:org-id/github/artifact-upload` + +**Request Body:** +```json +{ + "_meta": { + "repository": "owner/repo", + "repository_owner": "owner", + "run_id": "123456789", + "run_number": "42", + "workflow_name": "Security Scan", + "job": "upload-artifacts", + "sha": "abc123...", + "ref_name": "main", + "ref_type": "branch", + "event_name": "push", + "actor": "username", + "server_url": "https://github.com", + "api_url": "https://api.github.com", + "artifacts": ["sarif-results", "sbom-report"], + "extra_env_vars": { + "RUNNER_OS": "Linux", + "RUNNER_ARCH": "X64" + } + }, + "artifacts": ["sarif-results", "sbom-report"] +} +``` + +**Response:** +```json +{ + "txnid": "txn_abc123def456", + "success": true +} +``` + +### 2. Upload Artifact +**POST** `https://api.vulnetix.com/:org-id/github/artifact-upload/:txnid` + +**Request:** Multipart form data with files + +**Response:** +```json +{ + "uuid": "art_111222333", + "queue_path": "/queue/2024/01/art_111222333", + "success": true +} +``` + +### 3. Check Transaction Status +**GET** `https://api.vulnetix.com/:org-id/github/artifact-upload/:txnid/status` + +**Response:** +```json +{ + "status": "completed", + "txnid": "txn_abc123def456", + "artifacts": [ + { + "uuid": "art_111222333", + "name": "sarif-results", + "status": "processed", + "queue_path": "/queue/2024/01/art_111222333" + } + ] +} +``` + +### 4. Check Artifact Status +**GET** `https://api.vulnetix.com/:org-id/github/artifact/:uuid/status` + +**Response:** +```json +{ + "status": "processed", + "uuid": "art_111222333", + "name": "sarif-results", + "queue_path": "/queue/2024/01/art_111222333" +} +``` + +## Troubleshooting + +### Error: "GITHUB_TOKEN environment variable is required" + +**Solution:** Ensure the `GITHUB_TOKEN` is set in your workflow: +```yaml +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### Error: "Not running in GitHub Actions environment" + +This is a warning that appears when running outside GitHub Actions. The command will still attempt to run but may fail if required environment variables are missing. + +### Error: "No artifacts found in this workflow run" + +**Possible causes:** +1. Artifacts haven't been uploaded yet (ensure `needs:` dependencies are correct) +2. Artifacts were uploaded in a different workflow run +3. Artifacts have expired (GitHub Actions artifacts expire after 90 days by default) + +**Solution:** Ensure artifacts are uploaded before the `gha upload` step runs. + +### Error: "Failed to list artifacts: GitHub API returned status 403" + +**Solution:** Check that the workflow has proper permissions: +```yaml +permissions: + actions: read # Required to read workflow artifacts +``` + +## Best Practices + +1. **Use Workflow Dependencies**: Ensure artifact upload jobs depend on scanner jobs using `needs:` +2. **Set Proper Permissions**: Grant `actions: read` permission for artifact access +3. **Store Org ID Securely**: Use GitHub Secrets for the organization ID +4. **Check Upload Status**: Verify successful uploads using the status command +5. **Use JSON Output**: For automation, use `--json` flag to parse results programmatically + +## See Also + +- [Vulnetix CLI Documentation](../README.md) +- [SARIF Upload Command](../USAGE.md#sarif-command) +- [GitHub Actions Artifacts Documentation](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) diff --git a/internal/github/artifact.go b/internal/github/artifact.go new file mode 100644 index 0000000..b9be726 --- /dev/null +++ b/internal/github/artifact.go @@ -0,0 +1,267 @@ +package github + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +// Artifact represents a GitHub Actions artifact +type Artifact struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + SizeInBytes int64 `json:"size_in_bytes"` + URL string `json:"url"` + ArchiveDownloadURL string `json:"archive_download_url"` + Expired bool `json:"expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ArtifactsResponse represents the GitHub API response for artifacts +type ArtifactsResponse struct { + TotalCount int `json:"total_count"` + Artifacts []Artifact `json:"artifacts"` +} + +// ArtifactMetadata contains metadata about the workflow and artifacts +type ArtifactMetadata struct { + Repository string `json:"repository"` + RepositoryOwner string `json:"repository_owner"` + RunID string `json:"run_id"` + RunNumber string `json:"run_number"` + WorkflowName string `json:"workflow_name"` + JobName string `json:"job"` + SHA string `json:"sha"` + RefName string `json:"ref_name"` + RefType string `json:"ref_type"` + EventName string `json:"event_name"` + Actor string `json:"actor"` + ServerURL string `json:"server_url"` + APIURL string `json:"api_url"` + Artifacts []string `json:"artifacts"` + ExtraEnvVars map[string]string `json:"extra_env_vars,omitempty"` +} + +// ArtifactCollector handles collection of GitHub Actions artifacts +type ArtifactCollector struct { + token string + apiURL string + repository string + runID string + client *http.Client +} + +// NewArtifactCollector creates a new artifact collector +func NewArtifactCollector(token, apiURL, repository, runID string) *ArtifactCollector { + return &ArtifactCollector{ + token: token, + apiURL: apiURL, + repository: repository, + runID: runID, + client: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +// CollectMetadata collects metadata from GitHub Actions environment +func CollectMetadata(artifactNames []string) *ArtifactMetadata { + // Collect standard GitHub Actions environment variables + metadata := &ArtifactMetadata{ + Repository: getEnv("GITHUB_REPOSITORY"), + RepositoryOwner: getEnv("GITHUB_REPOSITORY_OWNER"), + RunID: getEnv("GITHUB_RUN_ID"), + RunNumber: getEnv("GITHUB_RUN_NUMBER"), + WorkflowName: getEnv("GITHUB_WORKFLOW"), + JobName: getEnv("GITHUB_JOB"), + SHA: getEnv("GITHUB_SHA"), + RefName: getEnv("GITHUB_REF_NAME"), + RefType: getEnv("GITHUB_REF_TYPE"), + EventName: getEnv("GITHUB_EVENT_NAME"), + Actor: getEnv("GITHUB_ACTOR"), + ServerURL: getEnv("GITHUB_SERVER_URL"), + APIURL: getEnv("GITHUB_API_URL"), + Artifacts: artifactNames, + } + + // Collect additional environment variables that might be useful + extraVars := make(map[string]string) + extraEnvKeys := []string{ + "GITHUB_HEAD_REF", + "GITHUB_BASE_REF", + "GITHUB_REF", + "GITHUB_WORKFLOW_REF", + "GITHUB_WORKFLOW_SHA", + "GITHUB_RUN_ATTEMPT", + "RUNNER_OS", + "RUNNER_ARCH", + "RUNNER_NAME", + } + + for _, key := range extraEnvKeys { + if val := getEnv(key); val != "" { + extraVars[key] = val + } + } + + if len(extraVars) > 0 { + metadata.ExtraEnvVars = extraVars + } + + return metadata +} + +// ListArtifacts lists all artifacts for the current workflow run +func (c *ArtifactCollector) ListArtifacts(ctx context.Context) ([]Artifact, error) { + if c.token == "" { + return nil, fmt.Errorf("GitHub token is required. Set GITHUB_TOKEN environment variable") + } + + url := fmt.Sprintf("%s/repos/%s/actions/runs/%s/artifacts", c.apiURL, c.repository, c.runID) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch artifacts: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var artifactsResp ArtifactsResponse + if err := json.NewDecoder(resp.Body).Decode(&artifactsResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return artifactsResp.Artifacts, nil +} + +// DownloadArtifact downloads an artifact and extracts it to a temporary directory +func (c *ArtifactCollector) DownloadArtifact(ctx context.Context, artifact Artifact) (string, error) { + if c.token == "" { + return "", fmt.Errorf("GitHub token is required") + } + + // Create temporary directory for extraction + tmpDir, err := os.MkdirTemp("", fmt.Sprintf("artifact-%s-*", artifact.Name)) + if err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + // Download artifact + req, err := http.NewRequestWithContext(ctx, "GET", artifact.ArchiveDownloadURL, nil) + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("failed to create download request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.client.Do(req) + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("failed to download artifact: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + os.RemoveAll(tmpDir) + return "", fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Save to temporary zip file + zipPath := filepath.Join(tmpDir, "artifact.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("failed to create zip file: %w", err) + } + + _, err = io.Copy(zipFile, resp.Body) + zipFile.Close() + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("failed to save artifact: %w", err) + } + + // Extract zip + if err := extractZip(zipPath, tmpDir); err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("failed to extract artifact: %w", err) + } + + // Remove the zip file + os.Remove(zipPath) + + return tmpDir, nil +} + +// extractZip extracts a zip file to the specified directory +func extractZip(zipPath, destDir string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer reader.Close() + + for _, file := range reader.File { + path := filepath.Join(destDir, file.Name) + + if file.FileInfo().IsDir() { + os.MkdirAll(path, file.Mode()) + continue + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return err + } + + fileReader, err := file.Open() + if err != nil { + destFile.Close() + return err + } + + _, err = io.Copy(destFile, fileReader) + destFile.Close() + fileReader.Close() + + if err != nil { + return err + } + } + + return nil +} + +// getEnv gets an environment variable value +func getEnv(key string) string { + return os.Getenv(key) +} diff --git a/internal/github/artifact_test.go b/internal/github/artifact_test.go new file mode 100644 index 0000000..281566e --- /dev/null +++ b/internal/github/artifact_test.go @@ -0,0 +1,133 @@ +package github + +import ( + "os" + "testing" +) + +func TestCollectMetadata(t *testing.T) { + // Set up test environment variables + testEnvVars := map[string]string{ + "GITHUB_REPOSITORY": "test/repo", + "GITHUB_REPOSITORY_OWNER": "test", + "GITHUB_RUN_ID": "123456", + "GITHUB_RUN_NUMBER": "42", + "GITHUB_WORKFLOW": "Test Workflow", + "GITHUB_JOB": "test-job", + "GITHUB_SHA": "abc123", + "GITHUB_REF_NAME": "main", + "GITHUB_REF_TYPE": "branch", + "GITHUB_EVENT_NAME": "push", + "GITHUB_ACTOR": "testuser", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_API_URL": "https://api.github.com", + "GITHUB_HEAD_REF": "feature-branch", + "RUNNER_OS": "Linux", + } + + // Set environment variables + for key, value := range testEnvVars { + os.Setenv(key, value) + defer os.Unsetenv(key) + } + + artifactNames := []string{"artifact1.zip", "artifact2.zip"} + metadata := CollectMetadata(artifactNames) + + // Validate metadata + if metadata.Repository != "test/repo" { + t.Errorf("Expected repository 'test/repo', got '%s'", metadata.Repository) + } + + if metadata.RepositoryOwner != "test" { + t.Errorf("Expected repository owner 'test', got '%s'", metadata.RepositoryOwner) + } + + if metadata.RunID != "123456" { + t.Errorf("Expected run ID '123456', got '%s'", metadata.RunID) + } + + if metadata.WorkflowName != "Test Workflow" { + t.Errorf("Expected workflow name 'Test Workflow', got '%s'", metadata.WorkflowName) + } + + if len(metadata.Artifacts) != 2 { + t.Errorf("Expected 2 artifacts, got %d", len(metadata.Artifacts)) + } + + if metadata.ExtraEnvVars == nil { + t.Error("Expected ExtraEnvVars to be populated") + } + + if metadata.ExtraEnvVars["GITHUB_HEAD_REF"] != "feature-branch" { + t.Errorf("Expected GITHUB_HEAD_REF 'feature-branch', got '%s'", metadata.ExtraEnvVars["GITHUB_HEAD_REF"]) + } +} + +func TestCollectMetadata_EmptyEnvironment(t *testing.T) { + // Clear all GitHub-related environment variables + gitHubEnvVars := []string{ + "GITHUB_REPOSITORY", + "GITHUB_REPOSITORY_OWNER", + "GITHUB_RUN_ID", + "GITHUB_RUN_NUMBER", + "GITHUB_WORKFLOW", + "GITHUB_JOB", + "GITHUB_SHA", + "GITHUB_REF_NAME", + "GITHUB_REF_TYPE", + "GITHUB_EVENT_NAME", + "GITHUB_ACTOR", + "GITHUB_SERVER_URL", + "GITHUB_API_URL", + } + + for _, key := range gitHubEnvVars { + os.Unsetenv(key) + } + + artifactNames := []string{"test.zip"} + metadata := CollectMetadata(artifactNames) + + // Metadata should be created but with empty values + if metadata.Repository != "" { + t.Errorf("Expected empty repository, got '%s'", metadata.Repository) + } + + if len(metadata.Artifacts) != 1 { + t.Errorf("Expected 1 artifact, got %d", len(metadata.Artifacts)) + } + + if metadata.Artifacts[0] != "test.zip" { + t.Errorf("Expected artifact 'test.zip', got '%s'", metadata.Artifacts[0]) + } +} + +func TestNewArtifactCollector(t *testing.T) { + token := "test-token" + apiURL := "https://api.github.com" + repository := "test/repo" + runID := "123456" + + collector := NewArtifactCollector(token, apiURL, repository, runID) + + if collector.token != token { + t.Errorf("Expected token '%s', got '%s'", token, collector.token) + } + + if collector.apiURL != apiURL { + t.Errorf("Expected apiURL '%s', got '%s'", apiURL, collector.apiURL) + } + + if collector.repository != repository { + t.Errorf("Expected repository '%s', got '%s'", repository, collector.repository) + } + + if collector.runID != runID { + t.Errorf("Expected runID '%s', got '%s'", runID, collector.runID) + } + + if collector.client == nil { + t.Error("Expected client to be initialized") + } +} diff --git a/internal/github/uploader.go b/internal/github/uploader.go new file mode 100644 index 0000000..c4a97db --- /dev/null +++ b/internal/github/uploader.go @@ -0,0 +1,303 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" +) + +// TransactionRequest represents the initial transaction creation request +type TransactionRequest struct { + Meta *ArtifactMetadata `json:"_meta"` + Artifacts []string `json:"artifacts"` +} + +// TransactionResponse represents the response from transaction creation +type TransactionResponse struct { + TxnID string `json:"txnid"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// ArtifactUploadResponse represents the response from artifact upload +type ArtifactUploadResponse struct { + UUID string `json:"uuid"` + QueuePath string `json:"queue_path"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// StatusResponse represents the status check response +type StatusResponse struct { + Status string `json:"status"` + TxnID string `json:"txnid,omitempty"` + Artifacts []ArtifactStatusDetail `json:"artifacts,omitempty"` + Message string `json:"message,omitempty"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// ArtifactStatusDetail represents the status of an individual artifact +type ArtifactStatusDetail struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Status string `json:"status"` + QueuePath string `json:"queue_path,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtifactUploader handles uploading artifacts to Vulnetix API +type ArtifactUploader struct { + baseURL string + orgID string + client *http.Client +} + +// NewArtifactUploader creates a new artifact uploader +func NewArtifactUploader(baseURL, orgID string) *ArtifactUploader { + return &ArtifactUploader{ + baseURL: baseURL, + orgID: orgID, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// InitiateTransaction initiates a new artifact upload transaction +func (u *ArtifactUploader) InitiateTransaction(metadata *ArtifactMetadata, artifactNames []string) (*TransactionResponse, error) { + url := fmt.Sprintf("%s/%s/github/artifact-upload", u.baseURL, u.orgID) + + request := TransactionRequest{ + Meta: metadata, + Artifacts: artifactNames, + } + + jsonData, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("transaction initiation failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var txnResp TransactionResponse + if err := json.Unmarshal(respBody, &txnResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !txnResp.Success { + return nil, fmt.Errorf("transaction initiation failed: %s", txnResp.Message) + } + + return &txnResp, nil +} + +// UploadArtifact uploads a single artifact file to the specified transaction +func (u *ArtifactUploader) UploadArtifact(txnID, artifactName, artifactDir string) (*ArtifactUploadResponse, error) { + url := fmt.Sprintf("%s/%s/github/artifact-upload/%s", u.baseURL, u.orgID, txnID) + + // Find all files in the artifact directory + files, err := findFilesInDir(artifactDir) + if err != nil { + return nil, fmt.Errorf("failed to find files in artifact directory: %w", err) + } + + if len(files) == 0 { + return nil, fmt.Errorf("no files found in artifact directory: %s", artifactDir) + } + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add artifact name as form field + if err := writer.WriteField("artifact_name", artifactName); err != nil { + return nil, fmt.Errorf("failed to write artifact name field: %w", err) + } + + // Add each file to the multipart form + for _, filePath := range files { + file, err := os.Open(filePath) + if err != nil { + writer.Close() + return nil, fmt.Errorf("failed to open file %s: %w", filePath, err) + } + + // Get relative path for the file + relPath, err := filepath.Rel(artifactDir, filePath) + if err != nil { + file.Close() + writer.Close() + return nil, fmt.Errorf("failed to get relative path: %w", err) + } + + part, err := writer.CreateFormFile("files", relPath) + if err != nil { + file.Close() + writer.Close() + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + _, err = io.Copy(part, file) + file.Close() + if err != nil { + writer.Close() + return nil, fmt.Errorf("failed to copy file content: %w", err) + } + } + + contentType := writer.FormDataContentType() + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + // Create and send request + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", contentType) + req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("upload request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("artifact upload failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var uploadResp ArtifactUploadResponse + if err := json.Unmarshal(respBody, &uploadResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !uploadResp.Success { + return nil, fmt.Errorf("artifact upload failed: %s", uploadResp.Message) + } + + return &uploadResp, nil +} + +// GetTransactionStatus retrieves the status of a transaction +func (u *ArtifactUploader) GetTransactionStatus(txnID string) (*StatusResponse, error) { + url := fmt.Sprintf("%s/%s/github/artifact-upload/%s/status", u.baseURL, u.orgID, txnID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("status request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status check failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var statusResp StatusResponse + if err := json.Unmarshal(respBody, &statusResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &statusResp, nil +} + +// GetArtifactStatus retrieves the status of a specific artifact by UUID +func (u *ArtifactUploader) GetArtifactStatus(artifactUUID string) (*StatusResponse, error) { + url := fmt.Sprintf("%s/%s/github/artifact/%s/status", u.baseURL, u.orgID, artifactUUID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("status request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status check failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var statusResp StatusResponse + if err := json.Unmarshal(respBody, &statusResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &statusResp, nil +} + +// findFilesInDir recursively finds all files in a directory +func findFilesInDir(dir string) ([]string, error) { + var files []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + files = append(files, path) + } + return nil + }) + + if err != nil { + return nil, err + } + + return files, nil +} diff --git a/internal/github/uploader_test.go b/internal/github/uploader_test.go new file mode 100644 index 0000000..ec5b91e --- /dev/null +++ b/internal/github/uploader_test.go @@ -0,0 +1,77 @@ +package github + +import ( + "testing" +) + +func TestNewArtifactUploader(t *testing.T) { + baseURL := "https://api.vulnetix.com" + orgID := "123e4567-e89b-12d3-a456-426614174000" + + uploader := NewArtifactUploader(baseURL, orgID) + + if uploader.baseURL != baseURL { + t.Errorf("Expected baseURL '%s', got '%s'", baseURL, uploader.baseURL) + } + + if uploader.orgID != orgID { + t.Errorf("Expected orgID '%s', got '%s'", orgID, uploader.orgID) + } + + if uploader.client == nil { + t.Error("Expected client to be initialized") + } + + if uploader.client.Timeout == 0 { + t.Error("Expected client timeout to be set") + } +} + +func TestTransactionRequest(t *testing.T) { + metadata := &ArtifactMetadata{ + Repository: "test/repo", + RepositoryOwner: "test", + RunID: "123456", + Artifacts: []string{"artifact1", "artifact2"}, + } + + artifactNames := []string{"artifact1", "artifact2"} + + req := TransactionRequest{ + Meta: metadata, + Artifacts: artifactNames, + } + + if req.Meta.Repository != "test/repo" { + t.Errorf("Expected repository 'test/repo', got '%s'", req.Meta.Repository) + } + + if len(req.Artifacts) != 2 { + t.Errorf("Expected 2 artifacts, got %d", len(req.Artifacts)) + } +} + +func TestArtifactStatusDetail(t *testing.T) { + status := ArtifactStatusDetail{ + UUID: "test-uuid", + Name: "test-artifact", + Status: "completed", + QueuePath: "/queue/path", + } + + if status.UUID != "test-uuid" { + t.Errorf("Expected UUID 'test-uuid', got '%s'", status.UUID) + } + + if status.Name != "test-artifact" { + t.Errorf("Expected name 'test-artifact', got '%s'", status.Name) + } + + if status.Status != "completed" { + t.Errorf("Expected status 'completed', got '%s'", status.Status) + } + + if status.QueuePath != "/queue/path" { + t.Errorf("Expected queue path '/queue/path', got '%s'", status.QueuePath) + } +} From 1e6e3c2200700d3d8fd8228077c3340ac3842522 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:16:37 +0000 Subject: [PATCH 2/4] Fix security vulnerabilities and critical issues in GitHub Actions integration - Fix defer in test loop using t.Setenv - Add artifact name sanitization to prevent path traversal - Use cmd.Context() for proper cancellation propagation - Increase HTTP timeout to 10 minutes for large artifacts - Fix Zip Slip vulnerability with path validation - Add size limit check (1GB max) for artifact downloads - Fix defer in loop for temp directory cleanup - Fix ignored JSON marshal error in output - Add authentication support via VULNETIX_API_KEY - Add txnID validation with alphanumeric regex - Fix file handle closing with proper error handling - Use safe file permissions (0644/0755) instead of zip entry permissions Co-authored-by: 0x73746F66 <93355168+0x73746F66@users.noreply.github.com> --- cmd/gha.go | 58 ++++++++++---------- internal/github/artifact.go | 91 ++++++++++++++++++++++++++++---- internal/github/artifact_test.go | 3 +- internal/github/uploader.go | 57 ++++++++++++++++++-- 4 files changed, 165 insertions(+), 44 deletions(-) diff --git a/cmd/gha.go b/cmd/gha.go index 3d146a1..f8aacf6 100644 --- a/cmd/gha.go +++ b/cmd/gha.go @@ -1,7 +1,6 @@ package cmd import ( - "context" "encoding/json" "fmt" "os" @@ -112,7 +111,7 @@ func runGHAUpload(cmd *cobra.Command, args []string) error { // List all artifacts fmt.Println("📦 Fetching workflow artifacts...") - ctx := context.Background() + ctx := cmd.Context() artifacts, err := collector.ListArtifacts(ctx) if err != nil { return fmt.Errorf("failed to list artifacts: %w", err) @@ -155,32 +154,34 @@ func runGHAUpload(cmd *cobra.Command, args []string) error { uploadResults := make([]map[string]string, 0, len(artifacts)) for i, artifact := range artifacts { - fmt.Printf(" [%d/%d] Uploading %s...\n", i+1, len(artifacts), artifact.Name) - - // Download and extract artifact - artifactDir, err := collector.DownloadArtifact(ctx, artifact) - if err != nil { - fmt.Printf(" ❌ Failed to download: %v\n", err) - continue - } - defer os.RemoveAll(artifactDir) + func() { + fmt.Printf(" [%d/%d] Uploading %s...\n", i+1, len(artifacts), artifact.Name) + + // Download and extract artifact + artifactDir, err := collector.DownloadArtifact(ctx, artifact) + if err != nil { + fmt.Printf(" ❌ Failed to download: %v\n", err) + return + } + defer os.RemoveAll(artifactDir) - // Upload to Vulnetix - uploadResp, err := uploader.UploadArtifact(txnResp.TxnID, artifact.Name, artifactDir) - if err != nil { - fmt.Printf(" ❌ Failed to upload: %v\n", err) - continue - } + // Upload to Vulnetix + uploadResp, err := uploader.UploadArtifact(txnResp.TxnID, artifact.Name, artifactDir) + if err != nil { + fmt.Printf(" ❌ Failed to upload: %v\n", err) + return + } - fmt.Printf(" ✅ Uploaded successfully\n") - fmt.Printf(" UUID: %s\n", uploadResp.UUID) - fmt.Printf(" Queue Path: %s\n", uploadResp.QueuePath) + fmt.Printf(" ✅ Uploaded successfully\n") + fmt.Printf(" UUID: %s\n", uploadResp.UUID) + fmt.Printf(" Queue Path: %s\n", uploadResp.QueuePath) - uploadResults = append(uploadResults, map[string]string{ - "name": artifact.Name, - "uuid": uploadResp.UUID, - "queue_path": uploadResp.QueuePath, - }) + uploadResults = append(uploadResults, map[string]string{ + "name": artifact.Name, + "uuid": uploadResp.UUID, + "queue_path": uploadResp.QueuePath, + }) + }() } fmt.Println() @@ -194,10 +195,13 @@ func runGHAUpload(cmd *cobra.Command, args []string) error { // Output JSON if requested if ghaOutputJSON { output := map[string]interface{}{ - "txnid": txnResp.TxnID, + "txnid": txnResp.TxnID, "artifacts": uploadResults, } - jsonData, _ := json.MarshalIndent(output, "", " ") + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } fmt.Println() fmt.Println(string(jsonData)) } diff --git a/internal/github/artifact.go b/internal/github/artifact.go index b9be726..972901c 100644 --- a/internal/github/artifact.go +++ b/internal/github/artifact.go @@ -9,9 +9,18 @@ import ( "net/http" "os" "path/filepath" + "regexp" + "strings" "time" ) +const ( + // maxArtifactSize is the maximum size for an artifact download (1GB) + maxArtifactSize = 1024 * 1024 * 1024 + // artifactDownloadTimeout is the timeout for downloading artifacts + artifactDownloadTimeout = 10 * time.Minute +) + // Artifact represents a GitHub Actions artifact type Artifact struct { ID int64 `json:"id"` @@ -68,11 +77,21 @@ func NewArtifactCollector(token, apiURL, repository, runID string) *ArtifactColl repository: repository, runID: runID, client: &http.Client{ - Timeout: 60 * time.Second, + Timeout: artifactDownloadTimeout, }, } } +// sanitizeArtifactName sanitizes artifact names to prevent path traversal +func sanitizeArtifactName(name string) string { + // Remove any path separators and special characters + re := regexp.MustCompile(`[^a-zA-Z0-9\-_\.]`) + sanitized := re.ReplaceAllString(name, "_") + // Remove leading dots to prevent hidden files + sanitized = strings.TrimLeft(sanitized, ".") + return sanitized +} + // CollectMetadata collects metadata from GitHub Actions environment func CollectMetadata(artifactNames []string) *ArtifactMetadata { // Collect standard GitHub Actions environment variables @@ -162,8 +181,19 @@ func (c *ArtifactCollector) DownloadArtifact(ctx context.Context, artifact Artif return "", fmt.Errorf("GitHub token is required") } + // Check artifact size + if artifact.SizeInBytes > maxArtifactSize { + return "", fmt.Errorf("artifact size (%d bytes) exceeds maximum allowed size (%d bytes)", artifact.SizeInBytes, maxArtifactSize) + } + + // Sanitize artifact name for directory pattern + sanitizedName := sanitizeArtifactName(artifact.Name) + if sanitizedName == "" { + sanitizedName = "artifact" + } + // Create temporary directory for extraction - tmpDir, err := os.MkdirTemp("", fmt.Sprintf("artifact-%s-*", artifact.Name)) + tmpDir, err := os.MkdirTemp("", fmt.Sprintf("artifact-%s-*", sanitizedName)) if err != nil { return "", fmt.Errorf("failed to create temp directory: %w", err) } @@ -199,7 +229,9 @@ func (c *ArtifactCollector) DownloadArtifact(ctx context.Context, artifact Artif return "", fmt.Errorf("failed to create zip file: %w", err) } - _, err = io.Copy(zipFile, resp.Body) + // Limit the reader to prevent resource exhaustion + limitedReader := io.LimitReader(resp.Body, maxArtifactSize) + _, err = io.Copy(zipFile, limitedReader) zipFile.Close() if err != nil { os.RemoveAll(tmpDir) @@ -226,19 +258,44 @@ func extractZip(zipPath, destDir string) error { } defer reader.Close() + // Clean the destination directory path for comparison + destDir = filepath.Clean(destDir) + for _, file := range reader.File { + // Prevent Zip Slip vulnerability by checking for path traversal + if strings.Contains(file.Name, "..") { + return fmt.Errorf("zip file contains potentially unsafe path: %s", file.Name) + } + + // Join and clean the path path := filepath.Join(destDir, file.Name) + path = filepath.Clean(path) + + // Verify the resulting path is within destDir + if !strings.HasPrefix(path, destDir+string(os.PathSeparator)) && path != destDir { + return fmt.Errorf("zip file contains entry outside destination directory: %s", file.Name) + } if file.FileInfo().IsDir() { - os.MkdirAll(path, file.Mode()) + // Use safe permissions for directories + if err := os.MkdirAll(path, 0755); err != nil { + return err + } continue } + // Create parent directory with safe permissions if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } - destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + // Use safe permissions for files (0644 for regular files) + fileMode := os.FileMode(0644) + if file.Mode().IsRegular() { + fileMode = 0644 + } + + destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { return err } @@ -249,12 +306,24 @@ func extractZip(zipPath, destDir string) error { return err } - _, err = io.Copy(destFile, fileReader) - destFile.Close() - fileReader.Close() - - if err != nil { - return err + // Copy with proper error handling + _, copyErr := io.Copy(destFile, fileReader) + + // Close file reader first + readerCloseErr := fileReader.Close() + + // Close destination file and check for close errors + destCloseErr := destFile.Close() + + // Check all errors in order + if copyErr != nil { + return copyErr + } + if readerCloseErr != nil { + return readerCloseErr + } + if destCloseErr != nil { + return destCloseErr } } diff --git a/internal/github/artifact_test.go b/internal/github/artifact_test.go index 281566e..c2484f7 100644 --- a/internal/github/artifact_test.go +++ b/internal/github/artifact_test.go @@ -27,8 +27,7 @@ func TestCollectMetadata(t *testing.T) { // Set environment variables for key, value := range testEnvVars { - os.Setenv(key, value) - defer os.Unsetenv(key) + t.Setenv(key, value) } artifactNames := []string{"artifact1.zip", "artifact2.zip"} diff --git a/internal/github/uploader.go b/internal/github/uploader.go index c4a97db..562ca44 100644 --- a/internal/github/uploader.go +++ b/internal/github/uploader.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "time" ) @@ -55,20 +56,54 @@ type ArtifactStatusDetail struct { type ArtifactUploader struct { baseURL string orgID string + apiKey string client *http.Client } // NewArtifactUploader creates a new artifact uploader func NewArtifactUploader(baseURL, orgID string) *ArtifactUploader { + // Try to get API key from environment + apiKey := os.Getenv("VULNETIX_API_KEY") + return &ArtifactUploader{ baseURL: baseURL, orgID: orgID, + apiKey: apiKey, client: &http.Client{ Timeout: 120 * time.Second, }, } } +// validateTxnID validates transaction ID format +func validateTxnID(txnID string) error { + if txnID == "" { + return fmt.Errorf("transaction ID cannot be empty") + } + + // Transaction ID should be alphanumeric with hyphens and underscores + matched, err := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, txnID) + if err != nil { + return fmt.Errorf("failed to validate transaction ID: %w", err) + } + if !matched { + return fmt.Errorf("invalid transaction ID format: must contain only alphanumeric characters, hyphens, and underscores") + } + + return nil +} + +// addAuthHeaders adds authentication headers to the request +func (u *ArtifactUploader) addAuthHeaders(req *http.Request) { + req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + + // Add API key if available + if u.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+u.apiKey) + req.Header.Set("X-API-Key", u.apiKey) + } +} + // InitiateTransaction initiates a new artifact upload transaction func (u *ArtifactUploader) InitiateTransaction(metadata *ArtifactMetadata, artifactNames []string) (*TransactionResponse, error) { url := fmt.Sprintf("%s/%s/github/artifact-upload", u.baseURL, u.orgID) @@ -89,7 +124,7 @@ func (u *ArtifactUploader) InitiateTransaction(metadata *ArtifactMetadata, artif } req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + u.addAuthHeaders(req) resp, err := u.client.Do(req) if err != nil { @@ -120,6 +155,11 @@ func (u *ArtifactUploader) InitiateTransaction(metadata *ArtifactMetadata, artif // UploadArtifact uploads a single artifact file to the specified transaction func (u *ArtifactUploader) UploadArtifact(txnID, artifactName, artifactDir string) (*ArtifactUploadResponse, error) { + // Validate transaction ID + if err := validateTxnID(txnID); err != nil { + return nil, fmt.Errorf("invalid transaction ID: %w", err) + } + url := fmt.Sprintf("%s/%s/github/artifact-upload/%s", u.baseURL, u.orgID, txnID) // Find all files in the artifact directory @@ -184,7 +224,7 @@ func (u *ArtifactUploader) UploadArtifact(txnID, artifactName, artifactDir strin } req.Header.Set("Content-Type", contentType) - req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + u.addAuthHeaders(req) resp, err := u.client.Do(req) if err != nil { @@ -215,6 +255,11 @@ func (u *ArtifactUploader) UploadArtifact(txnID, artifactName, artifactDir strin // GetTransactionStatus retrieves the status of a transaction func (u *ArtifactUploader) GetTransactionStatus(txnID string) (*StatusResponse, error) { + // Validate transaction ID + if err := validateTxnID(txnID); err != nil { + return nil, fmt.Errorf("invalid transaction ID: %w", err) + } + url := fmt.Sprintf("%s/%s/github/artifact-upload/%s/status", u.baseURL, u.orgID, txnID) req, err := http.NewRequest("GET", url, nil) @@ -222,7 +267,7 @@ func (u *ArtifactUploader) GetTransactionStatus(txnID string) (*StatusResponse, return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + u.addAuthHeaders(req) resp, err := u.client.Do(req) if err != nil { @@ -249,6 +294,10 @@ func (u *ArtifactUploader) GetTransactionStatus(txnID string) (*StatusResponse, // GetArtifactStatus retrieves the status of a specific artifact by UUID func (u *ArtifactUploader) GetArtifactStatus(artifactUUID string) (*StatusResponse, error) { + if artifactUUID == "" { + return nil, fmt.Errorf("artifact UUID cannot be empty") + } + url := fmt.Sprintf("%s/%s/github/artifact/%s/status", u.baseURL, u.orgID, artifactUUID) req, err := http.NewRequest("GET", url, nil) @@ -256,7 +305,7 @@ func (u *ArtifactUploader) GetArtifactStatus(artifactUUID string) (*StatusRespon return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "Vulnetix-CLI/1.0") + u.addAuthHeaders(req) resp, err := u.client.Do(req) if err != nil { From 49988197396bed754a44e0892ec24248b455c3e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:19:47 +0000 Subject: [PATCH 3/4] Add comprehensive unit tests for artifact collector and uploader - Test sanitizeArtifactName function with various inputs - Test ListArtifacts with mock HTTP server - Test Zip Slip vulnerability protection - Test valid zip extraction with safe permissions - Test artifact size limit enforcement - Test successful artifact download and extraction - Test validateTxnID function with various inputs - Test authentication header injection - Test transaction initiation with mock server - Test artifact upload with multipart form data - Test transaction and artifact status retrieval - Test error handling for invalid inputs Co-authored-by: 0x73746F66 <93355168+0x73746F66@users.noreply.github.com> --- internal/github/artifact_test.go | 299 ++++++++++++++++++++++++++++ internal/github/uploader_test.go | 325 +++++++++++++++++++++++++++++++ 2 files changed, 624 insertions(+) diff --git a/internal/github/artifact_test.go b/internal/github/artifact_test.go index c2484f7..e54d96d 100644 --- a/internal/github/artifact_test.go +++ b/internal/github/artifact_test.go @@ -1,8 +1,15 @@ package github import ( + "archive/zip" + "context" + "net/http" + "net/http/httptest" "os" + "path/filepath" + "strings" "testing" + "time" ) func TestCollectMetadata(t *testing.T) { @@ -130,3 +137,295 @@ func TestNewArtifactCollector(t *testing.T) { t.Error("Expected client to be initialized") } } + +func TestSanitizeArtifactName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"normal name", "my-artifact", "my-artifact"}, + {"with spaces", "my artifact", "my_artifact"}, + {"with path separator", "path/to/artifact", "path_to_artifact"}, + {"with dots", "../artifact", "_artifact"}, + {"with special chars", "artifact@#$%", "artifact____"}, + {"empty after sanitization", "...", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeArtifactName(tt.input) + if result != tt.expected { + t.Errorf("sanitizeArtifactName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestListArtifacts(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check auth header + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Return mock artifact list + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "total_count": 2, + "artifacts": [ + { + "id": 1, + "name": "artifact1", + "size_in_bytes": 1024, + "url": "https://api.github.com/repos/test/repo/actions/artifacts/1", + "archive_download_url": "https://api.github.com/repos/test/repo/actions/artifacts/1/zip" + }, + { + "id": 2, + "name": "artifact2", + "size_in_bytes": 2048, + "url": "https://api.github.com/repos/test/repo/actions/artifacts/2", + "archive_download_url": "https://api.github.com/repos/test/repo/actions/artifacts/2/zip" + } + ] + }`)) + })) + defer server.Close() + + collector := NewArtifactCollector("test-token", server.URL, "test/repo", "123") + + ctx := context.Background() + artifacts, err := collector.ListArtifacts(ctx) + if err != nil { + t.Fatalf("ListArtifacts failed: %v", err) + } + + if len(artifacts) != 2 { + t.Errorf("Expected 2 artifacts, got %d", len(artifacts)) + } + + if artifacts[0].Name != "artifact1" { + t.Errorf("Expected artifact name 'artifact1', got '%s'", artifacts[0].Name) + } + + if artifacts[1].SizeInBytes != 2048 { + t.Errorf("Expected artifact size 2048, got %d", artifacts[1].SizeInBytes) + } +} + +func TestListArtifacts_NoToken(t *testing.T) { + collector := NewArtifactCollector("", "https://api.github.com", "test/repo", "123") + + ctx := context.Background() + _, err := collector.ListArtifacts(ctx) + if err == nil { + t.Error("Expected error when token is missing, got nil") + } + + if !strings.Contains(err.Error(), "GitHub token is required") { + t.Errorf("Expected 'GitHub token is required' error, got: %v", err) + } +} + +func TestExtractZip_ZipSlipProtection(t *testing.T) { + // Create a malicious zip file with path traversal + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "malicious.zip") + + // Create a zip with path traversal attempt + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + w := zip.NewWriter(zipFile) + + // Try to create entry with ".." in path + _, err = w.Create("../../etc/passwd") + if err != nil { + zipFile.Close() + t.Fatalf("Failed to create zip entry: %v", err) + } + + w.Close() + zipFile.Close() + + // Attempt to extract + destDir := filepath.Join(tmpDir, "extracted") + err = extractZip(zipPath, destDir) + + // Should fail due to path traversal protection + if err == nil { + t.Error("Expected error for zip slip attempt, got nil") + } + + if !strings.Contains(err.Error(), "unsafe path") && !strings.Contains(err.Error(), "outside destination") { + t.Errorf("Expected path traversal error, got: %v", err) + } +} + +func TestExtractZip_ValidZip(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "valid.zip") + + // Create a valid zip file + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + w := zip.NewWriter(zipFile) + + // Add a test file + fileWriter, err := w.Create("test.txt") + if err != nil { + zipFile.Close() + t.Fatalf("Failed to create zip entry: %v", err) + } + + _, err = fileWriter.Write([]byte("test content")) + if err != nil { + zipFile.Close() + t.Fatalf("Failed to write zip entry: %v", err) + } + + w.Close() + zipFile.Close() + + // Extract + destDir := filepath.Join(tmpDir, "extracted") + err = extractZip(zipPath, destDir) + if err != nil { + t.Fatalf("extractZip failed: %v", err) + } + + // Verify extracted file + extractedFile := filepath.Join(destDir, "test.txt") + content, err := os.ReadFile(extractedFile) + if err != nil { + t.Fatalf("Failed to read extracted file: %v", err) + } + + if string(content) != "test content" { + t.Errorf("Expected 'test content', got '%s'", string(content)) + } + + // Check file permissions are safe + info, err := os.Stat(extractedFile) + if err != nil { + t.Fatalf("Failed to stat extracted file: %v", err) + } + + mode := info.Mode() + if mode != 0644 { + t.Errorf("Expected file mode 0644, got %v", mode) + } +} + +func TestDownloadArtifact_SizeLimit(t *testing.T) { + // Create a test server that returns artifact data + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + // Write some data + w.Write(make([]byte, 1024)) + })) + defer server.Close() + + collector := NewArtifactCollector("test-token", server.URL, "test/repo", "123") + + // Create artifact with size exceeding limit + artifact := Artifact{ + ID: 1, + Name: "large-artifact", + SizeInBytes: maxArtifactSize + 1, + ArchiveDownloadURL: server.URL, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + ctx := context.Background() + _, err := collector.DownloadArtifact(ctx, artifact) + + if err == nil { + t.Error("Expected error for artifact exceeding size limit, got nil") + } + + if !strings.Contains(err.Error(), "exceeds maximum allowed size") { + t.Errorf("Expected size limit error, got: %v", err) + } +} + +func TestDownloadArtifact_Success(t *testing.T) { + tmpDir := t.TempDir() + + // Create a valid zip file to serve + zipPath := filepath.Join(tmpDir, "artifact.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("Failed to create zip file: %v", err) + } + + w := zip.NewWriter(zipFile) + fileWriter, err := w.Create("test.txt") + if err != nil { + zipFile.Close() + t.Fatalf("Failed to create zip entry: %v", err) + } + fileWriter.Write([]byte("test content")) + w.Close() + zipFile.Close() + + // Read the zip file + zipData, err := os.ReadFile(zipPath) + if err != nil { + t.Fatalf("Failed to read zip file: %v", err) + } + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + w.Write(zipData) + })) + defer server.Close() + + collector := NewArtifactCollector("test-token", server.URL, "test/repo", "123") + + artifact := Artifact{ + ID: 1, + Name: "test-artifact", + SizeInBytes: int64(len(zipData)), + ArchiveDownloadURL: server.URL, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + ctx := context.Background() + extractDir, err := collector.DownloadArtifact(ctx, artifact) + if err != nil { + t.Fatalf("DownloadArtifact failed: %v", err) + } + defer os.RemoveAll(extractDir) + + // Verify extracted file + extractedFile := filepath.Join(extractDir, "test.txt") + content, err := os.ReadFile(extractedFile) + if err != nil { + t.Fatalf("Failed to read extracted file: %v", err) + } + + if string(content) != "test content" { + t.Errorf("Expected 'test content', got '%s'", string(content)) + } +} diff --git a/internal/github/uploader_test.go b/internal/github/uploader_test.go index ec5b91e..a2e609a 100644 --- a/internal/github/uploader_test.go +++ b/internal/github/uploader_test.go @@ -1,6 +1,13 @@ package github import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) @@ -75,3 +82,321 @@ func TestArtifactStatusDetail(t *testing.T) { t.Errorf("Expected queue path '/queue/path', got '%s'", status.QueuePath) } } + +func TestValidateTxnID(t *testing.T) { + tests := []struct { + name string + txnID string + expectErr bool + }{ + {"valid alphanumeric", "abc123", false}, + {"valid with hyphens", "abc-123-def", false}, + {"valid with underscores", "abc_123_def", false}, + {"empty string", "", true}, + {"with special chars", "abc@123", true}, + {"with spaces", "abc 123", true}, + {"with path separator", "abc/123", true}, + {"with dots", "abc.123", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTxnID(tt.txnID) + if tt.expectErr && err == nil { + t.Errorf("Expected error for txnID '%s', got nil", tt.txnID) + } + if !tt.expectErr && err != nil { + t.Errorf("Expected no error for txnID '%s', got: %v", tt.txnID, err) + } + }) + } +} + +func TestNewArtifactUploader_WithAPIKey(t *testing.T) { + // Set API key environment variable + t.Setenv("VULNETIX_API_KEY", "test-api-key") + + uploader := NewArtifactUploader("https://api.vulnetix.com", "test-org-id") + + if uploader.apiKey != "test-api-key" { + t.Errorf("Expected API key 'test-api-key', got '%s'", uploader.apiKey) + } +} + +func TestInitiateTransaction(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Verify content type + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + // Read and verify request body + body, _ := io.ReadAll(r.Body) + var req TransactionRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Errorf("Failed to unmarshal request: %v", err) + } + + // Return mock response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + resp := TransactionResponse{ + TxnID: "test-txn-123", + Success: true, + Message: "Transaction initiated", + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + uploader := &ArtifactUploader{ + baseURL: server.URL, + orgID: "test-org", + client: &http.Client{}, + } + + metadata := &ArtifactMetadata{ + Repository: "test/repo", + RunID: "123", + Artifacts: []string{"artifact1"}, + } + + txnResp, err := uploader.InitiateTransaction(metadata, []string{"artifact1"}) + if err != nil { + t.Fatalf("InitiateTransaction failed: %v", err) + } + + if txnResp.TxnID != "test-txn-123" { + t.Errorf("Expected TxnID 'test-txn-123', got '%s'", txnResp.TxnID) + } + + if !txnResp.Success { + t.Error("Expected Success to be true") + } +} + +func TestInitiateTransaction_WithAuth(t *testing.T) { + apiKey := "test-api-key" + + // Create a test server that checks auth + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify auth headers + if r.Header.Get("Authorization") != "Bearer "+apiKey { + t.Errorf("Expected Authorization header 'Bearer %s', got '%s'", apiKey, r.Header.Get("Authorization")) + } + if r.Header.Get("X-API-Key") != apiKey { + t.Errorf("Expected X-API-Key header '%s', got '%s'", apiKey, r.Header.Get("X-API-Key")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + resp := TransactionResponse{ + TxnID: "test-txn-123", + Success: true, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + uploader := &ArtifactUploader{ + baseURL: server.URL, + orgID: "test-org", + apiKey: apiKey, + client: &http.Client{}, + } + + metadata := &ArtifactMetadata{ + Repository: "test/repo", + RunID: "123", + } + + _, err := uploader.InitiateTransaction(metadata, []string{"artifact1"}) + if err != nil { + t.Fatalf("InitiateTransaction failed: %v", err) + } +} + +func TestUploadArtifact(t *testing.T) { + // Create temporary artifact directory with test files + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Verify content type is multipart + contentType := r.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "multipart/form-data") { + t.Errorf("Expected multipart/form-data content type, got %s", contentType) + } + + // Return mock response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + resp := ArtifactUploadResponse{ + UUID: "artifact-uuid-123", + QueuePath: "/queue/path", + Success: true, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + uploader := &ArtifactUploader{ + baseURL: server.URL, + orgID: "test-org", + client: &http.Client{}, + } + + uploadResp, err := uploader.UploadArtifact("test-txn-123", "test-artifact", tmpDir) + if err != nil { + t.Fatalf("UploadArtifact failed: %v", err) + } + + if uploadResp.UUID != "artifact-uuid-123" { + t.Errorf("Expected UUID 'artifact-uuid-123', got '%s'", uploadResp.UUID) + } + + if !uploadResp.Success { + t.Error("Expected Success to be true") + } +} + +func TestUploadArtifact_InvalidTxnID(t *testing.T) { + tmpDir := t.TempDir() + + uploader := &ArtifactUploader{ + baseURL: "https://api.vulnetix.com", + orgID: "test-org", + client: &http.Client{}, + } + + _, err := uploader.UploadArtifact("invalid/txnid", "test-artifact", tmpDir) + if err == nil { + t.Error("Expected error for invalid transaction ID, got nil") + } + + if !strings.Contains(err.Error(), "invalid transaction ID") { + t.Errorf("Expected invalid transaction ID error, got: %v", err) + } +} + +func TestGetTransactionStatus(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Verify URL path + expectedPath := "/test-org/github/artifact-upload/test-txn-123/status" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + // Return mock response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := StatusResponse{ + Status: "completed", + TxnID: "test-txn-123", + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + uploader := &ArtifactUploader{ + baseURL: server.URL, + orgID: "test-org", + client: &http.Client{}, + } + + statusResp, err := uploader.GetTransactionStatus("test-txn-123") + if err != nil { + t.Fatalf("GetTransactionStatus failed: %v", err) + } + + if statusResp.Status != "completed" { + t.Errorf("Expected status 'completed', got '%s'", statusResp.Status) + } + + if statusResp.TxnID != "test-txn-123" { + t.Errorf("Expected TxnID 'test-txn-123', got '%s'", statusResp.TxnID) + } +} + +func TestGetArtifactStatus(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Return mock response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := StatusResponse{ + Status: "processing", + Artifacts: []ArtifactStatusDetail{ + { + UUID: "artifact-uuid-123", + Name: "test-artifact", + Status: "processing", + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + uploader := &ArtifactUploader{ + baseURL: server.URL, + orgID: "test-org", + client: &http.Client{}, + } + + statusResp, err := uploader.GetArtifactStatus("artifact-uuid-123") + if err != nil { + t.Fatalf("GetArtifactStatus failed: %v", err) + } + + if statusResp.Status != "processing" { + t.Errorf("Expected status 'processing', got '%s'", statusResp.Status) + } + + if len(statusResp.Artifacts) != 1 { + t.Errorf("Expected 1 artifact, got %d", len(statusResp.Artifacts)) + } +} + +func TestGetArtifactStatus_EmptyUUID(t *testing.T) { + uploader := &ArtifactUploader{ + baseURL: "https://api.vulnetix.com", + orgID: "test-org", + client: &http.Client{}, + } + + _, err := uploader.GetArtifactStatus("") + if err == nil { + t.Error("Expected error for empty UUID, got nil") + } + + if !strings.Contains(err.Error(), "UUID cannot be empty") { + t.Errorf("Expected empty UUID error, got: %v", err) + } +} From c8ec1ea6c5be12c270aec0459a1e4dda3f110b52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:21:41 +0000 Subject: [PATCH 4/4] Optimize regex compilation for better performance - Move artifact name sanitization regex to package-level variable - Move transaction ID validation regex to package-level variable - Remove redundant file mode condition in extractZip These changes improve performance by compiling regex patterns once at package initialization instead of on every function call. Co-authored-by: 0x73746F66 <93355168+0x73746F66@users.noreply.github.com> --- internal/github/artifact.go | 15 +++++++-------- internal/github/uploader.go | 11 ++++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/github/artifact.go b/internal/github/artifact.go index 972901c..e4a7e25 100644 --- a/internal/github/artifact.go +++ b/internal/github/artifact.go @@ -21,6 +21,11 @@ const ( artifactDownloadTimeout = 10 * time.Minute ) +var ( + // artifactNameRegex matches safe characters for artifact names + artifactNameRegex = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]`) +) + // Artifact represents a GitHub Actions artifact type Artifact struct { ID int64 `json:"id"` @@ -85,8 +90,7 @@ func NewArtifactCollector(token, apiURL, repository, runID string) *ArtifactColl // sanitizeArtifactName sanitizes artifact names to prevent path traversal func sanitizeArtifactName(name string) string { // Remove any path separators and special characters - re := regexp.MustCompile(`[^a-zA-Z0-9\-_\.]`) - sanitized := re.ReplaceAllString(name, "_") + sanitized := artifactNameRegex.ReplaceAllString(name, "_") // Remove leading dots to prevent hidden files sanitized = strings.TrimLeft(sanitized, ".") return sanitized @@ -290,12 +294,7 @@ func extractZip(zipPath, destDir string) error { } // Use safe permissions for files (0644 for regular files) - fileMode := os.FileMode(0644) - if file.Mode().IsRegular() { - fileMode = 0644 - } - - destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) + destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return err } diff --git a/internal/github/uploader.go b/internal/github/uploader.go index 562ca44..0d4f656 100644 --- a/internal/github/uploader.go +++ b/internal/github/uploader.go @@ -13,6 +13,11 @@ import ( "time" ) +var ( + // txnIDRegex validates transaction ID format + txnIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +) + // TransactionRequest represents the initial transaction creation request type TransactionRequest struct { Meta *ArtifactMetadata `json:"_meta"` @@ -82,11 +87,7 @@ func validateTxnID(txnID string) error { } // Transaction ID should be alphanumeric with hyphens and underscores - matched, err := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, txnID) - if err != nil { - return fmt.Errorf("failed to validate transaction ID: %w", err) - } - if !matched { + if !txnIDRegex.MatchString(txnID) { return fmt.Errorf("invalid transaction ID format: must contain only alphanumeric characters, hyphens, and underscores") }