pwn 0.5.739 → 0.5.741

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Agent-Tool-Registry.md +163 -6
  4. data/documentation/Diagrams.md +1 -1
  5. data/documentation/Home.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +6 -2
  8. data/documentation/Recon-Findings-API.md +33 -1
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-ai-Agent.md +1 -1
  11. data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
  12. data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
  13. data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
  14. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
  19. data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
  20. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
  21. data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
  22. data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
  23. data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
  24. data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
  25. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
  26. data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
  27. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
  33. data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
  34. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  35. data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
  36. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
  37. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
  38. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  39. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
  40. data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
  41. data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
  44. data/etc/default_skills/pwn/reports/SKILL.md +2 -0
  45. data/lib/pwn/ai/agent/confirmation.rb +163 -0
  46. data/lib/pwn/ai/agent/dispatch.rb +43 -5
  47. data/lib/pwn/ai/agent/loop.rb +45 -11
  48. data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
  49. data/lib/pwn/ai/agent/registry.rb +2 -1
  50. data/lib/pwn/ai/agent/result.rb +76 -6
  51. data/lib/pwn/ai/agent/task_dag.rb +220 -0
  52. data/lib/pwn/ai/agent/tool_guard.rb +166 -24
  53. data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
  54. data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
  55. data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
  56. data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
  57. data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
  58. data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
  59. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
  60. data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
  61. data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
  62. data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
  63. data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
  64. data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
  65. data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
  66. data/lib/pwn/ai/agent/tools/shell.rb +22 -10
  67. data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
  68. data/lib/pwn/ai/agent.rb +3 -1
  69. data/lib/pwn/ai/cli.rb +26 -2
  70. data/lib/pwn/ai/context.rb +4 -2
  71. data/lib/pwn/ai/router.rb +196 -0
  72. data/lib/pwn/ai/tools/README.md +5 -0
  73. data/lib/pwn/ai.rb +1 -0
  74. data/lib/pwn/config.rb +10 -0
  75. data/lib/pwn/engagement.rb +45 -1
  76. data/lib/pwn/ffi/capstone.rb +120 -0
  77. data/lib/pwn/ffi/keystone.rb +107 -0
  78. data/lib/pwn/ffi.rb +2 -0
  79. data/lib/pwn/migrate.rb +5 -1
  80. data/lib/pwn/plugins/aflplusplus.rb +238 -13
  81. data/lib/pwn/plugins/ai_sandbox.rb +240 -0
  82. data/lib/pwn/plugins/artifact_registry.rb +188 -22
  83. data/lib/pwn/plugins/assembly.rb +73 -2
  84. data/lib/pwn/plugins/basic_auth.rb +17 -4
  85. data/lib/pwn/plugins/binary_parser.rb +439 -38
  86. data/lib/pwn/plugins/exploit_dev.rb +83 -14
  87. data/lib/pwn/plugins/findings.rb +208 -42
  88. data/lib/pwn/plugins/gdbmi.rb +265 -38
  89. data/lib/pwn/plugins/httpx.rb +93 -0
  90. data/lib/pwn/plugins/jobs.rb +260 -102
  91. data/lib/pwn/plugins/method_catalog.rb +328 -0
  92. data/lib/pwn/plugins/nmap_it.rb +157 -26
  93. data/lib/pwn/plugins/nuclei.rb +122 -29
  94. data/lib/pwn/plugins/preflight_checker.rb +3 -2
  95. data/lib/pwn/plugins/process_tube.rb +51 -2
  96. data/lib/pwn/plugins/radare2.rb +120 -26
  97. data/lib/pwn/plugins/recon.rb +33 -0
  98. data/lib/pwn/plugins/repl/mesh.rb +203 -13
  99. data/lib/pwn/plugins/rop.rb +121 -31
  100. data/lib/pwn/plugins/transparent_browser.rb +212 -14
  101. data/lib/pwn/plugins/vault.rb +182 -24
  102. data/lib/pwn/plugins.rb +4 -0
  103. data/lib/pwn/reports/engagement.rb +8 -2
  104. data/lib/pwn/reports/html.rb +28 -9
  105. data/lib/pwn/reports/json.rb +2 -1
  106. data/lib/pwn/reports/markdown.rb +49 -12
  107. data/lib/pwn/reports/sarif.rb +17 -3
  108. data/lib/pwn/reports.rb +184 -34
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/integration/reports_spec.rb +93 -0
  111. data/spec/integration/result_condition_spec.rb +11 -8
  112. data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
  113. data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
  114. data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
  115. data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
  116. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  117. data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
  118. data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
  119. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  120. data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
  121. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
  122. data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
  123. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
  124. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
  125. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
  126. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
  127. data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
  128. data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
  129. data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
  130. data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
  131. data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
  132. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
  133. data/spec/lib/pwn/ai/cli_spec.rb +12 -1
  134. data/spec/lib/pwn/ai/router_spec.rb +57 -0
  135. data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
  136. data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
  137. data/spec/lib/pwn/migrate_spec.rb +30 -2
  138. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
  139. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
  140. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
  141. data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
  142. data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
  143. data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
  144. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
  145. data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
  146. data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
  147. data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
  148. data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
  149. data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
  150. data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
  151. data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
  152. data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
  153. data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
  154. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
  155. data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
  156. data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
  157. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
  158. data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
  159. data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
  160. data/spec/lib/pwn/reports_spec.rb +196 -0
  161. data/spec/spec_helper.rb +3 -0
  162. data/third_party/pwn_rdoc.jsonl +230 -4
  163. metadata +47 -5
@@ -35,11 +35,16 @@ module PWN
35
35
  confidence = opts[:confidence]
36
36
  raise ArgumentError, 'confidence must be numeric in 0..1' unless confidence.is_a?(Numeric) && confidence.finite? && confidence.between?(0, 1)
37
37
 
38
- paths = opts[:evidence_paths]
39
- raise ArgumentError, 'evidence_paths must contain existing readable absolute file paths' unless paths.is_a?(Array) && !paths.empty? && paths.all? { |path| path.is_a?(String) && path.start_with?('/') && File.file?(path) && File.readable?(path) }
38
+ artifacts = resolve_evidence(opts)
39
+ paths = artifacts.map { |artifact| artifact[:path] }.uniq
40
+ steps = opts[:reproduction_steps] || [opts[:poc]]
41
+ raise ArgumentError, 'reproduction_steps must be a nonempty Array of nonempty strings' unless steps.is_a?(Array) && !steps.empty? && steps.all? { |step| step.is_a?(String) && !step.strip.empty? }
42
+
43
+ justification = opts[:severity_justification]
44
+ raise ArgumentError, 'severity_justification must be a nonempty string' if (opts.key?(:severity_justification) || Array(opts[:artifact_handles]).any?) && (!justification.is_a?(String) || justification.strip.empty?)
40
45
 
41
46
  refs = opts[:attack_chain_refs]
42
- rows = report(engagement_id: opts[:engagement_id].to_s)
47
+ rows = report.select { |row| engagement_scope(row) == engagement_scope(opts) }
43
48
  raise ArgumentError, 'attack_chain_refs must reference existing findings in this engagement' unless refs.is_a?(Array) && refs.all? { |id| id.is_a?(String) && rows.any? { |row| row[:id] == id } }
44
49
 
45
50
  score = opts[:cvss_score]
@@ -55,20 +60,92 @@ module PWN
55
60
  'critical'
56
61
  end
57
62
  row = opts.slice(:title, :cwe, :cvss_vector, :cvss_score, :affected_asset, :evidence_paths, :poc,
58
- :attack_chain_refs, :remediation, :confidence, :engagement_id, :session_id)
63
+ :attack_chain_refs, :remediation, :confidence, :engagement_id, :session_id, :artifact_handles, :severity_justification)
64
+ row[:enables] = opts.fetch(:enables, [])
65
+ row[:evidence_paths] = paths
66
+ row[:reproduction_steps] = steps
59
67
  row = row.merge(id: SecureRandom.hex(6), severity: severity, status: 'open', verification_status: 'not_executed',
60
68
  chain_refs: refs, host: opts[:affected_asset], target: opts[:affected_asset],
61
69
  repro_cmd: opts[:poc], evidence: paths.map { |path| Digest::SHA256.file(path).hexdigest },
62
70
  poc_artifacts: [], at: Time.now.utc.iso8601)
71
+ validate_links(row: row, rows: report)
72
+ validate_graph(rows: report + [row])
73
+ row[:evidence_artifacts] = evidence_anchor(row: row, arts: paths, artifacts: artifacts)
63
74
  FileUtils.mkdir_p(File.dirname(FILE))
64
75
  File.open(FILE, 'a') do |file|
65
76
  file.flock(File::LOCK_EX)
66
77
  file.puts(JSON.generate(row))
67
78
  end
68
- evidence_anchor(row: row, arts: paths)
69
79
  row
70
80
  end
71
81
 
82
+ private_class_method def self.engagement_scope(opts = {})
83
+ value = opts[:engagement_id].to_s
84
+ value.empty? ? 'default' : value
85
+ end
86
+
87
+ private_class_method def self.validate_links(opts = {})
88
+ row = opts[:row]
89
+ ids = row[:enables]
90
+ raise ArgumentError, 'enables must be an Array of finding ID strings' unless ids.is_a?(Array) && ids.all? { |id| id.is_a?(String) && !id.empty? }
91
+ raise ArgumentError, 'enables must not contain duplicate IDs' unless ids.uniq == ids
92
+ raise ArgumentError, 'enables must not reference self' if ids.include?(row[:id])
93
+
94
+ scope = engagement_scope(row)
95
+ raise ArgumentError, 'enables must reference existing findings in this engagement' unless ids.all? { |id| opts[:rows].any? { |target| target[:id] == id && engagement_scope(target) == scope } }
96
+ end
97
+
98
+ private_class_method def self.validate_graph(opts = {})
99
+ rows = opts[:rows]
100
+ index = rows.to_h { |row| [row[:id], row] }
101
+ edges = Hash.new { |hash, key| hash[key] = [] }
102
+ rows.each do |row|
103
+ pairs = Array(row[:enables]).map { |id| [row[:id], id] }
104
+ refs = Array(row[:attack_chain_refs]) + Array(row[:chain_refs]) + Array(row[:chain_parent_id])
105
+ pairs.concat(refs.map { |id| [id, row[:id]] })
106
+ pairs.each do |from, to|
107
+ next unless index[from] && index[to] && engagement_scope(index[from]) == engagement_scope(index[to])
108
+
109
+ edges[from] << to
110
+ end
111
+ end
112
+ visiting = {}
113
+ visited = {}
114
+ walk = lambda do |id|
115
+ raise ArgumentError, 'attack chain cycle is not allowed' if visiting[id]
116
+ return if visited[id]
117
+
118
+ visiting[id] = true
119
+ edges[id].uniq.each { |target| walk.call(target) }
120
+ visiting.delete(id)
121
+ visited[id] = true
122
+ end
123
+ index.each_key { |id| walk.call(id) }
124
+ rows
125
+ end
126
+
127
+ private_class_method def self.resolve_evidence(opts = {})
128
+ paths = opts[:evidence_paths] || []
129
+ handles = opts[:artifact_handles] || []
130
+ raise ArgumentError, 'evidence_paths must contain existing readable absolute file paths' unless paths.is_a?(Array) && paths.all? { |path| path.is_a?(String) && path.start_with?('/') && File.file?(path) && File.readable?(path) }
131
+ raise ArgumentError, 'artifact_handles must be an Array' unless handles.is_a?(Array)
132
+ raise ArgumentError, 'evidence_paths or artifact_handles are required' if paths.empty? && handles.empty?
133
+
134
+ artifacts = paths.map { |path| { path: path, kind: 'evidence', label: File.basename(path), sha256: Digest::SHA256.file(path).hexdigest, size: File.size(path) } }
135
+ handles.each do |item|
136
+ descriptor = item.is_a?(Hash) ? item.transform_keys(&:to_sym) : { handle: item }
137
+ kind = descriptor[:kind] || 'evidence'
138
+ raise ArgumentError, 'artifact kind must be pcap, screenshot, crash, poc or evidence' unless %w[pcap screenshot crash poc evidence].include?(kind)
139
+
140
+ resolved = PWN::Plugins::ArtifactRegistry.resolve(handle: descriptor[:handle], sha256: descriptor[:sha256])
141
+ label = descriptor[:label] || File.basename(resolved[:path])
142
+ raise ArgumentError, 'artifact label must be a nonempty string' unless label.is_a?(String) && !label.strip.empty?
143
+
144
+ artifacts << resolved.merge(kind: kind, label: label)
145
+ end
146
+ artifacts.uniq { |artifact| [artifact[:handle], artifact[:path]] }
147
+ end
148
+
72
149
  private_class_method def self.validate_cvss(opts = {})
73
150
  score = opts[:cvss_score]
74
151
  raise ArgumentError, 'cvss_score must be numeric in 0..10' unless score.is_a?(Numeric) && score.finite? && score.between?(0, 10)
@@ -135,6 +212,9 @@ module PWN
135
212
  evidence: sha_ev.any? ? sha_ev : ev_list,
136
213
  poc: opts[:poc].is_a?(Hash) ? opts[:poc] : { type: 'file', path: arts.first, reproduction_steps: opts[:reproduction_steps].to_s },
137
214
  poc_artifacts: arts,
215
+ url: opts[:url].to_s,
216
+ matched_at: (opts[:matched_at] || opts[:url]).to_s,
217
+ template_id: opts[:template_id].to_s,
138
218
  chain_refs: Array(opts[:chain_refs] || opts[:chain_parent_id]).map(&:to_s).reject(&:empty?),
139
219
  cvss: opts[:cvss].to_s,
140
220
  status: proven ? (opts[:status] || 'open').to_s : 'unproven',
@@ -143,14 +223,16 @@ module PWN
143
223
  session_id: opts[:session_id].to_s,
144
224
  at: Time.now.utc.iso8601
145
225
  }
226
+ row[:evidence_artifacts] = evidence_anchor(row: row, arts: arts)
146
227
  FileUtils.mkdir_p(File.dirname(FILE))
147
228
  File.open(FILE, 'a') { |f| f.puts(JSON.generate(row)) }
148
- evidence_anchor(row: row, arts: arts)
149
229
  row
150
230
  end
151
231
 
152
232
  public_class_method def self.evidence_verify(opts = {})
153
233
  eng = (opts[:engagement_id] || opts[:name] || 'default').to_s
234
+ raise ArgumentError, 'engagement_id must be a simple identifier' unless eng.match?(/\A[a-zA-Z0-9_-]+\z/)
235
+
154
236
  path = File.join(Dir.home, '.pwn', 'engagements', eng, 'evidence.jsonl')
155
237
  return { ok: true, rows: 0 } unless File.file?(path)
156
238
 
@@ -159,13 +241,15 @@ module PWN
159
241
  File.readlines(path).each do |ln|
160
242
  row = JSON.parse(ln, symbolize_names: true)
161
243
  n += 1
162
- unless File.file?(row[:path].to_s)
163
- mismatches << row[:path]
164
- next
244
+ [row[:path], row[:stored]].compact.uniq.each do |evidence|
245
+ unless File.file?(evidence.to_s)
246
+ mismatches << evidence
247
+ next
248
+ end
249
+
250
+ sha = Digest::SHA256.file(evidence).hexdigest
251
+ mismatches << evidence unless sha == row[:sha256].to_s && File.size(evidence) == row[:size]
165
252
  end
166
-
167
- sha = Digest::SHA256.file(row[:path]).hexdigest
168
- mismatches << row[:path] unless sha == row[:sha256].to_s
169
253
  end
170
254
  { ok: mismatches.empty?, rows: n, mismatches: mismatches }
171
255
  end
@@ -204,15 +288,31 @@ module PWN
204
288
  child.merge(composite_severity: sev)
205
289
  end
206
290
 
291
+ public_class_method def self.link(opts = {})
292
+ opts = opts.transform_keys(&:to_sym)
293
+ rows = report
294
+ row = rows.find { |item| item[:id] == opts[:id] }
295
+ raise ArgumentError, 'id must name an existing finding' unless row
296
+
297
+ updated = row.merge(enables: opts[:enables])
298
+ validate_links(row: updated, rows: rows)
299
+ validate_graph(rows: rows.map { |item| item[:id] == row[:id] ? updated : item })
300
+ rewrite_row(id: row[:id], patch: { enables: opts[:enables] })
301
+ end
302
+
207
303
  public_class_method def self.chain_score(opts = {})
304
+ opts = opts.transform_keys(&:to_sym)
208
305
  ids = Array(opts[:ids] || opts[:chain_refs]).map(&:to_s)
209
- rows = report.select { |r| ids.include?(r[:id].to_s) || ids.include?(r[:chain_parent_id].to_s) }
210
- rows = report if rows.empty? && ids.empty?
306
+ rows = report
307
+ rows = rows.select { |row| ids.include?(row[:id].to_s) } unless ids.empty?
308
+ chains = PWN::Reports.attack_chains(findings: rows)
309
+ chains = chains.select { |chain| chain[:finding_ids] == ids } unless ids.empty?
211
310
  ranks = { 'info' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4, 'unproven' => 0 }
212
- peak = rows.map { |r| ranks[r[:severity].to_s] || 0 }.max || 0
213
- sev = %w[info low medium high critical][peak] || 'info'
214
- { chain_refs: rows.map { |r| r[:id] }, score: sev, combined_severity: sev, n: rows.length,
215
- rationale: 'Maximum recorded constituent severity. No automatic escalation; linking is not proof of combined exploitability.' }
311
+ selected = chains.max_by { |chain| ranks[chain[:combined_severity]] || 0 }
312
+ peak = rows.map { |row| ranks[row[:severity].to_s] || 0 }.max || 0
313
+ sev = selected ? selected[:combined_severity] : %w[info low medium high critical][peak]
314
+ { chain_refs: rows.map { |row| row[:id] }, score: sev, combined_severity: sev, n: rows.length, chains: chains,
315
+ rationale: selected ? selected[:rationale] : 'Maximum recorded constituent severity. No automatic escalation; linking is not proof of combined exploitability.' }
216
316
  end
217
317
 
218
318
  # Attest a working PoC from request/response or script output. File hashes are not execution.
@@ -231,12 +331,20 @@ module PWN
231
331
 
232
332
  public_class_method def self.chain_impact(opts = {})
233
333
  opts = opts.transform_keys(&:to_sym)
234
- ids = Array(opts[:ids]).map(&:to_s)
235
- raise ArgumentError, 'ids must name at least two findings' if ids.length < 2
334
+ ids = opts[:ids]
335
+ raise ArgumentError, 'ids must name at least two distinct finding IDs' unless ids.is_a?(Array) && ids.length >= 2 && ids.uniq == ids && ids.all? { |id| id.is_a?(String) && !id.empty? }
236
336
 
237
337
  rows = report
238
338
  chosen = ids.map { |id| rows.find { |row| row[:id].to_s == id } }
239
339
  raise ArgumentError, 'ids must name existing findings' if chosen.any?(&:nil?)
340
+ raise ArgumentError, 'ids must belong to the same engagement' unless chosen.map { |row| engagement_scope(row) }.uniq.length == 1
341
+
342
+ candidate = rows.map(&:dup)
343
+ ids.each_cons(2) do |from, to|
344
+ row = candidate.find { |item| item[:id] == from }
345
+ row[:enables] = (Array(row[:enables]) + [to]).uniq
346
+ end
347
+ validate_graph(rows: candidate)
240
348
 
241
349
  path = opts[:combined_impact_path].to_s
242
350
  raise ArgumentError, 'combined_impact_path must be an existing readable absolute file' unless path.start_with?('/') && File.file?(path) && File.readable?(path)
@@ -253,14 +361,30 @@ module PWN
253
361
 
254
362
  combined = wanted
255
363
  end
256
- patch = { combined_impact: path, combined_severity: combined, attack_chain_refs: (Array(chosen.last[:attack_chain_refs]) + ids[0..-2]).uniq }
257
- updated = rewrite_row(id: ids.last, patch: patch)
258
- evidence_anchor(row: updated, arts: [path])
364
+ rationale = opts.fetch(:severity_justification, text)
365
+ raise ArgumentError, 'severity_justification must be a nonempty string' unless rationale.is_a?(String) && !rationale.strip.empty?
366
+
367
+ steps = opts.fetch(:reproduction_steps) do
368
+ chosen.flat_map do |row|
369
+ row[:reproduction_steps] || (row[:poc].is_a?(Hash) ? Array(row[:poc][:reproduction_steps]).reject { |step| step.to_s.empty? } : Array(row[:poc]))
370
+ end
371
+ end
372
+ raise ArgumentError, 'reproduction_steps must be an Array of nonempty strings' unless steps.is_a?(Array) && steps.all? { |step| step.is_a?(String) && !step.strip.empty? } && (!opts.key?(:reproduction_steps) || !steps.empty?)
373
+
374
+ artifacts = evidence_anchor(row: chosen.last, arts: [path])
375
+ assessment = { finding_ids: ids, combined_severity: combined, rationale: rationale,
376
+ evidence_artifacts: artifacts, reproduction_steps: steps }
377
+ assessments = Array(chosen.last[:chain_assessments]).reject { |item| item[:finding_ids] == ids } + [assessment]
378
+ patch = { combined_impact: path, combined_severity: combined, chain_assessments: assessments }
379
+ patch[:evidence_artifacts] = (Array(chosen.last[:evidence_artifacts]) + artifacts).uniq { |artifact| [artifact[:path], artifact[:sha256]] }
380
+ updated = candidate.find { |row| row[:id] == ids.last }
381
+ updated.merge!(patch)
382
+ persist_rows(rows: candidate)
259
383
  {
260
384
  finding_ids: ids,
261
385
  combined_severity: combined,
262
386
  combined_impact_path: path,
263
- rationale: opts[:escalate] ? 'independently evidenced combined impact' : score[:rationale],
387
+ rationale: rationale,
264
388
  finding: updated
265
389
  }
266
390
  end
@@ -316,19 +440,19 @@ module PWN
316
440
  status = hit ? 'reproduced' : 'failed'
317
441
  finding_status = row[:status]
318
442
  end
319
- updated = rewrite_row(
443
+ artifacts = (Array(row[:evidence_artifacts]) + evidence_anchor(row: row, arts: arts)).uniq { |artifact| [artifact[:path], artifact[:sha256]] }
444
+ rewrite_row(
320
445
  id: row[:id],
321
446
  patch: {
322
447
  verification_status: status,
323
448
  verification_kind: kind,
324
449
  impact: impact,
325
450
  poc_artifacts: arts,
451
+ evidence_artifacts: artifacts,
326
452
  status: finding_status,
327
453
  verified_at: Time.now.utc.iso8601
328
454
  }
329
455
  )
330
- evidence_anchor(row: updated, arts: arts)
331
- updated
332
456
  end
333
457
 
334
458
  private_class_method def self.execution_blob(opts = {})
@@ -356,38 +480,65 @@ module PWN
356
480
  raise ArgumentError, 'id must name an existing finding' unless idx
357
481
 
358
482
  rows[idx] = rows[idx].merge(patch)
483
+ persist_rows(rows: rows)
484
+ rows[idx]
485
+ end
486
+
487
+ private_class_method def self.persist_rows(opts = {})
488
+ rows = opts[:rows]
359
489
  FileUtils.mkdir_p(File.dirname(FILE))
360
490
  File.open(FILE, File::RDWR | File::CREAT, 0o644) do |file|
361
491
  file.flock(File::LOCK_EX)
362
492
  file.rewind
363
493
  file.truncate(0)
364
494
  rows.each { |row| file.puts(JSON.generate(row)) }
495
+ file.flush
496
+ file.fsync
365
497
  end
366
- rows[idx]
498
+ rows
367
499
  end
368
500
 
369
501
  private_class_method def self.evidence_anchor(opts = {})
370
502
  row = opts[:row]
371
503
  arts = Array(opts[:arts])
372
- eng = (row[:engagement_id] || 'default').to_s
504
+ eng = row[:engagement_id].to_s
505
+ eng = 'default' if eng.empty?
506
+ raise ArgumentError, 'engagement_id must be a simple identifier' unless eng.match?(/\A[a-zA-Z0-9_-]+\z/)
507
+
373
508
  dir = File.join(Dir.home, '.pwn', 'engagements', eng)
509
+ [File.dirname(dir), dir, File.join(dir, 'evidence'), File.join(dir, 'evidence.jsonl')].each do |entry|
510
+ raise ArgumentError, 'evidence storage contains a symlink' if File.symlink?(entry)
511
+ end
374
512
  FileUtils.mkdir_p(dir)
375
513
  path = File.join(dir, 'evidence.jsonl')
376
- arts.each do |art|
514
+ artifacts = opts[:artifacts] || arts.map { |art| { path: art } }
515
+ artifacts.filter_map do |artifact|
516
+ art = artifact[:path]
377
517
  next unless File.file?(art.to_s)
378
518
 
379
- rec = {
519
+ rec = artifact.merge(
380
520
  ts: Time.now.utc.iso8601,
381
521
  finding_id: row[:id],
382
522
  path: art,
383
523
  sha256: Digest::SHA256.file(art).hexdigest,
384
524
  size: File.size(art)
385
- }
525
+ )
526
+ raise ArgumentError, 'evidence changed during recording' if artifact[:sha256] && (artifact[:sha256] != rec[:sha256] || artifact[:size] != rec[:size])
527
+
386
528
  dest = File.join(dir, 'evidence', rec[:sha256])
529
+ raise ArgumentError, 'stored evidence must not be a symlink' if File.symlink?(dest)
530
+
387
531
  FileUtils.mkdir_p(File.dirname(dest))
388
532
  FileUtils.cp(art, dest) unless File.file?(dest)
533
+ raise ArgumentError, 'stored evidence hash mismatch' unless Digest::SHA256.file(dest).hexdigest == rec[:sha256] && File.size(dest) == rec[:size]
534
+
389
535
  rec[:stored] = dest
390
- File.open(path, 'a') { |file| file.puts(JSON.generate(rec)) }
536
+ File.chmod(0o600, dest)
537
+ File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
538
+ file.flock(File::LOCK_EX)
539
+ file.puts(JSON.generate(rec))
540
+ end
541
+ rec
391
542
  end
392
543
  end
393
544
 
@@ -407,9 +558,13 @@ module PWN
407
558
  cvss_vector: 'required - complete CVSS 3.0/3.1 base vector',
408
559
  cvss_score: 'required - numeric score matching vector',
409
560
  affected_asset: 'required - recon asset ID or asset identifier',
410
- evidence_paths: 'required - existing readable absolute paths',
561
+ evidence_paths: 'optional - existing readable absolute paths; required unless artifact_handles are supplied',
562
+ artifact_handles: 'optional - Array of handles or objects with handle, kind, label and optional expected sha256',
563
+ severity_justification: 'optional - evidence-based severity explanation; required for artifact handles',
564
+ reproduction_steps: 'optional - nonempty Array of ordered PoC instructions; defaults to the supplied poc command',
411
565
  poc: 'required - command or code string',
412
- attack_chain_refs: 'required - Array of existing same-engagement finding IDs',
566
+ enables: 'optional - Array of existing same-engagement IDs this finding enables; defaults to empty',
567
+ attack_chain_refs: 'required - Array of existing same-engagement predecessor finding IDs',
413
568
  remediation: 'required - remediation instructions',
414
569
  confidence: 'required - numeric 0..1',
415
570
  engagement_id: 'optional - simple engagement identifier',
@@ -434,7 +589,10 @@ module PWN
434
589
  status: 'optional - open|closed (defaults to open)',
435
590
  engagement_id: 'optional - engagement identifier',
436
591
  session_id: 'optional - pwn-ai session id',
437
- chain_parent_id: 'optional - id of a parent finding this issue chains from'
592
+ chain_parent_id: 'optional - id of a parent finding this issue chains from',
593
+ url: 'optional - matched-at URL from nuclei/httpx JSONL',
594
+ matched_at: 'optional - alias for url from scanner JSONL',
595
+ template_id: 'optional - nuclei template id that fired'
438
596
  )
439
597
 
440
598
  # Alias of report for querying stored findings.
@@ -469,9 +627,15 @@ module PWN
469
627
  name: 'optional - alias for engagement_id'
470
628
  )
471
629
 
472
- # Recompute combined severity for chained findings.
630
+ # Replace outgoing directed links; rejects cross-engagement IDs, duplicates and cycles.
631
+ #{self}.link(
632
+ id: 'required - existing finding ID whose outgoing links are replaced',
633
+ enables: 'required - Array of distinct existing same-engagement finding IDs; empty removes outgoing links'
634
+ )
635
+
636
+ # Score canonical directed paths using exact scoped assessments; no automatic uplift.
473
637
  #{self}.chain_score(
474
- ids: 'optional - Array of finding ids to score together',
638
+ ids: 'optional - ordered Array of finding IDs selecting an exact directed path; defaults to all findings',
475
639
  chain_refs: 'optional - alias for ids'
476
640
  )
477
641
 
@@ -497,10 +661,12 @@ module PWN
497
661
 
498
662
  # Escalate combined severity only with an evidence file that names every id.
499
663
  #{self}.chain_impact(
500
- ids: 'required - Array of at least two finding ids',
501
- combined_impact_path: 'required - absolute evidence file',
502
- escalate: 'optional - true to set combined_severity from evidence',
503
- combined_severity: 'optional - info|low|medium|high|critical when escalate is true'
664
+ ids: 'required - ordered Array of at least two distinct same-engagement finding IDs; adds consecutive enables edges',
665
+ combined_impact_path: 'required - readable absolute evidence file with at least 40 characters naming every ID',
666
+ escalate: 'optional - true to set combined_severity from evidence; otherwise retains the scored severity',
667
+ combined_severity: 'optional - info|low|medium|high|critical when escalate is true',
668
+ severity_justification: 'optional - nonempty evidence-based rationale; defaults to actual combined-impact file text',
669
+ reproduction_steps: 'optional - nonempty Array of ordered replay instructions; defaults to constituent finding steps'
504
670
  )
505
671
 
506
672
  # List unreproduced findings and whether two-plus reproduced rows lack a chain.