pwn 0.5.621 → 0.5.626

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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/README.md +7 -1
  5. data/bin/pwn_gqrx_scanner +78 -9
  6. data/documentation/Agent-Tool-Registry.md +3 -3
  7. data/documentation/Configuration.md +301 -47
  8. data/documentation/Cron.md +5 -3
  9. data/documentation/Diagrams.md +1 -1
  10. data/documentation/Extrospection.md +472 -26
  11. data/documentation/FFI.md +72 -8
  12. data/documentation/Home.md +4 -4
  13. data/documentation/How-PWN-Works.md +7 -3
  14. data/documentation/Mistakes.md +9 -0
  15. data/documentation/SDR.md +83 -10
  16. data/documentation/Skills-Memory-Learning.md +101 -11
  17. data/documentation/diagrams/agent-tool-registry.svg +56 -55
  18. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  19. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  20. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  21. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  22. data/documentation/diagrams/cron-scheduling.svg +23 -23
  23. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  24. data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -6
  25. data/documentation/diagrams/dot/hardware-hacking.dot +3 -1
  26. data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -2
  27. data/documentation/diagrams/dot/mistakes-negative-feedback.dot +4 -2
  28. data/documentation/diagrams/dot/overall-pwn-architecture.dot +3 -3
  29. data/documentation/diagrams/dot/persistence-filesystem.dot +3 -3
  30. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
  31. data/documentation/diagrams/dot/sdr-radio-flow.dot +39 -16
  32. data/documentation/diagrams/driver-framework.svg +13 -13
  33. data/documentation/diagrams/extrospection-world-awareness.svg +383 -177
  34. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  35. data/documentation/diagrams/hardware-hacking.svg +84 -69
  36. data/documentation/diagrams/history-to-drivers.svg +18 -18
  37. data/documentation/diagrams/memory-skills-detailed.svg +137 -120
  38. data/documentation/diagrams/mistakes-negative-feedback.svg +152 -137
  39. data/documentation/diagrams/network-infra-testing.svg +27 -27
  40. data/documentation/diagrams/overall-pwn-architecture.svg +97 -97
  41. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  42. data/documentation/diagrams/persistence-filesystem.svg +100 -94
  43. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  44. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +254 -207
  45. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  46. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  47. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  48. data/documentation/diagrams/sdr-radio-flow.svg +223 -91
  49. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  50. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  51. data/documentation/diagrams/web-application-testing.svg +26 -26
  52. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  53. data/documentation/pwn-ai-Agent.md +16 -10
  54. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  55. data/lib/pwn/ai/agent/assembly.rb +1 -1
  56. data/lib/pwn/ai/agent/btc.rb +1 -1
  57. data/lib/pwn/ai/agent/burp_suite.rb +1 -1
  58. data/lib/pwn/ai/agent/extrospection.rb +2562 -57
  59. data/lib/pwn/ai/agent/gqrx.rb +2 -2
  60. data/lib/pwn/ai/agent/hacker_one.rb +1 -1
  61. data/lib/pwn/ai/agent/learning.rb +16 -16
  62. data/lib/pwn/ai/agent/loop.rb +1 -1
  63. data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
  64. data/lib/pwn/ai/agent/sast.rb +1 -1
  65. data/lib/pwn/ai/agent/tools/extrospection.rb +311 -8
  66. data/lib/pwn/ai/agent/tools/learning.rb +7 -7
  67. data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
  68. data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
  69. data/lib/pwn/ai/agent.rb +1 -1
  70. data/lib/pwn/ai/open_ai.rb +10 -6
  71. data/lib/pwn/config.rb +5 -4
  72. data/lib/pwn/ffi/adalm_pluto.rb +498 -0
  73. data/lib/pwn/ffi/fftw.rb +192 -0
  74. data/lib/pwn/ffi/hack_rf.rb +193 -0
  75. data/lib/pwn/ffi/liquid.rb +244 -0
  76. data/lib/pwn/ffi/rtl_sdr.rb +210 -0
  77. data/lib/pwn/ffi/soapy_sdr.rb +177 -0
  78. data/lib/pwn/ffi/stdio.rb +9 -1
  79. data/lib/pwn/ffi/volk.rb +241 -0
  80. data/lib/pwn/ffi.rb +45 -2
  81. data/lib/pwn/plugins/burp_suite.rb +2 -2
  82. data/lib/pwn/sast/pom_version.rb +2 -2
  83. data/lib/pwn/sast/test_case_engine.rb +1 -1
  84. data/lib/pwn/sdr/decoder/adsb.rb +201 -12
  85. data/lib/pwn/sdr/decoder/apt.rb +19 -5
  86. data/lib/pwn/sdr/decoder/base.rb +314 -1
  87. data/lib/pwn/sdr/decoder/bluetooth.rb +91 -18
  88. data/lib/pwn/sdr/decoder/dect.rb +81 -23
  89. data/lib/pwn/sdr/decoder/dsp.rb +229 -15
  90. data/lib/pwn/sdr/decoder/flex.rb +23 -6
  91. data/lib/pwn/sdr/decoder/gps.rb +80 -18
  92. data/lib/pwn/sdr/decoder/gsm.rb +83 -28
  93. data/lib/pwn/sdr/decoder/iridium.rb +80 -18
  94. data/lib/pwn/sdr/decoder/lora.rb +84 -28
  95. data/lib/pwn/sdr/decoder/lte.rb +80 -18
  96. data/lib/pwn/sdr/decoder/morse.rb +23 -6
  97. data/lib/pwn/sdr/decoder/p25.rb +90 -31
  98. data/lib/pwn/sdr/decoder/pager.rb +23 -6
  99. data/lib/pwn/sdr/decoder/pocsag.rb +23 -6
  100. data/lib/pwn/sdr/decoder/rds.rb +219 -52
  101. data/lib/pwn/sdr/decoder/rfid.rb +81 -27
  102. data/lib/pwn/sdr/decoder/rtl433.rb +85 -27
  103. data/lib/pwn/sdr/decoder/rtty.rb +23 -6
  104. data/lib/pwn/sdr/decoder/wifi.rb +86 -32
  105. data/lib/pwn/sdr/decoder/zigbee.rb +88 -35
  106. data/lib/pwn/sdr/decoder.rb +6 -5
  107. data/lib/pwn/sdr/frequency_allocation.rb +124 -81
  108. data/lib/pwn/sdr/gqrx.rb +1513 -165
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/lib/pwn/ai/agent/extrospection_spec.rb +96 -0
  111. data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
  112. data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +8 -24
  113. data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +37 -0
  114. data/spec/lib/pwn/ffi/fftw_spec.rb +26 -0
  115. data/spec/lib/pwn/ffi/hack_rf_spec.rb +25 -0
  116. data/spec/lib/pwn/ffi/liquid_spec.rb +42 -0
  117. data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +24 -0
  118. data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +25 -0
  119. data/spec/lib/pwn/ffi/volk_spec.rb +36 -0
  120. data/spec/lib/pwn/sdr/decoder/base_iq_spec.rb +65 -0
  121. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +50 -4
  122. data/spec/lib/pwn/sdr/decoder/rds_spec.rb +45 -4
  123. data/third_party/pwn_rdoc.jsonl +279 -30
  124. metadata +21 -5
@@ -187,11 +187,28 @@ module PWN
187
187
 
188
188
  public_class_method def self.decode(opts = {})
189
189
  freq_obj = opts[:freq_obj]
190
- PWN::SDR::Decoder::Base.run_native(
191
- freq_obj: freq_obj,
192
- protocol: 'POCSAG',
193
- demod: Demod.new
194
- )
190
+ # Prefer true-air I/Q (FM-demod → existing audio demod) when the
191
+ # operator asks for a source/file or sets freq_obj[:iq_source].
192
+ # Otherwise keep the GQRX 48 kHz UDP audio path (run_native).
193
+ want_iq = opts[:source] || opts[:file] || freq_obj[:iq_source] || freq_obj[:iq_file]
194
+ if want_iq
195
+ PWN::SDR::Decoder::Base.run_iq(
196
+ freq_obj: freq_obj,
197
+ protocol: 'POCSAG',
198
+ demod: Demod.new,
199
+ sample_rate: (opts[:sample_rate] || freq_obj[:iq_rate] || 240_000).to_i,
200
+ source: opts[:source],
201
+ file: opts[:file],
202
+ fm_demod: true,
203
+ note: 'POCSAG true-air: FM-demod I/Q then native bit recovery; falls back to detector without SDR hardware.'
204
+ )
205
+ else
206
+ PWN::SDR::Decoder::Base.run_native(
207
+ freq_obj: freq_obj,
208
+ protocol: 'POCSAG',
209
+ demod: Demod.new
210
+ )
211
+ end
195
212
  end
196
213
 
197
214
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -203,7 +220,7 @@ module PWN
203
220
  # Display Usage for this Module
204
221
 
205
222
  public_class_method def self.help
206
- puts "USAGE (ruby-native, no external binaries):
223
+ puts "USAGE (true-air I/Q + GQRX-audio native paths, no external binaries):
207
224
  #{self}.decode(
208
225
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
209
226
  )
@@ -6,38 +6,124 @@ require 'tty-spinner'
6
6
  module PWN
7
7
  module SDR
8
8
  module Decoder
9
- # RDS Decoder Module for FM Radio Signals
9
+ # RDS Decoder Module for FM Radio Signals.
10
+ #
11
+ # Two entry points:
12
+ # .sample — non-interactive structured Hash (agents / cron / tools)
13
+ # .decode — interactive TTY spinner (human REPL via GQRX.init_freq)
14
+ #
15
+ # Both share the same GQRX RDS protocol path (U RDS, p RDS_PI / PS_NAME /
16
+ # RADIOTEXT). .sample is the canonical mid-layer API that Extrospection
17
+ # and any other automation should call.
10
18
  module RDS
19
+ DEFAULT_SETTLE_SECS = 8.0
20
+ DEFAULT_INTERVAL = 0.75
21
+ CALLSIGN_RX = /\A[A-Z]{1,2}[A-Z0-9]{2,4}\z/
22
+ CALLSIGN_RT_RX = /\A([A-Z]{1,2}[A-Z0-9]{2,4})\b/
23
+
24
+ # Supported Method Parameters::
25
+ # rds_hash = PWN::SDR::Decoder::RDS.sample(
26
+ # gqrx_sock: 'required unless freq_obj - TCPSocket from GQRX.connect',
27
+ # freq_obj: 'required unless gqrx_sock - Hash from GQRX.init_freq',
28
+ # settle_secs: 'optional - seconds to sample (default 8, max 30)',
29
+ # interval: 'optional - poll interval seconds (default 0.75)',
30
+ # leave_enabled: 'optional - leave RDS decoder ON after sample (default false)'
31
+ # )
32
+ #
33
+ # Returns::
34
+ # {
35
+ # pi:, ps_name:, radiotext:, station:,
36
+ # samples: Integer, settle_secs: Float,
37
+ # error: String? # present when RDS backend is unavailable
38
+ # }
39
+
40
+ public_class_method def self.sample(opts = {})
41
+ sock = resolve_sock(opts)
42
+ raise ArgumentError, 'gqrx_sock: or freq_obj: with :gqrx_sock required' unless sock
43
+
44
+ settle = (opts[:settle_secs] || DEFAULT_SETTLE_SECS).to_f.clamp(0.5, 30.0)
45
+ interval = [(opts[:interval] || DEFAULT_INTERVAL).to_f, 0.1].max
46
+ leave_on = opts[:leave_enabled] ? true : false
47
+ samples = []
48
+
49
+ unless enable_rds!(sock: sock)
50
+ return {
51
+ pi: nil,
52
+ ps_name: nil,
53
+ radiotext: nil,
54
+ station: nil,
55
+ samples: 0,
56
+ settle_secs: settle,
57
+ error: 'RDS not supported by this radio backend'
58
+ }
59
+ end
60
+
61
+ deadline = Time.now + settle
62
+ while Time.now < deadline
63
+ snap = poll_once(sock: sock)
64
+ samples << snap unless snap[:pi].empty? && snap[:ps].empty? && snap[:rt].empty?
65
+
66
+ # Early exit once we have a non-zero PI and a non-trivial RT —
67
+ # give one more interval for RadioText to finish filling.
68
+ pi = snap[:pi]
69
+ rt = snap[:rt]
70
+ if pi =~ /\A[0-9A-F]{4}\z/ && pi != '0000' && rt.length >= 8
71
+ sleep interval
72
+ snap2 = poll_once(sock: sock)
73
+ samples << snap2
74
+ break if snap2[:rt].length >= rt.length
75
+ end
76
+
77
+ sleep interval
78
+ end
79
+
80
+ disable_rds!(sock: sock) unless leave_on
81
+
82
+ aggregate(samples: samples, settle_secs: settle)
83
+ rescue ArgumentError
84
+ raise
85
+ rescue StandardError => e
86
+ disable_rds!(sock: sock) if sock && !leave_on
87
+ {
88
+ pi: nil,
89
+ ps_name: nil,
90
+ radiotext: nil,
91
+ station: nil,
92
+ samples: samples&.length.to_i,
93
+ settle_secs: settle,
94
+ error: "#{e.class}: #{e.message}"
95
+ }
96
+ end
97
+
11
98
  # Supported Method Parameters::
12
- # rds_resp = PWN::SDR::Decoder::RDS.decode(
13
- # freq_obj: 'required - GQRX socket object returned from #connect method'
99
+ # PWN::SDR::Decoder::RDS.decode(
100
+ # freq_obj: 'required - Hash returned from PWN::SDR::GQRX.init_freq'
14
101
  # )
102
+ #
103
+ # Interactive TTY UX: enables RDS, spins a live status line until the
104
+ # operator presses ENTER, then disables RDS. Does not return a Hash —
105
+ # callers that need structured data should use .sample instead.
15
106
 
16
107
  public_class_method def self.decode(opts = {})
17
108
  freq_obj = opts[:freq_obj]
109
+ raise ArgumentError, 'freq_obj: required' unless freq_obj.is_a?(Hash)
110
+
18
111
  gqrx_sock = freq_obj[:gqrx_sock]
112
+ raise ArgumentError, 'freq_obj[:gqrx_sock] required' unless gqrx_sock
19
113
 
20
- # freq_obj = freq_obj.dup
21
- freq_obj.delete(:gqrx_sock)
114
+ # Pretty-print the tuned frequency context without leaking the socket.
115
+ display = freq_obj.dup
116
+ display.delete(:gqrx_sock)
22
117
  skip_freq_char = "\n"
23
- puts JSON.pretty_generate(freq_obj)
118
+ puts JSON.pretty_generate(display)
24
119
  puts "\n*** FM Radio RDS Decoder ***"
25
120
  puts 'Press [ENTER] to continue to next frequency...'
26
121
 
27
- # Toggle RDS off and on to reset the decoder
28
- PWN::SDR::GQRX.cmd(
29
- gqrx_sock: gqrx_sock,
30
- cmd: 'U RDS 0',
31
- resp_ok: 'RPRT 0'
32
- )
33
-
34
- PWN::SDR::GQRX.cmd(
35
- gqrx_sock: gqrx_sock,
36
- cmd: 'U RDS 1',
37
- resp_ok: 'RPRT 0'
38
- )
122
+ unless enable_rds!(sock: gqrx_sock)
123
+ puts 'ERROR: RDS not supported by this radio backend'
124
+ return nil
125
+ end
39
126
 
40
- # Spinner setup with dynamic terminal width awareness
41
127
  spinner = TTY::Spinner.new(
42
128
  '[:spinner] :status',
43
129
  format: :arrow_pulse,
@@ -45,54 +131,38 @@ module PWN
45
131
  hide_cursor: true
46
132
  )
47
133
 
48
- # Conservative overhead for spinner animation, colors, and spacing
49
134
  spinner_overhead = 12
50
135
  max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
51
136
 
52
137
  initial_title = 'INFO: Decoding FM radio RDS data...'
53
138
  initial_title = initial_title[0...max_title_length] if initial_title.length > max_title_length
54
- spinner.update(title: initial_title)
139
+ spinner.update(status: initial_title)
55
140
  spinner.auto_spin
56
141
 
57
142
  last_resp = {}
58
143
 
59
144
  loop do
145
+ snap = poll_once(sock: gqrx_sock)
60
146
  rds_resp = {
61
- rds_pi: PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PI').to_s.strip.chomp.delete('.'),
62
- rds_ps_name: PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PS_NAME').to_s.strip.chomp,
63
- rds_radiotext: PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_RADIOTEXT').to_s.strip.chomp
147
+ rds_pi: snap[:pi],
148
+ rds_ps_name: snap[:ps],
149
+ rds_radiotext: snap[:rt]
64
150
  }
65
151
 
66
- # Only update when we have valid new data
67
- if rds_resp[:rds_pi] != '0000' && rds_resp != last_resp
68
- # --- Enforce RDS specification bounds and clean formatting ---
69
- # PI: 16-bit code >>> exactly 4 uppercase hex digits, zero-padded
70
- rds_pi = rds_resp[:rds_pi].upcase
71
- rds_pi = rds_pi.rjust(4, '0')[0, 4]
72
-
73
- # PS: exactly 8 ASCII characters (pad short with spaces, truncate long)
152
+ if rds_resp[:rds_pi] != '0000' && !rds_resp[:rds_pi].empty? && rds_resp != last_resp
153
+ rds_pi = rds_resp[:rds_pi].upcase.rjust(4, '0')[0, 4]
74
154
  rds_ps = "#{rds_resp[:rds_ps_name]} "[0, 8]
75
-
76
- # RadioText: strip trailing spaces (stations often pad to clear)
77
155
  rds_rt = rds_resp[:rds_radiotext].rstrip
78
156
 
79
- # Fixed prefix: always exactly 28 characters for predictable layout
80
- # Breakdown: "PI: " (4) + 4 hex (4) + " | PS: " (7) + 8 chars (8) + " | RT: " (7) = 28
81
157
  prefix = "Program ID: #{rds_pi} | Station Name: #{rds_ps} | Radio Txt: "
82
-
83
- # minimum visibility
84
- available_for_term = max_title_length - prefix.length
85
- available_for_term = [available_for_term, 10].max
86
-
158
+ available_for_term = [max_title_length - prefix.length, 10].max
87
159
  rt_display = rds_rt
88
160
  rt_display = "#{rt_display[0...available_for_term]}..." if rt_display.length > available_for_term
89
161
 
90
- msg = "#{prefix}#{rt_display}"
91
- spinner.update(status: msg)
162
+ spinner.update(status: "#{prefix}#{rt_display}")
92
163
  last_resp = rds_resp.dup
93
164
  end
94
165
 
95
- # Non-blocking check for ENTER key to exit
96
166
  if $stdin.wait_readable(0)
97
167
  begin
98
168
  char = $stdin.read_nonblock(1)
@@ -105,16 +175,10 @@ module PWN
105
175
  sleep 0.01
106
176
  end
107
177
  rescue StandardError => e
108
- spinner.error('Decoding failed') if defined?(spinner)
178
+ spinner.error('Decoding failed') if defined?(spinner) && spinner
109
179
  raise e
110
180
  ensure
111
- # Toggle RDS off and on to reset the decoder
112
- PWN::SDR::GQRX.cmd(
113
- gqrx_sock: gqrx_sock,
114
- cmd: 'U RDS 0',
115
- resp_ok: 'RPRT 0'
116
- )
117
-
181
+ disable_rds!(sock: gqrx_sock) if defined?(gqrx_sock) && gqrx_sock
118
182
  spinner.stop if defined?(spinner) && spinner
119
183
  end
120
184
 
@@ -130,6 +194,15 @@ module PWN
130
194
 
131
195
  public_class_method def self.help
132
196
  puts "USAGE:
197
+ # Non-interactive (agents / automation) — returns a Hash:
198
+ rds = #{self}.sample(
199
+ gqrx_sock: sock, # or freq_obj: from init_freq
200
+ settle_secs: 8, # default 8, max 30
201
+ leave_enabled: false
202
+ )
203
+ # => { pi:, ps_name:, radiotext:, station:, samples:, settle_secs: }
204
+
205
+ # Interactive TTY spinner (human REPL via GQRX.init_freq decoder: :rds):
133
206
  #{self}.decode(
134
207
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq method'
135
208
  )
@@ -137,6 +210,100 @@ module PWN
137
210
  #{self}.authors
138
211
  "
139
212
  end
213
+
214
+ # ---- internals -------------------------------------------------------
215
+
216
+ private_class_method def self.resolve_sock(opts = {})
217
+ return opts[:gqrx_sock] if opts[:gqrx_sock]
218
+
219
+ fo = opts[:freq_obj]
220
+ return fo[:gqrx_sock] if fo.is_a?(Hash)
221
+
222
+ nil
223
+ end
224
+
225
+ private_class_method def self.enable_rds!(opts = {})
226
+ sock = opts[:sock]
227
+ begin
228
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 0', resp_ok: 'RPRT 0')
229
+ rescue StandardError
230
+ nil
231
+ end
232
+ begin
233
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 1', resp_ok: 'RPRT 0')
234
+ true
235
+ rescue StandardError
236
+ false
237
+ end
238
+ end
239
+
240
+ private_class_method def self.disable_rds!(opts = {})
241
+ sock = opts[:sock]
242
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 0')
243
+ rescue StandardError
244
+ nil
245
+ end
246
+
247
+ private_class_method def self.poll_once(opts = {})
248
+ sock = opts[:sock]
249
+ pi = begin
250
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'p RDS_PI').to_s.strip.chomp.delete('.').upcase
251
+ rescue StandardError
252
+ ''
253
+ end
254
+ ps = begin
255
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'p RDS_PS_NAME').to_s.strip.chomp
256
+ rescue StandardError
257
+ ''
258
+ end
259
+ rt = begin
260
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'p RDS_RADIOTEXT').to_s.strip.chomp
261
+ rescue StandardError
262
+ ''
263
+ end
264
+ { pi: pi, ps: ps, rt: rt }
265
+ end
266
+
267
+ # Fold raw poll samples into the public Hash shape expected by
268
+ # Extrospection.rf_tune / agents (pi / ps_name / radiotext / station).
269
+ private_class_method def self.aggregate(opts = {})
270
+ samples = opts[:samples] || []
271
+ settle = opts[:settle_secs]
272
+
273
+ best_pi = samples.map { |s| s[:pi] }.find { |p| p =~ /\A[0-9A-F]{4}\z/ && p != '0000' }
274
+ best_pi = best_pi.to_s.rjust(4, '0')[0, 4] if best_pi
275
+
276
+ # PS often scrolls (artist / title / callsign cycle) — collect every
277
+ # non-empty sample, prefer a short all-caps callsign-like token, and
278
+ # fall back to the longest only when no callsign was seen.
279
+ ps_candidates = samples.map { |s| s[:ps].to_s.strip }.reject(&:empty?)
280
+ callsign_like = ps_candidates.find { |p| p =~ CALLSIGN_RX }
281
+ best_ps = callsign_like || ps_candidates.max_by(&:length)
282
+ best_ps = "#{best_ps} "[0, 8].rstrip if best_ps
283
+ best_rt = samples.map { |s| s[:rt].to_s.rstrip }.reject(&:empty?).max_by(&:length)
284
+
285
+ station = nil
286
+ if best_rt && best_rt =~ CALLSIGN_RT_RX
287
+ station = Regexp.last_match(1)
288
+ elsif callsign_like
289
+ station = callsign_like
290
+ elsif best_ps && best_ps =~ CALLSIGN_RX
291
+ station = best_ps
292
+ end
293
+
294
+ # If station callsign is known and best_ps is just a mid-scroll
295
+ # fragment of RadioText, prefer station so callers use station + RT.
296
+ best_ps = station if station && best_ps && best_rt && best_ps != station && best_rt.include?(best_ps)
297
+
298
+ {
299
+ pi: best_pi,
300
+ ps_name: best_ps,
301
+ radiotext: best_rt,
302
+ station: station,
303
+ samples: samples.length,
304
+ settle_secs: settle
305
+ }
306
+ end
140
307
  end
141
308
  end
142
309
  end
@@ -3,14 +3,69 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # Pure-Ruby RFID activity detector for LF (125/134 kHz), HF
7
- # (13.56 MHz) and UHF (860–960 MHz EPC Gen2).
8
- #
9
- # Near-field ASK/load-modulation on LF/HF requires an inductive
10
- # coupler (not an SDR antenna); UHF backscatter is 40–640 kbps ASK.
11
- # Native mode reports reader-carrier presence and tag-response
12
- # bursts by band. `parse_line` retained for offline text analysis.
6
+ # True-air + detector-fallback decoder for RFID.
7
+ # Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
8
+ # via Base.run_iq; degrades to Base.run_detector with no hardware.
13
9
  module RFID
10
+ # Streaming I/Q energy/burst demod for Base.run_iq.
11
+ class DemodIQ
12
+ def initialize(rate:, protocol:, modulation:, extra: {})
13
+ @rate = rate.to_f
14
+ @protocol = protocol
15
+ @modulation = modulation
16
+ @extra = extra
17
+ @floor = nil
18
+ @in_burst = false
19
+ @burst_t0 = nil
20
+ @peak = -200.0
21
+ @burst_n = 0
22
+ @threshold = (extra[:threshold] || 8.0).to_f
23
+ end
24
+
25
+ def feed_iq(samples, rate: nil, &)
26
+ @rate = rate.to_f if rate
27
+ m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
28
+ hop = [(@rate / 1000.0).round, 1].max
29
+ i = 0
30
+ while i < m2.length
31
+ win = m2[i, hop]
32
+ break if win.nil? || win.empty?
33
+
34
+ ms = win.sum / win.length
35
+ lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
36
+ @floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
37
+ delta = lvl - @floor
38
+ if delta >= @threshold
39
+ unless @in_burst
40
+ @in_burst = true
41
+ @burst_t0 = Time.now
42
+ @peak = lvl
43
+ end
44
+ @peak = lvl if lvl > @peak
45
+ elsif @in_burst
46
+ @in_burst = false
47
+ @burst_n += 1
48
+ dur_ms = ((Time.now - @burst_t0) * 1000).round
49
+ msg = {
50
+ protocol: @protocol, event: 'burst', source: 'iq',
51
+ burst_no: @burst_n, peak_dbfs: @peak.round(1),
52
+ floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
53
+ duration_ms: dur_ms, modulation: @modulation,
54
+ sample_rate: @rate.to_i
55
+ }.merge(@extra.except(:threshold))
56
+ # protocol-specific enrichment
57
+ msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
58
+ msg[:summary] = format(
59
+ '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
60
+ p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
61
+ )
62
+ yield msg if block_given?
63
+ end
64
+ i += hop
65
+ end
66
+ end
67
+ end
68
+
14
69
  # Supported Method Parameters::
15
70
  # PWN::SDR::Decoder::RFID.decode(
16
71
  # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
@@ -23,25 +78,26 @@ module PWN
23
78
  elsif hz.between?(13_000_000, 14_000_000) then 'HF'
24
79
  else 'UHF'
25
80
  end
26
- PWN::SDR::Decoder::Base.run_detector(
81
+
82
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
83
+ proto = "RFID-#{band}"
84
+ demod = DemodIQ.new(
85
+ rate: rate, protocol: proto, modulation: 'ASK/load-mod',
86
+ extra: { threshold: 6.0 }
87
+ )
88
+ PWN::SDR::Decoder::Base.run_iq(
27
89
  freq_obj: freq_obj,
28
- protocol: "RFID-#{band}",
29
- note: 'Native mode reports reader-carrier and tag-backscatter bursts by band.',
90
+ protocol: proto,
91
+ sample_rate: rate,
92
+ source: opts[:source],
93
+ file: opts[:file],
94
+ demod: demod,
30
95
  threshold: 6.0,
31
- describe: proc { |b|
32
- kind = case band
33
- when 'LF' then b[:duration_ms] > 100 ? 'reader-CW' : 'EM4x/HID-response'
34
- when 'HF' then b[:duration_ms] > 5 ? 'ISO14443-REQA/frame' : 'ISO15693-slot'
35
- else b[:duration_ms] > 20 ? 'reader-Query' : 'EPC-backscatter'
36
- end
37
- { band: band, modulation: 'ASK/load-mod', classification: kind }
38
- }
96
+ note: 'True-air I/Q path reports reader-carrier and tag-backscatter bursts by band.',
97
+ describe: proc { |b| { band: b[:band], modulation: 'ASK/load-mod', classification: b[:classification] } }
39
98
  )
40
99
  end
41
100
 
42
- # Supported Method Parameters::
43
- # PWN::SDR::Decoder::RFID.parse_line(line: 'UID: 04 A1 B2 C3 D4 E5 F6')
44
-
45
101
  public_class_method def self.parse_line(opts = {})
46
102
  line = opts[:line].to_s
47
103
  out = { protocol: 'RFID' }
@@ -60,16 +116,14 @@ module PWN
60
116
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
61
117
  end
62
118
 
63
- # Display Usage for this Module
64
-
65
119
  public_class_method def self.help
66
- puts "USAGE (ruby-native detector, no external binaries):
120
+ puts "USAGE (true-air I/Q via PWN::FFI + detector fallback):
67
121
  #{self}.decode(
68
- freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
122
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
123
+ source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
124
+ file: 'optional - .cu8/.cs16 capture'
69
125
  )
70
126
 
71
- #{self}.parse_line(line: 'UID: 04 A1 B2 C3 D4 E5 F6 SAK: 08 Mifare Classic 1K')
72
-
73
127
  #{self}.authors
74
128
  "
75
129
  end
@@ -5,17 +5,69 @@ require 'json'
5
5
  module PWN
6
6
  module SDR
7
7
  module Decoder
8
- # Pure-Ruby ISM/keyfob/sensor activity detector for the 315 / 390 /
9
- # 433.92 / 868 / 915 MHz device zoo.
10
- #
11
- # The upstream `rtl_433` binary carries ~250 device-specific protocol
12
- # dissectors; re-implementing that library is out of scope. This
13
- # module instead characterises OOK/ASK/FSK bursts natively (count,
14
- # duration, gap, peak dBFS) — enough to fingerprint a keyfob press,
15
- # a periodic weather-station beacon, or a TPMS chirp — without
16
- # invoking any external binary. `parse_line` still accepts rtl_433
17
- # `-F json` output for offline analysis.
8
+ # True-air + detector-fallback decoder for RTL433.
9
+ # Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
10
+ # via Base.run_iq; degrades to Base.run_detector with no hardware.
18
11
  module RTL433
12
+ # Streaming I/Q energy/burst demod for Base.run_iq.
13
+ class DemodIQ
14
+ def initialize(rate:, protocol:, modulation:, extra: {})
15
+ @rate = rate.to_f
16
+ @protocol = protocol
17
+ @modulation = modulation
18
+ @extra = extra
19
+ @floor = nil
20
+ @in_burst = false
21
+ @burst_t0 = nil
22
+ @peak = -200.0
23
+ @burst_n = 0
24
+ @threshold = (extra[:threshold] || 8.0).to_f
25
+ end
26
+
27
+ def feed_iq(samples, rate: nil, &)
28
+ @rate = rate.to_f if rate
29
+ m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
30
+ hop = [(@rate / 1000.0).round, 1].max
31
+ i = 0
32
+ while i < m2.length
33
+ win = m2[i, hop]
34
+ break if win.nil? || win.empty?
35
+
36
+ ms = win.sum / win.length
37
+ lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
38
+ @floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
39
+ delta = lvl - @floor
40
+ if delta >= @threshold
41
+ unless @in_burst
42
+ @in_burst = true
43
+ @burst_t0 = Time.now
44
+ @peak = lvl
45
+ end
46
+ @peak = lvl if lvl > @peak
47
+ elsif @in_burst
48
+ @in_burst = false
49
+ @burst_n += 1
50
+ dur_ms = ((Time.now - @burst_t0) * 1000).round
51
+ msg = {
52
+ protocol: @protocol, event: 'burst', source: 'iq',
53
+ burst_no: @burst_n, peak_dbfs: @peak.round(1),
54
+ floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
55
+ duration_ms: dur_ms, modulation: @modulation,
56
+ sample_rate: @rate.to_i
57
+ }.merge(@extra.except(:threshold))
58
+ # protocol-specific enrichment
59
+ msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
60
+ msg[:summary] = format(
61
+ '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
62
+ p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
63
+ )
64
+ yield msg if block_given?
65
+ end
66
+ i += hop
67
+ end
68
+ end
69
+ end
70
+
19
71
  # Supported Method Parameters::
20
72
  # PWN::SDR::Decoder::RTL433.decode(
21
73
  # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
@@ -23,24 +75,32 @@ module PWN
23
75
 
24
76
  public_class_method def self.decode(opts = {})
25
77
  freq_obj = opts[:freq_obj]
26
- PWN::SDR::Decoder::Base.run_detector(
78
+
79
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 250_000).to_i
80
+ proto = 'ISM-433'
81
+ demod = DemodIQ.new(
82
+ rate: rate, protocol: proto, modulation: 'OOK/ASK/FSK',
83
+ extra: { threshold: 10.0 }
84
+ )
85
+ PWN::SDR::Decoder::Base.run_iq(
27
86
  freq_obj: freq_obj,
28
- protocol: 'ISM-433',
29
- note: 'Native OOK/FSK burst characteriser (no rtl_433 binary). Feed captured `rtl_433 -F json` lines to .parse_line for per-device decode.',
87
+ protocol: proto,
88
+ sample_rate: rate,
89
+ source: opts[:source],
90
+ file: opts[:file],
91
+ demod: demod,
30
92
  threshold: 10.0,
93
+ note: 'True-air I/Q path characterises OOK/FSK bursts; offline rtl_433 JSON via .parse_line.',
31
94
  describe: proc { |b|
32
- kind = if b[:duration_ms] < 20 then 'keyfob/OOK-short'
33
- elsif b[:duration_ms] < 120 then 'sensor/OOK-packet'
34
- else 'FSK-continuous'
35
- end
36
- { modulation: 'OOK/ASK/FSK', classification: kind }
95
+ { modulation: 'OOK/ASK/FSK', classification: (if b[:duration_ms] < 20
96
+ 'keyfob/OOK-short'
97
+ else
98
+ (b[:duration_ms] < 120 ? 'sensor/OOK-packet' : 'FSK-continuous')
99
+ end) }
37
100
  }
38
101
  )
39
102
  end
40
103
 
41
- # Supported Method Parameters::
42
- # PWN::SDR::Decoder::RTL433.parse_line(line: '{"time":"...","model":"..."}')
43
-
44
104
  public_class_method def self.parse_line(opts = {})
45
105
  line = opts[:line].to_s
46
106
  h = begin
@@ -67,16 +127,14 @@ module PWN
67
127
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
68
128
  end
69
129
 
70
- # Display Usage for this Module
71
-
72
130
  public_class_method def self.help
73
- puts "USAGE (ruby-native detector, no external binaries):
131
+ puts "USAGE (true-air I/Q via PWN::FFI + detector fallback):
74
132
  #{self}.decode(
75
- freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
133
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
134
+ source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
135
+ file: 'optional - .cu8/.cs16 capture'
76
136
  )
77
137
 
78
- #{self}.parse_line(line: '{\"model\":\"Acurite-Tower\",\"id\":1234,...}')
79
-
80
138
  #{self}.authors
81
139
  "
82
140
  end