|
| 1 | +# frozen_string_literal: true |
| 2 | +# For use only on systems you own or have explicit |
| 3 | +# written authorization to test. |
| 4 | +# SPDX-License-Identifier: Proprietary |
| 5 | +# |
| 6 | +# ASRFacet-Rb: Attack Surface Reconnaissance Framework |
| 7 | +# Copyright (c) 2026 voltsparx |
| 8 | +# |
| 9 | +# Author: voltsparx |
| 10 | +# Repository: https://github.com/voltsparx/ASRFacet-Rb |
| 11 | +# Contact: voltsparx@gmail.com |
| 12 | +# License: See LICENSE file in the project root |
| 13 | +# |
| 14 | +# This file is part of ASRFacet-Rb and is subject to the terms |
| 15 | +# and conditions defined in the LICENSE file. |
| 16 | + |
| 17 | +module ASRFacet |
| 18 | + module Scanner |
| 19 | + class ProbeDB |
| 20 | + Probe = Struct.new(:name, :proto, :probe_str, :rarity, :wait_ms, :ports, :ssl_ports, :matches, :softmatches, keyword_init: true) do |
| 21 | + def matches_port?(port) |
| 22 | + ports.include?(port.to_i) || ssl_ports.include?(port.to_i) |
| 23 | + end |
| 24 | + |
| 25 | + def null_probe? |
| 26 | + name == "NULL" |
| 27 | + end |
| 28 | + |
| 29 | + def to_h |
| 30 | + { |
| 31 | + name: name, |
| 32 | + proto: proto, |
| 33 | + probe_str: probe_str, |
| 34 | + rarity: rarity, |
| 35 | + wait_ms: wait_ms, |
| 36 | + ports: ports, |
| 37 | + ssl_ports: ssl_ports, |
| 38 | + matches: matches, |
| 39 | + softmatches: softmatches |
| 40 | + } |
| 41 | + end |
| 42 | + end |
| 43 | + |
| 44 | + ROOT = File.expand_path("../../../temp/nmap", __dir__) |
| 45 | + SERVICES_PATH = File.join(ROOT, "nmap-services") |
| 46 | + PROBES_PATH = File.join(ROOT, "nmap-service-probes") |
| 47 | + |
| 48 | + class << self |
| 49 | + private |
| 50 | + |
| 51 | + def load_services |
| 52 | + top_ports = [] |
| 53 | + lookup = {} |
| 54 | + |
| 55 | + File.foreach(SERVICES_PATH) do |line| |
| 56 | + next if line.start_with?("#") |
| 57 | + |
| 58 | + parts = line.split |
| 59 | + next if parts.length < 2 |
| 60 | + |
| 61 | + service = parts[0] |
| 62 | + port_proto = parts[1] |
| 63 | + port_string, proto_string = port_proto.split("/", 2) |
| 64 | + next unless port_string && proto_string |
| 65 | + |
| 66 | + frequency = parts[2].to_f |
| 67 | + entry = { |
| 68 | + port: port_string.to_i, |
| 69 | + proto: proto_string.downcase.to_sym, |
| 70 | + service: service, |
| 71 | + frequency: frequency |
| 72 | + } |
| 73 | + lookup[[entry[:port], entry[:proto]]] = service |
| 74 | + top_ports << entry |
| 75 | + end |
| 76 | + |
| 77 | + [top_ports.sort_by { |entry| [-entry[:frequency], entry[:port], entry[:proto].to_s] }.first(1000).freeze, lookup.freeze] |
| 78 | + end |
| 79 | + |
| 80 | + def load_probes |
| 81 | + probes = [] |
| 82 | + current = nil |
| 83 | + |
| 84 | + File.foreach(PROBES_PATH, mode: "rb") do |raw_line| |
| 85 | + line = raw_line.sub(/\r?\n\z/, "").force_encoding(Encoding::BINARY) |
| 86 | + next if line.empty? || line.start_with?("#") |
| 87 | + |
| 88 | + if line.start_with?("Probe ") |
| 89 | + probes << current if current |
| 90 | + current = parse_probe_header(line) |
| 91 | + elsif line.start_with?("ports ") |
| 92 | + current[:ports] = expand_ports(line.delete_prefix("ports ").strip) |
| 93 | + elsif line.start_with?("sslports ") |
| 94 | + current[:ssl_ports] = expand_ports(line.delete_prefix("sslports ").strip) |
| 95 | + elsif line.start_with?("rarity ") |
| 96 | + current[:rarity] = line.delete_prefix("rarity ").to_i |
| 97 | + elsif line.start_with?("totalwaitms ") |
| 98 | + current[:wait_ms] = line.delete_prefix("totalwaitms ").to_i |
| 99 | + elsif line.start_with?("match ") |
| 100 | + current[:matches] << parse_match_line(line, soft: false) |
| 101 | + elsif line.start_with?("softmatch ") |
| 102 | + current[:softmatches] << parse_match_line(line, soft: true) |
| 103 | + end |
| 104 | + end |
| 105 | + |
| 106 | + probes << current if current |
| 107 | + probes.map { |entry| Probe.new(**entry) }.freeze |
| 108 | + end |
| 109 | + |
| 110 | + def parse_probe_header(line) |
| 111 | + _, proto, name, quoted = line.split(/\s+/, 4) |
| 112 | + _, payload, = extract_delimited(quoted.delete_prefix("q")) |
| 113 | + { |
| 114 | + name: name, |
| 115 | + proto: proto.downcase.to_sym, |
| 116 | + probe_str: decode_escapes(payload), |
| 117 | + rarity: 5, |
| 118 | + wait_ms: 5000, |
| 119 | + ports: [], |
| 120 | + ssl_ports: [], |
| 121 | + matches: [], |
| 122 | + softmatches: [] |
| 123 | + } |
| 124 | + end |
| 125 | + |
| 126 | + def parse_match_line(line, soft:) |
| 127 | + keyword, service, matcher = line.split(/\s+/, 3) |
| 128 | + raw_pattern = matcher.delete_prefix("m") |
| 129 | + pattern_source, remainder = extract_delimited(raw_pattern) |
| 130 | + flags = remainder.to_s[/\A([a-z]*)/, 1].to_s |
| 131 | + metadata = remainder.to_s.sub(/\A[a-z]*\s*/, "") |
| 132 | + { |
| 133 | + soft: soft || keyword == "softmatch", |
| 134 | + service: service, |
| 135 | + pattern_source: pattern_source, |
| 136 | + pattern_flags: flags, |
| 137 | + metadata: parse_metadata(metadata) |
| 138 | + } |
| 139 | + end |
| 140 | + |
| 141 | + def parse_metadata(metadata) |
| 142 | + { |
| 143 | + product: capture_token(metadata, "p"), |
| 144 | + version: capture_token(metadata, "v"), |
| 145 | + extra: capture_token(metadata, "i"), |
| 146 | + hostname: capture_token(metadata, "h"), |
| 147 | + os: capture_token(metadata, "o"), |
| 148 | + device: capture_token(metadata, "d"), |
| 149 | + cpes: metadata.scan(%r{cpe:/((?:\\.|[^/])*)/}).flatten |
| 150 | + } |
| 151 | + end |
| 152 | + |
| 153 | + def capture_token(text, key) |
| 154 | + text[/#{Regexp.escape(key)}\/((?:\\.|[^\/])*)\//, 1] |
| 155 | + end |
| 156 | + |
| 157 | + def extract_delimited(text) |
| 158 | + delimiter = text[0] |
| 159 | + buffer = +"" |
| 160 | + escaped = false |
| 161 | + index = 1 |
| 162 | + |
| 163 | + while index < text.length |
| 164 | + char = text[index] |
| 165 | + if escaped |
| 166 | + buffer << char |
| 167 | + escaped = false |
| 168 | + elsif char == "\\" |
| 169 | + buffer << char |
| 170 | + escaped = true |
| 171 | + elsif char == delimiter |
| 172 | + return [delimiter, buffer, text[(index + 1)..]] |
| 173 | + else |
| 174 | + buffer << char |
| 175 | + end |
| 176 | + index += 1 |
| 177 | + end |
| 178 | + |
| 179 | + [delimiter, buffer, nil] |
| 180 | + end |
| 181 | + |
| 182 | + def decode_escapes(text) |
| 183 | + output = +"" |
| 184 | + index = 0 |
| 185 | + |
| 186 | + while index < text.length |
| 187 | + char = text[index] |
| 188 | + if char != "\\" |
| 189 | + output << char |
| 190 | + index += 1 |
| 191 | + next |
| 192 | + end |
| 193 | + |
| 194 | + token = text[index + 1] |
| 195 | + case token |
| 196 | + when "r" then output << "\r" |
| 197 | + when "n" then output << "\n" |
| 198 | + when "t" then output << "\t" |
| 199 | + when "0" then output << "\0" |
| 200 | + when "\\" then output << "\\" |
| 201 | + when "x" |
| 202 | + output << text[(index + 2), 2].to_i(16).chr(Encoding::BINARY) |
| 203 | + index += 2 |
| 204 | + else |
| 205 | + output << token.to_s |
| 206 | + end |
| 207 | + index += 2 |
| 208 | + end |
| 209 | + |
| 210 | + output.force_encoding(Encoding::BINARY) |
| 211 | + end |
| 212 | + |
| 213 | + def expand_ports(spec) |
| 214 | + spec.split(",").flat_map do |segment| |
| 215 | + if segment.include?("-") |
| 216 | + first_port, last_port = segment.split("-", 2).map(&:to_i) |
| 217 | + (first_port..last_port).to_a |
| 218 | + else |
| 219 | + segment.to_i |
| 220 | + end |
| 221 | + end.uniq |
| 222 | + end |
| 223 | + end |
| 224 | + |
| 225 | + TOP_PORTS, SERVICE_LOOKUP = send(:load_services) |
| 226 | + PROBES = send(:load_probes) |
| 227 | + |
| 228 | + def probes_for(port, proto) |
| 229 | + normalized_proto = proto.to_sym |
| 230 | + matching, fallback = PROBES.select { |entry| entry.proto == normalized_proto }.partition { |entry| entry.matches_port?(port) || entry.null_probe? } |
| 231 | + matching + fallback |
| 232 | + end |
| 233 | + |
| 234 | + def service_for(port, proto) |
| 235 | + SERVICE_LOOKUP[[port.to_i, proto.to_sym]] || "unknown" |
| 236 | + end |
| 237 | + |
| 238 | + def top_ports(count) |
| 239 | + TOP_PORTS.first(count.to_i) |
| 240 | + end |
| 241 | + end |
| 242 | + end |
| 243 | +end |
0 commit comments