Skip to content
Open
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
45 changes: 41 additions & 4 deletions internal/logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
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
Expand Down Expand Up @@ -122,13 +129,43 @@ func (l *Logger) Close() error {
return firstErr
}

// CreateFile initializes a log file for the given URL and writes the header.
func CreateFile(logFiles *[]string, url string, dir string) error {
// 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
}
path := filepath.Join(dir, sanitizedURL+".log")
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)

Expand All @@ -142,7 +179,7 @@ func CreateFile(logFiles *[]string, url string, dir string) error {

// Write appends text to the log file for the given URL.
func Write(url string, dir string, text string) error {
path := filepath.Join(dir, url+".log")
path := logPath(dir, url)
return defaultLogger.write(path, text)
}

Expand Down
106 changes: 106 additions & 0 deletions internal/logger/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,112 @@ func TestCreateFile(t *testing.T) {
Close()
}

// TestCreateFileURLWithPathDoesNotFail proves a target with a URL path (e.g.
// http://127.0.0.1:8931/) gets a writable log file. Before the fix, the
// scheme-stripped sanitized URL kept the '/' verbatim, so CreateFile tried to
// open "<dir>/127.0.0.1:8931/.log", a path whose parent directory was never
// created, and OpenFile failed, aborting the whole target's scan.
func TestCreateFileURLWithPathDoesNotFail(t *testing.T) {
tmpDir := t.TempDir()

var logFiles []string
url := "http://127.0.0.1:8931/"
if err := CreateFile(&logFiles, url, tmpDir); err != nil {
t.Fatalf("CreateFile with path-bearing url: %v", err)
}

if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}

if len(logFiles) != 1 {
t.Fatalf("expected 1 log file, got %d", len(logFiles))
}

if filepath.Dir(logFiles[0]) != tmpDir {
t.Fatalf("expected log file directly under %q, got %q", tmpDir, logFiles[0])
}

content, err := os.ReadFile(logFiles[0])
if err != nil {
t.Fatalf("log file not readable: %v", err)
}
if !strings.Contains(string(content), "sif log file for "+url) {
t.Errorf("expected header content, got %q", content)
}

Close()
}

// TestCreateFileURLWithMultiSlashAndQueryDoesNotFail covers a messier target:
// multiple path segments (and a doubled slash) plus query characters. None of
// it should ever produce a nested directory or a failed OpenFile.
func TestCreateFileURLWithMultiSlashAndQueryDoesNotFail(t *testing.T) {
tmpDir := t.TempDir()

var logFiles []string
url := "https://example.com:8443//a/b/c?x=1&y=2"
if err := CreateFile(&logFiles, url, tmpDir); err != nil {
t.Fatalf("CreateFile with multi-slash/query url: %v", err)
}

if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}

if len(logFiles) != 1 {
t.Fatalf("expected 1 log file, got %d", len(logFiles))
}
if filepath.Dir(logFiles[0]) != tmpDir {
t.Fatalf("expected log file directly under %q, got %q", tmpDir, logFiles[0])
}
if _, err := os.Stat(logFiles[0]); err != nil {
t.Fatalf("log file not created: %v", err)
}

Close()
}

// TestWriteURLWithPathUsesSameFlatFile proves Write (used for every line after
// the header) resolves to the exact same path CreateFile wrote the header to,
// for a URL with a path component. Before the fix, both functions kept the raw
// '/' verbatim and would have hit the same missing-subdirectory error.
func TestWriteURLWithPathUsesSameFlatFile(t *testing.T) {
tmpDir := t.TempDir()
sanitizedURL := "127.0.0.1:8931/some/path"

if err := WriteHeader(sanitizedURL, tmpDir, "test"); err != nil {
t.Fatalf("WriteHeader with path-bearing url: %v", err)
}
if err := Write(sanitizedURL, tmpDir, "line two\n"); err != nil {
t.Fatalf("Write with path-bearing url: %v", err)
}
if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}

entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected exactly 1 flat log file under %q, got %d entries", tmpDir, len(entries))
}
if entries[0].IsDir() {
t.Fatalf("expected a flat file, got a directory: %s", entries[0].Name())
}

content, err := os.ReadFile(filepath.Join(tmpDir, entries[0].Name()))
if err != nil {
t.Fatalf("reading log file: %v", err)
}
if !strings.Contains(string(content), "line two") {
t.Errorf("Write did not land in the same file WriteHeader created: %q", content)
}

Close()
}

func TestConcurrentWrites(t *testing.T) {
tmpDir := t.TempDir()

Expand Down
3 changes: 2 additions & 1 deletion internal/scan/cloudstorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/logger"
"github.com/vmfunc/sif/internal/output"
"github.com/vmfunc/sif/internal/styles"
)

Expand All @@ -36,7 +37,7 @@ type CloudStorageResult struct {
}

func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStorageResult, error) {
fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "..."))
output.ScanStart("Cloud Storage Misconfiguration Scan")

sanitizedURL := stripScheme(url)

Expand Down
8 changes: 4 additions & 4 deletions internal/scan/js/frameworks/next.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ package frameworks

import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"

urlutil "github.com/projectdiscovery/utils/url"
"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/output"
)

// nextPagesRegex matches JavaScript file references in Next.js build manifest.
Expand All @@ -49,22 +49,22 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) {

req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, scriptUrl, http.NoBody)
if err != nil {
fmt.Println(err)
output.Error("%v", err)
return nil, err
}

// no timeout in scope here; 0 matches the previous DefaultClient behavior
// while still routing through the shared transport (proxy/headers/rate-limit).
resp, err := httpx.Client(0).Do(req)
if err != nil {
fmt.Println(err)
output.Error("%v", err)
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize))
if err != nil {
fmt.Println(err)
output.Error("%v", err)
return nil, err
}
// the manifest ships minified on one line; strip line breaks so the regex
Expand Down
36 changes: 36 additions & 0 deletions internal/scan/js/frameworks/stdout_bypass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package frameworks

import (
"os"
"strings"
"testing"
)

// TestNextDoesNotPrintDirectlyToStdout mirrors internal/scan's guard: next.go
// used to fmt.Println(err) straight to os.Stdout, which can interleave/tear
// under -concurrency>1 since only the output sink is lock-wrapped there.
func TestNextDoesNotPrintDirectlyToStdout(t *testing.T) {
data, err := os.ReadFile("next.go")
if err != nil {
t.Fatalf("reading next.go: %v", err)
}
src := string(data)
if strings.Contains(src, "fmt.Println(") {
t.Error("next.go still calls fmt.Println directly, want output.Error instead")
}
if strings.Contains(src, "os.Stdout") {
t.Error("next.go references os.Stdout directly, want the output sink instead")
}
}
44 changes: 44 additions & 0 deletions internal/scan/stdout_bypass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package scan

import (
"os"
"strings"
"testing"
)

// TestModulesDoNotPrintDirectlyToStdout guards against status/error lines that
// bypass the output sink via a raw fmt.Println(os.Stdout, ...): under
// -concurrency>1 those interleave/tear with lines the rest of the run writes
// through output.Info/Error, since only the sink is wrapped with a lock. every
// module should route its chrome through the output package instead.
func TestModulesDoNotPrintDirectlyToStdout(t *testing.T) {
files := []string{
"cloudstorage.go",
"subdomaintakeover.go",
}
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("reading %s: %v", f, err)
}
src := string(data)
if strings.Contains(src, "fmt.Println(") {
t.Errorf("%s still calls fmt.Println directly, want output.Info/Error/ScanStart instead", f)
}
if strings.Contains(src, "os.Stdout") {
t.Errorf("%s references os.Stdout directly, want the output sink instead", f)
}
}
}
3 changes: 2 additions & 1 deletion internal/scan/subdomaintakeover.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/logger"
"github.com/vmfunc/sif/internal/output"
"github.com/vmfunc/sif/internal/pool"
"github.com/vmfunc/sif/internal/styles"
)
Expand Down Expand Up @@ -70,7 +71,7 @@ var takeoverProviders = map[string]string{
// SubdomainTakeover checks dnsResults for dangling subdomains pointing at
// unclaimed third-party services.
func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, threads int, logdir string) ([]SubdomainTakeoverResult, error) {
fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Subdomain Takeover Vulnerability Check") + "..."))
output.ScanStart("Subdomain Takeover Vulnerability Check")

sanitizedURL := stripScheme(url)

Expand Down
Loading
Loading