Skip to content

Conversation

timabrmsn
Copy link
Contributor

@timabrmsn timabrmsn commented Feb 4, 2021

This PR adds some helpers for writing code42cli powered extensions with a minimal amount of boilerplate, giving the extension-writer cli profiles, exception handling, and sdk initialization out of the box, so all they need to do is their custom script logic. Extensions can be standalone scripts that require no installation, or can be installed as a plugin so the commands become part of the main CLI usage (using the click_plugins package that helpfully keeps everything else working if an extension breaks).

Standalone scripts

To write a cli-powered standalone script, you just need to import the script custom click.Group and sdk_options, then add your click commands. This example is a script that takes a guid from an option and pretty-prints the resulting settings dict:

import click
from code42cli.extensions import script, sdk_options
from pprint import pprint

@click.option("--guid", required=True)
@sdk_options
def get_device_settings(state, guid):
    settings_response = state.sdk.devices.get_settings(guid)
    pprint(settings_response.data)


if __name__ == "__main__":
    script.add_command(get_device_settings)
    script()

If there's only a single defined command, the custom group makes it the default, so it can be invoked just from the base script itself:

# python script.py -h
Usage: script.py  [OPTIONS]

Options:
  --guid TEXT     [required]
  -d, --debug     Turn on debug logging.
  --profile TEXT  The name of the Code42 CLI profile to use when executing this command.
  -h, --help      Show this message and exit.

If multiple commands are defined, then script operates as a normal group and enumerates them, requiring the subcommand be called:

import click
from code42cli.extensions import script, sdk_options
from pprint import pprint


@click.option("--guid", required=True)
@sdk_options
def get_device_settings(state, guid):
    settings_response = state.sdk.devices.get_settings(guid)
    pprint(settings_response.data)


@click.argument("guid")
@sdk_options
def get_device_user(state, guid):
    settings_response = state.sdk.devices.get_settings(guid)
    print(settings_response.user_id)


if __name__ == "__main__":
    script.add_command(get_device_settings)
    script.add_command(get_device_user)
    script()
# python script.py -h
Usage: script.py [OPTIONS] COMMAND [ARGS]...

Options:
  -h, --help  Show this message and exit.

Commands:
  get-device-settings
  get-device-user

To help when a user might have multiple python installations in their path and python script.py isn't finding the code42cli package (but the regular code42 -h command works), a --python option has been added to print out the path to the interpreter that code42 is installed in. So the user can then just call $(code42 --python) script.py and it should just work without having to fiddle with their paths.

Installable extensions

To make the above script installable as a permanent extension to the CLI, you can put it in a package structure with a setup.py where the defined entrypoints are code42cli.plugins, the CLI will then be able to find them.

I've created an example repo with two installable packages here: https://github.com/timabrmsn/cli_plugin_test

To install the settings example command from above you can run

$(code42 --python) -m pip install "git+https://github.com/timabrmsn/cli_plugin_test#egg=code42cli_settings&subdirectory=settings"

And to install the users one:

$(code42 --python) -m pip install "git+https://github.com/timabrmsn/cli_plugin_test#egg=code42cli_users&subdirectory=users"

This allows support/PS to have a single repo of their extension scripts, and they can either link directly to the standalone one, or instruct the user to install it, depending on the need/use-case.

@github-actions
Copy link

github-actions bot commented Feb 4, 2021

CLA Assistant Lite bot All contributors have signed the CLA ✍️

@alanag13
Copy link
Contributor

alanag13 commented Feb 4, 2021

A couple thoughts here:

  • It may be valuable to look into a way to see if it would be possible to dynamically discover/import these scripts. For example, could we have a designated directory in .code42cli where these could be dropped, and then they would all show up as additional options in the main help text? It seems like it would be really nice to be able to keep a collection of these and not have to remember the names/paths of custom scripts as they get developed over time.

  • We should probably look over our API surface and come up with a better organizational scheme for what is public (okay to call via an extension) and what isn't, and make sure to adjust our coding practices to reflect that the terminal itself is no longer the only interface.

  • We should demo this to PS, perhaps even only in this state, to give them a feel for what the experience would be like for them.

def parse_args(self, ctx, args):
if len(args) > 1 and args[0] == "--script":
args[0] = sys.executable
if platform.system() == "Windows":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we sanitize the args somehow?
Probably a far-fetched concern, but if the CLI was running on a server, like maybe as part of an integration of some sort, and users were able to call custom scripts remotely, could this could be used as part of a shell-injection attack?

Bandit scan:

 Issue: [B605:start_process_with_a_shell] Starting a process with a shell, possible injection detected, security issue.
   Severity: High   Confidence: High
   Location: src/code42cli/click_ext/groups.py:40
   More Info: https://bandit.readthedocs.io/en/latest/plugins/b605_start_process_with_a_shell.html
39                      cmd = shlex.join(args)
40                  status = os.system(cmd)
41                  sys.exit(status)

Oddly, bandit claims it is a HIGH severity but the documentation says it should be LOW/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to worry about that since this function is explicitly for running user-provided code. If someone can hijack the actual execution of the CLI to get it to call --script on something unintended, they likely already have the ability to just run any arbitrary command they want.

Copy link
Contributor

@antazoey antazoey Feb 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was imagining a scenario where some integration or app intentionally allows user input to the Code42 CLI, which like I said, is maybe a far-fetched scenario. But, in this scenario, they expose the CLI, so it's not hijacked (yet).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it would be outside of our control if someone did that, albeit a dangerous decision on their part, but sanitization on our end could be an extra layer of security

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did start out playing around with just having code42 --python just return the full path to the interpreter code42 was installed under. So then if a user was having problems with the regular python script.py they could try $(code42 --python) script.py.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll just revert the exec python logic back to just printing the python path. It keeps the code simple and gives users a relatively easy path to figuring things out if their environment is broken.

@antazoey
Copy link
Contributor

antazoey commented Feb 4, 2021

A command that executes raw input from the user is kind of a red flag to me. That should be avoided whenever possible.

What if this PR could be made into a private extension for internal Code42 use? They would use this private CLI package (who's upstream is this public repo), and from there, they can add their own scripts?

Just a thought.

@antazoey
Copy link
Contributor

antazoey commented Feb 4, 2021

What if - we made another python package, like code42cli-lib that contains the parts of the code42cli that we want to share. Then, they can just write normal python scripts.

Edit: Is it important that the be able call it via code42 <cmd> rather than just python <custom-script.py ?

Edit2: Or it can just be in this package -- just a code42cli.lib path where you can import the profile and stuff.

@kiran-chaudhary
Copy link
Contributor

I am sure this might have been discussed but for my benefit I would like to raise it, if someone knows python then they can use py42, the only advantage cli can provide here to use script is about incremental results. Also one cannot use the custom script to go ahead and try something that is not supported in Py42!! So do we have real use cases here? the whole purpose of having it open source was others can come and add commands they feel are useful and raise PR against it!!

@kiran-chaudhary
Copy link
Contributor

the standard way to support such thing would be to have -i or --interactive option instead of a --script option. As we do python -i and that would load some default modules/objects and one can start doing as explained in the description.

@kiran-chaudhary
Copy link
Contributor

Also tested out the same,

code42 --script -h throws error AttributeError: module 'shlex' has no attribute 'join'

@timabrmsn
Copy link
Contributor Author

@kiran-chaudhary the background for this is that while we do want internal teams who write scripts that go to customers (Pro Service, Support, SST, etc) to contribute to the CLI development, the CLI isn't going to be able to accommodate every specific use-case. Sometimes a customer has a very particular need that the general goal of the CLI doesn't accomplish, and so support/PS/SST will need to write something custom for them.

Exposing this "extension" functionality in the CLI relieves the writer of the burden of having to do any boilerplate logic around getting credentials from the user, initializing the sdk, and handling/logging every possible Py42 error.

So remember the use case here is primarily for internal teams to be able to quickly and easily write custom scripts that are intended to be given to and run by customers (who often don't have a lot of programming experience).

With these, our teams can write up a script quickly, give it to the customer and just say "run pip3 install code42cli and create a profile". And this only needs to be done once for all future custom scripts to just work!

And to @unparalleled-js's question about is it important if they call it code42 --script script.py instead of python script.py, it's important to have an easy option to run it if there are path issues that prevent python script.py from working (like if their pip cmd is linked to a different python install than their python cmd). This way we know that if code42 <regular_cmd> works, then our script should just work too.

@timabrmsn
Copy link
Contributor Author

AttributeError: module 'shlex' has no attribute 'join'

Apparently .join() was only added in 3.8. I'll update.

@antazoey
Copy link
Contributor

antazoey commented Feb 5, 2021

Can we at least get reach out to the security guild or someone and get their advice?

@antazoey
Copy link
Contributor

antazoey commented Feb 5, 2021

@timabrmsn If this is intended for internal people to use, as you say, shouldn't it not be part of the main/publicly available CLI?

@timabrmsn
Copy link
Contributor Author

The primary writers of these scripts are going to be internal teams, yes. But the primary runners of these scripts will be customers, so they have to work with regular public-facing CLI installs.

@antazoey
Copy link
Contributor

antazoey commented Feb 5, 2021

The primary writers of these scripts are going to be internal teams, yes. But the primary runners of these scripts will be customers, so they have to work with regular public-facing CLI installs.

Ah ok I did not realize that. I will process this.

@alanag13
Copy link
Contributor

alanag13 commented Feb 5, 2021

@timabrmsn @unparalleled-js @kiran-chaudhary there's a lot of chatter happening here and comments are getting buried. Lets set up a meeting and get everyone on the same page.

Copy link
Contributor

@kiran-chaudhary kiran-chaudhary left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM from code's point of view. Wondering why we haven't documented this feature, the description given in the PR should be documented for future reference.

@@ -0,0 +1,5 @@
from code42cli.click_ext.groups import ExtensionGroup
from code42cli.main import CONTEXT_SETTINGS
from code42cli.options import sdk_options # noqa: F401
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering why are ignoring unused imports or suppressing warnings, noqa... statement? I need to read more on the usage of this statement, but the first thing that comes to my mind is will this suppress warnings only in context to this module or will it suppress all-over code42cli!!?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also can this be resolved by abstraction/design, breaking up the modules in such a way that we don't have to suppress any warnings or unused imports!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure the comment only suppresses it for the line being commented, I still got unused import style failures after this commit went in.

I imported sdk_options here just for the convenience of script-writers, so they can just do:

from code42cli.extensions import script, sdk_options

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way to get around this would be to create a directory named extensions with an __init__.py importing things for convenience; it seems like that is ok to do because we do it other places to make importing things more convenient.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good call. Making it a module would also allow us to better expand/organize things in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, so the style cop still balks at unused imports even in the module init. I wonder why it doesn't do that in py42? I'm not seeing any config differences...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nevermind. I found where we're ignoring it in the setup.cfg

@timabrmsn
Copy link
Contributor Author

Wondering why we haven't documented this feature

Documentation will be coming, I'm just holding off for now until I can demo the feature to our customer-facing teams to get any feedback before we finalize this implementation. I'm also not sure where the best spot is for the documentation to live. It could be in the README, ReadTheDocs, or the Dev Portal articles...

@alanag13
Copy link
Contributor

LGTM too, but I'll leave this for now in case you get feedback and want to add changes here.

@antazoey
Copy link
Contributor

Documentation would be a plus

@antazoey antazoey self-requested a review February 26, 2021 15:00
Copy link
Contributor

@antazoey antazoey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I accidentally approved early - everything looks good, just need a changelog and perhaps a readme update

@timabrmsn
Copy link
Contributor Author

docs/guides.md Outdated
@@ -5,3 +5,4 @@
* [Ingest file events or alerts into a SIEM](userguides/siemexample.md)
* [Manage detection list users](userguides/detectionlists.md)
* [Manage legal hold users](userguides/legalhold.md)
* [Write custom extension scripts using the code42cli and py42](userguides/extensions.md)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the code42cli be the Code42 CLI?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, probably.

from code42cli.options import profile_option


def sdk_options(f):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you just want to be really explicit, but this could just be

from code42cli.options import sdk_options as sdk_opts

def sdk_options(f):
    """Decorator that adds two `click.option`s (--profile, --debug) to wrapped command, as well as
    passing the `code42cli.options.CLIState` object using the [click.make_pass_decorator](https://click.palletsprojects.com/en/7.x/api/#click.make_pass_decorator),
    which automatically instantiates the `py42` sdk using the Code42 profile provided from the `--profile`
    option. The `py42` sdk can be accessed from the `state.sdk` attribute.

    Example:

        @click.command()
        @sdk_options
        def get_current_user_command(state):
            my_user = state.sdk.users.get_current()
            print(my_user)
    """
    f = sdk_opts()(f)
    return f

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even sdk_options = sdk_opts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly wanted to have the object we expose be it's own thing in case we ever want to change our internal sdk_options we're not having to work around the extension backwards compatibility.

@antazoey antazoey self-requested a review March 1, 2021 19:18
Copy link
Contributor

@antazoey antazoey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool!


## Before you begin

The Code42 CLI is a python application written using the [click framework](https://click.palletsprojects.com/en/7.x/), and the exposed extension objects are custom `click` classes. A basic knowledge of how to define `click` commands, arguments, and options is required.
Copy link
Contributor

@antazoey antazoey Mar 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a fan of line breaks in markdown because of horizontal scrolling in text editors. It seems like most markdown renderers can handle converting the newlines to spaces too. Maybe this is the issue with my IDE -- but I have seen markdown rules around line length too, so I know it's not just me

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, for some reason I had it in my mind that the newlines could cause odd linebreaks in the rendered sphinx text, but I tested it out and it doesn't seem to matter. Made it much more readable now!

@timabrmsn timabrmsn merged commit db7e49e into master Mar 1, 2021
@github-actions github-actions bot locked and limited conversation to collaborators Mar 1, 2021
@antazoey antazoey deleted the feature/extension_scripts branch April 21, 2021 13:56
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants