pwn 0.5.737 → 0.5.739

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +0 -1
  3. data/Gemfile +2 -2
  4. data/documentation/Agent-Tool-Registry.md +17 -3
  5. data/documentation/Diagrams.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +74 -0
  8. data/documentation/Swarm.md +2 -3
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-REPL.md +0 -1
  11. data/documentation/pwn-ai-Agent.md +1 -1
  12. data/etc/default_skills/browser-exploitation/SKILL.md +10 -0
  13. data/etc/default_skills/firmware-unpack-emulate/SKILL.md +10 -0
  14. data/etc/default_skills/heap-exploitation/SKILL.md +11 -0
  15. data/etc/default_skills/kernel-pwn-triage/SKILL.md +11 -0
  16. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -1
  17. data/etc/default_skills/pwn/ai/agent/tools/sbom_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/corpus/SKILL.md +52 -0
  19. data/etc/default_skills/pwn/corpus/references/urls.md +3 -0
  20. data/etc/default_skills/pwn/engagement/SKILL.md +55 -0
  21. data/etc/default_skills/pwn/plugins/debugger/SKILL.md +62 -0
  22. data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/handler/SKILL.md +52 -0
  24. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/mitm_proxy/SKILL.md +1 -0
  26. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  28. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/redb/SKILL.md +52 -0
  30. data/etc/default_skills/pwn/plugins/sbom/SKILL.md +48 -0
  31. data/etc/default_skills/pwn/redaction/SKILL.md +1 -0
  32. data/etc/default_skills/rop-jop-chain-construction/SKILL.md +10 -0
  33. data/etc/default_skills/windows-internals-triage/SKILL.md +10 -0
  34. data/lib/pwn/ai/agent/loop.rb +80 -11
  35. data/lib/pwn/ai/agent/prompt_builder.rb +19 -1
  36. data/lib/pwn/ai/agent/registry.rb +34 -4
  37. data/lib/pwn/ai/agent/swarm.rb +4 -5
  38. data/lib/pwn/ai/agent/tool_guard.rb +11 -4
  39. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +2 -1
  40. data/lib/pwn/ai/agent/tools/job_run.rb +9 -3
  41. data/lib/pwn/ai/agent/tools/pty_session.rb +7 -1
  42. data/lib/pwn/ai/agent/tools/sbom_scan.rb +25 -0
  43. data/lib/pwn/ai/agent/tools/shell.rb +3 -2
  44. data/lib/pwn/ai/agent/tools/swarm.rb +3 -6
  45. data/lib/pwn/ai/agent/turn_finalizer.rb +2 -2
  46. data/lib/pwn/corpus.rb +107 -0
  47. data/lib/pwn/engagement.rb +158 -0
  48. data/lib/pwn/plugins/debugger.rb +284 -0
  49. data/lib/pwn/plugins/findings.rb +14 -3
  50. data/lib/pwn/plugins/fuzz.rb +62 -0
  51. data/lib/pwn/plugins/handler.rb +171 -0
  52. data/lib/pwn/plugins/jobs.rb +55 -0
  53. data/lib/pwn/plugins/mitm_proxy.rb +37 -2
  54. data/lib/pwn/plugins/nmap_it.rb +32 -0
  55. data/lib/pwn/plugins/packet.rb +18 -0
  56. data/lib/pwn/plugins/preflight_checker.rb +6 -2
  57. data/lib/pwn/plugins/process_tube.rb +99 -8
  58. data/lib/pwn/plugins/redb.rb +210 -0
  59. data/lib/pwn/plugins/repl.rb +0 -3
  60. data/lib/pwn/plugins/sbom.rb +145 -0
  61. data/lib/pwn/plugins.rb +4 -0
  62. data/lib/pwn/redaction.rb +53 -0
  63. data/lib/pwn/reports/engagement.rb +2 -1
  64. data/lib/pwn/sessions.rb +3 -1
  65. data/lib/pwn/version.rb +1 -1
  66. data/lib/pwn.rb +2 -0
  67. data/packer/provisioners/irc.sh +1 -1
  68. data/spec/lib/pwn/ai/agent/harness_2026_09_15_spec.rb +144 -0
  69. data/spec/lib/pwn/ai/agent/registry_spec.rb +50 -0
  70. data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +31 -0
  71. data/spec/lib/pwn/ai/agent/tools/sbom_scan_spec.rb +10 -0
  72. data/spec/lib/pwn/corpus_spec.rb +23 -0
  73. data/spec/lib/pwn/engagement_spec.rb +27 -0
  74. data/spec/lib/pwn/plugins/debugger_spec.rb +27 -0
  75. data/spec/lib/pwn/plugins/handler_spec.rb +25 -0
  76. data/spec/lib/pwn/plugins/redb_spec.rb +33 -0
  77. data/spec/lib/pwn/plugins/repl_spec.rb +7 -1
  78. data/spec/lib/pwn/plugins/sbom_spec.rb +22 -0
  79. data/third_party/pwn_rdoc.jsonl +97 -4
  80. metadata +36 -9
  81. data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +0 -47
  82. data/lib/pwn/plugins/repl/irc.rb +0 -153
  83. data/spec/lib/pwn/plugins/repl/irc_spec.rb +0 -21
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'sqlite3'
7
+ require 'open3'
8
+ require 'time'
9
+
10
+ module PWN
11
+ module Plugins
12
+ # Persistent per-binary analysis DB under ~/.pwn/redb.
13
+ module REDB
14
+ ROOT = File.join(Dir.home, '.pwn', 'redb')
15
+
16
+ public_class_method def self.open(opts = {})
17
+ bin = (opts[:bin] || opts[:path]).to_s
18
+ raise ArgumentError, 'bin is required' if bin.empty?
19
+ raise ArgumentError, "binary not found: #{bin}" unless File.file?(bin)
20
+
21
+ sha = Digest::SHA256.file(bin).hexdigest
22
+ dir = File.join(ROOT, sha)
23
+ FileUtils.mkdir_p(dir)
24
+ db_path = File.join(dir, 'analysis.sqlite')
25
+ db = SQLite3::Database.new(db_path)
26
+ db.results_as_hash = true
27
+ migrate(db: db)
28
+ analyze(db: db, bin: bin) if db.get_first_value("SELECT COUNT(*) FROM meta WHERE key = 'analyzed'").to_i.zero?
29
+ { sha256: sha, dir: dir, db: db_path, bin: File.expand_path(bin) }
30
+ ensure
31
+ db&.close
32
+ end
33
+
34
+ public_class_method def self.funcs(opts = {})
35
+ with_db(opts) { |db| db.execute('SELECT * FROM functions ORDER BY name') }
36
+ end
37
+
38
+ public_class_method def self.xrefs_to(opts = {})
39
+ sym = (opts[:sym] || opts[:name] || opts[:addr]).to_s
40
+ raise ArgumentError, 'sym is required' if sym.empty?
41
+
42
+ with_db(opts) { |db| db.execute('SELECT * FROM xrefs WHERE dst = ?', [sym]) }
43
+ end
44
+
45
+ public_class_method def self.strings(opts = {})
46
+ needle = opts[:match].to_s
47
+ sql = 'SELECT * FROM strings'
48
+ args = []
49
+ unless needle.empty?
50
+ sql += ' WHERE value LIKE ?'
51
+ args << "%#{needle}%"
52
+ end
53
+ with_db(opts) { |db| db.execute(sql, args) }
54
+ end
55
+
56
+ public_class_method def self.decompile(opts = {})
57
+ func = (opts[:func] || opts[:name]).to_s
58
+ raise ArgumentError, 'func is required' if func.empty?
59
+
60
+ with_db(opts) do |db|
61
+ row = db.get_first_row('SELECT * FROM decompile WHERE name = ?', [func])
62
+ return row if row
63
+
64
+ text = objdump_func(bin: PWN::Plugins::REDB.open(opts)[:bin], func: func)
65
+ db.execute('INSERT OR REPLACE INTO decompile(name, text, cached_at) VALUES(?,?,?)', [func, text, Time.now.utc.iso8601])
66
+ { 'name' => func, 'text' => text }
67
+ end
68
+ end
69
+
70
+ public_class_method def self.annotate(opts = {})
71
+ note = opts[:text].to_s
72
+ target = (opts[:addr] || opts[:func] || opts[:sym]).to_s
73
+ raise ArgumentError, 'addr or func is required' if target.empty?
74
+
75
+ with_db(opts) do |db|
76
+ db.execute('INSERT INTO annotations(target, text, cached_at) VALUES(?,?,?)', [target, note, Time.now.utc.iso8601])
77
+ { target: target, text: note }
78
+ end
79
+ end
80
+
81
+ public_class_method def self.authors
82
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
83
+ end
84
+
85
+ public_class_method def self.help
86
+ puts "USAGE:
87
+ # Open or reuse ~/.pwn/redb/<sha256> for a binary.
88
+ #{self}.open(
89
+ bin: 'required - filesystem path of the binary',
90
+ path: 'optional - alias for bin'
91
+ )
92
+
93
+ # List cached functions.
94
+ #{self}.funcs(
95
+ bin: 'required - filesystem path of the binary'
96
+ )
97
+
98
+ # Return callers of a symbol from cache.
99
+ #{self}.xrefs_to(
100
+ bin: 'required - filesystem path of the binary',
101
+ sym: 'required - destination symbol or address',
102
+ name: 'optional - alias for sym',
103
+ addr: 'optional - alias for sym'
104
+ )
105
+
106
+ # Search cached strings.
107
+ #{self}.strings(
108
+ bin: 'required - filesystem path of the binary',
109
+ match: 'optional - substring filter'
110
+ )
111
+
112
+ # Return cached decompilation, filling the cache from objdump on miss.
113
+ #{self}.decompile(
114
+ bin: 'required - filesystem path of the binary',
115
+ func: 'required - function name',
116
+ name: 'optional - alias for func'
117
+ )
118
+
119
+ # Persist an analyst annotation for a later session.
120
+ #{self}.annotate(
121
+ bin: 'required - filesystem path of the binary',
122
+ addr: 'optional - address to annotate',
123
+ func: 'optional - function name to annotate',
124
+ sym: 'optional - symbol to annotate',
125
+ text: 'optional - annotation body'
126
+ )
127
+
128
+ # Print the AUTHOR(S) string for this module.
129
+ #{self}.authors
130
+ "
131
+ constants.sort
132
+ end
133
+
134
+ private_class_method def self.migrate(opts = {})
135
+ db = opts[:db]
136
+ db.execute('CREATE TABLE IF NOT EXISTS functions(name TEXT PRIMARY KEY, addr TEXT)')
137
+ db.execute('CREATE TABLE IF NOT EXISTS xrefs(src TEXT, dst TEXT)')
138
+ db.execute('CREATE TABLE IF NOT EXISTS strings(value TEXT PRIMARY KEY)')
139
+ db.execute('CREATE TABLE IF NOT EXISTS imports(name TEXT PRIMARY KEY)')
140
+ db.execute('CREATE TABLE IF NOT EXISTS exports(name TEXT PRIMARY KEY)')
141
+ db.execute('CREATE TABLE IF NOT EXISTS decompile(name TEXT PRIMARY KEY, text TEXT, cached_at TEXT, kind TEXT)')
142
+ db.execute('CREATE TABLE IF NOT EXISTS annotations(target TEXT, text TEXT, cached_at TEXT)')
143
+ db.execute('CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT)')
144
+ end
145
+
146
+ private_class_method def self.with_db(opts = {})
147
+ meta = PWN::Plugins::REDB.open(opts)
148
+ db = SQLite3::Database.new(meta[:db])
149
+ db.results_as_hash = true
150
+ yield db
151
+ ensure
152
+ db&.close
153
+ end
154
+
155
+ private_class_method def self.analyze(opts = {})
156
+ db = opts[:db]
157
+ bin = opts[:bin]
158
+ analyze_binutils(db: db, bin: bin)
159
+ db.execute("INSERT OR REPLACE INTO meta(key, value) VALUES('analyzed', ?)", [Time.now.utc.iso8601])
160
+ end
161
+
162
+ private_class_method def self.analyze_r2(opts = {})
163
+ analyze_binutils(opts)
164
+ end
165
+
166
+ private_class_method def self.analyze_binutils(opts = {})
167
+ db = opts[:db]
168
+ bin = opts[:bin]
169
+ nm, = Open3.capture2('nm', '-C', bin)
170
+ nm.each_line do |line|
171
+ if (m = line.match(/\s+U\s+(\S+)/))
172
+ db.execute('INSERT OR IGNORE INTO imports(name) VALUES(?)', [m[1]])
173
+ next
174
+ end
175
+ next unless (m = line.match(/\A([0-9a-fA-F]+)\s+(\w)\s+(\S+)/))
176
+
177
+ db.execute('INSERT OR REPLACE INTO functions(name, addr) VALUES(?,?)', [m[3], m[1]])
178
+ db.execute('INSERT OR IGNORE INTO exports(name) VALUES(?)', [m[3]]) if m[2] == 'T'
179
+ end
180
+ dump, = Open3.capture2('objdump', '-d', bin)
181
+ current = nil
182
+ dump.each_line do |line|
183
+ current = Regexp.last_match(1) if line =~ /<([^>]+)>:/
184
+ next unless current && (line =~ /call\w*\s+.*<([^>@]+)/ || line =~ /call\w*\s+([A-Za-z_]\w*)(?:@plt)?/)
185
+
186
+ db.execute('INSERT INTO xrefs(src, dst) VALUES(?,?)', [current, Regexp.last_match(1)])
187
+ end
188
+ strings, = Open3.capture2('strings', '-a', bin)
189
+ strings.each_line { |s| db.execute('INSERT OR IGNORE INTO strings(value) VALUES(?)', [s.chomp]) }
190
+ end
191
+
192
+ private_class_method def self.objdump_func(opts = {})
193
+ dump, = Open3.capture2('objdump', '-d', opts[:bin].to_s)
194
+ keep = false
195
+ rows = []
196
+ dump.each_line do |line|
197
+ if line =~ /<#{Regexp.escape(opts[:func].to_s)}>:/
198
+ keep = true
199
+ rows << line
200
+ next
201
+ end
202
+ break if keep && line =~ /<[^>]+>:/
203
+
204
+ rows << line if keep
205
+ end
206
+ rows.join
207
+ end
208
+ end
209
+ end
210
+ end
@@ -16,7 +16,6 @@ module PWN
16
16
  module REPL
17
17
  autoload :ASM, 'pwn/plugins/repl/asm'
18
18
  autoload :AI, 'pwn/plugins/repl/ai'
19
- autoload :IRC, 'pwn/plugins/repl/irc'
20
19
  autoload :Mesh, 'pwn/plugins/repl/mesh'
21
20
  autoload :Vault, 'pwn/plugins/repl/vault'
22
21
 
@@ -442,7 +441,6 @@ module PWN
442
441
 
443
442
  PWN::Plugins::REPL::ASM.add_commands
444
443
  PWN::Plugins::REPL::AI.add_commands
445
- PWN::Plugins::REPL::IRC.add_commands
446
444
  PWN::Plugins::REPL::Mesh.add_commands
447
445
  PWN::Plugins::REPL::Vault.add_commands
448
446
 
@@ -1221,6 +1219,5 @@ end
1221
1219
 
1222
1220
  require_relative 'repl/asm'
1223
1221
  require_relative 'repl/ai'
1224
- require_relative 'repl/irc'
1225
1222
  require_relative 'repl/mesh'
1226
1223
  require_relative 'repl/vault'
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module PWN
7
+ module Plugins
8
+ # Generic lockfile/image CVE scan with engine selection.
9
+ module SBOM
10
+ public_class_method def self.required_bins
11
+ []
12
+ end
13
+
14
+ public_class_method def self.scan(opts = {})
15
+ target = (opts[:path_or_image] || opts[:path] || opts[:image]).to_s
16
+ raise ArgumentError, 'path_or_image is required' if target.empty?
17
+
18
+ engine = (opts[:engine] || select_engine(target: target)).to_s
19
+ rows = case engine
20
+ when 'grype' then parse_grype(target: target)
21
+ when 'trivy' then parse_trivy(target: target)
22
+ when 'osv-scanner' then parse_osv(target: target)
23
+ when 'syft+grype', 'syft'
24
+ inv = parse_syft(target: target)
25
+ if PWN::Plugins::PreflightChecker.bin?(name: 'grype')
26
+ parse_grype(target: target)
27
+ else
28
+ inv
29
+ end
30
+ else
31
+ raise IOError, 'no SBOM engine installed (syft, grype, trivy, or osv-scanner)'
32
+ end
33
+ findings = []
34
+ if defined?(PWN::Plugins::Findings) && opts[:record] == true
35
+ rows.each do |row|
36
+ next if row[:cve].to_s.empty?
37
+
38
+ findings << PWN::Plugins::Findings.record(title: "#{row[:package]} #{row[:cve]}", severity: row[:severity], host: target, evidence: row.inspect, poc: "sbom_scan #{target}")
39
+ end
40
+ end
41
+ { engine: engine, target: target, vulns: rows, findings: findings }
42
+ end
43
+
44
+ public_class_method def self.authors
45
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
46
+ end
47
+
48
+ public_class_method def self.help
49
+ puts "USAGE:
50
+ # List host binaries this module expects to be installed.
51
+ #{self}.required_bins
52
+
53
+ # Scan a lockfile or image with the first available engine.
54
+ #{self}.scan(
55
+ path_or_image: 'required - lockfile, directory, or image reference',
56
+ path: 'optional - alias for path_or_image',
57
+ image: 'optional - alias for path_or_image',
58
+ engine: 'optional - syft, grype, trivy, or osv-scanner',
59
+ record: 'optional - true records each CVE via Findings.record'
60
+ )
61
+
62
+ # Print the AUTHOR(S) string for this module.
63
+ #{self}.authors
64
+ "
65
+ constants.sort
66
+ end
67
+
68
+ private_class_method def self.select_engine(opts = {})
69
+ _target = opts[:target]
70
+ %w[grype trivy osv-scanner syft].find { |bin| PWN::Plugins::PreflightChecker.bin?(name: bin) }
71
+ end
72
+
73
+ private_class_method def self.run!(opts = {})
74
+ argv = opts[:argv]
75
+ if Open3.respond_to?(:capture3)
76
+ out, err, st = Open3.capture3(*argv)
77
+ raise IOError, "#{argv.first} failed: #{err}" unless st.success? || opts[:allow_nonzero] == true
78
+
79
+ else
80
+ out, = Open3.capture2(*argv)
81
+ end
82
+ out
83
+ rescue Errno::ENOENT
84
+ out, = Open3.capture2(*opts[:argv])
85
+ out
86
+ end
87
+
88
+ private_class_method def self.parse_grype(opts = {})
89
+ out = run!(argv: ['grype', '-o', 'json', opts[:target]], allow_nonzero: true)
90
+ json = JSON.parse(out)
91
+ Array(json['matches']).map do |row|
92
+ art = row['artifact'] || {}
93
+ vuln = row['vulnerability'] || {}
94
+ normalize(package: art['name'], version: art['version'], cve: vuln['id'], severity: vuln['severity'], fix: vuln.dig('fix', 'versions'))
95
+ end
96
+ end
97
+
98
+ private_class_method def self.parse_trivy(opts = {})
99
+ target = opts[:target].to_s
100
+ sub = File.file?(target) && target.match?(/\.(tar|tar\.gz|tgz|tar\.zst)\z/i) ? 'image' : 'fs'
101
+ sub = 'image' if target.include?(':') && !File.exist?(target)
102
+ out = run!(argv: ['trivy', sub, '--format', 'json', target], allow_nonzero: true)
103
+ json = JSON.parse(out)
104
+ Array(json['Results']).flat_map do |res|
105
+ Array(res['Vulnerabilities']).map do |vuln|
106
+ normalize(package: vuln['PkgName'], version: vuln['InstalledVersion'], cve: vuln['VulnerabilityID'], severity: vuln['Severity'], fix: vuln['FixedVersion'])
107
+ end
108
+ end
109
+ end
110
+
111
+ private_class_method def self.parse_osv(opts = {})
112
+ target = opts[:target].to_s
113
+ argv = ['osv-scanner', '--json']
114
+ argv.concat(File.file?(target) ? [target] : ['-r', target])
115
+ out = run!(argv: argv, allow_nonzero: true)
116
+ json = JSON.parse(out)
117
+ Array(json['results']).flat_map do |res|
118
+ Array(res['packages']).flat_map do |pkg|
119
+ Array(pkg['vulnerabilities']).map do |vuln|
120
+ normalize(package: pkg.dig('package', 'name'), version: pkg.dig('package', 'version'), cve: vuln['id'], severity: 'unknown', fix: nil)
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ private_class_method def self.parse_syft(opts = {})
127
+ out = run!(argv: ['syft', '-o', 'json', opts[:target]])
128
+ json = JSON.parse(out)
129
+ Array(json['artifacts']).map do |art|
130
+ normalize(package: art['name'], version: art['version'], cve: nil, severity: 'info', fix: nil)
131
+ end
132
+ end
133
+
134
+ private_class_method def self.normalize(opts = {})
135
+ {
136
+ package: opts[:package].to_s,
137
+ version: opts[:version].to_s,
138
+ cve: opts[:cve].to_s,
139
+ severity: opts[:severity].to_s.downcase,
140
+ fix_version: Array(opts[:fix]).first.to_s
141
+ }
142
+ end
143
+ end
144
+ end
145
+ end
data/lib/pwn/plugins.rb CHANGED
@@ -27,6 +27,7 @@ module PWN
27
27
  autoload :PreflightChecker, 'pwn/plugins/preflight_checker'
28
28
  autoload :PWNLogger, 'pwn/plugins/pwn_logger'
29
29
  autoload :DAOLDAP, 'pwn/plugins/dao_ldap'
30
+ autoload :Debugger, 'pwn/plugins/debugger'
30
31
  autoload :DAOMongo, 'pwn/plugins/dao_mongo'
31
32
  autoload :DAOPostgres, 'pwn/plugins/dao_postgres'
32
33
  autoload :DAOSQLite3, 'pwn/plugins/dao_sqlite3'
@@ -49,6 +50,7 @@ module PWN
49
50
  autoload :Github, 'pwn/plugins/github'
50
51
  autoload :GoogleWorkspace, 'pwn/plugins/google_workspace'
51
52
  autoload :HackerOne, 'pwn/plugins/hacker_one'
53
+ autoload :Handler, 'pwn/plugins/handler'
52
54
  autoload :Hunter, 'pwn/plugins/hunter'
53
55
  autoload :IPInfo, 'pwn/plugins/ip_info'
54
56
  autoload :IRC, 'pwn/plugins/irc'
@@ -79,9 +81,11 @@ module PWN
79
81
  autoload :RabbitMQ, 'pwn/plugins/rabbit_mq'
80
82
  autoload :Radare2, 'pwn/plugins/radare2'
81
83
  autoload :Recon, 'pwn/plugins/recon'
84
+ autoload :REDB, 'pwn/plugins/redb'
82
85
  autoload :REPL, 'pwn/plugins/repl'
83
86
  autoload :ROP, 'pwn/plugins/rop'
84
87
  autoload :Sandbox, 'pwn/plugins/sandbox'
88
+ autoload :SBOM, 'pwn/plugins/sbom'
85
89
  autoload :ScannableCodes, 'pwn/plugins/scannable_codes'
86
90
  autoload :Semgrep, 'pwn/plugins/semgrep'
87
91
  autoload :Serial, 'pwn/plugins/serial'
data/lib/pwn/redaction.rb CHANGED
@@ -47,6 +47,51 @@ module PWN
47
47
  "[REDACTED:#{opts[:kind]}:#{Digest::SHA256.hexdigest(value)[0, 8]}]"
48
48
  end
49
49
 
50
+ public_class_method def self.capture(opts = {})
51
+ value = opts[:value]
52
+ return { redacted: redact(value: value), refs: [] } unless value.is_a?(String)
53
+
54
+ text = value.to_s
55
+ refs = []
56
+ redacted = PATTERNS.reduce(text.dup) do |acc, (kind, regex)|
57
+ acc.gsub(regex) do |match|
58
+ tok = token(kind: kind, value: match)
59
+ refs << { kind: kind, token: tok }
60
+ PWN::Plugins::Vault.store(label: tok, secret: match, engagement: opts[:engagement]) if opts[:vault] != false && defined?(PWN::Plugins::Vault)
61
+ tok
62
+ rescue StandardError
63
+ tok
64
+ end
65
+ end
66
+ redacted = capture_entropy(text: redacted, refs: refs, vault: opts[:vault], engagement: opts[:engagement]) if opts[:entropy] == true
67
+ { redacted: redacted, refs: refs }
68
+ end
69
+
70
+ private_class_method def self.capture_entropy(opts = {})
71
+ text = opts[:text].to_s
72
+ refs = opts[:refs] || []
73
+ text.gsub(%r{[A-Za-z0-9+/=_-]{40,}}) do |match|
74
+ next match if match.start_with?('[REDACTED:')
75
+ next match unless entropy?(text: match)
76
+
77
+ tok = token(kind: 'entropy', value: match)
78
+ refs << { kind: 'entropy', token: tok }
79
+ PWN::Plugins::Vault.store(label: tok, secret: match, engagement: opts[:engagement]) if opts[:vault] != false && defined?(PWN::Plugins::Vault)
80
+ tok
81
+ rescue StandardError
82
+ match
83
+ end
84
+ end
85
+
86
+ private_class_method def self.entropy?(opts = {})
87
+ s = opts[:text].to_s
88
+ return false if s.length < 40
89
+
90
+ freq = s.each_char.tally
91
+ psum = freq.values.sum { |n| (n.to_f / s.length) * Math.log2(n.to_f / s.length) }
92
+ -psum >= 3.5
93
+ end
94
+
50
95
  public_class_method def self.authors
51
96
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
52
97
  end
@@ -62,6 +107,14 @@ module PWN
62
107
  kind: 'required - secret type label',
63
108
  value: 'required - sensitive value to hash'
64
109
  )
110
+ # Capture secrets into Vault labels and return a redacted copy.
111
+ #{self}.capture(
112
+ value: 'required - string that may contain credentials',
113
+ vault: 'optional - false skips Vault.store',
114
+ engagement: 'optional - engagement name stored with the vault label',
115
+ entropy: 'optional - true also captures high-entropy tokens'
116
+ )
117
+
65
118
  # Print the author information.
66
119
  #{self}.authors
67
120
  "
@@ -21,7 +21,8 @@ module PWN
21
21
  rows = if opts[:findings]
22
22
  Array(opts[:findings])
23
23
  elsif defined?(PWN::Plugins::Findings)
24
- PWN::Plugins::Findings.report
24
+ rows = PWN::Plugins::Findings.report
25
+ rows = rows.select { |row| row[:engagement_id].to_s == name || name == 'default' }
25
26
  else
26
27
  []
27
28
  end
data/lib/pwn/sessions.rb CHANGED
@@ -93,7 +93,9 @@ module PWN
93
93
  raise "Session #{sid} not found" unless File.exist?(path)
94
94
 
95
95
  role = (opts[:role] || 'user').to_s
96
- content = PWN::Redaction.redact(value: opts[:content])
96
+ raw = opts[:content]
97
+ PWN::Redaction.capture(value: raw, vault: true) if raw.is_a?(String) && %w[tool observation].include?(role)
98
+ content = PWN::Redaction.redact(value: raw)
97
99
  if content.is_a?(String)
98
100
  # Write-path policy: cap bulk by role using compact limits so append
99
101
  # cannot grow transcripts past TOOL_CONTENT_MAX / ASSISTANT_CONTENT_MAX.
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.737'
4
+ VERSION = '0.5.739'
5
5
  end
data/lib/pwn.rb CHANGED
@@ -17,6 +17,8 @@ module PWN
17
17
  autoload :Blockchain, 'pwn/blockchain'
18
18
  autoload :Config, 'pwn/config'
19
19
  autoload :Cron, 'pwn/cron'
20
+ autoload :Corpus, 'pwn/corpus'
21
+ autoload :Engagement, 'pwn/engagement'
20
22
  autoload :Memory, 'pwn/memory'
21
23
  autoload :MemoryIndex, 'pwn/memory_index'
22
24
  autoload :Migrate, 'pwn/migrate'
@@ -10,4 +10,4 @@ sudo sed -e 's/^new_cursors=true/new_cursors=false/g' \
10
10
  sudo systemctl enable inspircd
11
11
  sudo systemctl restart inspircd
12
12
 
13
- # TODO: tweak /etc/inspircd/inspircd.conf to compliment pwn-irc AI agents
13
+ # inspircd/irssi for PWN::Plugins::IRC clients
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+ require 'socket'
7
+
8
+ describe 'pwn-ai 2026-09-15 harness patches' do
9
+ it 'allows a heredoc range and markdown ellipsis but denies a trailing bare token' do
10
+ ok = <<~CMD
11
+ cat <<'EOF'
12
+ 1...10 and wait...
13
+ EOF
14
+ CMD
15
+ expect(PWN::AI::Agent::ToolGuard.placeholder?(text: ok)).to eq(false)
16
+ expect(PWN::AI::Agent::ToolGuard.placeholder?(text: "echo hi\n...\n")).to eq(true)
17
+ expect(PWN::AI::Agent::ToolGuard.placeholder?(text: 'echo hi ...')).to eq(false)
18
+ expect(PWN::AI::Agent::ToolGuard.placeholder?(text: '[ ... ]')).to eq(true)
19
+ expect(PWN::AI::Agent::ToolGuard.placeholder?(text: '...', placeholder_ok: true)).to eq(false)
20
+ end
21
+
22
+ it 'names the colliding payload hash and normalized fields on identical retry' do
23
+ 3.times do
24
+ n = PWN::AI::Agent::Loop.send(:note_same_payload!, name: 'shell', args: { command: "echo hi\n" })
25
+ next if n < 3
26
+
27
+ raw = PWN::AI::Agent::Loop.send(:checkpoint_result, name: 'shell', args: { command: "echo hi\n" })
28
+ body = JSON.parse(raw)
29
+ expect(body['payload_hash']).to match(/\A[0-9a-f]{16}\z/)
30
+ expect(body['normalized_fields']).to include('command')
31
+ expect(body['error']).to include("hash=#{body['payload_hash']}")
32
+ end
33
+ end
34
+
35
+ it 'injects extracted output paths into the system prompt' do
36
+ prompt = PWN::AI::Agent::PromptBuilder.build(request: 'Write the report to /tmp/pwn-harness-out.md')
37
+ expect(prompt).to include('DELIVERABLES')
38
+ expect(prompt).to include('/tmp/pwn-harness-out.md')
39
+ end
40
+
41
+ it 'expects a regex over a PTY session' do
42
+ id = PWN::Plugins::ProcessTube.spawn(cmd: ['printf', "uid=0(root)\n"])[:id]
43
+ out = PWN::Plugins::ProcessTube.expect(id: id, pattern: /uid=\d+/, timeout: 2, strip_ansi: true)
44
+ expect(out[:matched]).to match(/uid=/)
45
+ PWN::Plugins::ProcessTube.close(id: id)
46
+ end
47
+
48
+ it 'consumes matched PTY output so a second expect needs new bytes' do
49
+ id = PWN::Plugins::ProcessTube.spawn(cmd: ['printf', "uid=0(root)\nready\n"])[:id]
50
+ first = PWN::Plugins::ProcessTube.expect(id: id, pattern: /uid=\d+/, timeout: 2)
51
+ second = PWN::Plugins::ProcessTube.expect(id: id, pattern: /ready/, timeout: 2)
52
+ expect(first[:matched]).to match(/uid=/)
53
+ expect(second[:matched]).to include('ready')
54
+ PWN::Plugins::ProcessTube.close(id: id)
55
+ end
56
+
57
+ it 'runs a three-node job graph with partials on failure' do
58
+ Dir.mktmpdir('jobs') do |dir|
59
+ result = PWN::Plugins::Jobs.graph(
60
+ artifact_dir: dir,
61
+ jobs: [
62
+ { id: 'a', command: 'echo enum > a.txt' },
63
+ { id: 'b', command: 'echo httpx >> a.txt', needs: ['a'] },
64
+ { id: 'c', command: 'echo nuclei >> a.txt', needs: ['b'] }
65
+ ]
66
+ )
67
+ expect(result[:ok]).to eq(true)
68
+ expect(File.read(File.join(dir, 'a.txt'))).to include('nuclei')
69
+ end
70
+ end
71
+
72
+ it 'retains partials when an upstream job fails' do
73
+ Dir.mktmpdir('jobs-fail') do |dir|
74
+ result = PWN::Plugins::Jobs.graph(
75
+ artifact_dir: dir,
76
+ jobs: [
77
+ { id: 'a', command: 'false' },
78
+ { id: 'b', command: 'echo b', needs: ['a'] },
79
+ { id: 'c', command: 'echo c', needs: ['b'] }
80
+ ]
81
+ )
82
+ expect(result[:jobs]['a'][:ok]).to eq(false)
83
+ expect(result[:jobs]['b'][:skipped]).to eq(true)
84
+ expect(result[:jobs]['c'][:skipped]).to eq(true)
85
+ end
86
+ end
87
+
88
+ it 'enforces a per-job timeout' do
89
+ Dir.mktmpdir('jobs-to') do |dir|
90
+ result = PWN::Plugins::Jobs.graph(
91
+ artifact_dir: dir,
92
+ jobs: [{ id: 'slow', command: 'sleep 2', timeout: 0.05 }]
93
+ )
94
+ expect(result[:jobs]['slow'][:timeout]).to eq(true)
95
+ end
96
+ end
97
+
98
+ it 'dedups fuzz crashes and scores the minimized file' do
99
+ Dir.mktmpdir('crash') do |dir|
100
+ a = File.join(dir, '1')
101
+ b = File.join(dir, '2')
102
+ File.binwrite(a, 'AAAAAAAAB')
103
+ File.binwrite(b, 'AAAAAAAAB')
104
+ rows = PWN::Plugins::Fuzz.triage(dir: dir)
105
+ expect(rows.length).to eq(1)
106
+ expect(rows.first[:count]).to eq(2)
107
+ expect(rows.first[:exploitability]).to eq('pc_control_candidate')
108
+ end
109
+ end
110
+
111
+ it 'raises on an offline corpus cache miss' do
112
+ Dir.mktmpdir('corpus-miss') do |dir|
113
+ stub_const('PWN::Corpus::ROOT', dir)
114
+ expect { PWN::Corpus.get(name: :subdomains_top1m, offline: true) }.to raise_error(IOError, /offline/)
115
+ end
116
+ end
117
+
118
+ it 'parses nmap XML without requiring a live scan' do
119
+ Dir.mktmpdir('nmap') do |dir|
120
+ xml = File.join(dir, 'scan.xml')
121
+ File.write(xml, <<~XML)
122
+ <?xml version="1.0"?>
123
+ <nmaprun>
124
+ <host><address addr="10.0.0.1" addrtype="ipv4"/>
125
+ <ports><port protocol="tcp" portid="22"><state state="open"/><service name="ssh"/></port></ports>
126
+ </host>
127
+ </nmaprun>
128
+ XML
129
+ allow(PWN::Engagement).to receive(:warn_unless_in_scope).and_return({ ok: true })
130
+ allow(PWN::Engagement).to receive(:merge_scan)
131
+ row = PWN::Plugins::NmapIt.scan(xml: xml, engagement: false)
132
+ expect(row[:hosts].first[:host]).to eq('10.0.0.1')
133
+ expect(row[:ports].first[:port]).to eq(22)
134
+ end
135
+ end
136
+
137
+ it 'health lines include Plugin-Degradation anchors' do
138
+ doc = File.read(File.join(__dir__, '../../../../../documentation/Plugin-Degradation.md'))
139
+ PWN::Plugins::PreflightChecker::PLUGIN_DEPS.each_key do |plugin|
140
+ anchor = plugin.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
141
+ expect(doc).to include("## #{anchor}")
142
+ end
143
+ end
144
+ end