Skip to content

Native service fingerprinting#1667

Open
Mzack9999 wants to merge 14 commits intodevfrom
1594-native-service-fingerprinting
Open

Native service fingerprinting#1667
Mzack9999 wants to merge 14 commits intodevfrom
1594-native-service-fingerprinting

Conversation

@Mzack9999
Copy link
Copy Markdown
Member

@Mzack9999 Mzack9999 commented Mar 27, 2026

Native Service Fingerprinting (-sV)

Adds a built-in nmap-compatible service fingerprinting engine that runs directly inside naabu, no external nmap binary required. The engine parses standard nmap-service-probes files (11,951 match rules, 1,200+ identifiable services), sends protocol-specific probes to discovered ports, and matches responses against regex patterns to identify services, versions, and CPEs. Supports TLS-aware probing, probe intensity levels, fast mode, configurable concurrency, and custom probe files.

Benchmarked against nmap 7.98 on 8 mock TCP services (SSH, FTP, SMTP, IMAP, POP3, MySQL, HTTP nginx, HTTP Apache):

  • 100% accuracy (8/8 correct, 0 false positives), matching nmap exactly
  • 92x to 812x faster than nmap on banner-based services (SSH 2ms vs 185ms, POP3 <1ms vs 260ms, MySQL <1ms vs 147ms)
  • 6,000x faster on HTTP standard ports (port-hinted match in <1ms vs nmap's 6s)
  • 1.9x faster total scan time across all 8 services (6s vs 11.2s)
root@box:~# naabu -host scanme.sh -sV
scanme.sh:22 [ssh/OpenSSH/9.7p1]
scanme.sh:80 [http/Apache httpd/2.4.52]

New flags: -sD (service discovery), -sV (service version), -sV-fast (port-hinted only), -sV-timeout, -sV-workers, -sV-probes.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added service version detection (-sV) using Nmap-based probes for parallel protocol/version identification
    • Service detection supports fast mode, configurable timeout, worker concurrency, and custom probe files
    • Added CPE (Common Platform Enumeration) output in scan results
    • Service version results support proxy routing
  • Bug Fixes

    • Corrected UDP healthcheck label reporting
    • Improved IPv6 default route handling
  • Documentation

    • Added Service Version Detection section to README with usage examples and flag reference

Mzack9999 and others added 6 commits March 26, 2026 13:33
* parse Darwin routing table with golang.org/x/net/route

* add subnet mask to destination

* add LinkAddr support

* consolidate darwin and freebsd routing logic into one file

* use windows api to get routing table instead of os.exec

* fix potential nil pointer dereference

* fixing minor issues

---------

Co-authored-by: Mzack9999 <mzack9999@protonmail.com>
* fix: add system DNS fallback for split‑DNS/VPN resolution

* make system resolver optional

---------

Co-authored-by: Mzack9999 <mzack9999@protonmail.com>
* fix: scan type inconsistency after scanner initialization

If SYN scan type is specified for the runner, but NewScanner failed to acquire
a listen handler, NewScanner will fallback to CONNECT scan without updating
the runner's scan type option. This causes the runner to later attempt to use
raw packet scan with an empty listen handler when it should have used a normal
connect scan.

* minor fix

---------

Co-authored-by: Mzack9999 <mzack9999@protonmail.com>
@Mzack9999 Mzack9999 self-assigned this Mar 27, 2026
@Mzack9999 Mzack9999 added the Type: Enhancement Most issues will probably ask for additions or changes. label Mar 27, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

Walkthrough

A new fingerprint package implements nmap-style service version detection with parallel TCP/UDP probing, regex-based match evaluation, and probe database parsing. Integration into the scanner pipeline discovers and enriches service metadata on identified ports. Routing and prediction model enhancements support the feature.

Changes

Cohort / File(s) Summary
Fingerprint Engine Core
pkg/fingerprint/fingerprint.go, pkg/fingerprint/engine.go, pkg/fingerprint/options.go
Defines fingerprinting Target, Result, and Engine types with configuration options. Engine orchestrates parallel TCP/UDP probing with worker concurrency, timeouts, TLS detection, and response caching.
Probe Database Parsing
pkg/fingerprint/parser.go, pkg/fingerprint/locate.go
Parses nmap-service-probes files into ProbeDB with ServiceProbe and Match structures. Locates probes on system via environment variables, home directory, and nmap binary introspection.
Fingerprint Testing
pkg/fingerprint/engine_test.go, pkg/fingerprint/parser_test.go, pkg/fingerprint/bench_test.go, pkg/fingerprint/nmap_comparison_test.go
Comprehensive test suite validating probe parsing, engine control flow, protocol detection, fallback behavior, performance, and optional cross-validation against system nmap.
Runner Integration
pkg/runner/runner.go, pkg/runner/options.go, pkg/runner/validate.go
Integrates fingerprinting pipeline into scanner: registers CLI flags (--sV*), initializes engine, streams targets to fingerprinter, merges detected services into results, and enforces defaults/validation for timeouts and worker counts.
Output & Result Handling
pkg/runner/output.go, pkg/runner/output_test.go, pkg/result/results.go
Adds CPEs field to result output; updates JSON/CSV formatting and service field copying. Introduces GetHostPortsMap() for model training in smart-scan.
Prediction Model Enhancement
pkg/prediction/model.go, pkg/runner/smartscan.go
Adds MergeNew() method for non-destructive correlation merging. Updates smart-scan to retrain model from discovered ports and apply probability-gated correlation boosts.
Routing Fixes
pkg/routing/router.go, pkg/routing/router_bsd.go
Corrects DefaultSourceIP handling in routes and adds IPv6 fallback support when IPv4 interface has IPv6 addresses.
Configuration & Metadata
.gitignore, README.md, go.mod, pkg/runner/healthcheck.go
Documents new service-version feature; adds nmap-probes tool to gitignore; promotes regexp2 to direct dependency; fixes UDP healthcheck label.

Sequence Diagram

sequenceDiagram
    participant Scanner as Scanner
    participant Runner as Runner
    participant Engine as Fingerprint Engine
    participant ProbeDB as ProbeDB
    participant TCPTarget as TCP Target
    participant ResultMap as Result Map

    Scanner->>Runner: StartScan (ServiceVersion enabled)
    Runner->>Runner: initServiceDetection()
    Runner->>ProbeDB: LocateNmapProbes() & ParseProbeFile()
    ProbeDB-->>Runner: Loaded ServiceProbes
    Runner->>Engine: New(db, options)
    Engine-->>Runner: Engine ready

    Scanner->>Runner: onReceive(port discovered)
    Runner->>Runner: Push to fingerprint channel
    
    par Scanning continues
        Runner->>Engine: FingerprintStream(targets channel)
        Engine->>Engine: For each target: TCP/UDP probing
        Engine->>TCPTarget: Dial + write probe
        TCPTarget-->>Engine: Response
        Engine->>Engine: tryMatches(response vs ProbeDB)
        Engine-->>Runner: StreamResult (detected service)
    and
        Scanner->>Scanner: Continue port scanning
    end

    Runner->>ResultMap: Store Service by ip:port
    
    Scanner->>Runner: Scan complete
    Runner->>Runner: waitServiceDetection()
    Runner->>ResultMap: Merge services into scanResults
    Runner->>Runner: handleOutput (enriched with services)
    Runner-->>Scanner: Output with CPEs & service metadata
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • Adding predictive scanning #1666: The PR's updates to pkg/prediction/model.py (adding MergeNew) and pkg/runner/smartscan.go (threshold threading and model re-training) extend prediction model capabilities originally introduced in this predictive-scanning PR.

Suggested reviewers

  • ehsandeep
  • dogancanbakir

Poem

🐰 With probes and patterns in parallel flow,
Nmap secrets the fingerprinter will know,
SSH to HTTP in races so quick,
CPEs and versions—detection's our trick!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Native service fingerprinting' directly and clearly summarizes the main feature being added: a built-in, native service fingerprinting engine for naabu.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 1594-native-service-fingerprinting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ehsandeep ehsandeep linked an issue Mar 28, 2026 that may be closed by this pull request
@Mzack9999 Mzack9999 marked this pull request as ready for review March 31, 2026 18:12
@auto-assign auto-assign bot requested a review from dogancanbakir March 31, 2026 18:12
@Mzack9999 Mzack9999 requested a review from ehsandeep March 31, 2026 18:12
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (8)
pkg/port/port.go (1)

23-31: LGTM - Minor optimization suggestion.

The refactor to use fmt.Fprintf directly is cleaner. However, line 27 uses fmt.Fprintf for a constant string "/tls" which has unnecessary formatting overhead. Consider using builder.WriteString("/tls") instead.

💡 Optional micro-optimization
 func (p *Port) StringWithDetails() string {
 	var builder strings.Builder
 	_, _ = fmt.Fprintf(&builder, "%d[%s", p.Port, p.Protocol.String())
 	if p.TLS {
-		_, _ = fmt.Fprintf(&builder, "/tls")
+		_, _ = builder.WriteString("/tls")
 	}
-	_, _ = fmt.Fprintf(&builder, "]")
+	_, _ = builder.WriteString("]")
 	return builder.String()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/port/port.go` around lines 23 - 31, In Port.StringWithDetails(), avoid
using fmt.Fprintf to write the constant "/tls" (unnecessary formatting
overhead); replace the fmt.Fprintf(&builder, "/tls") call with
builder.WriteString("/tls") inside the TLS branch so the rest of the function
(fmt.Fprintf for the formatted pieces) remains unchanged.
pkg/routing/router_windows.go (1)

24-34: No fallback to outbound IP detection on Windows.

Unlike the BSD implementation which falls back to fallbackOutboundRoutes() when both native and netstat methods fail, Windows returns an error directly. This could cause initialization failures on systems where routing table access is restricted but outbound connectivity works.

Consider adding a similar fallback for consistency:

💡 Optional: Add outbound IP fallback
 func New() (Router, error) {
 	routes, err := fetchRoutesNative()
 	if err != nil {
 		gologger.Debug().Msgf("native Windows routing API failed, falling back to netsh: %v", err)
 		routes, err = fetchRoutesNetsh()
 	}
 	if err != nil {
-		return nil, err
+		gologger.Debug().Msgf("netsh fallback failed, falling back to outbound IPs: %v", err)
+		return fallbackOutboundRoutes()
 	}
 	return &baseRouter{Routes: routes}, nil
 }

Note: fallbackOutboundRoutes() is currently only defined in router_bsd.go. You'd need to extract it to router.go or duplicate it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/routing/router_windows.go` around lines 24 - 34, The Windows New() should
mirror BSD's behavior by attempting fallbackOutboundRoutes() if both
fetchRoutesNative() and fetchRoutesNetsh() fail: modify New() to call
fetchRoutesNative(), then on error try fetchRoutesNetsh(), and if that also
errors, call fallbackOutboundRoutes() and use its returned routes; ensure the
function references fetchRoutesNative, fetchRoutesNetsh, fallbackOutboundRoutes,
and baseRouter; if fallbackOutboundRoutes is currently only in router_bsd.go,
either move it into a shared file (router.go) or duplicate it into the Windows
build so the fallback is available.
pkg/fingerprint/locate.go (1)

41-88: Consider adding debug logging for probe file discovery.

When LocateNmapProbes() returns an empty string, there's no visibility into which paths were checked. This could make troubleshooting difficult for users.

💡 Optional: Add debug logging
 func nmapProbePaths() []string {
 	var paths []string
+	// Note: Paths are checked in order by LocateNmapProbes()
 
 	switch runtime.GOOS {
 	// ... existing code ...
 	}
 
 	return paths
 }

Or add logging in LocateNmapProbes() when no file is found to list searched paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fingerprint/locate.go` around lines 41 - 88, LocateNmapProbes() currently
returns an empty string with no visibility into what paths were searched; use
nmapProbePaths() to gather the candidate list and log it when no probe file is
found. Modify LocateNmapProbes() so that after iterating candidates (via
nmapProbePaths()) and before returning "", it emits a debug or info log
containing the slice of paths (and optionally which OS/ENV values influenced
them), e.g., call the existing logger with a message like "nmap probe not found,
searched paths: %v" and include nmapProbePaths() output to aid troubleshooting.
pkg/routing/router_test.go (2)

345-358: Unused variable srcIP in test.

Line 357 has _ = srcIP to silence the compiler, but srcIP (defined at line 346) is never used meaningfully in the test. Either use it in an assertion or remove it.

 func TestBaseRouter_RouteWithSrc_HWAddrMatch(t *testing.T) {
-	srcIP := net.ParseIP("10.0.0.5")
 	routes := []*Route{
 		{Type: IPv4, Destination: "10.0.0.0/8", NetworkInterface: eth0, Gateway: "10.0.0.1"},
 		{Type: IPv4, Destination: "192.168.0.0/16", NetworkInterface: eth1, Gateway: "192.168.0.1"},
 	}

 	router := &baseRouter{Routes: routes}
 	iface, _, _, err := router.RouteWithSrc(eth1.HardwareAddr, nil, net.ParseIP("8.8.8.8"))
 	require.NoError(t, err)
 	assert.Equal(t, eth1, iface)
-
-	_ = srcIP
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/routing/router_test.go` around lines 345 - 358, The test
TestBaseRouter_RouteWithSrc_HWAddrMatch defines srcIP but never uses it; remove
the unused variable and the no-op `_ = srcIP` (or alternatively use srcIP in the
RouteWithSrc call if the intention was to test source IP handling).
Specifically, edit the test in the baseRouter tests around the RouteWithSrc
invocation (function: RouteWithSrc on type baseRouter) to either pass srcIP into
RouteWithSrc or delete the srcIP declaration and the `_ = srcIP` line so the
test has no unused variables.

454-495: Unused variables in integration test.

Lines 493-494 have _ = router and _ = srcIP but neither is used in the test. The sub-tests call FindRouteForIp directly instead of using the router instance.

💡 Suggested fix: Use router instance or remove
 func TestBaseRouter_RealisticRouteTable(t *testing.T) {
-	srcIP := net.ParseIP("192.168.1.50")
-
 	routes := []*Route{
 		// ...
 	}

 	router := &baseRouter{Routes: routes}

 	t.Run("LAN address uses /24", func(t *testing.T) {
-		route, err := FindRouteForIp(net.ParseIP("192.168.1.100"), routes)
+		_, _, _, err := router.Route(net.ParseIP("192.168.1.100"))
 		require.NoError(t, err)
-		assert.Equal(t, eth0, route.NetworkInterface)
-		assert.Empty(t, route.Gateway)
+		// ... adjust assertions for Route() return values
 	})
 	// ... similar changes for other sub-tests
-
-	_ = router
-	_ = srcIP
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/routing/router_test.go` around lines 454 - 495, The test declares router
and srcIP but never uses them; either remove the unused variables or adjust
sub-tests to exercise the baseRouter instance and srcIP. Specifically, either
delete the lines assigning _ = router and _ = srcIP, or replace direct calls to
FindRouteForIp with calls through the router (e.g., call router.FindRouteForIp
or a method on baseRouter that wraps FindRouteForIp) and use srcIP where
appropriate (for example, use srcIP as the source address in any call that needs
it). Ensure references to baseRouter, router, srcIP and FindRouteForIp are
updated consistently so no unused variables remain.
pkg/fingerprint/options.go (1)

36-41: Consider validating nil dialer to prevent potential nil pointer dereference.

WithDialer accepts any DialFunc including nil. If called with nil, e.dialer would be set to nil, potentially causing a nil pointer dereference during TCP connections. While current callers in runner.go construct valid dialers, a defensive nil check would prevent future misuse.

🛡️ Optional defensive fix
 func WithDialer(d DialFunc) Option {
 	return func(e *Engine) {
+		if d != nil {
 			e.dialer = d
+		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fingerprint/options.go` around lines 36 - 41, The WithDialer Option
should defensively handle a nil DialFunc to avoid setting Engine.dialer to nil;
update the WithDialer function to check if d == nil and if so return a no-op
Option (leave e.dialer unchanged) otherwise set e.dialer = d. This change
touches the WithDialer function and the Engine.dialer field so callers like
runner.go remain unaffected but future misuse is prevented.
pkg/runner/runner_test.go (1)

991-1023: Test relies on global state mutation—ensure tests remain sequential.

This test mutates package-level variables (scan.PkgRouter, privileges.IsPrivileged, scan.ListenHandlers) without synchronization. While the defer restores correctly and tests aren't currently parallel, this pattern will cause data races if t.Parallel() is ever added to these tests.

Consider documenting this constraint or adding a comment to prevent accidental parallelization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/runner/runner_test.go` around lines 991 - 1023, This test mutates
package-level globals (scan.PkgRouter, privileges.IsPrivileged,
scan.ListenHandlers) which can cause data races if run in parallel; update
TestNewRunner_ScanTypeSyncAfterFallback by adding a clear comment above the test
stating it intentionally mutates global state and must not be run with
t.Parallel(), and either (preferred) protect the mutated globals with a
package-level test mutex or (alternatively) document the sequential requirement
in the test comment so future maintainers don't add t.Parallel(); reference the
symbols scan.PkgRouter, privileges.IsPrivileged, scan.ListenHandlers, and the
test name TestNewRunner_ScanTypeSyncAfterFallback when adding the comment or
mutex.
pkg/fingerprint/bench_test.go (1)

353-357: Avoid a hard wall-clock threshold in this test.

The <2s assertion depends on machine load and scheduler noise, so it can fail spuriously even when probe parallelism is working. This is safer as a benchmark or an opt-in integration test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fingerprint/bench_test.go` around lines 353 - 357, The test currently
fails on noisy machines because it asserts elapsed < 2*time.Second; change it to
avoid a hard wall-clock check by either (a) removing the absolute threshold and
instead comparing the parallel run against a measured sequential run (compute
sequentialElapsed and assert elapsed < sequentialElapsed - some margin) or (b)
make the timing assertion opt-in/configurable (use testing.Short or an env var
like TEST_TIMING_THRESHOLD) and otherwise only t.Logf the elapsed time; locate
the assertion referencing elapsed and the GetRequest/NULL probe comment in
bench_test.go to implement the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/fingerprint/engine_test.go`:
- Around line 197-203: The test currently only logs when an unexpected result is
present for key (fmt.Sprintf("%s:%d", host, port)) but should fail; update the
fast-mode assertion in engine_test.go so that if results[key] exists (indicating
the NULL probe or a non-fast probe ran and GetRequest executed), the test calls
t.Fatalf (or t.Errorf followed by t.FailNow) with a clear message that a result
was produced in fast mode, referencing key and that GetRequest should not have
run; modify the block checking results[key] to fail the test instead of t.Log.

In `@pkg/fingerprint/engine.go`:
- Around line 464-471: The current logic in the fingerprinting read sequence
(use of remaining, conn.SetReadDeadline and the single extra conn.Read call)
stops after at most one 200ms poll and may truncate multi-chunk responses;
change it to loop reads until the overall waitTimeout has elapsed or no more
data arrives: repeatedly set a short read deadline (e.g. 200ms), call conn.Read
and append to response while n>0, break only when a read times out/no data or
the remaining time is exhausted, and then return response and false; update the
code around the existing conn.SetReadDeadline/conn.Read usage so it continually
polls rather than returning after a single follow-up read.
- Around line 157-160: Engine.fingerprintOne currently ignores
ProbeDB.ExcludeTCP and ProbeDB.ExcludeUDP populated by ParseProbes, so excluded
ports are still dialed; update Engine.fingerprintOne to consult e.db.ExcludeTCP
and e.db.ExcludeUDP (or the corresponding exclude sets on the loaded ProbeDB)
before attempting any dial and skip fingerprinting for ports present in those
sets, and apply the same check to the other fingerprinting path referenced in
the review (the dialing loop around lines 684-688) so both code paths honor
ProbeDB.ExcludeTCP/ExcludeUDP.
- Around line 720-722: The UDP branch currently only calls
e.tryMatches(sp.Matches) and thus skips softmatch and fallback probe rules;
update the UDP matching logic so it evaluates soft matches and fallback probes
the same way TCP does: after checking sp.Matches call into the same softmatch
and fallback evaluation flow (e.g., call whatever helper(s) that handle
sp.SoftMatches and fallback probe rules or merge sp.Matches + sp.SoftMatches +
any fallback match set before invoking e.tryMatches), and ensure the final
return still uses matchResultToResult(result, false, "") so UDP results include
softmatch/fallback outcomes just like the TCP path.
- Around line 127-133: The send to results after calling e.fingerprintOne can
block indefinitely; modify the worker goroutine that calls e.fingerprintOne(ctx,
t) (and currently sends StreamResult with r.ToService()) to use a select that
attempts to send the StreamResult to the results channel or returns if
ctx.Done() is closed, and ensure wg.Done() is always executed (preferably via
defer wg.Done() at the start of the goroutine) so the closer goroutine's
wg.Wait() cannot hang; in short, guard the results <- send with a select { case
results<-sr: case <-ctx.Done(): } and guarantee wg.Done() runs even on cancel.

In `@pkg/fingerprint/fingerprint.go`:
- Around line 34-44: The ToService() method on Result is missing mapping for the
Result.DeviceType into port.Service.DeviceType; update the ToService() return
struct (in func (r *Result) ToService()) to set DeviceType: r.DeviceType so the
Result's DeviceType is propagated into the created *port.Service object.

In `@pkg/fingerprint/parser.go`:
- Around line 203-216: The code currently swallows parseMatch errors in the
parse loop (inside ParseProbes) by continuing, causing signatures to be silently
dropped; instead, have the loop propagate the error (do not continue). Replace
the `continue` in both the "match " and "softmatch " branches so that parseMatch
errors are returned (wrap with context including the offending line and whether
it was a match or softmatch) from the enclosing ParseProbes function (or
otherwise bubble the error to the caller), referencing parseMatch, ParseProbes,
current.Matches and current.SoftMatches so failures are visible to callers
rather than silently ignored.
- Around line 545-553: substituteGroups currently replaces capture placeholders
in ascending order which causes "$1" to be substituted inside "$10" etc.; change
the replacement loop in substituteGroups to iterate from the highest capture
index down to 1 (e.g., start at min(len(submatches)-1, 9) and decrement) so that
multi-digit placeholders like "$10" are replaced before "$1"; update the loop in
substituteGroups accordingly using the same strconv.Itoa and strings.ReplaceAll
calls but with descending indices.
- Around line 290-335: The decodeNmapEscapes function currently only decodes a
small whitelist and leaves unknown backslashes in the output (e.g., "\|"), so
update decodeNmapEscapes to treat any unrecognized escape as "literal next byte"
by appending s[i+1] and advancing i by 2 in the switch default branch; also
handle a trailing lone backslash at end of string by appending '\\' and
advancing to terminate. Make this change in the decodeNmapEscapes function to
ensure probes that escape delimiters are decoded correctly.

In `@pkg/routing/router_bsd.go`:
- Around line 246-260: The current IPv6 fallback creates a Route when ip6 is
nil, ignores errors from FindInterfaceByIp and assigns an IPv4-only interface
without a DefaultSourceIP which breaks baseRouter.Route()/FindSourceIpForIp; fix
by removing the blind fallback or by checking the candidate interface for IPv6
addresses before appending: call FindInterfaceByIp only when ip6 != nil and
propagate/handle its error instead of discarding it, and if you must reuse
routes[0].NetworkInterface ensure that interface actually has an IPv6 address
(or set DefaultSourceIP to a valid IPv6) before creating the Route struct
(update the code around ip6, FindInterfaceByIp, Route creation and the fallback
branch accordingly).

In `@pkg/routing/router.go`:
- Around line 63-71: baseRouter.Route currently returns (nil, nil,
route.DefaultSourceIP, nil) when route.DefaultSourceIP is set, causing callers
to get a nil *net.Interface even if the route contains NetworkInterface/Gateway
info; change the early return to return the route's NetworkInterface and Gateway
when present (i.e., return route.NetworkInterface, route.Gateway,
route.DefaultSourceIP, nil) so callers that need iface (e.g., for raw packet
sending) receive it; update the logic in Route (function baseRouter.Route) to
prefer route.NetworkInterface and route.Gateway when route.DefaultSourceIP !=
nil, falling back to nils only if those fields are actually absent.

In `@pkg/runner/healthcheck.go`:
- Line 78: The healthcheck output prints the wrong UDP endpoint (it writes
"scanme.sh:80" while the probe actually dials "scanme.sh:53"); update the
fmt.Fprintf call that writes to test (referenced by testResult and the
fmt.Fprintf call) to report the actual UDP IPv4 endpoint used by the probe (use
the same endpoint/variable used when dialing, e.g., the "scanme.sh:53" value or
the endpoint variable) so the diagnostic message matches the probe target.

In `@pkg/runner/output.go`:
- Around line 50-52: CSVFields currently uses fmt.Sprint which renders the CPEs
[]string as Go's bracketed slice (e.g. "[a b]") making CSV round-tripping
fragile; update the CSVFields implementation to explicitly serialize the CPEs
field (named CPEs on the output struct) into a single string (e.g.,
strings.Join(o.CPEs, ";") or JSON-encode) before returning it for the CSV column
so each CPE is reliably delimited and parseable; locate the CSVFields method
(around lines 161-168) and replace the fmt.Sprint usage for the CPEs case with
an explicit join/encoding step.

In `@pkg/runner/runner.go`:
- Around line 1120-1124: The log reads targetCount too early because the
goroutine that feeds targets increments targetCount concurrently, so move the
counting step before starting that goroutine: compute the total targets (e.g.,
len(targets) or pre-iterate channels/slices to get the count) and assign
targetCount prior to launching the feeding goroutine, then call
gologger.Info().Msgf using that count (or alternatively defer the log until
after the feeder has finished counting); reference variables: targetCount and
r.options.ServiceVersionWorkers and the existing feeder goroutine that populates
targets so you update where the log call and goroutine are ordered.

In `@pkg/scan/scan.go`:
- Around line 182-191: The loop presently downgrades options.ScanType from
TypeSyn to TypeConnect when Acquire fails, which silently flips SYN-only
discovery to CONNECT; instead, when Acquire returns an error and
options.ScanType == TypeSyn, stop downgrading and return or propagate the
acquireErr so the caller fails fast. Modify the loop in pkg/scan/scan.go (the
Acquire(...) call and handling around options.ScanType, TypeSyn, TypeConnect and
scanner.ListenHandler) to remove the automatic assignment options.ScanType =
TypeConnect and the continue path for the SYN case; if Acquire fails while
ScanType is TypeSyn, return acquireErr (or wrap and return) so the caller
enforces the SYN-only policy established in pkg/runner/validate.go.

In `@README.md`:
- Around line 103-104: Update the README help block to document the new native
fingerprinting CLI flags by adding entries for -sD, -sV, -sV-fast, -sV-timeout,
-sV-workers, and -sV-probes alongside the existing options; reference the same
help section that currently lists -dns-order and -sr/-system-resolver and add
concise descriptions and default values for each new flag (e.g., -sD to enable
service detection, -sV to enable version fingerprinting, -sV-fast to use a
reduced probe set, -sV-timeout to set probe timeout, -sV-workers to control
concurrency, -sV-probes to select probe profile) so the README usage output
reflects the new native fingerprinting feature instead of only showing the
deprecated -nmap option.

---

Nitpick comments:
In `@pkg/fingerprint/bench_test.go`:
- Around line 353-357: The test currently fails on noisy machines because it
asserts elapsed < 2*time.Second; change it to avoid a hard wall-clock check by
either (a) removing the absolute threshold and instead comparing the parallel
run against a measured sequential run (compute sequentialElapsed and assert
elapsed < sequentialElapsed - some margin) or (b) make the timing assertion
opt-in/configurable (use testing.Short or an env var like TEST_TIMING_THRESHOLD)
and otherwise only t.Logf the elapsed time; locate the assertion referencing
elapsed and the GetRequest/NULL probe comment in bench_test.go to implement the
change.

In `@pkg/fingerprint/locate.go`:
- Around line 41-88: LocateNmapProbes() currently returns an empty string with
no visibility into what paths were searched; use nmapProbePaths() to gather the
candidate list and log it when no probe file is found. Modify LocateNmapProbes()
so that after iterating candidates (via nmapProbePaths()) and before returning
"", it emits a debug or info log containing the slice of paths (and optionally
which OS/ENV values influenced them), e.g., call the existing logger with a
message like "nmap probe not found, searched paths: %v" and include
nmapProbePaths() output to aid troubleshooting.

In `@pkg/fingerprint/options.go`:
- Around line 36-41: The WithDialer Option should defensively handle a nil
DialFunc to avoid setting Engine.dialer to nil; update the WithDialer function
to check if d == nil and if so return a no-op Option (leave e.dialer unchanged)
otherwise set e.dialer = d. This change touches the WithDialer function and the
Engine.dialer field so callers like runner.go remain unaffected but future
misuse is prevented.

In `@pkg/port/port.go`:
- Around line 23-31: In Port.StringWithDetails(), avoid using fmt.Fprintf to
write the constant "/tls" (unnecessary formatting overhead); replace the
fmt.Fprintf(&builder, "/tls") call with builder.WriteString("/tls") inside the
TLS branch so the rest of the function (fmt.Fprintf for the formatted pieces)
remains unchanged.

In `@pkg/routing/router_test.go`:
- Around line 345-358: The test TestBaseRouter_RouteWithSrc_HWAddrMatch defines
srcIP but never uses it; remove the unused variable and the no-op `_ = srcIP`
(or alternatively use srcIP in the RouteWithSrc call if the intention was to
test source IP handling). Specifically, edit the test in the baseRouter tests
around the RouteWithSrc invocation (function: RouteWithSrc on type baseRouter)
to either pass srcIP into RouteWithSrc or delete the srcIP declaration and the
`_ = srcIP` line so the test has no unused variables.
- Around line 454-495: The test declares router and srcIP but never uses them;
either remove the unused variables or adjust sub-tests to exercise the
baseRouter instance and srcIP. Specifically, either delete the lines assigning _
= router and _ = srcIP, or replace direct calls to FindRouteForIp with calls
through the router (e.g., call router.FindRouteForIp or a method on baseRouter
that wraps FindRouteForIp) and use srcIP where appropriate (for example, use
srcIP as the source address in any call that needs it). Ensure references to
baseRouter, router, srcIP and FindRouteForIp are updated consistently so no
unused variables remain.

In `@pkg/routing/router_windows.go`:
- Around line 24-34: The Windows New() should mirror BSD's behavior by
attempting fallbackOutboundRoutes() if both fetchRoutesNative() and
fetchRoutesNetsh() fail: modify New() to call fetchRoutesNative(), then on error
try fetchRoutesNetsh(), and if that also errors, call fallbackOutboundRoutes()
and use its returned routes; ensure the function references fetchRoutesNative,
fetchRoutesNetsh, fallbackOutboundRoutes, and baseRouter; if
fallbackOutboundRoutes is currently only in router_bsd.go, either move it into a
shared file (router.go) or duplicate it into the Windows build so the fallback
is available.

In `@pkg/runner/runner_test.go`:
- Around line 991-1023: This test mutates package-level globals (scan.PkgRouter,
privileges.IsPrivileged, scan.ListenHandlers) which can cause data races if run
in parallel; update TestNewRunner_ScanTypeSyncAfterFallback by adding a clear
comment above the test stating it intentionally mutates global state and must
not be run with t.Parallel(), and either (preferred) protect the mutated globals
with a package-level test mutex or (alternatively) document the sequential
requirement in the test comment so future maintainers don't add t.Parallel();
reference the symbols scan.PkgRouter, privileges.IsPrivileged,
scan.ListenHandlers, and the test name TestNewRunner_ScanTypeSyncAfterFallback
when adding the comment or mutex.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5daf89a4-5dfb-4b60-beb2-7f0db7980eb1

📥 Commits

Reviewing files that changed from the base of the PR and between 3d56d22 and 1930518.

📒 Files selected for processing (34)
  • .gitignore
  • README.md
  • go.mod
  • pkg/fingerprint/bench_test.go
  • pkg/fingerprint/engine.go
  • pkg/fingerprint/engine_test.go
  • pkg/fingerprint/fingerprint.go
  • pkg/fingerprint/locate.go
  • pkg/fingerprint/nmap_comparison_test.go
  • pkg/fingerprint/options.go
  • pkg/fingerprint/parser.go
  • pkg/fingerprint/parser_test.go
  • pkg/port/port.go
  • pkg/routing/router.go
  • pkg/routing/router_bsd.go
  • pkg/routing/router_darwin.go
  • pkg/routing/router_freebsd.go
  • pkg/routing/router_test.go
  • pkg/routing/router_windows.go
  • pkg/runner/default.go
  • pkg/runner/healthcheck.go
  • pkg/runner/options.go
  • pkg/runner/output.go
  • pkg/runner/output_test.go
  • pkg/runner/runner.go
  • pkg/runner/runner_test.go
  • pkg/runner/util.go
  • pkg/runner/util_test.go
  • pkg/runner/validate.go
  • pkg/runner/validate_test.go
  • pkg/scan/option.go
  • pkg/scan/scan.go
  • pkg/scan/scan_common.go
  • pkg/scan/scan_type_test.go
💤 Files with no reviewable changes (2)
  • pkg/routing/router_darwin.go
  • pkg/routing/router_freebsd.go

@neo-by-projectdiscovery-dev
Copy link
Copy Markdown

neo-by-projectdiscovery-dev bot commented Mar 31, 2026

Neo - PR Security Review

No security issues found

Comment @pdneo help for available commands. · Open in Neo

Resolve conflicts in go.mod (take newer dependency versions from dev)
and pkg/scan/scan_type_test.go (trivial comment style).
@Mzack9999 Mzack9999 marked this pull request as draft April 1, 2026 16:19
@Mzack9999 Mzack9999 marked this pull request as ready for review April 1, 2026 20:37
@auto-assign auto-assign bot requested a review from dwisiswant0 April 1, 2026 20:37
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (1)
pkg/fingerprint/parser.go (1)

205-218: ⚠️ Potential issue | 🟠 Major

Stop silently dropping invalid signatures.

These branches still log and continue on parseMatch errors, so a malformed match or softmatch line yields a “successful” probe load with fewer fingerprints than the file actually defines.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fingerprint/parser.go` around lines 205 - 218, The code currently logs
and continues when parseMatch fails in the "match " and "softmatch " branches,
causing malformed signatures to be silently dropped; change both branches to
propagate the parse error instead of skipping: when parseMatch(line[6:]) or
parseMatch(line[10:]) returns err, wrap or annotate the error with context
(including current.Name and whether it was match/softmatch) and return that
error from the surrounding parser function so probe loading fails; update any
callers if needed to handle the returned error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/fingerprint/engine.go`:
- Around line 209-214: The current loop short-circuits on any r.hard from
channel ch (in the hinted parallel phase), causing NULL-probe "tcpwrapped"
closes to be treated as definitive; change the logic in the loop that consumes
ch so it only cancels probes and returns immediately when r.hard is a real hard
match that is NOT the tcpwrapped/null-close heuristic (i.e., detect the
tcpwrapped flag/state on r.hard and skip short-circuiting for it), otherwise
continue waiting for other hinted probe results (so GetRequest and other probes
can win); apply the same change to the other identical block around the
GetRequest area (the 395-400 occurrence) and ensure probeCancel() is only called
when you truly accept a non-tcpwrapped hard match before calling
matchResultToResult.

In `@pkg/result/results.go`:
- Around line 233-241: The loop over r.ipPorts should deduplicate numeric ports
per host before applying the "at least 2 ports" filter and before exporting;
change the logic in the block that iterates r.ipPorts/ports (the variable ports
and elements p with p.Port and p.String()) to build a set (map[int]struct{}) of
unique p.Port values, check if the set size is < 2 and continue if so, then
convert the set keys into the portList slice (optionally sorted) and assign
out[ip] = portList so duplicate numeric ports (e.g., 53/tcp and 53/udp) are not
counted twice.

In `@pkg/routing/router_bsd.go`:
- Around line 255-266: The fallback IPv6 route creation appends any IPv6 addr
(from iface.Addrs()) into Route.DefaultSourceIP, which can include link-local
addresses; change the loop in router_bsd.go that builds the IPv6 fallback so it
skips IPs where ipNet.IP.IsLinkLocalUnicast() is true and only appends a
Route{Type: IPv6, Default: true, DefaultSourceIP: ipNet.IP, NetworkInterface:
iface} when the address is not link-local, matching the source selection logic
used elsewhere (see router.go's !ipAddress.IsLinkLocalUnicast() check) and
preventing non-routable link-local addresses from being used as DefaultSourceIP.

In `@pkg/runner/runner.go`:
- Around line 1254-1270: The enriched service/version/CND text is only applied
to stdout in the r.options.ServiceVersion branch, but file output still goes
through WriteHostOutput and loses that info; extract the plain-text enrichment
logic (the code that builds hostPort, appends formatServiceInfo(p.Service) and
cdnName when r.options.OutputCDN && isCDNIP) into a reusable helper (e.g.,
formatEnrichedHostPort or FormatHostOutput) and call it from both the stdout
loop and from WriteHostOutput (or ensure WriteHostOutput invokes that helper
when r.options.ServiceVersion is set) so that -sV combined with -o produces the
same enriched lines in files as on the terminal.
- Around line 1134-1140: The workers for FingerprintStream are created with
context.WithCancel(context.Background()), which detaches them from the
RunEnumeration lifecycle and can leave goroutines blocked on r.fpTargetCh;
change the created context to derive from the RunEnumeration context (use the
existing ctx passed into RunEnumeration or r.ctx) by replacing
context.WithCancel(context.Background()) with context.WithCancel(ctx) so that
r.fpCancel, r.fpTargetCh, r.fpDone and the FingerprintStream call
(engine.FingerprintStream) are tied to the scan context and will be cancelled
when the parent context is done.
- Around line 258-276: The send to r.fpTargetCh inside the OnReceive path can
block and also panic if waitServiceDetection closes the channel concurrently;
modify the send so it never blocks and cannot crash: wrap the send of
fingerprint.Target (constructed from hostname, hostResult.IP and p.Port/TLS
fields) in a small helper that does a non-blocking select (case r.fpTargetCh <-
t: default:) and protects against a closed-channel panic with a defer recover(),
so late sends are safely dropped instead of blocking or panicking.

In `@pkg/runner/smartscan.go`:
- Around line 126-128: The call to sender.flush() currently ignores any returned
error; instead, check the error returned from sender.flush() (in the same
function where this snippet appears), and do not swallow it—either return the
error up the call stack or wrap it with context and propagate it (or at minimum
log it as an error with sufficient context) so transport failures are visible;
update the code around sender.flush() to handle the error case rather than
assigning it to _.
- Around line 98-104: The current logic trains a transient model from
r.scanner.ScanResults.GetHostPortsMap() and then calls model.MergeNew(learned),
which only adds previously-missing pairs so an early sparse observation
permanently fixes probabilities; instead, recompute and apply fresh
probabilities on every retry by rebuilding the learned model from hostPorts each
time and replacing/updating the main model rather than using MergeNew.
Concretely, in the retry block where you call prediction.NewModel(),
learned.Train(hostPorts) and model.MergeNew(learned), change the merge step to
either replace the model’s probabilities with the newly trained learned model
(e.g., assign or call a full-merge/replace method) or use a merge method that
overwrites existing entries so subsequent retries can correct earlier estimates.

---

Duplicate comments:
In `@pkg/fingerprint/parser.go`:
- Around line 205-218: The code currently logs and continues when parseMatch
fails in the "match " and "softmatch " branches, causing malformed signatures to
be silently dropped; change both branches to propagate the parse error instead
of skipping: when parseMatch(line[6:]) or parseMatch(line[10:]) returns err,
wrap or annotate the error with context (including current.Name and whether it
was match/softmatch) and return that error from the surrounding parser function
so probe loading fails; update any callers if needed to handle the returned
error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d35706d7-9256-49f2-bf8b-a6bb5352b195

📥 Commits

Reviewing files that changed from the base of the PR and between 1930518 and 882e57d.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (21)
  • .gitignore
  • README.md
  • go.mod
  • pkg/fingerprint/bench_test.go
  • pkg/fingerprint/engine.go
  • pkg/fingerprint/engine_test.go
  • pkg/fingerprint/fingerprint.go
  • pkg/fingerprint/nmap_comparison_test.go
  • pkg/fingerprint/parser.go
  • pkg/prediction/default_model.go
  • pkg/prediction/model.go
  • pkg/result/results.go
  • pkg/routing/router.go
  • pkg/routing/router_bsd.go
  • pkg/runner/healthcheck.go
  • pkg/runner/options.go
  • pkg/runner/output.go
  • pkg/runner/runner.go
  • pkg/runner/runner_test.go
  • pkg/runner/smartscan.go
  • pkg/runner/validate.go
✅ Files skipped from review due to trivial changes (4)
  • .gitignore
  • README.md
  • pkg/fingerprint/engine_test.go
  • pkg/fingerprint/bench_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • go.mod
  • pkg/runner/validate.go
  • pkg/runner/options.go
  • pkg/fingerprint/fingerprint.go
  • pkg/runner/runner_test.go
  • pkg/runner/healthcheck.go
  • pkg/fingerprint/nmap_comparison_test.go

Comment on lines +209 to +214
for i := 0; i < len(hinted); i++ {
r := <-ch
if r.hard != nil {
probeCancel()
return matchResultToResult(r.hard, r.tls, "")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't let NULL's tcpwrapped heuristic short-circuit active probes.

In the parallel hinted phase, a quick close on the NULL probe is treated as a definitive hard match. That can mislabel services that reject idle connections but do respond once an active probe sends data, because tcpwrapped wins before GetRequest or the other hinted probes finish.

Also applies to: 395-400

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fingerprint/engine.go` around lines 209 - 214, The current loop
short-circuits on any r.hard from channel ch (in the hinted parallel phase),
causing NULL-probe "tcpwrapped" closes to be treated as definitive; change the
logic in the loop that consumes ch so it only cancels probes and returns
immediately when r.hard is a real hard match that is NOT the
tcpwrapped/null-close heuristic (i.e., detect the tcpwrapped flag/state on
r.hard and skip short-circuiting for it), otherwise continue waiting for other
hinted probe results (so GetRequest and other probes can win); apply the same
change to the other identical block around the GetRequest area (the 395-400
occurrence) and ensure probeCancel() is only called when you truly accept a
non-tcpwrapped hard match before calling matchResultToResult.

Comment on lines +233 to +241
for ip, ports := range r.ipPorts {
if len(ports) < 2 {
continue
}
portList := make([]int, 0, len(ports))
for _, p := range ports {
portList = append(portList, p.Port)
}
out[ip] = portList
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Deduplicate numeric ports before exporting training data.

ipPorts is keyed by p.String(), so a host with both 53/tcp and 53/udp reaches this loop twice. Returning [53,53,...] here skews prediction.Model.Train's host counts and even lets a single unique port bypass the “at least 2 ports” filter.

🩹 Minimal fix
 	out := make(map[string][]int, len(r.ipPorts))
 	for ip, ports := range r.ipPorts {
-		if len(ports) < 2 {
-			continue
-		}
-		portList := make([]int, 0, len(ports))
-		for _, p := range ports {
-			portList = append(portList, p.Port)
-		}
-		out[ip] = portList
+		seen := make(map[int]struct{}, len(ports))
+		for _, p := range ports {
+			seen[p.Port] = struct{}{}
+		}
+		if len(seen) < 2 {
+			continue
+		}
+		portList := make([]int, 0, len(seen))
+		for portNum := range seen {
+			portList = append(portList, portNum)
+		}
+		out[ip] = portList
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for ip, ports := range r.ipPorts {
if len(ports) < 2 {
continue
}
portList := make([]int, 0, len(ports))
for _, p := range ports {
portList = append(portList, p.Port)
}
out[ip] = portList
for ip, ports := range r.ipPorts {
seen := make(map[int]struct{}, len(ports))
for _, p := range ports {
seen[p.Port] = struct{}{}
}
if len(seen) < 2 {
continue
}
portList := make([]int, 0, len(seen))
for portNum := range seen {
portList = append(portList, portNum)
}
out[ip] = portList
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/result/results.go` around lines 233 - 241, The loop over r.ipPorts should
deduplicate numeric ports per host before applying the "at least 2 ports" filter
and before exporting; change the logic in the block that iterates
r.ipPorts/ports (the variable ports and elements p with p.Port and p.String())
to build a set (map[int]struct{}) of unique p.Port values, check if the set size
is < 2 and continue if so, then convert the set keys into the portList slice
(optionally sorted) and assign out[ip] = portList so duplicate numeric ports
(e.g., 53/tcp and 53/udp) are not counted twice.

Comment on lines +255 to +266
// Only add an IPv6 fallback if the IPv4 interface has an IPv6 address,
// otherwise FindSourceIpForIp will fail with a confusing error.
if iface := routes[0].NetworkInterface; iface != nil {
if addrs, err := iface.Addrs(); err == nil {
for _, a := range addrs {
if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.To4() == nil {
routes = append(routes, &Route{
Type: IPv6,
Default: true,
DefaultSourceIP: ipNet.IP,
NetworkInterface: iface,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify mismatch: fallback accepts any IPv6, while normal source selection rejects link-local IPv6.
rg -n -C3 'To4\(\) == nil|IsLinkLocalUnicast|DefaultSourceIP' pkg/routing/router_bsd.go pkg/routing/router.go

Repository: projectdiscovery/naabu

Length of output: 2752


Exclude link-local IPv6 addresses from fallback DefaultSourceIP to align with standard source selection.

The fallback IPv6 source selection (lines 260–266) accepts any IPv6 address, including link-local (fe80::/10). However, the normal source selection in router.go:180 explicitly rejects link-local addresses (!ipAddress.IsLinkLocalUnicast()). When DefaultSourceIP is set, router.go:70 returns it directly without applying the link-local filter, allowing non-routable link-local addresses to be used as source for off-link IPv6 targets.

Suggested patch
-					if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.To4() == nil {
+					if ipNet, ok := a.(*net.IPNet); ok &&
+						ipNet.IP.To4() == nil &&
+						!ipNet.IP.IsLinkLocalUnicast() {
 						routes = append(routes, &Route{
 							Type:             IPv6,
 							Default:          true,
 							DefaultSourceIP:  ipNet.IP,
 							NetworkInterface: iface,
 						})
 						break
 					}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/routing/router_bsd.go` around lines 255 - 266, The fallback IPv6 route
creation appends any IPv6 addr (from iface.Addrs()) into Route.DefaultSourceIP,
which can include link-local addresses; change the loop in router_bsd.go that
builds the IPv6 fallback so it skips IPs where ipNet.IP.IsLinkLocalUnicast() is
true and only appends a Route{Type: IPv6, Default: true, DefaultSourceIP:
ipNet.IP, NetworkInterface: iface} when the address is not link-local, matching
the source selection logic used elsewhere (see router.go's
!ipAddress.IsLinkLocalUnicast() check) and preventing non-routable link-local
addresses from being used as DefaultSourceIP.

Comment on lines +258 to +276
// Feed on-the-fly service detection
if r.fpTargetCh != nil {
hostname := hostResult.IP
for _, h := range dt {
if h != "ip" && h != hostResult.IP {
hostname = h
break
}
}
for _, p := range hostResult.Ports {
r.fpTargetCh <- fingerprint.Target{
Host: hostname,
IP: hostResult.IP,
Port: p.Port,
TLSDetected: p.TLS, //nolint:staticcheck // deprecated but still set by scan layer
TLSChecked: true,
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Protect the fingerprint queue from blocking or panicking the scan path.

This send now runs inside OnReceive. Once the 1000-slot buffer fills, result handling blocks behind fingerprinting; later, waitServiceDetection closes the same channel without synchronizing with this send, so a late receive can panic with send on closed channel.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/runner/runner.go` around lines 258 - 276, The send to r.fpTargetCh inside
the OnReceive path can block and also panic if waitServiceDetection closes the
channel concurrently; modify the send so it never blocks and cannot crash: wrap
the send of fingerprint.Target (constructed from hostname, hostResult.IP and
p.Port/TLS fields) in a small helper that does a non-blocking select (case
r.fpTargetCh <- t: default:) and protects against a closed-channel panic with a
defer recover(), so late sends are safely dropped instead of blocking or
panicking.

Comment on lines +1134 to +1140
ctx, cancel := context.WithCancel(context.Background())
r.fpCancel = cancel
r.fpTargetCh = make(chan fingerprint.Target, 1000)
r.fpDone = make(chan struct{})
r.fpServices = make(map[string]*port.Service)

resultCh := engine.FingerprintStream(ctx, r.fpTargetCh)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tie service-detection workers to the scan context.

Using context.Background() detaches FingerprintStream from RunEnumeration. Any early return before waitServiceDetection runs—Load, target parsing, or handleNmap failures are enough—leaves the stream workers and collector goroutine parked forever on r.fpTargetCh.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/runner/runner.go` around lines 1134 - 1140, The workers for
FingerprintStream are created with context.WithCancel(context.Background()),
which detaches them from the RunEnumeration lifecycle and can leave goroutines
blocked on r.fpTargetCh; change the created context to derive from the
RunEnumeration context (use the existing ctx passed into RunEnumeration or
r.ctx) by replacing context.WithCancel(context.Background()) with
context.WithCancel(ctx) so that r.fpCancel, r.fpTargetCh, r.fpDone and the
FingerprintStream call (engine.FingerprintStream) are tied to the scan context
and will be cancelled when the parent context is done.

Comment on lines +1254 to +1270
// Print enriched plain text for -sV mode (before summary)
if r.options.ServiceVersion && !r.options.JSON && !r.options.CSV && !r.options.DisableStdout {
for _, p := range hostResult.Ports {
hostPort := fmt.Sprintf("%s:%d", host, p.Port)
if _, seen := hostPortPrinted[hostPort]; seen {
continue
}
hostPortPrinted[hostPort] = struct{}{}
line := hostPort
if serviceInfo := formatServiceInfo(p.Service); serviceInfo != "" {
line += " [" + serviceInfo + "]"
}
if r.options.OutputCDN && isCDNIP {
line += " [" + cdnName + "]"
}
gologger.Silent().Msgf("%s\n", line)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reuse the enriched text formatter for -o output.

This new -sV branch only enriches stdout. The plain-text file path still goes through WriteHostOutput, so naabu -sV -o out.txt loses the service info that the terminal shows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/runner/runner.go` around lines 1254 - 1270, The enriched
service/version/CND text is only applied to stdout in the
r.options.ServiceVersion branch, but file output still goes through
WriteHostOutput and loses that info; extract the plain-text enrichment logic
(the code that builds hostPort, appends formatServiceInfo(p.Service) and cdnName
when r.options.OutputCDN && isCDNIP) into a reusable helper (e.g.,
formatEnrichedHostPort or FormatHostOutput) and call it from both the stdout
loop and from WriteHostOutput (or ensure WriteHostOutput invokes that helper
when r.options.ServiceVersion is set) so that -sV combined with -o produces the
same enriched lines in files as on the terminal.

Comment on lines +98 to +104
if retry > 0 {
hostPorts := r.scanner.ScanResults.GetHostPortsMap()
if len(hostPorts) > 0 {
learned := prediction.NewModel()
learned.Train(hostPorts)
model.MergeNew(learned)
gologger.Debug().Msgf("Smart scan: model refined with %d multi-port hosts", len(hostPorts))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Recompute learned probabilities each retry instead of freezing the first observation.

hostPorts is rebuilt from all discoveries on every retry, but MergeNew only inserts pairs that were absent before. The first retry that sees a new pair permanently fixes its probability, so later retries cannot correct early sparse estimates and the queue stays biased.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/runner/smartscan.go` around lines 98 - 104, The current logic trains a
transient model from r.scanner.ScanResults.GetHostPortsMap() and then calls
model.MergeNew(learned), which only adds previously-missing pairs so an early
sparse observation permanently fixes probabilities; instead, recompute and apply
fresh probabilities on every retry by rebuilding the learned model from
hostPorts each time and replacing/updating the main model rather than using
MergeNew. Concretely, in the retry block where you call prediction.NewModel(),
learned.Train(hostPorts) and model.MergeNew(learned), change the merge step to
either replace the model’s probabilities with the newly trained learned model
(e.g., assign or call a full-merge/replace method) or use a merge method that
overwrites existing entries so subsequent retries can correct earlier estimates.

Comment on lines 126 to 128
if sender != nil {
sender.flush()
_ = sender.flush()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't swallow fast-path flush failures.

A sender.flush() error means this batch was not fully emitted. Ignoring it turns transport failures into silent false negatives and makes it impossible to tell whether retries are compensating for a broken sender.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/runner/smartscan.go` around lines 126 - 128, The call to sender.flush()
currently ignores any returned error; instead, check the error returned from
sender.flush() (in the same function where this snippet appears), and do not
swallow it—either return the error up the call stack or wrap it with context and
propagate it (or at minimum log it as an error with sufficient context) so
transport failures are visible; update the code around sender.flush() to handle
the error case rather than assigning it to _.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Enhancement Most issues will probably ask for additions or changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native Service Discovery & Banner Grabbing Support

5 participants