-- -- json.lua -- -- Copyright (c) 2020 rxi -- -- Permission is hereby granted, free of charge, to any person obtaining a copy of -- this software and associated documentation files (the "Software"), to deal in -- the Software without restriction, including without limitation the rights to -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -- of the Software, and to permit persons to whom the Software is furnished to do -- so, subject to the following conditions: -- -- The above copyright notice and this permission notice shall be included in all -- copies or substantial portions of the Software. -- -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -- SOFTWARE. -- local json = { _version = "0.1.2" } ------------------------------------------------------------------------------- -- Decode ------------------------------------------------------------------------------- local parse local function create_set(...) local res = {} for i = 1, select("#", ...) do res[ select(i, ...) ] = true end return res end local space_chars = create_set(" ", "\t", "\r", "\n") local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") local literals = create_set("true", "false", "null") local literal_map = { [ "true" ] = true, [ "false" ] = false, [ "null" ] = nil, } local function next_char(str, idx, set, negate) for i = idx, #str do if set[str:sub(i, i)] ~= negate then return i end end return #str + 1 end local function decode_error(str, idx, msg) local line_count = 1 local col_count = 1 for i = 1, idx - 1 do col_count = col_count + 1 if str:sub(i, i) == "\n" then line_count = line_count + 1 col_count = 1 end end error( string.format("%s at line %d col %d", msg, line_count, col_count) ) end local function codepoint_to_utf8(n) -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa local f = math.floor if n <= 0x7f then return string.char(n) elseif n <= 0x7ff then return string.char(f(n / 64) + 192, n % 64 + 128) elseif n <= 0xffff then return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) elseif n <= 0x10ffff then return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, f(n % 4096 / 64) + 128, n % 64 + 128) end error( string.format("invalid unicode codepoint '%x'", n) ) end local function parse_unicode_escape(s) local n1 = tonumber( s:sub(1, 4), 16 ) local n2 = tonumber( s:sub(7, 10), 16 ) -- Surrogate pair? if n2 then return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) else return codepoint_to_utf8(n1) end end local function parse_string(str, i) local res = "" local j = i + 1 local k = j while j <= #str do local x = str:byte(j) if x < 32 then decode_error(str, j, "control character in string") elseif x == 92 then -- `\`: Escape res = res .. str:sub(k, j - 1) j = j + 1 local c = str:sub(j, j) if c == "u" then local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) or str:match("^%x%x%x%x", j + 1) or decode_error(str, j - 1, "invalid unicode escape in string") res = res .. parse_unicode_escape(hex) j = j + #hex else if not escape_chars[c] then decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") end res = res .. escape_char_map_inv[c] end k = j + 1 elseif x == 34 then -- `"`: End of string res = res .. str:sub(k, j - 1) return res, j + 1 end j = j + 1 end decode_error(str, i, "expected closing quote for string") end local function parse_number(str, i) local x = next_char(str, i, delim_chars) local s = str:sub(i, x - 1) local n = tonumber(s) if not n then decode_error(str, i, "invalid number '" .. s .. "'") end return n, x end local function parse_literal(str, i) local x = next_char(str, i, delim_chars) local word = str:sub(i, x - 1) if not literals[word] then decode_error(str, i, "invalid literal '" .. word .. "'") end return literal_map[word], x end local function parse_array(str, i) local res = {} local n = 1 i = i + 1 while 1 do local x i = next_char(str, i, space_chars, true) -- Empty / end of array? if str:sub(i, i) == "]" then i = i + 1 break end -- Read token x, i = parse(str, i) res[n] = x n = n + 1 -- Next token i = next_char(str, i, space_chars, true) local chr = str:sub(i, i) i = i + 1 if chr == "]" then break end if chr ~= "," then decode_error(str, i, "expected ']' or ','") end end return res, i end local function parse_object(str, i) local res = {} i = i + 1 while 1 do local key, val i = next_char(str, i, space_chars, true) -- Empty / end of object? if str:sub(i, i) == "}" then i = i + 1 break end -- Read key if str:sub(i, i) ~= '"' then decode_error(str, i, "expected string for key") end key, i = parse(str, i) -- Read ':' delimiter i = next_char(str, i, space_chars, true) if str:sub(i, i) ~= ":" then decode_error(str, i, "expected ':' after key") end i = next_char(str, i + 1, space_chars, true) -- Read value val, i = parse(str, i) -- Set res[key] = val -- Next token i = next_char(str, i, space_chars, true) local chr = str:sub(i, i) i = i + 1 if chr == "}" then break end if chr ~= "," then decode_error(str, i, "expected '}' or ','") end end return res, i end local char_func_map = { [ '"' ] = parse_string, [ "0" ] = parse_number, [ "1" ] = parse_number, [ "2" ] = parse_number, [ "3" ] = parse_number, [ "4" ] = parse_number, [ "5" ] = parse_number, [ "6" ] = parse_number, [ "7" ] = parse_number, [ "8" ] = parse_number, [ "9" ] = parse_number, [ "-" ] = parse_number, [ "t" ] = parse_literal, [ "f" ] = parse_literal, [ "n" ] = parse_literal, [ "[" ] = parse_array, [ "{" ] = parse_object, } parse = function(str, idx) local chr = str:sub(idx, idx) local f = char_func_map[chr] if f then return f(str, idx) end decode_error(str, idx, "unexpected character '" .. chr .. "'") end function decode(str) if type(str) ~= "string" then error("expected argument of type string, got " .. type(str)) end local res, idx = parse(str, next_char(str, 1, space_chars, true)) idx = next_char(str, idx, space_chars, true) if idx <= #str then decode_error(str, idx, "trailing garbage") end return res end ------------------------------------------------------------------------------- -- SIEM.LUA SCRIPT STARTS BELOW ------------------------------------------------------------------------------- local function parse_configmap(configmap_file) local siem_configs = {} -- function to see if file exists local function file_exists(name) local f = io.open(name, "r") return f ~= nil and io.close(f) end if file_exists(configmap_file) then local file = io.open(configmap_file, "r") if file then local content = file:read("*a") if content ~= '' then siem_configs = decode(content) end file:close() end else print("File does not exist: " .. configmap_file) end return siem_configs end local severity_hierarchy = { ["debug"] = 1, ["info"] = 2, ["notice"] = 3, ["warn"] = 4, ["warning"] = 4, ["error"] = 5, ["crit"] = 6, ["critical"] = 6, ["alert"] = 7, ["emergency"] = 8, } local function severity_hierarchy_finder(severity_allowed, min_level) for severity, level in pairs(severity_hierarchy) do if level >= min_level then severity_allowed[severity] = true end end return severity_allowed end local function siem_helper(entry, record) record["log_type"] = entry.log_type record["log_class"] = entry.log_class return true, record end local function pattern_match_helper(entry, record) local function match_and_update(field) local matched_pattern = string.match(record[field], entry.pattern) if matched_pattern ~= nil then return siem_helper(entry, record) end return false, record end if entry.pattern == "" then return siem_helper(entry, record) end if record["message"] then return match_and_update("message") elseif record["msg"] then return match_and_update("msg") end return false, record end local function add_siem_record(tag, record, siem_configs, config_type) local tag_pattern = "^(k8s_container%.)([%a%-%d]+)_([%a%-%d]+)_([%a%-%d]+)" level = string.lower(record["severity"]) local _, namespace_name, pod_name, container_name = tag:match(tag_pattern) local record_updated = false -- loop through each configuration for index , entry in ipairs(siem_configs) do repeat_breaker = false repeat -- create the severity_allowed hashmap local severity_allowed = {} local severity_allowed = severity_hierarchy_finder(severity_allowed, severity_hierarchy[string.lower(entry.severity)]) if not severity_allowed[level] then break end -- Checks to see if the entry and tag data match local pod_pattern_match = entry.pod:gsub("-", "%%-") local container_pattern_match = entry.container:gsub("-", "%%-") local matched_pod = string.match(pod_name, pod_pattern_match) local matched_container = string.match(container_name, container_pattern_match) -- check for static config if config_type == "edge" then local namespace_pattern_match = entry.namespace:gsub("-", "%%-") local matched_namespace = string.match(namespace_name, namespace_pattern_match) if not (matched_namespace and matched_container and matched_pod) then break else -- To this point we are 100% certain that the record was updated with new values -- record_updated should be true and we need to return an updated record back record_updated, record = pattern_match_helper(entry, record) return record_updated, record end end -- check for custom config if config_type == "workload" then local workload_label workload_label = record["kubernetes"]["namespace"]["labels"]["siem.edge.ncr.com/helm-edge-id"] if entry.helm_edge_ID and workload_label and entry.pattern then if not (entry.helm_edge_ID == workload_label and matched_container and matched_pod and severity_allowed[level]) then break else -- To this point we are 100% certain that the record was updated with new values -- record_updated should be true and we need to return an updated record back record_updated, record = pattern_match_helper(entry, record) return record_updated, record end end end repeat_breaker = true until repeat_breaker end -- Here we know that we do not have any relevant information -- to add SIEM values so return the record back and -- record_updated should be false return record_updated, record end --[[ process_logs is the starting function that fluent-bit calls - return codes : -1 record must be deleted 0 record not modified, keep the original 1 record was modified, replace timestamp and record 2 record was modified, replace record and keep timestamp ]] function process_logs(tag, timestamp, record) -- logs with log_class=replay are logs that have potentially already made it to the -- cloud and could already be stored in a siem bucket. Since we don't want to store replayed -- logs in the siem again. we need to return the log as-is instead of adding new siem classifications if record["log_class"] and record["log_class"] == "replay" then return 0, timestamp, record end -- If there is no severity, kick out early if record["severity"] == nil then return 0, timestamp, record end local updated, new_record if (record["kubernetes"] ~= nil and record["kubernetes"]["namespace"] ~= nil and record["kubernetes"]["namespace"]["labels"]["siem.edge.ncr.com/helm-edge-id"] ~= nil) then local workload_siem_configmap_file = "/var/configs/workload-siem/configs" local workload_siem_configs = parse_configmap(workload_siem_configmap_file) updated, new_record = add_siem_record(tag, record, workload_siem_configs, "workload") else local edge_siem_configmap_file = "/var/configs/edge-siem/edge-siem" local edge_siem_configs = parse_configmap(edge_siem_configmap_file) updated, new_record = add_siem_record(tag, record, edge_siem_configs, "edge") end if updated then return 2, timestamp, new_record else return 0, timestamp, record end end return { parse_configmap = parse_configmap, add_siem_record = add_siem_record, }