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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
require 'tmpdir'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'fileutils'
|
|
5
7
|
|
|
6
8
|
describe PWN::Plugins::AFLplusplus do
|
|
7
9
|
it 'should display information for authors' do
|
|
@@ -19,4 +21,42 @@ describe PWN::Plugins::AFLplusplus do
|
|
|
19
21
|
expect(stats[:execs_per_sec] || stats['execs_per_sec']).to eq('1234.5')
|
|
20
22
|
end
|
|
21
23
|
end
|
|
24
|
+
|
|
25
|
+
it 'builds an AFL dictionary from interesting binary strings' do
|
|
26
|
+
Dir.mktmpdir('pwn-fuzz-dict-') do |dir|
|
|
27
|
+
src = File.join(dir, 't.c')
|
|
28
|
+
bin = File.join(dir, 't')
|
|
29
|
+
File.write(src, "#include <stdio.h>\nint main(void) { puts(\"https://fuzz.example/token\"); return 0; }\n")
|
|
30
|
+
out, status = Open3.capture2e('cc', '-O0', '-o', bin, src)
|
|
31
|
+
expect(status.success?).to eq(true), out
|
|
32
|
+
dict = described_class.dictionary_from_binary(path: bin)
|
|
33
|
+
blob = Array(dict[:tokens] || dict).join("\n")
|
|
34
|
+
expect(blob).to include('https://fuzz.example/token')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'dedups crashes by backtrace hash and hands unique ones to RE triage' do
|
|
39
|
+
Dir.mktmpdir('pwn-fuzz-dedup-') do |dir|
|
|
40
|
+
crashes = File.join(dir, 'crashes')
|
|
41
|
+
FileUtils.mkdir_p(crashes)
|
|
42
|
+
File.binwrite(File.join(crashes, 'id:000000,sig:11'), 'AAAA')
|
|
43
|
+
File.binwrite(File.join(crashes, 'id:000001,sig:11'), 'BBBB')
|
|
44
|
+
File.binwrite(File.join(crashes, 'id:000002,sig:11'), 'CCCC')
|
|
45
|
+
reports = {
|
|
46
|
+
'AAAA' => { signal: 'SIGSEGV', pc: '0x401136', fault_addr: '0x401136', backtrace: [{ func: 'vuln' }] },
|
|
47
|
+
'BBBB' => { signal: 'SIGSEGV', pc: '0x401136', fault_addr: '0x401136', backtrace: [{ func: 'vuln' }] },
|
|
48
|
+
'CCCC' => { signal: 'SIGABRT', pc: '0x401200', fault_addr: '0x0', backtrace: [{ func: 'abort' }] }
|
|
49
|
+
}
|
|
50
|
+
allow(PWN::Plugins::GDBMI).to receive(:run_to_crash) do |opts|
|
|
51
|
+
reports.fetch(opts[:stdin].to_s)
|
|
52
|
+
end
|
|
53
|
+
allow(PWN::Plugins::BinaryParser).to receive(:triage).and_return(file_type: 'elf', protections: { nx: true })
|
|
54
|
+
allow(PWN::Plugins::ExploitDev).to receive(:from_crash).and_return(offset: 64)
|
|
55
|
+
row = described_class.crash_triage(out_dir: dir, target: '/bin/true', handoff: true)
|
|
56
|
+
expect(row[:unique].length).to eq(2)
|
|
57
|
+
expect(row[:triaged].length).to eq(2)
|
|
58
|
+
expect(row[:triaged]).to all(include(:backtrace_hash, :crash, :binary_triage))
|
|
59
|
+
expect(row[:pipeline]).to eq('pwn-re-003')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
22
62
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
8
|
+
describe PWN::Plugins::AISandbox do
|
|
9
|
+
it 'should display information for authors' do
|
|
10
|
+
expect(described_class).to respond_to :authors
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'should display information for existing help method' do
|
|
14
|
+
expect(described_class).to respond_to :help
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'denies rm -rf /tmp/fake_root in strict mode without touching disk' do
|
|
18
|
+
root = '/tmp/fake_root'
|
|
19
|
+
FileUtils.mkdir_p(root)
|
|
20
|
+
marker = File.join(root, 'keep')
|
|
21
|
+
File.write(marker, 'safe')
|
|
22
|
+
begin
|
|
23
|
+
out = described_class.exec(kind: :shell, payload: 'rm -rf /tmp/fake_root', mode: 'strict')
|
|
24
|
+
expect(out[:error].to_s).to match(/sandbox violation/i)
|
|
25
|
+
expect(File.file?(marker)).to be true
|
|
26
|
+
ensure
|
|
27
|
+
FileUtils.rm_rf(root)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'denies FileUtils.rm_rf("/tmp/fake_root") in strict mode without touching disk' do
|
|
32
|
+
root = '/tmp/fake_root'
|
|
33
|
+
FileUtils.mkdir_p(root)
|
|
34
|
+
marker = File.join(root, 'keep')
|
|
35
|
+
File.write(marker, 'safe')
|
|
36
|
+
begin
|
|
37
|
+
out = described_class.exec(kind: :ruby, payload: "FileUtils.rm_rf('/tmp/fake_root')", mode: 'strict')
|
|
38
|
+
expect(out[:error].to_s).to match(/sandbox violation/i)
|
|
39
|
+
expect(File.file?(marker)).to be true
|
|
40
|
+
ensure
|
|
41
|
+
FileUtils.rm_rf(root)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -12,6 +12,120 @@ describe PWN::Plugins::ArtifactRegistry do
|
|
|
12
12
|
expect(described_class).to respond_to :help
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
describe 'spill' do
|
|
16
|
+
before do
|
|
17
|
+
@artifact_dir = Dir.mktmpdir
|
|
18
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(@artifact_dir, 'artifacts'))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
after do
|
|
22
|
+
FileUtils.remove_entry(@artifact_dir)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'stores exact bytes privately under a session and short digest' do
|
|
26
|
+
bytes = "\x00\xffhello".b
|
|
27
|
+
stored = described_class.spill(bytes: bytes, session_id: 'session-1')
|
|
28
|
+
expect(stored).to include(handle: "session-1/#{Digest::SHA256.hexdigest(bytes)[0, 8]}.bin", bytes: bytes.bytesize, sha256: Digest::SHA256.hexdigest(bytes))
|
|
29
|
+
expect(File.binread(stored[:path])).to eq(bytes)
|
|
30
|
+
expect(File.stat(stored[:path]).mode & 0o777).to eq(0o600)
|
|
31
|
+
expect(File.stat(File.dirname(stored[:path])).mode & 0o777).to eq(0o700)
|
|
32
|
+
expect(File.stat(described_class::ROOT).mode & 0o777).to eq(0o700)
|
|
33
|
+
expect(described_class.spill(bytes: bytes, session_id: 'session-1')).to eq(stored)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'pages 50 MiB losslessly with bounded reads and a streaming digest' do
|
|
37
|
+
bytes = (0..255).to_a.pack('C*') * (50 * 1024 * 1024 / 256)
|
|
38
|
+
stored = described_class.spill(bytes: bytes, session_id: 'large')
|
|
39
|
+
expected = Digest::SHA256.hexdigest(bytes)
|
|
40
|
+
digest = Digest::SHA256.new
|
|
41
|
+
offset = 0
|
|
42
|
+
pages = 0
|
|
43
|
+
loop do
|
|
44
|
+
page = described_class.read_page(handle: stored[:handle], offset: offset, length: 1_000_000)
|
|
45
|
+
expect(page[:bytes]).to be <= 2048
|
|
46
|
+
expect(page).to include(handle: stored[:handle], total_bytes: bytes.bytesize, encoding: 'base64', mode: 'base64')
|
|
47
|
+
decoded = Base64.strict_decode64(page[:body])
|
|
48
|
+
digest.update(decoded)
|
|
49
|
+
offset = page[:next_offset]
|
|
50
|
+
pages += 1
|
|
51
|
+
break if page[:eof]
|
|
52
|
+
end
|
|
53
|
+
expect(offset).to eq(bytes.bytesize)
|
|
54
|
+
expect(digest.hexdigest).to eq(expected)
|
|
55
|
+
puts "artifact paging verified: bytes=#{offset} pages=#{pages} sha256=#{digest.hexdigest}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'preserves split UTF-8 bytes, explicit encodings, refs and EOF metadata' do
|
|
59
|
+
stored = described_class.spill(bytes: 'éabc', session_id: 'text')
|
|
60
|
+
first = described_class.read_page(handle: stored[:handle], length: 1)
|
|
61
|
+
expect(Base64.strict_decode64(first[:body])).to eq("\xc3".b)
|
|
62
|
+
expect(described_class.read_page(ref: stored[:path], offset: 2, length: 99, max_length: 2)).to include(body: 'ab', bytes: 2, eof: false, next_offset: 4)
|
|
63
|
+
expect(described_class.read_page(path: stored[:path], mode: 'hex', length: 2)).to include(hex: 'c3a9', body: 'c3a9')
|
|
64
|
+
expect(described_class.read_page(path: stored[:path], mode: 'base64', offset: 99)).to include(body: '', bytes: 0, eof: true, next_offset: 99)
|
|
65
|
+
expect { described_class.read_page(handle: '../text/12345678.bin') }.to raise_error(ArgumentError)
|
|
66
|
+
expect { described_class.read_page(path: stored[:path], offset: -1) }.to raise_error(ArgumentError)
|
|
67
|
+
expect { described_class.read_page(path: stored[:path], sha256: 'bad') }.to raise_error(/sha256 mismatch/)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'bounds get reads rather than loading the entire file' do
|
|
71
|
+
stored = described_class.spill(bytes: 'x' * 100_000, session_id: 'get')
|
|
72
|
+
expect(File).to receive(:binread).with(stored[:path], 65_536).and_call_original
|
|
73
|
+
expect(described_class.get(path: stored[:path])[:body].bytesize).to eq(65_536)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'greps complete lines across page boundaries with bounded previews and resumable byte offsets' do
|
|
77
|
+
bytes = "first\n#{'x' * 2046}BOUNDARY#{'y' * 2_100_000}\nlast\xff\n".b
|
|
78
|
+
stored = described_class.spill(bytes: bytes, session_id: 'grep')
|
|
79
|
+
first = described_class.grep(handle: stored[:handle], regex: 'x', limit: 1)
|
|
80
|
+
expect(first[:matches].first).to include(offset: 6, fragment_offset: 6, bytes: 2048, continued: true)
|
|
81
|
+
expect(first).to include(next_offset: bytes.index('last'), eof: false, semantics: 'binary_lines')
|
|
82
|
+
offsets = []
|
|
83
|
+
cursor = 0
|
|
84
|
+
loop do
|
|
85
|
+
result = described_class.grep(handle: stored[:handle], regex: 'last', offset: cursor)
|
|
86
|
+
|
|
87
|
+
offsets.concat(result[:matches].map { |match| match[:offset] })
|
|
88
|
+
expect(result[:next_offset]).to be > cursor
|
|
89
|
+
cursor = result[:next_offset]
|
|
90
|
+
break if result[:eof]
|
|
91
|
+
end
|
|
92
|
+
expect(offsets).to eq([bytes.index('last')])
|
|
93
|
+
boundary = described_class.grep(handle: stored[:handle], regex: 'BOUNDARY')
|
|
94
|
+
expect(boundary[:matches].first[:offset]).to eq(bytes.index('BOUNDARY'))
|
|
95
|
+
bounded = described_class.grep(handle: stored[:handle], regex: 'BOUNDARY', max_bytes: 32)
|
|
96
|
+
expect(bounded[:matches].first[:text]).to include('BOUNDARY')
|
|
97
|
+
expect(bounded[:matches].sum { |hit| hit[:bytes] }).to be <= 32
|
|
98
|
+
last = described_class.grep(path: stored[:path], regex: 'last', offset: bytes.index('last'))
|
|
99
|
+
expect(Base64.strict_decode64(last[:matches].first[:text])).to eq("last\xff\n".b)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'caps grep match counts and regex runtime and rejects invalid regexes' do
|
|
103
|
+
stored = described_class.spill(bytes: "hit\n" * 100, session_id: 'grep-limit')
|
|
104
|
+
allow(Regexp).to receive(:new).and_call_original
|
|
105
|
+
expect(Regexp).to receive(:new).with('hit'.b, Regexp::NOENCODING, timeout: 0.05).and_call_original
|
|
106
|
+
result = described_class.grep(handle: stored[:handle], regex: 'hit', limit: 999)
|
|
107
|
+
expect(result[:matches].length).to eq(50)
|
|
108
|
+
expect(result[:next_offset]).to eq(200)
|
|
109
|
+
expect { described_class.grep(handle: stored[:handle], regex: '[') }.to raise_error(RegexpError)
|
|
110
|
+
expect { described_class.grep(handle: stored[:handle], regex: 'hit', offset: -1) }.to raise_error(ArgumentError)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'interrupts expensive regexes on a giant unterminated line' do
|
|
114
|
+
stored = described_class.spill(bytes: "#{'a' * 2047}!", session_id: 'timeout')
|
|
115
|
+
expect { described_class.grep(handle: stored[:handle], regex: '(a+)\\1+$') }.to raise_error(Regexp::TimeoutError)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'rejects traversal, symlink directories, and short digest collisions' do
|
|
119
|
+
expect { described_class.spill(bytes: 'x', session_id: '../escape') }.to raise_error(ArgumentError)
|
|
120
|
+
stored = described_class.spill(bytes: 'x', session_id: 'safe')
|
|
121
|
+
File.binwrite(stored[:path], 'different')
|
|
122
|
+
expect { described_class.spill(bytes: 'x', session_id: 'safe') }.to raise_error(/collision/)
|
|
123
|
+
expect(File.binread(stored[:path])).to eq('different')
|
|
124
|
+
File.symlink(File.dirname(stored[:path]), File.join(described_class::ROOT, 'link'))
|
|
125
|
+
expect { described_class.spill(bytes: 'x', session_id: 'link') }.to raise_error(/symlink/)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
15
129
|
it 'lists artifacts for a session id' do
|
|
16
130
|
Dir.mktmpdir do |dir|
|
|
17
131
|
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', dir)
|
|
@@ -28,7 +142,8 @@ describe PWN::Plugins::ArtifactRegistry do
|
|
|
28
142
|
src = File.join(dir, 'dump.txt')
|
|
29
143
|
File.write(src, "nop\ncall system\nret\n")
|
|
30
144
|
hits = described_class.read_page(path: src, grep: 'call.*system')
|
|
31
|
-
expect(hits[:matches].first[:text]).to
|
|
145
|
+
expect(hits[:matches].first[:text]).to eq('call system')
|
|
146
|
+
expect(hits[:matches].first[:line]).to eq(2)
|
|
32
147
|
end
|
|
33
148
|
end
|
|
34
149
|
|
|
@@ -12,4 +12,16 @@ describe PWN::Plugins::Assembly do
|
|
|
12
12
|
help_response = PWN::Plugins::Assembly
|
|
13
13
|
expect(help_response).to respond_to :help
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
it 'assemble and disassemble return structured engine output' do
|
|
17
|
+
asm = described_class.assemble(asm: "nop\nret", arch: 'x86_64')
|
|
18
|
+
expect(asm).to include(:bytes, :hex, :engine)
|
|
19
|
+
expect(asm[:bytes].bytesize).to be >= 2
|
|
20
|
+
expect(%w[keystone metasm]).to include(asm[:engine].to_s)
|
|
21
|
+
dis = described_class.disassemble(bytes: asm[:bytes], arch: 'x86_64')
|
|
22
|
+
expect(dis[:insns]).to be_an(Array)
|
|
23
|
+
blob = dis[:insns].map { |row| "#{row[:mnemonic]} #{row[:op_str]}" }.join(' ')
|
|
24
|
+
expect(blob).to match(/nop/i)
|
|
25
|
+
expect(%w[capstone metasm]).to include(dis[:engine].to_s)
|
|
26
|
+
end
|
|
15
27
|
end
|
|
@@ -12,4 +12,19 @@ describe PWN::Plugins::BasicAuth do
|
|
|
12
12
|
help_response = PWN::Plugins::BasicAuth
|
|
13
13
|
expect(help_response).to respond_to :help
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
it 'offers recon loot when a service prompts for auth' do
|
|
17
|
+
Dir.mktmpdir('pwn-basic-auth-') do |dir|
|
|
18
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
19
|
+
PWN::Plugins::Vault.ingest(
|
|
20
|
+
text: "username=admin\npassword=hunter2\n",
|
|
21
|
+
host: 'app.example.test',
|
|
22
|
+
source: 'recon',
|
|
23
|
+
where: 'banner:80',
|
|
24
|
+
engagement: 'lab'
|
|
25
|
+
)
|
|
26
|
+
encoded = described_class.encode(host: 'app.example.test', engagement: 'lab', service: 'http')
|
|
27
|
+
expect(described_class.decode(base64_str: encoded)).to eq('admin:hunter2')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
15
30
|
end
|
|
@@ -25,11 +25,68 @@ describe PWN::Plugins::BinaryParser do
|
|
|
25
25
|
skip 'no ELF on PATH' unless path
|
|
26
26
|
Dir.mktmpdir do |dir|
|
|
27
27
|
allow(Dir).to receive(:home).and_return(dir)
|
|
28
|
-
|
|
28
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
29
|
+
first = described_class.triage(path: path, session_id: 'fixture')
|
|
29
30
|
expect(first).to include(:format, :arch, :protections, :sha256, :sections)
|
|
30
31
|
expect(first[:cached]).to be false
|
|
31
|
-
second = described_class.triage(path: path)
|
|
32
|
+
second = described_class.triage(path: path, session_id: 'fixture')
|
|
32
33
|
expect(second[:cached]).to be true
|
|
33
34
|
end
|
|
34
35
|
end
|
|
36
|
+
|
|
37
|
+
it 'surfaces mitigations and attack surface for a stripped ELF in one JSON artifact' do
|
|
38
|
+
Dir.mktmpdir('pwn-triage-') do |dir|
|
|
39
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
40
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
|
|
41
|
+
source = File.join(dir, 'fixture.c')
|
|
42
|
+
path = File.join(dir, 'fixture')
|
|
43
|
+
File.write(source, <<~C)
|
|
44
|
+
#include <stdio.h>
|
|
45
|
+
int main(void) {
|
|
46
|
+
puts("https://example.test/api");
|
|
47
|
+
puts("/var/lib/pwn/keys");
|
|
48
|
+
puts("BEGIN RSA PRIVATE KEY");
|
|
49
|
+
printf("%s %n\\n", "fmt");
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
C
|
|
53
|
+
out, status = Open3.capture2e('cc', '-O0', '-g', '-fstack-protector-all', '-o', path, source)
|
|
54
|
+
expect(status.success?).to eq(true), out
|
|
55
|
+
Open3.capture2e('strip', '--strip-all', path)
|
|
56
|
+
row = described_class.triage(path: path, session_id: 'fixture')
|
|
57
|
+
expect(row[:file_type] || row[:format]).to match(/elf/i)
|
|
58
|
+
expect(row[:arch].to_s).not_to be_empty
|
|
59
|
+
expect(row[:linking].to_s).to match(/dynamic|static/)
|
|
60
|
+
expect(row[:protections]).to include(:nx, :pie, :relro, :canary, :cfi)
|
|
61
|
+
expect(row[:protections].values).to all(satisfy { |value| [true, false].include?(value) || value.is_a?(String) })
|
|
62
|
+
expect(row[:stripped]).to eq(true)
|
|
63
|
+
expect(Array(row[:imports]).any? { |name| name.to_s.include?('puts') || name.to_s.include?('printf') || name.to_s.include?('libc') }).to eq(true)
|
|
64
|
+
strings = row[:interesting_strings]
|
|
65
|
+
blob = JSON.generate(strings)
|
|
66
|
+
expect(blob).to include('https://example.test/api')
|
|
67
|
+
expect(blob).to match(%r{/var/lib/pwn/keys})
|
|
68
|
+
expect(blob).to include('%n')
|
|
69
|
+
expect(row[:entropy]).to be_a(Hash)
|
|
70
|
+
expect(row[:entropy].keys).not_to be_empty
|
|
71
|
+
expect(row[:packer]).to include(:packed, :indicators)
|
|
72
|
+
expect(row[:handle].to_s).to match(%r{\Afixture/[0-9a-f]{8}\.bin\z})
|
|
73
|
+
stored = JSON.parse(File.binread(row[:artifact][:path] || File.join(dir, 'artifacts', row[:handle])), symbolize_names: true)
|
|
74
|
+
expect(stored[:sha256]).to eq(row[:sha256])
|
|
75
|
+
expect(stored[:protections]).to include(:canary)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'elf_resolve maps symbols, GOT, and PLT on a compiled ELF' do
|
|
80
|
+
Dir.mktmpdir('pwn-elf-resolve-') do |dir|
|
|
81
|
+
src = File.join(dir, 't.c')
|
|
82
|
+
path = File.join(dir, 't')
|
|
83
|
+
File.write(src, "#include <stdio.h>\n#include <stdlib.h>\nint main(void) { puts(\"hi\"); if (0) system(\"x\"); return 0; }\n")
|
|
84
|
+
out, status = Open3.capture2e('cc', '-O0', '-no-pie', '-o', path, src)
|
|
85
|
+
expect(status.success?).to eq(true), out
|
|
86
|
+
row = described_class.elf_resolve(path: path)
|
|
87
|
+
expect(row[:plt].keys.map(&:to_s)).to include('puts')
|
|
88
|
+
expect(row[:got].keys.map(&:to_s)).to include('puts')
|
|
89
|
+
expect(row[:symbols]['main'] || row[:symbols][:main]).to be_a(Integer)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
35
92
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'open3'
|
|
4
6
|
|
|
5
7
|
describe PWN::Plugins::ExploitDev do
|
|
6
8
|
it 'should display information for authors' do
|
|
@@ -43,4 +45,46 @@ describe PWN::Plugins::ExploitDev do
|
|
|
43
45
|
expect(row).to include(:path)
|
|
44
46
|
expect(row[:symbols]).to be_a(Hash)
|
|
45
47
|
end
|
|
48
|
+
|
|
49
|
+
it 'gadgets returns queryable records parsed from ropper/rp-style output' do
|
|
50
|
+
allow(PWN::Plugins::ROP).to receive(:gadgets).and_return(
|
|
51
|
+
backend: 'ropper',
|
|
52
|
+
gadgets: [
|
|
53
|
+
{ address: 0x40101a, gadget: 'pop rdi; ret', regs_clobbered: %w[rdi rsp rip] },
|
|
54
|
+
{ address: 0x40101c, gadget: 'ret', regs_clobbered: %w[rsp rip] }
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
row = described_class.gadgets(path: '/bin/true', filter: 'pop rdi')
|
|
58
|
+
expect(row[:gadgets]).to be_an(Array)
|
|
59
|
+
expect(row[:gadgets]).to all(include(:address, :gadget))
|
|
60
|
+
expect(row[:gadgets].map { |g| g[:gadget] }).to include(a_string_matching(/pop rdi/i))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'ret2libc builds a packed chain from ELF resolve + pop rdi' do
|
|
64
|
+
Dir.mktmpdir('pwn-ret2libc-') do |dir|
|
|
65
|
+
src = File.join(dir, 'vuln.c')
|
|
66
|
+
bin = File.join(dir, 'vuln')
|
|
67
|
+
File.write(src, <<~C)
|
|
68
|
+
#include <stdio.h>
|
|
69
|
+
#include <stdlib.h>
|
|
70
|
+
#include <unistd.h>
|
|
71
|
+
char cmd[] = "echo RET2LIBC_OK";
|
|
72
|
+
void keep(void) { __asm__ volatile("pop %rdi; ret"); }
|
|
73
|
+
int main(int argc, char **argv) {
|
|
74
|
+
char buf[64];
|
|
75
|
+
(void)argv;
|
|
76
|
+
if (argc > 100) { system(cmd); keep(); }
|
|
77
|
+
read(0, buf, 256);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
C
|
|
81
|
+
out, status = Open3.capture2e('cc', '-O0', '-fno-stack-protector', '-no-pie', '-o', bin, src)
|
|
82
|
+
expect(status.success?).to eq(true), out
|
|
83
|
+
chain = described_class.ret2libc(path: bin, offset: 72, extra: 'cmd')
|
|
84
|
+
expect(chain[:payload].bytesize).to be > 72
|
|
85
|
+
expect(chain[:system]).to be_a(Integer)
|
|
86
|
+
expect(chain[:binsh] || chain[:arg]).to be_a(Integer)
|
|
87
|
+
expect(chain[:pop_rdi]).to be_a(Integer)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
46
90
|
end
|
|
@@ -8,6 +8,7 @@ RSpec.describe PWN::Plugins::Findings do
|
|
|
8
8
|
@dir = Dir.mktmpdir
|
|
9
9
|
allow(Dir).to receive(:home).and_return(@dir)
|
|
10
10
|
stub_const('PWN::Plugins::Findings::FILE', File.join(@dir, 'findings.jsonl'))
|
|
11
|
+
stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(@dir, 'artifacts'))
|
|
11
12
|
@evidence = File.join(@dir, 'response.txt')
|
|
12
13
|
File.write(@evidence, 'Local fixture returned the reproducible response.')
|
|
13
14
|
end
|
|
@@ -30,6 +31,56 @@ RSpec.describe PWN::Plugins::Findings do
|
|
|
30
31
|
expect(described_class.report(engagement_id: 'other')).to eq([])
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
it 'persists directed enables links and replaces outgoing links durably' do
|
|
35
|
+
target = described_class.record_structured(finding)
|
|
36
|
+
source = described_class.record_structured(finding.merge(enables: [target[:id]]))
|
|
37
|
+
expect(target[:enables]).to eq([])
|
|
38
|
+
expect(described_class.report.find { |row| row[:id] == source[:id] }[:enables]).to eq([target[:id]])
|
|
39
|
+
expect(described_class.link(id: source[:id], enables: [])[:enables]).to eq([])
|
|
40
|
+
expect(described_class.report.find { |row| row[:id] == source[:id] }[:enables]).to eq([])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'rejects invalid outgoing links and cycles including legacy incoming links without mutation' do
|
|
44
|
+
a = described_class.record_structured(finding)
|
|
45
|
+
b = described_class.record_structured(finding.merge(attack_chain_refs: [a[:id]]))
|
|
46
|
+
other = described_class.record_structured(finding.merge(engagement_id: 'other'))
|
|
47
|
+
before = File.read(described_class::FILE)
|
|
48
|
+
[nil, 'bad', [123], ['missing'], [a[:id]], [b[:id], b[:id]], [other[:id]]].each do |enables|
|
|
49
|
+
expect { described_class.link(id: a[:id], enables: enables) }.to raise_error(ArgumentError)
|
|
50
|
+
end
|
|
51
|
+
expect { described_class.link(id: b[:id], enables: [a[:id]]) }.to raise_error(ArgumentError, /cycle/)
|
|
52
|
+
expect { described_class.record_structured(finding.merge(enables: [b[:id]], attack_chain_refs: [b[:id]])) }.to raise_error(ArgumentError, /cycle/)
|
|
53
|
+
expect { described_class.record_structured(finding.merge(enables: [other[:id]])) }.to raise_error(ArgumentError, /engagement/)
|
|
54
|
+
expect { described_class.record_structured(finding.merge(enables: [b[:id], b[:id]])) }.to raise_error(ArgumentError, /duplicate/)
|
|
55
|
+
expect(File.read(described_class::FILE)).to eq(before)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'treats empty and default engagement identifiers as the same graph scope' do
|
|
59
|
+
a = described_class.record_structured(finding.merge(engagement_id: ''))
|
|
60
|
+
b = described_class.record_structured(finding.merge(engagement_id: 'default', enables: [a[:id]]))
|
|
61
|
+
c = described_class.record_structured(finding.merge(engagement_id: 'default', attack_chain_refs: [a[:id]]))
|
|
62
|
+
expect(c[:attack_chain_refs]).to eq([a[:id]])
|
|
63
|
+
expect(b[:enables]).to eq([a[:id]])
|
|
64
|
+
expect { described_class.link(id: a[:id], enables: [b[:id]]) }.to raise_error(ArgumentError, /cycle/)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'links artifact handles to durable evidence hashes, reproduction steps and severity justification' do
|
|
68
|
+
stored = PWN::Plugins::ArtifactRegistry.spill(bytes: "crash\x00\xff".b, session_id: 'fixture')
|
|
69
|
+
args = finding.except(:evidence_paths).merge(
|
|
70
|
+
artifact_handles: [{ handle: stored[:handle], kind: 'crash', label: 'crash.bin' }],
|
|
71
|
+
severity_justification: 'The fixture leaks data without authentication; no integrity or availability impact demonstrated.',
|
|
72
|
+
reproduction_steps: ['Start the local fixture.', 'Run the supplied PoC and compare its output.']
|
|
73
|
+
)
|
|
74
|
+
row = described_class.record_structured(args)
|
|
75
|
+
expect(row[:severity_justification]).to eq(args[:severity_justification])
|
|
76
|
+
expect(row[:reproduction_steps]).to eq(args[:reproduction_steps])
|
|
77
|
+
evidence = row.fetch(:evidence_artifacts).first
|
|
78
|
+
expect(evidence).to include(handle: stored[:handle], path: stored[:path], sha256: stored[:sha256], kind: 'crash')
|
|
79
|
+
expect(File.binread(evidence[:stored])).to eq("crash\x00\xff".b)
|
|
80
|
+
expect(described_class.report.first[:evidence_artifacts]).to eq(row[:evidence_artifacts])
|
|
81
|
+
expect(row[:verification_status]).to eq('not_executed')
|
|
82
|
+
end
|
|
83
|
+
|
|
33
84
|
it 'flags removed evidence and rejects malformed metrics with trailing tokens' do
|
|
34
85
|
described_class.record_structured(finding)
|
|
35
86
|
File.delete(@evidence)
|
|
@@ -38,6 +89,50 @@ RSpec.describe PWN::Plugins::Findings do
|
|
|
38
89
|
expect { described_class.record_structured(finding.merge(cvss_vector: finding[:cvss_vector].sub('AV:N', 'AV:N:extra'))) }.to raise_error(ArgumentError, /cvss_vector/)
|
|
39
90
|
end
|
|
40
91
|
|
|
92
|
+
it 'detects tampering of the durable copy even when the original evidence still matches' do
|
|
93
|
+
row = described_class.record_structured(finding)
|
|
94
|
+
stored = row[:evidence_artifacts].first[:stored]
|
|
95
|
+
File.binwrite(stored, 'corrupted stored bytes')
|
|
96
|
+
expect(described_class.evidence_verify(engagement_id: 'fixture')).to include(ok: false, mismatches: include(stored))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'refuses a symlink masquerading as a durable evidence copy' do
|
|
100
|
+
sha = Digest::SHA256.file(@evidence).hexdigest
|
|
101
|
+
stored = File.join(@dir, '.pwn', 'engagements', 'fixture', 'evidence', sha)
|
|
102
|
+
FileUtils.mkdir_p(File.dirname(stored))
|
|
103
|
+
File.symlink(@evidence, stored)
|
|
104
|
+
expect { described_class.record_structured(finding) }.to raise_error(ArgumentError, /symlink/)
|
|
105
|
+
expect(described_class.report).to eq([])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'rejects invalid, missing, symlinked or changed handles without recording a finding' do
|
|
109
|
+
artifact = PWN::Plugins::ArtifactRegistry.spill(bytes: 'original', session_id: 'fixture')
|
|
110
|
+
args = finding.except(:evidence_paths).merge(artifact_handles: [artifact[:handle]], severity_justification: 'Read-only exposure.')
|
|
111
|
+
['../fixture/00000000.bin', 'fixture/00000000.bin', "#{artifact[:handle]}\n"].each do |handle|
|
|
112
|
+
expect { described_class.record_structured(args.merge(artifact_handles: [handle])) }.to raise_error(ArgumentError, /handle/)
|
|
113
|
+
end
|
|
114
|
+
File.unlink(artifact[:path])
|
|
115
|
+
File.symlink(@evidence, artifact[:path])
|
|
116
|
+
expect { described_class.record_structured(args) }.to raise_error(ArgumentError, /symlink/)
|
|
117
|
+
File.unlink(artifact[:path])
|
|
118
|
+
File.binwrite(artifact[:path], 'changed')
|
|
119
|
+
expect { described_class.record_structured(args) }.to raise_error(ArgumentError, /digest mismatch/)
|
|
120
|
+
expect(described_class.report).to eq([])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'accepts legacy full hash handles and preserves an expected digest supplied with a descriptor' do
|
|
124
|
+
artifact = PWN::Plugins::ArtifactRegistry.put(bytes: 'puts :fixture')
|
|
125
|
+
args = finding.except(:evidence_paths).merge(artifact_handles: [artifact[:sha256]], severity_justification: 'Read-only exposure.')
|
|
126
|
+
row = described_class.record_structured(args)
|
|
127
|
+
expect(row[:evidence_artifacts].first).to include(handle: artifact[:sha256], sha256: artifact[:sha256])
|
|
128
|
+
descriptor = { handle: artifact[:handle], sha256: '0' * 64 }
|
|
129
|
+
expect { described_class.record_structured(args.merge(artifact_handles: [descriptor])) }.to raise_error(ArgumentError, /sha256 mismatch/)
|
|
130
|
+
expect { described_class.record_structured(args.merge(severity_justification: ' ')) }.to raise_error(ArgumentError, /severity_justification/)
|
|
131
|
+
[[], [''], 'run a command', [123]].each do |steps|
|
|
132
|
+
expect { described_class.record_structured(args.merge(reproduction_steps: steps)) }.to raise_error(ArgumentError, /reproduction_steps/)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
41
136
|
it 'rejects a score inconsistent with the vector and unsafe engagement paths' do
|
|
42
137
|
expect { described_class.record_structured(finding.merge(cvss_score: 9.8)) }.to raise_error(ArgumentError, /cvss_score/)
|
|
43
138
|
expect { described_class.record_structured(finding.merge(engagement_id: '../escape')) }.to raise_error(ArgumentError, /engagement_id/)
|
|
@@ -78,11 +173,111 @@ RSpec.describe PWN::Plugins::Findings do
|
|
|
78
173
|
File.write(response, "HTTP/1.1 200 OK\n\nuid=0(root) fixture")
|
|
79
174
|
verified = described_class.verify(id: row[:id], kind: 'http', request_path: request, response_path: response, impact: 'uid=0')
|
|
80
175
|
expect(verified[:verification_status]).to eq('reproduced')
|
|
176
|
+
expect(verified[:evidence_artifacts].map { |artifact| artifact[:path] }).to include(request, response)
|
|
177
|
+
expect(verified[:evidence_artifacts].all? { |artifact| Digest::SHA256.file(artifact[:stored]).hexdigest == artifact[:sha256] }).to be(true)
|
|
81
178
|
expect(described_class.report(engagement_id: 'fixture').first[:verification_status]).to eq('reproduced')
|
|
82
179
|
File.write(response, "HTTP/1.1 403 Forbidden\n\ndenied")
|
|
83
180
|
retest = described_class.retest(id: row[:id], kind: 'http', request_path: request, response_path: response, impact: 'uid=0')
|
|
84
181
|
expect(retest[:verification_status]).to eq('fixed')
|
|
85
182
|
expect(retest[:status]).to eq('closed')
|
|
183
|
+
responses = retest[:evidence_artifacts].select { |artifact| artifact[:path] == response }
|
|
184
|
+
expect(responses.map { |artifact| artifact[:sha256] }.uniq.length).to eq(2)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'persists path-scoped impact assessments with actual evidence and only consecutive directed edges' do
|
|
188
|
+
a = described_class.record_structured(finding.merge(title: 'SSRF', cwe: 'CWE-918'))
|
|
189
|
+
b = described_class.record_structured(finding.merge(title: 'Internal admin'))
|
|
190
|
+
c = described_class.record_structured(finding.merge(title: 'Command execution'))
|
|
191
|
+
d = described_class.record_structured(finding.merge(title: 'Alternate entry'))
|
|
192
|
+
ids = [a[:id], b[:id], c[:id]]
|
|
193
|
+
path = File.join(@dir, 'chain.txt')
|
|
194
|
+
text = "Fixture replay #{ids.join(' -> ')}: SSRF reaches internal admin and executes the fixture command."
|
|
195
|
+
File.write(path, text)
|
|
196
|
+
result = described_class.chain_impact(ids: ids, combined_impact_path: path, escalate: true, combined_severity: 'critical')
|
|
197
|
+
expect(result[:rationale]).to eq(text)
|
|
198
|
+
rows = described_class.report.to_h { |row| [row[:id], row] }
|
|
199
|
+
expect(rows[a[:id]][:enables]).to eq([b[:id]])
|
|
200
|
+
expect(rows[b[:id]][:enables]).to eq([c[:id]])
|
|
201
|
+
expect(rows[c[:id]][:attack_chain_refs]).to eq([])
|
|
202
|
+
assessment = rows[c[:id]].fetch(:chain_assessments).first
|
|
203
|
+
expect(assessment).to include(finding_ids: ids, combined_severity: 'critical', rationale: text,
|
|
204
|
+
reproduction_steps: [a[:poc], b[:poc], c[:poc]])
|
|
205
|
+
artifact = assessment[:evidence_artifacts].first
|
|
206
|
+
expect(artifact).to include(path: path, sha256: Digest::SHA256.file(path).hexdigest, size: File.size(path))
|
|
207
|
+
expect(File.read(artifact[:stored])).to eq(text)
|
|
208
|
+
alternate = File.join(@dir, 'alternate.txt')
|
|
209
|
+
File.write(alternate, "Fixture replay #{d[:id]} -> #{c[:id]} demonstrates read-only exposure, not command execution.")
|
|
210
|
+
described_class.chain_impact(ids: [d[:id], c[:id]], combined_impact_path: alternate, escalate: true, combined_severity: 'high',
|
|
211
|
+
severity_justification: 'Read-only internal exposure.', reproduction_steps: ['Replay alternate fixture.'])
|
|
212
|
+
described_class.chain_impact(ids: ids, combined_impact_path: path, escalate: true, combined_severity: 'critical',
|
|
213
|
+
severity_justification: 'Command execution proven in fixture.')
|
|
214
|
+
assessments = described_class.report.find { |row| row[:id] == c[:id] }[:chain_assessments]
|
|
215
|
+
expect(assessments.length).to eq(2)
|
|
216
|
+
expect(assessments.find { |item| item[:finding_ids] == ids }[:rationale]).to eq('Command execution proven in fixture.')
|
|
217
|
+
expect(assessments.find { |item| item[:finding_ids] == [d[:id], c[:id]] }).to include(combined_severity: 'high', reproduction_steps: ['Replay alternate fixture.'])
|
|
218
|
+
expect(described_class.chain_score(ids: ids)[:combined_severity]).to eq('critical')
|
|
219
|
+
expect(described_class.chain_score(ids: [d[:id], c[:id]])[:combined_severity]).to eq('high')
|
|
220
|
+
expect(described_class.chain_score(ids: [b[:id], c[:id]])[:combined_severity]).to eq('medium')
|
|
221
|
+
expect(described_class.chain_score(ids: ids.reverse)[:combined_severity]).to eq('medium')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'scores exact requested paths through the canonical graph without unrelated assessment uplift' do
|
|
225
|
+
a = described_class.record_structured(finding.merge(title: 'SSRF', cwe: 'CWE-918'))
|
|
226
|
+
b = described_class.record_structured(finding.merge(title: 'Internal admin'))
|
|
227
|
+
other = described_class.record_structured(finding.merge(title: 'Other entry'))
|
|
228
|
+
path = File.join(@dir, 'impact.txt')
|
|
229
|
+
text = "Fixture replay #{a[:id]} -> #{b[:id]} proves SSRF reaches internal admin and executes a command."
|
|
230
|
+
File.write(path, text)
|
|
231
|
+
described_class.chain_impact(ids: [a[:id], b[:id]], combined_impact_path: path, escalate: true, combined_severity: 'critical')
|
|
232
|
+
described_class.link(id: other[:id], enables: [b[:id]])
|
|
233
|
+
allow(PWN::Reports).to receive(:attack_chains).and_call_original
|
|
234
|
+
expect(PWN::Reports).to receive(:attack_chains).with(findings: array_including(hash_including(id: a[:id]), hash_including(id: b[:id]))).at_least(:once).and_call_original
|
|
235
|
+
score = described_class.chain_score(ids: [a[:id], b[:id]])
|
|
236
|
+
expect(score).to include(score: 'critical', combined_severity: 'critical', n: 2, rationale: text)
|
|
237
|
+
expect(score[:chains].first).to include(finding_ids: [a[:id], b[:id]], assessment_status: 'evidence_backed')
|
|
238
|
+
expect(described_class.chain_score(chain_refs: [other[:id], b[:id]])).to include(score: 'medium', n: 2)
|
|
239
|
+
expect(described_class.chain_score(ids: [b[:id]])).to include(score: 'medium', n: 1)
|
|
240
|
+
expect(described_class.chain_score(ids: ['missing'])).to include(score: 'info', n: 0)
|
|
241
|
+
expect(described_class.chain_score[:combined_severity]).to eq('critical')
|
|
242
|
+
expect(described_class.report.map { |row| row[:severity] }.uniq).to eq(['medium'])
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it 'rejects invalid impact paths before changing any finding or evidence ledger' do
|
|
246
|
+
a = described_class.record_structured(finding)
|
|
247
|
+
b = described_class.record_structured(finding.merge(enables: [a[:id]]))
|
|
248
|
+
other = described_class.record_structured(finding.merge(engagement_id: 'other'))
|
|
249
|
+
path = File.join(@dir, 'chain.txt')
|
|
250
|
+
File.write(path, "Fixture replay #{a[:id]} #{b[:id]} #{other[:id]} includes all IDs but does not waive graph validation.")
|
|
251
|
+
before = File.read(described_class::FILE)
|
|
252
|
+
ledger = File.join(@dir, '.pwn', 'engagements', 'fixture', 'evidence.jsonl')
|
|
253
|
+
evidence_before = File.read(ledger)
|
|
254
|
+
[[a[:id], a[:id]], [a[:id], other[:id]], [a[:id], b[:id]], ['missing', a[:id]]].each do |ids|
|
|
255
|
+
expect { described_class.chain_impact(ids: ids, combined_impact_path: path) }.to raise_error(ArgumentError)
|
|
256
|
+
end
|
|
257
|
+
expect(File.read(described_class::FILE)).to eq(before)
|
|
258
|
+
expect(File.read(ledger)).to eq(evidence_before)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it 'rejects reverse links against legacy chain_refs and validates evidence before persisting impact' do
|
|
262
|
+
a = described_class.record_structured(finding)
|
|
263
|
+
legacy = described_class.record(title: 'Legacy child', severity: 'medium', engagement_id: 'fixture',
|
|
264
|
+
poc_artifacts: [@evidence], chain_refs: [a[:id]],
|
|
265
|
+
evidence: 'This local fixture is retained as the legacy finding evidence.')
|
|
266
|
+
expect { described_class.link(id: legacy[:id], enables: [a[:id]]) }.to raise_error(ArgumentError, /cycle/)
|
|
267
|
+
expect { described_class.link(id: 'missing', enables: []) }.to raise_error(ArgumentError, /existing/)
|
|
268
|
+
path = File.join(@dir, 'impact-validation.txt')
|
|
269
|
+
args = { ids: [a[:id], legacy[:id]], combined_impact_path: path, escalate: true, combined_severity: 'critical' }
|
|
270
|
+
before = File.read(described_class::FILE)
|
|
271
|
+
expect { described_class.chain_impact(args) }.to raise_error(ArgumentError, /readable/)
|
|
272
|
+
['', 'A long evidence file that still fails to mention either finding ID.'].each do |text|
|
|
273
|
+
File.write(path, text)
|
|
274
|
+
expect { described_class.chain_impact(args) }.to raise_error(ArgumentError, /name every/)
|
|
275
|
+
end
|
|
276
|
+
File.write(path, "Fixture #{a[:id]} -> #{legacy[:id]} demonstrates reproducible combined impact.")
|
|
277
|
+
expect { described_class.chain_impact(args.merge(severity_justification: ' ')) }.to raise_error(ArgumentError, /severity_justification/)
|
|
278
|
+
expect { described_class.chain_impact(args.merge(reproduction_steps: [])) }.to raise_error(ArgumentError, /reproduction_steps/)
|
|
279
|
+
expect { described_class.chain_impact(args.merge(combined_severity: 'unknown')) }.to raise_error(ArgumentError, /combined_severity/)
|
|
280
|
+
expect(File.read(described_class::FILE)).to eq(before)
|
|
86
281
|
end
|
|
87
282
|
|
|
88
283
|
it 'rejects signature-only verification and requires combined-impact evidence to escalate a chain' do
|
|
@@ -97,7 +292,7 @@ RSpec.describe PWN::Plugins::Findings do
|
|
|
97
292
|
File.write(impact, "Chaining #{parent[:id]} with #{child[:id]} yields account takeover on the fixture.")
|
|
98
293
|
chained = described_class.chain_impact(ids: [parent[:id], child[:id]], combined_impact_path: impact, escalate: true, combined_severity: 'high')
|
|
99
294
|
expect(chained[:combined_severity]).to eq('high')
|
|
100
|
-
expect(chained[:rationale]).to
|
|
295
|
+
expect(chained[:rationale]).to eq(File.read(impact))
|
|
101
296
|
gaps = described_class.issue_work_gaps(engagement_id: 'fixture')
|
|
102
297
|
expect(gaps[:unverified]).to include(child[:id])
|
|
103
298
|
expect(gaps[:unverified]).not_to include(parent[:id])
|