pwn 0.5.716 → 0.5.718
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 +1 -1
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tools/verify/SKILL.md +45 -0
- data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_db/SKILL.md +7 -0
- data/etc/default_skills/pwn/plugins/exploit_db/references/urls.md +4 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/sarif/SKILL.md +51 -0
- data/etc/default_skills/pwn/reports/sarif/references/urls.md +3 -0
- data/lib/pwn/ai/agent/dispatch.rb +1 -1
- data/lib/pwn/ai/agent/loop.rb +46 -12
- data/lib/pwn/ai/agent/mistakes.rb +5 -2
- data/lib/pwn/ai/agent/prompt_builder.rb +12 -1
- data/lib/pwn/ai/agent/reward.rb +33 -5
- data/lib/pwn/ai/agent/swarm.rb +45 -0
- data/lib/pwn/ai/agent/tool_guard.rb +38 -6
- data/lib/pwn/ai/agent/tools/finding_record.rb +32 -11
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +3 -1
- data/lib/pwn/ai/agent/tools/intel_lookup.rb +7 -3
- data/lib/pwn/ai/agent/tools/pty_session.rb +43 -0
- data/lib/pwn/ai/agent/tools/session_export.rb +2 -2
- data/lib/pwn/ai/agent/tools/shell.rb +4 -1
- data/lib/pwn/ai/agent/tools/verify.rb +63 -0
- data/lib/pwn/memory.rb +56 -6
- data/lib/pwn/plugins/aflplusplus.rb +18 -0
- data/lib/pwn/plugins/binary_parser.rb +23 -5
- data/lib/pwn/plugins/detect_os.rb +2 -14
- data/lib/pwn/plugins/exploit_db.rb +155 -27
- data/lib/pwn/plugins/exploit_dev.rb +33 -5
- data/lib/pwn/plugins/findings.rb +41 -2
- data/lib/pwn/plugins/jobs.rb +43 -2
- data/lib/pwn/plugins/preflight_checker.rb +11 -1
- data/lib/pwn/plugins/process_tube.rb +44 -0
- data/lib/pwn/plugins/radare2.rb +9 -1
- data/lib/pwn/reports/sarif.rb +60 -0
- data/lib/pwn/reports.rb +1 -0
- data/lib/pwn/sessions.rb +22 -4
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/fixtures/error_class_docker.json +1 -1
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +2 -1
- data/spec/lib/pwn/ai/agent/reward_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +2 -1
- data/spec/lib/pwn/ai/agent/tools/pty_session_spec.rb +1 -0
- data/spec/lib/pwn/ai/agent/tools/verify_spec.rb +24 -0
- data/spec/lib/pwn/plugins/binary_parser_spec.rb +14 -0
- data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -1
- data/spec/lib/pwn/plugins/exploit_db_spec.rb +45 -5
- data/spec/lib/pwn/plugins/findings_spec.rb +13 -1
- data/spec/lib/pwn/reports/sarif_spec.rb +28 -0
- data/third_party/pwn_rdoc.jsonl +28 -1
- metadata +12 -5
- data/etc/intel/files_exploits.csv +0 -2
data/lib/pwn/plugins/findings.rb
CHANGED
|
@@ -30,6 +30,10 @@ module PWN
|
|
|
30
30
|
evidence: opts[:evidence].to_s,
|
|
31
31
|
poc: opts[:poc].to_s,
|
|
32
32
|
poc_artifacts: arts,
|
|
33
|
+
cvss: opts[:cvss].to_s,
|
|
34
|
+
status: (opts[:status] || 'open').to_s,
|
|
35
|
+
engagement_id: opts[:engagement_id].to_s,
|
|
36
|
+
chain_parent_id: opts[:chain_parent_id].to_s,
|
|
33
37
|
session_id: opts[:session_id].to_s,
|
|
34
38
|
at: Time.now.utc.iso8601
|
|
35
39
|
}
|
|
@@ -51,6 +55,21 @@ module PWN
|
|
|
51
55
|
rows
|
|
52
56
|
end
|
|
53
57
|
|
|
58
|
+
public_class_method def self.query(opts = {})
|
|
59
|
+
report(opts)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
public_class_method def self.chain(opts = {})
|
|
63
|
+
parent_id = opts[:parent_id].to_s
|
|
64
|
+
raise 'ERROR: parent_id is required' if parent_id.empty?
|
|
65
|
+
|
|
66
|
+
child = record(opts.merge(chain_parent_id: parent_id))
|
|
67
|
+
ranks = { 'info' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4 }
|
|
68
|
+
parent = report.find { |r| r[:id].to_s == parent_id }
|
|
69
|
+
sev = [parent&.[](:severity), child[:severity]].compact.max_by { |s| ranks[s.to_s] || 0 }
|
|
70
|
+
child.merge(composite_severity: sev)
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
public_class_method def self.render(opts = {})
|
|
55
74
|
dir = opts[:dir_path].to_s
|
|
56
75
|
dir = File.join(Dir.home, '.pwn', 'exports') if dir.empty?
|
|
@@ -60,7 +79,8 @@ module PWN
|
|
|
60
79
|
{
|
|
61
80
|
markdown: PWN::Reports::Markdown.generate(results_hash: payload, dir_path: dir, report_name: name),
|
|
62
81
|
html: PWN::Reports::HTML.generate(results_hash: payload, dir_path: dir, report_name: name),
|
|
63
|
-
json: PWN::Reports::JSON.generate(results_hash: payload, dir_path: dir, report_name: name)
|
|
82
|
+
json: PWN::Reports::JSON.generate(results_hash: payload, dir_path: dir, report_name: name),
|
|
83
|
+
sarif: PWN::Reports::SARIF.generate(results_hash: payload, dir_path: dir, report_name: name)
|
|
64
84
|
}
|
|
65
85
|
end
|
|
66
86
|
|
|
@@ -81,6 +101,25 @@ module PWN
|
|
|
81
101
|
evidence: 'optional - proof text or path',
|
|
82
102
|
poc: 'optional - filesystem path of a PoC',
|
|
83
103
|
poc_artifacts: 'required - Array of artifact paths proving the issue',
|
|
104
|
+
cvss: 'optional - CVSS vector or score string',
|
|
105
|
+
status: 'optional - open|closed (defaults to open)',
|
|
106
|
+
engagement_id: 'optional - engagement identifier',
|
|
107
|
+
session_id: 'optional - pwn-ai session id',
|
|
108
|
+
chain_parent_id: 'optional - id of a parent finding this issue chains from'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Alias of report for querying stored findings.
|
|
112
|
+
#{self}.query(
|
|
113
|
+
host: 'optional - affected host or URL'
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Record a child finding chained to a parent and return composite severity.
|
|
117
|
+
#{self}.chain(
|
|
118
|
+
parent_id: 'required - id of the parent finding',
|
|
119
|
+
title: 'required - short finding title',
|
|
120
|
+
severity: 'optional - info|low|medium|high|critical (defaults to info)',
|
|
121
|
+
poc_artifacts: 'required - Array of artifact paths proving the issue',
|
|
122
|
+
host: 'optional - affected host or URL',
|
|
84
123
|
session_id: 'optional - pwn-ai session id'
|
|
85
124
|
)
|
|
86
125
|
|
|
@@ -89,7 +128,7 @@ module PWN
|
|
|
89
128
|
host: 'optional - only rows whose host matches this string'
|
|
90
129
|
)
|
|
91
130
|
|
|
92
|
-
# Render findings as markdown, html, and
|
|
131
|
+
# Render findings as markdown, html, json, and SARIF reports.
|
|
93
132
|
#{self}.render(
|
|
94
133
|
dir_path: 'optional - output directory (defaults to ~/.pwn/exports)',
|
|
95
134
|
report_name: 'optional - basename without extension (defaults to findings)'
|
data/lib/pwn/plugins/jobs.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'open3'
|
|
7
|
+
require 'time'
|
|
7
8
|
|
|
8
9
|
module PWN
|
|
9
10
|
module Plugins
|
|
@@ -31,6 +32,7 @@ module PWN
|
|
|
31
32
|
command: cmd.to_s,
|
|
32
33
|
log: log,
|
|
33
34
|
session_id: opts[:session_id],
|
|
35
|
+
max_runtime: opts[:max_runtime].to_i,
|
|
34
36
|
started_at: Time.now.utc.iso8601
|
|
35
37
|
}
|
|
36
38
|
File.write(meta, JSON.generate(row))
|
|
@@ -46,7 +48,20 @@ module PWN
|
|
|
46
48
|
rescue Errno::ESRCH, Errno::EPERM
|
|
47
49
|
false
|
|
48
50
|
end
|
|
49
|
-
row.
|
|
51
|
+
max = row[:max_runtime].to_i
|
|
52
|
+
if alive && max.positive?
|
|
53
|
+
started = begin
|
|
54
|
+
Time.parse(row[:started_at].to_s)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
Time.now
|
|
57
|
+
end
|
|
58
|
+
if Time.now - started > max
|
|
59
|
+
Process.kill('TERM', row[:pid].to_i)
|
|
60
|
+
alive = false
|
|
61
|
+
row[:status] = 'TIMEOUT'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
row.merge(alive: alive, status: row[:status] || (alive ? 'RUNNING' : 'COMPLETED'))
|
|
50
65
|
end
|
|
51
66
|
|
|
52
67
|
public_class_method def self.tail(opts = {})
|
|
@@ -62,6 +77,20 @@ module PWN
|
|
|
62
77
|
row.merge(tail: tail(opts.merge(lines: opts[:lines] || 80)))
|
|
63
78
|
end
|
|
64
79
|
|
|
80
|
+
public_class_method def self.harvest(opts = {})
|
|
81
|
+
result(opts)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
public_class_method def self.list(opts = {})
|
|
85
|
+
_limit = opts[:limit]
|
|
86
|
+
Dir[File.join(JOBS_DIR, '*.json')].filter_map do |path|
|
|
87
|
+
row = JSON.parse(File.read(path), symbolize_names: true)
|
|
88
|
+
status(id: row[:id])
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
65
94
|
public_class_method def self.stop(opts = {})
|
|
66
95
|
row = load_job(opts)
|
|
67
96
|
Process.kill('TERM', row[:pid].to_i)
|
|
@@ -83,7 +112,8 @@ module PWN
|
|
|
83
112
|
#{self}.start(
|
|
84
113
|
command: 'required - command value consumed by #start (defaults to opts[:cmd])',
|
|
85
114
|
cmd: 'required - command string to run',
|
|
86
|
-
session_id: 'optional - session id value consumed by #start'
|
|
115
|
+
session_id: 'optional - session id value consumed by #start',
|
|
116
|
+
max_runtime: 'optional - seconds after which a live job is killed (TIMEOUT)'
|
|
87
117
|
)
|
|
88
118
|
|
|
89
119
|
# Run status and return its result
|
|
@@ -100,6 +130,17 @@ module PWN
|
|
|
100
130
|
lines: 'optional - tail line count (defaults to 80)'
|
|
101
131
|
)
|
|
102
132
|
|
|
133
|
+
# Alias of result for harvesting a detached job from a later session.
|
|
134
|
+
#{self}.harvest(
|
|
135
|
+
id: 'required - job id from #start',
|
|
136
|
+
lines: 'optional - tail line count (defaults to 80)'
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# List job metadata rows under ~/.pwn/jobs.
|
|
140
|
+
#{self}.list(
|
|
141
|
+
limit: 'optional - unused cap reserved for callers'
|
|
142
|
+
)
|
|
143
|
+
|
|
103
144
|
# Run stop and return its result
|
|
104
145
|
#{self}.stop
|
|
105
146
|
|
|
@@ -30,6 +30,14 @@ module PWN
|
|
|
30
30
|
'PWN::Plugins::CredentialAttack' => { bins: %w[hydra john hashcat] }
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
|
+
FALLBACKS = {
|
|
34
|
+
'PWN::Plugins::Metasploit' => %w[exploitdev pwn_eval],
|
|
35
|
+
'PWN::Plugins::BurpSuite' => %w[TransparentBrowser nuclei],
|
|
36
|
+
'PWN::Plugins::Zaproxy' => %w[nuclei TransparentBrowser],
|
|
37
|
+
'PWN::Plugins::K8s' => %w[pwn_eval],
|
|
38
|
+
'PWN::Plugins::ExploitDB' => %w[intel_lookup]
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
33
41
|
public_class_method def self.required_bins
|
|
34
42
|
[]
|
|
35
43
|
end
|
|
@@ -137,7 +145,9 @@ module PWN
|
|
|
137
145
|
lines = ["HOST plugins ok=#{rows.count { |r| r[:status] == :ok }} degraded=#{degraded.length} dead=#{dead.length}"]
|
|
138
146
|
(degraded + dead).first(cap).each do |r|
|
|
139
147
|
miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services])).join(',')
|
|
140
|
-
|
|
148
|
+
fb = Array(FALLBACKS[r[:plugin]]).join(',')
|
|
149
|
+
extra = fb.empty? ? '' : " fallback=#{fb}"
|
|
150
|
+
lines << " #{r[:status]} #{r[:plugin]} missing=#{miss}#{extra}"
|
|
141
151
|
end
|
|
142
152
|
lines.join("\n")
|
|
143
153
|
end
|
|
@@ -79,6 +79,32 @@ module PWN
|
|
|
79
79
|
false
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
public_class_method def self.expect(opts = {})
|
|
83
|
+
text = recvuntil(opts)
|
|
84
|
+
t = tube!(opts)
|
|
85
|
+
{ matched: text, offset: t[:buf].to_s.bytesize, id: opts[:id].to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
public_class_method def self.stream(opts = {})
|
|
89
|
+
t = tube!(opts)
|
|
90
|
+
off = opts[:offset].to_i
|
|
91
|
+
begin
|
|
92
|
+
t[:buf] << t[:r].read_nonblock(4_096)
|
|
93
|
+
rescue IO::WaitReadable, EOFError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
data = t[:buf].to_s.byteslice(off..-1).to_s
|
|
97
|
+
{ data: data, offset: t[:buf].to_s.bytesize, id: opts[:id].to_s }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
public_class_method def self.reap_orphans(opts = {})
|
|
101
|
+
_sid = opts[:session_id]
|
|
102
|
+
n = @tubes.length
|
|
103
|
+
@tubes.each_key { |id| close(id: id) }
|
|
104
|
+
@tubes.clear
|
|
105
|
+
n
|
|
106
|
+
end
|
|
107
|
+
|
|
82
108
|
public_class_method def self.authors
|
|
83
109
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
84
110
|
end
|
|
@@ -121,6 +147,24 @@ module PWN
|
|
|
121
147
|
id: 'optional - id value consumed by #close'
|
|
122
148
|
)
|
|
123
149
|
|
|
150
|
+
# Expect a pattern and return matched buffer plus byte offset.
|
|
151
|
+
#{self}.expect(
|
|
152
|
+
id: 'required - tube id from spawn or connect',
|
|
153
|
+
until: 'required - substring to wait for',
|
|
154
|
+
timeout: 'optional - seconds to wait before giving up'
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Tail the PTY buffer since a prior offset.
|
|
158
|
+
#{self}.stream(
|
|
159
|
+
id: 'required - tube id from spawn or connect',
|
|
160
|
+
offset: 'optional - byte offset to start from (defaults to 0)'
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Close every open tube (session end / orphan GC).
|
|
164
|
+
#{self}.reap_orphans(
|
|
165
|
+
session_id: 'optional - agent session id for transcript grouping'
|
|
166
|
+
)
|
|
167
|
+
|
|
124
168
|
# Print the AUTHOR(S) string for this module.
|
|
125
169
|
#{self}.authors
|
|
126
170
|
"
|
data/lib/pwn/plugins/radare2.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'open3'
|
|
5
5
|
require 'securerandom'
|
|
6
|
+
require 'digest'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
module Plugins
|
|
@@ -24,10 +25,17 @@ module PWN
|
|
|
24
25
|
raise 'ERROR: path is required' if path.empty?
|
|
25
26
|
raise "ERROR: binary not found: #{path}" unless File.file?(path)
|
|
26
27
|
|
|
28
|
+
sha = Digest::SHA256.file(path).hexdigest
|
|
29
|
+
hit = @sessions.find { |_id, sess| sess[:path] == path && sess[:sha256] == sha }
|
|
30
|
+
return hit[0] if hit
|
|
31
|
+
|
|
27
32
|
stdin, stdout, waiter = Open3.popen2('r2', '-q0', '-e', 'scr.color=0', path)
|
|
28
33
|
sid = SecureRandom.hex(8)
|
|
29
|
-
|
|
34
|
+
pid = waiter.pid if waiter.respond_to?(:pid)
|
|
35
|
+
@sessions[sid] = { stdin: stdin, stdout: stdout, waiter: waiter, path: path, sha256: sha, pid: pid }
|
|
30
36
|
read_until_null(io: stdout)
|
|
37
|
+
cmd(session: sid, cmd: 'aaa')
|
|
38
|
+
@sessions[sid][:aaa] = true
|
|
31
39
|
sid
|
|
32
40
|
end
|
|
33
41
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module Reports
|
|
7
|
+
# SARIF 2.1.0 writer for finding export.
|
|
8
|
+
module SARIF
|
|
9
|
+
public_class_method def self.generate(opts = {})
|
|
10
|
+
payload = PWN::Reports.report_payload(opts)
|
|
11
|
+
doc = {
|
|
12
|
+
version: '2.1.0',
|
|
13
|
+
'$schema' => 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
14
|
+
runs: [
|
|
15
|
+
{
|
|
16
|
+
tool: { driver: { name: 'pwn', version: (defined?(PWN::VERSION) ? PWN::VERSION : '0') } },
|
|
17
|
+
results: Array(payload[:findings]).map { |row| result_row(row: row) }
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
path = PWN::Reports.resolve_path(opts.merge(ext: 'sarif.json'))
|
|
22
|
+
File.write(path, ::JSON.pretty_generate(doc))
|
|
23
|
+
path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method def self.result_row(opts = {})
|
|
27
|
+
row = opts[:row]
|
|
28
|
+
row = {} unless row.is_a?(Hash)
|
|
29
|
+
sev = (row['severity'] || row[:severity] || 'note').to_s
|
|
30
|
+
level = { 'critical' => 'error', 'high' => 'error', 'medium' => 'warning', 'low' => 'note', 'info' => 'note' }[sev] || 'note'
|
|
31
|
+
{
|
|
32
|
+
ruleId: (row['id'] || row[:id] || 'finding').to_s,
|
|
33
|
+
level: level,
|
|
34
|
+
message: { text: (row['title'] || row[:title]).to_s },
|
|
35
|
+
properties: row
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
public_class_method def self.authors
|
|
40
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
public_class_method def self.help
|
|
44
|
+
puts "USAGE:
|
|
45
|
+
# Write a SARIF 2.1 document from a findings hash.
|
|
46
|
+
#{self}.generate(
|
|
47
|
+
results_hash: 'required - Hash with :findings Array',
|
|
48
|
+
dir_path: 'optional - output directory',
|
|
49
|
+
report_name: 'optional - basename without extension',
|
|
50
|
+
path: 'optional - exact output path'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Print the AUTHOR(S) string for this module.
|
|
54
|
+
#{self}.authors
|
|
55
|
+
"
|
|
56
|
+
constants.sort
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/pwn/reports.rb
CHANGED
data/lib/pwn/sessions.rb
CHANGED
|
@@ -162,6 +162,7 @@ module PWN
|
|
|
162
162
|
include_current = opts[:include_current] == true
|
|
163
163
|
|
|
164
164
|
hits = []
|
|
165
|
+
target = opts[:target].to_s.downcase
|
|
165
166
|
list.sort_by { |s| s[:mtime].to_s }.reverse.first(max_files).each do |sess|
|
|
166
167
|
sid = sess[:id].to_s
|
|
167
168
|
next if !include_current && !exclude.empty? && sid == exclude
|
|
@@ -172,9 +173,12 @@ module PWN
|
|
|
172
173
|
|
|
173
174
|
body = row[:content].to_s
|
|
174
175
|
next if body.strip.empty?
|
|
176
|
+
next if !target.empty? && !body.downcase.include?(target)
|
|
175
177
|
|
|
176
178
|
score = recall_score(body: body.downcase, query: query, tokens: tokens)
|
|
177
|
-
next if score <= 0
|
|
179
|
+
next if score <= 0 && target.empty?
|
|
180
|
+
|
|
181
|
+
score = [score, 1].max unless target.empty?
|
|
178
182
|
|
|
179
183
|
hits << {
|
|
180
184
|
session_id: sid,
|
|
@@ -561,7 +565,18 @@ module PWN
|
|
|
561
565
|
dest = File.join(dir, "#{sid}.tar.gz")
|
|
562
566
|
manifest = { File.basename(src) => Digest::SHA256.file(src).hexdigest }
|
|
563
567
|
Dir.mktmpdir do |tmp|
|
|
564
|
-
|
|
568
|
+
body = File.read(src)
|
|
569
|
+
body = redact(content: body)
|
|
570
|
+
File.write(File.join(tmp, File.basename(src)), body)
|
|
571
|
+
eng = opts[:engagement_id].to_s
|
|
572
|
+
unless eng.empty? && opts[:engagement] != true
|
|
573
|
+
rendered = PWN::Plugins::Findings.render(dir_path: tmp, report_name: 'findings') if defined?(PWN::Plugins::Findings)
|
|
574
|
+
Array(rendered&.values).each do |p|
|
|
575
|
+
next unless p.to_s != '' && File.file?(p.to_s)
|
|
576
|
+
|
|
577
|
+
manifest[File.basename(p)] = Digest::SHA256.file(p).hexdigest
|
|
578
|
+
end
|
|
579
|
+
end
|
|
565
580
|
File.write(File.join(tmp, 'manifest.json'), JSON.generate(manifest))
|
|
566
581
|
system('tar', '-czf', dest, '-C', tmp, '.')
|
|
567
582
|
end
|
|
@@ -653,7 +668,8 @@ module PWN
|
|
|
653
668
|
include_current: 'optional - Boolean include excluded id (default false)',
|
|
654
669
|
limit: 'optional - max hits (default 12)',
|
|
655
670
|
max_files: 'optional - newest files to scan (default 40)',
|
|
656
|
-
truncate: 'optional - chars per hit (default 280)'
|
|
671
|
+
truncate: 'optional - chars per hit (default 280)',
|
|
672
|
+
target: 'optional - IP, host, or token that must appear in the hit'
|
|
657
673
|
)
|
|
658
674
|
|
|
659
675
|
# Run to response history and return its result
|
|
@@ -704,7 +720,9 @@ module PWN
|
|
|
704
720
|
|
|
705
721
|
# Pack a session jsonl plus artifacts into ~/.pwn/exports/<id>.tar.gz.
|
|
706
722
|
#{self}.export(
|
|
707
|
-
session_id: 'required - session id to export'
|
|
723
|
+
session_id: 'required - session id to export',
|
|
724
|
+
engagement_id: 'optional - include findings SARIF/md/json in the tarball',
|
|
725
|
+
engagement: 'optional - Boolean include findings even without an id'
|
|
708
726
|
)
|
|
709
727
|
|
|
710
728
|
# Delete session jsonl files older than days (defaults to 90).
|
data/lib/pwn/version.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cases": [
|
|
3
|
-
{"error": "pull access denied for library/foo", "class": "
|
|
3
|
+
{"error": "pull access denied for library/foo", "class": "docker_registry_auth"},
|
|
4
4
|
{"error": "Conflict. The container name is already in use", "class": "name_conflict"},
|
|
5
5
|
{"error": "template parsing error: unexpected token", "class": "parse_error"}
|
|
6
6
|
]
|
|
@@ -197,8 +197,9 @@ describe PWN::AI::Agent::Mistakes do
|
|
|
197
197
|
end
|
|
198
198
|
|
|
199
199
|
it 'classifies pull-denied and name-conflict as distinct error classes' do
|
|
200
|
-
expect(described_class.error_class(error: 'pull access denied')).to eq('
|
|
200
|
+
expect(described_class.error_class(error: 'pull access denied')).to eq('docker_registry_auth')
|
|
201
201
|
expect(described_class.error_class(error: 'name already in use')).to eq('name_conflict')
|
|
202
|
+
expect(described_class.error_class(error: 'Cannot connect to the Docker daemon')).to eq('docker_daemon')
|
|
202
203
|
end
|
|
203
204
|
|
|
204
205
|
it 'does not collapse docker socket perms into missing_path' do
|
|
@@ -411,7 +411,7 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
411
411
|
|
|
412
412
|
it 'floors verified PASS analytical answers at 0.6' do
|
|
413
413
|
src = File.read(PWN::AI::Agent::Reward.method(:judge).source_location.first)
|
|
414
|
-
expect(src).to include('[score, 0.
|
|
414
|
+
expect(src).to include('[score, 0.7].max')
|
|
415
415
|
expect(src).to include('\bPASS\b')
|
|
416
416
|
end
|
|
417
417
|
|
|
@@ -184,7 +184,8 @@ describe PWN::AI::Agent::ToolGuard do
|
|
|
184
184
|
allow(Dir).to receive(:home).and_return(dir)
|
|
185
185
|
expect(described_class.scope_refusal(command: 'scan 10.0.0.5')).to be_nil
|
|
186
186
|
expect(described_class.scope_refusal(command: 'scan 192.168.1.2')).to be_nil
|
|
187
|
-
expect(described_class.scope_refusal(command: 'scan 8.8.8.8')).to
|
|
187
|
+
expect(described_class.scope_refusal(command: 'scan 8.8.8.8')[:token]).to eq('8.8.8.8')
|
|
188
|
+
expect(described_class.scope_refusal(command: 'scan 8.8.8.8')[:code]).to eq('SCOPE_DENY')
|
|
188
189
|
end
|
|
189
190
|
end
|
|
190
191
|
|
|
@@ -13,5 +13,6 @@ describe 'PWN::AI::Agent::Tools pty_session' do
|
|
|
13
13
|
expect(PWN::AI::Agent::Registry.lookup(name: 'pty_send')).not_to be_nil
|
|
14
14
|
expect(PWN::AI::Agent::Registry.lookup(name: 'pty_read')).not_to be_nil
|
|
15
15
|
expect(PWN::AI::Agent::Registry.lookup(name: 'pty_close')).not_to be_nil
|
|
16
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'pty_session')).not_to be_nil
|
|
16
17
|
end
|
|
17
18
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
describe 'PWN::AI::Agent::Tools verify' do
|
|
7
|
+
before(:all) do
|
|
8
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
9
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/verify.rb'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'registers expected tool names' do
|
|
13
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'verify')).not_to be_nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'passes file_exists when the path is on disk' do
|
|
17
|
+
Dir.mktmpdir do |dir|
|
|
18
|
+
path = File.join(dir, 'x')
|
|
19
|
+
File.write(path, 'ok')
|
|
20
|
+
out = PWN::AI::Agent::Registry.lookup(name: 'verify').handler.call(checks: [{ type: 'file_exists', path: path }])
|
|
21
|
+
expect(out[:passed]).to be true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
4
5
|
|
|
5
6
|
describe PWN::Plugins::BinaryParser do
|
|
6
7
|
it 'should display information for authors' do
|
|
@@ -18,4 +19,17 @@ describe PWN::Plugins::BinaryParser do
|
|
|
18
19
|
expect(info[:class].to_s).to match(/ELF|elf/i)
|
|
19
20
|
expect(described_class.sections(path: path)).to be_an(Array)
|
|
20
21
|
end
|
|
22
|
+
|
|
23
|
+
it 'triage returns structured JSON fields and caches by sha256' do
|
|
24
|
+
path = %w[/bin/ls /usr/bin/ls /bin/true /usr/bin/true].find { |p| File.file?(p) }
|
|
25
|
+
skip 'no ELF on PATH' unless path
|
|
26
|
+
Dir.mktmpdir do |dir|
|
|
27
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
28
|
+
first = described_class.triage(path: path)
|
|
29
|
+
expect(first).to include(:format, :arch, :protections, :sha256, :sections)
|
|
30
|
+
expect(first[:cached]).to be false
|
|
31
|
+
second = described_class.triage(path: path)
|
|
32
|
+
expect(second[:cached]).to be true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
21
35
|
end
|
|
@@ -80,7 +80,6 @@ describe PWN::Plugins::DetectOS do
|
|
|
80
80
|
expect(row[:native]).not_to include('gdb', 'r2', 'nuclei')
|
|
81
81
|
expect(row).not_to have_key(:missing)
|
|
82
82
|
expect(row[:summary]).to include('kali')
|
|
83
|
-
expect(row[:summary]).to include('jq')
|
|
84
83
|
expect(row[:summary]).not_to include('missing=')
|
|
85
84
|
end
|
|
86
85
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
4
6
|
|
|
5
7
|
describe PWN::Plugins::ExploitDB do
|
|
6
8
|
it 'should display information for authors' do
|
|
@@ -11,11 +13,49 @@ describe PWN::Plugins::ExploitDB do
|
|
|
11
13
|
expect(described_class).to respond_to :help
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
it '
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
it 'searches a CSV already synced under ~/.pwn/intel' do
|
|
17
|
+
Dir.mktmpdir do |dir|
|
|
18
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
19
|
+
intel = File.join(dir, '.pwn', 'intel')
|
|
20
|
+
FileUtils.mkdir_p(intel)
|
|
21
|
+
File.write(File.join(intel, 'files_exploits.csv'), "id,file,description,cve\n1,e.rb,Apache Log4j RCE,CVE-2021-44228\n")
|
|
22
|
+
allow(PWN::Plugins::PreflightChecker).to receive(:bin?).and_return(false)
|
|
23
|
+
row = described_class.search(query: 'CVE-2021-44228')
|
|
24
|
+
expect(row.to_s.downcase).to match(/log4|44228/)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'looks up tar from gtfobins.json under ~/.pwn/intel' do
|
|
29
|
+
Dir.mktmpdir do |dir|
|
|
30
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
31
|
+
intel = File.join(dir, '.pwn', 'intel')
|
|
32
|
+
FileUtils.mkdir_p(intel)
|
|
33
|
+
File.write(File.join(intel, 'gtfobins.json'), '{"tar":{"binary":"tar","functions":["file-read"]}}')
|
|
34
|
+
row = described_class.gtfobins(binary: 'tar')
|
|
35
|
+
expect(row[:hit].to_s.downcase).to include('tar')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'sync fetches latest ExploitDB CSV and GTFOBins into ~/.pwn/intel' do
|
|
40
|
+
Dir.mktmpdir do |dir|
|
|
41
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
42
|
+
csv = "id,file,description,cve\n1,e.rb,Apache Log4j RCE,CVE-2021-44228\n"
|
|
43
|
+
json = '{"tar":{"binary":"tar","functions":["file-read"]}}'
|
|
44
|
+
get = lambda do |url|
|
|
45
|
+
url.to_s.include?('files_exploits') ? csv : json
|
|
46
|
+
end
|
|
47
|
+
out = described_class.sync(get: get)
|
|
48
|
+
intel = File.join(dir, '.pwn', 'intel')
|
|
49
|
+
expect(out[:dest]).to eq(intel)
|
|
50
|
+
expect(File.read(File.join(intel, 'files_exploits.csv'))).to include('CVE-2021-44228')
|
|
51
|
+
expect(File.read(File.join(intel, 'gtfobins.json'))).to include('tar')
|
|
52
|
+
expect(out[:fetched]).to include('files_exploits.csv', 'gtfobins.json')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'does not copy from a tree-bundled etc/intel directory' do
|
|
57
|
+
src = File.read(described_class.method(:sync).source_location.first)
|
|
58
|
+
expect(src).not_to include('etc/intel')
|
|
19
59
|
end
|
|
20
60
|
|
|
21
61
|
it 'responds to search and cve_for_cpe' do
|
|
@@ -24,7 +24,19 @@ describe PWN::Plugins::Findings do
|
|
|
24
24
|
out = described_class.render(dir_path: dir, report_name: 'findings')
|
|
25
25
|
expect(File.file?(out[:markdown])).to be true
|
|
26
26
|
expect(File.file?(out[:html])).to be true
|
|
27
|
-
expect(File.file?(out[:
|
|
27
|
+
expect(File.file?(out[:sarif])).to be true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'chains a child finding and reports composite severity' do
|
|
32
|
+
Dir.mktmpdir do |dir|
|
|
33
|
+
stub_const('PWN::Plugins::Findings::FILE', File.join(dir, 'findings.jsonl'))
|
|
34
|
+
poc = File.join(dir, 'poc.rb')
|
|
35
|
+
File.write(poc, 'puts 1')
|
|
36
|
+
parent = described_class.record(title: 'xss', poc_artifacts: [poc], severity: 'medium')
|
|
37
|
+
child = described_class.chain(parent_id: parent[:id], title: 'account-takeover', poc_artifacts: [poc], severity: 'high')
|
|
38
|
+
expect(child[:chain_parent_id]).to eq(parent[:id])
|
|
39
|
+
expect(child[:composite_severity]).to eq('high')
|
|
28
40
|
end
|
|
29
41
|
end
|
|
30
42
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe PWN::Reports::SARIF do
|
|
8
|
+
it 'should display information for authors' do
|
|
9
|
+
expect(described_class).to respond_to :authors
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'should display information for existing help method' do
|
|
13
|
+
expect(described_class).to respond_to :help
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'writes a SARIF 2.1 document with finding results' do
|
|
17
|
+
Dir.mktmpdir do |dir|
|
|
18
|
+
path = described_class.generate(
|
|
19
|
+
results_hash: { title: 't', findings: [{ id: 'a1', title: 'xss', severity: 'high' }] },
|
|
20
|
+
dir_path: dir,
|
|
21
|
+
report_name: 'eng'
|
|
22
|
+
)
|
|
23
|
+
doc = JSON.parse(File.read(path))
|
|
24
|
+
expect(doc['version']).to eq('2.1.0')
|
|
25
|
+
expect(doc['runs'].first['results'].first['message']['text']).to eq('xss')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|