Skip to content

Commit 4ce95f5

Browse files
committed
Updates
1 parent 94d5038 commit 4ce95f5

37 files changed

Lines changed: 2346 additions & 0 deletions

lib/asrfacet_rb.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# frozen_string_literal: true
2+
# For use only on systems you own or have explicit
3+
# written authorization to test.
24
# SPDX-License-Identifier: Proprietary
35
#
46
# ASRFacet-Rb: Attack Surface Reconnaissance Framework
@@ -90,6 +92,31 @@
9092
require_relative "asrfacet_rb/intelligence/analysis/relationship_mapper"
9193
require_relative "asrfacet_rb/intelligence/analysis/attack_surface"
9294
require_relative "asrfacet_rb/intelligence/analysis/asset_differ"
95+
require_relative "asrfacet_rb/scanner/timing"
96+
require_relative "asrfacet_rb/scanner/results/port_result"
97+
require_relative "asrfacet_rb/scanner/results/host_result"
98+
require_relative "asrfacet_rb/scanner/results/scan_result"
99+
require_relative "asrfacet_rb/scanner/verbose_logger"
100+
require_relative "asrfacet_rb/scanner/probe_db"
101+
require_relative "asrfacet_rb/scanner/probes/tcp_prober"
102+
require_relative "asrfacet_rb/scanner/probes/udp_prober"
103+
require_relative "asrfacet_rb/scanner/probes/icmp_prober"
104+
require_relative "asrfacet_rb/scanner/version_detector"
105+
require_relative "asrfacet_rb/scanner/fingerprint_engine"
106+
require_relative "asrfacet_rb/scanner/scan_context"
107+
require_relative "asrfacet_rb/scanner/scan_types/base_scan"
108+
require_relative "asrfacet_rb/scanner/scan_types/connect_scan"
109+
require_relative "asrfacet_rb/scanner/scan_types/syn_scan"
110+
require_relative "asrfacet_rb/scanner/scan_types/udp_scan"
111+
require_relative "asrfacet_rb/scanner/scan_types/ack_scan"
112+
require_relative "asrfacet_rb/scanner/scan_types/fin_scan"
113+
require_relative "asrfacet_rb/scanner/scan_types/null_scan"
114+
require_relative "asrfacet_rb/scanner/scan_types/xmas_scan"
115+
require_relative "asrfacet_rb/scanner/scan_types/window_scan"
116+
require_relative "asrfacet_rb/scanner/scan_types/maimon_scan"
117+
require_relative "asrfacet_rb/scanner/scan_types/ping_scan"
118+
require_relative "asrfacet_rb/scanner/scan_types/service_scan"
119+
require_relative "asrfacet_rb/scanner/scan_engine"
93120
require_relative "asrfacet_rb/plugins/base"
94121
require_relative "asrfacet_rb/plugins/dns_enrich_plugin"
95122
require_relative "asrfacet_rb/passive/base_source"
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 FingerprintEngine
20+
def initialize(tcp_prober:, timeout: 1.0)
21+
@tcp_prober = tcp_prober
22+
@timeout = timeout
23+
end
24+
25+
def detect_os_for(target)
26+
fingerprint = @tcp_prober.fingerprint(host: target, timeout: @timeout)
27+
return unknown unless fingerprint
28+
29+
ttl = normalize_ttl(fingerprint[:ttl].to_i)
30+
window = fingerprint[:window].to_i
31+
options = Array(fingerprint[:tcp_options]).map(&:to_sym)
32+
rst_behavior = fingerprint[:rst_behavior].to_sym
33+
ip_pattern = classify_ip_id(Array(fingerprint[:ip_id_sequence]))
34+
35+
if ttl <= 64
36+
linux_guess(window, options, ip_pattern, rst_behavior)
37+
elsif ttl <= 128
38+
windows_guess(window, options, ip_pattern, rst_behavior)
39+
else
40+
network_guess(window, options, ip_pattern, rst_behavior)
41+
end
42+
end
43+
44+
private
45+
46+
def unknown
47+
{ os: "unknown", accuracy: 0, type: "unknown", vendor: "unknown", family: "unknown", cpe: nil }
48+
end
49+
50+
def normalize_ttl(ttl)
51+
return 32 if ttl.positive? && ttl <= 32
52+
return 64 if ttl <= 64
53+
return 128 if ttl <= 128
54+
55+
255
56+
end
57+
58+
def classify_ip_id(sequence)
59+
return :unknown if sequence.length < 2
60+
61+
deltas = sequence.each_cons(2).map { |left, right| right.to_i - left.to_i }
62+
return :incremental if deltas.all? { |delta| delta == 1 }
63+
return :random_positive if deltas.all?(&:positive?) && deltas.uniq.length > 1
64+
65+
:randomized
66+
end
67+
68+
def linux_guess(window, options, ip_pattern, rst_behavior)
69+
accuracy = 70
70+
accuracy += 10 if options.include?(:timestamp)
71+
accuracy += 5 if ip_pattern == :incremental
72+
accuracy += 5 if rst_behavior == :rst
73+
accuracy += 5 if window >= 29_200
74+
{ os: "Linux", accuracy: accuracy, type: "general purpose", vendor: "Linux", family: "Linux", cpe: "cpe:/o:linux:linux_kernel" }
75+
end
76+
77+
def windows_guess(window, options, ip_pattern, rst_behavior)
78+
accuracy = 68
79+
accuracy += 10 if window >= 8_192
80+
accuracy += 7 if options.include?(:window_scale)
81+
accuracy += 5 if rst_behavior == :rst
82+
accuracy += 5 if ip_pattern != :randomized
83+
{ os: "Windows", accuracy: accuracy, type: "general purpose", vendor: "Microsoft", family: "Windows", cpe: "cpe:/o:microsoft:windows" }
84+
end
85+
86+
def network_guess(window, options, ip_pattern, rst_behavior)
87+
accuracy = 60
88+
accuracy += 10 if window.zero?
89+
accuracy += 8 if options.include?(:mss)
90+
accuracy += 5 if ip_pattern == :incremental
91+
accuracy += 5 if rst_behavior == :rst
92+
{ os: "Network device", accuracy: accuracy, type: "network infrastructure", vendor: "Unknown", family: "Embedded", cpe: nil }
93+
end
94+
end
95+
end
96+
end
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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

Comments
 (0)