Skip to content

Commit 1453e09

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

File tree

4 files changed

+156
-0
lines changed

4 files changed

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