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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +0 -1
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +17 -3
- data/documentation/Diagrams.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +74 -0
- data/documentation/Swarm.md +2 -3
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-REPL.md +0 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/browser-exploitation/SKILL.md +10 -0
- data/etc/default_skills/firmware-unpack-emulate/SKILL.md +10 -0
- data/etc/default_skills/heap-exploitation/SKILL.md +11 -0
- data/etc/default_skills/kernel-pwn-triage/SKILL.md +11 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -1
- data/etc/default_skills/pwn/ai/agent/tools/sbom_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/corpus/SKILL.md +52 -0
- data/etc/default_skills/pwn/corpus/references/urls.md +3 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/debugger/SKILL.md +62 -0
- data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/handler/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/mitm_proxy/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/redb/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/sbom/SKILL.md +48 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +1 -0
- data/etc/default_skills/rop-jop-chain-construction/SKILL.md +10 -0
- data/etc/default_skills/windows-internals-triage/SKILL.md +10 -0
- data/lib/pwn/ai/agent/loop.rb +80 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +19 -1
- data/lib/pwn/ai/agent/registry.rb +34 -4
- data/lib/pwn/ai/agent/swarm.rb +4 -5
- data/lib/pwn/ai/agent/tool_guard.rb +11 -4
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +2 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +9 -3
- data/lib/pwn/ai/agent/tools/pty_session.rb +7 -1
- data/lib/pwn/ai/agent/tools/sbom_scan.rb +25 -0
- data/lib/pwn/ai/agent/tools/shell.rb +3 -2
- data/lib/pwn/ai/agent/tools/swarm.rb +3 -6
- data/lib/pwn/ai/agent/turn_finalizer.rb +2 -2
- data/lib/pwn/corpus.rb +107 -0
- data/lib/pwn/engagement.rb +158 -0
- data/lib/pwn/plugins/debugger.rb +284 -0
- data/lib/pwn/plugins/findings.rb +14 -3
- data/lib/pwn/plugins/fuzz.rb +62 -0
- data/lib/pwn/plugins/handler.rb +171 -0
- data/lib/pwn/plugins/jobs.rb +55 -0
- data/lib/pwn/plugins/mitm_proxy.rb +37 -2
- data/lib/pwn/plugins/nmap_it.rb +32 -0
- data/lib/pwn/plugins/packet.rb +18 -0
- data/lib/pwn/plugins/preflight_checker.rb +6 -2
- data/lib/pwn/plugins/process_tube.rb +99 -8
- data/lib/pwn/plugins/redb.rb +210 -0
- data/lib/pwn/plugins/repl.rb +0 -3
- data/lib/pwn/plugins/sbom.rb +145 -0
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/redaction.rb +53 -0
- data/lib/pwn/reports/engagement.rb +2 -1
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +2 -0
- data/packer/provisioners/irc.sh +1 -1
- data/spec/lib/pwn/ai/agent/harness_2026_09_15_spec.rb +144 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +50 -0
- data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +31 -0
- data/spec/lib/pwn/ai/agent/tools/sbom_scan_spec.rb +10 -0
- data/spec/lib/pwn/corpus_spec.rb +23 -0
- data/spec/lib/pwn/engagement_spec.rb +27 -0
- data/spec/lib/pwn/plugins/debugger_spec.rb +27 -0
- data/spec/lib/pwn/plugins/handler_spec.rb +25 -0
- data/spec/lib/pwn/plugins/redb_spec.rb +33 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +7 -1
- data/spec/lib/pwn/plugins/sbom_spec.rb +22 -0
- data/third_party/pwn_rdoc.jsonl +97 -4
- metadata +36 -9
- data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +0 -47
- data/lib/pwn/plugins/repl/irc.rb +0 -153
- 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
|
data/lib/pwn/plugins/repl.rb
CHANGED
|
@@ -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
|
-
|
|
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
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'
|
data/packer/provisioners/irc.sh
CHANGED
|
@@ -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
|