pwn 0.5.737 → 0.5.739
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/.rubocop_todo.yml +0 -1
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +17 -3
- data/documentation/Diagrams.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +74 -0
- data/documentation/Swarm.md +2 -3
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-REPL.md +0 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/browser-exploitation/SKILL.md +10 -0
- data/etc/default_skills/firmware-unpack-emulate/SKILL.md +10 -0
- data/etc/default_skills/heap-exploitation/SKILL.md +11 -0
- data/etc/default_skills/kernel-pwn-triage/SKILL.md +11 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -1
- data/etc/default_skills/pwn/ai/agent/tools/sbom_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/corpus/SKILL.md +52 -0
- data/etc/default_skills/pwn/corpus/references/urls.md +3 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/debugger/SKILL.md +62 -0
- data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/handler/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/mitm_proxy/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/redb/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/sbom/SKILL.md +48 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +1 -0
- data/etc/default_skills/rop-jop-chain-construction/SKILL.md +10 -0
- data/etc/default_skills/windows-internals-triage/SKILL.md +10 -0
- data/lib/pwn/ai/agent/loop.rb +80 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +19 -1
- data/lib/pwn/ai/agent/registry.rb +34 -4
- data/lib/pwn/ai/agent/swarm.rb +4 -5
- data/lib/pwn/ai/agent/tool_guard.rb +11 -4
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +2 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +9 -3
- data/lib/pwn/ai/agent/tools/pty_session.rb +7 -1
- data/lib/pwn/ai/agent/tools/sbom_scan.rb +25 -0
- data/lib/pwn/ai/agent/tools/shell.rb +3 -2
- data/lib/pwn/ai/agent/tools/swarm.rb +3 -6
- data/lib/pwn/ai/agent/turn_finalizer.rb +2 -2
- data/lib/pwn/corpus.rb +107 -0
- data/lib/pwn/engagement.rb +158 -0
- data/lib/pwn/plugins/debugger.rb +284 -0
- data/lib/pwn/plugins/findings.rb +14 -3
- data/lib/pwn/plugins/fuzz.rb +62 -0
- data/lib/pwn/plugins/handler.rb +171 -0
- data/lib/pwn/plugins/jobs.rb +55 -0
- data/lib/pwn/plugins/mitm_proxy.rb +37 -2
- data/lib/pwn/plugins/nmap_it.rb +32 -0
- data/lib/pwn/plugins/packet.rb +18 -0
- data/lib/pwn/plugins/preflight_checker.rb +6 -2
- data/lib/pwn/plugins/process_tube.rb +99 -8
- data/lib/pwn/plugins/redb.rb +210 -0
- data/lib/pwn/plugins/repl.rb +0 -3
- data/lib/pwn/plugins/sbom.rb +145 -0
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/redaction.rb +53 -0
- data/lib/pwn/reports/engagement.rb +2 -1
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +2 -0
- data/packer/provisioners/irc.sh +1 -1
- data/spec/lib/pwn/ai/agent/harness_2026_09_15_spec.rb +144 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +50 -0
- data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +31 -0
- data/spec/lib/pwn/ai/agent/tools/sbom_scan_spec.rb +10 -0
- data/spec/lib/pwn/corpus_spec.rb +23 -0
- data/spec/lib/pwn/engagement_spec.rb +27 -0
- data/spec/lib/pwn/plugins/debugger_spec.rb +27 -0
- data/spec/lib/pwn/plugins/handler_spec.rb +25 -0
- data/spec/lib/pwn/plugins/redb_spec.rb +33 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +7 -1
- data/spec/lib/pwn/plugins/sbom_spec.rb +22 -0
- data/third_party/pwn_rdoc.jsonl +97 -4
- metadata +36 -9
- data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +0 -47
- data/lib/pwn/plugins/repl/irc.rb +0 -153
- data/spec/lib/pwn/plugins/repl/irc_spec.rb +0 -21
|
@@ -47,6 +47,56 @@ describe PWN::AI::Agent::Registry do
|
|
|
47
47
|
expect(names.length).to eq(described_class::CORE_TOOLS.length)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
it 'advertises the MCP broker for a named backend in the normal core-only prompt path' do
|
|
51
|
+
described_class.discover
|
|
52
|
+
request = 'Use combo.nation to inspect the menu catalog'
|
|
53
|
+
enabled = PWN::AI::Agent::Loop.send(:default_interactive_toolsets, request: request)
|
|
54
|
+
expect(PWN::AI::MCP::ComboNation).not_to receive(:connect)
|
|
55
|
+
definitions = described_class.definitions(core_only: true, relevance: request, enabled: enabled)
|
|
56
|
+
tool = definitions.find { |row| row.dig(:function, :name) == 'mcp' }
|
|
57
|
+
expect(tool).not_to be_nil
|
|
58
|
+
expect(tool.dig(:function, :description)).to include('combo_nation', 'PWN::AI::MCP::ComboNation', 'menu_catalog')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'routes MCP names and capabilities without relying on keyword rank or changing unrelated core prompts' do
|
|
62
|
+
described_class.discover
|
|
63
|
+
allow(described_class).to receive(:router_enabled?).and_return(true)
|
|
64
|
+
['PWN::AI::MCP::ComboNation', 'combo_nation', 'ComboNation', 'combo.nation', 'menu_dial_calibration', 'MCP backends'].each do |query|
|
|
65
|
+
[true, false].each do |core_only|
|
|
66
|
+
names = described_class.definitions(core_only: core_only, relevance: query, top_k: 0).map { |row| row.dig(:function, :name) }
|
|
67
|
+
expect(names).to include('mcp')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
names = described_class.definitions(core_only: true, relevance: 'inspect a local Ruby file').map { |row| row.dig(:function, :name) }
|
|
71
|
+
expect(names).to eq(described_class::CORE_TOOLS)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'discovers another MCP client from its metadata and respects explicit toolset exclusion' do
|
|
75
|
+
described_class.discover
|
|
76
|
+
client = Module.new
|
|
77
|
+
%i[connect disconnect list_tools call_tool].each { |method| client.define_singleton_method(method) { |_| {} } }
|
|
78
|
+
client.const_set(:TOOLS, %w[inspect_widget])
|
|
79
|
+
stub_const('PWN::AI::MCP::WidgetLab', client)
|
|
80
|
+
expect(client).not_to receive(:connect)
|
|
81
|
+
%w[widget_lab inspect_widget].each do |query|
|
|
82
|
+
tools = described_class.definitions(core_only: true, relevance: query)
|
|
83
|
+
mcp = tools.find { |row| row.dig(:function, :name) == 'mcp' }
|
|
84
|
+
expect(mcp.dig(:function, :description)).to include('widget_lab', 'inspect_widget')
|
|
85
|
+
expect(described_class.definitions(core_only: true, relevance: query, enabled: [])).to eq([])
|
|
86
|
+
names = described_class.definitions(core_only: true, relevance: query, enabled: ['pwn']).map { |row| row.dig(:function, :name) }
|
|
87
|
+
expect(names).not_to include('mcp')
|
|
88
|
+
end
|
|
89
|
+
expect(described_class.lookup(name: 'mcp').schema[:description]).not_to include('WidgetLab')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'does not let MCP relevance bypass a failed availability check' do
|
|
93
|
+
described_class.discover
|
|
94
|
+
entry = described_class.lookup(name: 'mcp')
|
|
95
|
+
allow(entry.check).to receive(:call).and_return(false)
|
|
96
|
+
tools = described_class.definitions(core_only: true, relevance: 'combo.nation')
|
|
97
|
+
expect(tools.map { |row| row.dig(:function, :name) }).not_to include('mcp')
|
|
98
|
+
end
|
|
99
|
+
|
|
50
100
|
it 'applies observed shell and Ruby prerequisites to the existing core tools' do
|
|
51
101
|
described_class.discover
|
|
52
102
|
context = { environment: :local, capabilities: { shell: false, ruby: false } }
|
|
@@ -51,6 +51,37 @@ describe 'PWN::AI::Agent::Tools mcp' do
|
|
|
51
51
|
expect(PWN::AI::Agent::Registry.lookup(name: 'mcp').toolset).to eq('mcp')
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
it 'dispatches the broker advertised for a combo.nation prompt and preserves menu IDs across calls' do
|
|
55
|
+
registry = PWN::AI::Agent::Registry
|
|
56
|
+
tools = registry.definitions(core_only: true, relevance: 'Inspect combo.nation menus')
|
|
57
|
+
schema = tools.find { |tool| tool.dig(:function, :name) == 'mcp' }
|
|
58
|
+
expect(schema).not_to be_nil
|
|
59
|
+
fake = start_fake_server
|
|
60
|
+
# Only replace process spawning; exercise the real protocol client, broker,
|
|
61
|
+
# schema validation and Dispatch with JSON arguments as returned by a model.
|
|
62
|
+
allow(PWN::AI::MCP::ComboNation).to receive(:connect).and_wrap_original do |method, opts|
|
|
63
|
+
expect(opts[:allow_hardware]).not_to eq(true)
|
|
64
|
+
method.call(opts.merge(reader: fake[:reader], writer: fake[:writer], timeout: 2))
|
|
65
|
+
end
|
|
66
|
+
calls = [
|
|
67
|
+
{ action: 'list_tools', backend: 'combo_nation' },
|
|
68
|
+
{ action: 'call_tool', backend: 'combo_nation', name: 'menu_guess', arguments: { option: '5.10' } }
|
|
69
|
+
]
|
|
70
|
+
results = calls.map do |args|
|
|
71
|
+
JSON.parse(PWN::AI::Agent::Dispatch.call(
|
|
72
|
+
scope_policy: { enabled: false },
|
|
73
|
+
tool_call: { id: 'mcp-test', type: 'function', function: { name: schema.dig(:function, :name), arguments: JSON.generate(args) } }
|
|
74
|
+
))
|
|
75
|
+
end
|
|
76
|
+
expect(results.first.dig('result', 'tools').map { |tool| tool['name'] }).to include('menu_catalog')
|
|
77
|
+
expect(results.last.dig('result', 'parsed', 'arguments')).to eq('option' => '5.10')
|
|
78
|
+
expect(results.last.dig('result', 'allow_hardware')).to eq(false)
|
|
79
|
+
expect(PWN::AI::MCP::ComboNation).to have_received(:connect).once
|
|
80
|
+
ensure
|
|
81
|
+
PWN::AI::MCP.reset!
|
|
82
|
+
fake[:thread].join(2) if fake
|
|
83
|
+
end
|
|
84
|
+
|
|
54
85
|
it 'lists MCP backends and drives combo.nation tools through Dispatch-shaped args' do
|
|
55
86
|
entry = PWN::AI::Agent::Registry.lookup(name: 'mcp')
|
|
56
87
|
listed = entry.handler.call(action: 'backends')
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'PWN::AI::Agent::Tools sbom_scan' do
|
|
6
|
+
it 'registers sbom_scan' do
|
|
7
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/sbom_scan.rb'
|
|
8
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'sbom_scan')).not_to be_nil
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
describe PWN::Corpus do
|
|
7
|
+
it 'should display information for authors' do
|
|
8
|
+
expect(described_class).to respond_to :authors
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'should display information for existing help method' do
|
|
12
|
+
expect(described_class).to respond_to :help
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'resolves a named builtin corpus from cache' do
|
|
16
|
+
Dir.mktmpdir('corpus') do |dir|
|
|
17
|
+
stub_const('PWN::Corpus::ROOT', dir)
|
|
18
|
+
path = described_class.get(name: :fuzz_short, offline: true)
|
|
19
|
+
expect(File.read(path)).to include('../')
|
|
20
|
+
expect(described_class.get(name: :fuzz_short, offline: true)).to eq(path)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
describe PWN::Engagement do
|
|
7
|
+
it 'should display information for authors' do
|
|
8
|
+
expect(described_class).to respond_to :authors
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'should display information for existing help method' do
|
|
12
|
+
expect(described_class).to respond_to :help
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'persists host state and requires override for out-of-scope targets' do
|
|
16
|
+
Dir.mktmpdir('eng') do |dir|
|
|
17
|
+
stub_const('PWN::AI::Agent::Engagement::ROOT', dir)
|
|
18
|
+
stub_const('PWN::AI::Agent::Engagement::ACTIVE_FILE', File.join(dir, 'active'))
|
|
19
|
+
described_class.open(name: 'lab', scope_cidrs: ['10.0.0.0/8'])
|
|
20
|
+
described_class.record_host(host: '10.1.2.3', ports: [22], notes: ['ssh'])
|
|
21
|
+
expect(described_class.hosts.values.first[:ports]).to include(22)
|
|
22
|
+
expect(described_class.warn_unless_in_scope(host: '8.8.8.8')[:override_required]).to eq(true)
|
|
23
|
+
described_class.record_host(host: '8.8.8.8', ports: [53], override: true)
|
|
24
|
+
expect(described_class.hosts.values.map { |row| row[:host] }).to include('8.8.8.8')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
describe PWN::Plugins::Debugger do
|
|
7
|
+
it 'should display information for authors' do
|
|
8
|
+
expect(described_class).to respond_to :authors
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'should display information for existing help method' do
|
|
12
|
+
expect(described_class).to respond_to :help
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'recovers a cyclic offset without parsing gdb text' do
|
|
16
|
+
pattern = described_class.cyclic(length: 200)
|
|
17
|
+
slice = pattern[80, 4]
|
|
18
|
+
expect(described_class.cyclic_find(value: slice, length: 200)).to eq(80)
|
|
19
|
+
expect(described_class.to_pwntools_offsets(value: slice, length: 200)[:offset]).to eq(80)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'parses MI register values into a hash' do
|
|
23
|
+
row = described_class.send(:parse_mi, raw: '^done,register-values=[{number="16",name="rip",value="0x41414141"}]')
|
|
24
|
+
expect(row[:ok]).to eq(true)
|
|
25
|
+
expect(row[:registers]['rip']).to eq('0x41414141')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
describe PWN::Plugins::Handler do
|
|
7
|
+
it 'should display information for authors' do
|
|
8
|
+
expect(described_class).to respond_to :authors
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'should display information for existing help method' do
|
|
12
|
+
expect(described_class).to respond_to :help
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'generates a reverse shell payload without Metasploit' do
|
|
16
|
+
row = described_class.generate(kind: 'rev_sh', host: '127.0.0.1', port: 4444)
|
|
17
|
+
expect(row[:payload]).to include('127.0.0.1')
|
|
18
|
+
expect(row[:payload]).to include('4444')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'generates bind python and powershell payloads' do
|
|
22
|
+
expect(described_class.generate(kind: 'bind_python', port: 5555)[:payload]).to include('5555')
|
|
23
|
+
expect(described_class.generate(kind: 'bind_powershell', port: 5555)[:payload]).to include('GetStream')
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
describe PWN::Plugins::REDB do
|
|
8
|
+
it 'should display information for authors' do
|
|
9
|
+
expect(described_class).to respond_to :authors
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'should display information for existing help method' do
|
|
13
|
+
expect(described_class).to respond_to :help
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'answers who calls strcpy from cache on a second open' do
|
|
17
|
+
Dir.mktmpdir('redb') do |dir|
|
|
18
|
+
stub_const('PWN::Plugins::REDB::ROOT', File.join(dir, 'redb'))
|
|
19
|
+
src = File.join(dir, 'x.c')
|
|
20
|
+
bin = File.join(dir, 'x')
|
|
21
|
+
File.write(src, "#include <string.h>\nvoid caller(void){ char b[8]; strcpy(b, \"x\"); }\nint main(void){ caller(); return 0; }\n")
|
|
22
|
+
system('cc', '-O0', '-g', '-fno-builtin', '-fno-inline', '-o', bin, src)
|
|
23
|
+
described_class.open(bin: bin)
|
|
24
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
described_class.open(bin: bin)
|
|
26
|
+
xrefs = described_class.xrefs_to(bin: bin, sym: 'strcpy')
|
|
27
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
28
|
+
expect(elapsed).to be < 1.0
|
|
29
|
+
expect(xrefs).to be_an(Array)
|
|
30
|
+
expect(xrefs.map { |row| row['dst'] || row[:dst] }).to include('strcpy')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -8,12 +8,18 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
8
8
|
described_class,
|
|
9
9
|
described_class::ASM,
|
|
10
10
|
described_class::AI,
|
|
11
|
-
described_class::IRC,
|
|
12
11
|
described_class::Mesh,
|
|
13
12
|
described_class::Vault
|
|
14
13
|
].map { |mod| File.read(mod.method(:add_commands).source_location.first) }.uniq.join("\n")
|
|
15
14
|
end
|
|
16
15
|
|
|
16
|
+
it 'does not register pwn-irc or define REPL::IRC' do
|
|
17
|
+
described_class.add_commands
|
|
18
|
+
expect(Pry::Commands.find_command('pwn-irc')).to be_nil
|
|
19
|
+
expect(defined?(PWN::Plugins::REPL::IRC)).to be_nil
|
|
20
|
+
expect(PWN::Plugins::IRC).to be_a(Module)
|
|
21
|
+
end
|
|
22
|
+
|
|
17
23
|
describe 'ai.memory pinned engagement block' do
|
|
18
24
|
it 'handles dotted commands locally in AI mode and registers the Pry command' do
|
|
19
25
|
described_class.add_commands
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe PWN::Plugins::SBOM do
|
|
6
|
+
it 'should display information for authors' do
|
|
7
|
+
expect(described_class).to respond_to :authors
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'should display information for existing help method' do
|
|
11
|
+
expect(described_class).to respond_to :help
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'normalizes grype JSON into package/cve rows' do
|
|
15
|
+
json = { 'matches' => [{ 'artifact' => { 'name' => 'openssl', 'version' => '1.1.1' },
|
|
16
|
+
'vulnerability' => { 'id' => 'CVE-2024-0001', 'severity' => 'HIGH', 'fix' => { 'versions' => ['1.1.1w'] } } }] }
|
|
17
|
+
allow(Open3).to receive(:capture2).and_return([JSON.generate(json), instance_double(Process::Status, success?: true)])
|
|
18
|
+
allow(PWN::Plugins::PreflightChecker).to receive(:bin?).and_return(true)
|
|
19
|
+
out = described_class.scan(path_or_image: '/tmp/lock', engine: 'grype')
|
|
20
|
+
expect(out[:vulns].first).to include(package: 'openssl', cve: 'CVE-2024-0001', severity: 'high', fix_version: '1.1.1w')
|
|
21
|
+
end
|
|
22
|
+
end
|