Repository: projectdiscovery/nuclei

Issue:
[BUG] Nuclei always reports that nuclei-templates are out of date, even when they are not.
### Is there an existing issue for this?

- [x] I have searched the existing issues.

### Current Behavior

Whenever nuclei is run, a notice appears like this: `[INF] Your current nuclei-templates v10.2.7 are outdated. Latest is v10.2.7`
this is hopefully obviously nonsensical

### Expected Behavior

This notice does not appear as it is incorrect.

### Steps To Reproduce

1. Run any `nuclei` command (without any arguments works, does not occur if `-duc` is passed) after having recently updated nuclei-templates

### Relevant log output

```shell
nuclei #macos output

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.4.7

		projectdiscovery.io

[INF] Your current nuclei-templates v10.2.7 are outdated. Latest is v10.2.7
[INF] Successfully updated nuclei-templates (v10.2.7) to /Users/smorrow/nuclei-templates. GoodLuck!

Nuclei Templates v10.2.7 Changelog
+-------+-------+----------+---------+
| TOTAL | ADDED | MODIFIED | REMOVED |
+-------+-------+----------+---------+
|     2 |     0 |        2 |       0 |
+-------+-------+----------+---------+
[WRN] Found 1 templates with syntax error (use -validate flag for further examination)
[INF] Current nuclei version: v3.4.7 (outdated)
[INF] Current nuclei-templates version: v10.2.7 (outdated)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 55
[INF] Templates loaded for current scan: 8269
[INF] Executing 8066 signed templates from projectdiscovery/nuclei-templates
[WRN] Loading 203 unsigned templates for scan. Use with caution.
[INF] Scan completed in 41.598417ms. No results found.

nuclei #alpine output

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.4.7

		projectdiscovery.io

[INF] Your current nuclei-templates v10.2.7 are outdated. Latest is v10.2.7
[INF] Successfully updated nuclei-templates (v10.2.7) to /root/nuclei-templates. GoodLuck!

Nuclei Templates v10.2.7 Changelog
+-------+-------+----------+---------+
| TOTAL | ADDED | MODIFIED | REMOVED |
+-------+-------+----------+---------+
|     2 |     0 |        2 |       0 |
+-------+-------+----------+---------+
[WRN] Found 1 templates with syntax error (use -validate flag for further examination)
[WRN] Found 9 templates with runtime error (use -validate flag for further examination)
[INF] Current nuclei version: v3.4.7 (outdated)
[INF] Current nuclei-templates version: v10.2.7 (outdated)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 55
[INF] Templates loaded for current scan: 8263
[INF] Executing 8060 signed templates from projectdiscovery/nuclei-templates
[WRN] Loading 203 unsigned templates for scan. Use with caution.
[INF] Scan completed in 46.858006ms. No results found.
```

### Environment

```markdown
- OS: MacOS 15.3.1 (via homebrew) AND Alpine v3.22 (docker)
- Nuclei: v3.4.7
- Go: 1.24.6 (Alpine Linux Docker)
```

### Anything else?

Poking through the code, it seems like [isOutdatedVersion](https://github.com/projectdiscovery/nuclei/blob/8ef3662634f64a3d01316dcc5848bcffe2ccb6bc/pkg/catalog/config/constants.go#L47) is being called, but is getting an empty string for the latest version. The version originates in [nuclei_config.go](https://github.com/projectdiscovery/nuclei/blob/8ef3662634f64a3d01316dcc5848bcffe2ccb6bc/pkg/catalog/config/nucleiconfig.go#L283) which is pulling it from the template config directory? This doesn't make much sense to me since that template config file is updated with the templates, so it should *never* have a version there anyway, right? If there's an updated version we downloaded, there should be no newer "updated" version to get. 

I'm not exactly sure what the ideal way to fix this would be, but the implementation as it is now doesn't make sense and leads to added cost for users who are running this on cloud compute and are billed by time used (where the cost of having to wait for nuclei to download new templates costs real money.) Passing in `-duc` and instead relying on the container build to update community templates seems like the best approach for now.

Hints from issue comments:

Solution patch:
diff --git a/Makefile b/Makefile
index 0c3ab083..1b089e76 100644
--- a/Makefile
+++ b/Makefile
@@ -146,5 +146,14 @@ dsl-docs:
 template-validate: build
 template-validate:
 	./bin/nuclei -ut
-	./bin/nuclei -validate -et http/technologies
-	./bin/nuclei -validate -w workflows -et http/technologies
\ No newline at end of file
+	./bin/nuclei -validate \
+		-et .github/ \
+		-et helpers/payloads/ \
+		-et http/technologies \
+		-ept code
+	./bin/nuclei -validate \
+		-w workflows \
+		-et .github/ \
+		-et helpers/payloads/ \
+		-et http/technologies \
+		-ept code
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 40050393..71ab9060 100644
--- a/go.mod
+++ b/go.mod
@@ -191,7 +191,7 @@ require (
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/docker/cli v27.4.1+incompatible // indirect
-	github.com/docker/docker v27.1.1+incompatible // indirect
+	github.com/docker/docker v28.0.0+incompatible // indirect
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
@@ -213,7 +213,7 @@ require (
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
-	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+	github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
 	github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
diff --git a/go.sum b/go.sum
index 83ee118a..a8e3dcb9 100644
--- a/go.sum
+++ b/go.sum
@@ -297,8 +297,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
 github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
-github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v28.0.0+incompatible h1:Olh0KS820sJ7nPsBKChVhk5pzqcwDR15fumfAd/p9hM=
+github.com/docker/docker v28.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
 github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -397,8 +397,8 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
 github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
-github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
-github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
+github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/goburrow/cache v0.1.4 h1:As4KzO3hgmzPlnaMniZU9+VmoNYseUhuELbxy9mRBfw=
 github.com/goburrow/cache v0.1.4/go.mod h1:cDFesZDnIlrHoNlMYqqMpCRawuXulgx+y7mXU8HZ+/c=
 github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
diff --git a/pkg/catalog/config/constants.go b/pkg/catalog/config/constants.go
index 15d8e9da..a9de026d 100644
--- a/pkg/catalog/config/constants.go
+++ b/pkg/catalog/config/constants.go
@@ -31,7 +31,7 @@ const (
 	CLIConfigFileName               = "config.yaml"
 	ReportingConfigFilename         = "reporting-config.yaml"
 	// Version is the current version of nuclei
-	Version = `v3.4.7`
+	Version = `v3.4.8`
 	// Directory Names of custom templates
 	CustomS3TemplatesDirName     = "s3"
 	CustomGitHubTemplatesDirName = "github"
@@ -46,18 +46,21 @@ const (
 // if the current version is outdated
 func IsOutdatedVersion(current, latest string) bool {
 	if latest == "" {
-		// if pdtm api call failed it's assumed that the current version is outdated
-		// and it will be confirmed while updating from GitHub
-		// this fixes `version string empty` errors
-		return true
+		// NOTE(dwisiswant0): if PDTM API call failed or returned empty, we
+		// cannot determine if templates are outdated w/o additional checks
+		// return false to avoid unnecessary updates.
+		return false
 	}
+
 	current = trimDevIfExists(current)
 	currentVer, _ := semver.NewVersion(current)
 	newVer, _ := semver.NewVersion(latest)
+
 	if currentVer == nil || newVer == nil {
-		// fallback to naive comparison
-		return current == latest
+		// fallback to naive comparison - return true only if they are different
+		return current != latest
 	}
+
 	return newVer.GreaterThan(currentVer)
 }
 
diff --git a/pkg/installer/template.go b/pkg/installer/template.go
index 9e56f12a..d9f3d5ae 100644
--- a/pkg/installer/template.go
+++ b/pkg/installer/template.go
@@ -94,7 +94,24 @@ func (t *TemplateManager) UpdateIfOutdated() error {
 	if !fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
 		return t.FreshInstallIfNotExists()
 	}
-	if config.DefaultConfig.NeedsTemplateUpdate() {
+
+	needsUpdate := config.DefaultConfig.NeedsTemplateUpdate()
+
+	// NOTE(dwisiswant0): if PDTM API data is not available
+	// (LatestNucleiTemplatesVersion is empty) but we have a current template
+	// version, so we MUST verify against GitHub directly.
+	if !needsUpdate && config.DefaultConfig.LatestNucleiTemplatesVersion == "" && config.DefaultConfig.TemplateVersion != "" {
+		ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
+		if err == nil {
+			latestVersion := ghrd.Latest.GetTagName()
+			if config.IsOutdatedVersion(config.DefaultConfig.TemplateVersion, latestVersion) {
+				needsUpdate = true
+				gologger.Debug().Msgf("PDTM API unavailable, verified update needed via GitHub API: %s -> %s", config.DefaultConfig.TemplateVersion, latestVersion)
+			}
+		}
+	}
+
+	if needsUpdate {
 		return t.updateTemplatesAt(config.DefaultConfig.TemplatesDirectory)
 	}
 	return nil
@@ -142,7 +159,14 @@ func (t *TemplateManager) updateTemplatesAt(dir string) error {
 		return errorutil.NewWithErr(err).Msgf("failed to install templates at %s", dir)
 	}
 
-	gologger.Info().Msgf("Your current nuclei-templates %s are outdated. Latest is %s\n", config.DefaultConfig.TemplateVersion, ghrd.Latest.GetTagName())
+	latestVersion := ghrd.Latest.GetTagName()
+	currentVersion := config.DefaultConfig.TemplateVersion
+
+	if config.IsOutdatedVersion(currentVersion, latestVersion) {
+		gologger.Info().Msgf("Your current nuclei-templates %s are outdated. Latest is %s\n", currentVersion, latestVersion)
+	} else {
+		gologger.Debug().Msgf("Updating nuclei-templates from %s to %s (forced update)\n", currentVersion, latestVersion)
+	}
 
 	// write templates to disk
 	if err := t.writeTemplatesToDisk(ghrd, dir); err != nil {
diff --git a/pkg/protocols/headless/engine/engine.go b/pkg/protocols/headless/engine/engine.go
index 63dbb41e..99885023 100644
--- a/pkg/protocols/headless/engine/engine.go
+++ b/pkg/protocols/headless/engine/engine.go
@@ -20,12 +20,13 @@ import (
 
 // Browser is a browser structure for nuclei headless module
 type Browser struct {
-	customAgent  string
-	tempDir      string
-	previousPIDs map[int32]struct{} // track already running PIDs
-	engine       *rod.Browser
-	options      *types.Options
-	launcher     *launcher.Launcher
+	customAgent    string
+	defaultHeaders map[string]string
+	tempDir        string
+	previousPIDs   map[int32]struct{} // track already running PIDs
+	engine         *rod.Browser
+	options        *types.Options
+	launcher       *launcher.Launcher
 
 	// use getHTTPClient to get the http client
 	httpClient     *http.Client
@@ -95,6 +96,7 @@ func New(options *types.Options) (*Browser, error) {
 	if browserErr := browser.Connect(); browserErr != nil {
 		return nil, browserErr
 	}
+	defaultHeaders := make(map[string]string)
 	customAgent := ""
 	for _, option := range options.CustomHeaders {
 		parts := strings.SplitN(option, ":", 2)
@@ -103,12 +105,20 @@ func New(options *types.Options) (*Browser, error) {
 		}
 		if strings.EqualFold(parts[0], "User-Agent") {
 			customAgent = parts[1]
+		} else {
+			k := strings.TrimSpace(parts[0])
+			v := strings.TrimSpace(parts[1])
+			if k == "" || v == "" {
+				continue
+			}
+			defaultHeaders[k] = v
 		}
 	}
 
 	engine := &Browser{
 		tempDir:        dataStore,
 		customAgent:    customAgent,
+		defaultHeaders: defaultHeaders,
 		engine:         browser,
 		options:        options,
 		httpClientOnce: &sync.Once{},
@@ -135,6 +145,30 @@ func (b *Browser) UserAgent() string {
 	return b.customAgent
 }
 
+// applyDefaultHeaders setsheaders passed via cli -H flag
+func (b *Browser) applyDefaultHeaders(p *rod.Page) error {
+	pairs := make([]string, 0, len(b.defaultHeaders)*2+2)
+
+	hasAcceptLanguage := false
+	for k := range b.defaultHeaders {
+		if strings.EqualFold(k, "Accept-Language") {
+			hasAcceptLanguage = true
+			break
+		}
+	}
+	if !hasAcceptLanguage {
+		pairs = append(pairs, "Accept-Language", "en, en-GB, en-us;")
+	}
+	for k, v := range b.defaultHeaders {
+		pairs = append(pairs, k, v)
+	}
+	if len(pairs) == 0 {
+		return nil
+	}
+	_, err := p.SetExtraHeaders(pairs)
+	return err
+}
+
 func (b *Browser) getHTTPClient() (*http.Client, error) {
 	var err error
 	b.httpClientOnce.Do(func() {
diff --git a/pkg/protocols/headless/engine/page.go b/pkg/protocols/headless/engine/page.go
index 6986f80b..244d740c 100644
--- a/pkg/protocols/headless/engine/page.go
+++ b/pkg/protocols/headless/engine/page.go
@@ -61,6 +61,10 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
 	}
 	page = page.Timeout(options.Timeout)
 
+	if err = i.browser.applyDefaultHeaders(page); err != nil {
+		return nil, nil, err
+	}
+
 	if i.browser.customAgent != "" {
 		if userAgentErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: i.browser.customAgent}); userAgentErr != nil {
 			return nil, nil, userAgentErr
@@ -130,10 +134,6 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
 		return nil, nil, err
 	}
 
-	if _, err := page.SetExtraHeaders([]string{"Accept-Language", "en, en-GB, en-us;"}); err != nil {
-		return nil, nil, err
-	}
-
 	// inject cookies
 	// each http request is performed via the native go http client
 	// we first inject the shared cookies



Test patch:
diff --git a/pkg/installer/template_test.go b/pkg/installer/template_test.go
index 952c4459..435797b1 100644
--- a/pkg/installer/template_test.go
+++ b/pkg/installer/template_test.go
@@ -59,3 +59,42 @@ func TestTemplateInstallation(t *testing.T) {
 	require.FileExists(t, config.DefaultConfig.GetIgnoreFilePath())
 	t.Logf("Installed %d templates", counter)
 }
+
+func TestIsOutdatedVersion(t *testing.T) {
+	testCases := []struct {
+		current  string
+		latest   string
+		expected bool
+		desc     string
+	}{
+		// Test the empty latest version case (main bug fix)
+		{"v10.2.7", "", false, "Empty latest version should not trigger update"},
+
+		// Test same versions
+		{"v10.2.7", "v10.2.7", false, "Same versions should not trigger update"},
+
+		// Test outdated version
+		{"v10.2.6", "v10.2.7", true, "Older version should trigger update"},
+
+		// Test newer current version (edge case)
+		{"v10.2.8", "v10.2.7", false, "Newer current version should not trigger update"},
+
+		// Test dev versions
+		{"v10.2.7-dev", "v10.2.7", false, "Dev version matching release should not trigger update"},
+		{"v10.2.6-dev", "v10.2.7", true, "Outdated dev version should trigger update"},
+
+		// Test invalid semver fallback
+		{"invalid-version", "v10.2.7", true, "Invalid current version should trigger update (fallback)"},
+		{"v10.2.7", "invalid-version", true, "Invalid latest version should trigger update (fallback)"},
+		{"same-invalid", "same-invalid", false, "Same invalid versions should not trigger update (fallback)"},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			result := config.IsOutdatedVersion(tc.current, tc.latest)
+			require.Equal(t, tc.expected, result,
+				"IsOutdatedVersion(%q, %q) = %t, expected %t",
+				tc.current, tc.latest, result, tc.expected)
+		})
+	}
+}