diff --git a/pygitguardian/client.py b/pygitguardian/client.py index aa1e384e..c04dce06 100644 --- a/pygitguardian/client.py +++ b/pygitguardian/client.py @@ -28,6 +28,7 @@ CreateTeamMemberParameters, DeleteMemberParameters, Detail, + DetectorDetailsResponse, Document, DocumentSchema, HealthCheckResponse, @@ -570,6 +571,35 @@ def retrieve_secret_incident( obj.status_code = resp.status_code return obj + def detector_details( + self, + detector_name: str, + extra_headers: Optional[Dict[str, str]] = None, + ) -> Union[Detail, DetectorDetailsResponse]: + """ + detector_details handles the /detectors/{detector_name} endpoint of the API + + :param detector_name: detector name + :param extra_headers: additional headers to add to the request + :return: Detail or Detector response and status code + """ + + resp = self.get( + endpoint=f"secret_detectors/{detector_name}", + extra_headers=extra_headers, + ) + + obj: Union[Detail, DetectorDetailsResponse] + if is_ok(resp): + print(resp.json()) + obj = DetectorDetailsResponse.from_dict(resp.json()) + else: + obj = load_detail(resp) + + obj.status_code = resp.status_code + + return obj + def quota_overview( self, extra_headers: Optional[Dict[str, str]] = None, diff --git a/pygitguardian/models.py b/pygitguardian/models.py index 979a6661..8da21774 100644 --- a/pygitguardian/models.py +++ b/pygitguardian/models.py @@ -709,6 +709,147 @@ def __repr__(self) -> str: ) +class DetectorType(str, Enum): + SPECIFIC = "specific" + GENERIC = "generic" + CUSTOM = "custom" + + +class DetectorDetailsSchema(BaseSchema): + name = fields.String(required=True) + display_name = fields.String(required=True) + type = fields.Enum(DetectorType, by_value=True, required=True) + category = fields.String(required=True) + is_active = fields.Boolean(required=True) + scans_code_only = fields.Boolean(required=True) + checkable = fields.Boolean(required=True) + use_with_validity_check_disabled = fields.Boolean(required=True) + frequency = fields.Float(required=True) + removed_at = fields.String(required=False, load_default=None, dump_default=None) + open_incidents_count = fields.Int(required=True) + ignored_incidents_count = fields.Int(required=True) + resolved_incidents_count = fields.Int(required=True) + + @post_load + def make_detector(self, data: Dict[str, Any], **kwargs: Any) -> "DetectorDetails": + return DetectorDetails(**data) + + +class DetectorDetails(Base, FromDictMixin): + """ " + Response from /v1/detectors, to retrieve a detetor details + from the API + { + "name": "aws_iam", + "display_name": "AWS Keys", + "type": "specific", + "category": "Cloud Provider", + "is_active": true, + "scans_code_only": false, + "checkable": true, + "use_with_validity_check_disabled": true, + "frequency": "1O3.74", + "removed_at": null, + "open_incidents_count": 17, + "ignored_incidents_count": 9, + "resolved_incidents_count": 42 + } + """ + + SCHEMA = DetectorDetailsSchema() + + def __init__( + self, + name: str, + display_name: str, + type: DetectorType, + category: str, + is_active: bool, + scans_code_only: bool, + checkable: bool, + use_with_validity_check_disabled: bool, + frequency: float, + removed_at: str | None, + open_incidents_count: int, + ignored_incidents_count: int, + resolved_incidents_count: int, + **kwargs: Any, + ): + super().__init__() + self.name = name + self.display_name = display_name + self.type = type + self.category = category + self.is_active = is_active + self.scans_code_only = scans_code_only + self.checkable = checkable + self.use_with_validity_check_disabled = use_with_validity_check_disabled + self.frequency = frequency + self.removed_at = removed_at + self.open_incidents_count = open_incidents_count + self.ignored_incidents_count = ignored_incidents_count + self.resolved_incidents_count = resolved_incidents_count + + +class DetectorDetailsResponseSchema(BaseSchema): + name = fields.String(required=True) + display_name = fields.String(required=True) + type = fields.Enum(DetectorType, by_value=True, required=True) + category = fields.String(required=True) + is_active = fields.Boolean(required=True) + scans_code_only = fields.Boolean(required=True) + checkable = fields.Boolean(required=True) + use_with_validity_check_disabled = fields.Boolean(required=True) + frequency = fields.Float(required=True) + removed_at = fields.String(required=False, load_default=None, dump_default=None) + open_incidents_count = fields.Int(required=True) + ignored_incidents_count = fields.Int(required=True) + resolved_incidents_count = fields.Int(required=True) + + @post_load + def make_detector(self, data: Dict[str, Any], **kwargs: Any) -> "DetectorDetails": + return DetectorDetails(**data) + + +class DetectorDetailsResponse(Base, FromDictMixin): + SCHEMA = DetectorDetailsResponseSchema() + + def __init__(self, detector: DetectorDetails, **kwargs: Any): + super().__init__() + self.name = detector.name + self.display_name = detector.display_name + self.type = detector.type + self.category = detector.category + self.is_active = detector.is_active + self.scans_code_only = detector.scans_code_only + self.checkable = detector.checkable + self.use_with_validity_check_disabled = ( + detector.use_with_validity_check_disabled + ) + self.frequency = detector.frequency + self.removed_at = detector.removed_at + self.open_incidents_count = detector.open_incidents_count + self.ignored_incidents_count = detector.ignored_incidents_count + self.resolved_incidents_count = detector.resolved_incidents_count + + def __repr__(self) -> str: + return ( + f"name:{self.name}, " + f"display_name:{self.display_name}, " + f"type:{self.type}, " + f"category:{self.category}, " + f"is_active:{self.is_active}, " + f"scans_code_only:{self.scans_code_only}, " + f"checkable:{self.checkable}, " + f"use_with_validity_check_disabled:{self.use_with_validity_check_disabled}, " + f"frequency:{self.frequency}, " + f"removed_at:{self.removed_at}, " + f"open_incidents_count:{self.open_incidents_count}, " + f"ignored_incidents_count:{self.ignored_incidents_count}, " + f"resolved_incidents_count:{self.resolved_incidents_count}" + ) + + class TokenType(str, Enum): PERSONAL_ACCESS_TOKEN = "personal_access_token" SERVICE_ACCOUNT = "service_account" diff --git a/tests/cassettes/detector_details.yaml b/tests/cassettes/detector_details.yaml new file mode 100644 index 00000000..a30964b3 --- /dev/null +++ b/tests/cassettes/detector_details.yaml @@ -0,0 +1,57 @@ +interactions: + - request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - pygitguardian/1.20.0 (Linux;py3.10.13) + method: GET + uri: https://api.gitguardian.com/v1/secret_detectors/1password_service_account_token + response: + body: + string: '{"name":"1password_service_account_token","display_name":"1Password + Service Account Token","is_active":true,"type":"specific","category":"other","checkable":false,"frequency":0.02,"use_with_validity_check_disabled":true,"scans_code_only":false,"removed_at":null,"open_incidents_count":0,"ignored_incidents_count":0,"resolved_incidents_count":0}' + headers: + access-control-expose-headers: + - X-App-Version + allow: + - GET, HEAD, OPTIONS + content-length: + - '344' + content-type: + - application/json + cross-origin-opener-policy: + - same-origin + date: + - Tue, 08 Apr 2025 12:51:08 GMT + referrer-policy: + - strict-origin-when-cross-origin + server: + - istio-envoy + strict-transport-security: + - max-age=31536000; includeSubDomains + vary: + - Cookie + x-app-version: + - v2.185.0 + x-content-type-options: + - nosniff + - nosniff + x-envoy-upstream-service-time: + - '38' + x-frame-options: + - DENY + - SAMEORIGIN + x-secrets-engine-version: + - 2.135.3 + x-xss-protection: + - 1; mode=block + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_client.py b/tests/test_client.py index 508190c8..9b8d2938 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,6 +33,7 @@ CreateTeamMemberParameters, DeleteMemberParameters, Detail, + DetectorDetails, HoneytokenResponse, HoneytokenWithContextResponse, IncidentPermission, @@ -754,6 +755,22 @@ def test_bogus_rate_limit(): callbacks.on_rate_limited.assert_not_called() +def test_detector_overview(client: GGClient): + with my_vcr.use_cassette("detector_details.yaml"): + detector_response = client.detector_details("1password_service_account_token") + assert detector_response.status_code == 200 + if isinstance(detector_response, DetectorDetails): + assert detector_response.name == "1password_service_account_token" + assert detector_response.display_name == "1Password Service Account Token" + assert detector_response.type == "specific" + assert detector_response.category == "other" + assert detector_response.is_active + else: + pytest.fail("returned should be a DetectorDetails") + + assert type(detector_response.to_dict()) == OrderedDict + + def test_quota_overview(client: GGClient): with my_vcr.use_cassette("quota.yaml"): quota_response = client.quota_overview()