django-log-formatter-asim 1.2.0a0__py3-none-any.whl → 1.2.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,7 @@
1
- from .authentication import log_authentication
2
- from .file_activity import log_file_activity
1
+ from .account_management import LogAccountManagement
2
+ from .authentication import LogAuthentication
3
+ from .file_activity import LogFileActivity
4
+
5
+ log_account_management = LogAccountManagement()
6
+ log_authentication = LogAuthentication()
7
+ log_file_activity = LogFileActivity()
@@ -0,0 +1,133 @@
1
+ import datetime
2
+ import json
3
+ from enum import Enum
4
+ from typing import Optional
5
+ from typing import TypedDict
6
+
7
+ from django.http import HttpRequest
8
+
9
+ from .common import Activity
10
+ from .common import Client
11
+ from .common import LoggedInUser
12
+ from .common import Result
13
+ from .common import Server
14
+ from .common import Severity
15
+
16
+
17
+ class FileActivityEvent(str, Enum):
18
+ UserCreated = "UserCreated"
19
+ UserDeleted = "UserDeleted"
20
+ UserModified = "UserModified"
21
+ UserLocked = "UserLocked"
22
+ UserUnlocked = "UserUnlocked"
23
+ UserDisabled = "UserDisabled"
24
+ UserEnabled = "UserEnabled"
25
+ PasswordChanged = "PasswordChanged"
26
+ PasswordReset = "PasswordReset"
27
+ GroupCreated = "GroupCreated"
28
+ GroupDeleted = "GroupDeleted"
29
+ GroupModified = "GroupModified"
30
+ UserAddedToGroup = "UserAddedToGroup"
31
+ UserRemovedFromGroup = "UserRemovedFromGroup"
32
+ GroupEnumerated = "GroupEnumerated"
33
+ UserRead = "UserRead"
34
+ GroupRead = "GroupRead"
35
+
36
+
37
+ class Account(TypedDict, total=False):
38
+ """Dictionary to represent details of the account management event."""
39
+
40
+ """
41
+ If a user was managed, the username of that user
42
+ """
43
+ username: Optional[str]
44
+ """If a group was managed, the name of the group."""
45
+ group: Optional[str]
46
+ """
47
+ If the Account Management event is one of the following.
48
+
49
+ - UserModified
50
+ - GroupModified
51
+
52
+ Details of the property which was changed, in the form:
53
+ ("propertyName", "oldValue", "newValue")
54
+ """
55
+ changed: tuple[str, str, str]
56
+
57
+
58
+ class LogAccountManagement(Activity):
59
+ Event = FileActivityEvent
60
+ Result = Result
61
+ Severity = Severity
62
+
63
+ def __call__(
64
+ self,
65
+ request: HttpRequest,
66
+ event: Event,
67
+ account: Account,
68
+ result: Result,
69
+ user: Optional[LoggedInUser] = None,
70
+ server: Optional[Server] = None,
71
+ client: Optional[Client] = None,
72
+ severity: Optional[Severity] = None,
73
+ time_generated: Optional[datetime.datetime] = None,
74
+ result_details: Optional[str] = None,
75
+ message: Optional[str] = None,
76
+ ):
77
+ self._log_account_management(
78
+ request,
79
+ event,
80
+ account,
81
+ result,
82
+ {} if user == None else user,
83
+ {} if server == None else server,
84
+ {} if client == None else client,
85
+ time_generated or datetime.datetime.now(tz=datetime.timezone.utc),
86
+ severity,
87
+ result_details,
88
+ message,
89
+ )
90
+
91
+ def _log_account_management(
92
+ self,
93
+ request: HttpRequest,
94
+ event: Event,
95
+ account: Account,
96
+ result: Result,
97
+ user: LoggedInUser,
98
+ server: Server,
99
+ client: Client,
100
+ event_created: datetime.datetime,
101
+ severity: Optional[Severity] = None,
102
+ result_details: Optional[str] = None,
103
+ message: Optional[str] = None,
104
+ ):
105
+ log = {
106
+ "EventSchema": "UserManagement",
107
+ "EventSchemaVersion": "0.1.1",
108
+ "EventType": event,
109
+ }
110
+ log.update(
111
+ self._activity_fields(
112
+ request, event_created, result, server, client, severity, result_details, message
113
+ )
114
+ )
115
+
116
+ if "username" in user:
117
+ log["ActorUsername"] = user["username"]
118
+ elif hasattr(request, "user") and request.user.username:
119
+ log["ActorUsername"] = request.user.username
120
+
121
+ if "username" in account:
122
+ log["TargetUsername"] = account["username"]
123
+
124
+ if "group" in account:
125
+ log["GroupName"] = account["group"]
126
+
127
+ if "changed" in account:
128
+ (propertyName, previousPropertyValue, newPropertyName) = account["changed"]
129
+ log["UpdatedPropertyName"] = propertyName
130
+ log["PreviousPropertyValue"] = previousPropertyValue
131
+ log["NewPropertyValue"] = newPropertyName
132
+
133
+ print(json.dumps(log), flush=True)
@@ -1,6 +1,5 @@
1
1
  import datetime
2
2
  import json
3
- import os
4
3
  from enum import Enum
5
4
  from hashlib import sha3_512
6
5
  from typing import Literal
@@ -9,14 +8,11 @@ from typing import TypedDict
9
8
 
10
9
  from django.http import HttpRequest
11
10
 
12
- from django_log_formatter_asim.ecs import _get_container_id
13
-
11
+ from .common import Activity
14
12
  from .common import Client
15
13
  from .common import Result
16
14
  from .common import Server
17
15
  from .common import Severity
18
- from .common import _default_severity
19
- from .common import _get_client_ip_address
20
16
 
21
17
 
22
18
  class AuthenticationEvent(str, Enum):
@@ -62,164 +58,133 @@ class AuthenticationUser(TypedDict, total=False):
62
58
  sessionId: Optional[str]
63
59
 
64
60
 
65
- def log_authentication(
66
- request: HttpRequest,
67
- event: AuthenticationEvent,
68
- result: Result,
69
- login_method: AuthenticationLoginMethod,
70
- user: Optional[AuthenticationUser] = None,
71
- server: Optional[Server] = None,
72
- client: Optional[Client] = None,
73
- severity: Optional[Severity] = None,
74
- time_generated: Optional[datetime.datetime] = None,
75
- result_details: Optional[str] = None,
76
- message: Optional[str] = None,
77
- ):
78
- """
79
- Log an ASIM Authentication Event to standard output.
80
-
81
- :param request: django.http.HttpRequest object which initiated this Authentication request
82
- from which the following data will be logged if available
83
- - Django Authentication systems current username
84
- - Django Session middlewares Session Key
85
- - Client IP address
86
- - URL requested by the client
87
- - Server domain name
88
- :param event: What authentication action was attempted, either "Logon" or "Logoff"
89
- :param result: What outcome did the action have, either "Success", "Failure", "Partial", "NA"
90
- :param login_method: What authentication mechanism was being used, one of:
91
- - "Username & Password"
92
- - "Staff-SSO"
93
- - "UK.GOV-SSO"
94
- - "External IdP"
95
- :param user: Dictionary containing information on the subject of this Authentication event
96
- see AuthenticationUser class for more details.
97
- :param server: Dictionary containing information on the server servicing this Authentication event
98
- see Server class for more details.
99
- :param client: Dictionary containing information on the client performing this Authentication event
100
- see Client class for more details.
101
- :param severity: Optional severity of the event, defaults to "Informational", otherwise one of:
102
- - "Informational"
103
- - "Low"
104
- - "Medium"
105
- - "High"
106
- :param time_generated: Optional datetime for when the event happened, otherwise datetime.now
107
- :param result_details: Optional string describing any details associated with the events outcome.
108
- This field is typically populated when the result is a failure.
109
- :param message: Optional string describing the reason why the log was generated.
110
-
111
- See also: https://learn.microsoft.com/en-us/azure/sentinel/normalization-schema-authentication
112
- """
113
-
114
- _log_authentication(
115
- request,
116
- event,
117
- result,
118
- login_method,
119
- user={} if user == None else user,
120
- server={} if server == None else server,
121
- client={} if client == None else client,
122
- event_created=time_generated or datetime.datetime.now(tz=datetime.timezone.utc),
123
- severity=severity,
124
- result_details=result_details,
125
- message=message,
126
- )
127
-
128
-
129
- def _log_authentication(
130
- request: HttpRequest,
131
- event: AuthenticationEvent,
132
- result: Result,
133
- login_method: AuthenticationLoginMethod,
134
- user: AuthenticationUser,
135
- server: Server,
136
- client: Client,
137
- event_created: datetime.datetime,
138
- severity: Optional[Severity] = None,
139
- result_details: Optional[str] = None,
140
- message: Optional[str] = None,
141
- ):
142
- log = {
143
- "EventStartTime": event_created.isoformat(),
144
- "EventSeverity": severity or _default_severity(result),
145
- "EventOriginalType": _event_code(event, result),
146
- "EventType": event,
147
- "EventResult": result,
148
- "LogonMethod": login_method,
149
- "EventSchema": "Authentication",
150
- "EventSchemaVersion": "0.1.4",
151
- }
152
-
153
- if "domain_name" in server:
154
- log["HttpHost"] = server["domain_name"]
155
- elif "HTTP_HOST" in request.META:
156
- log["HttpHost"] = request.get_host()
157
-
158
- if "service_name" in server:
159
- log["TargetAppName"] = server["service_name"]
160
- elif os.environ.get("COPILOT_APPLICATION_NAME") and os.environ.get("COPILOT_SERVICE_NAME"):
161
- app_name = f"{os.environ['COPILOT_APPLICATION_NAME']}-{os.environ['COPILOT_SERVICE_NAME']}"
162
- log["TargetAppName"] = app_name
163
-
164
- if container_id := _get_container_id():
165
- log["TargetContainerId"] = container_id
166
-
167
- if "ip_address" in client:
168
- log["SrcIpAddr"] = client["ip_address"]
169
- elif client_ip := _get_client_ip_address(request):
170
- log["SrcIpAddr"] = client_ip
171
-
172
- if "requested_url" in client:
173
- log["TargetUrl"] = client["requested_url"]
174
- elif "HTTP_HOST" in request.META:
175
- log["TargetUrl"] = request.scheme + "://" + request.get_host() + request.get_full_path()
176
-
177
- if "role" in user:
178
- log["TargetUserType"] = user["role"]
179
-
180
- if "sessionId" in user:
181
- log["TargetSessionId"] = _cryptographically_hash(user["sessionId"])
182
- elif hasattr(request, "session") and request.session.session_key:
183
- log["TargetSessionId"] = _cryptographically_hash(request.session.session_key)
184
-
185
- if "username" in user:
186
- log["TargetUsername"] = user["username"]
187
- elif hasattr(request, "user") and request.user.username:
188
- log["TargetUsername"] = request.user.username
189
-
190
- if result_details:
191
- log["EventResultDetails"] = result_details
192
-
193
- if message:
194
- log["EventMessage"] = message
195
-
196
- if "ip_address" in server:
197
- log["DvcIpAddr"] = server["ip_address"]
198
-
199
- print(json.dumps(log), flush=True)
200
-
201
-
202
- log_authentication.Event = AuthenticationEvent
203
- log_authentication.Result = Result
204
- log_authentication.LoginMethod = AuthenticationLoginMethod
205
- log_authentication.Severity = Severity
206
-
207
-
208
- def _cryptographically_hash(data: Optional[str]) -> Optional[str]:
209
- if data is None:
210
- return None
211
- return sha3_512(data.encode("UTF-8")).hexdigest()
212
-
213
-
214
- def _event_code(event: AuthenticationEvent, result: Result) -> str:
215
- if event == AuthenticationEvent.Logon:
216
- if result == Result.Success:
217
- return "001a"
218
- elif result == Result.Failure:
219
- return "001b"
220
- elif event == AuthenticationEvent.Logoff:
221
- if result == Result.Success:
222
- return "001c"
223
- elif result == Result.Failure:
224
- return "001d"
225
- return "001"
61
+ class LogAuthentication(Activity):
62
+ Event = AuthenticationEvent
63
+ Result = Result
64
+ LoginMethod = AuthenticationLoginMethod
65
+ Severity = Severity
66
+
67
+ def __call__(
68
+ self,
69
+ request: HttpRequest,
70
+ event: AuthenticationEvent,
71
+ result: Result,
72
+ login_method: AuthenticationLoginMethod,
73
+ user: Optional[AuthenticationUser] = None,
74
+ server: Optional[Server] = None,
75
+ client: Optional[Client] = None,
76
+ severity: Optional[Severity] = None,
77
+ time_generated: Optional[datetime.datetime] = None,
78
+ result_details: Optional[str] = None,
79
+ message: Optional[str] = None,
80
+ ):
81
+ """
82
+ Log an ASIM Authentication Event to standard output.
83
+
84
+ :param request: django.http.HttpRequest object which initiated this Authentication request
85
+ from which the following data will be logged if available
86
+ - Django Authentication systems current username
87
+ - Django Session middlewares Session Key
88
+ - Client IP address
89
+ - URL requested by the client
90
+ - Server domain name
91
+ :param event: What authentication action was attempted, either "Logon" or "Logoff"
92
+ :param result: What outcome did the action have, either "Success", "Failure", "Partial", "NA"
93
+ :param login_method: What authentication mechanism was being used, one of:
94
+ - "Username & Password"
95
+ - "Staff-SSO"
96
+ - "UK.GOV-SSO"
97
+ - "External IdP"
98
+ :param user: Dictionary containing information on the subject of this Authentication event
99
+ see AuthenticationUser class for more details.
100
+ :param server: Dictionary containing information on the server servicing this Authentication event
101
+ see Server class for more details.
102
+ :param client: Dictionary containing information on the client performing this Authentication event
103
+ see Client class for more details.
104
+ :param severity: Optional severity of the event, defaults to "Informational", otherwise one of:
105
+ - "Informational"
106
+ - "Low"
107
+ - "Medium"
108
+ - "High"
109
+ :param time_generated: Optional datetime for when the event happened, otherwise datetime.now
110
+ :param result_details: Optional string describing any details associated with the events outcome.
111
+ This field is typically populated when the result is a failure.
112
+ :param message: Optional string describing the reason why the log was generated.
113
+
114
+ See also: https://learn.microsoft.com/en-us/azure/sentinel/normalization-schema-authentication
115
+ """
116
+
117
+ self._log_authentication(
118
+ request,
119
+ event,
120
+ result,
121
+ login_method,
122
+ user={} if user == None else user,
123
+ server={} if server == None else server,
124
+ client={} if client == None else client,
125
+ event_created=time_generated or datetime.datetime.now(tz=datetime.timezone.utc),
126
+ severity=severity,
127
+ result_details=result_details,
128
+ message=message,
129
+ )
130
+
131
+ def _log_authentication(
132
+ self,
133
+ request: HttpRequest,
134
+ event: AuthenticationEvent,
135
+ result: Result,
136
+ login_method: AuthenticationLoginMethod,
137
+ user: AuthenticationUser,
138
+ server: Server,
139
+ client: Client,
140
+ event_created: datetime.datetime,
141
+ severity: Optional[Severity] = None,
142
+ result_details: Optional[str] = None,
143
+ message: Optional[str] = None,
144
+ ):
145
+ log = {
146
+ "EventOriginalType": self._event_code(event, result),
147
+ "EventType": event,
148
+ "LogonMethod": login_method,
149
+ "EventSchema": "Authentication",
150
+ "EventSchemaVersion": "0.1.4",
151
+ }
152
+
153
+ log.update(
154
+ self._activity_fields(
155
+ request, event_created, result, server, client, severity, result_details, message
156
+ )
157
+ )
158
+
159
+ if "role" in user:
160
+ log["TargetUserType"] = user["role"]
161
+
162
+ if "sessionId" in user:
163
+ log["TargetSessionId"] = self._cryptographically_hash(user["sessionId"])
164
+ elif hasattr(request, "session") and request.session.session_key:
165
+ log["TargetSessionId"] = self._cryptographically_hash(request.session.session_key)
166
+
167
+ if "username" in user:
168
+ log["TargetUsername"] = user["username"]
169
+ elif hasattr(request, "user") and request.user.username:
170
+ log["TargetUsername"] = request.user.username
171
+
172
+ print(json.dumps(log), flush=True)
173
+
174
+ def _cryptographically_hash(self, data: Optional[str]) -> Optional[str]:
175
+ if data is None:
176
+ return None
177
+ return sha3_512(data.encode("UTF-8")).hexdigest()
178
+
179
+ def _event_code(self, event: AuthenticationEvent, result: Result) -> str:
180
+ if event == AuthenticationEvent.Logon:
181
+ if result == Result.Success:
182
+ return "001a"
183
+ elif result == Result.Failure:
184
+ return "001b"
185
+ elif event == AuthenticationEvent.Logoff:
186
+ if result == Result.Success:
187
+ return "001c"
188
+ elif result == Result.Failure:
189
+ return "001d"
190
+ return "001"
@@ -1,9 +1,23 @@
1
+ import datetime
2
+ import os
1
3
  from enum import Enum
2
4
  from typing import Optional
3
5
  from typing import TypedDict
4
6
 
5
7
  from django.http import HttpRequest
6
8
 
9
+ from django_log_formatter_asim.ecs import _get_container_id
10
+
11
+
12
+ class LoggedInUser(TypedDict, total=False):
13
+ """
14
+ A unique identifier for the user.
15
+
16
+ Defaults to the logged in Django User.username if not provided.
17
+ """
18
+
19
+ username: Optional[str]
20
+
7
21
 
8
22
  class Result(str, Enum):
9
23
  Success = "Success"
@@ -51,13 +65,67 @@ class Server(TypedDict, total=False):
51
65
  service_name: Optional[str]
52
66
 
53
67
 
54
- def _default_severity(result: Result) -> Severity:
55
- return Severity.Informational if result == Result.Success else Severity.Medium
56
-
57
-
58
- def _get_client_ip_address(request: HttpRequest) -> Optional[str]:
59
- # Import here as ipware uses settings
60
- from ipware import get_client_ip
61
-
62
- client_ip, _ = get_client_ip(request)
63
- return client_ip
68
+ class Activity:
69
+ def _activity_fields(
70
+ self,
71
+ request: HttpRequest,
72
+ event_created: datetime.datetime,
73
+ result: Result,
74
+ server: Server,
75
+ client: Client,
76
+ severity: Optional[Severity],
77
+ result_details: Optional[str],
78
+ message: Optional[str],
79
+ ):
80
+ log = {
81
+ "EventStartTime": event_created.isoformat(),
82
+ "EventSeverity": severity or self._default_severity(result),
83
+ "EventResult": result,
84
+ }
85
+
86
+ if "domain_name" in server:
87
+ log["HttpHost"] = server["domain_name"]
88
+ elif "HTTP_HOST" in request.META:
89
+ log["HttpHost"] = request.get_host()
90
+
91
+ if "service_name" in server:
92
+ log["TargetAppName"] = server["service_name"]
93
+ elif os.environ.get("COPILOT_APPLICATION_NAME") and os.environ.get("COPILOT_SERVICE_NAME"):
94
+ app_name = (
95
+ f"{os.environ['COPILOT_APPLICATION_NAME']}-{os.environ['COPILOT_SERVICE_NAME']}"
96
+ )
97
+ log["TargetAppName"] = app_name
98
+
99
+ if container_id := _get_container_id():
100
+ log["TargetContainerId"] = container_id
101
+
102
+ if "ip_address" in client:
103
+ log["SrcIpAddr"] = client["ip_address"]
104
+ elif client_ip := self._get_client_ip_address(request):
105
+ log["SrcIpAddr"] = client_ip
106
+
107
+ if "requested_url" in client:
108
+ log["TargetUrl"] = client["requested_url"]
109
+ elif "HTTP_HOST" in request.META:
110
+ log["TargetUrl"] = request.scheme + "://" + request.get_host() + request.get_full_path()
111
+
112
+ if result_details:
113
+ log["EventResultDetails"] = result_details
114
+
115
+ if message:
116
+ log["EventMessage"] = message
117
+
118
+ if "ip_address" in server:
119
+ log["DvcIpAddr"] = server["ip_address"]
120
+
121
+ return log
122
+
123
+ def _default_severity(sef, result: Result) -> Severity:
124
+ return Severity.Informational if result == Result.Success else Severity.Medium
125
+
126
+ def _get_client_ip_address(self, request: HttpRequest) -> Optional[str]:
127
+ # Import here as ipware uses settings
128
+ from ipware import get_client_ip
129
+
130
+ client_ip, _ = get_client_ip(request)
131
+ return client_ip
@@ -7,14 +7,12 @@ from typing import TypedDict
7
7
 
8
8
  from django.http import HttpRequest
9
9
 
10
- from django_log_formatter_asim.ecs import _get_container_id
11
-
10
+ from .common import Activity
12
11
  from .common import Client
12
+ from .common import LoggedInUser
13
13
  from .common import Result
14
14
  from .common import Server
15
15
  from .common import Severity
16
- from .common import _default_severity
17
- from .common import _get_client_ip_address
18
16
 
19
17
 
20
18
  class FileActivityEvent(str, Enum):
@@ -71,186 +69,146 @@ class FileActivityFile(FileActivityFileBase, total=False):
71
69
  size: Optional[int]
72
70
 
73
71
 
74
- class FileActivityUser(TypedDict, total=False):
75
- """
76
- A unique identifier for the user.
77
-
78
- Defaults to the logged in Django User.username if not provided.
79
- """
80
-
81
- username: Optional[str]
82
-
83
-
84
- def log_file_activity(
85
- request: HttpRequest,
86
- event: FileActivityEvent,
87
- result: Result,
88
- file: FileActivityFile,
89
- source_file: Optional[FileActivityFile] = None,
90
- user: Optional[FileActivityUser] = None,
91
- server: Optional[Server] = None,
92
- client: Optional[Client] = None,
93
- severity: Optional[Severity] = None,
94
- time_generated: Optional[datetime.datetime] = None,
95
- result_details: Optional[str] = None,
96
- message: Optional[str] = None,
97
- ):
98
- """
99
- Log an ASIM File Event to standard output.
100
-
101
- :param request: django.http.HttpRequest object which initiated this Authentication request
102
- from which the following data will be logged if available
103
- - Django Authentication systems current username
104
- - Client IP address
105
- - URL requested by the client
106
- - Server domain name
107
- :param event: What File Event action was attempted, one of:
108
- - FileAccessed
109
- - FileCreated
110
- - FileModified
111
- - FileDeleted
112
- - FileRenamed
113
- - FileCopied
114
- - FileMoved
115
- - FolderCreated
116
- - FolderDeleted
117
- - FolderMoved
118
- - FolderModified
119
- :param result: What outcome did the action have, either "Success", "Failure", "Partial", "NA"
120
- :param file: Dictionary containing information on the target of this File event see
121
- FileActivityFile for more details.
122
- :param source_file: Dictionary containing information on the source of this File event,
123
- this MUST be used for a FileRenamed, FileMoved, FileCopied, FolderMoved
124
- operation. See FileActivityFile for more details.
125
- :param user: Dictionary containing information on the logged in users username.
126
- :param server: Dictionary containing information on the server servicing this File event
127
- see Server class for more details.
128
- :param client: Dictionary containing information on the client performing this File event
129
- see Client class for more details.
130
- :param severity: Optional severity of the event, defaults to "Informational", otherwise one of:
131
- - "Informational"
132
- - "Low"
133
- - "Medium"
134
- - "High"
135
- :param time_generated: Optional datetime for when the event happened, otherwise datetime.now
136
- :param result_details: Optional string describing any details associated with the events outcome.
137
- This field is typically populated when the result is a failure.
138
- :param message: Optional string describing the reason why the log was generated.
139
-
140
- See also: https://learn.microsoft.com/en-us/azure/sentinel/normalization-schema-file-event
141
- """
142
-
143
- _log_file_activity(
144
- request,
145
- event,
146
- result,
147
- file,
148
- source_file,
149
- user={} if user == None else user,
150
- server={} if server == None else server,
151
- client={} if client == None else client,
152
- event_created=time_generated or datetime.datetime.now(tz=datetime.timezone.utc),
153
- severity=severity,
154
- result_details=result_details,
155
- message=message,
156
- )
157
-
158
-
159
- def _log_file_activity(
160
- request: HttpRequest,
161
- event: FileActivityEvent,
162
- result: Result,
163
- file: FileActivityFile,
164
- source_file: Optional[FileActivityFile],
165
- user: FileActivityUser,
166
- server: Server,
167
- client: Client,
168
- event_created: datetime.datetime,
169
- severity: Optional[Severity] = None,
170
- result_details: Optional[str] = None,
171
- message: Optional[str] = None,
172
- ):
173
- log = {
174
- "EventSchema": "FileEvent",
175
- "EventSchemaVersion": "0.2.1",
176
- "EventType": event,
177
- "EventResult": result,
178
- "EventStartTime": event_created.isoformat(),
179
- "EventSeverity": severity or _default_severity(result),
180
- }
181
-
182
- log.update(_generate_file_attributes(file, "Target"))
183
- if source_file:
184
- log.update(_generate_file_attributes(source_file, "Src"))
185
-
186
- if "domain_name" in server:
187
- log["HttpHost"] = server["domain_name"]
188
- elif "HTTP_HOST" in request.META:
189
- log["HttpHost"] = request.get_host()
190
-
191
- if "service_name" in server:
192
- log["TargetAppName"] = server["service_name"]
193
- elif os.environ.get("COPILOT_APPLICATION_NAME") and os.environ.get("COPILOT_SERVICE_NAME"):
194
- app_name = f"{os.environ['COPILOT_APPLICATION_NAME']}-{os.environ['COPILOT_SERVICE_NAME']}"
195
- log["TargetAppName"] = app_name
196
-
197
- if container_id := _get_container_id():
198
- log["TargetContainerId"] = container_id
199
-
200
- if "ip_address" in client:
201
- log["SrcIpAddr"] = client["ip_address"]
202
- elif client_ip := _get_client_ip_address(request):
203
- log["SrcIpAddr"] = client_ip
204
-
205
- if "requested_url" in client:
206
- log["TargetUrl"] = client["requested_url"]
207
- elif "HTTP_HOST" in request.META:
208
- log["TargetUrl"] = request.scheme + "://" + request.get_host() + request.get_full_path()
209
-
210
- if "username" in user:
211
- log["TargetUsername"] = user["username"]
212
- elif hasattr(request, "user") and request.user.username:
213
- log["TargetUsername"] = request.user.username
214
-
215
- if result_details:
216
- log["EventResultDetails"] = result_details
217
-
218
- if message:
219
- log["EventMessage"] = message
220
-
221
- if "ip_address" in server:
222
- log["DvcIpAddr"] = server["ip_address"]
223
-
224
- print(json.dumps(log), flush=True)
225
-
226
-
227
- def _generate_file_attributes(file: FileActivityFile, prefix: str) -> dict:
228
- log = {prefix + "FilePath": file["path"]}
229
-
230
- if "name" in file:
231
- log[prefix + "FileName"] = file["name"]
232
- else:
233
- log[prefix + "FileName"] = os.path.basename(file["path"])
234
-
235
- if "extension" in file:
236
- log[prefix + "FileExtension"] = file["extension"]
237
- else:
238
- file_name_parts = list(filter(None, log[prefix + "FileName"].split(".", 1)))
239
- if len(file_name_parts) > 1:
240
- log[prefix + "FileExtension"] = file_name_parts[1]
241
-
242
- if "content_type" in file:
243
- log[prefix + "FileMimeType"] = file["content_type"]
244
-
245
- if "sha256" in file:
246
- log[prefix + "FileSHA256"] = file["sha256"]
247
-
248
- if "size" in file:
249
- log[prefix + "FileSize"] = file["size"]
250
-
251
- return log
252
-
253
-
254
- log_file_activity.Event = FileActivityEvent
255
- log_file_activity.Result = Result
256
- log_file_activity.Severity = Severity
72
+ class LogFileActivity(Activity):
73
+ Event = FileActivityEvent
74
+ Result = Result
75
+ Severity = Severity
76
+
77
+ def __call__(
78
+ self,
79
+ request: HttpRequest,
80
+ event: FileActivityEvent,
81
+ result: Result,
82
+ file: FileActivityFile,
83
+ source_file: Optional[FileActivityFile] = None,
84
+ user: Optional[LoggedInUser] = None,
85
+ server: Optional[Server] = None,
86
+ client: Optional[Client] = None,
87
+ severity: Optional[Severity] = None,
88
+ time_generated: Optional[datetime.datetime] = None,
89
+ result_details: Optional[str] = None,
90
+ message: Optional[str] = None,
91
+ ):
92
+ """
93
+ Log an ASIM File Event to standard output.
94
+
95
+ :param request: django.http.HttpRequest object which initiated this Authentication request
96
+ from which the following data will be logged if available
97
+ - Django Authentication systems current username
98
+ - Client IP address
99
+ - URL requested by the client
100
+ - Server domain name
101
+ :param event: What File Event action was attempted, one of:
102
+ - FileAccessed
103
+ - FileCreated
104
+ - FileModified
105
+ - FileDeleted
106
+ - FileRenamed
107
+ - FileCopied
108
+ - FileMoved
109
+ - FolderCreated
110
+ - FolderDeleted
111
+ - FolderMoved
112
+ - FolderModified
113
+ :param result: What outcome did the action have, either "Success", "Failure", "Partial", "NA"
114
+ :param file: Dictionary containing information on the target of this File event see
115
+ FileActivityFile for more details.
116
+ :param source_file: Dictionary containing information on the source of this File event,
117
+ this MUST be used for a FileRenamed, FileMoved, FileCopied, FolderMoved
118
+ operation. See FileActivityFile for more details.
119
+ :param user: Dictionary containing information on the logged in users username.
120
+ :param server: Dictionary containing information on the server servicing this File event
121
+ see Server class for more details.
122
+ :param client: Dictionary containing information on the client performing this File event
123
+ see Client class for more details.
124
+ :param severity: Optional severity of the event, defaults to "Informational", otherwise one of:
125
+ - "Informational"
126
+ - "Low"
127
+ - "Medium"
128
+ - "High"
129
+ :param time_generated: Optional datetime for when the event happened, otherwise datetime.now
130
+ :param result_details: Optional string describing any details associated with the events outcome.
131
+ This field is typically populated when the result is a failure.
132
+ :param message: Optional string describing the reason why the log was generated.
133
+
134
+ See also: https://learn.microsoft.com/en-us/azure/sentinel/normalization-schema-file-event
135
+ """
136
+
137
+ self._log_file_activity(
138
+ request,
139
+ event,
140
+ result,
141
+ file,
142
+ source_file,
143
+ user={} if user == None else user,
144
+ server={} if server == None else server,
145
+ client={} if client == None else client,
146
+ event_created=time_generated or datetime.datetime.now(tz=datetime.timezone.utc),
147
+ severity=severity,
148
+ result_details=result_details,
149
+ message=message,
150
+ )
151
+
152
+ def _log_file_activity(
153
+ self,
154
+ request: HttpRequest,
155
+ event: FileActivityEvent,
156
+ result: Result,
157
+ file: FileActivityFile,
158
+ source_file: Optional[FileActivityFile],
159
+ user: LoggedInUser,
160
+ server: Server,
161
+ client: Client,
162
+ event_created: datetime.datetime,
163
+ severity: Optional[Severity] = None,
164
+ result_details: Optional[str] = None,
165
+ message: Optional[str] = None,
166
+ ):
167
+ log = {
168
+ "EventSchema": "FileEvent",
169
+ "EventSchemaVersion": "0.2.1",
170
+ "EventType": event,
171
+ }
172
+
173
+ log.update(
174
+ self._activity_fields(
175
+ request, event_created, result, server, client, severity, result_details, message
176
+ )
177
+ )
178
+
179
+ log.update(self._generate_file_attributes(file, "Target"))
180
+ if source_file:
181
+ log.update(self._generate_file_attributes(source_file, "Src"))
182
+
183
+ if "username" in user:
184
+ log["TargetUsername"] = user["username"]
185
+ elif hasattr(request, "user") and request.user.username:
186
+ log["TargetUsername"] = request.user.username
187
+
188
+ print(json.dumps(log), flush=True)
189
+
190
+ def _generate_file_attributes(self, file: FileActivityFile, prefix: str) -> dict:
191
+ log = {prefix + "FilePath": file["path"]}
192
+
193
+ if "name" in file:
194
+ log[prefix + "FileName"] = file["name"]
195
+ else:
196
+ log[prefix + "FileName"] = os.path.basename(file["path"])
197
+
198
+ if "extension" in file:
199
+ log[prefix + "FileExtension"] = file["extension"]
200
+ else:
201
+ file_name_parts = list(filter(None, log[prefix + "FileName"].split(".", 1)))
202
+ if len(file_name_parts) > 1:
203
+ log[prefix + "FileExtension"] = file_name_parts[1]
204
+
205
+ if "content_type" in file:
206
+ log[prefix + "FileMimeType"] = file["content_type"]
207
+
208
+ if "sha256" in file:
209
+ log[prefix + "FileSHA256"] = file["sha256"]
210
+
211
+ if "size" in file:
212
+ log[prefix + "FileSize"] = file["size"]
213
+
214
+ return log
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: django-log-formatter-asim
3
- Version: 1.2.0a0
3
+ Version: 1.2.0a1
4
4
  Summary: Formats Django logs in ASIM format.
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Author: Department for Business and Trade Platform Team
7
8
  Author-email: sre-team@digital.trade.gov.uk
8
9
  Requires-Python: >=3.9,<4
@@ -13,7 +14,8 @@ Classifier: Programming Language :: Python :: 3.10
13
14
  Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: ddtrace (>=3.2.1,<4.0.0)
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: ddtrace (>=3.2.1,<5)
17
19
  Requires-Dist: django (>=3,<5) ; python_version == "3.9"
18
20
  Requires-Dist: django (>=3,<6) ; python_version >= "3.10" and python_version < "4"
19
21
  Requires-Dist: django-ipware (>=7.0.1,<8.0.0)
@@ -77,6 +79,101 @@ LOGGING = {
77
79
  In this example we assign the ASIM formatter to a `handler` and ensure both `root` and `django` loggers use this `handler`.
78
80
  We then set `propagate` to `False` on the `django` logger, to avoid duplicating logs at the root level.
79
81
 
82
+ ### Settings
83
+
84
+ `DLFA_LOG_PERSONALLY_IDENTIFIABLE_INFORMATION` - the formatter checks this setting to see if personally identifiable information should be logged. If this is not set to true, only the user's id is logged.
85
+
86
+ `DLFA_TRACE_HEADERS` - used for defining custom zipkin headers, the defaults is `("X-Amzn-Trace-Id")`, but for applications hosted in GOV.UK PaaS you should use `("X-B3-TraceId", "X-B3-SpanId")`. If you are running your application in both places side by side during migration, the following should work in your Django settings:
87
+
88
+ ```python
89
+ from dbt_copilot_python.utility import is_copilot
90
+
91
+ if is_copilot():
92
+ DLFA_TRACE_HEADERS = ("X-B3-TraceId", "X-B3-SpanId")
93
+ ```
94
+
95
+ `DLFA_INCLUDE_RAW_LOG` - By default the original unformatted log is not included in the ASIM formatted log. You can enable that by setting this to `True` and it will be included in `AddidtionalFields.RawLog`.
96
+
97
+ > [!WARNING]
98
+ > Setting `DLFA_INCLUDE_RAW_LOG` to `True` will cause additional private fields to be output to your logs.
99
+ > This could include secrets, such as AWS Access Keys, private HTTP Request data, or personally identifiable information.
100
+ > This setting is not recommended for a production environment.
101
+
102
+ ### Serialisation behaviour
103
+
104
+ The package provides one `logging.Formatter` class, `ASIMFormatter` which routes log messages to a serialiser
105
+ which generates a python dict which the formatter converts to a JSON string and prints to standard output.
106
+
107
+ It has a generic serialiser called `ASIMRootFormatter` and a custom serlializer for log messages where the
108
+ logger is `django.request`.
109
+
110
+ ``` python
111
+ ASIM_FORMATTERS = {
112
+ "root": ASIMRootFormatter,
113
+ "django.request": ASIMRequestFormatter,
114
+ }
115
+ ```
116
+
117
+ #### ASIMRootFormatter
118
+
119
+ This serialiser outputs the following ASIM fields.
120
+
121
+ - `EventSchema` = `ProcessEvent`
122
+ - `ActingAppType` = `Django`
123
+ - `AdditionalFields[DjangoLogFormatterAsimVersion]`
124
+ - `EventSchemaVersion`
125
+ - `EventMessage`
126
+ - `EventCount`
127
+ - `EventStartTime`
128
+ - `EventEndTime`
129
+ - `EventType`
130
+ - `EventResult`
131
+ - `EventSeverity`
132
+ - `EventOriginalSeverity`
133
+
134
+ Additionally, the following DataDog fields where available:
135
+
136
+ - `dd.trace_id`
137
+ - `dd.span_id`
138
+ - `env`
139
+ - `service`
140
+ - `version`
141
+
142
+
143
+ #### ASIMRequestFormatter
144
+
145
+ This serialiser outputs the following ASIM fields in addition to the ones from ASIMRootFormatter.
146
+ It is coupled to the datastructure provided by the `django.request` logger.
147
+ The `django.request` logger only outputs requests where the response code is 4xx/5xx.
148
+
149
+ - `SrcIpAddr` and `IpAddr`
150
+ - `SrcPortNumber`
151
+ - `SrcUserId` and `SrcUsername`
152
+ - `HttpUserAgent`
153
+ - `AdditionalFields["TraceHeaders"][trace_header_name]` - See `DLFA_TRACE_HEADERS` setting for more information.
154
+
155
+ #### Creating a custom serialiser
156
+
157
+ If you wish to create your own ASIM serialiser, you can inherit from `ASIMRootFormatter` and call
158
+ `super().get_log_dict()` to get the base level logging data for augmentation:
159
+
160
+ ``` python
161
+ class MyASIMFormatter(ASIMRootFormatter):
162
+ def get_log_dict(self):
163
+ log_dict = super().get_log_dict()
164
+
165
+ # Customise logger event
166
+
167
+ return log_dict
168
+ ```
169
+
170
+ This serialiser can then be added to `ASIM_FORMATTERS`...
171
+
172
+ ```python
173
+ ASIM_FORMATTERS["my_logger"] = MyASIMFormatter
174
+ ```
175
+
176
+
80
177
  ### ASIM Events
81
178
 
82
179
  The events mostly follow the Microsoft schema but have been tailored to Department of Business and Trade needs.
@@ -127,48 +224,114 @@ log_authentication(
127
224
  }
128
225
  ```
129
226
 
130
- ### Settings
227
+ #### File Activity event
131
228
 
132
- `DLFA_LOG_PERSONALLY_IDENTIFIABLE_INFORMATION` - the formatter checks this setting to see if personally identifiable information should be logged. If this is not set to true, only the user's id is logged.
229
+ Following the [ASIM File Event Schema](https://learn.microsoft.com/en-us/azure/sentinel/normalization-schema-file-event).
133
230
 
134
- `DLFA_TRACE_HEADERS` - used for defining custom zipkin headers, the defaults is `("X-Amzn-Trace-Id")`, but for applications hosted in GOV.UK PaaS you should use `("X-B3-TraceId", "X-B3-SpanId")`. If you are running your application in both places side by side during migration, the following should work in your Django settings:
231
+ ```python
232
+ # Example usage
233
+ from django_log_formatter_asim.events import log_file_activity
135
234
 
136
- `DLFA_INCLUDE_RAW_LOG` - By default the original unformatted log is not included in the ASIM formatted log. You can enable that by setting this to `True` and it will be included in `AddidtionalFields.RawLog`.
235
+ log_file_activity(
236
+ request,
237
+ event=log_file_activity.Event.FileCopied,
238
+ result=log_file_activity.Result.Success,
239
+ file={
240
+ "path": "/tmp/copied.txt",
241
+ "content_type": "text/plain",
242
+ "extension": "txt",
243
+ "name": "copied.txt",
244
+ "sha256": "6798b7a132f37a0474002dec538ec52bdcd5f7b76e49e52c8a3d2016ca8d1d18",
245
+ "size": 14,
246
+ },
247
+ # source_file is only necessary if the event is one of FileRenamed, FileMoved, FileCopied, FolderMoved
248
+ source_file={
249
+ "path": "/tmp/original.txt",
250
+ "content_type": "text/plain",
251
+ "extension": "txt",
252
+ "name": "original.txt",
253
+ "sha256": "6798b7a132f37a0474002dec538ec52bdcd5f7b76e49e52c8a3d2016ca8d1d18",
254
+ "size": 14,
255
+ },
256
+ )
137
257
 
138
- ```python
139
- from dbt_copilot_python.utility import is_copilot
258
+ # Example JSON printed to standard output
259
+ {
260
+ # Values provided as arguments
261
+ "EventType": "FileCopied",
262
+ "EventResult": "Success",
140
263
 
141
- if is_copilot():
142
- DLFA_TRACE_HEADERS = ("X-B3-TraceId", "X-B3-SpanId")
143
- ```
264
+ "TargetFilePath": "/tmp/copied.txt",
265
+ "TargetFileName": "copied.txt",
266
+ "TargetFileExtension": "txt",
267
+ "TargetFileMimeType": "text/plain",
268
+ "TargetFileSHA256": "6798b7a132f37a0474002dec538ec52bdcd5f7b76e49e52c8a3d2016ca8d1d18",
269
+ "TargetFileSize": 14,
144
270
 
145
- ### Formatter classes
271
+ "SrcFilePath": "/tmp/original.txt",
272
+ "SrcFileName": "original.txt",
273
+ "SrcFileExtension": "txt",
274
+ "SrcFileMimeType": "text/plain",
275
+ "SrcFileSHA256": "6798b7a132f37a0474002dec538ec52bdcd5f7b76e49e52c8a3d2016ca8d1d18",
276
+ "SrcFileSize": 14,
146
277
 
147
- ``` python
148
- ASIM_FORMATTERS = {
149
- "root": ASIMSystemFormatter,
150
- "django.request": ASIMRequestFormatter,
151
- }
152
- ```
278
+ # Calculated / Hard coded fields
279
+ "EventStartTime": "2025-07-30T11:05:09.406460+00:00",
280
+ "EventSchema": "FileEvent",
281
+ "EventSchemaVersion": "0.2.1",
282
+ "EventSeverity": "Informational",
153
283
 
154
- The default class for other loggers is:
284
+ # Taken from Django HttpRequest object
285
+ "HttpHost": "WebServer.local",
286
+ "SrcIpAddr": "192.168.1.101",
287
+ "TargetUrl": "https://WebServer.local/steel",
288
+ "TargetUsername": "Adrian"
155
289
 
156
- ``` python
157
- ASIMSystemFormatter
290
+ # Taken from DBT Platform environment variables
291
+ "TargetAppName": "export-analytics-frontend",
292
+ }
158
293
  ```
159
294
 
160
- ### Creating a custom `logging.Formatter`
295
+ #### Account Management event
161
296
 
162
- If you wish to create your own ASIM formatter, you can inherit from ASIMSystemFormatter and call _get_event_base to get the base level logging data for use in augmentation:
297
+ Following the [ASIM User Management Schema](https://learn.microsoft.com/en-us/azure/sentinel/normalization-schema-user-management).
163
298
 
164
- ``` python
165
- class ASIMSystemFormatter(ASIMFormatterBase):
166
- def get_event(self):
167
- logger_event = self._get_event_base()
168
299
 
169
- # Customise logger event
300
+ ```python
301
+ # Example usage
302
+ from django_log_formatter_asim.events import log_account_management
303
+
304
+ log_account_management(
305
+ request,
306
+ event=log_account_management.Event.UserCreated,
307
+ result=log_account_management.Result.Success,
308
+ account={
309
+ "username": "Roger",
310
+ },
311
+ )
312
+
313
+ # Example JSON printed to standard output
314
+ {
315
+ # Values provided as arguments
316
+ "EventType": "UserCreated",
317
+ "EventResult": "Success",
318
+ "TargetUsername": "Roger",
170
319
 
171
- return logger_event
320
+ # Calculated / Hard coded fields
321
+ "EventStartTime": "2025-07-30T11:05:09.406460+00:00",
322
+ "EventSchema": "UserManagement",
323
+ "EventSchemaVersion": "0.1.1",
324
+ "EventSeverity": "Informational",
325
+
326
+ # Taken from Django HttpRequest object
327
+ "HttpHost": "WebServer.local",
328
+ "SrcIpAddr": "192.168.1.101",
329
+ "TargetUrl": "https://WebServer.local/admin/create-user",
330
+ "ActorUsername": "Adrian"
331
+
332
+ # Taken from DBT Platform environment variables
333
+ "TargetAppName": "export-analytics-frontend",
334
+ }
172
335
  ```
173
336
 
174
337
  ## Dependencies
@@ -233,3 +396,4 @@ poetry publish
233
396
  Check the [PyPI Release history](https://pypi.org/project/django-log-formatter-asim/#history) to make sure the package has been updated.
234
397
 
235
398
  For an optional manual check, install the package locally and test everything works as expected.
399
+
@@ -0,0 +1,11 @@
1
+ django_log_formatter_asim/__init__.py,sha256=ftbSWdejSnWK_MpT3RSfTgcs3E-661FM-e3ugnoiBqA,7133
2
+ django_log_formatter_asim/ecs.py,sha256=SSg3A5pfS5E-Hm7AXQnN1RrtXclgq07oSBxPCRn5gDg,536
3
+ django_log_formatter_asim/events/__init__.py,sha256=K78BzhkNJpI2AdpaiMmf9FrBQTurEdBFX1Xx2hy-TGg,270
4
+ django_log_formatter_asim/events/account_management.py,sha256=k6uPs9XcchCW2G3lcbdeXviSVWjJ4pK52mrYekwSLVQ,3936
5
+ django_log_formatter_asim/events/authentication.py,sha256=8Kn5_8WF-DunmgpqWPVhaNh3k1qYVCwLbQ2I7WPmHj8,6966
6
+ django_log_formatter_asim/events/common.py,sha256=CWC6Bo3R7yOY7NElJb1jxcD1el72Hl4CBZhFYgT0tIs,3874
7
+ django_log_formatter_asim/events/file_activity.py,sha256=hTscMhGyaYVsLzqMcw-aQB5ks4G2VpB9Pkw63LwxqaI,7793
8
+ django_log_formatter_asim-1.2.0a1.dist-info/METADATA,sha256=f-pWjsHzFENa5TdBbEsWPTvr5rcbKsb15arW6n-my8k,12478
9
+ django_log_formatter_asim-1.2.0a1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
10
+ django_log_formatter_asim-1.2.0a1.dist-info/licenses/LICENSE,sha256=dP79lN73--7LMApnankTGLqDbImXg8iYFqWgnExGkGk,1090
11
+ django_log_formatter_asim-1.2.0a1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,10 +0,0 @@
1
- django_log_formatter_asim/__init__.py,sha256=ftbSWdejSnWK_MpT3RSfTgcs3E-661FM-e3ugnoiBqA,7133
2
- django_log_formatter_asim/ecs.py,sha256=SSg3A5pfS5E-Hm7AXQnN1RrtXclgq07oSBxPCRn5gDg,536
3
- django_log_formatter_asim/events/__init__.py,sha256=th8AEFNM-J5lNlO-d8Lk465jXqplE3IoTwj4DlscwYo,92
4
- django_log_formatter_asim/events/authentication.py,sha256=WUHSllqTKTLnEdRlLn2SqCn3YR1_AKPA3y8Gm22CyIY,7808
5
- django_log_formatter_asim/events/common.py,sha256=-QjR9QPkMDqD3nXPiukAJvm8qjUt2LwzhL_zN5ae_t0,1702
6
- django_log_formatter_asim/events/file_activity.py,sha256=h8xlY0ABDCejAVnRMJ0aeWdNJZpu_54et1squMEfSM8,8755
7
- django_log_formatter_asim-1.2.0a0.dist-info/LICENSE,sha256=dP79lN73--7LMApnankTGLqDbImXg8iYFqWgnExGkGk,1090
8
- django_log_formatter_asim-1.2.0a0.dist-info/METADATA,sha256=ofAaVdlWggllH93apSQg16S0ZHaPmF1xd27APovOirc,7428
9
- django_log_formatter_asim-1.2.0a0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
10
- django_log_formatter_asim-1.2.0a0.dist-info/RECORD,,