Add non-metric category filters to compliance page

Adds a CategoryFilterBar with pill-shaped FilterChip components below the
metric health cards. Non-metric categories (Missing_AppID, Aging Vulns,
Missing_DF, etc.) are derived dynamically from device data and displayed
as color-coded filterable chips with device counts.

Unified filter state replaces the old metricFilter array, ensuring mutual
exclusivity between metric card filters and non-metric chip filters.
Includes 4 property-based tests validating derivation, filter predicate,
mutual exclusivity, and color resolution correctness.

Closes #26
This commit is contained in:
Jordan Ramos
2026-06-08 10:47:28 -06:00
parent 1f3833989a
commit d4c428248a
6 changed files with 550 additions and 11 deletions

View File

@@ -0,0 +1,71 @@
/**
* Property-Based Test: Color resolution with fallback
*
* Feature: compliance-nonmetric-filters, Property 4: Color resolution with fallback
* **Validates: Requirements 6.2, 6.3**
*
* For any non-metric category metric_id, the resolved color equals
* CATEGORY_COLORS[metricCategoriesConfig[metricId]] when both lookups succeed,
* else #94A3B8.
*/
import fc from 'fast-check';
import { deriveNonMetricCategories, CATEGORY_COLORS } from '../components/pages/CompliancePage';
// The color resolution logic extracted for direct testing
function resolveColor(metricId, categoriesConfig) {
const categoryName = categoriesConfig[metricId] || null;
return (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
}
// Generators
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
const knownCategories = Object.keys(CATEGORY_COLORS);
const categoryNameArb = fc.oneof(
fc.constantFrom(...knownCategories),
fc.string({ minLength: 1, maxLength: 20 }) // may not be in CATEGORY_COLORS
);
const categoriesConfigArb = fc.dictionary(metricIdArb, categoryNameArb);
describe('Compliance Non-Metric Filter — Property 4: Color resolution with fallback', () => {
it('color equals CATEGORY_COLORS[config[metricId]] when both lookups succeed, else #94A3B8', () => {
fc.assert(
fc.property(metricIdArb, categoriesConfigArb, (metricId, config) => {
const result = resolveColor(metricId, config);
const categoryName = config[metricId];
if (categoryName && CATEGORY_COLORS[categoryName]) {
expect(result).toBe(CATEGORY_COLORS[categoryName]);
} else {
expect(result).toBe('#94A3B8');
}
}),
{ numRuns: 100 }
);
});
it('deriveNonMetricCategories produces correct colors for all returned categories', () => {
const deviceWithMetricArb = metricIdArb.map(id => ({
hostname: 'host-' + id,
failing_metrics: [{ metric_id: id, metric_desc: '', category: '' }],
}));
fc.assert(
fc.property(
fc.array(deviceWithMetricArb, { minLength: 1, maxLength: 15 }),
categoriesConfigArb,
(devices, config) => {
// Empty summary means all device metric_ids are non-metric
const result = deriveNonMetricCategories(devices, [], config);
for (const item of result) {
const expectedColor = resolveColor(item.metricId, config);
expect(item.color).toBe(expectedColor);
}
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Property-Based Test: Non-metric category derivation is the set difference with accurate counts
*
* Feature: compliance-nonmetric-filters, Property 1: Non-metric category derivation
* **Validates: Requirements 1.1, 1.2, 2.2, 2.4**
*
* For any set of devices and any set of summary entries for a team,
* deriveNonMetricCategories returns exactly the metric_ids present in at least
* one device's failing_metrics array that do not appear in any summary entry's
* metric_id — deduplicated, sorted alphabetically by metricId, and each with a
* count equal to the number of devices whose failing_metrics contains that metric_id.
*/
import fc from 'fast-check';
import { deriveNonMetricCategories } from '../components/pages/CompliancePage';
// Generators
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
const failingMetricArb = fc.record({
metric_id: metricIdArb,
metric_desc: fc.constant(''),
category: fc.constant(''),
});
const deviceArb = fc.record({
hostname: fc.string({ minLength: 1, maxLength: 30 }),
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
});
const summaryEntryArb = fc.record({
metric_id: metricIdArb,
team: fc.constant('STEAM'),
compliance_pct: fc.double({ min: 0, max: 1 }),
status: fc.constantFrom('Meets/Exceeds Target', 'Within 15% of Target', 'Below 15% of Target'),
});
const categoriesConfigArb = fc.dictionary(metricIdArb, fc.constantFrom(
'Vulnerability Management', 'Access & MFA', 'Logging & Monitoring',
'Asset Data Quality', 'Endpoint Protection', 'Application Security',
'Disaster Recovery', 'Decommissioned Assets'
));
describe('Compliance Non-Metric Derivation — Property 1: Set difference with accurate counts', () => {
it('returned metric_ids are exactly the set difference of device metric_ids minus summary metric_ids', () => {
fc.assert(
fc.property(
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
categoriesConfigArb,
(devices, summaryEntries, config) => {
const result = deriveNonMetricCategories(devices, summaryEntries, config);
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
const deviceMetricIds = new Set();
for (const d of devices) {
for (const m of (d.failing_metrics || [])) {
if (m.metric_id) deviceMetricIds.add(m.metric_id);
}
}
const expectedIds = new Set([...deviceMetricIds].filter(id => !summaryIds.has(id)));
const resultIds = new Set(result.map(r => r.metricId));
expect(resultIds).toEqual(expectedIds);
}
),
{ numRuns: 100 }
);
});
it('returned list is deduplicated and sorted alphabetically by metricId', () => {
fc.assert(
fc.property(
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
categoriesConfigArb,
(devices, summaryEntries, config) => {
const result = deriveNonMetricCategories(devices, summaryEntries, config);
// Deduplicated
const ids = result.map(r => r.metricId);
expect(ids.length).toBe(new Set(ids).size);
// Sorted alphabetically
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
expect(ids).toEqual(sorted);
}
),
{ numRuns: 100 }
);
});
it('each count equals the number of devices whose failing_metrics contains that metric_id', () => {
fc.assert(
fc.property(
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
categoriesConfigArb,
(devices, summaryEntries, config) => {
const result = deriveNonMetricCategories(devices, summaryEntries, config);
for (const { metricId, count } of result) {
const expectedCount = devices.filter(d =>
(d.failing_metrics || []).some(m => m.metric_id === metricId)
).length;
expect(count).toBe(expectedCount);
}
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,85 @@
/**
* Property-Based Test: Filter predicate correctness
*
* Feature: compliance-nonmetric-filters, Property 2: Filter predicate correctness
* **Validates: Requirements 3.1, 5.2, 5.3, 5.4**
*
* For any filter state and any set of devices: when null, all devices pass;
* when metric, exactly devices with matching metric_id in ids pass;
* when nonmetric, exactly devices with matching metric_id pass.
*/
import fc from 'fast-check';
// Replicate the filter predicate logic from CompliancePage
function applyFilter(devices, filterState) {
return devices.filter(d => {
if (!filterState) return true;
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
return true;
});
}
// Generators
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
const failingMetricArb = fc.record({
metric_id: metricIdArb,
metric_desc: fc.constant(''),
category: fc.constant(''),
});
const deviceArb = fc.record({
hostname: fc.string({ minLength: 1, maxLength: 30 }),
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
});
const devicesArb = fc.array(deviceArb, { minLength: 0, maxLength: 20 });
const filterStateArb = fc.oneof(
fc.constant(null),
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
metricIdArb.map(id => ({ type: 'nonmetric', id }))
);
describe('Compliance Non-Metric Filter — Property 2: Filter predicate correctness', () => {
it('when filterState is null, all devices pass', () => {
fc.assert(
fc.property(devicesArb, (devices) => {
const result = applyFilter(devices, null);
expect(result.length).toBe(devices.length);
}),
{ numRuns: 100 }
);
});
it('when filterState is metric, exactly devices with a matching metric_id pass', () => {
fc.assert(
fc.property(devicesArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (devices, ids) => {
const filterState = { type: 'metric', ids };
const result = applyFilter(devices, filterState);
const expected = devices.filter(d =>
d.failing_metrics.some(m => ids.includes(m.metric_id))
);
expect(result).toEqual(expected);
}),
{ numRuns: 100 }
);
});
it('when filterState is nonmetric, exactly devices with that metric_id pass', () => {
fc.assert(
fc.property(devicesArb, metricIdArb, (devices, id) => {
const filterState = { type: 'nonmetric', id };
const result = applyFilter(devices, filterState);
const expected = devices.filter(d =>
d.failing_metrics.some(m => m.metric_id === id)
);
expect(result).toEqual(expected);
}),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,128 @@
/**
* Property-Based Test: Filter state mutual exclusivity
*
* Feature: compliance-nonmetric-filters, Property 3: Filter state mutual exclusivity
* **Validates: Requirements 3.4, 3.5, 5.1**
*
* For any prior filter state and any filter action (metric card click, chip click,
* or clear), the resulting state is exactly one of: null, { type: 'metric', ids: [...] },
* or { type: 'nonmetric', id: string } — never undefined, never both.
*/
import fc from 'fast-check';
// Replicate the filter state transition logic from CompliancePage
function applyFilterAction(currentState, action) {
if (action.type === 'clear') {
return null;
}
if (action.type === 'metric_click') {
const familyIds = action.ids;
// If currently active metric with same ids, toggle off
if (currentState?.type === 'metric' &&
currentState.ids.length === familyIds.length &&
familyIds.every(id => currentState.ids.includes(id))) {
return null;
}
return { type: 'metric', ids: familyIds };
}
if (action.type === 'chip_click') {
const metricId = action.id;
// If currently active nonmetric with same id, toggle off
if (currentState?.type === 'nonmetric' && currentState.id === metricId) {
return null;
}
return { type: 'nonmetric', id: metricId };
}
return currentState;
}
function isValidFilterState(state) {
if (state === null) return true;
if (state && state.type === 'metric' && Array.isArray(state.ids) && state.ids.length > 0) return true;
if (state && state.type === 'nonmetric' && typeof state.id === 'string' && state.id.length > 0) return true;
return false;
}
// Generators
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
const filterStateArb = fc.oneof(
fc.constant(null),
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
metricIdArb.map(id => ({ type: 'nonmetric', id }))
);
const filterActionArb = fc.oneof(
fc.constant({ type: 'clear' }),
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric_click', ids })),
metricIdArb.map(id => ({ type: 'chip_click', id }))
);
describe('Compliance Non-Metric Filter — Property 3: Filter state mutual exclusivity', () => {
it('resulting state is always exactly one of null, metric, or nonmetric — never undefined or combined', () => {
fc.assert(
fc.property(filterStateArb, filterActionArb, (priorState, action) => {
const result = applyFilterAction(priorState, action);
expect(result).not.toBeUndefined();
expect(isValidFilterState(result)).toBe(true);
// Never has both metric and nonmetric properties
if (result !== null) {
if (result.type === 'metric') {
expect(result).not.toHaveProperty('id');
}
if (result.type === 'nonmetric') {
expect(result).not.toHaveProperty('ids');
}
}
}),
{ numRuns: 100 }
);
});
it('clear action always results in null regardless of prior state', () => {
fc.assert(
fc.property(filterStateArb, (priorState) => {
const result = applyFilterAction(priorState, { type: 'clear' });
expect(result).toBeNull();
}),
{ numRuns: 100 }
);
});
it('metric click replaces any prior state with metric filter or null (toggle off)', () => {
fc.assert(
fc.property(filterStateArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (priorState, ids) => {
const result = applyFilterAction(priorState, { type: 'metric_click', ids });
if (result === null) {
// Toggled off — prior must have been metric with same ids
expect(priorState?.type).toBe('metric');
} else {
expect(result.type).toBe('metric');
expect(result.ids).toEqual(ids);
}
}),
{ numRuns: 100 }
);
});
it('chip click replaces any prior state with nonmetric filter or null (toggle off)', () => {
fc.assert(
fc.property(filterStateArb, metricIdArb, (priorState, id) => {
const result = applyFilterAction(priorState, { type: 'chip_click', id });
if (result === null) {
// Toggled off — prior must have been nonmetric with same id
expect(priorState?.type).toBe('nonmetric');
expect(priorState?.id).toBe(id);
} else {
expect(result.type).toBe('nonmetric');
expect(result.id).toBe(id);
}
}),
{ numRuns: 100 }
);
});
});

View File

@@ -7,6 +7,7 @@ import ComplianceChartsPanel from './ComplianceChartsPanel';
import MetricInfoPanel from './MetricInfoPanel';
import VCLReportPage from './VCLReportPage';
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
import metricCategoriesConfig from '../../data/complianceCategories.json';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
@@ -87,6 +88,32 @@ function groupByMetricFamily(allEntries, team) {
}));
}
// ---------------------------------------------------------------------------
// Non-metric category derivation
// ---------------------------------------------------------------------------
function deriveNonMetricCategories(devices, summaryEntries, categoriesConfig) {
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
const countMap = new Map();
for (const device of devices) {
if (!device.failing_metrics) continue;
const seen = new Set();
for (const m of device.failing_metrics) {
if (!m.metric_id || summaryIds.has(m.metric_id) || seen.has(m.metric_id)) continue;
seen.add(m.metric_id);
countMap.set(m.metric_id, (countMap.get(m.metric_id) || 0) + 1);
}
}
return [...countMap.entries()]
.map(([metricId, count]) => {
const categoryName = categoriesConfig[metricId] || null;
const color = (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
return { metricId, count, category: categoryName || 'Unknown', color };
})
.sort((a, b) => a.metricId.localeCompare(b.metricId));
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
@@ -246,6 +273,71 @@ function SeenBadge({ count }) {
);
}
function FilterChip({ metricId, count, color, active, dimmed, onClick }) {
const label = metricId.length > 24 ? metricId.slice(0, 24) + '…' : metricId;
return (
<button
onClick={onClick}
title={metricId.length > 24 ? metricId : undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.75rem',
borderRadius: '999px',
border: `1px solid ${active ? color : color + '40'}`,
background: active ? `${color}1F` : `${color}0A`,
cursor: 'pointer',
opacity: dimmed ? 0.5 : 1,
transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = color + '80'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = active ? color : color + '40'; }}
>
<span style={{
width: '6px', height: '6px', borderRadius: '50%',
background: color, flexShrink: 0,
}} />
<span style={{ fontSize: '0.72rem', color, fontFamily: 'monospace', fontWeight: '600', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '150px' }}>
{label}
</span>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B', fontWeight: '700' }}>
{count}
</span>
</button>
);
}
function CategoryFilterBar({ categories, activeFilter, onFilterSelect, onClear, dimmed }) {
if (!categories || categories.length === 0) return null;
return (
<div style={{ marginBottom: '1.5rem', opacity: dimmed ? 0.5 : 1, transition: 'opacity 0.15s' }}>
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
Non-Metric Categories
{activeFilter && (
<button onClick={onClear}
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
× clear filter
</button>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{categories.map(cat => (
<FilterChip
key={cat.metricId}
metricId={cat.metricId}
count={cat.count}
color={cat.color}
active={activeFilter === cat.metricId}
dimmed={false}
onClick={() => onFilterSelect(cat.metricId)}
/>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
@@ -256,7 +348,7 @@ export default function CompliancePage({ onNavigate }) {
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
const [activeTab, setActiveTab] = useState('active');
const [vclView, setVclView] = useState(false);
const [metricFilter, setMetricFilter] = useState(null);
const [filterState, setFilterState] = useState(null);
const [hostSearch, setHostSearch] = useState('');
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
const [devices, setDevices] = useState([]);
@@ -297,7 +389,7 @@ export default function CompliancePage({ onNavigate }) {
}, []);
useEffect(() => {
setMetricFilter(null);
setFilterState(null);
setHostSearch('');
setSelectedHost(null);
fetchSummary(activeTeam);
@@ -313,7 +405,7 @@ export default function CompliancePage({ onNavigate }) {
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setMetricFilter(null);
setFilterState(null);
fetchDevices(activeTeam, activeTab);
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -346,7 +438,12 @@ export default function CompliancePage({ onNavigate }) {
// In-memory filters
const filteredDevices = devices
.filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id)))
.filter(d => {
if (!filterState) return true;
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
return true;
})
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
const families = groupByMetricFamily(summary.entries, activeTeam);
@@ -495,8 +592,8 @@ export default function CompliancePage({ onNavigate }) {
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
Metric Health click to filter
{metricFilter && (
<button onClick={() => setMetricFilter(null)}
{filterState && (
<button onClick={() => setFilterState(null)}
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
× clear filter
</button>
@@ -505,7 +602,7 @@ export default function CompliancePage({ onNavigate }) {
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
{families.map(family => {
const familyIds = family.entries.map(e => e.metric_id);
const isActive = metricFilter !== null && metricFilter.length === familyIds.length && familyIds.every(id => metricFilter.includes(id));
const isActive = filterState?.type === 'metric' && filterState.ids.length === familyIds.length && familyIds.every(id => filterState.ids.includes(id));
return (
<div
key={family.metricId}
@@ -520,12 +617,12 @@ export default function CompliancePage({ onNavigate }) {
hoveredCardRef.current = null;
setHoveredMetric(null);
}}
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px' }}
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px', opacity: filterState?.type === 'nonmetric' ? 0.5 : 1, transition: 'opacity 0.15s' }}
>
<MetricHealthCard
family={family}
active={isActive}
onClick={() => setMetricFilter(isActive ? null : familyIds)}
onClick={() => setFilterState(isActive ? null : { type: 'metric', ids: familyIds })}
onInfoClick={(metricId) => setInfoMetric(metricId)}
definitionLookup={METRIC_DEFINITIONS}
/>
@@ -591,6 +688,27 @@ export default function CompliancePage({ onNavigate }) {
</div>
) : null}
{/* ── Non-metric category filter bar ─────────────────────── */}
{!vclView && !loading && (() => {
const nonMetricCategories = deriveNonMetricCategories(devices, summary.entries.filter(e => e.team === activeTeam), metricCategoriesConfig);
if (nonMetricCategories.length === 0) return null;
return (
<CategoryFilterBar
categories={nonMetricCategories}
activeFilter={filterState?.type === 'nonmetric' ? filterState.id : null}
onFilterSelect={(metricId) => {
if (filterState?.type === 'nonmetric' && filterState.id === metricId) {
setFilterState(null);
} else {
setFilterState({ type: 'nonmetric', id: metricId });
}
}}
onClear={() => setFilterState(null)}
dimmed={filterState?.type === 'metric'}
/>
);
})()}
{/* ── Historical trend charts ──────────────────────────────── */}
{!vclView && <ComplianceChartsPanel />}
@@ -677,7 +795,7 @@ export default function CompliancePage({ onNavigate }) {
</div>
) : filteredDevices.length === 0 ? (
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
{lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
</div>
) : (
filteredDevices.map(device => (
@@ -906,4 +1024,4 @@ function DeviceRow({ device, selected, onClick }) {
}
// Named exports for testing
export { computeWorstStatus, groupByMetricFamily };
export { computeWorstStatus, groupByMetricFamily, deriveNonMetricCategories, CATEGORY_COLORS };

View File

@@ -0,0 +1,25 @@
{
"1.1.1": "Logging & Monitoring",
"1.1.3": "Logging & Monitoring",
"1.4.1": "Logging & Monitoring",
"2.3.4i": "Vulnerability Management",
"2.3.6i": "Vulnerability Management",
"2.3.8i": "Vulnerability Management",
"5.2.4": "Access & MFA",
"5.2.5": "Access & MFA",
"5.2.6": "Access & MFA",
"5.2.7": "Access & MFA",
"5.2.8": "Access & MFA",
"5.3.4": "Endpoint Protection",
"5.5.4i": "Vulnerability Management",
"5.5.5": "Decommissioned Assets",
"5.8.1": "Application Security",
"7.1.1": "Logging & Monitoring",
"7.1.4": "Logging & Monitoring",
"7.6.13": "Disaster Recovery",
"7.6.16": "Disaster Recovery",
"Missing_AppID": "Asset Data Quality",
"Missing_DF": "Asset Data Quality",
"Missing_OS": "Asset Data Quality",
"5.5.2": "Other"
}