pwn 0.5.620 → 0.5.621

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/documentation/Agent-Tool-Registry.md +2 -2
  4. data/documentation/Cron.md +13 -0
  5. data/documentation/Diagrams.md +1 -1
  6. data/documentation/Extrospection.md +71 -5
  7. data/documentation/Home.md +2 -2
  8. data/documentation/How-PWN-Works.md +1 -1
  9. data/documentation/Mistakes.md +2 -1
  10. data/documentation/Persistence.md +2 -1
  11. data/documentation/Skills-Memory-Learning.md +1 -0
  12. data/documentation/Transparent-Browser.md +18 -1
  13. data/documentation/diagrams/agent-tool-registry.svg +55 -54
  14. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  15. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  16. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  17. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  18. data/documentation/diagrams/cron-scheduling.svg +23 -23
  19. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  20. data/documentation/diagrams/dot/extrospection-world-awareness.dot +29 -8
  21. data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -0
  22. data/documentation/diagrams/dot/overall-pwn-architecture.dot +2 -2
  23. data/documentation/diagrams/dot/persistence-filesystem.dot +2 -1
  24. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
  25. data/documentation/diagrams/driver-framework.svg +13 -13
  26. data/documentation/diagrams/extrospection-world-awareness.svg +229 -99
  27. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  28. data/documentation/diagrams/hardware-hacking.svg +18 -18
  29. data/documentation/diagrams/history-to-drivers.svg +18 -18
  30. data/documentation/diagrams/memory-skills-detailed.svg +124 -101
  31. data/documentation/diagrams/mistakes-negative-feedback.svg +55 -55
  32. data/documentation/diagrams/network-infra-testing.svg +27 -27
  33. data/documentation/diagrams/overall-pwn-architecture.svg +85 -85
  34. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  35. data/documentation/diagrams/persistence-filesystem.svg +94 -85
  36. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  37. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +226 -180
  38. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  39. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  40. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  41. data/documentation/diagrams/sdr-radio-flow.svg +31 -31
  42. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  43. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  44. data/documentation/diagrams/web-application-testing.svg +26 -26
  45. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  46. data/documentation/pwn-ai-Agent.md +1 -1
  47. data/lib/pwn/ai/agent/extrospection.rb +433 -4
  48. data/lib/pwn/ai/agent/tools/extrospection.rb +88 -3
  49. data/lib/pwn/sdr/decoder/adsb.rb +14 -33
  50. data/lib/pwn/sdr/decoder/apt.rb +97 -36
  51. data/lib/pwn/sdr/decoder/base.rb +258 -171
  52. data/lib/pwn/sdr/decoder/bluetooth.rb +13 -20
  53. data/lib/pwn/sdr/decoder/dect.rb +13 -27
  54. data/lib/pwn/sdr/decoder/dsp.rb +396 -0
  55. data/lib/pwn/sdr/decoder/flex.rb +177 -210
  56. data/lib/pwn/sdr/decoder/gps.rb +12 -21
  57. data/lib/pwn/sdr/decoder/gsm.rb +21 -73
  58. data/lib/pwn/sdr/decoder/iridium.rb +11 -26
  59. data/lib/pwn/sdr/decoder/lora.rb +16 -23
  60. data/lib/pwn/sdr/decoder/lte.rb +11 -32
  61. data/lib/pwn/sdr/decoder/morse.rb +95 -21
  62. data/lib/pwn/sdr/decoder/p25.rb +19 -16
  63. data/lib/pwn/sdr/decoder/pager.rb +22 -38
  64. data/lib/pwn/sdr/decoder/pocsag.rb +175 -68
  65. data/lib/pwn/sdr/decoder/rfid.rb +24 -33
  66. data/lib/pwn/sdr/decoder/rtl433.rb +21 -30
  67. data/lib/pwn/sdr/decoder/rtty.rb +100 -27
  68. data/lib/pwn/sdr/decoder/wifi.rb +21 -42
  69. data/lib/pwn/sdr/decoder/zigbee.rb +18 -40
  70. data/lib/pwn/sdr/decoder.rb +8 -0
  71. data/lib/pwn/version.rb +1 -1
  72. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
  73. data/third_party/pwn_rdoc.jsonl +24 -3
  74. metadata +3 -1
@@ -9,6 +9,7 @@ require 'open3'
9
9
  require 'etc'
10
10
  require 'uri'
11
11
  require 'shellwords'
12
+ require 'timeout'
12
13
 
13
14
  module PWN
14
15
  module AI
@@ -43,6 +44,12 @@ module PWN
43
44
  MAX_OBSERVATIONS = 500
44
45
  RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
45
46
  PROBE_BINS = (%w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker] + RF_BINS).freeze
47
+ WEB_SHOT_DIR = File.join(Dir.home, '.pwn', 'extrospection', 'web')
48
+ DEFAULT_WEB_ANCHORS = %w[
49
+ https://services.nvd.nist.gov/rest/json/cves/2.0
50
+ https://www.exploit-db.com/
51
+ https://raw.githubusercontent.com/0dayinc/pwn/master/lib/pwn/version.rb
52
+ ].freeze
46
53
 
47
54
  # Supported Method Parameters::
48
55
  # store = PWN::AI::Agent::Extrospection.load
@@ -72,7 +79,7 @@ module PWN
72
79
  # Supported Method Parameters::
73
80
  # snap = PWN::AI::Agent::Extrospection.snapshot(
74
81
  # persist: 'optional - Boolean, write snapshot to disk & rotate previous (default true)',
75
- # sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf] (default all)'
82
+ # sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf, :web] (default all except :web)'
76
83
  # )
77
84
  #
78
85
  # Captures a fingerprint of the OUTSIDE world. When persist:true the
@@ -94,6 +101,7 @@ module PWN
94
101
  snap[:repo] = probe_repo if sections.include?(:repo)
95
102
  snap[:env] = probe_env if sections.include?(:env)
96
103
  snap[:rf] = probe_rf if sections.include?(:rf)
104
+ snap[:web] = probe_web if sections.include?(:web)
97
105
  snap[:captured_at] = Time.now.utc.iso8601
98
106
  snap[:fingerprint] = Digest::SHA256.hexdigest(JSON.generate(snap.except(:captured_at)))[0, 16]
99
107
 
@@ -112,7 +120,7 @@ module PWN
112
120
  # obs = PWN::AI::Agent::Extrospection.observe(
113
121
  # source: 'required - where the observation came from (nmap, shodan, burp, cve, human, ...)',
114
122
  # data: 'required - the observation payload (String or Hash)',
115
- # category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :misc (default :misc)',
123
+ # category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :web, :misc (default :misc)',
116
124
  # target: 'optional - host/ip/url/asset the observation is about',
117
125
  # tags: 'optional - Array of String labels',
118
126
  # ttl: 'optional - seconds until this observation is considered stale (default nil = forever)'
@@ -238,6 +246,189 @@ module PWN
238
246
  { query: query, feeds: feeds, results: results, total: results.values.flatten.compact.length }
239
247
  end
240
248
 
249
+ # Supported Method Parameters::
250
+ # verdict = PWN::AI::Agent::Extrospection.verify(
251
+ # claim: 'required - factual claim to fact-check against the live web',
252
+ # kind: 'optional - :cve | :version | :doc | :generic (default auto-detect)',
253
+ # url: 'optional - explicit URL the claim cites (forces kind: :doc)',
254
+ # commit: 'optional - write Mistakes/Memory/observe on refute/confirm (default true)',
255
+ # proxy: 'optional - upstream proxy for TransparentBrowser (e.g. "tor")'
256
+ # )
257
+ #
258
+ # Browser-backed self fact-checking. Drives PWN::Plugins::TransparentBrowser
259
+ # in :headless mode against a canonical source for the claim class,
260
+ # renders the DOM (JS executed), and returns:
261
+ #
262
+ # { claim:, kind:, verdict: :confirmed|:refuted|:unknown,
263
+ # confidence: 0.0..1.0,
264
+ # evidence: [{url:, title:, excerpt:, dom_sha:, screenshot:}],
265
+ # action_taken: :mistakes_record | :extro_observe | :learning_note | nil }
266
+ #
267
+ # On :refuted -> Mistakes.record(tool:'assumption', error:claim) so the
268
+ # KNOWN MISTAKES block warns every future run.
269
+ # On :confirmed-> observe(category::intel, ttl:30d) reinforces w/ freshness.
270
+ # On :unknown -> Learning.note_outcome(success:false, tags:['needs_human']).
271
+ #
272
+ # This is the extrospective mirror of Mistakes: a PROACTIVE trigger that
273
+ # catches the model being wrong about the world before a human does.
274
+
275
+ public_class_method def self.verify(opts = {})
276
+ claim = opts[:claim].to_s.strip
277
+ raise 'ERROR: claim is required' if claim.empty?
278
+
279
+ kind = (opts[:kind] || detect_claim_kind(claim: claim, url: opts[:url])).to_sym
280
+ commit = opts.key?(:commit) ? !opts[:commit].nil? && opts[:commit] != false : true
281
+ proxy = opts[:proxy] || web_config[:proxy]
282
+
283
+ evidence = []
284
+ verdict = :unknown
285
+ conf = 0.0
286
+
287
+ with_headless_browser(proxy: proxy) do |bo|
288
+ case kind
289
+ when :cve
290
+ cve = claim[/CVE-\d{4}-\d{4,}/i].to_s.upcase
291
+ %W[https://nvd.nist.gov/vuln/detail/#{cve} https://www.cve.org/CVERecord?id=#{cve}].each do |u|
292
+ fp = fingerprint_page(browser_obj: bo, url: u)
293
+ evidence << fp if fp
294
+ end
295
+ body = evidence.map { |e| e[:text].to_s }.join(' ')
296
+ if evidence.empty? || evidence.all? { |e| e[:status].to_i >= 400 } || body =~ /could not be found|does not exist|reserved but/i
297
+ verdict = :refuted
298
+ conf = 0.8
299
+ else
300
+ prod = claim.sub(/CVE-\d{4}-\d{4,}/i, '').gsub(/affects?|is|in|the/i, ' ').split.select { |w| w.length > 2 }
301
+ hit = prod.count { |w| body.downcase.include?(w.downcase) }
302
+ if prod.empty? || hit.to_f / prod.length >= 0.5
303
+ verdict = :confirmed
304
+ conf = prod.empty? ? 0.6 : [0.5 + (hit.to_f / prod.length * 0.5), 0.95].min
305
+ else
306
+ verdict = :refuted
307
+ conf = 0.6
308
+ end
309
+ end
310
+ when :doc
311
+ u = opts[:url] || claim[URI::DEFAULT_PARSER.make_regexp(%w[http https])]
312
+ fp = fingerprint_page(browser_obj: bo, url: u)
313
+ evidence << fp if fp
314
+ if fp.nil? || fp[:status].to_i >= 400 || fp[:reachable] == false
315
+ verdict = :refuted
316
+ conf = 0.7
317
+ else
318
+ snippet = claim.sub(u.to_s, '').strip
319
+ overlap = fuzzy_overlap(needle: snippet, haystack: fp[:text].to_s)
320
+ if fp[:title].to_s =~ /not found|404|error/i && overlap < 0.2
321
+ verdict = :refuted
322
+ conf = 0.7
323
+ else
324
+ verdict = overlap >= 0.4 ? :confirmed : :unknown
325
+ conf = overlap
326
+ end
327
+ end
328
+ when :version
329
+ proj = claim[/[A-Za-z][\w.+-]{2,}/].to_s
330
+ ver = claim[/v?\d+\.\d+(?:\.\d+)?/].to_s
331
+ %W[https://rubygems.org/gems/#{proj} https://pypi.org/project/#{proj}/ https://github.com/search?q=#{URI.encode_www_form_component(proj)}&type=repositories].each do |u|
332
+ fp = fingerprint_page(browser_obj: bo, url: u)
333
+ evidence << fp if fp
334
+ end
335
+ body = evidence.map { |e| e[:text].to_s }.join(' ')
336
+ if !ver.empty? && body.include?(ver)
337
+ verdict = :confirmed
338
+ conf = 0.7
339
+ elsif body =~ /\d+\.\d+(?:\.\d+)?/
340
+ verdict = :unknown
341
+ conf = 0.3
342
+ end
343
+ else
344
+ q = URI.encode_www_form_component(claim)
345
+ fp = fingerprint_page(browser_obj: bo, url: "https://html.duckduckgo.com/html/?q=#{q}")
346
+ evidence << fp if fp
347
+ overlap = fuzzy_overlap(needle: claim, haystack: fp ? fp[:text].to_s : '')
348
+ verdict = overlap >= 0.5 ? :confirmed : :unknown
349
+ conf = overlap
350
+ end
351
+ end
352
+
353
+ evidence.each { |e| e.delete(:text) }
354
+ action = commit ? commit_verdict(claim: claim, kind: kind, verdict: verdict, confidence: conf, evidence: evidence) : nil
355
+ { claim: claim, kind: kind, verdict: verdict, confidence: conf.round(2), evidence: evidence, action_taken: action }
356
+ rescue StandardError => e
357
+ { claim: claim, kind: kind, verdict: :unknown, confidence: 0.0, evidence: evidence, error: "#{e.class}: #{e.message}", action_taken: nil }
358
+ end
359
+
360
+ # Supported Method Parameters::
361
+ # obs = PWN::AI::Agent::Extrospection.watch(
362
+ # url: 'required - URL to render and fingerprint',
363
+ # selector: 'optional - CSS selector whose innerText to hash (default full body)',
364
+ # ttl: 'optional - seconds until stale (default 7 days)',
365
+ # proxy: 'optional - upstream proxy for TransparentBrowser'
366
+ # )
367
+ #
368
+ # Passive change-detection on an external artefact you care about
369
+ # (target /api/version, vendor changelog, bounty scope page).
370
+ # Renders headless, hashes the DOM text, screenshots, and persists as
371
+ # observe(category: :web). On subsequent snapshot(sections:[:web]) or
372
+ # watch of the same URL, a hash mismatch surfaces in drift() as
373
+ # ~web.<host>.dom_sha exactly like ~toolchain.nmap today.
374
+
375
+ public_class_method def self.watch(opts = {})
376
+ url = opts[:url].to_s
377
+ raise 'ERROR: url is required' if url.empty?
378
+
379
+ ttl = opts[:ttl] || (7 * 24 * 3600)
380
+ proxy = opts[:proxy] || web_config[:proxy]
381
+ fp = nil
382
+ with_headless_browser(proxy: proxy) do |bo|
383
+ fp = fingerprint_page(browser_obj: bo, url: url, selector: opts[:selector], screenshot: true)
384
+ end
385
+ raise "unreachable: #{url}" unless fp
386
+
387
+ prior = observations(category: 'web', target: url, limit: 1).first
388
+ changed = prior && prior.dig(:data, :dom_sha) && prior.dig(:data, :dom_sha) != fp[:dom_sha]
389
+ data = fp.slice(:status, :final_url, :title, :dom_sha, :cert_fp, :cert_not_after, :screenshot).merge(excerpt: fp[:text].to_s[0, 200])
390
+ observe(source: 'transparent_browser', category: :web, target: url, data: data, tags: (['watch'] + Array(opts[:tags])).compact, ttl: ttl)
391
+ { url: url, changed: changed, prior_sha: prior && prior.dig(:data, :dom_sha), current: data }
392
+ end
393
+
394
+ # Supported Method Parameters::
395
+ # report = PWN::AI::Agent::Extrospection.revalidate_memory(
396
+ # limit: 'optional - max :fact entries to check (default 25)',
397
+ # proxy: 'optional - upstream proxy for TransparentBrowser'
398
+ # )
399
+ #
400
+ # The browser as garbage-collector for PWN::Memory. Walks :fact entries
401
+ # containing a CVE id, version string or URL, runs verify() on each and
402
+ # prefixes stale ones with [UNVERIFIED yyyy-mm-dd] so the injected
403
+ # MEMORY block stops calcifying into confidently-wrong priors.
404
+ # Designed to be scheduled: cron_create(schedule:'0 4 * * 0',
405
+ # ruby:'PWN::AI::Agent::Extrospection.revalidate_memory')
406
+
407
+ public_class_method def self.revalidate_memory(opts = {})
408
+ return { checked: 0, refuted: [], confirmed: [], unknown: [] } unless defined?(PWN::Memory)
409
+
410
+ lim = opts[:limit] || 25
411
+ proxy = opts[:proxy] || web_config[:proxy]
412
+ rx = %r{(CVE-\d{4}-\d{4,}|v?\d+\.\d+\.\d+|https?://\S+)}i
413
+ out = { checked: 0, refuted: [], confirmed: [], unknown: [] }
414
+
415
+ PWN::Memory.load.each do |key, v|
416
+ break if out[:checked] >= lim
417
+ next unless v.is_a?(Hash) && v[:category].to_s == 'fact' && v[:value].to_s =~ rx
418
+ next if v[:value].to_s.start_with?('[UNVERIFIED')
419
+
420
+ res = verify(claim: v[:value].to_s[0, 400], commit: false, proxy: proxy)
421
+ out[:checked] += 1
422
+ out[res[:verdict]] << key
423
+ next unless res[:verdict] == :refuted && res[:confidence] >= 0.6
424
+
425
+ stamp = Time.now.utc.strftime('%Y-%m-%d')
426
+ PWN::Memory.remember(key: key, value: "[UNVERIFIED #{stamp}] #{v[:value]}", category: :fact)
427
+ observe(source: 'extro_verify', category: :web, target: key.to_s, data: "Memory :fact '#{key}' failed re-validation (#{res[:confidence]})", tags: %w[stale memory])
428
+ end
429
+ out
430
+ end
431
+
241
432
  # Supported Method Parameters::
242
433
  # findings = PWN::AI::Agent::Extrospection.correlate(
243
434
  # limit: 'optional - max findings returned (default 10)'
@@ -304,7 +495,40 @@ module PWN
304
495
  end
305
496
  end
306
497
 
307
- # 5) raw drift as low-priority findings when nothing else matched
498
+ # 5) :web drift on a target the same day a Learning failure references it —
499
+ # "your exploit stopped working because the TARGET changed, not your approach"
500
+ if defined?(Learning)
501
+ observations(category: 'web', limit: 50).each do |ob|
502
+ tgt = ob[:target].to_s
503
+ host = safe_host(url: tgt)
504
+ Learning.outcomes(limit: 30, success: false).each do |o|
505
+ next unless o[:task].to_s.include?(host) || o[:details].to_s.include?(host)
506
+
507
+ findings << { kind: :target_web_drift, target: tgt, dom_sha: ob.dig(:data, :dom_sha), task: o[:task], advice: "Target #{host} DOM changed (#{ob[:timestamp]}) around this failure — re-recon before assuming your technique is wrong." }
508
+ end
509
+ end
510
+ end
511
+
512
+ # 6) extro_verify refutations whose claim substring appears in a PWN::Memory :fact -> stale memory
513
+ if defined?(PWN::Memory)
514
+ observations(category: 'web', tag: 'stale', limit: 50).each do |ob|
515
+ findings << { kind: :stale_memory_fact, key: ob[:target], evidence: ob[:data], advice: "PWN::Memory[:#{ob[:target]}] failed browser re-verification — audit or memory_forget it before it poisons future prompts." }
516
+ end
517
+ end
518
+
519
+ # 7) :intel observations whose source anchor is currently probe_web-unreachable -> downgrade
520
+ web = snap[:web] || {}
521
+ web.each do |host, fp|
522
+ next unless fp.is_a?(Hash) && (fp[:reachable] == false || fp[:status].to_i >= 500)
523
+
524
+ observations(category: 'intel', limit: 100).each do |ob|
525
+ next unless ob[:source].to_s.include?(host.to_s) || ob[:data].to_s.include?(host.to_s)
526
+
527
+ findings << { kind: :intel_source_unreachable, source: host, status: fp[:status], intel: ob[:data].to_s[0, 120], advice: "Feed anchor #{host} is currently unreachable (#{fp[:status] || 'down'}) — treat this :intel as stale until probe_web sees it 2xx again." }
528
+ end
529
+ end
530
+
531
+ # 8) raw drift as low-priority findings when nothing else matched
308
532
  Array(delta[:added]).first(5).each { |c| findings << { kind: :env_added, detail: c } } if findings.empty?
309
533
  Array(delta[:removed]).first(5).each { |c| findings << { kind: :env_removed, detail: c } } if findings.empty?
310
534
 
@@ -363,7 +587,8 @@ module PWN
363
587
  drift_removed: Array(delta[:removed]).length,
364
588
  toolchain_bins: (snap[:toolchain] || {}).count { |_, v| !v.to_s.empty? },
365
589
  listening_ports: Array(snap.dig(:net, :listening)).length,
366
- rf_devices: (snap[:rf] || {}).values_at(:rtl_sdr, :hackrf, :flipper, :gqrx_sock).count { |v| rf_present?(val: v) }
590
+ 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] }
367
592
  }
368
593
  end
369
594
 
@@ -536,6 +761,202 @@ module PWN
536
761
  []
537
762
  end
538
763
 
764
+ # Rendered-DOM fingerprint of a small, config-declared set of canonical
765
+ # web anchors the agent depends on for truth (feed reachability,
766
+ # upstream drift, in-scope target tech fingerprint). OPT-IN — never
767
+ # part of the default auto_extrospect section set because a headless
768
+ # browser is ~1-3s cold vs ~50ms for probe_host.
769
+ private_class_method def self.probe_web
770
+ anchors = web_anchors
771
+ return { anchors: 0 } if anchors.empty?
772
+
773
+ out = {}
774
+ with_headless_browser(proxy: web_config[:proxy]) do |bo|
775
+ anchors.first(web_config[:max_anchors]).each do |u|
776
+ host = safe_host(url: u).tr('.', '_')
777
+ fp = fingerprint_page(browser_obj: bo, url: u, screenshot: web_config[:screenshot])
778
+ out[host.to_sym] = fp ? fp.slice(:status, :final_url, :title, :dom_sha, :server, :generator, :cert_fp, :cert_not_after, :reachable, :screenshot) : { reachable: false }
779
+ end
780
+ end
781
+ out
782
+ rescue StandardError => e
783
+ { error: "#{e.class}: #{e.message}" }
784
+ end
785
+
786
+ private_class_method def self.web_config
787
+ cfg = (PWN::Env.dig(:ai, :agent, :extrospection, :web) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)) || {}
788
+ {
789
+ anchors: Array(cfg[:anchors] || cfg[:web_anchors]),
790
+ proxy: cfg[:proxy],
791
+ max_anchors: (cfg[:max_anchors] || 8).to_i,
792
+ per_page_timeout: (cfg[:per_page_timeout] || 15).to_i,
793
+ screenshot: cfg.key?(:screenshot) ? !cfg[:screenshot].nil? && cfg[:screenshot] != false : false,
794
+ allow_targets: cfg[:allow_targets] ? true : false
795
+ }
796
+ rescue StandardError
797
+ { anchors: [], proxy: nil, max_anchors: 8, per_page_timeout: 15, screenshot: false, allow_targets: false }
798
+ end
799
+
800
+ private_class_method def self.web_anchors
801
+ a = web_config[:anchors]
802
+ a = DEFAULT_WEB_ANCHORS.dup if a.empty?
803
+ a += Array(PWN::Env[:targets]) if defined?(PWN::Env) && PWN::Env.is_a?(Hash) && web_config[:allow_targets]
804
+ a.compact.map(&:to_s).reject(&:empty?).uniq
805
+ rescue StandardError
806
+ DEFAULT_WEB_ANCHORS.dup
807
+ end
808
+
809
+ private_class_method def self.safe_host(opts = {})
810
+ URI.parse(opts[:url].to_s).host || opts[:url].to_s
811
+ rescue StandardError
812
+ opts[:url].to_s
813
+ end
814
+
815
+ # One shared headless browser per call — open once, reuse across
816
+ # anchors, always close in ensure. Prefers :headless (firefox) which
817
+ # is lightest; degrades to :rest (RestClient) if watir/selenium is
818
+ # unavailable so probe_web/verify/watch never hard-fail the loop.
819
+ private_class_method def self.with_headless_browser(opts = {})
820
+ require 'pwn/plugins/transparent_browser'
821
+ bo = nil
822
+ begin
823
+ bo = PWN::Plugins::TransparentBrowser.open(browser_type: :headless, proxy: opts[:proxy])
824
+ rescue StandardError
825
+ bo = PWN::Plugins::TransparentBrowser.open(browser_type: :rest, proxy: opts[:proxy])
826
+ end
827
+ yield bo
828
+ ensure
829
+ begin
830
+ PWN::Plugins::TransparentBrowser.close(browser_obj: bo) if bo
831
+ rescue StandardError
832
+ nil
833
+ end
834
+ end
835
+
836
+ private_class_method def self.fingerprint_page(opts = {})
837
+ bo = opts[:browser_obj]
838
+ url = opts[:url].to_s
839
+ return nil if bo.nil? || url.empty?
840
+
841
+ tmo = web_config[:per_page_timeout]
842
+ b = bo[:browser]
843
+ if bo[:type] == :rest
844
+ resp = b.get(url) { |r, _rq, _res| r }
845
+ body = resp.body.to_s
846
+ require 'nokogiri'
847
+ doc = Nokogiri::HTML.parse(body)
848
+ text = doc.text.to_s
849
+ cfp, cna = tls_fingerprint(url: url)
850
+ return { url: url, final_url: url, status: resp.code.to_i, title: doc.title.to_s,
851
+ server: resp.headers[:server].to_s, generator: doc.at('meta[name="generator"]')&.[]('content').to_s,
852
+ dom_sha: Digest::SHA256.hexdigest(text)[0, 16], text: text, cert_fp: cfp,
853
+ cert_not_after: cna, reachable: resp.code.to_i < 400 }
854
+ end
855
+ Timeout.timeout(tmo) { b.goto(url) }
856
+ text = extract_text(browser: b, selector: opts[:selector])
857
+ title = safe_attr(browser: b, meth: :title)
858
+ final = safe_attr(browser: b, meth: :url) || url
859
+ gen = safe_meta(browser: b, name: 'generator')
860
+ shot = opts[:screenshot] ? capture_screenshot(browser: b, url: url) : nil
861
+ cfp, cna = tls_fingerprint(url: final)
862
+ { url: url, final_url: final, status: 200, title: title.to_s, generator: gen.to_s, server: nil,
863
+ dom_sha: Digest::SHA256.hexdigest(text.to_s)[0, 16], text: text.to_s,
864
+ cert_fp: cfp, cert_not_after: cna, screenshot: shot, reachable: true }
865
+ rescue StandardError => e
866
+ { url: url, reachable: false, status: 0, error: "#{e.class}: #{e.message.to_s[0, 120]}", text: '' }
867
+ end
868
+
869
+ private_class_method def self.extract_text(opts = {})
870
+ b = opts[:browser]
871
+ sel = opts[:selector]
872
+ return b.element(css: sel).text if sel && b.element(css: sel).exists?
873
+
874
+ b.text
875
+ rescue StandardError
876
+ b.respond_to?(:text) ? b.text.to_s : ''
877
+ end
878
+
879
+ private_class_method def self.safe_attr(opts = {})
880
+ opts[:browser].public_send(opts[:meth])
881
+ rescue StandardError
882
+ nil
883
+ end
884
+
885
+ private_class_method def self.safe_meta(opts = {})
886
+ el = opts[:browser].element(css: "meta[name=\"#{opts[:name]}\"]")
887
+ el.exists? ? el.attribute_value('content') : ''
888
+ rescue StandardError
889
+ ''
890
+ end
891
+
892
+ private_class_method def self.capture_screenshot(opts = {})
893
+ FileUtils.mkdir_p(WEB_SHOT_DIR)
894
+ host = safe_host(url: opts[:url]).gsub(/[^\w.-]/, '_')
895
+ path = File.join(WEB_SHOT_DIR, "#{host}.png")
896
+ opts[:browser].screenshot.save(path)
897
+ path
898
+ rescue StandardError
899
+ nil
900
+ end
901
+
902
+ private_class_method def self.tls_fingerprint(opts = {})
903
+ u = URI.parse(opts[:url].to_s)
904
+ return [nil, nil] unless u.scheme == 'https'
905
+
906
+ require 'openssl'
907
+ ssl = tcp = nil
908
+ tcp = TCPSocket.new(u.host, u.port || 443)
909
+ ctx = OpenSSL::SSL::SSLContext.new
910
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
911
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
912
+ ssl.hostname = u.host
913
+ ssl.connect
914
+ cert = ssl.peer_cert
915
+ [Digest::SHA256.hexdigest(cert.to_der)[0, 16], cert.not_after.utc.iso8601]
916
+ rescue StandardError
917
+ [nil, nil]
918
+ ensure
919
+ ssl&.close
920
+ tcp&.close
921
+ end
922
+
923
+ private_class_method def self.fuzzy_overlap(opts = {})
924
+ needle = opts[:needle].to_s.downcase.scan(/[a-z0-9]{3,}/).uniq
925
+ hay = opts[:haystack].to_s.downcase
926
+ return 0.0 if needle.empty?
927
+
928
+ hit = needle.count { |w| hay.include?(w) }
929
+ hit.to_f / needle.length
930
+ end
931
+
932
+ private_class_method def self.detect_claim_kind(opts = {})
933
+ c = opts[:claim].to_s
934
+ return :doc if opts[:url] || c =~ URI::DEFAULT_PARSER.make_regexp(%w[http https])
935
+ return :cve if c =~ /CVE-\d{4}-\d{4,}/i
936
+ return :version if c =~ /\bv?\d+\.\d+(?:\.\d+)?\b/
937
+
938
+ :generic
939
+ end
940
+
941
+ private_class_method def self.commit_verdict(opts = {})
942
+ claim = opts[:claim]
943
+ ev = Array(opts[:evidence]).first
944
+ case opts[:verdict]
945
+ when :refuted
946
+ Mistakes.record(tool: 'assumption', error: "REFUTED (extro_verify #{opts[:kind]}, conf=#{opts[:confidence].round(2)}): #{claim}", args: ev&.dig(:final_url), source: :model) if defined?(Mistakes)
947
+ observe(source: 'extro_verify', category: :web, target: ev&.dig(:final_url), data: "REFUTED: #{claim}", tags: %w[verify refuted], ttl: 30 * 24 * 3600)
948
+ :mistakes_record
949
+ when :confirmed
950
+ observe(source: 'extro_verify', category: :intel, target: ev&.dig(:final_url), data: claim, tags: %w[verify confirmed], ttl: 30 * 24 * 3600)
951
+ :extro_observe
952
+ else
953
+ Learning.note_outcome(task: "extro_verify: #{claim[0, 120]}", success: false, details: 'verdict :unknown — needs human review', tags: %w[needs_human extro_verify]) if defined?(Learning) && Learning.respond_to?(:note_outcome)
954
+ :learning_note
955
+ end
956
+ rescue StandardError
957
+ nil
958
+ end
959
+
539
960
  private_class_method def self.flatten(opts = {})
540
961
  hash = opts[:hash] || {}
541
962
  prefix = opts[:prefix].to_s
@@ -663,11 +1084,19 @@ module PWN
663
1084
  PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
664
1085
  PWN::AI::Agent::Extrospection.stats
665
1086
  PWN::AI::Agent::Extrospection.auto_extrospect(session_id: sid) # called from Learning.auto_reflect
1087
+ PWN::AI::Agent::Extrospection.snapshot(sections: %i[web]) # opt-in browser probe of web_anchors
1088
+ PWN::AI::Agent::Extrospection.watch(url: 'https://target/api/version')
1089
+ PWN::AI::Agent::Extrospection.verify(claim: 'CVE-2026-12345 affects OpenSSL 3.2.1')
1090
+ PWN::AI::Agent::Extrospection.revalidate_memory # cron: GC stale PWN::Memory :fact entries
666
1091
  PWN::AI::Agent::Extrospection.reset
667
1092
 
668
1093
  Enable end-of-run auto-extrospection with:
669
1094
  PWN::Env[:ai][:agent][:auto_extrospect] = true
670
1095
 
1096
+ Configure browser-backed :web probe / verify / watch with:
1097
+ PWN::Env[:ai][:agent][:extrospection][:web] =
1098
+ { anchors: [...], proxy: 'tor', max_anchors: 8, per_page_timeout: 15, screenshot: false, allow_targets: false }
1099
+
671
1100
  #{self}.authors
672
1101
  USAGE
673
1102
  end
@@ -29,7 +29,7 @@ PWN::AI::Agent::Registry.register(
29
29
  type: 'object',
30
30
  properties: {
31
31
  persist: { type: 'boolean', default: true, description: 'Write to disk & rotate previous baseline.' },
32
- sections: { type: 'array', items: { type: 'string', enum: %w[host net toolchain repo env rf] }, description: 'Subset of probes to run (default all).' }
32
+ sections: { type: 'array', items: { type: 'string', enum: %w[host net toolchain repo env rf web] }, description: 'Subset of probes to run (default all).' }
33
33
  },
34
34
  required: []
35
35
  }
@@ -86,7 +86,7 @@ PWN::AI::Agent::Registry.register(
86
86
  properties: {
87
87
  source: { type: 'string', description: 'Where it came from (nmap, shodan, burp, cve, human, ...).' },
88
88
  data: { type: 'string', description: 'The observation itself.' },
89
- category: { type: 'string', enum: %w[recon vuln intel target network env rf misc], default: 'misc' },
89
+ category: { type: 'string', enum: %w[recon vuln intel target network env rf web misc], default: 'misc' },
90
90
  target: { type: 'string', description: 'Host / IP / URL / asset the observation is about.' },
91
91
  tags: { type: 'array', items: { type: 'string' } },
92
92
  ttl: { type: 'integer', description: 'Seconds until stale (omit = forever).' }
@@ -187,7 +187,10 @@ PWN::AI::Agent::Registry.register(
187
187
  'against toolchain drift / missing binaries, (b) Learning ' \
188
188
  'failures against host/net/repo drift on the same day, ' \
189
189
  '(c) recorded :intel observations against installed ' \
190
- 'component versions. Returns actionable findings so the ' \
190
+ 'component versions, (d) :web DOM drift on watched targets ' \
191
+ 'against Learning failures citing that host, (e) refuted ' \
192
+ 'extro_verify claims against stale PWN::Memory :fact ' \
193
+ 'entries. Returns actionable findings so the ' \
191
194
  'agent can distinguish "I did it wrong" from "the world ' \
192
195
  'changed under me".',
193
196
  parameters: {
@@ -278,3 +281,85 @@ PWN::AI::Agent::Registry.register(
278
281
  { previous: prev, current: ai[:agent][:auto_extrospect] ? true : false }
279
282
  }
280
283
  )
284
+
285
+ PWN::AI::Agent::Registry.register(
286
+ name: 'extro_verify',
287
+ toolset: 'extrospection',
288
+ schema: {
289
+ name: 'extro_verify',
290
+ description: 'Browser-backed SELF FACT-CHECK. Drives ' \
291
+ 'PWN::Plugins::TransparentBrowser (:headless) against a ' \
292
+ 'canonical source for the claim class (NVD/CVE.org for ' \
293
+ ':cve, rubygems/PyPI/GitHub for :version, the cited URL ' \
294
+ 'for :doc, DuckDuckGo HTML for :generic), renders the DOM ' \
295
+ 'with JS executed, and returns {claim:, kind:, verdict: ' \
296
+ ':confirmed|:refuted|:unknown, confidence:, evidence:[], ' \
297
+ 'action_taken:}. On :refuted → Mistakes.record(tool:' \
298
+ "'assumption') so KNOWN MISTAKES warns every future run; " \
299
+ 'on :confirmed → observe(:intel, ttl:30d); on :unknown → ' \
300
+ 'Learning.note_outcome(tags:[needs_human]). This is the ' \
301
+ 'PROACTIVE trigger that catches the model being wrong ' \
302
+ 'about the world before a human does — the extrospective ' \
303
+ 'mirror of mistakes_record.',
304
+ parameters: {
305
+ type: 'object',
306
+ properties: {
307
+ claim: { type: 'string', description: 'Factual claim to fact-check (a CVE assertion, "latest X is v1.2.3", a cited URL + quoted snippet, or free text).' },
308
+ kind: { type: 'string', enum: %w[cve version doc generic], description: 'Force a verifier. Omit to auto-detect from the claim.' },
309
+ url: { type: 'string', description: 'Explicit URL the claim cites (forces kind: :doc).' },
310
+ commit: { type: 'boolean', default: true, description: 'Write Mistakes/observe/Learning on the verdict.' },
311
+ proxy: { type: 'string', description: 'Upstream proxy for TransparentBrowser (e.g. "tor" or http://127.0.0.1:8080).' }
312
+ },
313
+ required: %w[claim]
314
+ }
315
+ },
316
+ check: -> { defined?(PWN::AI::Agent::Extrospection) && PWN::AI::Agent::Extrospection.respond_to?(:verify) },
317
+ handler: lambda { |args|
318
+ o = { claim: args[:claim] }
319
+ o[:kind] = args[:kind] if args[:kind]
320
+ o[:url] = args[:url] if args[:url]
321
+ o[:commit] = args[:commit] if args.key?(:commit)
322
+ o[:proxy] = args[:proxy] if args[:proxy]
323
+ PWN::AI::Agent::Extrospection.verify(o)
324
+ }
325
+ )
326
+
327
+ PWN::AI::Agent::Registry.register(
328
+ name: 'extro_watch',
329
+ toolset: 'extrospection',
330
+ schema: {
331
+ name: 'extro_watch',
332
+ description: 'Passive change-detection on an external web artefact you ' \
333
+ 'care about (a target /api/version, a vendor changelog, a ' \
334
+ 'bug-bounty scope page). Renders the URL headlessly via ' \
335
+ 'PWN::Plugins::TransparentBrowser, hashes the RENDERED DOM ' \
336
+ 'text (JS-delivered changes count), captures title / TLS ' \
337
+ 'cert fp / screenshot, and persists it as observe(' \
338
+ 'category: :web). Re-running against the same URL returns ' \
339
+ '{changed: true|false, prior_sha:, current:{…}}; a ' \
340
+ 'subsequent extro_snapshot(sections:["web"]) surfaces the ' \
341
+ 'delta in drift() as ~web.<host>.dom_sha exactly like ' \
342
+ '~toolchain.nmap today.',
343
+ parameters: {
344
+ type: 'object',
345
+ properties: {
346
+ url: { type: 'string', description: 'URL to render, hash and watch.' },
347
+ selector: { type: 'string', description: 'CSS selector whose innerText to hash (default full body).' },
348
+ ttl: { type: 'integer', description: 'Seconds until this :web observation is stale (default 604800 = 7d).' },
349
+ tags: { type: 'array', items: { type: 'string' } },
350
+ proxy: { type: 'string', description: 'Upstream proxy for TransparentBrowser (e.g. "tor").' }
351
+ },
352
+ required: %w[url]
353
+ }
354
+ },
355
+ check: -> { defined?(PWN::AI::Agent::Extrospection) && PWN::AI::Agent::Extrospection.respond_to?(:watch) },
356
+ handler: lambda { |args|
357
+ PWN::AI::Agent::Extrospection.watch(
358
+ url: args[:url],
359
+ selector: args[:selector],
360
+ ttl: args[:ttl],
361
+ tags: args[:tags],
362
+ proxy: args[:proxy]
363
+ )
364
+ }
365
+ )