diff --git a/webapp/main.py b/webapp/main.py index 6c636a6..40ea1d5 100644 --- a/webapp/main.py +++ b/webapp/main.py @@ -39,18 +39,21 @@ async def api_flow_list(request): services = request.query_params.getlist("service") app_proto = request.query_params.get("app_proto") search = request.query_params.get("search") - tags = request.query_params.getlist("tag") + required_tags = request.query_params.getlist("tag") + denied_tags = request.query_params.getlist("deny_tag") + if not ts_to.isnumeric(): raise HTTPException(400) # Query flows and associated tags using filters query = """ WITH fsrvs AS (SELECT value FROM json_each(?1)), - ftags AS (SELECT value FROM json_each(?2)), - fsearchfid AS (SELECT value FROM json_each(?5)) + f_req_tags AS (SELECT value FROM json_each(?2)), + f_deny_tags AS (SELECT value FROM json_each(?3)), + fsearchfid AS (SELECT value FROM json_each(?6)) SELECT id, ts_start, ts_end, dest_ipport, app_proto, (SELECT GROUP_CONCAT(tag) FROM alert WHERE flow_id = flow.id) AS tags - FROM flow WHERE ts_start <= ?3 AND (?4 IS NULL OR app_proto = ?4) + FROM flow WHERE ts_start <= ?4 AND (?5 IS NULL OR app_proto = ?5) """ if services == ["!"]: # Filter flows related to no services @@ -58,14 +61,25 @@ async def api_flow_list(request): services = sum(CTF_CONFIG["services"].values(), []) elif services: query += "AND (src_ipport IN fsrvs OR dest_ipport IN fsrvs)" - if tags: + + if denied_tags: + # No alert with at least a denied tag exists for this flow + query += """ + AND NOT EXISTS ( + SELECT 1 FROM alert + WHERE flow_id == flow.id AND alert.tag IN f_deny_tags + ) + """ + + if required_tags: # Relational division to get all flow_id matching all chosen tags query += """ AND flow.id IN ( - SELECT flow_id FROM alert WHERE tag IN ftags GROUP BY flow_id - HAVING COUNT(*) = (SELECT COUNT(*) FROM ftags) + SELECT flow_id FROM alert WHERE tag IN f_req_tags GROUP BY flow_id + HAVING COUNT(*) = (SELECT COUNT(*) FROM f_req_tags) ) """ + search_fid = [] if search: cursor = await payload_database.execute( @@ -81,7 +95,8 @@ async def api_flow_list(request): query, ( json.dumps(services), - json.dumps(tags), + json.dumps(required_tags), + json.dumps(denied_tags), int(ts_to) * 1000, app_proto, json.dumps(search_fid), diff --git a/webapp/static/js/api.js b/webapp/static/js/api.js index fdf2e68..29d7705 100644 --- a/webapp/static/js/api.js +++ b/webapp/static/js/api.js @@ -20,9 +20,10 @@ export default class Api { * @param {Array} services Keep only flows matching these IP address and ports * @param {String} appProto Keep only flows matching this app-layer protocol * @param {String} search Search for this glob pattern in flows payloads - * @param {Array} tags Keep only flows matching these tags + * @param {Array} requiredTags Keep only flows matching these tags + * @param {Array} deniedTags Deny flows matching these tags */ - async listFlows (timestampFrom, timestampTo, services, appProto, search, tags) { + async listFlows (timestampFrom, timestampTo, services, appProto, search, requiredTags, deniedTags) { const url = new URL(`${location.origin}${location.pathname}api/flow`) if (typeof timestampFrom === 'number') { url.searchParams.append('from', timestampFrom) @@ -39,9 +40,12 @@ export default class Api { if (search) { url.searchParams.append('search', search) } - tags?.forEach((t) => { + requiredTags?.forEach((t) => { url.searchParams.append('tag', t) }) + deniedTags?.forEach((t) => { + url.searchParams.append('deny_tag', t) + }) const response = await fetch(url.href, {}) if (!response.ok) { throw Error('failed to list flows') diff --git a/webapp/static/js/flowlist.js b/webapp/static/js/flowlist.js index a14f47a..b454f1f 100644 --- a/webapp/static/js/flowlist.js +++ b/webapp/static/js/flowlist.js @@ -158,19 +158,61 @@ class FlowList { const tag = e.target.closest('a')?.dataset.tag if (tag) { const url = new URL(document.location) - const activeTags = url.searchParams.getAll('tag') - if (activeTags.includes(tag)) { - // Remove tag - url.searchParams.delete('tag') - activeTags.forEach(t => { - if (t !== tag) { - url.searchParams.append('tag', t) - } - }) + + let requiredTags = url.searchParams.getAll('tag') + let deniedTags = url.searchParams.getAll('deny_tag') + + const is_required = requiredTags.includes(tag) + const is_denied = deniedTags.includes(tag) + + let next_state; + if (is_required) { + if (e.shiftKey) { + // required => denied + next_state = 'denied' + } else { + // required => inactive + next_state = 'inactive' + } + } else if (is_denied) { + if (e.shiftKey) { + // denied => required + next_state = 'required' + } else { + // denied => inactive + next_state = 'inactive' + } } else { - // Add tag - url.searchParams.append('tag', tag) + if (e.shiftKey) { + // inactive => denied + next_state = 'denied' + } else { + // inactive => required + next_state = 'required' + } + } + + deniedTags = deniedTags.filter(t => t !== tag) + requiredTags = requiredTags.filter(t => t !== tag) + + if (next_state == 'required') { + requiredTags.push(tag) + } + if (next_state == 'denied') { + deniedTags.push(tag) } + + url.searchParams.delete("tag") + url.searchParams.delete("deny_tag") + + requiredTags.forEach(t => { + url.searchParams.append('tag', t) + }) + + deniedTags.forEach(t => { + url.searchParams.append('deny_tag', t) + }) + window.history.pushState(null, '', url.href) this.update() e.preventDefault() @@ -218,11 +260,20 @@ class FlowList { * Build tag element * @param {String} text Tag name * @param {String} color Tag color + * @param {boolean} [fill=false] Active style * @returns HTML element representing the tag */ - tagBadge (text, color) { + tagBadge (text, color, fill = true) { const badge = document.createElement('span') - badge.classList.add('badge', `text-bg-${color ?? 'none'}`, 'mb-1', 'me-1', 'p-1') + badge.classList.add('badge', 'mb-1', 'me-1', 'p-1') + if (fill) { + badge.classList.add(`text-bg-${color ?? 'none'}`) + } else { + badge.classList.add( + `border-${color ?? 'none'}`, + `text-${color ?? 'none'}` + ) + } badge.textContent = text return badge } @@ -271,11 +322,15 @@ class FlowList { // Create tag and append to dropdown const { tag, color } = t const url = new URL(document.location) - const activeTags = url.searchParams.getAll('tag') - const badge = this.tagBadge(tag, color) - badge.classList.add('border', 'border-2') - badge.classList.toggle('border-purple', activeTags.includes(tag)) - badge.classList.toggle('text-bg-purple', activeTags.includes(tag)) + + const is_required = url.searchParams.getAll('tag').includes(tag) + const is_denied = url.searchParams.getAll('deny_tag').includes(tag) + + const is_active = is_required || is_denied + + const badge = this.tagBadge(tag, color, is_active) + badge.classList.toggle('text-decoration-line-through', is_denied) + const link = document.createElement('a') link.href = '#' link.dataset.tag = tag @@ -381,14 +436,16 @@ class FlowList { const services = url.searchParams.getAll('service') const filterAppProto = url.searchParams.get('app_proto') const filterSearch = url.searchParams.get('search') - const filterTags = url.searchParams.getAll('tag') + const filterRequiredTags = url.searchParams.getAll('tag') + const filterDeniedTags = url.searchParams.getAll('deny_tag') const { flows, appProto, tags } = await this.apiClient.listFlows( fromTs ? Number(fromTs) : null, toTs ? Number(toTs) : null, services, filterAppProto, filterSearch, - filterTags + filterRequiredTags, + filterDeniedTags ) // Update search input @@ -402,7 +459,7 @@ class FlowList { this.updateActiveFlow() // Update filter dropdown visual indicator - document.querySelector('#dropdown-filter > button').classList.toggle('text-bg-purple', toTs || filterTags.length || filterAppProto || filterSearch) + document.querySelector('#dropdown-filter > button').classList.toggle('text-bg-purple', toTs || filterRequiredTags.length || filterDeniedTags.length || filterAppProto || filterSearch) // Update service filter select state document.getElementById('services-select').value = services.join(',')