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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +163 -6
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +6 -2
- data/documentation/Recon-Findings-API.md +33 -1
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
- data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
- data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
- data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
- data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
- data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
- data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/SKILL.md +2 -0
- data/lib/pwn/ai/agent/confirmation.rb +163 -0
- data/lib/pwn/ai/agent/dispatch.rb +43 -5
- data/lib/pwn/ai/agent/loop.rb +45 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
- data/lib/pwn/ai/agent/registry.rb +2 -1
- data/lib/pwn/ai/agent/result.rb +76 -6
- data/lib/pwn/ai/agent/task_dag.rb +220 -0
- data/lib/pwn/ai/agent/tool_guard.rb +166 -24
- data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
- data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
- data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
- data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
- data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
- data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
- data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
- data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
- data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
- data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
- data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
- data/lib/pwn/ai/agent/tools/shell.rb +22 -10
- data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
- data/lib/pwn/ai/agent.rb +3 -1
- data/lib/pwn/ai/cli.rb +26 -2
- data/lib/pwn/ai/context.rb +4 -2
- data/lib/pwn/ai/router.rb +196 -0
- data/lib/pwn/ai/tools/README.md +5 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +10 -0
- data/lib/pwn/engagement.rb +45 -1
- data/lib/pwn/ffi/capstone.rb +120 -0
- data/lib/pwn/ffi/keystone.rb +107 -0
- data/lib/pwn/ffi.rb +2 -0
- data/lib/pwn/migrate.rb +5 -1
- data/lib/pwn/plugins/aflplusplus.rb +238 -13
- data/lib/pwn/plugins/ai_sandbox.rb +240 -0
- data/lib/pwn/plugins/artifact_registry.rb +188 -22
- data/lib/pwn/plugins/assembly.rb +73 -2
- data/lib/pwn/plugins/basic_auth.rb +17 -4
- data/lib/pwn/plugins/binary_parser.rb +439 -38
- data/lib/pwn/plugins/exploit_dev.rb +83 -14
- data/lib/pwn/plugins/findings.rb +208 -42
- data/lib/pwn/plugins/gdbmi.rb +265 -38
- data/lib/pwn/plugins/httpx.rb +93 -0
- data/lib/pwn/plugins/jobs.rb +260 -102
- data/lib/pwn/plugins/method_catalog.rb +328 -0
- data/lib/pwn/plugins/nmap_it.rb +157 -26
- data/lib/pwn/plugins/nuclei.rb +122 -29
- data/lib/pwn/plugins/preflight_checker.rb +3 -2
- data/lib/pwn/plugins/process_tube.rb +51 -2
- data/lib/pwn/plugins/radare2.rb +120 -26
- data/lib/pwn/plugins/recon.rb +33 -0
- data/lib/pwn/plugins/repl/mesh.rb +203 -13
- data/lib/pwn/plugins/rop.rb +121 -31
- data/lib/pwn/plugins/transparent_browser.rb +212 -14
- data/lib/pwn/plugins/vault.rb +182 -24
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +8 -2
- data/lib/pwn/reports/html.rb +28 -9
- data/lib/pwn/reports/json.rb +2 -1
- data/lib/pwn/reports/markdown.rb +49 -12
- data/lib/pwn/reports/sarif.rb +17 -3
- data/lib/pwn/reports.rb +184 -34
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reports_spec.rb +93 -0
- data/spec/integration/result_condition_spec.rb +11 -8
- data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
- data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
- data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
- data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
- data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
- data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
- data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
- data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
- data/spec/lib/pwn/ai/cli_spec.rb +12 -1
- data/spec/lib/pwn/ai/router_spec.rb +57 -0
- data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
- data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
- data/spec/lib/pwn/migrate_spec.rb +30 -2
- data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
- data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
- data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
- data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
- data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
- data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
- data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
- data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
- data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
- data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
- data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
- data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
- data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
- data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
- data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
- data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
- data/spec/lib/pwn/reports_spec.rb +196 -0
- data/spec/spec_helper.rb +3 -0
- data/third_party/pwn_rdoc.jsonl +230 -4
- metadata +47 -5
data/lib/pwn/plugins/findings.rb
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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(
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
210
|
-
rows =
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
235
|
-
raise ArgumentError, 'ids must name at least two
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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: '
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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.
|