Skip to content

Commit 0705cdd

Browse files
feat: handle new endpoint to scan documents and create incidents for the found secrets
1 parent be13fe7 commit 0705cdd

File tree

4 files changed

+155
-0
lines changed

4 files changed

+155
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Added
2+
3+
- New `GGClient.scan_and_create_incidents()` function that scans content for secrets and automatically creates incidents for any findings.

pygitguardian/client.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from io import BytesIO
99
from pathlib import Path
1010
from typing import Any, Dict, List, Optional, Union, cast
11+
from uuid import UUID
1112

1213
import requests
1314
from requests import Response, Session, codes
@@ -510,6 +511,7 @@ def multi_content_scan(
510511
else:
511512
raise TypeError("each document must be a dict")
512513

514+
# Validate documents using DocumentSchema
513515
for document in request_obj:
514516
DocumentSchema.validate_size(
515517
document, self.secret_scan_preferences.maximum_document_size
@@ -538,6 +540,71 @@ def multi_content_scan(
538540

539541
return obj
540542

543+
def scan_and_create_incidents(
544+
self,
545+
documents: List[Dict[str, str]],
546+
source_uuid: UUID,
547+
extra_headers: Optional[Dict[str, str]] = None,
548+
) -> Union[Detail, MultiScanResult]:
549+
"""
550+
scan_and_create_incidents handles the /scan/create-incidents endpoint of the API.
551+
552+
If documents contain `0` bytes, they will be replaced with the ASCII substitute
553+
character.
554+
555+
:param documents: List of dictionaries containing the keys document
556+
and, optionally, filename.
557+
example: [{"document":"example content","filename":"intro.py"}]
558+
:param source_uuid: the source UUID that will be used to identify the custom source, for which
559+
incidents will be created
560+
:param extra_headers: additional headers to add to the request
561+
:return: Detail or ScanResult response and status code
562+
"""
563+
max_documents = self.secret_scan_preferences.maximum_documents_per_scan
564+
if len(documents) > max_documents:
565+
raise ValueError(
566+
f"too many documents submitted for scan (max={max_documents})"
567+
)
568+
569+
if all(isinstance(doc, dict) for doc in documents):
570+
request_obj = cast(
571+
List[Dict[str, Any]], Document.SCHEMA.load(documents, many=True)
572+
)
573+
else:
574+
raise TypeError("each document must be a dict")
575+
576+
# Validate documents using DocumentSchema
577+
for document in request_obj:
578+
DocumentSchema.validate_size(
579+
document, self.secret_scan_preferences.maximum_document_size
580+
)
581+
582+
payload = {
583+
"source_uuid": source_uuid,
584+
"documents": [
585+
{
586+
"document_identifier": document["filename"],
587+
"document": document["document"],
588+
}
589+
for document in request_obj
590+
],
591+
}
592+
resp = self.post(
593+
endpoint="scan/create-incidents",
594+
data=payload,
595+
extra_headers=extra_headers,
596+
)
597+
598+
obj: Union[Detail, MultiScanResult]
599+
if is_ok(resp):
600+
obj = MultiScanResult.from_dict({"scan_results": resp.json()})
601+
else:
602+
obj = load_detail(resp)
603+
604+
obj.status_code = resp.status_code
605+
606+
return obj
607+
541608
def retrieve_secret_incident(
542609
self, incident_id: int, with_occurrences: int = 20
543610
) -> Union[Detail, SecretIncident]:

pygitguardian/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,7 @@ class TokenScope(str, Enum):
757757
CUSTOM_TAGS_READ = "custom_tags:read"
758758
CUSTOM_TAGS_WRITE = "custom_tags:write"
759759
SECRET_READ = "secrets:read"
760+
SCAN_CREATE_INCIDENTS = "scan:create-incidents"
760761

761762

762763
class APITokensResponseSchema(BaseSchema):

tests/test_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,90 @@ def test_multiscan_parameters(client: GGClient, ignore_known_secrets, all_secret
606606
assert mock_response.call_count == 1
607607

608608

609+
@responses.activate
610+
def test_scan_and_create_incidents_parameters(client: GGClient):
611+
"""
612+
GIVEN a ggclient
613+
WHEN calling scan_and_create_incidents with parameters
614+
THEN the parameters are passed in the request
615+
"""
616+
617+
to_match = {}
618+
619+
mock_response = responses.post(
620+
url=client._url_from_endpoint("scan/create-incidents", "v1"),
621+
status=200,
622+
match=[matchers.query_param_matcher(to_match)],
623+
json=[
624+
{
625+
"policy_break_count": 1,
626+
"policies": ["pol"],
627+
"policy_breaks": [
628+
{
629+
"type": "break",
630+
"detector_name": "break",
631+
"detector_group_name": "break",
632+
"documentation_url": None,
633+
"policy": "mypol",
634+
"matches": [
635+
{
636+
"match": "hello",
637+
"type": "hello",
638+
}
639+
],
640+
}
641+
],
642+
}
643+
],
644+
)
645+
646+
client.scan_and_create_incidents(
647+
[{"filename": FILENAME, "document": DOCUMENT}],
648+
source_uuid="123e4567-e89b-12d3-a456-426614174000",
649+
)
650+
651+
assert mock_response.call_count == 1
652+
653+
654+
@responses.activate
655+
def test_scan_and_create_incidents_payload_structure(client: GGClient):
656+
"""
657+
GIVEN a ggclient
658+
WHEN calling scan_and_create_incidents
659+
THEN the payload is structured correctly with documents and source_uuid
660+
"""
661+
662+
documents = [{"filename": FILENAME, "document": DOCUMENT}]
663+
source_uuid = "123e4567-e89b-12d3-a456-426614174000"
664+
665+
expected_payload = {
666+
"documents": [
667+
{
668+
"document": DOCUMENT,
669+
"document_identifier": FILENAME,
670+
}
671+
],
672+
"source_uuid": source_uuid,
673+
}
674+
675+
mock_response = responses.post(
676+
url=client._url_from_endpoint("scan/create-incidents", "v1"),
677+
status=200,
678+
match=[matchers.json_params_matcher(expected_payload)],
679+
json=[
680+
{
681+
"policy_break_count": 0,
682+
"policies": ["pol"],
683+
"policy_breaks": [],
684+
}
685+
],
686+
)
687+
688+
client.scan_and_create_incidents(documents, source_uuid)
689+
690+
assert mock_response.call_count == 1
691+
692+
609693
@responses.activate
610694
def test_retrieve_secret_incident(client: GGClient):
611695
"""

0 commit comments

Comments
 (0)