Skip to content

Commit 1680072

Browse files
authored
Merge pull request #38 from mbengit/master
Refactor log handling
2 parents e5a61b5 + 4188f1b commit 1680072

File tree

6 files changed

+372
-212
lines changed

6 files changed

+372
-212
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ The qpylib library hosted here is for use only with apps that have been written
1414
Python 3 to run on Red Hat Universal Base Image. It is not compatible with apps
1515
written to run on a CentOS base image.
1616

17+
Most functions are the equivalent of their pre-Github counterparts, although
18+
there may be some small differences in parameter names.
19+
1720
## Project details
1821

1922
* [LICENSE](LICENSE)

qpylib/encryption/cryptoutil.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2019 IBM Corporation All Rights Reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from cryptography.hazmat.backends import default_backend
6+
from cryptography.hazmat.primitives import hashes
7+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
8+
9+
def derive_key(key_material, salt, iterations=100000, length=32):
10+
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(),
11+
length=length,
12+
salt=salt,
13+
iterations=iterations,
14+
backend=default_backend())
15+
return kdf.derive(key_material)

qpylib/encryption/enginev4.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
import string
77
import secrets
88
from cryptography.fernet import Fernet
9-
from cryptography.hazmat.backends import default_backend
10-
from cryptography.hazmat.primitives import hashes
11-
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
9+
from . import cryptoutil
1210

1311
class Enginev4():
1412
''' Enginev4 uses Fernet encryption.
@@ -30,14 +28,9 @@ def decrypt(self):
3028
return decrypted_bytes.decode('utf-8')
3129

3230
def _derive_key(self):
33-
kdf = PBKDF2HMAC(
34-
algorithm=hashes.SHA256(),
35-
length=32,
36-
salt=self.config['salt'].encode('utf-8'),
37-
iterations=self.config['iterations'],
38-
backend=default_backend()
39-
)
40-
key = kdf.derive(self.app_uuid.encode('utf-8'))
31+
key = cryptoutil.derive_key(self.app_uuid.encode('utf-8'),
32+
self.config['salt'].encode('utf-8'),
33+
self.config['iterations'])
4134
return base64.urlsafe_b64encode(key)
4235

4336
@staticmethod

qpylib/log_qpylib.py

Lines changed: 140 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,74 +4,160 @@
44

55
import logging
66
from logging.handlers import RotatingFileHandler, SysLogHandler
7-
from . import app_qpylib
8-
from . import util_qpylib
7+
import re
8+
from qpylib.encryption.cryptoutil import derive_key
9+
from . import app_qpylib, util_qpylib
910

10-
APP_FILE_LOG_FORMAT = '%(asctime)s [%(module)s.%(funcName)s] [%(threadName)s] [%(levelname)s] - %(message)s'
11-
APP_CONSOLE_LOG_FORMAT = '%(asctime)s %(module)s.%(funcName)s: %(message)s'
11+
# Log format for local file logging.
12+
# Uses default date/time formatting from logging module.
13+
# Before the logging formatter is created, APPID is substituted with the actual app ID,
14+
# which is constant for all logs generated within an app container.
15+
# Example: 2020-08-19 12:48:11,423 [Thread-4] [INFO] [APP_ID:1005] [NOT:0000006000] hello
1216

13-
QLOGGER = 0
17+
APP_FILE_LOG_FORMAT = '%(asctime)s [%(threadName)s] [%(levelname)s] [APP_ID:APPID] [NOT:%(ncode)s] %(message)s'
1418

15-
def log(message, level):
16-
log_fn = _choose_log_fn(level)
17-
log_fn("[APP_ID/{0}][NOT:{1}] {2}"
18-
.format(app_qpylib.get_app_id(), _map_notification_code(level), message))
19+
# Log format for Syslog.
20+
# See https://tools.ietf.org/html/rfc5424 for more details on the following specification:
21+
# SYSLOG-MSG = HEADER SP STRUCTURED-DATA SP MSG
22+
# HEADER = PRI VERSION SP TIMESTAMP SP HOSTNAME SP APPNAME SP PROCID SP MSGID
23+
# PRI is taken care of by SysLogHandler and not included in the log format below.
24+
# VERSION is hard-coded to 1.
25+
# TIMESTAMP in the specification is e.g. 2020-04-12T19:20:50.345678+01:00
26+
# Seconds fraction is not supported in "time" module, which is what the logging
27+
# module uses. SYSLOG_LOG_FORMAT uses asctime formatted with SYSLOG_TIME_FORMAT.
28+
# HOSTNAME is set to a hash generated using the app ID and QRADAR_APP_UUID.
29+
# The reason for not using the container host name or IP address is that for a
30+
# given app instance, both of those values can change across a reboot.
31+
# APPNAME is set to a sanitised copy of the app manifest name field.
32+
# PROCID is set to the app ID.
33+
# HOSTNAME, APPNAME and PROCID are all constants, replaced in the format string with their values.
34+
# MSGID and STRUCTURED-DATA are set to the Syslog nil value '-'.
35+
# Example: <14>1 2020-08-21T14:16:51+0100 d15fbd9fdd86b30e MyExampleApp 1005 - - [NOT:0000006000] hello
36+
37+
SYSLOG_LOG_FORMAT = '1 %(asctime)s HOSTNAME APPNAME PROCID - - [NOT:%(ncode)s] %(message)s'
38+
SYSLOG_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S%z'
39+
40+
# Globals related to logging.Logger instance.
41+
QLOGGER = None
42+
LOG_LEVEL_TO_FUNCTION = None
1943

2044
def create_log():
2145
global QLOGGER
2246
QLOGGER = logging.getLogger('com.ibm.applicationLogger')
23-
QLOGGER.setLevel(default_log_level())
47+
QLOGGER.setLevel(_default_log_level())
48+
QLOGGER.addFilter(NotificationCodeFilter())
2449

25-
handler = RotatingFileHandler(_log_file_location(), maxBytes=2*1024*1024, backupCount=5)
26-
handler.setFormatter(logging.Formatter(APP_FILE_LOG_FORMAT))
27-
QLOGGER.addHandler(handler)
50+
for handler in _generate_handlers():
51+
QLOGGER.addHandler(handler)
2852

29-
if not util_qpylib.is_sdk():
30-
console_ip = app_qpylib.get_console_ip()
31-
if util_qpylib.is_ipv6_address(console_ip):
32-
console_ip = console_ip[1:-1]
33-
syslog_handler = SysLogHandler(address=(console_ip, 514))
34-
syslog_handler.setFormatter(logging.Formatter(APP_CONSOLE_LOG_FORMAT))
35-
QLOGGER.addHandler(syslog_handler)
53+
global LOG_LEVEL_TO_FUNCTION
54+
LOG_LEVEL_TO_FUNCTION = {
55+
'DEBUG': QLOGGER.debug,
56+
'INFO': QLOGGER.info,
57+
'WARNING': QLOGGER.warning,
58+
'ERROR': QLOGGER.error,
59+
'EXCEPTION': QLOGGER.exception,
60+
'CRITICAL': QLOGGER.critical
61+
}
62+
63+
def log(message, level):
64+
global LOG_LEVEL_TO_FUNCTION
65+
if not LOG_LEVEL_TO_FUNCTION:
66+
raise RuntimeError('You cannot use log before logging has been initialised')
67+
log_function = LOG_LEVEL_TO_FUNCTION.get(level.upper())
68+
if not log_function:
69+
raise ValueError("Unknown level: '{0}'".format(level))
70+
log_function(message)
3671

3772
def set_log_level(level='INFO'):
3873
global QLOGGER
39-
QLOGGER.setLevel(_map_log_level(level))
74+
if not QLOGGER:
75+
raise RuntimeError('You cannot use set_log_level before logging has been initialised')
76+
QLOGGER.setLevel(level.upper())
4077

41-
def default_log_level():
42-
return _map_log_level(app_qpylib.get_manifest_field_value('log_level', 'INFO'))
78+
def _default_log_level():
79+
return app_qpylib.get_manifest_field_value('log_level', 'INFO').upper()
4380

4481
def _log_file_location():
4582
return app_qpylib.get_log_path('app.log')
4683

47-
def _choose_log_fn(level):
48-
global QLOGGER
49-
if QLOGGER == 0:
50-
raise RuntimeError('You cannot use log before logging has been initialised')
51-
return {
52-
'INFO': QLOGGER.info,
53-
'DEBUG': QLOGGER.debug,
54-
'WARNING': QLOGGER.warning,
55-
'ERROR': QLOGGER.error,
56-
'EXCEPTION': QLOGGER.exception,
57-
'CRITICAL': QLOGGER.critical
58-
}.get(level.upper(), QLOGGER.info)
59-
60-
def _map_notification_code(level):
61-
return {
62-
'INFO': "0000006000",
63-
'DEBUG': "0000006000",
64-
'WARNING': "0000004000",
65-
'ERROR': "0000003000",
66-
'EXCEPTION': "0000003000",
67-
'CRITICAL': "0000003000"
68-
}.get(level.upper(), "0000006000")
69-
70-
def _map_log_level(level):
71-
return {
72-
'INFO': logging.INFO,
73-
'DEBUG': logging.DEBUG,
74-
'WARNING': logging.WARNING,
75-
'ERROR': logging.ERROR,
76-
'CRITICAL': logging.CRITICAL
77-
}.get(level.upper(), logging.INFO)
84+
def _generate_handlers():
85+
handlers = []
86+
87+
app_id = str(app_qpylib.get_app_id())
88+
handlers.append(_create_file_handler(app_id))
89+
90+
address = None
91+
qradar_app_uuid = None
92+
try:
93+
address = _get_address_for_syslog()
94+
qradar_app_uuid = app_qpylib.get_env_var('QRADAR_APP_UUID')
95+
except KeyError:
96+
pass
97+
if address and qradar_app_uuid:
98+
handlers.append(_create_syslog_handler(address, app_id, qradar_app_uuid))
99+
100+
return handlers
101+
102+
def _create_file_handler(app_id):
103+
handler = RotatingFileHandler(_log_file_location(), maxBytes=2*1024*1024, backupCount=5)
104+
log_format = APP_FILE_LOG_FORMAT.replace('APPID', app_id)
105+
handler.setFormatter(logging.Formatter(log_format))
106+
return handler
107+
108+
def _create_syslog_handler(syslog_address, app_id, qradar_app_uuid):
109+
log_format = _create_syslog_log_format(app_id, qradar_app_uuid)
110+
handler = SysLogHandler(address=syslog_address)
111+
handler.setFormatter(logging.Formatter(log_format, SYSLOG_TIME_FORMAT))
112+
return handler
113+
114+
def _get_address_for_syslog():
115+
console_ip = app_qpylib.get_console_ip()
116+
if util_qpylib.is_ipv6_address(console_ip):
117+
console_ip = console_ip[1:-1]
118+
return (console_ip, 514)
119+
120+
def _create_syslog_log_format(app_id, qradar_app_uuid):
121+
pseudo_hostname = _create_pseudo_hostname(app_id, qradar_app_uuid)
122+
sanitized_app_name = _create_sanitized_app_name()
123+
return SYSLOG_LOG_FORMAT.replace('HOSTNAME', pseudo_hostname) \
124+
.replace('APPNAME', sanitized_app_name) \
125+
.replace('PROCID', app_id)
126+
127+
def _create_pseudo_hostname(app_id, qradar_app_uuid):
128+
# This uses a key derivation function rather than a straightforward hash
129+
# function so that the unique value generated has 16 characters (hex).
130+
# Anything longer would occupy too much space in each log record.
131+
key = derive_key(app_id.encode('utf-8'),
132+
qradar_app_uuid.encode('utf-8'),
133+
length=8)
134+
return key.hex()
135+
136+
def _create_sanitized_app_name():
137+
''' Extracts app name from manifest, strips unwanted characters,
138+
and truncates to max length 48, as per RFC5424.
139+
'''
140+
return re.sub(r'\W+', '', app_qpylib.get_app_name())[:48]
141+
142+
class NotificationCodeFilter(logging.Filter):
143+
''' Filter which adds a field named ncode to each log record.
144+
Allows notification code to be specified in log handler
145+
format strings.
146+
'''
147+
# These are standard QRadar codes for identifying log levels.
148+
Q_INFO_CODE = '0000006000'
149+
Q_WARNING_CODE = '0000004000'
150+
Q_ERROR_CODE = '0000003000'
151+
152+
def filter(self, record):
153+
record.ncode = self._log_level_to_notification_code.get(
154+
record.levelname.upper(), self.Q_INFO_CODE)
155+
return True
156+
157+
_log_level_to_notification_code = {
158+
'DEBUG': Q_INFO_CODE,
159+
'INFO': Q_INFO_CODE,
160+
'WARNING': Q_WARNING_CODE,
161+
'ERROR': Q_ERROR_CODE,
162+
'CRITICAL': Q_ERROR_CODE
163+
}

qpylib/qpylib.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,34 @@
1212

1313
# ==== Logging ====
1414

15+
def create_log():
16+
''' Initialises logging.
17+
Threshold log level is set to the value of the "log_level" field
18+
in the app manifest.json, or INFO if that field is absent.
19+
Creates a file log handler which directs logs to store/log/app.log.
20+
Creates a Syslog handler, but only if environment variables
21+
QRADAR_CONSOLE_IP and QRADAR_APP_UUID are both set.
22+
Must be called before any call to log() or set_log_level().
23+
Raises ValueError if the manifest threshold log level is invalid.
24+
'''
25+
log_qpylib.create_log()
26+
1527
def log(message, level='INFO'):
1628
''' Logs a message at the given level, which defaults to INFO.
17-
Level values: DEBUG, INFO, WARNING, ERROR, CRITICAL.
29+
Level values: DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL.
30+
EXCEPTION is ERROR plus extra exception details.
1831
Raises RuntimeError if logging was not previously initialised
1932
by a call to qpylib.create_log().
33+
Raises ValueError if level is invalid.
2034
'''
2135
log_qpylib.log(message, level)
2236

23-
def create_log():
24-
''' Initialises logging with INFO as the threshold log level.
25-
Must be called before any call to qpylib.log().
26-
'''
27-
log_qpylib.create_log()
28-
2937
def set_log_level(level):
3038
''' Sets the threshold log level.
3139
Level values: DEBUG, INFO, WARNING, ERROR, CRITICAL.
40+
Raises RuntimeError if logging was not previously initialised
41+
by a call to qpylib.create_log().
42+
Raises ValueError if level is invalid.
3243
'''
3344
log_qpylib.set_log_level(level)
3445

0 commit comments

Comments
 (0)