Skip to content

Commit 5e8ef27

Browse files
add inline targets and secrets to template profiless (projectdiscovery#5567) (projectdiscovery#6858)
* feat: template profile inline targets and secrets (projectdiscovery#5567) * fix inline secrets temp file edge cases * fixing lint --------- Co-authored-by: Mzack9999 <mzack9999@protonmail.com>
1 parent 0ae6d90 commit 5e8ef27

File tree

3 files changed

+356
-4
lines changed

3 files changed

+356
-4
lines changed

cmd/nuclei/main.go

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,22 @@ import (
4747
)
4848

4949
var (
50-
cfgFile string
51-
templateProfile string
52-
memProfile string // optional profile file path
53-
options = &types.Options{}
50+
cfgFile string
51+
templateProfile string
52+
memProfile string // optional profile file path
53+
options = &types.Options{}
54+
inlineSecretsTempFiles []string
5455
)
5556

5657
func main() {
5758
options.Logger = gologger.DefaultLogger
5859

60+
defer func() {
61+
for _, f := range inlineSecretsTempFiles {
62+
_ = os.Remove(f)
63+
}
64+
}()
65+
5966
// enables CLI specific configs mostly interactive behavior
6067
config.CurrentAppMode = config.AppModeCLI
6168

@@ -221,6 +228,9 @@ func main() {
221228
options.Logger.Error().Msgf("Couldn't create resume file: %s\n", err)
222229
}
223230
}
231+
for _, f := range inlineSecretsTempFiles {
232+
_ = os.Remove(f)
233+
}
224234
os.Exit(1)
225235
}()
226236

@@ -257,6 +267,7 @@ on extensive configurability, massive extensibility and ease of use.`)
257267
flagSet.CreateGroup("input", "Target",
258268
flagSet.StringSliceVarP(&options.Targets, "target", "u", nil, "target URLs/hosts to scan", goflags.CommaSeparatedStringSliceOptions),
259269
flagSet.StringVarP(&options.TargetsFilePath, "list", "l", "", "path to file containing a list of target URLs/hosts to scan (one per line)"),
270+
flagSet.StringVarP(&options.InlineTargetsList, "targets-inline", "", "", "inline multiline target list (for use in template profiles)"),
260271
flagSet.StringSliceVarP(&options.ExcludeTargets, "exclude-hosts", "eh", nil, "hosts to exclude to scan from the input list (ip, cidr, hostname)", goflags.FileCommaSeparatedStringSliceOptions),
261272
flagSet.StringVar(&options.Resume, "resume", "", "resume scan from and save to specified file (clustering will be disabled)"),
262273
flagSet.BoolVarP(&options.ScanAllIPs, "scan-all-ips", "sa", false, "scan all the IP's associated with dns record"),
@@ -660,6 +671,39 @@ Additional documentation is available at: https://docs.nuclei.sh/getting-started
660671
if err := flagSet.MergeConfigFile(templateProfile); err != nil {
661672
options.Logger.Fatal().Msgf("Could not read template profile: %s\n", err)
662673
}
674+
675+
// Process inline target list from profile.
676+
// Supports both the dedicated targets-inline key and multiline
677+
// content in the list key (which normally holds a file path).
678+
if options.InlineTargetsList != "" {
679+
inlineTargets := strings.Split(strings.TrimSpace(options.InlineTargetsList), "\n")
680+
for _, target := range inlineTargets {
681+
target = strings.TrimSpace(target)
682+
if target != "" && !strings.HasPrefix(target, "#") {
683+
options.Targets = append(options.Targets, target)
684+
}
685+
}
686+
}
687+
if strings.Contains(options.TargetsFilePath, "\n") {
688+
// list key has multiline content, treat as inline targets
689+
inlineTargets := strings.Split(strings.TrimSpace(options.TargetsFilePath), "\n")
690+
for _, target := range inlineTargets {
691+
target = strings.TrimSpace(target)
692+
if target != "" && !strings.HasPrefix(target, "#") {
693+
options.Targets = append(options.Targets, target)
694+
}
695+
}
696+
options.TargetsFilePath = ""
697+
}
698+
699+
// Process inline secrets from profile YAML
700+
tempSecretsFile, err := processInlineSecretsFromProfile(templateProfile, options)
701+
if err != nil {
702+
options.Logger.Fatal().Msgf("Could not process inline secrets: %s\n", err)
703+
}
704+
if tempSecretsFile != "" {
705+
inlineSecretsTempFiles = append(inlineSecretsTempFiles, tempSecretsFile)
706+
}
663707
}
664708

665709
if len(options.SecretsFile) > 0 {
@@ -812,3 +856,54 @@ func findProfilePathById(profileId, templatesDir string) string {
812856
}
813857
return profilePath
814858
}
859+
860+
// profileSecrets is a helper struct to extract secrets section from a template profile YAML
861+
type profileSecrets struct {
862+
Secrets interface{} `yaml:"secrets"`
863+
}
864+
865+
// processInlineSecretsFromProfile parses the profile YAML file for inline secrets
866+
// and creates a temporary secrets file compatible with nuclei's auth provider.
867+
// Returns the path to the temp file or empty string if no secrets found.
868+
func processInlineSecretsFromProfile(profilePath string, options *types.Options) (string, error) {
869+
data, err := os.ReadFile(profilePath)
870+
if err != nil {
871+
return "", fmt.Errorf("could not read profile file: %w", err)
872+
}
873+
874+
var profile profileSecrets
875+
if err := yaml.Unmarshal(data, &profile); err != nil {
876+
return "", fmt.Errorf("could not parse profile YAML: %w", err)
877+
}
878+
879+
if profile.Secrets == nil {
880+
return "", nil
881+
}
882+
883+
secretsData, err := yaml.Marshal(profile.Secrets)
884+
if err != nil {
885+
return "", fmt.Errorf("could not marshal inline secrets: %w", err)
886+
}
887+
888+
tempDir := filepath.Join(os.TempDir(), "nuclei-secrets")
889+
if err := os.MkdirAll(tempDir, 0700); err != nil {
890+
return "", fmt.Errorf("could not create temp directory: %w", err)
891+
}
892+
893+
tempFile, err := os.CreateTemp(tempDir, "inline-secrets-*.yaml")
894+
if err != nil {
895+
return "", fmt.Errorf("could not create temp secrets file: %w", err)
896+
}
897+
defer func() {
898+
_ = tempFile.Close()
899+
}()
900+
901+
if _, err := tempFile.Write(secretsData); err != nil {
902+
_ = tempFile.Close()
903+
_ = os.Remove(tempFile.Name())
904+
return "", fmt.Errorf("could not write to temp secrets file: %w", err)
905+
}
906+
907+
options.SecretsFile = append(options.SecretsFile, tempFile.Name())
908+
return tempFile.Name(), nil
909+
}

cmd/nuclei/profile_test.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/projectdiscovery/nuclei/v3/pkg/types"
10+
)
11+
12+
func TestProcessInlineSecretsFromProfile(t *testing.T) {
13+
t.Run("profile with inline secrets", func(t *testing.T) {
14+
profileContent := `
15+
secrets:
16+
static:
17+
- type: header
18+
domains:
19+
- api.example.com
20+
headers:
21+
- key: x-api-key
22+
value: test-key-123
23+
`
24+
tmpFile, err := os.CreateTemp(t.TempDir(), "test-profile-*.yaml")
25+
if err != nil {
26+
t.Fatalf("could not create temp file: %v", err)
27+
}
28+
if _, err := tmpFile.WriteString(profileContent); err != nil {
29+
t.Fatalf("could not write profile: %v", err)
30+
}
31+
_ = tmpFile.Close()
32+
33+
opts := &types.Options{}
34+
tempPath, err := processInlineSecretsFromProfile(tmpFile.Name(), opts)
35+
if err != nil {
36+
t.Fatalf("processInlineSecretsFromProfile failed: %v", err)
37+
}
38+
defer func() {
39+
_ = os.Remove(tempPath)
40+
}()
41+
42+
if len(opts.SecretsFile) != 1 {
43+
t.Fatalf("expected 1 secrets file, got %d", len(opts.SecretsFile))
44+
}
45+
46+
secretsPath := opts.SecretsFile[0]
47+
if !strings.Contains(secretsPath, "inline-secrets-") {
48+
t.Errorf("secrets file path should contain 'inline-secrets-', got %s", secretsPath)
49+
}
50+
51+
data, err := os.ReadFile(secretsPath)
52+
if err != nil {
53+
t.Fatalf("could not read generated secrets file: %v", err)
54+
}
55+
56+
content := string(data)
57+
if !strings.Contains(content, "x-api-key") {
58+
t.Errorf("secrets file should contain header key, got:\n%s", content)
59+
}
60+
if !strings.Contains(content, "test-key-123") {
61+
t.Errorf("secrets file should contain header value, got:\n%s", content)
62+
}
63+
if !strings.Contains(content, "api.example.com") {
64+
t.Errorf("secrets file should contain domain, got:\n%s", content)
65+
}
66+
})
67+
68+
t.Run("profile without secrets", func(t *testing.T) {
69+
profileContent := `
70+
severity:
71+
- critical
72+
- high
73+
`
74+
tmpFile, err := os.CreateTemp(t.TempDir(), "test-profile-*.yaml")
75+
if err != nil {
76+
t.Fatalf("could not create temp file: %v", err)
77+
}
78+
if _, err := tmpFile.WriteString(profileContent); err != nil {
79+
t.Fatalf("could not write profile: %v", err)
80+
}
81+
if err := tmpFile.Close(); err != nil {
82+
t.Fatalf("could not close temp file: %v", err)
83+
}
84+
85+
opts := &types.Options{}
86+
tempPath, err := processInlineSecretsFromProfile(tmpFile.Name(), opts)
87+
if err != nil {
88+
t.Fatalf("processInlineSecretsFromProfile should not fail: %v", err)
89+
}
90+
91+
if tempPath != "" {
92+
t.Errorf("expected empty temp path for profile without secrets, got %s", tempPath)
93+
}
94+
95+
if len(opts.SecretsFile) != 0 {
96+
t.Errorf("expected 0 secrets files for profile without secrets, got %d", len(opts.SecretsFile))
97+
}
98+
})
99+
100+
t.Run("nonexistent profile", func(t *testing.T) {
101+
opts := &types.Options{}
102+
_, err := processInlineSecretsFromProfile(filepath.Join(t.TempDir(), "nonexistent.yaml"), opts)
103+
if err == nil {
104+
t.Error("expected error for nonexistent file")
105+
}
106+
})
107+
}
108+
109+
func TestInlineTargetsParsing(t *testing.T) {
110+
t.Run("multiline list key treated as inline targets", func(t *testing.T) {
111+
opts := &types.Options{
112+
TargetsFilePath: "example.com\ntest.com\nscanme.sh\n",
113+
}
114+
115+
if strings.Contains(opts.TargetsFilePath, "\n") {
116+
inlineTargets := strings.Split(strings.TrimSpace(opts.TargetsFilePath), "\n")
117+
for _, target := range inlineTargets {
118+
target = strings.TrimSpace(target)
119+
if target != "" && !strings.HasPrefix(target, "#") {
120+
opts.Targets = append(opts.Targets, target)
121+
}
122+
}
123+
opts.TargetsFilePath = ""
124+
}
125+
126+
if len(opts.Targets) != 3 {
127+
t.Fatalf("expected 3 targets, got %d: %v", len(opts.Targets), opts.Targets)
128+
}
129+
if opts.Targets[0] != "example.com" {
130+
t.Errorf("expected first target 'example.com', got '%s'", opts.Targets[0])
131+
}
132+
if opts.Targets[1] != "test.com" {
133+
t.Errorf("expected second target 'test.com', got '%s'", opts.Targets[1])
134+
}
135+
if opts.Targets[2] != "scanme.sh" {
136+
t.Errorf("expected third target 'scanme.sh', got '%s'", opts.Targets[2])
137+
}
138+
if opts.TargetsFilePath != "" {
139+
t.Errorf("TargetsFilePath should be cleared, got '%s'", opts.TargetsFilePath)
140+
}
141+
})
142+
143+
t.Run("single line list key remains as file path", func(t *testing.T) {
144+
opts := &types.Options{
145+
TargetsFilePath: "/path/to/targets.txt",
146+
}
147+
148+
if strings.Contains(opts.TargetsFilePath, "\n") {
149+
t.Error("single-line path should not be treated as inline targets")
150+
}
151+
152+
if opts.TargetsFilePath != "/path/to/targets.txt" {
153+
t.Errorf("file path should remain unchanged, got '%s'", opts.TargetsFilePath)
154+
}
155+
})
156+
157+
t.Run("inline targets with comments and blank lines", func(t *testing.T) {
158+
opts := &types.Options{
159+
TargetsFilePath: "example.com\n# this is a comment\n\ntest.com\n",
160+
}
161+
162+
if strings.Contains(opts.TargetsFilePath, "\n") {
163+
inlineTargets := strings.Split(strings.TrimSpace(opts.TargetsFilePath), "\n")
164+
for _, target := range inlineTargets {
165+
target = strings.TrimSpace(target)
166+
if target != "" && !strings.HasPrefix(target, "#") {
167+
opts.Targets = append(opts.Targets, target)
168+
}
169+
}
170+
opts.TargetsFilePath = ""
171+
}
172+
173+
if len(opts.Targets) != 2 {
174+
t.Fatalf("expected 2 targets (comments/blanks filtered), got %d: %v", len(opts.Targets), opts.Targets)
175+
}
176+
})
177+
178+
t.Run("targets-inline key", func(t *testing.T) {
179+
opts := &types.Options{
180+
InlineTargetsList: "host1.com\nhost2.com\nhost3.com",
181+
}
182+
183+
if opts.InlineTargetsList != "" {
184+
inlineTargets := strings.Split(strings.TrimSpace(opts.InlineTargetsList), "\n")
185+
for _, target := range inlineTargets {
186+
target = strings.TrimSpace(target)
187+
if target != "" && !strings.HasPrefix(target, "#") {
188+
opts.Targets = append(opts.Targets, target)
189+
}
190+
}
191+
}
192+
193+
if len(opts.Targets) != 3 {
194+
t.Fatalf("expected 3 targets from targets-inline, got %d", len(opts.Targets))
195+
}
196+
})
197+
}
198+
199+
func TestInlineSecretsFileFormat(t *testing.T) {
200+
// Verify the generated secrets file has the correct YAML structure
201+
// that matches what authx.GetAuthDataFromYAML expects
202+
profileContent := `
203+
secrets:
204+
static:
205+
- type: header
206+
domains:
207+
- api.example.com
208+
headers:
209+
- key: Authorization
210+
value: Bearer test-token
211+
dynamic:
212+
- template: oauth-flow.yaml
213+
variables:
214+
- key: username
215+
value: testuser
216+
type: cookie
217+
domains:
218+
- auth.example.com
219+
`
220+
tmpFile, err := os.CreateTemp(t.TempDir(), "test-secrets-*.yaml")
221+
if err != nil {
222+
t.Fatalf("could not create temp file: %v", err)
223+
}
224+
if _, err := tmpFile.WriteString(profileContent); err != nil {
225+
t.Fatalf("could not write profile: %v", err)
226+
}
227+
if err := tmpFile.Close(); err != nil {
228+
t.Fatalf("could not close temp file: %v", err)
229+
}
230+
231+
opts := &types.Options{}
232+
tempPath, err := processInlineSecretsFromProfile(tmpFile.Name(), opts)
233+
if err != nil {
234+
t.Fatalf("processInlineSecretsFromProfile failed: %v", err)
235+
}
236+
defer func() {
237+
_ = os.Remove(tempPath)
238+
}()
239+
240+
data, err := os.ReadFile(opts.SecretsFile[0])
241+
if err != nil {
242+
t.Fatalf("could not read secrets file: %v", err)
243+
}
244+
245+
content := string(data)
246+
// The secrets section should contain static and dynamic at root level,
247+
// matching the Authx struct yaml tags
248+
if !strings.Contains(content, "static:") {
249+
t.Errorf("secrets file should have 'static:' key for Authx compatibility, got:\n%s", content)
250+
}
251+
if !strings.Contains(content, "dynamic:") {
252+
t.Errorf("secrets file should have 'dynamic:' key for Authx compatibility, got:\n%s", content)
253+
}
254+
}

0 commit comments

Comments
 (0)