From e304b2b3d4f15650e37b533153a0a686d2d31367 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Wed, 6 May 2026 13:49:04 +0200 Subject: [PATCH] Refactor scanner into modular package and add AlertHub-style frontend - Split scanner.py into scanners/ package (entra, mailbox, sharepoint, common) - Add Exchange Online PowerShell probe scripts under scanners/exo_scripts - Frontend overhaul: AlertHub-style sidebar layout, dark logo asset, expanded app.js/index.html/styles.css - Backend updates across main.py, worker.py, models.py, schemas.py, csv_import.py - Update Dockerfile and build-and-push.sh - Update TECHNICAL.md, changelog-develop.md, add summary changelog.md Co-Authored-By: Claude Opus 4.7 (1M context) --- build-and-push.sh | 220 ++--- containers/clearview/Dockerfile | 24 +- containers/clearview/site/app.js | 676 ++++++++++++++- .../site/assets/clearview-logo-dark.svg | 16 + containers/clearview/site/index.html | 799 +++++++++++------- containers/clearview/site/styles.css | 154 ++++ .../clearview/src/clearview_app/csv_import.py | 82 +- .../clearview/src/clearview_app/main.py | 535 ++++++++++-- .../clearview/src/clearview_app/models.py | 8 + .../clearview/src/clearview_app/scanner.py | 488 +---------- .../src/clearview_app/scanners/__init__.py | 61 ++ .../src/clearview_app/scanners/common.py | 52 ++ .../src/clearview_app/scanners/entra.py | 293 +++++++ .../src/clearview_app/scanners/mailbox.py | 257 ++++++ .../src/clearview_app/scanners/sharepoint.py | 722 ++++++++++++++++ .../clearview/src/clearview_app/schemas.py | 32 +- .../clearview/src/clearview_app/worker.py | 76 +- docs/TECHNICAL.md | 113 ++- docs/changelog-develop.md | 49 ++ docs/changelog.md | 15 + 20 files changed, 3709 insertions(+), 963 deletions(-) create mode 100644 containers/clearview/site/assets/clearview-logo-dark.svg create mode 100644 containers/clearview/src/clearview_app/scanners/__init__.py create mode 100644 containers/clearview/src/clearview_app/scanners/common.py create mode 100644 containers/clearview/src/clearview_app/scanners/entra.py create mode 100644 containers/clearview/src/clearview_app/scanners/mailbox.py create mode 100644 containers/clearview/src/clearview_app/scanners/sharepoint.py create mode 100644 docs/changelog.md diff --git a/build-and-push.sh b/build-and-push.sh index aa35196..6b4be0e 100755 --- a/build-and-push.sh +++ b/build-and-push.sh @@ -1,52 +1,56 @@ #!/usr/bin/env bash set -euo pipefail +# ============================================================================ +# build-and-push.sh +# +# Purpose: +# - Build & push Docker images for each service under ./containers/* +# - Two modes: +# t (test) = only push :dev +# r (release) = push :, :dev, :latest +# version is read from the top of changelog.md +# +# No git operations: committing and tagging is done manually. +# +# Usage: +# ./build-and-push.sh [mode] +# - mode = t -> test build, push :dev only +# - mode = r -> release build, version taken from changelog.md +# - omitted -> prompt (default: t) +# +# Requirements: +# - docs/changelog.md (relative to repo root), with the most recent release +# at the top as: +# ## vX.Y.Z — YYYY-MM-DD +# (the version is parsed from the first such line) +# - One Dockerfile per service under ./containers//Dockerfile +# ============================================================================ + DOCKER_REGISTRY="gitea.oskamp.info" DOCKER_NAMESPACE="ivooskamp" -VERSION_FILE="version.txt" -START_VERSION="v0.1.0" +CHANGELOG_FILE="docs/changelog.md" CONTAINERS_DIR="containers" -LAST_BRANCH_FILE=".last-branch" -BUMP="${1:-}" -if [[ -z "${BUMP}" ]]; then - echo "Select bump type: [1] patch, [2] minor, [3] major, [t] test (default: t)" - read -r BUMP - BUMP="${BUMP:-t}" +# --- Input: prompt if missing ------------------------------------------------ +MODE="${1:-}" +if [[ -z "${MODE}" ]]; then + echo "Select build type: [t] test build (push :dev only), [r] release build (default: t)" + read -r MODE + MODE="${MODE:-t}" fi -if [[ "$BUMP" != "1" && "$BUMP" != "2" && "$BUMP" != "3" && "$BUMP" != "t" ]]; then - echo "[ERROR] Unknown bump type '$BUMP' (use 1, 2, 3, or t)." - exit 1 -fi - -read_version() { - if [[ -f "$VERSION_FILE" ]]; then - tr -d ' \t\n\r' < "$VERSION_FILE" - else - echo "$START_VERSION" - fi -} - -write_version() { - echo "$1" > "$VERSION_FILE" -} - -bump_version() { - local cur="$1" - local kind="$2" - local core="${cur#v}" - IFS='.' read -r MA MI PA <<< "$core" - case "$kind" in - 1) PA=$((PA + 1));; - 2) MI=$((MI + 1)); PA=0;; - 3) MA=$((MA + 1)); MI=0; PA=0;; - *) echo "[ERROR] Unknown bump kind"; exit 1;; - esac - echo "v${MA}.${MI}.${PA}" -} +case "$MODE" in + t|test) MODE="t" ;; + r|release) MODE="r" ;; + *) + echo "[ERROR] Unknown mode '$MODE' (use 't' for test or 'r' for release)." + exit 1 + ;; +esac +# --- Helpers ----------------------------------------------------------------- check_docker_ready() { if ! docker info >/dev/null 2>&1; then echo "[ERROR] Docker daemon not reachable. Is Docker running and do you have permission to use it?" @@ -57,11 +61,11 @@ check_docker_ready() { ensure_registry_login() { local cfg="${HOME}/.docker/config.json" if [[ ! -f "$cfg" ]]; then - echo "[ERROR] Docker config not found at $cfg. Please login: docker login ${DOCKER_REGISTRY}" + echo "[ERROR] Docker config not found at $cfg. Please login: docker login ${DOCKER_REGISTRY}" exit 1 fi if ! grep -q "\"${DOCKER_REGISTRY}\"" "$cfg"; then - echo "[ERROR] No registry auth found for ${DOCKER_REGISTRY}. Please run: docker login ${DOCKER_REGISTRY}" + echo "[ERROR] No registry auth found for ${DOCKER_REGISTRY}. Please run: docker login ${DOCKER_REGISTRY}" exit 1 fi } @@ -70,7 +74,7 @@ validate_repo_component() { local comp="$1" if [[ ! "$comp" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then echo "[ERROR] Invalid repository component '$comp'." - echo " Must match: ^[a-z0-9]+([._-][a-z0-9]+)*$" + echo " Must match: ^[a-z0-9]+([._-][a-z0-9]+)*$ (lowercase, digits, ., _, - as separators)." return 1 fi } @@ -88,11 +92,33 @@ validate_tag() { fi } -if [[ ! -d ".git" ]]; then - echo "[ERROR] Not a git repository (.git missing)." - exit 1 -fi +# Parse the first "## vX.Y.Z ..." heading from changelog.md. +# Accepts: ## v1.0.3 — 2026-04-24 +# ## v1.0.3 - 2026-04-24 +# ## v1.0.3 +read_version_from_changelog() { + if [[ ! -f "$CHANGELOG_FILE" ]]; then + echo "[ERROR] $CHANGELOG_FILE not found in $(pwd)." >&2 + exit 1 + fi + local line + # Match lines starting with "## v.." + line="$(grep -m1 -E '^##[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+' "$CHANGELOG_FILE" || true)" + if [[ -z "$line" ]]; then + echo "[ERROR] No release heading found in $CHANGELOG_FILE (expected e.g. '## v1.0.3 — 2026-04-24' near the top)." >&2 + exit 1 + fi + # Extract the vX.Y.Z token + local version + version="$(echo "$line" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)" + if [[ -z "$version" ]]; then + echo "[ERROR] Could not parse version from line: $line" >&2 + exit 1 + fi + echo "$version" +} +# --- Preflight --------------------------------------------------------------- if [[ ! -d "$CONTAINERS_DIR" ]]; then echo "[ERROR] '$CONTAINERS_DIR' directory missing. Expected ./${CONTAINERS_DIR}// with a Dockerfile." exit 1 @@ -102,59 +128,40 @@ check_docker_ready ensure_registry_login validate_repo_component "$DOCKER_NAMESPACE" -DETECTED_BRANCH="$(git branch --show-current 2>/dev/null || true)" -if [[ -z "$DETECTED_BRANCH" ]]; then - DETECTED_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" -fi -if [[ -z "$DETECTED_BRANCH" ]]; then - DETECTED_BRANCH="main" -fi - -UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "origin/$DETECTED_BRANCH")" -HEAD_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" -LAST_BRANCH_FILE_PATH="$(pwd)/$LAST_BRANCH_FILE" - -echo "[INFO] Repo: $(pwd)" -echo "[INFO] Current branch: $DETECTED_BRANCH" -echo "[INFO] Upstream: $UPSTREAM_REF" -echo "[INFO] HEAD (sha): $HEAD_SHA" - -CURRENT_VERSION="$(read_version)" -NEW_VERSION="$CURRENT_VERSION" -DO_TAG_AND_BUMP=true - -if [[ "$BUMP" == "t" ]]; then - echo "[INFO] Test build: keeping version $CURRENT_VERSION; will only update :dev." - DO_TAG_AND_BUMP=false +# Informational: show branch and HEAD if this happens to be a git repo. +BRANCH_INFO="" +HEAD_INFO="" +if [[ -d ".git" ]]; then + BRANCH_INFO="$(git branch --show-current 2>/dev/null || echo unknown)" + HEAD_INFO="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + echo "[INFO] Repo: $(pwd)" + echo "[INFO] Current branch: $BRANCH_INFO" + echo "[INFO] HEAD (sha): $HEAD_INFO" else - NEW_VERSION="$(bump_version "$CURRENT_VERSION" "$BUMP")" - echo "[INFO] New version: $NEW_VERSION" + echo "[INFO] Repo: $(pwd) (not a git checkout)" fi -if $DO_TAG_AND_BUMP; then - validate_tag "$NEW_VERSION" +# --- Determine version (release only) ---------------------------------------- +VERSION="" +if [[ "$MODE" == "r" ]]; then + VERSION="$(read_version_from_changelog)" + echo "[INFO] Release version (from $CHANGELOG_FILE): $VERSION" + validate_tag "$VERSION" validate_tag "latest" + + # Ask for confirmation so you never accidentally re-push an old version or a wrong one. + read -r -p "Proceed building & pushing as ${VERSION}? [y/N] " CONFIRM + CONFIRM="${CONFIRM:-N}" + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo "[INFO] Aborted by user." + exit 0 + fi +else + echo "[INFO] Test build: only :dev will be pushed." fi validate_tag "dev" -if $DO_TAG_AND_BUMP; then - echo "[INFO] Writing $NEW_VERSION to $VERSION_FILE" - write_version "$NEW_VERSION" - - echo "[INFO] Git add + commit (branch: $DETECTED_BRANCH)" - git add "$VERSION_FILE" - git commit -m "Release $NEW_VERSION on branch $DETECTED_BRANCH (bump type $BUMP)" - - echo "[INFO] Git tag $NEW_VERSION" - git tag -a "$NEW_VERSION" -m "Release $NEW_VERSION" - - echo "[INFO] Git push + tags" - git push origin "$DETECTED_BRANCH" - git push --tags -else - echo "[INFO] Skipping commit/tagging (test build)." -fi - +# --- Build & push per service ------------------------------------------------ shopt -s nullglob services=( "$CONTAINERS_DIR"/* ) if [[ ${#services[@]} -eq 0 ]]; then @@ -178,21 +185,21 @@ for svc_path in "${services[@]}"; do IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}" - if $DO_TAG_AND_BUMP; then + if [[ "$MODE" == "r" ]]; then echo "============================================================" - echo "[INFO] Building ${svc} -> tags: ${NEW_VERSION}, dev, latest" + echo "[INFO] Building ${svc} -> tags: ${VERSION}, dev, latest" echo "============================================================" docker build \ - -t "${IMAGE_BASE}:${NEW_VERSION}" \ + -t "${IMAGE_BASE}:${VERSION}" \ -t "${IMAGE_BASE}:dev" \ -t "${IMAGE_BASE}:latest" \ "$svc_path" - docker push "${IMAGE_BASE}:${NEW_VERSION}" + docker push "${IMAGE_BASE}:${VERSION}" docker push "${IMAGE_BASE}:dev" docker push "${IMAGE_BASE}:latest" - BUILT_IMAGES+=("${IMAGE_BASE}:${NEW_VERSION}" "${IMAGE_BASE}:dev" "${IMAGE_BASE}:latest") + BUILT_IMAGES+=("${IMAGE_BASE}:${VERSION}" "${IMAGE_BASE}:dev" "${IMAGE_BASE}:latest") else echo "============================================================" echo "[INFO] Test build ${svc} -> tag: dev" @@ -203,18 +210,27 @@ for svc_path in "${services[@]}"; do fi done -echo "$DETECTED_BRANCH" > "$LAST_BRANCH_FILE_PATH" - +# --- Summary ----------------------------------------------------------------- echo "" echo "============================================================" -echo "[SUMMARY] Build & push complete (branch: $DETECTED_BRANCH)" -if $DO_TAG_AND_BUMP; then - echo "[INFO] Release version: $NEW_VERSION" +if [[ "$MODE" == "r" ]]; then + echo "[SUMMARY] Release build & push complete: $VERSION" else - echo "[INFO] Test build (no version bump)" + echo "[SUMMARY] Test build & push complete (:dev only)" +fi +if [[ -n "$BRANCH_INFO" ]]; then + echo "[INFO] Branch: $BRANCH_INFO HEAD: $HEAD_INFO" fi echo "[INFO] Images pushed:" for img in "${BUILT_IMAGES[@]}"; do echo " - $img" done echo "============================================================" +echo "" +echo "[REMINDER] No git operations were performed. If this was a release," +echo " commit and tag manually, e.g.:" +if [[ "$MODE" == "r" ]]; then + echo " git add -A && git commit -m \"Release ${VERSION}\"" + echo " git tag -a ${VERSION} -m \"Release ${VERSION}\"" + echo " git push && git push --tags" +fi diff --git a/containers/clearview/Dockerfile b/containers/clearview/Dockerfile index b940b14..91b8181 100644 --- a/containers/clearview/Dockerfile +++ b/containers/clearview/Dockerfile @@ -1,11 +1,33 @@ -FROM python:3.12-slim +FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app/src +# Suppress PowerShell telemetry inside the container +ENV POWERSHELL_TELEMETRY_OPTOUT=1 +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 WORKDIR /app +# --------------------------------------------------------------------------- +# PowerShell 7 + ExchangeOnlineManagement module +# Required for Exchange Online mailbox permission scanning. +# --------------------------------------------------------------------------- +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \ + -o /tmp/packages-microsoft-prod.deb \ + && dpkg -i /tmp/packages-microsoft-prod.deb \ + && rm /tmp/packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends powershell \ + && pwsh -NoProfile -NonInteractive -Command \ + "Set-PSRepository -Name PSGallery -InstallationPolicy Trusted; \ + Install-Module -Name ExchangeOnlineManagement -Scope AllUsers -Force -AllowClobber" \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt diff --git a/containers/clearview/site/app.js b/containers/clearview/site/app.js index e48462a..def074f 100644 --- a/containers/clearview/site/app.js +++ b/containers/clearview/site/app.js @@ -4,6 +4,18 @@ selectedJobData: null, refreshTimer: null, tenants: [], + sharingLinkSelectionByJob: {}, + currentRoute: 'dashboard', + }; + + const ROUTE_TITLES = { + 'dashboard': 'Dashboard', + 'jobs': 'Scan Jobs', + 'scan-sharepoint': 'New SharePoint Scan', + 'scan-mailbox': 'New Mailbox Scan', + 'scan-entra': 'New Entra Group Scan', + 'tenants': 'Tenants', + 'settings': 'Settings', }; const els = { @@ -24,6 +36,7 @@ scanAppDisplayName: document.getElementById('scanAppDisplayName'), newTenantName: document.getElementById('newTenantName'), newTenantTenantId: document.getElementById('newTenantTenantId'), + newTenantPrimaryDomain: document.getElementById('newTenantPrimaryDomain'), newTenantClientId: document.getElementById('newTenantClientId'), newTenantClientSecret: document.getElementById('newTenantClientSecret'), saveTenantBtn: document.getElementById('saveTenantBtn'), @@ -43,11 +56,34 @@ csvFile: document.getElementById('csvFile'), csvSkipDefaults: document.getElementById('csvSkipDefaults'), submitFeedback: document.getElementById('submitFeedback'), + sharepointScanMode: document.getElementById('sharepointScanMode'), + // Mailbox scan panel + entraScanTenantSelect: document.getElementById('entraScanTenantSelect'), + manualEntraForm: document.getElementById('manualEntraForm'), + csvEntraForm: document.getElementById('csvEntraForm'), + allEntraForm: document.getElementById('allEntraForm'), + manualEntraIds: document.getElementById('manualEntraIds'), + csvEntraFile: document.getElementById('csvEntraFile'), + entraSubmitFeedback: document.getElementById('entraSubmitFeedback'), + mailboxScanTenantSelect: document.getElementById('mailboxScanTenantSelect'), + manualMailboxForm: document.getElementById('manualMailboxForm'), + csvMailboxForm: document.getElementById('csvMailboxForm'), + allMailboxesForm: document.getElementById('allMailboxesForm'), + allMailboxesOrg: document.getElementById('allMailboxesOrg'), + manualMailboxes: document.getElementById('manualMailboxes'), + csvMailboxFile: document.getElementById('csvMailboxFile'), + mailboxSubmitFeedback: document.getElementById('mailboxSubmitFeedback'), // Jobs panel refreshJobsBtn: document.getElementById('refreshJobsBtn'), jobTenantFilter: document.getElementById('jobTenantFilter'), + jobTypeFilter: document.getElementById('jobTypeFilter'), jobsTableBody: document.getElementById('jobsTableBody'), jobAutoRefresh: document.getElementById('jobAutoRefresh'), + // Sidebar / routing + contentTitle: document.getElementById('contentTitle'), + targetsTableHead: document.getElementById('targetsTableHead'), + targetsHeading: document.getElementById('targetsHeading'), + deviationsTableHead: document.getElementById('deviationsTableHead'), // Job detail panel targetsTableBody: document.getElementById('targetsTableBody'), deviationsTableBody: document.getElementById('deviationsTableBody'), @@ -60,6 +96,9 @@ sharingLinksTypes: document.getElementById('sharingLinksTypes'), resolveSharingLinksBtn: document.getElementById('resolveSharingLinksBtn'), resolveFeedback: document.getElementById('resolveFeedback'), + resolveGroupsBlock: document.getElementById('resolveGroupsBlock'), + resolveGroupsBtn: document.getElementById('resolveGroupsBtn'), + resolveGroupsFeedback: document.getElementById('resolveGroupsFeedback'), // Hero stats statTenants: document.getElementById('statTenants'), statJobs: document.getElementById('statJobs'), @@ -97,6 +136,43 @@ return date.toLocaleString(); } + function renderProbeStatus(target) { + if (!target.last_probe_at) { + return 'Not tested yet'; + } + const when = formatDate(target.last_probe_at); + const msg = target.last_probe_message || ''; + if (target.last_probe_ok) { + return 'OK ' + escHtml(when) + ''; + } + return 'Failed ' + escHtml(when) + '
' + escHtml(msg) + ''; + } + + async function testTargetConnection(targetId, button) { + if (!state.selectedJobId) return; + const originalLabel = button.textContent; + button.disabled = true; + button.textContent = 'Testing…'; + try { + const resp = await fetch( + '/api/scan-jobs/' + encodeURIComponent(state.selectedJobId) + + '/targets/' + encodeURIComponent(targetId) + '/test-connection', + { method: 'POST' } + ); + if (!resp.ok) { + const body = await resp.text(); + throw new Error('HTTP ' + resp.status + ': ' + body); + } + await resp.json(); + await refreshSelectedJob(); + } catch (err) { + window.alert('Test failed: ' + err.message); + } finally { + button.disabled = false; + button.textContent = originalLabel; + } + } + function statusBadge(status) { const cls = status === 'completed' ? 'ok' : status === 'running' ? 'warn' @@ -176,11 +252,9 @@ els.tenantsTableBody.querySelectorAll('[data-tenant-scan]').forEach(function (btn) { btn.addEventListener('click', function () { const id = btn.getAttribute('data-tenant-scan'); - // Pre-select this tenant in the scan form els.scanTenantSelect.value = id; onScanTenantChange(); - // Scroll to scan panel - els.manualScanForm.closest('.panel').scrollIntoView({ behavior: 'smooth' }); + navigateTo('scan-sharepoint'); }); }); @@ -198,7 +272,7 @@ } function populateTenantDropdowns() { - // Scan tenant select + // SharePoint scan tenant select (supports manual creds) const scanVal = els.scanTenantSelect.value; els.scanTenantSelect.innerHTML = '' + @@ -210,6 +284,36 @@ els.scanTenantSelect.value = scanVal; } + // Entra scan tenant select (cert required for Graph) + if (els.entraScanTenantSelect) { + const ev = els.entraScanTenantSelect.value; + els.entraScanTenantSelect.innerHTML = + '' + + state.tenants.map(function (t) { + var label = escHtml(t.name); + if (!t.has_certificate) label += ' (no certificate)'; + return ''; + }).join(''); + if (ev) els.entraScanTenantSelect.value = ev; + } + + // Mailbox scan tenant select (cert only, no manual creds) + if (els.mailboxScanTenantSelect) { + const mbVal = els.mailboxScanTenantSelect.value; + els.mailboxScanTenantSelect.innerHTML = + '' + + state.tenants.map(function (t) { + var label = escHtml(t.name); + if (!t.has_certificate) { + label += ' (no certificate)'; + } + return ''; + }).join(''); + if (mbVal) { + els.mailboxScanTenantSelect.value = mbVal; + } + } + // Job tenant filter select const filterVal = els.jobTenantFilter.value; els.jobTenantFilter.innerHTML = @@ -244,6 +348,7 @@ async function saveTenant() { const name = (els.newTenantName.value || '').trim(); const tenantId = (els.newTenantTenantId.value || '').trim(); + const primaryDomain = (els.newTenantPrimaryDomain ? els.newTenantPrimaryDomain.value : '').trim().toLowerCase(); const clientId = (els.newTenantClientId.value || '').trim(); const clientSecret = (els.newTenantClientSecret.value || '').trim(); @@ -256,7 +361,13 @@ await requestJson('/api/tenants', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name, tenant_id: tenantId, client_id: clientId, client_secret: clientSecret }), + body: JSON.stringify({ + name: name, + tenant_id: tenantId, + primary_domain: primaryDomain || null, + client_id: clientId, + client_secret: clientSecret, + }), }); showFeedback(els.tenantFeedback, 'Tenant "' + name + '" saved.', 'ok'); closeTenantForm(); @@ -287,6 +398,7 @@ els.addTenantBtn.removeAttribute('hidden'); els.newTenantName.value = ''; els.newTenantTenantId.value = ''; + if (els.newTenantPrimaryDomain) els.newTenantPrimaryDomain.value = ''; els.newTenantClientId.value = ''; els.newTenantClientSecret.value = ''; if (els.connectedTenantId) els.connectedTenantId.value = ''; @@ -371,6 +483,7 @@ if (status === 'connected') { const tenantId = params.get('tenant_id') || ''; + navigateTo('tenants'); openTenantForm(); if (tenantId && els.newTenantTenantId) { els.newTenantTenantId.value = tenantId; @@ -422,7 +535,8 @@ return; } try { - const payload = Object.assign({ site_urls: urls, skip_default_sites: !!els.manualSkipDefaults.checked }, auth); + const mode = (els.sharepointScanMode && els.sharepointScanMode.value) || 'sharepoint'; + const payload = Object.assign({ scan_type: mode, site_urls: urls, skip_default_sites: !!els.manualSkipDefaults.checked }, auth); const result = await requestJson('/api/scan-jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -459,8 +573,10 @@ return; } const skipDefaults = els.csvSkipDefaults.checked ? 'true' : 'false'; + const mode = (els.sharepointScanMode && els.sharepointScanMode.value) || 'sharepoint'; const formData = new FormData(); formData.append('file', file); + formData.append('scan_type', mode); if (auth.tenant_profile_id) { formData.append('tenant_profile_id', auth.tenant_profile_id); } else { @@ -495,10 +611,14 @@ async function refreshJobs() { const filterTenant = els.jobTenantFilter.value; + const filterType = els.jobTypeFilter ? els.jobTypeFilter.value : ''; let url = '/api/scan-jobs?limit=50'; if (filterTenant) { url += '&tenant_profile_id=' + encodeURIComponent(filterTenant); } + if (filterType) { + url += '&scan_type=' + encodeURIComponent(filterType); + } const jobs = await requestJson(url); els.statJobs.textContent = String(jobs.length); @@ -507,7 +627,7 @@ }).length); if (!jobs.length) { - els.jobsTableBody.innerHTML = 'No jobs yet.'; + els.jobsTableBody.innerHTML = 'No jobs yet.'; return; } @@ -522,9 +642,21 @@ const tenantLabel = job.tenant_name ? '' + escHtml(job.tenant_name) + '' : 'manual'; + const scanType = job.scan_type || 'sharepoint'; + var typeLabel; + if (scanType === 'mailbox') { + typeLabel = 'Mailbox'; + } else if (scanType === 'sharepoint_root') { + typeLabel = 'SP Root'; + } else if (scanType === 'entra_groups') { + typeLabel = 'Entra'; + } else { + typeLabel = 'SharePoint'; + } return ( '' + '' + job.id + '' + + '' + typeLabel + '' + '' + tenantLabel + '' + '' + job.source_type + '' + '' + statusBadge(job.status) + '' + @@ -546,6 +678,7 @@ els.jobsTableBody.querySelectorAll('[data-job-inspect]').forEach(function (button) { button.addEventListener('click', function () { state.selectedJobId = button.getAttribute('data-job-inspect'); + navigateTo('jobs'); refreshSelectedJob().catch(function () { showFeedback(els.submitFeedback, 'Failed to load selected job details.', 'error'); }); @@ -586,7 +719,7 @@ state.selectedJobData = null; els.selectedJobId.textContent = 'No selection'; els.jobSummary.textContent = 'Select a job to inspect targets and deviations.'; - els.targetsTableBody.innerHTML = 'No job selected.'; + els.targetsTableBody.innerHTML = 'No job selected.'; els.deviationsTableBody.innerHTML = 'No deviation data yet.'; els.jobSiteFilter.innerHTML = ''; els.exportJobBtn.setAttribute('hidden', ''); @@ -599,6 +732,9 @@ } function renderJobTables(job) { + const scanTypeNow = job.scan_type || 'sharepoint'; + const isMailbox = scanTypeNow === 'mailbox'; + const isEntra = scanTypeNow === 'entra_groups'; const siteFilter = els.jobSiteFilter.value; const filteredTargets = siteFilter @@ -609,18 +745,91 @@ ? job.deviations.filter(function (d) { return d.site_url === siteFilter; }) : job.deviations; + // Header swap based on scan type + if (els.targetsHeading) { + els.targetsHeading.textContent = isMailbox ? 'Mailboxes' : isEntra ? 'Groups' : 'Targets'; + } + if (els.targetsTableHead) { + var targetsHead; + if (isMailbox) { + targetsHead = 'MailboxStatusAttemptsErrorConnection test'; + } else if (isEntra) { + targetsHead = 'GroupStatusAttemptsErrorConnection test'; + } else { + targetsHead = 'URLStatusAttemptsErrorConnection test'; + } + els.targetsTableHead.innerHTML = targetsHead; + } + if (els.deviationsTableHead) { + var devHead; + if (isMailbox) { + devHead = 'MailboxObjectPermission TypePrincipalAccess Rights'; + } else if (isEntra) { + devHead = 'GroupGroup TypeUserRole'; + } else { + devHead = 'SiteObjectTypePrincipalRoleDelta'; + } + els.deviationsTableHead.innerHTML = devHead; + } + + // Hide SharingLinks/Resolve Groups for non-SharePoint jobs + if (isMailbox || isEntra) { + if (els.sharingLinksResolveBlock) els.sharingLinksResolveBlock.setAttribute('hidden', ''); + if (els.resolveGroupsBlock) els.resolveGroupsBlock.setAttribute('hidden', ''); + } else if (els.resolveGroupsBlock) { + els.resolveGroupsBlock.removeAttribute('hidden'); + } + els.targetsTableBody.innerHTML = filteredTargets.length ? filteredTargets.map(function (target) { return ( - '' + + '' + '' + escHtml(target.site_url) + '' + '' + statusBadge(target.status) + '' + '' + target.attempts + '' + '' + escHtml(target.error_message || '-') + '' + + '' + renderProbeStatus(target) + '' + + '' + '' ); }).join('') - : 'No targets.'; + : 'No targets.'; + + if (isMailbox) { + els.deviationsTableBody.innerHTML = filteredDeviations.length + ? filteredDeviations.map(function (d) { + return ( + '' + + '' + escHtml(d.site_url) + '' + + '' + escHtml(d.object_url) + '' + + '' + escHtml(d.permission_type || d.object_type) + '' + + '' + escHtml(d.principal) + '' + + '' + escHtml(d.role_name) + '' + + '' + + '' + ); + }).join('') + : 'No mailbox permissions found for this job.'; + return; + } + + if (isEntra) { + els.deviationsTableBody.innerHTML = filteredDeviations.length + ? filteredDeviations.map(function (d) { + return ( + '' + + '' + escHtml(d.object_url) + '' + + '' + escHtml(d.permission_type || '') + '' + + '' + escHtml(d.principal) + '' + + '' + escHtml(d.role_name) + '' + + '' + + '' + + '' + ); + }).join('') + : 'No group memberships found for this job.'; + return; + } els.deviationsTableBody.innerHTML = filteredDeviations.length ? filteredDeviations.map(function (deviation) { @@ -639,7 +848,11 @@ principalCell = '' + badge + members + ''; } else { const principalShort = shortPrincipal(deviation.principal); - principalCell = '' + escHtml(principalShort) + ''; + var membersBlock = ''; + if (deviation.resolved_members) { + membersBlock = '
' + escHtml(deviation.resolved_members) + ''; + } + principalCell = '' + escHtml(principalShort) + '' + membersBlock + ''; } return ( '' + @@ -700,25 +913,43 @@ els.exportJobBtn.removeAttribute('hidden'); // Build resolve sharing links section - _renderResolveBlock(job); + await _renderResolveBlock(job); renderJobTables(job); } - function _renderResolveBlock(job) { + async function _renderResolveBlock(job) { + // Preserve current selection before re-render (auto-refresh runs every few seconds). + if (state.selectedJobId === job.id) { + var currentSelected = Array.from( + els.sharingLinksTypes.querySelectorAll('.sharing-link-type-check:checked') + ).map(function (cb) { return cb.value; }); + state.sharingLinkSelectionByJob[job.id] = currentSelected; + } + if (job.status === 'queued' || job.status === 'running') { els.sharingLinksResolveBlock.setAttribute('hidden', ''); return; } - // Collect unique link types present in this job's deviations + if ((job.scan_type || 'sharepoint') === 'mailbox') { + els.sharingLinksResolveBlock.setAttribute('hidden', ''); + return; + } + var typeCounts = {}; - job.deviations.forEach(function (dev) { - var lt = sharingLinkType(dev.principal); - if (lt) { - typeCounts[lt] = (typeCounts[lt] || 0) + 1; - } - }); + try { + var typeData = await requestJson('/api/scan-jobs/' + encodeURIComponent(job.id) + '/sharing-link-types'); + typeCounts = typeData.type_counts || {}; + } catch (_err) { + // Fallback to currently loaded deviations when aggregate endpoint fails. + job.deviations.forEach(function (dev) { + var lt = sharingLinkType(dev.principal); + if (lt) { + typeCounts[lt] = (typeCounts[lt] || 0) + 1; + } + }); + } var types = Object.keys(typeCounts); if (!types.length) { @@ -727,8 +958,15 @@ } types.sort(); + var rememberedSelection = state.sharingLinkSelectionByJob[job.id]; els.sharingLinksTypes.innerHTML = types.map(function (lt) { - var checked = SHARING_LINK_DEFAULT_CHECKED.indexOf(lt) !== -1 ? 'checked' : ''; + var isChecked; + if (Array.isArray(rememberedSelection)) { + isChecked = rememberedSelection.indexOf(lt) !== -1; + } else { + isChecked = SHARING_LINK_DEFAULT_CHECKED.indexOf(lt) !== -1; + } + var checked = isChecked ? 'checked' : ''; var riskCls = sharingLinkRiskClass(lt); return ( '