1--
2-- json.lua
3--
4-- Copyright (c) 2020 rxi
5--
6-- Permission is hereby granted, free of charge, to any person obtaining a copy of
7-- this software and associated documentation files (the "Software"), to deal in
8-- the Software without restriction, including without limitation the rights to
9-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10-- of the Software, and to permit persons to whom the Software is furnished to do
11-- so, subject to the following conditions:
12--
13-- The above copyright notice and this permission notice shall be included in all
14-- copies or substantial portions of the Software.
15--
16-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22-- SOFTWARE.
23--
24
25local json = { _version = "0.1.2" }
26
27-------------------------------------------------------------------------------
28-- Decode
29-------------------------------------------------------------------------------
30
31local parse
32
33local function create_set(...)
34 local res = {}
35 for i = 1, select("#", ...) do
36 res[ select(i, ...) ] = true
37 end
38 return res
39end
40
41local space_chars = create_set(" ", "\t", "\r", "\n")
42local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
43local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
44local literals = create_set("true", "false", "null")
45
46local literal_map = {
47 [ "true" ] = true,
48 [ "false" ] = false,
49 [ "null" ] = nil,
50}
51
52
53local function next_char(str, idx, set, negate)
54 for i = idx, #str do
55 if set[str:sub(i, i)] ~= negate then
56 return i
57 end
58 end
59 return #str + 1
60end
61
62
63local function decode_error(str, idx, msg)
64 local line_count = 1
65 local col_count = 1
66 for i = 1, idx - 1 do
67 col_count = col_count + 1
68 if str:sub(i, i) == "\n" then
69 line_count = line_count + 1
70 col_count = 1
71 end
72 end
73 error( string.format("%s at line %d col %d", msg, line_count, col_count) )
74end
75
76
77local function codepoint_to_utf8(n)
78 -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
79 local f = math.floor
80 if n <= 0x7f then
81 return string.char(n)
82 elseif n <= 0x7ff then
83 return string.char(f(n / 64) + 192, n % 64 + 128)
84 elseif n <= 0xffff then
85 return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
86 elseif n <= 0x10ffff then
87 return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
88 f(n % 4096 / 64) + 128, n % 64 + 128)
89 end
90 error( string.format("invalid unicode codepoint '%x'", n) )
91end
92
93
94local function parse_unicode_escape(s)
95 local n1 = tonumber( s:sub(1, 4), 16 )
96 local n2 = tonumber( s:sub(7, 10), 16 )
97 -- Surrogate pair?
98 if n2 then
99 return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
100 else
101 return codepoint_to_utf8(n1)
102 end
103end
104
105
106local function parse_string(str, i)
107 local res = ""
108 local j = i + 1
109 local k = j
110
111 while j <= #str do
112 local x = str:byte(j)
113
114 if x < 32 then
115 decode_error(str, j, "control character in string")
116
117 elseif x == 92 then -- `\`: Escape
118 res = res .. str:sub(k, j - 1)
119 j = j + 1
120 local c = str:sub(j, j)
121 if c == "u" then
122 local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
123 or str:match("^%x%x%x%x", j + 1)
124 or decode_error(str, j - 1, "invalid unicode escape in string")
125 res = res .. parse_unicode_escape(hex)
126 j = j + #hex
127 else
128 if not escape_chars[c] then
129 decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
130 end
131 res = res .. escape_char_map_inv[c]
132 end
133 k = j + 1
134
135 elseif x == 34 then -- `"`: End of string
136 res = res .. str:sub(k, j - 1)
137 return res, j + 1
138 end
139
140 j = j + 1
141 end
142
143 decode_error(str, i, "expected closing quote for string")
144end
145
146
147local function parse_number(str, i)
148 local x = next_char(str, i, delim_chars)
149 local s = str:sub(i, x - 1)
150 local n = tonumber(s)
151 if not n then
152 decode_error(str, i, "invalid number '" .. s .. "'")
153 end
154 return n, x
155end
156
157
158local function parse_literal(str, i)
159 local x = next_char(str, i, delim_chars)
160 local word = str:sub(i, x - 1)
161 if not literals[word] then
162 decode_error(str, i, "invalid literal '" .. word .. "'")
163 end
164 return literal_map[word], x
165end
166
167
168local function parse_array(str, i)
169 local res = {}
170 local n = 1
171 i = i + 1
172 while 1 do
173 local x
174 i = next_char(str, i, space_chars, true)
175 -- Empty / end of array?
176 if str:sub(i, i) == "]" then
177 i = i + 1
178 break
179 end
180 -- Read token
181 x, i = parse(str, i)
182 res[n] = x
183 n = n + 1
184 -- Next token
185 i = next_char(str, i, space_chars, true)
186 local chr = str:sub(i, i)
187 i = i + 1
188 if chr == "]" then break end
189 if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
190 end
191 return res, i
192end
193
194
195local function parse_object(str, i)
196 local res = {}
197 i = i + 1
198 while 1 do
199 local key, val
200 i = next_char(str, i, space_chars, true)
201 -- Empty / end of object?
202 if str:sub(i, i) == "}" then
203 i = i + 1
204 break
205 end
206 -- Read key
207 if str:sub(i, i) ~= '"' then
208 decode_error(str, i, "expected string for key")
209 end
210 key, i = parse(str, i)
211 -- Read ':' delimiter
212 i = next_char(str, i, space_chars, true)
213 if str:sub(i, i) ~= ":" then
214 decode_error(str, i, "expected ':' after key")
215 end
216 i = next_char(str, i + 1, space_chars, true)
217 -- Read value
218 val, i = parse(str, i)
219 -- Set
220 res[key] = val
221 -- Next token
222 i = next_char(str, i, space_chars, true)
223 local chr = str:sub(i, i)
224 i = i + 1
225 if chr == "}" then break end
226 if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
227 end
228 return res, i
229end
230
231
232local char_func_map = {
233 [ '"' ] = parse_string,
234 [ "0" ] = parse_number,
235 [ "1" ] = parse_number,
236 [ "2" ] = parse_number,
237 [ "3" ] = parse_number,
238 [ "4" ] = parse_number,
239 [ "5" ] = parse_number,
240 [ "6" ] = parse_number,
241 [ "7" ] = parse_number,
242 [ "8" ] = parse_number,
243 [ "9" ] = parse_number,
244 [ "-" ] = parse_number,
245 [ "t" ] = parse_literal,
246 [ "f" ] = parse_literal,
247 [ "n" ] = parse_literal,
248 [ "[" ] = parse_array,
249 [ "{" ] = parse_object,
250}
251
252
253parse = function(str, idx)
254 local chr = str:sub(idx, idx)
255 local f = char_func_map[chr]
256 if f then
257 return f(str, idx)
258 end
259 decode_error(str, idx, "unexpected character '" .. chr .. "'")
260end
261
262
263function decode(str)
264 if type(str) ~= "string" then
265 error("expected argument of type string, got " .. type(str))
266 end
267 local res, idx = parse(str, next_char(str, 1, space_chars, true))
268 idx = next_char(str, idx, space_chars, true)
269 if idx <= #str then
270 decode_error(str, idx, "trailing garbage")
271 end
272 return res
273end
274
275-------------------------------------------------------------------------------
276-- SIEM.LUA SCRIPT STARTS BELOW
277-------------------------------------------------------------------------------
278
279local function parse_configmap(configmap_file)
280 local siem_configs = {}
281
282 -- function to see if file exists
283 local function file_exists(name)
284 local f = io.open(name, "r")
285 return f ~= nil and io.close(f)
286 end
287 if file_exists(configmap_file) then
288 local file = io.open(configmap_file, "r")
289 if file then
290 local content = file:read("*a")
291 if content ~= '' then
292 siem_configs = decode(content)
293 end
294 file:close()
295 end
296 else
297 print("File does not exist: " .. configmap_file)
298 end
299
300 return siem_configs
301end
302
303local severity_hierarchy = {
304 ["debug"] = 1,
305 ["info"] = 2,
306 ["notice"] = 3,
307 ["warn"] = 4,
308 ["warning"] = 4,
309 ["error"] = 5,
310 ["crit"] = 6,
311 ["critical"] = 6,
312 ["alert"] = 7,
313 ["emergency"] = 8,
314}
315
316local function severity_hierarchy_finder(severity_allowed, min_level)
317 for severity, level in pairs(severity_hierarchy) do
318 if level >= min_level then
319 severity_allowed[severity] = true
320 end
321 end
322 return severity_allowed
323end
324
325local function siem_helper(entry, record)
326 record["log_type"] = entry.log_type
327 record["log_class"] = entry.log_class
328 return true, record
329end
330
331
332local function pattern_match_helper(entry, record)
333 local function match_and_update(field)
334 local matched_pattern = string.match(record[field], entry.pattern)
335 if matched_pattern ~= nil then
336 return siem_helper(entry, record)
337 end
338 return false, record
339 end
340
341 if entry.pattern == "" then
342 return siem_helper(entry, record)
343 end
344
345 if record["message"] then
346 return match_and_update("message")
347 elseif record["msg"] then
348 return match_and_update("msg")
349 end
350
351 return false, record
352end
353
354local function add_siem_record(tag, record, siem_configs, config_type)
355 local tag_pattern = "^(k8s_container%.)([%a%-%d]+)_([%a%-%d]+)_([%a%-%d]+)"
356 level = string.lower(record["severity"])
357 local _, namespace_name, pod_name, container_name = tag:match(tag_pattern)
358
359 local record_updated = false
360 -- loop through each configuration
361 for index , entry in ipairs(siem_configs) do
362 repeat_breaker = false
363 repeat
364 -- create the severity_allowed hashmap
365 local severity_allowed = {}
366 local severity_allowed = severity_hierarchy_finder(severity_allowed, severity_hierarchy[string.lower(entry.severity)])
367 if not severity_allowed[level] then
368 break
369 end
370 -- Checks to see if the entry and tag data match
371 local pod_pattern_match = entry.pod:gsub("-", "%%-")
372 local container_pattern_match = entry.container:gsub("-", "%%-")
373 local matched_pod = string.match(pod_name, pod_pattern_match)
374 local matched_container = string.match(container_name, container_pattern_match)
375
376 -- check for static config
377 if config_type == "edge" then
378 local namespace_pattern_match = entry.namespace:gsub("-", "%%-")
379 local matched_namespace = string.match(namespace_name, namespace_pattern_match)
380
381 if not (matched_namespace and matched_container and matched_pod) then
382 break
383 else
384 -- To this point we are 100% certain that the record was updated with new values
385 -- record_updated should be true and we need to return an updated record back
386 record_updated, record = pattern_match_helper(entry, record)
387 return record_updated, record
388 end
389 end
390
391 -- check for custom config
392 if config_type == "workload" then
393 local workload_label
394
395 workload_label = record["kubernetes"]["namespace"]["labels"]["siem.edge.ncr.com/helm-edge-id"]
396
397 if entry.helm_edge_ID and workload_label and entry.pattern then
398 if not (entry.helm_edge_ID == workload_label and matched_container and matched_pod and severity_allowed[level]) then
399 break
400
401 else
402 -- To this point we are 100% certain that the record was updated with new values
403 -- record_updated should be true and we need to return an updated record back
404 record_updated, record = pattern_match_helper(entry, record)
405 return record_updated, record
406 end
407 end
408 end
409 repeat_breaker = true
410 until repeat_breaker
411 end
412 -- Here we know that we do not have any relevant information
413 -- to add SIEM values so return the record back and
414 -- record_updated should be false
415 return record_updated, record
416end
417
418
419--[[
420 process_logs is the starting function that fluent-bit calls
421 - return codes : -1 record must be deleted
422 0 record not modified, keep the original
423 1 record was modified, replace timestamp and record
424 2 record was modified, replace record and keep timestamp
425 ]]
426 function process_logs(tag, timestamp, record)
427 -- logs with log_class=replay are logs that have potentially already made it to the
428 -- cloud and could already be stored in a siem bucket. Since we don't want to store replayed
429 -- logs in the siem again. we need to return the log as-is instead of adding new siem classifications
430 if record["log_class"] and record["log_class"] == "replay" then
431 return 0, timestamp, record
432 end
433
434 -- If there is no severity, kick out early
435 if record["severity"] == nil then
436 return 0, timestamp, record
437 end
438
439
440 local updated, new_record
441 if (record["kubernetes"] ~= nil and record["kubernetes"]["namespace"] ~= nil and record["kubernetes"]["namespace"]["labels"]["siem.edge.ncr.com/helm-edge-id"] ~= nil) then
442 local workload_siem_configmap_file = "/var/configs/workload-siem/configs"
443 local workload_siem_configs = parse_configmap(workload_siem_configmap_file)
444 updated, new_record = add_siem_record(tag, record, workload_siem_configs, "workload")
445 else
446 local edge_siem_configmap_file = "/var/configs/edge-siem/edge-siem"
447 local edge_siem_configs = parse_configmap(edge_siem_configmap_file)
448 updated, new_record = add_siem_record(tag, record, edge_siem_configs, "edge")
449 end
450
451 if updated then
452 return 2, timestamp, new_record
453 else
454 return 0, timestamp, record
455 end
456 end
457
458return {
459 parse_configmap = parse_configmap,
460 add_siem_record = add_siem_record,
461 }
View as plain text