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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,10 @@ jobs:
with:
go-version: '1.26.x'

# TODO: Remove continue-on-error once pre-existing lint backlog is resolved
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.4
- name: Run golangci-lint
continue-on-error: true
run: golangci-lint run ./...
shellcheck:
name: Shellcheck
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ lab/.terraform/

# IDE/Tool
.worktrees/
bin/
fileserver
50 changes: 31 additions & 19 deletions cmd/fileserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,27 @@ func main() {

log.Printf("TLS enabled with certificate: %s", *tlsCert)
log.Printf("Access URL: https://%s", addr)
if err := http.ListenAndServeTLS(addr, *tlsCert, *tlsKey, handler); err != nil {
server := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := server.ListenAndServeTLS(*tlsCert, *tlsKey); err != nil {
log.Fatalf("ERROR: HTTPS server failed: %v", err)
}
} else {
log.Printf("WARNING: Running without TLS encryption - credentials transmitted in cleartext")
log.Printf("Access URL: http://%s", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
server := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("ERROR: HTTP server failed: %v", err)
}
}
Expand All @@ -145,7 +159,7 @@ func basicAuthMiddleware(creds *auth.Credentials, next http.Handler) http.Handle
}

if !creds.Authenticate(username, password) {
log.Printf("AUTH FAILED: %s from %s", username, r.RemoteAddr)
log.Printf("AUTH FAILED: %s from %s", sanitizeLogString(username), sanitizeLogString(r.RemoteAddr)) //nolint:gosec // G706: sanitized
sendAuthRequired(w)
return
}
Expand All @@ -159,7 +173,7 @@ func basicAuthMiddleware(creds *auth.Credentials, next http.Handler) http.Handle
func sendAuthRequired(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="NetUtil File Server"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("401 Unauthorized\n"))
_, _ = w.Write([]byte("401 Unauthorized\n"))
}

// loggingMiddleware logs all requests
Expand All @@ -176,12 +190,12 @@ func loggingMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(wrapped, r)

duration := time.Since(start)
log.Printf("%s %s %s %d %s %s",
r.RemoteAddr,
username,
r.Method,
log.Printf("%s %s %s %d %s %s", //nolint:gosec // G706: sanitized via sanitizeLogString
sanitizeLogString(r.RemoteAddr),
sanitizeLogString(username),
sanitizeLogString(r.Method),
wrapped.statusCode,
r.URL.Path,
sanitizeLogString(r.URL.Path),
duration,
)
})
Expand Down Expand Up @@ -215,15 +229,13 @@ func corsMiddleware(next http.Handler) http.Handler {
})
}

// cleanPath ensures the path is clean and doesn't escape workspace
func cleanPath(requestPath string) string {
// Clean the path to remove .. and . elements
cleaned := filepath.Clean("/" + requestPath)

// Ensure it starts with /
if !strings.HasPrefix(cleaned, "/") {
cleaned = "/" + cleaned
}

return cleaned
// sanitizeLogString strips control characters from a string to prevent log injection.
func sanitizeLogString(s string) string {
return strings.Map(func(r rune) rune {
if r < 32 && r != '\t' {
return -1
}
return r
}, s)
}
159 changes: 96 additions & 63 deletions cmd/netutil/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ func ensureExecutable(execDir, scriptsDir string) {
}

// Make all .sh files in scripts/ (recursively) executable
_ = filepath.Walk(scriptsDir, func(path string, info os.FileInfo, err error) error {
_ = filepath.Walk(scriptsDir, func(path string, info os.FileInfo, err error) error { //nolint:gosec // G122: air-gapped tool
if err != nil || info.IsDir() {
return nil
}
if filepath.Ext(path) == ".sh" && info.Mode()&0111 == 0 {
_ = os.Chmod(path, info.Mode()|0755)
_ = os.Chmod(path, info.Mode()|0755) //nolint:gosec // G122: air-gapped tool
}
return nil
})
Expand All @@ -70,64 +70,22 @@ func main() {
}

func run() int {
// Define command-line flags
scriptsDirFlag := flag.String("scripts-dir", "", "Path to scripts directory (default: next to executable)")
showVersion := flag.Bool("version", false, "Show version")
flag.Parse()

if *showVersion {
fmt.Printf("NetUtility %s\n", version)
return 0
}

// Determine scripts directory
scriptsDir := getDefaultScriptsDir()
if *scriptsDirFlag != "" {
scriptsDir = *scriptsDirFlag
scriptsDir, shouldExit, exitCode := parseCLIFlags()
if shouldExit {
return exitCode
}

// Ensure bin/ and scripts/ are executable (idempotent, silent)
execPath, _ := os.Executable()
ensureExecutable(filepath.Dir(execPath), scriptsDir)

// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to load config: %v\n", err)
cfg = config.GetDefaultConfig()
}

// Validate and sanitize configuration
if err := cfg.ValidateConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Configuration validation failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Sanitizing configuration...\n")
cfg.SanitizeConfig()

// Save sanitized config
if saveErr := cfg.SaveConfig(); saveErr != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save sanitized config: %v\n", saveErr)
}
}

// Check if this is first-time setup
if cfg.NeedsFirstTimeSetup() {
fmt.Println("=== Welcome to NetUtility ===")
fmt.Println("First-time setup required.")
fmt.Println()
fmt.Println("NetUtility needs a workspace directory to store:")
fmt.Println(" • Network captures and analysis results")
fmt.Println(" • Vulnerability scan data")
fmt.Println(" • Configuration backups")
fmt.Println(" • Log files")
fmt.Println()

if err := runFirstTimeSetup(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Setup failed: %v\n", err)
return 1
}
// Load and validate configuration
cfg := loadAndSanitizeConfig()

fmt.Println("Setup complete! Starting NetUtility...")
fmt.Println()
// Handle first-time setup if needed
if err := handleFirstTimeSetup(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Setup failed: %v\n", err)
return 1
}

// Initialize script registry with resolved scripts directory
Expand All @@ -138,15 +96,7 @@ func run() int {
}

// Set up workspace environment
if cfg.IsWorkspaceConfigured() {
// Ensure workspace is writable (handles creation and ownership)
if err := cfg.EnsureWorkspaceWritable(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to ensure workspace is writable: %v\n", err)
} else {
// Set NETUTIL_WORKDIR environment variable
os.Setenv("NETUTIL_WORKDIR", cfg.WorkspaceDir)
}
}
setupWorkspaceEnv(cfg)

// Get remaining arguments after flag parsing
args := flag.Args()
Expand Down Expand Up @@ -191,6 +141,89 @@ func run() int {
return 0
}

// parseCLIFlags parses command-line flags and returns the resolved scripts directory.
// If the program should exit early (e.g., --version), shouldExit is true and
// exitCode contains the appropriate code.
func parseCLIFlags() (scriptsDir string, shouldExit bool, exitCode int) {
scriptsDirFlag := flag.String("scripts-dir", "", "Path to scripts directory (default: next to executable)")
showVersion := flag.Bool("version", false, "Show version")
flag.Parse()

if *showVersion {
fmt.Printf("NetUtility %s\n", version)
return "", true, 0
}

scriptsDir = getDefaultScriptsDir()
if *scriptsDirFlag != "" {
scriptsDir = *scriptsDirFlag
}
return scriptsDir, false, 0
}

// loadAndSanitizeConfig loads the application configuration, falling back to
// defaults on failure. If validation fails, the config is sanitized and saved.
func loadAndSanitizeConfig() *config.Config {
cfg, err := config.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to load config: %v\n", err)
cfg = config.GetDefaultConfig()
}

if err := cfg.ValidateConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Configuration validation failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Sanitizing configuration...\n")
cfg.SanitizeConfig()

if saveErr := cfg.SaveConfig(); saveErr != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save sanitized config: %v\n", saveErr)
}
}

return cfg
}

// handleFirstTimeSetup runs the initial workspace configuration if needed.
// Returns nil if no setup is needed or if setup succeeds.
func handleFirstTimeSetup(cfg *config.Config) error {
if !cfg.NeedsFirstTimeSetup() {
return nil
}

fmt.Println("=== Welcome to NetUtility ===")
fmt.Println("First-time setup required.")
fmt.Println()
fmt.Println("NetUtility needs a workspace directory to store:")
fmt.Println(" • Network captures and analysis results")
fmt.Println(" • Vulnerability scan data")
fmt.Println(" • Configuration backups")
fmt.Println(" • Log files")
fmt.Println()

if err := runFirstTimeSetup(cfg); err != nil {
return err
}

fmt.Println("Setup complete! Starting NetUtility...")
fmt.Println()
return nil
}

// setupWorkspaceEnv ensures the workspace directory is writable and sets the
// NETUTIL_WORKDIR environment variable for child processes.
func setupWorkspaceEnv(cfg *config.Config) {
if !cfg.IsWorkspaceConfigured() {
return
}
if err := cfg.EnsureWorkspaceWritable(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to ensure workspace is writable: %v\n", err)
return
}
if err := os.Setenv("NETUTIL_WORKDIR", cfg.WorkspaceDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to set NETUTIL_WORKDIR: %v\n", err)
}
}

// Command mappings for CLI shortcuts
var commandMappings = map[string]ScriptInfo{
// Discovery
Expand Down Expand Up @@ -501,7 +534,7 @@ func runScriptDirect(scriptPath string, scriptName string) bool {

// Ask user to press enter to continue (TUI mode)
fmt.Printf("\nPress Enter to return to menu...")
fmt.Scanln()
_, _ = fmt.Scanln()

// Clear screen again
fmt.Print("\033[2J\033[H")
Expand Down
2 changes: 1 addition & 1 deletion internal/app/privileges.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func escalatePrivileges() error {
return fmt.Errorf("failed to get executable path: %w", err)
}

cmd := exec.Command("sudo", append([]string{executable}, os.Args[1:]...)...)
cmd := exec.Command("sudo", append([]string{executable}, os.Args[1:]...)...) //nolint:gosec // G702: executable from os.Executable
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ type Credentials struct {
// File format: username:bcrypt_hash (one per line)
// Lines starting with # are comments, blank lines are ignored
func LoadCredentials(path string) (*Credentials, error) {
file, err := os.Open(path)
file, err := os.Open(path) //nolint:gosec // G304: path from CLI flag/config
if err != nil {
return nil, fmt.Errorf("failed to open credentials file: %w", err)
}
defer file.Close()
defer func() { _ = file.Close() }()

creds := &Credentials{
users: make(map[string]string),
Expand Down
Loading
Loading