pwn 0.5.620 → 0.5.622

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/README.md +1 -1
  4. data/documentation/Agent-Tool-Registry.md +3 -3
  5. data/documentation/Configuration.md +292 -47
  6. data/documentation/Cron.md +14 -1
  7. data/documentation/Diagrams.md +1 -1
  8. data/documentation/Extrospection.md +71 -5
  9. data/documentation/Home.md +2 -2
  10. data/documentation/How-PWN-Works.md +1 -1
  11. data/documentation/Mistakes.md +2 -1
  12. data/documentation/Persistence.md +2 -1
  13. data/documentation/Skills-Memory-Learning.md +3 -2
  14. data/documentation/Transparent-Browser.md +18 -1
  15. data/documentation/diagrams/agent-tool-registry.svg +55 -54
  16. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  17. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  18. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  19. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  20. data/documentation/diagrams/cron-scheduling.svg +23 -23
  21. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  22. data/documentation/diagrams/dot/extrospection-world-awareness.dot +29 -8
  23. data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -0
  24. data/documentation/diagrams/dot/overall-pwn-architecture.dot +2 -2
  25. data/documentation/diagrams/dot/persistence-filesystem.dot +2 -1
  26. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +11 -5
  27. data/documentation/diagrams/driver-framework.svg +13 -13
  28. data/documentation/diagrams/extrospection-world-awareness.svg +229 -99
  29. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  30. data/documentation/diagrams/hardware-hacking.svg +18 -18
  31. data/documentation/diagrams/history-to-drivers.svg +18 -18
  32. data/documentation/diagrams/memory-skills-detailed.svg +124 -101
  33. data/documentation/diagrams/mistakes-negative-feedback.svg +55 -55
  34. data/documentation/diagrams/network-infra-testing.svg +27 -27
  35. data/documentation/diagrams/overall-pwn-architecture.svg +85 -85
  36. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  37. data/documentation/diagrams/persistence-filesystem.svg +94 -85
  38. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  39. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +226 -180
  40. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  41. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  42. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  43. data/documentation/diagrams/sdr-radio-flow.svg +31 -31
  44. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  45. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  46. data/documentation/diagrams/web-application-testing.svg +26 -26
  47. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  48. data/documentation/pwn-ai-Agent.md +4 -4
  49. data/lib/pwn/ai/agent/assembly.rb +1 -1
  50. data/lib/pwn/ai/agent/btc.rb +1 -1
  51. data/lib/pwn/ai/agent/burp_suite.rb +1 -1
  52. data/lib/pwn/ai/agent/extrospection.rb +435 -6
  53. data/lib/pwn/ai/agent/gqrx.rb +2 -2
  54. data/lib/pwn/ai/agent/hacker_one.rb +1 -1
  55. data/lib/pwn/ai/agent/learning.rb +16 -16
  56. data/lib/pwn/ai/agent/loop.rb +1 -1
  57. data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
  58. data/lib/pwn/ai/agent/sast.rb +1 -1
  59. data/lib/pwn/ai/agent/tools/extrospection.rb +89 -4
  60. data/lib/pwn/ai/agent/tools/learning.rb +7 -7
  61. data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
  62. data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
  63. data/lib/pwn/ai/agent.rb +1 -1
  64. data/lib/pwn/ai/open_ai.rb +10 -6
  65. data/lib/pwn/config.rb +5 -4
  66. data/lib/pwn/plugins/burp_suite.rb +2 -2
  67. data/lib/pwn/sast/pom_version.rb +2 -2
  68. data/lib/pwn/sast/test_case_engine.rb +1 -1
  69. data/lib/pwn/sdr/decoder/adsb.rb +14 -33
  70. data/lib/pwn/sdr/decoder/apt.rb +97 -36
  71. data/lib/pwn/sdr/decoder/base.rb +258 -171
  72. data/lib/pwn/sdr/decoder/bluetooth.rb +13 -20
  73. data/lib/pwn/sdr/decoder/dect.rb +13 -27
  74. data/lib/pwn/sdr/decoder/dsp.rb +396 -0
  75. data/lib/pwn/sdr/decoder/flex.rb +177 -210
  76. data/lib/pwn/sdr/decoder/gps.rb +12 -21
  77. data/lib/pwn/sdr/decoder/gsm.rb +21 -73
  78. data/lib/pwn/sdr/decoder/iridium.rb +11 -26
  79. data/lib/pwn/sdr/decoder/lora.rb +16 -23
  80. data/lib/pwn/sdr/decoder/lte.rb +11 -32
  81. data/lib/pwn/sdr/decoder/morse.rb +95 -21
  82. data/lib/pwn/sdr/decoder/p25.rb +19 -16
  83. data/lib/pwn/sdr/decoder/pager.rb +22 -38
  84. data/lib/pwn/sdr/decoder/pocsag.rb +175 -68
  85. data/lib/pwn/sdr/decoder/rfid.rb +24 -33
  86. data/lib/pwn/sdr/decoder/rtl433.rb +21 -30
  87. data/lib/pwn/sdr/decoder/rtty.rb +100 -27
  88. data/lib/pwn/sdr/decoder/wifi.rb +21 -42
  89. data/lib/pwn/sdr/decoder/zigbee.rb +18 -40
  90. data/lib/pwn/sdr/decoder.rb +8 -0
  91. data/lib/pwn/sdr/gqrx.rb +1 -1
  92. data/lib/pwn/version.rb +1 -1
  93. data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
  94. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
  95. data/third_party/pwn_rdoc.jsonl +75 -19
  96. metadata +7 -5
@@ -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
 
@@ -372,7 +597,7 @@ module PWN
372
597
  # session_id: 'optional - id of the just-completed session (for tagging)'
373
598
  # )
374
599
  #
375
- # Called by Learning.auto_reflect when
600
+ # Called by Learning.auto_introspect when
376
601
  # PWN::Env[:ai][:agent][:auto_extrospect] is truthy. Captures a fresh
377
602
  # snapshot and, if drift is non-trivial, records it as an observation
378
603
  # and a PWN::Memory :env fact so the NEXT session's system prompt
@@ -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
@@ -662,12 +1083,20 @@ module PWN
662
1083
  PWN::AI::Agent::Extrospection.correlate # introspection x extrospection findings
663
1084
  PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
664
1085
  PWN::AI::Agent::Extrospection.stats
665
- PWN::AI::Agent::Extrospection.auto_extrospect(session_id: sid) # called from Learning.auto_reflect
1086
+ PWN::AI::Agent::Extrospection.auto_extrospect(session_id: sid) # called from Learning.auto_introspect
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
@@ -3,7 +3,7 @@
3
3
  module PWN
4
4
  module AI
5
5
  module Agent
6
- # This module is an AI agent designed to analyze signal data captured by a software-defined-radio using GQRX. It uses the PWN::AI::Agent::Introspection.reflect_on method to analyze the signal data and provide insights based on the location where the data was captured. The agent can determine if the frequency is licensed or unlicensed based on FCC records and provide relevant information about the transmission. This module is useful for security professionals, researchers, and hobbyists interested in analyzing radio signals and understanding their context.
6
+ # This module is an AI agent designed to analyze signal data captured by a software-defined-radio using GQRX. It uses the PWN::AI::Agent::Reflect.on method to analyze the signal data and provide insights based on the location where the data was captured. The agent can determine if the frequency is licensed or unlicensed based on FCC records and provide relevant information about the transmission. This module is useful for security professionals, researchers, and hobbyists interested in analyzing radio signals and understanding their context.
7
7
  module GQRX
8
8
  # Supported Method Parameters::
9
9
  # ai_analysis = PWN::AI::Agent::GQRX.analyze(
@@ -20,7 +20,7 @@ module PWN
20
20
 
21
21
  system_role_content = "Analyze signal data captured by a software-defined-radio using GQRX at the following location: #{location}. Respond with just FCC information about the transmission if available. If the frequency is unlicensed or not found in FCC records, state that clearly. Be clear and concise in your analysis."
22
22
 
23
- PWN::AI::Agent::Introspection.reflect_on(
23
+ PWN::AI::Agent::Reflect.on(
24
24
  system_role_content: system_role_content,
25
25
  request: request,
26
26
  suppress_pii_warning: true
@@ -29,7 +29,7 @@ module PWN
29
29
  raise "ERROR: type parameter value of #{type} is not supported"
30
30
  end
31
31
 
32
- PWN::AI::Agent::Introspection.reflect_on(
32
+ PWN::AI::Agent::Reflect.on(
33
33
  system_role_content: system_role_content,
34
34
  request: request,
35
35
  spinner: true,
@@ -16,7 +16,7 @@ module PWN
16
16
  #
17
17
  # Data flows:
18
18
  # Loop.run --(tool telemetry)--> Metrics.record
19
- # Loop.run --(final answer)----> Learning.auto_reflect (opt-in)
19
+ # Loop.run --(final answer)----> Learning.auto_introspect (opt-in)
20
20
  # model --(tool calls)------> learning_note_outcome / _distill_skill
21
21
  # PromptBuilder <----------------- Learning.to_context + Metrics.to_context
22
22
  #
@@ -164,9 +164,9 @@ module PWN
164
164
  # dry_run: 'optional - when true, do not write to Memory/Skills (default false)'
165
165
  # )
166
166
  #
167
- # Uses PWN::AI::Agent::Introspection (when available) to LLM-summarise the
167
+ # Uses PWN::AI::Agent::Reflect (when available) to LLM-summarise the
168
168
  # session into structured lessons. Falls back to a heuristic
169
- # extractor when introspection is disabled so learning never stops.
169
+ # extractor when module_reflection is disabled so learning never stops.
170
170
 
171
171
  public_class_method def self.reflect(opts = {})
172
172
  session_id = opts[:session_id]
@@ -193,19 +193,19 @@ module PWN
193
193
  end
194
194
 
195
195
  # Supported Method Parameters::
196
- # PWN::AI::Agent::Learning.auto_reflect(
196
+ # PWN::AI::Agent::Learning.auto_introspect(
197
197
  # session_id: 'required - id of the just-completed session',
198
198
  # request: 'optional - original user request (for outcome logging)',
199
199
  # final: 'optional - final assistant answer (for outcome logging)'
200
200
  # )
201
201
  #
202
- # Called by Loop.run when PWN::Env[:ai][:agent][:auto_reflect] is
202
+ # Called by Loop.run when PWN::Env[:ai][:agent][:auto_introspect] is
203
203
  # truthy. Never raises — learning must not break the primary loop.
204
204
 
205
- public_class_method def self.auto_reflect(opts = {})
205
+ public_class_method def self.auto_introspect(opts = {})
206
206
  session_id = opts[:session_id]
207
207
  return unless session_id
208
- return unless auto_reflect_enabled?
208
+ return unless auto_introspect_enabled?
209
209
 
210
210
  ok = infer_success(session_id: session_id, final: opts[:final])
211
211
  note_outcome(
@@ -218,7 +218,7 @@ module PWN
218
218
  reflect(session_id: session_id)
219
219
  Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection)
220
220
  rescue StandardError => e
221
- warn "[pwn-ai/learning] auto_reflect swallowed: #{e.class}: #{e.message}"
221
+ warn "[pwn-ai/learning] auto_introspect swallowed: #{e.class}: #{e.message}"
222
222
  nil
223
223
  end
224
224
 
@@ -301,10 +301,10 @@ module PWN
301
301
  # privates
302
302
  # -------------------------------------------------------------
303
303
 
304
- private_class_method def self.auto_reflect_enabled?
304
+ private_class_method def self.auto_introspect_enabled?
305
305
  return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
306
306
 
307
- PWN::Env.dig(:ai, :agent, :auto_reflect) ? true : false
307
+ PWN::Env.dig(:ai, :agent, :auto_introspect) ? true : false
308
308
  rescue StandardError
309
309
  false
310
310
  end
@@ -314,7 +314,7 @@ module PWN
314
314
  # Derive a success signal stronger than "final answer non-empty":
315
315
  # look at the tool-failure ratio inside the just-completed turn AND
316
316
  # scan the final text for self-reported failure language. Without
317
- # this, auto_reflect logs ~100 % success and the negative-feedback
317
+ # this, auto_introspect logs ~100 % success and the negative-feedback
318
318
  # side of the learning loop never fires.
319
319
  private_class_method def self.infer_success(opts = {})
320
320
  final = opts[:final].to_s
@@ -353,11 +353,11 @@ module PWN
353
353
 
354
354
  private_class_method def self.introspective_lessons(opts = {})
355
355
  transcript = opts[:transcript] || []
356
- return [] unless defined?(PWN::AI::Agent::Introspection)
357
- return [] unless defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env.dig(:ai, :introspection)
356
+ return [] unless defined?(PWN::AI::Agent::Reflect)
357
+ return [] unless defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env.dig(:ai, :module_reflection)
358
358
 
359
359
  req = "Analyse this pwn-ai session transcript and emit up to 5 durable, generalizable lessons (one per line, no numbering, imperative voice) that would make future runs faster or more reliable. Focus on tool selection, error recovery, and target-agnostic technique. Ignore trivia.\n\nTRANSCRIPT:\n#{transcript_text(transcript: transcript)}"
360
- resp = PWN::AI::Agent::Introspection.reflect_on(request: req, suppress_pii_warning: true)
360
+ resp = PWN::AI::Agent::Reflect.on(request: req, suppress_pii_warning: true)
361
361
  resp.to_s.lines.map(&:strip).reject(&:empty?).first(5)
362
362
  rescue StandardError
363
363
  []
@@ -420,7 +420,7 @@ module PWN
420
420
  PWN::AI::Agent::Learning.note_outcome(task: 'nmap sweep 10.0.0.0/24', success: true, details: '12 hosts up')
421
421
  PWN::AI::Agent::Learning.outcomes(limit: 20, success: false)
422
422
  PWN::AI::Agent::Learning.reflect(session_id: sid) # LLM or heuristic → PWN::Memory
423
- PWN::AI::Agent::Learning.auto_reflect(session_id: sid, request: req, final: text)
423
+ PWN::AI::Agent::Learning.auto_introspect(session_id: sid, request: req, final: text)
424
424
  PWN::AI::Agent::Learning.distill_skill(name: 'quick_recon', session_id: sid)
425
425
  PWN::AI::Agent::Learning.consolidate(max_entries: 200) # dedupe + prune Memory
426
426
  PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder
@@ -428,7 +428,7 @@ module PWN
428
428
  PWN::AI::Agent::Learning.reset
429
429
 
430
430
  Enable end-of-run auto-learning with:
431
- PWN::Env[:ai][:agent][:auto_reflect] = true
431
+ PWN::Env[:ai][:agent][:auto_introspect] = true
432
432
 
433
433
  #{self}.authors
434
434
  USAGE
@@ -241,7 +241,7 @@ module PWN
241
241
  if calls.empty?
242
242
  text = msg[:content].to_s
243
243
  append_session(session_id: session_id, role: 'assistant', content: text)
244
- Learning.auto_reflect(session_id: session_id, request: request, final: text) if defined?(Learning)
244
+ Learning.auto_introspect(session_id: session_id, request: request, final: text) if defined?(Learning)
245
245
  return text
246
246
  end
247
247