Files
sif/internal/logger/logger.go
T
TigahandGitHub a44cfb230f fix(store,logger,scan): concurrency-adjacent races and correctness fixes (#343)
* fix(logger): create log dirs and flatten url log filenames

CreateFile/Write kept the '/' from a target's url path when building the
log filename, so a target like http://host:port/ tried to open
<dir>/host:port/.log whose parent directory never existed. os.OpenFile
failed and scanTarget aborted the whole target before any scanning ran.

fold every '/' and '\\' run in the sanitized url into a single '_' via a
shared logPath helper so CreateFile and Write always resolve to the same
flat file, and mkdir the parent defensively in getWriter as a second line
of defense.

* fix(scan): route module status lines through the locking sink

subdomaintakeover, cloudstorage, and the next.js framework detector
printed their status/error lines with a bare fmt.Println straight to
os.Stdout, bypassing the output package's sink. under -concurrency>1
only the sink is lock-wrapped, so these lines could interleave or tear
against lines other targets write concurrently.

swap them for output.ScanStart / output.Error, matching every other
scanner in the package.

* fix(store): make snapshot filenames injective and writes atomic

sanitize() folds every separator run (and a literal '_') to one '_', so
distinct targets like https://a.com/x and https://a.com//x, or
host:8443/path and host_8443_path, sanitized to the identical string and
shared one snapshot file - the second target's Save silently clobbered
the first's baseline. Save's os.WriteFile also wasn't atomic, so two
targets racing on a collided path (or -concurrency>1 in general) could
interleave partial writes.

pathFor now appends 16 hex chars of the target's sha256 to the sanitized
name, so distinct targets never collide, and Save writes through a temp
file in the same dir followed by os.Rename so a reader always sees a
complete snapshot or the previous one, never a partial write.

this changes the on-disk snapshot filename scheme: existing users' -diff
baselines under the old sanitize()-only names won't be found and the
next run re-baselines from scratch (every finding reports as newly
added once). noting this as a deliberate tradeoff for a local hardening
pass rather than silently orphaning them.

* fix(store): check the deferred temp-file cleanup error

golangci-lint (errcheck) flagged the bare defer os.Remove(tmpPath) in
writeFileAtomic; wrap it so the discard is explicit.
2026-07-22 12:55:08 -07:00

200 lines
5.6 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package logger
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Logger manages buffered file writers for efficient logging.
// File handles are kept open and writes are buffered to minimize I/O overhead.
type Logger struct {
mu sync.RWMutex
writers map[string]*bufio.Writer
files map[string]*os.File
}
var defaultLogger = &Logger{
writers: make(map[string]*bufio.Writer),
files: make(map[string]*os.File),
}
// Init creates the log directory if it doesn't exist.
func Init(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.Mkdir(dir, 0o750); err != nil {
return err
}
}
return nil
}
// getWriter returns a buffered writer for the given file path, creating it if needed.
func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
l.mu.RLock()
w, exists := l.writers[path]
l.mu.RUnlock()
if exists {
return w, nil
}
l.mu.Lock()
defer l.mu.Unlock()
// Double-check after acquiring write lock
if w, exists = l.writers[path]; exists {
return w, nil
}
// belt-and-suspenders: logPath already flattens '/' out of the filename
// so path's parent is always dir itself, but a future caller could still
// hand getWriter a nested path directly, so make sure it exists too.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
w = bufio.NewWriter(f)
l.writers[path] = w
l.files[path] = f
return w, nil
}
// write writes text to the specified log file using buffered I/O.
func (l *Logger) write(path, text string) error {
w, err := l.getWriter(path)
if err != nil {
return err
}
l.mu.Lock()
_, err = w.WriteString(text)
l.mu.Unlock()
return err
}
// Flush flushes all buffered writers to disk.
func (l *Logger) Flush() error {
l.mu.Lock()
defer l.mu.Unlock()
for _, w := range l.writers {
if err := w.Flush(); err != nil {
return err
}
}
return nil
}
// Close flushes and closes all open file handles.
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
var firstErr error
for path, w := range l.writers {
if err := w.Flush(); err != nil && firstErr == nil {
firstErr = err
}
if err := l.files[path].Close(); err != nil && firstErr == nil {
firstErr = err
}
}
l.writers = make(map[string]*bufio.Writer)
l.files = make(map[string]*os.File)
return firstErr
}
// flattenPath folds every '/' and '\\' run in s into a single '_' and trims
// them from the ends. a target's path segments (or a stray backslash) would
// otherwise be read as an implied (and almost always missing) subdirectory
// tree; a URL maps to exactly one flat log file, never a directory tree.
func flattenPath(s string) string {
var b strings.Builder
b.Grow(len(s))
prevSep := false
for i := 0; i < len(s); i++ {
c := s[i]
if c == '/' || c == '\\' {
if !prevSep {
b.WriteByte('_')
prevSep = true
}
continue
}
b.WriteByte(c)
prevSep = false
}
return strings.Trim(b.String(), "_")
}
// logPath builds the flattened log-file path for a URL under dir. CreateFile
// and Write both go through it so the header and every later line for a
// target always land in the same file.
func logPath(dir, url string) string {
sanitizedURL := url
if _, after, ok := strings.Cut(url, "://"); ok {
sanitizedURL = after
}
return filepath.Join(dir, flattenPath(sanitizedURL)+".log")
}
// CreateFile initializes a log file for the given URL and writes the header.
func CreateFile(logFiles *[]string, url string, dir string) error {
path := logPath(dir, url)
header := fmt.Sprintf(" _____________\n__________(_)__ __/\n__ ___/_ /__ /_ \n_(__ )_ / _ __/ \n/____/ /_/ /_/ \n\nsif log file for %s\nhttps://sif.sh\n\n", url)
if err := defaultLogger.write(path, header); err != nil {
return err
}
*logFiles = append(*logFiles, path)
return nil
}
// Write appends text to the log file for the given URL.
func Write(url string, dir string, text string) error {
path := logPath(dir, url)
return defaultLogger.write(path, text)
}
// WriteHeader writes a section header to the log file.
func WriteHeader(url string, dir string, scan string) error {
return Write(url, dir, fmt.Sprintf("\n\n--------------\nStarting %s\n--------------\n", scan))
}
// Flush flushes all buffered log data to disk.
func Flush() error {
return defaultLogger.Flush()
}
// Close flushes and closes all log files. Should be called before program exit.
func Close() error {
return defaultLogger.Close()
}