Skip to content

Commit 28c0c33

Browse files
authored
Merge pull request #50 from GitGuardian/alacombe/implement-honeytoken-creation
implement `create_honeytoken` method
2 parents b3536c1 + 186aa23 commit 28c0c33

File tree

5 files changed

+252
-2
lines changed

5 files changed

+252
-2
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
### Added
3+
4+
- Add `GGClient.create_honeytoken()` method.

pygitguardian/client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Detail,
2626
Document,
2727
HealthCheckResponse,
28+
HoneytokenResponse,
2829
MultiScanResult,
2930
QuotaResponse,
3031
ScanResult,
@@ -80,6 +81,17 @@ def is_ok(resp: Response) -> bool:
8081
)
8182

8283

84+
def is_create_ok(resp: Response) -> bool:
85+
"""
86+
is_create_ok returns True if the API returns code 201
87+
and the content type is JSON.
88+
"""
89+
return (
90+
resp.headers["content-type"] == "application/json"
91+
and resp.status_code == codes.created
92+
)
93+
94+
8395
def _create_tar(root_path: Path, filenames: List[str]) -> bytes:
8496
"""
8597
:param root_path: the root_path from which the tar is created
@@ -390,6 +402,43 @@ def quota_overview(
390402

391403
return obj
392404

405+
def create_honeytoken(
406+
self,
407+
name: Optional[str],
408+
type_: str,
409+
description: Optional[str],
410+
extra_headers: Optional[Dict[str, str]] = None,
411+
) -> Union[Detail, HoneytokenResponse]:
412+
"""
413+
Create a honeytoken via the /honeytokens endpoint of the API
414+
415+
:param name: the honeytoken name
416+
:param type_: the honeytoken type
417+
:param description: the honeytoken description
418+
:param extra_headers: additional headers to add to the request
419+
:return: Detail or Honeytoken response and status code
420+
"""
421+
try:
422+
resp = self.post(
423+
endpoint="honeytokens",
424+
extra_headers=extra_headers,
425+
data={
426+
"name": name,
427+
"type": type_,
428+
"description": description,
429+
},
430+
)
431+
except requests.exceptions.ReadTimeout:
432+
result = Detail("The request timed out.")
433+
result.status_code = 504
434+
else:
435+
if is_create_ok(resp):
436+
result = HoneytokenResponse.SCHEMA.load(resp.json())
437+
else:
438+
result = load_detail(resp)
439+
result.status_code = resp.status_code
440+
return result
441+
393442
# For IaC Scans
394443
def iac_directory_scan(
395444
self,

pygitguardian/models.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import date
1+
from datetime import date, datetime
22
from typing import Any, ClassVar, Dict, List, Optional, cast
33

44
from marshmallow import (
@@ -485,6 +485,99 @@ def __repr__(self) -> str:
485485
return f"content:{repr(self.content)}"
486486

487487

488+
class HoneytokenResponseSchema(BaseSchema):
489+
id = fields.Int()
490+
name = fields.String()
491+
description = fields.String(allow_none=True)
492+
created_at = fields.AwareDateTime()
493+
status = fields.String()
494+
triggered_at = fields.AwareDateTime(allow_none=True)
495+
revoked_at = fields.AwareDateTime(allow_none=True)
496+
open_events_count = fields.Int(allow_none=True)
497+
type_ = fields.String(data_key="type")
498+
creator_id = fields.Int(allow_none=True)
499+
revoker_id = fields.Int(allow_none=True)
500+
creator_api_token_id = fields.String(allow_none=True)
501+
revoker_api_token_id = fields.String(allow_none=True)
502+
token = fields.Mapping(key=fields.String(), value=fields.String())
503+
tags = fields.List(fields.String())
504+
505+
@post_load
506+
def make_honeytoken_response(
507+
self, data: Dict[str, Any], **kwargs: Any
508+
) -> "HoneytokenResponse":
509+
return HoneytokenResponse(**data)
510+
511+
512+
class HoneytokenResponse(Base):
513+
"""
514+
honeytoken creation in the GitGuardian API.
515+
Allows users to create and get a honeytoken.
516+
Example:
517+
{
518+
"id": 141,
519+
"name": "honeytoken A",
520+
"description": "honeytoken used in the repository AA",
521+
"created_at": "2019-08-22T14:15:22Z",
522+
"status": "active",
523+
"triggered_at": "2019-08-22T14:15:22Z",
524+
"revoked_at": "2019-08-22T14:15:22Z",
525+
"open_events_count": 122,
526+
"type": "AWS",
527+
"creator_id": 122,
528+
"revoker_id": 122,
529+
"creator_api_token_id": null,
530+
"revoker_api_token_id": null,
531+
"token": {
532+
"access_token_id": "AAAA",
533+
"secret_key": "BBB"
534+
},
535+
"tags": ["publicly_exposed"]
536+
}
537+
"""
538+
539+
SCHEMA = HoneytokenResponseSchema()
540+
541+
def __init__(
542+
self,
543+
id: int,
544+
name: str,
545+
description: Optional[str],
546+
created_at: datetime,
547+
status: str,
548+
triggered_at: Optional[datetime],
549+
revoked_at: Optional[datetime],
550+
open_events_count: Optional[int],
551+
type_: str,
552+
creator_id: Optional[int],
553+
revoker_id: Optional[int],
554+
creator_api_token_id: Optional[str],
555+
revoker_api_token_id: Optional[str],
556+
token: Dict[str, str],
557+
tags: List[str],
558+
**kwargs: Any,
559+
) -> None:
560+
super().__init__()
561+
self.id = id
562+
self.name = name
563+
self.description = description
564+
self.created_at = created_at
565+
self.status = status
566+
self.triggered_at = triggered_at
567+
self.revoked_at = revoked_at
568+
self.open_events_count = open_events_count
569+
self.type_ = type_
570+
self.creator_id = creator_id
571+
self.revoker_id = revoker_id
572+
self.creator_api_token_id = creator_api_token_id
573+
self.revoker_api_token_id = revoker_api_token_id
574+
self.token = token
575+
self.tags = tags
576+
577+
def __repr__(self) -> str:
578+
return f"honeytoken:{self.id} {self.name}"
579+
580+
488581
class HealthCheckResponseSchema(BaseSchema):
489582
detail = fields.String(allow_none=False)
490583
status_code = fields.Int(allow_none=False)

tests/test_client.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
DOCUMENT_SIZE_THRESHOLD_BYTES,
1717
MULTI_DOCUMENT_LIMIT,
1818
)
19-
from pygitguardian.models import Detail, MultiScanResult, QuotaResponse, ScanResult
19+
from pygitguardian.models import (
20+
Detail,
21+
HoneytokenResponse,
22+
MultiScanResult,
23+
QuotaResponse,
24+
ScanResult,
25+
)
2026

2127
from .conftest import my_vcr
2228

@@ -646,3 +652,75 @@ def test_versions_from_headers(request_mock: Mock, client: GGClient, method):
646652
other_client = GGClient(api_key="")
647653
assert other_client.app_version is app_version_value
648654
assert other_client.secrets_engine_version is secrets_engine_version_value
655+
656+
657+
@patch("requests.Session.request")
658+
def test_create_honeytoken(
659+
request_mock: Mock,
660+
client: GGClient,
661+
):
662+
"""
663+
GIVEN a ggclient
664+
WHEN calling create_honeytoken with parameters
665+
THEN the parameters are passed in the request and the returned honeytoken use the parameters
666+
"""
667+
mock_response = Mock(spec=Response)
668+
mock_response.headers = {"content-type": "application/json"}
669+
mock_response.status_code = 201
670+
mock_response.json.return_value = {
671+
"id": 141,
672+
"name": "honeytoken A",
673+
"description": "honeytoken used in the repository AA",
674+
"created_at": "2019-08-22T14:15:22Z",
675+
"status": "active",
676+
"triggered_at": "2019-08-22T14:15:22Z",
677+
"revoked_at": None,
678+
"open_events_count": 2,
679+
"type": "AWS",
680+
"creator_id": 122,
681+
"revoker_id": None,
682+
"creator_api_token_id": None,
683+
"revoker_api_token_id": None,
684+
"token": {
685+
"access_token_id": "AAAA",
686+
"secret_key": "BBB"
687+
},
688+
"tags": ["publicly_exposed"]
689+
}
690+
691+
request_mock.return_value = mock_response
692+
693+
result = client.create_honeytoken(name="honeytoken A",
694+
description="honeytoken used in the repository AA",
695+
type_="AWS")
696+
697+
assert request_mock.called
698+
assert isinstance(result, HoneytokenResponse)
699+
700+
701+
@patch("requests.Session.request")
702+
def test_create_honeytoken_error(
703+
request_mock: Mock,
704+
client: GGClient,
705+
):
706+
"""
707+
GIVEN a ggclient
708+
WHEN calling create_honeytoken with parameters without the right access
709+
THEN I get a Detail objects containing the error detail
710+
"""
711+
mock_response = Mock(spec=Response)
712+
mock_response.headers = {"content-type": "application/json"}
713+
mock_response.status_code = 400
714+
mock_response.json.return_value = {
715+
"detail": "Not authorized",
716+
}
717+
718+
request_mock.return_value = mock_response
719+
720+
result = client.create_honeytoken(name="honeytoken A",
721+
description="honeytoken used in the repository AA",
722+
type_="AWS")
723+
724+
assert request_mock.called
725+
assert isinstance(result, Detail)
726+
result.status_code == 400

tests/test_models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
Document,
99
DocumentSchema,
1010
HealthCheckResponseSchema,
11+
HoneytokenResponse,
12+
HoneytokenResponseSchema,
1113
Match,
1214
MatchSchema,
1315
MultiScanResult,
@@ -135,6 +137,30 @@ def test_document_handle_surrogates(self):
135137
Detail,
136138
{"detail": "Fail"},
137139
),
140+
(
141+
HoneytokenResponseSchema,
142+
HoneytokenResponse,
143+
{
144+
"id": 141,
145+
"name": "honeytoken A",
146+
"description": "honeytoken used in the repository AA",
147+
"created_at": "2019-08-22T14:15:22Z",
148+
"status": "active",
149+
"triggered_at": "2019-08-22T14:15:22Z",
150+
"revoked_at": None,
151+
"open_events_count": 2,
152+
"type": "AWS",
153+
"creator_id": 122,
154+
"revoker_id": None,
155+
"creator_api_token_id": None,
156+
"revoker_api_token_id": None,
157+
"token": {
158+
"access_token_id": "AAAA",
159+
"secret_key": "BBB"
160+
},
161+
"tags": ["publicly_exposed"]
162+
},
163+
),
138164
],
139165
)
140166
def test_schema_loads(self, schema_klass, expected_klass, instance_data):

0 commit comments

Comments
 (0)