diff --git a/CITATION.cff b/CITATION.cff
index 0e783d1..eb6afea 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -17,6 +17,12 @@ authors:
- given-names: Johnny
family-names: Steenbergen
affiliation: CrowdStrike
+ - given-names: Banu
+ family-names: Yuceer
+ affiliation: CrowdStrike
+ - given-names: Joshua
+ family-names: Hiller
+ affiliation: CrowdStrike
repository-code: 'https://github.com/CrowdStrike/foundry-fn-python'
url: 'https://www.crowdstrike.com'
repository-artifact: 'https://pypi.org/project/crowdstrike-foundry-function/'
@@ -31,7 +37,7 @@ keywords:
- crowdstrike-api
- crowdstrike-falcon-api
- crowdstrike-foundry
- - crowdstrike-ffaas
+ - crowdstrike-faas
- python
- windows
- linux
diff --git a/README.md b/README.md
index 7a26dc4..f04e309 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,19 @@
-
+
+
+
+
+
-# Foundry Function as a Service Python SDK
+# Falcon Foundry Function as a Service Python FDK
`foundry-fn-python` is a community-driven, open source project designed to enable the authoring of functions.
-While not a formal CrowdStrike product, `foundry-fn-python` is maintained by CrowdStrike and supported in partnership
-with the open source developer community.
+While not a formal CrowdStrike product, the `foundry-fn-python` project and the `crowdstrike-foundry-function` FDK package are maintained by CrowdStrike and supported in partnership with the open source developer community.
## Installation ⚙️
-### Via `pip`
+### Installation via `pip`
-The SDK can be installed or updated via `pip install`:
+The FDK can be installed or updated via `pip install`:
```shell
python3 -m pip install crowdstrike-foundry-function
@@ -18,13 +21,12 @@ python3 -m pip install crowdstrike-foundry-function
## Quickstart 💫
-### Code
+### Example Code
-Add the SDK to your project by following the [installation](#installation) instructions above,
-then create your `handler.py`:
+Add the FDK to your project by following the [installation](#installation) instructions above,
+then create your `main.py` that contains your handler implementation:
```python
-import logging
from crowdstrike.foundry.function import (
APIError,
Request,
@@ -32,138 +34,512 @@ from crowdstrike.foundry.function import (
Function,
)
-func = Function.instance() # *** (1) ***
+func = Function.instance()
+
+# An example POST handler
+@func.handler(method='POST', path='/my-resource')
+def on_post(request: Request) -> Response:
-@func.handler(method='POST', path='/create') # *** (2) ***
-def on_create(request: Request, config: [dict[str, any], None],
- logger: logging.Logger) -> Response: # *** (3), (4), (5) ***
- if len(request.body) == 0:
+ # Validate the request body
+ if 'name' not in request.body:
+ # This example expects 'name' field in the request body.
+ # Return an error response (400 - Bad Request) if not provided by the caller
return Response(
code=400,
- errors=[APIError(code=400, message='empty body')]
+ errors=[APIError(code=400, message='name field is missing from request body')]
)
- #####
- # do something useful
- #####
+ # Process the request
+ new_resource_id = 1
+ # ...snip...
- return Response( # *** (6) ***
- body={'hello': 'world'},
+ # Return a success response
+ return Response(
+ body={
+ 'result': f'Resource with name {request.body["name"]} is created.',
+ 'id': new_resource_id
+ },
code=200,
)
-@func.handler(method='PUT', path='/update')
-def on_update(request: Request) -> Response: # *** (7) ***
- # do stuff
+if __name__ == '__main__':
+ func.run()
+```
+
+### Example breakdown
+
+#### The Function object
+The `Function` class wraps the Foundry Function implementation. A `Function` instance can consist of one or more handlers, with each corresponding to an endpoint. You should only have one `Function` object defined per function implemented within your Foundry application. Multiple instances will result in unexpected behavior.
+
+```python
+func = Function.instance()
+```
+
+#### The function handler decorator
+The handler decorator defines a Python method as handler for a specific endpoint. This handler must have the `method` and `path` keywords defined. The `method` keyword will correspond to one of the supported HTTP methods (`GET`, `POST`, `PUT`, `PATCH` or `DELETE`). The `path` keyword will define the URL used to trigger this method, and should be unique.
+
+```python
+@func.handler(method='POST', path='/my-resource')
+```
+
+#### Method details - Request
+Our python handler function is decorated with `@func.handler`. The first argument to our method must be a `Request` object which defines the HTTP request payload and metadata.
+
+A `Request` object consists of:
+
+* `body`: The request payload as given in the Function Gateway `body` payload field. This will be deserialized as a dictionary (`dict[str, Any]`).
+* `params`: The request headers (`params.header`) and query string parameters (`params.query`).
+* `url`: The request path relative to the function. This is a string.
+* `method`: The request HTTP method or verb.
+* `access_token`: Caller-supplied access token.
+
+In this example we've named our method `on_post`, but you may name the method whatever you wish.
+
+```python
+def on_post(request: Request) -> Response:
+```
+
+#### Method details - Response
+The return type for our method should be a `Response` object.
+
+##### Successful responses
+A successful response will be a `Response` object containing the fields `body` (a dictionary containing the response returned to the function) and `code` (the HTTP status code returned to the function).
+
+```python
+# Return a success response
+return Response(
+ body={
+ 'result': f'Resource with name {request.body["name"]} is created.',
+ 'id': new_resource_id
+ },
+ code=200,
+)
+```
+
+##### Error responses
+An unsuccessful response will be a `Response` object containing the fields `errors` (a list of `APIError` objects) and `code` (the HTTP status code returned to the function).
+
+An `APIError` object will contain a `code` indicating the type of the error and a `message` which should contain the error text.
+
+If no `code` is provided as part of the `Response` object, this value will be derived from the greatest valid HTTP code present within the `APIError` list.
+
+```python
+return Response(
+ code=400,
+ errors=[APIError(code=400, message='id field is missing from request params')]
+)
+```
+
+#### Running the function
+The runner method is the general starting point for execution of your function and will be executed when your code is called by Foundry. This causes the `Function` to initialize and start execution. This should be the last line of your script as code defined after the `func.run()` statement may not be executed. You may implement code before this statement as necessary.
+
+```python
+if __name__ == '__main__':
+ func.run()
+```
+
+#### Retrieving parameters passed to your Function
+You may retrieve query string values passed to your function by accessing the `request.params.query` dictionary.
+
+```python
+resource_id = request.params.query.get("id")
+```
+
+
+#### Additional HTTP method examples
+Different types of HTTP requests will follow the same pattern demonstrated in our `POST` request above.
+
+##### HTTP GET
+
+```python
+from crowdstrike.foundry.function import (
+ APIError,
+ Request,
+ Response,
+ Function,
+)
+
+
+func = Function.instance()
+
+# An example GET handler
+@func.handler(method='GET', path='/my-resource')
+def on_get(request: Request) -> Response:
+
+ # Fetch the requested resources
+ resources = []
+ # ...snip...
+
+ # Return the requested resources
return Response(
- # ...snip...
+ body={'resources': resources},
+ code=200,
)
-@func.handler(method='DELETE', path='/foo')
-def on_delete(request: Request, config: [dict[str, any], None]) -> Response: # *** (8) ***
- # do stuff
+if __name__ == '__main__':
+ func.run()
+```
+
+##### HTTP PUT
+
+```python
+from crowdstrike.foundry.function import (
+ APIError,
+ Request,
+ Response,
+ Function,
+)
+
+
+func = Function.instance()
+
+# An example PUT handler
+@func.handler(method='PUT', path='/my-resource')
+def on_put(request: Request) -> Response:
+
+ # Obtain the id of the resource to update from the request query parameters
+ resource_id = request.params.query.get('id')
+ if not resource_id:
+ # This example expects 'id' field in the request query parameters.
+ # Returns an error response (400 - Bad Request) if not provided by the caller
+ return Response(
+ code=400,
+ errors=[APIError(code=400, message='id field is missing from request params')]
+ )
+
+ # Get the update data provided in the request body and
+ # Update the resource with the data provided
+ data = request.body.get('data')
+ # ...snip...
+
+ # Return success with the updated resource info
return Response(
- # ...snip...
+ body={
+ 'result': f'Resource {resource_id} is updated successfully.',
+ 'data': data
+ },
+ code=200,
)
if __name__ == '__main__':
- func.run() # *** (9) ***
-```
-
-1. `Function`: The `Function` class wraps the Foundry Function implementation.
- Each `Function` instance consists of a number of handlers, with each handler corresponding to an endpoint.
- Only one `Function` should exist per Python implementation.
- Multiple `Function`s will result in undefined behavior.
-2. `@func.handler`: The handler decorator defines a Python function/method as an endpoint.
- At a minimum, the `handler` must have a `method` and a `path`.
- The `method` must be one of `DELETE`, `GET`, `PATCH`, `POST`, and `PUT`.
- The `path` corresponds to the `url` field in the request.
- The SDK will provide any loaded configuration as an argument.
-3. Methods decorated with `@handler` must take arguments in the order of `Request` and `dict|None`
- (i.e. the request and either the configuration or nothing; see example above),
- and must return a `Response`.
-4. `request`: Request payload and metadata. At the time of this writing, the `Request` object consists of:
- 1. `body`: The request payload as given in the Function Gateway `body` payload field. Will be deserialized as
- a `dict[str, Any]`.
- 2. `params`: Contains request headers and query parameters.
- 3. `url`: The request path relative to the function as a string.
- 4. `method`: The request HTTP method or verb.
- 5. `access_token`: Caller-supplied access token.
-5. `logger`: Unless there is specific reason not to, the function author should use the `Logger` provided to the
- function.
- When deployed, the supplied `Logger` will be formatted in a custom manner and will have fields injected to assist
- with working against our internal logging infrastructure.
- Failure to use the provided `Logger` can thus make triage more difficult.
-6. Return from a `@handler` function: Returns a `Response` object.
- The `Response` object contains fields `body` (payload of the response as a `dict`),
- `code` (an `int` representing an HTTP status code),
- `errors` (a list of any `APIError`s), and `header` (a `dict[str, list[str]]` of any special HTTP headers which
- should be present on the response).
- If no `code` is provided but a list of `errors` is, the `code` will be derived from the greatest positive valid HTTP
- code present on the given `APIError`s.
-7. `on_update(request: Request)`: If only one argument is provided, only a `Request` will be provided.
-8. `on_delete(request: Request, config: [dict[str, any], None])`: If two arguments are provided, a `Request` and config
- will be provided.
-9. `func.run()`: Runner method and general starting point of execution.
- Calling `run()` causes the `Function` to finish initializing and start executing.
- Any code declared following this method may not necessarily be executed.
- As such, it is recommended to place this as the last line of your script.
+ func.run()
+```
+
+##### HTTP DELETE
+
+```python
+from crowdstrike.foundry.function import (
+ APIError,
+ Request,
+ Response,
+ Function,
+)
+
+
+func = Function.instance()
+
+# An example DELETE handler
+@func.handler(method='DELETE', path='/my-resource')
+def on_delete(request: Request) -> Response:
+
+ # Obtain the id of the resource to update from the request query parameters
+ resource_id = request.params.query.get('id')
+ if not resource_id:
+ # This example expects 'id' field in the request query parameters.
+ # Returns an error response (400 - Bad Request) if not provided by the caller
+ return Response(
+ code=400,
+ errors=[APIError(code=400, message='id field is missing from request params')]
+ )
+
+ # Delete the requested resource
+ # ...snip...
+
+ # Return success back to the caller
+ return Response(
+ code=200,
+ )
+
+
+if __name__ == '__main__':
+ func.run()
+```
+
### Testing locally
-The SDK provides an out-of-the-box runtime for executing the function.
-A basic HTTP server will be listening on port 8081.
+The FDK provides an out-of-the-box runtime for executing the function.
+
+#### Executing your code
+> [!NOTE]
+> A basic HTTP server will be started to listen on port 8081 when executing your code locally.
```shell
-cd my-project && python3 main.py
+cd my-project
+python3 main.py
```
-Requests can now be made against the executable.
+You can use `curl` or another python application to make requests to the web server that has been started.
+
+##### Example POST request
```shell
-curl -X POST 'http://localhost:8081' \
+# Test POST /my-resource request
+curl --location 'http://localhost:8081' \
-H 'Content-Type: application/json' \
--data '{
"body": {
- "foo": "bar"
+ "name": "bar"
},
"method": "POST",
- "url": "/create"
+ "url": "/my-resource"
+}'
+```
+
+##### Example GET request
+
+```shell
+# Test GET /my-resource request
+curl --location 'http://localhost:8081' \
+ -H 'Content-Type: application/json' \
+ --data '{
+ "method": "GET",
+ "url": "/my-resource"
+}'
+```
+
+##### Example PUT request
+
+```shell
+# Test PUT /my-resource request
+curl --location 'http://localhost:8081' \
+ -H 'Content-Type: application/json' \
+ --data '{
+ "body": {
+ "name": "bar",
+ },
+ "params": {
+ "query": {
+ "id": "12345"
+ }
+ },
+ "method": "PUT",
+ "url": "/my-resource"
}'
```
-## Working with `falconpy`
+##### Example DELETE request
+
+```shell
+# Test DELETE /my-resource request
+curl --location 'http://localhost:8081' \
+ -H 'Content-Type: application/json' \
+ --data '{
+ "params": {
+ "query": {
+ "id": "12345"
+ }
+ },
+ "method": "DELETE",
+ "url": "/my-resource"
+}'
+```
+
+#### Executing your code without an HTTP server
+
+If you prefer to test your function locally without starting an HTTP server, you can provide the request payload in a JSON file on the command line.
+
+First, create a JSON file containing your request payload.
+Example `request_payload.json` file:
+```shell
+{
+ "body": {
+ "name": "bar"
+ },
+ "method": "POST",
+ "url": "/my-resource"
+}
+```
+
+Then invoke your function handler as follows:
+
+```shell
+python3 main.py --data ./request_payload.json
+```
+
+This will execute the requested function handler and print the response returned, including the response status code, body and headers.
+
+You can also provide request headers to your function on the command line:
+```shell
+python3 main.py --data request_payload.json --header "Content-Type: application/json" --header "X-CUSTOM-HEADER: testing"
+```
+
+## Leveraging the FalconPy SDK to interact with CrowdStrike APIs inside of your Foundry function
+Foundry function authors should include `crowdstrike-falconpy` within their _requirements.txt_ file and then import `falconpy` explicitly in their function code.
-Function authors should import `falconpy` explicitly as a requirement in their project when needed.
+You may use any [FalconPy Service Class](https://falconpy.io/Home.html#service-collections) or the [FalconPy Uber Class](https://falconpy.io/Usage/Basic-Uber-Class-usage.html) within your function.
-### General usage
+### General FalconPy usage information
+FalconPy implements [Context Authentication](https://falconpy.io/Usage/Authenticating-to-the-API.html#context-authentication) for use within Foundry Functions, removing the need for developers to provide their `access_token` to the class as this value is provided by context when the function is executed.
-**Important:** Create a new instance of each `falconpy` client you want on each request.
+> [!TIP]
+> If you are instantiating a FalconPy class within your method, you will need to do this for every method you implement. If you instantiate the FalconPy class outside of your method, but before the `func.run()` statement, this object will be available to all methods defined in your function code.
+To test the function locally without having to adjust your code, you can set the following environment variables in your local environment:
+
+| Variable Name | Purpose |
+| :--- | :--- |
+| `FALCON_CLIENT_ID` | CrowdStrike Falcon API client ID |
+| `FALCON_CLIENT_SECRET` | CrowdStrike Falcon API client secret |
+
+
+#### FalconPy usage example
```python
-# omitting other imports
-from falconpy.alerts import Alerts
-from crowdstrike.foundry.function import cloud, Function
+
+from falconpy import Hosts
+from crowdstrike.foundry.function import (
+ Function,
+ Request,
+ Response,
+ APIError
+)
+
func = Function.instance()
-@func.handler(...)
-def endpoint(request, config):
- # ... omitting other code ...
- # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- # !!! create a new client instance on each request !!!
- # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+@func.handler(method='POST', path='/hosts-query')
+def on_hosts_query(request: Request) -> Response:
+
+ # get the requested Host IDs from the request body
+ host_ids = request.body.get("ids")
+ if not host_ids:
+ return Response(
+ code=400,
+ errors=[APIError(code=400, message='Required host ids are not provided')]
+ )
+
+ # Initialize falconpy client for Hosts API
+ # This example uses context authentication
+ hosts_client = Hosts()
+
+ # Call falconpy API to fetch the details of the requested hosts
+ api_result = hosts_client.get_device_details_v1(ids=host_ids)
+ if api_result['status_code'] != 200:
+ # falconpy API returned an error
+ response = Response(
+ code=api_result['status_code'],
+ errors=[
+ APIError(code=api_result['status_code'], message="falconpy API call failed")
+ ]
+ )
+ else:
+ # falconpy API was successful, return the requested data
+ response = Response(
+ body={
+ 'hosts': api_result['body']['resources']
+ },
+ code=200
+ )
- falconpy_alerts = Alerts(access_token=request.access_token, base_url=cloud())
+ return response
- # ... omitting other code ...
+if __name__ == '__main__':
+ func.run()
```
+## Using custom configurations and debug logging
+Foundry supports custom configurations and debug logging to support developers with the implementation of their functions.
+
+### Implementing custom configurations
+Using a custom configuration within a function is optional and may be provided as a JSON file. This functionality is intended to give the developer a location to store custom configuration data, such as API keys and credentials, in a secure manner when the function is deployed on Falcon platform.
+
+> [!NOTE]
+> The configuration is encrypted with a unique key per function in the cloud.
+
+To utilize a custom configuration within a function, include the `config` keyword argument as shown in the example below.
+
+The `config` keyword is an optional argument to the handler function and must be the second argument if provided.
+
+### Enabling logging
+Logging for a function is optional but adding log messages to functions can make triage and debugging easier when troubleshooting problems. When a function is deployed on the Falcon platform, the messages logged with the provided `logger` are formatted in a custom manner with fields injected to assist with working within the Falcon logging infrastructure.
+
+> [!NOTE]
+> You may use native [FalconPy logging](https://falconpy.io/Usage/Logging.html) in conjunction with your function logger config by providing the `debug` keyword when you instantiate your FalconPy class.
+
+To utilize logging in a function, include the `logger` parameter as shown in the example below.
+
+`logger` is an optional parameter to the handler function and must the third parameter if provided.
+
+
+```python
+from logging import Logger
+from typing import Union, Any
+from falconpy import Hosts
+from crowdstrike.foundry.function import (
+ Function,
+ Request,
+ Response,
+ APIError
+)
+
+
+func = Function.instance()
+
+
+@func.handler(method='POST', path='/hosts-query')
+def on_hosts_query(request: Request, config: Union[dict[str, Any], None], logger: Logger) -> Response:
+
+ logger.info("POST handler for /hosts-query is invoked")
+
+ # get the requested Host IDs from the request body
+ host_ids = request.body.get("ids")
+ if not host_ids:
+ logger.error("ids argument is missing from request parameters")
+ return Response(
+ code=400,
+ errors=[APIError(code=400, message='Required host ids are not provided')]
+ )
+
+ # Example config provided to the function
+ action = "Dev resource update"
+ if config and config.get("is_production", False):
+ action = "Production resource update"
+
+ # Initialize falconpy client for Hosts API and enable debugging
+ hosts_client = Hosts(debug=True)
+
+ # Call falconpy API to fetch the details of the requested hosts
+ api_result = hosts_client.get_device_details_v1(ids=host_ids)
+ if api_result['status_code'] != 200:
+ # FalconPy SDK returned an error
+ response = Response(
+ code=api_result['status_code'],
+ errors=[
+ APIError(code=api_result['status_code'], message="FalconPy API call failed")
+ ]
+ )
+ else:
+ # falconpy API was successful, return the requested data
+ response = Response(
+ body={
+ 'hosts': api_result['body']['resources'],
+ 'action': action
+ },
+ code=200
+ )
+
+ return response
+
+if __name__ == '__main__':
+ func.run()
+```
+
+
---
diff --git a/SECURITY.md b/SECURITY.md
index 769fef2..b800a73 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,4 +1,8 @@
-
+
+
+
+
+
# Security Policy
@@ -7,7 +11,7 @@ This document outlines security policy and procedures for the CrowdStrike `found
+ [Supported Python versions](#supported-python-versions)
+ [Supported Operating Systems](#supported-operating-systems)
+ [Supported CrowdStrike regions](#supported-crowdstrike-regions)
-+ [Supported foundry-fn-python versions](#supported-foundry-fn-python-versions)
++ [Supported crowdStrike-foundry-function versions](#supported-crowdstrike-foundry-function-versions)
+ [Reporting a potential security vulnerability](#reporting-a-potential-security-vulnerability)
+ [Disclosure and Mitigation Process](#disclosure-and-mitigation-process)
@@ -31,7 +35,7 @@ foundry-fn-python is unit tested for functionality across all commercial CrowdSt
| US-2 |
| EU-1 |
-## Supported foundry-fn-python versions
+## Supported crowdstrike-foundry-function versions
When discovered, we release security vulnerability patches for the most recent release at an accelerated cadence.
diff --git a/SUPPORT.md b/SUPPORT.md
index 433461e..535029a 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -1,8 +1,12 @@
-
+
+
+
+
+
# Repository Support
-`foundry-fn-python` is a community-driven, open source project designed to assist developers in leveraging the power of CrowdStrike APIs within their solutions. While not a formal CrowdStrike product, `foundry-fn-python` is maintained by CrowdStrike and supported in partnership with the open source developer community.
+`foundry-fn-python` is a community-driven, open source project designed to assist developers in leveraging the power of CrowdStrike APIs within their solutions. While not a formal CrowdStrike product, `foundry-fn-python` repo and the `crowdstrike-foundry-function` FDK package are maintained by CrowdStrike and supported in partnership with the open source developer community.
## Issue Reporting and Questions 🐛
diff --git a/docs/asset/cs-logo-red.png b/docs/asset/cs-logo-red.png
new file mode 100644
index 0000000..50ac32b
Binary files /dev/null and b/docs/asset/cs-logo-red.png differ
diff --git a/docs/asset/cs-logo.png b/docs/asset/cs-logo.png
index 72e2dff..8efa550 100644
Binary files a/docs/asset/cs-logo.png and b/docs/asset/cs-logo.png differ
diff --git a/examples/complex_inputs/complex_inputs_example.py b/examples/complex_inputs/complex_inputs_example.py
index 9a43e47..ec1e007 100644
--- a/examples/complex_inputs/complex_inputs_example.py
+++ b/examples/complex_inputs/complex_inputs_example.py
@@ -1,16 +1,20 @@
-from crowdstrike.foundry.function import Function, Request, Response, APIError
-from logging import Logger
+"""Example Foundry Function with POST handler."""
+from crowdstrike.foundry.function import Function, Request, Response
func = Function.instance()
@func.handler(method='POST', path='/my-endpoint')
-def handle_complex_inputs(request: Request, config: [dict[str, any], None], logger: Logger) -> Response:
- '''
- handle_complex_inputs showcases how to provide multiple inputs to a function, some of which happen to be files.
+def handle_complex_inputs(request: Request) -> Response:
+ """Implement example function handler to showcase how to handle complex inputs.
+
+ This example demonstrates how to provide multiple inputs to a function, some of which happen to be files.
In this case, it simply echoes back the contents of those files concatenated together with spaces.
This could easily be changed to something more advanced to work with arbitrary binary.
- '''
+
+ :param request: :class:`Request` to handle.
+ :return: :class:`Response`
+ """
greeting = f'Welcome {request.body.get("name", "")}, age {request.body.get("age", "")}'
file_contents = []
for v in request.files.values():
diff --git a/pyproject.toml b/pyproject.toml
index 49f1903..81663b0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,9 +1,57 @@
+[build-system]
+requires = ['setuptools>=67.7.2']
+build-backend = "setuptools.build_meta"
+
[project]
name = "crowdstrike-foundry-function"
description = "CrowdStrike Foundry Function Software Developer Kit for Python"
requires-python = ">=3.8.0"
dynamic = ['dependencies', 'version']
readme = "README.md"
+authors = [
+ { name = "CrowdStrike", email = "foundry-fn-python@crowdstrike.com" },
+]
+maintainers = [
+ { name = "John Stone", email = "foundry-fn-python@crowdstrike.com" },
+ { name = "Chris Cannon", email = "foundry-fn-python@crowdstrike.com" },
+ { name = "Johnny Steenbergen", email = "foundry-fn-python@crowdstrike.com" },
+ { name = "Banu Yuceer", email = "foundry-fn-python@crowdstrike.com" },
+ { name = "Joshua Hiller", email = "foundry-fn-python@crowdstrike.com" },
+]
+keywords = [
+ "crowdstrike",
+ "crowdstrike-falcon-foundry",
+ "foundry",
+ "foundry-functions",
+ "crowdstrike-faas",
+ "sdk"
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Framework :: Flake8",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: The Unlicense (Unlicense)",
+ "Natural Language :: English",
+ "Operating System :: MacOS",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: OS Independent",
+ "Operating System :: POSIX",
+ "Operating System :: POSIX :: Linux",
+ "Operating System :: Unix",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Security",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: System :: Systems Administration",
+ "Topic :: Utilities",
+]
-[build-system]
-requires = ['setuptools>=67.7.2']
+[project.urls]
+Homepage = "https://github.com/CrowdStrike/foundry-fn-python"
+Tracker = "https://github.com/CrowdStrike/foundry-fn-python/issues"
diff --git a/setup.py b/setup.py
index 709b978..870d6b2 100644
--- a/setup.py
+++ b/setup.py
@@ -1,8 +1,8 @@
# !/usr/bin/env python
+"""Package setup for crowdstrike-foundry-function."""
import os
from setuptools import setup
-from typing import List
DESCRIPTION = 'CrowdStrike Foundry Function Software Developer Kit for Python'
PACKAGE_NAME = 'crowdstrike.foundry.function'
@@ -16,10 +16,11 @@
SETUP_REQUIRES = [
'setuptools',
]
-VERSION = '1.1.0'
+VERSION = '1.1.1'
def main():
+ """Build crowdstrike-foundry-function package."""
setup(
description=DESCRIPTION,
install_requires=find_dependencies(os.path.join(os.path.dirname(__file__), "requirements.txt")),
@@ -34,12 +35,14 @@ def main():
)
-def find_dependencies(requirements) -> List[str]:
+def find_dependencies(requirements) -> list[str]:
+ """Parse the package dependencies from requirements file."""
with open(requirements, 'r') as reqs:
return [line for line in sanitized_lines(reqs.readlines())]
def long_description() -> str:
+ """Generate package long description."""
with open("README.md", "r", encoding="utf-8") as fh:
descript = fh.read()
@@ -53,7 +56,8 @@ def long_description() -> str:
return descript
-def sanitized_lines(lines: List[str]):
+def sanitized_lines(lines: list[str]):
+ """Sanitize the lines read from requirements file."""
for line in lines:
line = line.strip()
if line != '' and line[0] != '#':
diff --git a/src/crowdstrike/__init__.py b/src/crowdstrike/__init__.py
index e69de29..bd8145c 100644
--- a/src/crowdstrike/__init__.py
+++ b/src/crowdstrike/__init__.py
@@ -0,0 +1 @@
+"""CrowdStrike Foundry Functions FDK."""
diff --git a/src/crowdstrike/foundry/__init__.py b/src/crowdstrike/foundry/__init__.py
index e69de29..bd8145c 100644
--- a/src/crowdstrike/foundry/__init__.py
+++ b/src/crowdstrike/foundry/__init__.py
@@ -0,0 +1 @@
+"""CrowdStrike Foundry Functions FDK."""
diff --git a/src/crowdstrike/foundry/function/__init__.py b/src/crowdstrike/foundry/function/__init__.py
index 1b93bc4..4046ddc 100644
--- a/src/crowdstrike/foundry/function/__init__.py
+++ b/src/crowdstrike/foundry/function/__init__.py
@@ -1,14 +1,19 @@
-from crowdstrike.foundry.function.model import *
+"""CrowdStrike Foundry Functions FDK."""
import sys
-import logging
+from typing import Union
+from crowdstrike.foundry.function.model import (
+ RequestParams,
+ APIError,
+ Request,
+ Response,
+ FDKException
+)
class Function:
- """
- Represents a Function.
- """
+ """Represents a Function."""
- _instance: ['Function', None] = None
+ _instance: Union['Function', None] = None
@staticmethod
def instance(
@@ -19,8 +24,8 @@ def instance(
router=None,
runner=None,
) -> 'Function':
- """
- Fetch the singleton instance of the :class:`Function`, creating one if one does not yet exist.
+ """Fetch the singleton instance of the :class:`Function`, creating one if one does not yet exist.
+
:param module: Name of the module in which code should be imported.
:param config: Configuration to provide to the user's code.
:param config_loader: :class:`ConfigLoaderBase` instance capable of loading configuration if `config` is None.
@@ -49,7 +54,8 @@ def __init__(
router=None,
runner=None,
):
- """
+ """Construct an instance of the class.
+
:param module: Name of the module in which code should be imported.
:param config: Configuration to provide to the user's code.
:param config_loader: :class:`ConfigLoaderBase` instance capable of loading configuration if `config` is None.
@@ -76,15 +82,21 @@ def __init__(
self._router = Router(self._config)
if self._runner is None:
from crowdstrike.foundry.function.runner import Runner
- from crowdstrike.foundry.function.runner_http import HTTPRunner
- self._runner = Runner(HTTPRunner())
+ if len(sys.argv) > 1:
+ # when arguments are provided to the function,
+ # run in CLI mode without starting an http server
+ from crowdstrike.foundry.function.runner_cli import CLIRunner
+ self._runner = Runner(CLIRunner())
+ else:
+ from crowdstrike.foundry.function.runner_http import HTTPRunner
+ self._runner = Runner(HTTPRunner())
self._runner.bind_router(self._router)
self._loader.register_module(module)
def run(self, *args, **kwargs):
- """
- Runs the function. Essentially the "main" method of the function.
+ """Run the function. Essentially the "main" method of the function.
+
Any arguments provided to this method are forwarded directly down into :class:`RunnerBase` instance.
:return: Any result from the given :class:`RunnerBase` instance.
"""
@@ -92,8 +104,8 @@ def run(self, *args, **kwargs):
return self._runner.run(*args, **kwargs)
def handler(self, method: str, path: str):
- """
- Decorator for handlers.
+ """Define the decorator for handlers.
+
:param method: HTTP method or verb to bind to this handler.
:param path: URL path at which this handler resides.
"""
@@ -110,8 +122,8 @@ def call(func):
def cloud() -> str:
- """
- Retrieves a FalconPy-compatible identifier which identifies the cloud in which this function is running.
+ """Retrieve a FalconPy-compatible identifier which identifies the cloud in which this function is running.
+
:return: Cloud in which this function is executing.
"""
import os
diff --git a/src/crowdstrike/foundry/function/config_loader.py b/src/crowdstrike/foundry/function/config_loader.py
index fca27ab..b629954 100644
--- a/src/crowdstrike/foundry/function/config_loader.py
+++ b/src/crowdstrike/foundry/function/config_loader.py
@@ -1,34 +1,30 @@
+"""Config loader for CrowdStrike Foundry Functions FDK."""
from abc import ABC, abstractmethod
class ConfigLoaderBase(ABC):
- """
- Base class for any class which is able to load configuration.
- """
+ """Base class for any class which is able to load configuration."""
@abstractmethod
def load(self):
- """
- Loads the configuration.
- """
+ """Load the configuration."""
pass
class ConfigLoader(ConfigLoaderBase):
- """
- Middleware for loading configuration.
- """
+ """Middleware for loading configuration."""
def __init__(self, loader: ConfigLoaderBase):
- """
+ """Construct an instance of the class.
+
:param loader: Desired :class:`ConfigLoaderBase` instance.
"""
ConfigLoaderBase.__init__(self)
self._loader = loader
def load(self):
- """
- Loads the configuration.
+ """Load the configuration.
+
:returns: Any loaded configuration.
"""
return self._loader.load()
diff --git a/src/crowdstrike/foundry/function/config_loader_fs.py b/src/crowdstrike/foundry/function/config_loader_fs.py
index c4fe2dc..e75a9c7 100644
--- a/src/crowdstrike/foundry/function/config_loader_fs.py
+++ b/src/crowdstrike/foundry/function/config_loader_fs.py
@@ -1,21 +1,22 @@
+"""File system config loader for CrowdStrike Foundry Functions FDK."""
import json
import os
from crowdstrike.foundry.function.config_loader import ConfigLoaderBase
class FileSystemConfigLoader(ConfigLoaderBase):
- """
- Loads configuration from the local filesystem.
- """
+ """Loads configuration from the local filesystem."""
def __init__(self):
+ """Initialize the file system config loader."""
ConfigLoaderBase.__init__(self)
def load(self):
- """
- Loads the configuration located at the path specified in the `CS_FN_CONFIG_PATH` environment variable.
+ """Load the configuration located at the path specified in the `CS_FN_CONFIG_PATH` environment variable.
+
The path may be either relative or absolute.
If the environment variable is not provided, no configuration will be loaded.
+
:returns: Any loaded configuration.
"""
file_path = os.environ.get('CS_FN_CONFIG_PATH', None)
diff --git a/src/crowdstrike/foundry/function/context.py b/src/crowdstrike/foundry/function/context.py
index ced07b0..9be001a 100644
--- a/src/crowdstrike/foundry/function/context.py
+++ b/src/crowdstrike/foundry/function/context.py
@@ -1,3 +1,4 @@
+"""Request context for CrowdStrike Foundry Functions FDK."""
from contextvars import ContextVar
# While this holds the inbound handler request, do not access them directly
diff --git a/src/crowdstrike/foundry/function/loader.py b/src/crowdstrike/foundry/function/loader.py
index d9ba8a4..3addcc5 100644
--- a/src/crowdstrike/foundry/function/loader.py
+++ b/src/crowdstrike/foundry/function/loader.py
@@ -1,14 +1,16 @@
+"""Loader for CrowdStrike Foundry Function FDK."""
+
+
class Loader:
- """
- Module loader.
- """
+ """Module loader."""
def __init__(self):
+ """Initialize the loader."""
self._modules = set()
def register_module(self, module: str):
- """
- Registers a module to be loaded at function run.
+ """Register a module to be loaded at function run.
+
:param module: Name of the module. If the empty string or `__main__`, will ignore.
"""
if module == '' or module == '__main__':
@@ -16,9 +18,7 @@ def register_module(self, module: str):
self._modules.add(module)
def load(self):
- """
- Loads any registered modules.
- """
+ """Load any registered modules."""
from importlib import import_module
for m in self._modules:
import_module(m)
diff --git a/src/crowdstrike/foundry/function/mapping.py b/src/crowdstrike/foundry/function/mapping.py
index 7e94762..3b5a7a0 100644
--- a/src/crowdstrike/foundry/function/mapping.py
+++ b/src/crowdstrike/foundry/function/mapping.py
@@ -1,11 +1,14 @@
-from crowdstrike.foundry.function.model import Request, RequestParams, Response
+"""Data mapping utilities for CrowdStrike Foundry Function FDK."""
+
+
from dataclasses import dataclass, fields, is_dataclass
-from typing import Dict
+from typing import Union
+from crowdstrike.foundry.function.model import Request, RequestParams, Response
-def response_to_dict(r: Response) -> Dict:
- """
- Converts a :class:`Response` to a dictionary.
+def response_to_dict(r: Response) -> dict:
+ """Convert a :class:`Response` to a dictionary.
+
:param r: :class:`Response` instance to convert.
:return: Dictionary version of the provided instance.
"""
@@ -30,9 +33,9 @@ def response_to_dict(r: Response) -> Dict:
return d
-def dict_to_request(d: Dict) -> Request:
- """
- Converts a dictionary to a :class:`Request`.
+def dict_to_request(d: dict) -> Request:
+ """Convert a dictionary to a :class:`Request`.
+
:param d: Dictionary instance to attempt to map.
:return: :class:`Request` instance populated by the given dictionary.
"""
@@ -46,9 +49,9 @@ def dict_to_request(d: Dict) -> Request:
return req
-def dict_to_dataclass(d: Dict, dc) -> [None, dataclass]:
- """
- Maps the contents of a dictionary to a dataclass object.
+def dict_to_dataclass(d: Union[dict, None], dc) -> Union[None, dataclass]:
+ """Map the contents of a dictionary to a dataclass object.
+
:param d: Dictionary from which to extract values.
:param dc: Dataclass to receive the values.
:return: Provided dataclass object.
@@ -73,8 +76,8 @@ def dict_to_dataclass(d: Dict, dc) -> [None, dataclass]:
def canonize_header(h: str) -> str:
- """
- Converts a header key into its canonical version.
+ """Convert a header key into its canonical version.
+
:param h: Header key.
:return: Canonized version.
"""
diff --git a/src/crowdstrike/foundry/function/model.py b/src/crowdstrike/foundry/function/model.py
index 2755c73..e8abdd8 100644
--- a/src/crowdstrike/foundry/function/model.py
+++ b/src/crowdstrike/foundry/function/model.py
@@ -1,24 +1,31 @@
+"""Data models for CrowdStrike Foundry Function FDK."""
from dataclasses import dataclass, field
-from typing import Dict, List
+from typing import Any, Dict, List
@dataclass
class RequestParams:
+ """Defines the data model for request parameters."""
+
header: Dict[str, List[str]] = field(default_factory=lambda: {})
query: Dict[str, List[str]] = field(default_factory=lambda: {})
@dataclass
class APIError:
+ """Defines the data model for API errors."""
+
code: int = field(default=0)
message: str = field(default='')
@dataclass
class Request:
+ """Defines the data model for request provided to the function handler."""
+
access_token: str = field(default='')
- body: Dict[str, any] = field(default_factory=lambda: {})
- context: Dict[str, any] = field(default_factory=lambda: {})
+ body: Dict[str, Any] = field(default_factory=lambda: {})
+ context: Dict[str, Any] = field(default_factory=lambda: {})
files: Dict[str, bytes] = field(default_factory=lambda: {})
fn_id: str = field(default='')
fn_version: int = field(default=0)
@@ -30,15 +37,23 @@ class Request:
@dataclass
class Response:
- body: Dict[str, any] = field(default_factory=lambda: {})
+ """Defines the data model for response returned from the function handler."""
+
+ body: Dict[str, Any] = field(default_factory=lambda: {})
code: int = field(default=0)
errors: List[APIError] = field(default_factory=lambda: [])
header: Dict[str, List[str]] = field(default_factory=lambda: {})
class FDKException(Exception):
+ """Defines the FDKException that will be raised when an error occurs."""
def __init__(self, code: int, message: str):
+ """Initialize the FDKException.
+
+ :param code: The error code.
+ :param message: The error message.
+ """
Exception.__init__(self, message)
self.code = code
self.message = message
diff --git a/src/crowdstrike/foundry/function/router.py b/src/crowdstrike/foundry/function/router.py
index 77ce072..8988501 100644
--- a/src/crowdstrike/foundry/function/router.py
+++ b/src/crowdstrike/foundry/function/router.py
@@ -1,30 +1,35 @@
-from crowdstrike.foundry.function.model import FDKException, Request, Response
+"""Router for CrowdStrike Foundry Function FDK."""
from dataclasses import dataclass
from http.client import BAD_REQUEST, METHOD_NOT_ALLOWED, NOT_FOUND, SERVICE_UNAVAILABLE
from inspect import signature
from logging import Logger
-from typing import Callable
+from typing import Callable, Union
+from crowdstrike.foundry.function.model import FDKException, Request, Response
@dataclass
class Route:
+ """Defines the Route data model."""
+
func: Callable
method: str
path: str
class Router:
- """
- Serves to route function requests to the appropriate handler functions.
- """
+ """Serves to route function requests to the appropriate handler functions."""
def __init__(self, config):
+ """Initialize the router.
+
+ :param config: The config loaded from the configuration file, if provided.
+ """
self._config = config
self._routes = {}
- def route(self, req: Request, logger: [Logger, None] = None) -> Response:
- """
- Given the method and path of a :class:`Request`, invokes the corresponding handler if one exists.
+ def route(self, req: Request, logger: Union[Logger, None] = None) -> Response:
+ """Given the method and path of a :class:`Request`, invokes the corresponding handler if one exists.
+
:param req: :class:`Request` presented to the function.
:param logger: :class:`Logger` instance. Note: A CrowdStrike-specific logging instance will be provided
internally.
@@ -49,7 +54,7 @@ def route(self, req: Request, logger: [Logger, None] = None) -> Response:
return self._call_route(r, req, logger)
- def _call_route(self, route: Route, req: Request, logger: [Logger, None] = None):
+ def _call_route(self, route: Route, req: Request, logger: Union[Logger, None] = None):
f = route.func
len_params = len(signature(f).parameters)
@@ -61,8 +66,8 @@ def _call_route(self, route: Route, req: Request, logger: [Logger, None] = None)
return f(req)
def register(self, r: Route):
- """
- Registers a :class:`Route` with this instance.
+ """Register a :class:`Route` with this instance.
+
:param r: :class:`Route` to register.
"""
r.method = r.method.upper().strip()
diff --git a/src/crowdstrike/foundry/function/runner.py b/src/crowdstrike/foundry/function/runner.py
index 9baea16..9f51397 100644
--- a/src/crowdstrike/foundry/function/runner.py
+++ b/src/crowdstrike/foundry/function/runner.py
@@ -1,3 +1,4 @@
+"""Runner base classes for CrowdStrike Foundry Function FDK."""
import signal
import sys
from abc import ABC, abstractmethod
@@ -5,28 +6,32 @@
class RunnerBase(ABC):
+ """Abstract base class for runner implementations."""
+
def __init__(self):
+ """Initialize the runner."""
self.router = None
def bind_router(self, router: Router):
+ """Set the router for the runner."""
self.router = router
@abstractmethod
def run(self, *args, **kwargs):
+ """Start the runtime."""
pass
class Runner(RunnerBase):
+ """Base class for runner implementations."""
def __init__(self, runner: RunnerBase = None):
+ """Initialize the runner."""
RunnerBase.__init__(self)
self._runner = runner
def run(self, *args, **kwargs):
- """
- Starts runtime.
- """
-
+ """Start the runtime."""
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
diff --git a/src/crowdstrike/foundry/function/runner_cli.py b/src/crowdstrike/foundry/function/runner_cli.py
new file mode 100644
index 0000000..3ff44ba
--- /dev/null
+++ b/src/crowdstrike/foundry/function/runner_cli.py
@@ -0,0 +1,186 @@
+"""CLI runner for CrowdStrike Foundry Functions FDK."""
+import argparse
+import json
+from logging import Formatter, Logger, StreamHandler, getLogger
+from sys import stdout
+from typing import Dict, List, Union
+from crowdstrike.foundry.function.context import ctx_request
+from crowdstrike.foundry.function.mapping import canonize_header, dict_to_request, response_to_dict
+from crowdstrike.foundry.function.model import APIError, FDKException, Request, Response
+from crowdstrike.foundry.function.runner import RunnerBase
+
+
+INTERNAL_SERVER_ERROR = 500
+
+
+def _new_cli_logger() -> Logger:
+ f = Formatter('%(asctime)s [%(levelname)s] %(filename)s %(funcName)s:%(lineno)d -> %(message)s')
+
+ h = StreamHandler(stdout)
+ h.setFormatter(f)
+
+ logger = getLogger("cs-logger")
+ logger.setLevel('DEBUG')
+ logger.addHandler(h)
+ return logger
+
+
+class CLIRunner(RunnerBase):
+ """Runs the user's request without starting an HTTP server."""
+
+ def __init__(self):
+ """Initialize the CLI runner."""
+ RunnerBase.__init__(self)
+ self.logger = None
+ self.headers = None
+ self.data = None
+ self.args = None
+
+ self._setup_arguments()
+
+ def _setup_arguments(self):
+ self.parser = argparse.ArgumentParser(
+ description=(
+ "Invoke the function handler with the provided input without starting an HTTP server. "
+ "If one or more arguments are provided, the function is executed without starting an HTTP server. "
+ "If no arguments are provided, an HTTP server is started to listen for requests."
+ )
+ )
+
+ self.parser.add_argument(
+ '-d', '--data', type=str,
+ help='Path to a JSON file containing the "method", "url", and optionally the "body" and "params" fields.'
+ )
+ self.parser.add_argument(
+ '-H', '--header', type=str, action='append', nargs='*',
+ help="Optional HTTP request headers to provide to the function handler"
+ )
+ # This is used to simulate file input when content-type is multipart/form-data, value must be a file
+ self.parser.add_argument(
+ '-f', '--file', type=str, action='append', nargs='*',
+ help='Optional file input to the function handler.'
+ 'The "Content-Type: multipart/form-data" header must also be specified for file input.'
+ )
+
+ def _process_headers(self, headers: list):
+ for header in headers:
+ name_value = header[0].split(":")
+ if len(name_value) != 2:
+ self.parser.error('Invalid header. Must be in "name: value" format.')
+ header_name = name_value[0].strip().lower()
+ if header_name not in self.headers:
+ self.headers[header_name] = name_value[1].strip().lower()
+
+ def _add_headers(self, payload: dict):
+ # add request headers provided on the command line to the input
+ # payload to make them available to the function handler
+ if not self.headers:
+ return
+ payload_params = payload.setdefault('params', {})
+ payload_headers = payload_params.setdefault('header', {})
+ for header_name, header_value in self.headers.items():
+ payload_headers[header_name] = [header_value]
+
+ def _verify_arguments(self):
+ self.headers = {}
+ if self.args.header:
+ self._process_headers(self.args.header)
+
+ if self.args.file:
+ content_type = self.headers.get('content-type', 'application/json')
+ if not content_type.startswith('multipart/form-data'):
+ self.parser.print_help()
+ self.parser.error('Also provide -H "Content-Type: multipart/form-data" to use the --file argument')
+
+ def run(self, *args, **kwargs):
+ """Execute the requested function handler with the input provided on the command line."""
+ self.logger = kwargs.get('logger', None)
+ if self.logger is None:
+ self.logger = _new_cli_logger()
+
+ self.args = self.parser.parse_args()
+ self._verify_arguments()
+
+ self.logger.info('Running without HTTP server')
+ self._exec_request()
+
+ def _exec_request(self):
+ req = self._read_request()
+ ctx_request.set(req)
+ try:
+ resp = self.router.route(req, logger=self.logger)
+ except FDKException as fe:
+ resp = Response(errors=[APIError(code=fe.code, message=fe.message)])
+ self._write_response(req, resp)
+
+ def _read_request(self) -> Request:
+ payload = self._read_json_request()
+ self._add_headers(payload)
+ content_type = self.headers.get('content-type', 'application/json')
+ if content_type.startswith('multipart/form-data'):
+ payload['files'] = self._read_multipart_request()
+ return dict_to_request(payload)
+
+ def _read_json_request(self) -> dict:
+ payload = {}
+ with open(self.args.data, 'r') as fd:
+ payload = json.load(fd)
+ return payload
+
+ def _read_multipart_request(self) -> dict:
+ files = {}
+ for file in self.args.file:
+ with open(file[0], 'r') as fd:
+ files[file[0]] = fd.read().encode('utf-8')
+ return files
+
+ def _write_response(self, req: Request, resp: Union[Response, None]):
+ if resp is None or not isinstance(resp, Response):
+ msg = f'Object is not of type {Response.__base__.__name__}. Got {type(resp)} instead.'
+ resp = Response(errors=[APIError(code=INTERNAL_SERVER_ERROR, message=msg)])
+
+ if resp.code == 0 and resp.errors is not None and len(resp.errors) > 0:
+ for e in resp.errors:
+ e_code = e.code
+ if type(e_code) is not int and e_code is not None:
+ e_code = int(e_code)
+ if type(e_code) is int and 100 <= e.code and resp.code < e.code < 600:
+ resp.code = e_code
+
+ resp.header = self._resp_headers(req, resp)
+ payload_dict = response_to_dict(resp)
+ payload = json.dumps(payload_dict)
+
+ print('')
+ print(f'Status code: {resp.code}')
+ print(f'Response Header: Content-Length: {str(len(payload))}')
+ print('Response Header: Content-Type: application/json')
+ for k, v in resp.header.items():
+ print(f'Response Header: {k}: {v}')
+ print('Response Payload:')
+ print(payload)
+
+ def _resp_headers(self, req: Request, resp: Response):
+ headers = {}
+ if resp.header is not None and len(resp.header) > 0:
+ for k, v in resp.header.items():
+ if v is None or len(v) == 0:
+ continue
+ headers[canonize_header(k)] = v
+
+ if req.params is None or req.params.header is None or len(req.params.header) == 0:
+ return headers
+
+ req_header = req.params.header
+ self._take_header('X-Cs-Executionid', req_header, headers)
+ self._take_header('X-Cs-Origin', req_header, headers)
+ self._take_header('X-Cs-Traceid', req_header, headers)
+
+ headers = {k: ';'.join(v) for k, v in headers}
+ return headers
+
+ def _take_header(self, key: str, src_header: Dict[str, List[str]], dst_header: Dict[str, List[str]]):
+ value = src_header.get(key, [])
+ if len(value) == 0:
+ return
+ dst_header[key] = value
diff --git a/src/crowdstrike/foundry/function/runner_http.py b/src/crowdstrike/foundry/function/runner_http.py
index eebc6f9..5eda217 100644
--- a/src/crowdstrike/foundry/function/runner_http.py
+++ b/src/crowdstrike/foundry/function/runner_http.py
@@ -1,15 +1,17 @@
+"""HTTP runner for CrowdStrike Foundry Function FDK."""
import json
import os
+from sys import stdout
+from http.client import INTERNAL_SERVER_ERROR
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from logging import Formatter, Logger, StreamHandler, getLogger
import python_multipart
+from typing import Dict, List, Union
from crowdstrike.foundry.function.context import ctx_request
from crowdstrike.foundry.function.mapping import canonize_header, dict_to_request, response_to_dict
from crowdstrike.foundry.function.model import APIError, FDKException, Request, Response
from crowdstrike.foundry.function.router import Router
from crowdstrike.foundry.function.runner import RunnerBase
-from http.client import INTERNAL_SERVER_ERROR
-from http.server import BaseHTTPRequestHandler, HTTPServer
-from logging import Formatter, Logger, StreamHandler, getLogger
-from sys import stdout
def _new_http_logger() -> Logger:
@@ -18,22 +20,22 @@ def _new_http_logger() -> Logger:
h = StreamHandler(stdout)
h.setFormatter(f)
- l = getLogger("cs-logger")
- l.setLevel('DEBUG')
- l.addHandler(h)
- return l
+ logger = getLogger("cs-logger")
+ logger.setLevel('DEBUG')
+ logger.addHandler(h)
+ return logger
class HTTPRunner(RunnerBase):
- """
- Runs the user's code as part of an HTTP server.
- """
+ """Runs the user's code as part of an HTTP server."""
def __init__(self):
+ """Initialize the HTTP runner."""
RunnerBase.__init__(self)
self._port = int(os.environ.get('PORT', '8081'))
def run(self, *args, **kwargs):
+ """Start the HTTP server and listen for requests."""
logger = kwargs.get('logger', None)
if logger is None:
logger = _new_http_logger()
@@ -45,57 +47,47 @@ def run(self, *args, **kwargs):
class HTTPRequestHandler(BaseHTTPRequestHandler):
+ """Implements the HTTP request handlers."""
+
_logger = None
_router = None
@staticmethod
def bind_logger(logger: Logger):
+ """Set the logger to use."""
HTTPRequestHandler._logger = logger
@staticmethod
def bind_router(router: Router):
+ """Set the router to use."""
HTTPRequestHandler._router = router
def do_DELETE(self):
- """
- Executes on HTTP DELETE.
- """
+ """Execute on HTTP DELETE."""
self._exec_request()
def do_GET(self):
- """
- Executes on HTTP GET.
- """
+ """Execute on HTTP GET."""
self._exec_request()
def do_HEAD(self):
- """
- Executes on HTTP HEAD.
- """
+ """Execute on HTTP HEAD."""
self._exec_request()
def do_OPTIONS(self):
- """
- Executes on HTTP OPTIONS.
- """
+ """Execute on HTTP OPTIONS."""
self._exec_request()
def do_PATCH(self):
- """
- Executes on HTTP PATCH.
- """
+ """Execute on HTTP PATCH."""
self._exec_request()
def do_POST(self):
- """
- Executes on HTTP POST.
- """
+ """Execute on HTTP POST."""
self._exec_request()
def do_PUT(self):
- """
- Executes on HTTP PUT.
- """
+ """Execute on HTTP PUT."""
self._exec_request()
def _exec_request(self):
@@ -140,7 +132,7 @@ def on_field(field):
body = json.loads(value.decode('utf-8').strip())
def on_file(file):
- nonlocal files
+ nonlocal files # noqa: F824
# Offset will currently be at the end of the buffer.
# Need to reset it to the beginning so we can read it.
file.file_object.seek(0)
@@ -152,7 +144,7 @@ def on_file(file):
req['files'] = files
return req
- def _write_response(self, req: Request, resp: [Response, None]):
+ def _write_response(self, req: Request, resp: Union[Response, None]):
if resp is None or not isinstance(resp, Response):
msg = f'Object is not of type {Response.__base__.__name__}. Got {type(resp)} instead.'
resp = Response(errors=[APIError(code=INTERNAL_SERVER_ERROR, message=msg)])
@@ -196,7 +188,7 @@ def _resp_headers(self, req: Request, resp: Response):
headers = {k: ';'.join(v) for k, v in headers}
return headers
- def _take_header(self, key: str, src_header: dict[str, list[str]], dst_header: dict[str, list[str]]):
+ def _take_header(self, key: str, src_header: Dict[str, List[str]], dst_header: Dict[str, List[str]]):
value = src_header.get(key, [])
if len(value) == 0:
return
diff --git a/test_data/requests/cli_request1.json b/test_data/requests/cli_request1.json
new file mode 100644
index 0000000..8613835
--- /dev/null
+++ b/test_data/requests/cli_request1.json
@@ -0,0 +1,7 @@
+{
+ "method": "POST",
+ "url": "/request1",
+ "body": {
+ "hello": "world"
+ }
+}
diff --git a/test_data/requests/cli_request2.json b/test_data/requests/cli_request2.json
new file mode 100644
index 0000000..0570231
--- /dev/null
+++ b/test_data/requests/cli_request2.json
@@ -0,0 +1,7 @@
+{
+ "method": "POST",
+ "url": "/request2",
+ "body": {
+ "hello": "world"
+ }
+}
diff --git a/test_data/requests/cli_request3.json b/test_data/requests/cli_request3.json
new file mode 100644
index 0000000..cbf7af0
--- /dev/null
+++ b/test_data/requests/cli_request3.json
@@ -0,0 +1,7 @@
+{
+ "method": "POST",
+ "url": "/request3",
+ "body": {
+ "hello": "world"
+ }
+}
diff --git a/test_data/requests/cli_request4.json b/test_data/requests/cli_request4.json
new file mode 100644
index 0000000..98c4813
--- /dev/null
+++ b/test_data/requests/cli_request4.json
@@ -0,0 +1,17 @@
+{
+ "method": "POST",
+ "url": "/request4",
+ "body": {
+ "hello": "world"
+ },
+ "params": {
+ "query": {
+ "test": [
+ true
+ ],
+ "test2": [
+ "yes"
+ ]
+ }
+ }
+}
diff --git a/test_data/requests/cli_request5.json b/test_data/requests/cli_request5.json
new file mode 100644
index 0000000..bd4affb
--- /dev/null
+++ b/test_data/requests/cli_request5.json
@@ -0,0 +1,7 @@
+{
+ "method": "GET",
+ "url": "/xyz",
+ "body": {
+ "hello": "world"
+ }
+}
diff --git a/test_data/requests/cli_request6.json b/test_data/requests/cli_request6.json
new file mode 100644
index 0000000..5dde52b
--- /dev/null
+++ b/test_data/requests/cli_request6.json
@@ -0,0 +1,7 @@
+{
+ "method": "GET",
+ "url": "/request1",
+ "body": {
+ "hello": "world"
+ }
+}
diff --git a/tests/crowdstrike/foundry/function/test__init__.py b/tests/crowdstrike/foundry/function/test__init__.py
index 11fdf61..1b116bd 100644
--- a/tests/crowdstrike/foundry/function/test__init__.py
+++ b/tests/crowdstrike/foundry/function/test__init__.py
@@ -1,10 +1,10 @@
import os
-from crowdstrike.foundry.function import Function, Request, Response, FDKException, cloud
-from crowdstrike.foundry.function.router import Route, Router
from logging import Logger, getLogger
-from tests.crowdstrike.foundry.function.utils import CapturingRunner, StaticConfigLoader
from unittest import main, TestCase
from unittest.mock import patch
+from crowdstrike.foundry.function import Function, Request, Response, FDKException, cloud
+from crowdstrike.foundry.function.router import Route, Router
+from tests.crowdstrike.foundry.function.utils import CapturingRunner, StaticConfigLoader
if __name__ == '__main__':
main()
@@ -110,12 +110,12 @@ def test_request3(self):
resp = self.runner.response
self.assertIsNotNone(resp, 'response is none')
self.assertEqual(200, resp.code, f'expected response of 200 but got {resp.code}')
- l = resp.body.get('logger', None)
- self.assertIsInstance(l, Logger,'no logger present in response')
+ logger = resp.body.get('logger', None)
+ self.assertIsInstance(logger, Logger,'no logger present in response')
self.assertDictEqual(
{
'config': {'a': 'b'},
- 'logger': l,
+ 'logger': logger,
'req': {'hello': 'world'},
},
resp.body,
diff --git a/tests/crowdstrike/foundry/function/test_cli_runner.py b/tests/crowdstrike/foundry/function/test_cli_runner.py
new file mode 100644
index 0000000..c3447e9
--- /dev/null
+++ b/tests/crowdstrike/foundry/function/test_cli_runner.py
@@ -0,0 +1,141 @@
+from io import StringIO
+from logging import getLogger
+from unittest import main, TestCase
+from unittest.mock import patch
+from crowdstrike.foundry.function import Function, Response
+from crowdstrike.foundry.function.router import Route, Router
+from crowdstrike.foundry.function.runner_cli import CLIRunner
+from tests.crowdstrike.foundry.function.utils import StaticConfigLoader
+
+if __name__ == '__main__':
+ main()
+
+
+def do_request1(req):
+ return Response(
+ body={
+ 'req': req.body,
+ },
+ code=200,
+ )
+
+
+def do_request2(req, config):
+ return Response(
+ body={
+ 'config': config,
+ 'req': req.body,
+ },
+ code=200,
+ )
+
+
+def do_request3(req, config, logger):
+ return Response(
+ body={
+ 'config': config,
+ 'logger': logger.name,
+ 'req': req.body,
+ },
+ code=200,
+ )
+
+def do_request4(req):
+ return Response(
+ body={
+ 'req_body': req.body,
+ 'req_query': req.params.query,
+ 'req_headers': req.params.header,
+ },
+ code=200,
+ )
+
+
+class TestCLIRequestLifecycle(TestCase):
+ def setUp(self):
+ config = {'a': 'b'}
+ router = Router(config)
+ router.register(Route(
+ method='POST',
+ path='/request1',
+ func=do_request1,
+ ))
+ router.register(Route(
+ method='POST',
+ path='/request2',
+ func=do_request2,
+ ))
+ router.register(Route(
+ method='POST',
+ path='/request3',
+ func=do_request3,
+ ))
+ router.register(Route(
+ method='POST',
+ path='/request4',
+ func=do_request4,
+ ))
+ self.runner = CLIRunner()
+ self.runner.bind_router(router)
+ self.function = Function(
+ config_loader=StaticConfigLoader(config),
+ router=router,
+ runner=self.runner,
+ )
+
+ @patch('sys.argv', ['main.py', '--data', './test_data/requests/cli_request1.json'])
+ def test_request1(self):
+ with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+ self.function.run()
+ resp = mock_stdout.getvalue()
+ expected_resp = '\nStatus code: 200\nResponse Header: Content-Length: 50\nResponse Header: Content-Type: application/json\nResponse Payload:\n{"code": 200, "body": {"req": {"hello": "world"}}}\n'
+ self.assertEqual(resp, expected_resp, 'Unexpected response received')
+
+ @patch('sys.argv', ['main.py', '--data', './test_data/requests/cli_request2.json'])
+ def test_request2(self):
+ with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+ self.function.run()
+ resp = mock_stdout.getvalue()
+ expected_resp = '\nStatus code: 200\nResponse Header: Content-Length: 72\nResponse Header: Content-Type: application/json\nResponse Payload:\n{"code": 200, "body": {"config": {"a": "b"}, "req": {"hello": "world"}}}\n'
+ self.assertEqual(resp, expected_resp, 'Unexpected response received')
+
+ @patch('sys.argv', ['main.py', '--data', './test_data/requests/cli_request3.json'])
+ def test_request3(self):
+ with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+ use_logger = getLogger('__name__')
+ self.function.run(logger=use_logger)
+ resp = mock_stdout.getvalue()
+ expected_resp = '\nStatus code: 200\nResponse Header: Content-Length: 94\nResponse Header: Content-Type: application/json\nResponse Payload:\n{"code": 200, "body": {"config": {"a": "b"}, "logger": "__name__", "req": {"hello": "world"}}}\n'
+ self.assertEqual(resp, expected_resp, 'Unexpected response received')
+
+ @patch('sys.argv', ['main.py', '--data', './test_data/requests/cli_request4.json', '-H', 'X-TEST-HEADER: test', '--header', 'Accept: application/json'])
+ def test_request4(self):
+ with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+ self.function.run()
+ resp = mock_stdout.getvalue()
+ expected_resp = (
+ '\nStatus code: 200\nResponse Header: Content-Length: 180\n'
+ 'Response Header: Content-Type: application/json\n'
+ 'Response Payload:\n{"code": 200, "body": {"req_body": {"hello": "world"}, '
+ '"req_query": {"test": [true], "test2": ["yes"]}, '
+ '"req_headers": {"X-Test-Header": ["test"], "Accept": ["application/json"]}}}\n'
+ )
+ self.assertEqual(expected_resp, resp,'Unexpected response received')
+
+ @patch('sys.argv', ['main.py', '--data', './test_data/requests/cli_request5.json'])
+ def test_unknown_endpoint(self):
+ with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+ self.function.run()
+ resp = mock_stdout.getvalue()
+ expected_resp = '\nStatus code: 404\nResponse Header: Content-Length: 86\nResponse Header: Content-Type: application/json\nResponse Payload:\n{"code": 404, "body": {}, "errors": [{"code": 404, "message": "Not Found: GET /xyz"}]}\n'
+ self.assertEqual(resp, expected_resp, 'Unexpected response received')
+
+ @patch('sys.argv', ['main.py', '--data', './test_data/requests/cli_request6.json'])
+ def test_unknown_method(self):
+ with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+ self.function.run()
+ resp = mock_stdout.getvalue()
+ expected_resp = '\nStatus code: 405\nResponse Header: Content-Length: 102\nResponse Header: Content-Type: application/json\nResponse Payload:\n{"code": 405, "body": {}, "errors": [{"code": 405, "message": "Method Not Allowed: GET at endpoint"}]}\n'
+ self.assertEqual(resp, expected_resp, 'Unexpected response received')
+
+