Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
contents: write
id-token: write # Required for keyless cosign signing via OIDC

jobs:
test:
Expand Down Expand Up @@ -50,6 +51,9 @@ jobs:
owner: basecamp
repositories: homebrew-tap

- name: Install Cosign
uses: sigstore/cosign-installer@v3

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Security

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
secrets:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install gitleaks
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz
sudo mv gitleaks /usr/local/bin/

- name: Run gitleaks
run: make secrets
28 changes: 28 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Gitleaks configuration for bcq
# https://github.com/gitleaks/gitleaks#configuration

title = "bcq gitleaks config"

# Allowlist specific false positives
[allowlist]
description = "Known safe patterns"

# OAuth client secrets are public by design (they're embedded in CLI binaries)
# See: https://www.oauth.com/oauth2-servers/mobile-and-native-apps/
regexTarget = "match"
regexes = [
# Public OAuth client secrets for native apps (not actual secrets)
'''launchpadClientSecret\s*=\s*"[a-f0-9]+"''',
'''bc3ClientSecret\s*=\s*"[a-f0-9]+"''',

# Domain names (not secrets)
'''launchpad\.37signals\.com''',
'''basecampapi\.(com|localhost)''',
]

# Test files with fake/example tokens
paths = [
'''_test\.go$''',
'''benchmarks/.*''',
'''skills-benchmarking/.*''',
]
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ linters:
- misspell
- bodyclose
- noctx
- gosec

settings:
misspell:
Expand Down
20 changes: 20 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ checksum:
name_template: 'checksums.txt'
algorithm: sha256

# Generate SBOM for supply chain transparency
sboms:
- artifacts: archive
documents:
- sbom

# Sign checksums with cosign (keyless via Sigstore)
# Requires: permissions.id-token: write in workflow for OIDC
signs:
- cmd: cosign
certificate: '${artifact}.pem'
args:
- sign-blob
- '--output-certificate=${certificate}'
- '--output-signature=${signature}'
- '${artifact}'
- '--yes'
artifacts: checksum
output: true

changelog:
sort: asc
use: github
Expand Down
7 changes: 6 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@

repos:
- repo: https://github.com/golangci/golangci-lint
rev: v1.62.2
rev: v2.1.6
hooks:
- id: golangci-lint
args: [--timeout=5m]

- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
Expand Down
40 changes: 40 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,38 @@ check: fmt-check vet test
run: build
$(BUILD_DIR)/$(BINARY)

# --- Security targets ---

# Run all security checks
.PHONY: security
security: lint vuln secrets

# Run vulnerability scanner
.PHONY: vuln
vuln:
@echo "Running govulncheck..."
go run golang.org/x/vuln/cmd/govulncheck@latest ./...

# Run secret scanner
.PHONY: secrets
secrets:
@command -v gitleaks >/dev/null || (echo "Install gitleaks: brew install gitleaks" && exit 1)
gitleaks detect --source . --verbose

# Run fuzz tests (30s each by default)
.PHONY: fuzz
fuzz:
@echo "Running dateparse fuzz test..."
go test -fuzz=FuzzParseFrom -fuzztime=30s ./internal/dateparse/
@echo "Running URL parsing fuzz test..."
go test -fuzz=FuzzURLPathParsing -fuzztime=30s ./internal/commands/

# Run quick fuzz tests (10s each, for CI)
.PHONY: fuzz-quick
fuzz-quick:
go test -fuzz=FuzzParseFrom -fuzztime=10s ./internal/dateparse/
go test -fuzz=FuzzURLPathParsing -fuzztime=10s ./internal/commands/

# Show help
.PHONY: help
help:
Expand All @@ -155,4 +187,12 @@ help:
@echo " install Install to GOPATH/bin"
@echo " check Run all checks (fmt-check, vet, test)"
@echo " run Build and run"
@echo ""
@echo "Security:"
@echo " security Run all security checks (lint, vuln, secrets)"
@echo " vuln Run govulncheck for dependency vulnerabilities"
@echo " secrets Run gitleaks for secret detection"
@echo " fuzz Run fuzz tests (30s each)"
@echo " fuzz-quick Run quick fuzz tests (10s each, for CI)"
@echo ""
@echo " help Show this help"
20 changes: 10 additions & 10 deletions internal/api/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func (c *Cache) GetETag(key string) string {
defer c.mu.RUnlock()

etagsFile := filepath.Join(c.dir, "etags.json")
data, err := os.ReadFile(etagsFile)
data, err := os.ReadFile(etagsFile) //nolint:gosec // G304: Path is from trusted cache dir
if err != nil {
return ""
}
Expand All @@ -59,7 +59,7 @@ func (c *Cache) GetBody(key string) []byte {
defer c.mu.RUnlock()

bodyFile := filepath.Join(c.dir, "responses", key+".body")
data, err := os.ReadFile(bodyFile)
data, err := os.ReadFile(bodyFile) //nolint:gosec // G304: Path is from trusted cache dir
if err != nil {
return nil
}
Expand All @@ -84,7 +84,7 @@ func (c *Cache) Set(key string, body []byte, etag string) error {
return err
}
if err := os.Rename(tmpFile, bodyFile); err != nil {
os.Remove(tmpFile)
_ = os.Remove(tmpFile) // Best-effort cleanup
return err
}

Expand All @@ -93,8 +93,8 @@ func (c *Cache) Set(key string, body []byte, etag string) error {
etags := make(map[string]string)

// Load existing etags
if data, err := os.ReadFile(etagsFile); err == nil {
json.Unmarshal(data, &etags)
if data, err := os.ReadFile(etagsFile); err == nil { //nolint:gosec // G304: Path is from trusted cache dir
_ = json.Unmarshal(data, &etags) // Ignore error - will start with empty map
}

etags[key] = etag
Expand All @@ -110,7 +110,7 @@ func (c *Cache) Set(key string, body []byte, etag string) error {
return err
}
if err := os.Rename(tmpEtags, etagsFile); err != nil {
os.Remove(tmpEtags)
_ = os.Remove(tmpEtags) // Best-effort cleanup
return err
}

Expand All @@ -128,7 +128,7 @@ func (c *Cache) Clear() error {
}

etagsFile := filepath.Join(c.dir, "etags.json")
os.Remove(etagsFile)
_ = os.Remove(etagsFile) // Best-effort cleanup

return nil
}
Expand All @@ -140,14 +140,14 @@ func (c *Cache) Invalidate(key string) error {

// Remove body file
bodyFile := filepath.Join(c.dir, "responses", key+".body")
os.Remove(bodyFile)
_ = os.Remove(bodyFile) // Best-effort cleanup

// Remove from etags.json
etagsFile := filepath.Join(c.dir, "etags.json")
etags := make(map[string]string)

if data, err := os.ReadFile(etagsFile); err == nil {
json.Unmarshal(data, &etags)
if data, err := os.ReadFile(etagsFile); err == nil { //nolint:gosec // G304: Path is from trusted cache dir
_ = json.Unmarshal(data, &etags) // Ignore error - will start with empty map
}

delete(etags, key)
Expand Down
4 changes: 2 additions & 2 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func (c *Client) singleRequest(ctx context.Context, method, url string, body any
// Cache GET responses with ETag
if method == "GET" && cacheKey != "" {
if etag := resp.Header.Get("ETag"); etag != "" {
c.cache.Set(cacheKey, respBody, etag)
_ = c.cache.Set(cacheKey, respBody, etag) // Best-effort cache write
if c.verbose {
fmt.Printf("[bcq] Cache: stored with ETag %s\n", etag)
}
Expand Down Expand Up @@ -344,7 +344,7 @@ func (c *Client) backoffDelay(attempt int) time.Duration {
delay := baseDelay * time.Duration(1<<(attempt-1))

// Add jitter (0-100ms)
jitter := time.Duration(rand.Int63n(int64(maxJitter)))
jitter := time.Duration(rand.Int63n(int64(maxJitter))) //nolint:gosec // G404: Jitter doesn't need crypto rand

return delay + jitter
}
Expand Down
18 changes: 12 additions & 6 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ type ClientCredentials struct {
ClientSecret string `json:"client_secret,omitempty"`
}

// Built-in Launchpad OAuth credentials for production
// Built-in Launchpad OAuth credentials for production.
// These are public client credentials for the native CLI app, not secrets.
const (
launchpadClientID = "5fdd0da8e485ae6f80f4ce0a4938640bb22f1348"
launchpadClientSecret = "a3dc33d78258e828efd6768ac2cd67f32ec1910a"
launchpadClientSecret = "a3dc33d78258e828efd6768ac2cd67f32ec1910a" //nolint:gosec // G101: Public OAuth client secret for native app
)

// Manager handles OAuth authentication.
Expand Down Expand Up @@ -305,7 +306,7 @@ func (m *Manager) loadClientCredentials(ctx context.Context, oauthCfg *OAuthConf

func (m *Manager) loadBC3Client() (*ClientCredentials, error) {
clientFile := config.GlobalConfigDir() + "/client.json"
data, err := os.ReadFile(clientFile)
data, err := os.ReadFile(clientFile) //nolint:gosec // G304: Path is from trusted config dir
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -433,6 +434,7 @@ func (m *Manager) waitForCallback(ctx context.Context, expectedState, authURL st
errCh := make(chan error, 1)

server := &http.Server{
ReadHeaderTimeout: 10 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
Expand Down Expand Up @@ -541,7 +543,9 @@ func (m *Manager) exchangeCode(ctx context.Context, cfg *OAuthConfig, oauthType,

func generateCodeVerifier() string {
b := make([]byte, 32)
rand.Read(b)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

Expand All @@ -552,7 +556,9 @@ func generateCodeChallenge(verifier string) string {

func generateState() string {
b := make([]byte, 16)
rand.Read(b)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

Expand All @@ -575,7 +581,7 @@ func openBrowser(url string) error {
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}

return exec.Command(cmd, args...).Start() //nolint:noctx // Browser launch is fire-and-forget
return exec.Command(cmd, args...).Start() //nolint:gosec,noctx // G204: cmd is hardcoded per-platform; fire-and-forget
}

// GetUserID returns the stored user ID for the current origin.
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewStore(fallbackDir string) *Store {
testKey := "bcq::test"
err := keyring.Set(serviceName, testKey, "test")
if err == nil {
keyring.Delete(serviceName, testKey)
_ = keyring.Delete(serviceName, testKey) // Best-effort cleanup
return &Store{useKeyring: true, fallbackDir: fallbackDir}
}
return &Store{useKeyring: false, fallbackDir: fallbackDir}
Expand Down Expand Up @@ -190,7 +190,7 @@ func (s *Store) MigrateToKeyring() error {
}

// Remove the plaintext file after successful migration
os.Remove(s.credentialsPath())
_ = os.Remove(s.credentialsPath()) // Best-effort cleanup
return nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func NewRootCmd() *cobra.Command {
cmd.PersistentFlags().StringVar(&flags.CacheDir, "cache-dir", "", "Cache directory")

// Hide some flags from help
cmd.PersistentFlags().MarkHidden("base-url")
_ = cmd.PersistentFlags().MarkHidden("base-url") // Error only if flag doesn't exist

return cmd
}
Expand Down Expand Up @@ -170,7 +170,7 @@ func Execute() {
Format: format,
Writer: os.Stdout,
})
writer.Err(err)
_ = writer.Err(err) // Error output is best-effort before exit

os.Exit(apiErr.ExitCode())
}
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func newAPIPostCmd() *cobra.Command {
}

cmd.Flags().StringVarP(&data, "data", "d", "", "JSON request body (required)")
cmd.MarkFlagRequired("data")
_ = cmd.MarkFlagRequired("data") // Error only if flag doesn't exist

return cmd
}
Expand Down Expand Up @@ -150,7 +150,7 @@ func newAPIPutCmd() *cobra.Command {
}

cmd.Flags().StringVarP(&data, "data", "d", "", "JSON request body (required)")
cmd.MarkFlagRequired("data")
_ = cmd.MarkFlagRequired("data") // Error only if flag doesn't exist

return cmd
}
Expand Down
Loading