-
Notifications
You must be signed in to change notification settings - Fork 14k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for killing sessions based on last checkin time #18364
Add support for killing sessions based on last checkin time #18364
Conversation
I don't think this will be a scalable approach adding a one off flag for check-in functionality. It's not immediately clear how a user will filter/search for sessions that are checked in before or after a point in time - i.e. only run this command on sessions checked in less than 1 hour ago, or kill sessions greater than 1 day old Maybe we need to consider adding additional keyword search terms to the
Taking inspiration from Docker's filter options - it has similar support for filtering:
A similar DSL in msfconsole might be:
It would be out of scope, but we should do some pen and paper exercises first to ensure that the semantics would work for the search table:
|
8cd5fb5
to
6938597
Compare
4969912
to
d7abe5d
Compare
ca6ebd7
to
9683743
Compare
|
||
framework.sessions.each { |k| | ||
next unless session_ids.nil? || session_ids.include?(k[0]) | ||
session = k[1] | ||
row = create_msf_session_row(session, show_extended) | ||
tbl << row | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
framework.sessions.each { |k| | |
next unless session_ids.nil? || session_ids.include?(k[0]) | |
session = k[1] | |
row = create_msf_session_row(session, show_extended) | |
tbl << row | |
} | |
framework.sessions.each do |id , session| | |
next unless session_ids.nil? || session_ids.include?(id) | |
row = create_msf_session_row(session, show_extended) | |
tbl << row | |
} |
I know you're just using the existing code here but we can tidy this up a little since we're making changes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@adfoster-r7 this conflicts with going back to each_sorted as that only iterates on the ids - thoughts on where to go?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We didn't realise that each_sorted
only gave session ids rather than the sessions themselves
Taking a look at the each
function though shows that it is sorted anyway
metasploit-framework/lib/msf/core/session_manager.rb
Lines 191 to 197 in 2f0509f
def each(&block) | |
list = [] | |
self.keys.sort.each do |sidx| | |
list << [sidx, self[sidx]] | |
end | |
list.each(&block) | |
end |
so IMO I think we're good to stick with each
since you need the session objects anyway and it keeps the same sorting
session = framework.sessions[k] | ||
|
||
framework.sessions.each { |k| | ||
next unless session_ids.nil? || session_ids.include?(k[0]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If sessions_ids
is nil then it will be nil for every iteration of the loop, no need to check it over and over
you can also probably be a little smarter about filtering the sessions you want to include here, instead of looping over all the frameworks sessions and checking the id it might make more sense to loop over your list of sessions_ids
and then grab the session with framework.sessions.get(id)
@@ -987,6 +990,11 @@ def self.dump_sessions_verbose(framework, opts={}) | |||
framework.sessions.each_sorted do |k| | |||
session = framework.sessions[k] | |||
|
|||
if opts[:session_ids] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
similar to my previous comment, you should only need to check if opts[:session_ids]
has a value once rather than every loop
@@ -1425,6 +1431,7 @@ def cmd_sessions(*args) | |||
begin | |||
method = nil | |||
quiet = false | |||
search = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
search = false |
I don't think this is being used
if search_term | ||
matching_sessions = get_matching_sessions(search_term) | ||
if matching_sessions.empty? | ||
print_error("No matching sessions.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
print_error("No matching sessions.") | |
print_status("No matching sessions.") |
Not having sessions match your search term isn't an error, this also aligns with a later print_status
for not matching any sessions
matching_sessions = {} | ||
|
||
framework.sessions.each do |session_id, session| | ||
case search_term.split(":")[0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make sure to check if the search term is valid and feed that back to the user
case search_term.split(":")[0] | ||
when "last_checkin" | ||
if session.respond_to?(:last_checkin) && session.last_checkin && evaluate_search_criteria(session, search_term) | ||
matching_sessions.store(session_id, session) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
matching_sessions.store(session_id, session) | |
matching_sessions[session_id] = session |
This might just be a personal preference thing but I think this reads better and is much more common in the code base
when "session_type" | ||
matching_sessions.store(session_id, session) if evaluate_search_criteria(session, search_term) | ||
when "session_id" | ||
matching_sessions.store(session_id, session) if evaluate_search_criteria(session, search_term) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all these cases in the switch statement don't seem to do anything different?
matches.each do |session_id, session| | ||
matching_sessions.store(session_id, session) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://apidock.com/ruby/Hash/merge you can use merge
or merge!
instead of looping over your hashes
matches = filter_sessions_by_search(term) | ||
matches.each do |session_id, session| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
am I correct in thinking that this results in your filter being an or
filter rather than and
? so if you search for before a certain checkin time and for session to be a meterpreter type rather than getting only meterpreter sessions checking in before a certain time you get all sessions that have checked in and all meterpreter sessions regardless of checkin time?
I would have expected the search to be an and
and I think that's what users would expect too, but let me know if you disagree
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking an or filter for the case of someone searching for multiple different ids or session types, and having it be consistent from there. Seems like a few options -
- keep it as an
or
, which might lead to some users on the search results if they try to query on multiple fields as you pointed out - change it to an
and
, effectively disabling the ability to search for multiple different types of the same field - add the logic to make the or/and context sensitive, which might be more convoluted/inconsistent, but has the most functionality
Happy to defer to you here, lmk what you think
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's try and base our implementation off of how docker does it https://docs.docker.com/engine/reference/commandline/images/#filter since thye seem to have a fairly good UX for this kinda thing already
sessions IDs would be an or
then anything else would be an and
I really think the ability to provide a before time and an after time to select sessions from a certain range could be a pretty nice feature to have and I think it's more intuitive that way
Probably best to write up the test cases first for what we would expect to happen and maybe get a few more opinions on that when people can see what the workflow will be more easily
Killing matching sessions... | ||
Active sessions | ||
=============== | ||
|
||
Id Name Type Information Connection | ||
-- ---- ---- ----------- ---------- | ||
2 sesh2 meterpreter info tunnel (127.0.0.1) | ||
3 sesh3 java info tunnel (127.0.0.1) | ||
TABLE |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's indent this for readability, similar to
metasploit-framework/spec/lib/msf/base/serializer/readable_text_spec.rb
Lines 111 to 117 in 1ee7f03
expect(described_class.dump_datastore('Table name', Msf::DataStore.new, indent_length)).to match_table <<~TABLE | |
Table name | |
========== | |
No entries in data store. | |
TABLE | |
end |
for context heredoc can be aligned/extra indented: https://www.rubyguides.com/2018/11/ruby-heredoc/
|
||
let(:sessions) do | ||
{ | ||
1 => double('Session', last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not tested: I believe you should be able to use instance_double(::Msf::Session, ...)
here. instance_double
is nicer as it will verify your mocked calls against a real class instance - so if you mock values that are deleted from the real object your tests will start failing. Whilst a mock would continue to work.
This should work the methods that are called aren't defined with metaprogramming/method_missing logic
In RSpec 3.0 and higher, instance_double is an implementation of verifying doubles. Verifying doubles are defined in RSpec as "a stricter alternative to normal doubles that provide guarantees about what is being verified. When using verifying doubles, RSpec will check that the methods being stubbed are actually present on the underlying object, if it is available."
https://www.simplybusiness.co.uk/about-us/tech/2020/05/rspec-instance-doubles/
{ | ||
1 => double('Session', last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"), | ||
2 => double('Session', last_checkin: (Time.now - 90), type: 'meterpreter', sid:2, sname: "sesh2", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"), | ||
3 => double('Session', last_checkin: Time.now, type: 'java', sid:3, sname: "sesh3", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For anything timing related, it's probably best to mock time with TimeCop to ensure that your tests are deterministic on every test run. It also provides a nice API for time travel
metasploit-framework/spec/lib/msf/ui/console/command_dispatcher/db/klist_spec.rb
Lines 195 to 202 in 1ee7f03
before(:each) do | |
Timecop.freeze(Time.parse('Dec 18, 2022 12:33:40.000000000 GMT')) | |
create_tickets | |
end | |
after do | |
Timecop.return | |
end |
@@ -834,6 +834,7 @@ def self.dump_datastore(name, ds, indent = DefaultIndent, col = DefaultColumnWra | |||
def self.dump_sessions(framework, opts={}) | |||
output = "" | |||
verbose = opts[:verbose] || false | |||
session_ids = opts[:session_ids] || nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd be worried about race conditions in IDs changing over time or being deleted by threads due to race conditions in code etc.
What are your thoughts on passing in the sessions object here in the opts that you would like to have serialized? Of course if the user doesn't provide the sessions, it'll grab them from framework.sessions
. I think this would be a nicer API - and we wouldn't need this logic that's been added below either 🤔
framework.sessions.each { |k|
next unless session_ids.nil? || session_ids.include?(k[0])
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that makes sense - I'll change it up!
@@ -987,6 +990,11 @@ def self.dump_sessions_verbose(framework, opts={}) | |||
framework.sessions.each_sorted do |k| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to above, maybe this https://github.com/rapid7/metasploit-framework/pull/18364/files#r1347404847
@@ -115,6 +114,13 @@ class Core | |||
["-g", "--global"] => [ false, "Operate on global datastore variables"] | |||
) | |||
|
|||
VALID_PARAMS = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
VALID_PARAMS = | |
VALID_SESSION_SEARCH_PARAMS = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And can we use private_constant :VALID_SESSION_SEARCH_PARAMS
too to ensure this constant isn't available externally, as currently this constant would now be available as part of this classes public API 👍
https://aaronlasseigne.com/2016/10/26/know-ruby-private_constant/
context "with no sessions" do | ||
it "should show an empty table" do | ||
core.cmd_sessions | ||
expect(@output).to eq([ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be great to be consistent with the use of match_table
for these newly added tests 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looked into this a bit, this case doesn't return a table so using match table results in failure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
match_table
is a misnomer, it's mostly just a match_multiline_string
matcher
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting. There does seem to be some sort of formatting mismatch with the expecation though.
1) Msf::Ui::Console::CommandDispatcher::Core#cmd_sessions with no sessions should show an empty table
Failure/Error:
expect(@output).to match_table <<~TABLE
Active sessions
===============
No active sessions.
TABLE
Expected:
'Active sessions'
'==============='
''
'No active sessions.'
Received:
'["Active sessions", "===============", "", "No active sessions."]'
Raw Result:
["Active sessions", "===============", "", "No active sessions."]
Diff:
@@ -1,5 +1,2 @@
-Active sessions
-===============
-
-No active sessions.
+["Active sessions", "===============", "", "No active sessions."]
I'll dive deeper and see what I can uncover
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like you're expecting the string to equal an array:
Expected:
'Active sessions'
'==============='
''
'No active sessions.'
Received:
'["Active sessions", "===============", "", "No active sessions."]' <----
i.e. you're missing the @output.join("\n")
that's being used in the other test examples
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, thank ya!
framework.sessions.each_sorted { |k| | ||
session = framework.sessions[k] | ||
|
||
framework.sessions.each { |k| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I imagine we'll still want each_sorted
here, not sure if it was dropped intentionally or not 👀
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It didn't seem necessary and was causing issues with testing, but I'll fix it 💪
85c9cfb
to
6642afd
Compare
before(:each) do | ||
allow(driver).to receive(:active_session=) | ||
allow(framework).to receive(:sessions).and_return(sessions) | ||
Timecop.freeze(Time.parse('Dec 18, 2022 12:33:40.000000000 GMT')) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we don''t need to set up timecop before each test, we can do it once for this section of tests and also make sure to cleanup and tell timecop to return things to normal
https://github.com/travisjeffery/timecop#:~:text=You%20can%20mock%20the%20time%20for%20a%20set%20of%20tests%20easily%20via%20setup/teardown%20methods
end | ||
end | ||
|
||
context "searches with sessions with different ids" do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this context is good english but that might just be me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...isn't? 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦 yup isn't, maybe I shouldn't comment on english if I can't do it myself 🙃
context "searches with sessions with different checkin values" do | ||
let(:sessions) do | ||
{ | ||
1 => instance_double(::Msf::Sessions::Meterpreter_x64_Win, last_checkin: Time.parse('Dec 18, 2022 12:33:40.000000000 GMT'), type: 'meterpreter', sid:1, sname: "session1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since you're using Timecop you don't need to be parsing dates anymore
context "with sessions" do | ||
let(:sessions) do | ||
{ | ||
1 => instance_double(::Msf::Sessions::Meterpreter_x64_Win, last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just thinking out loud here but would it make more sense/be easier to read to just have a common set of session you could use across most of your tests here? rather than making a bespoke set for each test case you have
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree it would be easier to read, I put it this way since we don't want them to exist for the "no active sessions" context which exists at the same level as all the other contexts. But if we're fine separating them, or there's another rspec trick I'm not privy to, happy to change that up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Additionally, I suppose using the same sessions might be an issue when testing sessions that don't respond to last_checkin, if we want to see what a query returns when none of the sessions do
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my head I was thinking you could have two contexts, with and without sessions but it's entirely possible there's some technical issues you might or have already ran into that I'm not thinking of why you can't do that
end | ||
|
||
it "returns matching session when searching for id" do | ||
core.cmd_sessions("--search", "session_id:1") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there any tests for what happens when you combine searching for a session id and something else like type or checkin?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line 602!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still not seeing it 😅 I can see where you're testing type with checking but not with session id
it might be somewhere and the line numbers have changed since you replied and I just can't see though
print_error("Cannot search for last_checkin with two #{operator1} arguments.") | ||
return false | ||
end | ||
if (operator1 == "before" && operator2 == "after" && parse_duration(value2) < parse_duration(value1)) || (operator1 == "after" && operator2 == "before" && parse_duration(value1) < parse_duration(value2)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use single quotes ''
over double quotes ""
when not needed, I think rubocop should throw you a warning about that
# Make sure user is properly querying checkin | ||
unless checkin_searches.length() < 2 | ||
unless validate_checkin_searches(checkin_searches) | ||
return | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you should validate the checkin searches regardless of how many you have since if you only have a single checkin search you'll miss out on the rest of the validation you have (like that only before
and after
are valid)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that validation occurs later, but you're right it'd be good to consolidate it into that function to clean up the logic!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circling back - checkin_searches.length() < 2
is itself a validation, so the logic does need to be tweaked but we need to keep that line somewhere
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isn't that validation redundant since if you have 2 or more searches you'll either have an invalid operator or a duplicate? but if you think it's necessary then can't it be moved inside the validate_checkin_searches
function?
# Retrieve checkin search results. AND filter with a max length of 2 | ||
unless checkin_searches.empty? | ||
checkin_matches = {} | ||
checkin_matches = filter_sessions_by_search(checkin_searches.first) | ||
if checkin_searches[1] | ||
matches = filter_sessions_by_search(checkin_searches[1]) | ||
checkin_matches = checkin_matches.select{ |session_id, session| matches[session_id] == session } | ||
end | ||
searches << checkin_matches | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
checkin_matches = {}
checkin_searches.each do | search_query |
...
end
you should just be able to loop over the contents and achieve the same thing you're trying to do here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little confused what a loop would add here since checkin_searches can't be more than two elements and all we're trying to do is get the intersection of the two if there are two, can you elaborate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
uhhh yea I was thinking it could look a bit tidier and maybe more future proof but don't worry about it sure
if matching_sessions | ||
print_status("Killing matching sessions...") | ||
print_line | ||
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should this be a print_status
?
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) | |
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was just going with the existing functionality, but that's no reason not to give it a swap if we think it's better
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this question became automagically unresolved; It shouldn't be print_status
here as the table would be printed with a [*]
prefix which isn't needed
unless matching_sessions | ||
return | ||
end | ||
if matching_sessions.empty? | ||
print_error("No matching sessions.") | ||
return | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unless matching_sessions | |
return | |
end | |
if matching_sessions.empty? | |
print_error("No matching sessions.") | |
return | |
end | |
if matching_sessions.empty? | |
print_error("No matching sessions.") | |
return | |
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assuming it can't return nil, but if it can you'll need to check that too
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It returns nil if the user enters invalid input (basically anywhere we'd hit print_error()
and the nil bubbles up as a flag to cut off the search, which is why the unless matching_sessions
is there - should I go about this differently?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in that case you could change my suggestion here to
if matching_sessions.nil? || matching_sessions.empty?
I would also explicitly return nil
if that's what you're expecting
@@ -56,7 +55,7 @@ class Core | |||
["-k", "--kill"] => [ true, "Terminate the specified thread ID.", "<id>" ], | |||
["-K", "--kill-all"] => [ false, "Terminate all non-critical threads." ], | |||
["-i", "--info"] => [ true, "Lists detailed information about a thread.", "<id>" ], | |||
["-l", "--list"] => [ false, "List all background threads." ], | |||
["-l", "--list"] => [ false, "List all background threads." ], | |||
["-v", "--verbose"] => [ false, "Print more detailed info. Use with -i and -l" ]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't comment on the line directly, but updating the --search
options above:
["-S", "--search"] => [ true, "Row search filter.", "<filter>" ],
To include an example of the filter would be useful for users to discover this functionality 👍
The thought being - if the feature isn't documented for users, it doesn't necessarily exist
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adding in session_type
would be useful too, just to make sure all the options are visible to the user
expected_values = { | ||
"1s" => 1, | ||
"2s" => 2, | ||
"1.5m" => 90, | ||
"1.5d" => 129600, | ||
"1d1h1m1s" => 90061, | ||
"1.5d1.5h1.5m1.5s" => 135091, | ||
"1.75m70s" => 175 | ||
} | ||
|
||
it_behaves_like "parses time values correctly", expected_values |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't quite how you use it_behaves_like
, which is more useful for testing the different behavior of instances/objects
Shared examples let you describe behaviour of classes or modules. When declared, a shared group’s content is stored. It is only realized in the context of another example group, which provides any context the shared group needs to run.
https://rspec.info/features/3-12/rspec-core/example-groups/shared-examples/
Paired with the above knowledge - and that it's generally best practice is to have 1 expectation for each it
block - https://www.betterspecs.org/#single
This pattern might be cleaner:
expected_values = { | |
"1s" => 1, | |
"2s" => 2, | |
"1.5m" => 90, | |
"1.5d" => 129600, | |
"1d1h1m1s" => 90061, | |
"1.5d1.5h1.5m1.5s" => 135091, | |
"1.75m70s" => 175 | |
} | |
it_behaves_like "parses time values correctly", expected_values | |
{ | |
"1s" => 1, | |
"2s" => 2, | |
"1.5m" => 90, | |
"1.5d" => 129600, | |
"1d1h1m1s" => 90061, | |
"1.5d1.5h1.5m1.5s" => 135091, | |
"1.75m70s" => 175 | |
}.each do |input, expected| | |
it "returns #{expected} seconds for the input #{input}" do | |
expect(core.parse_duration(input)).to eq(output) | |
end | |
end |
As it allows you to run failed tests individually from the command line, whilst with your current pattern that's not possible
require 'optparse' | ||
|
||
require 'pry-byebug' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
require 'pry-byebug' |
This is what's causing the test failures
unless matching_sessions | ||
return | ||
end | ||
if matching_sessions.empty? | ||
print_error("No matching sessions.") | ||
return | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in that case you could change my suggestion here to
if matching_sessions.nil? || matching_sessions.empty?
I would also explicitly return nil
if that's what you're expecting
def validate_checkin_searches(checkin_searches) | ||
checkin_searches.each do |search_term| | ||
unless search_term.split(":").length == 3 | ||
print_error("Please only specify last_checkin, before or after, and a time. Ex: last_checkin:before:1m3os") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
print_error("Please only specify last_checkin, before or after, and a time. Ex: last_checkin:before:1m3os") | |
print_error("Please only specify last_checkin, before or after, and a time. Ex: last_checkin:before:1m30s") |
return false | ||
end | ||
operator = checkin_searches[0].split(":")[1] | ||
unless operator == "before" || operator == "after" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
still have these "before" and "after" strings, you should be able to check if VALID_OPERATORS
includes your operator here
return false | ||
end | ||
end | ||
return true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return true | |
true |
pretty sure this is what rubocop would want
end | ||
when "session_id" | ||
return session.sid.to_s == operator | ||
when "session_type" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dropping this comment here but it applies in a few places
when you're using a string like 'session_type'
here multiple times you should consider making it a constant
end | ||
end | ||
|
||
context "searches with sessions with different ids" do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦 yup isn't, maybe I shouldn't comment on english if I can't do it myself 🙃
end | ||
|
||
it "returns matching session when searching for id" do | ||
core.cmd_sessions("--search", "session_id:1") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still not seeing it 😅 I can see where you're testing type with checking but not with session id
it might be somewhere and the line numbers have changed since you replied and I just can't see though
context "with sessions" do | ||
let(:sessions) do | ||
{ | ||
1 => instance_double(::Msf::Sessions::Meterpreter_x64_Win, last_checkin: Time.now, type: 'meterpreter', sid:1, sname: "sesh1", info: "info", session_host: "127.0.0.1", tunnel_to_s: "tunnel") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my head I was thinking you could have two contexts, with and without sessions but it's entirely possible there's some technical issues you might or have already ran into that I'm not thinking of why you can't do that
|
||
describe "#parse_duration" do | ||
expected_values = { | ||
"1s" => 1, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a warning is a good shout, I wouldn't expect it to combine them, I reckon it's more likely someone typo'd a second d
instead on an h
and would appreciate knowing they made a mistake
end | ||
end | ||
|
||
context "searches for checkin with sessions that do not respond to checkin" do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what do you think about adding a test to verify the session doesn't respond to checkin? that way if commandshell gets that feature or some future traveller updates this test unknowingly to a session that does respond to checkin it'll warn them
224a457
to
742aa78
Compare
SESSION_TYPE = "session_type" | ||
SESSION_ID = "session_id" | ||
LAST_CHECKIN = "last_checkin" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SESSION_TYPE = "session_type" | |
SESSION_ID = "session_id" | |
LAST_CHECKIN = "last_checkin" | |
SESSION_TYPE = 'session_type' | |
SESSION_ID = 'session_id' | |
LAST_CHECKIN = 'last_checkin' |
should these also be private constants?
end | ||
time_value = search_term.split(":")[2] | ||
time_unit_string = time_value.gsub(/[^a-zA-Z]/, '') | ||
unless time_unit_string == time_unit_string.squeeze |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
squeeze
only removes duplicate letters in a row right? so dds
becomes ds
but it doesn't work with say for example dmd
it would remain dmd
right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but at that point it'll get caught by the parse_time function and print print_error("Unrecognized time format: #{value}")
|
||
def parse_duration(duration) | ||
total_time = 0 | ||
time_tokens = duration.scan(/(?:\d+\.?\d*|\.\d+)/).zip(duration.scan(/[a-zA-Z]+/)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens here if the user puts in capital letters? I don't see them being downcase'd anywhere and the switch statement only deals with the lower case characters
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added a downcase to the switch 👍
@@ -1794,6 +1843,177 @@ def cmd_sessions(*args) | |||
true | |||
end | |||
|
|||
def get_matching_sessions(search_term) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zgoldman-r7 I think @dwelch-r7 has called it out a few times now separately - but could we run Rubocop on all new code that's being added? i.e. these chunks of code, and the code in your test. You can run it from the command line with bundle exec rubocop -A file_1.rb file_2.rb
The rest of the file hasn't been rubocop'ed, so you'll just need to make sure the new code is rubocop'ed
@@ -1755,7 +1804,7 @@ def cmd_sessions(*args) | |||
end | |||
when 'list', 'list_inactive', nil | |||
print_line | |||
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, search_term: search_term)) | |||
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry dude I know I asked you to change this but it needs to go back, I thought it was a mistake but the tables shouldn't be print_status
since it appends a little extra to the output
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) | |
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) |
if matching_sessions | ||
print_status('Killing matching sessions...') | ||
print_line | ||
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here as below, just regular print for tables, my bad
print_status(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) | |
print(Serializer::ReadableText.dump_sessions(framework, show_active: show_active, show_inactive: show_inactive, show_extended: show_extended, verbose: verbose, sessions: matching_sessions)) |
'1.5d1.5h1.5m1.5s' => 135091, | ||
'1.75m70s' => 175 | ||
}.each do |input, expected| | ||
it 'returns #{expected} seconds for the input #{input}' do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
String interpolation only works with double quotes, and the indentation is a bit off above; I think Rubocop might've caught both of these
Original comment:
#18364 (comment)
432650a
to
565e528
Compare
when GREATER_THAN | ||
return checkin_time < threshold_time | ||
when LESS_THAN | ||
return checkin_time > threshold_time |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just thinking out loud here, what do we think about swapping the order of the comparisons here so the switch case lines up with the >
and <
operators and renaming checkin_time
to last_checkin_time
to make it clear which one is the user input and which is the sessions actual last checkin time
when GREATER_THAN
return threshold_time > last_checkin_time
when LESS_THAN
return threshold_time < last_checkin_time
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ohh I like it
VALID_SESSION_SEARCH_PARAMS = | ||
%w[ | ||
last_checkin | ||
session_id | ||
session_type | ||
] | ||
VALID_OPERATORS = | ||
%w[ | ||
less_than | ||
greater_than | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use the constants defined above in here aswell
21f18a1
to
c134908
Compare
remove single flag pivot to search flag added support for search session type adds search session id support remove stale references reshuffle code fix time parsing, add command support fix search list, reduce duplicated code testing added killall with search lists table of killed sessions sessions are no longer represented by ids addresses feedback on code structure and search behavior some test reshuffling, switch raised errors to printed ones add checkin validation, rest of cmd_sessions tests add time parsing test refactoring test reformatting and adjusted error validation make error handling more explicit, add test context fixes sub quotes, make constant rubocopping switch before and after to greater than and less than mbetter incorporate constants update example
c134908
to
b4b7352
Compare
Release NotesAdd support for filtering sessions based on last checkin time, session type and id |
Looks like the verification steps on this PR are wrong 👀 |
Looks like the existing issue is still open, I'm not sure if that's intentional or not - or if there's more work required here? 👀 If it was meant to be closed, a trick that we use is following the convention of adding |
I think we're missing an associated wiki entry for this functionality so folk can search how to kill stale sessions from the docs site 📚 |
A small UX niggle I ran into when trying to use this functionality for the weekly wraup; It looks like there's no user affordance given when the user provides an incorrect search term:
For MVP we could just add the list of available valid terms so that the user can self service:
Out of scope - If the filter functionality ever became complex enough, we could follow the pattern of how the
|
Implements existing --search (or -S) flag to address #5664
closes #5664
Uses:
list sessions that have checked in within 10 seconds:
sessions --search "last_checkin:less_than:10s"
list stale sessions that havent within 10 seconds:
sessions --search "last_checkin:greater_than:10s"
kill all stale sessions older than 20 seconds:
sessions --search "last_checkin:greater_than:10s" -K
sessions -K --search "last_checkin:greater_than:10s"
search for a session with specific ids
sessions --search "session_id:1 session_id:2"
search for sessions of a particular type
sessions --search "session_type:meterpreter"
search for sessions with multiple query parameters
sessions --search "session_id:1 session_type:meterpreter last_checkin:after:10s"
kill sessions that match a range of query parameters
sessions --search "session_id:1 session_type:meterpreter last_checkin:after:10s" -K
run a command on sessions that match a range of query parameters
sessions -c 'whoami' --search "session_id:1 session_type:meterpreter last_checkin:after:10s"
Verification
List the steps needed to make sure this thing works
msfconsole
sessions -v
to see what metadata exists