Skip to content

Commit df195d0

Browse files
Merge pull request #232 from 0x41424142/patching
Small fixes, added pm.count_product_vulns and sql.upload_pm_product_vuln_counts
2 parents b6ae7c7 + 1572fd7 commit df195d0

File tree

14 files changed

+317
-79
lines changed

14 files changed

+317
-79
lines changed

docs/patch.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ You can use any of the endpoints currently supported:
3131
| ```get_patch_catalog``` | Returns the patch catalog for a given platform according to ```patchId```. |
3232
| ```get_packages_in_linux_patch``` | Returns the packages associated with a Linux patch. |
3333
| ```get_products_in_windows_patch``` | Returns the products associated with a Windows patch. |
34+
| ```count_product_vulns``` | Return the number of vulns (active and fixed) from products in your environment. |
3435

3536
## Get PM Version API
3637

@@ -772,6 +773,51 @@ products = get_products_in_windows_patch(
772773
)
773774
```
774775

776+
## Count Product Vulnerabilities API
777+
778+
```count_product_vulns``` returns the number of active and fixed vulnerabilities stemming from products.
779+
780+
|Parameter| Possible Values |Description| Required|
781+
|--|--|--|--|
782+
|```auth```|```qualysdk.auth.TokenAuth``` | Authentication object ||
783+
| ```severityList``` | ```Union[str, list[Literal["Critical", "Important", "Moderate", "Low", "None"]]]``` | The severity levels to count vulnerabilities for. Can be a list or strings or a comma-separated string ||
784+
| ```tagUUIDs``` | ```Union[str, list[str]]``` | The UUIDs of the tags to filter with ||
785+
786+
```py
787+
from qualysdk.auth import TokenAuth
788+
from qualysdk.pm import count_product_vulns
789+
790+
auth = TokenAuth(<username>, <password>, platform='qg1')
791+
792+
# Get the number of Critical and Important
793+
# vulnerabilities for all products:
794+
count = count_product_vulns(
795+
auth,
796+
severityList=["Critical", "Important"]
797+
)
798+
>>>[
799+
ProductVulnCount(
800+
name='Windows',
801+
totalQIDCount=123,
802+
patchableQIDCount=None,
803+
type='APP_FAMILY',
804+
patchableQIDs=None,
805+
totalQIDs=None,
806+
severity='Critical'
807+
),
808+
ProductVulnCount(
809+
name='Office',
810+
totalQIDCount=123,
811+
patchableQIDCount=None,
812+
type='APP_FAMILY',
813+
patchableQIDs=None,
814+
totalQIDs=None,
815+
severity='Critical'
816+
),
817+
...
818+
]
819+
```
820+
775821

776822
## ```qualysdk-pm``` CLI tool
777823

docs/sql.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ The final optional parameter is ```table_name```. If you want to specify a custo
142142
| ```upload_pm_patch_catalog``` | Patch Management | ```pm.get_patch_catalog()``` | ```pm_patch_catalog``` |
143143
| ```upload_pm_linux_packages``` | Patch Management | ```pm.get_packages_in_linux_patch()``` | ```pm_linux_packages``` |
144144
| ```upload_pm_windows_products``` | Patch Management | ```pm.get_products_in_windows_patch()``` | ```pm_windows_products``` |
145+
| ```upload_pm_product_vuln_counts``` | Patch Management | ```pm.count_product_vulns()``` | ```pm_product_vuln_counts``` |
145146
| ```upload_cert_certs``` | Certificate View | ```cert.list_certs()``` | ```cert_certs``` for certificates and ```cert_assets``` for assets (key = certs.id -> assets.certId) |
146147

147148

poetry.lock

Lines changed: 67 additions & 67 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "qualysdk"
3-
version = "0.2.6"
3+
version = "0.2.7"
44
description = "SDK for interacting with Qualys APIs, across most modules the platform offers."
55
authors = ["0x41424142 <jake@jakelindsay.uk>", "0x4A616B65 <jake.lindsay@thermofisher.com>"]
66
maintainers = ["Jake Lindsay <jake@jakelindsay.uk>"]
@@ -27,11 +27,11 @@ bs4 = "^0.0.2"
2727
lxml = "^5.2.2"
2828
pandas = "^2.2.2"
2929
mkdocs = "^1.6.0"
30-
sqlalchemy = "^2.0.32"
30+
sqlalchemy = "^2.0.37"
3131
numpy = "^2.2.1"
3232
pymssql = "^2.3.2"
3333
pymysql = "^1.1.1"
34-
pymdown-extensions = "^10.13"
34+
pymdown-extensions = "^10.14"
3535
mkdocs-material = "^9.5.48"
3636
psycopg2 = {version = "^2.9.9", platform = "win32"}
3737
psycopg2-binary = [

qualysdk/base/call_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def call_api(
131131
auth_tuple = (auth.username, auth.password)
132132

133133
# Make certain payloads/params requests-friendly:
134+
# TODO: need to evaluate other modules this may apply to.
134135
if module != "pm":
135136
if payload:
136137
payload = convert_bools_and_nones(payload)

qualysdk/base/call_schemas/pm_schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,16 @@
209209
"pagination": False,
210210
"auth_type": "token",
211211
},
212+
"count_product_vulns": {
213+
"endpoint": "/pm/v1/patchcatalog/patch/products/vulnerability/count",
214+
"method": ["GET"],
215+
"valid_params": ["severityList", "tagUUIDs"],
216+
"valid_POST_data": [],
217+
"use_requests_json_data": False,
218+
"return_type": "json",
219+
"pagination": False,
220+
"auth_type": "token",
221+
},
212222
},
213223
}
214224
)

qualysdk/pm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@
2020
get_patch_catalog,
2121
get_packages_in_linux_patch,
2222
get_products_in_windows_patch,
23+
count_product_vulns,
2324
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from dataclasses import dataclass
2+
from typing import Literal
3+
4+
from ...base.base_class import BaseClass
5+
6+
7+
@dataclass
8+
class ProductVulnCount(BaseClass):
9+
"""
10+
Represents a product and its associated QID count/details.
11+
"""
12+
13+
name: str
14+
totalQIDCount: int = 0
15+
patchableQIDCount: int = None
16+
type: str = None
17+
patchableQIDs: str = None
18+
totalQIDs: int = None
19+
severity: Literal["Critical", "Important", "Moderate", "Low", "None"] = "Undefined"

qualysdk/pm/patchcatalog.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .data_classes.CatalogPatch import CatalogPatch, PackageDetail
1111
from .data_classes.AssociatedProduct import AssociatedProduct
12+
from .data_classes.ProductVulnCount import ProductVulnCount
1213
from ..base.base_list import BaseList
1314
from ..auth.token import TokenAuth
1415
from ..base.call_api import call_api
@@ -385,3 +386,102 @@ def get_products_in_windows_patch(
385386
t.join()
386387

387388
return responses
389+
390+
391+
from typing import Union, List, Literal, overload
392+
393+
394+
@overload
395+
def count_product_vulns(
396+
auth: TokenAuth,
397+
severityList: Union[
398+
Literal["Critical", "Important", "Moderate", "Low", "None"],
399+
List[Literal["Critical", "Important", "Moderate", "Low", "None"]],
400+
] = None,
401+
tagUUIDs: str = None,
402+
) -> BaseList[ProductVulnCount]:
403+
...
404+
405+
406+
@overload
407+
def count_product_vulns(
408+
auth: TokenAuth,
409+
severityList: Union[
410+
Literal["Critical", "Important", "Moderate", "Low", "None"],
411+
List[Literal["Critical", "Important", "Moderate", "Low", "None"]],
412+
] = None,
413+
tagUUIDs: List[str] = None,
414+
) -> BaseList[ProductVulnCount]:
415+
...
416+
417+
418+
def count_product_vulns(
419+
auth: TokenAuth,
420+
severityList: Union[
421+
Literal["Critical", "Important", "Moderate", "Low", "None"],
422+
List[Literal["Critical", "Important", "Moderate", "Low", "None"]],
423+
] = None,
424+
tagUUIDs: Union[str, List[str]] = None,
425+
) -> BaseList[ProductVulnCount]:
426+
"""
427+
Get a count of active and fixed product vulnerabilities.
428+
429+
If no severityList value is passed, all severities will be returned (Critical, Important, Moderate, Low, None).
430+
431+
Args:
432+
auth (TokenAuth): The authentication object.
433+
severityList (Union[str, List[str]]): The severity or severities to filter by.
434+
Can be a single value or a list of values. For all severities, leave this blank.
435+
tagUUIDs (Union[str, List[str]]): The tag UUIDs to filter by.
436+
437+
Returns:
438+
BaseList[ProductVulnCount]: A list of ProductVulnCount objects.
439+
"""
440+
# Normalize severityList to a list
441+
if severityList is None:
442+
severity_values = ["Critical", "Important", "Moderate", "Low", "None"]
443+
# check if the string or list contains all of the valid values
444+
elif isinstance(severityList, str) and set(
445+
[i.title() for i in severityList.split(",")]
446+
) == set(["Critical", "Important", "Moderate", "Low", "None"]):
447+
severity_values = ["Critical", "Important", "Moderate", "Low", "None"]
448+
449+
elif isinstance(severityList, (list, BaseList)) and set(severityList) == set(
450+
["Critical", "Important", "Moderate", "Low", "None"]
451+
):
452+
severity_values = ["Critical", "Important", "Moderate", "Low", "None"]
453+
454+
elif isinstance(severityList, str):
455+
severity_values = [s.title().strip() for s in severityList.split(",")]
456+
457+
elif isinstance(severityList, (list, BaseList)):
458+
severity_values = severityList
459+
460+
else:
461+
raise ValueError(
462+
"Invalid severityList format. Must be a string or a list of strings."
463+
)
464+
465+
results = BaseList()
466+
467+
if tagUUIDs and isinstance(tagUUIDs, (list, BaseList)):
468+
tagUUIDs = ",".join(tagUUIDs)
469+
elif tagUUIDs and not isinstance(tagUUIDs, str):
470+
raise ValueError("tagUUIDs must be a string or a list of strings.")
471+
472+
for severity in severity_values:
473+
response = call_api(
474+
auth,
475+
"pm",
476+
"count_product_vulns",
477+
params={"severityList": severity, "tagUUIDs": tagUUIDs},
478+
)
479+
480+
if response.status_code not in range(200, 299):
481+
raise QualysAPIError(response.json())
482+
483+
for entry in response.json():
484+
entry["severity"] = severity if severity else "All"
485+
results.append(ProductVulnCount(**entry))
486+
487+
return results

qualysdk/sql/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,6 @@
7676
upload_pm_patch_catalog,
7777
upload_pm_linux_packages,
7878
upload_pm_windows_products,
79+
upload_pm_product_vuln_counts,
7980
)
8081
from .cert import upload_cert_certs

qualysdk/sql/pm.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,3 +759,56 @@ def upload_pm_linux_packages(
759759

760760
# Upload the data:
761761
return upload_data(df, table_name, cnxn, COLS, override_import_dt)
762+
763+
764+
def upload_pm_product_vuln_counts(
765+
counts: BaseList,
766+
cnxn: Connection,
767+
table_name: str = "pm_product_vuln_counts",
768+
override_import_dt: datetime = None,
769+
) -> int:
770+
"""
771+
Upload results from ```pm.count_product_vulns``` to a SQL database.
772+
773+
Args:
774+
counts (BaseList): A BaseList of ProductVulnCount objects.
775+
cnxn (Connection): The Connection object to the SQL database.
776+
table_name (str): The name of the table to upload to. Defaults to "pm_product_vuln_counts".
777+
override_import_dt (datetime): If provided, will override the import_datetime column with this value.
778+
779+
Returns:
780+
int: The number of rows uploaded.
781+
"""
782+
'''@dataclass
783+
class ProductVulnCount(BaseClass):
784+
"""
785+
Represents a product and its associated QID count/details.
786+
"""
787+
788+
name: str
789+
totalQIDCount: int = 0
790+
patchableQIDCount: int = None
791+
type: str = None
792+
patchableQIDs: str = None
793+
totalQIDs: int = None
794+
severity: Literal["Critical", "Important", "Moderate", "Low", "None"] = "Undefined"'''
795+
796+
COLS = {
797+
"name": types.String().with_variant(TEXT(charset="utf8"), "mysql", "mariadb"),
798+
"totalQIDCount": types.Integer(),
799+
"patchableQIDCount": types.Integer(),
800+
"type": types.String().with_variant(TEXT(charset="utf8"), "mysql", "mariadb"),
801+
"patchableQIDs": types.String().with_variant(
802+
TEXT(charset="utf8"), "mysql", "mariadb"
803+
),
804+
"totalQIDs": types.Integer(),
805+
"severity": types.String().with_variant(
806+
TEXT(charset="utf8"), "mysql", "mariadb"
807+
),
808+
}
809+
810+
# Prepare the dataclass for insertion:
811+
df = DataFrame([prepare_dataclass(count) for count in counts])
812+
813+
# Upload the data:
814+
return upload_data(df, table_name, cnxn, COLS, override_import_dt)

qualysdk/sql/vmdr.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pandas import DataFrame
88
from sqlalchemy import Connection, types
99
from sqlalchemy.dialects.mysql import TEXT
10+
from sqlalchemy.dialects.mssql import DATETIME2
1011

1112
from .base import upload_data, prepare_dataclass
1213
from ..base.base_list import BaseList
@@ -218,7 +219,7 @@ def upload_vmdr_kb(
218219
"SOLUTION_COMMENT": types.String().with_variant(
219220
TEXT(charset="utf8"), "mysql", "mariadb"
220221
),
221-
"PATCH_PUBLISHED_DATE": types.DateTime(),
222+
"PATCH_PUBLISHED_DATE": types.DateTime().with_variant(DATETIME2, "mssql"),
222223
}
223224

224225
# Convert the BaseList to a DataFrame:

qualysdk/vmdr/data_classes/kb_entry.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,18 @@ def __post_init__(self):
214214
simplefilter("ignore") # ignore the warning about the html.parser
215215
for html_field in HTML_FIELDS:
216216
if getattr(self, html_field):
217-
setattr(
218-
self,
219-
html_field,
220-
BeautifulSoup(
221-
getattr(self, html_field), "html.parser"
222-
).get_text(),
223-
)
217+
soup = BeautifulSoup(getattr(self, html_field), "html.parser")
218+
for a_tag in soup.find_all("a"):
219+
if a_tag.has_attr("href"):
220+
a_tag.replace_with(a_tag["href"])
221+
setattr(self, html_field, soup.get_text())
222+
# setattr(
223+
# self,
224+
# html_field,
225+
# BeautifulSoup(
226+
# getattr(self, html_field), "html.parser"
227+
# ).get_text(),
228+
# )
224229

225230
# convert the lists to BaseList objects:
226231
if self.BUGTRAQ_LIST:

qualysdk/vmdr/data_classes/vendor_reference.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def __post_init__(self):
3232
)
3333

3434
def __str__(self) -> str:
35-
return self.ID
35+
return f"{self.ID} ({self.URL})"
3636

3737
def __contains__(self, item):
3838
# see if it was found in the name or vendor:

0 commit comments

Comments
 (0)