diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96c9b9720..2767a0a36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -318,11 +318,35 @@ jobs: fi - name: Run gosec Security Scanner - uses: securego/gosec@4a3bd8af174872c778439083ded7adbf3747e770 # v2.26.1 - with: - args: '-fmt sarif -out gosec-results.sarif ./...' + run: | + # Install pinned gosec using the job's existing setup-go (GO_VERSION 1.26.5). + # The securego/gosec Docker action bundles its own Go toolchain (1.26.1) which + # cannot satisfy the "go 1.26.5" module requirement in go.mod, causing it to + # load 0 files and exit 1 with a toolchain mismatch rather than real findings. + go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1 + # Multi-module repo: each ./... only walks the current module so scanning root + # alone silently misses pkg/ and providers/*. Mirror the govulncheck per-module + # loop, collect per-module SARIF, then merge for the upload step. + set -e + for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; do + tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/') + out="$RUNNER_TEMP/gosec-${tag}.sarif" + echo "==> gosec in $mod" + (cd "$mod" && gosec -fmt sarif -out "$out" ./...) + # Code scanning rejects a SARIF file whose runs share a category + # (github.blog changelog 2025-07-21), so give each module's run a + # unique automationDetails.id before merging. + jq --arg id "gosec-${tag}/" '.runs |= map(.automationDetails = {id: $id})' \ + "$out" > "$out.tmp" && mv "$out.tmp" "$out" + done + # Merge per-module SARIF runs into one file for the upload step. + # jq is preinstalled on the GitHub Ubuntu runner image (no new deps). + jq -s '{version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", runs: [.[].runs[]]}' \ + "$RUNNER_TEMP"/gosec-*.sarif > gosec-results.sarif + echo "Merged $(jq '.runs | length' gosec-results.sarif) SARIF runs" - name: Upload gosec results to GitHub Security + if: always() uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: gosec-results.sarif @@ -335,17 +359,48 @@ jobs: format: 'sarif' output: 'trivy-results.sarif' severity: 'CRITICAL,HIGH' + # Pin the Trivy binary independently of the action SHA. v0.36.0 is + # the latest trivy-action release but bundles Trivy v0.70.0, which + # panics in adaptDefaultTags on terraform/environments/aws/main.tf + # (null default_tags vars). Fixed in Trivy >= v0.72.0. The action + # forwards this input to aquasecurity/setup-trivy, so both scan + # steps run the same pinned binary. Keep both steps on this version. + version: 'v0.72.0' - name: Upload Trivy results to GitHub Security uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: 'trivy-results.sarif' - - name: Run tfsec (Terraform Security) - uses: aquasecurity/tfsec-action@b466648d6e39e7c75324f25d83891162a721f2d6 # v1.0.3 + # Terraform IaC misconfiguration scanning. Replaces the deprecated + # aquasecurity/tfsec-action, whose bundled HCL parser rejects Terraform + # 1.5+ `check {}` blocks (e.g. terraform/modules/deployment-checks/main.tf) + # with a hard "scan failed" parse error that soft_fail does not suppress + # (soft_fail only downgrades findings, not scan errors). Trivy is tfsec's + # official successor, parses `check {}` blocks, and (like the fs scan + # above) uses the default exit-code 0 so misconfig findings are reported + # to the Security tab without gating the job -- matching tfsec's prior + # soft_fail: true behaviour while preserving Terraform IaC coverage. + - name: Run Trivy IaC misconfiguration scanner (Terraform) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: 'config' + scan-ref: 'terraform/' + format: 'sarif' + output: 'trivy-config-results.sarif' + severity: 'CRITICAL,HIGH' + # Same pinned Trivy binary as the filesystem scan above (>= v0.72.0 + # avoids the adaptDefaultTags panic on null default_tags vars). + version: 'v0.72.0' + + - name: Upload Trivy IaC results to GitHub Security + if: always() + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: - working_directory: terraform/ - soft_fail: true + sarif_file: 'trivy-config-results.sarif' + # Distinct category so this IaC analysis does not overwrite the + # filesystem Trivy analysis uploaded above (both report as "Trivy"). + category: 'trivy-iac' # Snyk security scanning snyk-scan: diff --git a/ci_cd_sanity_tests/cmd/ri-exchange/main.go b/ci_cd_sanity_tests/cmd/ri-exchange/main.go index a4a9cfee7..43cb5c4ff 100644 --- a/ci_cd_sanity_tests/cmd/ri-exchange/main.go +++ b/ci_cd_sanity_tests/cmd/ri-exchange/main.go @@ -26,6 +26,15 @@ type Output struct { Error string `json:"error,omitempty"` } +// validateTargetCount exits with an error message when n is outside the int32 +// range. Extracted to keep main's cyclomatic complexity within the project limit. +func validateTargetCount(n int) { + if n < 1 || n > (1<<31-1) { + fmt.Fprintln(os.Stderr, "ERROR: --target-count must be between 1 and math.MaxInt32") + os.Exit(2) + } +} + func parseIDs(s string) []string { var out []string for _, p := range strings.Split(s, ",") { @@ -56,6 +65,8 @@ func main() { ) flag.Parse() + validateTargetCount(*targetCount) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSec)*time.Second) defer cancel() @@ -74,7 +85,7 @@ func main() { AccountChk: *expectedAccount, ReservedIDs: ids, TargetOfferingID: *targetOffering, - TargetCount: int32(*targetCount), + TargetCount: int32(*targetCount), // #nosec G115 -- range-validated above (1 <= targetCount <= math.MaxInt32); int->int32 cannot overflow } if !*execute { @@ -84,8 +95,8 @@ func main() { ExpectedAccount: *expectedAccount, ReservedIDs: ids, TargetOfferingID: *targetOffering, - TargetCount: int32(*targetCount), - DryRun: false, // IAMCheckOnly: false = real quote, true = only verify IAM permissions + TargetCount: int32(*targetCount), // #nosec G115 -- range-validated above (1 <= targetCount <= math.MaxInt32); int->int32 cannot overflow + DryRun: false, // IAMCheckOnly: false = real quote, true = only verify IAM permissions }) if err != nil { o.Error = err.Error() @@ -133,7 +144,7 @@ func main() { ExpectedAccount: *expectedAccount, ReservedIDs: ids, TargetOfferingID: *targetOffering, - TargetCount: int32(*targetCount), + TargetCount: int32(*targetCount), // #nosec G115 -- range-validated above (1 <= targetCount <= math.MaxInt32); int->int32 cannot overflow MaxPaymentDueUSD: maxRat, }) o.Quote = q diff --git a/ci_cd_sanity_tests/cmd/sanity/main.go b/ci_cd_sanity_tests/cmd/sanity/main.go index 8c9afc69f..fd666d92e 100644 --- a/ci_cd_sanity_tests/cmd/sanity/main.go +++ b/ci_cd_sanity_tests/cmd/sanity/main.go @@ -10,6 +10,14 @@ import ( "github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/aws" ) +// requireInt32Range exits with an error when n is outside [1, math.MaxInt32]. +func requireInt32Range(flag string, n int) { + if n < 1 || n > (1<<31-1) { + fmt.Fprintf(os.Stderr, "ERROR: %s must be between 1 and math.MaxInt32\n", flag) + os.Exit(2) + } +} + func main() { var ( region = flag.String("region", "us-east-1", "AWS region for sanity checks") @@ -18,6 +26,7 @@ func main() { outPath = flag.String("out", "sanity_report.json", "Output JSON report path") ) flag.Parse() + requireInt32Range("--max-list", *maxList) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() @@ -25,7 +34,7 @@ func main() { rep, err := aws.Run(ctx, aws.Options{ Region: *region, ExpectedAccount: *expectedAccount, - MaxList: int32(*maxList), + MaxList: int32(*maxList), // #nosec G115 -- range-validated above (1 <= maxList <= math.MaxInt32); int->int32 cannot overflow }) if err != nil { fmt.Fprintf(os.Stderr, "sanity run failed: %v\n", err) diff --git a/ci_cd_sanity_tests/pkg/sanity/azure/azure.go b/ci_cd_sanity_tests/pkg/sanity/azure/azure.go index c60e176b9..a18c0fb78 100644 --- a/ci_cd_sanity_tests/pkg/sanity/azure/azure.go +++ b/ci_cd_sanity_tests/pkg/sanity/azure/azure.go @@ -103,7 +103,7 @@ func Run(ctx context.Context, opts Options) (*report.Report, error) { runCmd := func(name string, args ...string) ([]byte, report.CheckResult) { start := time.Now().UTC() - cmd := exec.CommandContext(rctx, "az", args...) + cmd := exec.CommandContext(rctx, "az", args...) // #nosec G702,G204 -- CI sanity test tooling; binary is hardcoded "az" (Azure CLI), args are Azure CLI subcommands constructed within the test code out, err := cmd.CombinedOutput() end := time.Now().UTC() diff --git a/cmd/configure_azure.go b/cmd/configure_azure.go index 7449ea7e0..d85439ee5 100644 --- a/cmd/configure_azure.go +++ b/cmd/configure_azure.go @@ -130,7 +130,7 @@ func storeAzureCredentials(ctx context.Context, store SecretsStore, stackName st } // Marshal credentials to JSON - credJSON, err := json.Marshal(creds) + credJSON, err := json.Marshal(creds) // #nosec G117 -- intentional: marshaling Azure credential struct (contains ClientSecret field) for secure storage in the credential store if err != nil { return fmt.Errorf("failed to marshal credentials: %w", err) } @@ -341,7 +341,7 @@ func createAzureServicePrincipal(reader *bufio.Reader, subscriptionID string) er if choice == "r" || choice == "run" || choice == "" { fmt.Println() fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command("az", "ad", "sp", "create-for-rbac", + cmd := exec.Command("az", "ad", "sp", "create-for-rbac", // #nosec G204 -- binary "az" is hardcoded; subscriptionID validated by validateAzureUUID before exec "--name", "CUDly", "--role", "Reservations Administrator", "--scopes", fmt.Sprintf("/subscriptions/%s", subscriptionID)) @@ -401,7 +401,7 @@ func executeExplicitCommand(reader *bufio.Reader, displayCmd string, program str fmt.Printf("Executing: %s\n", displayCmd) fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command(program, args...) + cmd := exec.Command(program, args...) // #nosec G204 -- configure CLI tool; program is always "az" (Azure CLI) per all callers; no user input reaches this function cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin diff --git a/cmd/configure_gcp.go b/cmd/configure_gcp.go index 1cd4ca010..cbcdf4a85 100644 --- a/cmd/configure_gcp.go +++ b/cmd/configure_gcp.go @@ -210,7 +210,7 @@ func loadAWSConfigForGCP(ctx context.Context) (aws.Config, error) { func loadAndUpdateGCPCredentials(credsFile string) (GCPCredentials, []byte, error) { expandedPath := expandHomeDirectory(credsFile) - credsData, err := os.ReadFile(expandedPath) + credsData, err := os.ReadFile(expandedPath) // #nosec G304 -- GCP credentials file path is operator-supplied via CLI argument; operator controls the value if err != nil { return GCPCredentials{}, nil, fmt.Errorf("failed to read credentials file: %w", err) } @@ -222,7 +222,7 @@ func loadAndUpdateGCPCredentials(credsFile string) (GCPCredentials, []byte, erro if gcpOpts.ProjectID != "" { creds.ProjectID = gcpOpts.ProjectID - credsData, err = json.Marshal(creds) + credsData, err = json.Marshal(creds) // #nosec G117 -- intentional: marshaling GCP credential struct (contains PrivateKey field) for secure storage in the credential store if err != nil { return GCPCredentials{}, nil, fmt.Errorf("failed to marshal updated credentials: %w", err) } @@ -289,7 +289,7 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { // Set the project - use exec.Command with arguments instead of shell fmt.Println() fmt.Println("Setting project...") - cmd := exec.Command("gcloud", "config", "set", "project", projectID) + cmd := exec.Command("gcloud", "config", "set", "project", projectID) // #nosec G204 -- binary "gcloud" is hardcoded; projectID validated by validateGCPProjectID before exec cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -407,7 +407,7 @@ func executeGCPCommand(reader *bufio.Reader, displayCmd string, program string, fmt.Printf("Executing: %s\n", displayCmd) fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command(program, args...) + cmd := exec.Command(program, args...) // #nosec G204 -- configure CLI tool; program is always "gcloud" per all callers; no user input reaches this function cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin diff --git a/cmd/helpers.go b/cmd/helpers.go index d3e0272ed..343584f9e 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -533,7 +533,7 @@ func ConfirmPurchase(totalInstances int, totalSavings float64, skipConfirmation // CheckAuditLogWritable opens the audit log file in append mode to verify it is writable. // Returns an error if the path cannot be opened for writing. func CheckAuditLogWritable(path string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) // #nosec G304 -- audit log path is operator-configured; value is not reachable from user input if err != nil { return fmt.Errorf("audit log %q not writable: %w", path, err) } diff --git a/cmd/multi_service_csv.go b/cmd/multi_service_csv.go index cb8f210cc..57aa6e089 100644 --- a/cmd/multi_service_csv.go +++ b/cmd/multi_service_csv.go @@ -27,7 +27,7 @@ func determineCSVCoverage(cfg Config) float64 { // loadRecommendationsFromCSV reads and returns recommendations from a CSV file. func loadRecommendationsFromCSV(csvPath string) ([]common.Recommendation, error) { - file, err := os.Open(csvPath) + file, err := os.Open(csvPath) // #nosec G304 -- CLI tool: csvPath is an operator-supplied command-line argument if err != nil { return nil, fmt.Errorf("failed to open CSV file: %w", err) } @@ -195,7 +195,7 @@ func writeMultiServiceCSVReport(results []common.PurchaseResult, filepath string return nil } - file, err := os.Create(filepath) + file, err := os.Create(filepath) // #nosec G304 -- CLI tool: filepath is an operator-supplied output path argument if err != nil { return fmt.Errorf("failed to create CSV file: %w", err) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 6b41579c0..9892ef329 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -34,8 +34,12 @@ func main() { // Export BUILD_TIME and GIT_SHA to the environment so the api package can // read them without importing main (import cycle). VERSION is passed // directly to NewApplication to avoid the env round-trip (04-N1). - os.Setenv("BUILD_TIME", BuildTime) - os.Setenv("GIT_SHA", GitSHA) + if err := os.Setenv("BUILD_TIME", BuildTime); err != nil { + log.Printf("failed to export BUILD_TIME: %v", err) + } + if err := os.Setenv("GIT_SHA", GitSHA); err != nil { + log.Printf("failed to export GIT_SHA: %v", err) + } ctx := context.Background() @@ -88,11 +92,11 @@ func getTaskTimeout() time.Duration { if v := os.Getenv("TASK_TIMEOUT"); v != "" { secs, err := strconv.Atoi(v) if err != nil { - log.Printf("WARNING: TASK_TIMEOUT=%q is not a valid integer; using default %v", v, defaultTimeout) + log.Printf("WARNING: TASK_TIMEOUT=%q is not a valid integer; using default %v", v, defaultTimeout) // #nosec G706 -- TASK_TIMEOUT env var is operator-controlled; logged for diagnostics return defaultTimeout } if secs <= 0 { - log.Printf("WARNING: TASK_TIMEOUT=%q must be a positive number; using default %v", v, defaultTimeout) + log.Printf("WARNING: TASK_TIMEOUT=%q must be a positive number; using default %v", v, defaultTimeout) // #nosec G706 -- TASK_TIMEOUT env var is operator-controlled; logged for diagnostics return defaultTimeout } return time.Duration(secs) * time.Second @@ -120,7 +124,7 @@ func determineRuntimeMode(modeFlag string) string { case "lambda", "http": return runtimeMode default: - log.Printf("Warning: unrecognized RUNTIME_MODE %q, falling back to auto-detection", runtimeMode) + log.Printf("Warning: unrecognized RUNTIME_MODE %q, falling back to auto-detection", runtimeMode) // #nosec G706 -- RUNTIME_MODE env var is operator-controlled; logged for diagnostics } } diff --git a/internal/api/handler_purchases_revoke.go b/internal/api/handler_purchases_revoke.go index 05725f442..ec06866c5 100644 --- a/internal/api/handler_purchases_revoke.go +++ b/internal/api/handler_purchases_revoke.go @@ -421,7 +421,7 @@ func (h *Handler) calculateAzureRevoke(ctx context.Context, req *events.LambdaFu return nil, fmt.Errorf("revoke/calculate: create calculate-refund client: %w", err) } - quantity := int32(count) //nolint:gosec + quantity := int32(count) // #nosec G115 -- Azure reservation count bounded by API limits (< sessionID + quoted amount (TOCTOU check). - quantity := int32(record.Count) //nolint:gosec // Count > 0 validated at purchase + quantity := int32(record.Count) // #nosec G115 -- Azure reservation count validated at purchase; bounded by API limits (< math.MaxInt32 { return nil, fmt.Errorf("MinConnections value %d exceeds int32 max", config.MinConnections) } - poolConfig.MaxConns = int32(config.MaxConnections) - poolConfig.MinConns = int32(config.MinConnections) + poolConfig.MaxConns = int32(config.MaxConnections) // #nosec G115 -- bounds-checked against math.MaxInt32 above + poolConfig.MinConns = int32(config.MinConnections) // #nosec G115 -- bounds-checked against math.MaxInt32 above poolConfig.MaxConnLifetime = config.MaxConnLifetime poolConfig.MaxConnIdleTime = config.MaxConnIdleTime poolConfig.HealthCheckPeriod = config.HealthCheckPeriod diff --git a/internal/database/postgres/migrations/migrate.go b/internal/database/postgres/migrations/migrate.go index 7f1396183..b82443651 100644 --- a/internal/database/postgres/migrations/migrate.go +++ b/internal/database/postgres/migrations/migrate.go @@ -104,7 +104,9 @@ func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrat // migration without direct DB access. Remove the env var after the // next successful deploy. if err := maybeForceMigrationVersion(m); err != nil { - m.Close() + if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil { + log.Printf("migrate: close after force-version error: source=%v db=%v", srcErr, dbErr) + } return nil, err } @@ -113,7 +115,9 @@ func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrat // Up() re-applies any pending migrations, letting a cold start self-recover // instead of staying broken until a manual force. if err := maybeAutoHealDirty(m); err != nil { - m.Close() + if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil { + log.Printf("migrate: close after auto-heal error: source=%v db=%v", srcErr, dbErr) + } return nil, err } diff --git a/internal/deploy/docker.go b/internal/deploy/docker.go index 0adce4494..2fbab0d79 100644 --- a/internal/deploy/docker.go +++ b/internal/deploy/docker.go @@ -98,7 +98,7 @@ func NewDefaultCommandRunner() *DefaultCommandRunner { // Run runs a command and streams output to stdout/stderr. func (r *DefaultCommandRunner) Run(name string, args ...string) error { - cmd := exec.Command(name, args...) + cmd := exec.Command(name, args...) // #nosec G204 -- deploy tooling: callers hardcode binary names (npm, docker, aws); no user input reaches this function cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() @@ -106,7 +106,7 @@ func (r *DefaultCommandRunner) Run(name string, args ...string) error { // RunWithStdin runs a command with stdin input and streams output to stdout/stderr. func (r *DefaultCommandRunner) RunWithStdin(name string, stdin string, args ...string) error { - cmd := exec.Command(name, args...) + cmd := exec.Command(name, args...) // #nosec G204 -- deploy tooling: callers hardcode binary names (npm, docker, aws); no user input reaches this function cmd.Stdin = strings.NewReader(stdin) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/deploy/frontend.go b/internal/deploy/frontend.go index 14a1f638b..d8a75b913 100644 --- a/internal/deploy/frontend.go +++ b/internal/deploy/frontend.go @@ -82,47 +82,52 @@ func (s *FrontendService) uploadDirectory(ctx context.Context, distDir, bucketNa if d.IsDir() { return nil } - - // Get relative path - relPath, err := filepath.Rel(distDir, path) - if err != nil { - return err + // Reject symlinks: filepath.WalkDir does not traverse symlinks but + // os.ReadFile will dereference them. A symlinked file inside the build + // dir could point outside it, so we skip any symlink entry explicitly. + if d.Type()&fs.ModeSymlink != 0 { + log.Printf("deploy: skipping symlink %s", path) + return nil } - // Convert to forward slashes for S3 keys - key := strings.ReplaceAll(relPath, string(filepath.Separator), "/") + return s.uploadFile(ctx, distDir, bucketName, path) + }) +} - // Read file - content, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read %s: %w", path, err) - } +// uploadFile reads a single regular file and puts it in the S3 bucket. +// path must be a non-symlink descendant of distDir (enforced by the caller). +func (s *FrontendService) uploadFile(ctx context.Context, distDir, bucketName, path string) error { + relPath, err := filepath.Rel(distDir, path) + if err != nil { + return err + } + key := strings.ReplaceAll(relPath, string(filepath.Separator), "/") - // Determine content type - contentType := mime.TypeByExtension(filepath.Ext(path)) - if contentType == "" { - contentType = "application/octet-stream" - } + content, err := os.ReadFile(path) // #nosec G304,G122 -- path is from WalkDir callback, symlinks rejected by caller; always a regular file descendant of distDir (npm build output under operator control) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } - // Set cache control based on file type - cacheControl := "max-age=31536000" // 1 year for assets - if strings.HasSuffix(key, ".html") || strings.HasSuffix(key, ".json") || strings.HasSuffix(key, ".webmanifest") { - cacheControl = "no-cache, no-store, must-revalidate" - } + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } - // Upload to S3 - _, err = s.S3Client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - Body: bytes.NewReader(content), - ContentType: aws.String(contentType), - CacheControl: aws.String(cacheControl), - }) - if err != nil { - return fmt.Errorf("failed to upload %s: %w", key, err) - } + cacheControl := "max-age=31536000" // 1 year for assets + if strings.HasSuffix(key, ".html") || strings.HasSuffix(key, ".json") || strings.HasSuffix(key, ".webmanifest") { + cacheControl = "no-cache, no-store, must-revalidate" + } - return nil + _, err = s.S3Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: bytes.NewReader(content), + ContentType: aws.String(contentType), + CacheControl: aws.String(cacheControl), }) + if err != nil { + return fmt.Errorf("failed to upload %s: %w", key, err) + } + return nil } // FindFrontendDir finds the frontend directory. @@ -134,7 +139,7 @@ func (s *FrontendService) FindFrontendDir() (string, error) { // Check environment variable first for deployed binaries if envPath := os.Getenv("CUDLY_FRONTEND_DIR"); envPath != "" { packageJSON := filepath.Join(envPath, "package.json") - if _, err := os.Stat(packageJSON); err == nil { + if _, err := os.Stat(packageJSON); err == nil { // #nosec G703 -- CUDLY_FRONTEND_DIR is an operator-set deployment config env var, not user input return filepath.Abs(envPath) } } diff --git a/internal/deploy/profiles.go b/internal/deploy/profiles.go index dddaf430d..640b38c7a 100644 --- a/internal/deploy/profiles.go +++ b/internal/deploy/profiles.go @@ -71,7 +71,7 @@ func LoadConfig() (*DeploymentConfig, error) { }, nil } - data, err := os.ReadFile(configPath) + data, err := os.ReadFile(configPath) // #nosec G304 -- configPath is GetConfigPath() = ~/.cudly/deployment.yaml (os.UserHomeDir + hardcoded subpath); not user input if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } diff --git a/internal/email/templates.go b/internal/email/templates.go index 9cf23f3f2..39da7e6b9 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -137,7 +137,8 @@ Review failed purchases: This is an automated message from CUDly. ` -const passwordResetTemplate = `CUDly - Password Reset Request +const passwordResetTemplate = "" + // #nosec G101 -- email template; "password" in body text is email copy, not a hardcoded credential + `CUDly - Password Reset Request ============================== Hello {{.Email}}, @@ -160,7 +161,8 @@ This is an automated message from CUDly. // Modeled on purchaseApprovalRequestHTMLTemplate (line 367) โ€” inline styles // because most email clients (Outlook, mobile Gmail) ignore class-based CSS. // Issue #355. -const passwordResetHTMLTemplate = ` +const passwordResetHTMLTemplate = "" + // #nosec G101 -- HTML email template; "password" in body text is email copy, not a hardcoded credential + ` CUDly - Password Reset Request diff --git a/internal/oidc/jwks.go b/internal/oidc/jwks.go index 1b0ad0c8a..5e66bc0e3 100644 --- a/internal/oidc/jwks.go +++ b/internal/oidc/jwks.go @@ -69,7 +69,7 @@ func BuildJWKS(ctx context.Context, signer Signer) (JWKS, error) { func bigEndianExponent(e int) []byte { buf := make([]byte, 0, 4) for shift := 24; shift >= 0; shift -= 8 { - b := byte(e >> shift) + b := byte(e >> shift) // #nosec G115 -- intentional byte extraction: RSA exponent fits in 3 bytes; byte(e>>shift) extracts each octet per RFC 7518 ยง6.3.1 if b != 0 || len(buf) > 0 { buf = append(buf, b) } diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index 5b4c80183..55cbb7c20 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -47,7 +47,7 @@ func RenderTable(result scorer.ScoredResult) string { rec.CommitmentType, ) } - w.Flush() + w.Flush() // #nosec G104 -- writing to bytes.Buffer which never returns an error return buf.String() } @@ -76,7 +76,7 @@ func RenderExcluded(result scorer.ScoredResult) string { fr.FilterReason, ) } - w.Flush() + w.Flush() // #nosec G104 -- writing to bytes.Buffer which never returns an error return buf.String() } diff --git a/internal/server/app.go b/internal/server/app.go index 17fa87fbe..839357d05 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -169,7 +169,7 @@ func resolveMigrationsTimeout() time.Duration { } d, err := time.ParseDuration(v) if err != nil || d <= 0 { - log.Printf("CUDLY_MIGRATION_TIMEOUT invalid (%q); using default %s", v, defaultMigrationsTimeout) + log.Printf("CUDLY_MIGRATION_TIMEOUT invalid (%q); using default %s", v, defaultMigrationsTimeout) // #nosec G706 -- env var value is operator-controlled configuration; logged for diagnostics return defaultMigrationsTimeout } return d @@ -939,7 +939,7 @@ func getEnvInt(key string, defaultVal int) int { if val := os.Getenv(key); val != "" { result, err := strconv.Atoi(val) if err != nil { - log.Printf("WARNING: %s=%q is not a valid integer; using default %d", key, val, defaultVal) + log.Printf("WARNING: %s=%q is not a valid integer; using default %d", key, val, defaultVal) // #nosec G706 -- env var value is operator-controlled configuration; logged for diagnostics return defaultVal } return result @@ -956,7 +956,7 @@ func getEnvFloat(key string, defaultVal float64) float64 { if val := os.Getenv(key); val != "" { result, err := strconv.ParseFloat(val, 64) if err != nil { - log.Printf("WARNING: %s=%q is not a valid float; using default %g", key, val, defaultVal) + log.Printf("WARNING: %s=%q is not a valid float; using default %g", key, val, defaultVal) // #nosec G706 -- env var value is operator-controlled configuration; logged for diagnostics return defaultVal } return result diff --git a/internal/server/handler.go b/internal/server/handler.go index 46549cf09..affed3f0c 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -75,7 +75,7 @@ var scheduledEventActions = map[string]ScheduledTaskType{ // HandleScheduledTask processes a scheduled task by type. // It acquires a PostgreSQL advisory lock to prevent concurrent execution of the same task. func (app *Application) HandleScheduledTask(ctx context.Context, taskType ScheduledTaskType) (any, error) { - log.Printf("Handling scheduled task: %s", taskType) + log.Printf("Handling scheduled task: %s", taskType) // #nosec G706 -- taskType validated to contain no '/' before dispatch; informational audit log if err := app.ensureDB(ctx); err != nil { return nil, fmt.Errorf("database connection failed: %w", err) @@ -89,7 +89,7 @@ func (app *Application) HandleScheduledTask(ctx context.Context, taskType Schedu return nil, fmt.Errorf("failed to check task lock: %w", err) } if !acquired { - log.Printf("Task %s already running (advisory lock held), skipping", taskType) + log.Printf("Task %s already running (advisory lock held), skipping", taskType) // #nosec G706 -- taskType validated before dispatch; informational audit log return map[string]string{"status": "skipped", "reason": "already_running"}, nil } defer locker.ReleaseAdvisoryLock(ctx, lockID) @@ -136,8 +136,8 @@ func (app *Application) taskLocker() TaskLocker { // taskLockID derives a stable int64 lock ID from the task type name. func taskLockID(taskType ScheduledTaskType) int64 { h := fnv.New64a() - h.Write([]byte("cudly:task:" + string(taskType))) - return int64(h.Sum64()) + h.Write([]byte("cudly:task:" + string(taskType))) // #nosec G104 -- hash.Hash64.Write never returns an error per Go's hash.Hash interface contract + return int64(h.Sum64()) // #nosec G115 -- advisory lock ID: FNV-64a bit pattern reinterpreted as int64 for pg_advisory_lock; sign irrelevant, overflow expected } // handleCollectRecommendations collects cost optimization recommendations. diff --git a/internal/server/http.go b/internal/server/http.go index c61773397..119c5a21d 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -246,7 +246,7 @@ func (app *Application) handleScheduledHTTP(w http.ResponseWriter, r *http.Reque // Execute scheduled task result, err := app.HandleScheduledTask(ctx, taskType) if err != nil { - log.Printf("Scheduled task %q error: %v", taskTypeStr, err) + log.Printf("Scheduled task %q error: %v", taskTypeStr, err) // #nosec G706 -- taskTypeStr printed with %q which escapes special chars; validated to contain no '/' before this point http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -400,7 +400,7 @@ func lambdaResponseToHTTP(w http.ResponseWriter, lambdaResp *events.LambdaFuncti // Set status code and write body w.WriteHeader(lambdaResp.StatusCode) - if _, err := w.Write(body); err != nil { + if _, err := w.Write(body); err != nil { // #nosec G705 -- body is the proxied Lambda API response; Content-Type is set from the validated Lambda response headers above log.Printf("http: failed to write response body: %v", err) } } diff --git a/internal/server/scheduledauth/validator.go b/internal/server/scheduledauth/validator.go index 6581d3ac7..48ac649ac 100644 --- a/internal/server/scheduledauth/validator.go +++ b/internal/server/scheduledauth/validator.go @@ -287,14 +287,14 @@ func validateJWKSBody(r io.Reader) error { func (v *Validator) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if v.mode == ModeDisabled { - log.Printf("scheduledauth: WARN โ€” request to %s allowed without auth (mode=disabled)", r.URL.Path) + log.Printf("scheduledauth: WARN โ€” request to %s allowed without auth (mode=disabled)", r.URL.Path) // #nosec G706 -- URL path logged only when auth is disabled (operator-set mode); informational audit log next.ServeHTTP(w, r) return } authz := r.Header.Get("Authorization") if err := v.Validate(r.Context(), authz); err != nil { - log.Printf("scheduledauth: rejected %s %s: %v", r.Method, r.URL.Path, err) + log.Printf("scheduledauth: rejected %s %s: %v", r.Method, r.URL.Path, err) // #nosec G706 -- HTTP method and path logged for auth audit; Go net/http normalizes paths before handler dispatch http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -368,7 +368,7 @@ func (v *Validator) validateOIDC(ctx context.Context, authz string) error { return fmt.Errorf("%w: subject %q not in allowlist", ErrUnauthorized, idToken.Subject) } - log.Printf("scheduledauth: oidc token accepted (sub=%s, aud=%v)", idToken.Subject, idToken.Audience) + log.Printf("scheduledauth: oidc token accepted (sub=%s, aud=%v)", idToken.Subject, idToken.Audience) // #nosec G706 -- subject is validated against a pre-configured allowlist before this log; informational audit entry return nil } diff --git a/internal/server/static.go b/internal/server/static.go index 6693699a9..cfed3c3ba 100644 --- a/internal/server/static.go +++ b/internal/server/static.go @@ -31,7 +31,7 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } setCacheHeaders(w, cleanPath) - http.ServeFile(w, r, filePath) + http.ServeFile(w, r, filePath) // #nosec G703 -- filePath validated by resolveStaticFilePath: filepath.Abs + isPathContainedIn prevents directory traversal } // setCacheHeaders sets Cache-Control based on file type. @@ -120,7 +120,7 @@ func serveStaticForLambda(dir, urlPath string) (content []byte, contentType stri return nil, "", "", false } - data, err := os.ReadFile(filePath) + data, err := os.ReadFile(filePath) // #nosec G304 -- filePath validated by resolveStaticFilePath: filepath.Abs + isPathContainedIn prevents path traversal if err != nil { return nil, "", "", false } @@ -142,19 +142,19 @@ func staticDirFromEnv() string { } // Verify the directory and index.html exist indexPath := filepath.Join(dir, "index.html") - if _, err := os.Stat(indexPath); err != nil { + if _, err := os.Stat(indexPath); err != nil { // #nosec G703 -- dir is STATIC_DIR, an operator-set deployment config env var, not user input if !os.IsNotExist(err) { - log.Printf("STATIC_DIR set to %s but index.html not accessible: %v", dir, err) + log.Printf("STATIC_DIR set to %s but index.html not accessible: %v", dir, err) // #nosec G706 -- STATIC_DIR is operator-set config; value not reachable from user input } return "" } // Verify it's actually a directory - info, err := os.Stat(dir) + info, err := os.Stat(dir) // #nosec G703 -- dir is STATIC_DIR, operator-set deployment config env var if err != nil || !info.IsDir() { - log.Printf("STATIC_DIR %s is not a directory", dir) + log.Printf("STATIC_DIR %s is not a directory", dir) // #nosec G706 -- STATIC_DIR is operator-set config; value not reachable from user input return "" } - log.Printf("Static file serving enabled from %s", dir) + log.Printf("Static file serving enabled from %s", dir) // #nosec G706 -- STATIC_DIR is operator-set config; value not reachable from user input return dir } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 27a009abf..1a6f843db 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -18,12 +18,16 @@ func TestContext(t *testing.T) context.Context { // SetEnv sets an environment variable for the duration of the test. func SetEnv(t *testing.T, key, value string) { old := os.Getenv(key) - os.Setenv(key, value) + if err := os.Setenv(key, value); err != nil { + t.Fatalf("SetEnv: os.Setenv(%q): %v", key, err) + } t.Cleanup(func() { if old == "" { os.Unsetenv(key) } else { - os.Setenv(key, old) + if err := os.Setenv(key, old); err != nil { + t.Logf("SetEnv cleanup: os.Setenv(%q): %v", key, err) + } } }) } diff --git a/pkg/common/audit.go b/pkg/common/audit.go index 2eda4adfa..457914bd7 100644 --- a/pkg/common/audit.go +++ b/pkg/common/audit.go @@ -24,7 +24,7 @@ func WriteAuditRecord(record AuditRecord, path string) error { // ops tooling and reconciled against purchase_history; restricting to // 0600 would break that workflow without adding meaningful protection // since the file lives under the run-owned working dir. - // #nosec G302 -- 0644 perms are required for downstream readers. + // #nosec G302,G304 -- 0644 perms required for downstream readers; path is operator-configured audit log location. f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("open audit log %s: %w", path, err) diff --git a/pkg/config/load.go b/pkg/config/load.go index 309356683..73d0b26e9 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -30,7 +30,7 @@ func defaults() Config { Server: ServerConfig{ Enabled: false, Listen: ":8080", - APIKeyEnv: "CUDLY_API_KEY", + APIKeyEnv: "CUDLY_API_KEY", // #nosec G101 -- name of the env var to look up the API key, not a credential value }, Azure: AzureConfig{Scope: "shared"}, } @@ -90,7 +90,7 @@ func resolveFilePath(argPath string) (string, bool, error) { // applyYAML reads the YAML file at path and merges it into cfg. // If explicit is false and the file doesn't exist, it is silently ignored. func applyYAML(cfg *Config, path string, explicit bool) error { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- operator-supplied config file path; reading an arbitrary config file is by design if err != nil { if errors.Is(err, os.ErrNotExist) && !explicit { return nil diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go index e33a1693c..2684d6b67 100644 --- a/pkg/logging/logger.go +++ b/pkg/logging/logger.go @@ -48,7 +48,7 @@ func (l *Logger) getLevel() Level { // setLevel stores the logger's level atomically. func (l *Logger) setLevel(level Level) { - l.level.Store(int32(level)) + l.level.Store(int32(level)) // #nosec G115 -- Level is a bounded enum (LevelDebug=0..LevelError=3); int->int32 cannot overflow } // Config holds logger configuration diff --git a/providers/aws/provider.go b/providers/aws/provider.go index c2b2d5a13..db6aa5762 100644 --- a/providers/aws/provider.go +++ b/providers/aws/provider.go @@ -29,7 +29,7 @@ import ( // GetCredentials. If the SDK ever renames them on upgrade, // TestGetCredentials_SourceMapping will flag the mismatch at test time. const ( - awsSourceSharedConfigCredentials = "SharedConfigCredentials" + awsSourceSharedConfigCredentials = "SharedConfigCredentials" // #nosec G101 -- AWS SDK credential-source name string, not a credential value awsSourceAssumeRoleProvider = "AssumeRoleProvider" ) @@ -502,7 +502,9 @@ func (p *AWSProvider) GetRecommendationsClient(ctx context.Context) (provider.Re // Register the AWS provider with the global registry func init() { - provider.RegisterProvider("aws", func(config *provider.ProviderConfig) (provider.Provider, error) { + if err := provider.RegisterProvider("aws", func(config *provider.ProviderConfig) (provider.Provider, error) { return NewAWSProvider(config) - }) + }); err != nil { + panic("failed to register AWS provider: " + err.Error()) + } } diff --git a/providers/aws/services/ec2/client.go b/providers/aws/services/ec2/client.go index 2b31d1cf1..0d8bfa594 100644 --- a/providers/aws/services/ec2/client.go +++ b/providers/aws/services/ec2/client.go @@ -151,7 +151,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati // Create the purchase request input := &ec2.PurchaseReservedInstancesOfferingInput{ ReservedInstancesOfferingId: aws.String(offeringID), - InstanceCount: aws.Int32(int32(rec.Count)), + InstanceCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 } // Execute the purchase diff --git a/providers/aws/services/elasticache/client.go b/providers/aws/services/elasticache/client.go index 987e32e96..d14058c0c 100644 --- a/providers/aws/services/elasticache/client.go +++ b/providers/aws/services/elasticache/client.go @@ -162,7 +162,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati input := &elasticache.PurchaseReservedCacheNodesOfferingInput{ ReservedCacheNodesOfferingId: aws.String(offeringID), - CacheNodeCount: aws.Int32(int32(rec.Count)), + CacheNodeCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 ReservedCacheNodeId: aws.String(reservationID), Tags: c.createPurchaseTags(rec, opts.Source), } diff --git a/providers/aws/services/memorydb/client.go b/providers/aws/services/memorydb/client.go index 2e4542fe1..9466473cd 100644 --- a/providers/aws/services/memorydb/client.go +++ b/providers/aws/services/memorydb/client.go @@ -159,7 +159,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati input := &memorydb.PurchaseReservedNodesOfferingInput{ ReservedNodesOfferingId: aws.String(offeringID), ReservationId: aws.String(reservationID), - NodeCount: aws.Int32(int32(rec.Count)), + NodeCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 Tags: c.createPurchaseTags(rec, opts.Source), } diff --git a/providers/aws/services/opensearch/client.go b/providers/aws/services/opensearch/client.go index e4071aedf..5b250dff6 100644 --- a/providers/aws/services/opensearch/client.go +++ b/providers/aws/services/opensearch/client.go @@ -188,7 +188,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati input := &opensearch.PurchaseReservedInstanceOfferingInput{ ReservedInstanceOfferingId: aws.String(offeringID), ReservationName: aws.String(reservationName), - InstanceCount: aws.Int32(int32(rec.Count)), + InstanceCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 } response, err := c.client.PurchaseReservedInstanceOffering(ctx, input) diff --git a/providers/aws/services/rds/client.go b/providers/aws/services/rds/client.go index 58e16a3dd..d895c9c14 100644 --- a/providers/aws/services/rds/client.go +++ b/providers/aws/services/rds/client.go @@ -162,7 +162,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati input := &rds.PurchaseReservedDBInstancesOfferingInput{ ReservedDBInstancesOfferingId: aws.String(offeringID), ReservedDBInstanceId: aws.String(reservationID), - DBInstanceCount: aws.Int32(int32(rec.Count)), + DBInstanceCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 Tags: c.createPurchaseTags(rec, opts.Source), } diff --git a/providers/aws/services/redshift/client.go b/providers/aws/services/redshift/client.go index a7b49b864..395d73113 100644 --- a/providers/aws/services/redshift/client.go +++ b/providers/aws/services/redshift/client.go @@ -188,7 +188,7 @@ func (c *Client) PurchaseCommitment(ctx context.Context, rec common.Recommendati input := &redshift.PurchaseReservedNodeOfferingInput{ ReservedNodeOfferingId: aws.String(offeringID), - NodeCount: aws.Int32(int32(rec.Count)), + NodeCount: aws.Int32(int32(rec.Count)), // #nosec G115 -- Count from CE recommendation; AWS RI purchase limits keep this far below math.MaxInt32 } response, err := c.client.PurchaseReservedNodeOffering(ctx, input) diff --git a/providers/azure/provider.go b/providers/azure/provider.go index 2817e7120..f67d5634a 100644 --- a/providers/azure/provider.go +++ b/providers/azure/provider.go @@ -539,7 +539,9 @@ func (p *AzureProvider) GetRecommendationsClientForAccount(ctx context.Context, // Register the Azure provider with the global registry func init() { - provider.RegisterProvider("azure", func(config *provider.ProviderConfig) (provider.Provider, error) { + if err := provider.RegisterProvider("azure", func(config *provider.ProviderConfig) (provider.Provider, error) { return NewAzureProvider(config) - }) + }); err != nil { + panic("failed to register Azure provider: " + err.Error()) + } } diff --git a/providers/azure/services/compute/client.go b/providers/azure/services/compute/client.go index 77d175b4a..7062f54a0 100644 --- a/providers/azure/services/compute/client.go +++ b/providers/azure/services/compute/client.go @@ -369,7 +369,7 @@ func (c *ComputeClient) fetchCapacityProviderState(ctx context.Context, bearerTo return providerRegistrationState{}, fmt.Errorf("check provider: %w", err) } body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + resp.Body.Close() // #nosec G104 -- body fully drained by io.ReadAll before Close; transport close error does not affect correctness if resp.StatusCode < 200 || resp.StatusCode >= 300 { // Non-2xx from the provider check (e.g. 403 permissions, 429 throttle). @@ -403,7 +403,7 @@ func (c *ComputeClient) triggerCapacityProviderRegistration(ctx context.Context, return fmt.Errorf("register provider: %w", err) } regBody, _ := io.ReadAll(regResp.Body) - regResp.Body.Close() + regResp.Body.Close() // #nosec G104 -- body fully drained by io.ReadAll before Close; transport close error does not affect correctness if regResp.StatusCode < 200 || regResp.StatusCode >= 300 { return fmt.Errorf("register Microsoft.Capacity provider returned HTTP %d: %s", regResp.StatusCode, string(regBody)) } diff --git a/providers/azure/services/internal/reservations/purchase.go b/providers/azure/services/internal/reservations/purchase.go index 9602ecaa6..7bf666e69 100644 --- a/providers/azure/services/internal/reservations/purchase.go +++ b/providers/azure/services/internal/reservations/purchase.go @@ -212,7 +212,7 @@ func doCalculatePrice(ctx context.Context, httpClient HTTPClient, calcURL string return "", fmt.Errorf("calculatePrice HTTP call: %w", err) } body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + resp.Body.Close() // #nosec G104 -- body fully drained by io.ReadAll before Close; transport close error does not affect correctness if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("calculatePrice failed with status %d: %s", resp.StatusCode, string(body)) @@ -315,7 +315,7 @@ func fetchReservationOrdersPage(ctx context.Context, httpClient HTTPClient, page return nil, fmt.Errorf("list reservation orders HTTP call: %w", err) } body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + resp.Body.Close() // #nosec G104 -- body fully drained by io.ReadAll before Close; transport close error does not affect correctness if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("list reservation orders failed with status %d: %s", resp.StatusCode, string(body)) @@ -400,7 +400,7 @@ func doPurchase(ctx context.Context, httpClient HTTPClient, purchaseURL string, return fmt.Errorf("failed to purchase reservation: %w", err) } body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + resp.Body.Close() // #nosec G104 -- body fully drained by io.ReadAll before Close; transport close error does not affect correctness if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusAccepted { return nil diff --git a/providers/gcp/provider.go b/providers/gcp/provider.go index b2cb19a61..28fd867a4 100644 --- a/providers/gcp/provider.go +++ b/providers/gcp/provider.go @@ -302,11 +302,11 @@ func (p *GCPProvider) detectCredentialSource() (provider.CredentialSource, bool) func adcWellKnownFileExists() bool { const adcFile = "application_default_credentials.json" if dir := os.Getenv("CLOUDSDK_CONFIG"); dir != "" { - _, err := os.Stat(filepath.Join(dir, adcFile)) + _, err := os.Stat(filepath.Join(dir, adcFile)) // #nosec G703 -- CLOUDSDK_CONFIG is a standard gcloud env var set by the OS or gcloud CLI, not user-controlled input return err == nil } if appData := os.Getenv("APPDATA"); appData != "" { - if _, err := os.Stat(filepath.Join(appData, "gcloud", adcFile)); err == nil { + if _, err := os.Stat(filepath.Join(appData, "gcloud", adcFile)); err == nil { // #nosec G703 -- APPDATA is a standard Windows system env var set by the OS, not user-controlled input return true } } @@ -551,7 +551,9 @@ func findActiveProjectInPage(out *string, page *cloudresourcemanager.ListProject func init() { // Register GCP provider in the global registry - provider.RegisterProvider("gcp", func(config *provider.ProviderConfig) (provider.Provider, error) { + if err := provider.RegisterProvider("gcp", func(config *provider.ProviderConfig) (provider.Provider, error) { return NewProvider(config) - }) + }); err != nil { + panic("failed to register GCP provider: " + err.Error()) + } }