Skip to content

Commit

Permalink
Merge pull request #45 from UKB-IT-Sec/37-armis-import-sort-function
Browse files Browse the repository at this point in the history
37 armis import sort function
  • Loading branch information
weidenba authored Nov 27, 2024
2 parents 1af21ca + 0f3e3a9 commit 8382d33
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 50 deletions.
17 changes: 15 additions & 2 deletions src/helper/armis.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,29 @@ def _remove_existing_devices(deviceList):

# flake8: noqa: E231
@armiscloud
def get_devices(acloud, site):
def get_devices(acloud, sites):
vlan_bl = ""
vlan_blacklist = armis_config['armis-server'].get('vlan_blacklist', '')
vlan_bl = f"!networkInterface:(vlans:{vlan_blacklist})" if vlan_blacklist else ""
sites = ','.join(f'"{site}"' for site in sites)
deviceList = acloud.get_devices(
asq=f'in:devices site:"{site.get("name")}" timeFrame:"7 Days" {vlan_bl}',
asq=f'in:devices site:{sites} timeFrame:"7 Days" {vlan_bl}',
fields_wanted=['id', 'ipAddress', 'macAddress', 'name', 'boundaries']
)
return _remove_existing_devices(deviceList)
# flake8: qa


def get_boundaries(deviceList):
unique_boundaries = set()
for device in deviceList:
boundaries = [b.strip() for b in device['boundaries'].split(',')]
unique_boundaries.update(boundaries)

return sorted(list(unique_boundaries))

def get_tenant_url():
return 'https://{}'.format(armis_config['armis-server']['tenant_hostname'])

def map_ids_to_names(selectedSiteIds, armisServerSites):
return [armisServerSites[id]['name'] for id in selectedSiteIds if id in armisServerSites]
14 changes: 8 additions & 6 deletions src/nac/subviews/armis.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from django.core.cache import cache
from django.shortcuts import render

from helper.armis import get_armis_sites, get_devices, get_tenant_url
from helper.armis import get_armis_sites, get_devices, get_tenant_url, get_boundaries, map_ids_to_names


class ArmisView(View):
Expand All @@ -35,14 +35,16 @@ def _get_context(self): # sets the site-context for armis_import.html, uses cac

def get(self, request, *args, **kwargs): # rendering the html base with site-context
context = self._get_context()
context['display'] = True
return render(request, self.template_name, context)

def post(self, request, *args, **kwargs): # gets site-id chosen in html-dropdown, gets Devices based on site-id, shows them via device-context
context = self._get_context()

selected_site = request.POST.get('site-id')
context['selected_site'] = selected_site if selected_site else ''
if selected_site:
context['devices'] = get_devices(context['armis_sites'][selected_site])
selected_sites = request.POST.getlist('site-ids[]')
context['display'] = False if selected_sites else True
context['selected_sites'] = selected_sites if selected_sites else ''
if selected_sites:
context['devices'] = get_devices(map_ids_to_names(selected_sites, context['armis_sites']))
context['boundaries'] = get_boundaries(context['devices'])

return render(request, self.template_name, context)
22 changes: 22 additions & 0 deletions src/static/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,26 @@ td {
margin-top: 5px;
white-space: nowrap;
z-index: 2000;
}
.checkbox-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); /* displays checkbox in 100pt width columns, as many as possible based on window size*/
gap: 10px 20px;
padding: 10px;
}

.checkbox-item {
display: flex;
align-items: center;
}

.checkbox-label {
margin-left: 8px;
cursor: pointer;
}

.custom-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
181 changes: 151 additions & 30 deletions src/templates/armis_import.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<h1>Armis Import</h1>
<form method="POST" action="{% url 'armis_import' %}">
{% csrf_token %}
<select name="site-id">
<option value="" disabled {% if not selected_site %}selected{% endif %}>Choose site...</option>
{% for site_id, site_info in armis_sites.items %}
<option value="{{ site_id }}" {% if site_id == selected_site %}selected{% endif %}>
{{ site_info.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-secondary btn-sm">select</button>
</form>
<h1>Armis Import</h1>
<button id="toggleSiteForm" class="btn btn-secondary btn-sm">
{% if display %}Hide Sites{% else %}Show Sites{% endif %}
</button>
<div id="formContainer" style="display: {% if display %}block{% else %}none{% endif %}">
<form id="siteSubmitForm" method="POST" action="{% url 'armis_import' %}">
{% csrf_token %}
<div class="checkbox-container">
{% for site_id, site_info in armis_sites.items %}
<div class="checkbox-item">
<input type="checkbox" id="site-{{ site_id }}" name="site-ids[]" value="{{ site_id }}" class="custom-checkbox">
<label for="site-{{ site_id }}" class="checkbox-label">{{ site_info.name }}</label>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-secondary btn-sm">Select</button>
</form>
</div>
{%if devices%}
<div id="pagination">
<button id="prevPage" class="link-button">
Expand Down Expand Up @@ -64,18 +69,38 @@ <h1>Armis Import</h1>
color: blue;
cursor: pointer;
}
.sort-icon {
cursor: pointer;
user-select: none;
}
</style>
<tr>
<th scope="col">Device Name</th>
<th scope="col">MAC Address</th>
<th scope="col">IP Address</th>
<th scope="col">Boundaries</th>
<th scope="col">
<input type="text" id="searchInput" placeholder="Search Name, IP, or MAC">
</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<th scope="col">
<select id="boundaryFilter">
<option value="">Filter Boundaries / All</option>
{% for boundary in boundaries %}
<option value="{{ boundary }}">{{ boundary }}</option>
{% endfor %}
</select>
</th>
<th scope="col"></th>
</tr>
<tr>
<th scope="col">Device Name <span class="sort-icon" data-column="name"></span></th>
<th scope="col">MAC Address <span class="sort-icon" data-column="macAddress"></span></th>
<th scope="col">IP Address <span class="sort-icon" data-column="ipAddress"></span></th>
<th scope="col">Boundaries</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>

<div id="detailModal" class="modal">
<div class="modal-content">
Expand All @@ -101,13 +126,112 @@ <h2 id="modalTitle"></h2>
},
{% endfor %}
];
let filteredData = [...data]; // copies array to prevent changes on original data
let sortOrder = 'asc';
let searchTerm = '';

document.getElementById('toggleSiteForm').addEventListener('click', function() {
var formContainer = document.getElementById('formContainer');
if (formContainer.style.display === 'none') {
formContainer.style.display = 'block';
this.textContent = 'Hide Sites';
} else {
formContainer.style.display = 'none';
this.textContent = 'Show Sites';
}
});
function setupFilters() {
const boundaryFilter = document.getElementById("boundaryFilter");
const searchInput = document.getElementById("searchInput");

boundaryFilter.addEventListener("change", applyFilters); // calls applyFilter function on select
searchInput.addEventListener("input", applyFilters); // calls applyFilter function on every input
}

function setupSorting() {
const sortIcons = document.querySelectorAll('.sort-icon'); //Get all html-elements that are sort-elements
sortIcons.forEach(icon => {
icon.addEventListener('click', () => {
const column = icon.getAttribute('data-column'); //determines which column to order
sortData(column);
});
});
}

function sortData(column) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; //switches asc to desc or desc to asc for every click so user can switch between order

filteredData.sort((a, b) => { // sorts data-array by providing a function that returns <0, 0, >0 for specific comparisons
const valueA = a[column];
const valueB = b[column];

if (valueA === "None" && valueB === "None") return 0;
if (valueA === "None") return sortOrder === 'asc' ? -1 : 1; // short for if (sortOrder === 'asc') { return -1;} else {return 1;}
if (valueB === "None") return sortOrder === 'asc' ? 1 : -1;

if (column === 'macAddress' || column === 'ipAddress') {
return compareAddresses(valueA, valueB);
}

return sortOrder === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); // returns 0,1,-1 for string comparison
});

currentPage = 1;
displayTable(currentPage);
updateSortIcons(column);
}

function compareAddresses(addrA, addrB) {
const partsA = addrA.split(/[.:]/).map(part => parseInt(part, 16) || 0); //splits mac or ip Adress into integer blocks
const partsB = addrB.split(/[.:]/).map(part => parseInt(part, 16) || 0);

for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
if (partsA[i] === undefined) return sortOrder === 'asc' ? -1 : 1;
if (partsB[i] === undefined) return sortOrder === 'asc' ? 1 : -1;
if (partsA[i] !== partsB[i]) {
return sortOrder === 'asc'
? partsA[i] - partsB[i] //return <0 if block of A is smaller than block of B -> A comes before B
: partsB[i] - partsA[i];
}
}
return 0; //mac or IPAddress are equal
}

function updateSortIcons(sortedColumn) { // switches icon so user knows if order is asc oder desc
const sortIcons = document.querySelectorAll('.sort-icon');
sortIcons.forEach(icon => {
const column = icon.getAttribute('data-column');
if (column === sortedColumn) {
icon.textContent = sortOrder === 'asc' ? '▲' : '▼';
} else {
icon.textContent = '▲';
}
});
}
function applyFilters() {
const selectedBoundary = document.getElementById("boundaryFilter").value;
searchTerm = document.getElementById("searchInput").value.toLowerCase();

filteredData = data.filter(function(device) { // filters for devices where the function returns true
const matchesBoundary = !selectedBoundary || device.boundaries.split(',').map(b => b.trim()).includes(selectedBoundary); // sets selectedBoundary to True if boundary isnt set,
const matchesSearch = device.name.toLowerCase().startsWith(searchTerm) || device.name.toLowerCase().includes(searchTerm) ||
device.ipAddress.toLowerCase().replace(/[^0-9]/g, '').startsWith(searchTerm.replace(/[^a-z0-9]/g, '') || false) || // replace everything that is not in 0-9 range
device.macAddress.toLowerCase().replace(/[^a-f0-9]/g, '').startsWith(searchTerm.replace(/[^a-z0-9]/g, '') || false); // replace everything that is not in hex (a-f,0-9) range
return matchesBoundary && matchesSearch; // and-Operator so devices can get filtered by boundary AND searchTerm, otherwise it would filter for either one
});

currentPage = 1;
displayTable(currentPage);
}

function displayTable(page) {

const tableBody = document.querySelector("#deviceTable tbody");
tableBody.innerHTML = "";

const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const pageData = data.slice(start, end);
const pageData = filteredData.slice(start, end);

pageData.forEach((device, index) => {
const row = tableBody.insertRow();
Expand Down Expand Up @@ -148,7 +272,7 @@ <h2 id="modalTitle"></h2>
});

document.getElementById("currentPage").textContent = page;
document.getElementById("totalPages").textContent = Math.ceil(data.length / itemsPerPage);
document.getElementById("totalPages").textContent = Math.ceil(filteredData.length / itemsPerPage);
}

function showDetails(title, addresses) {
Expand Down Expand Up @@ -205,10 +329,7 @@ <h2 id="modalTitle"></h2>
displayTable(currentPage);
setupPagination();
setupModal();
setupFilters();
setupSorting();
</script>
{% endblock content %}




#Tests schreiben, prüfen wegen unique
{% endblock content %}
7 changes: 1 addition & 6 deletions src/tests/test_helper/test_armis.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ def mock_config():
}


@pytest.fixture
def mock_armis_cloud():
return MagicMock()


@patch('helper.armis.armis_config', new_callable=MagicMock)
@patch('helper.armis.ArmisCloud')
def test_armiscloud_decorator(mock_armis_cloud, mock_armis_config, mock_config):
Expand Down Expand Up @@ -94,7 +89,7 @@ def test_get_devices(mock_remove_existing_devices, mock_config):

# manually call original function without decorator to prevent argument error
with patch('helper.armis.armis_config', mock_config):
result = get_devices.__wrapped__(mock_armis_cloud, {'name': 'TestSite'})
result = get_devices.__wrapped__(mock_armis_cloud, {'TestSite'})

assert result == mock_devices
mock_armis_cloud.get_devices.assert_called_once_with(
Expand Down
15 changes: 9 additions & 6 deletions src/tests/test_views/test_armis_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,21 @@ def test_get(self, mock_render, mock_get_context, armis_view, rf):
def test_post_with_site(self, mock_render, mock_get_devices, mock_get_context, armis_view, rf):
mock_context = {'armis_sites': {'1': {'name': 'Site1'}}}
mock_get_context.return_value = mock_context
mock_devices = [{'name': 'Device1'}, {'name': 'Device2'}]
mock_devices = [{'name': 'Device1', 'boundaries': 'boundary1'}, {'name': 'Device2', 'boundaries': 'boundary2'}]
mock_get_devices.return_value = mock_devices

request = rf.post('/armis/', {'site-id': '1'})
request = rf.post('/armis/', {'site-ids[]': '1'})
armis_view.post(request)

expected_context = {
'armis_sites': {'1': {'name': 'Site1'}},
'selected_site': '1',
'devices': mock_devices
'display': False,
'selected_sites': ['1'],
'devices': mock_devices,
'boundaries': ['boundary1', 'boundary2']
}
mock_render.assert_called_once_with(request, armis_view.template_name, expected_context)
mock_get_devices.assert_called_once_with({'name': 'Site1'})
mock_get_devices.assert_called_once_with(['Site1'])

@patch('nac.subviews.armis.ArmisView._get_context')
@patch('nac.subviews.armis.render')
Expand All @@ -75,6 +77,7 @@ def test_post_without_site(self, mock_render, mock_get_context, armis_view, rf):

expected_context = {
'armis_sites': {'1': {'name': 'Site1'}},
'selected_site': ''
'display': True,
'selected_sites': ''
}
mock_render.assert_called_once_with(request, armis_view.template_name, expected_context)

0 comments on commit 8382d33

Please sign in to comment.