From 58d5b75eed28745ec07dcbb69af1217bc094fd19 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:40:38 -0700 Subject: [PATCH 1/4] feat(config): add -config/-profile config file and profile overlays extend the existing goflags yaml config mechanism (the same one -template already uses) with an explicit -config path and named -profile overlays, instead of adding a second config system. resolveConfigInput unifies -config/-profile/-template into a single flat yaml path for goflags to merge before Parse: unset, it returns "" and goflags falls back to its ambient ~/.config/sif/config.yaml unchanged. a profile overlays its keys onto the file's top-level keys in a go map before handing goflags a flat temp file, so precedence stays explicit cli flag > profile > file default > built-in default via goflags' own DefValue sentinel merge. -config and -template share one config slot and are mutually exclusive. verifies empirically that goflags auto-creates and merges the ambient config file with no explicit SetConfigFilePath call, which this feature depends on. --- internal/config/config.go | 26 ++- internal/config/configfile.go | 168 ++++++++++++++++++ internal/config/configfile_test.go | 270 +++++++++++++++++++++++++++++ internal/config/template.go | 16 +- 4 files changed, 456 insertions(+), 24 deletions(-) create mode 100644 internal/config/configfile.go create mode 100644 internal/config/configfile_test.go diff --git a/internal/config/config.go b/internal/config/config.go index e30a30d3..2c1bc380 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,6 +84,8 @@ type Settings struct { Notify bool // -notify: ship findings to configured providers NotifySeverity string // -notify-severity: minimum severity to send (info..critical) NotifyConfig string // -notify-config: path to a notify-compatible yaml file + ConfigFile string // -config: path to a yaml config file ("" = default ~/.config/sif/config.yaml) + Profile string // -profile: named profile overlay from the config file } // minThreads is the floor for the worker count. Threads feeds wg.Add across the @@ -174,6 +176,11 @@ func registerFlags(settings *Settings) *goflags.FlagSet { flagSet.StringVar(&settings.Template, "template", "", "Load scan settings from a template (preset minimal/recon/full, or a local yaml file)"), ) + flagSet.CreateGroup("config", "Config", + flagSet.StringVar(&settings.ConfigFile, "config", "", "Load flags from a yaml config file (default ~/.config/sif/config.yaml)"), + flagSet.StringVar(&settings.Profile, "profile", "", "Select a named profile from the config file"), + ) + flagSet.CreateGroup("http", "HTTP", flagSet.StringVar(&settings.Proxy, "proxy", "", "Proxy for all requests (http/https/socks5 url)"), flagSet.StringSliceVarP(&settings.Header, "header", "H", nil, "Custom header to send (repeatable or comma-separated, \"Key: Value\")", goflags.CommaSeparatedStringSliceOptions), @@ -214,19 +221,20 @@ func Parse() *Settings { settings := &Settings{} flagSet := registerFlags(settings) - // -template presets a batch of scans from a yaml file or named preset; point - // goflags at it before Parse so it merges as config (cli flags still win) and - // replaces the ambient config for this run. - templatePath, cleanup, err := templateConfigPath(os.Args[1:]) + // -config/-profile/-template all resolve to a single flat yaml config path + // for goflags to merge, so point it there before Parse (cli flags still + // win). unset, this returns "" and goflags falls back to its own ambient + // default config path, unchanged from before this feature existed. + configPath, cleanup, err := resolveConfigInput(os.Args[1:]) if err != nil { - log.Fatalf("Could not load template: %s", err) + log.Fatalf("Could not load config: %s", err) } - if templatePath != "" { - flagSet.SetConfigFilePath(templatePath) + if configPath != "" { + flagSet.SetConfigFilePath(configPath) } - // Parse merges the template config synchronously, so a temp preset file can - // be removed right after, before any fatal exit (no leaking defer). + // Parse merges the config synchronously, so a temp overlay file can be + // removed right after, before any fatal exit (no leaking defer). parseErr := flagSet.Parse() if cleanup != nil { cleanup() diff --git a/internal/config/configfile.go b/internal/config/configfile.go new file mode 100644 index 00000000..86cbf420 --- /dev/null +++ b/internal/config/configfile.go @@ -0,0 +1,168 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package config + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + folderutil "github.com/projectdiscovery/utils/folder" + "gopkg.in/yaml.v3" +) + +// resolveConfigInput turns -config/-profile/-template into a single flat yaml +// config path for goflags to merge, plus a cleanup for any temp file it wrote. +// it returns ("", nil, nil) when none of the three are set, so goflags falls +// back to its own ambient default config path, unchanged. +func resolveConfigInput(args []string) (string, func(), error) { + cfg := rawFlagValue(args, "config") + prof := rawFlagValue(args, "profile") + tmpl := rawFlagValue(args, "template") + + if tmpl != "" && (cfg != "" || prof != "") { + return "", nil, fmt.Errorf("-config and -template cannot be combined") + } + if tmpl != "" { + return templateConfigPath(args) + } + if cfg == "" && prof == "" { + return "", nil, nil + } + + path := cfg + if path == "" { + path = defaultConfigFilePath() + } else if info, err := os.Stat(path); err != nil { //nolint:gosec // G304/G703: user-supplied config path, same as -template/-w + return "", nil, fmt.Errorf("config file %q not found", path) + } else if info.IsDir() { + return "", nil, fmt.Errorf("config file %q is a directory", path) + } + + if prof == "" { + return path, nil, nil + } + return buildProfileConfig(path, prof) +} + +// rawFlagValue pulls a -name value out of raw args before Parse (space and = +// forms, single and double dash). the value has to be known before Parse, so +// it cannot come from the parsed flag itself. +func rawFlagValue(args []string, name string) string { + long := "-" + name + dlong := "--" + name + for i, arg := range args { + if arg == long || arg == dlong { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + if v, ok := strings.CutPrefix(arg, long+"="); ok { + return v + } + if v, ok := strings.CutPrefix(arg, dlong+"="); ok { + return v + } + } + return "" +} + +// defaultConfigFilePath mirrors goflags' own GetConfigFilePath default +// (path.go:19 in goflags), so -profile without an explicit -config resolves +// to the same ambient file goflags would otherwise merge. +func defaultConfigFilePath() string { + appName := filepath.Base(os.Args[0]) + toolName := strings.TrimSuffix(appName, filepath.Ext(appName)) + return filepath.Join(folderutil.AppConfigDirOrDefault(".", toolName), "config.yaml") +} + +// buildProfileConfig reads the config file at path, overlays profiles[profile] +// onto the top-level keys, and writes the merged flat map to a temp yaml file +// for goflags to merge (map overlay guarantees the profile wins over the +// file's top-level defaults). +func buildProfileConfig(path, profile string) (string, func(), error) { + top, profiles, err := loadConfigMap(path) + if err != nil { + return "", nil, err + } + overlay, ok := profiles[profile] + if !ok { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + sort.Strings(names) + available := "none" + if len(names) > 0 { + available = strings.Join(names, ", ") + } + return "", nil, fmt.Errorf("unknown profile %q; available profiles: %s", profile, available) + } + + merged := make(map[string]any, len(top)+len(overlay)) + for k, v := range top { + merged[k] = v + } + for k, v := range overlay { + merged[k] = v + } + + data, err := yaml.Marshal(merged) + if err != nil { + return "", nil, err + } + return materializePreset(data) +} + +// loadConfigMap decodes a config file into its top-level keys and its +// profiles submap. a missing file (the ambient default that has never been +// written) yields empty maps rather than an error, so -profile against it +// fails with a normal "unknown profile" error instead of a raw stat error. +func loadConfigMap(path string) (top map[string]any, profiles map[string]map[string]any, err error) { + data, readErr := os.ReadFile(path) //nolint:gosec // G304: user-supplied config path, same as -template/-w + if readErr != nil { + if os.IsNotExist(readErr) { + return map[string]any{}, map[string]map[string]any{}, nil + } + return nil, nil, readErr + } + + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, nil, fmt.Errorf("config file %q is not valid yaml: %w", path, err) + } + + top = make(map[string]any, len(raw)) + profiles = make(map[string]map[string]any) + for k, v := range raw { + if k != "profiles" { + top[k] = v + continue + } + pm, ok := v.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("config file %q: profiles must be a map", path) + } + for name, pv := range pm { + sub, ok := pv.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("config file %q: profile %q must be a map", path, name) + } + profiles[name] = sub + } + } + return top, profiles, nil +} diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go new file mode 100644 index 00000000..c488b63f --- /dev/null +++ b/internal/config/configfile_test.go @@ -0,0 +1,270 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/projectdiscovery/goflags" +) + +// this whole feature rests on an unverified-until-now goflags behavior: with +// no explicit SetConfigFilePath call, goflags still resolves an ambient +// config path from the binary name, auto-creates it (all keys commented out) +// on first Parse, and merges real values from it on a later Parse. verify +// that empirically rather than trusting the goflags source read. +func TestGoflagsAutoWritesAndMergesAmbientConfig(t *testing.T) { + goflags.DisableAutoConfigMigration = true + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + settings := &Settings{} + flagSet := registerFlags(settings) + if err := flagSet.Parse("-u", "x"); err != nil { + t.Fatalf("first parse: %s", err) + } + + cfgPath, err := flagSet.GetConfigFilePath() + if err != nil { + t.Fatalf("GetConfigFilePath: %s", err) + } + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("expected goflags to auto-create %s, got: %s", cfgPath, err) + } + + if err := os.WriteFile(cfgPath, append(data, []byte("\nthreads: 42\n")...), 0o600); err != nil { + t.Fatalf("write real value: %s", err) + } + + settings2 := &Settings{} + flagSet2 := registerFlags(settings2) + if err := flagSet2.Parse("-u", "x"); err != nil { + t.Fatalf("second parse: %s", err) + } + if settings2.Threads != 42 { + t.Fatalf("expected ambient config merge to set threads=42, got %d", settings2.Threads) + } +} + +// writeConfigFile drops a yaml config in a temp dir and returns its path. +func writeConfigFile(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %s", err) + } + return path +} + +// parseWith registers the real flags, resolves -config/-profile/-template out +// of args, merges the result as the goflags config, and parses args. it +// mirrors how config.Parse() wires resolveConfigInput to flagSet.Parse. +func parseWith(t *testing.T, args ...string) *Settings { + t.Helper() + goflags.DisableAutoConfigMigration = true + settings := &Settings{} + flagSet := registerFlags(settings) + + path, cleanup, err := resolveConfigInput(args) + if err != nil { + t.Fatalf("resolveConfigInput: %s", err) + } + if cleanup != nil { + defer cleanup() + } + if path != "" { + flagSet.SetConfigFilePath(path) + } + if err := flagSet.Parse(args...); err != nil { + t.Fatalf("parse: %s", err) + } + return settings +} + +// zero-config must stay byte-identical to today: no -config/-profile/-template +// means resolveConfigInput returns a no-op and every flag keeps its +// registered built-in default. +func TestResolveConfigInputZeroConfigUnchanged(t *testing.T) { + path, cleanup, err := resolveConfigInput([]string{"-u", "x"}) + if err != nil { + t.Fatalf("resolveConfigInput: %s", err) + } + if cleanup != nil { + t.Error("expected no cleanup for zero-config") + } + if path != "" { + t.Errorf("expected empty path for zero-config, got %q", path) + } + + settings := parseWith(t, "-u", "x") + if settings.Threads != 10 { + t.Errorf("expected built-in default threads=10, got %d", settings.Threads) + } + if settings.Headers { + t.Error("expected built-in default headers=false") + } + if settings.SQL { + t.Error("expected built-in default sql=false") + } +} + +// a plain -config file overrides built-in defaults for the keys it sets and +// leaves everything else untouched. +func TestConfigFileOverridesDefault(t *testing.T) { + path := writeConfigFile(t, "threads: 20\nheaders: true\n") + settings := parseWith(t, "-u", "x", "-config", path) + + if settings.Threads != 20 { + t.Errorf("expected file threads=20, got %d", settings.Threads) + } + if !settings.Headers { + t.Error("expected file headers=true") + } + if settings.SQL { + t.Error("expected untouched sql to stay false") + } +} + +// an explicit cli flag beats the file, mirroring TestTemplateConfigPrecedence. +// caveat (pre-existing goflags property, not introduced here): a cli flag +// explicitly set to its own default is indistinguishable from unset, so a +// file value for that flag would still win in that edge case. +func TestExplicitFlagBeatsFile(t *testing.T) { + path := writeConfigFile(t, "threads: 20\n") + settings := parseWith(t, "-u", "x", "-config", path, "-threads", "5") + + if settings.Threads != 5 { + t.Errorf("expected cli threads=5 to win over file, got %d", settings.Threads) + } +} + +// a profile overlays on top of the file's top-level keys, and also exercises +// an enum (dirlist) and a StringSlice (header) round-tripping through the +// yaml.Marshal + goflags merge. +func TestProfileOverlaysTopLevel(t *testing.T) { + path := writeConfigFile(t, ""+ + "threads: 20\n"+ + "profiles:\n"+ + " quick:\n"+ + " probe: true\n"+ + " threads: 30\n"+ + " dirlist: small\n"+ + " header:\n"+ + " - \"X-Test: 1\"\n") + + settings := parseWith(t, "-u", "x", "-config", path, "-profile", "quick") + + if !settings.Probe { + t.Error("expected profile probe=true") + } + if settings.Threads != 30 { + t.Errorf("expected profile threads=30 to beat file top-level 20, got %d", settings.Threads) + } + if settings.Dirlist != "small" { + t.Errorf("expected profile dirlist=small, got %q", settings.Dirlist) + } + if len(settings.Header) != 1 || settings.Header[0] != "X-Test: 1" { + t.Errorf("expected profile header slice [X-Test: 1], got %v", settings.Header) + } +} + +// an explicit cli flag still beats a profile value for the same key. +func TestCLIBeatsProfile(t *testing.T) { + path := writeConfigFile(t, ""+ + "threads: 20\n"+ + "profiles:\n"+ + " quick:\n"+ + " threads: 30\n") + + settings := parseWith(t, "-u", "x", "-config", path, "-profile", "quick", "-threads", "7") + + if settings.Threads != 7 { + t.Errorf("expected cli threads=7 to win over profile, got %d", settings.Threads) + } +} + +// selecting an unknown profile is a hard error listing the profiles that do +// exist, mirroring TestResolveTemplateUnknownName. +func TestUnknownProfileErrors(t *testing.T) { + path := writeConfigFile(t, "profiles:\n quick:\n probe: true\n") + + _, _, err := resolveConfigInput([]string{"-config", path, "-profile", "deep"}) + if err == nil { + t.Fatal("expected an error for an unknown profile") + } + if !containsAll(err.Error(), "deep", "quick") { + t.Errorf("expected error to name the requested and available profiles, got: %s", err) + } +} + +// -config and -template are mutually exclusive: both target goflags' single +// config file slot and cannot be chained through it. +func TestConfigAndTemplateAreMutuallyExclusive(t *testing.T) { + cases := [][]string{ + {"-config", "/some/path.yaml", "-template", "recon"}, + {"-template", "recon", "-profile", "quick"}, + } + for _, args := range cases { + if _, _, err := resolveConfigInput(args); err == nil { + t.Errorf("expected an error combining %v", args) + } + } +} + +// an explicit -config naming a file that does not exist is a hard error; it +// must not silently fall back to the ambient default. +func TestMissingExplicitConfigFileErrors(t *testing.T) { + if _, _, err := resolveConfigInput([]string{"-config", "/does/not/exist.yaml"}); err == nil { + t.Fatal("expected an error for a missing -config file") + } +} + +func TestRawFlagValue(t *testing.T) { + cases := []struct { + name string + args []string + flag string + want string + }{ + {"long with space", []string{"-config", "a.yaml"}, "config", "a.yaml"}, + {"double dash with space", []string{"--config", "b.yaml"}, "config", "b.yaml"}, + {"long with equals", []string{"-config=c.yaml"}, "config", "c.yaml"}, + {"double dash with equals", []string{"--config=d.yaml"}, "config", "d.yaml"}, + {"absent", []string{"-u", "x"}, "config", ""}, + {"trailing without value", []string{"-u", "x", "-config"}, "config", ""}, + {"profile long with space", []string{"-profile", "quick"}, "profile", "quick"}, + {"profile with equals", []string{"-profile=quick"}, "profile", "quick"}, + {"profile absent", []string{"-config", "a.yaml"}, "profile", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := rawFlagValue(tc.args, tc.flag); got != tc.want { + t.Errorf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +} diff --git a/internal/config/template.go b/internal/config/template.go index 6210d4fa..8a800a35 100644 --- a/internal/config/template.go +++ b/internal/config/template.go @@ -40,21 +40,7 @@ func templateConfigPath(args []string) (string, func(), error) { // templateFlagValue pulls the -template value out of raw args; the config path // has to be known before Parse, so it cannot come from the parsed flag itself. func templateFlagValue(args []string) string { - for i, arg := range args { - if arg == "-template" || arg == "--template" { - if i+1 < len(args) { - return args[i+1] - } - return "" - } - if v, ok := strings.CutPrefix(arg, "-template="); ok { - return v - } - if v, ok := strings.CutPrefix(arg, "--template="); ok { - return v - } - } - return "" + return rawFlagValue(args, "template") } // resolveTemplate turns the -template value into a config file path. an existing From 648146e8aa6827bc52acdbcb10709a2855faf6f5 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:40:49 -0700 Subject: [PATCH 2/4] docs(config): document -config/-profile and drop the dead toml example template-example.toml was unreferenced by any go file and did not match how goflags actually reads config (flat long-name keys, yaml). replace it with config-example.yaml showing the real schema, including a profiles block, and document -config/-profile in the usage/configuration guides and the man page. --- config-example.yaml | 24 ++++++++++++++++++++++++ docs/configuration.md | 21 +++++++++++++++++++++ docs/usage.md | 4 ++++ man/sif.1 | 6 ++++++ template-example.toml | 10 ---------- 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 config-example.yaml delete mode 100644 template-example.toml diff --git a/config-example.yaml b/config-example.yaml new file mode 100644 index 00000000..53dc7efd --- /dev/null +++ b/config-example.yaml @@ -0,0 +1,24 @@ +# sif config file. keys are flag long-names; top-level values are applied as +# file defaults on every run, the same as ~/.config/sif/config.yaml. +# +# sif manages that ambient path itself (goflags auto-creates it, all keys +# commented out, on first run); this file documents the schema, it is not +# read automatically. pass -config to point sif at a file like this one. + +threads: 20 +timeout: 15s +headers: true + +# profiles are named overlays selected with -profile NAME. a profile's keys +# win over the top-level defaults above; an explicit cli flag still wins over +# both. +profiles: + quick: + probe: true + favicon: true + dirlist: small + deep: + crawl: true + crawl-depth: 3 + sql: true + nuclei: true diff --git a/docs/configuration.md b/docs/configuration.md index 63e60f06..b6d1845d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,6 +63,27 @@ enable verbose logging: sif also reads an ambient config at `~/.config/sif/config.yaml` (created on first run) keyed by the same flag names. passing `-template` uses that template as the config for the run instead of the ambient file. +### config file and profiles + +`-config PATH` points sif at a yaml config file instead of the ambient `~/.config/sif/config.yaml`. its top-level keys are flag long-names, applied as file defaults, the same schema the ambient file uses. see [config-example.yaml](../config-example.yaml). + +`-profile NAME` selects a `profiles.NAME` block from that config file and overlays its keys on top of the top-level defaults before the run starts: + +```yaml +threads: 20 + +profiles: + quick: + probe: true + dirlist: small +``` + +```bash +./sif -u https://example.com -config ./config-example.yaml -profile quick +``` + +precedence, highest wins: an explicit cli flag, then the selected profile, then the file's top-level defaults, then sif's built-in defaults. selecting a profile that does not exist in the file is a hard error listing the profiles that do. `-config` and `-template` are mutually exclusive, since both point sif at the same underlying config slot. + ## user modules place custom modules in: diff --git a/docs/usage.md b/docs/usage.md index be0b7572..ec5f9642 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -405,6 +405,10 @@ threads: 20 flags passed on the command line take precedence over the template, so `--template recon -xss` runs the recon preset with an added xss probe. +### --config, --profile + +`--config PATH` loads flags from a yaml config file instead of the ambient `~/.config/sif/config.yaml`. `--profile NAME` selects a named overlay from that file's `profiles` block. see [configuration](configuration.md#config-file-and-profiles) for the schema and precedence. `-config` and `-template` cannot be combined. + ## http options these apply to every outbound request across all scanners (proxy, custom headers, cookie and rate limiting share one client). a scanner that sets a header explicitly still wins over the global default. diff --git a/man/sif.1 b/man/sif.1 index b364fa8a..8cd6863f 100644 --- a/man/sif.1 +++ b/man/sif.1 @@ -177,6 +177,12 @@ number of concurrent workers (default 10). values below 1 are clamped to 1. .BR \-\-template " \fIname\fR" sif runtime template to use. .TP +.BR \-config " \fIpath\fR" +load flags from a yaml config file (default ~/.config/sif/config.yaml). mutually exclusive with \-\-template. +.TP +.BR \-profile " \fIname\fR" +select a named profile block from the config file. +.TP .BR \-proxy " \fIurl\fR" route every request through a proxy. accepts http, https or socks5 urls. .TP diff --git a/template-example.toml b/template-example.toml deleted file mode 100644 index 61b8a551..00000000 --- a/template-example.toml +++ /dev/null @@ -1,10 +0,0 @@ -[scans] -scans = [ - "whois", - "dork", - "dirlist common" -] - -[config] -logging = true -threads = 10 \ No newline at end of file From c9b495ce58868794148fdb7c6dc39b454dff4118 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:34:43 -0700 Subject: [PATCH 3/4] fix(config): validate malformed config on the no-profile path resolveConfigInput used to return an explicit -config path unparsed when -profile was not set, so a malformed yaml file skipped validation entirely. goflags then silently discarded the decode error, dropping every real setting in the file with no diagnostic and exit 0, while the same file with -profile already errored cleanly through loadConfigMap. route both branches through one buildFlatConfig that always loads via loadConfigMap and only overlays a profile when one is selected, so a malformed file errors the same way regardless of -profile. --- internal/config/configfile.go | 50 +++++++++++++++--------------- internal/config/configfile_test.go | 43 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/internal/config/configfile.go b/internal/config/configfile.go index 86cbf420..734eeaa3 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -51,10 +51,7 @@ func resolveConfigInput(args []string) (string, func(), error) { return "", nil, fmt.Errorf("config file %q is a directory", path) } - if prof == "" { - return path, nil, nil - } - return buildProfileConfig(path, prof) + return buildFlatConfig(path, prof) } // rawFlagValue pulls a -name value out of raw args before Parse (space and = @@ -89,35 +86,38 @@ func defaultConfigFilePath() string { return filepath.Join(folderutil.AppConfigDirOrDefault(".", toolName), "config.yaml") } -// buildProfileConfig reads the config file at path, overlays profiles[profile] -// onto the top-level keys, and writes the merged flat map to a temp yaml file -// for goflags to merge (map overlay guarantees the profile wins over the -// file's top-level defaults). -func buildProfileConfig(path, profile string) (string, func(), error) { +// buildFlatConfig reads the config file at path through loadConfigMap (so a +// malformed file always errors the same way, whether or not -profile is set), +// optionally overlays profiles[profile] onto the top-level keys, and writes +// the result to a temp yaml file for goflags to merge. +func buildFlatConfig(path, profile string) (string, func(), error) { top, profiles, err := loadConfigMap(path) if err != nil { return "", nil, err } - overlay, ok := profiles[profile] - if !ok { - names := make([]string, 0, len(profiles)) - for name := range profiles { - names = append(names, name) - } - sort.Strings(names) - available := "none" - if len(names) > 0 { - available = strings.Join(names, ", ") - } - return "", nil, fmt.Errorf("unknown profile %q; available profiles: %s", profile, available) - } - merged := make(map[string]any, len(top)+len(overlay)) + merged := make(map[string]any, len(top)) for k, v := range top { merged[k] = v } - for k, v := range overlay { - merged[k] = v + + if profile != "" { + overlay, ok := profiles[profile] + if !ok { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + sort.Strings(names) + available := "none" + if len(names) > 0 { + available = strings.Join(names, ", ") + } + return "", nil, fmt.Errorf("unknown profile %q; available profiles: %s", profile, available) + } + for k, v := range overlay { + merged[k] = v + } } data, err := yaml.Marshal(merged) diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go index c488b63f..32e2cfcb 100644 --- a/internal/config/configfile_test.go +++ b/internal/config/configfile_test.go @@ -234,6 +234,49 @@ func TestMissingExplicitConfigFileErrors(t *testing.T) { } } +// malformed yaml in an explicit -config file must be a hard error, matching +// the error the -profile branch already produces (its overlay logic routes +// through loadConfigMap, which validates). without -profile, resolveConfigInput +// used to return the raw path unparsed, skipping validation entirely; goflags +// then silently discarded the decode error, dropping every real setting in +// the file with no diagnostic and exit 0. +func TestMalformedConfigNoProfileErrors(t *testing.T) { + path := writeConfigFile(t, "timeout: \"unterminated\n") + + _, _, err := resolveConfigInput([]string{"-config", path}) + if err == nil { + t.Fatal("expected an error for malformed yaml with no -profile") + } + if !strings.Contains(err.Error(), "is not valid yaml") { + t.Errorf("expected a %q error, got: %s", "is not valid yaml", err) + } +} + +// same malformed file, this time with -profile set, to confirm both branches +// now share one error message. +func TestMalformedConfigWithProfileErrors(t *testing.T) { + path := writeConfigFile(t, "timeout: \"unterminated\n") + + _, _, err := resolveConfigInput([]string{"-config", path, "-profile", "quick"}) + if err == nil { + t.Fatal("expected an error for malformed yaml with -profile") + } + if !strings.Contains(err.Error(), "is not valid yaml") { + t.Errorf("expected a %q error, got: %s", "is not valid yaml", err) + } +} + +// a well-formed -config file with no -profile must still apply normally; +// routing it through the shared validator must not change this. +func TestWellFormedConfigNoProfileStillApplies(t *testing.T) { + path := writeConfigFile(t, "threads: 25\n") + settings := parseWith(t, "-u", "x", "-config", path) + + if settings.Threads != 25 { + t.Errorf("expected well-formed no-profile config threads=25, got %d", settings.Threads) + } +} + func TestRawFlagValue(t *testing.T) { cases := []struct { name string From 0e673cb03b12687a40b282fe7db10c9c101f8da5 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:36:08 -0700 Subject: [PATCH 4/4] fix(config): let explicit cli flags override config and profile values goflags' own merge (readConfigFile) treats a flag whose current value equals its DefValue as "unset" and applies the config value over it. that makes an explicit cli flag silently lose to the config file or a profile whenever the user happens to pass the flag's own default, e.g. "-timeout 10s" against the built-in 10s default: today the file's 1s wins even though the flag was set explicitly on the command line. the same class of bug hits -threads, -concurrency, -notify-severity, and any profile value on the same merge path. scan the raw args for every flag the user actually passed (long or short alias, space or "=" form) and strip those keys out of the resolved config/ profile map before it ever reaches goflags, so cli precedence holds unconditionally instead of only when the value differs from the default. flagAliasGroups derives the long/short alias groups from the real flag registration (grouping by the shared flag.Value pointer) rather than a second hardcoded name table, so it can't drift from registerFlags. goflags also swallows a type-mismatched config value entirely (discards fl.Value.Set's error); that is a separate, lower-severity gap in the vendored dependency itself and is left alone, noted in a comment. --- internal/config/configfile.go | 85 ++++++++++++++++++++++++++++-- internal/config/configfile_test.go | 43 +++++++++++++-- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/internal/config/configfile.go b/internal/config/configfile.go index 734eeaa3..67181de4 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -13,6 +13,7 @@ package config import ( + "flag" "fmt" "os" "path/filepath" @@ -51,7 +52,7 @@ func resolveConfigInput(args []string) (string, func(), error) { return "", nil, fmt.Errorf("config file %q is a directory", path) } - return buildFlatConfig(path, prof) + return buildFlatConfig(path, prof, args) } // rawFlagValue pulls a -name value out of raw args before Parse (space and = @@ -88,9 +89,22 @@ func defaultConfigFilePath() string { // buildFlatConfig reads the config file at path through loadConfigMap (so a // malformed file always errors the same way, whether or not -profile is set), -// optionally overlays profiles[profile] onto the top-level keys, and writes -// the result to a temp yaml file for goflags to merge. -func buildFlatConfig(path, profile string) (string, func(), error) { +// optionally overlays profiles[profile] onto the top-level keys, strips any +// key the command line set explicitly, and writes what's left to a temp yaml +// file for goflags to merge. +// +// the strip step exists because goflags' own merge (readConfigFile) treats a +// flag whose current value equals its DefValue as "unset" and applies the +// config value over it; that makes an explicit cli flag lose to the config +// file whenever the user happens to pass the flag's own default (e.g. +// "-timeout 10s" against the built-in 10s default). deleting those keys here, +// before the map ever reaches goflags, makes cli precedence unconditional. +// +// note: goflags' merge also swallows a type-mismatched config value (e.g. a +// string where an int flag expects one) because readConfigFile discards +// fl.Value.Set's error. that is a separate, lower-severity gap in the vendored +// dependency itself and is not addressed here. +func buildFlatConfig(path, profile string, args []string) (string, func(), error) { top, profiles, err := loadConfigMap(path) if err != nil { return "", nil, err @@ -120,6 +134,10 @@ func buildFlatConfig(path, profile string) (string, func(), error) { } } + for key := range explicitConfigKeys(args) { + delete(merged, key) + } + data, err := yaml.Marshal(merged) if err != nil { return "", nil, err @@ -127,6 +145,65 @@ func buildFlatConfig(path, profile string) (string, func(), error) { return materializePreset(data) } +// explicitConfigKeys returns every config key that the command line set +// explicitly (by long name or short alias), so buildFlatConfig can drop it +// from the file/profile map regardless of what value the flag was given. +func explicitConfigKeys(args []string) map[string]bool { + groups := flagAliasGroups() + keys := map[string]bool{} + for token := range explicitFlagTokens(args) { + for _, alias := range groups[token] { + keys[alias] = true + } + } + return keys +} + +// explicitFlagTokens pulls the bare name out of every "-name"/"--name"/ +// "-name=value"/"--name=value" token in args. it is a presence check only +// (not a value parse), so it does not need to know which flags take a +// following argument. +func explicitFlagTokens(args []string) map[string]bool { + tokens := map[string]bool{} + for _, arg := range args { + if !strings.HasPrefix(arg, "-") { + continue + } + name := strings.TrimLeft(arg, "-") + if name == "" { + continue + } + if i := strings.IndexByte(name, '='); i >= 0 { + name = name[:i] + } + tokens[name] = true + } + return tokens +} + +// flagAliasGroups maps every registered flag name to the full set of names +// (long and short) that back the same Settings field, keyed by the shared +// flag.Value each alias registers against a throwaway FlagSet. registering +// flags has no side effects (no parsing, no file i/o), so building this on +// every call stays cheap and keeps it in sync with registerFlags by +// construction rather than a second hardcoded name table. +func flagAliasGroups() map[string][]string { + flagSet := registerFlags(&Settings{}) + + byValue := map[flag.Value][]string{} + flagSet.CommandLine.VisitAll(func(f *flag.Flag) { + byValue[f.Value] = append(byValue[f.Value], f.Name) + }) + + groups := make(map[string][]string, len(byValue)) + for _, names := range byValue { + for _, name := range names { + groups[name] = names + } + } + return groups +} + // loadConfigMap decodes a config file into its top-level keys and its // profiles submap. a missing file (the ambient default that has never been // written) yields empty maps rather than an error, so -profile against it diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go index 32e2cfcb..69cbb7f8 100644 --- a/internal/config/configfile_test.go +++ b/internal/config/configfile_test.go @@ -17,6 +17,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/projectdiscovery/goflags" ) @@ -141,9 +142,6 @@ func TestConfigFileOverridesDefault(t *testing.T) { } // an explicit cli flag beats the file, mirroring TestTemplateConfigPrecedence. -// caveat (pre-existing goflags property, not introduced here): a cli flag -// explicitly set to its own default is indistinguishable from unset, so a -// file value for that flag would still win in that edge case. func TestExplicitFlagBeatsFile(t *testing.T) { path := writeConfigFile(t, "threads: 20\n") settings := parseWith(t, "-u", "x", "-config", path, "-threads", "5") @@ -153,6 +151,30 @@ func TestExplicitFlagBeatsFile(t *testing.T) { } } +// an explicit cli flag must beat the file even when its value happens to +// equal the flag's own registered default: goflags treats "flag == DefValue" +// as "unset" (readConfigFile), so without stripping explicit keys out of the +// config map first, the file value would silently win here. +func TestExplicitFlagAtDefaultBeatsFile(t *testing.T) { + path := writeConfigFile(t, "timeout: 1s\n") + settings := parseWith(t, "-u", "x", "-config", path, "-timeout", "10s") + + if settings.Timeout != 10*time.Second { + t.Errorf("expected explicit cli timeout=10s to beat file's 1s, got %s", settings.Timeout) + } +} + +// the same default-value precedence bug via the flag's short alias: a config +// keyed by the long name must not survive an explicit "-t 10s" on the cli. +func TestExplicitShortFlagAtDefaultBeatsFile(t *testing.T) { + path := writeConfigFile(t, "timeout: 1s\n") + settings := parseWith(t, "-u", "x", "-config", path, "-t", "10s") + + if settings.Timeout != 10*time.Second { + t.Errorf("expected explicit cli -t 10s to beat file's 1s, got %s", settings.Timeout) + } +} + // a profile overlays on top of the file's top-level keys, and also exercises // an enum (dirlist) and a StringSlice (header) round-tripping through the // yaml.Marshal + goflags merge. @@ -198,6 +220,21 @@ func TestCLIBeatsProfile(t *testing.T) { } } +// same default-value precedence bug as TestExplicitFlagAtDefaultBeatsFile, +// but through a profile overlay instead of the file's top level. +func TestExplicitFlagAtDefaultBeatsProfile(t *testing.T) { + path := writeConfigFile(t, ""+ + "profiles:\n"+ + " quick:\n"+ + " timeout: 1s\n") + + settings := parseWith(t, "-u", "x", "-config", path, "-profile", "quick", "-timeout", "10s") + + if settings.Timeout != 10*time.Second { + t.Errorf("expected explicit cli timeout=10s to beat profile's 1s, got %s", settings.Timeout) + } +} + // selecting an unknown profile is a hard error listing the profiles that do // exist, mirroring TestResolveTemplateUnknownName. func TestUnknownProfileErrors(t *testing.T) {