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/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..67181de4 --- /dev/null +++ b/internal/config/configfile.go @@ -0,0 +1,245 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package config + +import ( + "flag" + "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) + } + + return buildFlatConfig(path, prof, args) +} + +// 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") +} + +// 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, 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 + } + + merged := make(map[string]any, len(top)) + for k, v := range top { + 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 + } + } + + for key := range explicitConfigKeys(args) { + delete(merged, key) + } + + data, err := yaml.Marshal(merged) + if err != nil { + return "", nil, err + } + 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 +// 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..69cbb7f8 --- /dev/null +++ b/internal/config/configfile_test.go @@ -0,0 +1,350 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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. +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) + } +} + +// 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. +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) + } +} + +// 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) { + 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") + } +} + +// 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 + 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 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