pwn 0.5.618 → 0.5.621

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/README.md +14 -6
  4. data/bin/pwn_gqrx_scanner +25 -5
  5. data/documentation/Agent-Tool-Registry.md +4 -4
  6. data/documentation/Cron.md +13 -0
  7. data/documentation/Diagrams.md +6 -2
  8. data/documentation/Extrospection.md +96 -11
  9. data/documentation/Home.md +5 -4
  10. data/documentation/How-PWN-Works.md +7 -4
  11. data/documentation/Mistakes.md +97 -0
  12. data/documentation/Persistence.md +4 -1
  13. data/documentation/SDR.md +23 -5
  14. data/documentation/Skills-Memory-Learning.md +16 -5
  15. data/documentation/Transparent-Browser.md +18 -1
  16. data/documentation/diagrams/agent-tool-registry.svg +103 -100
  17. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  18. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  19. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  20. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  21. data/documentation/diagrams/cron-scheduling.svg +23 -23
  22. data/documentation/diagrams/dot/agent-tool-registry.dot +3 -3
  23. data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -10
  24. data/documentation/diagrams/dot/memory-skills-detailed.dot +16 -5
  25. data/documentation/diagrams/dot/mistakes-negative-feedback.dot +93 -0
  26. data/documentation/diagrams/dot/overall-pwn-architecture.dot +7 -5
  27. data/documentation/diagrams/dot/persistence-filesystem.dot +4 -2
  28. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +25 -7
  29. data/documentation/diagrams/dot/sdr-radio-flow.dot +13 -11
  30. data/documentation/diagrams/driver-framework.svg +13 -13
  31. data/documentation/diagrams/extrospection-world-awareness.svg +241 -93
  32. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  33. data/documentation/diagrams/hardware-hacking.svg +18 -18
  34. data/documentation/diagrams/history-to-drivers.svg +18 -18
  35. data/documentation/diagrams/memory-skills-detailed.svg +169 -97
  36. data/documentation/diagrams/mistakes-negative-feedback.svg +266 -0
  37. data/documentation/diagrams/network-infra-testing.svg +27 -27
  38. data/documentation/diagrams/overall-pwn-architecture.svg +184 -173
  39. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  40. data/documentation/diagrams/persistence-filesystem.svg +111 -87
  41. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  42. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +263 -149
  43. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  44. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  45. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  46. data/documentation/diagrams/sdr-radio-flow.svg +76 -56
  47. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  48. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  49. data/documentation/diagrams/web-application-testing.svg +26 -26
  50. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  51. data/documentation/pwn-ai-Agent.md +28 -15
  52. data/lib/pwn/ai/agent/extrospection.rb +494 -6
  53. data/lib/pwn/ai/agent/learning.rb +67 -7
  54. data/lib/pwn/ai/agent/loop.rb +72 -6
  55. data/lib/pwn/ai/agent/mistakes.rb +369 -0
  56. data/lib/pwn/ai/agent/prompt_builder.rb +10 -1
  57. data/lib/pwn/ai/agent/tools/extrospection.rb +88 -3
  58. data/lib/pwn/ai/agent/tools/mistakes.rb +132 -0
  59. data/lib/pwn/ai/agent.rb +1 -0
  60. data/lib/pwn/plugins/jira_data_center.rb +2 -2
  61. data/lib/pwn/sdr/decoder/adsb.rb +82 -0
  62. data/lib/pwn/sdr/decoder/apt.rb +136 -0
  63. data/lib/pwn/sdr/decoder/base.rb +258 -171
  64. data/lib/pwn/sdr/decoder/bluetooth.rb +71 -0
  65. data/lib/pwn/sdr/decoder/dect.rb +70 -0
  66. data/lib/pwn/sdr/decoder/dsp.rb +396 -0
  67. data/lib/pwn/sdr/decoder/flex.rb +177 -210
  68. data/lib/pwn/sdr/decoder/gps.rb +74 -0
  69. data/lib/pwn/sdr/decoder/gsm.rb +31 -62
  70. data/lib/pwn/sdr/decoder/iridium.rb +66 -0
  71. data/lib/pwn/sdr/decoder/lora.rb +80 -0
  72. data/lib/pwn/sdr/decoder/lte.rb +67 -0
  73. data/lib/pwn/sdr/decoder/morse.rb +138 -0
  74. data/lib/pwn/sdr/decoder/p25.rb +74 -0
  75. data/lib/pwn/sdr/decoder/pager.rb +61 -0
  76. data/lib/pwn/sdr/decoder/pocsag.rb +176 -54
  77. data/lib/pwn/sdr/decoder/rfid.rb +79 -0
  78. data/lib/pwn/sdr/decoder/rtl433.rb +86 -0
  79. data/lib/pwn/sdr/decoder/rtty.rb +139 -0
  80. data/lib/pwn/sdr/decoder/wifi.rb +77 -0
  81. data/lib/pwn/sdr/decoder/zigbee.rb +74 -0
  82. data/lib/pwn/sdr/decoder.rb +75 -7
  83. data/lib/pwn/sdr/frequency_allocation.rb +82 -41
  84. data/lib/pwn/sdr/gqrx.rb +186 -95
  85. data/lib/pwn/version.rb +1 -1
  86. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +36 -0
  87. data/spec/lib/pwn/ai/agent/tools/mistakes_spec.rb +12 -0
  88. data/spec/lib/pwn/sdr/decoder/adsb_spec.rb +15 -0
  89. data/spec/lib/pwn/sdr/decoder/apt_spec.rb +15 -0
  90. data/spec/lib/pwn/sdr/decoder/bluetooth_spec.rb +15 -0
  91. data/spec/lib/pwn/sdr/decoder/dect_spec.rb +15 -0
  92. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
  93. data/spec/lib/pwn/sdr/decoder/gps_spec.rb +15 -0
  94. data/spec/lib/pwn/sdr/decoder/iridium_spec.rb +15 -0
  95. data/spec/lib/pwn/sdr/decoder/lora_spec.rb +15 -0
  96. data/spec/lib/pwn/sdr/decoder/lte_spec.rb +15 -0
  97. data/spec/lib/pwn/sdr/decoder/morse_spec.rb +15 -0
  98. data/spec/lib/pwn/sdr/decoder/p25_spec.rb +15 -0
  99. data/spec/lib/pwn/sdr/decoder/pager_spec.rb +15 -0
  100. data/spec/lib/pwn/sdr/decoder/rfid_spec.rb +15 -0
  101. data/spec/lib/pwn/sdr/decoder/rtl433_spec.rb +15 -0
  102. data/spec/lib/pwn/sdr/decoder/rtty_spec.rb +15 -0
  103. data/spec/lib/pwn/sdr/decoder/wifi_spec.rb +15 -0
  104. data/spec/lib/pwn/sdr/decoder/zigbee_spec.rb +15 -0
  105. data/third_party/pwn_rdoc.jsonl +115 -5
  106. metadata +46 -6
  107. data/README.md.bak +0 -200
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'open3'
5
4
  require 'tty-spinner'
6
5
  require 'tty-screen'
7
6
  require 'io/wait'
@@ -9,199 +8,205 @@ require 'io/wait'
9
8
  module PWN
10
9
  module SDR
11
10
  module Decoder
12
- # Shared pipeline plumbing for every PWN::SDR::Decoder::* module.
11
+ # Shared, 100 % Ruby-native pipeline plumbing for every
12
+ # PWN::SDR::Decoder::* module.
13
13
  #
14
- # Encapsulates the pattern established by PWN::SDR::Decoder::Flex and
15
- # PWN::SDR::Decoder::RDS:
14
+ # Two entry points, neither of which shell out to any external binary:
16
15
  #
17
- # 1. Bind to the GQRX UDP audio stream (48 kHz s16le mono).
18
- # 2. Feed it through `sox` (resample to 22 050 Hz) into an external
19
- # demodulator (multimon-ng, rtl_433, acarsdec, dsd, ...).
20
- # 3. Read decoded lines back on a background thread, hand each to a
21
- # caller-supplied parser Proc, merge with freq_obj, JSON-log it.
22
- # 4. Show a TTY::Spinner status line and exit cleanly on [ENTER].
16
+ # run_native — Bind the GQRX 48 kHz s16le mono UDP audio tap, unpack
17
+ # the samples with PWN::SDR::Decoder::DSP, hand each
18
+ # chunk to a caller-supplied `demod:` object that
19
+ # responds to `#feed(samples, &emit)`. Every Hash the
20
+ # demodulator emits is merged with freq_obj, JSON-
21
+ # pretty-printed, JSONL-logged, and shown on the
22
+ # spinner. [ENTER] stops cleanly.
23
23
  #
24
- # Decoders whose external tool needs raw I/Q direct from the SDR (ADSB,
25
- # GSM, LoRa, WiFi, ZigBee, ...) can instead pass `direct_cmd:` — Base
26
- # will spawn it stand-alone (no UDP sox bridge) and still handle the
27
- # spinner / stdout parser / logging / [ENTER]-to-skip loop uniformly.
24
+ # run_detector For protocols whose bit-rate/bandwidth cannot be
25
+ # recovered from a 48 kHz demodulated-audio tap (GSM,
26
+ # LTE, ADS-B, WiFi, LoRa, GPS, DECT, ZigBee, Bluetooth,
27
+ # Iridium, P25, ISM/RFID …). Pure-Ruby energy / burst
28
+ # characteriser: polls GQRX `l STRENGTH`, and (when the
29
+ # UDP tap is enabled) computes RMS-dBFS on the audio.
30
+ # Emits `{event: 'burst', dbfs:, duration_ms:}` frames
31
+ # whenever the signal crosses an adaptive threshold, so
32
+ # the operator still gets structured, logged intel
33
+ # without ANY external decoding binary.
28
34
  module Base
29
35
  # Supported Method Parameters::
30
- # PWN::SDR::Decoder::Base.run_pipeline(
31
- # freq_obj: 'required - freq_obj Hash from PWN::SDR::GQRX.init_freq',
32
- # protocol: 'required - short name used in banner / log filename',
33
- # decode_cmd: 'optional - shell pipeline reading 22 050 Hz s16le on stdin',
34
- # direct_cmd: 'optional - stand-alone shell cmd (owns the SDR itself)',
35
- # line_match: 'optional - Regexp/String a stdout line must match',
36
- # parser: 'optional - Proc.new { |line| Hash } to structure a line',
37
- # required_bins: 'optional - Array of executables that must exist on PATH',
38
- # resample_hz: 'optional - sox output rate for decode_cmd (default 22050)'
36
+ # PWN::SDR::Decoder::Base.run_native(
37
+ # freq_obj: 'required - freq_obj Hash from PWN::SDR::GQRX.init_freq',
38
+ # protocol: 'required - short name for banner / log filename',
39
+ # demod: 'required - object responding to #feed(samples,&emit)',
40
+ # rate: 'optional - assumed UDP sample rate (default 48000)'
39
41
  # )
40
42
 
41
- public_class_method def self.run_pipeline(opts = {})
42
- freq_obj = opts[:freq_obj]
43
- protocol = opts[:protocol] || 'SIGNAL'
44
- decode_cmd = opts[:decode_cmd]
45
- direct_cmd = opts[:direct_cmd]
46
- line_match = opts[:line_match]
47
- parser = opts[:parser]
48
- required_bins = Array(opts[:required_bins])
49
- resample_hz = opts[:resample_hz] || 22_050
43
+ public_class_method def self.run_native(opts = {})
44
+ freq_obj = opts[:freq_obj]
45
+ protocol = opts[:protocol] || 'SIGNAL'
46
+ demod = opts[:demod]
47
+ rate = (opts[:rate] || 48_000).to_i
50
48
 
51
49
  raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
50
+ raise 'ERROR: :demod must respond to #feed' unless demod.respond_to?(:feed)
52
51
 
53
- gqrx_sock = freq_obj[:gqrx_sock]
54
- udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
55
- udp_port = freq_obj[:udp_port] || 7355
56
- freq_obj = freq_obj.dup
57
- freq_obj.delete(:gqrx_sock)
58
- freq_obj.delete(:decoder_module)
59
-
60
- skip_freq_char = "\n"
52
+ udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
53
+ udp_port = freq_obj[:udp_port] || 7355
54
+ log_obj = strip_freq_obj(freq_obj: freq_obj)
61
55
 
62
- puts JSON.pretty_generate(freq_obj)
63
- puts "\n*** #{protocol} Decoder ***"
56
+ puts JSON.pretty_generate(log_obj)
57
+ puts "\n*** #{protocol} Decoder (ruby-native) ***"
64
58
  puts 'Press [ENTER] to continue to next frequency...'
65
59
 
66
- missing = required_bins.reject { |b| bin_available?(bin: b) }
67
- unless missing.empty?
68
- puts "[!] Missing required executable(s): #{missing.join(', ')}"
69
- puts ' Install them and re-run, or press [ENTER] to skip.'
70
- end
71
-
72
- spinner = TTY::Spinner.new(
73
- '[:spinner] :status',
74
- format: :arrow_pulse,
75
- clear: true,
76
- hide_cursor: true
60
+ spinner, max_len = build_spinner(
61
+ banner: "INFO: Decoding #{protocol} on udp://#{udp_ip}:#{udp_port} @ #{rate} Hz (native)"
77
62
  )
78
- spinner_overhead = 12
79
- max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
80
- banner = "INFO: Decoding #{protocol} on udp://#{udp_ip}:#{udp_port} ..."
81
- banner = "INFO: Decoding #{protocol} via `#{direct_cmd.to_s.split.first}` ..." if direct_cmd
82
- banner = banner[0...max_title_length] if banner.length > max_title_length
83
- spinner.update(status: banner)
84
- spinner.auto_spin
85
-
86
- log_file = "/tmp/#{protocol.downcase.gsub(/[^a-z0-9]+/, '_')}_decoder_#{Time.now.strftime('%Y%m%d')}.log"
87
-
88
- udp_listener = nil
89
- receiver_thread = nil
90
- mm_stdin = mm_stdout = mm_stderr = mm_wait_thr = nil
63
+ log_file = log_path(protocol: protocol)
91
64
 
92
- if direct_cmd && missing.empty?
93
- mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(direct_cmd)
94
- elsif decode_cmd && missing.empty?
95
- udp_listener = PWN::SDR::GQRX.listen_udp(udp_ip: udp_ip, udp_port: udp_port)
65
+ udp_listener = PWN::SDR::GQRX.listen_udp(udp_ip: udp_ip, udp_port: udp_port)
66
+ audio_q = Queue.new
67
+ current_line = 'Waiting for data frames...'
96
68
 
97
- full_cmd = 'sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - ' \
98
- "-t raw -e signed-integer -b 16 -r #{resample_hz} -c 1 - | #{decode_cmd}"
99
- mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(full_cmd)
100
-
101
- receiver_thread = Thread.new do
102
- loop do
103
- data, = udp_listener.recv(4096)
104
- next unless data.to_s.bytesize.positive?
105
-
106
- mm_stdin.write(data)
107
- begin
108
- mm_stdin.flush
109
- rescue StandardError
110
- nil
111
- end
112
- end
113
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
114
- nil
69
+ receiver_thread = Thread.new do
70
+ loop do
71
+ data, = udp_listener.recv(4096)
72
+ audio_q.push(data) if data.to_s.bytesize.positive?
115
73
  end
74
+ rescue IOError, Errno::ECONNRESET, Errno::EBADF
75
+ nil
116
76
  end
117
77
 
118
- current_title = 'Waiting for data frames...'
119
- decoder_thread = nil
120
- if mm_stdout
121
- decoder_thread = Thread.new do
122
- buffer = ''
123
- loop do
124
- chunk = mm_stdout.readpartial(4096)
125
- buffer = "#{buffer}#{chunk}"
126
- while (line = buffer.slice!(/^.*\n/))
127
- line = line.chomp
128
- next if line.empty?
129
- next if line_match && !match_line?(line: line, matcher: line_match)
130
-
131
- dec_msg = { decoded_at: Time.now.strftime('%Y-%m-%d %H:%M:%S%z'), raw: line }
132
- dec_msg.merge!(parser.call(line)) if parser.respond_to?(:call)
133
- final_msg = freq_obj.merge(dec_msg)
134
-
135
- spinner.stop
136
- puts JSON.pretty_generate(final_msg)
137
- spinner.auto_spin
138
-
139
- File.open(log_file, 'a') { |f| f.puts("#{JSON.generate(final_msg)},") }
140
- disp = dec_msg[:summary] || line
141
- current_title = disp[0...max_title_length]
142
- end
143
- rescue IOError
144
- break
145
- end
78
+ decoder_thread = Thread.new do
79
+ emit = proc do |msg|
80
+ next unless msg.is_a?(Hash)
81
+
82
+ final = log_obj.merge(decoded_at: Time.now.strftime('%Y-%m-%d %H:%M:%S%z')).merge(msg)
83
+ spinner.stop
84
+ puts JSON.pretty_generate(final)
85
+ spinner.auto_spin
86
+ File.open(log_file, 'a') { |f| f.puts("#{JSON.generate(final)},") }
87
+ disp = (msg[:summary] || msg[:raw] || msg.values.compact.first).to_s
88
+ current_line = disp[0...max_len]
146
89
  end
147
- elsif gqrx_sock
148
- # No external decoder — fall back to live signal-strength telemetry
149
- decoder_thread = Thread.new do
150
- loop do
151
- lvl = PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
152
- current_title = "signal #{format('%+.1f', lvl)} dBFS (analog / no external demod)"
153
- sleep 0.3
154
- end
155
- rescue StandardError
156
- nil
157
- end
158
- end
159
-
160
- loop do
161
- spinner.update(status: current_title)
162
- next unless $stdin.wait_readable(0)
163
-
164
- begin
165
- char = $stdin.read_nonblock(1)
166
- next unless char == skip_freq_char
167
-
168
- puts "\n[!] ENTER pressed → stopping #{protocol} decoder..."
169
- break
170
- rescue IO::WaitReadable, EOFError
171
- nil
90
+ loop do
91
+ raw = audio_q.pop
92
+ samples = PWN::SDR::Decoder::DSP.unpack_s16le(data: raw)
93
+ demod.feed(samples, &emit)
172
94
  end
95
+ rescue StandardError => e
96
+ current_line = "demod error: #{e.class}: #{e.message}"
173
97
  end
174
98
 
99
+ wait_for_enter(spinner: spinner, title_ref: -> { current_line })
175
100
  spinner.success('Decoding stopped')
176
101
  rescue StandardError => e
177
- spinner.error("Decoding failed: #{e.message}") if defined?(spinner) && spinner
102
+ spinner&.error("Decoding failed: #{e.message}") if defined?(spinner)
178
103
  raise
179
104
  ensure
180
- [receiver_thread, decoder_thread].compact.each { |t| t.kill if t.alive? }
181
- [mm_stdin, mm_stdout, mm_stderr].compact.each do |io|
182
- io.close
183
- rescue StandardError
184
- nil
185
- end
186
- begin
187
- mm_wait_thr&.value
188
- rescue StandardError
189
- nil
190
- end
105
+ [receiver_thread, decoder_thread].compact.each { |t| t.kill if t&.alive? }
191
106
  PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if udp_listener
192
- spinner.stop if defined?(spinner) && spinner
107
+ spinner&.stop if defined?(spinner)
193
108
  end
194
109
 
195
110
  # Supported Method Parameters::
196
- # PWN::SDR::Decoder::Base.bin_available?(bin: 'multimon-ng')
111
+ # PWN::SDR::Decoder::Base.run_detector(
112
+ # freq_obj: 'required - freq_obj Hash from PWN::SDR::GQRX.init_freq',
113
+ # protocol: 'required - short name for banner / log filename',
114
+ # note: 'optional - one-line explanation shown once',
115
+ # threshold: 'optional - dBFS above rolling floor to call a burst (default 8.0)',
116
+ # describe: 'optional - Proc.new { |burst_hash| Hash } extra fields'
117
+ # )
197
118
 
198
- public_class_method def self.bin_available?(opts = {})
199
- bin = opts[:bin].to_s
200
- return false if bin.empty?
119
+ public_class_method def self.run_detector(opts = {})
120
+ freq_obj = opts[:freq_obj]
121
+ protocol = opts[:protocol] || 'SIGNAL'
122
+ note = opts[:note]
123
+ threshold = (opts[:threshold] || 8.0).to_f
124
+ describe = opts[:describe]
201
125
 
202
- ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
203
- File.executable?(File.join(dir, bin))
126
+ raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
127
+
128
+ gqrx_sock = freq_obj[:gqrx_sock]
129
+ udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
130
+ udp_port = freq_obj[:udp_port] || 7355
131
+ log_obj = strip_freq_obj(freq_obj: freq_obj)
132
+
133
+ puts JSON.pretty_generate(log_obj)
134
+ puts "\n*** #{protocol} Signal Detector (ruby-native) ***"
135
+ puts "[i] #{note}" if note
136
+ puts 'Press [ENTER] to continue to next frequency...'
137
+
138
+ spinner, max_len = build_spinner(
139
+ banner: "INFO: Characterising #{protocol} activity on #{log_obj[:freq] || "udp://#{udp_ip}:#{udp_port}"} (native)"
140
+ )
141
+ log_file = log_path(protocol: protocol)
142
+
143
+ udp_listener = begin
144
+ PWN::SDR::GQRX.listen_udp(udp_ip: udp_ip, udp_port: udp_port)
145
+ rescue StandardError
146
+ nil
204
147
  end
148
+
149
+ current_line = 'Establishing noise floor...'
150
+ floor = nil
151
+ in_burst = false
152
+ burst_t0 = nil
153
+ peak = -200.0
154
+ burst_n = 0
155
+
156
+ detector_thread = Thread.new do
157
+ emit = proc do |msg|
158
+ final = log_obj.merge(decoded_at: Time.now.strftime('%Y-%m-%d %H:%M:%S%z')).merge(msg)
159
+ spinner.stop
160
+ puts JSON.pretty_generate(final)
161
+ spinner.auto_spin
162
+ File.open(log_file, 'a') { |f| f.puts("#{JSON.generate(final)},") }
163
+ current_line = (msg[:summary] || '').to_s[0...max_len]
164
+ end
165
+
166
+ loop do
167
+ lvl = read_level(gqrx_sock: gqrx_sock, udp_listener: udp_listener)
168
+ if lvl
169
+ floor = floor.nil? ? lvl : ((floor * 0.98) + (lvl * 0.02))
170
+ delta = lvl - floor
171
+ if delta >= threshold
172
+ unless in_burst
173
+ in_burst = true
174
+ burst_t0 = Time.now
175
+ peak = lvl
176
+ end
177
+ peak = lvl if lvl > peak
178
+ current_line = format('%<p>s BURST %<l>+.1f dBFS (Δ%<d>+.1f, floor %<f>+.1f)', p: protocol, l: lvl, d: delta, f: floor)[0...max_len]
179
+ elsif in_burst
180
+ in_burst = false
181
+ burst_n += 1
182
+ dur_ms = ((Time.now - burst_t0) * 1000).round
183
+ msg = {
184
+ protocol: protocol, event: 'burst', burst_no: burst_n,
185
+ peak_dbfs: peak.round(1), floor_dbfs: floor.round(1),
186
+ delta_db: (peak - floor).round(1), duration_ms: dur_ms,
187
+ summary: format('%<p>s burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms', p: protocol, n: burst_n, pk: peak, d: peak - floor, ms: dur_ms)
188
+ }
189
+ msg.merge!(describe.call(msg)) if describe.respond_to?(:call)
190
+ emit.call(msg)
191
+ else
192
+ current_line = format('%<p>s idle %<l>+.1f dBFS (floor %<f>+.1f, Δ%<d>+.1f)', p: protocol, l: lvl, f: floor, d: delta)[0...max_len]
193
+ end
194
+ end
195
+ sleep(udp_listener ? 0 : 0.1)
196
+ end
197
+ rescue StandardError => e
198
+ current_line = "detector error: #{e.class}: #{e.message}"
199
+ end
200
+
201
+ wait_for_enter(spinner: spinner, title_ref: -> { current_line })
202
+ spinner.success('Detector stopped')
203
+ rescue StandardError => e
204
+ spinner&.error("Detector failed: #{e.message}") if defined?(spinner)
205
+ raise
206
+ ensure
207
+ detector_thread&.kill if defined?(detector_thread) && detector_thread&.alive?
208
+ PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if udp_listener
209
+ spinner&.stop if defined?(spinner)
205
210
  end
206
211
 
207
212
  # Supported Method Parameters::
@@ -218,6 +223,85 @@ module PWN
218
223
  end
219
224
  end
220
225
 
226
+ # ---------------------------------------------------------------
227
+ # Internals
228
+ # ---------------------------------------------------------------
229
+
230
+ # Supported Method Parameters::
231
+ # h = PWN::SDR::Decoder::Base.strip_freq_obj(freq_obj: {...})
232
+
233
+ private_class_method def self.strip_freq_obj(opts = {})
234
+ fo = opts[:freq_obj].dup
235
+ fo.delete(:gqrx_sock)
236
+ fo.delete(:decoder_module)
237
+ fo
238
+ end
239
+
240
+ # Supported Method Parameters::
241
+ # spinner, max_len = PWN::SDR::Decoder::Base.build_spinner(banner: '...')
242
+
243
+ private_class_method def self.build_spinner(opts = {})
244
+ banner = opts[:banner].to_s
245
+ spinner = TTY::Spinner.new('[:spinner] :status', format: :arrow_pulse, clear: true, hide_cursor: true)
246
+ overhead = 12
247
+ max_len = [TTY::Screen.width - overhead, 50].max
248
+ banner = banner[0...max_len] if banner.length > max_len
249
+ spinner.update(status: banner)
250
+ spinner.auto_spin
251
+ [spinner, max_len]
252
+ end
253
+
254
+ # Supported Method Parameters::
255
+ # path = PWN::SDR::Decoder::Base.log_path(protocol: 'POCSAG')
256
+
257
+ private_class_method def self.log_path(opts = {})
258
+ protocol = opts[:protocol].to_s
259
+ "/tmp/#{protocol.downcase.gsub(/[^a-z0-9]+/, '_')}_decoder_#{Time.now.strftime('%Y%m%d')}.log"
260
+ end
261
+
262
+ # Supported Method Parameters::
263
+ # lvl = PWN::SDR::Decoder::Base.read_level(gqrx_sock:, udp_listener:)
264
+
265
+ private_class_method def self.read_level(opts = {})
266
+ gqrx_sock = opts[:gqrx_sock]
267
+ udp_listener = opts[:udp_listener]
268
+ if udp_listener
269
+ begin
270
+ data, = udp_listener.recv(4096)
271
+ return PWN::SDR::Decoder::DSP.rms_dbfs(samples: PWN::SDR::Decoder::DSP.unpack_s16le(data: data)) if data.to_s.bytesize.positive?
272
+ rescue IOError, Errno::ECONNRESET, Errno::EBADF
273
+ nil
274
+ end
275
+ end
276
+ return nil unless gqrx_sock
277
+
278
+ PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
279
+ rescue StandardError
280
+ nil
281
+ end
282
+
283
+ # Supported Method Parameters::
284
+ # PWN::SDR::Decoder::Base.wait_for_enter(spinner:, title_ref:)
285
+
286
+ private_class_method def self.wait_for_enter(opts = {})
287
+ spinner = opts[:spinner]
288
+ title_ref = opts[:title_ref]
289
+ loop do
290
+ spinner.update(status: title_ref.call)
291
+ next unless $stdin.wait_readable(0)
292
+
293
+ begin
294
+ char = $stdin.read_nonblock(1)
295
+ next unless char == "\n"
296
+
297
+ puts "\n[!] ENTER pressed → stopping..."
298
+ break
299
+ rescue IO::WaitReadable, EOFError
300
+ nil
301
+ end
302
+ end
303
+ end
304
+
221
305
  # Author(s):: 0day Inc. <support@0dayinc.com>
222
306
 
223
307
  public_class_method def self.authors
@@ -227,19 +311,22 @@ module PWN
227
311
  # Display Usage for this Module
228
312
 
229
313
  public_class_method def self.help
230
- puts "USAGE:
231
- #{self}.run_pipeline(
232
- freq_obj: 'required - freq_obj from PWN::SDR::GQRX.init_freq',
233
- protocol: 'required - short protocol name',
234
- decode_cmd: 'optional - stdin pipeline (sox-resampled 22 050 Hz s16le)',
235
- direct_cmd: 'optional - stand-alone cmd that owns the SDR',
236
- line_match: 'optional - Regexp/String/Array line filter',
237
- parser: 'optional - Proc { |line| Hash } structured extractor',
238
- required_bins: 'optional - Array of executables that must be on PATH',
239
- resample_hz: 'optional - sox output rate (default 22050)'
314
+ puts "USAGE (100 % ruby-native — no external binaries):
315
+ #{self}.run_native(
316
+ freq_obj: 'required - freq_obj from PWN::SDR::GQRX.init_freq',
317
+ protocol: 'required - short protocol name',
318
+ demod: 'required - object with #feed(samples,&emit)',
319
+ rate: 'optional - UDP sample rate (default 48000)'
320
+ )
321
+
322
+ #{self}.run_detector(
323
+ freq_obj: 'required - freq_obj from PWN::SDR::GQRX.init_freq',
324
+ protocol: 'required - short protocol name',
325
+ note: 'optional - explanation shown to operator',
326
+ threshold: 'optional - dB above floor for burst (default 8.0)',
327
+ describe: 'optional - Proc { |burst| Hash } extra fields'
240
328
  )
241
329
 
242
- #{self}.bin_available?(bin: 'multimon-ng')
243
330
  #{self}.match_line?(line: str, matcher: Regexp|String|Array)
244
331
 
245
332
  #{self}.authors
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PWN
4
+ module SDR
5
+ module Decoder
6
+ # Pure-Ruby Bluetooth Classic (BR/EDR) & BLE activity detector.
7
+ #
8
+ # 1 Mbit/s GFSK with 79 (BR/EDR) or 40 (BLE) FHSS channels — native
9
+ # mode reports per-channel hop bursts and derives channel index from
10
+ # freq_obj. `parse_line` retained for offline text analysis.
11
+ module Bluetooth
12
+ # Supported Method Parameters::
13
+ # PWN::SDR::Decoder::Bluetooth.decode(
14
+ # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
15
+ # )
16
+
17
+ public_class_method def self.decode(opts = {})
18
+ freq_obj = opts[:freq_obj]
19
+ hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
20
+ ble = freq_obj[:ble] || freq_obj[:mode].to_s.casecmp('ble').zero?
21
+ ch = ble ? ((hz - 2_402_000_000) / 2_000_000).clamp(0, 39) : ((hz - 2_402_000_000) / 1_000_000).clamp(0, 78)
22
+ PWN::SDR::Decoder::Base.run_detector(
23
+ freq_obj: freq_obj,
24
+ protocol: ble ? 'BLE' : 'BT-BR/EDR',
25
+ note: '1 Mbit/s GFSK FHSS — native mode reports single-channel hop bursts only.',
26
+ threshold: 9.0,
27
+ describe: proc { |b|
28
+ { modulation: 'GFSK', channel: ch, hop_slots: (b[:duration_ms] / 0.625).round, classification: ble && [37, 38, 39].include?(ch) ? 'BLE-advertising' : 'data-hop' }
29
+ }
30
+ )
31
+ end
32
+
33
+ # Supported Method Parameters::
34
+ # PWN::SDR::Decoder::Bluetooth.parse_line(line: 'systime=... LAP=9e8b33 ...')
35
+
36
+ public_class_method def self.parse_line(opts = {})
37
+ line = opts[:line].to_s
38
+ out = { protocol: 'Bluetooth' }
39
+ out[:lap] = ::Regexp.last_match(1) if line =~ /LAP[=: ]([0-9a-fA-F]{6})/
40
+ out[:uap] = ::Regexp.last_match(1) if line =~ /UAP[=: ]([0-9a-fA-F]{2})/
41
+ out[:bd_addr] = ::Regexp.last_match(1) if line =~ /(?:AdvA|BD_ADDR)[=: ]([0-9a-fA-F:]{12,17})/
42
+ out[:pdu_type] = ::Regexp.last_match(1) if line =~ /\b(ADV_\w+|SCAN_\w+|CONNECT_REQ)\b/
43
+ out[:channel] = ::Regexp.last_match(1) if line =~ /ch[=: ]?(\d{1,2})\b/i
44
+ out[:rssi] = ::Regexp.last_match(1) if line =~ /rssi[=: ]?(-?\d+)/i
45
+ out[:summary] = "BT #{out.values_at(:pdu_type, :bd_addr, :lap).compact.join(' ')}".strip
46
+ out.compact
47
+ end
48
+
49
+ # Author(s):: 0day Inc. <support@0dayinc.com>
50
+
51
+ public_class_method def self.authors
52
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
53
+ end
54
+
55
+ # Display Usage for this Module
56
+
57
+ public_class_method def self.help
58
+ puts "USAGE (ruby-native detector, no external binaries):
59
+ #{self}.decode(
60
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
61
+ )
62
+
63
+ #{self}.parse_line(line: 'systime=... ch=37 LAP=9e8b33 ...')
64
+
65
+ #{self}.authors
66
+ "
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PWN
4
+ module SDR
5
+ module Decoder
6
+ # Pure-Ruby DECT (1.88–1.90 GHz EU / 1.92–1.93 GHz US) activity detector.
7
+ #
8
+ # 1.152 Mbit/s GFSK across 10 TDMA carriers — native mode reports
9
+ # slot bursts (417 μs) and derives carrier index from freq_obj.
10
+ # `parse_line` retained for offline text analysis.
11
+ module DECT
12
+ # Supported Method Parameters::
13
+ # PWN::SDR::Decoder::DECT.decode(
14
+ # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
15
+ # )
16
+
17
+ public_class_method def self.decode(opts = {})
18
+ freq_obj = opts[:freq_obj]
19
+ hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
20
+ ch = ((1_897_344_000 - hz) / 1_728_000.0).round.clamp(0, 9)
21
+ PWN::SDR::Decoder::Base.run_detector(
22
+ freq_obj: freq_obj,
23
+ protocol: 'DECT',
24
+ note: '1.152 Mbit/s GFSK 24-slot TDMA — native mode reports slot bursts and carrier index.',
25
+ threshold: 7.0,
26
+ describe: proc { |b|
27
+ slots = (b[:duration_ms] / 0.417).round
28
+ { modulation: 'GFSK', carrier: ch, tdma_slots: slots, classification: slots >= 24 ? 'FP-beacon-frame' : 'PP-burst' }
29
+ }
30
+ )
31
+ end
32
+
33
+ # Supported Method Parameters::
34
+ # PWN::SDR::Decoder::DECT.parse_line(line: 'RFPI: 01 23 45 67 89 slot 4 carrier 3 RSSI -55')
35
+
36
+ public_class_method def self.parse_line(opts = {})
37
+ line = opts[:line].to_s
38
+ out = { protocol: 'DECT' }
39
+ out[:rfpi] = ::Regexp.last_match(1).delete(' ') if line =~ /RFPI[:=]?\s*((?:[0-9A-Fa-f]{2}\s*){5})/
40
+ out[:slot] = ::Regexp.last_match(1) if line =~ /slot\s*(\d+)/i
41
+ out[:carrier] = ::Regexp.last_match(1) if line =~ /carrier\s*(\d+)/i
42
+ out[:rssi] = ::Regexp.last_match(1) if line =~ /RSSI[:=]?\s*(-?\d+)/i
43
+ out[:role] = ::Regexp.last_match(1) if line =~ /\b(FP|PP)\b/
44
+ out[:summary] = "DECT RFPI=#{out[:rfpi]} slot=#{out[:slot]} carrier=#{out[:carrier]}"
45
+ out.compact
46
+ end
47
+
48
+ # Author(s):: 0day Inc. <support@0dayinc.com>
49
+
50
+ public_class_method def self.authors
51
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
52
+ end
53
+
54
+ # Display Usage for this Module
55
+
56
+ public_class_method def self.help
57
+ puts "USAGE (ruby-native detector, no external binaries):
58
+ #{self}.decode(
59
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
60
+ )
61
+
62
+ #{self}.parse_line(line: 'RFPI: 01 23 45 67 89 slot 4 carrier 3')
63
+
64
+ #{self}.authors
65
+ "
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end