Skip to content

Commit dce0ec3

Browse files
author
Garance Gourdel
committed
feat(incidents): implement models and client for /incidents/secrets/ endpoint
1 parent f226e8a commit dce0ec3

File tree

5 files changed

+484
-1
lines changed

5 files changed

+484
-1
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Added
2+
3+
- Added `GGClient.retrieve_secret_incident()` to retrieve the incident associated with a secret (see https://api.gitguardian.com/docs#tag/Secret-Incidents/operation/retrieve-incidents)

pygitguardian/client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
QuotaResponse,
3838
RemediationMessages,
3939
ScanResult,
40+
SecretIncident,
4041
SecretScanPreferences,
4142
ServerMetadata,
4243
)
@@ -454,6 +455,30 @@ def multi_content_scan(
454455

455456
return obj
456457

458+
def retrieve_secret_incident(
459+
self, incident_id: int, with_occurrences: int = 20
460+
) -> Union[Detail, SecretIncident]:
461+
"""
462+
retrieve_secret_incident handles the /incidents/secret/{incident_id} endpoint of the API
463+
464+
:param incident_id: incident id
465+
:param with_occurrences: number of occurrences of the incident to retrieve (default 20)
466+
"""
467+
468+
resp = self.get(
469+
endpoint=f"incidents/secrets/{incident_id}",
470+
params={"with_occurrences": with_occurrences},
471+
)
472+
473+
obj: Union[Detail, SecretIncident]
474+
if is_ok(resp):
475+
obj = SecretIncident.from_dict(resp.json())
476+
else:
477+
obj = load_detail(resp)
478+
479+
obj.status_code = resp.status_code
480+
return obj
481+
457482
def quota_overview(
458483
self,
459484
extra_headers: Optional[Dict[str, str]] = None,

pygitguardian/models.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass, field
55
from datetime import date, datetime
66
from enum import Enum
7-
from typing import Any, ClassVar, Dict, List, Optional, cast
7+
from typing import Any, ClassVar, Dict, List, Literal, Optional, Type, cast
88
from uuid import UUID
99

1010
import marshmallow_dataclass
@@ -794,3 +794,179 @@ class JWTService(Enum):
794794
"""Enum for the different services GIM can generate a JWT for."""
795795

796796
HMSL = "hmsl"
797+
798+
799+
@dataclass
800+
class Detector(Base, FromDictMixin):
801+
name: str
802+
display_name: str
803+
nature: str
804+
family: str
805+
detector_group_name: str
806+
detector_group_display_name: str
807+
808+
809+
Severity = Literal["low", "medium", "high", "critical", "unknown"]
810+
ValidityStatus = Literal["valid", "invalid", "failed_to_check", "no_checker", "unknown"]
811+
IncidentStatus = Literal["IGNORED", "TRIGGERED", "RESOLVED", "ASSIGNED"]
812+
Tag = Literal[
813+
"DEFAULT_BRANCH",
814+
"FROM_HISTORICAL_SCAN",
815+
"CHECK_RUN_SKIP_FALSE_POSITIVE",
816+
"CHECK_RUN_SKIP_LOW_RISK",
817+
"CHECK_RUN_SKIP_TEST_CRED",
818+
"IGNORED_IN_CHECK_RUN",
819+
"FALSE_POSITIVE",
820+
"PUBLICLY_EXPOSED",
821+
"PUBLICLY_LEAKED",
822+
"REGRESSION",
823+
"SENSITIVE_FILE",
824+
"TEST_FILE",
825+
]
826+
IgnoreReason = Literal["test_credential", "false_positive", "low_risk"]
827+
OccurrenceKind = Literal["realtime", "historical"]
828+
OccurrencePresence = Literal["present", "removed"]
829+
Visibility = Literal["private", "internal", "public"]
830+
831+
832+
@dataclass
833+
class SecretPresence(Base, FromDictMixin):
834+
files_requiring_code_fix: int
835+
files_pending_merge: int
836+
files_fixed: int
837+
outside_vcs: int
838+
removed_outside_vcs: int
839+
in_vcs: int
840+
removed_in_vcs: int
841+
842+
843+
@dataclass
844+
class Answer(Base, FromDictMixin):
845+
type: str
846+
field_ref: str
847+
field_label: str
848+
boolean: Optional[bool] = None
849+
text: Optional[str] = None
850+
851+
852+
@dataclass
853+
class Feedback(Base, FromDictMixin):
854+
created_at: datetime
855+
updated_at: datetime
856+
member_id: int
857+
email: str
858+
answers: List[Answer]
859+
860+
861+
@dataclass
862+
class Source(Base, FromDictMixin):
863+
id: int
864+
url: str
865+
type: str
866+
full_name: str
867+
health: Literal["safe", "unknown", "at_risk"]
868+
default_branch: Optional[str]
869+
default_branch_head: Optional[str]
870+
open_incidents_count: int
871+
closed_incidents_count: int
872+
secret_incidents_breakdown: Dict[str, Any] # TODO: add SecretIncidentsBreakdown
873+
visibility: Visibility
874+
external_id: str
875+
source_criticality: str
876+
last_scan: Optional[Dict[str, Any]] # TODO: add LastScan
877+
monitored: bool
878+
879+
880+
@dataclass
881+
class OccurrenceMatch(Base, FromDictMixin):
882+
"""
883+
Describes the match of an occurrence, different from the Match return as part of a PolicyBreak.
884+
885+
name: type of the match such as "api_key", "password", "client_id", "client_secret"...
886+
indice_start: start index of the match in the document (0-based)
887+
indice_end: end index of the match in the document (0-based, strictly greater than indice_start)
888+
pre_line_start: Optional start line number (1-based) of the match in the document (before the git patch)
889+
pre_line_end: Optional end line number (1-based) of the match in the document (before the git patch)
890+
post_line_start: Optional start line number (1-based) of the match in the document (after the git patch)
891+
post_line_end: Optional end line number (1-based) of the match in the document (after the git patch)
892+
"""
893+
894+
name: str
895+
indice_start: int
896+
indice_end: int
897+
pre_line_start: Optional[int]
898+
pre_line_end: Optional[int]
899+
post_line_start: Optional[int]
900+
post_line_end: Optional[int]
901+
902+
903+
@dataclass
904+
class SecretOccurrence(Base, FromDictMixin):
905+
id: int
906+
incident_id: int
907+
kind: OccurrenceKind
908+
source: Source
909+
author_name: str
910+
author_info: str
911+
date: datetime # Publish date
912+
url: str
913+
matches: List[OccurrenceMatch]
914+
tags: List[str]
915+
sha: Optional[str] # Commit sha
916+
presence: OccurrencePresence
917+
filepath: Optional[str]
918+
919+
920+
SecretOccurrenceSchema = cast(
921+
Type[BaseSchema],
922+
marshmallow_dataclass.class_schema(SecretOccurrence, base_schema=BaseSchema),
923+
)
924+
SecretOccurrence.SCHEMA = SecretOccurrenceSchema()
925+
926+
927+
@dataclass(repr=False) # the default repr would be too long
928+
class SecretIncident(Base, FromDictMixin):
929+
"""
930+
Secret Incident describes a leaked secret incident.
931+
"""
932+
933+
id: int
934+
date: datetime
935+
detector: Detector
936+
secret_hash: str
937+
hmsl_hash: str
938+
gitguardian_url: str
939+
regression: bool
940+
status: IncidentStatus
941+
assignee_id: Optional[int]
942+
assignee_email: Optional[str]
943+
occurrences_count: int
944+
secret_presence: SecretPresence
945+
ignore_reason: Optional[IgnoreReason]
946+
triggered_at: Optional[datetime]
947+
ignored_at: Optional[datetime]
948+
ignorer_id: Optional[int]
949+
ignorer_api_token_id: Optional[UUID]
950+
resolver_id: Optional[int]
951+
resolver_api_token_id: Optional[UUID]
952+
secret_revoked: bool
953+
severity: Severity
954+
validity: ValidityStatus
955+
resolved_at: Optional[datetime]
956+
share_url: Optional[str]
957+
tags: List[Tag]
958+
feedback_list: List[Feedback]
959+
occurrences: Optional[List[SecretOccurrence]]
960+
961+
def __repr__(self) -> str:
962+
return (
963+
f"id:{self.id}, detector_name:{self.detector.name},"
964+
f" url:{self.gitguardian_url}"
965+
)
966+
967+
968+
SecretIncidentSchema = cast(
969+
Type[BaseSchema],
970+
marshmallow_dataclass.class_schema(SecretIncident, base_schema=BaseSchema),
971+
)
972+
SecretIncident.SCHEMA = SecretIncidentSchema()

tests/test_client.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,88 @@ def test_multiscan_parameters(
616616
assert mock_response.call_count == 1
617617

618618

619+
@responses.activate
620+
def test_retrieve_secret_incident(client: GGClient):
621+
"""
622+
GIVEN a ggclient
623+
WHEN calling retrieve_secret_incident with parameters
624+
THEN the parameters are passed in the request
625+
"""
626+
627+
mock_response = responses.get(
628+
url=client._url_from_endpoint("incidents/secrets/3759", "v1"),
629+
status=200,
630+
match=[matchers.query_param_matcher({"with_occurrences": 20})],
631+
json={
632+
"id": 3759,
633+
"date": "2019-08-22T14:15:22Z",
634+
"detector": {
635+
"name": "slack_bot_token",
636+
"display_name": "Slack Bot Token",
637+
"nature": "specific",
638+
"family": "apikey",
639+
"detector_group_name": "slackbot_token",
640+
"detector_group_display_name": "Slack Bot Token",
641+
},
642+
"secret_hash": "Ri9FjVgdOlPnBmujoxP4XPJcbe82BhJXB/SAngijw/juCISuOMgPzYhV28m6OG24",
643+
"hmsl_hash": "05975add34ddc9a38a0fb57c7d3e676ffed57080516fc16bf8d8f14308fedb86",
644+
"gitguardian_url": "https://dashboard.gitguardian.com/workspace/1/incidents/3899",
645+
"regression": False,
646+
"status": "IGNORED",
647+
"assignee_id": 309,
648+
"assignee_email": "eric@gitguardian.com",
649+
"occurrences_count": 4,
650+
"secret_presence": {
651+
"files_requiring_code_fix": 1,
652+
"files_pending_merge": 1,
653+
"files_fixed": 1,
654+
"outside_vcs": 1,
655+
"removed_outside_vcs": 0,
656+
"in_vcs": 3,
657+
"removed_in_vcs": 0,
658+
},
659+
"ignore_reason": "test_credential",
660+
"triggered_at": "2019-05-12T09:37:49Z",
661+
"ignored_at": "2019-08-24T14:15:22Z",
662+
"ignorer_id": 309,
663+
"ignorer_api_token_id": "fdf075f9-1662-4cf1-9171-af50568158a8",
664+
"resolver_id": 395,
665+
"resolver_api_token_id": "fdf075f9-1662-4cf1-9171-af50568158a8",
666+
"secret_revoked": False,
667+
"severity": "high",
668+
"validity": "valid",
669+
"resolved_at": None,
670+
"share_url": "https://dashboard.gitguardian.com/share/incidents/11111111-1111-1111-1111-111111111111",
671+
"tags": ["FROM_HISTORICAL_SCAN", "SENSITIVE_FILE"],
672+
"feedback_list": [
673+
{
674+
"created_at": "2021-05-20T12:40:55.662949Z",
675+
"updated_at": "2021-05-20T12:40:55.662949Z",
676+
"member_id": 42,
677+
"email": "eric@gitguardian.com",
678+
"answers": [
679+
{
680+
"type": "boolean",
681+
"field_ref": "actual_secret_yes_no",
682+
"field_label": "Is it an actual secret?",
683+
"boolean": True,
684+
}
685+
],
686+
}
687+
],
688+
"occurrences": None,
689+
},
690+
)
691+
692+
result = client.retrieve_secret_incident(3759)
693+
694+
assert mock_response.call_count == 1
695+
assert result.id == 3759
696+
assert result.detector.name == "slack_bot_token"
697+
assert result.ignore_reason == "test_credential"
698+
assert result.secret_revoked is False
699+
700+
619701
@responses.activate
620702
def test_rate_limit():
621703
"""

0 commit comments

Comments
 (0)