synapsecns/sanguine

View on GitHub
contrib/golang-ci-lint/main.go

Summary

Maintainability
C
1 day
Test Coverage
// Package main provides a tool for downloading and running specific versions of golangci-lint
// across different architectures and repositories. It supports version pinning through
// .golangci-version files and maintains a local cache of downloaded binaries.
//
// Security Features:
//   - Validates all file paths and URLs
//   - Enforces secure file permissions (0400 for files, 0750 for directories)
//   - Prevents command injection
//   - Implements secure archive extraction
//   - Uses cryptographic verification for downloads
//   - Handles decompression bombs (50MB limit)
//   - Validates binary paths and permissions
//
// Usage:
//
//    golang-ci-lint [flags] [golangci-lint arguments]
//
// The tool automatically:
//   - Downloads the correct version for the current architecture
//   - Caches binaries for future use
//   - Validates security constraints
//   - Propagates exit codes from golangci-lint
//   - Supports cross-repository usage via go-findroot
//
// Environment:
//   - Uses go-findroot for repository detection
//   - Maintains secure file permissions
//   - Supports various architectures and operating systems
//   - Handles timeouts and cancellation via context
//
// Example:
//
//    golang-ci-lint run --fix --config=/path/to/.golangci.yml
//
// Note: This tool is designed to be used across different repositories and
// automatically detects the repository root using go-findroot.
package main

import (
    "archive/tar"
    "compress/gzip"
    "context"
    "crypto/sha256"
    "crypto/tls"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "os/exec"
    "path/filepath"
    "runtime"
    "strings"
    "time"

    "github.com/integralist/go-findroot/find"
    "github.com/synapsecns/sanguine/contrib/golang-ci-lint/permissions"
)

const (
    downloadURLTemplate = "https://github.com/golangci/golangci-lint/releases/download/v%s/golangci-lint-%s-%s-%s.tar.gz"
    cacheDir            = "cache"
    filePerms           = permissions.FilePerms
    dirPerms            = permissions.DirPerms
    execPerms           = permissions.ExecPerms
    maxDecompressSize   = 50 * 1024 * 1024 // 50MB limit for decompression
    httpTimeout         = 30 * time.Second
    maxIdleConns        = 10               // Maximum number of idle connections
    idleConnTimeout     = 5 * time.Second  // Idle connection timeout
    splitParts          = 2                // For strings.SplitN in validateURL
    extraArgsCapacity   = 3                // Extra capacity for processArgs slice
    minTLSVersion       = tls.VersionTLS12 // Minimum TLS version for security
    defaultArgCapacity  = 3                // Default capacity for processArgs slice
)

var (
    // ErrBinaryNotFound indicates the golangci-lint binary wasn't found in the archive.
    ErrBinaryNotFound = errors.New("golangci-lint binary not found in archive")
    // ErrInvalidPath indicates a path is outside the repository root.
    ErrInvalidPath = errors.New("path is outside repository root")
    // ErrInvalidURL indicates the download URL is not from GitHub releases.
    ErrInvalidURL = errors.New("invalid URL: must be from github.com/golangci/golangci-lint/releases")
)

// validateURL ensures the download URL is from GitHub releases.
func validateURL(rawURL string) error {
    u, err := url.Parse(rawURL)
    if err != nil {
        return fmt.Errorf("failed to parse URL: %w", err)
    }
    if u.Scheme != "https" || u.Host != "github.com" {
        return ErrInvalidURL
    }
    path := strings.TrimPrefix(u.Path, "/")
    parts := strings.Split(path, "/")
    if len(parts) < 5 || parts[0] != "golangci" || parts[1] != "golangci-lint" || parts[2] != "releases" {
        return ErrInvalidURL
    }
    return nil
}

// validatePath ensures a path is safe to use and within allowed directories.
// nolint: cyclop
func validatePath(path string, allowedDirs ...string) error {
    // Get repository root using go-findroot
    root, err := find.Repo()
    if err == nil {
        // Add repository root to allowed directories
        allowedDirs = append(allowedDirs, root.Path)
    }

    // Resolve to absolute path and handle symlinks
    absPath, err := filepath.Abs(path)
    if err != nil {
        return fmt.Errorf("failed to resolve absolute path: %w", err)
    }

    // Get the real path, following all symlinks
    realPath, err := filepath.EvalSymlinks(absPath)
    if err != nil {
        // If the file doesn't exist yet, use the absolute path
        if !os.IsNotExist(err) {
            return fmt.Errorf("failed to resolve symlinks: %w", err)
        }
        realPath = absPath
    }

    // Normalize paths for MacOS by removing /private prefix from /var paths
    if runtime.GOOS == "darwin" {
        if strings.HasPrefix(realPath, "/private/var") {
            realPath = strings.Replace(realPath, "/private/var", "/var", 1)
        }
        if strings.HasPrefix(absPath, "/private/var") {
            absPath = strings.Replace(absPath, "/private/var", "/var", 1)
        }
    }

    if strings.Contains(realPath, "..") {
        return fmt.Errorf("path contains directory traversal: %s", realPath)
    }

    // Always allow temp dir and cache dir
    tmpDir := filepath.Clean(os.TempDir())
    if runtime.GOOS == "darwin" && strings.HasPrefix(tmpDir, "/private/var") {
        tmpDir = strings.Replace(tmpDir, "/private/var", "/var", 1)
    }

    defaultDirs := []string{
        tmpDir,
        filepath.Clean(cacheDir),
    }

    // Combine default and additional allowed directories
    allowedDirs = append(defaultDirs, allowedDirs...)

    // Check against allowed directories
    for _, dir := range allowedDirs {
        absDir, err := filepath.Abs(dir)
        if err != nil {
            continue
        }
        cleanDir := filepath.Clean(absDir)
        if runtime.GOOS == "darwin" && strings.HasPrefix(cleanDir, "/private/var") {
            cleanDir = strings.Replace(cleanDir, "/private/var", "/var", 1)
        }
        // Check both realPath and absPath for compatibility
        if strings.HasPrefix(realPath, cleanDir) || strings.HasPrefix(absPath, cleanDir) {
            return nil
        }
    }

    return fmt.Errorf("path outside allowed directories: %s", realPath)
}

// safeJoin safely joins paths, preventing directory traversal.
func safeJoin(root, path string) (string, error) {
    // Clean both paths
    root = filepath.Clean(root)
    path = filepath.Clean(path)

    // Join paths and verify the result is still under root
    joined := filepath.Join(root, path)
    if !strings.HasPrefix(filepath.Clean(joined), root) {
        return "", ErrInvalidPath
    }
    return joined, nil
}

// validateBinaryPermissions ensures binary files have correct permissions.
func validateBinaryPermissions(path string) error {
    info, err := os.Stat(path)
    if err != nil {
        return fmt.Errorf("failed to stat binary: %w", err)
    }
    if info.Mode().Perm() != execPerms {
        return fmt.Errorf("invalid binary permissions: %o (expected %o)", info.Mode().Perm(), execPerms)
    }
    return nil
}

// setupLinter prepares the golangci-lint binary, downloading it if necessary.
func setupLinter(ctx context.Context, version, osName, arch string) (string, error) {
    root, err := find.Repo()
    if err != nil {
        return "", fmt.Errorf("failed to get repository root: %w", err)
    }

    // Create cache directory first with proper permissions
    cacheDir := filepath.Join(root.Path, "contrib/golang-ci-lint/cache")
    if err := os.MkdirAll(cacheDir, dirPerms); err != nil {
        return "", fmt.Errorf("failed to create cache directory: %w", err)
    }

    binaryName := fmt.Sprintf("golangci-lint-%s-%s-%s/golangci-lint", version, osName, arch)
    cachePath := filepath.Join(cacheDir, binaryName)

    // Create binary directory if it doesn't exist
    if err := os.MkdirAll(filepath.Dir(cachePath), dirPerms); err != nil {
        return "", fmt.Errorf("failed to create binary directory: %w", err)
    }

    // Check cache and download if needed
    if _, err := os.Stat(cachePath); err != nil {
        if err := downloadAndExtract(ctx, version, osName, arch, cachePath); err != nil {
            return "", fmt.Errorf("failed to setup golangci-lint: %w", err)
        }
    }

    return cachePath, nil
}

// findWorkDir locates the current working directory.
func findWorkDir() (string, error) {
    // Get repository root using go-findroot
    root, err := find.Repo()
    if err != nil {
        return "", fmt.Errorf("failed to get repository root: %w", err)
    }

    // Use current directory as working directory
    workDir, err := os.Getwd()
    if err != nil {
        return "", fmt.Errorf("failed to get current directory: %w", err)
    }

    // Validate the path
    if err := validatePath(workDir, root.Path); err != nil {
        return "", fmt.Errorf("invalid working directory: %w", err)
    }

    return workDir, nil
}

// processArgs ensures proper argument formatting and adds default configuration.
// nolint: cyclop
func processArgs(args []string, root string) ([]string, string) {
    var workDir string
    processedArgs := make([]string, 0, len(args)+defaultArgCapacity)

    // Process arguments
    for i := 0; i < len(args); i++ {
        arg := args[i]
        switch {
        case arg == "--path":
            if i+1 < len(args) {
                workDir = args[i+1]
                i++ // Skip the next argument since we consumed it
            }
        default:
            // Replace $(GIT_ROOT) in all arguments
            arg = strings.ReplaceAll(arg, "$(GIT_ROOT)", root)
            processedArgs = append(processedArgs, arg)
        }
    }

    // Ensure "run" is the first argument if not present
    hasRun := false
    for _, arg := range processedArgs {
        if arg == "run" {
            hasRun = true
            break
        }
    }
    if !hasRun {
        processedArgs = append([]string{"run"}, processedArgs...)
    }

    // Add default config if not specified
    hasConfig := false
    for _, arg := range processedArgs {
        if strings.HasPrefix(arg, "--config") {
            hasConfig = true
            break
        }
    }
    if !hasConfig {
        configPath := filepath.Join(root, ".golangci.yml")
        processedArgs = append(processedArgs, "--config", configPath)
    }

    return processedArgs, workDir
}

// extractTarGz extracts a specific file from a tar.gz archive.
func extractTarGz(tr *tar.Reader, destPath string) error {
    // Set secure permissions for file operations
    cleanup, err := permissions.SetSecureUmask()
    if err != nil {
        return fmt.Errorf("failed to set secure permissions: %w", err)
    }
    defer cleanup()

    // Create parent directory with secure permissions
    if err := os.MkdirAll(filepath.Dir(destPath), dirPerms); err != nil {
        return fmt.Errorf("failed to create destination directory: %w", err)
    }

    return findAndExtractBinary(tr, destPath)
}

// findAndExtractBinary locates and extracts the golangci-lint binary from the archive.
func findAndExtractBinary(tr *tar.Reader, destPath string) error {
    for {
        header, err := tr.Next()
        if errors.Is(err, io.EOF) {
            break
        }
        if err != nil {
            return fmt.Errorf("failed to read tar: %w", err)
        }

        if strings.HasSuffix(header.Name, "golangci-lint") {
            return extractBinaryFile(tr, header, destPath)
        }
    }
    return ErrBinaryNotFound
}

// extractBinaryFile handles the extraction of a single binary file.
func extractBinaryFile(tr *tar.Reader, header *tar.Header, destPath string) error {
    // Validate file size
    if header.Size > maxDecompressSize {
        return fmt.Errorf("file too large: %d bytes", header.Size)
    }

    // Create temporary file
    tmpFile := destPath + ".tmp"
    if err := createAndCopyBinary(tr, tmpFile); err != nil {
        return err
    }

    // Move to final destination with correct permissions
    if err := finalizeBinary(tmpFile, destPath); err != nil {
        return err
    }

    return nil
}

func createAndCopyBinary(tr *tar.Reader, tmpFile string) error {
    var errs []error

    // Validate path before file operations
    if err := validatePath(tmpFile, os.TempDir()); err != nil {
        return fmt.Errorf("invalid temporary file path: %w", err)
    }

    // Safe to use os.OpenFile here as path is validated by validatePath above
    //nolint:gosec // G304: path is validated by validatePath above
    file, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerms)
    if err != nil {
        return fmt.Errorf("failed to create temporary file: %w", err)
    }
    defer func() {
        if closeErr := file.Close(); closeErr != nil {
            errs = append(errs, fmt.Errorf("failed to close file: %w", closeErr))
        }
    }()

    // Copy with size limit and verify hash
    hasher := sha256.New()
    reader := io.TeeReader(io.LimitReader(tr, maxDecompressSize), hasher)
    if _, err := io.Copy(file, reader); err != nil {
        if removeErr := os.Remove(tmpFile); removeErr != nil {
            errs = append(errs, fmt.Errorf("failed to remove temporary file: %w", removeErr))
        }
        errs = append(errs, fmt.Errorf("failed to extract file: %w", err))
        return errors.Join(errs...)
    }

    // Log hash for verification
    hash := hex.EncodeToString(hasher.Sum(nil))
    fmt.Printf("Extracted file hash: %s\n", hash)

    if len(errs) > 0 {
        return errors.Join(errs...)
    }
    return nil
}

// finalizeBinary moves the temporary file to its final destination.
func finalizeBinary(tmpFile, destPath string) error {
    var errs []error
    // Set correct permissions
    if err := os.Chmod(tmpFile, execPerms); err != nil {
        if removeErr := os.Remove(tmpFile); removeErr != nil {
            errs = append(errs, fmt.Errorf("failed to remove temporary file: %w", removeErr))
        }
        errs = append(errs, fmt.Errorf("failed to set file permissions: %w", err))
        return errors.Join(errs...)
    }

    // Move file to final destination
    if err := os.Rename(tmpFile, destPath); err != nil {
        if removeErr := os.Remove(tmpFile); removeErr != nil {
            errs = append(errs, fmt.Errorf("failed to remove temporary file: %w", removeErr))
        }
        errs = append(errs, fmt.Errorf("failed to move file to destination: %w", err))
        return errors.Join(errs...)
    }

    return validateBinaryPermissions(destPath)
}

func readVersion(root string) (string, error) {
    versionPath, err := safeJoin(root, ".golangci-version")
    if err != nil {
        return "", fmt.Errorf("invalid version file path: %w", err)
    }

    // Validate path before reading
    if err := validatePath(versionPath, root); err != nil {
        return "", fmt.Errorf("invalid version file path: %w", err)
    }

    // Safe to use os.ReadFile here as path is validated by validatePath above
    //nolint:gosec // G304: path is validated by validatePath above
    version, err := os.ReadFile(versionPath)
    if err != nil {
        return "", fmt.Errorf("reading .golangci-version: %w", err)
    }
    return strings.TrimSpace(string(version)), nil
}

// nolint: cyclop
func runLinter(ctx context.Context, binaryPath, workDir string, args []string) error {
    // Validate binary permissions before execution
    info, err := os.Stat(binaryPath)
    if err != nil {
        return fmt.Errorf("checking binary before execution: %w", err)
    }
    if info.Mode().Perm() != execPerms {
        if err := os.Chmod(binaryPath, execPerms); err != nil {
            return fmt.Errorf("setting binary permissions: %w", err)
        }
    }

    // Find repository root for go.work
    root, err := find.Repo()
    if err != nil {
        return fmt.Errorf("finding repository root: %w", err)
    }

    // If workDir is specified, validate and use it
    if workDir != "" {
        if err := validatePath(workDir); err != nil {
            return fmt.Errorf("invalid working directory: %w", err)
        }
        // Check if workDir contains go.mod
        if _, err := os.Stat(filepath.Join(workDir, "go.mod")); err != nil {
            return fmt.Errorf("working directory must contain go.mod file: %w", err)
        }
    } else {
        // Use current directory if no workDir specified
        workDir, err = os.Getwd()
        if err != nil {
            return fmt.Errorf("getting current directory: %w", err)
        }
    }

    // Create command with validated paths and args
    cmd := exec.CommandContext(ctx, binaryPath, args...)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Dir = workDir
    cmd.Env = append(os.Environ(),
        "GO111MODULE=on",
        fmt.Sprintf("GOWORK=%s", filepath.Join(root.Path, "go.work")),
    )

    if err := cmd.Run(); err != nil {
        var exitErr *exec.ExitError
        if errors.As(err, &exitErr) {
            return exitErr
        }
        return fmt.Errorf("running linter: %w", err)
    }
    return nil
}

func setupGitRoot() error {
    root, err := find.Repo()
    if err != nil {
        return fmt.Errorf("failed to find repository root: %w", err)
    }
    if err := os.Setenv("GIT_ROOT", root.Path); err != nil {
        return fmt.Errorf("failed to set GIT_ROOT: %w", err)
    }
    return nil
}

func main() {
    if err := setupGitRoot(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    if err := run(context.Background()); err != nil {
        var exitErr *exec.ExitError
        if errors.As(err, &exitErr) {
            os.Exit(exitErr.ExitCode())
        }
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

func run(ctx context.Context) error {
    // Set secure permissions for file operations
    cleanup, err := permissions.SetSecureUmask()
    if err != nil {
        return fmt.Errorf("failed to set secure permissions: %w", err)
    }
    defer cleanup()

    // Find repository root using go-findroot
    root, err := find.Repo()
    if err != nil {
        return fmt.Errorf("finding repository root: %w", err)
    }

    // Read version from .golangci-version file
    version, err := readVersion(root.Path)
    if err != nil {
        return fmt.Errorf("reading version: %w", err)
    }

    // Setup linter with correct version
    cachePath, err := setupLinter(ctx, version, runtime.GOOS, runtime.GOARCH)
    if err != nil {
        return fmt.Errorf("setting up linter: %w", err)
    }

    // Process arguments first to get working directory
    args, workDir := processArgs(os.Args[1:], root.Path)

    // If no working directory specified via --path, use current directory
    if workDir == "" {
        workDir, err = findWorkDir()
        if err != nil {
            return fmt.Errorf("finding working directory: %w", err)
        }
    }

    // Run linter with processed arguments and working directory
    if err := runLinter(ctx, cachePath, workDir, args); err != nil {
        return fmt.Errorf("running linter: %w", err)
    }

    return nil
}

func setupHTTPClient() *http.Client {
    return &http.Client{
        Timeout: httpTimeout,
        Transport: &http.Transport{
            DisableCompression: true,
            MaxIdleConns:       maxIdleConns,
            IdleConnTimeout:    idleConnTimeout,
            ForceAttemptHTTP2:  true,
            TLSClientConfig: &tls.Config{
                MinVersion:    tls.VersionTLS12,
                Renegotiation: tls.RenegotiateNever,
                CurvePreferences: []tls.CurveID{
                    tls.X25519,
                    tls.CurveP256,
                },
                CipherSuites: []uint16{
                    tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
                    tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
                    tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
                    tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
                    tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
                    tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
                },
            },
        },
    }
}

func downloadFile(ctx context.Context, url string, tmpFile *os.File) error {
    client := setupHTTPClient()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }
    req.Header.Set("User-Agent", "golangci-lint-manager/1.0")
    req.Header.Set("Accept", "application/octet-stream")

    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("failed to download golangci-lint: %w", err)
    }
    defer func() {
        if closeErr := resp.Body.Close(); closeErr != nil {
            err = errors.Join(err, fmt.Errorf("failed to close response body: %w", closeErr))
        }
    }()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("failed to download golangci-lint: HTTP %d: %s", resp.StatusCode, resp.Status)
    }

    contentType := resp.Header.Get("Content-Type")
    if !strings.Contains(contentType, "application/octet-stream") &&
        !strings.Contains(contentType, "application/x-gzip") {
        return fmt.Errorf("unexpected content type: %s", contentType)
    }

    hasher := sha256.New()
    reader := io.TeeReader(io.LimitReader(resp.Body, maxDecompressSize), hasher)
    if _, err := io.Copy(tmpFile, reader); err != nil {
        return fmt.Errorf("failed to save download: %w", err)
    }

    hash := hex.EncodeToString(hasher.Sum(nil))
    fmt.Printf("Downloaded file hash: %s\n", hash)
    return nil
}

func downloadAndExtract(ctx context.Context, version, osName, arch, destPath string) error {
    tmpFile, err := os.CreateTemp("", "golangci-lint-*.tar.gz")
    if err != nil {
        return fmt.Errorf("failed to create temp file: %w", err)
    }
    defer func() {
        if closeErr := tmpFile.Close(); closeErr != nil {
            err = errors.Join(err, fmt.Errorf("failed to close temp file: %w", closeErr))
        }
        if removeErr := os.Remove(tmpFile.Name()); removeErr != nil {
            err = errors.Join(err, fmt.Errorf("failed to remove temp file: %w", removeErr))
        }
    }()

    url := fmt.Sprintf(downloadURLTemplate, version, version, osName, arch)
    if err := validateURL(url); err != nil {
        return fmt.Errorf("invalid download URL: %w", err)
    }
    fmt.Printf("Downloading golangci-lint v%s from %s\n", version, url)

    if err := downloadFile(ctx, url, tmpFile); err != nil {
        return err
    }

    cacheDir := filepath.Clean(filepath.Dir(destPath))
    if err := os.MkdirAll(cacheDir, dirPerms); err != nil {
        return fmt.Errorf("failed to create cache directory: %w", err)
    }

    return extractBinary(ctx, tmpFile.Name(), destPath)
}

func extractBinary(_ context.Context, tarPath, destPath string) error {
    // Validate paths before operations
    if err := validatePath(tarPath, os.TempDir()); err != nil {
        return fmt.Errorf("invalid tar file path: %w", err)
    }
    if err := validatePath(destPath, cacheDir); err != nil {
        return fmt.Errorf("invalid destination path: %w", err)
    }

    // Set secure permissions for file operations
    cleanup, err := permissions.SetSecureUmask()
    if err != nil {
        return fmt.Errorf("failed to set secure permissions: %w", err)
    }
    defer cleanup()

    // Open archive with secure permissions
    // Safe to use os.OpenFile here as path is validated by validatePath above
    //nolint:gosec // G304: path is validated by validatePath above
    file, err := os.OpenFile(tarPath, os.O_RDONLY, filePerms)
    if err != nil {
        return fmt.Errorf("failed to open archive: %w", err)
    }
    defer func() {
        if closeErr := file.Close(); closeErr != nil {
            err = errors.Join(err, fmt.Errorf("failed to close archive: %w", closeErr))
        }
    }()

    // Create gzip reader with size limit
    gzr, err := gzip.NewReader(io.LimitReader(file, maxDecompressSize))
    if err != nil {
        return fmt.Errorf("failed to create gzip reader: %w", err)
    }
    defer func() {
        if closeErr := gzr.Close(); closeErr != nil {
            err = errors.Join(err, fmt.Errorf("failed to close gzip reader: %w", closeErr))
        }
    }()

    return extractTarGz(tar.NewReader(gzr), destPath)
}