Generate report
Skill fabioc-aloha/Alex_Skill_Mall/plugins/security-privacy/generate-report
Generate an interactive, self-contained HTML dashboard displaying SFI-TI3.2.2 tenant isolation violations for a service, org, or alias -- includes filtering, sorting, severity coding, TSG links, S360 deep links.From its SKILL.md
npx -y skills add fabioc-aloha/Alex_Skill_Mall --skill generate-reportAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
29.2 KB, ~7.8k tokens by cl100k_base, as published. Nobody here has run it
Generate Tenant Isolation Violation Report
Generate an interactive, self-contained HTML dashboard that displays all SFI-TI3.2.2 tenant isolation violations for a given service, org, or alias. The report opens in the default browser and includes filtering, sorting, color-coded severity, TSG links, and S360 deep links.
Trigger detection
Activate when the user says:
- "generate report" / "create dashboard" / "show violations report"
- "HTML report" / "violation dashboard" / "export violations"
- "tenant isolation report" / "create violation report"
- "generate TI report" / "show tenant isolation dashboard"
- "build HTML dashboard" / "S360 TI report"
- "generate SFI-TI3.2.2 report" / "export tenant isolation violations"
Step 1 — Accept violation data
The input is structured violation data from the fetch-violations skill, typically passed
by the violation-triager skill. Each violation object is a normalized flat record
derived from the MCP response (flattened from CustomDimensions and S360Dimensions):
Title — Violation type (one of 9 titles, see Step 2)
TargetId — S360 target ID (service-level)
AppId — Application GUID (from CustomDimensions; empty for connector items)
AppDisplayName — App name (from CustomDimensions; empty for connector items)
AppHomeTenantId — Home tenant GUID
cloudType — "Public", "Fairfax", "Mooncake"
SLAState — "OnTime" or "OutOfSla"
CurrentDueDate — Current due date
CurrentETA — ETA set by action owner
ActionOwnerAlias — Action owner alias (from S360Dimensions)
ADOWorkItemHTMLUrl — ADO work item link (from S360Dimensions)
URL — TSG / remediation link
KpiActionItemId — Unique action item identifier
The
remediationCategory,severity, andtsgLinkfields are derived by the skill at classification time (Step 2), not from the MCP response.
If no violation data is provided (or the array is empty), respond:
"No violation data provided. Please run
fetch-violationsfirst to retrieve violations, then pass the results to this skill."
Step 2 — Classify violations for display
For each violation, compute three derived fields:
Remediation Category
Map ViolationTitle to a remediation category:
| ViolationTitle | Remediation Category |
|---|---|
AAD Entra Apps in Prod Tenants using certificates in Non-Prod Tenant | Cert Rehoming |
AAD Entra Apps cloud mismatches with Cert Cloud | Cert Rehoming |
AAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud auth scenarios | FIC Setup |
AAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud 1P scenarios | FIC Setup |
1pApp certs Tenant mismatches the Tenant of the Security Group | SG Update |
AAD Entra apps with cross-tenant violations | Cross-Tenant |
Connector domain OWNER should not use shared subjectName for authentication across tenant | Connector |
Connector domain CONSUMER should not use shared subjectName for authentication across tenant | Connector |
1P App with invalid security group | SG Update |
Severity Color
Determine severity based on remediation complexity:
- Green (Autofix eligible): Violations where an automated SG update can resolve them
1P App with invalid security group
- Yellow (Guidance available): Violations with clear TSG guidance but requiring manual owner action
AAD Entra Apps in Prod Tenants using certificates in Non-Prod TenantAAD Entra Apps cloud mismatches with Cert CloudAAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud auth scenariosAAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud 1P scenarios1pApp certs Tenant mismatches the Tenant of the Security Group— requires a follow-up SG-vs-cert decision
- Red (Complex remediation): Multi-step or cross-team coordination required
AAD Entra apps with cross-tenant violationsConnector domain OWNER should not use shared subjectName for authentication across tenantConnector domain CONSUMER should not use shared subjectName for authentication across tenant
TSG Link
Map ViolationTitle to the correct aka.ms troubleshooting guide:
| ViolationTitle | TSG Link |
|---|---|
AAD Entra Apps in Prod Tenants using certificates in Non-Prod Tenant | https://aka.ms/entraQ4 |
AAD Entra Apps cloud mismatches with Cert Cloud | https://aka.ms/entraQ5 |
AAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud auth scenarios | https://aka.ms/entraQ5 |
AAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud 1P scenarios | https://aka.ms/entraQ5 |
1pApp certs Tenant mismatches the Tenant of the Security Group | https://aka.ms/entraQ6 |
AAD Entra apps with cross-tenant violations | https://aka.ms/entraQ7 |
Connector domain OWNER should not use shared subjectName for authentication across tenant | https://aka.ms/entraQ8 |
Connector domain CONSUMER should not use shared subjectName for authentication across tenant | https://aka.ms/entraQ8 |
1P App with invalid security group | https://aka.ms/entraQ9 |
Store the classified results as violations[] with added fields:
remediationCategory, severity (green/yellow/red), tsgLink.
Compute summary:
total = violations.length
autofixCount = count where severity == 'green'
guidanceCount = count where severity == 'yellow'
complexCount = count where severity == 'red'
uniqueApps = distinct AppId count
Step 3 — Generate the HTML report
Write a complete, self-contained HTML file to:
.\s360-ti322-report-{YYYYMMDD}-{HHmmss}.html
The violations array must be serialized as JSON and embedded as a JS variable.
Use these placeholder substitutions:
{TARGET_LABEL} → filter description (alias/service/org) or "All"
{GENERATED} → current datetime ISO string
{VIOLATIONS_JSON} → JSON.stringify(violations) — each item contains all original
fields plus remediationCategory, severity, tsgLink
{TOTAL} → total
{AUTOFIX_COUNT} → autofixCount
{GUIDANCE_COUNT} → guidanceCount
{COMPLEX_COUNT} → complexCount
{UNIQUE_APPS} → uniqueApps
HTML template
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SFI-TI 3.2.2 Tenant Isolation — Violation Report</title>
<style>
/* ===== CSS Reset & Variables ===== */
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--surface2: #f0f2f5;
--border: #dfe3ea;
--header-bg: #1b2a4a;
--header-text: #ffffff;
--text: #1e293b;
--muted: #64748b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #16a34a;
--green-bg: rgba(22,163,74,.08);
--green-border: rgba(22,163,74,.25);
--yellow: #ca8a04;
--yellow-bg: rgba(202,138,4,.08);
--yellow-border: rgba(202,138,4,.25);
--red: #dc2626;
--red-bg: rgba(220,38,38,.08);
--red-border: rgba(220,38,38,.25);
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* ===== Header ===== */
.header {
background: var(--header-bg);
color: var(--header-text);
padding: 24px 32px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
}
.header h1 {
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.header .meta {
font-size: .82rem;
opacity: .75;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.header .meta span { white-space: nowrap; }
/* ===== Main container ===== */
.container {
max-width: 1440px;
margin: 0 auto;
padding: 24px 32px;
}
/* ===== Summary Cards ===== */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow);
transition: transform .15s, box-shadow .15s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,.1);
}
.card .num {
font-size: 2rem;
font-weight: 800;
line-height: 1;
}
.card .label {
color: var(--muted);
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .05em;
margin-top: 4px;
}
.card.total .num { color: var(--accent); }
.card.green .num { color: var(--green); }
.card.yellow .num { color: var(--yellow); }
.card.red .num { color: var(--red); }
.card.services .num { color: var(--muted); }
/* Card left-border accents */
.card.green { border-left: 4px solid var(--green); }
.card.yellow { border-left: 4px solid var(--yellow); }
.card.red { border-left: 4px solid var(--red); }
.card.total { border-left: 4px solid var(--accent); }
/* ===== Filters Bar ===== */
.filters {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 18px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
box-shadow: var(--shadow);
}
.filters label {
font-size: .78rem;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
}
.filters select,
.filters input[type="text"] {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 7px 12px;
font-size: .83rem;
font-family: var(--font);
outline: none;
transition: border-color .15s;
}
.filters select:focus,
.filters input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(59,130,246,.15);
}
.filters input[type="text"] { width: 240px; }
.filter-count {
margin-left: auto;
font-size: .82rem;
color: var(--muted);
font-weight: 600;
}
/* ===== Table ===== */
.tbl-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.tbl-scroll {
overflow-x: auto;
max-height: 75vh;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: .83rem;
}
thead { position: sticky; top: 0; z-index: 10; }
th {
background: var(--header-bg);
color: var(--header-text);
font-weight: 600;
text-align: left;
padding: 10px 14px;
font-size: .72rem;
text-transform: uppercase;
letter-spacing: .05em;
white-space: nowrap;
cursor: pointer;
user-select: none;
border-bottom: 2px solid rgba(255,255,255,.1);
}
th:hover { background: #243558; }
th .sort-arrow { margin-left: 4px; font-size: .65rem; opacity: .5; }
th.sorted .sort-arrow { opacity: 1; }
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr.hidden { display: none; }
tr:not(.hidden):hover td { background: rgba(59,130,246,.03); }
/* Severity row tinting */
tr.severity-green td { border-left-color: var(--green); }
tr.severity-yellow td { border-left-color: var(--yellow); }
tr.severity-red td { border-left-color: var(--red); }
tr.severity-green td:first-child { box-shadow: inset 4px 0 0 var(--green); }
tr.severity-yellow td:first-child { box-shadow: inset 4px 0 0 var(--yellow); }
tr.severity-red td:first-child { box-shadow: inset 4px 0 0 var(--red); }
/* ===== Badges & Dots ===== */
.severity-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
vertical-align: middle;
}
.dot-green { background: var(--green); }
.dot-yellow { background: var(--yellow); }
.dot-red { background: var(--red); }
.badge {
display: inline-block;
border-radius: 12px;
padding: 2px 10px;
font-size: .72rem;
font-weight: 600;
white-space: nowrap;
}
.badge-green { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); }
.badge-yellow { background: var(--yellow-bg); color: var(--yellow); border: 1px solid var(--yellow-border); }
.badge-red { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); }
.badge-1p { background: rgba(59,130,246,.1); color: var(--accent); border: 1px solid rgba(59,130,246,.25); }
.badge-3p { background: rgba(100,116,139,.1); color: var(--muted); border: 1px solid rgba(100,116,139,.25); }
/* ===== Links ===== */
a.tsg-link,
a.s360-link {
color: var(--accent);
text-decoration: none;
font-size: .78rem;
font-weight: 500;
white-space: nowrap;
}
a.tsg-link:hover,
a.s360-link:hover { text-decoration: underline; }
/* ===== Violation title ===== */
.violation-title {
font-size: .78rem;
max-width: 260px;
line-height: 1.35;
word-wrap: break-word;
}
.app-name { font-weight: 600; }
.cloud-tag {
display: inline-block;
font-size: .7rem;
color: var(--muted);
background: var(--surface2);
border-radius: 4px;
padding: 1px 6px;
}
.state-text {
font-size: .78rem;
line-height: 1.35;
max-width: 200px;
word-wrap: break-word;
}
/* ===== Empty state ===== */
#empty-state {
display: none;
text-align: center;
padding: 48px 20px;
color: var(--muted);
font-size: .95rem;
}
/* ===== Footer ===== */
.footer {
color: var(--muted);
font-size: .72rem;
margin-top: 32px;
text-align: center;
padding-bottom: 20px;
}
/* ===== Print styles ===== */
@media print {
body { background: #fff; font-size: 11px; }
.header { background: #1b2a4a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.filters { display: none; }
.tbl-scroll { max-height: none; overflow: visible; }
.card { break-inside: avoid; }
tr.hidden { display: none !important; }
a { color: var(--accent) !important; }
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.header { padding: 16px; }
.container { padding: 16px; }
.cards { grid-template-columns: repeat(2, 1fr); }
.filters input[type="text"] { width: 100%; }
}
</style>
</head>
<body>
<!-- ===== Header ===== -->
<div class="header">
<div>
<h1>🛡️ SFI-TI 3.2.2 Tenant Isolation — Violation Report</h1>
<div class="meta">
<span>Target: <strong>{TARGET_LABEL}</strong></span>
<span>Generated: {GENERATED}</span>
<span id="violation-counter"></span>
</div>
</div>
</div>
<!-- ===== Main Content ===== -->
<div class="container">
<!-- Summary Cards -->
<div class="cards">
<div class="card total">
<div class="num">{TOTAL}</div>
<div class="label">Total Violations</div>
</div>
<div class="card green">
<div class="num">{AUTOFIX_COUNT}</div>
<div class="label">✅ Autofix Eligible</div>
</div>
<div class="card yellow">
<div class="num">{GUIDANCE_COUNT}</div>
<div class="label">⚠️ Guidance Available</div>
</div>
<div class="card red">
<div class="num">{COMPLEX_COUNT}</div>
<div class="label">🔴 Complex Remediation</div>
</div>
<div class="card services">
<div class="num">{UNIQUE_APPS}</div>
<div class="label">Unique Apps</div>
</div>
</div>
<!-- Filters Bar -->
<div class="filters">
<label for="search">Search:</label>
<input id="search" type="text" placeholder="🔍 Search apps, violations, owners…" oninput="applyFilters()">
<label for="filter-violation">Violation:</label>
<select id="filter-violation" onchange="applyFilters()">
<option value="">All Violations</option>
</select>
<label for="filter-cloud">Cloud:</label>
<select id="filter-cloud" onchange="applyFilters()">
<option value="">All Clouds</option>
</select>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="applyFilters()">
<option value="">All Categories</option>
</select>
<label for="filter-severity">Severity:</label>
<select id="filter-severity" onchange="applyFilters()">
<option value="">All</option>
<option value="green">✅ Autofix</option>
<option value="yellow">⚠️ Guidance</option>
<option value="red">🔴 Complex</option>
</select>
<span class="filter-count" id="filter-count"></span>
</div>
<!-- Violations Table -->
<div class="tbl-wrap">
<div class="tbl-scroll">
<table id="main-table">
<thead>
<tr>
<th data-col="appName" onclick="sortTable('appName')">
App Name <span class="sort-arrow">▲▼</span>
</th>
<th data-col="violationTitle" onclick="sortTable('violationTitle')">
Violation <span class="sort-arrow">▲▼</span>
</th>
<th data-col="cloud" onclick="sortTable('cloud')">
Cloud <span class="sort-arrow">▲▼</span>
</th>
<th data-col="owner" onclick="sortTable('owner')">
Owner <span class="sort-arrow">▲▼</span>
</th>
<th data-col="sla" onclick="sortTable('sla')">
SLA <span class="sort-arrow">▲▼</span>
</th>
<th data-col="eta" onclick="sortTable('eta')">
ETA <span class="sort-arrow">▲▼</span>
</th>
<th data-col="category" onclick="sortTable('category')">
Category <span class="sort-arrow">▲▼</span>
</th>
<th>TSG</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty-state">No violations match your current filters.</div>
</div>
</div>
</div>
<div class="footer">
SFI-TI 3.2.2 Tenant Isolation Violation Report · Generated by GitHub Copilot CLI · {GENERATED}
</div>
<script>
// ===================================================================
// DATA — injected by the skill at generation time
// ===================================================================
const VIOLATIONS = {VIOLATIONS_JSON};
// ===================================================================
// CLASSIFICATION HELPERS
// ===================================================================
// Severity sort order for table sorting
const SEVERITY_ORDER = { red: 0, yellow: 1, green: 2 };
// Severity label map
const SEVERITY_LABELS = {
green: 'Autofix',
yellow: 'Guidance',
red: 'Complex'
};
// ===================================================================
// UTILITY FUNCTIONS
// ===================================================================
function escHtml(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function truncate(s, max) {
if (!s) return '';
return s.length > max ? s.substring(0, max) + '…' : s;
}
function severityDotHtml(sev) {
return '<span class="severity-dot dot-' + sev + '" title="' + (SEVERITY_LABELS[sev] || sev) + '"></span>';
}
function severityBadgeHtml(sev) {
const label = SEVERITY_LABELS[sev] || sev;
return '<span class="badge badge-' + sev + '">' + escHtml(label) + '</span>';
}
function appTypeBadgeHtml(type) {
const cls = (type || '').toUpperCase() === '1P' ? 'badge-1p' : 'badge-3p';
return '<span class="badge ' + cls + '">' + escHtml(type || '—') + '</span>';
}
// ===================================================================
// POPULATE FILTER DROPDOWNS
// ===================================================================
// Violation titles
const violationTitles = [...new Set(VIOLATIONS.map(v => v.Title || v.ViolationTitle))].filter(Boolean).sort();
const violationSelect = document.getElementById('filter-violation');
violationTitles.forEach(t => {
const o = document.createElement('option');
o.value = t;
o.textContent = truncate(t, 70);
violationSelect.appendChild(o);
});
// Clouds
const clouds = [...new Set(VIOLATIONS.map(v => v.cloudType || v.AppCloudType))].filter(Boolean).sort();
const cloudSelect = document.getElementById('filter-cloud');
clouds.forEach(c => {
const o = document.createElement('option');
o.value = c;
o.textContent = c;
cloudSelect.appendChild(o);
});
// Remediation categories
const categories = [...new Set(VIOLATIONS.map(v => v.remediationCategory))].filter(Boolean).sort();
const categorySelect = document.getElementById('filter-category');
categories.forEach(c => {
const o = document.createElement('option');
o.value = c;
o.textContent = c;
categorySelect.appendChild(o);
});
// ===================================================================
// RENDER TABLE ROWS
// ===================================================================
const tbody = document.getElementById('tbody');
VIOLATIONS.forEach((v, idx) => {
const tr = document.createElement('tr');
tr.dataset.idx = idx;
tr.dataset.violation = v.Title || v.ViolationTitle || '';
tr.dataset.cloud = v.cloudType || v.AppCloudType || '';
tr.dataset.category = v.remediationCategory || '';
tr.dataset.severity = v.severity || '';
tr.dataset.sortSeverity = String(SEVERITY_ORDER[v.severity || 'yellow'] ?? 99);
tr.dataset.search = [
v.AppDisplayName, v.AppId, v.Title || v.ViolationTitle,
v.cloudType || v.AppCloudType, v.ActionOwnerAlias,
v.SLAState, v.TargetId, v.remediationCategory
].join(' ').toLowerCase();
// Severity-based row class
tr.className = 'severity-' + (v.severity || 'yellow');
// Sort-friendly data attributes
tr.dataset.sortAppname = (v.AppDisplayName || '').toLowerCase();
tr.dataset.sortViolation = (v.Title || v.ViolationTitle || '').toLowerCase();
tr.dataset.sortCloud = (v.cloudType || v.AppCloudType || '').toLowerCase();
tr.dataset.sortOwner = (v.ActionOwnerAlias || '').toLowerCase();
tr.dataset.sortSla = (v.SLAState || '').toLowerCase();
tr.dataset.sortEta = (v.CurrentETA || '').toLowerCase();
tr.dataset.sortCategory = (v.remediationCategory || '').toLowerCase();
tr.innerHTML = [
// App Name
'<td><div class="app-name">' + escHtml(v.AppDisplayName) + '</div>'
+ '<div style="font-size:.72rem;color:var(--muted)">'
+ escHtml(v.AppId || v.TargetId || '')
+ '</div></td>',
// Violation Title
'<td><div class="violation-title">' + escHtml(truncate(v.Title || v.ViolationTitle, 80)) + '</div></td>',
// Cloud
'<td><span class="cloud-tag">' + escHtml(v.cloudType || v.AppCloudType || '—') + '</span></td>',
// Owner
'<td><div class="state-text">' + escHtml(v.ActionOwnerAlias || '—') + '</div></td>',
// SLA
'<td><div class="state-text">' + escHtml(v.SLAState || '—') + '</div></td>',
// ETA
'<td><div class="state-text">' + escHtml(v.CurrentETA || '—') + '</div></td>',
// Remediation Category
'<td>' + severityBadgeHtml(v.severity) + '<div style="margin-top:3px;font-size:.75rem;color:var(--muted)">'
+ escHtml(v.remediationCategory || '') + '</div></td>',
// TSG Link
'<td>' + (v.tsgLink
? '<a class="tsg-link" href="' + escHtml(v.tsgLink) + '" target="_blank" rel="noopener">TSG ↗</a>'
: '<span style="color:var(--muted)">—</span>') + '</td>'
].join('');
tbody.appendChild(tr);
});
// ===================================================================
// FILTERING
// ===================================================================
function applyFilters() {
const q = document.getElementById('search').value.toLowerCase();
const violation = document.getElementById('filter-violation').value;
const cloud = document.getElementById('filter-cloud').value;
const category = document.getElementById('filter-category').value;
const severity = document.getElementById('filter-severity').value;
let visible = 0;
const rows = tbody.querySelectorAll('tr');
rows.forEach(tr => {
const matchSearch = !q || tr.dataset.search.includes(q);
const matchViolation = !violation || tr.dataset.violation === violation;
const matchCloud = !cloud || tr.dataset.cloud === cloud;
const matchCategory = !category || tr.dataset.category === category;
const matchSeverity = !severity || tr.dataset.severity === severity;
const show = matchSearch && matchViolation && matchCloud && matchCategory && matchSeverity;
tr.classList.toggle('hidden', !show);
if (show) visible++;
});
document.getElementById('filter-count').textContent =
visible + ' of ' + VIOLATIONS.length + ' violations';
document.getElementById('empty-state').style.display =
visible === 0 ? 'block' : 'none';
}
// Initialize filter count
applyFilters();
// ===================================================================
// TABLE SORTING
// ===================================================================
let currentSort = { col: null, asc: true };
function sortTable(col) {
const rows = Array.from(tbody.querySelectorAll('tr'));
// Toggle direction if same column
if (currentSort.col === col) {
currentSort.asc = !currentSort.asc;
} else {
currentSort.col = col;
currentSort.asc = true;
}
const dataAttr = 'sort' + col.charAt(0).toUpperCase() + col.slice(1);
const dir = currentSort.asc ? 1 : -1;
rows.sort((a, b) => {
const va = a.dataset[dataAttr] || '';
const vb = b.dataset[dataAttr] || '';
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return 0;
});
// Re-append sorted rows
rows.forEach(r => tbody.appendChild(r));
// Update sort arrow indicators
document.querySelectorAll('th').forEach(th => th.classList.remove('sorted'));
const activeTh = document.querySelector('th[data-col="' + col + '"]');
if (activeTh) {
activeTh.classList.add('sorted');
const arrow = activeTh.querySelector('.sort-arrow');
if (arrow) arrow.textContent = currentSort.asc ? '▲' : '▼';
}
}
// Default sort: severity (red first), then app name
sortTable('severity');
// ===================================================================
// VIOLATION COUNTER in header
// ===================================================================
document.getElementById('violation-counter').textContent =
VIOLATIONS.length + ' violation' + (VIOLATIONS.length !== 1 ? 's' : '') + ' found';
</script>
</body>
</html>
Step 4 — Save the report
Save the generated HTML file using a timestamped filename:
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportPath = ".\s360-ti322-report-$timestamp.html"
# ... write the HTML content to $reportPath ...
Set-Content -Path $reportPath -Value $htmlContent -Encoding UTF8
Then open in the default browser:
Start-Process $reportPath
Report the full path to the user:
"Report saved to: {reportPath}"
Step 5 — Summary output
After generating the file and opening the browser, print a text summary:
📊 Generated: s360-ti322-report-{timestamp}.html
Total violations: {total}
✅ Autofix eligible: {autofixCount} (SG update scenarios)
⚠️ Guidance available: {guidanceCount} (cert rehoming, FIC setup)
🔴 Complex remediation: {complexCount} (FIC/cross-tenant/connector)
Unique apps: {uniqueApps}
If 3+ violations are in the "red" (complex) category, list the top 3:
🚨 Most complex violations:
1. [{AppDisplayName}] {ViolationTitle} — {ActionOwnerAlias}
2. ...
3. ...
Error handling
| Scenario | Response |
|---|---|
| No violation data provided | "No violation data. Run fetch-violations first." |
| Empty violations array | "No violations found for this target. Fully compliant 🎉" |
| File write failure | Report the error and suggest an alternate path |
| Unknown ViolationTitle | Classify as yellow severity, category "Unknown", no TSG link |
Classification reference
Full mapping table for quick lookup during generation:
| ViolationTitle | Category | Severity | TSG |
|---|---|---|---|
| AAD Entra Apps in Prod Tenants using certificates in Non-Prod Tenant | Cert Rehoming | yellow | aka.ms/entraQ4 |
| AAD Entra Apps cloud mismatches with Cert Cloud | Cert Rehoming | yellow | aka.ms/entraQ5 |
| AAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud auth scenarios | FIC Setup | yellow | aka.ms/entraQ5 |
| AAD Entra Apps cloud mismatches with Cert Cloud - Cross-cloud 1P scenarios | FIC Setup | yellow | aka.ms/entraQ5 |
| 1pApp certs Tenant mismatches the Tenant of the Security Group | SG / Cert Decision | yellow | aka.ms/entraQ6 |
| AAD Entra apps with cross-tenant violations | Cross-Tenant | red | aka.ms/entraQ7 |
| Connector domain OWNER should not use shared subjectName for authentication across tenant | Connector | red | aka.ms/entraQ8 |
| Connector domain CONSUMER should not use shared subjectName for authentication across tenant | Connector | red | aka.ms/entraQ8 |
| 1P App with invalid security group | SG Update | green | aka.ms/entraQ9 |
Key tools used
| Tool | MCP | Purpose |
|---|---|---|
PowerShell (Set-Content, Start-Process) | — | Write HTML file, open browser |
fetch-violations skill (upstream) | s360-breeze | Provides violation data input |