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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +2 -0
  4. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
  5. data/etc/default_skills/pwn/ai/agent/tools/verify/SKILL.md +45 -0
  6. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +1 -0
  7. data/etc/default_skills/pwn/plugins/exploit_db/SKILL.md +7 -0
  8. data/etc/default_skills/pwn/plugins/exploit_db/references/urls.md +4 -0
  9. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/findings/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +2 -0
  12. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +3 -0
  13. data/etc/default_skills/pwn/reports/sarif/SKILL.md +51 -0
  14. data/etc/default_skills/pwn/reports/sarif/references/urls.md +3 -0
  15. data/lib/pwn/ai/agent/dispatch.rb +1 -1
  16. data/lib/pwn/ai/agent/loop.rb +46 -12
  17. data/lib/pwn/ai/agent/mistakes.rb +5 -2
  18. data/lib/pwn/ai/agent/prompt_builder.rb +12 -1
  19. data/lib/pwn/ai/agent/reward.rb +33 -5
  20. data/lib/pwn/ai/agent/swarm.rb +45 -0
  21. data/lib/pwn/ai/agent/tool_guard.rb +38 -6
  22. data/lib/pwn/ai/agent/tools/finding_record.rb +32 -11
  23. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +3 -1
  24. data/lib/pwn/ai/agent/tools/intel_lookup.rb +7 -3
  25. data/lib/pwn/ai/agent/tools/pty_session.rb +43 -0
  26. data/lib/pwn/ai/agent/tools/session_export.rb +2 -2
  27. data/lib/pwn/ai/agent/tools/shell.rb +4 -1
  28. data/lib/pwn/ai/agent/tools/verify.rb +63 -0
  29. data/lib/pwn/memory.rb +56 -6
  30. data/lib/pwn/plugins/aflplusplus.rb +18 -0
  31. data/lib/pwn/plugins/binary_parser.rb +23 -5
  32. data/lib/pwn/plugins/detect_os.rb +2 -14
  33. data/lib/pwn/plugins/exploit_db.rb +155 -27
  34. data/lib/pwn/plugins/exploit_dev.rb +33 -5
  35. data/lib/pwn/plugins/findings.rb +41 -2
  36. data/lib/pwn/plugins/jobs.rb +43 -2
  37. data/lib/pwn/plugins/preflight_checker.rb +11 -1
  38. data/lib/pwn/plugins/process_tube.rb +44 -0
  39. data/lib/pwn/plugins/radare2.rb +9 -1
  40. data/lib/pwn/reports/sarif.rb +60 -0
  41. data/lib/pwn/reports.rb +1 -0
  42. data/lib/pwn/sessions.rb +22 -4
  43. data/lib/pwn/version.rb +1 -1
  44. data/spec/lib/pwn/ai/agent/fixtures/error_class_docker.json +1 -1
  45. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +2 -1
  46. data/spec/lib/pwn/ai/agent/reward_spec.rb +1 -1
  47. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +2 -1
  48. data/spec/lib/pwn/ai/agent/tools/pty_session_spec.rb +1 -0
  49. data/spec/lib/pwn/ai/agent/tools/verify_spec.rb +24 -0
  50. data/spec/lib/pwn/plugins/binary_parser_spec.rb +14 -0
  51. data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -1
  52. data/spec/lib/pwn/plugins/exploit_db_spec.rb +45 -5
  53. data/spec/lib/pwn/plugins/findings_spec.rb +13 -1
  54. data/spec/lib/pwn/reports/sarif_spec.rb +28 -0
  55. data/third_party/pwn_rdoc.jsonl +28 -1
  56. metadata +12 -5
  57. data/etc/intel/files_exploits.csv +0 -2
@@ -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 json reports.
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)'
@@ -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.merge(alive: alive)
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
- lines << " #{r[:status]} #{r[:plugin]} missing=#{miss}"
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
  "
@@ -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
- @sessions[sid] = { stdin: stdin, stdout: stdout, waiter: waiter, path: path }
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
@@ -18,6 +18,7 @@ module PWN
18
18
  autoload :PDF, 'pwn/reports/pdf'
19
19
  autoload :Phone, 'pwn/reports/phone'
20
20
  autoload :SAST, 'pwn/reports/sast'
21
+ autoload :SARIF, 'pwn/reports/sarif'
21
22
  autoload :URIBuster, 'pwn/reports/uri_buster'
22
23
  autoload :XML, 'pwn/reports/xml'
23
24
 
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
- FileUtils.cp(src, File.join(tmp, File.basename(src)))
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,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.716'
4
+ VERSION = '0.5.718'
5
5
  end
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "cases": [
3
- {"error": "pull access denied for library/foo", "class": "auth_denied"},
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('auth_denied')
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.6].max')
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 include('8.8.8.8')
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 'returns log4shell from the local intel CSV when searchsploit is absent' do
15
- allow(PWN::Plugins::PreflightChecker).to receive(:bin?).and_return(false)
16
- row = described_class.search(query: 'CVE-2021-44228')
17
- blob = row.to_s.downcase
18
- expect(blob).to match(/log4|44228/)
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[:json])).to be true
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