From 57ea1e5840f3517bc88162a1c510f4a953430caa Mon Sep 17 00:00:00 2001 From: Zachary Karpinski Date: Wed, 10 Jan 2024 20:23:40 -0500 Subject: [PATCH 1/2] added inventory api support and experimental added inventory api support added experimental apis to pull vulnerability report refactored code --- codeinsight_sdk/client.py | 23 ++++- codeinsight_sdk/experimental.py | 43 +++++++++ codeinsight_sdk/handler.py | 15 +++ codeinsight_sdk/handlers/__init__.py | 3 + codeinsight_sdk/handlers/inventory.py | 50 ++++++++++ .../{handlers.py => handlers/project.py} | 77 +-------------- codeinsight_sdk/handlers/report.py | 55 +++++++++++ codeinsight_sdk/models.py | 5 +- tests/test_client.py | 4 +- tests/test_experimental.py | 96 +++++++++++++++++++ tests/test_handlers.py | 16 +--- tests/test_not_implemented.py | 6 +- 12 files changed, 294 insertions(+), 99 deletions(-) create mode 100644 codeinsight_sdk/experimental.py create mode 100644 codeinsight_sdk/handler.py create mode 100644 codeinsight_sdk/handlers/__init__.py create mode 100644 codeinsight_sdk/handlers/inventory.py rename codeinsight_sdk/{handlers.py => handlers/project.py} (79%) create mode 100644 codeinsight_sdk/handlers/report.py create mode 100644 tests/test_experimental.py diff --git a/codeinsight_sdk/client.py b/codeinsight_sdk/client.py index 9e9ae27..80f4785 100644 --- a/codeinsight_sdk/client.py +++ b/codeinsight_sdk/client.py @@ -3,16 +3,22 @@ from .handlers import ProjectHandler, ReportHandler from .exceptions import CodeInsightError +from .experimental import ExperimentalHandler +from .handlers.inventory import InventoryHandler logger = logging.getLogger(__name__) class CodeInsightClient: + """Client for the code insight API.""" + def __init__(self, base_url: str, api_token: str, timeout: int = 60, - verify_ssl: bool = True + verify_ssl: bool = True, + experimental: bool = False ): + self.base_url = base_url self.api_url = f"{base_url}/codeinsight/api" self.__api_token = api_token @@ -23,6 +29,7 @@ def __init__(self, } self.__timeout = timeout self.__verify_ssl = verify_ssl + self.experimental_enabled = experimental def request(self, method, url_part: str, params: dict = None, body: any = None, data: any = None, content_type: str = None): url = f"{self.api_url}/{url_part}" @@ -57,10 +64,20 @@ def projects(self) -> ProjectHandler: def reports(self) -> ReportHandler: return ReportHandler(self) + @property + def inventories(self): + return InventoryHandler(self) + + @property + def experimental(self) -> ExperimentalHandler: + if self.experimental_enabled == False: + raise CodeInsightError("Experimental API is not enabled for this instance") + else: + return ExperimentalHandler(self) + # Coming soon...? - def inventories(self): - raise NotImplementedError("Inventories are not yet implemented") + def vulnerabilites(self): raise NotImplementedError diff --git a/codeinsight_sdk/experimental.py b/codeinsight_sdk/experimental.py new file mode 100644 index 0000000..ffb1610 --- /dev/null +++ b/codeinsight_sdk/experimental.py @@ -0,0 +1,43 @@ +from .handler import Handler +from .models import ProjectInventoryItem + +class ExperimentalHandler(Handler): + def __init__(self, client): + super().__init__(client) + + def get(self): + # Do nothing, there is no get for this handler + pass + + def get_project_vulnerabilities(self, project_id:int) -> list[ProjectInventoryItem]: + """ + Get all vulnerabilities for a project. + + Args: + project_id (int): The project id. + + Returns: + dict: The vulnerabilities. + """ + # First we get the inventory summary for the project with vulnerability summary + # Then we iterate over the inventory items and calling the inventory vulnerability endpoint for each item with a vulnerability + inventory = self.client.projects.get_inventory_summary(project_id, vulnerabilitySummary=True) + + # Iterate over the inventory items, find which have vulnerabilities. + item: ProjectInventoryItem + vuln_items: list(ProjectInventoryItem) = [] + for item in inventory: + if item.vulnerabilitySummary is None: + continue + + # If the item has a vulnerability, get the vulnerability details for this item and append it + if sum(item.vulnerabilitySummary[0]['CvssV3'].values()) > 0: + + vul_detail = self.client.inventories.get_inventory_vulnerabilities(item.id) + item.vulnerabilities = vul_detail + vuln_items.append(item) + else: + # If the item has no vulnerabilities, skip it + continue + + return vuln_items \ No newline at end of file diff --git a/codeinsight_sdk/handler.py b/codeinsight_sdk/handler.py new file mode 100644 index 0000000..8a0b5b3 --- /dev/null +++ b/codeinsight_sdk/handler.py @@ -0,0 +1,15 @@ +import abc +from typing import List + +from .models import Project, ProjectInventory, ProjectInventoryItem, Report +from .exceptions import CodeInsightError + +class Handler(abc.ABC): + def __init__(self, client): + self.client = client + self.cls = None + + @abc.abstractmethod + def get(self): + pass + diff --git a/codeinsight_sdk/handlers/__init__.py b/codeinsight_sdk/handlers/__init__.py new file mode 100644 index 0000000..e549db5 --- /dev/null +++ b/codeinsight_sdk/handlers/__init__.py @@ -0,0 +1,3 @@ +from .inventory import InventoryHandler +from .project import ProjectHandler +from .report import ReportHandler \ No newline at end of file diff --git a/codeinsight_sdk/handlers/inventory.py b/codeinsight_sdk/handlers/inventory.py new file mode 100644 index 0000000..8de2e5a --- /dev/null +++ b/codeinsight_sdk/handlers/inventory.py @@ -0,0 +1,50 @@ +from ..models import ProjectInventoryItem, Vulnerability +from ..handler import Handler + +class InventoryHandler(Handler): + """ Handles operations related to inventories.""" + + def __init__(self, client): + super().__init__(client) + self.cls = ProjectInventoryItem + + def get(self, inventoryId: int) -> list[ProjectInventoryItem]: + """ + Get an inventory item by id. + + Args: + inventoryId (int): The inventory item id. + + Returns: + ProjectInventoryItem: The inventory item. + """ + path = f"inventories/{inventoryId}" + resp = self.client.request("GET", url_part=path) + inventory = [] + for inv_item in resp.json()['data']: + inventory.append(ProjectInventoryItem.from_dict(inv_item)) + return inventory + + def get_inventory_vulnerabilities(self, inventoryId: int, + limit: int = 25, + offset: int = 1) -> list[Vulnerability]: + """ + Get all vulnerabilities for an inventory item. + + Args: + inventoryId (int): The inventory item id. + + Returns: + dict: The vulnerabilities. + """ + path = f"inventories/{inventoryId}/vulnerabilities" + params = {"limit": limit, "offset": offset} + resp = self.client.request("GET", url_part=path, params=params) + + # TODO - Iterate pages + + inventory_vuls: list(Vulnerability) = [] + for v in resp.json()['data']: + inventory_vuls.append(Vulnerability.from_dict(v)) + + return inventory_vuls \ No newline at end of file diff --git a/codeinsight_sdk/handlers.py b/codeinsight_sdk/handlers/project.py similarity index 79% rename from codeinsight_sdk/handlers.py rename to codeinsight_sdk/handlers/project.py index b2c8ad4..65e7a71 100644 --- a/codeinsight_sdk/handlers.py +++ b/codeinsight_sdk/handlers/project.py @@ -1,28 +1,9 @@ -import abc from typing import List -from codeinsight_sdk.models import Project, ProjectInventory, ProjectInventoryItem, Report -from codeinsight_sdk.exceptions import CodeInsightError +from ..models import Project, ProjectInventory, ProjectInventoryItem +from ..handler import Handler -class Handler(abc.ABC): - def __init__(self, client): - self.client = client - self.cls = None - - @staticmethod - def create(client, cls): - k = cls.__name__ - handlers = {"Project": ProjectHandler, - "Report": ReportHandler - } - handler = handlers.get(k) - if handler is None: - raise ValueError(f"Handler not found for class '{k}'") - return handler(client) - - @abc.abstractmethod - def get(self): - pass +from ..exceptions import CodeInsightError class ProjectHandler(Handler): def __init__(self, client): @@ -208,55 +189,3 @@ def upload_codebase(self, project_id:int, content_type = "application/octet-stream" resp = self.client.request("POST", url_part=path, params=params, data=code_file,content_type=content_type) return resp.status_code - -class ReportHandler(Handler): - """ - A class that handles operations related to reports. - - Args: - client (Client): The client object used for making API requests. - - Attributes: - cls (Report): The class representing a report. - - Methods: - get(id): Retrieves a report by its ID. - all(): Retrieves all reports. - - """ - - def __init__(self, client): - super().__init__(client) - self.cls = Report - - def get(self, id:int): - """ - Retrieves a report by its ID. - - Args: - id (int): The ID of the report to retrieve. - - Returns: - Report: The report object. - - """ - path = f"reports/{id}" - resp = self.client.request("GET", url_part=path) - report_data = resp.json()['data'] - report = self.cls.from_dict(report_data) - return report - - def all(self): - """ - Retrieves all reports. - - Returns: - list: A list of report objects. - - """ - path = "reports" - resp = self.client.request("GET", url_part=path) - reports = [] - for report_data in resp.json()['data']: - reports.append(self.cls.from_dict(report_data)) - return reports diff --git a/codeinsight_sdk/handlers/report.py b/codeinsight_sdk/handlers/report.py new file mode 100644 index 0000000..bb382ca --- /dev/null +++ b/codeinsight_sdk/handlers/report.py @@ -0,0 +1,55 @@ +from ..models import Report +from ..handler import Handler + + +class ReportHandler(Handler): + """ + A class that handles operations related to reports. + + Args: + client (Client): The client object used for making API requests. + + Attributes: + cls (Report): The class representing a report. + + Methods: + get(id): Retrieves a report by its ID. + all(): Retrieves all reports. + + """ + + def __init__(self, client): + super().__init__(client) + self.cls = Report + + def get(self, id:int): + """ + Retrieves a report by its ID. + + Args: + id (int): The ID of the report to retrieve. + + Returns: + Report: The report object. + + """ + path = f"reports/{id}" + resp = self.client.request("GET", url_part=path) + report_data = resp.json()['data'] + report = self.cls.from_dict(report_data) + return report + + def all(self): + """ + Retrieves all reports. + + Returns: + list: A list of report objects. + + """ + path = "reports" + resp = self.client.request("GET", url_part=path) + reports = [] + for report_data in resp.json()['data']: + reports.append(self.cls.from_dict(report_data)) + return reports diff --git a/codeinsight_sdk/models.py b/codeinsight_sdk/models.py index 06d0c5b..81adf43 100644 --- a/codeinsight_sdk/models.py +++ b/codeinsight_sdk/models.py @@ -1,3 +1,6 @@ +# pylint: disable=invalid-name +# Disable invalid name because the API uses camelCase + from dataclasses import dataclass from dataclasses_json import DataClassJsonMixin, dataclass_json from typing import Any, Optional, List, Dict @@ -39,7 +42,7 @@ class ProjectInventoryItem(DataClassJsonMixin): componentUrl: Optional[str] = None componentDescription: Optional[str] = None vulnerabilities: Optional[List[Vulnerability]] = None - vulnerabilitySummary: Optional[Dict[str, Dict]] = None + vulnerabilitySummary: Optional[List[Dict[str, Dict]]] = None filePaths: Optional[List[str]] = None @dataclass_json #Trying this style instead of DataClassJsonMixin diff --git a/tests/test_client.py b/tests/test_client.py index 06a6b27..afe4e8e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,6 +20,9 @@ def client(self): def test_client(self, client): assert client.base_url == TEST_URL + + def test_client_expertimental_disabled(self, client): + assert client.experimental_enabled == False def test_endpoint_not_found(self, client): with requests_mock.Mocker() as m: @@ -168,7 +171,6 @@ def test_get_project_inventory_summary(self,client): "componentVersionName":"2.0" } ] - } """ with requests_mock.Mocker() as m: diff --git a/tests/test_experimental.py b/tests/test_experimental.py new file mode 100644 index 0000000..fe0e0ea --- /dev/null +++ b/tests/test_experimental.py @@ -0,0 +1,96 @@ +import pytest +import logging + +import requests_mock + + +from codeinsight_sdk import CodeInsightClient +from codeinsight_sdk.exceptions import CodeInsightError + +logger = logging.getLogger(__name__) + +## CHANGE ME ## +TEST_URL = "https://api.revenera.com" +TEST_API_TOKEN = "your_api_token" + +class TestExperimental: + @pytest.fixture + def client(self): + return CodeInsightClient(TEST_URL, TEST_API_TOKEN, experimental=True) + + def test_experimental_enabled(self, client): + assert client.experimental_enabled == True + + def test_get_project_vulnerabilities(self, client): + project_id = 1 + total_pages = 4 + total_records = total_pages * 2 + response_header = {"content-type": "application/json"} + response_header["current-page"] = "1" + response_header["number-of-pages"] = str(total_pages) + response_header["total-records"] = str(total_records) + fake_response_json = """ { "data": [ + { + "itemNumber": 1, + "id": 12345, + "name": "Inventory Item 1", + "type":"component", + "priority":"low", + "createdBy":"Zach", + "createdOn":"Today", + "updatedOn":"Tomorrow", + "componentName":"snakeyaml", + "componentVersionName":"2.0" + }, + { + "itemNumber": 2, + "id": 12346, + "name": "Inventory Item 2", + "type":"component", + "priority":"low", + "createdBy":"Zach", + "createdOn":"Today", + "updatedOn":"Tomorrow", + "componentName":"snakeyaml", + "componentVersionName":"2.0", + "vulnerabilitySummary": [{ + "CvssV2": { + "High": 2, + "Medium": 2, + "Low": 3, + "Unknown": 4 + }, + "CvssV3": { + "Critical": 1, + "High": 1, + "Medium": 2, + "Low": 6, + "Unknown": 1 + } + }] + } + ]} + """ + + mock_resp_vuln = """ { "data": [ { + "vulnerabilityId":123456, + "vulnerabilityName":"CVE-123-45678", + "vulnerabilityDescription":"Insecure library! Watch out.", + "vulnerabilityCvssV3Score": 3.3, + "vulnerabilityCvssV3Severity":"LOW"}, + { + "vulnerabilityId":123457, + "vulnerabilityName":"CVE-987-65432", + "vulnerabilityDescription":"Insecure library 2! Watch out.", + "vulnerabilityCvssV3Score": 9, + "vulnerabilityCvssV3Severity":"CRITICAL"} ] } + """ + with requests_mock.Mocker() as m: + m.get(f"{TEST_URL}/codeinsight/api/projects/{project_id}/inventorySummary", + text=fake_response_json, headers=response_header) + m.get(f"{TEST_URL}/codeinsight/api/inventories/12346/vulnerabilities", + text=mock_resp_vuln, headers=response_header) + vulnerable_items = client.experimental.get_project_vulnerabilities(project_id) + assert len(vulnerable_items) > 0 + assert vulnerable_items[0].vulnerabilities is not None + assert vulnerable_items[0].vulnerabilities[1].vulnerabilityName == "CVE-987-65432" diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 12ea084..6bc3bdd 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,7 +1,7 @@ import pytest from codeinsight_sdk import CodeInsightClient -from codeinsight_sdk.handlers import Handler, ProjectHandler, ReportHandler +from codeinsight_sdk.handlers import ProjectHandler, ReportHandler from codeinsight_sdk.models import Project, Report @@ -9,17 +9,3 @@ class TestHandlers(object): @pytest.fixture def client(self): return CodeInsightClient("","") - - def test_bad_handler(self, client): - with pytest.raises(Exception): - Handler.create(client) - - def test_project_handler(self, client): - project_handler = Handler.create(client, Project) - assert isinstance(project_handler, ProjectHandler) - assert issubclass(ProjectHandler, Handler) - - def test_report_handler(self, client): - _handler = Handler.create(client, Report) - assert isinstance(_handler, ReportHandler) - assert issubclass(ReportHandler, Handler) \ No newline at end of file diff --git a/tests/test_not_implemented.py b/tests/test_not_implemented.py index 383def8..b00177c 100644 --- a/tests/test_not_implemented.py +++ b/tests/test_not_implemented.py @@ -8,11 +8,7 @@ class TestNotImplemented(object): def client(self): return CodeInsightClient("","") - ## Coming soon features ## - def test_inventories(self, client): - with pytest.raises(NotImplementedError): - client.inventories() - + ## Coming soon features ## def test_vulnerabilities(self, client): with pytest.raises(NotImplementedError): client.vulnerabilites() From 69d4c35b412a041f12dc5e0260eaf83976a7b3de Mon Sep 17 00:00:00 2001 From: Zachary Karpinski Date: Wed, 10 Jan 2024 21:39:34 -0500 Subject: [PATCH 2/2] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9b0ff1..78f9abb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "codeinsight_sdk" -version = "0.0.7" +version = "0.0.8" description = "A Python client for the Revenera Code Insight" authors = ["Zachary Karpinski <1206496+zkarpinski@users.noreply.github.com>"] readme = "README.md"