Skip to content
47 changes: 40 additions & 7 deletions internal/runner/preflight_portscan.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (f *filteringInputProvider) SetWithExclusions(executionId string, value str
}
func (f *filteringInputProvider) Iterate(callback func(value *contextargs.MetaInput) bool) {
f.base.Iterate(func(mi *contextargs.MetaInput) bool {
key, err := mi.MarshalString()
key, err := preflightInputKey(mi)
if err != nil {
return callback(mi)
}
Expand All @@ -65,6 +65,29 @@ func (f *filteringInputProvider) Iterate(callback func(value *contextargs.MetaIn
})
}

func preflightInputKey(metaInput *contextargs.MetaInput) (string, error) {
if metaInput.TargetFilter == nil {
// Preserve the exact historical key for all non-JSONL/legacy inputs.
return metaInput.MarshalString()
}

// Marshal the legacy fields without TargetFilter so filter list ordering
// cannot affect the key. MetaInput.ID() contributes TargetFilter's
// canonical, presence-aware identity when used on a filter-only input.
legacyInput := contextargs.NewMetaInput()
legacyInput.Input = metaInput.Input
legacyInput.CustomIP = metaInput.CustomIP
legacyInput.ReqResp = metaInput.ReqResp
legacyKey, err := legacyInput.MarshalString()
if err != nil {
return "", err
}

filterInput := contextargs.NewMetaInput()
filterInput.TargetFilter = metaInput.TargetFilter
return legacyKey + "\x00target-filter:" + filterInput.ID(), nil
}

// preflightResolveAndPortScan resolves hostname targets and performs a TCP connect scan for ports
// required by loaded templates. Targets that are non-resolvable hostnames or have no relevant open
// ports are filtered out from the input provider.
Expand Down Expand Up @@ -97,7 +120,7 @@ func (r *Runner) preflightResolveAndPortScan(store *loader.Store) error {
var totalTargets atomic.Int64
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
totalTargets.Add(1)
key, err := mi.MarshalString()
key, err := preflightInputKey(mi)
if err != nil {
return true
}
Expand Down Expand Up @@ -282,20 +305,20 @@ func (r *Runner) preflightResolveAndPortScan(store *loader.Store) error {
close(stopProgress)

// Apply filtering wrapper
allowedAll := allowed.GetAll()
allowedInputCount := countAllowedPreflightInputs(inputs, allowed)
r.inputProvider = &filteringInputProvider{
base: r.inputProvider,
allowed: allowed,
allowCnt: int64(len(allowedAll)),
allowCnt: allowedInputCount,
execID: r.options.ExecutionId,
}

// Summary
if !r.options.Silent {
dropped := totalTargets.Load() - kept.Load()
dropped := totalTargets.Load() - allowedInputCount
r.Logger.Info().Msgf("Preflight summary: total=%d kept=%d filtered_dns=%d filtered_ports=%d",
totalTargets.Load(), kept.Load(), dnsFail.Load(), portFail.Load())
r.Logger.Info().Msgf("Preflight targets: dropped=%d left=%d", dropped, kept.Load())
totalTargets.Load(), allowedInputCount, dnsFail.Load(), portFail.Load())
r.Logger.Info().Msgf("Preflight targets: dropped=%d left=%d", dropped, allowedInputCount)
perPortOpenAll := perPortOpen.GetAll()
if len(perPortOpenAll) > 0 {
type kv struct {
Expand Down Expand Up @@ -327,6 +350,16 @@ func (r *Runner) preflightResolveAndPortScan(store *loader.Store) error {
return nil
}

func countAllowedPreflightInputs(inputs []preflightTarget, allowed *mapsutil.SyncLockMap[string, struct{}]) int64 {
var count int64
for _, input := range inputs {
if _, ok := allowed.Get(input.key); ok {
count++
}
}
return count
}

type preflightTarget struct {
key string
target string
Expand Down
143 changes: 143 additions & 0 deletions internal/runner/preflight_portscan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package runner

import (
"testing"

"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
inputtypes "github.com/projectdiscovery/nuclei/v3/pkg/input/types"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
mapsutil "github.com/projectdiscovery/utils/maps"
"github.com/stretchr/testify/require"
)

type preflightInputProviderStub struct {
inputs []*contextargs.MetaInput
}

func (p *preflightInputProviderStub) Count() int64 {
return int64(len(p.inputs))
}

func (p *preflightInputProviderStub) Iterate(callback func(*contextargs.MetaInput) bool) {
for _, input := range p.inputs {
if !callback(input) {
return
}
}
}

func (*preflightInputProviderStub) Set(string, string) {}
func (*preflightInputProviderStub) SetWithProbe(string, string, inputtypes.InputLivenessProbe) error {
return nil
}
func (*preflightInputProviderStub) SetWithExclusions(string, string) error { return nil }
func (*preflightInputProviderStub) InputType() string { return provider.TargetInputProvider }
func (*preflightInputProviderStub) Close() {}

func TestPreflightInputKeyPreservesLegacyMarshalString(t *testing.T) {
input := contextargs.NewMetaInput()
input.Input = "https://example.com"
input.CustomIP = "192.0.2.1"

marshaled, err := input.MarshalString()
require.NoError(t, err)

key, err := preflightInputKey(input)
require.NoError(t, err)
require.Equal(t, marshaled, key)
}

func TestPreflightInputKeySeparatesPresenceAwareFilters(t *testing.T) {
omitted := newPreflightFilteredInput(&contextargs.TargetFilter{})
explicitEmpty := newPreflightFilteredInput(&contextargs.TargetFilter{
HasTags: true,
Tags: []string{},
})

// TargetFilter's Has* fields are intentionally not serialized, so the old
// MarshalString key could not distinguish these two JSONL records.
omittedMarshaled, err := omitted.MarshalString()
require.NoError(t, err)
explicitEmptyMarshaled, err := explicitEmpty.MarshalString()
require.NoError(t, err)
require.Equal(t, omittedMarshaled, explicitEmptyMarshaled)

omittedKey, err := preflightInputKey(omitted)
require.NoError(t, err)
explicitEmptyKey, err := preflightInputKey(explicitEmpty)
require.NoError(t, err)
require.NotEqual(t, omittedKey, explicitEmptyKey)

// Semantically equivalent filter lists produce one stable canonical key,
// independent of input ordering.
firstOrdering := newPreflightFilteredInput(&contextargs.TargetFilter{
HasTags: true,
Tags: []string{"beta", "alpha"},
})
secondOrdering := newPreflightFilteredInput(&contextargs.TargetFilter{
HasTags: true,
Tags: []string{"alpha", "beta"},
})
firstKey, err := preflightInputKey(firstOrdering)
require.NoError(t, err)
secondKey, err := preflightInputKey(secondOrdering)
require.NoError(t, err)
require.Equal(t, firstKey, secondKey)

allowed := mapsutil.NewSyncLockMap[string, struct{}]()
require.NoError(t, allowed.Set(omittedKey, struct{}{}))
require.NoError(t, allowed.Set(explicitEmptyKey, struct{}{}))

filtered := &filteringInputProvider{
base: &preflightInputProviderStub{inputs: []*contextargs.MetaInput{omitted, explicitEmpty}},
allowed: allowed,
allowCnt: 2,
execID: "test",
}
var iterated []*contextargs.MetaInput
filtered.Iterate(func(input *contextargs.MetaInput) bool {
iterated = append(iterated, input)
return true
})
require.EqualValues(t, 2, filtered.Count())
require.Len(t, iterated, 2)
}

func TestFilteringInputProviderCountsKeptInputsNotAllowedKeys(t *testing.T) {
first := contextargs.NewMetaInput()
first.Input = "https://duplicate.example"
second := contextargs.NewMetaInput()
second.Input = first.Input

key, err := preflightInputKey(first)
require.NoError(t, err)
allowed := mapsutil.NewSyncLockMap[string, struct{}]()
require.NoError(t, allowed.Set(key, struct{}{}))
require.Len(t, allowed.GetAll(), 1)
require.EqualValues(t, 2, countAllowedPreflightInputs([]preflightTarget{
{key: key},
{key: key},
}, allowed))

filtered := &filteringInputProvider{
base: &preflightInputProviderStub{inputs: []*contextargs.MetaInput{first, second}},
allowed: allowed,
allowCnt: 2,
execID: "test",
}
var iterated int
filtered.Iterate(func(*contextargs.MetaInput) bool {
iterated++
return true
})

require.EqualValues(t, 2, filtered.Count())
require.Equal(t, 2, iterated)
}

func newPreflightFilteredInput(filter *contextargs.TargetFilter) *contextargs.MetaInput {
input := contextargs.NewMetaInput()
input.Input = "https://duplicate.example"
input.TargetFilter = filter
return input
}
5 changes: 4 additions & 1 deletion internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,10 @@ func (r *Runner) RunEnumeration() error {

// If using input-file flags, only load http fuzzing based templates.
loaderConfig := loader.NewConfig(r.options, r.catalog, executorOpts)
if !strings.EqualFold(r.options.InputFileMode, "list") || r.options.DAST {
if err := r.prepareTargetFilters(loaderConfig); err != nil {
return errors.Wrap(err, "could not prepare per-target JSONL filters")
}
if r.inputProvider.InputType() == provider.MultiFormatInputProvider || r.options.DAST {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// if input type is not list (implicitly enable fuzzing)
r.options.DAST = true
}
Expand Down
Loading