diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 6f15b22..131c639 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -1,7 +1,7 @@ name: Linting on: [push, pull_request] jobs: - lint: + lint: # Run per push for internal contributers. This isn't possible for forked pull requests, # so we'll need to run on PR events for external contributers. # String comparison below is case insensitive. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7e6c658..70d7508 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/phantomcyber/dev-cicd-tools - rev: v1.13 + rev: v1.16 hooks: - id: org-hook - id: package-app-dependencies - repo: https://github.com/Yelp/detect-secrets - rev: v1.2.0 + rev: v1.4.0 hooks: - id: detect-secrets args: ['--no-verify'] diff --git a/LICENSE b/LICENSE index a74307f..12c3721 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 Splunk Inc. + Copyright (c) 2023 SEKOIA.IO Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -198,4 +198,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..2273e2f --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +# File: __init__.py +# +# Copyright (c) 2023 SEKOIA.IO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions +# and limitations under the License. diff --git a/logo_sekoiaio.svg b/logo_sekoiaio.svg new file mode 100644 index 0000000..8ce239e --- /dev/null +++ b/logo_sekoiaio.svg @@ -0,0 +1,209 @@ + + + + + + + + + + + diff --git a/logo_sekoiaio_dark.svg b/logo_sekoiaio_dark.svg new file mode 100644 index 0000000..950dc59 --- /dev/null +++ b/logo_sekoiaio_dark.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + diff --git a/manual_readme_content.md b/manual_readme_content.md new file mode 100644 index 0000000..e84b8f3 --- /dev/null +++ b/manual_readme_content.md @@ -0,0 +1,50 @@ +[comment]: # "File: README.md" +[comment]: # "Copyright (c) 2023 SEKOIA.IO" +[comment]: # "" +[comment]: # "Licensed under the Apache License, Version 2.0 (the 'License');" +[comment]: # "you may not use this file except in compliance with the License." +[comment]: # "You may obtain a copy of the License at" +[comment]: # "" +[comment]: # " http://www.apache.org/licenses/LICENSE-2.0" +[comment]: # "" +[comment]: # "Unless required by applicable law or agreed to in writing, software distributed under" +[comment]: # "the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND," +[comment]: # "either express or implied. See the License for the specific language governing permissions" +[comment]: # "and limitations under the License." +[comment]: # "" +# Sekoia.io actions +## Purpose + +Develop a Splunk SOAR App that interact with [SEKOIA.IO](http://SEKOIA.IO) CTI. + +## Authentication + +To interact with the [SEKOIA.IO](http://SEKOIA.IO) API, use an API key. + +see [this documentation](https://docs.sekoia.io/cti/features/integrations/api/) for more information + +## Actions + +The App should implement the following actions + +### Get indicator + +This action allow the user to get an indicator according to some criteria + +### Get indicator Context + +Create an action that allow the user to get the context of an indicator + +### Get Observable + +Create an action that allow the user to get an observable according to some criteria + +## Port Information + +The app uses HTTP/ HTTPS protocol for communicating with the Sekoiaio api. Below are the default +ports used by Splunk SOAR. + +|         Service Name | Transport Protocol | Port | +|----------------------|--------------------|------| +|         http | tcp | 80 | +|         https | tcp | 443 | diff --git a/release_notes/unreleased.md b/release_notes/unreleased.md index fbcb2fd..91ef90d 100644 --- a/release_notes/unreleased.md +++ b/release_notes/unreleased.md @@ -1 +1,2 @@ **Unreleased** +* Initial release with Python 3 support diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..68f6470 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +beautifulsoup4==4.12.2 diff --git a/sekoiaio.json b/sekoiaio.json new file mode 100644 index 0000000..e440819 --- /dev/null +++ b/sekoiaio.json @@ -0,0 +1,280 @@ +{ + "appid": "78e7ba61-eb65-421f-9bdf-28a6d14a3deb", + "name": "Sekoia", + "description": "This app will interact with SEKOIA.IO", + "type": "information", + "product_vendor": "SEKOIA.IO", + "logo": "logo_sekoiaio.svg", + "logo_dark": "logo_sekoiaio_dark.svg", + "product_name": "sekoia.io", + "python_version": "3", + "product_version_regex": ".*", + "publisher": "SEKOIA.IO", + "license": "Copyright (c) 2023 SEKOIA.IO", + "app_version": "1.0.0", + "utctime_updated": "2023-10-09T08:07:31.780040Z", + "package_name": "phantom_sekoiaio", + "fips_compliant": false, + "main_module": "sekoiaio_connector.py", + "min_phantom_version": "6.1.1.211", + "app_wizard_version": "1.0.0", + "configuration": { + "base_url": { + "description": "The SEKOIA API base url", + "data_type": "string", + "required": true, + "default": "https://api.sekoia.io", + "order": 0, + "name": "base_url", + "id": 0 + }, + "api_key": { + "description": "The SEKOIA API key", + "data_type": "password", + "required": true, + "order": 1, + "name": "api_key", + "id": 1 + }, + "verify_server_cert": { + "description": "Verify server SSL (Default: true)", + "data_type": "boolean", + "default": true, + "order": 2, + "name": "verify_server_cert", + "id": 2 + } + }, + "actions": [ + { + "action": "test connectivity", + "identifier": "test_connectivity", + "description": "Validate the asset configuration for connectivity using supplied configuration", + "type": "test", + "read_only": true, + "parameters": {}, + "output": [], + "versions": "EQ(*)" + }, + { + "action": "get indicator", + "identifier": "get_indicator", + "description": "Get an indicator according to some criteria", + "type": "investigate", + "read_only": true, + "parameters": { + "value": { + "description": "Value of the indicator", + "data_type": "string", + "order": 0, + "name": "value" + }, + "type": { + "description": "Type of the indicator", + "data_type": "string", + "order": 1, + "name": "type" + } + }, + "output": [ + { + "data_path": "action_result.parameter.value", + "data_type": "string", + "column_name": "Value", + "column_order": 0 + }, + { + "data_path": "action_result.parameter.type", + "data_type": "string", + "column_name": "Type", + "column_order": 1 + }, + { + "data_path": "action_result.status", + "data_type": "string", + "column_name": "Status", + "column_order": 2 + }, + { + "data_path": "action_result.message", + "data_type": "string" + }, + { + "data_path": "summary.total_objects", + "data_type": "numeric" + }, + { + "data_path": "summary.total_objects_successful", + "data_type": "numeric" + }, + { + "data_path": "action_result.data", + "data_type": "string" + }, + { + "data_path": "action_result.summary.num_data", + "data_type": "numeric" + } + ], + "render": { + "type": "table" + }, + "versions": "EQ(*)" + }, + { + "action": "get indicator context", + "identifier": "get_indicator_context", + "description": "Get the context of an indicator", + "type": "investigate", + "read_only": true, + "parameters": { + "value": { + "description": "Value of the indicator", + "data_type": "string", + "order": 0, + "name": "value" + }, + "type": { + "description": "Type of the indicator", + "data_type": "string", + "order": 1, + "name": "type" + } + }, + "output": [ + { + "data_path": "action_result.parameter.value", + "data_type": "string", + "column_name": "Value", + "column_order": 0 + }, + { + "data_path": "action_result.parameter.type", + "data_type": "string", + "column_name": "Type", + "column_order": 1 + }, + { + "data_path": "action_result.status", + "data_type": "string", + "column_name": "Status", + "column_order": 2 + }, + { + "data_path": "action_result.message", + "data_type": "string" + }, + { + "data_path": "summary.total_objects", + "data_type": "numeric" + }, + { + "data_path": "summary.total_objects_successful", + "data_type": "numeric" + }, + { + "data_path": "action_result.data", + "data_type": "string" + }, + { + "data_path": "action_result.summary.num_data", + "data_type": "numeric" + } + ], + "render": { + "type": "table" + }, + "versions": "EQ(*)" + }, + { + "action": "get observable", + "identifier": "get_observable", + "description": "Get an observable according to some criteria", + "type": "investigate", + "read_only": true, + "parameters": { + "value": { + "description": "Value of the indicator", + "data_type": "string", + "order": 0, + "name": "value" + }, + "type": { + "description": "Type of the indicator", + "data_type": "string", + "order": 1, + "name": "type" + }, + "limit": { + "description": "Set the limit of items (Default:20)", + "data_type": "numeric", + "default": 20, + "order": 2, + "name": "limit" + } + }, + "output": [ + { + "data_path": "action_result.parameter.value", + "data_type": "string", + "column_name": "Value", + "column_order": 0 + }, + { + "data_path": "action_result.parameter.type", + "data_type": "string", + "column_name": "type", + "column_order": 1 + }, + { + "data_path": "action_result.parameter.limit", + "data_type": "numeric", + "column_name": "Limit", + "column_order": 2 + }, + { + "data_path": "action_result.status", + "data_type": "string", + "column_name": "Status", + "column_order": 3 + }, + { + "data_path": "action_result.message", + "data_type": "string" + }, + { + "data_path": "summary.total_objects", + "data_type": "numeric" + }, + { + "data_path": "summary.total_objects_successful", + "data_type": "numeric" + }, + { + "data_path": "action_result.data", + "data_type": "string" + }, + { + "data_path": "action_result.summary.num_data_get_observable", + "data_type": "numeric" + } + ], + "render": { + "type": "table" + }, + "versions": "EQ(*)" + } + ], + "pip39_dependencies": { + "wheel": [ + { + "module": "beautifulsoup4", + "input_file": "wheels/py3/beautifulsoup4-4.12.2-py3-none-any.whl" + }, + { + "module": "soupsieve", + "input_file": "wheels/py3/soupsieve-2.5-py3-none-any.whl" + } + ] + } +} diff --git a/sekoiaio_connector.py b/sekoiaio_connector.py new file mode 100644 index 0000000..e91ee9d --- /dev/null +++ b/sekoiaio_connector.py @@ -0,0 +1,398 @@ +# File: sekoiaio_connector.py +# +# Copyright (c) 2023 SEKOIA.IO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions +# and limitations under the License. + +import json + +# Phantom App imports +import phantom.app as phantom +# Useful libraries +import requests +from bs4 import BeautifulSoup +from phantom.action_result import ActionResult +from phantom.base_connector import BaseConnector + +# Usage of the consts file is recommended +import sekoiaio_consts as consts + + +class RetVal(tuple): + def __new__(cls, val1, val2=None): + return tuple.__new__(RetVal, (val1, val2)) + + +class SekoiaioConnector(BaseConnector): + def __init__(self): + super().__init__() + self.state = None + self.base_url = None + self.api_key = None + + def initialize(self): + self.state = self.load_state() + config = self.get_config() + self.base_url = config["base_url"] + self.api_key = config["api_key"] + + return phantom.APP_SUCCESS + + def finalize(self): + self.save_state(self.state) + return phantom.APP_SUCCESS + + def _process_brackets(self, text_response): + return text_response.replace("{", "{{").replace("}", "}}") + + def _process_empty_response(self, response, action_result): + if response.status_code == 200: + return RetVal(phantom.APP_SUCCESS, {}) + + return RetVal( + action_result.set_status( + phantom.APP_ERROR, consts.EMPTY_RESPONSE_ERROR_LOG + ), + None, + ) + + def _process_html_response(self, response, action_result): + status_code = response.status_code + + try: + soup = BeautifulSoup(response.text, "html.parser") + error_text = soup.text + split_lines = error_text.split("\n") + error_text = "\n".join(x.strip() for x in split_lines if x.strip()) + except Exception as e: + error_text = f"Cannot parse error details : {e}" + + message = f"Status Code: {status_code}. Data from server:\n{error_text}\n" + + message = self._process_brackets(message) + return RetVal(action_result.set_status(phantom.APP_ERROR, message), None) + + def _process_json_response(self, response, action_result): + try: + resp_json = response.json() + except Exception as e: + return RetVal( + action_result.set_status( + phantom.APP_ERROR, + f"Unable to parse JSON response. Error: {str(e)}", + ), + None, + ) + + if 200 <= response.status_code < 399: + return RetVal(phantom.APP_SUCCESS, resp_json) + + if response.status_code == 400: + return action_result.set_status( + phantom.APP_ERROR, consts.JSON_RESPONSE_400_ERROR_LOG + ) + + if response.status_code == 401: + return action_result.set_status( + phantom.APP_ERROR, consts.JSON_RESPONSE_401_ERROR_LOG + ) + + if response.status_code == 403: + return action_result.set_status( + phantom.APP_ERROR, consts.JSON_RESPONSE_403_ERROR_LOG + ) + + message = f"Error from server. Status Code: {response.status_code} \ + Data from server: {self._process_brackets(response.text)}" + + return RetVal(action_result.set_status(phantom.APP_ERROR, message), None) + + def _process_response(self, response, action_result): + if hasattr(action_result, "add_debug_data"): + action_result.add_debug_data({"response_status_code": response.status_code}) + action_result.add_debug_data({"response_text": response.text}) + action_result.add_debug_data({"response_headers": response.headers}) + + if "json" in response.headers.get("Content-Type", ""): + return self._process_json_response(response, action_result) + + if "html" in response.headers.get("Content-Type", ""): + return self._process_html_response(response, action_result) + + if not response.text: + return self._process_empty_response(response, action_result) + + message = f"Can't process response from server. \ + Status Code: {response.status_code} Data from server: \ + {self._process_brackets(response.text)}" + + return RetVal(action_result.set_status(phantom.APP_ERROR, message), None) + + def _make_rest_call(self, endpoint, action_result, method="get", **kwargs): + config = self.get_config() + resp_json = None + try: + request_func = getattr(requests, method) + except AttributeError: + return RetVal( + action_result.set_status( + phantom.APP_ERROR, f"Invalid method: {method}" + ), + resp_json, + ) + url = self.base_url + endpoint + try: + response = request_func( + url, verify=config.get("verify_server_cert", True), **kwargs + ) + except requests.exceptions.InvalidURL: + error_message = f"Error connecting to server. Invalid URL: {url}" + return RetVal( + action_result.set_status(phantom.APP_ERROR, error_message), resp_json + ) + except requests.exceptions.InvalidSchema: + error_message = f"Error connecting to server. \ + No connection adapters were found for {url}" + return RetVal( + action_result.set_status(phantom.APP_ERROR, error_message), resp_json + ) + except requests.exceptions.ConnectionError: + error_message = f"Error connecting to server. \ + Connection Refused from the Server for {url}" + return RetVal( + action_result.set_status(phantom.APP_ERROR, error_message), resp_json + ) + except Exception as e: + return RetVal( + action_result.set_status( + phantom.APP_ERROR, + f"Error Connecting to server. Details: {str(e)}", + ), + resp_json, + ) + + return self._process_response(response, action_result) + + def _handle_test_connectivity(self, param): + action_result = self.add_action_result(ActionResult(dict(param))) + self.save_progress("Start Connecting to endpoint ..... !!") + headers = {"Authorization": f"Bearer {self.api_key}"} + ret_val, response = self._make_rest_call( + "/v1/auth/validate", action_result, params=None, headers=headers + ) + + if phantom.is_fail(ret_val): + self.save_progress(f"Test Connectivity Failed. {consts.DOCUMENTATION_LOG}") + return action_result.get_status() + + self.save_progress( + "Test connectivity passed, your token is valid. You can use it." + ) + return action_result.set_status(phantom.APP_SUCCESS) + + def _handle_get_indicator(self, param): + self.save_progress( + f"In get indicator action handler for: {self.get_action_identifier()}" + ) + action_result = self.add_action_result(ActionResult(dict(param))) + + # This 2 required parameters to requests this endpoint + # Take a look at + # /cti/develop/rest_api/intelligences/Indicators + value, type_ = param.get("value", ""), param.get("type", "") + params, headers = {"value": value, "type": type_}, { + "Authorization": f"Bearer {self.api_key}" + } + ret_val, response = self._make_rest_call( + "/v2/inthreat/indicators", action_result, params=params, headers=headers + ) + + if phantom.is_fail(ret_val): + return action_result.get_status() + + self.save_progress("Received response from endpoint") + # Add the response into the data section + action_result.add_data(response.get("items", [])) + + # Add a dictionary that is made up of the + # most important values from data into the summary + summary = action_result.update_summary({}) + summary["num_data"] = action_result.get_data_size() + + return action_result.set_status(phantom.APP_SUCCESS) + + def _handle_get_indicator_context(self, param): + self.save_progress( + f"In get indicator context action handler for: {self.get_action_identifier()}" + ) + action_result = self.add_action_result(ActionResult(dict(param))) + + # This 2 required parameters to requests this endpoint + # Take a look at + # cti/develop/rest_api/intelligence/Indicators/operation/get_indicator_context_resource + value, type_ = param.get("value", ""), param.get("type", "") + params, headers = {"value": value, "type": type_}, { + "Authorization": f"Bearer {self.api_key}" + } + ret_val, response = self._make_rest_call( + "/v2/inthreat/indicators/context", + action_result, + params=params, + headers=headers, + ) + + if phantom.is_fail(ret_val): + return action_result.get_status() + + self.save_progress("Great, we get response from the endpoint !!") + + action_result.add_data(response) + + # Add a dictionary that is made up of + # the most important values from data into the summary + summary = action_result.update_summary({}) + summary["num_data"] = action_result.get_data_size() + + return action_result.set_status(phantom.APP_SUCCESS) + + def _handle_get_observable(self, param): + self.save_progress(f"In action handler for: {self.get_action_identifier()}") + action_result = self.add_action_result(ActionResult(dict(param))) + + value, type_, limit = ( + param.get("value", ""), + param.get("type", ""), + param.get("limit", 20), + ) + params, headers = {"value": value, "type": type_, "limit": limit}, { + "Authorization": f"Bearer {self.api_key}" + } + # make rest call + ret_val, response = self._make_rest_call( + "/v2/inthreat/observables", action_result, params=params, headers=headers + ) + + if phantom.is_fail(ret_val): + return action_result.get_status() + + self.save_progress("Great, we get response from the endpoint !!") + + # Add the response into the data section + action_result.add_data(response.get("items", [])) + + # Add a dictionary that is made up of the + # most important values from data into the summary + summary = action_result.update_summary({}) + summary["num_data_get_observable"] = action_result.get_data_size() + + return action_result.set_status(phantom.APP_SUCCESS) + + def handle_action(self, param): + ret_val = phantom.APP_SUCCESS + + # Get the action that we are supposed to execute for this App Run + action_id = self.get_action_identifier() + + self.debug_print("action_id", self.get_action_identifier()) + + if action_id == "get_indicator": + ret_val = self._handle_get_indicator(param) + + if action_id == "get_indicator_context": + ret_val = self._handle_get_indicator_context(param) + + if action_id == "get_observable": + ret_val = self._handle_get_observable(param) + + if action_id == "test_connectivity": + ret_val = self._handle_test_connectivity(param) + + return ret_val + + +def main(): + import argparse + import sys + + argparser = argparse.ArgumentParser() + + argparser.add_argument("input_test_json", help="Input Test JSON file") + argparser.add_argument("-u", "--username", help="username", required=False) + argparser.add_argument("-p", "--password", help="password", required=False) + argparser.add_argument( + "-v", + "--verify", + action="store_true", + help="verify", + required=False, + default=False, + ) + + args = argparser.parse_args() + session_id = None + + username = args.username + password = args.password + verify = args.verify + + if username is not None and password is None: + # User specified a username but not a password, so ask + import getpass + + password = getpass.getpass("Password: ") + + if username and password: + try: + login_url = SekoiaioConnector._get_phantom_base_url() + "/login" + + print("Accessing the Login page") + r = requests.get(login_url, verify=False) + csrftoken = r.cookies["csrftoken"] + + data = { + "username": username, + "password": password, + "csrfmiddlewaretoken": csrftoken, + } + + headers = {"Cookie": "csrftoken=" + csrftoken, "Referer": login_url} + + print("Logging into Platform to get the session id") + r2 = requests.post(login_url, verify=verify, data=data, headers=headers) + session_id = r2.cookies["sessionid"] + except Exception as e: + print( + f"Unable to get session id \ + from the platform. Error: {str(e)}" + ) + sys.exit(1) + + with open(args.input_test_json) as f: + in_json = f.read() + in_json = json.loads(in_json) + print(json.dumps(in_json, indent=4)) + + connector = SekoiaioConnector() + connector.print_progress_message = True + + if session_id is not None: + in_json["user_session_token"] = session_id + connector._set_csrf_info(csrftoken, headers["Referer"]) + + ret_val = connector._handle_action(json.dumps(in_json), None) + print(json.dumps(json.loads(ret_val), indent=4)) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/sekoiaio_consts.py b/sekoiaio_consts.py new file mode 100644 index 0000000..f38f52c --- /dev/null +++ b/sekoiaio_consts.py @@ -0,0 +1,26 @@ +# File: sekoiaio_consts.py +# +# Copyright (c) 2023 SEKOIA.IO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions +# and limitations under the License. + +# Log messages +EMPTY_RESPONSE_ERROR_LOG = "Empty response and no information in the header" +JSON_RESPONSE_400_ERROR_LOG = "Invalid parameters. \ + Please recheck your parameters." +JSON_RESPONSE_401_ERROR_LOG = "Authentification failed. Please \ + pay attention to use an API KEY" +JSON_RESPONSE_403_ERROR_LOG = "Insufficient permissions. \ + please check if you have an INTHREAT_READ_OBJECTS permission" +DOCUMENTATION_LOG = "Please visit the API Key documentation \ + for more information: \ + https://docs.sekoia.io/getting_started/manage_api_keys/" diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c4644ad --- /dev/null +++ b/tox.ini @@ -0,0 +1,7 @@ +[flake8] +max-line-length = 145 +max-complexity = 28 +extend-ignore = F403,E128,E126,E111,E121,E127,E731,E201,E202,F405,E722,D,W292 + +[isort] +line_length = 145 diff --git a/wheels/py3/beautifulsoup4-4.12.2-py3-none-any.whl b/wheels/py3/beautifulsoup4-4.12.2-py3-none-any.whl new file mode 100644 index 0000000..2b31b54 Binary files /dev/null and b/wheels/py3/beautifulsoup4-4.12.2-py3-none-any.whl differ diff --git a/wheels/py3/soupsieve-2.5-py3-none-any.whl b/wheels/py3/soupsieve-2.5-py3-none-any.whl new file mode 100644 index 0000000..e1be128 Binary files /dev/null and b/wheels/py3/soupsieve-2.5-py3-none-any.whl differ