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..f8aacf6 --- /dev/null +++ b/cmd/gha.go @@ -0,0 +1,315 @@ +package cmd + +import ( + "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 := cmd.Context() + 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 { + 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) + return + } + + 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, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + 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..e4a7e25 --- /dev/null +++ b/internal/github/artifact.go @@ -0,0 +1,335 @@ +package github + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "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 +) + +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"` + 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: artifactDownloadTimeout, + }, + } +} + +// sanitizeArtifactName sanitizes artifact names to prevent path traversal +func sanitizeArtifactName(name string) string { + // Remove any path separators and special characters + sanitized := artifactNameRegex.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 + 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") + } + + // 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-*", sanitizedName)) + 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) + } + + // 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) + 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() + + // 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() { + // 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 + } + + // Use safe permissions for files (0644 for regular files) + destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + + fileReader, err := file.Open() + if err != nil { + destFile.Close() + 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 + } + } + + 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..e54d96d --- /dev/null +++ b/internal/github/artifact_test.go @@ -0,0 +1,431 @@ +package github + +import ( + "archive/zip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +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 { + t.Setenv(key, value) + } + + 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") + } +} + +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.go b/internal/github/uploader.go new file mode 100644 index 0000000..0d4f656 --- /dev/null +++ b/internal/github/uploader.go @@ -0,0 +1,353 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "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"` + 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 + 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 + if !txnIDRegex.MatchString(txnID) { + 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) + + 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") + u.addAuthHeaders(req) + + 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) { + // 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 + 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) + u.addAuthHeaders(req) + + 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) { + // 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) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + u.addAuthHeaders(req) + + 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) { + 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) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + u.addAuthHeaders(req) + + 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..a2e609a --- /dev/null +++ b/internal/github/uploader_test.go @@ -0,0 +1,402 @@ +package github + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "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) + } +} + +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) + } +}