pwn 0.5.618 → 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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/README.md +14 -6
- data/bin/pwn_gqrx_scanner +25 -5
- data/documentation/Agent-Tool-Registry.md +4 -4
- data/documentation/Cron.md +13 -0
- data/documentation/Diagrams.md +6 -2
- data/documentation/Extrospection.md +96 -11
- data/documentation/Home.md +5 -4
- data/documentation/How-PWN-Works.md +7 -4
- data/documentation/Mistakes.md +97 -0
- data/documentation/Persistence.md +4 -1
- data/documentation/SDR.md +23 -5
- data/documentation/Skills-Memory-Learning.md +16 -5
- data/documentation/Transparent-Browser.md +18 -1
- data/documentation/diagrams/agent-tool-registry.svg +103 -100
- 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 +3 -3
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -10
- data/documentation/diagrams/dot/memory-skills-detailed.dot +16 -5
- data/documentation/diagrams/dot/mistakes-negative-feedback.dot +93 -0
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +7 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +4 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +25 -7
- data/documentation/diagrams/dot/sdr-radio-flow.dot +13 -11
- data/documentation/diagrams/driver-framework.svg +13 -13
- data/documentation/diagrams/extrospection-world-awareness.svg +241 -93
- data/documentation/diagrams/fuzzing-workflow.svg +24 -24
- data/documentation/diagrams/hardware-hacking.svg +18 -18
- data/documentation/diagrams/history-to-drivers.svg +18 -18
- data/documentation/diagrams/memory-skills-detailed.svg +169 -97
- data/documentation/diagrams/mistakes-negative-feedback.svg +266 -0
- data/documentation/diagrams/network-infra-testing.svg +27 -27
- data/documentation/diagrams/overall-pwn-architecture.svg +184 -173
- data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
- data/documentation/diagrams/persistence-filesystem.svg +111 -87
- data/documentation/diagrams/plugin-ecosystem.svg +35 -35
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +263 -149
- 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 +76 -56
- 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 +28 -15
- data/lib/pwn/ai/agent/extrospection.rb +494 -6
- data/lib/pwn/ai/agent/learning.rb +67 -7
- data/lib/pwn/ai/agent/loop.rb +72 -6
- data/lib/pwn/ai/agent/mistakes.rb +369 -0
- data/lib/pwn/ai/agent/prompt_builder.rb +10 -1
- data/lib/pwn/ai/agent/tools/extrospection.rb +88 -3
- data/lib/pwn/ai/agent/tools/mistakes.rb +132 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/plugins/jira_data_center.rb +2 -2
- data/lib/pwn/sdr/decoder/adsb.rb +82 -0
- data/lib/pwn/sdr/decoder/apt.rb +136 -0
- data/lib/pwn/sdr/decoder/base.rb +258 -171
- data/lib/pwn/sdr/decoder/bluetooth.rb +71 -0
- data/lib/pwn/sdr/decoder/dect.rb +70 -0
- data/lib/pwn/sdr/decoder/dsp.rb +396 -0
- data/lib/pwn/sdr/decoder/flex.rb +177 -210
- data/lib/pwn/sdr/decoder/gps.rb +74 -0
- data/lib/pwn/sdr/decoder/gsm.rb +31 -62
- data/lib/pwn/sdr/decoder/iridium.rb +66 -0
- data/lib/pwn/sdr/decoder/lora.rb +80 -0
- data/lib/pwn/sdr/decoder/lte.rb +67 -0
- data/lib/pwn/sdr/decoder/morse.rb +138 -0
- data/lib/pwn/sdr/decoder/p25.rb +74 -0
- data/lib/pwn/sdr/decoder/pager.rb +61 -0
- data/lib/pwn/sdr/decoder/pocsag.rb +176 -54
- data/lib/pwn/sdr/decoder/rfid.rb +79 -0
- data/lib/pwn/sdr/decoder/rtl433.rb +86 -0
- data/lib/pwn/sdr/decoder/rtty.rb +139 -0
- data/lib/pwn/sdr/decoder/wifi.rb +77 -0
- data/lib/pwn/sdr/decoder/zigbee.rb +74 -0
- data/lib/pwn/sdr/decoder.rb +75 -7
- data/lib/pwn/sdr/frequency_allocation.rb +82 -41
- data/lib/pwn/sdr/gqrx.rb +186 -95
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/mistakes_spec.rb +12 -0
- data/spec/lib/pwn/sdr/decoder/adsb_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/apt_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/bluetooth_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/dect_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/gps_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/iridium_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/lora_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/lte_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/morse_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/p25_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/pager_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/rfid_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/rtl433_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/rtty_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/wifi_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/zigbee_spec.rb +15 -0
- data/third_party/pwn_rdoc.jsonl +115 -5
- metadata +46 -6
- data/README.md.bak +0 -200
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
`pwn-ai` is a natural-language front end to everything in `PWN::`. You describe
|
|
4
4
|
the goal; the agent plans a sequence of tool calls (`pwn_eval`, `shell`,
|
|
5
|
-
`memory_*`, `skill_*`, `extro_*`, `agent_*`, …), executes them
|
|
6
|
-
process, observes the results, and loops until it can give you
|
|
5
|
+
`memory_*`, `skill_*`, `mistakes_*`, `extro_*`, `agent_*`, …), executes them
|
|
6
|
+
against the live process, observes the results, and loops until it can give you
|
|
7
|
+
a final answer — **learning from every failure so it doesn't repeat it**.
|
|
7
8
|
|
|
8
9
|
## Two ways to run it
|
|
9
10
|
|
|
10
11
|
```text
|
|
11
12
|
# 1. Interactive TUI (inside the pwn REPL)
|
|
12
|
-
pwn[v0.5.
|
|
13
|
-
✨ pwn-ai · anthropic · session
|
|
13
|
+
pwn[v0.5.618]:001 >>> pwn-ai
|
|
14
|
+
✨ pwn-ai · anthropic · session 20260707_225041_d7f2f3bb
|
|
14
15
|
> Use NmapIt to sweep 10.0.0.0/24, then TransparentBrowser via Burp on any
|
|
15
16
|
host with 443 open, active-scan, and give me a Reports::SAST summary.
|
|
16
17
|
```
|
|
@@ -23,12 +24,21 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
|
|
|
23
24
|
## Anatomy of a turn
|
|
24
25
|
|
|
25
26
|
1. **PromptBuilder** assembles the system prompt: your request + MEMORY block +
|
|
26
|
-
SKILLS list + LEARNING stats +
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
SKILLS list + LEARNING stats + **KNOWN MISTAKES / KNOWN FIXES** +
|
|
28
|
+
TOOL EFFECTIVENESS + EXTROSPECTION block.
|
|
29
|
+
2. **Loop** checks the incoming message against `Mistakes::CORRECTION_RX` — if
|
|
30
|
+
it reads like *"no, that's wrong"* the previous outcome is flipped to
|
|
31
|
+
`success:false` and fingerprinted.
|
|
32
|
+
3. Loop sends the prompt to the active `PWN::AI::<Engine>` client.
|
|
33
|
+
4. Provider replies with `tool_calls` → **Dispatch** executes each one via the
|
|
34
|
+
[Registry](Agent-Tool-Registry.md); **Metrics** records duration/success.
|
|
35
|
+
Any *failure* is fingerprinted into **Mistakes** (`count++`, cross-session)
|
|
36
|
+
and the tool result gets an inline `correction_hint` (`seen N×, sig=…,
|
|
37
|
+
KNOWN FIX: …`) so the very next iteration self-corrects. If the persistent
|
|
38
|
+
count ≥ 3, `guard_repeated_failure` interrupts with an explicit
|
|
39
|
+
*change-approach* instruction.
|
|
40
|
+
5. Results are appended to the message list; go to 3.
|
|
41
|
+
6. When the reply has *no* tool_calls it's the **final answer** →
|
|
32
42
|
`Learning.auto_reflect` and `Extrospection.auto_extrospect` fire (if
|
|
33
43
|
enabled), transcript is flushed to `~/.pwn/sessions/`.
|
|
34
44
|
|
|
@@ -36,7 +46,7 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
|
|
|
36
46
|
|
|
37
47
|
## What the agent can call
|
|
38
48
|
|
|
39
|
-
10 toolsets ·
|
|
49
|
+
10 toolsets · 54 tools — full table at
|
|
40
50
|
[Agent Tool Registry](Agent-Tool-Registry.md).
|
|
41
51
|
|
|
42
52
|
The two that matter most:
|
|
@@ -46,9 +56,9 @@ The two that matter most:
|
|
|
46
56
|
| `pwn_eval` | **Any** Ruby in-process — the whole `PWN::` namespace, `require`, monkey-patch, everything |
|
|
47
57
|
| `shell` | **Any** OS command on the host |
|
|
48
58
|
|
|
49
|
-
Everything else (memory, skills, learning, extrospection, cron,
|
|
50
|
-
sessions, metrics) is a convenience wrapper the model can discover from
|
|
51
|
-
schema alone.
|
|
59
|
+
Everything else (memory, skills, learning, **mistakes**, extrospection, cron,
|
|
60
|
+
swarm, sessions, metrics) is a convenience wrapper the model can discover from
|
|
61
|
+
the schema alone.
|
|
52
62
|
|
|
53
63
|
## Delegating to other agents
|
|
54
64
|
|
|
@@ -64,9 +74,12 @@ full `Loop.run` under a persona overlay) that share a JSONL bus. See
|
|
|
64
74
|
- Disable `auto_reflect` during noisy fuzz loops
|
|
65
75
|
(`learning_auto_reflect_toggle(enabled: false)`), re-enable for the summary
|
|
66
76
|
turn.
|
|
77
|
+
- Run `mistakes_list` before retrying something that failed last session — the
|
|
78
|
+
fix may already be recorded.
|
|
67
79
|
|
|
68
80
|
**See also:** [AI Integration](AI-Integration.md) ·
|
|
69
81
|
[Skills, Memory & Learning](Skills-Memory-Learning.md) ·
|
|
70
|
-
[
|
|
82
|
+
[Mistakes](Mistakes.md) · [Extrospection](Extrospection.md) ·
|
|
83
|
+
[Swarm](Swarm.md) · [Cron](Cron.md)
|
|
71
84
|
|
|
72
85
|
[← Home](Home.md)
|
|
@@ -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
|
|
@@ -41,7 +42,14 @@ module PWN
|
|
|
41
42
|
module Extrospection
|
|
42
43
|
EXTRO_FILE = File.join(Dir.home, '.pwn', 'extrospection.json')
|
|
43
44
|
MAX_OBSERVATIONS = 500
|
|
44
|
-
|
|
45
|
+
RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
|
|
46
|
+
PROBE_BINS = (%w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker] + RF_BINS).freeze
|
|
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
|
|
45
53
|
|
|
46
54
|
# Supported Method Parameters::
|
|
47
55
|
# store = PWN::AI::Agent::Extrospection.load
|
|
@@ -71,7 +79,7 @@ module PWN
|
|
|
71
79
|
# Supported Method Parameters::
|
|
72
80
|
# snap = PWN::AI::Agent::Extrospection.snapshot(
|
|
73
81
|
# persist: 'optional - Boolean, write snapshot to disk & rotate previous (default true)',
|
|
74
|
-
# sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env] (default all)'
|
|
82
|
+
# sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf, :web] (default all except :web)'
|
|
75
83
|
# )
|
|
76
84
|
#
|
|
77
85
|
# Captures a fingerprint of the OUTSIDE world. When persist:true the
|
|
@@ -84,7 +92,7 @@ module PWN
|
|
|
84
92
|
true
|
|
85
93
|
end
|
|
86
94
|
sections = Array(opts[:sections]).map(&:to_sym)
|
|
87
|
-
sections = %i[host net toolchain repo env] if sections.empty?
|
|
95
|
+
sections = %i[host net toolchain repo env rf] if sections.empty?
|
|
88
96
|
|
|
89
97
|
snap = {}
|
|
90
98
|
snap[:host] = probe_host if sections.include?(:host)
|
|
@@ -92,6 +100,8 @@ module PWN
|
|
|
92
100
|
snap[:toolchain] = probe_toolchain if sections.include?(:toolchain)
|
|
93
101
|
snap[:repo] = probe_repo if sections.include?(:repo)
|
|
94
102
|
snap[:env] = probe_env if sections.include?(:env)
|
|
103
|
+
snap[:rf] = probe_rf if sections.include?(:rf)
|
|
104
|
+
snap[:web] = probe_web if sections.include?(:web)
|
|
95
105
|
snap[:captured_at] = Time.now.utc.iso8601
|
|
96
106
|
snap[:fingerprint] = Digest::SHA256.hexdigest(JSON.generate(snap.except(:captured_at)))[0, 16]
|
|
97
107
|
|
|
@@ -110,7 +120,7 @@ module PWN
|
|
|
110
120
|
# obs = PWN::AI::Agent::Extrospection.observe(
|
|
111
121
|
# source: 'required - where the observation came from (nmap, shodan, burp, cve, human, ...)',
|
|
112
122
|
# data: 'required - the observation payload (String or Hash)',
|
|
113
|
-
# category: 'optional - :recon, :vuln, :intel, :target, :network, :misc (default :misc)',
|
|
123
|
+
# category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :web, :misc (default :misc)',
|
|
114
124
|
# target: 'optional - host/ip/url/asset the observation is about',
|
|
115
125
|
# tags: 'optional - Array of String labels',
|
|
116
126
|
# ttl: 'optional - seconds until this observation is considered stale (default nil = forever)'
|
|
@@ -236,6 +246,189 @@ module PWN
|
|
|
236
246
|
{ query: query, feeds: feeds, results: results, total: results.values.flatten.compact.length }
|
|
237
247
|
end
|
|
238
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
|
+
|
|
239
432
|
# Supported Method Parameters::
|
|
240
433
|
# findings = PWN::AI::Agent::Extrospection.correlate(
|
|
241
434
|
# limit: 'optional - max findings returned (default 10)'
|
|
@@ -288,7 +481,54 @@ module PWN
|
|
|
288
481
|
end
|
|
289
482
|
end
|
|
290
483
|
|
|
291
|
-
# 4)
|
|
484
|
+
# 4) :rf observations vs missing SDR hardware / binaries
|
|
485
|
+
rf = snap[:rf] || {}
|
|
486
|
+
hw_present = %i[rtl_sdr hackrf flipper gqrx_sock].any? { |k| rf_present?(val: rf[k]) }
|
|
487
|
+
observations(category: 'rf', limit: 50).each do |ob|
|
|
488
|
+
miss = RF_BINS.select { |b| pkgs[b.to_sym].to_s.empty? }
|
|
489
|
+
if !hw_present
|
|
490
|
+
findings << { kind: :rf_no_hardware, observation: ob[:data], source: ob[:source], target: ob[:target], advice: 'RF observation recorded but no SDR hardware detected in snapshot — plug in RTL-SDR/HackRF/Flipper or start gqrx (`-r`) before trusting RF results.' }
|
|
491
|
+
elsif rf[:gqrx_sock] == false && ob[:source].to_s == 'gqrx'
|
|
492
|
+
findings << { kind: :rf_gqrx_down, observation: ob[:data], target: ob[:target], advice: 'gqrx remote-control socket (127.0.0.1:7356) is closed — start gqrx with remote control enabled before re-running the scan.' }
|
|
493
|
+
elsif !miss.empty?
|
|
494
|
+
findings << { kind: :rf_toolchain_gap, missing: miss, observation: ob[:data], advice: "SDR toolchain gap: install #{miss.join(', ')} to decode/act on this RF observation." }
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
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
|
|
292
532
|
Array(delta[:added]).first(5).each { |c| findings << { kind: :env_added, detail: c } } if findings.empty?
|
|
293
533
|
Array(delta[:removed]).first(5).each { |c| findings << { kind: :env_removed, detail: c } } if findings.empty?
|
|
294
534
|
|
|
@@ -346,7 +586,9 @@ module PWN
|
|
|
346
586
|
drift_added: Array(delta[:added]).length,
|
|
347
587
|
drift_removed: Array(delta[:removed]).length,
|
|
348
588
|
toolchain_bins: (snap[:toolchain] || {}).count { |_, v| !v.to_s.empty? },
|
|
349
|
-
listening_ports: Array(snap.dig(:net, :listening)).length
|
|
589
|
+
listening_ports: Array(snap.dig(:net, :listening)).length,
|
|
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] }
|
|
350
592
|
}
|
|
351
593
|
end
|
|
352
594
|
|
|
@@ -478,6 +720,243 @@ module PWN
|
|
|
478
720
|
{ cwd: Dir.pwd, ruby: RUBY_VERSION }
|
|
479
721
|
end
|
|
480
722
|
|
|
723
|
+
# Passive RF / SDR hardware inventory. NO transmit, NO active spectrum
|
|
724
|
+
# scan — this is the RF analogue of probe_toolchain: "what radios and
|
|
725
|
+
# SDR plumbing are attached / reachable right now?" so drift can flag
|
|
726
|
+
# "HackRF unplugged", "gqrx remote-control down", "new RTL dongle".
|
|
727
|
+
private_class_method def self.probe_rf
|
|
728
|
+
{
|
|
729
|
+
rtl_sdr: sh(cmd: 'timeout 3 rtl_test -t 2>&1 | head -5'),
|
|
730
|
+
hackrf: sh(cmd: 'timeout 3 hackrf_info 2>&1 | head -8'),
|
|
731
|
+
soapy: sh(cmd: 'timeout 3 SoapySDRUtil --find 2>&1 | head -10'),
|
|
732
|
+
gqrx_sock: tcp_open?(host: '127.0.0.1', port: 7356),
|
|
733
|
+
flipper: Dir.glob('/dev/serial/by-id/*Flipper*').any?,
|
|
734
|
+
serial_devs: Dir.glob('/dev/{ttyUSB,ttyACM}*'),
|
|
735
|
+
band_plans: rf_band_plan_keys.length
|
|
736
|
+
}
|
|
737
|
+
rescue StandardError => e
|
|
738
|
+
{ error: "#{e.class}: #{e.message}" }
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
private_class_method def self.tcp_open?(opts = {})
|
|
742
|
+
host = opts[:host] || '127.0.0.1'
|
|
743
|
+
port = opts[:port].to_i
|
|
744
|
+
Socket.tcp(host, port, connect_timeout: 1, &:close)
|
|
745
|
+
true
|
|
746
|
+
rescue StandardError
|
|
747
|
+
false
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
private_class_method def self.rf_present?(opts = {})
|
|
751
|
+
v = opts[:val]
|
|
752
|
+
return false if v.nil? || v == false || v.to_s.strip.empty?
|
|
753
|
+
|
|
754
|
+
v.to_s !~ /no\s+.*devices|no\s+hackrf|not\s+found|no\s+such\s+file|command\s+not\s+found|^false$/i
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
private_class_method def self.rf_band_plan_keys
|
|
758
|
+
require 'pwn/sdr/frequency_allocation' unless defined?(PWN::SDR::FrequencyAllocation)
|
|
759
|
+
PWN::SDR::FrequencyAllocation.band_plans.keys.map(&:to_s).sort
|
|
760
|
+
rescue StandardError
|
|
761
|
+
[]
|
|
762
|
+
end
|
|
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
|
+
|
|
481
960
|
private_class_method def self.flatten(opts = {})
|
|
482
961
|
hash = opts[:hash] || {}
|
|
483
962
|
prefix = opts[:prefix].to_s
|
|
@@ -598,17 +1077,26 @@ module PWN
|
|
|
598
1077
|
PWN::AI::Agent::Extrospection.snapshot # probe host, persist, return {snapshot:, drift:}
|
|
599
1078
|
PWN::AI::Agent::Extrospection.drift(live: true) # what changed vs last snapshot
|
|
600
1079
|
PWN::AI::Agent::Extrospection.observe(source: 'nmap', category: :recon, target: '10.0.0.5', data: '22/tcp open ssh 9.6')
|
|
1080
|
+
PWN::AI::Agent::Extrospection.observe(source: 'gqrx', category: :rf, target: '433.920MHz', data: 'peak -34.2 dBFS bw=200k')
|
|
601
1081
|
PWN::AI::Agent::Extrospection.observations(category: 'recon', target: '10.0.0.5')
|
|
602
1082
|
PWN::AI::Agent::Extrospection.intel(query: 'openssl 3.0', record: true)
|
|
603
1083
|
PWN::AI::Agent::Extrospection.correlate # introspection x extrospection findings
|
|
604
1084
|
PWN::AI::Agent::Extrospection.to_context # injected by PromptBuilder
|
|
605
1085
|
PWN::AI::Agent::Extrospection.stats
|
|
606
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
|
|
607
1091
|
PWN::AI::Agent::Extrospection.reset
|
|
608
1092
|
|
|
609
1093
|
Enable end-of-run auto-extrospection with:
|
|
610
1094
|
PWN::Env[:ai][:agent][:auto_extrospect] = true
|
|
611
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
|
+
|
|
612
1100
|
#{self}.authors
|
|
613
1101
|
USAGE
|
|
614
1102
|
end
|