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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -2
- data/Gemfile +1 -1
- data/README.md +7 -1
- data/bin/pwn_gqrx_scanner +78 -9
- data/documentation/Agent-Tool-Registry.md +3 -3
- data/documentation/Configuration.md +301 -47
- data/documentation/Cron.md +5 -3
- data/documentation/Diagrams.md +1 -1
- data/documentation/Extrospection.md +472 -26
- data/documentation/FFI.md +72 -8
- data/documentation/Home.md +4 -4
- data/documentation/How-PWN-Works.md +7 -3
- data/documentation/Mistakes.md +9 -0
- data/documentation/SDR.md +83 -10
- data/documentation/Skills-Memory-Learning.md +101 -11
- data/documentation/diagrams/agent-tool-registry.svg +56 -55
- data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
- data/documentation/diagrams/aws-cloud-security.svg +25 -25
- data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
- data/documentation/diagrams/code-scanning-sast.svg +25 -25
- data/documentation/diagrams/cron-scheduling.svg +23 -23
- data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -6
- data/documentation/diagrams/dot/hardware-hacking.dot +3 -1
- data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -2
- data/documentation/diagrams/dot/mistakes-negative-feedback.dot +4 -2
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +3 -3
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
- data/documentation/diagrams/dot/sdr-radio-flow.dot +39 -16
- data/documentation/diagrams/driver-framework.svg +13 -13
- data/documentation/diagrams/extrospection-world-awareness.svg +383 -177
- data/documentation/diagrams/fuzzing-workflow.svg +24 -24
- data/documentation/diagrams/hardware-hacking.svg +84 -69
- data/documentation/diagrams/history-to-drivers.svg +18 -18
- data/documentation/diagrams/memory-skills-detailed.svg +137 -120
- data/documentation/diagrams/mistakes-negative-feedback.svg +152 -137
- data/documentation/diagrams/network-infra-testing.svg +27 -27
- data/documentation/diagrams/overall-pwn-architecture.svg +97 -97
- data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
- data/documentation/diagrams/persistence-filesystem.svg +100 -94
- data/documentation/diagrams/plugin-ecosystem.svg +35 -35
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +254 -207
- data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
- data/documentation/diagrams/reporting-pipeline.svg +18 -18
- data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
- data/documentation/diagrams/sdr-radio-flow.svg +223 -91
- data/documentation/diagrams/sessions-cron-automation.svg +18 -18
- data/documentation/diagrams/swarm-multi-agent.svg +39 -39
- data/documentation/diagrams/web-application-testing.svg +26 -26
- data/documentation/diagrams/zero-day-research-flow.svg +25 -25
- data/documentation/pwn-ai-Agent.md +16 -10
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/lib/pwn/ai/agent/assembly.rb +1 -1
- data/lib/pwn/ai/agent/btc.rb +1 -1
- data/lib/pwn/ai/agent/burp_suite.rb +1 -1
- data/lib/pwn/ai/agent/extrospection.rb +2562 -57
- data/lib/pwn/ai/agent/gqrx.rb +2 -2
- data/lib/pwn/ai/agent/hacker_one.rb +1 -1
- data/lib/pwn/ai/agent/learning.rb +16 -16
- data/lib/pwn/ai/agent/loop.rb +1 -1
- data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
- data/lib/pwn/ai/agent/sast.rb +1 -1
- data/lib/pwn/ai/agent/tools/extrospection.rb +311 -8
- data/lib/pwn/ai/agent/tools/learning.rb +7 -7
- data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
- data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
- data/lib/pwn/ai/agent.rb +1 -1
- data/lib/pwn/ai/open_ai.rb +10 -6
- data/lib/pwn/config.rb +5 -4
- data/lib/pwn/ffi/adalm_pluto.rb +498 -0
- data/lib/pwn/ffi/fftw.rb +192 -0
- data/lib/pwn/ffi/hack_rf.rb +193 -0
- data/lib/pwn/ffi/liquid.rb +244 -0
- data/lib/pwn/ffi/rtl_sdr.rb +210 -0
- data/lib/pwn/ffi/soapy_sdr.rb +177 -0
- data/lib/pwn/ffi/stdio.rb +9 -1
- data/lib/pwn/ffi/volk.rb +241 -0
- data/lib/pwn/ffi.rb +45 -2
- data/lib/pwn/plugins/burp_suite.rb +2 -2
- data/lib/pwn/sast/pom_version.rb +2 -2
- data/lib/pwn/sast/test_case_engine.rb +1 -1
- data/lib/pwn/sdr/decoder/adsb.rb +201 -12
- data/lib/pwn/sdr/decoder/apt.rb +19 -5
- data/lib/pwn/sdr/decoder/base.rb +314 -1
- data/lib/pwn/sdr/decoder/bluetooth.rb +91 -18
- data/lib/pwn/sdr/decoder/dect.rb +81 -23
- data/lib/pwn/sdr/decoder/dsp.rb +229 -15
- data/lib/pwn/sdr/decoder/flex.rb +23 -6
- data/lib/pwn/sdr/decoder/gps.rb +80 -18
- data/lib/pwn/sdr/decoder/gsm.rb +83 -28
- data/lib/pwn/sdr/decoder/iridium.rb +80 -18
- data/lib/pwn/sdr/decoder/lora.rb +84 -28
- data/lib/pwn/sdr/decoder/lte.rb +80 -18
- data/lib/pwn/sdr/decoder/morse.rb +23 -6
- data/lib/pwn/sdr/decoder/p25.rb +90 -31
- data/lib/pwn/sdr/decoder/pager.rb +23 -6
- data/lib/pwn/sdr/decoder/pocsag.rb +23 -6
- data/lib/pwn/sdr/decoder/rds.rb +219 -52
- data/lib/pwn/sdr/decoder/rfid.rb +81 -27
- data/lib/pwn/sdr/decoder/rtl433.rb +85 -27
- data/lib/pwn/sdr/decoder/rtty.rb +23 -6
- data/lib/pwn/sdr/decoder/wifi.rb +86 -32
- data/lib/pwn/sdr/decoder/zigbee.rb +88 -35
- data/lib/pwn/sdr/decoder.rb +6 -5
- data/lib/pwn/sdr/frequency_allocation.rb +124 -81
- data/lib/pwn/sdr/gqrx.rb +1513 -165
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +96 -0
- data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
- data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +8 -24
- data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +37 -0
- data/spec/lib/pwn/ffi/fftw_spec.rb +26 -0
- data/spec/lib/pwn/ffi/hack_rf_spec.rb +25 -0
- data/spec/lib/pwn/ffi/liquid_spec.rb +42 -0
- data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +24 -0
- data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +25 -0
- data/spec/lib/pwn/ffi/volk_spec.rb +36 -0
- data/spec/lib/pwn/sdr/decoder/base_iq_spec.rb +65 -0
- data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +50 -4
- data/spec/lib/pwn/sdr/decoder/rds_spec.rb +45 -4
- data/third_party/pwn_rdoc.jsonl +279 -30
- 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).
|
|
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
|
-
#
|
|
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.
|
|
29
|
-
# Learning.note_outcome Extrospection.observe
|
|
30
|
-
# Learning.reflect Extrospection.drift
|
|
31
|
-
# Learning.stats Extrospection.
|
|
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
|
|
35
|
-
#
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
#
|
|
86
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1824
|
+
# ── RF sense helpers (rf_tune) ─────────────────────────────────────
|
|
1067
1825
|
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|