Skip to content

feat(detector_details): Add mathod to retrieve detector details #140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions pygitguardian/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
CreateTeamMemberParameters,
DeleteMemberParameters,
Detail,
DetectorDetailsResponse,
Document,
DocumentSchema,
HealthCheckResponse,
Expand Down Expand Up @@ -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())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

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,
Expand Down
141 changes: 141 additions & 0 deletions pygitguardian/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you make this a dataclass then you don't need to write that long __init__() function.

And using marshmallow_dataclass you don't need to write the schema class manually.

You can have a look at SecretIncident for inspiration.

""" "
Response from /v1/detectors, to retrieve a detetor details
Comment on lines +739 to +740
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typos (but +1 for adding a docstring 🤩)

Suggested change
""" "
Response from /v1/detectors, to retrieve a detetor details
"""
Response from /v1/detectors, to retrieve a detector 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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need both a DetectorDetails and a DetectorDetailsReponse? I think we could make detector_details() return a DetectorDetails instance, but maybe I am missing something.

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"
Expand Down
57 changes: 57 additions & 0 deletions tests/cassettes/detector_details.yaml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CreateTeamMemberParameters,
DeleteMemberParameters,
Detail,
DetectorDetails,
HoneytokenResponse,
HoneytokenWithContextResponse,
IncidentPermission,
Expand Down Expand Up @@ -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()
Expand Down
Loading