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
@@ -15,41 +15,81 @@ module PWN
15
15
  module AI
16
16
  module Agent
17
17
  # PWN::AI::Agent::Extrospection is the outward-facing counterpart to
18
- # PWN::AI::Agent::Learning (introspection). Where Learning/Metrics
19
- # look INWARD at the agent's own tool telemetry, task outcomes and
20
- # session transcripts, Extrospection looks OUTWARD at the world the
21
- # agent operates in: host state, toolchain versions, network posture,
22
- # repo drift, and external threat-intel (CVE / Exploit-DB / ATT&CK).
18
+ # PWN::AI::Agent::Learning (introspection).
23
19
  #
24
- # Together they close BOTH halves of the pwn-ai feedback loop:
20
+ # PRIMARY INTENT on-demand external sensing
21
+ # -------------------------------------------
22
+ # Quickly explore *external* resources when that produces a more
23
+ # informed answer. Call a sense tool only when the question needs it:
24
+ #
25
+ # "weather in Tokyo" → verify / watch / TransparentBrowser
26
+ # "what's on 101.1 FM?" → rf_tune(freq: "101.1") → RDS / observe(:rf)
27
+ # "CVE for openssl 3.0?" → intel(query:) / verify(claim:, kind: :cve)
28
+ # "did the target change?" → watch(url:) / snapshot(sections: [:web])
29
+ #
30
+ # Secondary / optional — ambient host baseline
31
+ # --------------------------------------------
32
+ # snapshot / drift / correlate can record cheap local posture so the
33
+ # agent can tell "I called the API wrong" from "the world moved"
34
+ # (kernel upgrade, dongle unplugged). This is NEVER the reason to
35
+ # launch GUI / JVM / heavy-REPL binaries — those are presence-only.
36
+ # auto_extrospect, when enabled, uses only side-effect-free sections.
25
37
  #
26
38
  # INTROSPECTIVE (self) EXTROSPECTIVE (world)
27
39
  # ---------------------- -------------------------------------
28
- # Metrics.record Extrospection.snapshot (host probe)
29
- # Learning.note_outcome Extrospection.observe (recon fact)
30
- # Learning.reflect Extrospection.drift (env delta)
31
- # Learning.stats Extrospection.intel (CVE/EDB)
32
- # Extrospection.correlate (self x world)
40
+ # Metrics.record Extrospection.intel/verify/watch/rf_tune (sense)
41
+ # Learning.note_outcome Extrospection.observe (fact)
42
+ # Learning.reflect Extrospection.snapshot/drift (baseline)
43
+ # Learning.stats Extrospection.correlate (self×world)
33
44
  #
34
- # PromptBuilder re-injects Extrospection.to_context on every turn so
35
- # the model gains situational awareness of what changed on THIS host
36
- # between sessions ("kernel upgraded", "nmap now missing", "port 8080
37
- # newly listening", "CVE-2026-XXXX matches installed openssl") and
38
- # can correlate that drift against introspective failures.
39
- #
40
- # Everything is file-backed under ~/.pwn/extrospection.json so it
41
- # survives across REPL restarts and is shared by every future session.
45
+ # PromptBuilder re-injects Extrospection.to_context on every turn.
46
+ # Persistence: ~/.pwn/extrospection.json across REPL restarts.
42
47
  module Extrospection
43
48
  EXTRO_FILE = File.join(Dir.home, '.pwn', 'extrospection.json')
44
49
  MAX_OBSERVATIONS = 500
45
- RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
46
- PROBE_BINS = (%w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker] + RF_BINS).freeze
50
+ # CLI tools that accept a cheap, non-interactive --version / -V.
51
+ SAFE_VERSION_BINS = %w[nmap curl git ruby python3 gcc openssl docker].freeze
52
+ # GUI / JVM / heavy REPL / interactive tools — presence-only.
53
+ # NEVER spawn these from auto-probe (Burp Suite splash, ZAP UI, msfconsole, GQRX).
54
+ PRESENCE_ONLY_BINS = %w[burpsuite zaproxy msfconsole gqrx sqlmap].freeze
55
+ RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
56
+ OSINT_BINS = %w[whois dig host curl jq].freeze
57
+ SERIAL_BINS = %w[minicom picocom screen cu].freeze
58
+ TELECOMM_BINS = %w[baresip asterisk linphone sngrep].freeze
59
+ PACKET_BINS = %w[tshark tcpdump tcpreplay dumpcap].freeze
60
+ VISION_BINS = %w[tesseract zbarimg qrencode convert identify].freeze
61
+ VOICE_BINS = %w[sox espeak-ng espeak festival whisper spd-say arecord aplay].freeze
62
+ PROBE_BINS = (
63
+ SAFE_VERSION_BINS + PRESENCE_ONLY_BINS + RF_BINS +
64
+ OSINT_BINS + SERIAL_BINS + TELECOMM_BINS + PACKET_BINS +
65
+ VISION_BINS + VOICE_BINS
66
+ ).uniq.freeze
67
+ # Cheap, side-effect-free sections used by auto_extrospect.
68
+ # toolchain / rf / web / osint / serial / telecomm / packet / vision / voice
69
+ # are on-demand only (sense tools or explicit snapshot).
70
+ AUTO_SECTIONS = %i[host repo env].freeze
47
71
  WEB_SHOT_DIR = File.join(Dir.home, '.pwn', 'extrospection', 'web')
48
72
  DEFAULT_WEB_ANCHORS = %w[
49
73
  https://services.nvd.nist.gov/rest/json/cves/2.0
50
74
  https://www.exploit-db.com/
51
75
  https://raw.githubusercontent.com/0dayinc/pwn/master/lib/pwn/version.rb
52
76
  ].freeze
77
+ # Public / free OSINT anchors (no key). Keys unlock richer feeds via PWN::Env.
78
+ # Expanded from https://github.com/public-api-lists/public-api-lists
79
+ # (Anti-Malware, Security, Geocoding, Government, Health, Open Data, Vehicle, …).
80
+ DEFAULT_OSINT_FEEDS = %i[
81
+ ip geo dns whois rdap crtsh bgpview shodan hunter
82
+ phone fcc_id patent person username github wayback
83
+ otx urlhaus threatfox urlscan hackertarget openfda
84
+ nominatim opencorporates courtlistener sec_edgar vital_records
85
+ ipapi_is iplocate ipwhois
86
+ abuseipdb virustotal greynoise
87
+ certspotter epss cisa_kev
88
+ nhtsa nppes federal_register uk_police callook
89
+ mac_vendor universities microlink
90
+ agify genderize nationalize
91
+ haveibeenpwned securitytrails
92
+ ].freeze
53
93
 
54
94
  # Supported Method Parameters::
55
95
  # store = PWN::AI::Agent::Extrospection.load
@@ -79,11 +119,13 @@ module PWN
79
119
  # Supported Method Parameters::
80
120
  # snap = PWN::AI::Agent::Extrospection.snapshot(
81
121
  # persist: 'optional - Boolean, write snapshot to disk & rotate previous (default true)',
82
- # sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf, :web] (default all except :web)'
122
+ # sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf, :web, :osint, :serial, :telecomm, :packet, :vision, :voice] (default host/net/toolchain/repo/env/rf)'
83
123
  # )
84
124
  #
85
- # Captures a fingerprint of the OUTSIDE world. When persist:true the
86
- # prior snapshot is rotated into :previous so .drift can diff them.
125
+ # Ambient host baseline (secondary to sense tools like intel/verify/watch).
126
+ # :toolchain never spawns GUI/JVM tools (presence-only). auto_extrospect
127
+ # uses AUTO_SECTIONS (host/repo/env) only. When persist:true the prior
128
+ # snapshot is rotated into :previous so .drift can diff them.
87
129
 
88
130
  public_class_method def self.snapshot(opts = {})
89
131
  persist = if opts.key?(:persist)
@@ -102,6 +144,12 @@ module PWN
102
144
  snap[:env] = probe_env if sections.include?(:env)
103
145
  snap[:rf] = probe_rf if sections.include?(:rf)
104
146
  snap[:web] = probe_web if sections.include?(:web)
147
+ snap[:osint] = probe_osint if sections.include?(:osint)
148
+ snap[:serial] = probe_serial if sections.include?(:serial)
149
+ snap[:telecomm] = probe_telecomm if sections.include?(:telecomm)
150
+ snap[:packet] = probe_packet if sections.include?(:packet)
151
+ snap[:vision] = probe_vision if sections.include?(:vision)
152
+ snap[:voice] = probe_voice if sections.include?(:voice)
105
153
  snap[:captured_at] = Time.now.utc.iso8601
106
154
  snap[:fingerprint] = Digest::SHA256.hexdigest(JSON.generate(snap.except(:captured_at)))[0, 16]
107
155
 
@@ -120,7 +168,7 @@ module PWN
120
168
  # obs = PWN::AI::Agent::Extrospection.observe(
121
169
  # source: 'required - where the observation came from (nmap, shodan, burp, cve, human, ...)',
122
170
  # data: 'required - the observation payload (String or Hash)',
123
- # category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :web, :misc (default :misc)',
171
+ # category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :web, :osint, :serial, :telecomm, :packet, :vision, :voice, :misc (default :misc)',
124
172
  # target: 'optional - host/ip/url/asset the observation is about',
125
173
  # tags: 'optional - Array of String labels',
126
174
  # ttl: 'optional - seconds until this observation is considered stale (default nil = forever)'
@@ -391,6 +439,690 @@ module PWN
391
439
  { url: url, changed: changed, prior_sha: prior && prior.dig(:data, :dom_sha), current: data }
392
440
  end
393
441
 
442
+ # Supported Method Parameters::
443
+ # result = PWN::AI::Agent::Extrospection.rf_tune(
444
+ # freq: 'required - frequency: "101.1", "101.1 FM", "101.100.000", 101_100_000, ...',
445
+ # host: 'optional - GQRX remote-control host (default 127.0.0.1)',
446
+ # port: 'optional - GQRX remote-control port (default 7356)',
447
+ # settle_secs: 'optional - seconds to sample RDS after tune (default 8)',
448
+ # rds: 'optional - force RDS on/off (default: auto when FM broadcast / band-plan decoder=:rds)',
449
+ # demodulator_mode: 'optional - e.g. :WFM_ST, :WFM, :FM, :AM (default from band-plan / FM range)',
450
+ # bandwidth: 'optional - passband Hz string, e.g. "200.000" (default from band-plan)',
451
+ # record: 'optional - also observe(category: :rf) so it hits EXTROSPECTION (default true)',
452
+ # ttl: 'optional - observation TTL seconds (default 300 — radio content is ephemeral)'
453
+ # )
454
+ #
455
+ # RF sense organ — the RF analogue of extro_watch / extro_verify.
456
+ # Tunes a *running* GQRX instance (never launches the GUI), demodulates,
457
+ # measures strength, and when appropriate samples RDS (PI / PS / RadioText)
458
+ # so questions like "what's playing on 101.1?" have a live answer.
459
+ # Requires GQRX remote control already listening (default :7356) and an
460
+ # SDR attached; fails fast with actionable advice otherwise.
461
+ # On success, records observe(category: :rf, source: 'gqrx') so correlate
462
+ # and to_context keep the agent aware of what was last heard.
463
+
464
+ public_class_method def self.rf_tune(opts = {})
465
+ raw_freq = opts[:freq]
466
+ raise 'ERROR: freq is required' if raw_freq.nil? || raw_freq.to_s.strip.empty?
467
+
468
+ host = (opts[:host] || rf_config[:host] || '127.0.0.1').to_s
469
+ port = (opts[:port] || rf_config[:port] || 7356).to_i
470
+ settle = (opts[:settle_secs] || rf_config[:settle_secs] || 8).to_f
471
+ settle = 1.0 if settle < 1.0
472
+ settle = 30.0 if settle > 30.0
473
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
474
+ ttl = (opts[:ttl] || rf_config[:ttl] || 300).to_i
475
+ force_rds = opts.key?(:rds) ? opts[:rds] : nil
476
+
477
+ hz_i, freq_label = normalize_rf_freq(freq: raw_freq)
478
+ plan = match_rf_band_plan(hz: hz_i)
479
+ demod = (opts[:demodulator_mode] || (plan && plan[:demodulator_mode]) || default_rf_demod(hz: hz_i)).to_s.upcase
480
+ bandwidth = (opts[:bandwidth] || (plan && plan[:bandwidth]) || default_rf_bandwidth(hz: hz_i)).to_s
481
+ passband_hz = begin
482
+ require 'pwn/sdr' unless defined?(PWN::SDR)
483
+ PWN::SDR.hz_to_i(freq: bandwidth)
484
+ rescue StandardError
485
+ bandwidth.to_s.gsub(/[^\d]/, '').to_i
486
+ end
487
+ passband_hz = 200_000 if passband_hz <= 0
488
+ decoder_key = plan && plan[:decoder]
489
+ band_name = plan && plan[:name]
490
+ do_rds = if force_rds.nil?
491
+ decoder_key.to_s == 'rds' || hz_i.between?(87_500_000, 108_100_000)
492
+ else
493
+ !force_rds.nil? && force_rds != false
494
+ end
495
+
496
+ unless tcp_open?(host: host, port: port)
497
+ advice = 'Start GQRX with remote control enabled (Tools → Remote Control, port 7356) and an SDR attached, then retry extro_rf_tune.'
498
+ err = {
499
+ ok: false,
500
+ error: "gqrx remote control not reachable at #{host}:#{port}",
501
+ advice: advice,
502
+ freq: freq_label,
503
+ hz: hz_i,
504
+ band_plan: band_name,
505
+ demodulator_mode: demod,
506
+ bandwidth: bandwidth
507
+ }
508
+ observe(source: 'gqrx', category: :rf, target: freq_label, data: err, tags: %w[rf_tune unreachable], ttl: ttl) if record
509
+ return err
510
+ end
511
+
512
+ require 'pwn/sdr/gqrx' unless defined?(PWN::SDR::GQRX)
513
+ sock = nil
514
+ begin
515
+ sock = PWN::SDR::GQRX.connect(target: host, port: port)
516
+ # Ensure DSP is running so strength/RDS update.
517
+ begin
518
+ dsp = PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'u DSP').to_s.strip
519
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U DSP 1', resp_ok: 'RPRT 0') if dsp == '0'
520
+ rescue StandardError
521
+ nil
522
+ end
523
+
524
+ PWN::SDR::GQRX.cmd(
525
+ gqrx_sock: sock,
526
+ cmd: "M #{demod} #{passband_hz}",
527
+ resp_ok: 'RPRT 0'
528
+ )
529
+ PWN::SDR::GQRX.cmd(
530
+ gqrx_sock: sock,
531
+ cmd: "F #{hz_i}",
532
+ resp_ok: 'RPRT 0'
533
+ )
534
+ sleep 0.4
535
+
536
+ strength = begin
537
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'l STRENGTH').to_f
538
+ rescue StandardError
539
+ nil
540
+ end
541
+ mode_now = begin
542
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'm').to_s.strip
543
+ rescue StandardError
544
+ "#{demod} #{passband_hz}"
545
+ end
546
+ tuned = begin
547
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'f').to_s.strip
548
+ rescue StandardError
549
+ freq_label
550
+ end
551
+
552
+ rds = do_rds ? sample_rds(gqrx_sock: sock, settle_secs: settle) : nil
553
+
554
+ payload = {
555
+ ok: true,
556
+ freq: freq_label,
557
+ hz: hz_i,
558
+ tuned: tuned,
559
+ strength_dbfs: strength,
560
+ demodulator_mode: demod,
561
+ mode: mode_now,
562
+ bandwidth: bandwidth,
563
+ passband_hz: passband_hz,
564
+ band_plan: band_name,
565
+ decoder: decoder_key,
566
+ rds: rds,
567
+ now_playing: rds && (rds[:radiotext].to_s.strip.empty? ? nil : rds[:radiotext].to_s.strip),
568
+ station: rds && (rds[:station].to_s.strip.empty? ? nil : rds[:station].to_s.strip),
569
+ host: host,
570
+ port: port,
571
+ captured_at: Time.now.utc.iso8601
572
+ }
573
+
574
+ if record
575
+ summary = build_rf_summary(payload: payload)
576
+ observe(
577
+ source: 'gqrx',
578
+ category: :rf,
579
+ target: freq_label,
580
+ data: summary,
581
+ tags: ['rf_tune', band_name, decoder_key, ('rds' if rds)].compact.map(&:to_s),
582
+ ttl: ttl
583
+ )
584
+ payload[:observed] = true
585
+ payload[:summary] = summary
586
+ else
587
+ payload[:observed] = false
588
+ end
589
+
590
+ payload
591
+ rescue StandardError => e
592
+ {
593
+ ok: false,
594
+ error: "#{e.class}: #{e.message}",
595
+ freq: freq_label,
596
+ hz: hz_i,
597
+ band_plan: band_name,
598
+ advice: 'Confirm GQRX is running with remote control, the SDR is not claimed by another process, and the frequency is in-band for the attached radio.'
599
+ }
600
+ ensure
601
+ begin
602
+ PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 0') if sock && do_rds
603
+ rescue StandardError
604
+ nil
605
+ end
606
+ begin
607
+ PWN::SDR::GQRX.disconnect(gqrx_sock: sock) if sock
608
+ rescue StandardError
609
+ nil
610
+ end
611
+ end
612
+ end
613
+
614
+ # ============================================================
615
+ # OSINT sense organ
616
+ # ============================================================
617
+ # Supported Method Parameters::
618
+ # result = PWN::AI::Agent::Extrospection.osint(
619
+ # query: 'required - phone, IP, domain, email, person name, FCC ID, patent #, username, VIN, MAC, callsign, …',
620
+ # kind: 'optional - auto|:ip|:geo|:dns|:whois|:rdap|:crtsh|:bgp|:shodan|:hunter|:phone|:fcc_id|:patent|:person|:username|:github|:wayback|:url|:company|:cik|:openfda|:vital_records|:threat|:vin|:mac|:callsign|:npi|:cve (default :auto)',
621
+ # feeds: 'optional - Array subset of DEFAULT_OSINT_FEEDS (default: auto-selected from kind)',
622
+ # limit: 'optional - max hits per feed (default 5)',
623
+ # record: 'optional - also observe(category: :osint) (default true)',
624
+ # ttl: 'optional - observation TTL seconds (default 86400)',
625
+ # api_keys: 'optional - Hash of {shodan:, hunter:, abuseipdb:, virustotal:, greynoise:, haveibeenpwned:, securitytrails:} overriding PWN::Env / ENV'
626
+ # )
627
+ #
628
+ # Aggregates as many *public / free* OSINT APIs as possible (sourced
629
+ # in part from public-api-lists/public-api-lists), with optional
630
+ # keyed feeds (Shodan / Hunter / AbuseIPDB / VirusTotal / GreyNoise /
631
+ # HaveIBeenPwned / SecurityTrails) when keys exist in PWN::Env or ENV.
632
+ # Best-effort: any unreachable feed degrades to an error hash rather
633
+ # than raising. First-class kinds cover: reverse phone, person /
634
+ # missing-person, patent, FCC ID, VIN (NHTSA), MAC OUI, ham callsign
635
+ # (Callook), IP/ASN/BGP + threat reputation (ipapi.is / iplocate /
636
+ # ipwho.is / AbuseIPDB / GreyNoise), CT (crt.sh + Cert Spotter),
637
+ # whois/RDAP, GitHub, Wayback, OTX / URLHaus / ThreatFox / urlscan,
638
+ # HackerTarget, openFDA, NPPES NPI, Nominatim, OpenCorporates,
639
+ # CourtListener, SEC EDGAR, Federal Register, UK Police, EPSS +
640
+ # CISA KEV, Microlink unfurl, universities, name demographics
641
+ # (Agify/Genderize/Nationalize), and vital-records public plans.
642
+ public_class_method def self.osint(opts = {})
643
+ query = opts[:query].to_s.strip
644
+ raise 'ERROR: query is required' if query.empty?
645
+
646
+ kind = (opts[:kind] || :auto).to_s.to_sym
647
+ kind = detect_osint_kind(query: query) if kind == :auto
648
+ feeds = Array(opts[:feeds]).map(&:to_sym)
649
+ feeds = osint_feeds_for(kind: kind) if feeds.empty?
650
+ limit = (opts[:limit] || 5).to_i
651
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
652
+ ttl = (opts[:ttl] || osint_config[:ttl] || 86_400).to_i
653
+ keys = osint_api_keys(override: opts[:api_keys])
654
+
655
+ results = { ok: true, query: query, kind: kind, feeds: {}, captured_at: Time.now.utc.iso8601 }
656
+ feeds.each do |feed|
657
+ results[:feeds][feed] = osint_dispatch(feed: feed, query: query, kind: kind, limit: limit, keys: keys)
658
+ rescue StandardError => e
659
+ results[:feeds][feed] = { error: "#{e.class}: #{e.message.to_s[0, 160]}" }
660
+ end
661
+
662
+ results[:summary] = build_osint_summary(results: results)
663
+ if record
664
+ observe(
665
+ source: 'osint',
666
+ category: :osint,
667
+ target: query,
668
+ data: results[:summary],
669
+ tags: (['osint', kind.to_s] + feeds.map(&:to_s)).uniq,
670
+ ttl: ttl
671
+ )
672
+ results[:observed] = true
673
+ else
674
+ results[:observed] = false
675
+ end
676
+ results
677
+ end
678
+
679
+ # ============================================================
680
+ # Serial sense organ
681
+ # ============================================================
682
+ # Supported Method Parameters::
683
+ # result = PWN::AI::Agent::Extrospection.serial_sense(
684
+ # block_dev: 'optional - device path (default first ttyUSB/ttyACM or config)',
685
+ # baud: 'optional - baud rate (default 9600)',
686
+ # payload: 'optional - String or byte Array to write (e.g. "ATI\\r" or [0x41,0x54])',
687
+ # settle_secs:'optional - seconds to read after write (default 1.5)',
688
+ # data_bits: 'optional (default 8)',
689
+ # stop_bits: 'optional (default 1)',
690
+ # parity: 'optional :none|:even|:odd (default :none)',
691
+ # record: 'optional - observe(category: :serial) (default true)',
692
+ # ttl: 'optional - observation TTL (default 600)'
693
+ # )
694
+ #
695
+ # Passive inventory via snapshot(sections:[:serial]); this verb is the
696
+ # active serial sense — open a device (PWN::Plugins::Serial), optional
697
+ # payload write, drain response, disconnect. Never keeps the port
698
+ # open across calls so other tools can claim the bus.
699
+ public_class_method def self.serial_sense(opts = {})
700
+ cfg = serial_config
701
+ block_dev = (opts[:block_dev] || cfg[:block_dev] || first_serial_dev).to_s
702
+ baud = (opts[:baud] || cfg[:baud] || 9600).to_i
703
+ settle = (opts[:settle_secs] || cfg[:settle_secs] || 1.5).to_f
704
+ settle = 0.2 if settle < 0.2
705
+ settle = 30.0 if settle > 30.0
706
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
707
+ ttl = (opts[:ttl] || cfg[:ttl] || 600).to_i
708
+ payload = opts[:payload]
709
+
710
+ inv = probe_serial
711
+ if block_dev.empty? || !File.exist?(block_dev)
712
+ err = {
713
+ ok: false,
714
+ error: "serial device not found: #{block_dev.inspect}",
715
+ advice: 'Plug a USB-UART / modem / Arduino or pass block_dev: "/dev/ttyUSB0". See probe_serial inventory.',
716
+ inventory: inv
717
+ }
718
+ observe(source: 'serial', category: :serial, target: block_dev, data: err, tags: %w[serial_sense unreachable], ttl: ttl) if record
719
+ return err
720
+ end
721
+
722
+ require 'pwn/plugins/serial' unless defined?(PWN::Plugins::Serial)
723
+ serial_obj = nil
724
+ begin
725
+ serial_obj = PWN::Plugins::Serial.connect(
726
+ block_dev: block_dev,
727
+ baud: baud,
728
+ data_bits: (opts[:data_bits] || 8).to_i,
729
+ stop_bits: (opts[:stop_bits] || 1).to_i,
730
+ parity: (opts[:parity] || :none).to_sym
731
+ )
732
+ PWN::Plugins::Serial.flush_session_data
733
+ PWN::Plugins::Serial.request(serial_obj: serial_obj, payload: payload) if payload
734
+ sleep settle
735
+ raw = Array(PWN::Plugins::Serial.dump_session_data)
736
+ text = raw.join.force_encoding('UTF-8').scrub
737
+ hex = raw.map do |b|
738
+ case b
739
+ when Integer then format('%02x', b & 0xff)
740
+ when String then b.bytes.map { |x| format('%02x', x) }.join
741
+ else b.to_s.unpack1('H*').to_s
742
+ end
743
+ end.join(' ')
744
+ line = begin
745
+ PWN::Plugins::Serial.get_line_state(serial_obj: serial_obj)
746
+ rescue StandardError
747
+ nil
748
+ end
749
+ modem = begin
750
+ PWN::Plugins::Serial.get_modem_params(serial_obj: serial_obj)
751
+ rescue StandardError
752
+ nil
753
+ end
754
+
755
+ payload_out = {
756
+ ok: true,
757
+ block_dev: block_dev,
758
+ baud: baud,
759
+ bytes: raw.length,
760
+ text: text[0, 2_000],
761
+ hex: hex[0, 2_000],
762
+ line_state: line,
763
+ modem_params: modem,
764
+ inventory: inv,
765
+ captured_at: Time.now.utc.iso8601
766
+ }
767
+ if record
768
+ summary = "serial #{block_dev}@#{baud} bytes=#{raw.length} text=#{text.to_s.gsub(/\s+/, ' ')[0, 120]}"
769
+ observe(source: 'serial', category: :serial, target: block_dev, data: summary, tags: %w[serial_sense], ttl: ttl)
770
+ payload_out[:observed] = true
771
+ payload_out[:summary] = summary
772
+ end
773
+ payload_out
774
+ rescue StandardError => e
775
+ {
776
+ ok: false,
777
+ error: "#{e.class}: #{e.message}",
778
+ block_dev: block_dev,
779
+ advice: 'Confirm device path, permissions (dialout group), and that no minicom/screen is holding the port.'
780
+ }
781
+ ensure
782
+ begin
783
+ PWN::Plugins::Serial.disconnect(serial_obj: serial_obj) if serial_obj
784
+ rescue StandardError
785
+ nil
786
+ end
787
+ end
788
+ end
789
+
790
+ # ============================================================
791
+ # Telecomm sense organ (SIP / VoIP / PSTN via BareSIP)
792
+ # ============================================================
793
+ # Supported Method Parameters::
794
+ # result = PWN::AI::Agent::Extrospection.telecomm(
795
+ # action: 'optional - :status|:dial|:hangup|:inventory (default :inventory)',
796
+ # target: 'optional - SIP URI / phone number for :dial (e.g. "sip:alice@example.com" or "+13125551212")',
797
+ # host: 'optional - BareSIP HTTP control host (default 127.0.0.1)',
798
+ # port: 'optional - BareSIP HTTP control port (default 8000)',
799
+ # record: 'optional - observe(category: :telecomm) (default true)',
800
+ # ttl: 'optional - observation TTL (default 600)'
801
+ # )
802
+ #
803
+ # Telecomm analogue of rf_tune — senses live SIP / VoIP / PSTN state
804
+ # through a *running* BareSIP instance (never launches it). Status
805
+ # and inventory always; dial/hangup are explicit and OPSEC-sensitive.
806
+ public_class_method def self.telecomm(opts = {})
807
+ action = (opts[:action] || :inventory).to_s.to_sym
808
+ host = (opts[:host] || telecomm_config[:host] || '127.0.0.1').to_s
809
+ port = (opts[:port] || telecomm_config[:port] || 8000).to_i
810
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
811
+ ttl = (opts[:ttl] || telecomm_config[:ttl] || 600).to_i
812
+ target = opts[:target].to_s.strip
813
+
814
+ inv = probe_telecomm
815
+ http_up = tcp_open?(host: host, port: port)
816
+
817
+ out = {
818
+ ok: true,
819
+ action: action,
820
+ host: host,
821
+ port: port,
822
+ baresip_http: http_up,
823
+ inventory: inv,
824
+ captured_at: Time.now.utc.iso8601
825
+ }
826
+
827
+ case action
828
+ when :inventory, :status
829
+ out[:status] = telecomm_baresip_cmd(host: host, port: port, cmd: '/?') if http_up
830
+ out[:status] ||= inv
831
+ when :dial
832
+ if target.empty?
833
+ out[:ok] = false
834
+ out[:error] = 'target is required for action: :dial'
835
+ out[:advice] = 'Pass target: "sip:user@host" or E.164 phone number.'
836
+ elsif !http_up
837
+ out[:ok] = false
838
+ out[:error] = "baresip HTTP control not reachable at #{host}:#{port}"
839
+ out[:advice] = 'Start baresip with HTTP module enabled (or PWN::Plugins::BareSIP.start) then retry.'
840
+ else
841
+ out[:dial] = telecomm_baresip_cmd(host: host, port: port, cmd: "/?dial=#{URI.encode_www_form_component(target)}")
842
+ end
843
+ when :hangup
844
+ if http_up
845
+ out[:hangup] = telecomm_baresip_cmd(host: host, port: port, cmd: '/?hangup')
846
+ else
847
+ out[:ok] = false
848
+ out[:error] = "baresip HTTP control not reachable at #{host}:#{port}"
849
+ end
850
+ else
851
+ out[:ok] = false
852
+ out[:error] = "unsupported action: #{action}"
853
+ out[:advice] = 'Use :inventory, :status, :dial, or :hangup.'
854
+ end
855
+
856
+ if record
857
+ summary = "telecomm action=#{action} baresip_http=#{http_up} target=#{target.empty? ? '-' : target}"
858
+ observe(source: 'telecomm', category: :telecomm, target: (target.empty? ? "#{host}:#{port}" : target), data: summary, tags: ['telecomm', action.to_s], ttl: ttl)
859
+ out[:observed] = true
860
+ out[:summary] = summary
861
+ end
862
+ out
863
+ end
864
+
865
+ # ============================================================
866
+ # Packet sense organ
867
+ # ============================================================
868
+ # Supported Method Parameters::
869
+ # result = PWN::AI::Agent::Extrospection.packet_sense(
870
+ # action: 'optional - :inventory|:capture|:summarize_pcap (default :inventory)',
871
+ # iface: 'optional - capture interface (default first non-lo or "any")',
872
+ # filter: 'optional - BPF filter (e.g. "tcp port 443")',
873
+ # count: 'optional - packets to capture (default 20, max 200)',
874
+ # timeout: 'optional - capture seconds (default 5, max 60)',
875
+ # path: 'optional - pcap path for :summarize_pcap',
876
+ # record: 'optional - observe(category: :packet) (default true)',
877
+ # ttl: 'optional - observation TTL (default 600)'
878
+ # )
879
+ #
880
+ # Passive L2/L3 sense via tshark/tcpdump when present; pcap
881
+ # summarisation via PWN::Plugins::Packet + tshark. Capture is short
882
+ # and bounded so the agent never hangs mid-turn.
883
+ public_class_method def self.packet_sense(opts = {})
884
+ action = (opts[:action] || :inventory).to_s.to_sym
885
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
886
+ ttl = (opts[:ttl] || packet_config[:ttl] || 600).to_i
887
+ iface = (opts[:iface] || packet_config[:iface] || default_capture_iface).to_s
888
+ filter = opts[:filter].to_s
889
+ count = (opts[:count] || 20).to_i.clamp(1, 200)
890
+ timeout = (opts[:timeout] || 5).to_i.clamp(1, 60)
891
+ path = opts[:path].to_s
892
+
893
+ inv = probe_packet
894
+ out = {
895
+ ok: true,
896
+ action: action,
897
+ iface: iface,
898
+ inventory: inv,
899
+ captured_at: Time.now.utc.iso8601
900
+ }
901
+
902
+ case action
903
+ when :inventory
904
+ out[:ifaces] = inv[:ifaces]
905
+ out[:bins] = inv[:bins]
906
+ when :capture
907
+ tshark = sh(cmd: 'which tshark 2>/dev/null')
908
+ tcpdump = sh(cmd: 'which tcpdump 2>/dev/null')
909
+ if tshark.empty? && tcpdump.empty?
910
+ out[:ok] = false
911
+ out[:error] = 'neither tshark nor tcpdump found in PATH'
912
+ out[:advice] = 'Install wireshark-common / tcpdump, or summarize an existing pcap with action: :summarize_pcap path:.'
913
+ else
914
+ pcap_out = File.join(Dir.home, '.pwn', 'extrospection', 'packet', "cap_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.pcap")
915
+ FileUtils.mkdir_p(File.dirname(pcap_out))
916
+ if tshark.empty?
917
+ bpf = filter.empty? ? '' : filter
918
+ cmd = "timeout #{timeout + 2} tcpdump -i #{Shellwords.escape(iface)} -c #{count} -w #{Shellwords.escape(pcap_out)} #{Shellwords.escape(bpf)} 2>&1"
919
+ else
920
+ bpf = filter.empty? ? '' : "-f #{Shellwords.escape(filter)}"
921
+ cmd = "timeout #{timeout + 2} tshark -i #{Shellwords.escape(iface)} -c #{count} -a duration:#{timeout} -w #{Shellwords.escape(pcap_out)} #{bpf} 2>&1"
922
+ end
923
+ log = sh(cmd: cmd)
924
+ out[:pcap] = File.exist?(pcap_out) ? pcap_out : nil
925
+ out[:log] = log.to_s[0, 500]
926
+ out[:summary] = summarize_pcap_file(path: pcap_out) if out[:pcap]
927
+ out[:ok] = !out[:pcap].nil?
928
+ out[:error] = 'capture produced no pcap' unless out[:ok]
929
+ end
930
+ when :summarize_pcap
931
+ if path.empty? || !File.exist?(path)
932
+ out[:ok] = false
933
+ out[:error] = "pcap not found: #{path.inspect}"
934
+ else
935
+ out[:path] = path
936
+ out[:summary] = summarize_pcap_file(path: path)
937
+ end
938
+ else
939
+ out[:ok] = false
940
+ out[:error] = "unsupported action: #{action}"
941
+ end
942
+
943
+ if record
944
+ blob = out[:summary].is_a?(Hash) ? out[:summary].to_json[0, 300] : out[:summary].to_s[0, 200]
945
+ summary = "packet action=#{action} iface=#{iface} #{blob}"
946
+ observe(source: 'packet', category: :packet, target: iface, data: summary, tags: ['packet', action.to_s], ttl: ttl)
947
+ out[:observed] = true
948
+ out[:obs_summary] = summary
949
+ end
950
+ out
951
+ end
952
+
953
+ # ============================================================
954
+ # Vision / OCR sense organ
955
+ # ============================================================
956
+ # Supported Method Parameters::
957
+ # result = PWN::AI::Agent::Extrospection.vision(
958
+ # file: 'required - path to image / screenshot / PDF-page render',
959
+ # action: 'optional - :ocr|:barcode|:inventory (default :ocr when file given, else :inventory)',
960
+ # lang: 'optional - tesseract language (default eng)',
961
+ # record: 'optional - observe(category: :vision) (default true)',
962
+ # ttl: 'optional - observation TTL (default 86400)'
963
+ # )
964
+ #
965
+ # Eyes on the host: OCR via PWN::Plugins::OCR (RTesseract / tesseract)
966
+ # and barcode/QR decode via zbarimg when present. Inventory only when
967
+ # no file is supplied.
968
+ public_class_method def self.vision(opts = {})
969
+ file = opts[:file].to_s.strip
970
+ action = (opts[:action] || (file.empty? ? :inventory : :ocr)).to_s.to_sym
971
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
972
+ ttl = (opts[:ttl] || vision_config[:ttl] || 86_400).to_i
973
+ lang = (opts[:lang] || vision_config[:lang] || 'eng').to_s
974
+
975
+ inv = probe_vision
976
+ out = {
977
+ ok: true,
978
+ action: action,
979
+ inventory: inv,
980
+ captured_at: Time.now.utc.iso8601
981
+ }
982
+
983
+ case action
984
+ when :inventory
985
+ # nothing else
986
+ when :ocr
987
+ if file.empty? || !File.exist?(file)
988
+ out[:ok] = false
989
+ out[:error] = "image not found: #{file.inspect}"
990
+ out[:advice] = 'Pass file: "/path/to/image.png" (png/jpg/tiff/webp).'
991
+ elsif inv.dig(:bins, :tesseract).to_s.empty? && !defined?(RTesseract)
992
+ out[:ok] = false
993
+ out[:error] = 'tesseract / rtesseract unavailable'
994
+ out[:advice] = 'Install tesseract-ocr (+ eng language data) or the rtesseract gem.'
995
+ else
996
+ text = vision_ocr(file: file, lang: lang)
997
+ out[:file] = file
998
+ out[:text] = text.to_s[0, 8_000]
999
+ out[:chars] = text.to_s.length
1000
+ out[:preview] = text.to_s.gsub(/\s+/, ' ')[0, 200]
1001
+ end
1002
+ when :barcode
1003
+ if file.empty? || !File.exist?(file)
1004
+ out[:ok] = false
1005
+ out[:error] = "image not found: #{file.inspect}"
1006
+ else
1007
+ codes = vision_barcodes(file: file)
1008
+ out[:file] = file
1009
+ out[:codes] = codes
1010
+ end
1011
+ else
1012
+ out[:ok] = false
1013
+ out[:error] = "unsupported action: #{action}"
1014
+ end
1015
+
1016
+ if record
1017
+ summary = if out[:preview]
1018
+ "vision ocr file=#{File.basename(file)} chars=#{out[:chars]} preview=#{out[:preview]}"
1019
+ elsif out[:codes]
1020
+ "vision barcode file=#{File.basename(file)} codes=#{Array(out[:codes]).length}"
1021
+ else
1022
+ "vision action=#{action} tesseract=#{!inv.dig(:bins, :tesseract).to_s.empty?}"
1023
+ end
1024
+ observe(source: 'vision', category: :vision, target: (file.empty? ? 'inventory' : file), data: summary, tags: ['vision', action.to_s], ttl: ttl)
1025
+ out[:observed] = true
1026
+ out[:summary] = summary
1027
+ end
1028
+ out
1029
+ end
1030
+
1031
+ # ============================================================
1032
+ # Voice sense organ (TTS / STT / inventory)
1033
+ # ============================================================
1034
+ # Supported Method Parameters::
1035
+ # result = PWN::AI::Agent::Extrospection.voice_sense(
1036
+ # action: 'optional - :inventory|:tts|:stt (default :inventory)',
1037
+ # text: 'optional - text to speak for :tts (or text_path:)',
1038
+ # text_path: 'optional - path to text file for :tts',
1039
+ # audio: 'optional - path to audio file for :stt',
1040
+ # out: 'optional - output audio path for :tts (wav)',
1041
+ # engine: 'optional - :espeak|:festival|:spd_say|:whisper (auto)',
1042
+ # model: 'optional - whisper model (default tiny)',
1043
+ # record: 'optional - observe(category: :voice) (default true)',
1044
+ # ttl: 'optional - observation TTL (default 3600)'
1045
+ # )
1046
+ #
1047
+ # Wraps PWN::Plugins::Voice + system TTS/STT binaries (espeak-ng,
1048
+ # festival, spd-say, whisper, sox). Inventory is always free;
1049
+ # TTS/STT are on-demand and persist optional artefacts under
1050
+ # ~/.pwn/extrospection/voice/.
1051
+ public_class_method def self.voice_sense(opts = {})
1052
+ action = (opts[:action] || :inventory).to_s.to_sym
1053
+ record = opts.key?(:record) ? !opts[:record].nil? && opts[:record] != false : true
1054
+ ttl = (opts[:ttl] || voice_config[:ttl] || 3_600).to_i
1055
+ inv = probe_voice
1056
+
1057
+ out = {
1058
+ ok: true,
1059
+ action: action,
1060
+ inventory: inv,
1061
+ captured_at: Time.now.utc.iso8601
1062
+ }
1063
+
1064
+ case action
1065
+ when :inventory
1066
+ # nothing else
1067
+ when :tts
1068
+ text = opts[:text].to_s
1069
+ if text.empty? && opts[:text_path]
1070
+ text = begin
1071
+ File.read(opts[:text_path].to_s)
1072
+ rescue StandardError
1073
+ ''
1074
+ end
1075
+ end
1076
+ if text.strip.empty?
1077
+ out[:ok] = false
1078
+ out[:error] = 'text or text_path is required for action: :tts'
1079
+ else
1080
+ engine = (opts[:engine] || pick_tts_engine(inv: inv)).to_s.to_sym
1081
+ art = File.join(Dir.home, '.pwn', 'extrospection', 'voice')
1082
+ FileUtils.mkdir_p(art)
1083
+ wav = (opts[:out] || File.join(art, "tts_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.wav")).to_s
1084
+ ok, log = voice_tts(text: text, engine: engine, out: wav, inv: inv)
1085
+ out[:engine] = engine
1086
+ out[:audio] = File.exist?(wav) ? wav : nil
1087
+ out[:log] = log.to_s[0, 400]
1088
+ out[:chars] = text.length
1089
+ out[:ok] = ok
1090
+ out[:error] = 'TTS engine failed' unless ok
1091
+ end
1092
+ when :stt
1093
+ audio = opts[:audio].to_s
1094
+ if audio.empty? || !File.exist?(audio)
1095
+ out[:ok] = false
1096
+ out[:error] = "audio not found: #{audio.inspect}"
1097
+ out[:advice] = 'Pass audio: "/path/to/recording.wav". Requires whisper binary or plugin path.'
1098
+ else
1099
+ engine = (opts[:engine] || pick_stt_engine(inv: inv)).to_s.to_sym
1100
+ text, log = voice_stt(audio: audio, engine: engine, model: opts[:model] || 'tiny', inv: inv)
1101
+ out[:engine] = engine
1102
+ out[:audio] = audio
1103
+ out[:text] = text.to_s[0, 8_000]
1104
+ out[:log] = log.to_s[0, 400]
1105
+ out[:ok] = !text.to_s.strip.empty?
1106
+ out[:error] = 'STT produced empty transcript' unless out[:ok]
1107
+ end
1108
+ else
1109
+ out[:ok] = false
1110
+ out[:error] = "unsupported action: #{action}"
1111
+ end
1112
+
1113
+ if record
1114
+ summary = case action
1115
+ when :tts then "voice tts engine=#{out[:engine]} chars=#{out[:chars]} audio=#{out[:audio]}"
1116
+ when :stt then "voice stt engine=#{out[:engine]} text=#{out[:text].to_s.gsub(/\s+/, ' ')[0, 120]}"
1117
+ else "voice inventory espeak=#{!inv.dig(:bins, :'espeak-ng').to_s.empty? && inv.dig(:bins, :'espeak-ng') != ''} sox=#{!inv.dig(:bins, :sox).to_s.empty?}"
1118
+ end
1119
+ observe(source: 'voice', category: :voice, target: action.to_s, data: summary, tags: ['voice', action.to_s], ttl: ttl)
1120
+ out[:observed] = true
1121
+ out[:summary] = summary
1122
+ end
1123
+ out
1124
+ end
1125
+
394
1126
  # Supported Method Parameters::
395
1127
  # report = PWN::AI::Agent::Extrospection.revalidate_memory(
396
1128
  # limit: 'optional - max :fact entries to check (default 25)',
@@ -588,7 +1320,13 @@ module PWN
588
1320
  toolchain_bins: (snap[:toolchain] || {}).count { |_, v| !v.to_s.empty? },
589
1321
  listening_ports: Array(snap.dig(:net, :listening)).length,
590
1322
  rf_devices: (snap[:rf] || {}).values_at(:rtl_sdr, :hackrf, :flipper, :gqrx_sock).count { |v| rf_present?(val: v) },
591
- web_anchors: (snap[:web] || {}).count { |_, v| v.is_a?(Hash) && v[:reachable] }
1323
+ web_anchors: (snap[:web] || {}).count { |_, v| v.is_a?(Hash) && v[:reachable] },
1324
+ serial_devs: Array((snap[:serial] || {})[:devices]).length,
1325
+ packet_bins: ((snap[:packet] || {})[:bins] || {}).count { |_, v| !v.to_s.empty? },
1326
+ vision_bins: ((snap[:vision] || {})[:bins] || {}).count { |_, v| !v.to_s.empty? },
1327
+ voice_bins: ((snap[:voice] || {})[:bins] || {}).count { |_, v| !v.to_s.empty? },
1328
+ telecomm_http: (snap[:telecomm] || {})[:baresip_http] ? true : false,
1329
+ osint_feeds: Array((snap[:osint] || {})[:feeds_available]).length
592
1330
  }
593
1331
  end
594
1332
 
@@ -597,7 +1335,7 @@ module PWN
597
1335
  # session_id: 'optional - id of the just-completed session (for tagging)'
598
1336
  # )
599
1337
  #
600
- # Called by Learning.auto_reflect when
1338
+ # Called by Learning.auto_introspect when
601
1339
  # PWN::Env[:ai][:agent][:auto_extrospect] is truthy. Captures a fresh
602
1340
  # snapshot and, if drift is non-trivial, records it as an observation
603
1341
  # and a PWN::Memory :env fact so the NEXT session's system prompt
@@ -607,7 +1345,9 @@ module PWN
607
1345
  sid = opts[:session_id]
608
1346
  return unless auto_extrospect_enabled?
609
1347
 
610
- res = snapshot(persist: true)
1348
+ # Ambient baseline only — never toolchain / rf / web (those spawn
1349
+ # hardware probes or GUI binaries and belong on the *sense* path).
1350
+ res = snapshot(persist: true, sections: AUTO_SECTIONS)
611
1351
  delta = res[:drift]
612
1352
  moved = Array(delta[:changed]).length + Array(delta[:added]).length + Array(delta[:removed]).length
613
1353
  return res if moved.zero?
@@ -689,13 +1429,31 @@ module PWN
689
1429
  { listening: listen, interfaces: ifaces, default_route: route }
690
1430
  end
691
1431
 
1432
+ # Inventory of tooling PATH presence. SAFE_VERSION_BINS may receive a
1433
+ # timeout-bounded --version; PRESENCE_ONLY_BINS + anything that looks
1434
+ # GUI/JVM never get executed — path-only. Never open Burp/ZAP/msf/GQRX.
692
1435
  private_class_method def self.probe_toolchain
693
1436
  PROBE_BINS.each_with_object({}) do |b, h|
694
- path = sh(cmd: "which #{b} 2>/dev/null")
695
- h[b.to_sym] = path.empty? ? '' : "#{path} #{sh(cmd: "#{b} --version 2>/dev/null").lines.first.to_s.strip[0, 80]}"
1437
+ path = sh(cmd: "which #{Shellwords.escape(b)} 2>/dev/null").split("\n").first.to_s.strip
1438
+ if path.empty?
1439
+ h[b.to_sym] = ''
1440
+ elsif presence_only_bin?(name: b)
1441
+ h[b.to_sym] = path
1442
+ else
1443
+ ver = sh(cmd: "timeout 2 #{Shellwords.escape(b)} --version 2>/dev/null").lines.first.to_s.strip[0, 80]
1444
+ h[b.to_sym] = "#{path} #{ver}".strip
1445
+ end
696
1446
  end
697
1447
  end
698
1448
 
1449
+ private_class_method def self.presence_only_bin?(opts = {})
1450
+ n = opts[:name].to_s
1451
+ return true if PRESENCE_ONLY_BINS.include?(n)
1452
+
1453
+ # Defence in depth: never auto-exec anything that smells like a GUI suite.
1454
+ n.match?(/burp|zaproxy|zap$|msfconsole|gqrx|wireshark|firefox|chrome|chromium/i)
1455
+ end
1456
+
699
1457
  private_class_method def self.probe_repo
700
1458
  root = defined?(PWN::ROOT) ? PWN::ROOT.to_s : Dir.pwd
701
1459
  return {} unless Dir.exist?(File.join(root, '.git'))
@@ -1063,39 +1821,1786 @@ module PWN
1063
1821
  []
1064
1822
  end
1065
1823
 
1066
- # Author(s):: 0day Inc. <support@0dayinc.com>
1824
+ # ── RF sense helpers (rf_tune) ─────────────────────────────────────
1067
1825
 
1068
- public_class_method def self.authors
1069
- "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
1826
+ private_class_method def self.rf_config
1827
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :rf) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
1828
+ {
1829
+ host: cfg[:host] || '127.0.0.1',
1830
+ port: (cfg[:port] || 7356).to_i,
1831
+ settle_secs: (cfg[:settle_secs] || 8).to_f,
1832
+ ttl: (cfg[:ttl] || 300).to_i
1833
+ }
1834
+ rescue StandardError
1835
+ { host: '127.0.0.1', port: 7356, settle_secs: 8.0, ttl: 300 }
1070
1836
  end
1071
1837
 
1072
- # Display Usage for this Module
1838
+ # Accept free-form user input ("101.1", "101.1 FM", "101.1 MHz",
1839
+ # "101100000", "101.100.000", 101_100_000) → [hz_i, human_label].
1840
+ private_class_method def self.normalize_rf_freq(opts = {})
1841
+ raw = opts[:freq]
1842
+ s = raw.to_s.strip
1843
+ unit = :auto
1844
+ case s
1845
+ when /\b(mhz|m hz)\b/i, /\bfm\b/i
1846
+ unit = :mhz
1847
+ when /\b(khz|k hz)\b/i
1848
+ unit = :khz
1849
+ when /\b(ghz|g hz)\b/i
1850
+ unit = :ghz
1851
+ when /\bhz\b/i
1852
+ unit = :hz
1853
+ end
1854
+ s = s.gsub(/[^0-9._-]/, '')
1855
+ # Dotted-group form used throughout PWN::SDR ("101.100.000")
1856
+ if s.count('.') >= 2
1857
+ hz = s.gsub('.', '').to_i
1858
+ return [hz, human_rf_label(hz: hz)]
1859
+ end
1860
+ num = s.gsub('_', '').to_f
1861
+ raise "ERROR: could not parse frequency from #{opts[:freq].inspect}" if num <= 0
1862
+
1863
+ hz = case unit
1864
+ when :hz then num.to_i
1865
+ when :khz then (num * 1_000).to_i
1866
+ when :mhz then (num * 1_000_000).to_i
1867
+ when :ghz then (num * 1_000_000_000).to_i
1868
+ else
1869
+ # Heuristic: bare integers ≥ 1e6 already look like Hz
1870
+ # (e.g. 101100000); everything smaller is treated as MHz
1871
+ # because broadcast / hobby speech says "101.1" / "433.92".
1872
+ if num >= 1_000_000
1873
+ num.to_i
1874
+ else
1875
+ (num * 1_000_000).to_i
1876
+ end
1877
+ end
1878
+ # Snap FM broadcast channels onto the 100 kHz raster when they land near it.
1879
+ hz = ((hz + 50_000) / 100_000) * 100_000 if hz.between?(87_500_000, 108_100_000)
1880
+ [hz, human_rf_label(hz: hz)]
1881
+ end
1073
1882
 
1074
- public_class_method def self.help
1075
- puts <<~USAGE
1076
- USAGE:
1077
- PWN::AI::Agent::Extrospection.snapshot # probe host, persist, return {snapshot:, drift:}
1078
- PWN::AI::Agent::Extrospection.drift(live: true) # what changed vs last snapshot
1079
- PWN::AI::Agent::Extrospection.observe(source: 'nmap', category: :recon, target: '10.0.0.5', data: '22/tcp open ssh 9.6')
1080
- PWN::AI::Agent::Extrospection.observe(source: 'gqrx', category: :rf, target: '433.920MHz', data: 'peak -34.2 dBFS bw=200k')
1081
- PWN::AI::Agent::Extrospection.observations(category: 'recon', target: '10.0.0.5')
1082
- PWN::AI::Agent::Extrospection.intel(query: 'openssl 3.0', record: true)
1083
- PWN::AI::Agent::Extrospection.correlate # introspection x extrospection findings
1084
- PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
1085
- PWN::AI::Agent::Extrospection.stats
1086
- PWN::AI::Agent::Extrospection.auto_extrospect(session_id: sid) # called from Learning.auto_reflect
1087
- PWN::AI::Agent::Extrospection.snapshot(sections: %i[web]) # opt-in browser probe of web_anchors
1088
- PWN::AI::Agent::Extrospection.watch(url: 'https://target/api/version')
1089
- PWN::AI::Agent::Extrospection.verify(claim: 'CVE-2026-12345 affects OpenSSL 3.2.1')
1090
- PWN::AI::Agent::Extrospection.revalidate_memory # cron: GC stale PWN::Memory :fact entries
1091
- PWN::AI::Agent::Extrospection.reset
1883
+ private_class_method def self.human_rf_label(opts = {})
1884
+ hz = opts[:hz].to_i
1885
+ if hz >= 1_000_000
1886
+ mhz = hz / 1_000_000.0
1887
+ # Drop trailing zeros: 101.1 not 101.100000
1888
+ txt = format('%.6f', mhz).sub(/\.?0+$/, '')
1889
+ "#{txt} MHz"
1890
+ elsif hz >= 1_000
1891
+ "#{format('%.3f', hz / 1_000.0).sub(/\.?0+$/, '')} kHz"
1892
+ else
1893
+ "#{hz} Hz"
1894
+ end
1895
+ end
1092
1896
 
1093
- Enable end-of-run auto-extrospection with:
1094
- PWN::Env[:ai][:agent][:auto_extrospect] = true
1897
+ private_class_method def self.match_rf_band_plan(opts = {})
1898
+ hz = opts[:hz].to_i
1899
+ require 'pwn/sdr/frequency_allocation' unless defined?(PWN::SDR::FrequencyAllocation)
1900
+ # Prefer the narrowest matching plan so e.g. fm_radio (87.9–108 MHz)
1901
+ # wins over the broad analog_tv_vhf (54–216 MHz) that contains it.
1902
+ # Explicit decoder-bearing plans also beat bare occupancy plans.
1903
+ matches = []
1904
+ PWN::SDR::FrequencyAllocation.band_plans.each do |name, plan|
1905
+ Array(plan[:ranges]).each do |r|
1906
+ lo = begin
1907
+ PWN::SDR.hz_to_i(freq: r[:start_freq])
1908
+ rescue StandardError
1909
+ r[:start_freq].to_s.gsub(/[^\d]/, '').to_i
1910
+ end
1911
+ hi = begin
1912
+ PWN::SDR.hz_to_i(freq: r[:target_freq])
1913
+ rescue StandardError
1914
+ r[:target_freq].to_s.gsub(/[^\d]/, '').to_i
1915
+ end
1916
+ lo, hi = hi, lo if lo > hi
1917
+ # FM broadcast band plan ends at 108.000.000 — include the top channel.
1918
+ hi += 100_000 if name.to_s == 'fm_radio'
1919
+ next unless hz.between?(lo, hi)
1920
+
1921
+ width = (hi - lo).abs
1922
+ decoder_bonus = plan[:decoder] ? 0 : 1
1923
+ # Lower score = better match. Decoder-bearing + narrower wins.
1924
+ score = [decoder_bonus, width]
1925
+ matches << { score: score, name: name.to_s, plan: plan, width: width }
1926
+ end
1927
+ end
1928
+ return nil if matches.empty?
1095
1929
 
1096
- Configure browser-backed :web probe / verify / watch with:
1097
- PWN::Env[:ai][:agent][:extrospection][:web] =
1098
- { anchors: [...], proxy: 'tor', max_anchors: 8, per_page_timeout: 15, screenshot: false, allow_targets: false }
1930
+ best = matches.min_by { |m| m[:score] }
1931
+ best[:plan].merge(name: best[:name])
1932
+ rescue StandardError
1933
+ nil
1934
+ end
1935
+
1936
+ private_class_method def self.default_rf_demod(opts = {})
1937
+ hz = opts[:hz].to_i
1938
+ return :WFM_ST if hz.between?(87_500_000, 108_100_000)
1939
+ return :AM if hz.between?(530_000, 1_710_000)
1940
+ return :FM if hz >= 30_000_000
1941
+
1942
+ :WFM
1943
+ end
1944
+
1945
+ private_class_method def self.default_rf_bandwidth(opts = {})
1946
+ hz = opts[:hz].to_i
1947
+ return '200.000' if hz.between?(87_500_000, 108_100_000)
1948
+ return '10.000' if hz.between?(530_000, 1_710_000)
1949
+
1950
+ '15.000'
1951
+ end
1952
+
1953
+ # Sample GQRX's built-in RDS decoder over settle_secs.
1954
+ # Canonical implementation lives on PWN::SDR::Decoder::RDS.sample —
1955
+ # this is a thin adapter so rf_tune stays stable for agents.
1956
+ # Returns { pi:, ps_name:, radiotext:, station:, samples: N, settle_secs: }.
1957
+ private_class_method def self.sample_rds(opts = {})
1958
+ require 'pwn/sdr/decoder/rds' unless defined?(PWN::SDR::Decoder::RDS)
1959
+ PWN::SDR::Decoder::RDS.sample(
1960
+ gqrx_sock: opts[:gqrx_sock],
1961
+ freq_obj: opts[:freq_obj],
1962
+ settle_secs: opts[:settle_secs],
1963
+ interval: opts[:interval],
1964
+ leave_enabled: opts[:leave_enabled]
1965
+ )
1966
+ end
1967
+
1968
+ private_class_method def self.build_rf_summary(opts = {})
1969
+ payload = opts[:payload] || {}
1970
+ parts = []
1971
+ parts << payload[:freq].to_s
1972
+ parts << "str=#{payload[:strength_dbfs]} dBFS" if payload[:strength_dbfs]
1973
+ parts << "mode=#{payload[:demodulator_mode]}" if payload[:demodulator_mode]
1974
+ if payload[:rds].is_a?(Hash)
1975
+ r = payload[:rds]
1976
+ parts << "PI=#{r[:pi]}" if r[:pi]
1977
+ parts << "station=#{r[:station]}" if r[:station]
1978
+ parts << "PS=#{r[:ps_name]}" if r[:ps_name] && r[:ps_name] != r[:station]
1979
+ parts << "RT=#{r[:radiotext]}" if r[:radiotext]
1980
+ end
1981
+ parts << "plan=#{payload[:band_plan]}" if payload[:band_plan]
1982
+ parts.join(' | ')
1983
+ end
1984
+
1985
+ # ── OSINT / Serial / Telecomm / Packet / Vision / Voice probes ──
1986
+
1987
+ private_class_method def self.probe_osint
1988
+ keys = osint_api_keys
1989
+ {
1990
+ feeds_available: DEFAULT_OSINT_FEEDS.map(&:to_s),
1991
+ keyed: {
1992
+ shodan: !keys[:shodan].to_s.empty?,
1993
+ hunter: !keys[:hunter].to_s.empty?,
1994
+ abuseipdb: !keys[:abuseipdb].to_s.empty?,
1995
+ virustotal: !keys[:virustotal].to_s.empty?,
1996
+ greynoise: !keys[:greynoise].to_s.empty?,
1997
+ haveibeenpwned: !keys[:haveibeenpwned].to_s.empty?,
1998
+ securitytrails: !keys[:securitytrails].to_s.empty?
1999
+ },
2000
+ bins: presence_map(bins: OSINT_BINS),
2001
+ endpoints: {
2002
+ ip_api: 'http://ip-api.com/json/',
2003
+ ipwhois: 'https://ipwho.is/',
2004
+ ipapi_is: 'https://api.ipapi.is/',
2005
+ iplocate: 'https://www.iplocate.io/api/lookup/',
2006
+ rdap: 'https://rdap.org/',
2007
+ crtsh: 'https://crt.sh/',
2008
+ certspotter: 'https://api.certspotter.com/v1/issuances',
2009
+ bgpview: 'https://api.bgpview.io/',
2010
+ fcc_device: 'https://device.report/fcc/',
2011
+ fcc_api: 'https://data.fcc.gov/api/',
2012
+ patents_google: 'https://patents.google.com/',
2013
+ patentsview: 'https://api.patentsview.org/',
2014
+ openfda: 'https://api.fda.gov/',
2015
+ nppes: 'https://npiregistry.cms.hhs.gov/api/',
2016
+ nominatim: 'https://nominatim.openstreetmap.org/',
2017
+ wayback: 'https://archive.org/wayback/available',
2018
+ github: 'https://api.github.com/',
2019
+ dns_google: 'https://dns.google/resolve',
2020
+ otx: 'https://otx.alienvault.com/api/v1/',
2021
+ urlhaus: 'https://urlhaus-api.abuse.ch/v1/',
2022
+ threatfox: 'https://threatfox-api.abuse.ch/api/v1/',
2023
+ urlscan: 'https://urlscan.io/api/v1/search/',
2024
+ hackertarget: 'https://api.hackertarget.com/',
2025
+ opencorporates: 'https://api.opencorporates.com/v0.4/',
2026
+ courtlistener: 'https://www.courtlistener.com/api/rest/v4/',
2027
+ sec_edgar: 'https://efts.sec.gov/LATEST/search-index',
2028
+ federal_register: 'https://www.federalregister.gov/api/v1/',
2029
+ uk_police: 'https://data.police.uk/api/',
2030
+ callook: 'https://callook.info/',
2031
+ nhtsa_vpic: 'https://vpic.nhtsa.dot.gov/api/',
2032
+ maclookup: 'https://api.maclookup.app/v2/macs/',
2033
+ macvendors: 'https://api.macvendors.com/',
2034
+ universities: 'http://universities.hipolabs.com/search',
2035
+ microlink: 'https://api.microlink.io/',
2036
+ agify: 'https://api.agify.io/',
2037
+ genderize: 'https://api.genderize.io/',
2038
+ nationalize: 'https://api.nationalize.io/',
2039
+ epss: 'https://api.first.org/data/v1/epss',
2040
+ cisa_kev: 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json',
2041
+ abuseipdb: 'https://api.abuseipdb.com/api/v2/',
2042
+ virustotal: 'https://www.virustotal.com/api/v3/',
2043
+ greynoise: 'https://api.greynoise.io/',
2044
+ haveibeenpwned: 'https://haveibeenpwned.com/api/v3/',
2045
+ securitytrails: 'https://api.securitytrails.com/v1/',
2046
+ opensanctions: 'https://api.opensanctions.org/',
2047
+ wikidata: 'https://www.wikidata.org/w/api.php',
2048
+ wikipedia: 'https://en.wikipedia.org/w/api.php',
2049
+ namus: 'https://www.namus.gov/Search',
2050
+ charley: 'https://charleyproject.org/',
2051
+ fbi_kidnap: 'https://www.fbi.gov/wanted/kidnap',
2052
+ vital_cdc: 'https://www.cdc.gov/nchs/nvss/index.htm',
2053
+ vital_familysearch: 'https://www.familysearch.org/en/search/'
2054
+ },
2055
+ public_api_lists: 'https://github.com/public-api-lists/public-api-lists'
2056
+ }
2057
+ rescue StandardError => e
2058
+ { error: "#{e.class}: #{e.message}" }
2059
+ end
2060
+
2061
+ private_class_method def self.probe_serial
2062
+ by_id = Dir.glob('/dev/serial/by-id/*').map do |p|
2063
+ { path: p, target: begin
2064
+ File.readlink(p)
2065
+ rescue StandardError
2066
+ p
2067
+ end }
2068
+ end
2069
+ devs = Dir.glob('/dev/{ttyUSB,ttyACM,ttyS,ttyAMA}*')
2070
+ {
2071
+ devices: devs,
2072
+ by_id: by_id.first(20),
2073
+ bins: presence_map(bins: SERIAL_BINS),
2074
+ default: first_serial_dev
2075
+ }
2076
+ rescue StandardError => e
2077
+ { error: "#{e.class}: #{e.message}" }
2078
+ end
2079
+
2080
+ private_class_method def self.probe_telecomm
2081
+ host = telecomm_config[:host] || '127.0.0.1'
2082
+ port = (telecomm_config[:port] || 8000).to_i
2083
+ {
2084
+ bins: presence_map(bins: TELECOMM_BINS),
2085
+ baresip_http: tcp_open?(host: host, port: port),
2086
+ baresip_host: host,
2087
+ baresip_port: port,
2088
+ baresip_home: Dir.exist?(File.join(Dir.home, '.baresip')),
2089
+ sip_ports_listening: begin
2090
+ sh(cmd: "ss -tulnH 2>/dev/null | awk '{print $5}'").lines.map(&:strip).grep(/:(5060|5061|5080|8000)\b/).uniq
2091
+ rescue StandardError
2092
+ []
2093
+ end
2094
+ }
2095
+ rescue StandardError => e
2096
+ { error: "#{e.class}: #{e.message}" }
2097
+ end
2098
+
2099
+ private_class_method def self.probe_packet
2100
+ ifaces = begin
2101
+ sh(cmd: "ip -o link show 2>/dev/null | awk -F': ' '{print $2}'").lines.map { |l| l.strip.split('@').first }.reject { |i| i == 'lo' || i.empty? }
2102
+ rescue StandardError
2103
+ []
2104
+ end
2105
+ {
2106
+ bins: presence_map(bins: PACKET_BINS),
2107
+ ifaces: ifaces,
2108
+ default_iface: default_capture_iface,
2109
+ cap_dir: File.join(Dir.home, '.pwn', 'extrospection', 'packet')
2110
+ }
2111
+ rescue StandardError => e
2112
+ { error: "#{e.class}: #{e.message}" }
2113
+ end
2114
+
2115
+ private_class_method def self.probe_vision
2116
+ {
2117
+ bins: presence_map(bins: VISION_BINS),
2118
+ tesseract_langs: begin
2119
+ raw = sh(cmd: 'tesseract --list-langs 2>&1')
2120
+ raw.lines.drop(1).map(&:strip).reject(&:empty?).first(20)
2121
+ rescue StandardError
2122
+ []
2123
+ end,
2124
+ plugin_ocr: defined?(PWN::Plugins::OCR) || File.exist?(File.join(defined?(PWN::ROOT) ? PWN::ROOT.to_s : '/opt/pwn', 'lib/pwn/plugins/ocr.rb')),
2125
+ plugin_scannable: defined?(PWN::Plugins::ScannableCodes) || true
2126
+ }
2127
+ rescue StandardError => e
2128
+ { error: "#{e.class}: #{e.message}" }
2129
+ end
2130
+
2131
+ private_class_method def self.probe_voice
2132
+ {
2133
+ bins: presence_map(bins: VOICE_BINS),
2134
+ plugin_voice: true,
2135
+ art_dir: File.join(Dir.home, '.pwn', 'extrospection', 'voice')
2136
+ }
2137
+ rescue StandardError => e
2138
+ { error: "#{e.class}: #{e.message}" }
2139
+ end
2140
+
2141
+ private_class_method def self.presence_map(opts = {})
2142
+ Array(opts[:bins]).each_with_object({}) do |b, h|
2143
+ path = sh(cmd: "which #{Shellwords.escape(b)} 2>/dev/null").split("\n").first.to_s.strip
2144
+ h[b.to_sym] = path
2145
+ end
2146
+ end
2147
+
2148
+ private_class_method def self.first_serial_dev
2149
+ (Dir.glob('/dev/ttyUSB*') + Dir.glob('/dev/ttyACM*')).min.to_s
2150
+ end
2151
+
2152
+ private_class_method def self.default_capture_iface
2153
+ ifaces = begin
2154
+ sh(cmd: "ip -o link show 2>/dev/null | awk -F': ' '{print $2}'").lines.map { |l| l.strip.split('@').first }.reject { |i| i == 'lo' || i.empty? }
2155
+ rescue StandardError
2156
+ []
2157
+ end
2158
+ ifaces.first || 'any'
2159
+ end
2160
+
2161
+ # ── limb configs ────────────────────────────────────────────
2162
+
2163
+ private_class_method def self.osint_config
2164
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :osint) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2165
+ { ttl: (cfg[:ttl] || 86_400).to_i, proxy: cfg[:proxy] }
2166
+ rescue StandardError
2167
+ { ttl: 86_400, proxy: nil }
2168
+ end
2169
+
2170
+ private_class_method def self.serial_config
2171
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :serial) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2172
+ { block_dev: cfg[:block_dev], baud: (cfg[:baud] || 9600).to_i, settle_secs: (cfg[:settle_secs] || 1.5).to_f, ttl: (cfg[:ttl] || 600).to_i }
2173
+ rescue StandardError
2174
+ { block_dev: nil, baud: 9600, settle_secs: 1.5, ttl: 600 }
2175
+ end
2176
+
2177
+ private_class_method def self.telecomm_config
2178
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :telecomm) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2179
+ { host: cfg[:host] || '127.0.0.1', port: (cfg[:port] || 8000).to_i, ttl: (cfg[:ttl] || 600).to_i }
2180
+ rescue StandardError
2181
+ { host: '127.0.0.1', port: 8000, ttl: 600 }
2182
+ end
2183
+
2184
+ private_class_method def self.packet_config
2185
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :packet) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2186
+ { iface: cfg[:iface], ttl: (cfg[:ttl] || 600).to_i }
2187
+ rescue StandardError
2188
+ { iface: nil, ttl: 600 }
2189
+ end
2190
+
2191
+ private_class_method def self.vision_config
2192
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :vision) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2193
+ { lang: cfg[:lang] || 'eng', ttl: (cfg[:ttl] || 86_400).to_i }
2194
+ rescue StandardError
2195
+ { lang: 'eng', ttl: 86_400 }
2196
+ end
2197
+
2198
+ private_class_method def self.voice_config
2199
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :voice) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2200
+ { ttl: (cfg[:ttl] || 3_600).to_i }
2201
+ rescue StandardError
2202
+ { ttl: 3_600 }
2203
+ end
2204
+
2205
+ # ── OSINT helpers ───────────────────────────────────────────
2206
+
2207
+ private_class_method def self.osint_api_keys(opts = {})
2208
+ ov = opts[:override] || {}
2209
+ env_cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :osint, :api_keys) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2210
+ pwn_cfg = (PWN::Env[:shodan] if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
2211
+ pick = lambda do |sym, *env_names|
2212
+ val = ov[sym] || env_cfg[sym]
2213
+ env_names.each { |n| val ||= ENV[n] if ENV[n] }
2214
+ val.to_s
2215
+ end
2216
+ {
2217
+ shodan: (ov[:shodan] || env_cfg[:shodan] || pwn_cfg[:api_key] || ENV['SHODAN_API_KEY'] || ENV.fetch('PWN_SHODAN_API_KEY', nil)).to_s,
2218
+ hunter: pick.call(:hunter, 'HUNTER_API_KEY', 'PWN_HUNTER_API_KEY'),
2219
+ abuseipdb: pick.call(:abuseipdb, 'ABUSEIPDB_API_KEY', 'PWN_ABUSEIPDB_API_KEY'),
2220
+ virustotal: pick.call(:virustotal, 'VIRUSTOTAL_API_KEY', 'VT_API_KEY', 'PWN_VIRUSTOTAL_API_KEY'),
2221
+ greynoise: pick.call(:greynoise, 'GREYNOISE_API_KEY', 'PWN_GREYNOISE_API_KEY'),
2222
+ haveibeenpwned: pick.call(:haveibeenpwned, 'HIBP_API_KEY', 'HAVEIBEENPWNED_API_KEY', 'PWN_HIBP_API_KEY'),
2223
+ securitytrails: pick.call(:securitytrails, 'SECURITYTRAILS_API_KEY', 'PWN_SECURITYTRAILS_API_KEY')
2224
+ }
2225
+ rescue StandardError
2226
+ {
2227
+ shodan: ENV['SHODAN_API_KEY'].to_s,
2228
+ hunter: ENV['HUNTER_API_KEY'].to_s,
2229
+ abuseipdb: ENV['ABUSEIPDB_API_KEY'].to_s,
2230
+ virustotal: ENV['VIRUSTOTAL_API_KEY'].to_s,
2231
+ greynoise: ENV['GREYNOISE_API_KEY'].to_s,
2232
+ haveibeenpwned: ENV['HIBP_API_KEY'].to_s,
2233
+ securitytrails: ENV['SECURITYTRAILS_API_KEY'].to_s
2234
+ }
2235
+ end
2236
+
2237
+ private_class_method def self.detect_osint_kind(opts = {})
2238
+ q = opts[:query].to_s.strip
2239
+ # Order matters: IPs must beat the phone heuristic (dots are valid phone punctuation).
2240
+ return :ip if q.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\z/)
2241
+ # MAC before IPv6 — colon form "00:11:22:33:44:55" is also hex+colons.
2242
+ return :mac if q.match?(/\A(?:[0-9A-Fa-f]{2}([-:])){5}[0-9A-Fa-f]{2}\z/) ||
2243
+ q.match?(/\A[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\z/) ||
2244
+ q.match?(/\A[0-9A-Fa-f]{12}\z/)
2245
+ # IPv6: require at least two ':' and a hex group structure (not MAC's 6 octets).
2246
+ return :ip if q.include?(':') && q.count(':') >= 2 && q.match?(/\A[0-9a-f:.]+\z/i) && !q.match?(/\A(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\z/i)
2247
+ return :email if q.include?('@') && q.include?('.')
2248
+ return :url if q.match?(%r{\Ahttps?://}i)
2249
+ return :cve if q.match?(/\ACVE-\d{4}-\d{4,}\z/i)
2250
+ # 17-char VIN (excludes I,O,Q per ISO 3779)
2251
+ return :vin if q.match?(/\A[A-HJ-NPR-Z0-9]{17}\z/i)
2252
+ # Amateur radio callsign (Callook / FCC ULS)
2253
+ return :callsign if q.match?(%r{\A(?:[A-Z0-9]{1,3}/)?[A-Z]{1,2}\d[A-Z]{1,3}\z}i) && q.match?(/\d/)
2254
+ return :domain if q.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/i) && !q.include?(' ')
2255
+ return :github if q.start_with?('github.com/', 'gh:')
2256
+ return :patent if q.match?(/\A(?:US|EP|WO|CN|JP)\s?\d{5,}[A-Z]?\d?\z/i) || q.match?(/\Apatent\b/i)
2257
+ return :cik if q.match?(/\A(?:CIK)?\s?\d{10}\z/i)
2258
+ # NPI is 10-digit; only when prefixed so bare digits don't steal phone/CIK.
2259
+ return :npi if q.match?(/\A(?:NPI[:\s#-]*)?\d{10}\z/i) && q.match?(/\ANPI\b/i)
2260
+ return :phone if q.match?(/\A\+\d[\d\-\s().]{6,}\z/) || q.match?(/\A\(?\d{3}\)?[-\s.]?\d{3}[-\s.]?\d{4}\z/) || q.match?(/\A\d{10,15}\z/)
2261
+ # FCC ID: grantee (3+) + optional hyphen + product code; require a letter and a digit.
2262
+ return :fcc_id if q.match?(/\A[A-Z0-9]{3,5}-[A-Z0-9]{1,14}\z/i) || (q.match?(/\A[A-Z0-9]{5,14}\z/i) && q.match?(/[A-Za-z]/) && q.match?(/\d/) && !q.match?(/\A(?:US|EP|WO)\d/i))
2263
+ # Geocode-looking free-text address (number + street token)
2264
+ return :geo if q.match?(/\A\d{1,6}\s+[A-Za-z]/) && q.match?(/\b(?:st|street|ave|avenue|rd|road|blvd|ln|lane|dr|drive|ct|court|way|hwy|highway)\b/i)
2265
+ return :company if q.match?(/\b(?:inc\.?|llc|ltd\.?|corp\.?|corporation|company|co\.|plc|gmbh|s\.a\.|s\.l\.)\b/i)
2266
+ # Vital-record keywords before free-text person name (order matters).
2267
+ return :vital_records if q.match?(/\b(?:birth|death|marriage|divorce|vital)\b/i)
2268
+ return :username if q.match?(/\A@?[A-Za-z0-9_\-.]{2,32}\z/) && !q.include?(' ')
2269
+ return :person if q.match?(/\A[A-Za-z]+(?:\s+[A-Za-z.'-]+)+\z/)
2270
+
2271
+ :person
2272
+ end
2273
+
2274
+ private_class_method def self.osint_feeds_for(opts = {})
2275
+ case opts[:kind].to_sym
2276
+ when :ip then %i[ip geo ipapi_is iplocate ipwhois dns rdap bgpview otx abuseipdb greynoise shodan hackertarget]
2277
+ when :geo then %i[geo nominatim ip]
2278
+ when :domain, :dns, :whois, :rdap then %i[dns whois rdap crtsh certspotter wayback otx urlhaus urlscan shodan securitytrails hackertarget]
2279
+ when :url then %i[urlscan otx urlhaus wayback microlink virustotal]
2280
+ when :email then %i[hunter person github haveibeenpwned]
2281
+ when :phone then %i[phone person]
2282
+ when :fcc_id then %i[fcc_id]
2283
+ when :patent then %i[patent]
2284
+ when :person then %i[person username github open_sanctions agify genderize nationalize vital_records]
2285
+ when :username, :github then %i[username github]
2286
+ when :company then %i[opencorporates sec_edgar federal_register person courtlistener]
2287
+ when :cik then %i[sec_edgar opencorporates]
2288
+ when :wayback then %i[wayback]
2289
+ when :crtsh then %i[crtsh certspotter]
2290
+ when :bgp, :bgpview then %i[bgpview]
2291
+ when :shodan then %i[shodan]
2292
+ when :hunter then %i[hunter]
2293
+ when :openfda then %i[openfda]
2294
+ when :vital_records then %i[vital_records person]
2295
+ when :threat then %i[otx urlhaus threatfox abuseipdb greynoise virustotal epss cisa_kev]
2296
+ when :vin then %i[nhtsa]
2297
+ when :mac then %i[mac_vendor]
2298
+ when :callsign then %i[callook]
2299
+ when :npi then %i[nppes]
2300
+ when :cve then %i[epss cisa_kev]
2301
+ else DEFAULT_OSINT_FEEDS.first(8)
2302
+ end
2303
+ end
2304
+
2305
+ private_class_method def self.osint_dispatch(opts = {})
2306
+ feed = opts[:feed].to_sym
2307
+ query = opts[:query].to_s
2308
+ limit = opts[:limit] || 5
2309
+ keys = opts[:keys] || {}
2310
+ case feed
2311
+ when :ip, :geo then osint_ip_geo(query: query)
2312
+ when :dns then osint_dns(query: query)
2313
+ when :whois then osint_whois(query: query)
2314
+ when :rdap then osint_rdap(query: query)
2315
+ when :crtsh then osint_crtsh(query: query, limit: limit)
2316
+ when :bgpview, :bgp then osint_bgpview(query: query)
2317
+ when :shodan then osint_shodan(query: query, api_key: keys[:shodan], limit: limit)
2318
+ when :hunter then osint_hunter(query: query, api_key: keys[:hunter], limit: limit)
2319
+ when :phone then osint_phone(query: query)
2320
+ when :fcc_id then osint_fcc_id(query: query)
2321
+ when :patent then osint_patent(query: query, limit: limit)
2322
+ when :person, :open_sanctions then osint_person(query: query, limit: limit)
2323
+ when :username then osint_username(query: query)
2324
+ when :github then osint_github(query: query)
2325
+ when :wayback then osint_wayback(query: query)
2326
+ when :otx then osint_otx(query: query)
2327
+ when :urlhaus then osint_urlhaus(query: query)
2328
+ when :threatfox then osint_threatfox(query: query)
2329
+ when :urlscan then osint_urlscan(query: query, limit: limit)
2330
+ when :hackertarget then osint_hackertarget(query: query)
2331
+ when :openfda then osint_openfda(query: query, limit: limit)
2332
+ when :nominatim then osint_nominatim(query: query, limit: limit)
2333
+ when :opencorporates then osint_opencorporates(query: query, limit: limit)
2334
+ when :courtlistener then osint_courtlistener(query: query, limit: limit)
2335
+ when :sec_edgar then osint_sec_edgar(query: query, limit: limit)
2336
+ when :vital_records then osint_vital_records(query: query)
2337
+ # ── public-api-lists extensions ──────────────────────────
2338
+ when :ipapi_is then osint_ipapi_is(query: query)
2339
+ when :iplocate then osint_iplocate(query: query)
2340
+ when :ipwhois, :ipwho_is then osint_ipwhois(query: query)
2341
+ when :abuseipdb then osint_abuseipdb(query: query, api_key: keys[:abuseipdb])
2342
+ when :virustotal then osint_virustotal(query: query, api_key: keys[:virustotal])
2343
+ when :greynoise then osint_greynoise(query: query, api_key: keys[:greynoise])
2344
+ when :certspotter then osint_certspotter(query: query, limit: limit)
2345
+ when :epss then osint_epss(query: query)
2346
+ when :cisa_kev then osint_cisa_kev(query: query, limit: limit)
2347
+ when :nhtsa, :vin then osint_nhtsa(query: query)
2348
+ when :nppes, :npi then osint_nppes(query: query, limit: limit)
2349
+ when :federal_register then osint_federal_register(query: query, limit: limit)
2350
+ when :uk_police then osint_uk_police(query: query, limit: limit)
2351
+ when :callook, :callsign then osint_callook(query: query)
2352
+ when :mac_vendor, :mac then osint_mac_vendor(query: query)
2353
+ when :universities then osint_universities(query: query, limit: limit)
2354
+ when :microlink then osint_microlink(query: query)
2355
+ when :agify then osint_agify(query: query)
2356
+ when :genderize then osint_genderize(query: query)
2357
+ when :nationalize then osint_nationalize(query: query)
2358
+ when :haveibeenpwned, :hibp then osint_haveibeenpwned(query: query, api_key: keys[:haveibeenpwned])
2359
+ when :securitytrails then osint_securitytrails(query: query, api_key: keys[:securitytrails])
2360
+ else { error: "unknown feed: #{feed}" }
2361
+ end
2362
+ end
2363
+
2364
+ private_class_method def self.osint_ip_geo(opts = {})
2365
+ q = opts[:query].to_s
2366
+ # Prefer existing PWN plugin when query is an IP/host.
2367
+ if defined?(PWN::Plugins::IPInfo) || true
2368
+ begin
2369
+ require 'pwn/plugins/ip_info' unless defined?(PWN::Plugins::IPInfo)
2370
+ res = PWN::Plugins::IPInfo.get(target: q, skip_api: false)
2371
+ return { source: 'ip-api.com+plugin', data: res }
2372
+ rescue StandardError => e
2373
+ # fall through to raw REST
2374
+ _ = e
2375
+ end
2376
+ end
2377
+ body = http_get_json(url: "http://ip-api.com/json/#{URI.encode_www_form_component(q)}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,reverse,mobile,proxy,query")
2378
+ { source: 'ip-api.com', data: body }
2379
+ end
2380
+
2381
+ private_class_method def self.osint_dns(opts = {})
2382
+ q = opts[:query].to_s
2383
+ local = {
2384
+ a: sh(cmd: "dig +short A #{Shellwords.escape(q)} 2>/dev/null").lines.map(&:strip).reject(&:empty?).first(10),
2385
+ aaaa: sh(cmd: "dig +short AAAA #{Shellwords.escape(q)} 2>/dev/null").lines.map(&:strip).reject(&:empty?).first(10),
2386
+ mx: sh(cmd: "dig +short MX #{Shellwords.escape(q)} 2>/dev/null").lines.map(&:strip).reject(&:empty?).first(10),
2387
+ ns: sh(cmd: "dig +short NS #{Shellwords.escape(q)} 2>/dev/null").lines.map(&:strip).reject(&:empty?).first(10),
2388
+ txt: sh(cmd: "dig +short TXT #{Shellwords.escape(q)} 2>/dev/null").lines.map(&:strip).reject(&:empty?).first(10)
2389
+ }
2390
+ doh = http_get_json(url: "https://dns.google/resolve?name=#{URI.encode_www_form_component(q)}&type=A")
2391
+ { source: 'dig+dns.google', local: local, doh: doh }
2392
+ end
2393
+
2394
+ private_class_method def self.osint_whois(opts = {})
2395
+ q = opts[:query].to_s
2396
+ raw = sh(cmd: "timeout 8 whois #{Shellwords.escape(q)} 2>/dev/null")
2397
+ { source: 'whois', data: raw.to_s[0, 4_000] }
2398
+ end
2399
+
2400
+ private_class_method def self.osint_rdap(opts = {})
2401
+ q = opts[:query].to_s
2402
+ # Try domain then IP RDAP bootstrap via rdap.org
2403
+ body = http_get_json(url: "https://rdap.org/domain/#{URI.encode_www_form_component(q)}")
2404
+ body ||= http_get_json(url: "https://rdap.org/ip/#{URI.encode_www_form_component(q)}")
2405
+ { source: 'rdap.org', data: body }
2406
+ end
2407
+
2408
+ private_class_method def self.osint_crtsh(opts = {})
2409
+ q = opts[:query].to_s
2410
+ limit = opts[:limit] || 5
2411
+ # crt.sh returns JSON array
2412
+ require 'rest-client'
2413
+ resp = RestClient::Request.execute(
2414
+ method: :get,
2415
+ url: "https://crt.sh/?q=#{URI.encode_www_form_component(q)}&output=json",
2416
+ timeout: 12,
2417
+ open_timeout: 5,
2418
+ headers: { accept: :json, user_agent: 'pwn-ai-extrospection' }
2419
+ )
2420
+ rows = JSON.parse(resp.body, symbolize_names: true)
2421
+ hits = Array(rows).first(limit).map do |r|
2422
+ { id: r[:id], logged_at: r[:entry_timestamp], not_before: r[:not_before], not_after: r[:not_after], name: r[:name_value].to_s[0, 200], issuer: r[:issuer_name].to_s[0, 120] }
2423
+ end
2424
+ { source: 'crt.sh', total_hint: Array(rows).length, hits: hits }
2425
+ rescue StandardError => e
2426
+ { source: 'crt.sh', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2427
+ end
2428
+
2429
+ private_class_method def self.osint_bgpview(opts = {})
2430
+ q = opts[:query].to_s
2431
+ if q.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\z/)
2432
+ body = http_get_json(url: "https://api.bgpview.io/ip/#{q}")
2433
+ elsif q.match?(/\AAS?\d+\z/i)
2434
+ asn = q.gsub(/[^0-9]/, '')
2435
+ body = http_get_json(url: "https://api.bgpview.io/asn/#{asn}")
2436
+ else
2437
+ body = http_get_json(url: "https://api.bgpview.io/search?query_term=#{URI.encode_www_form_component(q)}")
2438
+ end
2439
+ { source: 'bgpview.io', data: body }
2440
+ end
2441
+
2442
+ private_class_method def self.osint_shodan(opts = {})
2443
+ key = opts[:api_key].to_s
2444
+ return { source: 'shodan', skipped: true, reason: 'no API key (set SHODAN_API_KEY or PWN::Env)' } if key.empty?
2445
+
2446
+ require 'pwn/plugins/shodan' unless defined?(PWN::Plugins::Shodan)
2447
+ res = PWN::Plugins::Shodan.search(api_key: key, query: opts[:query].to_s)
2448
+ # trim
2449
+ if res.is_a?(Hash)
2450
+ matches = Array(res[:matches] || res['matches']).first(opts[:limit] || 5)
2451
+ { source: 'shodan', total: res[:total] || res['total'], matches: matches }
2452
+ else
2453
+ { source: 'shodan', data: res }
2454
+ end
2455
+ rescue StandardError => e
2456
+ { source: 'shodan', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2457
+ end
2458
+
2459
+ private_class_method def self.osint_hunter(opts = {})
2460
+ key = opts[:api_key].to_s
2461
+ return { source: 'hunter.how', skipped: true, reason: 'no API key (set HUNTER_API_KEY)' } if key.empty?
2462
+
2463
+ require 'pwn/plugins/hunter' unless defined?(PWN::Plugins::Hunter)
2464
+ end_time = Time.now.utc.strftime('%Y-%m-%d')
2465
+ start_time = (Time.now.utc - (30 * 24 * 3600)).strftime('%Y-%m-%d')
2466
+ res = PWN::Plugins::Hunter.search(api_key: key, query: opts[:query].to_s, start_time: start_time, end_time: end_time, page_size: opts[:limit] || 5)
2467
+ { source: 'hunter.how', data: res }
2468
+ rescue StandardError => e
2469
+ { source: 'hunter.how', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2470
+ end
2471
+
2472
+ private_class_method def self.osint_phone(opts = {})
2473
+ q = opts[:query].to_s.gsub(/[^\d+]/, '')
2474
+ # Free public heuristics + libphonenumber-less metadata. Prefer NumVerify if key present later.
2475
+ digits = q.gsub(/\D/, '')
2476
+ country = case digits
2477
+ when /\A1\d{10}\z/ then 'US/CA (NANP)'
2478
+ when /\A44\d+/ then 'UK'
2479
+ when /\A33\d+/ then 'FR'
2480
+ when /\A49\d+/ then 'DE'
2481
+ when /\A81\d+/ then 'JP'
2482
+ when /\A86\d+/ then 'CN'
2483
+ when /\A61\d+/ then 'AU'
2484
+ when /\A91\d+/ then 'IN'
2485
+ else 'unknown'
2486
+ end
2487
+ nanp = nil
2488
+ if digits.match?(/\A1?(\d{3})(\d{3})(\d{4})\z/)
2489
+ area = Regexp.last_match(1)
2490
+ # Area-code → region via public NPA style (best-effort subset)
2491
+ nanp = { area_code: area, nxx: Regexp.last_match(2), station: Regexp.last_match(3) }
2492
+ end
2493
+ # OpenCNAM-style public CNAM is largely paywalled; expose format + link targets for deeper manual/keyed lookup.
2494
+ {
2495
+ source: 'phone-heuristic',
2496
+ e164_guess: (q.start_with?('+') ? q : "+#{digits}"),
2497
+ digits: digits,
2498
+ country_guess: country,
2499
+ nanp: nanp,
2500
+ reverse_lookup_targets: [
2501
+ "https://www.truepeoplesearch.com/results?phoneno=#{digits}",
2502
+ "https://www.whitepages.com/phone/#{digits}",
2503
+ "https://www.fastpeoplesearch.com/#{digits.chars.each_slice(3).map(&:join).join('-')}"
2504
+ ],
2505
+ note: 'Public reverse-phone APIs are largely paywalled; heuristic metadata + OSINT search targets returned. Set a CNAM/NumVerify key in PWN::Env for live CNAM.'
2506
+ }
2507
+ end
2508
+
2509
+ private_class_method def self.osint_fcc_id(opts = {})
2510
+ q = opts[:query].to_s.strip.upcase.gsub(/\s+/, '')
2511
+ grantee = q[0, 3]
2512
+ product = q.sub(/\A[A-Z0-9]{3}-?/i, '')
2513
+ pages = []
2514
+ # Prefer headless/TB where available for bot-walled aggregators; fall back to REST.
2515
+ candidates = [
2516
+ "https://fccid.io/#{URI.encode_www_form_component(q)}",
2517
+ "https://device.report/fcc/#{URI.encode_www_form_component(q)}",
2518
+ "https://www.fcc.gov/oet/ea/fccid?grantee_code=#{URI.encode_www_form_component(grantee)}&product_code=#{URI.encode_www_form_component(product)}"
2519
+ ]
2520
+ candidates.each do |url|
2521
+ body = ''
2522
+ title = ''
2523
+ begin
2524
+ require 'rest-client'
2525
+ resp = RestClient::Request.execute(
2526
+ method: :get,
2527
+ url: url,
2528
+ timeout: 12,
2529
+ open_timeout: 5,
2530
+ max_redirects: 5,
2531
+ headers: {
2532
+ user_agent: 'Mozilla/5.0 (compatible; pwn-ai-extrospection/0.5)',
2533
+ accept: 'text/html,application/json'
2534
+ }
2535
+ )
2536
+ body = resp.body.to_s
2537
+ title = body[%r{<title[^>]*>(.*?)</title>}im, 1].to_s.gsub(/\s+/, ' ').strip
2538
+ rescue StandardError
2539
+ # TransparentBrowser rest/headless as last resort
2540
+ begin
2541
+ require 'pwn/plugins/transparent_browser' unless defined?(PWN::Plugins::TransparentBrowser)
2542
+ bo = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
2543
+ resp = bo[:browser].get(url) { |r, _rq, _res| r }
2544
+ body = resp.body.to_s
2545
+ title = body[%r{<title[^>]*>(.*?)</title>}im, 1].to_s.gsub(/\s+/, ' ').strip
2546
+ PWN::Plugins::TransparentBrowser.close(browser_obj: bo)
2547
+ rescue StandardError => e
2548
+ pages << { url: url, error: "#{e.class}: #{e.message.to_s[0, 100]}" }
2549
+ next
2550
+ end
2551
+ end
2552
+ next if body.empty?
2553
+
2554
+ interesting = body.scan(/(?:Grantee|Product|Frequency|Grant Date|Equipment Class|Rule Parts|FCC ID|Applicant)[^<]{0,60}/i)
2555
+ .map { |s| s.gsub(/\s+/, ' ').strip }.uniq.first(16)
2556
+ # Also extract MHz mentions for RF device content
2557
+ freqs = body.scan(/\b\d{2,5}(?:\.\d+)?\s*(?:MHz|GHz|kHz)\b/i).uniq.first(12)
2558
+ pages << { url: url, title: title[0, 160], excerpts: interesting, frequencies: freqs, bytes: body.length, reachable: true }
2559
+ end
2560
+ {
2561
+ source: 'fccid.io+device.report+fcc.gov',
2562
+ fcc_id: q,
2563
+ grantee_code: grantee,
2564
+ product_code: product,
2565
+ pages: pages,
2566
+ references: [
2567
+ "https://fccid.io/#{q}",
2568
+ "https://device.report/fcc/#{q}",
2569
+ 'https://www.fcc.gov/oet/ea/fccid'
2570
+ ]
2571
+ }
2572
+ end
2573
+
2574
+ private_class_method def self.osint_patent(opts = {})
2575
+ q = opts[:query].to_s.strip
2576
+ limit = opts[:limit] || 5
2577
+ # Google Patents public search (HTML) + USPTO patentsview when query looks like a number
2578
+ gurl = "https://patents.google.com/?q=#{URI.encode_www_form_component(q)}"
2579
+ html = begin
2580
+ require 'rest-client'
2581
+ RestClient::Request.execute(method: :get, url: gurl, timeout: 12, open_timeout: 5, headers: { user_agent: 'pwn-ai-extrospection' }).body.to_s
2582
+ rescue StandardError
2583
+ ''
2584
+ end
2585
+ titles = html.scan(/itemprop="title"[^>]*content="([^"]+)"/i).flatten.uniq.first(limit)
2586
+ titles = html.scan(%r{<title>(.*?)</title>}im).flatten.map { |t| t.gsub(/\s+/, ' ').strip } if titles.empty?
2587
+ # PatentsView API (public, no key) — inventor / patent search
2588
+ pv = nil
2589
+ if q.match?(/\d{5,}/)
2590
+ num = q.gsub(/[^0-9]/, '')
2591
+ pv = http_get_json(url: "https://api.patentsview.org/patents/query?q={\"patent_number\":\"#{num}\"}&f=[\"patent_number\",\"patent_title\",\"patent_date\",\"assignee_organization\"]&o={\"per_page\":#{limit}}")
2592
+ else
2593
+ pv = http_get_json(url: "https://api.patentsview.org/patents/query?q={\"_text_any\":{\"patent_title\":\"#{q.gsub('"', '')}\"}}&f=[\"patent_number\",\"patent_title\",\"patent_date\"]&o={\"per_page\":#{limit}}")
2594
+ end
2595
+ { source: 'google-patents+patentsview', query: q, google_url: gurl, titles: titles, patentsview: pv }
2596
+ end
2597
+
2598
+ private_class_method def self.osint_person(opts = {})
2599
+ q = opts[:query].to_s.strip
2600
+ limit = opts[:limit] || 5
2601
+ # Public people-search is policy-constrained; return structured search plan + free sources.
2602
+ # Wikidata + Wikipedia open search are fully public and useful for missing-person / public-figure.
2603
+ wiki = http_get_json(url: "https://en.wikipedia.org/w/api.php?action=opensearch&search=#{URI.encode_www_form_component(q)}&limit=#{limit}&namespace=0&format=json")
2604
+ wikidata = http_get_json(url: "https://www.wikidata.org/w/api.php?action=wbsearchentities&search=#{URI.encode_www_form_component(q)}&language=en&limit=#{limit}&format=json")
2605
+ open_sanctions = http_get_json(url: "https://api.opensanctions.org/search/default?q=#{URI.encode_www_form_component(q)}")
2606
+ {
2607
+ source: 'wikipedia+wikidata+opensanctions',
2608
+ query: q,
2609
+ wikipedia: wiki,
2610
+ wikidata: wikidata,
2611
+ open_sanctions: open_sanctions.is_a?(Hash) ? open_sanctions.slice(:results, :total) : open_sanctions,
2612
+ missing_person_targets: [
2613
+ 'https://www.namus.gov/Search',
2614
+ 'https://www.fbi.gov/wanted/kidnap',
2615
+ "https://charleyproject.org/?s=#{URI.encode_www_form_component(q)}"
2616
+ ],
2617
+ note: 'Commercial people-search APIs (Pipl, Spokeo, etc.) require keys/ToS; public free sources aggregated here.'
2618
+ }
2619
+ end
2620
+
2621
+ private_class_method def self.osint_username(opts = {})
2622
+ q = opts[:query].to_s.sub(/\A@/, '').strip
2623
+ platforms = {
2624
+ github: "https://api.github.com/users/#{URI.encode_www_form_component(q)}",
2625
+ gitlab: "https://gitlab.com/api/v4/users?username=#{URI.encode_www_form_component(q)}",
2626
+ reddit: "https://www.reddit.com/user/#{URI.encode_www_form_component(q)}/about.json"
2627
+ }
2628
+ hits = {}
2629
+ platforms.each do |name, url|
2630
+ body = http_get_json(url: url)
2631
+ hits[name] = body if body
2632
+ rescue StandardError => e
2633
+ hits[name] = { error: e.message.to_s[0, 80] }
2634
+ end
2635
+ { source: 'username-public-apis', username: q, hits: hits }
2636
+ end
2637
+
2638
+ private_class_method def self.osint_github(opts = {})
2639
+ q = opts[:query].to_s.sub(%r{\A(github\.com/|gh:)}, '').strip
2640
+ user = http_get_json(url: "https://api.github.com/users/#{URI.encode_www_form_component(q)}")
2641
+ search = http_get_json(url: "https://api.github.com/search/users?q=#{URI.encode_www_form_component(q)}&per_page=5")
2642
+ { source: 'api.github.com', user: user, search: search }
2643
+ end
2644
+
2645
+ private_class_method def self.osint_wayback(opts = {})
2646
+ q = opts[:query].to_s
2647
+ q = "http://#{q}" unless q.match?(%r{\Ahttps?://}i)
2648
+ body = http_get_json(url: "https://archive.org/wayback/available?url=#{URI.encode_www_form_component(q)}")
2649
+ cdx = begin
2650
+ require 'rest-client'
2651
+ resp = RestClient::Request.execute(
2652
+ method: :get,
2653
+ url: "https://web.archive.org/cdx/search/cdx?url=#{URI.encode_www_form_component(q)}&output=json&limit=5",
2654
+ timeout: 10,
2655
+ open_timeout: 4,
2656
+ headers: { user_agent: 'pwn-ai-extrospection' }
2657
+ )
2658
+ JSON.parse(resp.body)
2659
+ rescue StandardError
2660
+ nil
2661
+ end
2662
+ { source: 'archive.org', available: body, cdx: cdx }
2663
+ end
2664
+
2665
+ private_class_method def self.osint_otx(opts = {})
2666
+ q = opts[:query].to_s.strip
2667
+ kind = if q.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\z/)
2668
+ 'IPv4'
2669
+ elsif q.include?(':') && q.match?(/\A[0-9a-fA-F:.]+\z/)
2670
+ 'IPv6'
2671
+ elsif q.match?(%r{\Ahttps?://}i)
2672
+ 'url'
2673
+ elsif q.include?('.')
2674
+ 'domain'
2675
+ else
2676
+ 'hostname'
2677
+ end
2678
+ path = case kind
2679
+ when 'url' then "https://otx.alienvault.com/api/v1/indicators/url/#{URI.encode_www_form_component(q)}/general"
2680
+ else "https://otx.alienvault.com/api/v1/indicators/#{kind}/#{URI.encode_www_form_component(q)}/general"
2681
+ end
2682
+ body = http_get_json(url: path)
2683
+ pulse = http_get_json(url: path.sub(%r{/general\z}, '/passive_dns')) if kind != 'url'
2684
+ { source: 'otx.alienvault.com', indicator: q, kind: kind, general: body, passive_dns: pulse }
2685
+ end
2686
+
2687
+ private_class_method def self.osint_urlhaus(opts = {})
2688
+ q = opts[:query].to_s.strip
2689
+ require 'rest-client'
2690
+ # URLHaus accepts host or url via POST
2691
+ payload = if q.match?(%r{\Ahttps?://}i)
2692
+ { url: q }
2693
+ else
2694
+ { host: q } # IPv4 / FQDN / hostname all map to host lookup
2695
+ end
2696
+ resp = RestClient::Request.execute(
2697
+ method: :post,
2698
+ url: payload.key?(:url) ? 'https://urlhaus-api.abuse.ch/v1/url/' : 'https://urlhaus-api.abuse.ch/v1/host/',
2699
+ payload: payload,
2700
+ timeout: 12,
2701
+ open_timeout: 5,
2702
+ headers: { user_agent: 'pwn-ai-extrospection' }
2703
+ )
2704
+ { source: 'urlhaus-api.abuse.ch', data: JSON.parse(resp.body, symbolize_names: true) }
2705
+ rescue StandardError => e
2706
+ { source: 'urlhaus-api.abuse.ch', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2707
+ end
2708
+
2709
+ private_class_method def self.osint_threatfox(opts = {})
2710
+ q = opts[:query].to_s.strip
2711
+ require 'rest-client'
2712
+ body = {
2713
+ query: 'search_ioc',
2714
+ search_term: q
2715
+ }.to_json
2716
+ resp = RestClient::Request.execute(
2717
+ method: :post,
2718
+ url: 'https://threatfox-api.abuse.ch/api/v1/',
2719
+ payload: body,
2720
+ timeout: 12,
2721
+ open_timeout: 5,
2722
+ headers: { content_type: :json, user_agent: 'pwn-ai-extrospection' }
2723
+ )
2724
+ { source: 'threatfox-api.abuse.ch', data: JSON.parse(resp.body, symbolize_names: true) }
2725
+ rescue StandardError => e
2726
+ { source: 'threatfox-api.abuse.ch', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2727
+ end
2728
+
2729
+ private_class_method def self.osint_urlscan(opts = {})
2730
+ q = opts[:query].to_s.strip
2731
+ limit = opts[:limit] || 5
2732
+ # Public search endpoint (no key required for search)
2733
+ body = http_get_json(url: "https://urlscan.io/api/v1/search/?q=#{URI.encode_www_form_component(q)}&size=#{limit}")
2734
+ results = Array(body.is_a?(Hash) ? (body[:results] || body['results']) : []).first(limit).map do |r|
2735
+ r = r.transform_keys(&:to_sym) if r.is_a?(Hash) && r.keys.first.is_a?(String)
2736
+ page = (r[:page] || {}).is_a?(Hash) ? r[:page] : {}
2737
+ page = page.transform_keys(&:to_sym) if page.keys.first.is_a?(String)
2738
+ {
2739
+ url: page[:url] || r.dig(:task, :url),
2740
+ domain: page[:domain],
2741
+ ip: page[:ip],
2742
+ country: page[:country],
2743
+ server: page[:server],
2744
+ screenshot: r[:screenshot],
2745
+ result: r[:result]
2746
+ }
2747
+ end
2748
+ { source: 'urlscan.io', total: body.is_a?(Hash) ? (body[:total] || body['total']) : nil, hits: results }
2749
+ end
2750
+
2751
+ private_class_method def self.osint_hackertarget(opts = {})
2752
+ q = opts[:query].to_s.strip
2753
+ require 'rest-client'
2754
+ base = 'https://api.hackertarget.com'
2755
+ outs = {}
2756
+ endpoints = {
2757
+ whois: "#{base}/whois/?q=#{URI.encode_www_form_component(q)}",
2758
+ dnslookup: "#{base}/dnslookup/?q=#{URI.encode_www_form_component(q)}",
2759
+ reversedns: "#{base}/reversedns/?q=#{URI.encode_www_form_component(q)}",
2760
+ geoip: "#{base}/geoip/?q=#{URI.encode_www_form_component(q)}",
2761
+ httpheaders: "#{base}/httpheaders/?q=#{URI.encode_www_form_component(q)}"
2762
+ }
2763
+ # Only fire a couple of free endpoints to stay within public rate limits
2764
+ endpoints.each do |name, url|
2765
+ resp = RestClient::Request.execute(method: :get, url: url, timeout: 10, open_timeout: 4, headers: { user_agent: 'pwn-ai-extrospection' })
2766
+ outs[name] = resp.body.to_s[0, 2_000]
2767
+ rescue StandardError => e
2768
+ outs[name] = "error: #{e.class}: #{e.message.to_s[0, 80]}"
2769
+ end
2770
+ { source: 'api.hackertarget.com', data: outs }
2771
+ end
2772
+
2773
+ private_class_method def self.osint_openfda(opts = {})
2774
+ q = opts[:query].to_s.strip
2775
+ limit = opts[:limit] || 5
2776
+ # Device / drug / enforcement open search — best-effort public endpoints
2777
+ enc = URI.encode_www_form_component(q)
2778
+ device = http_get_json(url: "https://api.fda.gov/device/510k.json?search=device_name:#{enc}+device_name:\"#{enc}\"&limit=#{limit}")
2779
+ device ||= http_get_json(url: "https://api.fda.gov/device/510k.json?search=#{enc}&limit=#{limit}")
2780
+ drug = http_get_json(url: "https://api.fda.gov/drug/label.json?search=openfda.brand_name:#{enc}&limit=#{limit}")
2781
+ enforce = http_get_json(url: "https://api.fda.gov/device/enforcement.json?search=product_description:#{enc}&limit=#{limit}")
2782
+ {
2783
+ source: 'api.fda.gov',
2784
+ query: q,
2785
+ device_510k: device.is_a?(Hash) ? device.slice(:meta, :results) : device,
2786
+ drug_label: drug.is_a?(Hash) ? drug.slice(:meta, :results) : drug,
2787
+ enforcement: enforce.is_a?(Hash) ? enforce.slice(:meta, :results) : enforce
2788
+ }
2789
+ end
2790
+
2791
+ private_class_method def self.osint_nominatim(opts = {})
2792
+ q = opts[:query].to_s.strip
2793
+ limit = opts[:limit] || 5
2794
+ # OpenStreetMap Nominatim — public, requires identifying UA (we set one)
2795
+ require 'rest-client'
2796
+ url = "https://nominatim.openstreetmap.org/search?q=#{URI.encode_www_form_component(q)}&format=json&addressdetails=1&limit=#{limit}"
2797
+ resp = RestClient::Request.execute(
2798
+ method: :get,
2799
+ url: url,
2800
+ timeout: 12,
2801
+ open_timeout: 5,
2802
+ headers: { user_agent: 'pwn-ai-extrospection/0.5 (research; support@0dayinc.com)', accept: :json }
2803
+ )
2804
+ rows = JSON.parse(resp.body, symbolize_names: true)
2805
+ hits = Array(rows).first(limit).map do |r|
2806
+ {
2807
+ display_name: r[:display_name],
2808
+ lat: r[:lat],
2809
+ lon: r[:lon],
2810
+ type: r[:type],
2811
+ class: r[:class],
2812
+ osm_id: r[:osm_id],
2813
+ address: r[:address]
2814
+ }
2815
+ end
2816
+ { source: 'nominatim.openstreetmap.org', hits: hits }
2817
+ rescue StandardError => e
2818
+ { source: 'nominatim.openstreetmap.org', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2819
+ end
2820
+
2821
+ private_class_method def self.osint_opencorporates(opts = {})
2822
+ q = opts[:query].to_s.strip
2823
+ limit = opts[:limit] || 5
2824
+ # OpenCorporates public API (rate-limited without token)
2825
+ body = http_get_json(url: "https://api.opencorporates.com/v0.4/companies/search?q=#{URI.encode_www_form_component(q)}&per_page=#{limit}")
2826
+ companies = []
2827
+ if body.is_a?(Hash)
2828
+ results = body.dig(:results, :companies) || body.dig('results', 'companies') || []
2829
+ companies = Array(results).first(limit).map do |row|
2830
+ c = row[:company] || row['company'] || row
2831
+ c = c.transform_keys(&:to_sym) if c.is_a?(Hash) && c.keys.first.is_a?(String)
2832
+ {
2833
+ name: c[:name],
2834
+ company_number: c[:company_number],
2835
+ jurisdiction: c[:jurisdiction_code],
2836
+ incorporation_date: c[:incorporation_date],
2837
+ company_type: c[:company_type],
2838
+ opencorporates_url: c[:opencorporates_url],
2839
+ current_status: c[:current_status]
2840
+ }
2841
+ end
2842
+ end
2843
+ { source: 'api.opencorporates.com', total: body.is_a?(Hash) ? (body.dig(:results, :total_count) || body.dig('results', 'total_count')) : nil, companies: companies, raw_error: (body[:error] if body.is_a?(Hash)) }
2844
+ end
2845
+
2846
+ private_class_method def self.osint_courtlistener(opts = {})
2847
+ q = opts[:query].to_s.strip
2848
+ limit = opts[:limit] || 5
2849
+ # CourtListener (Free Law Project) public search — dockets / opinions
2850
+ search = http_get_json(url: "https://www.courtlistener.com/api/rest/v4/search/?q=#{URI.encode_www_form_component(q)}&type=o&page_size=#{limit}")
2851
+ people = http_get_json(url: "https://www.courtlistener.com/api/rest/v4/people/?name=#{URI.encode_www_form_component(q)}&page_size=#{limit}")
2852
+ {
2853
+ source: 'courtlistener.com',
2854
+ opinions_search: search.is_a?(Hash) ? search.slice(:count, :results) : search,
2855
+ people: people.is_a?(Hash) ? people.slice(:count, :results) : people,
2856
+ reference: "https://www.courtlistener.com/?q=#{URI.encode_www_form_component(q)}"
2857
+ }
2858
+ end
2859
+
2860
+ private_class_method def self.osint_sec_edgar(opts = {})
2861
+ q = opts[:query].to_s.strip
2862
+ limit = opts[:limit] || 5
2863
+ require 'rest-client'
2864
+ # SEC EDGAR full-text search index (public). Also company tickers JSON.
2865
+ headers = {
2866
+ user_agent: 'pwn-ai-extrospection research support@0dayinc.com',
2867
+ accept: 'application/json'
2868
+ }
2869
+ # Prefer company tickers map when query looks like a ticker / name
2870
+ tickers = nil
2871
+ begin
2872
+ resp = RestClient::Request.execute(method: :get, url: 'https://www.sec.gov/files/company_tickers.json', timeout: 12, open_timeout: 5, headers: headers)
2873
+ map = JSON.parse(resp.body)
2874
+ ql = q.downcase
2875
+ tickers = map.values.select do |v|
2876
+ v = v.transform_keys(&:to_s)
2877
+ v['ticker'].to_s.downcase == ql || v['title'].to_s.downcase.include?(ql) || format('%010d', v['cik_str'].to_i) == q.gsub(/\D/, '').rjust(10, '0')
2878
+ end.first(limit)
2879
+ rescue StandardError => e
2880
+ tickers = [{ error: "#{e.class}: #{e.message.to_s[0, 80]}" }]
2881
+ end
2882
+ efts = http_get_json(url: "https://efts.sec.gov/LATEST/search-index?q=#{URI.encode_www_form_component(q)}&dateRange=custom&startdt=2000-01-01&enddt=#{Time.now.utc.strftime('%Y-%m-%d')}&forms=")
2883
+ {
2884
+ source: 'sec.gov+efts',
2885
+ query: q,
2886
+ company_tickers: tickers,
2887
+ filings_index: efts,
2888
+ references: [
2889
+ "https://www.sec.gov/cgi-bin/browse-edgar?company=#{URI.encode_www_form_component(q)}&action=getcompany",
2890
+ "https://efts.sec.gov/LATEST/search-index?q=#{URI.encode_www_form_component(q)}"
2891
+ ]
2892
+ }
2893
+ end
2894
+
2895
+ private_class_method def self.osint_vital_records(opts = {})
2896
+ q = opts[:query].to_s.strip
2897
+ # Vital records (birth/death/marriage) are almost entirely state/local and often restricted.
2898
+ # Return a structured public-record search plan rather than claiming live B2B access.
2899
+ {
2900
+ source: 'vital-records-public-plan',
2901
+ query: q,
2902
+ note: 'US birth/death/marriage certificates are state vital-records offices (often restricted 100 years). Aggregate public genealogy + missing-person pivots only.',
2903
+ public_genealogy: [
2904
+ "https://www.familysearch.org/en/search/record/results?q.givenName=#{URI.encode_www_form_component(q.split.first.to_s)}&q.surname=#{URI.encode_www_form_component(q.split.last.to_s)}",
2905
+ "https://www.findagrave.com/memorial/search?firstname=#{URI.encode_www_form_component(q.split.first.to_s)}&lastname=#{URI.encode_www_form_component(q.split.last.to_s)}",
2906
+ "https://www.ancestry.com/search/?name=#{URI.encode_www_form_component(q.tr(' ', '+'))}",
2907
+ 'https://www.cdc.gov/nchs/w2w/index.htm'
2908
+ ],
2909
+ state_vital_records_index: 'https://www.cdc.gov/nchs/w2w/index.htm',
2910
+ missing_person_targets: [
2911
+ 'https://www.namus.gov/Search',
2912
+ 'https://www.fbi.gov/wanted/kidnap',
2913
+ "https://charleyproject.org/?s=#{URI.encode_www_form_component(q)}"
2914
+ ],
2915
+ court_public: [
2916
+ "https://www.courtlistener.com/?q=#{URI.encode_www_form_component(q)}",
2917
+ 'https://pacer.uscourts.gov/'
2918
+ ]
2919
+ }
2920
+ end
2921
+
2922
+ # ── public-api-lists OSINT feed implementations ─────────────
2923
+ # Sourced / inspired by https://github.com/public-api-lists/public-api-lists
2924
+ # Categories: Anti-Malware, Security, Geocoding, Government, Health,
2925
+ # Open Data, Vehicle, Development (name demographics), Social.
2926
+
2927
+ private_class_method def self.osint_ipapi_is(opts = {})
2928
+ q = opts[:query].to_s
2929
+ body = http_get_json(url: "https://api.ipapi.is?q=#{URI.encode_www_form_component(q)}")
2930
+ { source: 'ipapi.is', data: body }
2931
+ end
2932
+
2933
+ private_class_method def self.osint_iplocate(opts = {})
2934
+ q = opts[:query].to_s
2935
+ body = http_get_json(url: "https://www.iplocate.io/api/lookup/#{URI.encode_www_form_component(q)}")
2936
+ { source: 'iplocate.io', data: body }
2937
+ end
2938
+
2939
+ private_class_method def self.osint_ipwhois(opts = {})
2940
+ q = opts[:query].to_s
2941
+ body = http_get_json(url: "https://ipwho.is/#{URI.encode_www_form_component(q)}")
2942
+ { source: 'ipwho.is', data: body }
2943
+ end
2944
+
2945
+ private_class_method def self.osint_abuseipdb(opts = {})
2946
+ key = opts[:api_key].to_s
2947
+ return { source: 'abuseipdb', skipped: true, reason: 'no API key (set ABUSEIPDB_API_KEY)' } if key.empty?
2948
+
2949
+ q = opts[:query].to_s
2950
+ require 'rest-client'
2951
+ resp = RestClient::Request.execute(
2952
+ method: :get,
2953
+ url: "https://api.abuseipdb.com/api/v2/check?ipAddress=#{URI.encode_www_form_component(q)}&maxAgeInDays=90&verbose",
2954
+ timeout: 10,
2955
+ open_timeout: 4,
2956
+ headers: {
2957
+ accept: :json,
2958
+ 'Key' => key,
2959
+ user_agent: 'pwn-ai-extrospection'
2960
+ }
2961
+ )
2962
+ body = JSON.parse(resp.body, symbolize_names: true)
2963
+ { source: 'abuseipdb', data: body }
2964
+ rescue StandardError => e
2965
+ { source: 'abuseipdb', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
2966
+ end
2967
+
2968
+ private_class_method def self.osint_virustotal(opts = {})
2969
+ key = opts[:api_key].to_s
2970
+ return { source: 'virustotal', skipped: true, reason: 'no API key (set VIRUSTOTAL_API_KEY)' } if key.empty?
2971
+
2972
+ q = opts[:query].to_s
2973
+ require 'base64' unless defined?(Base64)
2974
+ require 'rest-client'
2975
+ path = if q.match?(%r{\Ahttps?://}i)
2976
+ "https://www.virustotal.com/api/v3/urls/#{Base64.urlsafe_encode64(q, padding: false)}"
2977
+ elsif q.include?('.') && !q.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\z/)
2978
+ "https://www.virustotal.com/api/v3/domains/#{URI.encode_www_form_component(q)}"
2979
+ else
2980
+ "https://www.virustotal.com/api/v3/ip_addresses/#{URI.encode_www_form_component(q)}"
2981
+ end
2982
+ resp = RestClient::Request.execute(
2983
+ method: :get,
2984
+ url: path,
2985
+ timeout: 12,
2986
+ open_timeout: 5,
2987
+ headers: {
2988
+ accept: :json,
2989
+ 'x-apikey' => key,
2990
+ user_agent: 'pwn-ai-extrospection'
2991
+ }
2992
+ )
2993
+ body = JSON.parse(resp.body, symbolize_names: true)
2994
+ attrs = body.is_a?(Hash) ? (body.dig(:data, :attributes) || body[:data] || body) : body
2995
+ if attrs.is_a?(Hash)
2996
+ slim = attrs.slice(
2997
+ :last_analysis_stats, :reputation, :tags, :as_owner, :asn, :country,
2998
+ :last_https_certificate_date, :categories, :total_votes, :harmless,
2999
+ :malicious, :suspicious, :undetected, :timeout, :last_analysis_date
3000
+ )
3001
+ slim = attrs if slim.compact.empty?
3002
+ { source: 'virustotal', data: slim }
3003
+ else
3004
+ { source: 'virustotal', data: body }
3005
+ end
3006
+ rescue StandardError => e
3007
+ { source: 'virustotal', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3008
+ end
3009
+
3010
+ private_class_method def self.osint_greynoise(opts = {})
3011
+ q = opts[:query].to_s
3012
+ key = opts[:api_key].to_s
3013
+ require 'rest-client'
3014
+ headers = { accept: :json, user_agent: 'pwn-ai-extrospection' }
3015
+ headers['key'] = key unless key.empty?
3016
+ url = if key.empty?
3017
+ "https://api.greynoise.io/v3/community/#{URI.encode_www_form_component(q)}"
3018
+ else
3019
+ "https://api.greynoise.io/v2/noise/context/#{URI.encode_www_form_component(q)}"
3020
+ end
3021
+ resp = RestClient::Request.execute(method: :get, url: url, timeout: 10, open_timeout: 4, headers: headers)
3022
+ body = JSON.parse(resp.body, symbolize_names: true)
3023
+ { source: (key.empty? ? 'greynoise-community' : 'greynoise'), data: body }
3024
+ rescue RestClient::NotFound
3025
+ { source: 'greynoise', data: { ip: q, noise: false, riot: false, message: 'IP not observed' } }
3026
+ rescue StandardError => e
3027
+ { source: 'greynoise', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3028
+ end
3029
+
3030
+ private_class_method def self.osint_certspotter(opts = {})
3031
+ q = opts[:query].to_s.sub(%r{\Ahttps?://}, '').split('/').first
3032
+ limit = opts[:limit] || 5
3033
+ body = http_get_json(
3034
+ url: "https://api.certspotter.com/v1/issuances?domain=#{URI.encode_www_form_component(q)}&include_subdomains=true&expand=dns_names&expand=issuer"
3035
+ )
3036
+ hits = Array(body).first(limit).map do |r|
3037
+ next r unless r.is_a?(Hash)
3038
+
3039
+ {
3040
+ id: r[:id],
3041
+ dns_names: Array(r[:dns_names]).first(10),
3042
+ issuer: (if r.is_a?(Hash) && r[:issuer].is_a?(Hash)
3043
+ r[:issuer][:name]
3044
+ else
3045
+ (r.is_a?(Hash) ? r[:issuer] : nil)
3046
+ end),
3047
+ not_before: r[:not_before],
3048
+ not_after: r[:not_after]
3049
+ }
3050
+ end
3051
+ { source: 'certspotter', total_hint: Array(body).length, hits: hits }
3052
+ rescue StandardError => e
3053
+ { source: 'certspotter', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3054
+ end
3055
+
3056
+ private_class_method def self.osint_epss(opts = {})
3057
+ q = opts[:query].to_s.strip
3058
+ cve = q[/CVE-\d{4}-\d+/i]
3059
+ return { source: 'epss', error: 'query must contain a CVE-YYYY-NNNN id' } unless cve
3060
+
3061
+ body = http_get_json(url: "https://api.first.org/data/v1/epss?cve=#{URI.encode_www_form_component(cve.upcase)}")
3062
+ row = Array(body.is_a?(Hash) ? body[:data] : body).first
3063
+ { source: 'first.org/epss', cve: cve.upcase, data: row || body }
3064
+ end
3065
+
3066
+ private_class_method def self.osint_cisa_kev(opts = {})
3067
+ q = opts[:query].to_s.strip
3068
+ limit = opts[:limit] || 5
3069
+ body = http_get_json(url: 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json')
3070
+ vulns = Array(body.is_a?(Hash) ? body[:vulnerabilities] : nil)
3071
+ if q.match?(/CVE-\d{4}-\d+/i)
3072
+ cve = q[/CVE-\d{4}-\d+/i].upcase
3073
+ hits = vulns.select { |v| v[:cveID].to_s.upcase == cve }
3074
+ elsif !q.empty? && !q.match?(/\Ahttps?:/i)
3075
+ ql = q.downcase
3076
+ hits = vulns.select do |v|
3077
+ v.values_at(:cveID, :vendorProject, :product, :shortDescription, :vulnerabilityName)
3078
+ .compact.join(' ').downcase.include?(ql)
3079
+ end
3080
+ else
3081
+ hits = vulns
3082
+ end
3083
+ {
3084
+ source: 'cisa-kev',
3085
+ catalog_version: (body.is_a?(Hash) ? body[:catalogVersion] : nil),
3086
+ total_catalog: vulns.length,
3087
+ hits: hits.first(limit).map { |v| v.slice(:cveID, :vendorProject, :product, :vulnerabilityName, :dateAdded, :dueDate, :knownRansomwareCampaignUse, :shortDescription) }
3088
+ }
3089
+ rescue StandardError => e
3090
+ { source: 'cisa-kev', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3091
+ end
3092
+
3093
+ private_class_method def self.osint_nhtsa(opts = {})
3094
+ q = opts[:query].to_s.strip.upcase
3095
+ if q.match?(/\A[A-HJ-NPR-Z0-9]{17}\z/)
3096
+ begin
3097
+ require 'pwn/plugins/vin' unless defined?(PWN::Plugins::VIN)
3098
+ res = PWN::Plugins::VIN.decode_vin(vin: q)
3099
+ return { source: 'nhtsa-vpic+plugin', vin: q, data: res }
3100
+ rescue StandardError
3101
+ # fall through
3102
+ end
3103
+ end
3104
+ path = if q.match?(/\A[A-HJ-NPR-Z0-9]{17}\z/)
3105
+ "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/#{URI.encode_www_form_component(q)}?format=json"
3106
+ else
3107
+ "https://vpic.nhtsa.dot.gov/api/vehicles/GetModelsForMake/#{URI.encode_www_form_component(q)}?format=json"
3108
+ end
3109
+ body = http_get_json(url: path)
3110
+ results = Array(body.is_a?(Hash) ? body[:Results] : nil)
3111
+ if results.first.is_a?(Hash) && results.first.key?(:VIN)
3112
+ slim = results.first.reject { |_k, v| v.nil? || v.to_s.strip.empty? }
3113
+ { source: 'nhtsa-vpic', vin: q, data: slim }
3114
+ else
3115
+ { source: 'nhtsa-vpic', query: q, count: (body.is_a?(Hash) ? body[:Count] : results.length), results: results.first(10) }
3116
+ end
3117
+ rescue StandardError => e
3118
+ { source: 'nhtsa-vpic', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3119
+ end
3120
+
3121
+ private_class_method def self.osint_nppes(opts = {})
3122
+ q = opts[:query].to_s.strip
3123
+ limit = opts[:limit] || 5
3124
+ params = if q.match?(/\A(?:NPI[:\s#-]*)?\d{10}\z/i)
3125
+ npi = q.gsub(/\D/, '')
3126
+ "number=#{npi}"
3127
+ elsif q.include?(',')
3128
+ last, first = q.split(',', 2).map(&:strip)
3129
+ "last_name=#{URI.encode_www_form_component(last)}&first_name=#{URI.encode_www_form_component(first)}"
3130
+ elsif q.match?(/\A[A-Za-z]+(?:\s+[A-Za-z.'-]+)+\z/)
3131
+ parts = q.split(/\s+/)
3132
+ "first_name=#{URI.encode_www_form_component(parts.first)}&last_name=#{URI.encode_www_form_component(parts.last)}"
3133
+ else
3134
+ "organization_name=#{URI.encode_www_form_component(q)}"
3135
+ end
3136
+ body = http_get_json(url: "https://npiregistry.cms.hhs.gov/api/?version=2.1&#{params}&limit=#{limit}")
3137
+ results = Array(body.is_a?(Hash) ? body[:results] : nil).first(limit).map do |r|
3138
+ basic = r[:basic] || {}
3139
+ {
3140
+ npi: r[:number],
3141
+ enumeration_type: r[:enumeration_type],
3142
+ name: [basic[:first_name], basic[:last_name]].compact.join(' ').strip,
3143
+ organization: basic[:organization_name],
3144
+ credential: basic[:credential],
3145
+ status: basic[:status],
3146
+ addresses: Array(r[:addresses]).first(2),
3147
+ taxonomies: Array(r[:taxonomies]).first(3)
3148
+ }
3149
+ end
3150
+ { source: 'nppes', result_count: (body.is_a?(Hash) ? body[:result_count] : results.length), results: results }
3151
+ rescue StandardError => e
3152
+ { source: 'nppes', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3153
+ end
3154
+
3155
+ private_class_method def self.osint_federal_register(opts = {})
3156
+ q = opts[:query].to_s.strip
3157
+ limit = opts[:limit] || 5
3158
+ body = http_get_json(
3159
+ url: "https://www.federalregister.gov/api/v1/documents.json?conditions%5Bterm%5D=#{URI.encode_www_form_component(q)}&per_page=#{limit}&order=newest"
3160
+ )
3161
+ results = Array(body.is_a?(Hash) ? body[:results] : nil).first(limit).map do |d|
3162
+ {
3163
+ title: d[:title],
3164
+ type: d[:type],
3165
+ abstract: d[:abstract].to_s[0, 300],
3166
+ document_number: d[:document_number],
3167
+ html_url: d[:html_url],
3168
+ pdf_url: d[:pdf_url],
3169
+ publication_date: d[:publication_date],
3170
+ agencies: Array(d[:agencies]).map { |a| a.is_a?(Hash) ? a[:name] : a }.first(5)
3171
+ }
3172
+ end
3173
+ { source: 'federalregister.gov', count: (body.is_a?(Hash) ? body[:count] : results.length), results: results }
3174
+ rescue StandardError => e
3175
+ { source: 'federalregister.gov', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3176
+ end
3177
+
3178
+ private_class_method def self.osint_uk_police(opts = {})
3179
+ q = opts[:query].to_s.strip
3180
+ limit = opts[:limit] || 5
3181
+ if q.match?(/\A-?\d+\.\d+\s*,\s*-?\d+\.\d+\z/)
3182
+ lat, lng = q.split(',').map(&:strip)
3183
+ body = http_get_json(url: "https://data.police.uk/api/crimes-street/all-crime?lat=#{lat}&lng=#{lng}")
3184
+ hits = Array(body).first(limit).map { |c| c.slice(:category, :location_type, :location, :month, :outcome_status) }
3185
+ { source: 'data.police.uk', mode: 'street', lat: lat, lng: lng, total_hint: Array(body).length, hits: hits }
3186
+ elsif q.match?(/\A[a-z0-9-]+\z/i) && !q.include?('.')
3187
+ forces = http_get_json(url: 'https://data.police.uk/api/forces')
3188
+ match = Array(forces).find { |f| f[:id].to_s == q.downcase || f[:name].to_s.downcase.include?(q.downcase) }
3189
+ if match
3190
+ detail = http_get_json(url: "https://data.police.uk/api/forces/#{match[:id]}")
3191
+ { source: 'data.police.uk', mode: 'force', force: match, detail: detail }
3192
+ else
3193
+ { source: 'data.police.uk', mode: 'force-search', matches: Array(forces).select { |f| f[:name].to_s.downcase.include?(q.downcase) }.first(limit) }
3194
+ end
3195
+ else
3196
+ forces = http_get_json(url: 'https://data.police.uk/api/forces')
3197
+ updated = http_get_json(url: 'https://data.police.uk/api/crime-last-updated')
3198
+ { source: 'data.police.uk', mode: 'catalog', last_updated: updated, forces_sample: Array(forces).first(limit), note: 'Pass "lat,lng" for street crimes or a force id/name' }
3199
+ end
3200
+ rescue StandardError => e
3201
+ { source: 'data.police.uk', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3202
+ end
3203
+
3204
+ private_class_method def self.osint_callook(opts = {})
3205
+ q = opts[:query].to_s.strip.upcase.sub(%r{\A.*/}, '')
3206
+ body = http_get_json(url: "https://callook.info/#{URI.encode_www_form_component(q)}/json")
3207
+ { source: 'callook.info', callsign: q, data: body }
3208
+ end
3209
+
3210
+ private_class_method def self.osint_mac_vendor(opts = {})
3211
+ q = opts[:query].to_s.strip
3212
+ hex = q.gsub(/[^0-9A-Fa-f]/, '')
3213
+ mac = if hex.length >= 6
3214
+ hex[0, 12].downcase.scan(/../).join(':')
3215
+ else
3216
+ q
3217
+ end
3218
+ body = http_get_json(url: "https://api.maclookup.app/v2/macs/#{URI.encode_www_form_component(mac)}")
3219
+ if body
3220
+ { source: 'maclookup.app', mac: mac, data: body }
3221
+ else
3222
+ require 'rest-client'
3223
+ resp = RestClient::Request.execute(
3224
+ method: :get,
3225
+ url: "https://api.macvendors.com/#{URI.encode_www_form_component(mac)}",
3226
+ timeout: 8,
3227
+ open_timeout: 4,
3228
+ headers: { user_agent: 'pwn-ai-extrospection' }
3229
+ )
3230
+ { source: 'macvendors.com', mac: mac, vendor: resp.body.to_s.strip }
3231
+ end
3232
+ rescue StandardError => e
3233
+ { source: 'mac-vendor', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3234
+ end
3235
+
3236
+ private_class_method def self.osint_universities(opts = {})
3237
+ q = opts[:query].to_s.strip
3238
+ limit = opts[:limit] || 5
3239
+ # hipolabs is HTTP (not HTTPS); RestClient follows redirects fine.
3240
+ body = http_get_json(url: "http://universities.hipolabs.com/search?name=#{URI.encode_www_form_component(q)}")
3241
+ hits = Array(body).first(limit).map do |u|
3242
+ if u.is_a?(Hash)
3243
+ {
3244
+ name: u[:name],
3245
+ country: u[:country],
3246
+ alpha_two_code: u[:alpha_two_code],
3247
+ domains: u[:domains],
3248
+ web_pages: u[:web_pages],
3249
+ state_province: u[:'state-province']
3250
+ }
3251
+ else
3252
+ u
3253
+ end
3254
+ end
3255
+ { source: 'hipolabs-universities', total_hint: Array(body).length, hits: hits }
3256
+ rescue StandardError => e
3257
+ { source: 'hipolabs-universities', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3258
+ end
3259
+
3260
+ private_class_method def self.osint_microlink(opts = {})
3261
+ q = opts[:query].to_s.strip
3262
+ q = "https://#{q}" unless q.match?(%r{\Ahttps?://}i)
3263
+ body = http_get_json(url: "https://api.microlink.io?url=#{URI.encode_www_form_component(q)}")
3264
+ data = body.is_a?(Hash) ? (body[:data] || body) : body
3265
+ if data.is_a?(Hash)
3266
+ { source: 'microlink.io', status: (body.is_a?(Hash) ? body[:status] : nil), data: data.slice(:title, :description, :publisher, :author, :date, :url, :image, :logo, :lang) }
3267
+ else
3268
+ { source: 'microlink.io', data: body }
3269
+ end
3270
+ end
3271
+
3272
+ private_class_method def self.osint_agify(opts = {})
3273
+ name = opts[:query].to_s.strip.split(/\s+/).first
3274
+ body = http_get_json(url: "https://api.agify.io?name=#{URI.encode_www_form_component(name)}")
3275
+ { source: 'agify.io', data: body }
3276
+ end
3277
+
3278
+ private_class_method def self.osint_genderize(opts = {})
3279
+ name = opts[:query].to_s.strip.split(/\s+/).first
3280
+ body = http_get_json(url: "https://api.genderize.io?name=#{URI.encode_www_form_component(name)}")
3281
+ { source: 'genderize.io', data: body }
3282
+ end
3283
+
3284
+ private_class_method def self.osint_nationalize(opts = {})
3285
+ name = opts[:query].to_s.strip.split(/\s+/).first
3286
+ body = http_get_json(url: "https://api.nationalize.io?name=#{URI.encode_www_form_component(name)}")
3287
+ { source: 'nationalize.io', data: body }
3288
+ end
3289
+
3290
+ private_class_method def self.osint_haveibeenpwned(opts = {})
3291
+ key = opts[:api_key].to_s
3292
+ return { source: 'haveibeenpwned', skipped: true, reason: 'no API key (set HIBP_API_KEY)' } if key.empty?
3293
+
3294
+ q = opts[:query].to_s.strip
3295
+ require 'rest-client'
3296
+ path = "https://haveibeenpwned.com/api/v3/breachedaccount/#{URI.encode_www_form_component(q)}?truncateResponse=false"
3297
+ resp = RestClient::Request.execute(
3298
+ method: :get,
3299
+ url: path,
3300
+ timeout: 12,
3301
+ open_timeout: 5,
3302
+ headers: {
3303
+ accept: :json,
3304
+ 'hibp-api-key' => key,
3305
+ user_agent: 'pwn-ai-extrospection'
3306
+ }
3307
+ )
3308
+ body = JSON.parse(resp.body, symbolize_names: true)
3309
+ hits = Array(body).first(10).map { |b| b.is_a?(Hash) ? b.slice(:Name, :Title, :Domain, :BreachDate, :PwnCount, :DataClasses, :IsVerified) : b }
3310
+ { source: 'haveibeenpwned', count: Array(body).length, breaches: hits }
3311
+ rescue RestClient::NotFound
3312
+ { source: 'haveibeenpwned', count: 0, breaches: [], note: 'no breaches for account' }
3313
+ rescue StandardError => e
3314
+ { source: 'haveibeenpwned', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3315
+ end
3316
+
3317
+ private_class_method def self.osint_securitytrails(opts = {})
3318
+ key = opts[:api_key].to_s
3319
+ return { source: 'securitytrails', skipped: true, reason: 'no API key (set SECURITYTRAILS_API_KEY)' } if key.empty?
3320
+
3321
+ q = opts[:query].to_s.strip.sub(%r{\Ahttps?://}, '').split('/').first
3322
+ require 'rest-client'
3323
+ resp = RestClient::Request.execute(
3324
+ method: :get,
3325
+ url: "https://api.securitytrails.com/v1/domain/#{URI.encode_www_form_component(q)}",
3326
+ timeout: 12,
3327
+ open_timeout: 5,
3328
+ headers: {
3329
+ accept: :json,
3330
+ apikey: key,
3331
+ user_agent: 'pwn-ai-extrospection'
3332
+ }
3333
+ )
3334
+ body = JSON.parse(resp.body, symbolize_names: true)
3335
+ { source: 'securitytrails', data: (body.is_a?(Hash) ? body.slice(:hostname, :alexa_rank, :current_dns, :apex_domain, :subdomain_count) : body) }
3336
+ rescue StandardError => e
3337
+ { source: 'securitytrails', error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3338
+ end
3339
+
3340
+ private_class_method def self.build_osint_summary(opts = {})
3341
+ r = opts[:results] || {}
3342
+ parts = ["osint kind=#{r[:kind]} query=#{r[:query]}"]
3343
+ Array(r[:feeds]).each do |feed, val|
3344
+ if val.is_a?(Hash) && val[:error]
3345
+ parts << "#{feed}=err"
3346
+ elsif val.is_a?(Hash) && val[:skipped]
3347
+ parts << "#{feed}=skip"
3348
+ else
3349
+ parts << "#{feed}=ok"
3350
+ end
3351
+ end
3352
+ parts.join(' | ')[0, 400]
3353
+ end
3354
+
3355
+ # ── Telecomm helpers ────────────────────────────────────────
3356
+
3357
+ private_class_method def self.telecomm_baresip_cmd(opts = {})
3358
+ host = opts[:host]
3359
+ port = opts[:port]
3360
+ cmd = opts[:cmd].to_s
3361
+ require 'rest-client'
3362
+ url = "http://#{host}:#{port}#{cmd.start_with?('/') ? cmd : "/?#{cmd}"}"
3363
+ resp = RestClient::Request.execute(method: :get, url: url, timeout: 5, open_timeout: 2, headers: { user_agent: 'pwn-ai-extrospection' })
3364
+ body = resp.body.to_s
3365
+ text = begin
3366
+ require 'nokogiri'
3367
+ Nokogiri::HTML.parse(body).text.gsub(/\s+/, ' ').strip
3368
+ rescue StandardError
3369
+ body
3370
+ end
3371
+ { http_status: resp.code, text: text[0, 1_000] }
3372
+ rescue StandardError => e
3373
+ { error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3374
+ end
3375
+
3376
+ # ── Packet helpers ──────────────────────────────────────────
3377
+
3378
+ private_class_method def self.summarize_pcap_file(opts = {})
3379
+ path = opts[:path].to_s
3380
+ return { error: 'missing pcap' } unless File.exist?(path)
3381
+
3382
+ tshark = sh(cmd: 'which tshark 2>/dev/null')
3383
+ unless tshark.empty?
3384
+ stats = sh(cmd: "timeout 15 tshark -r #{Shellwords.escape(path)} -q -z io,phs 2>/dev/null")
3385
+ convs = sh(cmd: "timeout 15 tshark -r #{Shellwords.escape(path)} -q -z conv,ip 2>/dev/null")
3386
+ frames = sh(cmd: "timeout 15 tshark -r #{Shellwords.escape(path)} -T fields -e frame.number 2>/dev/null | wc -l")
3387
+ return {
3388
+ path: path,
3389
+ bytes: File.size(path),
3390
+ frames: frames.to_i,
3391
+ protocol_hierarchy: stats.to_s[0, 1_500],
3392
+ ip_conversations: convs.to_s[0, 1_500]
3393
+ }
3394
+ end
3395
+
3396
+ # Fall back to PacketFu packet count / top ethertypes
3397
+ begin
3398
+ require 'pwn/plugins/packet' unless defined?(PWN::Plugins::Packet)
3399
+ pkts = PWN::Plugins::Packet.open_pcap_file(path: path)
3400
+ ethertypes = Hash.new(0)
3401
+ Array(pkts).first(500).each do |p|
3402
+ et = begin
3403
+ p.eth_proto
3404
+ rescue StandardError
3405
+ nil
3406
+ end
3407
+ ethertypes[et] += 1 if et
3408
+ end
3409
+ { path: path, bytes: File.size(path), frames: Array(pkts).length, ethertypes: ethertypes }
3410
+ rescue StandardError => e
3411
+ { path: path, bytes: File.size(path), error: "#{e.class}: #{e.message.to_s[0, 120]}" }
3412
+ end
3413
+ end
3414
+
3415
+ # ── Vision helpers ──────────────────────────────────────────
3416
+
3417
+ private_class_method def self.vision_ocr(opts = {})
3418
+ file = opts[:file].to_s
3419
+ lang = opts[:lang] || 'eng'
3420
+ begin
3421
+ require 'pwn/plugins/ocr' unless defined?(PWN::Plugins::OCR)
3422
+ # Plugin currently takes file only; lang is respected via TESSDATA / shell fallback.
3423
+ return PWN::Plugins::OCR.process(file: file).to_s
3424
+ rescue StandardError
3425
+ nil
3426
+ end
3427
+ sh(cmd: "timeout 60 tesseract #{Shellwords.escape(file)} stdout -l #{Shellwords.escape(lang)} 2>/dev/null")
3428
+ end
3429
+
3430
+ private_class_method def self.vision_barcodes(opts = {})
3431
+ file = opts[:file].to_s
3432
+ zbar = sh(cmd: 'which zbarimg 2>/dev/null')
3433
+ unless zbar.empty?
3434
+ raw = sh(cmd: "timeout 30 zbarimg -q #{Shellwords.escape(file)} 2>/dev/null")
3435
+ return raw.lines.map(&:strip).reject(&:empty?)
3436
+ end
3437
+ # No decoder — report guidance
3438
+ [{ error: 'zbarimg not installed', advice: 'apt install zbar-tools' }]
3439
+ end
3440
+
3441
+ # ── Voice helpers ───────────────────────────────────────────
3442
+
3443
+ private_class_method def self.pick_tts_engine(opts = {})
3444
+ inv = opts[:inv] || {}
3445
+ bins = inv[:bins] || {}
3446
+ return :espeak if !bins[:'espeak-ng'].to_s.empty? || !bins[:espeak].to_s.empty?
3447
+ return :spd_say unless bins[:'spd-say'].to_s.empty?
3448
+ return :festival unless bins[:festival].to_s.empty?
3449
+
3450
+ :espeak
3451
+ end
3452
+
3453
+ private_class_method def self.pick_stt_engine(opts = {})
3454
+ inv = opts[:inv] || {}
3455
+ bins = inv[:bins] || {}
3456
+ return :whisper unless bins[:whisper].to_s.empty?
3457
+
3458
+ :whisper
3459
+ end
3460
+
3461
+ private_class_method def self.voice_tts(opts = {})
3462
+ text = opts[:text].to_s
3463
+ engine = opts[:engine].to_sym
3464
+ out = opts[:out].to_s
3465
+ inv = opts[:inv] || {}
3466
+ bins = inv[:bins] || {}
3467
+ case engine
3468
+ when :espeak
3469
+ bin = bins[:'espeak-ng'].to_s
3470
+ bin = bins[:espeak].to_s if bin.empty?
3471
+ bin = sh(cmd: 'which espeak-ng 2>/dev/null || which espeak 2>/dev/null') if bin.empty?
3472
+ return [false, 'espeak not found'] if bin.empty?
3473
+
3474
+ # prefer wav write if -w supported
3475
+ log = sh(cmd: "#{Shellwords.escape(bin)} -w #{Shellwords.escape(out)} #{Shellwords.escape(text)} 2>&1")
3476
+ return [File.exist?(out), log] if File.exist?(out)
3477
+
3478
+ log = sh(cmd: "printf '%s' #{Shellwords.escape(text)} | #{Shellwords.escape(bin)} --stdout > #{Shellwords.escape(out)} 2>&1")
3479
+ [File.exist?(out) && File.size(out).positive?, log]
3480
+ when :spd_say
3481
+ bin = bins[:'spd-say'].to_s
3482
+ bin = sh(cmd: 'which spd-say 2>/dev/null') if bin.empty?
3483
+ return [false, 'spd-say not found'] if bin.empty?
3484
+
3485
+ log = sh(cmd: "#{Shellwords.escape(bin)} -w #{Shellwords.escape(text)} 2>&1")
3486
+ # spd-say speaks to audio device — no wav; mark ok on exit
3487
+ [true, log]
3488
+ when :festival
3489
+ # Use plugin if text file available
3490
+ tmp = File.join(Dir.tmpdir, "pwn_tts_#{Process.pid}.txt")
3491
+ File.write(tmp, text)
3492
+ begin
3493
+ require 'pwn/plugins/voice' unless defined?(PWN::Plugins::Voice)
3494
+ PWN::Plugins::Voice.text_to_speech(text_path: tmp)
3495
+ [true, 'festival via PWN::Plugins::Voice']
3496
+ rescue StandardError => e
3497
+ [false, "#{e.class}: #{e.message}"]
3498
+ ensure
3499
+ FileUtils.rm_f(tmp)
3500
+ end
3501
+ else
3502
+ [false, "unsupported TTS engine: #{engine}"]
3503
+ end
3504
+ end
3505
+
3506
+ private_class_method def self.voice_stt(opts = {})
3507
+ audio = opts[:audio].to_s
3508
+ engine = opts[:engine].to_sym
3509
+ model = opts[:model] || 'tiny'
3510
+ inv = opts[:inv] || {}
3511
+ bins = inv[:bins] || {}
3512
+ case engine
3513
+ when :whisper
3514
+ bin = bins[:whisper].to_s
3515
+ bin = sh(cmd: 'which whisper 2>/dev/null') if bin.empty?
3516
+ art = File.join(Dir.home, '.pwn', 'extrospection', 'voice')
3517
+ FileUtils.mkdir_p(art)
3518
+ if bin.empty?
3519
+ # try plugin (raises if whisper missing)
3520
+ begin
3521
+ require 'pwn/plugins/voice' unless defined?(PWN::Plugins::Voice)
3522
+ PWN::Plugins::Voice.speech_to_text(audio_file_path: audio, model: model, output_dir: art)
3523
+ rescue StandardError => e
3524
+ return ['', "whisper unavailable: #{e.class}: #{e.message}"]
3525
+ end
3526
+ else
3527
+ sh(cmd: "timeout 300 #{Shellwords.escape(bin)} #{Shellwords.escape(audio)} --model #{Shellwords.escape(model)} --output_dir #{Shellwords.escape(art)} --output_format txt 2>&1")
3528
+ end
3529
+ base = File.join(art, "#{File.basename(audio, '.*')}.txt")
3530
+ alt = Dir.glob(File.join(art, '*.txt')).max_by { |f| File.mtime(f) }
3531
+ path = File.exist?(base) ? base : alt
3532
+ text = path && File.exist?(path) ? File.read(path) : ''
3533
+ [text, "whisper model=#{model} out=#{path}"]
3534
+ else
3535
+ ['', "unsupported STT engine: #{engine}"]
3536
+ end
3537
+ end
3538
+
3539
+ # Author(s):: 0day Inc. <support@0dayinc.com>
3540
+
3541
+ public_class_method def self.authors
3542
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
3543
+ end
3544
+
3545
+ # Display Usage for this Module
3546
+
3547
+ public_class_method def self.help
3548
+ puts <<~USAGE
3549
+ USAGE:
3550
+ PWN::AI::Agent::Extrospection.snapshot # probe host, persist, return {snapshot:, drift:}
3551
+ PWN::AI::Agent::Extrospection.drift(live: true) # what changed vs last snapshot
3552
+ PWN::AI::Agent::Extrospection.observe(source: 'nmap', category: :recon, target: '10.0.0.5', data: '22/tcp open ssh 9.6')
3553
+ PWN::AI::Agent::Extrospection.observe(source: 'gqrx', category: :rf, target: '433.920MHz', data: 'peak -34.2 dBFS bw=200k')
3554
+ PWN::AI::Agent::Extrospection.observations(category: 'recon', target: '10.0.0.5')
3555
+ PWN::AI::Agent::Extrospection.intel(query: 'openssl 3.0', record: true)
3556
+ PWN::AI::Agent::Extrospection.correlate # introspection x extrospection findings
3557
+ PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
3558
+ PWN::AI::Agent::Extrospection.stats
3559
+ PWN::AI::Agent::Extrospection.auto_extrospect(session_id: sid) # called from Learning.auto_introspect
3560
+ PWN::AI::Agent::Extrospection.snapshot(sections: %i[web]) # opt-in browser probe of web_anchors
3561
+ PWN::AI::Agent::Extrospection.watch(url: 'https://target/api/version')
3562
+ PWN::AI::Agent::Extrospection.rf_tune(freq: '101.1') # tune GQRX + RDS → now_playing
3563
+ PWN::AI::Agent::Extrospection.osint(query: '+13125551212', kind: :phone)
3564
+ PWN::AI::Agent::Extrospection.osint(query: '2ABIP-ESP32', kind: :fcc_id)
3565
+ PWN::AI::Agent::Extrospection.osint(query: 'US10123456', kind: :patent)
3566
+ PWN::AI::Agent::Extrospection.osint(query: '1HGCM82633A004352', kind: :vin)
3567
+ PWN::AI::Agent::Extrospection.osint(query: '00:11:22:33:44:55', kind: :mac)
3568
+ PWN::AI::Agent::Extrospection.osint(query: 'W1AW', kind: :callsign)
3569
+ PWN::AI::Agent::Extrospection.osint(query: 'CVE-2021-44228', kind: :cve)
3570
+ PWN::AI::Agent::Extrospection.serial_sense(payload: "ATI\r")
3571
+ PWN::AI::Agent::Extrospection.telecomm(action: :status)
3572
+ PWN::AI::Agent::Extrospection.packet_sense(action: :capture, filter: 'tcp port 443', count: 10)
3573
+ PWN::AI::Agent::Extrospection.vision(file: '/tmp/shot.png', action: :ocr)
3574
+ PWN::AI::Agent::Extrospection.voice_sense(action: :tts, text: 'hello from pwn')
3575
+ PWN::AI::Agent::Extrospection.verify(claim: 'CVE-2026-12345 affects OpenSSL 3.2.1')
3576
+ PWN::AI::Agent::Extrospection.revalidate_memory # cron: GC stale PWN::Memory :fact entries
3577
+ PWN::AI::Agent::Extrospection.reset
3578
+
3579
+ PRIMARY use = on-demand sensing (intel / verify / watch / rf_tune / osint /
3580
+ serial_sense / telecomm / packet_sense / vision / voice_sense / observe /
3581
+ rf / web / osint / serial / telecomm / packet / vision / voice).
3582
+ auto_extrospect is OPTIONAL ambient baseline (host/repo/env only — never
3583
+ launches burpsuite/zaproxy/msfconsole/gqrx). Prefer calling sense tools
3584
+ when a question needs the outside world, not after every turn.
3585
+
3586
+ Enable end-of-run ambient baseline with:
3587
+ PWN::Env[:ai][:agent][:auto_extrospect] = true # sections: AUTO_SECTIONS
3588
+
3589
+ Configure browser-backed :web probe / verify / watch with:
3590
+ PWN::Env[:ai][:agent][:extrospection][:web] =
3591
+ { anchors: [...], proxy: 'tor', max_anchors: 8, per_page_timeout: 15, screenshot: false, allow_targets: false }
3592
+
3593
+ Configure RF sense (rf_tune) with:
3594
+ PWN::Env[:ai][:agent][:extrospection][:rf] =
3595
+ { host: '127.0.0.1', port: 7356, settle_secs: 8, ttl: 300 }
3596
+
3597
+ Configure new limbs:
3598
+ PWN::Env[:ai][:agent][:extrospection][:osint] = { ttl: 86400, api_keys: { shodan: '…', hunter: '…', abuseipdb: '…', virustotal: '…', greynoise: '…', haveibeenpwned: '…', securitytrails: '…' } }
3599
+ PWN::Env[:ai][:agent][:extrospection][:serial] = { block_dev: '/dev/ttyUSB0', baud: 115200, settle_secs: 1.5 }
3600
+ PWN::Env[:ai][:agent][:extrospection][:telecomm] = { host: '127.0.0.1', port: 8000 }
3601
+ PWN::Env[:ai][:agent][:extrospection][:packet] = { iface: 'eth0' }
3602
+ PWN::Env[:ai][:agent][:extrospection][:vision] = { lang: 'eng' }
3603
+ PWN::Env[:ai][:agent][:extrospection][:voice] = { ttl: 3600 }
1099
3604
 
1100
3605
  #{self}.authors
1101
3606
  USAGE