Address gosec security warnings (bd-102)

- Enable gosec linter in .golangci.yml
- Tighten file permissions: 0755→0750 for directories, 0644→0600 for configs
- Git hooks remain 0700 (executable, user-only access)
- Add #nosec comments for safe cases with justifications:
  - G204: Safe subprocess launches (git show, bd daemon)
  - G304: File inclusions with controlled paths
  - G201: SQL formatting with controlled column names
  - G115: Integer conversions with controlled values

All gosec warnings resolved (20→0). All tests passing.

Amp-Thread-ID: https://ampcode.com/threads/T-d7166b9e-cbbe-4c7b-9e48-3df36b20f0d0
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-26 22:48:19 -07:00
parent 4ea347e08a
commit 648ecfafe7
21 changed files with 67 additions and 31 deletions

View File

@@ -88,7 +88,7 @@ func checkGitForIssues() (int, string) {
for _, relPath := range candidates {
// Use ToSlash for git path compatibility on Windows
gitPath := filepath.ToSlash(relPath)
cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", gitPath))
cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", gitPath)) // #nosec G204 - git command with safe args
output, err := cmd.Output()
if err == nil && len(output) > 0 {
lines := bytes.Count(output, []byte("\n"))
@@ -139,7 +139,7 @@ func findGitRoot() string {
func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath string) error {
// Get content from git (use ToSlash for Windows compatibility)
gitPath := filepath.ToSlash(jsonlPath)
cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", gitPath))
cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", gitPath)) // #nosec G204 - git command with safe args
jsonlData, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to read from git: %w", err)

View File

@@ -114,7 +114,7 @@ Examples:
commentText, _ := cmd.Flags().GetString("file")
if commentText != "" {
// Read from file
data, err := os.ReadFile(commentText)
data, err := os.ReadFile(commentText) // #nosec G304 - user-provided file path is intentional
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
os.Exit(1)

View File

@@ -533,7 +533,7 @@ func migrateToGlobalDaemon() {
binPath = os.Args[0]
}
cmd := exec.Command(binPath, "daemon", "--global")
cmd := exec.Command(binPath, "daemon", "--global") // #nosec G204 - bd daemon command from trusted binary
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err == nil {
cmd.Stdout = devNull
@@ -643,7 +643,7 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid
args = append(args, "--global")
}
cmd := exec.Command(exe, args...)
cmd := exec.Command(exe, args...) // #nosec G204 - bd daemon command from trusted binary
cmd.Env = append(os.Environ(), "BD_DAEMON_FOREGROUND=1")
configureDaemonProcess(cmd)
@@ -671,6 +671,7 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid
for i := 0; i < 20; i++ {
time.Sleep(100 * time.Millisecond)
// #nosec G304 - controlled path from config
if data, err := os.ReadFile(pidFile); err == nil {
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && pid == expectedPID {
fmt.Printf("Daemon started (PID %d)\n", expectedPID)
@@ -791,7 +792,7 @@ func exportToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
// We need to implement direct import logic here
func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPath string) error {
// Read JSONL file
file, err := os.Open(jsonlPath)
file, err := os.Open(jsonlPath) // #nosec G304 - controlled path from config
if err != nil {
return fmt.Errorf("failed to open JSONL: %w", err)
}
@@ -950,6 +951,7 @@ func setupDaemonLock(pidFile string, dbPath string, log daemonLogger) (io.Closer
}
myPID := os.Getpid()
// #nosec G304 - controlled path from config
if data, err := os.ReadFile(pidFile); err == nil {
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && pid == myPID {
// PID file is correct, continue

View File

@@ -45,6 +45,7 @@ func acquireDaemonLock(beadsDir string, dbPath string) (*DaemonLock, error) {
lockPath := filepath.Join(beadsDir, "daemon.lock")
// Open or create the lock file
// #nosec G304 - controlled path from config
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, fmt.Errorf("cannot open lock file: %w", err)
@@ -88,6 +89,7 @@ func tryDaemonLock(beadsDir string) (running bool, pid int) {
lockPath := filepath.Join(beadsDir, "daemon.lock")
// Open lock file with read-write access (required for LockFileEx on Windows)
// #nosec G304 - controlled path from config
f, err := os.OpenFile(lockPath, os.O_RDWR, 0)
if err != nil {
// No lock file - could be old daemon without lock support
@@ -134,6 +136,7 @@ func tryDaemonLock(beadsDir string) (running bool, pid int) {
func readDaemonLockInfo(beadsDir string) (*DaemonLockInfo, error) {
lockPath := filepath.Join(beadsDir, "daemon.lock")
// #nosec G304 - controlled path from config
data, err := os.ReadFile(lockPath)
if err != nil {
return nil, err
@@ -182,6 +185,7 @@ func validateDaemonLock(beadsDir string, expectedDB string) error {
// This is used for backward compatibility with pre-lock daemons.
func checkPIDFile(beadsDir string) (running bool, pid int) {
pidFile := filepath.Join(beadsDir, "daemon.pid")
// #nosec G304 - controlled path from config
data, err := os.ReadFile(pidFile)
if err != nil {
return false, 0

View File

@@ -257,6 +257,7 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
if jsonOutput {
// JSON mode: read entire file
// #nosec G304 - controlled path from daemon discovery
content, err := os.ReadFile(logPath)
if err != nil {
outputJSON(map[string]string{"error": err.Error()})
@@ -283,6 +284,7 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
}
func tailLines(filePath string, n int) error {
// #nosec G304 - controlled path from daemon discovery
file, err := os.Open(filePath)
if err != nil {
return err
@@ -312,6 +314,7 @@ func tailLines(filePath string, n int) error {
}
func tailFollow(filePath string) {
// #nosec G304 - controlled path from daemon discovery
file, err := os.Open(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)

View File

@@ -310,6 +310,7 @@ func removeIssueFromJSONL(issueID string) error {
}
// Read all issues except the deleted one
// #nosec G304 - controlled path from config
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
@@ -345,6 +346,7 @@ func removeIssueFromJSONL(issueID string) error {
// Write to temp file atomically
temp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())
// #nosec G304 - controlled path from config
out, err := os.OpenFile(temp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
@@ -602,6 +604,7 @@ func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, conn
// readIssueIDsFromFile reads issue IDs from a file (one per line)
func readIssueIDsFromFile(filename string) ([]string, error) {
// #nosec G304 - user-provided file path is intentional
f, err := os.Open(filename)
if err != nil {
return nil, err

View File

@@ -69,6 +69,7 @@ func shouldSkipExport(ctx context.Context, store storage.Storage, issue *types.I
// countIssuesInJSONL counts the number of issues in a JSONL file
func countIssuesInJSONL(path string) (int, error) {
// #nosec G304 - controlled path from config
file, err := os.Open(path)
if err != nil {
return 0, err

View File

@@ -39,6 +39,7 @@ Behavior:
// Open input
in := os.Stdin
if input != "" {
// #nosec G304 - user-provided file path is intentional
f, err := os.Open(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening input file: %v\n", err)

View File

@@ -272,11 +272,13 @@ func hooksInstalled() bool {
}
// Verify they're bd hooks by checking for signature comment
// #nosec G304 - controlled path from git directory
preCommitContent, err := os.ReadFile(preCommit)
if err != nil || !strings.Contains(string(preCommitContent), "bd (beads) pre-commit hook") {
return false
}
// #nosec G304 - controlled path from git directory
postMergeContent, err := os.ReadFile(postMerge)
if err != nil || !strings.Contains(string(postMergeContent), "bd (beads) post-merge hook") {
return false
@@ -290,7 +292,7 @@ func installGitHooks() error {
hooksDir := filepath.Join(".git", "hooks")
// Ensure hooks directory exists
if err := os.MkdirAll(hooksDir, 0755); err != nil {
if err := os.MkdirAll(hooksDir, 0750); err != nil {
return fmt.Errorf("failed to create hooks directory: %w", err)
}
@@ -375,6 +377,7 @@ exit 0
for _, hookPath := range []string{preCommitPath, postMergePath} {
if _, err := os.Stat(hookPath); err == nil {
// Read existing hook to check if it's already a bd hook
// #nosec G304 - controlled path from git directory
content, err := os.ReadFile(hookPath)
if err == nil && strings.Contains(string(content), "bd (beads)") {
// Already a bd hook, skip backup
@@ -389,13 +392,15 @@ exit 0
}
}
// Write pre-commit hook
if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0755); err != nil {
// Write pre-commit hook (executable scripts need 0700)
// #nosec G306 - git hooks must be executable
if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0700); err != nil {
return fmt.Errorf("failed to write pre-commit hook: %w", err)
}
// Write post-merge hook
if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0755); err != nil {
// Write post-merge hook (executable scripts need 0700)
// #nosec G306 - git hooks must be executable
if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0700); err != nil {
return fmt.Errorf("failed to write post-merge hook: %w", err)
}

View File

@@ -601,7 +601,7 @@ func restartDaemonForVersionMismatch() bool {
}
args := []string{"daemon"}
cmd := exec.Command(exe, args...)
cmd := exec.Command(exe, args...) // #nosec G204 - bd daemon command from trusted binary
cmd.Env = append(os.Environ(), "BD_DAEMON_FOREGROUND=1")
// Set working directory to database directory so daemon finds correct DB
@@ -696,6 +696,7 @@ func isDaemonHealthy(socketPath string) bool {
}
func acquireStartLock(lockPath, socketPath string) bool {
// #nosec G304 - controlled path from config
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
debugLog("another process is starting daemon, waiting for readiness")
@@ -776,7 +777,7 @@ func startDaemonProcess(socketPath string, isGlobal bool) bool {
args = append(args, "--global")
}
cmd := exec.Command(binPath, args...)
cmd := exec.Command(binPath, args...) // #nosec G204 - bd daemon command from trusted binary
setupDaemonIO(cmd)
if !isGlobal && dbPath != "" {
@@ -824,6 +825,7 @@ func getPIDFileForSocket(socketPath string) string {
// readPIDFromFile reads a PID from a file
func readPIDFromFile(path string) (int, error) {
// #nosec G304 - controlled path from config
data, err := os.ReadFile(path)
if err != nil {
return 0, err
@@ -879,7 +881,7 @@ func canRetryDaemonStart() bool {
}
// Exponential backoff: 5s, 10s, 20s, 40s, 80s, 120s (capped at 120s)
backoff := time.Duration(5*(1<<uint(daemonStartFailures-1))) * time.Second
backoff := time.Duration(5*(1<<uint(daemonStartFailures-1))) * time.Second // #nosec G115 - controlled value, no overflow risk
if backoff > 120*time.Second {
backoff = 120 * time.Second
}
@@ -943,7 +945,7 @@ func findJSONLPath() string {
// Ensure the directory exists (important for new databases)
// This is the only difference from the public API - we create the directory
dbDir := filepath.Dir(dbPath)
if err := os.MkdirAll(dbDir, 0755); err != nil {
if err := os.MkdirAll(dbDir, 0750); err != nil {
// If we can't create the directory, return discovered path anyway
// (the subsequent write will fail with a clearer error)
return jsonlPath
@@ -1237,6 +1239,7 @@ func flushToJSONL() {
// Read existing JSONL into a map (skip for full export - we'll rebuild from scratch)
issueMap := make(map[string]*types.Issue)
if !fullExport {
// #nosec G304 - controlled path from config
if existingFile, err := os.Open(jsonlPath); err == nil {
scanner := bufio.NewScanner(existingFile)
lineNum := 0
@@ -1295,6 +1298,7 @@ func flushToJSONL() {
// Write to temp file first, then rename (atomic)
// Use PID in filename to avoid collisions between concurrent bd commands (bd-306)
tempPath := fmt.Sprintf("%s.tmp.%d", jsonlPath, os.Getpid())
// #nosec G304 - controlled path from config
f, err := os.Create(tempPath)
if err != nil {
recordFailure(fmt.Errorf("failed to create temp file: %w", err))
@@ -1331,6 +1335,7 @@ func flushToJSONL() {
}
// Store hash of exported JSONL (fixes bd-84: enables hash-based auto-import)
// #nosec G304 - controlled path from config
jsonlData, err := os.ReadFile(jsonlPath)
if err == nil {
hasher := sha256.New()
@@ -2270,7 +2275,7 @@ Examples:
tmpFile.Close()
// Open the editor
editorCmd := exec.Command(editor, tmpPath)
editorCmd := exec.Command(editor, tmpPath) // #nosec G204 - user-provided editor command is intentional
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
@@ -2281,6 +2286,7 @@ Examples:
}
// Read the edited content
// #nosec G304 - controlled temp file path
editedContent, err := os.ReadFile(tmpPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)

View File

@@ -154,6 +154,7 @@ func gitCheckout(ref string) error {
// readIssueFromJSONL reads a specific issue from JSONL file
func readIssueFromJSONL(jsonlPath, issueID string) (*types.Issue, error) {
// #nosec G304 - controlled path from config
file, err := os.Open(jsonlPath)
if err != nil {
return nil, fmt.Errorf("failed to open JSONL: %w", err)

View File

@@ -466,7 +466,7 @@ func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool)
}
// Run import command with --resolve-collisions to automatically handle conflicts
cmd := exec.CommandContext(ctx, exe, args...)
cmd := exec.CommandContext(ctx, exe, args...) // #nosec G204 - bd import command from trusted binary
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("import failed: %w\n%s", err, output)