refactor: share path-resolution and body-cap helpers (#355)

* refactor: share one helper for the per-user sif config directory

the modules loader and the framework custom-signature loader each
hand-rolled the same ~/.config/sif/<sub> (and %LocalAppData%\sif\<sub>
on windows) path. extract internal/sifpath.UserSubdir and route both
through it so the per-user layout is defined in one place.

no behavior change: the helper reproduces the existing paths exactly.

* refactor: read capped response bodies through one httpx helper

the frameworks detector and the module executor each defined their own
5 MB body cap and ran the same io.ReadAll(io.LimitReader(...)) read. move
the cap and the read into httpx.MaxBodySize / httpx.ReadCappedBody so both
scanners share one ceiling instead of two consts that can drift.

no behavior change: the cap value and read semantics are unchanged.
This commit is contained in:
Tigah
2026-07-22 15:48:49 -07:00
committed by GitHub
parent c2dbe4a19f
commit f6db61b0ef
7 changed files with 109 additions and 31 deletions
+12
View File
@@ -68,6 +68,11 @@ const (
// conn, so we cap the read and let the conn be discarded instead.
const drainCap = 16 << 10
// MaxBodySize is the shared ceiling on how much of a response body the scanners
// read into memory, so a hostile or accidental multi-gigabyte response can't
// exhaust the process.
const MaxBodySize = 5 << 20
// Options carries the runtime knobs that apply to every outbound request.
// RateLimit is requests/sec (0 = unlimited); Headers are "Key: Value" strings.
type Options struct {
@@ -213,6 +218,13 @@ func DrainClose(resp *http.Response) {
resp.Body.Close()
}
// ReadCappedBody reads resp.Body up to MaxBodySize, the shared cap every scanner
// uses so one runaway response can't exhaust memory. It does not close the body;
// pair it with DrainClose or an explicit Close.
func ReadCappedBody(resp *http.Response) ([]byte, error) {
return io.ReadAll(io.LimitReader(resp.Body, MaxBodySize))
}
// parseHeaders splits each "Key: Value" entry on the first ": ". Entries
// without the separator are rejected so a typo fails loud instead of silently.
// The returned map is always non-nil so callers can range it unconditionally.
+2 -4
View File
@@ -26,11 +26,9 @@ import (
"time"
"github.com/tidwall/gjson"
"github.com/vmfunc/sif/internal/httpx"
)
// MaxBodySize limits response body to prevent memory exhaustion.
const MaxBodySize = 5 * 1024 * 1024
// ErrUnsupportedModuleType signals an executor for a module type that is not
// yet implemented. Returning it (rather than an empty result) keeps callers
// from mistaking "not implemented" for "scanned, found nothing".
@@ -422,7 +420,7 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest
defer resp.Body.Close()
// Read body with limit
respBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodySize))
respBody, err := httpx.ReadCappedBody(resp)
if err != nil {
return Finding{}, false
}
+8 -13
View File
@@ -17,10 +17,10 @@ import (
"io/fs"
"os"
"path/filepath"
"runtime"
"github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/output"
"github.com/vmfunc/sif/internal/sifpath"
)
// builtinFS holds the modules embedded into the binary. it's set once, from the
@@ -45,20 +45,15 @@ type Loader struct {
// It automatically detects the built-in modules directory and sets up
// the user modules directory based on the operating system.
func NewLoader() (*Loader, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("get home dir: %w", err)
}
// resolveBuiltinDir already probes the executable dir, the working
// directory and the system data dirs, so the per-user lookup below is the
// only thing sifpath needs to own here.
builtinDir := resolveBuiltinDir()
// User modules directory based on OS
var userDir string
switch runtime.GOOS {
case "windows":
userDir = filepath.Join(home, "AppData", "Local", "sif", "modules")
default:
userDir = filepath.Join(home, ".config", "sif", "modules")
// User modules directory (can override built-ins)
userDir, err := sifpath.UserSubdir("modules")
if err != nil {
return nil, fmt.Errorf("resolve user modules dir: %w", err)
}
return &Loader{
+2 -9
View File
@@ -26,11 +26,11 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
charmlog "github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/output"
"github.com/vmfunc/sif/internal/sifpath"
"gopkg.in/yaml.v3"
)
@@ -129,14 +129,7 @@ func parseCustomDetector(path string) (Detector, error) {
// customSignaturesDir is the per-user directory that holds yaml-defined
// detectors, alongside the user modules directory.
func customSignaturesDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if runtime.GOOS == "windows" {
return filepath.Join(home, "AppData", "Local", "sif", "signatures"), nil
}
return filepath.Join(home, ".config", "sif", "signatures"), nil
return sifpath.UserSubdir("signatures")
}
// loadCustomDetectors registers every signature file under the user directory.
+1 -5
View File
@@ -15,7 +15,6 @@ package frameworks
import (
"context"
"fmt"
"io"
"net/http"
"sort"
"strings"
@@ -31,9 +30,6 @@ import (
// detectionThreshold is the minimum confidence for a detection to be reported.
const detectionThreshold = 0.5
// maxBodySize limits response body to prevent memory exhaustion.
const maxBodySize = 5 * 1024 * 1024
// detectionResult holds the result from a single detector.
type detectionResult struct {
name string
@@ -66,7 +62,7 @@ func gatherDetections(url string, timeout time.Duration) ([]detectionResult, str
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
body, err := httpx.ReadCappedBody(resp)
if err != nil {
return nil, "", err
}
+36
View File
@@ -0,0 +1,36 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package sifpath resolves the per-user sif directories so every subsystem
// agrees on where user-supplied files live.
package sifpath
import (
"os"
"path/filepath"
"runtime"
)
// UserSubdir returns the per-user sif configuration subdirectory for name (for
// example "modules" or "signatures"). It preserves sif's historical layout:
// ~/.config/sif/<name> on unix-like systems and %LOCALAPPDATA%\sif\<name> on
// windows.
func UserSubdir(name string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if runtime.GOOS == "windows" {
return filepath.Join(home, "AppData", "Local", "sif", name), nil
}
return filepath.Join(home, ".config", "sif", name), nil
}
+48
View File
@@ -0,0 +1,48 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package sifpath
import (
"path/filepath"
"testing"
)
func TestUserSubdirLayout(t *testing.T) {
got, err := UserSubdir("modules")
if err != nil {
t.Fatalf("UserSubdir returned error: %v", err)
}
if !filepath.IsAbs(got) {
t.Errorf("UserSubdir(%q) = %q, want an absolute path", "modules", got)
}
if base := filepath.Base(got); base != "modules" {
t.Errorf("UserSubdir(%q) base = %q, want %q", "modules", base, "modules")
}
if parent := filepath.Base(filepath.Dir(got)); parent != "sif" {
t.Errorf("UserSubdir(%q) parent = %q, want %q", "modules", parent, "sif")
}
}
func TestUserSubdirSiblings(t *testing.T) {
mods, err := UserSubdir("modules")
if err != nil {
t.Fatalf("UserSubdir(modules): %v", err)
}
sigs, err := UserSubdir("signatures")
if err != nil {
t.Fatalf("UserSubdir(signatures): %v", err)
}
if filepath.Dir(mods) != filepath.Dir(sigs) {
t.Errorf("modules dir %q and signatures dir %q should share a parent", mods, sigs)
}
}