Fingerprint#match interpolation takes the wrong branch when an optional capture becomes "" instead of nil

Open Beginner friendly
#24 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
86/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
ruby
Domain
backend

Research direction

Start in lib/recog/fingerprint.rb around lines 112-130, where replacement interpolation branches on the captured value. Reproduce the issue with the LiteSpeed fingerprint and "LiteSpeed Enterprise" example, then verify that an empty optional capture receives the same '-' fallback as nil and that the resulting service.cpe23 value is correct.

Written by the indexing model from the issue text.

Description

Describe the bug
Fingerprint#match's interpolation logic in lib/recog/fingerprint.rb branches on whether a captured value is present:

https://github.com/rapid7/recog-ruby/blob/733225e0d46501e32f1c9fe7606f8f998f091a3/lib/recog/fingerprint.rb#L112-L130

replacements.each_pair do |replacement_k, replacement_vs|
  replacement_vs.each do |replacement|
    if result[replacement]
      result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, result[replacement])
    else
      # ...falls back to '-' for cpe23 version fields...
      result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, '-')
    end

This assumes an unmatched optional capture group is always nil. That's true for a pattern like (.+)?, but not for (.*) — when the group doesn't participate in the match, (.+)? captures nil while (.*) captures "". Since "" is truthy in Ruby, the if result[replacement] branch is taken instead of the intended else fallback, and the raw (empty) capture gets interpolated instead of the - placeholder.

This matters because any XML pattern change that swaps an optional capturing group from (X+)? to (X*) — for example, as part of quieting Ruby 3.3's nested repeat operator warning will silently change this interpolation behavior for any fingerprint relying on the nil fallback, even though the regex itself still matches the same strings.

To Reproduce
Steps to reproduce the behavior:

  1. Use a fingerprint whose version capture is optional and currently written as (.+)?
  2. Match it against a string where that optional group doesn't participate (no version present)
  3. Note result["service.version"] is nil and result["service.cpe23"] correctly gets the - placeholder
  4. Change the pattern's optional group from (.+)? to (.*) (a seemingly equivalent rewrite - both match the exact same strings)
  5. Re-run the same match — result["service.version"] is now "" instead of nil, so the if result[replacement] branch is taken, and result["service.cpe23"] ends up missing the - placeholder entirely

Code that reproduces the behavior:

# with (.+)? - correct
result["service.version"] #=> nil
result["service.cpe23"]   #=> "cpe:/a:litespeedtech:litespeed_web_server:-"

# with (.*) - incorrect
result["service.version"] #=> ""
result["service.cpe23"]   #=> "cpe:/a:litespeedtech:litespeed_web_server:"

Matcher that reproduces the behavior:

<fingerprint pattern="^LiteSpeed\/?([\d.]+)?(?: \S+)?">
  <description>LiteSpeed</description>
  <example>LiteSpeed Enterprise</example>
  <param pos="0" name="service.vendor" value="LiteSpeed Technologies"/>
  <param pos="0" name="service.product" value="LiteSpeed Web Server"/>
  <param pos="1" name="service.version"/>
  <param pos="0" name="service.cpe23" value="cpe:/a:litespeedtech:litespeed_web_server:{service.version}"/>
</fingerprint>

Matching this against "LiteSpeed Enterprise" (no version present) reproduces the behavior above.

Expected behavior
An unmatched optional capture should be treated the same for interpolation purposes whether the underlying regex produces nil or "" - i.e. the cpe23 value should get the - placeholder in both cases, since no version is actually present. Suggested fix: treat an empty captured string the same as a nil capture when deciding whether to interpolate:

if result[replacement] && !result[replacement].empty?

Environment

  • Operating System: N/A (pure Ruby logic bug, not OS-dependent)
  • Ruby Version: 3.3.8
  • Recog Version: current main (relevant code: lib/recog/fingerprint.rb, lines 112-130)

Additional context
Found while reviewing rapid7/recog#662, which proposed a blanket (.+)?(.*) substitution across the XML database to quiet Ruby 3.3's "nested repeat operator" warning. That PR was closed in favor of rapid7/recog#682, which audits each pattern individually instead. This issue tracks the underlying fingerprint.rb bug separately, as requested by @cdelafuente-r7: https://github.com/rapid7/recog/issues/662#issuecomment-5207469937

Happy to submit a PR for this fix.

Dominant language
Ruby
Stars
10
Forks
9
PR merge metrics
No merged PRs in 30d

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Similar issues

More Ruby issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.