- AGE encryption with master password model - Core commands: init, show, insert, edit, generate, rm, mv, cp, find, grep, ls - Git integration for version control - Clipboard support (X11 and Wayland) - Secure password generation - Backup and restore functionality - Comprehensive security features - Complete documentation
1214 lines
32 KiB
Go
1214 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// cmdInit initializes a new password store with a master password
|
|
// Sets up everything needed to start storing passwords
|
|
func cmdInit(args []string) {
|
|
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
|
var pathFlag string
|
|
fs.StringVar(&pathFlag, "path", "", "subfolder path")
|
|
fs.StringVar(&pathFlag, "p", "", "subfolder path (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() > 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: passage init does not take arguments. It will prompt for a master password.\n")
|
|
fmt.Fprintf(os.Stderr, "Usage: passage init [--path=subfolder]\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
storeDir := getStoreDir()
|
|
targetDir := storeDir
|
|
if pathFlag != "" {
|
|
if err := checkSneakyPaths(pathFlag); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
pathFlag, err := sanitizePath(pathFlag)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
targetDir = filepath.Join(storeDir, pathFlag)
|
|
}
|
|
|
|
if err := os.MkdirAll(targetDir, 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Check if already initialized
|
|
if checkStoreInitialized(targetDir) {
|
|
fmt.Fprintf(os.Stderr, "Error: password store already initialized at %s\n", targetDir)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Get the master password (twice, because typos are annoying)
|
|
fmt.Fprintf(os.Stderr, "Initializing password store...\n")
|
|
masterPassword, err := getMasterPassword("Enter master password: ")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer masterPassword.Clear() // Always clean up
|
|
|
|
// Make sure they typed it right
|
|
confirmPassword, err := getMasterPassword("Confirm master password: ")
|
|
if err != nil {
|
|
masterPassword.Clear()
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer confirmPassword.Clear()
|
|
|
|
// Constant-time comparison - can't let attackers guess passwords by timing
|
|
if !constantTimeCompare(masterPassword.Bytes(), confirmPassword.Bytes()) {
|
|
masterPassword.Clear()
|
|
confirmPassword.Clear()
|
|
fmt.Fprintf(os.Stderr, "Error: passwords do not match\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Store password hash for verification
|
|
if err := storeMasterPasswordHash(targetDir, masterPassword.Bytes()); err != nil {
|
|
masterPassword.Clear()
|
|
confirmPassword.Clear()
|
|
fmt.Fprintf(os.Stderr, "Error: failed to store password hash: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Password store initialized%s\n",
|
|
func() string {
|
|
if pathFlag != "" {
|
|
return fmt.Sprintf(" (%s)", pathFlag)
|
|
}
|
|
return ""
|
|
}())
|
|
|
|
// Initialize git if requested or if .git already exists
|
|
gitDir := filepath.Join(storeDir, ".git")
|
|
if _, err := os.Stat(gitDir); err == nil {
|
|
masterPassPath := getMasterPassPath(targetDir)
|
|
gitAddFile(masterPassPath, fmt.Sprintf("Initialize password store%s.",
|
|
func() string {
|
|
if pathFlag != "" {
|
|
return fmt.Sprintf(" (%s)", pathFlag)
|
|
}
|
|
return ""
|
|
}()))
|
|
}
|
|
}
|
|
|
|
// getMasterPasswordForOperation prompts for master password and verifies store is initialized
|
|
func getMasterPasswordForOperation(storeDir string) (*SecureBytes, error) {
|
|
if !checkStoreInitialized(storeDir) {
|
|
return nil, fmt.Errorf("password store not initialized. Run 'passage init' first")
|
|
}
|
|
|
|
masterPassword, err := getMasterPassword("Enter master password: ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if the password is correct
|
|
valid, err := verifyMasterPassword(storeDir, masterPassword.Bytes())
|
|
if err != nil {
|
|
// Hash file missing or corrupted - might be an old store, warn but allow
|
|
fmt.Fprintf(os.Stderr, "Warning: could not verify password hash: %v\n", err)
|
|
} else if !valid {
|
|
// Wrong password - clear it and bail
|
|
masterPassword.Clear()
|
|
return nil, fmt.Errorf("incorrect master password")
|
|
}
|
|
|
|
return masterPassword, nil
|
|
}
|
|
|
|
// cmdShow displays a password
|
|
func cmdShow(args []string) {
|
|
fs := flag.NewFlagSet("show", flag.ExitOnError)
|
|
var clipFlag string
|
|
var clipLine int
|
|
fs.StringVar(&clipFlag, "clip", "", "copy to clipboard")
|
|
fs.StringVar(&clipFlag, "c", "", "copy to clipboard (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
storeDir := getStoreDir()
|
|
var path string
|
|
if fs.NArg() > 0 {
|
|
path = fs.Arg(0)
|
|
if err := checkSneakyPaths(path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
path, _ = sanitizePath(path) // Already checked, ignore error
|
|
}
|
|
|
|
passfile := filepath.Join(storeDir, path+passageExt)
|
|
|
|
// Check if it's a directory
|
|
if info, err := os.Stat(filepath.Join(storeDir, path)); err == nil && info.IsDir() {
|
|
cmdList(path)
|
|
return
|
|
}
|
|
|
|
if _, err := os.Stat(passfile); err != nil {
|
|
if path == "" {
|
|
// List root
|
|
cmdList("")
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", path)
|
|
os.Exit(1)
|
|
}
|
|
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer masterPassword.Clear()
|
|
|
|
decrypted, err := decryptFile(passfile, "", masterPassword.Bytes())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Check file size before reading
|
|
if err := checkFileSize(passfile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, err := readLimited(decrypted, maxFileSize)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
if clipFlag != "" {
|
|
if clipFlag == "clip" || clipFlag == "c" {
|
|
clipLine = 1
|
|
} else {
|
|
if n, err := fmt.Sscanf(clipFlag, "%d", &clipLine); err != nil || n != 1 || clipLine <= 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid clip line number\n")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
if clipLine < 1 || clipLine > len(lines) {
|
|
fmt.Fprintf(os.Stderr, "Error: line %d does not exist\n", clipLine)
|
|
os.Exit(1)
|
|
}
|
|
clipToClipboard(lines[clipLine-1], path)
|
|
} else {
|
|
fmt.Print(string(data))
|
|
}
|
|
}
|
|
|
|
// cmdList lists passwords in a directory
|
|
func cmdList(path string) {
|
|
if path != "" {
|
|
if err := checkSneakyPaths(path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
path, _ = sanitizePath(path) // Already checked
|
|
}
|
|
storeDir := getStoreDir()
|
|
targetDir := storeDir
|
|
if path != "" {
|
|
targetDir = filepath.Join(storeDir, path)
|
|
}
|
|
|
|
if path == "" {
|
|
fmt.Println("Password Store")
|
|
} else {
|
|
fmt.Println(path)
|
|
}
|
|
|
|
entries, err := os.ReadDir(targetDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
fmt.Fprintf(os.Stderr, "Error: password store is empty. Try \"passage init\"\n")
|
|
os.Exit(1)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
fmt.Printf("%s/\n", entry.Name())
|
|
} else if strings.HasSuffix(entry.Name(), passageExt) {
|
|
name := strings.TrimSuffix(entry.Name(), passageExt)
|
|
if name != ".passage-id" {
|
|
fmt.Printf("%s\n", name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// cmdFind searches for passwords by name
|
|
func cmdFind(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage find pass-names...\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
storeDir := getStoreDir()
|
|
// Validate all search terms for path traversal
|
|
for _, term := range args {
|
|
if err := checkSneakyPaths(term); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
terms := strings.Join(args, "|")
|
|
pattern := regexp.MustCompile("(?i)" + regexp.QuoteMeta(terms))
|
|
|
|
err := filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, passageExt) {
|
|
return nil
|
|
}
|
|
relPath, err := filepath.Rel(storeDir, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
name := strings.TrimSuffix(relPath, passageExt)
|
|
if pattern.MatchString(name) {
|
|
fmt.Println(name)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// cmdGrep searches within password contents
|
|
func cmdGrep(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage grep [GREPOPTIONS] search-string\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
storeDir := getStoreDir()
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
err = filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, passageExt) {
|
|
return nil
|
|
}
|
|
|
|
decrypted, err := decryptFile(path, "", masterPassword.Bytes())
|
|
if err != nil {
|
|
return nil // Skip files we can't decrypt
|
|
}
|
|
|
|
// Check file size before reading
|
|
if err := checkFileSize(path); err != nil {
|
|
return nil // Skip files that are too large
|
|
}
|
|
|
|
data, err := readLimited(decrypted, maxFileSize)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Simple grep - just check if search string is in content
|
|
searchStr := args[len(args)-1]
|
|
if strings.Contains(string(data), searchStr) {
|
|
relPath, _ := filepath.Rel(storeDir, path)
|
|
name := strings.TrimSuffix(relPath, passageExt)
|
|
fmt.Printf("%s:\n", name)
|
|
lines := strings.Split(string(data), "\n")
|
|
for i, line := range lines {
|
|
if strings.Contains(line, searchStr) {
|
|
fmt.Printf(" %d: %s\n", i+1, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// cmdInsert inserts a new password
|
|
func cmdInsert(args []string) {
|
|
fs := flag.NewFlagSet("insert", flag.ExitOnError)
|
|
multiline := fs.Bool("multiline", false, "multiline entry")
|
|
multilineShort := fs.Bool("m", false, "multiline entry (short)")
|
|
force := fs.Bool("force", false, "overwrite without prompting")
|
|
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() != 1 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage insert [--multiline] [--force] pass-name\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
path := fs.Arg(0)
|
|
storeDir := getStoreDir()
|
|
passfile := filepath.Join(storeDir, path+passageExt)
|
|
|
|
if !*force && !*forceShort {
|
|
if _, err := os.Stat(passfile); err == nil {
|
|
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", path)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response != "y" && response != "yes" {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var password string
|
|
if *multiline || *multilineShort {
|
|
fmt.Printf("Enter contents of %s and press Ctrl+D when finished:\n\n", path)
|
|
data, err := readLimited(os.Stdin, maxPasswordLength)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read input: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
password = string(data)
|
|
if err := checkPasswordLength(password); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Printf("Enter password for %s: ", path)
|
|
bytePassword, err := readPassword()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read password: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println()
|
|
password = string(bytePassword)
|
|
}
|
|
|
|
// Validate password length
|
|
if err := checkPasswordLength(password); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Create temporary file with password in secure location
|
|
tmpFile, err := createSecureTempFile("passage-insert-", ".tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer os.Remove(tmpFile)
|
|
|
|
if err := os.WriteFile(tmpFile, []byte(password), 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
gitAddFile(passfile, fmt.Sprintf("Add given password for %s to store.", path))
|
|
fmt.Printf("Password for %s added to store.\n", path)
|
|
}
|
|
|
|
// cmdEdit edits a password
|
|
func cmdEdit(args []string) {
|
|
if len(args) != 1 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage edit pass-name\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
path := args[0]
|
|
if err := checkSneakyPaths(path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
path, err := sanitizePath(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
storeDir := getStoreDir()
|
|
passfile := filepath.Join(storeDir, path+passageExt)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Create temporary file in secure location
|
|
tmpFile, err := createSecureTempFile("passage-edit-", ".tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() {
|
|
// Ensure cleanup even on panic
|
|
os.Remove(tmpFile)
|
|
}()
|
|
|
|
// Decrypt existing file if it exists
|
|
if _, err := os.Stat(passfile); err == nil {
|
|
decrypted, err := decryptFile(passfile, "", masterPassword.Bytes())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, err := readLimited(decrypted, maxFileSize)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := checkPasswordLength(string(data)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Edit file
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vi"
|
|
}
|
|
|
|
// Paranoid check - don't let users inject commands through EDITOR
|
|
// Only simple editor names allowed, no paths or shell metacharacters
|
|
if strings.Contains(editor, "/") || strings.Contains(editor, "..") || strings.Contains(editor, "|") || strings.Contains(editor, "&") || strings.Contains(editor, ";") {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid editor command\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd := exec.Command(editor, tmpFile)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: editor failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Check if file was modified
|
|
if _, err := os.Stat(tmpFile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: new password not saved\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate edited content length
|
|
editedData, err := os.ReadFile(tmpFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read edited file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := checkPasswordLength(string(editedData)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Encrypt and save
|
|
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
action := "Add"
|
|
if _, err := os.Stat(passfile); err == nil {
|
|
action = "Edit"
|
|
}
|
|
|
|
gitAddFile(passfile, fmt.Sprintf("%s password for %s using %s.", action, path, editor))
|
|
fmt.Printf("Password for %s %sd.\n", path, strings.ToLower(action))
|
|
}
|
|
|
|
// cmdGenerate generates a new password
|
|
func cmdGenerate(args []string) {
|
|
fs := flag.NewFlagSet("generate", flag.ExitOnError)
|
|
noSymbols := fs.Bool("no-symbols", false, "don't use symbols")
|
|
noSymbolsShort := fs.Bool("n", false, "don't use symbols (short)")
|
|
clip := fs.Bool("clip", false, "copy to clipboard")
|
|
clipShort := fs.Bool("c", false, "copy to clipboard (short)")
|
|
force := fs.Bool("force", false, "overwrite without prompting")
|
|
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
|
|
inPlace := fs.Bool("in-place", false, "replace only first line")
|
|
inPlaceShort := fs.Bool("i", false, "replace only first line (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() < 1 || fs.NArg() > 2 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
path := fs.Arg(0)
|
|
if err := checkSneakyPaths(path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
path, err := sanitizePath(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
length := 25
|
|
if fs.NArg() == 2 {
|
|
if n, err := fmt.Sscanf(fs.Arg(1), "%d", &length); err != nil || n != 1 || length <= 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: pass-length must be a positive number\n")
|
|
os.Exit(1)
|
|
}
|
|
// Prevent integer overflow and DoS
|
|
const maxLength = 10000
|
|
if length > maxLength {
|
|
fmt.Fprintf(os.Stderr, "Error: pass-length too large (max %d)\n", maxLength)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
storeDir := getStoreDir()
|
|
passfile := filepath.Join(storeDir, path+passageExt)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if !*force && !*forceShort && !*inPlace && !*inPlaceShort {
|
|
if _, err := os.Stat(passfile); err == nil {
|
|
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", path)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response != "y" && response != "yes" {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate password
|
|
var charset string
|
|
if *noSymbols || *noSymbolsShort {
|
|
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
} else {
|
|
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"
|
|
}
|
|
|
|
password := generatePassword(length, charset)
|
|
|
|
if *inPlace || *inPlaceShort {
|
|
// Read existing file and replace first line
|
|
if _, err := os.Stat(passfile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: file does not exist for in-place edit\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
decrypted, err := decryptFile(passfile, "", masterPassword.Bytes())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, err := io.ReadAll(decrypted)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
if len(lines) > 0 {
|
|
lines[0] = password
|
|
} else {
|
|
lines = []string{password}
|
|
}
|
|
newContent := strings.Join(lines, "\n")
|
|
|
|
if err := checkPasswordLength(newContent); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
tmpFile, err := createSecureTempFile("passage-generate-", ".tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer os.Remove(tmpFile)
|
|
|
|
if err := os.WriteFile(tmpFile, []byte(newContent), 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
// Create new file
|
|
tmpFile, err := createSecureTempFile("passage-generate-", ".tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer os.Remove(tmpFile)
|
|
|
|
if err := os.WriteFile(tmpFile, []byte(password), 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
verb := "Add"
|
|
if *inPlace || *inPlaceShort {
|
|
verb = "Replace"
|
|
}
|
|
gitAddFile(passfile, fmt.Sprintf("%s generated password for %s.", verb, path))
|
|
|
|
if *clip || *clipShort {
|
|
clipToClipboard(password, path)
|
|
} else {
|
|
fmt.Printf("The generated password for %s is:\n%s\n", path, password)
|
|
}
|
|
}
|
|
|
|
// cmdDelete removes a password
|
|
func cmdDelete(args []string) {
|
|
fs := flag.NewFlagSet("rm", flag.ExitOnError)
|
|
recursive := fs.Bool("recursive", false, "recursive delete")
|
|
recursiveShort := fs.Bool("r", false, "recursive delete (short)")
|
|
force := fs.Bool("force", false, "delete without prompting")
|
|
forceShort := fs.Bool("f", false, "delete without prompting (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() != 1 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage rm [--recursive] [--force] pass-name\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
path := fs.Arg(0)
|
|
if err := checkSneakyPaths(path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
var err error
|
|
path, err = sanitizePath(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
storeDir := getStoreDir()
|
|
passfile := filepath.Join(storeDir, path+passageExt)
|
|
passdir := filepath.Join(storeDir, path)
|
|
|
|
var target string
|
|
if info, err2 := os.Stat(passfile); err2 == nil && !info.IsDir() {
|
|
target = passfile
|
|
} else if info, err2 := os.Stat(passdir); err2 == nil && info.IsDir() {
|
|
target = passdir
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", path)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if !*force && !*forceShort {
|
|
fmt.Printf("Are you sure you would like to delete %s? [y/N] ", path)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response != "y" && response != "yes" {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if *recursive || *recursiveShort {
|
|
err = os.RemoveAll(target)
|
|
} else {
|
|
err = os.Remove(target)
|
|
}
|
|
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to delete: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
gitRemoveFile(target, fmt.Sprintf("Remove %s from store.", path))
|
|
fmt.Printf("Removed %s\n", path)
|
|
}
|
|
|
|
// cmdMove moves/renames a password
|
|
func cmdMove(args []string) {
|
|
fs := flag.NewFlagSet("mv", flag.ExitOnError)
|
|
force := fs.Bool("force", false, "overwrite without prompting")
|
|
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() != 2 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage mv [--force] old-path new-path\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
oldPath := fs.Arg(0)
|
|
newPath := fs.Arg(1)
|
|
if err := checkSneakyPaths(oldPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := checkSneakyPaths(newPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
oldPath, err := sanitizePath(oldPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
newPath, err = sanitizePath(newPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
storeDir := getStoreDir()
|
|
oldFile := filepath.Join(storeDir, oldPath+passageExt)
|
|
newFile := filepath.Join(storeDir, newPath+passageExt)
|
|
|
|
if _, err := os.Stat(oldFile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", oldPath)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if _, err := os.Stat(newFile); err == nil && !*force && !*forceShort {
|
|
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", newPath)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response != "y" && response != "yes" {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(newFile), 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Get master password for re-encryption
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Decrypt old file
|
|
decrypted, err := decryptFile(oldFile, "", masterPassword.Bytes())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, err := io.ReadAll(decrypted)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate data length
|
|
if err := checkPasswordLength(string(data)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Encrypt with new recipients
|
|
tmpFile, err := createSecureTempFile("passage-mv-", ".tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer os.Remove(tmpFile)
|
|
|
|
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := encryptFile(tmpFile, newFile, masterPassword.Bytes()); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Remove old file
|
|
if err := os.Remove(oldFile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to remove old file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
gitMoveFile(oldFile, newFile, fmt.Sprintf("Rename %s to %s.", oldPath, newPath))
|
|
fmt.Printf("Renamed %s to %s\n", oldPath, newPath)
|
|
}
|
|
|
|
// cmdCopy copies a password
|
|
func cmdCopy(args []string) {
|
|
fs := flag.NewFlagSet("cp", flag.ExitOnError)
|
|
force := fs.Bool("force", false, "overwrite without prompting")
|
|
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() != 2 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage cp [--force] old-path new-path\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
oldPath := fs.Arg(0)
|
|
newPath := fs.Arg(1)
|
|
if err := checkSneakyPaths(oldPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := checkSneakyPaths(newPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
oldPath, err := sanitizePath(oldPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
newPath, err = sanitizePath(newPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
storeDir := getStoreDir()
|
|
oldFile := filepath.Join(storeDir, oldPath+passageExt)
|
|
newFile := filepath.Join(storeDir, newPath+passageExt)
|
|
|
|
if _, err := os.Stat(oldFile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", oldPath)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if _, err := os.Stat(newFile); err == nil && !*force && !*forceShort {
|
|
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", newPath)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
if response != "y" && response != "yes" {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(newFile), 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Get master password for re-encryption
|
|
masterPassword, err := getMasterPasswordForOperation(storeDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Decrypt old file
|
|
decrypted, err := decryptFile(oldFile, "", masterPassword.Bytes())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, err := io.ReadAll(decrypted)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate data length
|
|
if err := checkPasswordLength(string(data)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Encrypt with new recipients
|
|
tmpFile, err := createSecureTempFile("passage-mv-", ".tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer os.Remove(tmpFile)
|
|
|
|
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := encryptFile(tmpFile, newFile, masterPassword.Bytes()); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
gitAddFile(newFile, fmt.Sprintf("Copy %s to %s.", oldPath, newPath))
|
|
fmt.Printf("Copied %s to %s\n", oldPath, newPath)
|
|
}
|
|
|
|
// cmdGit runs git commands
|
|
func cmdGit(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintf(os.Stderr, "Usage: passage git git-command-args...\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
storeDir := getStoreDir()
|
|
gitDir := filepath.Join(storeDir, ".git")
|
|
|
|
if args[0] == "init" {
|
|
cmd := exec.Command("git", append([]string{"-C", storeDir, "init"}, args[1:]...)...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: git init failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if _, err := os.Stat(gitDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: the password store is not a git repository. Try \"passage git init\"\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd := exec.Command("git", append([]string{"-C", storeDir}, args...)...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: git command failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func readPassword() ([]byte, error) {
|
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
|
return term.ReadPassword(int(os.Stdin.Fd()))
|
|
}
|
|
// Fallback for non-terminal input
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(strings.TrimSpace(line)), nil
|
|
}
|
|
|
|
func generatePassword(length int, charset string) string {
|
|
password := make([]byte, length)
|
|
max := big.NewInt(int64(len(charset)))
|
|
for i := range password {
|
|
n, err := rand.Int(rand.Reader, max)
|
|
if err != nil {
|
|
// crypto/rand failed? That's... concerning. Fallback to deterministic (not ideal)
|
|
password[i] = charset[i%len(charset)]
|
|
} else {
|
|
password[i] = charset[n.Int64()]
|
|
}
|
|
}
|
|
return string(password)
|
|
}
|
|
|
|
// clipToClipboard is now in clipboard.go
|
|
|
|
func gitAddFile(path, message string) {
|
|
storeDir := getStoreDir()
|
|
gitDir := filepath.Join(storeDir, ".git")
|
|
if _, err := os.Stat(gitDir); err != nil {
|
|
return // Not a git repo
|
|
}
|
|
|
|
relPath, err := filepath.Rel(storeDir, path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Clean up the commit message - don't let users inject commands
|
|
message = sanitizeGitMessage(message)
|
|
|
|
cmd := exec.Command("git", "-C", storeDir, "add", relPath)
|
|
if err := cmd.Run(); err != nil {
|
|
// Git failed? Oh well, not critical - passwords are still saved
|
|
return
|
|
}
|
|
|
|
cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message)
|
|
cmd.Run() // Git is optional, so failures are fine
|
|
}
|
|
|
|
func gitRemoveFile(path, message string) {
|
|
storeDir := getStoreDir()
|
|
gitDir := filepath.Join(storeDir, ".git")
|
|
if _, err := os.Stat(gitDir); err != nil {
|
|
return
|
|
}
|
|
|
|
relPath, err := filepath.Rel(storeDir, path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Sanitize commit message
|
|
message = sanitizeGitMessage(message)
|
|
|
|
cmd := exec.Command("git", "-C", storeDir, "rm", "-r", relPath)
|
|
if err := cmd.Run(); err != nil {
|
|
return
|
|
}
|
|
|
|
cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message)
|
|
cmd.Run()
|
|
}
|
|
|
|
func gitMoveFile(oldPath, newPath, message string) {
|
|
storeDir := getStoreDir()
|
|
gitDir := filepath.Join(storeDir, ".git")
|
|
if _, err := os.Stat(gitDir); err != nil {
|
|
return
|
|
}
|
|
|
|
oldRel, err := filepath.Rel(storeDir, oldPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
newRel, err := filepath.Rel(storeDir, newPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Sanitize commit message
|
|
message = sanitizeGitMessage(message)
|
|
|
|
cmd := exec.Command("git", "-C", storeDir, "mv", oldRel, newRel)
|
|
if err := cmd.Run(); err != nil {
|
|
return
|
|
}
|
|
|
|
cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message)
|
|
cmd.Run()
|
|
}
|
|
|
|
// sanitizeGitMessage removes potentially dangerous characters from git commit messages
|
|
// Because git commit messages shouldn't be attack vectors
|
|
func sanitizeGitMessage(message string) string {
|
|
// Strip out newlines and null bytes - they could be used for injection
|
|
message = strings.ReplaceAll(message, "\n", " ")
|
|
message = strings.ReplaceAll(message, "\r", " ")
|
|
message = strings.ReplaceAll(message, "\x00", "")
|
|
// Reasonable length limit - if you need more, write a blog post instead
|
|
const maxMessageLength = 1000
|
|
if len(message) > maxMessageLength {
|
|
message = message[:maxMessageLength]
|
|
}
|
|
return message
|
|
}
|