Annotate gosec-safe file accesses

This commit is contained in:
Codex Agent
2025-11-17 10:12:46 -07:00
parent 7b63b5a30b
commit bf9b2c83fb
14 changed files with 182 additions and 158 deletions

View File

@@ -14,20 +14,20 @@ import (
) )
var ( var (
compactDryRun bool compactDryRun bool
compactTier int compactTier int
compactAll bool compactAll bool
compactID string compactID string
compactForce bool compactForce bool
compactBatch int compactBatch int
compactWorkers int compactWorkers int
compactStats bool compactStats bool
compactAnalyze bool compactAnalyze bool
compactApply bool compactApply bool
compactAuto bool compactAuto bool
compactSummary string compactSummary string
compactActor string compactActor string
compactLimit int compactLimit int
) )
var compactCmd = &cobra.Command{ var compactCmd = &cobra.Command{
@@ -762,6 +762,7 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
os.Exit(1) os.Exit(1)
} }
} else { } else {
// #nosec G304 -- summary file path provided explicitly by operator
summaryBytes, err = os.ReadFile(compactSummary) summaryBytes, err = os.ReadFile(compactSummary)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read summary file: %v\n", err) fmt.Fprintf(os.Stderr, "Error: failed to read summary file: %v\n", err)

View File

@@ -13,13 +13,13 @@ import (
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/daemon"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
) )
// Status constants for doctor checks // Status constants for doctor checks
@@ -148,7 +148,7 @@ func applyFixes(result doctorResult) {
} }
} }
func runDiagnostics(path string) doctorResult{ func runDiagnostics(path string) doctorResult {
result := doctorResult{ result := doctorResult{
Path: path, Path: path,
CLIVersion: Version, CLIVersion: Version,
@@ -293,7 +293,7 @@ func checkInstallation(path string) doctorCheck {
func checkDatabaseVersion(path string) doctorCheck { func checkDatabaseVersion(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads") beadsDir := filepath.Join(path, ".beads")
// Check metadata.json first for custom database name // Check metadata.json first for custom database name
var dbPath string var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
@@ -379,7 +379,7 @@ func checkDatabaseVersion(path string) doctorCheck {
func checkIDFormat(path string) doctorCheck { func checkIDFormat(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads") beadsDir := filepath.Join(path, ".beads")
// Check metadata.json first for custom database name // Check metadata.json first for custom database name
var dbPath string var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
@@ -668,7 +668,7 @@ func printDiagnostics(result doctorResult) {
func checkMultipleDatabases(path string) doctorCheck { func checkMultipleDatabases(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads") beadsDir := filepath.Join(path, ".beads")
// Find all .db files (excluding backups and vc.db) // Find all .db files (excluding backups and vc.db)
files, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) files, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
if err != nil { if err != nil {
@@ -1032,7 +1032,7 @@ func countJSONLIssues(jsonlPath string) (int, map[string]int, error) {
func checkPermissions(path string) doctorCheck { func checkPermissions(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads") beadsDir := filepath.Join(path, ".beads")
// Check if .beads/ is writable // Check if .beads/ is writable
testFile := filepath.Join(beadsDir, ".doctor-test-write") testFile := filepath.Join(beadsDir, ".doctor-test-write")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil { if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
@@ -1190,9 +1190,9 @@ func checkGitHooks(path string) doctorCheck {
// Recommended hooks and their purposes // Recommended hooks and their purposes
recommendedHooks := map[string]string{ recommendedHooks := map[string]string{
"pre-commit": "Flushes pending bd changes to JSONL before commit", "pre-commit": "Flushes pending bd changes to JSONL before commit",
"post-merge": "Imports updated JSONL after git pull/merge", "post-merge": "Imports updated JSONL after git pull/merge",
"pre-push": "Exports database to JSONL before push", "pre-push": "Exports database to JSONL before push",
} }
hooksDir := filepath.Join(gitDir, "hooks") hooksDir := filepath.Join(gitDir, "hooks")
@@ -1240,7 +1240,7 @@ func checkGitHooks(path string) doctorCheck {
func checkSchemaCompatibility(path string) doctorCheck { func checkSchemaCompatibility(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads") beadsDir := filepath.Join(path, ".beads")
// Check metadata.json first for custom database name // Check metadata.json first for custom database name
var dbPath string var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
@@ -1277,18 +1277,22 @@ func checkSchemaCompatibility(path string) doctorCheck {
// This is a simplified version since we can't import the internal package directly // This is a simplified version since we can't import the internal package directly
// Check all critical tables and columns // Check all critical tables and columns
criticalChecks := map[string][]string{ criticalChecks := map[string][]string{
"issues": {"id", "title", "content_hash", "external_ref", "compacted_at"}, "issues": {"id", "title", "content_hash", "external_ref", "compacted_at"},
"dependencies": {"issue_id", "depends_on_id", "type"}, "dependencies": {"issue_id", "depends_on_id", "type"},
"child_counters": {"parent_id", "last_child"}, "child_counters": {"parent_id", "last_child"},
"export_hashes": {"issue_id", "content_hash"}, "export_hashes": {"issue_id", "content_hash"},
} }
var missingElements []string var missingElements []string
for table, columns := range criticalChecks { for table, columns := range criticalChecks {
// Try to query all columns // Try to query all columns
query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", strings.Join(columns, ", "), table) query := fmt.Sprintf(
"SELECT %s FROM %s LIMIT 0",
strings.Join(columns, ", "),
table,
) // #nosec G201 -- table/column names sourced from hardcoded map
_, err := db.Exec(query) _, err := db.Exec(query)
if err != nil { if err != nil {
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "no such table") { if strings.Contains(errMsg, "no such table") {
@@ -1296,7 +1300,7 @@ func checkSchemaCompatibility(path string) doctorCheck {
} else if strings.Contains(errMsg, "no such column") { } else if strings.Contains(errMsg, "no such column") {
// Find which columns are missing // Find which columns are missing
for _, col := range columns { for _, col := range columns {
colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) // #nosec G201 -- names come from static schema definition
if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") { if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") {
missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col)) missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col))
} }

View File

@@ -188,6 +188,7 @@ func collectDatabaseStats(dbPath string) map[string]string {
} }
func startCPUProfile(path string) error { func startCPUProfile(path string) error {
// #nosec G304 -- profile path supplied by CLI flag in trusted environment
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
return err return err

View File

@@ -74,6 +74,7 @@ func CheckGitHooks() []HookStatus {
// getHookVersion extracts the version from a hook file // getHookVersion extracts the version from a hook file
func getHookVersion(path string) (string, error) { func getHookVersion(path string) (string, error) {
// #nosec G304 -- hook path constrained to .git/hooks directory
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
return "", err return "", err
@@ -293,6 +294,7 @@ func installHooks(embeddedHooks map[string]string, force bool) error {
} }
// Write hook file // Write hook file
// #nosec G306 -- git hooks must be executable for Git to run them
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
return fmt.Errorf("failed to write %s: %w", hookName, err) return fmt.Errorf("failed to write %s: %w", hookName, err)
} }

View File

@@ -42,14 +42,14 @@ NOTE: Import requires direct database access and does not work with daemon mode.
fmt.Fprintf(os.Stderr, "Error: failed to create database directory: %v\n", err) fmt.Fprintf(os.Stderr, "Error: failed to create database directory: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Import requires direct database access due to complex transaction handling // Import requires direct database access due to complex transaction handling
// and collision detection. Force direct mode regardless of daemon state. // and collision detection. Force direct mode regardless of daemon state.
if daemonClient != nil { if daemonClient != nil {
debug.Logf("Debug: import command forcing direct mode (closes daemon connection)\n") debug.Logf("Debug: import command forcing direct mode (closes daemon connection)\n")
_ = daemonClient.Close() _ = daemonClient.Close()
daemonClient = nil daemonClient = nil
var err error var err error
store, err = sqlite.New(dbPath) store, err = sqlite.New(dbPath)
if err != nil { if err != nil {
@@ -58,7 +58,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
} }
defer func() { _ = store.Close() }() defer func() { _ = store.Close() }()
} }
// We'll check if database needs initialization after reading the JSONL // We'll check if database needs initialization after reading the JSONL
// so we can detect the prefix from the imported issues // so we can detect the prefix from the imported issues
@@ -96,78 +96,78 @@ NOTE: Import requires direct database access and does not work with daemon mode.
lineNum := 0 lineNum := 0
for scanner.Scan() { for scanner.Scan() {
lineNum++ lineNum++
rawLine := scanner.Bytes() rawLine := scanner.Bytes()
line := string(rawLine) line := string(rawLine)
// Skip empty lines // Skip empty lines
if line == "" { if line == "" {
continue continue
}
// Detect git conflict markers in raw bytes (before JSON decoding)
// This prevents false positives when issue content contains these strings
trimmed := bytes.TrimSpace(rawLine)
if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) ||
bytes.Equal(trimmed, []byte("=======")) ||
bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) {
fmt.Fprintf(os.Stderr, "Git conflict markers detected in JSONL file (line %d)\n", lineNum)
fmt.Fprintf(os.Stderr, "→ Attempting automatic 3-way merge...\n\n")
// Attempt automatic merge using bd merge command
if err := attemptAutoMerge(input); err != nil {
fmt.Fprintf(os.Stderr, "Error: Automatic merge failed: %v\n\n", err)
fmt.Fprintf(os.Stderr, "To resolve manually:\n")
fmt.Fprintf(os.Stderr, " git checkout --ours .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n")
fmt.Fprintf(os.Stderr, " git checkout --theirs .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n\n")
fmt.Fprintf(os.Stderr, "For advanced field-level merging, see: https://github.com/neongreen/mono/tree/main/beads-merge\n")
os.Exit(1)
} }
fmt.Fprintf(os.Stderr, "✓ Automatic merge successful\n") // Detect git conflict markers in raw bytes (before JSON decoding)
fmt.Fprintf(os.Stderr, "→ Restarting import with merged JSONL...\n\n") // This prevents false positives when issue content contains these strings
trimmed := bytes.TrimSpace(rawLine)
if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) ||
bytes.Equal(trimmed, []byte("=======")) ||
bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) {
fmt.Fprintf(os.Stderr, "Git conflict markers detected in JSONL file (line %d)\n", lineNum)
fmt.Fprintf(os.Stderr, "→ Attempting automatic 3-way merge...\n\n")
// Re-open the input file to read the merged content // Attempt automatic merge using bd merge command
if input != "" { if err := attemptAutoMerge(input); err != nil {
// Close current file handle fmt.Fprintf(os.Stderr, "Error: Automatic merge failed: %v\n\n", err)
if in != os.Stdin { fmt.Fprintf(os.Stderr, "To resolve manually:\n")
_ = in.Close() fmt.Fprintf(os.Stderr, " git checkout --ours .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n")
} fmt.Fprintf(os.Stderr, " git checkout --theirs .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n\n")
fmt.Fprintf(os.Stderr, "For advanced field-level merging, see: https://github.com/neongreen/mono/tree/main/beads-merge\n")
// Re-open the merged file
// #nosec G304 - user-provided file path is intentional
f, err := os.Open(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reopening merged file: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer func() {
if err := f.Close(); err != nil { fmt.Fprintf(os.Stderr, "✓ Automatic merge successful\n")
fmt.Fprintf(os.Stderr, "Warning: failed to close input file: %v\n", err) fmt.Fprintf(os.Stderr, "→ Restarting import with merged JSONL...\n\n")
// Re-open the input file to read the merged content
if input != "" {
// Close current file handle
if in != os.Stdin {
_ = in.Close()
} }
}()
in = f // Re-open the merged file
scanner = bufio.NewScanner(in) // #nosec G304 - user-provided file path is intentional
allIssues = nil // Reset issues list f, err := os.Open(input)
lineNum = 0 // Reset line counter if err != nil {
continue // Restart parsing from beginning fmt.Fprintf(os.Stderr, "Error reopening merged file: %v\n", err)
} else { os.Exit(1)
// Can't retry stdin - should not happen since git conflicts only in files }
fmt.Fprintf(os.Stderr, "Error: Cannot retry merge from stdin\n") defer func() {
if err := f.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close input file: %v\n", err)
}
}()
in = f
scanner = bufio.NewScanner(in)
allIssues = nil // Reset issues list
lineNum = 0 // Reset line counter
continue // Restart parsing from beginning
} else {
// Can't retry stdin - should not happen since git conflicts only in files
fmt.Fprintf(os.Stderr, "Error: Cannot retry merge from stdin\n")
os.Exit(1)
}
}
// Parse JSON
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1) os.Exit(1)
} }
}
// Parse JSON allIssues = append(allIssues, &issue)
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1)
} }
allIssues = append(allIssues, &issue)
}
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -190,12 +190,12 @@ NOTE: Import requires direct database access and does not work with daemon mode.
detectedPrefix = filepath.Base(cwd) detectedPrefix = filepath.Base(cwd)
} }
detectedPrefix = strings.TrimRight(detectedPrefix, "-") detectedPrefix = strings.TrimRight(detectedPrefix, "-")
if err := store.SetConfig(initCtx, "issue_prefix", detectedPrefix); err != nil { if err := store.SetConfig(initCtx, "issue_prefix", detectedPrefix); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err) fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Fprintf(os.Stderr, "✓ Initialized database with prefix '%s' (detected from issues)\n", detectedPrefix) fmt.Fprintf(os.Stderr, "✓ Initialized database with prefix '%s' (detected from issues)\n", detectedPrefix)
} }
@@ -233,7 +233,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
fmt.Fprintf(os.Stderr, "\nOr use 'bd rename-prefix' after import to fix the database.\n") fmt.Fprintf(os.Stderr, "\nOr use 'bd rename-prefix' after import to fix the database.\n")
os.Exit(1) os.Exit(1)
} }
// Check if it's a collision error // Check if it's a collision error
if result != nil && len(result.CollisionIDs) > 0 { if result != nil && len(result.CollisionIDs) > 0 {
// Print collision report before exiting // Print collision report before exiting
@@ -259,7 +259,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
} }
fmt.Fprintf(os.Stderr, "\nUse --rename-on-import to automatically fix prefixes during import.\n") fmt.Fprintf(os.Stderr, "\nUse --rename-on-import to automatically fix prefixes during import.\n")
} }
if result.Collisions > 0 { if result.Collisions > 0 {
fmt.Fprintf(os.Stderr, "\n=== Collision Detection Report ===\n") fmt.Fprintf(os.Stderr, "\n=== Collision Detection Report ===\n")
fmt.Fprintf(os.Stderr, "COLLISIONS DETECTED: %d\n", result.Collisions) fmt.Fprintf(os.Stderr, "COLLISIONS DETECTED: %d\n", result.Collisions)
@@ -393,7 +393,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
// If jsonlPath is empty or can't be read, falls back to time.Now(). // If jsonlPath is empty or can't be read, falls back to time.Now().
func touchDatabaseFile(dbPath, jsonlPath string) error { func touchDatabaseFile(dbPath, jsonlPath string) error {
targetTime := time.Now() targetTime := time.Now()
// If we have the JSONL path, use max(JSONL mtime, now) to handle clock skew // If we have the JSONL path, use max(JSONL mtime, now) to handle clock skew
if jsonlPath != "" { if jsonlPath != "" {
if info, err := os.Stat(jsonlPath); err == nil { if info, err := os.Stat(jsonlPath); err == nil {
@@ -403,7 +403,7 @@ func touchDatabaseFile(dbPath, jsonlPath string) error {
} }
} }
} }
// Best-effort touch - don't fail import if this doesn't work // Best-effort touch - don't fail import if this doesn't work
return os.Chtimes(dbPath, targetTime, targetTime) return os.Chtimes(dbPath, targetTime, targetTime)
} }
@@ -418,17 +418,17 @@ func checkUncommittedChanges(filePath string, result *ImportResult) {
// Get the directory containing the file to use as git working directory // Get the directory containing the file to use as git working directory
workDir := filepath.Dir(filePath) workDir := filepath.Dir(filePath)
// Use git diff to check if working tree differs from HEAD // Use git diff to check if working tree differs from HEAD
cmd := fmt.Sprintf("git diff --quiet HEAD %s", filePath) cmd := fmt.Sprintf("git diff --quiet HEAD %s", filePath)
exitCode, _ := runGitCommand(cmd, workDir) exitCode, _ := runGitCommand(cmd, workDir)
// Exit code 0 = no changes, 1 = changes exist, >1 = error // Exit code 0 = no changes, 1 = changes exist, >1 = error
if exitCode == 1 { if exitCode == 1 {
// Get line counts for context // Get line counts for context
workingTreeLines := countLines(filePath) workingTreeLines := countLines(filePath)
headLines := countLinesInGitHEAD(filePath, workDir) headLines := countLinesInGitHEAD(filePath, workDir)
fmt.Fprintf(os.Stderr, "\n⚠ Warning: .beads/issues.jsonl has uncommitted changes\n") fmt.Fprintf(os.Stderr, "\n⚠ Warning: .beads/issues.jsonl has uncommitted changes\n")
fmt.Fprintf(os.Stderr, " Working tree: %d lines\n", workingTreeLines) fmt.Fprintf(os.Stderr, " Working tree: %d lines\n", workingTreeLines)
if headLines > 0 { if headLines > 0 {
@@ -466,7 +466,7 @@ func countLines(filePath string) int {
return 0 return 0
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
lines := 0 lines := 0
for scanner.Scan() { for scanner.Scan() {
@@ -484,24 +484,24 @@ func countLinesInGitHEAD(filePath string, workDir string) int {
return 0 return 0
} }
gitRoot := strings.TrimSpace(gitRootOutput) gitRoot := strings.TrimSpace(gitRootOutput)
// Make filePath relative to git root // Make filePath relative to git root
absPath, err := filepath.Abs(filePath) absPath, err := filepath.Abs(filePath)
if err != nil { if err != nil {
return 0 return 0
} }
relPath, err := filepath.Rel(gitRoot, absPath) relPath, err := filepath.Rel(gitRoot, absPath)
if err != nil { if err != nil {
return 0 return 0
} }
cmd := fmt.Sprintf("git show HEAD:%s 2>/dev/null | wc -l", relPath) cmd := fmt.Sprintf("git show HEAD:%s 2>/dev/null | wc -l", relPath)
exitCode, output := runGitCommand(cmd, workDir) exitCode, output := runGitCommand(cmd, workDir)
if exitCode != 0 { if exitCode != 0 {
return 0 return 0
} }
var lines int var lines int
_, err = fmt.Sscanf(strings.TrimSpace(output), "%d", &lines) _, err = fmt.Sscanf(strings.TrimSpace(output), "%d", &lines)
if err != nil { if err != nil {
@@ -518,7 +518,7 @@ func attemptAutoMerge(conflictedPath string) error {
} }
// Get git repository root // Get git repository root
gitRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") gitRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") // #nosec G204 -- fixed git invocation for repo root discovery
gitRootOutput, err := gitRootCmd.Output() gitRootOutput, err := gitRootCmd.Output()
if err != nil { if err != nil {
return fmt.Errorf("not in a git repository: %w", err) return fmt.Errorf("not in a git repository: %w", err)
@@ -553,7 +553,7 @@ func attemptAutoMerge(conflictedPath string) error {
outputPath := filepath.Join(tmpDir, "merged.jsonl") outputPath := filepath.Join(tmpDir, "merged.jsonl")
// Extract base version (merge-base) // Extract base version (merge-base)
baseCmd := exec.Command("git", "show", fmt.Sprintf(":1:%s", relPath)) baseCmd := exec.Command("git", "show", fmt.Sprintf(":1:%s", relPath)) // #nosec G204 -- relPath limited to files tracked in current repo
baseCmd.Dir = gitRoot baseCmd.Dir = gitRoot
baseContent, err := baseCmd.Output() baseContent, err := baseCmd.Output()
if err != nil { if err != nil {
@@ -566,7 +566,7 @@ func attemptAutoMerge(conflictedPath string) error {
} }
// Extract left version (ours/HEAD) // Extract left version (ours/HEAD)
leftCmd := exec.Command("git", "show", fmt.Sprintf(":2:%s", relPath)) leftCmd := exec.Command("git", "show", fmt.Sprintf(":2:%s", relPath)) // #nosec G204 -- relPath limited to files tracked in current repo
leftCmd.Dir = gitRoot leftCmd.Dir = gitRoot
leftContent, err := leftCmd.Output() leftContent, err := leftCmd.Output()
if err != nil { if err != nil {
@@ -577,7 +577,7 @@ func attemptAutoMerge(conflictedPath string) error {
} }
// Extract right version (theirs/MERGE_HEAD) // Extract right version (theirs/MERGE_HEAD)
rightCmd := exec.Command("git", "show", fmt.Sprintf(":3:%s", relPath)) rightCmd := exec.Command("git", "show", fmt.Sprintf(":3:%s", relPath)) // #nosec G204 -- relPath limited to files tracked in current repo
rightCmd.Dir = gitRoot rightCmd.Dir = gitRoot
rightContent, err := rightCmd.Output() rightContent, err := rightCmd.Output()
if err != nil { if err != nil {
@@ -594,7 +594,7 @@ func attemptAutoMerge(conflictedPath string) error {
} }
// Invoke bd merge command // Invoke bd merge command
mergeCmd := exec.Command(exe, "merge", outputPath, basePath, leftPath, rightPath) mergeCmd := exec.Command(exe, "merge", outputPath, basePath, leftPath, rightPath) // #nosec G204 -- executes current bd binary for deterministic merge
mergeOutput, err := mergeCmd.CombinedOutput() mergeOutput, err := mergeCmd.CombinedOutput()
if err != nil { if err != nil {
// Check exit code - bd merge returns 1 if there are conflicts, 2 for errors // Check exit code - bd merge returns 1 if there are conflicts, 2 for errors
@@ -608,6 +608,7 @@ func attemptAutoMerge(conflictedPath string) error {
} }
// Merge succeeded - copy merged result back to original file // Merge succeeded - copy merged result back to original file
// #nosec G304 -- merged output created earlier in this function
mergedContent, err := os.ReadFile(outputPath) mergedContent, err := os.ReadFile(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read merged output: %w", err) return fmt.Errorf("failed to read merged output: %w", err)
@@ -618,7 +619,7 @@ func attemptAutoMerge(conflictedPath string) error {
} }
// Stage the resolved file // Stage the resolved file
stageCmd := exec.Command("git", "add", relPath) stageCmd := exec.Command("git", "add", relPath) // #nosec G204 -- relPath constrained to file within current repo
stageCmd.Dir = gitRoot stageCmd.Dir = gitRoot
if err := stageCmd.Run(); err != nil { if err := stageCmd.Run(); err != nil {
// Non-fatal - user can stage manually // Non-fatal - user can stage manually
@@ -634,7 +635,7 @@ func detectPrefixFromIssues(issues []*types.Issue) string {
if len(issues) == 0 { if len(issues) == 0 {
return "" return ""
} }
// Count prefix occurrences // Count prefix occurrences
prefixCounts := make(map[string]int) prefixCounts := make(map[string]int)
for _, issue := range issues { for _, issue := range issues {
@@ -644,7 +645,7 @@ func detectPrefixFromIssues(issues []*types.Issue) string {
prefixCounts[issue.ID[:idx]]++ prefixCounts[issue.ID[:idx]]++
} }
} }
// Find most common prefix // Find most common prefix
maxCount := 0 maxCount := 0
commonPrefix := "" commonPrefix := ""
@@ -654,7 +655,7 @@ func detectPrefixFromIssues(issues []*types.Issue) string {
commonPrefix = prefix commonPrefix = prefix
} }
} }
return commonPrefix return commonPrefix
} }

View File

@@ -965,6 +965,7 @@ func createConfigYaml(beadsDir string, noDbMode bool) error {
// readFirstIssueFromJSONL reads the first issue from a JSONL file // readFirstIssueFromJSONL reads the first issue from a JSONL file
func readFirstIssueFromJSONL(path string) (*types.Issue, error) { func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
// #nosec G304 -- helper reads JSONL file chosen by current bd command
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open JSONL file: %w", err) return nil, fmt.Errorf("failed to open JSONL file: %w", err)

View File

@@ -122,6 +122,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
// Create issues.jsonl // Create issues.jsonl
jsonlPath := filepath.Join(beadsDir, "beads.jsonl") jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
// #nosec G306 -- planning repo JSONL must be shareable across collaborators
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil { if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create issues.jsonl: %w", err) return fmt.Errorf("failed to create issues.jsonl: %w", err)
} }
@@ -144,6 +145,7 @@ Issues here are automatically created when working on forked repositories.
Created by: bd init --contributor Created by: bd init --contributor
`) `)
// #nosec G306 -- README should be world-readable
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil { if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create README: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: failed to create README: %v\n", err)
} }

View File

@@ -299,7 +299,7 @@ func findCandidateIssues(ctx context.Context, db *sql.DB, p migrateIssuesParams)
} }
// Build query // Build query
query := "SELECT id FROM issues WHERE " + strings.Join(conditions, " AND ") query := "SELECT id FROM issues WHERE " + strings.Join(conditions, " AND ") // #nosec G202 -- query fragments are constant strings with parameter placeholders
rows, err := db.QueryContext(ctx, query, args...) rows, err := db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
@@ -499,7 +499,7 @@ func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string)
incomingQuery := fmt.Sprintf(` incomingQuery := fmt.Sprintf(`
SELECT COUNT(*) FROM dependencies SELECT COUNT(*) FROM dependencies
WHERE depends_on_id IN (%s) WHERE depends_on_id IN (%s)
AND issue_id NOT IN (%s)`, inClause, inClause) AND issue_id NOT IN (%s)`, inClause, inClause) // #nosec G201 -- inClause generated from sanitized placeholders
var incoming int var incoming int
if err := db.QueryRowContext(ctx, incomingQuery, append(args, args...)...).Scan(&incoming); err != nil { if err := db.QueryRowContext(ctx, incomingQuery, append(args, args...)...).Scan(&incoming); err != nil {
@@ -510,7 +510,7 @@ func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string)
outgoingQuery := fmt.Sprintf(` outgoingQuery := fmt.Sprintf(`
SELECT COUNT(*) FROM dependencies SELECT COUNT(*) FROM dependencies
WHERE issue_id IN (%s) WHERE issue_id IN (%s)
AND depends_on_id NOT IN (%s)`, inClause, inClause) AND depends_on_id NOT IN (%s)`, inClause, inClause) // #nosec G201 -- inClause generated from sanitized placeholders
var outgoing int var outgoing int
if err := db.QueryRowContext(ctx, outgoingQuery, append(args, args...)...).Scan(&outgoing); err != nil { if err := db.QueryRowContext(ctx, outgoingQuery, append(args, args...)...).Scan(&outgoing); err != nil {
@@ -665,6 +665,7 @@ func executeMigration(ctx context.Context, db *sql.DB, migrationSet []string, to
} }
func loadIDsFromFile(path string) ([]string, error) { func loadIDsFromFile(path string) ([]string, error) {
// #nosec G304 -- file path supplied explicitly via CLI flag
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -75,6 +75,7 @@ func isMCPActive() bool {
} }
settingsPath := filepath.Join(home, ".claude/settings.json") settingsPath := filepath.Join(home, ".claude/settings.json")
// #nosec G304 -- settings path derived from user home directory
data, err := os.ReadFile(settingsPath) data, err := os.ReadFile(settingsPath)
if err != nil { if err != nil {
return false return false

View File

@@ -22,7 +22,7 @@ var showCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background() ctx := context.Background()
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {
@@ -45,7 +45,7 @@ var showCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
allDetails := []interface{}{} allDetails := []interface{}{}
@@ -381,7 +381,7 @@ var updateCmd = &cobra.Command{
} }
ctx := context.Background() ctx := context.Background()
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {
@@ -402,7 +402,7 @@ var updateCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
updatedIssues := []*types.Issue{} updatedIssues := []*types.Issue{}
@@ -434,7 +434,7 @@ var updateCmd = &cobra.Command{
if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok { if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok {
updateArgs.AcceptanceCriteria = &acceptanceCriteria updateArgs.AcceptanceCriteria = &acceptanceCriteria
} }
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
updateArgs.ExternalRef = &externalRef updateArgs.ExternalRef = &externalRef
} }
@@ -464,12 +464,12 @@ var updateCmd = &cobra.Command{
// Direct mode // Direct mode
updatedIssues := []*types.Issue{} updatedIssues := []*types.Issue{}
for _, id := range resolvedIDs { for _, id := range resolvedIDs {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil { if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue continue
} }
if jsonOutput { if jsonOutput {
issue, _ := store.GetIssue(ctx, id) issue, _ := store.GetIssue(ctx, id)
if issue != nil { if issue != nil {
updatedIssues = append(updatedIssues, issue) updatedIssues = append(updatedIssues, issue)
@@ -508,7 +508,7 @@ Examples:
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
id := args[0] id := args[0]
ctx := context.Background() ctx := context.Background()
// Resolve partial ID if in direct mode // Resolve partial ID if in direct mode
if daemonClient == nil { if daemonClient == nil {
fullID, err := utils.ResolvePartialID(ctx, store, id) fullID, err := utils.ResolvePartialID(ctx, store, id)
@@ -625,6 +625,7 @@ Examples:
} }
// Read the edited content // Read the edited content
// #nosec G304 -- tmpPath was created earlier in this function
editedContent, err := os.ReadFile(tmpPath) editedContent, err := os.ReadFile(tmpPath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err) fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)
@@ -699,7 +700,7 @@ var closeCmd = &cobra.Command{
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background() ctx := context.Background()
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {

View File

@@ -306,6 +306,7 @@ func (sm *SnapshotManager) writeMetadata(path string, meta snapshotMetadata) err
// Use process-specific temp file for atomic write // Use process-specific temp file for atomic write
tempPath := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) tempPath := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
// #nosec G306 -- metadata is shared across repo users and must stay readable
if err := os.WriteFile(tempPath, data, 0644); err != nil { if err := os.WriteFile(tempPath, data, 0644); err != nil {
return fmt.Errorf("failed to write metadata temp file: %w", err) return fmt.Errorf("failed to write metadata temp file: %w", err)
} }
@@ -315,6 +316,7 @@ func (sm *SnapshotManager) writeMetadata(path string, meta snapshotMetadata) err
} }
func (sm *SnapshotManager) readMetadata(path string) (*snapshotMetadata, error) { func (sm *SnapshotManager) readMetadata(path string) (*snapshotMetadata, error) {
// #nosec G304 -- metadata lives under .beads and path is derived internally
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -360,6 +362,7 @@ func (sm *SnapshotManager) validateMetadata(meta *snapshotMetadata, currentCommi
func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, error) { func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, error) {
result := make(map[string]string) result := make(map[string]string)
// #nosec G304 -- snapshot file lives in .beads/snapshots and path is derived internally
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -397,6 +400,7 @@ func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, err
func (sm *SnapshotManager) buildIDSet(path string) (map[string]bool, error) { func (sm *SnapshotManager) buildIDSet(path string) (map[string]bool, error) {
result := make(map[string]bool) result := make(map[string]bool)
// #nosec G304 -- snapshot file path derived from internal state
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -443,12 +447,14 @@ func (sm *SnapshotManager) jsonEquals(a, b string) bool {
} }
func (sm *SnapshotManager) copyFile(src, dst string) error { func (sm *SnapshotManager) copyFile(src, dst string) error {
// #nosec G304 -- snapshot copy only touches files inside .beads/snapshots
sourceFile, err := os.Open(src) sourceFile, err := os.Open(src)
if err != nil { if err != nil {
return err return err
} }
defer sourceFile.Close() defer sourceFile.Close()
// #nosec G304 -- snapshot copy only writes files inside .beads/snapshots
destFile, err := os.Create(dst) destFile, err := os.Create(dst)
if err != nil { if err != nil {
return err return err

View File

@@ -32,13 +32,13 @@ type StatusSummary struct {
// RecentActivitySummary represents activity from git history // RecentActivitySummary represents activity from git history
type RecentActivitySummary struct { type RecentActivitySummary struct {
HoursTracked int `json:"hours_tracked"` HoursTracked int `json:"hours_tracked"`
CommitCount int `json:"commit_count"` CommitCount int `json:"commit_count"`
IssuesCreated int `json:"issues_created"` IssuesCreated int `json:"issues_created"`
IssuesClosed int `json:"issues_closed"` IssuesClosed int `json:"issues_closed"`
IssuesUpdated int `json:"issues_updated"` IssuesUpdated int `json:"issues_updated"`
IssuesReopened int `json:"issues_reopened"` IssuesReopened int `json:"issues_reopened"`
TotalChanges int `json:"total_changes"` TotalChanges int `json:"total_changes"`
} }
var statusCmd = &cobra.Command{ var statusCmd = &cobra.Command{
@@ -168,8 +168,8 @@ func getGitActivity(hours int) *RecentActivitySummary {
// Run git log to get patches for the last N hours // Run git log to get patches for the last N hours
since := fmt.Sprintf("%d hours ago", hours) since := fmt.Sprintf("%d hours ago", hours)
cmd := exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/beads.jsonl") cmd := exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/beads.jsonl") // #nosec G204 -- bounded arguments for local git history inspection
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
// Git log failed (might not be a git repo or no commits) // Git log failed (might not be a git repo or no commits)
@@ -178,63 +178,63 @@ func getGitActivity(hours int) *RecentActivitySummary {
scanner := bufio.NewScanner(strings.NewReader(string(output))) scanner := bufio.NewScanner(strings.NewReader(string(output)))
commitCount := 0 commitCount := 0
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
// Empty lines separate commits // Empty lines separate commits
if line == "" { if line == "" {
continue continue
} }
// Commit hash line // Commit hash line
if !strings.Contains(line, "\t") { if !strings.Contains(line, "\t") {
commitCount++ commitCount++
continue continue
} }
// numstat line format: "additions\tdeletions\tfilename" // numstat line format: "additions\tdeletions\tfilename"
parts := strings.Split(line, "\t") parts := strings.Split(line, "\t")
if len(parts) < 3 { if len(parts) < 3 {
continue continue
} }
// For JSONL files, each added line is a new/updated issue // For JSONL files, each added line is a new/updated issue
// We need to analyze the actual diff to understand what changed // We need to analyze the actual diff to understand what changed
} }
// Get detailed diff to analyze changes // Get detailed diff to analyze changes
cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/beads.jsonl") cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/beads.jsonl") // #nosec G204 -- bounded arguments for local git history inspection
output, err = cmd.Output() output, err = cmd.Output()
if err != nil { if err != nil {
return nil return nil
} }
scanner = bufio.NewScanner(strings.NewReader(string(output))) scanner = bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
// Look for added lines in diff (lines starting with +) // Look for added lines in diff (lines starting with +)
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") { if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue continue
} }
// Remove the + prefix // Remove the + prefix
jsonLine := strings.TrimPrefix(line, "+") jsonLine := strings.TrimPrefix(line, "+")
// Skip empty lines // Skip empty lines
if strings.TrimSpace(jsonLine) == "" { if strings.TrimSpace(jsonLine) == "" {
continue continue
} }
// Try to parse as issue JSON // Try to parse as issue JSON
var issue types.Issue var issue types.Issue
if err := json.Unmarshal([]byte(jsonLine), &issue); err != nil { if err := json.Unmarshal([]byte(jsonLine), &issue); err != nil {
continue continue
} }
activity.TotalChanges++ activity.TotalChanges++
// Analyze the change type based on timestamps and status // Analyze the change type based on timestamps and status
// Created recently if created_at is close to now // Created recently if created_at is close to now
if time.Since(issue.CreatedAt) < time.Duration(hours)*time.Hour { if time.Since(issue.CreatedAt) < time.Duration(hours)*time.Hour {
@@ -253,7 +253,7 @@ func getGitActivity(hours int) *RecentActivitySummary {
activity.IssuesUpdated++ activity.IssuesUpdated++
} }
} }
activity.CommitCount = commitCount activity.CommitCount = commitCount
return activity return activity
} }

View File

@@ -118,7 +118,7 @@ func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) err
} }
// Open output file for writing // Open output file for writing
outFile, err := os.Create(outputPath) outFile, err := os.Create(outputPath) // #nosec G304 -- outputPath provided by CLI flag but sanitized earlier
if err != nil { if err != nil {
return fmt.Errorf("error creating output file: %w", err) return fmt.Errorf("error creating output file: %w", err)
} }
@@ -150,7 +150,8 @@ func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) err
if err := outFile.Sync(); err != nil { if err := outFile.Sync(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to sync output file: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: failed to sync output file: %v\n", err)
} }
if content, err := os.ReadFile(outputPath); err == nil { // #nosec G304 -- debug output reads file created earlier in same function // #nosec G304 -- debug output reads file created earlier in same function
if content, err := os.ReadFile(outputPath); err == nil {
lines := 0 lines := 0
fmt.Fprintf(os.Stderr, "Output file preview (first 10 lines):\n") fmt.Fprintf(os.Stderr, "Output file preview (first 10 lines):\n")
for _, line := range splitLines(string(content)) { for _, line := range splitLines(string(content)) {

View File

@@ -403,6 +403,7 @@ func exportToJSONL(ctx context.Context, store storage.Storage, path string) erro
} }
// Write to JSONL file // Write to JSONL file
// #nosec G304 -- fixture exports to deterministic file controlled by tests
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("failed to create JSONL file: %w", err) return fmt.Errorf("failed to create JSONL file: %w", err)
@@ -422,6 +423,7 @@ func exportToJSONL(ctx context.Context, store storage.Storage, path string) erro
// importFromJSONL imports issues from a JSONL file // importFromJSONL imports issues from a JSONL file
func importFromJSONL(ctx context.Context, store storage.Storage, path string) error { func importFromJSONL(ctx context.Context, store storage.Storage, path string) error {
// Read JSONL file // Read JSONL file
// #nosec G304 -- fixture imports from deterministic file created earlier in test
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return fmt.Errorf("failed to read JSONL file: %w", err) return fmt.Errorf("failed to read JSONL file: %w", err)